feat: Add IntegrationRepository and ShopProClient for managing integrations and fetching products from shopPRO API
This commit is contained in:
2
.env
2
.env
@@ -12,3 +12,5 @@ DB_DATABASE=host700513_orderpro
|
|||||||
DB_USERNAME=host700513_orderpro
|
DB_USERNAME=host700513_orderpro
|
||||||
DB_PASSWORD=hrDNtUBg9grwZ7syN77S
|
DB_PASSWORD=hrDNtUBg9grwZ7syN77S
|
||||||
DB_CHARSET=utf8mb4
|
DB_CHARSET=utf8mb4
|
||||||
|
|
||||||
|
INTEGRATIONS_SECRET=nB3sTkXAbBLqA2Ent74R9Mi1118bAbWa
|
||||||
@@ -3,6 +3,7 @@ APP_ENV=local
|
|||||||
APP_DEBUG=true
|
APP_DEBUG=true
|
||||||
APP_URL=http://localhost:8000
|
APP_URL=http://localhost:8000
|
||||||
SESSION_NAME=orderpro_session
|
SESSION_NAME=orderpro_session
|
||||||
|
INTEGRATIONS_SECRET=change-me-long-random-secret
|
||||||
|
|
||||||
DB_CONNECTION=mysql
|
DB_CONNECTION=mysql
|
||||||
DB_HOST=127.0.0.1
|
DB_HOST=127.0.0.1
|
||||||
|
|||||||
4
.vscode/ftp-kr.json
vendored
4
.vscode/ftp-kr.json
vendored
@@ -12,6 +12,8 @@
|
|||||||
"ignoreRemoteModification": true,
|
"ignoreRemoteModification": true,
|
||||||
"ignore": [
|
"ignore": [
|
||||||
".git",
|
".git",
|
||||||
"/.vscode"
|
"/.vscode",
|
||||||
|
"/.claude",
|
||||||
|
".gitignore"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
675
.vscode/ftp-kr.sync.cache.json
vendored
675
.vscode/ftp-kr.sync.cache.json
vendored
File diff suppressed because it is too large
Load Diff
18
AGENTS.md
Normal file
18
AGENTS.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Projektowe zasady dla Codex
|
||||||
|
|
||||||
|
## Baza danych i migracje
|
||||||
|
- `DB_HOST_REMOTE` jest techniczne tylko dla agenta (Codex) do recznych operacji DB/migracji.
|
||||||
|
- Nie podpinaj `DB_HOST_REMOTE` do runtime aplikacji.
|
||||||
|
- Runtime aplikacji ma korzystac standardowo z `DB_HOST`.
|
||||||
|
|
||||||
|
## Utrwalanie stalych wymagan
|
||||||
|
- Trwale wymagania techniczne zapisuj w tym pliku (`AGENTS.md`) w root projektu.
|
||||||
|
- Dla zmiennych srodowiskowych utrzymuj tez wpisy w `.env.example`.
|
||||||
|
|
||||||
|
## Alerty i potwierdzenia UI
|
||||||
|
- W aplikacji uzywaj modulu `resources/modules/jquery-alerts` (build do `public/assets/js/modules/jquery-alerts.js` i `public/assets/css/modules/jquery-alerts.css`).
|
||||||
|
- Nie dodawaj nowych natywnych `alert()` / `confirm()` w widokach; dla potwierdzen akcji (np. usuwanie) korzystaj z `window.OrderProAlerts.confirm(...)`.
|
||||||
|
|
||||||
|
## Style frontendu
|
||||||
|
- Nie trzymaj styli CSS w plikach widokow (`resources/views/...`).
|
||||||
|
- Wszystkie style umieszczaj w plikach SCSS (`resources/scss/...`) i buduj do `public/assets/css/...`.
|
||||||
@@ -92,3 +92,22 @@ Legenda statusow:
|
|||||||
- klasa polaczenia PDO,
|
- klasa polaczenia PDO,
|
||||||
- konfiguracja z `.env`,
|
- konfiguracja z `.env`,
|
||||||
- prosty endpoint kontrolny DB.
|
- prosty endpoint kontrolny DB.
|
||||||
|
|
||||||
|
## Sprint 1.5 - Modul produktow (bez importu/eksportu)
|
||||||
|
|
||||||
|
62. `DONE` Utworzyc migracje tabel: `products`, `product_translations`, `product_images`, `product_categories`.
|
||||||
|
63. `DONE` Utworzyc migracje tabel: `product_variants`, `product_variant_attributes`.
|
||||||
|
64. `DONE` Utworzyc migracje tabel slownikowych: `attributes`, `attribute_translations`, `attribute_values`, `attribute_value_translations`.
|
||||||
|
65. `DONE` Utworzyc migracje tabel pomocniczych: `product_change_log`, `sales_channels`, `product_channel_map`.
|
||||||
|
66. `DONE` Dodac repozytoria i serwis domenowy dla produktow.
|
||||||
|
67. `DONE` Dodac walidator produktow i wariantow (nazwa, cena, SKU produktu i wariantu, EAN, kombinacje atrybutow).
|
||||||
|
68. `DONE` Dodac ekran listy produktow (`/products`) z filtrami i paginacja.
|
||||||
|
69. `DONE` Dodac ekran tworzenia produktu (`/products/create`) i zapis (`POST /products`).
|
||||||
|
70. `DONE` Dodac ekran szczegolow i edycji produktu (`/products/edit?id={id}`).
|
||||||
|
71. `TODO` Dodac obsluge wariantow (dodaj/edytuj) na szczegolach produktu + konwersja simple <-> variant_parent.
|
||||||
|
72. `TODO` Dodac sekcje atrybutow i wartosci (lista + dodawanie).
|
||||||
|
73. `DONE` Dodac wpisy do `product_change_log` przy zmianach krytycznych.
|
||||||
|
74. `DONE` Dodac pozycje "Produkty" do nawigacji + tlumaczenia `resources/lang/pl.php`.
|
||||||
|
75. `DONE` Dodac dwukierunkowe przeliczanie cen brutto/netto (UI + backend) na podstawie VAT.
|
||||||
|
76. `TODO` Dodac upload i zapis zdjec produktu na serwerze orderPRO.
|
||||||
|
77. `TODO` Przeprowadzic test manualny flow: create/edit/list/filter/variant.
|
||||||
|
|||||||
235
DOCS/PLAN_MODULU_POWIAZAN_PRODUKTOW.md
Normal file
235
DOCS/PLAN_MODULU_POWIAZAN_PRODUKTOW.md
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
# orderPRO - Plan wdrozenia modulu powiazan produktow (shopPRO + marketplace)
|
||||||
|
|
||||||
|
Data: 2026-02-23
|
||||||
|
Status: draft do implementacji
|
||||||
|
|
||||||
|
## Status realizacji (2026-02-23)
|
||||||
|
- Etap A: wykonany
|
||||||
|
- Etap B: wykonany
|
||||||
|
- Etap C: wykonany
|
||||||
|
- Etap D: wykonany (LinkMatcherService, priorytety EAN/SKU, endpoint sugestii)
|
||||||
|
- Etap E: w toku (UI historii zdarzen + soft-unlink i audyt gotowe; do domkniecia testy manualne E2E)
|
||||||
|
|
||||||
|
## Ustalenie implementacyjne (2026-02-23)
|
||||||
|
- Import produktu z integracji tworzy od razu powiazanie w `product_channel_map` z ustawionym `integration_id` dla tej konkretnej instancji konta.
|
||||||
|
|
||||||
|
## 1. Cel etapu
|
||||||
|
Zbudowac osobny modul "Powiazania ofert" pozwalajacy mapowac produkty orderPRO do ofert zewnetrznych z instancji:
|
||||||
|
- shopPRO,
|
||||||
|
- marketplace.
|
||||||
|
|
||||||
|
Modul ma dzialac jako osobna karta (analogicznie do podejscia Apilo/BaseLinker), ale osadzona w obecnej architekturze orderPRO.
|
||||||
|
|
||||||
|
## 2. Stan obecny i luka
|
||||||
|
### 2.1 Co juz jest
|
||||||
|
- tabele `sales_channels` oraz `product_channel_map`,
|
||||||
|
- seeding kanalow (`shoppro`, `allegro`, `erli`) w `IntegrationRepository::ensureSalesChannelsSeeded()`,
|
||||||
|
- podstawowe mapowanie `product_id + channel_code + external ids` przez `upsertProductChannelMap(...)`.
|
||||||
|
|
||||||
|
### 2.2 Czego brakuje
|
||||||
|
- brak rozroznienia instancji kont (np. kilka kont marketplace),
|
||||||
|
- brak lokalnego cache ofert zewnetrznych do wyszukiwania i laczenia,
|
||||||
|
- brak statusow powiazania i konfliktow,
|
||||||
|
- brak dedykowanego UI "Powiazania",
|
||||||
|
- brak audytu operacji recznych (powiaz/przepnij/odlacz).
|
||||||
|
|
||||||
|
## 3. Zakres MVP modulu powiazan
|
||||||
|
W etapie MVP wdrazamy:
|
||||||
|
1. osobna karta "Powiazania" na szczegolach produktu,
|
||||||
|
2. reczne powiazanie produktu orderPRO z oferta zewnetrzna,
|
||||||
|
3. auto-podpowiedzi po `EAN` i `SKU` (bez automatycznego zapisu),
|
||||||
|
4. odlaczanie i przepinanie powiazan,
|
||||||
|
5. podstawowy audit trail w osobnej tabeli logow,
|
||||||
|
6. import/listowanie ofert z aktywnych integracji do lokalnej tabeli cache.
|
||||||
|
|
||||||
|
W MVP NIE wdrazamy:
|
||||||
|
- pelnej, automatycznej synchronizacji cen/stanow,
|
||||||
|
- fuzzy matching po nazwie jako auto-link,
|
||||||
|
- masowych operacji na setkach rekordow naraz.
|
||||||
|
|
||||||
|
## 4. Model danych (docelowy dla MVP + rozszerzalny)
|
||||||
|
### 4.1 Rozszerzenie istniejacej tabeli mapowania
|
||||||
|
Tabela: `product_channel_map` (istniejaca)
|
||||||
|
|
||||||
|
Dodac kolumny:
|
||||||
|
- `integration_id` INT UNSIGNED NULL (FK -> `integrations.id`) - wskazuje konkretna instancje konta,
|
||||||
|
- `link_type` VARCHAR(32) NOT NULL DEFAULT 'manual' (`manual`, `auto_sku`, `auto_ean`),
|
||||||
|
- `link_status` VARCHAR(32) NOT NULL DEFAULT 'active' (`active`, `conflict`, `inactive`, `unverified`),
|
||||||
|
- `confidence` TINYINT UNSIGNED NULL,
|
||||||
|
- `linked_at` DATETIME NULL,
|
||||||
|
- `linked_by_user_id` INT UNSIGNED NULL (FK -> `users.id`),
|
||||||
|
- `unlinked_at` DATETIME NULL,
|
||||||
|
- `unlinked_by_user_id` INT UNSIGNED NULL (FK -> `users.id`),
|
||||||
|
- `sync_meta_json` JSON NULL (pole techniczne pod dane z API).
|
||||||
|
|
||||||
|
Indeksy:
|
||||||
|
- `(integration_id, external_product_id, external_variant_id)`,
|
||||||
|
- `(product_id, link_status)`,
|
||||||
|
- `(channel_id, link_status)`.
|
||||||
|
|
||||||
|
Uwagi:
|
||||||
|
- utrzymac kompatybilnosc ze starym kodem (`upsertProductChannelMap`),
|
||||||
|
- `integration_id` moze byc NULL tylko dla rekordow historycznych.
|
||||||
|
|
||||||
|
### 4.2 Nowa tabela cache ofert zewnetrznych
|
||||||
|
Tabela: `channel_offers`
|
||||||
|
- `id` INT UNSIGNED PK
|
||||||
|
- `integration_id` INT UNSIGNED NOT NULL FK -> `integrations.id`
|
||||||
|
- `channel_id` INT UNSIGNED NOT NULL FK -> `sales_channels.id`
|
||||||
|
- `external_product_id` VARCHAR(128) NOT NULL
|
||||||
|
- `external_variant_id` VARCHAR(128) NULL
|
||||||
|
- `external_offer_id` VARCHAR(128) NULL
|
||||||
|
- `name` VARCHAR(255) NOT NULL
|
||||||
|
- `sku` VARCHAR(128) NULL
|
||||||
|
- `ean` VARCHAR(32) NULL
|
||||||
|
- `price_brutto` DECIMAL(12,2) NULL
|
||||||
|
- `quantity` DECIMAL(12,3) NULL
|
||||||
|
- `currency` VARCHAR(8) NULL
|
||||||
|
- `offer_status` VARCHAR(32) NOT NULL DEFAULT 'active'
|
||||||
|
- `source_updated_at` DATETIME NULL
|
||||||
|
- `last_seen_at` DATETIME NOT NULL
|
||||||
|
- `payload_json` JSON NULL
|
||||||
|
- `created_at`, `updated_at`
|
||||||
|
|
||||||
|
Unikalnosc:
|
||||||
|
- `UNIQUE (integration_id, external_product_id, external_variant_id)`.
|
||||||
|
|
||||||
|
### 4.3 Nowa tabela logow powiazan
|
||||||
|
Tabela: `product_link_events`
|
||||||
|
- `id` INT UNSIGNED PK
|
||||||
|
- `product_channel_map_id` INT UNSIGNED NOT NULL FK -> `product_channel_map.id`
|
||||||
|
- `event_type` VARCHAR(32) NOT NULL (`linked`, `relinked`, `unlinked`, `status_changed`, `conflict_detected`)
|
||||||
|
- `before_json` JSON NULL
|
||||||
|
- `after_json` JSON NULL
|
||||||
|
- `created_by_user_id` INT UNSIGNED NULL FK -> `users.id`
|
||||||
|
- `created_at` DATETIME NOT NULL
|
||||||
|
|
||||||
|
## 5. Architektura aplikacyjna (orderPRO)
|
||||||
|
Nowy modul: `src/Modules/ProductLinks`
|
||||||
|
|
||||||
|
Klasy:
|
||||||
|
- `ProductLinksController` - karta "Powiazania", akcje reczne,
|
||||||
|
- `ProductLinksService` - logika powiaz/przepnij/odlacz + reguly konfliktow,
|
||||||
|
- `ProductLinksRepository` - odczyt/zapis `product_channel_map` i `product_link_events`,
|
||||||
|
- `ChannelOffersRepository` - odczyt cache ofert,
|
||||||
|
- `LinkMatcherService` - podpowiedzi po EAN/SKU,
|
||||||
|
- `OfferImportService` - import ofert z API integracji do `channel_offers`.
|
||||||
|
|
||||||
|
Wspolpraca z istniejacymi modulami:
|
||||||
|
- `Settings/IntegrationRepository` - lista aktywnych instancji,
|
||||||
|
- `Products/ProductRepository` - dane produktu lokalnego,
|
||||||
|
- `Core/Security/Csrf` + `Auth` - autoryzacja i CSRF dla akcji POST.
|
||||||
|
|
||||||
|
## 6. UI/UX (osobna karta)
|
||||||
|
### 6.1 Widok produktu
|
||||||
|
W `resources/views/products/show.php` dodac zakladke:
|
||||||
|
- `Powiazania`.
|
||||||
|
|
||||||
|
Nowy widok czesciowy:
|
||||||
|
- `resources/views/products/partials/links.php`.
|
||||||
|
|
||||||
|
### 6.2 Sekcje karty
|
||||||
|
1. Aktualne powiazania:
|
||||||
|
- instancja (`integrations.name`),
|
||||||
|
- kanal (`sales_channels.name`),
|
||||||
|
- oferta (`name`, `external_product_id`, `external_variant_id`),
|
||||||
|
- dopasowanie (`link_type`, `link_status`, `confidence`),
|
||||||
|
- akcje: `Przepnij`, `Odlacz`.
|
||||||
|
|
||||||
|
2. Wyszukiwarka ofert:
|
||||||
|
- filtr po instancji,
|
||||||
|
- filtr po SKU/EAN/nazwie,
|
||||||
|
- lista wynikow z `channel_offers`,
|
||||||
|
- akcja `Powiaz`.
|
||||||
|
|
||||||
|
3. Podpowiedzi automatyczne:
|
||||||
|
- sekcja "Proponowane dopasowania" (EAN/SKU),
|
||||||
|
- tylko sugestia, finalny zapis reczny przez operatora.
|
||||||
|
|
||||||
|
### 6.3 Potwierdzenia i alerty
|
||||||
|
Krytyczne akcje (`Przepnij`, `Odlacz`) realizowac przez:
|
||||||
|
- `window.OrderProAlerts.confirm(...)`.
|
||||||
|
|
||||||
|
Nie dodawac natywnych `alert()` i `confirm()`.
|
||||||
|
|
||||||
|
## 7. API / routing wewnetrzny (SSR + POST)
|
||||||
|
Proponowane endpointy:
|
||||||
|
- `GET /products/{id}/links` - zwrot zawartosci karty (lub render w `show`),
|
||||||
|
- `POST /products/{id}/links` - utworzenie recznego powiazania,
|
||||||
|
- `POST /products/{id}/links/{mapId}/relink` - przepiecie na inna oferte,
|
||||||
|
- `POST /products/{id}/links/{mapId}/unlink` - odlaczenie,
|
||||||
|
- `GET /products/{id}/links/suggestions` - sugestie EAN/SKU.
|
||||||
|
|
||||||
|
Backoffice import ofert:
|
||||||
|
- `POST /settings/integrations/{id}/offers/import` - reczny import cache,
|
||||||
|
- docelowo cron/job: `php bin/cron_import_offers.php` (po MVP).
|
||||||
|
|
||||||
|
## 8. Reguly biznesowe
|
||||||
|
1. Jeden rekord oferty zewnetrznej (`integration_id + external_product_id + external_variant_id`) moze byc aktywnie powiazany tylko z jednym produktem orderPRO.
|
||||||
|
2. Jeden produkt orderPRO moze miec wiele powiazan (multi-channel, multi-instance).
|
||||||
|
3. Powiazanie reczne ma priorytet nad sugestiami auto-match.
|
||||||
|
4. `EAN exact` ma wyzszy priorytet sugestii niz `SKU exact`.
|
||||||
|
5. `SKU normalized` (bez spacji, myslnikow, podkreslen) jest nizszy niz `SKU exact`.
|
||||||
|
6. Konflikty nie modyfikuja automatycznie aktywnego linku - nadaja status `conflict` i wymagaja decyzji operatora.
|
||||||
|
7. Odlaczenie nie kasuje historii: rekord mapy moze przejsc w `link_status = inactive`.
|
||||||
|
|
||||||
|
## 9. Etapy wdrozenia
|
||||||
|
## Etap A - migracje i repozytoria
|
||||||
|
- migracja rozszerzajaca `product_channel_map`,
|
||||||
|
- nowe tabele `channel_offers`, `product_link_events`,
|
||||||
|
- repozytoria i podstawowe testy manualne SQL.
|
||||||
|
|
||||||
|
Kryterium akceptacji:
|
||||||
|
- migracje przechodza lokalnie,
|
||||||
|
- mozna zapisac i odczytac link wraz z `integration_id` i `link_status`.
|
||||||
|
|
||||||
|
## Etap B - import i cache ofert
|
||||||
|
- adapter importu ofert z aktywnych integracji,
|
||||||
|
- upsert do `channel_offers`,
|
||||||
|
- reczny trigger importu z panelu Integracje.
|
||||||
|
|
||||||
|
Kryterium akceptacji:
|
||||||
|
- po imporcie widoczne sa oferty w cache dla wskazanej integracji,
|
||||||
|
- kolejne importy aktualizuja rekordy bez duplikatow.
|
||||||
|
|
||||||
|
## Etap C - UI karty Powiazania
|
||||||
|
- dodanie zakladki w szczegolach produktu,
|
||||||
|
- lista aktywnych powiazan,
|
||||||
|
- formularz recznego powiazania,
|
||||||
|
- akcje odlacz/przepnij z `OrderProAlerts.confirm`.
|
||||||
|
|
||||||
|
Kryterium akceptacji:
|
||||||
|
- operator bez SQL moze powiazac, przepiac i odlaczyc oferte.
|
||||||
|
|
||||||
|
## Etap D - sugestie i konflikty
|
||||||
|
- `LinkMatcherService` (EAN/SKU),
|
||||||
|
- widok sugestii,
|
||||||
|
- logika konfliktu i statusy.
|
||||||
|
|
||||||
|
Kryterium akceptacji:
|
||||||
|
- sugestie sa widoczne i oznaczone confidence,
|
||||||
|
- konflikt nie psuje istniejacego aktywnego mapowania.
|
||||||
|
|
||||||
|
## Etap E - hardening i audyt
|
||||||
|
- logowanie zdarzen do `product_link_events`,
|
||||||
|
- dopracowanie komunikatow i walidacji,
|
||||||
|
- testy manualne end-to-end.
|
||||||
|
|
||||||
|
Kryterium akceptacji:
|
||||||
|
- kazda operacja reczna ma wpis w historii,
|
||||||
|
- UI poprawnie pokazuje status i ostatnia zmiane.
|
||||||
|
|
||||||
|
## 10. Definicja "Done" dla modulu
|
||||||
|
- istnieje osobna karta "Powiazania" na produkcie,
|
||||||
|
- mozliwe jest reczne mapowanie produkt <-> oferta (shopPRO/marketplace),
|
||||||
|
- dziala odlaczanie i przepinanie z potwierdzeniem UI,
|
||||||
|
- cache ofert z integracji dziala i jest przeszukiwalny,
|
||||||
|
- konflikty sa oznaczane i nie niszcza danych,
|
||||||
|
- operacje sa audytowane.
|
||||||
|
|
||||||
|
## 11. Kolejny krok po MVP
|
||||||
|
Po MVP uruchomic:
|
||||||
|
1. job/cron cyklicznego importu ofert,
|
||||||
|
2. automatyczne auto-linkowanie tylko dla przypadkow `confidence >= prog`,
|
||||||
|
3. masowe operacje mapowania (bulk),
|
||||||
|
4. wykorzystanie powiazan w sync cen/stanow i publikacji ofert.
|
||||||
289
DOCS/PLAN_MODULU_PRODUKTOW.md
Normal file
289
DOCS/PLAN_MODULU_PRODUKTOW.md
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
# orderPRO - Plan wdrozenia modulu produktow (bez importu/eksportu)
|
||||||
|
|
||||||
|
Data: 2026-02-23
|
||||||
|
Status: draft do implementacji
|
||||||
|
|
||||||
|
## 1. Cel etapu
|
||||||
|
Zbudowac lokalny modul produktow w orderPRO, gotowy pod obsluge wielu kanalow sprzedazy (co najmniej 2 sklepy shopPRO), ale bez uruchamiania synchronizacji import/export.
|
||||||
|
|
||||||
|
W tym etapie tworzymy:
|
||||||
|
- model danych produktu w orderPRO,
|
||||||
|
- panel do listowania, filtrowania, podgladu i edycji produktow,
|
||||||
|
- obsluge wariantow i atrybutow,
|
||||||
|
- podstawy pod przyszle mapowanie kanalowe (shopPRO, Allegro, Erli).
|
||||||
|
|
||||||
|
W tym etapie NIE tworzymy:
|
||||||
|
- importu z shopPRO,
|
||||||
|
- eksportu do shopPRO,
|
||||||
|
- eksportu do marketplace,
|
||||||
|
- schedulerow/cronow synchronizacji produktow.
|
||||||
|
|
||||||
|
## 2. Zalozenia domenowe
|
||||||
|
1. orderPRO jest source of truth dla danych produktowych po wdrozeniu tego etapu.
|
||||||
|
2. Produkt ma stabilny identyfikator lokalny oraz techniczny UUID do laczenia z integracjami.
|
||||||
|
3. Jeden produkt moze miec wiele wariantow.
|
||||||
|
4. Ceny sa przechowywane lokalnie i przeliczane dwukierunkowo (brutto <-> netto) na podstawie VAT.
|
||||||
|
5. Stan magazynowy jest przechowywany wylacznie na produkcie glownym.
|
||||||
|
6. Modul ma byc przygotowany pod wiele kanalow, ale bez aktywnych procesow synchronizacji.
|
||||||
|
7. W MVP obslugiwany jest jezyk `pl`, z zachowaniem struktury pod kolejne jezyki.
|
||||||
|
|
||||||
|
## 3. Zakres funkcjonalny MVP modulu produktow
|
||||||
|
### 3.1 Lista produktow
|
||||||
|
- paginacja,
|
||||||
|
- wyszukiwanie po nazwie, SKU, EAN,
|
||||||
|
- filtry: status, typ (prosty/wariantowy), producent, data modyfikacji,
|
||||||
|
- sortowanie: id, nazwa, SKU, cena, stan, status, data modyfikacji,
|
||||||
|
- widoczne flagi: aktywny/nieaktywny, promowany.
|
||||||
|
|
||||||
|
### 3.2 Szczegoly produktu
|
||||||
|
- dane glowne: nazwa, SKU, EAN, status, promoted,
|
||||||
|
- ceny: brutto/netto + promo,
|
||||||
|
- stan magazynowy i waga,
|
||||||
|
- VAT i jednostka,
|
||||||
|
- kategorie lokalne,
|
||||||
|
- opis i meta (PL jako minimum),
|
||||||
|
- galeria zdjec (metadane i kolejnosc).
|
||||||
|
|
||||||
|
### 3.3 Tworzenie i edycja produktu
|
||||||
|
- formularz z walidacja,
|
||||||
|
- partial update,
|
||||||
|
- historia zmian (minimum: kto, kiedy, co zmienil).
|
||||||
|
|
||||||
|
### 3.4 Warianty
|
||||||
|
- dodawanie wariantu po kombinacji atrybutow,
|
||||||
|
- walidacja unikalnosci kombinacji,
|
||||||
|
- osobne pola SKU/EAN/cena/waga/status dla wariantu (bez osobnego stanu),
|
||||||
|
- aktywacja/dezaktywacja wariantu.
|
||||||
|
|
||||||
|
### 3.5 Atrybuty i wartosci
|
||||||
|
- slownik atrybutow i wartosci lokalnych,
|
||||||
|
- oznaczenie typu atrybutu,
|
||||||
|
- mozliwosc przypisania wielu atrybutow do produktu.
|
||||||
|
|
||||||
|
## 4. Model danych (propozycja)
|
||||||
|
### 4.1 Tabele glówne
|
||||||
|
1. `products`
|
||||||
|
- `id` BIGINT PK
|
||||||
|
- `uuid` CHAR(36) UNIQUE
|
||||||
|
- `type` ENUM('simple','variant_parent')
|
||||||
|
- `sku` VARCHAR(128) NULL UNIQUE
|
||||||
|
- `ean` VARCHAR(32) NULL
|
||||||
|
- `status` TINYINT(1)
|
||||||
|
- `promoted` TINYINT(1)
|
||||||
|
- `vat` DECIMAL(5,2) NULL
|
||||||
|
- `weight` DECIMAL(10,3) NULL
|
||||||
|
- `price_brutto` DECIMAL(12,2)
|
||||||
|
- `price_brutto_promo` DECIMAL(12,2) NULL
|
||||||
|
- `price_netto` DECIMAL(12,2) NULL
|
||||||
|
- `price_netto_promo` DECIMAL(12,2) NULL
|
||||||
|
- `quantity` DECIMAL(12,3) DEFAULT 0
|
||||||
|
- `producer_id` BIGINT NULL
|
||||||
|
- `product_unit_id` BIGINT NULL
|
||||||
|
- `created_at`, `updated_at`, `deleted_at`
|
||||||
|
|
||||||
|
2. `product_translations`
|
||||||
|
- `id` BIGINT PK
|
||||||
|
- `product_id` BIGINT FK -> products.id
|
||||||
|
- `lang` VARCHAR(8)
|
||||||
|
- `name` VARCHAR(255)
|
||||||
|
- `short_description` TEXT NULL
|
||||||
|
- `description` LONGTEXT NULL
|
||||||
|
- `meta_title` VARCHAR(255) NULL
|
||||||
|
- `meta_description` VARCHAR(255) NULL
|
||||||
|
- `meta_keywords` VARCHAR(255) NULL
|
||||||
|
- `seo_link` VARCHAR(255) NULL
|
||||||
|
- UNIQUE (`product_id`, `lang`)
|
||||||
|
|
||||||
|
3. `product_images`
|
||||||
|
- `id` BIGINT PK
|
||||||
|
- `product_id` BIGINT FK
|
||||||
|
- `storage_path` VARCHAR(255)
|
||||||
|
- `alt` VARCHAR(255) NULL
|
||||||
|
- `sort_order` INT DEFAULT 0
|
||||||
|
- `is_main` TINYINT(1) DEFAULT 0
|
||||||
|
|
||||||
|
4. `product_categories`
|
||||||
|
- `product_id` BIGINT FK
|
||||||
|
- `category_id` BIGINT FK
|
||||||
|
- PK (`product_id`, `category_id`)
|
||||||
|
|
||||||
|
5. `product_variants`
|
||||||
|
- `id` BIGINT PK
|
||||||
|
- `product_id` BIGINT FK -> products.id
|
||||||
|
- `permutation_hash` VARCHAR(191)
|
||||||
|
- `sku` VARCHAR(128) NULL UNIQUE
|
||||||
|
- `ean` VARCHAR(32) NULL
|
||||||
|
- `status` TINYINT(1)
|
||||||
|
- `price_brutto` DECIMAL(12,2) NULL
|
||||||
|
- `price_brutto_promo` DECIMAL(12,2) NULL
|
||||||
|
- `price_netto` DECIMAL(12,2) NULL
|
||||||
|
- `price_netto_promo` DECIMAL(12,2) NULL
|
||||||
|
- `weight` DECIMAL(10,3) NULL
|
||||||
|
- `created_at`, `updated_at`
|
||||||
|
- UNIQUE (`product_id`, `permutation_hash`)
|
||||||
|
|
||||||
|
6. `product_variant_attributes`
|
||||||
|
- `variant_id` BIGINT FK -> product_variants.id
|
||||||
|
- `attribute_id` BIGINT FK
|
||||||
|
- `value_id` BIGINT FK
|
||||||
|
- PK (`variant_id`, `attribute_id`)
|
||||||
|
|
||||||
|
7. `attributes`
|
||||||
|
- `id` BIGINT PK
|
||||||
|
- `type` TINYINT
|
||||||
|
- `status` TINYINT(1)
|
||||||
|
|
||||||
|
8. `attribute_translations`
|
||||||
|
- `attribute_id` BIGINT FK
|
||||||
|
- `lang` VARCHAR(8)
|
||||||
|
- `name` VARCHAR(255)
|
||||||
|
- PK (`attribute_id`, `lang`)
|
||||||
|
|
||||||
|
9. `attribute_values`
|
||||||
|
- `id` BIGINT PK
|
||||||
|
- `attribute_id` BIGINT FK
|
||||||
|
- `status` TINYINT(1)
|
||||||
|
- `is_default` TINYINT(1)
|
||||||
|
- `impact_on_price` DECIMAL(12,2) NULL
|
||||||
|
|
||||||
|
10. `attribute_value_translations`
|
||||||
|
- `value_id` BIGINT FK
|
||||||
|
- `lang` VARCHAR(8)
|
||||||
|
- `name` VARCHAR(255)
|
||||||
|
- PK (`value_id`, `lang`)
|
||||||
|
|
||||||
|
11. `product_change_log`
|
||||||
|
- `id` BIGINT PK
|
||||||
|
- `product_id` BIGINT FK
|
||||||
|
- `user_id` BIGINT FK -> users.id
|
||||||
|
- `change_type` VARCHAR(64)
|
||||||
|
- `before_json` JSON NULL
|
||||||
|
- `after_json` JSON NULL
|
||||||
|
- `created_at`
|
||||||
|
|
||||||
|
### 4.2 Tabele "future-ready" pod integracje (bez aktywnej synchronizacji)
|
||||||
|
12. `sales_channels`
|
||||||
|
- `id` BIGINT PK
|
||||||
|
- `code` VARCHAR(64) UNIQUE (np. shoppro_1, shoppro_2, allegro, erli)
|
||||||
|
- `name` VARCHAR(128)
|
||||||
|
- `type` VARCHAR(64) (shoppro/marketplace)
|
||||||
|
- `status` TINYINT(1)
|
||||||
|
|
||||||
|
13. `product_channel_map`
|
||||||
|
- `id` BIGINT PK
|
||||||
|
- `product_id` BIGINT FK
|
||||||
|
- `channel_id` BIGINT FK
|
||||||
|
- `external_product_id` VARCHAR(128) NULL
|
||||||
|
- `external_variant_id` VARCHAR(128) NULL
|
||||||
|
- `sync_state` VARCHAR(32) DEFAULT 'not_linked'
|
||||||
|
- `last_sync_at` DATETIME NULL
|
||||||
|
- UNIQUE (`product_id`, `channel_id`, `external_product_id`, `external_variant_id`)
|
||||||
|
|
||||||
|
Uwagi:
|
||||||
|
- na tym etapie `product_channel_map` jest tylko przygotowaniem danych,
|
||||||
|
- nie tworzymy procesow, ktore aktualizuja `sync_state` automatycznie.
|
||||||
|
|
||||||
|
## 5. Architektura aplikacyjna (orderPRO)
|
||||||
|
Nowy modul: `src/Modules/Products`
|
||||||
|
|
||||||
|
Sugerowane klasy:
|
||||||
|
- `ProductsController` (SSR: lista, szczegoly, create, update),
|
||||||
|
- `ProductVariantsController`,
|
||||||
|
- `AttributesController`,
|
||||||
|
- `ProductRepository`,
|
||||||
|
- `VariantRepository`,
|
||||||
|
- `AttributeRepository`,
|
||||||
|
- `ProductValidator`,
|
||||||
|
- `ProductService` (transakcje i reguly domenowe),
|
||||||
|
- DTO/normalizery do mapowania formularz <-> domena.
|
||||||
|
|
||||||
|
Widoki:
|
||||||
|
- `resources/views/products/index.php`
|
||||||
|
- `resources/views/products/form.php`
|
||||||
|
- `resources/views/products/show.php`
|
||||||
|
- `resources/views/products/partials/variants.php`
|
||||||
|
- `resources/views/products/partials/attributes.php`
|
||||||
|
|
||||||
|
Routing (propozycja):
|
||||||
|
- `GET /products`
|
||||||
|
- `GET /products/create`
|
||||||
|
- `POST /products`
|
||||||
|
- `GET /products/{id}`
|
||||||
|
- `POST /products/{id}` (na teraz bez PUT, zgodnie z obecnym stylem formularzy)
|
||||||
|
- `POST /products/{id}/variants`
|
||||||
|
- `POST /variants/{id}`
|
||||||
|
- `GET /attributes`
|
||||||
|
- `POST /attributes`
|
||||||
|
|
||||||
|
## 6. Walidacja i reguly biznesowe
|
||||||
|
1. Wymagane: minimum jedna nazwa tlumaczenia (`pl.name`) i `price_brutto` dla produktu prostego.
|
||||||
|
2. `sku` jest unikalne na poziomie produktu glownego (`products.sku`).
|
||||||
|
3. `sku` wariantu jest unikalne na poziomie wariantu (`product_variants.sku`) gdy jest uzupelnione.
|
||||||
|
4. Ceny brutto/netto sa liczone dwukierunkowo (na podstawie wpisanego pola i stawki VAT).
|
||||||
|
5. Wariant musi miec niepusta i unikalna kombinacje atrybutow (`permutation_hash`).
|
||||||
|
6. Stan magazynowy jest przechowywany tylko na produkcie glownym.
|
||||||
|
7. `ean` opcjonalny, ale jesli podany to walidowany formatowo.
|
||||||
|
8. Zmiany krytyczne (cena, status, stan, sku, ean) wpisywane do `product_change_log`.
|
||||||
|
|
||||||
|
## 7. Etapy wdrozenia
|
||||||
|
## Etap A - fundament danych
|
||||||
|
- migracje tabel produktowych i atrybutowych,
|
||||||
|
- indeksy pod filtry listy,
|
||||||
|
- seed bazowych danych slownikowych (opcjonalnie).
|
||||||
|
|
||||||
|
Kryterium akceptacji:
|
||||||
|
- migracje przechodza lokalnie i tabela status w `Settings > Aktualizacja bazy` pokazuje brak pending.
|
||||||
|
|
||||||
|
## Etap B - backend CRUD produktu
|
||||||
|
- repozytoria + serwis + walidacja,
|
||||||
|
- endpointy/form actions dla create/edit/show/list,
|
||||||
|
- log zmian.
|
||||||
|
|
||||||
|
Kryterium akceptacji:
|
||||||
|
- mozna utworzyc i edytowac produkt prosty,
|
||||||
|
- walidacje dzialaja,
|
||||||
|
- zmiany zapisuja sie w `product_change_log`.
|
||||||
|
|
||||||
|
## Etap C - warianty i atrybuty
|
||||||
|
- CRUD atrybutow i wartosci,
|
||||||
|
- dodawanie/edycja wariantow,
|
||||||
|
- unikalnosc kombinacji atrybutow,
|
||||||
|
- obsluga konwersji simple <-> variant_parent.
|
||||||
|
|
||||||
|
Kryterium akceptacji:
|
||||||
|
- mozna dodac wiele wariantow jednego produktu,
|
||||||
|
- system blokuje duplikat kombinacji,
|
||||||
|
- lista i szczegoly poprawnie prezentuja warianty.
|
||||||
|
|
||||||
|
## Etap D - UI/UX panelu i stabilizacja
|
||||||
|
- finalne widoki i filtry,
|
||||||
|
- komunikaty flash i bledy,
|
||||||
|
- porzadkowanie tlumaczen `resources/lang/pl.php`,
|
||||||
|
- testy manualne flow end-to-end.
|
||||||
|
|
||||||
|
Kryterium akceptacji:
|
||||||
|
- caly flow produktowy dziala bez SQL manualnego,
|
||||||
|
- menu zawiera sekcje "Produkty",
|
||||||
|
- formularze sa zgodne z istniejacym stylem aplikacji.
|
||||||
|
|
||||||
|
## 8. Ustalenia biznesowe (2026-02-23)
|
||||||
|
1. `sku` jest unikalne na poziomie produktu glownego.
|
||||||
|
2. `sku` jest unikalne rowniez na poziomie wariantu (jesli wariant ma wypelnione SKU).
|
||||||
|
3. Ceny netto/brutto sa wyliczane automatycznie w obie strony.
|
||||||
|
4. Stan magazynowy jest tylko na produkcie glownym.
|
||||||
|
5. MVP dziala na jezyku `pl`, ale struktura danych zostaje wielojezyczna.
|
||||||
|
6. Kategorie w tym etapie tylko jako relacja (`product_categories`), bez CRUD kategorii.
|
||||||
|
7. Zdjecia sa przechowywane na serwerze orderPRO.
|
||||||
|
8. Dozwolona jest konwersja produktu prostego na wariantowy i odwrotnie.
|
||||||
|
9. Uprawnienia szczegolowe do modulu produktow sa odlozone (dostep dla zalogowanych).
|
||||||
|
|
||||||
|
## 9. Definicja "Done" dla tego etapu
|
||||||
|
- modul produktow dostepny z panelu,
|
||||||
|
- kompletne CRUD dla produktu prostego,
|
||||||
|
- obsluga wariantow i atrybutow,
|
||||||
|
- dzialajace filtrowanie i paginacja,
|
||||||
|
- log zmian,
|
||||||
|
- przygotowane mapowanie kanalowe (tabele), ale bez aktywnej synchronizacji.
|
||||||
|
|
||||||
|
## 10. Co dalej po tym etapie
|
||||||
|
Kolejny etap to osobny plan: import produktow z 2x shopPRO + export z orderPRO do shopPRO/Allegro/Erli z kolejka, retry i monitorowaniem sync.
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
1. [x] Wszystkie teksty trzymac w pliku z tlumaczeniami.
|
1. Na podglądzie produktu zmień wyświetlanie zdjęć na siatkę (grid)
|
||||||
2. [x] Jako styli uzywac SCSS, ktore sa kompilowane i minifikowane do CSS.
|
2. W tabelach w filtrach filtr Na strone nie jest potrzebny bo ta opcja jest na dole w stronicowaniu.
|
||||||
|
3. W tabelach w sortowanie nie jest potrzebny bo ta opcja jest dostępna w nagłowkach tabel.
|
||||||
|
4. https://orderpro.projectpro.pl/products/8 rozszerzyć kolumnę z nazwami parametrów
|
||||||
@@ -13,6 +13,9 @@ return [
|
|||||||
'name' => Env::get('SESSION_NAME', 'orderpro_session'),
|
'name' => Env::get('SESSION_NAME', 'orderpro_session'),
|
||||||
'path' => dirname(__DIR__) . '/storage/sessions',
|
'path' => dirname(__DIR__) . '/storage/sessions',
|
||||||
],
|
],
|
||||||
|
'integrations' => [
|
||||||
|
'secret' => Env::get('INTEGRATIONS_SECRET', ''),
|
||||||
|
],
|
||||||
'view_path' => dirname(__DIR__) . '/resources/views',
|
'view_path' => dirname(__DIR__) . '/resources/views',
|
||||||
'lang_path' => dirname(__DIR__) . '/resources/lang',
|
'lang_path' => dirname(__DIR__) . '/resources/lang',
|
||||||
'log_path' => dirname(__DIR__) . '/storage/logs/app.log',
|
'log_path' => dirname(__DIR__) . '/storage/logs/app.log',
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS products (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
uuid CHAR(36) NOT NULL,
|
||||||
|
type ENUM('simple','variant_parent') NOT NULL DEFAULT 'simple',
|
||||||
|
sku VARCHAR(128) NULL,
|
||||||
|
ean VARCHAR(32) NULL,
|
||||||
|
status TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
promoted TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
vat DECIMAL(5,2) NULL,
|
||||||
|
weight DECIMAL(10,3) NULL,
|
||||||
|
price_brutto DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||||
|
price_brutto_promo DECIMAL(12,2) NULL,
|
||||||
|
price_netto DECIMAL(12,2) NULL,
|
||||||
|
price_netto_promo DECIMAL(12,2) NULL,
|
||||||
|
quantity DECIMAL(12,3) NOT NULL DEFAULT 0.000,
|
||||||
|
producer_id INT UNSIGNED NULL,
|
||||||
|
product_unit_id INT UNSIGNED NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
deleted_at DATETIME NULL,
|
||||||
|
UNIQUE KEY products_uuid_unique (uuid),
|
||||||
|
UNIQUE KEY products_sku_unique (sku),
|
||||||
|
KEY products_status_idx (status),
|
||||||
|
KEY products_type_idx (type),
|
||||||
|
KEY products_updated_at_idx (updated_at),
|
||||||
|
KEY products_ean_idx (ean)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS product_translations (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
product_id INT UNSIGNED NOT NULL,
|
||||||
|
lang VARCHAR(8) NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
short_description TEXT NULL,
|
||||||
|
description LONGTEXT NULL,
|
||||||
|
meta_title VARCHAR(255) NULL,
|
||||||
|
meta_description VARCHAR(255) NULL,
|
||||||
|
meta_keywords VARCHAR(255) NULL,
|
||||||
|
seo_link VARCHAR(255) NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY product_translations_product_lang_unique (product_id, lang),
|
||||||
|
KEY product_translations_lang_name_idx (lang, name),
|
||||||
|
CONSTRAINT product_translations_product_fk
|
||||||
|
FOREIGN KEY (product_id) REFERENCES products(id)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS product_images (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
product_id INT UNSIGNED NOT NULL,
|
||||||
|
storage_path VARCHAR(255) NOT NULL,
|
||||||
|
alt VARCHAR(255) NULL,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
is_main TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
KEY product_images_product_sort_idx (product_id, sort_order),
|
||||||
|
KEY product_images_main_idx (product_id, is_main),
|
||||||
|
CONSTRAINT product_images_product_fk
|
||||||
|
FOREIGN KEY (product_id) REFERENCES products(id)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS product_categories (
|
||||||
|
product_id INT UNSIGNED NOT NULL,
|
||||||
|
category_id INT UNSIGNED NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (product_id, category_id),
|
||||||
|
KEY product_categories_category_idx (category_id),
|
||||||
|
CONSTRAINT product_categories_product_fk
|
||||||
|
FOREIGN KEY (product_id) REFERENCES products(id)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS attributes (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
type TINYINT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
status TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
KEY attributes_status_idx (status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS attribute_translations (
|
||||||
|
attribute_id INT UNSIGNED NOT NULL,
|
||||||
|
lang VARCHAR(8) NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (attribute_id, lang),
|
||||||
|
KEY attribute_translations_lang_name_idx (lang, name),
|
||||||
|
CONSTRAINT attribute_translations_attribute_fk
|
||||||
|
FOREIGN KEY (attribute_id) REFERENCES attributes(id)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS attribute_values (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
attribute_id INT UNSIGNED NOT NULL,
|
||||||
|
status TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
is_default TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
impact_on_price DECIMAL(12,2) NULL,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
KEY attribute_values_attribute_idx (attribute_id),
|
||||||
|
KEY attribute_values_default_idx (attribute_id, is_default),
|
||||||
|
CONSTRAINT attribute_values_attribute_fk
|
||||||
|
FOREIGN KEY (attribute_id) REFERENCES attributes(id)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS attribute_value_translations (
|
||||||
|
value_id INT UNSIGNED NOT NULL,
|
||||||
|
lang VARCHAR(8) NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (value_id, lang),
|
||||||
|
KEY attribute_value_translations_lang_name_idx (lang, name),
|
||||||
|
CONSTRAINT attribute_value_translations_value_fk
|
||||||
|
FOREIGN KEY (value_id) REFERENCES attribute_values(id)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS product_variants (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
product_id INT UNSIGNED NOT NULL,
|
||||||
|
permutation_hash VARCHAR(191) NOT NULL,
|
||||||
|
sku VARCHAR(128) NULL,
|
||||||
|
ean VARCHAR(32) NULL,
|
||||||
|
status TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
price_brutto DECIMAL(12,2) NULL,
|
||||||
|
price_brutto_promo DECIMAL(12,2) NULL,
|
||||||
|
price_netto DECIMAL(12,2) NULL,
|
||||||
|
price_netto_promo DECIMAL(12,2) NULL,
|
||||||
|
weight DECIMAL(10,3) NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY product_variants_sku_unique (sku),
|
||||||
|
UNIQUE KEY product_variants_product_permutation_unique (product_id, permutation_hash),
|
||||||
|
KEY product_variants_product_idx (product_id),
|
||||||
|
KEY product_variants_status_idx (status),
|
||||||
|
CONSTRAINT product_variants_product_fk
|
||||||
|
FOREIGN KEY (product_id) REFERENCES products(id)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS product_variant_attributes (
|
||||||
|
variant_id INT UNSIGNED NOT NULL,
|
||||||
|
attribute_id INT UNSIGNED NOT NULL,
|
||||||
|
value_id INT UNSIGNED NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (variant_id, attribute_id),
|
||||||
|
KEY product_variant_attributes_value_idx (value_id),
|
||||||
|
CONSTRAINT product_variant_attributes_variant_fk
|
||||||
|
FOREIGN KEY (variant_id) REFERENCES product_variants(id)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT product_variant_attributes_attribute_fk
|
||||||
|
FOREIGN KEY (attribute_id) REFERENCES attributes(id)
|
||||||
|
ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT product_variant_attributes_value_fk
|
||||||
|
FOREIGN KEY (value_id) REFERENCES attribute_values(id)
|
||||||
|
ON DELETE RESTRICT ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS product_change_log (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
product_id INT UNSIGNED NOT NULL,
|
||||||
|
user_id INT UNSIGNED NULL,
|
||||||
|
change_type VARCHAR(64) NOT NULL,
|
||||||
|
before_json JSON NULL,
|
||||||
|
after_json JSON NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
KEY product_change_log_product_idx (product_id),
|
||||||
|
KEY product_change_log_user_idx (user_id),
|
||||||
|
KEY product_change_log_created_idx (created_at),
|
||||||
|
CONSTRAINT product_change_log_product_fk
|
||||||
|
FOREIGN KEY (product_id) REFERENCES products(id)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT product_change_log_user_fk
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sales_channels (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
code VARCHAR(64) NOT NULL,
|
||||||
|
name VARCHAR(128) NOT NULL,
|
||||||
|
type VARCHAR(64) NOT NULL,
|
||||||
|
status TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY sales_channels_code_unique (code),
|
||||||
|
KEY sales_channels_type_status_idx (type, status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS product_channel_map (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
product_id INT UNSIGNED NOT NULL,
|
||||||
|
channel_id INT UNSIGNED NOT NULL,
|
||||||
|
external_product_id VARCHAR(128) NULL,
|
||||||
|
external_variant_id VARCHAR(128) NULL,
|
||||||
|
sync_state VARCHAR(32) NOT NULL DEFAULT 'not_linked',
|
||||||
|
last_sync_at DATETIME NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
KEY product_channel_map_product_idx (product_id),
|
||||||
|
KEY product_channel_map_channel_idx (channel_id),
|
||||||
|
KEY product_channel_map_sync_state_idx (sync_state),
|
||||||
|
UNIQUE KEY product_channel_map_unique (product_id, channel_id, external_product_id, external_variant_id),
|
||||||
|
CONSTRAINT product_channel_map_product_fk
|
||||||
|
FOREIGN KEY (product_id) REFERENCES products(id)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT product_channel_map_channel_fk
|
||||||
|
FOREIGN KEY (channel_id) REFERENCES sales_channels(id)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS integrations (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
type VARCHAR(32) NOT NULL,
|
||||||
|
name VARCHAR(128) NOT NULL,
|
||||||
|
base_url VARCHAR(255) NOT NULL,
|
||||||
|
api_key_encrypted TEXT NULL,
|
||||||
|
timeout_seconds SMALLINT UNSIGNED NOT NULL DEFAULT 10,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
last_test_status VARCHAR(16) NULL,
|
||||||
|
last_test_http_code SMALLINT UNSIGNED NULL,
|
||||||
|
last_test_message VARCHAR(255) NULL,
|
||||||
|
last_test_at DATETIME NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY integrations_type_name_unique (type, name),
|
||||||
|
KEY integrations_type_active_idx (type, is_active),
|
||||||
|
KEY integrations_last_test_at_idx (last_test_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS integration_test_logs (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
integration_id INT UNSIGNED NOT NULL,
|
||||||
|
status VARCHAR(16) NOT NULL,
|
||||||
|
http_code SMALLINT UNSIGNED NULL,
|
||||||
|
message VARCHAR(255) NOT NULL,
|
||||||
|
endpoint_url VARCHAR(255) NULL,
|
||||||
|
tested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
KEY integration_test_logs_integration_idx (integration_id),
|
||||||
|
KEY integration_test_logs_tested_at_idx (tested_at),
|
||||||
|
CONSTRAINT integration_test_logs_integration_fk
|
||||||
|
FOREIGN KEY (integration_id) REFERENCES integrations(id)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
ALTER TABLE product_channel_map
|
||||||
|
ADD COLUMN integration_id INT UNSIGNED NULL AFTER channel_id,
|
||||||
|
ADD COLUMN link_type VARCHAR(32) NOT NULL DEFAULT 'manual' AFTER sync_state,
|
||||||
|
ADD COLUMN link_status VARCHAR(32) NOT NULL DEFAULT 'active' AFTER link_type,
|
||||||
|
ADD COLUMN confidence TINYINT UNSIGNED NULL AFTER link_status,
|
||||||
|
ADD COLUMN linked_at DATETIME NULL AFTER confidence,
|
||||||
|
ADD COLUMN linked_by_user_id INT UNSIGNED NULL AFTER linked_at,
|
||||||
|
ADD COLUMN unlinked_at DATETIME NULL AFTER linked_by_user_id,
|
||||||
|
ADD COLUMN unlinked_by_user_id INT UNSIGNED NULL AFTER unlinked_at,
|
||||||
|
ADD COLUMN sync_meta_json JSON NULL AFTER unlinked_by_user_id,
|
||||||
|
ADD KEY product_channel_map_integration_external_idx (integration_id, external_product_id, external_variant_id),
|
||||||
|
ADD KEY product_channel_map_product_link_status_idx (product_id, link_status),
|
||||||
|
ADD KEY product_channel_map_channel_link_status_idx (channel_id, link_status),
|
||||||
|
ADD KEY product_channel_map_linked_by_user_idx (linked_by_user_id),
|
||||||
|
ADD KEY product_channel_map_unlinked_by_user_idx (unlinked_by_user_id),
|
||||||
|
ADD CONSTRAINT product_channel_map_integration_fk
|
||||||
|
FOREIGN KEY (integration_id) REFERENCES integrations(id)
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE,
|
||||||
|
ADD CONSTRAINT product_channel_map_linked_by_user_fk
|
||||||
|
FOREIGN KEY (linked_by_user_id) REFERENCES users(id)
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE,
|
||||||
|
ADD CONSTRAINT product_channel_map_unlinked_by_user_fk
|
||||||
|
FOREIGN KEY (unlinked_by_user_id) REFERENCES users(id)
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS channel_offers (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
integration_id INT UNSIGNED NOT NULL,
|
||||||
|
channel_id INT UNSIGNED NOT NULL,
|
||||||
|
external_product_id VARCHAR(128) NOT NULL,
|
||||||
|
external_variant_id VARCHAR(128) NULL,
|
||||||
|
external_offer_id VARCHAR(128) NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
sku VARCHAR(128) NULL,
|
||||||
|
ean VARCHAR(32) NULL,
|
||||||
|
price_brutto DECIMAL(12,2) NULL,
|
||||||
|
quantity DECIMAL(12,3) NULL,
|
||||||
|
currency VARCHAR(8) NULL,
|
||||||
|
offer_status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||||
|
source_updated_at DATETIME NULL,
|
||||||
|
last_seen_at DATETIME NOT NULL,
|
||||||
|
payload_json JSON NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY channel_offers_unique_external (integration_id, external_product_id, external_variant_id),
|
||||||
|
KEY channel_offers_integration_channel_idx (integration_id, channel_id),
|
||||||
|
KEY channel_offers_sku_idx (sku),
|
||||||
|
KEY channel_offers_ean_idx (ean),
|
||||||
|
KEY channel_offers_offer_status_idx (offer_status),
|
||||||
|
KEY channel_offers_last_seen_idx (last_seen_at),
|
||||||
|
CONSTRAINT channel_offers_integration_fk
|
||||||
|
FOREIGN KEY (integration_id) REFERENCES integrations(id)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT channel_offers_channel_fk
|
||||||
|
FOREIGN KEY (channel_id) REFERENCES sales_channels(id)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS product_link_events (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
product_channel_map_id INT UNSIGNED NOT NULL,
|
||||||
|
event_type VARCHAR(32) NOT NULL,
|
||||||
|
before_json JSON NULL,
|
||||||
|
after_json JSON NULL,
|
||||||
|
created_by_user_id INT UNSIGNED NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
KEY product_link_events_map_idx (product_channel_map_id),
|
||||||
|
KEY product_link_events_event_type_idx (event_type),
|
||||||
|
KEY product_link_events_created_by_user_idx (created_by_user_id),
|
||||||
|
KEY product_link_events_created_at_idx (created_at),
|
||||||
|
CONSTRAINT product_link_events_map_fk
|
||||||
|
FOREIGN KEY (product_channel_map_id) REFERENCES product_channel_map(id)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT product_link_events_created_by_user_fk
|
||||||
|
FOREIGN KEY (created_by_user_id) REFERENCES users(id)
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
:root{--c-primary: #6690f4;--c-primary-dark: #3164db;--c-bg: #f4f6f9;--c-surface: #ffffff;--c-text: #4e5e6a;--c-text-strong: #2d3748;--c-muted: #718096;--c-border: #e2e8f0;--c-danger: #cc0000;--focus-ring: 0 0 0 3px rgba(102, 144, 244, 0.15);--shadow-card: 0 1px 4px rgba(0, 0, 0, 0.06)}.btn{display:inline-flex;align-items:center;justify-content:center;min-height:38px;padding:8px 16px;border:1px solid rgba(0,0,0,0);border-radius:8px;font:inherit;font-weight:600;text-decoration:none;cursor:pointer;transition:background-color .2s ease,border-color .2s ease,color .2s ease,transform .1s ease}.btn--primary{color:#fff;background:var(--c-primary)}.btn--primary:hover{background:var(--c-primary-dark)}.btn--secondary{color:var(--c-text-strong);border-color:var(--c-border);background:var(--c-surface)}.btn--secondary:hover{border-color:#cbd5e0;background:#f8fafc}.btn--block{width:100%}.btn:active{transform:translateY(1px)}.btn:focus-visible{outline:none;box-shadow:var(--focus-ring);border-color:var(--c-primary)}.form-control{width:100%;min-height:38px;border:1px solid var(--c-border);border-radius:8px;padding:7px 12px;font:inherit;color:var(--c-text-strong);background:#fff;transition:border-color .2s ease,box-shadow .2s ease}.form-control:focus{outline:none;border-color:var(--c-primary);box-shadow:var(--focus-ring)}.alert{padding:12px 14px;border-radius:8px;border:1px solid rgba(0,0,0,0);font-size:13px;min-height:44px}.alert--danger{border-color:#fed7d7;background:#fff5f5;color:var(--c-danger)}.alert--success{border-color:#b7ebcf;background:#f0fff6;color:#0f6b39}.alert--warning{border-color:#f7dd8b;background:#fff8e8;color:#815500}.form-field{display:grid;gap:7px}.field-label{color:var(--c-text-strong);font-size:13px;font-weight:600}.table-wrap{width:100%;overflow-x:auto}.table{width:100%;border-collapse:collapse;background:var(--c-surface)}.table th,.table td{padding:10px 12px;border-bottom:1px solid var(--c-border);text-align:left}.table th{color:var(--c-text-strong);font-weight:700;background:#f8fafc}.pagination{display:flex;align-items:center;flex-wrap:wrap;gap:8px}.pagination__item{display:inline-flex;align-items:center;justify-content:center;min-width:36px;height:36px;padding:0 10px;border-radius:8px;border:1px solid var(--c-border);color:var(--c-text-strong);background:var(--c-surface);text-decoration:none;font-weight:600}.pagination__item:hover{border-color:#cbd5e0;background:#f8fafc}.pagination__item.is-active{border-color:var(--c-primary);color:var(--c-primary);background:#edf2ff}:root{--shadow-card: 0 20px 50px rgba(22, 34, 58, 0.14)}*{box-sizing:border-box}html,body{min-height:100%}body{margin:0;font-family:"Roboto","Segoe UI",sans-serif;color:var(--c-text);background:var(--c-bg);overflow-x:hidden}.bg-orb{position:fixed;width:460px;height:460px;border-radius:999px;filter:blur(28px);z-index:0;opacity:.45;pointer-events:none}.bg-orb-left{top:-200px;left:-180px;background:radial-gradient(circle, rgba(102, 144, 244, 0.48) 0%, rgba(102, 144, 244, 0) 70%)}.bg-orb-right{right:-200px;bottom:-220px;background:radial-gradient(circle, rgba(30, 42, 58, 0.36) 0%, rgba(30, 42, 58, 0) 70%)}.login-page{min-height:100vh;display:grid;place-items:center;padding:32px 20px;position:relative;z-index:1}.login-card{width:100%;max-width:430px;background:var(--c-surface);border:1px solid var(--c-border);border-radius:12px;box-shadow:var(--shadow-card);padding:34px 30px 28px;animation:card-enter 420ms ease-out}.login-header{margin-bottom:24px}.login-badge{display:inline-block;margin:0 0 14px;padding:5px 12px;border-radius:999px;border:1px solid #d9e2ff;background:#eef2ff;color:#3f5faf;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.06em}h1{margin:0;color:var(--c-text-strong);font-size:clamp(1.6rem,2.5vw,1.9rem);line-height:1.15;font-weight:700}.login-subtitle{margin:10px 0 0;font-size:15px;line-height:1.55;color:var(--c-muted)}.login-alert{margin-bottom:18px}.login-alert-placeholder{opacity:.56}.login-form{display:grid;gap:16px}.form-field{display:grid;gap:7px}.field-label{color:var(--c-text-strong);font-size:13px;font-weight:600}.login-form .form-control{min-height:46px;padding:0 14px;border-width:2px}.login-form .form-control::placeholder{color:#cbd5e0}.login-submit{margin-top:2px;font-size:15px;min-height:48px}@keyframes card-enter{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}@media(max-width: 640px){.login-page{padding:18px 14px}.login-card{padding:24px 20px 20px}h1{font-size:1.55rem}}
|
:root{--c-primary: #6690f4;--c-primary-dark: #3164db;--c-bg: #f4f6f9;--c-surface: #ffffff;--c-text: #4e5e6a;--c-text-strong: #2d3748;--c-muted: #718096;--c-border: #e2e8f0;--c-danger: #cc0000;--focus-ring: 0 0 0 3px rgba(102, 144, 244, 0.15);--shadow-card: 0 1px 4px rgba(0, 0, 0, 0.06)}.btn{display:inline-flex;align-items:center;justify-content:center;min-height:38px;padding:8px 16px;border:1px solid rgba(0,0,0,0);border-radius:8px;font:inherit;font-weight:600;text-decoration:none;cursor:pointer;transition:background-color .2s ease,border-color .2s ease,color .2s ease,transform .1s ease}.btn--primary{color:#fff;background:var(--c-primary)}.btn--primary:hover{background:var(--c-primary-dark)}.btn--secondary{color:var(--c-text-strong);border-color:var(--c-border);background:var(--c-surface)}.btn--secondary:hover{border-color:#cbd5e0;background:#f8fafc}.btn--danger{color:#fff;border-color:#b91c1c;background:#dc2626}.btn--danger:hover{border-color:#991b1b;background:#b91c1c}.btn--block{width:100%}.btn:active{transform:translateY(1px)}.btn:focus-visible{outline:none;box-shadow:var(--focus-ring);border-color:var(--c-primary)}.form-control{width:100%;min-height:38px;border:1px solid var(--c-border);border-radius:8px;padding:7px 12px;font:inherit;color:var(--c-text-strong);background:#fff;transition:border-color .2s ease,box-shadow .2s ease}.form-control:focus{outline:none;border-color:var(--c-primary);box-shadow:var(--focus-ring)}.alert{padding:12px 14px;border-radius:8px;border:1px solid rgba(0,0,0,0);font-size:13px;min-height:44px}.alert--danger{border-color:#fed7d7;background:#fff5f5;color:var(--c-danger)}.alert--success{border-color:#b7ebcf;background:#f0fff6;color:#0f6b39}.alert--warning{border-color:#f7dd8b;background:#fff8e8;color:#815500}.form-field{display:grid;gap:7px}.field-label{color:var(--c-text-strong);font-size:13px;font-weight:600}.table-wrap{width:100%;overflow-x:auto}.table{width:100%;border-collapse:collapse;background:var(--c-surface)}.table th,.table td{padding:10px 12px;border-bottom:1px solid var(--c-border);text-align:left}.table th{color:var(--c-text-strong);font-weight:700;background:#f8fafc}.pagination{display:flex;align-items:center;flex-wrap:wrap;gap:8px}.pagination__item{display:inline-flex;align-items:center;justify-content:center;min-width:36px;height:36px;padding:0 10px;border-radius:8px;border:1px solid var(--c-border);color:var(--c-text-strong);background:var(--c-surface);text-decoration:none;font-weight:600}.pagination__item:hover{border-color:#cbd5e0;background:#f8fafc}.pagination__item.is-active{border-color:var(--c-primary);color:var(--c-primary);background:#edf2ff}:root{--shadow-card: 0 20px 50px rgba(22, 34, 58, 0.14)}*{box-sizing:border-box}html,body{min-height:100%}body{margin:0;font-family:"Roboto","Segoe UI",sans-serif;color:var(--c-text);background:var(--c-bg);overflow-x:hidden}.bg-orb{position:fixed;width:460px;height:460px;border-radius:999px;filter:blur(28px);z-index:0;opacity:.45;pointer-events:none}.bg-orb-left{top:-200px;left:-180px;background:radial-gradient(circle, rgba(102, 144, 244, 0.48) 0%, rgba(102, 144, 244, 0) 70%)}.bg-orb-right{right:-200px;bottom:-220px;background:radial-gradient(circle, rgba(30, 42, 58, 0.36) 0%, rgba(30, 42, 58, 0) 70%)}.login-page{min-height:100vh;display:grid;place-items:center;padding:32px 20px;position:relative;z-index:1}.login-card{width:100%;max-width:430px;background:var(--c-surface);border:1px solid var(--c-border);border-radius:12px;box-shadow:var(--shadow-card);padding:34px 30px 28px;animation:card-enter 420ms ease-out}.login-header{margin-bottom:24px}.login-badge{display:inline-block;margin:0 0 14px;padding:5px 12px;border-radius:999px;border:1px solid #d9e2ff;background:#eef2ff;color:#3f5faf;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.06em}h1{margin:0;color:var(--c-text-strong);font-size:clamp(1.6rem,2.5vw,1.9rem);line-height:1.15;font-weight:700}.login-subtitle{margin:10px 0 0;font-size:15px;line-height:1.55;color:var(--c-muted)}.login-alert{margin-bottom:18px}.login-alert-placeholder{opacity:.56}.login-form{display:grid;gap:16px}.form-field{display:grid;gap:7px}.field-label{color:var(--c-text-strong);font-size:13px;font-weight:600}.login-form .form-control{min-height:46px;padding:0 14px;border-width:2px}.login-form .form-control::placeholder{color:#cbd5e0}.login-submit{margin-top:2px;font-size:15px;min-height:48px}@keyframes card-enter{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}@media(max-width: 640px){.login-page{padding:18px 14px}.login-card{padding:24px 20px 20px}h1{font-size:1.55rem}}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
.jq-alert{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:12px;padding:12px 14px;border:1px solid rgba(0,0,0,0);border-radius:8px;font-size:14px;opacity:0;transform:translateY(4px);transition:opacity .18s ease,transform .18s ease}.jq-alert.is-visible{opacity:1;transform:translateY(0)}.jq-alert--info{color:#1e3a8a;background:#eff6ff;border-color:#bfdbfe}.jq-alert--success{color:#065f46;background:#ecfdf5;border-color:#a7f3d0}.jq-alert--warning{color:#92400e;background:#fffbeb;border-color:#fde68a}.jq-alert--error{color:#991b1b;background:#fef2f2;border-color:#fecaca}.jq-alert__content{flex:1}.jq-alert__close{appearance:none;border:0;padding:0;line-height:1;font-size:18px;cursor:pointer;color:inherit;background:rgba(0,0,0,0)}
|
.jq-alert-host{position:fixed;top:16px;right:16px;z-index:300;width:min(420px,100vw - 32px)}.jq-alert{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:12px;padding:12px 14px;border:1px solid rgba(0,0,0,0);border-radius:8px;font-size:14px;opacity:0;transform:translateY(4px);transition:opacity .18s ease,transform .18s ease}.jq-alert.is-visible{opacity:1;transform:translateY(0)}.jq-alert--info{color:#1e3a8a;background:#eff6ff;border-color:#bfdbfe}.jq-alert--success{color:#065f46;background:#ecfdf5;border-color:#a7f3d0}.jq-alert--warning{color:#92400e;background:#fffbeb;border-color:#fde68a}.jq-alert--error{color:#991b1b;background:#fef2f2;border-color:#fecaca}.jq-alert__content{flex:1}.jq-alert__close{appearance:none;border:0;padding:0;line-height:1;font-size:18px;cursor:pointer;color:inherit;background:rgba(0,0,0,0)}.jq-alert-modal-backdrop{position:fixed;inset:0;z-index:310;display:flex;align-items:center;justify-content:center;padding:16px;background:rgba(15,23,42,.5);opacity:0;transition:opacity .18s ease}.jq-alert-modal-backdrop.is-visible{opacity:1}.jq-alert-modal{width:min(520px,100%);border-radius:10px;border:1px solid #dbe3ee;background:#fff;box-shadow:0 18px 42px rgba(15,23,42,.3)}.jq-alert-modal__header{padding:14px 16px;border-bottom:1px solid #e2e8f0}.jq-alert-modal__header h3{margin:0;color:#2d3748;font-size:18px}.jq-alert-modal__body{padding:14px 16px;color:#4e5e6a;line-height:1.45}.jq-alert-modal__footer{display:flex;justify-content:flex-end;gap:8px;padding:0 16px 14px}
|
||||||
|
|||||||
230
public/assets/js/modules/jquery-alerts.js
vendored
230
public/assets/js/modules/jquery-alerts.js
vendored
@@ -1,17 +1,17 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
(function (factory) {
|
(function (factory) {
|
||||||
if (typeof module === "object" && module.exports) {
|
if (typeof module === "object" && module.exports && typeof window === "undefined") {
|
||||||
module.exports = factory;
|
module.exports = factory(null, null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window.jQuery !== "undefined") {
|
var win = typeof window !== "undefined" ? window : null;
|
||||||
factory(window.jQuery);
|
var $ = win && typeof win.jQuery !== "undefined" ? win.jQuery : null;
|
||||||
}
|
factory($, win);
|
||||||
})(function ($) {
|
})(function ($, win) {
|
||||||
if (!$ || !$.fn) {
|
if (!win || !win.document) {
|
||||||
return;
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULTS = {
|
const DEFAULTS = {
|
||||||
@@ -21,52 +21,190 @@
|
|||||||
classPrefix: "jq-alert",
|
classPrefix: "jq-alert",
|
||||||
};
|
};
|
||||||
|
|
||||||
function removeAlert($el) {
|
function removeAlert(el) {
|
||||||
$el.removeClass("is-visible");
|
if (!el) {
|
||||||
window.setTimeout(function () {
|
return;
|
||||||
$el.remove();
|
}
|
||||||
|
el.classList.remove("is-visible");
|
||||||
|
win.setTimeout(function () {
|
||||||
|
if (el.parentNode) {
|
||||||
|
el.parentNode.removeChild(el);
|
||||||
|
}
|
||||||
}, 180);
|
}, 180);
|
||||||
}
|
}
|
||||||
|
|
||||||
$.fn.orderProAlert = function (options) {
|
function createElement(tag, className) {
|
||||||
const settings = $.extend({}, DEFAULTS, options);
|
var el = win.document.createElement(tag);
|
||||||
|
if (className) {
|
||||||
|
el.className = className;
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
return this.each(function () {
|
function getGlobalHost() {
|
||||||
const $host = $(this);
|
var id = "jq-alert-host";
|
||||||
const $alert = $("<div>", {
|
var existing = win.document.getElementById(id);
|
||||||
class: settings.classPrefix + " " + settings.classPrefix + "--" + settings.type + " is-visible",
|
if (existing) {
|
||||||
role: "alert",
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
var host = createElement("div", "jq-alert-host");
|
||||||
|
host.id = id;
|
||||||
|
win.document.body.appendChild(host);
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAlert(host, options) {
|
||||||
|
var settings = Object.assign({}, DEFAULTS, options || {});
|
||||||
|
var alertEl = createElement(
|
||||||
|
"div",
|
||||||
|
settings.classPrefix + " " + settings.classPrefix + "--" + settings.type + " is-visible"
|
||||||
|
);
|
||||||
|
alertEl.setAttribute("role", "alert");
|
||||||
|
|
||||||
|
var content = createElement("div", settings.classPrefix + "__content");
|
||||||
|
content.textContent = String(settings.message || "");
|
||||||
|
alertEl.appendChild(content);
|
||||||
|
|
||||||
|
if (settings.dismissible) {
|
||||||
|
var close = createElement("button", settings.classPrefix + "__close");
|
||||||
|
close.type = "button";
|
||||||
|
close.setAttribute("aria-label", "Close alert");
|
||||||
|
close.textContent = "x";
|
||||||
|
close.addEventListener("click", function () {
|
||||||
|
removeAlert(alertEl);
|
||||||
});
|
});
|
||||||
|
alertEl.appendChild(close);
|
||||||
|
}
|
||||||
|
|
||||||
const $content = $("<div>", {
|
host.appendChild(alertEl);
|
||||||
class: settings.classPrefix + "__content",
|
|
||||||
text: String(settings.message || ""),
|
if (settings.timeout > 0) {
|
||||||
|
win.setTimeout(function () {
|
||||||
|
removeAlert(alertEl);
|
||||||
|
}, settings.timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
return alertEl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(options) {
|
||||||
|
return renderAlert(getGlobalHost(), options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeConfirm(backdrop, resolve, value) {
|
||||||
|
if (!backdrop || backdrop.getAttribute("data-closed") === "1") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
backdrop.setAttribute("data-closed", "1");
|
||||||
|
backdrop.classList.remove("is-visible");
|
||||||
|
win.setTimeout(function () {
|
||||||
|
if (backdrop.parentNode) {
|
||||||
|
backdrop.parentNode.removeChild(backdrop);
|
||||||
|
}
|
||||||
|
resolve(value);
|
||||||
|
}, 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirm(options) {
|
||||||
|
var settings = Object.assign(
|
||||||
|
{
|
||||||
|
title: "Potwierdzenie",
|
||||||
|
message: "",
|
||||||
|
confirmLabel: "Potwierdz",
|
||||||
|
cancelLabel: "Anuluj",
|
||||||
|
danger: false,
|
||||||
|
},
|
||||||
|
options || {}
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Promise(function (resolve) {
|
||||||
|
var backdrop = createElement("div", "jq-alert-modal-backdrop is-visible");
|
||||||
|
var modal = createElement("div", "jq-alert-modal");
|
||||||
|
modal.setAttribute("role", "dialog");
|
||||||
|
modal.setAttribute("aria-modal", "true");
|
||||||
|
|
||||||
|
var titleId = "jq-alert-modal-title-" + Date.now();
|
||||||
|
modal.setAttribute("aria-labelledby", titleId);
|
||||||
|
|
||||||
|
var header = createElement("div", "jq-alert-modal__header");
|
||||||
|
var title = createElement("h3");
|
||||||
|
title.id = titleId;
|
||||||
|
title.textContent = String(settings.title || "");
|
||||||
|
header.appendChild(title);
|
||||||
|
|
||||||
|
var body = createElement("div", "jq-alert-modal__body");
|
||||||
|
body.textContent = String(settings.message || "");
|
||||||
|
|
||||||
|
var footer = createElement("div", "jq-alert-modal__footer");
|
||||||
|
var cancelButton = createElement("button", "btn btn--secondary");
|
||||||
|
cancelButton.type = "button";
|
||||||
|
cancelButton.textContent = String(settings.cancelLabel || "Anuluj");
|
||||||
|
var confirmButton = createElement(
|
||||||
|
"button",
|
||||||
|
settings.danger ? "btn btn--danger" : "btn btn--primary"
|
||||||
|
);
|
||||||
|
confirmButton.type = "button";
|
||||||
|
confirmButton.textContent = String(settings.confirmLabel || "Potwierdz");
|
||||||
|
footer.appendChild(cancelButton);
|
||||||
|
footer.appendChild(confirmButton);
|
||||||
|
|
||||||
|
modal.appendChild(header);
|
||||||
|
modal.appendChild(body);
|
||||||
|
modal.appendChild(footer);
|
||||||
|
backdrop.appendChild(modal);
|
||||||
|
win.document.body.appendChild(backdrop);
|
||||||
|
|
||||||
|
var onKeyDown = function (event) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var cleanup = function () {
|
||||||
|
win.document.removeEventListener("keydown", onKeyDown);
|
||||||
|
};
|
||||||
|
|
||||||
|
var onConfirm = function () {
|
||||||
|
cleanup();
|
||||||
|
if (typeof settings.onConfirm === "function") {
|
||||||
|
settings.onConfirm();
|
||||||
|
}
|
||||||
|
closeConfirm(backdrop, resolve, true);
|
||||||
|
};
|
||||||
|
var onCancel = function () {
|
||||||
|
cleanup();
|
||||||
|
if (typeof settings.onCancel === "function") {
|
||||||
|
settings.onCancel();
|
||||||
|
}
|
||||||
|
closeConfirm(backdrop, resolve, false);
|
||||||
|
};
|
||||||
|
|
||||||
|
confirmButton.addEventListener("click", onConfirm);
|
||||||
|
cancelButton.addEventListener("click", onCancel);
|
||||||
|
backdrop.addEventListener("click", function (event) {
|
||||||
|
if (event.target === backdrop) {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
win.document.addEventListener("keydown", onKeyDown);
|
||||||
|
|
||||||
$alert.append($content);
|
confirmButton.focus();
|
||||||
|
|
||||||
if (settings.dismissible) {
|
|
||||||
const $close = $("<button>", {
|
|
||||||
class: settings.classPrefix + "__close",
|
|
||||||
type: "button",
|
|
||||||
"aria-label": "Close alert",
|
|
||||||
text: "x",
|
|
||||||
});
|
|
||||||
|
|
||||||
$close.on("click", function () {
|
|
||||||
removeAlert($alert);
|
|
||||||
});
|
|
||||||
|
|
||||||
$alert.append($close);
|
|
||||||
}
|
|
||||||
|
|
||||||
$host.append($alert);
|
|
||||||
|
|
||||||
if (settings.timeout > 0) {
|
|
||||||
window.setTimeout(function () {
|
|
||||||
removeAlert($alert);
|
|
||||||
}, settings.timeout);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
win.OrderProAlerts = {
|
||||||
|
show: show,
|
||||||
|
confirm: confirm,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if ($ && $.fn) {
|
||||||
|
$.fn.orderProAlert = function (options) {
|
||||||
|
var settings = $.extend({}, DEFAULTS, options);
|
||||||
|
return this.each(function () {
|
||||||
|
renderAlert(this, settings);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return win.OrderProAlerts;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ return [
|
|||||||
'navigation' => [
|
'navigation' => [
|
||||||
'main_menu' => 'Menu glowne',
|
'main_menu' => 'Menu glowne',
|
||||||
'users' => 'Uzytkownicy',
|
'users' => 'Uzytkownicy',
|
||||||
|
'products' => 'Produkty',
|
||||||
'dashboard' => 'Dashboard',
|
'dashboard' => 'Dashboard',
|
||||||
'settings' => 'Ustawienia',
|
'settings' => 'Ustawienia',
|
||||||
],
|
],
|
||||||
@@ -68,6 +69,203 @@ return [
|
|||||||
'email_taken' => 'Ten adres email jest juz zajety.',
|
'email_taken' => 'Ten adres email jest juz zajety.',
|
||||||
'password_min' => 'Haslo musi miec co najmniej 8 znakow.',
|
'password_min' => 'Haslo musi miec co najmniej 8 znakow.',
|
||||||
],
|
],
|
||||||
|
'filters' => [
|
||||||
|
'search' => 'Szukaj (imie, email)',
|
||||||
|
'sort' => 'Sortowanie',
|
||||||
|
'direction' => 'Kierunek',
|
||||||
|
'per_page' => 'Na strone',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'products' => [
|
||||||
|
'title' => 'Produkty',
|
||||||
|
'description' => 'Lokalna baza produktow orderPRO przygotowana pod przyszly sync kanalowy.',
|
||||||
|
'empty' => 'Brak produktow. Dodaj pierwszy produkt.',
|
||||||
|
'flash' => [
|
||||||
|
'created' => 'Produkt zostal dodany.',
|
||||||
|
'updated' => 'Produkt zostal zaktualizowany.',
|
||||||
|
'deleted' => 'Produkt zostal usuniety.',
|
||||||
|
'delete_failed' => 'Nie udalo sie usunac produktu.',
|
||||||
|
'not_found' => 'Nie znaleziono wskazanego produktu.',
|
||||||
|
],
|
||||||
|
'actions' => [
|
||||||
|
'add' => 'Dodaj produkt',
|
||||||
|
'import_shoppro' => 'Import z shopPRO',
|
||||||
|
'preview' => 'Podglad',
|
||||||
|
'links' => 'Powiazania',
|
||||||
|
'edit' => 'Edytuj',
|
||||||
|
'delete' => 'Usun',
|
||||||
|
'save' => 'Zapisz',
|
||||||
|
'back' => 'Wroc do listy',
|
||||||
|
'filter' => 'Filtruj',
|
||||||
|
'reset' => 'Resetuj',
|
||||||
|
],
|
||||||
|
'tabs' => [
|
||||||
|
'details' => 'Szczegoly',
|
||||||
|
'links' => 'Powiazania',
|
||||||
|
],
|
||||||
|
'confirm' => [
|
||||||
|
'delete' => 'Czy na pewno usunac produkt #:id?',
|
||||||
|
],
|
||||||
|
'fields' => [
|
||||||
|
'name' => 'Nazwa',
|
||||||
|
'type' => 'Typ',
|
||||||
|
'status' => 'Status',
|
||||||
|
'promoted' => 'Promowany',
|
||||||
|
'vat' => 'VAT (%)',
|
||||||
|
'weight' => 'Waga',
|
||||||
|
'quantity' => 'Stan',
|
||||||
|
'price_input_mode' => 'Tryb ceny',
|
||||||
|
'price_brutto' => 'Cena brutto',
|
||||||
|
'price_netto' => 'Cena netto',
|
||||||
|
'price_brutto_promo' => 'Cena brutto promo',
|
||||||
|
'price_netto_promo' => 'Cena netto promo',
|
||||||
|
'short_description' => 'Krotki opis',
|
||||||
|
'description' => 'Opis',
|
||||||
|
'meta_title' => 'Meta title',
|
||||||
|
'meta_description' => 'Meta description',
|
||||||
|
'meta_keywords' => 'Meta keywords',
|
||||||
|
'seo_link' => 'SEO link',
|
||||||
|
'updated_at' => 'Data modyfikacji',
|
||||||
|
'actions' => 'Akcje',
|
||||||
|
],
|
||||||
|
'status' => [
|
||||||
|
'active' => 'Aktywny',
|
||||||
|
'inactive' => 'Nieaktywny',
|
||||||
|
],
|
||||||
|
'promoted' => [
|
||||||
|
'yes' => 'Tak',
|
||||||
|
'no' => 'Nie',
|
||||||
|
],
|
||||||
|
'type' => [
|
||||||
|
'simple' => 'Prosty',
|
||||||
|
'variant_parent' => 'Wariantowy',
|
||||||
|
],
|
||||||
|
'price_mode' => [
|
||||||
|
'brutto' => 'Wpisuje brutto',
|
||||||
|
'netto' => 'Wpisuje netto',
|
||||||
|
],
|
||||||
|
'filters' => [
|
||||||
|
'title' => 'Filtry i sortowanie',
|
||||||
|
'search' => 'Szukaj (nazwa, SKU, EAN)',
|
||||||
|
'status' => 'Status',
|
||||||
|
'type' => 'Typ',
|
||||||
|
'sort' => 'Sortowanie',
|
||||||
|
'direction' => 'Kierunek',
|
||||||
|
'per_page' => 'Na strone',
|
||||||
|
'any' => 'Wszystkie',
|
||||||
|
],
|
||||||
|
'pagination' => [
|
||||||
|
'summary' => 'Lacznie rekordow: :total',
|
||||||
|
],
|
||||||
|
'create' => [
|
||||||
|
'title' => 'Dodaj produkt',
|
||||||
|
'description' => 'Utworz lokalny produkt w orderPRO (bez eksportu).',
|
||||||
|
],
|
||||||
|
'edit' => [
|
||||||
|
'title' => 'Edycja produktu #:id',
|
||||||
|
'description' => 'Aktualizuj dane produktu i zapisuj zmiany w logu.',
|
||||||
|
],
|
||||||
|
'show' => [
|
||||||
|
'title' => 'Podglad produktu #:id',
|
||||||
|
'description' => 'Widok tylko do odczytu wszystkich danych produktu.',
|
||||||
|
'details' => 'Szczegoly produktu',
|
||||||
|
],
|
||||||
|
'links' => [
|
||||||
|
'title' => 'Powiazania',
|
||||||
|
'page_title' => 'Powiazania produktu #:id',
|
||||||
|
'description' => 'Mapowanie produktu orderPRO do ofert zewnetrznych (shopPRO / marketplace).',
|
||||||
|
'current_links' => 'Aktualne powiazania',
|
||||||
|
'search_title' => 'Wyszukiwarka ofert',
|
||||||
|
'empty_links' => 'Brak powiazan dla tego produktu.',
|
||||||
|
'empty_offers' => 'Brak ofert do wyswietlenia. Wybierz integracje, wpisz filtr lub wykonaj import ofert.',
|
||||||
|
'integration_placeholder' => '-- wybierz integracje --',
|
||||||
|
'search_placeholder' => 'Szukaj po nazwie, SKU, EAN lub external_product_id',
|
||||||
|
'fields' => [
|
||||||
|
'integration' => 'Instancja',
|
||||||
|
'channel' => 'Kanal',
|
||||||
|
'search' => 'Fraza',
|
||||||
|
'offer_name' => 'Oferta',
|
||||||
|
'external_product_id' => 'External product ID',
|
||||||
|
'external_variant_id' => 'External variant ID',
|
||||||
|
'external_variant_id_optional' => 'Opcjonalny variant ID',
|
||||||
|
'link_type' => 'Typ',
|
||||||
|
'confidence' => 'Confidence',
|
||||||
|
'link_status' => 'Status',
|
||||||
|
'updated_at' => 'Ostatnia zmiana',
|
||||||
|
'history' => 'Historia',
|
||||||
|
'match_hint' => 'Dopasowanie',
|
||||||
|
'actions' => 'Akcje',
|
||||||
|
],
|
||||||
|
'actions' => [
|
||||||
|
'search' => 'Szukaj ofert',
|
||||||
|
'link' => 'Powiaz',
|
||||||
|
'relink' => 'Przepnij',
|
||||||
|
'unlink' => 'Odlacz',
|
||||||
|
],
|
||||||
|
'confirm' => [
|
||||||
|
'title' => 'Potwierdzenie',
|
||||||
|
'unlink_message' => 'Czy na pewno odlaczyc to powiazanie?',
|
||||||
|
'relink_message' => 'Czy na pewno przepiac powiazanie na nowe ID zewnetrzne?',
|
||||||
|
'yes' => 'Potwierdz',
|
||||||
|
'no' => 'Anuluj',
|
||||||
|
],
|
||||||
|
'flash' => [
|
||||||
|
'linked' => 'Powiazanie zostalo zapisane.',
|
||||||
|
'relinked' => 'Powiazanie zostalo przepiete.',
|
||||||
|
'unlinked' => 'Powiazanie zostalo odlaczone.',
|
||||||
|
'link_failed' => 'Nie udalo sie zapisac powiazania.',
|
||||||
|
'relink_failed' => 'Nie udalo sie przepiac powiazania.',
|
||||||
|
'unlink_failed' => 'Nie udalo sie odlaczyc powiazania.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'images' => [
|
||||||
|
'title' => 'Zdjecia',
|
||||||
|
'description' => 'Aktualne zdjecia produktu. Mozesz usunac wybrane, dodac nowe i wskazac glowne.',
|
||||||
|
'empty' => 'Brak zapisanych zdjec.',
|
||||||
|
'remove' => 'Usun to zdjecie',
|
||||||
|
'add_new' => 'Dodaj nowe zdjecia',
|
||||||
|
'set_main' => 'Ustaw jako glowne',
|
||||||
|
'main' => 'Glowne',
|
||||||
|
'main_hint' => 'Jesli nie wybierzesz recznie zdjecia glownego, system ustawi pierwsze dostepne.',
|
||||||
|
'uploading' => 'Wysylanie zdjec...',
|
||||||
|
'uploaded_ok' => 'Zdjecia zostaly dodane.',
|
||||||
|
'confirm_title' => 'Potwierdzenie',
|
||||||
|
'confirm_delete' => 'Czy na pewno usunac to zdjecie?',
|
||||||
|
'confirm_yes' => 'Usun',
|
||||||
|
'confirm_no' => 'Anuluj',
|
||||||
|
],
|
||||||
|
'variants' => [
|
||||||
|
'title' => 'Warianty produktu',
|
||||||
|
'empty' => 'Brak wariantow dla tego produktu.',
|
||||||
|
'attributes' => 'Atrybuty',
|
||||||
|
'import_warning_title' => 'Ostrzezenie po imporcie wariantow',
|
||||||
|
'import_warning_date' => 'Data ostrzezenia',
|
||||||
|
],
|
||||||
|
'import' => [
|
||||||
|
'title' => 'Import produktow z shopPRO',
|
||||||
|
'close' => 'Zamknij',
|
||||||
|
'integration' => 'Integracja',
|
||||||
|
'integration_placeholder' => '-- wybierz integracje --',
|
||||||
|
'mode' => 'Zakres importu',
|
||||||
|
'mode_all' => 'Wszystkie produkty',
|
||||||
|
'mode_single' => 'Pojedynczy produkt',
|
||||||
|
'external_id' => 'ID produktu w shopPRO',
|
||||||
|
'with_variants' => 'Importuj warianty produktu',
|
||||||
|
'with_variants_hint' => 'Opcja nadpisze lokalna liste wariantow dla importowanego produktu danymi z shopPRO.',
|
||||||
|
'no_integrations' => 'Brak aktywnych integracji shopPRO z kluczem API. Skonfiguruj je w Ustawienia -> Integracje shopPRO.',
|
||||||
|
'submit' => 'Uruchom import',
|
||||||
|
'flash' => [
|
||||||
|
'failed' => 'Import produktow zakonczyl sie bledem.',
|
||||||
|
'integration_required' => 'Wybierz integracje do importu.',
|
||||||
|
'integration_not_found' => 'Nie znaleziono wskazanej integracji.',
|
||||||
|
'api_key_missing' => 'Wybrana integracja nie ma zapisanego klucza API.',
|
||||||
|
'single_id_required' => 'Dla importu pojedynczego podaj ID produktu.',
|
||||||
|
'mode_invalid' => 'Niepoprawny tryb importu.',
|
||||||
|
'no_products' => 'Brak produktow do importu po stronie shopPRO.',
|
||||||
|
'single_ok' => 'Import zakonczony. shopPRO #:external_id -> lokalny produkt #:local_id.',
|
||||||
|
'all_done' => 'Import zakonczony. Zaimportowano: :imported, bledy: :failed.',
|
||||||
|
],
|
||||||
|
],
|
||||||
],
|
],
|
||||||
'settings' => [
|
'settings' => [
|
||||||
'title' => 'Ustawienia',
|
'title' => 'Ustawienia',
|
||||||
@@ -98,5 +296,77 @@ return [
|
|||||||
'failed' => 'Nie udalo sie wykonac migracji. Sprawdz log i polaczenie bazy.',
|
'failed' => 'Nie udalo sie wykonac migracji. Sprawdz log i polaczenie bazy.',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
'integrations' => [
|
||||||
|
'title' => 'Integracje shopPRO',
|
||||||
|
'list_title' => 'Integracje shopPRO',
|
||||||
|
'create_title' => 'Dodaj integracje',
|
||||||
|
'edit_title' => 'Edytuj integracje',
|
||||||
|
'empty' => 'Brak skonfigurowanych integracji.',
|
||||||
|
'fields' => [
|
||||||
|
'name' => 'Nazwa',
|
||||||
|
'base_url' => 'Base URL',
|
||||||
|
'api_key' => 'API Key',
|
||||||
|
'timeout_seconds' => 'Timeout (sekundy)',
|
||||||
|
'active' => 'Aktywna',
|
||||||
|
'active_checkbox' => 'Integracja aktywna',
|
||||||
|
'last_test' => 'Ostatni test',
|
||||||
|
'actions' => 'Akcje',
|
||||||
|
],
|
||||||
|
'actions' => [
|
||||||
|
'save' => 'Zapisz integracje',
|
||||||
|
'edit' => 'Edytuj',
|
||||||
|
'test' => 'Test polaczenia',
|
||||||
|
'test_now' => 'Sprawdz teraz',
|
||||||
|
'import_offers_cache' => 'Importuj oferty',
|
||||||
|
'new' => 'Nowa integracja',
|
||||||
|
],
|
||||||
|
'active' => [
|
||||||
|
'yes' => 'Tak',
|
||||||
|
'no' => 'Nie',
|
||||||
|
],
|
||||||
|
'test_status' => [
|
||||||
|
'never' => 'Nie testowano',
|
||||||
|
'ok' => 'OK',
|
||||||
|
'error' => 'Blad',
|
||||||
|
],
|
||||||
|
'logs_title' => 'Ostatnie testy polaczenia',
|
||||||
|
'logs' => [
|
||||||
|
'fields' => [
|
||||||
|
'tested_at' => 'Data testu',
|
||||||
|
'status' => 'Status',
|
||||||
|
'http_code' => 'HTTP',
|
||||||
|
'message' => 'Komunikat',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'api_key_placeholder_edit' => 'Zostaw puste, aby zachowac obecny klucz',
|
||||||
|
'api_key_saved' => 'Klucz API jest zapisany.',
|
||||||
|
'api_key_missing' => 'Brak zapisanego klucza API.',
|
||||||
|
'validation' => [
|
||||||
|
'name_min' => 'Nazwa integracji musi miec co najmniej 2 znaki.',
|
||||||
|
'base_url_invalid' => 'Podaj poprawny adres URL (http lub https).',
|
||||||
|
'api_key_required' => 'Podaj klucz API dla integracji.',
|
||||||
|
'name_taken' => 'Integracja o tej nazwie juz istnieje.',
|
||||||
|
],
|
||||||
|
'flash' => [
|
||||||
|
'created' => 'Integracja zostala dodana.',
|
||||||
|
'updated' => 'Integracja zostala zapisana.',
|
||||||
|
'not_found' => 'Nie znaleziono wskazanej integracji.',
|
||||||
|
'failed' => 'Nie udalo sie zapisac integracji.',
|
||||||
|
'test_ok' => 'Test polaczenia zakonczony powodzeniem.',
|
||||||
|
'test_failed' => 'Test polaczenia zakonczyl sie bledem.',
|
||||||
|
'import_offers_ok' => 'Import cache ofert zakonczony. Przetworzone rekordy: :imported, bledy: :failed, strony API: :pages.',
|
||||||
|
'import_offers_failed' => 'Import cache ofert zakonczyl sie bledem.',
|
||||||
|
'import_ok' => 'Import produktu zakonczony. shopPRO #:external_id -> lokalny produkt #:local_id. Kanaly: shopPRO=wystawiony, Allegro=nieustalone, Erli=niedostepny.',
|
||||||
|
'import_failed' => 'Import produktu zakonczyl sie bledem.',
|
||||||
|
'import_no_products' => 'Brak aktywnych produktow do importu po stronie shopPRO.',
|
||||||
|
],
|
||||||
|
'import' => [
|
||||||
|
'title' => 'Import testowy produktu',
|
||||||
|
'description' => 'Importuje jeden produkt z shopPRO. Mozesz podac ID produktu albo zostawic puste, aby pobrac pierwszy aktywny.',
|
||||||
|
'external_product_id' => 'ID produktu shopPRO (opcjonalnie)',
|
||||||
|
'external_product_id_placeholder' => 'np. 123',
|
||||||
|
'action' => 'Importuj 1 produkt',
|
||||||
|
],
|
||||||
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
230
resources/modules/jquery-alerts/jquery-alerts.js
vendored
230
resources/modules/jquery-alerts/jquery-alerts.js
vendored
@@ -1,17 +1,17 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
(function (factory) {
|
(function (factory) {
|
||||||
if (typeof module === "object" && module.exports) {
|
if (typeof module === "object" && module.exports && typeof window === "undefined") {
|
||||||
module.exports = factory;
|
module.exports = factory(null, null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window.jQuery !== "undefined") {
|
var win = typeof window !== "undefined" ? window : null;
|
||||||
factory(window.jQuery);
|
var $ = win && typeof win.jQuery !== "undefined" ? win.jQuery : null;
|
||||||
}
|
factory($, win);
|
||||||
})(function ($) {
|
})(function ($, win) {
|
||||||
if (!$ || !$.fn) {
|
if (!win || !win.document) {
|
||||||
return;
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULTS = {
|
const DEFAULTS = {
|
||||||
@@ -21,52 +21,190 @@
|
|||||||
classPrefix: "jq-alert",
|
classPrefix: "jq-alert",
|
||||||
};
|
};
|
||||||
|
|
||||||
function removeAlert($el) {
|
function removeAlert(el) {
|
||||||
$el.removeClass("is-visible");
|
if (!el) {
|
||||||
window.setTimeout(function () {
|
return;
|
||||||
$el.remove();
|
}
|
||||||
|
el.classList.remove("is-visible");
|
||||||
|
win.setTimeout(function () {
|
||||||
|
if (el.parentNode) {
|
||||||
|
el.parentNode.removeChild(el);
|
||||||
|
}
|
||||||
}, 180);
|
}, 180);
|
||||||
}
|
}
|
||||||
|
|
||||||
$.fn.orderProAlert = function (options) {
|
function createElement(tag, className) {
|
||||||
const settings = $.extend({}, DEFAULTS, options);
|
var el = win.document.createElement(tag);
|
||||||
|
if (className) {
|
||||||
|
el.className = className;
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
return this.each(function () {
|
function getGlobalHost() {
|
||||||
const $host = $(this);
|
var id = "jq-alert-host";
|
||||||
const $alert = $("<div>", {
|
var existing = win.document.getElementById(id);
|
||||||
class: settings.classPrefix + " " + settings.classPrefix + "--" + settings.type + " is-visible",
|
if (existing) {
|
||||||
role: "alert",
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
var host = createElement("div", "jq-alert-host");
|
||||||
|
host.id = id;
|
||||||
|
win.document.body.appendChild(host);
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAlert(host, options) {
|
||||||
|
var settings = Object.assign({}, DEFAULTS, options || {});
|
||||||
|
var alertEl = createElement(
|
||||||
|
"div",
|
||||||
|
settings.classPrefix + " " + settings.classPrefix + "--" + settings.type + " is-visible"
|
||||||
|
);
|
||||||
|
alertEl.setAttribute("role", "alert");
|
||||||
|
|
||||||
|
var content = createElement("div", settings.classPrefix + "__content");
|
||||||
|
content.textContent = String(settings.message || "");
|
||||||
|
alertEl.appendChild(content);
|
||||||
|
|
||||||
|
if (settings.dismissible) {
|
||||||
|
var close = createElement("button", settings.classPrefix + "__close");
|
||||||
|
close.type = "button";
|
||||||
|
close.setAttribute("aria-label", "Close alert");
|
||||||
|
close.textContent = "x";
|
||||||
|
close.addEventListener("click", function () {
|
||||||
|
removeAlert(alertEl);
|
||||||
});
|
});
|
||||||
|
alertEl.appendChild(close);
|
||||||
|
}
|
||||||
|
|
||||||
const $content = $("<div>", {
|
host.appendChild(alertEl);
|
||||||
class: settings.classPrefix + "__content",
|
|
||||||
text: String(settings.message || ""),
|
if (settings.timeout > 0) {
|
||||||
|
win.setTimeout(function () {
|
||||||
|
removeAlert(alertEl);
|
||||||
|
}, settings.timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
return alertEl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(options) {
|
||||||
|
return renderAlert(getGlobalHost(), options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeConfirm(backdrop, resolve, value) {
|
||||||
|
if (!backdrop || backdrop.getAttribute("data-closed") === "1") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
backdrop.setAttribute("data-closed", "1");
|
||||||
|
backdrop.classList.remove("is-visible");
|
||||||
|
win.setTimeout(function () {
|
||||||
|
if (backdrop.parentNode) {
|
||||||
|
backdrop.parentNode.removeChild(backdrop);
|
||||||
|
}
|
||||||
|
resolve(value);
|
||||||
|
}, 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirm(options) {
|
||||||
|
var settings = Object.assign(
|
||||||
|
{
|
||||||
|
title: "Potwierdzenie",
|
||||||
|
message: "",
|
||||||
|
confirmLabel: "Potwierdz",
|
||||||
|
cancelLabel: "Anuluj",
|
||||||
|
danger: false,
|
||||||
|
},
|
||||||
|
options || {}
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Promise(function (resolve) {
|
||||||
|
var backdrop = createElement("div", "jq-alert-modal-backdrop is-visible");
|
||||||
|
var modal = createElement("div", "jq-alert-modal");
|
||||||
|
modal.setAttribute("role", "dialog");
|
||||||
|
modal.setAttribute("aria-modal", "true");
|
||||||
|
|
||||||
|
var titleId = "jq-alert-modal-title-" + Date.now();
|
||||||
|
modal.setAttribute("aria-labelledby", titleId);
|
||||||
|
|
||||||
|
var header = createElement("div", "jq-alert-modal__header");
|
||||||
|
var title = createElement("h3");
|
||||||
|
title.id = titleId;
|
||||||
|
title.textContent = String(settings.title || "");
|
||||||
|
header.appendChild(title);
|
||||||
|
|
||||||
|
var body = createElement("div", "jq-alert-modal__body");
|
||||||
|
body.textContent = String(settings.message || "");
|
||||||
|
|
||||||
|
var footer = createElement("div", "jq-alert-modal__footer");
|
||||||
|
var cancelButton = createElement("button", "btn btn--secondary");
|
||||||
|
cancelButton.type = "button";
|
||||||
|
cancelButton.textContent = String(settings.cancelLabel || "Anuluj");
|
||||||
|
var confirmButton = createElement(
|
||||||
|
"button",
|
||||||
|
settings.danger ? "btn btn--danger" : "btn btn--primary"
|
||||||
|
);
|
||||||
|
confirmButton.type = "button";
|
||||||
|
confirmButton.textContent = String(settings.confirmLabel || "Potwierdz");
|
||||||
|
footer.appendChild(cancelButton);
|
||||||
|
footer.appendChild(confirmButton);
|
||||||
|
|
||||||
|
modal.appendChild(header);
|
||||||
|
modal.appendChild(body);
|
||||||
|
modal.appendChild(footer);
|
||||||
|
backdrop.appendChild(modal);
|
||||||
|
win.document.body.appendChild(backdrop);
|
||||||
|
|
||||||
|
var onKeyDown = function (event) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var cleanup = function () {
|
||||||
|
win.document.removeEventListener("keydown", onKeyDown);
|
||||||
|
};
|
||||||
|
|
||||||
|
var onConfirm = function () {
|
||||||
|
cleanup();
|
||||||
|
if (typeof settings.onConfirm === "function") {
|
||||||
|
settings.onConfirm();
|
||||||
|
}
|
||||||
|
closeConfirm(backdrop, resolve, true);
|
||||||
|
};
|
||||||
|
var onCancel = function () {
|
||||||
|
cleanup();
|
||||||
|
if (typeof settings.onCancel === "function") {
|
||||||
|
settings.onCancel();
|
||||||
|
}
|
||||||
|
closeConfirm(backdrop, resolve, false);
|
||||||
|
};
|
||||||
|
|
||||||
|
confirmButton.addEventListener("click", onConfirm);
|
||||||
|
cancelButton.addEventListener("click", onCancel);
|
||||||
|
backdrop.addEventListener("click", function (event) {
|
||||||
|
if (event.target === backdrop) {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
win.document.addEventListener("keydown", onKeyDown);
|
||||||
|
|
||||||
$alert.append($content);
|
confirmButton.focus();
|
||||||
|
|
||||||
if (settings.dismissible) {
|
|
||||||
const $close = $("<button>", {
|
|
||||||
class: settings.classPrefix + "__close",
|
|
||||||
type: "button",
|
|
||||||
"aria-label": "Close alert",
|
|
||||||
text: "x",
|
|
||||||
});
|
|
||||||
|
|
||||||
$close.on("click", function () {
|
|
||||||
removeAlert($alert);
|
|
||||||
});
|
|
||||||
|
|
||||||
$alert.append($close);
|
|
||||||
}
|
|
||||||
|
|
||||||
$host.append($alert);
|
|
||||||
|
|
||||||
if (settings.timeout > 0) {
|
|
||||||
window.setTimeout(function () {
|
|
||||||
removeAlert($alert);
|
|
||||||
}, settings.timeout);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
win.OrderProAlerts = {
|
||||||
|
show: show,
|
||||||
|
confirm: confirm,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if ($ && $.fn) {
|
||||||
|
$.fn.orderProAlert = function (options) {
|
||||||
|
var settings = $.extend({}, DEFAULTS, options);
|
||||||
|
return this.each(function () {
|
||||||
|
renderAlert(this, settings);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return win.OrderProAlerts;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
.jq-alert-host {
|
||||||
|
position: fixed;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 300;
|
||||||
|
width: min(420px, calc(100vw - 32px));
|
||||||
|
}
|
||||||
|
|
||||||
.jq-alert {
|
.jq-alert {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -56,3 +64,52 @@
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.jq-alert-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 310;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(15, 23, 42, 0.5);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jq-alert-modal-backdrop.is-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jq-alert-modal {
|
||||||
|
width: min(520px, 100%);
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #dbe3ee;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jq-alert-modal__header {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jq-alert-modal__header h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #2d3748;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jq-alert-modal__body {
|
||||||
|
padding: 14px 16px;
|
||||||
|
color: #4e5e6a;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jq-alert-modal__footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 16px 14px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -98,9 +98,10 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 1120px;
|
max-width: none;
|
||||||
margin: 24px auto;
|
width: calc(100% - 28px);
|
||||||
padding: 0 18px 24px;
|
margin: 18px 14px;
|
||||||
|
padding: 0 6px 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
@@ -209,6 +210,521 @@ a {
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-form .form-control {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-inline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
width: min(560px, 100%);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.35);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-bottom: 1px solid var(--c-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal__header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--c-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal__body {
|
||||||
|
padding: 16px 18px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid #fed7d7;
|
||||||
|
background: #fff5f5;
|
||||||
|
color: #9b2c2c;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill.is-active {
|
||||||
|
border-color: #b7ebcf;
|
||||||
|
background: #f0fff6;
|
||||||
|
color: #0f6b39;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-list__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-list__left {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-list-header-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.js-filter-toggle-btn.is-active {
|
||||||
|
border-color: #cbd5e0;
|
||||||
|
background: #edf2ff;
|
||||||
|
color: var(--c-primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-filter-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--c-primary);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-filters-wrapper {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-filters-wrapper.is-open {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-list-filters {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-col-toggle-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-col-toggle-dropdown {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
z-index: 30;
|
||||||
|
width: 260px;
|
||||||
|
max-height: 360px;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--c-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 10px 25px rgba(15, 23, 42, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-col-toggle-dropdown.is-open {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-col-toggle-header {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--c-border);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--c-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-col-toggle-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--c-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-col-toggle-item:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-col-toggle-footer {
|
||||||
|
border-top: 1px solid var(--c-border);
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-col-hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-col-switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 34px;
|
||||||
|
min-width: 34px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-col-switch input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-col-switch-slider {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: #cbd5e1;
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-col-switch-slider::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
height: 14px;
|
||||||
|
width: 14px;
|
||||||
|
left: 2px;
|
||||||
|
bottom: 2px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-col-switch input:checked + .table-col-switch-slider {
|
||||||
|
background: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-col-switch input:checked + .table-col-switch-slider::before {
|
||||||
|
transform: translateX(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-sort-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--c-text-strong);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-sort-link:hover {
|
||||||
|
color: var(--c-primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-sort-icon.is-muted {
|
||||||
|
color: #a0aec0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-list__footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-list-per-page-form {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-list-per-page-form .form-control {
|
||||||
|
min-width: 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-inline-action {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-name-cell {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-name-thumb {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 6px;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 1px solid var(--c-border);
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-name-thumb--empty {
|
||||||
|
display: inline-block;
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px dashed #cbd5e0;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-name-thumb-btn {
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-name-thumb-btn:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--focus-ring);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal--image-preview {
|
||||||
|
width: min(760px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-image-preview__img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 70vh;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-images-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-image-card {
|
||||||
|
border: 1px solid #dfe3ea;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-image-card__thumb-wrap {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f2f5f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-image-card__thumb {
|
||||||
|
width: 100%;
|
||||||
|
height: 160px;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-image-card__thumb.is-empty {
|
||||||
|
height: 160px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: #6b7785;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-image-card__badge {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
left: 8px;
|
||||||
|
background: #1f7a43;
|
||||||
|
color: #fff;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-image-card.is-main .product-image-card__badge {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-image-card__meta {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.25;
|
||||||
|
color: #5f6b79;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-image-card__actions {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-image-card__actions .btn {
|
||||||
|
min-height: 34px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.2;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-links-search-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: minmax(220px, 320px) minmax(220px, 1fr) auto;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-links-head {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-tabs-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-links-inline-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
grid-template-columns: minmax(140px, 1fr) minmax(140px, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-links-actions-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-links-actions-row .product-links-relink-form {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-links-unlink-form {
|
||||||
|
margin: 0;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-link-events-list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-link-events-list li {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-link-events-type {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--c-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-link-events-date {
|
||||||
|
color: var(--c-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-show-images-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-show-image-card {
|
||||||
|
border: 1px solid var(--c-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-show-image {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 260px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #d9e0ea;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.app-shell {
|
.app-shell {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -241,14 +757,47 @@ a {
|
|||||||
|
|
||||||
.container {
|
.container {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
padding: 0 14px 18px;
|
width: calc(100% - 16px);
|
||||||
|
margin-left: 8px;
|
||||||
|
margin-right: 8px;
|
||||||
|
padding: 0 4px 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-grid {
|
.settings-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-head {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-grid,
|
||||||
|
.form-grid,
|
||||||
|
.table-list-filters,
|
||||||
|
.product-links-search-form,
|
||||||
|
.product-links-inline-form {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-actions {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-list__header,
|
||||||
|
.table-list__footer {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-links-head {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal--image-preview {
|
||||||
|
width: min(92vw, 100%);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,17 @@
|
|||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn--danger {
|
||||||
|
color: #ffffff;
|
||||||
|
border-color: #b91c1c;
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--danger:hover {
|
||||||
|
border-color: #991b1b;
|
||||||
|
background: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
.btn--block {
|
.btn--block {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
473
resources/views/components/table-list.php
Normal file
473
resources/views/components/table-list.php
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$config = is_array($tableList ?? null) ? $tableList : [];
|
||||||
|
$basePath = (string) ($config['base_path'] ?? '');
|
||||||
|
$query = is_array($config['query'] ?? null) ? $config['query'] : [];
|
||||||
|
$filters = is_array($config['filters'] ?? null) ? $config['filters'] : [];
|
||||||
|
$columns = is_array($config['columns'] ?? null) ? $config['columns'] : [];
|
||||||
|
$rows = is_array($config['rows'] ?? null) ? $config['rows'] : [];
|
||||||
|
$pagination = is_array($config['pagination'] ?? null) ? $config['pagination'] : [];
|
||||||
|
$perPageOptions = is_array($config['per_page_options'] ?? null) ? $config['per_page_options'] : [20, 50, 100];
|
||||||
|
$createUrl = (string) ($config['create_url'] ?? '');
|
||||||
|
$createLabel = (string) ($config['create_label'] ?? '');
|
||||||
|
$headerActions = is_array($config['header_actions'] ?? null) ? $config['header_actions'] : [];
|
||||||
|
$emptyMessage = (string) ($config['empty_message'] ?? 'Brak rekordow.');
|
||||||
|
$showActions = (bool) ($config['show_actions'] ?? true);
|
||||||
|
$actionsLabel = (string) ($config['actions_label'] ?? 'Akcje');
|
||||||
|
$listKey = (string) ($config['list_key'] ?? md5($basePath !== '' ? $basePath : 'table-list'));
|
||||||
|
|
||||||
|
$currentSort = (string) ($query['sort'] ?? 'id');
|
||||||
|
$currentDir = strtoupper((string) ($query['sort_dir'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC';
|
||||||
|
$page = max(1, (int) ($pagination['page'] ?? 1));
|
||||||
|
$totalPages = max(1, (int) ($pagination['total_pages'] ?? 1));
|
||||||
|
$total = max(0, (int) ($pagination['total'] ?? 0));
|
||||||
|
$perPage = max(1, (int) ($pagination['per_page'] ?? 20));
|
||||||
|
|
||||||
|
$activeFiltersCount = 0;
|
||||||
|
foreach ($filters as $filter) {
|
||||||
|
if ((string) ($filter['value'] ?? '') !== '') {
|
||||||
|
$activeFiltersCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$hasActiveFilters = $activeFiltersCount > 0;
|
||||||
|
|
||||||
|
$buildUrl = static function (array $params = []) use ($basePath, $query): string {
|
||||||
|
$merged = array_merge($query, $params);
|
||||||
|
foreach ($merged as $key => $value) {
|
||||||
|
if ($value === '' || $value === null) {
|
||||||
|
unset($merged[$key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$qs = http_build_query($merged);
|
||||||
|
if ($qs === '') {
|
||||||
|
return $basePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $basePath . '?' . $qs;
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
|
||||||
|
<section class="card table-list" data-table-list-id="<?= $e($listKey) ?>" data-table-list-base="<?= $e($basePath) ?>">
|
||||||
|
<div class="table-list__header">
|
||||||
|
<div class="table-list__left">
|
||||||
|
<?php if ($createUrl !== '' && $createLabel !== ''): ?>
|
||||||
|
<a href="<?= $e($createUrl) ?>" class="btn btn--primary">
|
||||||
|
<?= $e($createLabel) ?>
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php foreach ($headerActions as $action): ?>
|
||||||
|
<?php
|
||||||
|
$actionType = (string) ($action['type'] ?? 'link');
|
||||||
|
$actionLabel = (string) ($action['label'] ?? '');
|
||||||
|
$actionClass = (string) ($action['class'] ?? 'btn btn--secondary');
|
||||||
|
$actionAttrs = is_array($action['attrs'] ?? null) ? $action['attrs'] : [];
|
||||||
|
if ($actionLabel === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<?php if ($actionType === 'button'): ?>
|
||||||
|
<button type="button" class="<?= $e($actionClass) ?>"
|
||||||
|
<?php foreach ($actionAttrs as $attrKey => $attrValue): ?>
|
||||||
|
<?= $e((string) $attrKey) ?>="<?= $e((string) $attrValue) ?>"
|
||||||
|
<?php endforeach; ?>
|
||||||
|
><?= $e($actionLabel) ?></button>
|
||||||
|
<?php else: ?>
|
||||||
|
<a href="<?= $e((string) ($action['url'] ?? '#')) ?>" class="<?= $e($actionClass) ?>"
|
||||||
|
<?php foreach ($actionAttrs as $attrKey => $attrValue): ?>
|
||||||
|
<?= $e((string) $attrKey) ?>="<?= $e((string) $attrValue) ?>"
|
||||||
|
<?php endforeach; ?>
|
||||||
|
><?= $e($actionLabel) ?></a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-list-header-actions">
|
||||||
|
<span class="muted">Wynikow: <?= $e((string) $total) ?></span>
|
||||||
|
|
||||||
|
<?php if (!empty($filters)): ?>
|
||||||
|
<button type="button" class="btn btn--secondary js-filter-toggle-btn<?= $hasActiveFilters ? ' is-active' : '' ?>" title="Filtry">
|
||||||
|
Filtry
|
||||||
|
<?php if ($hasActiveFilters): ?>
|
||||||
|
<span class="table-filter-badge"><?= $e((string) $activeFiltersCount) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="table-col-toggle-wrapper">
|
||||||
|
<button type="button" class="btn btn--secondary js-col-toggle-btn" title="Widocznosc kolumn">Kolumny</button>
|
||||||
|
<div class="table-col-toggle-dropdown js-col-toggle-dropdown">
|
||||||
|
<div class="table-col-toggle-header">Widocznosc kolumn</div>
|
||||||
|
<?php foreach ($columns as $index => $column): ?>
|
||||||
|
<?php $colKey = (string) ($column['key'] ?? ('col_' . $index)); ?>
|
||||||
|
<label class="table-col-toggle-item">
|
||||||
|
<span class="table-col-switch">
|
||||||
|
<input type="checkbox" class="js-col-toggle-checkbox" data-col-key="<?= $e($colKey) ?>" checked>
|
||||||
|
<span class="table-col-switch-slider"></span>
|
||||||
|
</span>
|
||||||
|
<?= $e((string) ($column['label'] ?? $colKey)) ?>
|
||||||
|
</label>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<div class="table-col-toggle-footer">
|
||||||
|
<button type="button" class="btn btn--secondary js-col-toggle-reset">Pokaz wszystkie</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!empty($filters)): ?>
|
||||||
|
<div class="table-filters-wrapper js-table-filters-wrapper<?= $hasActiveFilters ? ' is-open' : '' ?>">
|
||||||
|
<form method="get" action="<?= $e($basePath) ?>" class="table-list-filters js-table-filters-form">
|
||||||
|
<?php foreach ($filters as $filter): ?>
|
||||||
|
<?php
|
||||||
|
$filterKey = (string) ($filter['key'] ?? '');
|
||||||
|
$filterType = (string) ($filter['type'] ?? 'text');
|
||||||
|
$filterLabel = (string) ($filter['label'] ?? $filterKey);
|
||||||
|
$filterValue = (string) ($filter['value'] ?? '');
|
||||||
|
$inputId = 'filter_' . preg_replace('/[^a-zA-Z0-9_]+/', '_', $filterKey) . '_' . $listKey;
|
||||||
|
?>
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($filterLabel) ?></span>
|
||||||
|
<?php if ($filterType === 'select'): ?>
|
||||||
|
<select class="form-control" id="<?= $e($inputId) ?>" name="<?= $e($filterKey) ?>">
|
||||||
|
<?php foreach ((array) ($filter['options'] ?? []) as $value => $label): ?>
|
||||||
|
<option value="<?= $e((string) $value) ?>"<?= (string) $value === $filterValue ? ' selected' : '' ?>>
|
||||||
|
<?= $e((string) $label) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<?php elseif ($filterType === 'date'): ?>
|
||||||
|
<input class="form-control" id="<?= $e($inputId) ?>" type="date" name="<?= $e($filterKey) ?>" value="<?= $e($filterValue) ?>">
|
||||||
|
<?php else: ?>
|
||||||
|
<input class="form-control" id="<?= $e($inputId) ?>" type="text" name="<?= $e($filterKey) ?>" value="<?= $e($filterValue) ?>">
|
||||||
|
<?php endif; ?>
|
||||||
|
</label>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
<?php foreach ($query as $key => $value): ?>
|
||||||
|
<?php if (!in_array((string) $key, array_map(static fn (array $filter): string => (string) ($filter['key'] ?? ''), $filters), true) && (string) $key !== 'page'): ?>
|
||||||
|
<input type="hidden" name="<?= $e((string) $key) ?>" value="<?= $e((string) $value) ?>">
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
<div class="filters-actions">
|
||||||
|
<button type="submit" class="btn btn--primary">Szukaj</button>
|
||||||
|
<a href="<?= $e($basePath) ?>" class="btn btn--secondary js-table-filters-clear">Wyczysc</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="table table-list-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<?php foreach ($columns as $index => $column): ?>
|
||||||
|
<?php
|
||||||
|
$colKey = (string) ($column['key'] ?? ('col_' . $index));
|
||||||
|
$label = (string) ($column['label'] ?? $colKey);
|
||||||
|
$sortable = (bool) ($column['sortable'] ?? false);
|
||||||
|
$sortKey = (string) ($column['sort_key'] ?? $colKey);
|
||||||
|
$isCurrent = $sortable && $currentSort === $sortKey;
|
||||||
|
$nextDir = $isCurrent && $currentDir === 'ASC' ? 'DESC' : 'ASC';
|
||||||
|
$headerClass = trim((string) ($column['class'] ?? ''));
|
||||||
|
?>
|
||||||
|
<th class="<?= $e($headerClass) ?>" data-col-key="<?= $e($colKey) ?>">
|
||||||
|
<?php if ($sortable): ?>
|
||||||
|
<a href="<?= $e($buildUrl(['sort' => $sortKey, 'sort_dir' => $nextDir, 'page' => 1])) ?>" class="table-sort-link">
|
||||||
|
<?= $e($label) ?>
|
||||||
|
<?php if ($isCurrent): ?>
|
||||||
|
<span class="table-sort-icon"><?= $e($currentDir === 'ASC' ? '↑' : '↓') ?></span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="table-sort-icon is-muted">↕</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</a>
|
||||||
|
<?php else: ?>
|
||||||
|
<?= $e($label) ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</th>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php if ($showActions): ?>
|
||||||
|
<th><?= $e($actionsLabel) ?></th>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php if (empty($rows)): ?>
|
||||||
|
<tr>
|
||||||
|
<td colspan="<?= $e((string) (count($columns) + ($showActions ? 1 : 0))) ?>" class="muted">
|
||||||
|
<?= $e($emptyMessage) ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php else: ?>
|
||||||
|
<?php foreach ($rows as $row): ?>
|
||||||
|
<tr>
|
||||||
|
<?php foreach ($columns as $index => $column): ?>
|
||||||
|
<?php
|
||||||
|
$colKey = (string) ($column['key'] ?? ('col_' . $index));
|
||||||
|
$raw = (bool) ($column['raw'] ?? false);
|
||||||
|
$cellClass = trim((string) ($column['class'] ?? ''));
|
||||||
|
$value = $row[$colKey] ?? '';
|
||||||
|
?>
|
||||||
|
<td class="<?= $e($cellClass) ?>" data-col-key="<?= $e($colKey) ?>">
|
||||||
|
<?php if ($raw): ?>
|
||||||
|
<?= (string) $value ?>
|
||||||
|
<?php else: ?>
|
||||||
|
<?= $e((string) $value) ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
<?php if ($showActions): ?>
|
||||||
|
<td>
|
||||||
|
<?php foreach ((array) ($row['_actions'] ?? []) as $action): ?>
|
||||||
|
<?php
|
||||||
|
$actionMethod = strtolower((string) ($action['method'] ?? 'get'));
|
||||||
|
$actionUrl = (string) ($action['url'] ?? '#');
|
||||||
|
$actionClass = (string) ($action['class'] ?? 'btn btn--secondary');
|
||||||
|
$actionLabel = (string) ($action['label'] ?? 'Akcja');
|
||||||
|
$actionConfirm = trim((string) ($action['confirm'] ?? ''));
|
||||||
|
$actionConfirmTitle = trim((string) ($action['confirm_title'] ?? 'Potwierdzenie'));
|
||||||
|
$actionConfirmYes = trim((string) ($action['confirm_yes'] ?? 'Potwierdz'));
|
||||||
|
$actionConfirmNo = trim((string) ($action['confirm_no'] ?? 'Anuluj'));
|
||||||
|
$actionParams = is_array($action['params'] ?? null) ? $action['params'] : [];
|
||||||
|
?>
|
||||||
|
<?php if ($actionMethod === 'post'): ?>
|
||||||
|
<form
|
||||||
|
action="<?= $e($actionUrl) ?>"
|
||||||
|
method="post"
|
||||||
|
class="table-inline-action"
|
||||||
|
<?php if ($actionConfirm !== ''): ?>
|
||||||
|
data-alert-confirm="<?= $e($actionConfirm) ?>"
|
||||||
|
data-alert-confirm-title="<?= $e($actionConfirmTitle) ?>"
|
||||||
|
data-alert-confirm-yes="<?= $e($actionConfirmYes) ?>"
|
||||||
|
data-alert-confirm-no="<?= $e($actionConfirmNo) ?>"
|
||||||
|
<?php endif; ?>
|
||||||
|
>
|
||||||
|
<input type="hidden" name="_token" value="<?= $e((string) ($csrfToken ?? '')) ?>">
|
||||||
|
<?php foreach ($actionParams as $paramKey => $paramValue): ?>
|
||||||
|
<input type="hidden" name="<?= $e((string) $paramKey) ?>" value="<?= $e((string) $paramValue) ?>">
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<button type="submit" class="<?= $e($actionClass) ?>"><?= $e($actionLabel) ?></button>
|
||||||
|
</form>
|
||||||
|
<?php else: ?>
|
||||||
|
<a href="<?= $e($actionUrl) ?>" class="<?= $e($actionClass) ?>">
|
||||||
|
<?= $e($actionLabel) ?>
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</td>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-list__footer">
|
||||||
|
<div class="pagination">
|
||||||
|
<?php $startPage = max(1, $page - 2); ?>
|
||||||
|
<?php $endPage = min($totalPages, $page + 2); ?>
|
||||||
|
|
||||||
|
<a class="pagination__item<?= $page <= 1 ? ' is-disabled' : '' ?>" href="<?= $e($buildUrl(['page' => 1])) ?>">«</a>
|
||||||
|
<a class="pagination__item<?= $page <= 1 ? ' is-disabled' : '' ?>" href="<?= $e($buildUrl(['page' => max(1, $page - 1)])) ?>">‹</a>
|
||||||
|
|
||||||
|
<?php for ($i = $startPage; $i <= $endPage; $i++): ?>
|
||||||
|
<a class="pagination__item<?= $i === $page ? ' is-active' : '' ?>" href="<?= $e($buildUrl(['page' => $i])) ?>">
|
||||||
|
<?= $e((string) $i) ?>
|
||||||
|
</a>
|
||||||
|
<?php endfor; ?>
|
||||||
|
|
||||||
|
<a class="pagination__item<?= $page >= $totalPages ? ' is-disabled' : '' ?>" href="<?= $e($buildUrl(['page' => min($totalPages, $page + 1)])) ?>">›</a>
|
||||||
|
<a class="pagination__item<?= $page >= $totalPages ? ' is-disabled' : '' ?>" href="<?= $e($buildUrl(['page' => $totalPages])) ?>">»</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="get" action="<?= $e($basePath) ?>" class="table-list-per-page-form js-per-page-form">
|
||||||
|
<?php foreach ($query as $key => $value): ?>
|
||||||
|
<?php if ((string) $key !== 'per_page' && (string) $key !== 'page'): ?>
|
||||||
|
<input type="hidden" name="<?= $e((string) $key) ?>" value="<?= $e((string) $value) ?>">
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<input type="hidden" name="page" value="1">
|
||||||
|
<span>Wyswietlaj</span>
|
||||||
|
<select class="form-control js-per-page-select" name="per_page">
|
||||||
|
<?php foreach ($perPageOptions as $opt): ?>
|
||||||
|
<option value="<?= $e((string) $opt) ?>"<?= (int) $opt === $perPage ? ' selected' : '' ?>><?= $e((string) $opt) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<span>rekordow</span>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var root = document.querySelector('[data-table-list-id="<?= $e($listKey) ?>"]');
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
var basePath = root.getAttribute('data-table-list-base') || '';
|
||||||
|
var storagePrefix = 'tableList_' + (basePath || 'default') + '_<?= $e($listKey) ?>';
|
||||||
|
var filterKey = storagePrefix + '_filters_open';
|
||||||
|
var colsKey = storagePrefix + '_hidden_cols';
|
||||||
|
var queryKey = storagePrefix + '_query';
|
||||||
|
var clearKey = storagePrefix + '_cleared';
|
||||||
|
|
||||||
|
function readJson(key, fallback) {
|
||||||
|
try {
|
||||||
|
var val = localStorage.getItem(key);
|
||||||
|
if (!val) return fallback;
|
||||||
|
return JSON.parse(val);
|
||||||
|
} catch (e) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeJson(key, val) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, JSON.stringify(val));
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
var filterWrapper = root.querySelector('.js-table-filters-wrapper');
|
||||||
|
var filterBtn = root.querySelector('.js-filter-toggle-btn');
|
||||||
|
if (filterWrapper && filterBtn) {
|
||||||
|
var savedOpen = localStorage.getItem(filterKey) === '1';
|
||||||
|
if (savedOpen && !filterWrapper.classList.contains('is-open')) {
|
||||||
|
filterWrapper.classList.add('is-open');
|
||||||
|
filterBtn.classList.add('is-active');
|
||||||
|
}
|
||||||
|
|
||||||
|
filterBtn.addEventListener('click', function() {
|
||||||
|
var isOpen = filterWrapper.classList.toggle('is-open');
|
||||||
|
filterBtn.classList.toggle('is-active', isOpen);
|
||||||
|
try { localStorage.setItem(filterKey, isOpen ? '1' : '0'); } catch (e) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var dropdownBtn = root.querySelector('.js-col-toggle-btn');
|
||||||
|
var dropdown = root.querySelector('.js-col-toggle-dropdown');
|
||||||
|
if (dropdownBtn && dropdown) {
|
||||||
|
dropdownBtn.addEventListener('click', function(ev) {
|
||||||
|
ev.stopPropagation();
|
||||||
|
dropdown.classList.toggle('is-open');
|
||||||
|
});
|
||||||
|
dropdown.addEventListener('click', function(ev) { ev.stopPropagation(); });
|
||||||
|
document.addEventListener('click', function() { dropdown.classList.remove('is-open'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyHiddenCols(hiddenCols) {
|
||||||
|
var allCells = root.querySelectorAll('th[data-col-key], td[data-col-key]');
|
||||||
|
allCells.forEach(function(cell) {
|
||||||
|
var key = cell.getAttribute('data-col-key');
|
||||||
|
cell.classList.toggle('table-col-hidden', hiddenCols.indexOf(key) !== -1);
|
||||||
|
});
|
||||||
|
|
||||||
|
var checkboxes = root.querySelectorAll('.js-col-toggle-checkbox');
|
||||||
|
checkboxes.forEach(function(cb) {
|
||||||
|
var key = cb.getAttribute('data-col-key');
|
||||||
|
cb.checked = hiddenCols.indexOf(key) === -1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var hiddenCols = readJson(colsKey, []);
|
||||||
|
if (!Array.isArray(hiddenCols)) hiddenCols = [];
|
||||||
|
applyHiddenCols(hiddenCols);
|
||||||
|
|
||||||
|
root.querySelectorAll('.js-col-toggle-checkbox').forEach(function(cb) {
|
||||||
|
cb.addEventListener('change', function() {
|
||||||
|
var key = cb.getAttribute('data-col-key');
|
||||||
|
hiddenCols = readJson(colsKey, []);
|
||||||
|
if (!Array.isArray(hiddenCols)) hiddenCols = [];
|
||||||
|
|
||||||
|
if (cb.checked) {
|
||||||
|
hiddenCols = hiddenCols.filter(function(v) { return v !== key; });
|
||||||
|
} else if (hiddenCols.indexOf(key) === -1) {
|
||||||
|
hiddenCols.push(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJson(colsKey, hiddenCols);
|
||||||
|
applyHiddenCols(hiddenCols);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var resetBtn = root.querySelector('.js-col-toggle-reset');
|
||||||
|
if (resetBtn) {
|
||||||
|
resetBtn.addEventListener('click', function() {
|
||||||
|
hiddenCols = [];
|
||||||
|
writeJson(colsKey, hiddenCols);
|
||||||
|
applyHiddenCols(hiddenCols);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
root.querySelectorAll('.js-per-page-select').forEach(function(select) {
|
||||||
|
select.addEventListener('change', function() {
|
||||||
|
var form = select.closest('form');
|
||||||
|
if (form) form.submit();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var clearBtn = root.querySelector('.js-table-filters-clear');
|
||||||
|
if (clearBtn) {
|
||||||
|
clearBtn.addEventListener('click', function() {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(queryKey);
|
||||||
|
sessionStorage.setItem(clearKey, '1');
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
root.addEventListener('submit', function(event) {
|
||||||
|
var form = event.target;
|
||||||
|
if (!form || !form.matches || !form.matches('form[data-alert-confirm]')) return;
|
||||||
|
if (form.getAttribute('data-confirmed') === '1') {
|
||||||
|
form.removeAttribute('data-confirmed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var message = form.getAttribute('data-alert-confirm') || '';
|
||||||
|
if (message === '') return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
var title = form.getAttribute('data-alert-confirm-title') || 'Potwierdzenie';
|
||||||
|
var confirmYes = form.getAttribute('data-alert-confirm-yes') || 'Potwierdz';
|
||||||
|
var confirmNo = form.getAttribute('data-alert-confirm-no') || 'Anuluj';
|
||||||
|
|
||||||
|
if (window.OrderProAlerts && typeof window.OrderProAlerts.confirm === 'function') {
|
||||||
|
window.OrderProAlerts.confirm({
|
||||||
|
title: title,
|
||||||
|
message: message,
|
||||||
|
confirmLabel: confirmYes,
|
||||||
|
cancelLabel: confirmNo,
|
||||||
|
danger: true
|
||||||
|
}).then(function(accepted) {
|
||||||
|
if (!accepted) return;
|
||||||
|
form.setAttribute('data-confirmed', '1');
|
||||||
|
form.submit();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.confirm(message)) {
|
||||||
|
form.setAttribute('data-confirmed', '1');
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
var query = window.location.search ? window.location.search.substring(1) : '';
|
||||||
|
var justCleared = sessionStorage.getItem(clearKey) === '1';
|
||||||
|
sessionStorage.removeItem(clearKey);
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
localStorage.setItem(queryKey, query);
|
||||||
|
} else if (!justCleared) {
|
||||||
|
var saved = localStorage.getItem(queryKey);
|
||||||
|
if (saved) window.location.replace(basePath + '?' + saved);
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="/assets/css/app.css">
|
<link rel="stylesheet" href="/assets/css/app.css">
|
||||||
|
<link rel="stylesheet" href="/assets/css/modules/jquery-alerts.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<?php $currentMenu = (string) ($activeMenu ?? ''); ?>
|
<?php $currentMenu = (string) ($activeMenu ?? ''); ?>
|
||||||
@@ -22,7 +23,10 @@
|
|||||||
<a class="sidebar__link<?= $currentMenu === 'users' ? ' is-active' : '' ?>" href="/users">
|
<a class="sidebar__link<?= $currentMenu === 'users' ? ' is-active' : '' ?>" href="/users">
|
||||||
<?= $e($t('navigation.users')) ?>
|
<?= $e($t('navigation.users')) ?>
|
||||||
</a>
|
</a>
|
||||||
<a class="sidebar__link<?= $currentMenu === 'settings' ? ' is-active' : '' ?>" href="/settings/database">
|
<a class="sidebar__link<?= $currentMenu === 'products' ? ' is-active' : '' ?>" href="/products">
|
||||||
|
<?= $e($t('navigation.products')) ?>
|
||||||
|
</a>
|
||||||
|
<a class="sidebar__link<?= $currentMenu === 'settings' ? ' is-active' : '' ?>" href="/settings/integrations/shoppro">
|
||||||
<?= $e($t('navigation.settings')) ?>
|
<?= $e($t('navigation.settings')) ?>
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -44,5 +48,6 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script src="/assets/js/modules/jquery-alerts.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
139
resources/views/products/create.php
Normal file
139
resources/views/products/create.php
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
<section class="card">
|
||||||
|
<h1><?= $e($t('products.create.title')) ?></h1>
|
||||||
|
<p class="muted"><?= $e($t('products.create.description')) ?></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card mt-16">
|
||||||
|
<?php if (!empty($errors)): ?>
|
||||||
|
<div class="alert alert--danger" role="alert">
|
||||||
|
<?php foreach ((array) $errors as $error): ?>
|
||||||
|
<div><?= $e((string) $error) ?></div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form class="product-form mt-16" method="post" action="/products">
|
||||||
|
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('products.fields.name')) ?></span>
|
||||||
|
<input class="form-control" type="text" name="name" required value="<?= $e((string) ($form['name'] ?? '')) ?>">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label">SKU</span>
|
||||||
|
<input class="form-control" type="text" name="sku" value="<?= $e((string) ($form['sku'] ?? '')) ?>">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label">EAN</span>
|
||||||
|
<input class="form-control" type="text" name="ean" value="<?= $e((string) ($form['ean'] ?? '')) ?>">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('products.fields.type')) ?></span>
|
||||||
|
<select class="form-control" name="type">
|
||||||
|
<option value="simple"<?= (string) ($form['type'] ?? '') === 'simple' ? ' selected' : '' ?>><?= $e($t('products.type.simple')) ?></option>
|
||||||
|
<option value="variant_parent"<?= (string) ($form['type'] ?? '') === 'variant_parent' ? ' selected' : '' ?>><?= $e($t('products.type.variant_parent')) ?></option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('products.fields.status')) ?></span>
|
||||||
|
<select class="form-control" name="status">
|
||||||
|
<option value="1"<?= (string) ($form['status'] ?? '1') === '1' ? ' selected' : '' ?>><?= $e($t('products.status.active')) ?></option>
|
||||||
|
<option value="0"<?= (string) ($form['status'] ?? '1') === '0' ? ' selected' : '' ?>><?= $e($t('products.status.inactive')) ?></option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('products.fields.promoted')) ?></span>
|
||||||
|
<select class="form-control" name="promoted">
|
||||||
|
<option value="0"<?= (string) ($form['promoted'] ?? '0') === '0' ? ' selected' : '' ?>><?= $e($t('products.promoted.no')) ?></option>
|
||||||
|
<option value="1"<?= (string) ($form['promoted'] ?? '0') === '1' ? ' selected' : '' ?>><?= $e($t('products.promoted.yes')) ?></option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('products.fields.vat')) ?></span>
|
||||||
|
<input class="form-control" type="number" step="0.01" min="0" max="100" name="vat" value="<?= $e((string) ($form['vat'] ?? '')) ?>">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('products.fields.quantity')) ?></span>
|
||||||
|
<input class="form-control" type="number" step="0.001" min="0" name="quantity" value="<?= $e((string) ($form['quantity'] ?? '0')) ?>">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('products.fields.weight')) ?></span>
|
||||||
|
<input class="form-control" type="number" step="0.001" min="0" name="weight" value="<?= $e((string) ($form['weight'] ?? '')) ?>">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('products.fields.price_input_mode')) ?></span>
|
||||||
|
<select class="form-control" name="price_input_mode">
|
||||||
|
<option value="brutto"<?= (string) ($form['price_input_mode'] ?? 'brutto') === 'brutto' ? ' selected' : '' ?>><?= $e($t('products.price_mode.brutto')) ?></option>
|
||||||
|
<option value="netto"<?= (string) ($form['price_input_mode'] ?? 'brutto') === 'netto' ? ' selected' : '' ?>><?= $e($t('products.price_mode.netto')) ?></option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('products.fields.price_brutto')) ?></span>
|
||||||
|
<input class="form-control" type="number" step="0.01" min="0" name="price_brutto" value="<?= $e((string) ($form['price_brutto'] ?? '')) ?>">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('products.fields.price_netto')) ?></span>
|
||||||
|
<input class="form-control" type="number" step="0.01" min="0" name="price_netto" value="<?= $e((string) ($form['price_netto'] ?? '')) ?>">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('products.fields.price_brutto_promo')) ?></span>
|
||||||
|
<input class="form-control" type="number" step="0.01" min="0" name="price_brutto_promo" value="<?= $e((string) ($form['price_brutto_promo'] ?? '')) ?>">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('products.fields.price_netto_promo')) ?></span>
|
||||||
|
<input class="form-control" type="number" step="0.01" min="0" name="price_netto_promo" value="<?= $e((string) ($form['price_netto_promo'] ?? '')) ?>">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="form-field mt-16">
|
||||||
|
<span class="field-label"><?= $e($t('products.fields.short_description')) ?></span>
|
||||||
|
<textarea class="form-control" name="short_description" rows="3"><?= $e((string) ($form['short_description'] ?? '')) ?></textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field mt-12">
|
||||||
|
<span class="field-label"><?= $e($t('products.fields.description')) ?></span>
|
||||||
|
<textarea class="form-control" name="description" rows="6"><?= $e((string) ($form['description'] ?? '')) ?></textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="form-grid mt-16">
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('products.fields.meta_title')) ?></span>
|
||||||
|
<input class="form-control" type="text" name="meta_title" value="<?= $e((string) ($form['meta_title'] ?? '')) ?>">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('products.fields.meta_description')) ?></span>
|
||||||
|
<input class="form-control" type="text" name="meta_description" value="<?= $e((string) ($form['meta_description'] ?? '')) ?>">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('products.fields.meta_keywords')) ?></span>
|
||||||
|
<input class="form-control" type="text" name="meta_keywords" value="<?= $e((string) ($form['meta_keywords'] ?? '')) ?>">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('products.fields.seo_link')) ?></span>
|
||||||
|
<input class="form-control" type="text" name="seo_link" value="<?= $e((string) ($form['seo_link'] ?? '')) ?>">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions mt-16">
|
||||||
|
<button class="btn btn--primary" type="submit"><?= $e($t('products.actions.save')) ?></button>
|
||||||
|
<a class="btn btn--secondary" href="/products"><?= $e($t('products.actions.back')) ?></a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
383
resources/views/products/edit.php
Normal file
383
resources/views/products/edit.php
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
<section class="card">
|
||||||
|
<h1><?= $e($t('products.edit.title', ['id' => (string) ($productId ?? 0)])) ?></h1>
|
||||||
|
<p class="muted"><?= $e($t('products.edit.description')) ?></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card mt-16">
|
||||||
|
<?php if (!empty($errors)): ?>
|
||||||
|
<div class="alert alert--danger" role="alert">
|
||||||
|
<?php foreach ((array) $errors as $error): ?>
|
||||||
|
<div><?= $e((string) $error) ?></div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php $images = is_array($productImages ?? null) ? $productImages : []; ?>
|
||||||
|
<form class="product-form mt-16" method="post" action="/products/update" enctype="multipart/form-data">
|
||||||
|
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
|
||||||
|
<input type="hidden" name="id" value="<?= $e((string) ($productId ?? 0)) ?>">
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('products.fields.name')) ?></span>
|
||||||
|
<input class="form-control" type="text" name="name" required value="<?= $e((string) ($form['name'] ?? '')) ?>">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label">SKU</span>
|
||||||
|
<input class="form-control" type="text" name="sku" value="<?= $e((string) ($form['sku'] ?? '')) ?>">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label">EAN</span>
|
||||||
|
<input class="form-control" type="text" name="ean" value="<?= $e((string) ($form['ean'] ?? '')) ?>">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('products.fields.type')) ?></span>
|
||||||
|
<select class="form-control" name="type">
|
||||||
|
<option value="simple"<?= (string) ($form['type'] ?? '') === 'simple' ? ' selected' : '' ?>><?= $e($t('products.type.simple')) ?></option>
|
||||||
|
<option value="variant_parent"<?= (string) ($form['type'] ?? '') === 'variant_parent' ? ' selected' : '' ?>><?= $e($t('products.type.variant_parent')) ?></option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('products.fields.status')) ?></span>
|
||||||
|
<select class="form-control" name="status">
|
||||||
|
<option value="1"<?= (string) ($form['status'] ?? '1') === '1' ? ' selected' : '' ?>><?= $e($t('products.status.active')) ?></option>
|
||||||
|
<option value="0"<?= (string) ($form['status'] ?? '1') === '0' ? ' selected' : '' ?>><?= $e($t('products.status.inactive')) ?></option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('products.fields.promoted')) ?></span>
|
||||||
|
<select class="form-control" name="promoted">
|
||||||
|
<option value="0"<?= (string) ($form['promoted'] ?? '0') === '0' ? ' selected' : '' ?>><?= $e($t('products.promoted.no')) ?></option>
|
||||||
|
<option value="1"<?= (string) ($form['promoted'] ?? '0') === '1' ? ' selected' : '' ?>><?= $e($t('products.promoted.yes')) ?></option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('products.fields.vat')) ?></span>
|
||||||
|
<input class="form-control" type="number" step="0.01" min="0" max="100" name="vat" value="<?= $e((string) ($form['vat'] ?? '')) ?>">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('products.fields.quantity')) ?></span>
|
||||||
|
<input class="form-control" type="number" step="0.001" min="0" name="quantity" value="<?= $e((string) ($form['quantity'] ?? '0')) ?>">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('products.fields.weight')) ?></span>
|
||||||
|
<input class="form-control" type="number" step="0.001" min="0" name="weight" value="<?= $e((string) ($form['weight'] ?? '')) ?>">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('products.fields.price_input_mode')) ?></span>
|
||||||
|
<select class="form-control" name="price_input_mode">
|
||||||
|
<option value="brutto"<?= (string) ($form['price_input_mode'] ?? 'brutto') === 'brutto' ? ' selected' : '' ?>><?= $e($t('products.price_mode.brutto')) ?></option>
|
||||||
|
<option value="netto"<?= (string) ($form['price_input_mode'] ?? 'brutto') === 'netto' ? ' selected' : '' ?>><?= $e($t('products.price_mode.netto')) ?></option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('products.fields.price_brutto')) ?></span>
|
||||||
|
<input class="form-control" type="number" step="0.01" min="0" name="price_brutto" value="<?= $e((string) ($form['price_brutto'] ?? '')) ?>">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('products.fields.price_netto')) ?></span>
|
||||||
|
<input class="form-control" type="number" step="0.01" min="0" name="price_netto" value="<?= $e((string) ($form['price_netto'] ?? '')) ?>">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('products.fields.price_brutto_promo')) ?></span>
|
||||||
|
<input class="form-control" type="number" step="0.01" min="0" name="price_brutto_promo" value="<?= $e((string) ($form['price_brutto_promo'] ?? '')) ?>">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('products.fields.price_netto_promo')) ?></span>
|
||||||
|
<input class="form-control" type="number" step="0.01" min="0" name="price_netto_promo" value="<?= $e((string) ($form['price_netto_promo'] ?? '')) ?>">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="form-field mt-16">
|
||||||
|
<span class="field-label"><?= $e($t('products.fields.short_description')) ?></span>
|
||||||
|
<textarea class="form-control" name="short_description" rows="3"><?= $e((string) ($form['short_description'] ?? '')) ?></textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field mt-12">
|
||||||
|
<span class="field-label"><?= $e($t('products.fields.description')) ?></span>
|
||||||
|
<textarea class="form-control" name="description" rows="6"><?= $e((string) ($form['description'] ?? '')) ?></textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="form-grid mt-16">
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('products.fields.meta_title')) ?></span>
|
||||||
|
<input class="form-control" type="text" name="meta_title" value="<?= $e((string) ($form['meta_title'] ?? '')) ?>">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('products.fields.meta_description')) ?></span>
|
||||||
|
<input class="form-control" type="text" name="meta_description" value="<?= $e((string) ($form['meta_description'] ?? '')) ?>">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('products.fields.meta_keywords')) ?></span>
|
||||||
|
<input class="form-control" type="text" name="meta_keywords" value="<?= $e((string) ($form['meta_keywords'] ?? '')) ?>">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('products.fields.seo_link')) ?></span>
|
||||||
|
<input class="form-control" type="text" name="seo_link" value="<?= $e((string) ($form['seo_link'] ?? '')) ?>">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="card mt-16">
|
||||||
|
<h3><?= $e($t('products.images.title')) ?></h3>
|
||||||
|
<p class="muted"><?= $e($t('products.images.description')) ?></p>
|
||||||
|
<input type="hidden" id="product-image-csrf" value="<?= $e($csrfToken ?? '') ?>">
|
||||||
|
|
||||||
|
<div class="product-images-grid mt-12" id="product-images-grid" data-product-id="<?= $e((string) ($productId ?? 0)) ?>">
|
||||||
|
<?php foreach ($images as $image): ?>
|
||||||
|
<?php
|
||||||
|
$imageId = (int) ($image['id'] ?? 0);
|
||||||
|
$isMain = (int) ($image['is_main'] ?? 0) === 1;
|
||||||
|
$publicUrl = (string) ($image['public_url'] ?? '');
|
||||||
|
?>
|
||||||
|
<article
|
||||||
|
class="product-image-card<?= $isMain ? ' is-main' : '' ?>"
|
||||||
|
data-image-id="<?= $e((string) $imageId) ?>"
|
||||||
|
data-storage-path="<?= $e((string) ($image['storage_path'] ?? '')) ?>"
|
||||||
|
>
|
||||||
|
<div class="product-image-card__thumb-wrap">
|
||||||
|
<?php if ($publicUrl !== ''): ?>
|
||||||
|
<img class="product-image-card__thumb" src="<?= $e($publicUrl) ?>" alt="<?= $e((string) ($image['alt'] ?? '')) ?>">
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="product-image-card__thumb is-empty">NO IMAGE</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<span class="product-image-card__badge"><?= $e($t('products.images.main')) ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="product-image-card__meta"><?= $e((string) ($image['storage_path'] ?? '')) ?></div>
|
||||||
|
<div class="product-image-card__actions">
|
||||||
|
<button type="button" class="btn btn--secondary btn-set-main"<?= $isMain ? ' disabled' : '' ?>>
|
||||||
|
<?= $e($t('products.images.set_main')) ?>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn--danger btn-delete-image">
|
||||||
|
<?= $e($t('products.images.remove')) ?>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<p class="muted mt-12" id="product-images-empty"<?= $images === [] ? '' : ' style="display:none;"' ?>>
|
||||||
|
<?= $e($t('products.images.empty')) ?>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label class="form-field mt-16">
|
||||||
|
<span class="field-label"><?= $e($t('products.images.add_new')) ?></span>
|
||||||
|
<input class="form-control" type="file" id="product-image-upload" name="new_images[]" accept=".jpg,.jpeg,.png,.webp,.gif,image/*" multiple>
|
||||||
|
</label>
|
||||||
|
<p class="muted" id="product-image-upload-status"></p>
|
||||||
|
<p class="muted"><?= $e($t('products.images.main_hint')) ?></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="form-actions mt-16">
|
||||||
|
<button class="btn btn--primary" type="submit"><?= $e($t('products.actions.save')) ?></button>
|
||||||
|
<a class="btn btn--secondary" href="/products"><?= $e($t('products.actions.back')) ?></a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var grid = document.getElementById('product-images-grid');
|
||||||
|
var emptyState = document.getElementById('product-images-empty');
|
||||||
|
var uploadInput = document.getElementById('product-image-upload');
|
||||||
|
var uploadStatus = document.getElementById('product-image-upload-status');
|
||||||
|
var tokenInput = document.getElementById('product-image-csrf');
|
||||||
|
if (!grid || !uploadInput || !tokenInput) return;
|
||||||
|
|
||||||
|
var productId = grid.getAttribute('data-product-id');
|
||||||
|
var csrfToken = tokenInput.value || '';
|
||||||
|
var txtSetMain = <?= json_encode((string) $t('products.images.set_main'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
|
||||||
|
var txtRemove = <?= json_encode((string) $t('products.images.remove'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
|
||||||
|
var txtMain = <?= json_encode((string) $t('products.images.main'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
|
||||||
|
var txtUploadPending = <?= json_encode((string) $t('products.images.uploading'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
|
||||||
|
var txtUploadOk = <?= json_encode((string) $t('products.images.uploaded_ok'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
|
||||||
|
var txtDeleteConfirm = <?= json_encode((string) $t('products.images.confirm_delete'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
|
||||||
|
var txtConfirmTitle = <?= json_encode((string) $t('products.images.confirm_title'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
|
||||||
|
var txtConfirmYes = <?= json_encode((string) $t('products.images.confirm_yes'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
|
||||||
|
var txtConfirmNo = <?= json_encode((string) $t('products.images.confirm_no'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
|
||||||
|
|
||||||
|
function refreshMainState(mainId) {
|
||||||
|
var cards = grid.querySelectorAll('.product-image-card');
|
||||||
|
cards.forEach(function(card) {
|
||||||
|
var cardId = Number(card.getAttribute('data-image-id') || 0);
|
||||||
|
var isMain = cardId === mainId;
|
||||||
|
card.classList.toggle('is-main', isMain);
|
||||||
|
var setMainBtn = card.querySelector('.btn-set-main');
|
||||||
|
if (setMainBtn) setMainBtn.disabled = isMain;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEmptyState() {
|
||||||
|
if (!emptyState) return;
|
||||||
|
emptyState.style.display = grid.querySelector('.product-image-card') ? 'none' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCard(image) {
|
||||||
|
var article = document.createElement('article');
|
||||||
|
article.className = 'product-image-card' + (Number(image.is_main) === 1 ? ' is-main' : '');
|
||||||
|
article.setAttribute('data-image-id', String(image.id));
|
||||||
|
article.setAttribute('data-storage-path', String(image.storage_path || ''));
|
||||||
|
|
||||||
|
var thumbWrap = document.createElement('div');
|
||||||
|
thumbWrap.className = 'product-image-card__thumb-wrap';
|
||||||
|
|
||||||
|
if (image.public_url) {
|
||||||
|
var img = document.createElement('img');
|
||||||
|
img.className = 'product-image-card__thumb';
|
||||||
|
img.src = image.public_url;
|
||||||
|
img.alt = image.alt || '';
|
||||||
|
thumbWrap.appendChild(img);
|
||||||
|
} else {
|
||||||
|
var noimg = document.createElement('div');
|
||||||
|
noimg.className = 'product-image-card__thumb is-empty';
|
||||||
|
noimg.textContent = 'NO IMAGE';
|
||||||
|
thumbWrap.appendChild(noimg);
|
||||||
|
}
|
||||||
|
|
||||||
|
var badge = document.createElement('span');
|
||||||
|
badge.className = 'product-image-card__badge';
|
||||||
|
badge.textContent = txtMain;
|
||||||
|
thumbWrap.appendChild(badge);
|
||||||
|
|
||||||
|
var meta = document.createElement('div');
|
||||||
|
meta.className = 'product-image-card__meta';
|
||||||
|
meta.textContent = image.storage_path || '';
|
||||||
|
|
||||||
|
var actions = document.createElement('div');
|
||||||
|
actions.className = 'product-image-card__actions';
|
||||||
|
|
||||||
|
var setMainBtn = document.createElement('button');
|
||||||
|
setMainBtn.type = 'button';
|
||||||
|
setMainBtn.className = 'btn btn--secondary btn-set-main';
|
||||||
|
setMainBtn.textContent = txtSetMain;
|
||||||
|
if (Number(image.is_main) === 1) setMainBtn.disabled = true;
|
||||||
|
|
||||||
|
var removeBtn = document.createElement('button');
|
||||||
|
removeBtn.type = 'button';
|
||||||
|
removeBtn.className = 'btn btn--danger btn-delete-image';
|
||||||
|
removeBtn.textContent = txtRemove;
|
||||||
|
|
||||||
|
actions.appendChild(setMainBtn);
|
||||||
|
actions.appendChild(removeBtn);
|
||||||
|
article.appendChild(thumbWrap);
|
||||||
|
article.appendChild(meta);
|
||||||
|
article.appendChild(actions);
|
||||||
|
|
||||||
|
return article;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postForm(url, data) {
|
||||||
|
var response = await fetch(url, { method: 'POST', body: data, credentials: 'same-origin' });
|
||||||
|
var payload = await response.json();
|
||||||
|
if (!response.ok || payload.ok !== true) {
|
||||||
|
throw new Error(payload.message || 'Blad operacji.');
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadInput.addEventListener('change', async function() {
|
||||||
|
if (!uploadInput.files || uploadInput.files.length === 0) return;
|
||||||
|
var formData = new FormData();
|
||||||
|
formData.append('_token', csrfToken);
|
||||||
|
formData.append('id', String(productId));
|
||||||
|
Array.prototype.forEach.call(uploadInput.files, function(file) {
|
||||||
|
formData.append('new_images[]', file);
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadStatus.textContent = txtUploadPending;
|
||||||
|
uploadInput.disabled = true;
|
||||||
|
try {
|
||||||
|
var result = await postForm('/products/images/upload', formData);
|
||||||
|
(result.images || []).forEach(function(image) {
|
||||||
|
grid.appendChild(buildCard(image));
|
||||||
|
if (Number(image.is_main) === 1) refreshMainState(Number(image.id));
|
||||||
|
});
|
||||||
|
uploadStatus.textContent = txtUploadOk;
|
||||||
|
if (result.message) uploadStatus.textContent += ' ' + result.message;
|
||||||
|
updateEmptyState();
|
||||||
|
} catch (error) {
|
||||||
|
uploadStatus.textContent = error.message || 'Blad uploadu.';
|
||||||
|
} finally {
|
||||||
|
uploadInput.value = '';
|
||||||
|
uploadInput.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
grid.addEventListener('click', async function(event) {
|
||||||
|
var target = event.target;
|
||||||
|
if (!target || !(target instanceof HTMLElement)) return;
|
||||||
|
var card = target.closest('.product-image-card');
|
||||||
|
if (!card) return;
|
||||||
|
var imageId = Number(card.getAttribute('data-image-id') || 0);
|
||||||
|
if (imageId <= 0) return;
|
||||||
|
|
||||||
|
if (target.classList.contains('btn-set-main')) {
|
||||||
|
if (target.disabled) return;
|
||||||
|
var dataMain = new FormData();
|
||||||
|
dataMain.append('_token', csrfToken);
|
||||||
|
dataMain.append('id', String(productId));
|
||||||
|
dataMain.append('image_id', String(imageId));
|
||||||
|
|
||||||
|
target.disabled = true;
|
||||||
|
try {
|
||||||
|
await postForm('/products/images/set-main', dataMain);
|
||||||
|
refreshMainState(imageId);
|
||||||
|
} catch (error) {
|
||||||
|
target.disabled = false;
|
||||||
|
if (window.OrderProAlerts && typeof window.OrderProAlerts.alert === 'function') {
|
||||||
|
window.OrderProAlerts.alert({ title: 'Blad', message: error.message || 'Blad operacji.', danger: true });
|
||||||
|
} else if (uploadStatus) {
|
||||||
|
uploadStatus.textContent = error.message || 'Blad operacji.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.classList.contains('btn-delete-image')) {
|
||||||
|
var confirmDelete = async function() {
|
||||||
|
var dataDelete = new FormData();
|
||||||
|
dataDelete.append('_token', csrfToken);
|
||||||
|
dataDelete.append('id', String(productId));
|
||||||
|
dataDelete.append('image_id', String(imageId));
|
||||||
|
var result = await postForm('/products/images/delete', dataDelete);
|
||||||
|
card.remove();
|
||||||
|
if (Number(result.main_image_id || 0) > 0) {
|
||||||
|
refreshMainState(Number(result.main_image_id));
|
||||||
|
}
|
||||||
|
updateEmptyState();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (window.OrderProAlerts && typeof window.OrderProAlerts.confirm === 'function') {
|
||||||
|
var accepted = await window.OrderProAlerts.confirm({
|
||||||
|
title: txtConfirmTitle,
|
||||||
|
message: txtDeleteConfirm,
|
||||||
|
confirmLabel: txtConfirmYes,
|
||||||
|
cancelLabel: txtConfirmNo,
|
||||||
|
danger: true
|
||||||
|
});
|
||||||
|
if (!accepted) return;
|
||||||
|
try { await confirmDelete(); } catch (error) {
|
||||||
|
window.OrderProAlerts.alert({ title: 'Blad', message: error.message || 'Blad operacji.', danger: true });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try { await confirmDelete(); } catch (error) {
|
||||||
|
if (uploadStatus) uploadStatus.textContent = error.message || 'Blad operacji.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
177
resources/views/products/index.php
Normal file
177
resources/views/products/index.php
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
<section class="card">
|
||||||
|
<div class="page-head">
|
||||||
|
<div>
|
||||||
|
<h1><?= $e($t('products.title')) ?></h1>
|
||||||
|
<p class="muted"><?= $e($t('products.description')) ?></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<?php if (!empty($errorMessage)): ?>
|
||||||
|
<section class="card mt-16">
|
||||||
|
<div class="alert alert--danger" role="alert">
|
||||||
|
<?= $e((string) $errorMessage) ?>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (!empty($successMessage)): ?>
|
||||||
|
<section class="card mt-16">
|
||||||
|
<div class="alert alert--success" role="status">
|
||||||
|
<?= $e((string) $successMessage) ?>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php require __DIR__ . '/../components/table-list.php'; ?>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
$integrations = is_array($shopProIntegrations ?? null) ? $shopProIntegrations : [];
|
||||||
|
?>
|
||||||
|
<div class="modal-backdrop" data-modal-backdrop="product-image-preview-modal" hidden>
|
||||||
|
<div class="modal modal--image-preview" role="dialog" aria-modal="true" aria-labelledby="product-image-preview-title">
|
||||||
|
<div class="modal__header">
|
||||||
|
<h3 id="product-image-preview-title">Podglad zdjecia</h3>
|
||||||
|
<button type="button" class="btn btn--secondary" data-close-modal="product-image-preview-modal">Zamknij</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal__body">
|
||||||
|
<img src="" alt="" class="product-image-preview__img" data-product-image-preview-target>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-backdrop" data-modal-backdrop="product-import-modal" hidden>
|
||||||
|
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="product-import-modal-title">
|
||||||
|
<div class="modal__header">
|
||||||
|
<h3 id="product-import-modal-title"><?= $e($t('products.import.title')) ?></h3>
|
||||||
|
<button type="button" class="btn btn--secondary" data-close-modal="product-import-modal"><?= $e($t('products.import.close')) ?></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action="/products/import/shoppro" method="post" class="modal__body">
|
||||||
|
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('products.import.integration')) ?></span>
|
||||||
|
<select class="form-control" name="integration_id" required>
|
||||||
|
<option value=""><?= $e($t('products.import.integration_placeholder')) ?></option>
|
||||||
|
<?php foreach ($integrations as $integration): ?>
|
||||||
|
<option value="<?= $e((string) ($integration['id'] ?? 0)) ?>">
|
||||||
|
<?= $e((string) ($integration['name'] ?? '')) ?> (ID: <?= $e((string) ($integration['id'] ?? 0)) ?>)
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<?php if (empty($integrations)): ?>
|
||||||
|
<p class="muted"><?= $e($t('products.import.no_integrations')) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('products.import.mode')) ?></span>
|
||||||
|
<label class="field-inline">
|
||||||
|
<input type="radio" name="import_mode" value="all" checked>
|
||||||
|
<?= $e($t('products.import.mode_all')) ?>
|
||||||
|
</label>
|
||||||
|
<label class="field-inline">
|
||||||
|
<input type="radio" name="import_mode" value="single">
|
||||||
|
<?= $e($t('products.import.mode_single')) ?>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="form-field" data-single-id-wrap hidden>
|
||||||
|
<span class="field-label"><?= $e($t('products.import.external_id')) ?></span>
|
||||||
|
<input class="form-control" type="number" min="1" name="external_product_id" data-single-id-input>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label">
|
||||||
|
<input type="checkbox" name="import_variants" value="1">
|
||||||
|
<?= $e($t('products.import.with_variants')) ?>
|
||||||
|
</span>
|
||||||
|
<small class="muted"><?= $e($t('products.import.with_variants_hint')) ?></small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="form-actions mt-16">
|
||||||
|
<button type="submit" class="btn btn--primary"<?= empty($integrations) ? ' disabled' : '' ?>><?= $e($t('products.import.submit')) ?></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var openBtn = document.querySelector('[data-open-modal="product-import-modal"]');
|
||||||
|
var backdrop = document.querySelector('[data-modal-backdrop="product-import-modal"]');
|
||||||
|
var closeBtn = document.querySelector('[data-close-modal="product-import-modal"]');
|
||||||
|
if (!openBtn || !backdrop || !closeBtn) return;
|
||||||
|
|
||||||
|
var modeInputs = backdrop.querySelectorAll('input[name="import_mode"]');
|
||||||
|
var singleIdWrap = backdrop.querySelector('[data-single-id-wrap]');
|
||||||
|
var singleIdInput = backdrop.querySelector('[data-single-id-input]');
|
||||||
|
|
||||||
|
function syncMode() {
|
||||||
|
var mode = 'all';
|
||||||
|
modeInputs.forEach(function(input) {
|
||||||
|
if (input.checked) mode = input.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
var isSingle = mode === 'single';
|
||||||
|
if (singleIdWrap) singleIdWrap.hidden = !isSingle;
|
||||||
|
if (singleIdInput) {
|
||||||
|
singleIdInput.required = isSingle;
|
||||||
|
if (!isSingle) singleIdInput.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
openBtn.addEventListener('click', function() {
|
||||||
|
backdrop.hidden = false;
|
||||||
|
syncMode();
|
||||||
|
});
|
||||||
|
|
||||||
|
closeBtn.addEventListener('click', function() {
|
||||||
|
backdrop.hidden = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
backdrop.addEventListener('click', function(event) {
|
||||||
|
if (event.target === backdrop) {
|
||||||
|
backdrop.hidden = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
modeInputs.forEach(function(input) {
|
||||||
|
input.addEventListener('change', syncMode);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
var previewBackdrop = document.querySelector('[data-modal-backdrop="product-image-preview-modal"]');
|
||||||
|
if (!previewBackdrop) return;
|
||||||
|
|
||||||
|
var previewImage = previewBackdrop.querySelector('[data-product-image-preview-target]');
|
||||||
|
var closeBtn = previewBackdrop.querySelector('[data-close-modal="product-image-preview-modal"]');
|
||||||
|
if (!previewImage || !closeBtn) return;
|
||||||
|
|
||||||
|
function closePreview() {
|
||||||
|
previewBackdrop.hidden = true;
|
||||||
|
previewImage.setAttribute('src', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function(event) {
|
||||||
|
var trigger = event.target.closest('[data-product-image-preview]');
|
||||||
|
if (!trigger) return;
|
||||||
|
|
||||||
|
var imageUrl = trigger.getAttribute('data-product-image-preview') || '';
|
||||||
|
if (imageUrl === '') return;
|
||||||
|
|
||||||
|
previewImage.setAttribute('src', imageUrl);
|
||||||
|
previewBackdrop.hidden = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
closeBtn.addEventListener('click', closePreview);
|
||||||
|
|
||||||
|
previewBackdrop.addEventListener('click', function(event) {
|
||||||
|
if (event.target === previewBackdrop) {
|
||||||
|
closePreview();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
251
resources/views/products/links.php
Normal file
251
resources/views/products/links.php
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
<?php $item = is_array($product ?? null) ? $product : []; ?>
|
||||||
|
<?php $links = is_array($productLinks ?? null) ? $productLinks : []; ?>
|
||||||
|
<?php $integrations = is_array($linkIntegrations ?? null) ? $linkIntegrations : []; ?>
|
||||||
|
<?php $offers = is_array($linkOffers ?? null) ? $linkOffers : []; ?>
|
||||||
|
<?php $eventsByMap = is_array($productLinkEventsByMap ?? null) ? $productLinkEventsByMap : []; ?>
|
||||||
|
<?php $selectedIntegrationId = (int) ($selectedLinksIntegrationId ?? 0); ?>
|
||||||
|
<?php $linksQueryValue = (string) ($linksQuery ?? ''); ?>
|
||||||
|
<?php $productIdValue = (int) ($productId ?? 0); ?>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h1><?= $e($t('products.links.page_title', ['id' => (string) ($productId ?? 0)])) ?></h1>
|
||||||
|
<p class="muted"><?= $e($t('products.links.description')) ?></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card mt-16">
|
||||||
|
<div class="product-tabs-nav">
|
||||||
|
<a class="btn btn--secondary" href="/products/<?= $e((string) $productIdValue) ?>"><?= $e($t('products.tabs.details')) ?></a>
|
||||||
|
<span class="btn btn--primary"><?= $e($t('products.tabs.links')) ?></span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card mt-16">
|
||||||
|
<div class="product-links-head">
|
||||||
|
<div>
|
||||||
|
<strong><?= $e($t('products.fields.name')) ?>:</strong>
|
||||||
|
<?= $e((string) ($item['name'] ?? '')) ?>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>SKU:</strong>
|
||||||
|
<?= $e((string) ($item['sku'] ?? '')) ?>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>EAN:</strong>
|
||||||
|
<?= $e((string) ($item['ean'] ?? '')) ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card mt-16">
|
||||||
|
<h3><?= $e($t('products.links.title')) ?></h3>
|
||||||
|
|
||||||
|
<?php if (!empty($linksErrorMessage)): ?>
|
||||||
|
<div class="alert alert--danger mt-12" role="alert"><?= $e((string) $linksErrorMessage) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (!empty($linksSuccessMessage)): ?>
|
||||||
|
<div class="alert alert--success mt-12" role="status"><?= $e((string) $linksSuccessMessage) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<h4 class="section-title mt-16"><?= $e($t('products.links.current_links')) ?></h4>
|
||||||
|
<?php if ($links === []): ?>
|
||||||
|
<p class="muted mt-12"><?= $e($t('products.links.empty_links')) ?></p>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="table-wrap mt-12">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><?= $e($t('products.links.fields.integration')) ?></th>
|
||||||
|
<th><?= $e($t('products.links.fields.channel')) ?></th>
|
||||||
|
<th><?= $e($t('products.links.fields.external_product_id')) ?></th>
|
||||||
|
<th><?= $e($t('products.links.fields.external_variant_id')) ?></th>
|
||||||
|
<th><?= $e($t('products.links.fields.link_type')) ?></th>
|
||||||
|
<th><?= $e($t('products.links.fields.confidence')) ?></th>
|
||||||
|
<th><?= $e($t('products.links.fields.link_status')) ?></th>
|
||||||
|
<th><?= $e($t('products.links.fields.updated_at')) ?></th>
|
||||||
|
<th><?= $e($t('products.links.fields.history')) ?></th>
|
||||||
|
<th><?= $e($t('products.links.fields.actions')) ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($links as $link): ?>
|
||||||
|
<?php
|
||||||
|
$mapId = (int) ($link['id'] ?? 0);
|
||||||
|
$linkStatus = (string) ($link['link_status'] ?? '');
|
||||||
|
$isActive = $linkStatus === 'active';
|
||||||
|
$confidence = $link['confidence'] ?? null;
|
||||||
|
$lastChangeAt = trim((string) ($link['updated_at'] ?? ''));
|
||||||
|
if ($lastChangeAt === '') {
|
||||||
|
$lastChangeAt = trim((string) ($link['linked_at'] ?? ''));
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<tr>
|
||||||
|
<td><?= $e((string) (($link['integration_name'] ?? '') !== '' ? $link['integration_name'] : ('#' . (string) ($link['integration_id'] ?? 0)))) ?></td>
|
||||||
|
<td><?= $e((string) ($link['channel_name'] ?? '')) ?></td>
|
||||||
|
<td><?= $e((string) ($link['external_product_id'] ?? '')) ?></td>
|
||||||
|
<td><?= $e((string) ($link['external_variant_id'] ?? '')) ?></td>
|
||||||
|
<td><?= $e((string) ($link['link_type'] ?? '')) ?></td>
|
||||||
|
<td><?= $e($confidence === null ? '-' : ((string) $confidence . '%')) ?></td>
|
||||||
|
<td>
|
||||||
|
<span class="status-pill<?= $isActive ? ' is-active' : '' ?>">
|
||||||
|
<?= $e($linkStatus) ?>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td><?= $e($lastChangeAt === '' ? '-' : $lastChangeAt) ?></td>
|
||||||
|
<td>
|
||||||
|
<?php $events = is_array($eventsByMap[$mapId] ?? null) ? $eventsByMap[$mapId] : []; ?>
|
||||||
|
<?php if ($events === []): ?>
|
||||||
|
<span class="muted">-</span>
|
||||||
|
<?php else: ?>
|
||||||
|
<ul class="product-link-events-list">
|
||||||
|
<?php foreach ($events as $event): ?>
|
||||||
|
<li>
|
||||||
|
<span class="product-link-events-type"><?= $e((string) ($event['event_type'] ?? '')) ?></span>
|
||||||
|
<span class="product-link-events-date"><?= $e((string) ($event['created_at'] ?? '')) ?></span>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="product-links-actions-row">
|
||||||
|
<form action="/products/<?= $e((string) $productIdValue) ?>/links/<?= $e((string) $mapId) ?>/relink" method="post" class="product-links-inline-form product-links-relink-form">
|
||||||
|
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
|
||||||
|
<input type="hidden" name="product_id" value="<?= $e((string) ($productId ?? 0)) ?>">
|
||||||
|
<input type="hidden" name="map_id" value="<?= $e((string) $mapId) ?>">
|
||||||
|
<input type="hidden" name="integration_id" value="<?= $e((string) ((int) ($link['integration_id'] ?? 0))) ?>">
|
||||||
|
<input class="form-control" type="text" name="external_product_id" required value="<?= $e((string) ($link['external_product_id'] ?? '')) ?>">
|
||||||
|
<input class="form-control" type="text" name="external_variant_id" value="<?= $e((string) ($link['external_variant_id'] ?? '')) ?>" placeholder="<?= $e($t('products.links.fields.external_variant_id_optional')) ?>">
|
||||||
|
<button type="submit" class="btn btn--secondary" data-links-action="relink"><?= $e($t('products.links.actions.relink')) ?></button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form action="/products/<?= $e((string) $productIdValue) ?>/links/<?= $e((string) $mapId) ?>/unlink" method="post" class="product-links-unlink-form">
|
||||||
|
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
|
||||||
|
<input type="hidden" name="product_id" value="<?= $e((string) ($productId ?? 0)) ?>">
|
||||||
|
<input type="hidden" name="map_id" value="<?= $e((string) $mapId) ?>">
|
||||||
|
<button type="submit" class="btn btn--danger" data-links-action="unlink"><?= $e($t('products.links.actions.unlink')) ?></button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<h4 class="section-title mt-16"><?= $e($t('products.links.search_title')) ?></h4>
|
||||||
|
<form class="product-links-search-form mt-12" action="/products/<?= $e((string) $productIdValue) ?>/links" method="get">
|
||||||
|
<input type="hidden" name="id" value="<?= $e((string) ($productId ?? 0)) ?>">
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('products.links.fields.integration')) ?></span>
|
||||||
|
<select class="form-control" name="links_integration_id" required>
|
||||||
|
<option value="0"><?= $e($t('products.links.integration_placeholder')) ?></option>
|
||||||
|
<?php foreach ($integrations as $integration): ?>
|
||||||
|
<?php $integrationId = (int) ($integration['id'] ?? 0); ?>
|
||||||
|
<option value="<?= $e((string) $integrationId) ?>"<?= $integrationId === $selectedIntegrationId ? ' selected' : '' ?>>
|
||||||
|
<?= $e((string) ($integration['name'] ?? '')) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('products.links.fields.search')) ?></span>
|
||||||
|
<input class="form-control" type="text" name="links_query" value="<?= $e($linksQueryValue) ?>" placeholder="<?= $e($t('products.links.search_placeholder')) ?>">
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn btn--primary"><?= $e($t('products.links.actions.search')) ?></button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<?php if ($offers === []): ?>
|
||||||
|
<p class="muted mt-12"><?= $e($t('products.links.empty_offers')) ?></p>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="table-wrap mt-12">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><?= $e($t('products.links.fields.offer_name')) ?></th>
|
||||||
|
<th>SKU</th>
|
||||||
|
<th>EAN</th>
|
||||||
|
<th><?= $e($t('products.links.fields.external_product_id')) ?></th>
|
||||||
|
<th><?= $e($t('products.links.fields.external_variant_id')) ?></th>
|
||||||
|
<th><?= $e($t('products.links.fields.match_hint')) ?></th>
|
||||||
|
<th><?= $e($t('products.links.fields.confidence')) ?></th>
|
||||||
|
<th><?= $e($t('products.links.fields.actions')) ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($offers as $offer): ?>
|
||||||
|
<tr>
|
||||||
|
<td><?= $e((string) ($offer['name'] ?? '')) ?></td>
|
||||||
|
<td><?= $e((string) ($offer['sku'] ?? '')) ?></td>
|
||||||
|
<td><?= $e((string) ($offer['ean'] ?? '')) ?></td>
|
||||||
|
<td><?= $e((string) ($offer['external_product_id'] ?? '')) ?></td>
|
||||||
|
<td><?= $e((string) ($offer['external_variant_id'] ?? '')) ?></td>
|
||||||
|
<td><?= $e((string) ($offer['match_hint'] ?? '')) ?></td>
|
||||||
|
<td><?= $e((string) ((int) ($offer['match_confidence'] ?? 0)) . '%') ?></td>
|
||||||
|
<td>
|
||||||
|
<form action="/products/<?= $e((string) $productIdValue) ?>/links" method="post">
|
||||||
|
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
|
||||||
|
<input type="hidden" name="product_id" value="<?= $e((string) ($productId ?? 0)) ?>">
|
||||||
|
<input type="hidden" name="integration_id" value="<?= $e((string) ($offer['integration_id'] ?? 0)) ?>">
|
||||||
|
<input type="hidden" name="external_product_id" value="<?= $e((string) ($offer['external_product_id'] ?? '')) ?>">
|
||||||
|
<input type="hidden" name="external_variant_id" value="<?= $e((string) ($offer['external_variant_id'] ?? '')) ?>">
|
||||||
|
<button type="submit" class="btn btn--primary"><?= $e($t('products.links.actions.link')) ?></button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card mt-16">
|
||||||
|
<a class="btn btn--secondary" href="/products"><?= $e($t('products.actions.back')) ?></a>
|
||||||
|
<a class="btn btn--secondary" href="/products/<?= $e((string) $productIdValue) ?>"><?= $e($t('products.actions.preview')) ?></a>
|
||||||
|
<a class="btn btn--primary" href="/products/edit?id=<?= $e((string) ($productId ?? 0)) ?>"><?= $e($t('products.actions.edit')) ?></a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var unlinkForms = document.querySelectorAll('.product-links-unlink-form');
|
||||||
|
var relinkForms = document.querySelectorAll('.product-links-relink-form');
|
||||||
|
var unlinkMessage = <?= json_encode((string) $t('products.links.confirm.unlink_message'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
|
||||||
|
var relinkMessage = <?= json_encode((string) $t('products.links.confirm.relink_message'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
|
||||||
|
var confirmTitle = <?= json_encode((string) $t('products.links.confirm.title'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
|
||||||
|
var confirmYes = <?= json_encode((string) $t('products.links.confirm.yes'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
|
||||||
|
var confirmNo = <?= json_encode((string) $t('products.links.confirm.no'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
|
||||||
|
|
||||||
|
async function handleConfirmSubmit(event, message, danger) {
|
||||||
|
if (!window.OrderProAlerts || typeof window.OrderProAlerts.confirm !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
var accepted = await window.OrderProAlerts.confirm({
|
||||||
|
title: confirmTitle,
|
||||||
|
message: message,
|
||||||
|
confirmLabel: confirmYes,
|
||||||
|
cancelLabel: confirmNo,
|
||||||
|
danger: danger === true
|
||||||
|
});
|
||||||
|
if (!accepted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.target.submit();
|
||||||
|
}
|
||||||
|
|
||||||
|
unlinkForms.forEach(function (form) {
|
||||||
|
form.addEventListener('submit', function (event) {
|
||||||
|
handleConfirmSubmit(event, unlinkMessage, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
relinkForms.forEach(function (form) {
|
||||||
|
form.addEventListener('submit', function (event) {
|
||||||
|
handleConfirmSubmit(event, relinkMessage, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
135
resources/views/products/show.php
Normal file
135
resources/views/products/show.php
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
<section class="card">
|
||||||
|
<h1><?= $e($t('products.show.title', ['id' => (string) ($productId ?? 0)])) ?></h1>
|
||||||
|
<p class="muted"><?= $e($t('products.show.description')) ?></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<?php $item = is_array($product ?? null) ? $product : []; ?>
|
||||||
|
<?php $images = is_array($productImages ?? null) ? $productImages : []; ?>
|
||||||
|
<?php $variants = is_array($productVariants ?? null) ? $productVariants : []; ?>
|
||||||
|
<?php $importWarning = is_array($productImportWarning ?? null) ? $productImportWarning : null; ?>
|
||||||
|
<?php $productIdValue = (int) ($productId ?? 0); ?>
|
||||||
|
|
||||||
|
<section class="card mt-16">
|
||||||
|
<div class="product-tabs-nav">
|
||||||
|
<span class="btn btn--primary"><?= $e($t('products.tabs.details')) ?></span>
|
||||||
|
<a class="btn btn--secondary" href="/products/<?= $e((string) $productIdValue) ?>/links"><?= $e($t('products.tabs.links')) ?></a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card mt-16">
|
||||||
|
<?php if ($importWarning !== null && !empty($importWarning['messages'])): ?>
|
||||||
|
<div class="alert alert--danger" role="alert">
|
||||||
|
<div><strong><?= $e($t('products.variants.import_warning_title')) ?></strong></div>
|
||||||
|
<?php foreach ((array) ($importWarning['messages'] ?? []) as $warning): ?>
|
||||||
|
<div><?= $e((string) $warning) ?></div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php if (!empty($importWarning['created_at'])): ?>
|
||||||
|
<div class="muted mt-8"><?= $e($t('products.variants.import_warning_date')) ?>: <?= $e((string) $importWarning['created_at']) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<h3><?= $e($t('products.show.details')) ?></h3>
|
||||||
|
<table class="table mt-12">
|
||||||
|
<tbody>
|
||||||
|
<tr><th>ID</th><td><?= $e((string) ($item['id'] ?? '')) ?></td></tr>
|
||||||
|
<tr><th><?= $e($t('products.fields.name')) ?></th><td><?= $e((string) ($item['name'] ?? '')) ?></td></tr>
|
||||||
|
<tr><th>SKU</th><td><?= $e((string) ($item['sku'] ?? '')) ?></td></tr>
|
||||||
|
<tr><th>EAN</th><td><?= $e((string) ($item['ean'] ?? '')) ?></td></tr>
|
||||||
|
<tr><th><?= $e($t('products.fields.type')) ?></th><td><?= $e((string) ($item['type'] ?? '')) ?></td></tr>
|
||||||
|
<tr><th><?= $e($t('products.fields.status')) ?></th><td><?= $e((string) ($item['status'] ?? '')) ?></td></tr>
|
||||||
|
<tr><th><?= $e($t('products.fields.promoted')) ?></th><td><?= $e((string) ($item['promoted'] ?? '')) ?></td></tr>
|
||||||
|
<tr><th><?= $e($t('products.fields.vat')) ?></th><td><?= $e((string) ($item['vat'] ?? '')) ?></td></tr>
|
||||||
|
<tr><th><?= $e($t('products.fields.weight')) ?></th><td><?= $e((string) ($item['weight'] ?? '')) ?></td></tr>
|
||||||
|
<tr><th><?= $e($t('products.fields.quantity')) ?></th><td><?= $e((string) ($item['quantity'] ?? '')) ?></td></tr>
|
||||||
|
<tr><th><?= $e($t('products.fields.price_brutto')) ?></th><td><?= $e((string) ($item['price_brutto'] ?? '')) ?></td></tr>
|
||||||
|
<tr><th><?= $e($t('products.fields.price_netto')) ?></th><td><?= $e((string) ($item['price_netto'] ?? '')) ?></td></tr>
|
||||||
|
<tr><th><?= $e($t('products.fields.price_brutto_promo')) ?></th><td><?= $e((string) ($item['price_brutto_promo'] ?? '')) ?></td></tr>
|
||||||
|
<tr><th><?= $e($t('products.fields.price_netto_promo')) ?></th><td><?= $e((string) ($item['price_netto_promo'] ?? '')) ?></td></tr>
|
||||||
|
<tr><th><?= $e($t('products.fields.short_description')) ?></th><td><?= $e((string) ($item['short_description'] ?? '')) ?></td></tr>
|
||||||
|
<tr><th><?= $e($t('products.fields.description')) ?></th><td><?= $e((string) ($item['description'] ?? '')) ?></td></tr>
|
||||||
|
<tr><th><?= $e($t('products.fields.meta_title')) ?></th><td><?= $e((string) ($item['meta_title'] ?? '')) ?></td></tr>
|
||||||
|
<tr><th><?= $e($t('products.fields.meta_description')) ?></th><td><?= $e((string) ($item['meta_description'] ?? '')) ?></td></tr>
|
||||||
|
<tr><th><?= $e($t('products.fields.meta_keywords')) ?></th><td><?= $e((string) ($item['meta_keywords'] ?? '')) ?></td></tr>
|
||||||
|
<tr><th><?= $e($t('products.fields.seo_link')) ?></th><td><?= $e((string) ($item['seo_link'] ?? '')) ?></td></tr>
|
||||||
|
<tr><th><?= $e($t('products.fields.updated_at')) ?></th><td><?= $e((string) ($item['updated_at'] ?? '')) ?></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card mt-16">
|
||||||
|
<h3><?= $e($t('products.variants.title')) ?></h3>
|
||||||
|
<?php if ($variants === []): ?>
|
||||||
|
<p class="muted"><?= $e($t('products.variants.empty')) ?></p>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="table-wrap mt-12">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>SKU</th>
|
||||||
|
<th>EAN</th>
|
||||||
|
<th><?= $e($t('products.fields.price_brutto')) ?></th>
|
||||||
|
<th><?= $e($t('products.fields.price_netto')) ?></th>
|
||||||
|
<th><?= $e($t('products.fields.weight')) ?></th>
|
||||||
|
<th><?= $e($t('products.fields.status')) ?></th>
|
||||||
|
<th><?= $e($t('products.variants.attributes')) ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($variants as $variant): ?>
|
||||||
|
<?php
|
||||||
|
$attributes = is_array($variant['attributes'] ?? null) ? $variant['attributes'] : [];
|
||||||
|
$attributeText = [];
|
||||||
|
foreach ($attributes as $attribute) {
|
||||||
|
$attributeName = (string) ($attribute['attribute_name'] ?? '');
|
||||||
|
$valueName = (string) ($attribute['value_name'] ?? '');
|
||||||
|
if ($attributeName === '' || $valueName === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$attributeText[] = $attributeName . ': ' . $valueName;
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<tr>
|
||||||
|
<td><?= $e((string) ($variant['id'] ?? 0)) ?></td>
|
||||||
|
<td><?= $e((string) ($variant['sku'] ?? '')) ?></td>
|
||||||
|
<td><?= $e((string) ($variant['ean'] ?? '')) ?></td>
|
||||||
|
<td><?= $e((string) ($variant['price_brutto'] ?? '')) ?></td>
|
||||||
|
<td><?= $e((string) ($variant['price_netto'] ?? '')) ?></td>
|
||||||
|
<td><?= $e((string) ($variant['weight'] ?? '')) ?></td>
|
||||||
|
<td><?= $e(((int) ($variant['status'] ?? 0)) === 1 ? $t('products.status.active') : $t('products.status.inactive')) ?></td>
|
||||||
|
<td><?= $e($attributeText !== [] ? implode(', ', $attributeText) : '-') ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card mt-16">
|
||||||
|
<h3><?= $e($t('products.images.title')) ?></h3>
|
||||||
|
<?php if ($images === []): ?>
|
||||||
|
<p class="muted"><?= $e($t('products.images.empty')) ?></p>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="product-show-images-grid mt-12">
|
||||||
|
<?php foreach ($images as $image): ?>
|
||||||
|
<div class="product-show-image-card">
|
||||||
|
<div><strong>ID:</strong> <?= $e((string) ($image['id'] ?? 0)) ?><?= ((int) ($image['is_main'] ?? 0) === 1) ? ' | <strong>' . $e($t('products.images.main')) . '</strong>' : '' ?></div>
|
||||||
|
<div class="muted"><?= $e((string) ($image['storage_path'] ?? '')) ?></div>
|
||||||
|
<?php if ((string) ($image['public_url'] ?? '') !== ''): ?>
|
||||||
|
<div class="mt-12">
|
||||||
|
<img src="<?= $e((string) $image['public_url']) ?>" alt="<?= $e((string) ($image['alt'] ?? '')) ?>" class="product-show-image">
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card mt-16">
|
||||||
|
<a class="btn btn--secondary" href="/products"><?= $e($t('products.actions.back')) ?></a>
|
||||||
|
<a class="btn btn--secondary" href="/products/<?= $e((string) $productIdValue) ?>/links"><?= $e($t('products.actions.links')) ?></a>
|
||||||
|
<a class="btn btn--primary" href="/products/edit?id=<?= $e((string) $productIdValue) ?>"><?= $e($t('products.actions.edit')) ?></a>
|
||||||
|
</section>
|
||||||
@@ -11,7 +11,8 @@ $logs = (array) ($runLogs ?? []);
|
|||||||
<h1><?= $e($t('settings.title')) ?></h1>
|
<h1><?= $e($t('settings.title')) ?></h1>
|
||||||
<p class="muted"><?= $e($t('settings.description')) ?></p>
|
<p class="muted"><?= $e($t('settings.description')) ?></p>
|
||||||
<nav class="settings-nav mt-16" aria-label="<?= $e($t('settings.submenu_label')) ?>">
|
<nav class="settings-nav mt-16" aria-label="<?= $e($t('settings.submenu_label')) ?>">
|
||||||
<a class="settings-nav__link is-active" href="/settings/database"><?= $e($t('settings.database.title')) ?></a>
|
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'integrations' ? '' : ' is-active' ?>" href="/settings/database"><?= $e($t('settings.database.title')) ?></a>
|
||||||
|
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'integrations' ? ' is-active' : '' ?>" href="/settings/integrations/shoppro"><?= $e($t('settings.integrations.title')) ?></a>
|
||||||
</nav>
|
</nav>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
186
resources/views/settings/integrations.php
Normal file
186
resources/views/settings/integrations.php
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
<?php
|
||||||
|
$list = (array) ($integrations ?? []);
|
||||||
|
$selected = is_array($selectedIntegration ?? null) ? $selectedIntegration : null;
|
||||||
|
$formValues = is_array($form ?? null) ? $form : [];
|
||||||
|
$tests = (array) ($recentTests ?? []);
|
||||||
|
$isEdit = ((int) ($formValues['integration_id'] ?? 0)) > 0;
|
||||||
|
?>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h1><?= $e($t('settings.title')) ?></h1>
|
||||||
|
<p class="muted"><?= $e($t('settings.description')) ?></p>
|
||||||
|
<nav class="settings-nav mt-16" aria-label="<?= $e($t('settings.submenu_label')) ?>">
|
||||||
|
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'integrations' ? '' : ' is-active' ?>" href="/settings/database"><?= $e($t('settings.database.title')) ?></a>
|
||||||
|
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'integrations' ? ' is-active' : '' ?>" href="/settings/integrations/shoppro"><?= $e($t('settings.integrations.title')) ?></a>
|
||||||
|
</nav>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card mt-16">
|
||||||
|
<h2 class="section-title"><?= $e($t('settings.integrations.list_title')) ?></h2>
|
||||||
|
|
||||||
|
<?php if (!empty($errorMessage)): ?>
|
||||||
|
<div class="alert alert--danger mt-12" role="alert">
|
||||||
|
<?= $e((string) $errorMessage) ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (!empty($successMessage)): ?>
|
||||||
|
<div class="alert alert--success mt-12" role="status">
|
||||||
|
<?= $e((string) $successMessage) ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="table-wrap mt-12">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th><?= $e($t('settings.integrations.fields.name')) ?></th>
|
||||||
|
<th><?= $e($t('settings.integrations.fields.base_url')) ?></th>
|
||||||
|
<th><?= $e($t('settings.integrations.fields.active')) ?></th>
|
||||||
|
<th><?= $e($t('settings.integrations.fields.last_test')) ?></th>
|
||||||
|
<th><?= $e($t('settings.integrations.fields.actions')) ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php if (empty($list)): ?>
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="muted"><?= $e($t('settings.integrations.empty')) ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php else: ?>
|
||||||
|
<?php foreach ($list as $item): ?>
|
||||||
|
<?php
|
||||||
|
$status = (string) ($item['last_test_status'] ?? '');
|
||||||
|
$statusLabel = $status === 'ok'
|
||||||
|
? $t('settings.integrations.test_status.ok')
|
||||||
|
: ($status === 'error' ? $t('settings.integrations.test_status.error') : $t('settings.integrations.test_status.never'));
|
||||||
|
?>
|
||||||
|
<tr>
|
||||||
|
<td><?= $e((string) ($item['id'] ?? 0)) ?></td>
|
||||||
|
<td><?= $e((string) ($item['name'] ?? '')) ?></td>
|
||||||
|
<td><?= $e((string) ($item['base_url'] ?? '')) ?></td>
|
||||||
|
<td>
|
||||||
|
<?php if ((bool) ($item['is_active'] ?? false)): ?>
|
||||||
|
<span class="status-pill is-active"><?= $e($t('settings.integrations.active.yes')) ?></span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="status-pill"><?= $e($t('settings.integrations.active.no')) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div><?= $e($statusLabel) ?></div>
|
||||||
|
<?php if (!empty($item['last_test_at'])): ?>
|
||||||
|
<small class="muted"><?= $e((string) $item['last_test_at']) ?><?php if (($item['last_test_http_code'] ?? null) !== null): ?> | HTTP <?= $e((string) $item['last_test_http_code']) ?><?php endif; ?></small>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a class="btn btn--secondary" href="/settings/integrations/shoppro?id=<?= $e((string) ($item['id'] ?? 0)) ?>"><?= $e($t('settings.integrations.actions.edit')) ?></a>
|
||||||
|
<form action="/settings/integrations/shoppro/test" method="post" style="display:inline-block; margin-left:6px;">
|
||||||
|
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
|
||||||
|
<input type="hidden" name="integration_id" value="<?= $e((string) ($item['id'] ?? 0)) ?>">
|
||||||
|
<button type="submit" class="btn btn--primary"><?= $e($t('settings.integrations.actions.test')) ?></button>
|
||||||
|
</form>
|
||||||
|
<form action="/settings/integrations/shoppro/import-offers-cache" method="post" style="display:inline-block; margin-left:6px;">
|
||||||
|
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
|
||||||
|
<input type="hidden" name="integration_id" value="<?= $e((string) ($item['id'] ?? 0)) ?>">
|
||||||
|
<button type="submit" class="btn btn--secondary"><?= $e($t('settings.integrations.actions.import_offers_cache')) ?></button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card mt-16">
|
||||||
|
<h2 class="section-title">
|
||||||
|
<?= $e($isEdit ? $t('settings.integrations.edit_title') : $t('settings.integrations.create_title')) ?>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form class="mt-16" action="/settings/integrations/shoppro/save" method="post">
|
||||||
|
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
|
||||||
|
<input type="hidden" name="integration_id" value="<?= $e((string) ($formValues['integration_id'] ?? '0')) ?>">
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('settings.integrations.fields.name')) ?></span>
|
||||||
|
<input class="form-control" type="text" name="name" value="<?= $e((string) ($formValues['name'] ?? '')) ?>" required>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('settings.integrations.fields.base_url')) ?></span>
|
||||||
|
<input class="form-control" type="url" name="base_url" value="<?= $e((string) ($formValues['base_url'] ?? '')) ?>" placeholder="https://shoppro.project-dc.pl/" required>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('settings.integrations.fields.api_key')) ?></span>
|
||||||
|
<input class="form-control" type="password" name="api_key" value="" placeholder="<?= $e($isEdit ? $t('settings.integrations.api_key_placeholder_edit') : '') ?>">
|
||||||
|
<?php if ($isEdit): ?>
|
||||||
|
<small class="muted">
|
||||||
|
<?= $e(($selected['has_api_key'] ?? false) ? $t('settings.integrations.api_key_saved') : $t('settings.integrations.api_key_missing')) ?>
|
||||||
|
</small>
|
||||||
|
<?php endif; ?>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span class="field-label"><?= $e($t('settings.integrations.fields.timeout_seconds')) ?></span>
|
||||||
|
<input class="form-control" type="number" min="3" max="60" name="timeout_seconds" value="<?= $e((string) ($formValues['timeout_seconds'] ?? '10')) ?>">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="form-field mt-12">
|
||||||
|
<span class="field-label">
|
||||||
|
<input type="checkbox" name="is_active" value="1"<?= ((string) ($formValues['is_active'] ?? '1')) === '1' ? ' checked' : '' ?>>
|
||||||
|
<?= $e($t('settings.integrations.fields.active_checkbox')) ?>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="form-actions mt-16">
|
||||||
|
<button type="submit" class="btn btn--primary"><?= $e($t('settings.integrations.actions.save')) ?></button>
|
||||||
|
<a href="/settings/integrations/shoppro" class="btn btn--secondary"><?= $e($t('settings.integrations.actions.new')) ?></a>
|
||||||
|
<?php if ($isEdit): ?>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn--secondary"
|
||||||
|
formaction="/settings/integrations/shoppro/test"
|
||||||
|
formmethod="post"
|
||||||
|
><?= $e($t('settings.integrations.actions.test_now')) ?></button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn--secondary"
|
||||||
|
formaction="/settings/integrations/shoppro/import-offers-cache"
|
||||||
|
formmethod="post"
|
||||||
|
><?= $e($t('settings.integrations.actions.import_offers_cache')) ?></button>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<?php if ($selected !== null && !empty($tests)): ?>
|
||||||
|
<h3 class="section-title mt-16"><?= $e($t('settings.integrations.logs_title')) ?></h3>
|
||||||
|
<div class="table-wrap mt-12">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><?= $e($t('settings.integrations.logs.fields.tested_at')) ?></th>
|
||||||
|
<th><?= $e($t('settings.integrations.logs.fields.status')) ?></th>
|
||||||
|
<th><?= $e($t('settings.integrations.logs.fields.http_code')) ?></th>
|
||||||
|
<th><?= $e($t('settings.integrations.logs.fields.message')) ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($tests as $test): ?>
|
||||||
|
<?php $httpCode = $test['http_code'] ?? null; ?>
|
||||||
|
<tr>
|
||||||
|
<td><?= $e((string) ($test['tested_at'] ?? '')) ?></td>
|
||||||
|
<td><?= $e((string) ($test['status'] ?? '')) ?></td>
|
||||||
|
<td><?= $e($httpCode === null ? '-' : (string) $httpCode) ?></td>
|
||||||
|
<td><?= $e((string) ($test['message'] ?? '')) ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
</section>
|
||||||
@@ -40,33 +40,4 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card mt-16">
|
<?php require __DIR__ . '/../components/table-list.php'; ?>
|
||||||
<h2 class="section-title"><?= $e($t('users.list_title')) ?></h2>
|
|
||||||
|
|
||||||
<div class="table-wrap mt-12">
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th><?= $e($t('users.fields.name')) ?></th>
|
|
||||||
<th><?= $e($t('users.fields.email')) ?></th>
|
|
||||||
<th><?= $e($t('users.fields.created_at')) ?></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<?php if (empty($users)): ?>
|
|
||||||
<tr>
|
|
||||||
<td colspan="3" class="muted"><?= $e($t('users.empty')) ?></td>
|
|
||||||
</tr>
|
|
||||||
<?php else: ?>
|
|
||||||
<?php foreach ($users as $row): ?>
|
|
||||||
<tr>
|
|
||||||
<td><?= $e((string) ($row['name'] ?? '')) ?></td>
|
|
||||||
<td><?= $e((string) ($row['email'] ?? '')) ?></td>
|
|
||||||
<td><?= $e((string) ($row['created_at'] ?? '')) ?></td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|||||||
@@ -7,7 +7,19 @@ use App\Core\Http\Response;
|
|||||||
use App\Core\Security\Csrf;
|
use App\Core\Security\Csrf;
|
||||||
use App\Modules\Auth\AuthController;
|
use App\Modules\Auth\AuthController;
|
||||||
use App\Modules\Auth\AuthMiddleware;
|
use App\Modules\Auth\AuthMiddleware;
|
||||||
|
use App\Modules\ProductLinks\ChannelOffersRepository;
|
||||||
|
use App\Modules\ProductLinks\LinkMatcherService;
|
||||||
|
use App\Modules\ProductLinks\OfferImportService;
|
||||||
|
use App\Modules\ProductLinks\ProductLinksController;
|
||||||
|
use App\Modules\ProductLinks\ProductLinksRepository;
|
||||||
|
use App\Modules\ProductLinks\ProductLinksService;
|
||||||
|
use App\Modules\Products\ProductRepository;
|
||||||
|
use App\Modules\Products\ProductsController;
|
||||||
|
use App\Modules\Products\ProductService;
|
||||||
|
use App\Modules\Products\ProductValidator;
|
||||||
|
use App\Modules\Settings\IntegrationRepository;
|
||||||
use App\Modules\Settings\SettingsController;
|
use App\Modules\Settings\SettingsController;
|
||||||
|
use App\Modules\Settings\ShopProClient;
|
||||||
use App\Modules\Users\UsersController;
|
use App\Modules\Users\UsersController;
|
||||||
|
|
||||||
return static function (Application $app): void {
|
return static function (Application $app): void {
|
||||||
@@ -18,7 +30,46 @@ return static function (Application $app): void {
|
|||||||
|
|
||||||
$authController = new AuthController($template, $auth, $translator);
|
$authController = new AuthController($template, $auth, $translator);
|
||||||
$usersController = new UsersController($template, $translator, $auth, $app->users());
|
$usersController = new UsersController($template, $translator, $auth, $app->users());
|
||||||
$settingsController = new SettingsController($template, $translator, $auth, $app->migrator());
|
$integrationRepository = new IntegrationRepository(
|
||||||
|
$app->db(),
|
||||||
|
(string) $app->config('app.integrations.secret', '')
|
||||||
|
);
|
||||||
|
$shopProClient = new ShopProClient();
|
||||||
|
$channelOffersRepository = new ChannelOffersRepository($app->db());
|
||||||
|
$productLinksRepository = new ProductLinksRepository($app->db());
|
||||||
|
$linkMatcherService = new LinkMatcherService();
|
||||||
|
$productLinksService = new ProductLinksService(
|
||||||
|
$productLinksRepository,
|
||||||
|
$channelOffersRepository,
|
||||||
|
$integrationRepository,
|
||||||
|
$linkMatcherService,
|
||||||
|
$app->db()
|
||||||
|
);
|
||||||
|
$productLinksController = new ProductLinksController($translator, $auth, $productLinksService);
|
||||||
|
$offerImportService = new OfferImportService($shopProClient, $channelOffersRepository, $app->db());
|
||||||
|
$productRepository = new ProductRepository($app->db());
|
||||||
|
$settingsController = new SettingsController(
|
||||||
|
$template,
|
||||||
|
$translator,
|
||||||
|
$auth,
|
||||||
|
$app->migrator(),
|
||||||
|
$integrationRepository,
|
||||||
|
$shopProClient,
|
||||||
|
$offerImportService,
|
||||||
|
$productRepository,
|
||||||
|
$app->db()
|
||||||
|
);
|
||||||
|
$productValidator = new ProductValidator();
|
||||||
|
$productService = new ProductService($app->db(), $productRepository, $productValidator);
|
||||||
|
$productsController = new ProductsController(
|
||||||
|
$template,
|
||||||
|
$translator,
|
||||||
|
$auth,
|
||||||
|
$productRepository,
|
||||||
|
$productService,
|
||||||
|
$integrationRepository,
|
||||||
|
$productLinksService
|
||||||
|
);
|
||||||
$authMiddleware = new AuthMiddleware($auth);
|
$authMiddleware = new AuthMiddleware($auth);
|
||||||
|
|
||||||
$router->get('/health', static fn (Request $request): Response => Response::json([
|
$router->get('/health', static fn (Request $request): Response => Response::json([
|
||||||
@@ -51,6 +102,37 @@ return static function (Application $app): void {
|
|||||||
|
|
||||||
$router->get('/users', [$usersController, 'index'], [$authMiddleware]);
|
$router->get('/users', [$usersController, 'index'], [$authMiddleware]);
|
||||||
$router->post('/users', [$usersController, 'store'], [$authMiddleware]);
|
$router->post('/users', [$usersController, 'store'], [$authMiddleware]);
|
||||||
|
$router->get('/products', [$productsController, 'index'], [$authMiddleware]);
|
||||||
|
$router->get('/products/show', [$productsController, 'show'], [$authMiddleware]);
|
||||||
|
$router->get('/products/{id}', [$productsController, 'show'], [$authMiddleware]);
|
||||||
|
$router->get('/products/links', [$productsController, 'links'], [$authMiddleware]);
|
||||||
|
$router->get('/products/{id}/links', [$productsController, 'links'], [$authMiddleware]);
|
||||||
|
$router->get('/products/{id}/links/suggestions', [$productsController, 'linkSuggestions'], [$authMiddleware]);
|
||||||
|
$router->get('/products/create', [$productsController, 'create'], [$authMiddleware]);
|
||||||
|
$router->post('/products', [$productsController, 'store'], [$authMiddleware]);
|
||||||
|
$router->get('/products/edit', [$productsController, 'edit'], [$authMiddleware]);
|
||||||
|
$router->post('/products/update', [$productsController, 'update'], [$authMiddleware]);
|
||||||
|
$router->post('/products/delete', [$productsController, 'destroy'], [$authMiddleware]);
|
||||||
|
$router->post('/products/images/upload', [$productsController, 'uploadImage'], [$authMiddleware]);
|
||||||
|
$router->post('/products/images/set-main', [$productsController, 'setMainImage'], [$authMiddleware]);
|
||||||
|
$router->post('/products/images/delete', [$productsController, 'deleteImage'], [$authMiddleware]);
|
||||||
|
$router->post('/products/links/create', [$productLinksController, 'create'], [$authMiddleware]);
|
||||||
|
$router->post('/products/links/relink', [$productLinksController, 'relink'], [$authMiddleware]);
|
||||||
|
$router->post('/products/links/unlink', [$productLinksController, 'unlink'], [$authMiddleware]);
|
||||||
|
$router->post('/products/{id}/links', [$productLinksController, 'create'], [$authMiddleware]);
|
||||||
|
$router->post('/products/{id}/links/{mapId}/relink', [$productLinksController, 'relink'], [$authMiddleware]);
|
||||||
|
$router->post('/products/{id}/links/{mapId}/unlink', [$productLinksController, 'unlink'], [$authMiddleware]);
|
||||||
|
$router->post('/products/import/shoppro', [$settingsController, 'importProducts'], [$authMiddleware]);
|
||||||
|
$router->get('/settings/integrations', static fn (Request $request): Response => Response::redirect('/settings/integrations/shoppro'), [$authMiddleware]);
|
||||||
|
$router->get('/settings/integrations/shoppro', [$settingsController, 'integrations'], [$authMiddleware]);
|
||||||
|
$router->post('/settings/integrations/save', [$settingsController, 'saveIntegration'], [$authMiddleware]);
|
||||||
|
$router->post('/settings/integrations/shoppro/save', [$settingsController, 'saveIntegration'], [$authMiddleware]);
|
||||||
|
$router->post('/settings/integrations/test', [$settingsController, 'testIntegration'], [$authMiddleware]);
|
||||||
|
$router->post('/settings/integrations/shoppro/test', [$settingsController, 'testIntegration'], [$authMiddleware]);
|
||||||
|
$router->post('/settings/integrations/import-one-product', [$settingsController, 'importOneProduct'], [$authMiddleware]);
|
||||||
|
$router->post('/settings/integrations/shoppro/import-one-product', [$settingsController, 'importOneProduct'], [$authMiddleware]);
|
||||||
|
$router->post('/settings/integrations/import-offers-cache', [$settingsController, 'importOffersCache'], [$authMiddleware]);
|
||||||
|
$router->post('/settings/integrations/shoppro/import-offers-cache', [$settingsController, 'importOffersCache'], [$authMiddleware]);
|
||||||
$router->get('/settings/database', [$settingsController, 'database'], [$authMiddleware]);
|
$router->get('/settings/database', [$settingsController, 'database'], [$authMiddleware]);
|
||||||
$router->post('/settings/database/migrate', [$settingsController, 'migrate'], [$authMiddleware]);
|
$router->post('/settings/database/migrate', [$settingsController, 'migrate'], [$authMiddleware]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -74,14 +74,16 @@ final class Migrator
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->pdo->beginTransaction();
|
|
||||||
try {
|
try {
|
||||||
|
$this->pdo->beginTransaction();
|
||||||
$this->pdo->exec($sql);
|
$this->pdo->exec($sql);
|
||||||
$insert->execute([
|
$insert->execute([
|
||||||
'filename' => $filename,
|
'filename' => $filename,
|
||||||
'executed_at' => date('Y-m-d H:i:s'),
|
'executed_at' => date('Y-m-d H:i:s'),
|
||||||
]);
|
]);
|
||||||
$this->pdo->commit();
|
if ($this->pdo->inTransaction()) {
|
||||||
|
$this->pdo->commit();
|
||||||
|
}
|
||||||
$executed++;
|
$executed++;
|
||||||
$logs[] = '[ok] ' . $filename;
|
$logs[] = '[ok] ' . $filename;
|
||||||
} catch (Throwable $exception) {
|
} catch (Throwable $exception) {
|
||||||
|
|||||||
@@ -8,18 +8,21 @@ final class Request
|
|||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $query
|
* @param array<string, mixed> $query
|
||||||
* @param array<string, mixed> $request
|
* @param array<string, mixed> $request
|
||||||
|
* @param array<string, mixed> $files
|
||||||
* @param array<string, mixed> $server
|
* @param array<string, mixed> $server
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly array $query,
|
private readonly array $query,
|
||||||
private readonly array $request,
|
private readonly array $request,
|
||||||
private readonly array $server
|
private readonly array $files,
|
||||||
|
private readonly array $server,
|
||||||
|
private readonly array $attributes = []
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function capture(): self
|
public static function capture(): self
|
||||||
{
|
{
|
||||||
return new self($_GET, $_POST, $_SERVER);
|
return new self($_GET, $_POST, $_FILES, $_SERVER);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function method(): string
|
public function method(): string
|
||||||
@@ -45,9 +48,21 @@ final class Request
|
|||||||
return $this->query[$key];
|
return $this->query[$key];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (array_key_exists($key, $this->attributes)) {
|
||||||
|
return $this->attributes[$key];
|
||||||
|
}
|
||||||
|
|
||||||
return $default;
|
return $default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $attributes
|
||||||
|
*/
|
||||||
|
public function withAttributes(array $attributes): self
|
||||||
|
{
|
||||||
|
return new self($this->query, $this->request, $this->files, $this->server, $attributes);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
@@ -55,4 +70,21 @@ final class Request
|
|||||||
{
|
{
|
||||||
return array_merge($this->query, $this->request);
|
return array_merge($this->query, $this->request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function file(string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
if (array_key_exists($key, $this->files)) {
|
||||||
|
return $this->files[$key];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function allFiles(): array
|
||||||
|
{
|
||||||
|
return $this->files;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ final class Router
|
|||||||
return Response::html('Not found', 404);
|
return Response::html('Not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$params = (array) ($route['params'] ?? []);
|
||||||
|
if ($params !== []) {
|
||||||
|
$request = $request->withAttributes($params);
|
||||||
|
}
|
||||||
|
|
||||||
$handler = $route['handler'];
|
$handler = $route['handler'];
|
||||||
$middlewares = $route['middlewares'];
|
$middlewares = $route['middlewares'];
|
||||||
|
|
||||||
@@ -93,6 +98,23 @@ final class Router
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach ($this->routes as $route) {
|
||||||
|
if ($route['method'] !== $normalizedMethod) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!$this->hasRouteParams($route['path'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$match = $this->matchPathParams($route['path'], $normalizedPath);
|
||||||
|
if ($match === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$route['params'] = $match;
|
||||||
|
return $route;
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,4 +145,43 @@ final class Router
|
|||||||
throw new RuntimeException('Invalid middleware definition');
|
throw new RuntimeException('Invalid middleware definition');
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function hasRouteParams(string $path): bool
|
||||||
|
{
|
||||||
|
return str_contains($path, '{') && str_contains($path, '}');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>|null
|
||||||
|
*/
|
||||||
|
private function matchPathParams(string $routePath, string $requestPath): ?array
|
||||||
|
{
|
||||||
|
$routeSegments = explode('/', trim($routePath, '/'));
|
||||||
|
$regexSegments = [];
|
||||||
|
foreach ($routeSegments as $segment) {
|
||||||
|
if (preg_match('/^\{([a-zA-Z_][a-zA-Z0-9_]*)\}$/', $segment, $match) === 1) {
|
||||||
|
$regexSegments[] = '(?P<' . $match[1] . '>[^/]+)';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$regexSegments[] = preg_quote($segment, '#');
|
||||||
|
}
|
||||||
|
|
||||||
|
$pattern = '#^/' . implode('/', $regexSegments) . '$#';
|
||||||
|
if ($routePath === '/') {
|
||||||
|
$pattern = '#^/$#';
|
||||||
|
}
|
||||||
|
if (preg_match($pattern, $requestPath, $matches) !== 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$params = [];
|
||||||
|
foreach ($matches as $key => $value) {
|
||||||
|
if (is_string($key)) {
|
||||||
|
$params[$key] = (string) $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $params;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
255
src/Modules/ProductLinks/ChannelOffersRepository.php
Normal file
255
src/Modules/ProductLinks/ChannelOffersRepository.php
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\ProductLinks;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
final class ChannelOffersRepository
|
||||||
|
{
|
||||||
|
public function __construct(private readonly PDO $pdo)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function listByIntegration(int $integrationId, int $limit = 100): array
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'SELECT co.id, co.integration_id, co.channel_id,
|
||||||
|
co.external_product_id, co.external_variant_id, co.external_offer_id,
|
||||||
|
co.name, co.sku, co.ean, co.price_brutto, co.quantity, co.currency,
|
||||||
|
co.offer_status, co.source_updated_at, co.last_seen_at, co.payload_json,
|
||||||
|
co.created_at, co.updated_at,
|
||||||
|
sc.code AS channel_code, sc.name AS channel_name
|
||||||
|
FROM channel_offers co
|
||||||
|
INNER JOIN sales_channels sc ON sc.id = co.channel_id
|
||||||
|
WHERE co.integration_id = :integration_id
|
||||||
|
ORDER BY co.last_seen_at DESC, co.id DESC
|
||||||
|
LIMIT :limit'
|
||||||
|
);
|
||||||
|
$statement->bindValue(':integration_id', $integrationId, PDO::PARAM_INT);
|
||||||
|
$statement->bindValue(':limit', max(1, $limit), PDO::PARAM_INT);
|
||||||
|
$statement->execute();
|
||||||
|
|
||||||
|
$rows = $statement->fetchAll();
|
||||||
|
if (!is_array($rows)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_map([$this, 'mapOfferRow'], $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function search(
|
||||||
|
int $integrationId,
|
||||||
|
?int $channelId,
|
||||||
|
string $query,
|
||||||
|
int $limit = 50
|
||||||
|
): array {
|
||||||
|
$sql = 'SELECT co.id, co.integration_id, co.channel_id,
|
||||||
|
co.external_product_id, co.external_variant_id, co.external_offer_id,
|
||||||
|
co.name, co.sku, co.ean, co.price_brutto, co.quantity, co.currency,
|
||||||
|
co.offer_status, co.source_updated_at, co.last_seen_at, co.payload_json,
|
||||||
|
co.created_at, co.updated_at,
|
||||||
|
sc.code AS channel_code, sc.name AS channel_name
|
||||||
|
FROM channel_offers co
|
||||||
|
INNER JOIN sales_channels sc ON sc.id = co.channel_id
|
||||||
|
WHERE co.integration_id = :integration_id';
|
||||||
|
$params = ['integration_id' => $integrationId];
|
||||||
|
|
||||||
|
if ($channelId !== null && $channelId > 0) {
|
||||||
|
$sql .= ' AND co.channel_id = :channel_id';
|
||||||
|
$params['channel_id'] = $channelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$needle = trim($query);
|
||||||
|
if ($needle !== '') {
|
||||||
|
$sql .= ' AND (
|
||||||
|
co.name LIKE :query_name
|
||||||
|
OR co.sku LIKE :query_sku
|
||||||
|
OR co.ean LIKE :query_ean
|
||||||
|
OR co.external_product_id LIKE :query_external_product_id
|
||||||
|
)';
|
||||||
|
$like = '%' . $needle . '%';
|
||||||
|
$params['query_name'] = $like;
|
||||||
|
$params['query_sku'] = $like;
|
||||||
|
$params['query_ean'] = $like;
|
||||||
|
$params['query_external_product_id'] = $like;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= ' ORDER BY co.last_seen_at DESC, co.id DESC LIMIT :limit';
|
||||||
|
|
||||||
|
$statement = $this->pdo->prepare($sql);
|
||||||
|
foreach ($params as $key => $value) {
|
||||||
|
$statement->bindValue(':' . $key, $value);
|
||||||
|
}
|
||||||
|
$statement->bindValue(':limit', max(1, $limit), PDO::PARAM_INT);
|
||||||
|
$statement->execute();
|
||||||
|
|
||||||
|
$rows = $statement->fetchAll();
|
||||||
|
if (!is_array($rows)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_map([$this, 'mapOfferRow'], $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function upsertOffer(
|
||||||
|
int $integrationId,
|
||||||
|
int $channelId,
|
||||||
|
string $externalProductId,
|
||||||
|
?string $externalVariantId,
|
||||||
|
?string $externalOfferId,
|
||||||
|
string $name,
|
||||||
|
?string $sku,
|
||||||
|
?string $ean,
|
||||||
|
?float $priceBrutto,
|
||||||
|
?float $quantity,
|
||||||
|
?string $currency,
|
||||||
|
string $offerStatus,
|
||||||
|
?string $sourceUpdatedAt,
|
||||||
|
string $lastSeenAt,
|
||||||
|
?string $payloadJson
|
||||||
|
): void {
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'INSERT INTO channel_offers (
|
||||||
|
integration_id, channel_id, external_product_id, external_variant_id, external_offer_id,
|
||||||
|
name, sku, ean, price_brutto, quantity, currency, offer_status,
|
||||||
|
source_updated_at, last_seen_at, payload_json, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
:integration_id, :channel_id, :external_product_id, :external_variant_id, :external_offer_id,
|
||||||
|
:name, :sku, :ean, :price_brutto, :quantity, :currency, :offer_status,
|
||||||
|
:source_updated_at, :last_seen_at, :payload_json, :created_at, :updated_at
|
||||||
|
) ON DUPLICATE KEY UPDATE
|
||||||
|
channel_id = VALUES(channel_id),
|
||||||
|
external_offer_id = VALUES(external_offer_id),
|
||||||
|
name = VALUES(name),
|
||||||
|
sku = VALUES(sku),
|
||||||
|
ean = VALUES(ean),
|
||||||
|
price_brutto = VALUES(price_brutto),
|
||||||
|
quantity = VALUES(quantity),
|
||||||
|
currency = VALUES(currency),
|
||||||
|
offer_status = VALUES(offer_status),
|
||||||
|
source_updated_at = VALUES(source_updated_at),
|
||||||
|
last_seen_at = VALUES(last_seen_at),
|
||||||
|
payload_json = VALUES(payload_json),
|
||||||
|
updated_at = VALUES(updated_at)'
|
||||||
|
);
|
||||||
|
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$statement->execute([
|
||||||
|
'integration_id' => $integrationId,
|
||||||
|
'channel_id' => $channelId,
|
||||||
|
'external_product_id' => trim($externalProductId),
|
||||||
|
'external_variant_id' => $this->nullableText($externalVariantId),
|
||||||
|
'external_offer_id' => $this->nullableText($externalOfferId),
|
||||||
|
'name' => trim($name),
|
||||||
|
'sku' => $this->nullableText($sku),
|
||||||
|
'ean' => $this->nullableText($ean),
|
||||||
|
'price_brutto' => $priceBrutto,
|
||||||
|
'quantity' => $quantity,
|
||||||
|
'currency' => $this->nullableText($currency),
|
||||||
|
'offer_status' => trim($offerStatus),
|
||||||
|
'source_updated_at' => $this->nullableText($sourceUpdatedAt),
|
||||||
|
'last_seen_at' => $lastSeenAt,
|
||||||
|
'payload_json' => $this->nullableJson($payloadJson),
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public function findByExternalIdentity(
|
||||||
|
int $integrationId,
|
||||||
|
string $externalProductId,
|
||||||
|
?string $externalVariantId
|
||||||
|
): ?array {
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'SELECT co.id, co.integration_id, co.channel_id,
|
||||||
|
co.external_product_id, co.external_variant_id, co.external_offer_id,
|
||||||
|
co.name, co.sku, co.ean, co.price_brutto, co.quantity, co.currency,
|
||||||
|
co.offer_status, co.source_updated_at, co.last_seen_at, co.payload_json,
|
||||||
|
co.created_at, co.updated_at,
|
||||||
|
sc.code AS channel_code, sc.name AS channel_name
|
||||||
|
FROM channel_offers co
|
||||||
|
INNER JOIN sales_channels sc ON sc.id = co.channel_id
|
||||||
|
WHERE co.integration_id = :integration_id
|
||||||
|
AND co.external_product_id = :external_product_id
|
||||||
|
AND (
|
||||||
|
(:external_variant_id_value IS NULL AND co.external_variant_id IS NULL)
|
||||||
|
OR co.external_variant_id = :external_variant_id_match
|
||||||
|
)
|
||||||
|
ORDER BY co.id DESC
|
||||||
|
LIMIT 1'
|
||||||
|
);
|
||||||
|
$statement->execute([
|
||||||
|
'integration_id' => $integrationId,
|
||||||
|
'external_product_id' => trim($externalProductId),
|
||||||
|
'external_variant_id_value' => $this->nullableText($externalVariantId),
|
||||||
|
'external_variant_id_match' => $this->nullableText($externalVariantId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$row = $statement->fetch();
|
||||||
|
if (!is_array($row)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->mapOfferRow($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nullableText(?string $value): ?string
|
||||||
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$trimmed = trim($value);
|
||||||
|
return $trimmed === '' ? null : $trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nullableJson(?string $payload): ?string
|
||||||
|
{
|
||||||
|
if ($payload === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$trimmed = trim($payload);
|
||||||
|
return $trimmed === '' ? null : $trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $row
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function mapOfferRow(array $row): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => (int) ($row['id'] ?? 0),
|
||||||
|
'integration_id' => (int) ($row['integration_id'] ?? 0),
|
||||||
|
'channel_id' => (int) ($row['channel_id'] ?? 0),
|
||||||
|
'external_product_id' => (string) ($row['external_product_id'] ?? ''),
|
||||||
|
'external_variant_id' => isset($row['external_variant_id']) ? (string) $row['external_variant_id'] : null,
|
||||||
|
'external_offer_id' => isset($row['external_offer_id']) ? (string) $row['external_offer_id'] : null,
|
||||||
|
'name' => (string) ($row['name'] ?? ''),
|
||||||
|
'sku' => isset($row['sku']) ? (string) $row['sku'] : null,
|
||||||
|
'ean' => isset($row['ean']) ? (string) $row['ean'] : null,
|
||||||
|
'price_brutto' => $row['price_brutto'] === null ? null : (float) $row['price_brutto'],
|
||||||
|
'quantity' => $row['quantity'] === null ? null : (float) $row['quantity'],
|
||||||
|
'currency' => isset($row['currency']) ? (string) $row['currency'] : null,
|
||||||
|
'offer_status' => (string) ($row['offer_status'] ?? 'active'),
|
||||||
|
'source_updated_at' => isset($row['source_updated_at']) ? (string) $row['source_updated_at'] : null,
|
||||||
|
'last_seen_at' => (string) ($row['last_seen_at'] ?? ''),
|
||||||
|
'payload_json' => isset($row['payload_json']) ? (string) $row['payload_json'] : null,
|
||||||
|
'channel_code' => isset($row['channel_code']) ? (string) $row['channel_code'] : '',
|
||||||
|
'channel_name' => isset($row['channel_name']) ? (string) $row['channel_name'] : '',
|
||||||
|
'created_at' => (string) ($row['created_at'] ?? ''),
|
||||||
|
'updated_at' => (string) ($row['updated_at'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/Modules/ProductLinks/LinkMatcherService.php
Normal file
63
src/Modules/ProductLinks/LinkMatcherService.php
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\ProductLinks;
|
||||||
|
|
||||||
|
final class LinkMatcherService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $product
|
||||||
|
* @param array<string, mixed> $offer
|
||||||
|
* @return array{match_hint:string,confidence:int,link_type:string}
|
||||||
|
*/
|
||||||
|
public function match(array $product, array $offer): array
|
||||||
|
{
|
||||||
|
$productEan = trim((string) ($product['ean'] ?? ''));
|
||||||
|
$productSku = trim((string) ($product['sku'] ?? ''));
|
||||||
|
$offerEan = trim((string) ($offer['ean'] ?? ''));
|
||||||
|
$offerSku = trim((string) ($offer['sku'] ?? ''));
|
||||||
|
|
||||||
|
if ($productEan !== '' && $offerEan !== '' && $productEan === $offerEan) {
|
||||||
|
return [
|
||||||
|
'match_hint' => 'EAN exact',
|
||||||
|
'confidence' => 98,
|
||||||
|
'link_type' => 'auto_ean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($productSku !== '' && $offerSku !== '' && mb_strtolower($productSku) === mb_strtolower($offerSku)) {
|
||||||
|
return [
|
||||||
|
'match_hint' => 'SKU exact',
|
||||||
|
'confidence' => 90,
|
||||||
|
'link_type' => 'auto_sku',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedProductSku = $this->normalizeSku($productSku);
|
||||||
|
$normalizedOfferSku = $this->normalizeSku($offerSku);
|
||||||
|
if ($normalizedProductSku !== '' && $normalizedProductSku === $normalizedOfferSku) {
|
||||||
|
return [
|
||||||
|
'match_hint' => 'SKU normalized',
|
||||||
|
'confidence' => 75,
|
||||||
|
'link_type' => 'auto_sku',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'match_hint' => '',
|
||||||
|
'confidence' => 0,
|
||||||
|
'link_type' => 'manual',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeSku(string $value): string
|
||||||
|
{
|
||||||
|
if ($value === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$lower = mb_strtolower($value);
|
||||||
|
return preg_replace('/[\s\-_]+/u', '', $lower) ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
218
src/Modules/ProductLinks/OfferImportService.php
Normal file
218
src/Modules/ProductLinks/OfferImportService.php
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\ProductLinks;
|
||||||
|
|
||||||
|
use App\Modules\Settings\ShopProClient;
|
||||||
|
use PDO;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class OfferImportService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ShopProClient $shopProClient,
|
||||||
|
private readonly ChannelOffersRepository $offers,
|
||||||
|
private readonly PDO $pdo
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $credentials
|
||||||
|
* @return array{
|
||||||
|
* ok:bool,
|
||||||
|
* imported:int,
|
||||||
|
* failed:int,
|
||||||
|
* pages:int,
|
||||||
|
* message:string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function importShopProOffers(array $credentials, int $perPage = 100, int $maxPages = 200): array
|
||||||
|
{
|
||||||
|
$integrationId = (int) ($credentials['id'] ?? 0);
|
||||||
|
$baseUrl = trim((string) ($credentials['base_url'] ?? ''));
|
||||||
|
$apiKey = trim((string) ($credentials['api_key'] ?? ''));
|
||||||
|
$timeout = (int) ($credentials['timeout_seconds'] ?? 10);
|
||||||
|
|
||||||
|
if ($integrationId <= 0) {
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'imported' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
'pages' => 0,
|
||||||
|
'message' => 'Niepoprawne ID integracji.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($baseUrl === '' || $apiKey === '') {
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'imported' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
'pages' => 0,
|
||||||
|
'message' => 'Brak base_url lub api_key dla integracji.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$channelId = $this->findChannelIdByCode('shoppro');
|
||||||
|
if ($channelId === null) {
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'imported' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
'pages' => 0,
|
||||||
|
'message' => 'Brak kanalu sprzedazy shoppro. Uruchom seeding kanalow.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$imported = 0;
|
||||||
|
$failed = 0;
|
||||||
|
$pages = 0;
|
||||||
|
$errors = [];
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$page = 1;
|
||||||
|
$safePerPage = max(1, min(100, $perPage));
|
||||||
|
$safeMaxPages = max(1, min(500, $maxPages));
|
||||||
|
|
||||||
|
while ($page <= $safeMaxPages) {
|
||||||
|
$listResult = $this->shopProClient->fetchProducts($baseUrl, $apiKey, $timeout, $page, $safePerPage);
|
||||||
|
if (($listResult['ok'] ?? false) !== true) {
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'imported' => $imported,
|
||||||
|
'failed' => $failed,
|
||||||
|
'pages' => $pages,
|
||||||
|
'message' => 'Blad pobierania listy produktow (strona ' . $page . '): ' . (string) ($listResult['message'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = is_array($listResult['items'] ?? null) ? $listResult['items'] : [];
|
||||||
|
if ($items === []) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pages++;
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$mapped = $this->mapExternalItem($item, $now);
|
||||||
|
if ($mapped === null) {
|
||||||
|
$failed++;
|
||||||
|
if (count($errors) < 3) {
|
||||||
|
$errors[] = 'Pominieto rekord bez poprawnego ID produktu z API.';
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->offers->upsertOffer(
|
||||||
|
$integrationId,
|
||||||
|
$channelId,
|
||||||
|
(string) $mapped['external_product_id'],
|
||||||
|
$mapped['external_variant_id'] === null ? null : (string) $mapped['external_variant_id'],
|
||||||
|
$mapped['external_offer_id'] === null ? null : (string) $mapped['external_offer_id'],
|
||||||
|
(string) $mapped['name'],
|
||||||
|
$mapped['sku'] === null ? null : (string) $mapped['sku'],
|
||||||
|
$mapped['ean'] === null ? null : (string) $mapped['ean'],
|
||||||
|
$mapped['price_brutto'] === null ? null : (float) $mapped['price_brutto'],
|
||||||
|
$mapped['quantity'] === null ? null : (float) $mapped['quantity'],
|
||||||
|
$mapped['currency'] === null ? null : (string) $mapped['currency'],
|
||||||
|
(string) $mapped['offer_status'],
|
||||||
|
$mapped['source_updated_at'] === null ? null : (string) $mapped['source_updated_at'],
|
||||||
|
(string) $mapped['last_seen_at'],
|
||||||
|
(string) $mapped['payload_json']
|
||||||
|
);
|
||||||
|
$imported++;
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$failed++;
|
||||||
|
if (count($errors) < 3) {
|
||||||
|
$errors[] = $exception->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($items) < $safePerPage) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$page++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = '';
|
||||||
|
if ($errors !== []) {
|
||||||
|
$message = implode(' | ', $errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => true,
|
||||||
|
'imported' => $imported,
|
||||||
|
'failed' => $failed,
|
||||||
|
'pages' => $pages,
|
||||||
|
'message' => $message,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findChannelIdByCode(string $code): ?int
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare('SELECT id FROM sales_channels WHERE code = :code LIMIT 1');
|
||||||
|
$statement->execute(['code' => trim($code)]);
|
||||||
|
$value = $statement->fetchColumn();
|
||||||
|
|
||||||
|
if ($value === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $item
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private function mapExternalItem(array $item, string $lastSeenAt): ?array
|
||||||
|
{
|
||||||
|
$externalProductId = $this->nullableText($item['id'] ?? null);
|
||||||
|
if ($externalProductId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = $this->nullableText($item['name'] ?? null);
|
||||||
|
if ($name === null) {
|
||||||
|
$name = 'shopPRO #' . $externalProductId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payloadJson = json_encode($item, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
if ($payloadJson === false) {
|
||||||
|
$payloadJson = '{}';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'external_product_id' => $externalProductId,
|
||||||
|
'external_variant_id' => $this->nullableText($item['variant_id'] ?? $item['external_variant_id'] ?? null),
|
||||||
|
'external_offer_id' => $this->nullableText($item['offer_id'] ?? $item['external_offer_id'] ?? null),
|
||||||
|
'name' => $name,
|
||||||
|
'sku' => $this->nullableText($item['sku'] ?? null),
|
||||||
|
'ean' => $this->nullableText($item['ean'] ?? null),
|
||||||
|
'price_brutto' => $this->nullableFloat($item['price_brutto'] ?? $item['price'] ?? null),
|
||||||
|
'quantity' => $this->nullableFloat($item['quantity'] ?? null, 3),
|
||||||
|
'currency' => $this->nullableText($item['currency'] ?? null),
|
||||||
|
'offer_status' => ((int) ($item['status'] ?? 1)) === 1 ? 'active' : 'inactive',
|
||||||
|
'source_updated_at' => $this->nullableText($item['updated_at'] ?? $item['modified_at'] ?? null),
|
||||||
|
'last_seen_at' => $lastSeenAt,
|
||||||
|
'payload_json' => $payloadJson,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nullableText(mixed $value): ?string
|
||||||
|
{
|
||||||
|
$text = trim((string) $value);
|
||||||
|
return $text === '' ? null : $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nullableFloat(mixed $value, int $precision = 2): ?float
|
||||||
|
{
|
||||||
|
$text = trim((string) $value);
|
||||||
|
if ($text === '' || !is_numeric($text)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return round((float) $text, $precision);
|
||||||
|
}
|
||||||
|
}
|
||||||
156
src/Modules/ProductLinks/ProductLinksController.php
Normal file
156
src/Modules/ProductLinks/ProductLinksController.php
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\ProductLinks;
|
||||||
|
|
||||||
|
use App\Core\Http\Request;
|
||||||
|
use App\Core\Http\Response;
|
||||||
|
use App\Core\I18n\Translator;
|
||||||
|
use App\Core\Security\Csrf;
|
||||||
|
use App\Core\Support\Flash;
|
||||||
|
use App\Modules\Auth\AuthService;
|
||||||
|
|
||||||
|
final class ProductLinksController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly Translator $translator,
|
||||||
|
private readonly AuthService $auth,
|
||||||
|
private readonly ProductLinksService $service
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(Request $request): Response
|
||||||
|
{
|
||||||
|
$productId = $this->resolvePositiveInt($request, ['id', 'product_id']);
|
||||||
|
if (!$this->validateCsrf($request, $productId)) {
|
||||||
|
return Response::redirect($this->linksPagePath($productId));
|
||||||
|
}
|
||||||
|
|
||||||
|
$integrationId = $this->resolvePositiveInt($request, ['integration_id']);
|
||||||
|
$externalProductId = (string) $request->input('external_product_id', '');
|
||||||
|
$externalVariantId = $this->nullableText((string) $request->input('external_variant_id', ''));
|
||||||
|
$userId = $this->resolveUserId();
|
||||||
|
|
||||||
|
$result = $this->service->createManualLink(
|
||||||
|
$productId,
|
||||||
|
$integrationId,
|
||||||
|
$externalProductId,
|
||||||
|
$externalVariantId,
|
||||||
|
$userId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (($result['ok'] ?? false) !== true) {
|
||||||
|
Flash::set('product_links_error', (string) ($result['message'] ?? $this->translator->get('products.links.flash.link_failed')));
|
||||||
|
return Response::redirect($this->linksPagePath($productId));
|
||||||
|
}
|
||||||
|
|
||||||
|
Flash::set('product_links_success', $this->translator->get('products.links.flash.linked'));
|
||||||
|
return Response::redirect($this->linksPagePath($productId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function relink(Request $request): Response
|
||||||
|
{
|
||||||
|
$productId = $this->resolvePositiveInt($request, ['id', 'product_id']);
|
||||||
|
if (!$this->validateCsrf($request, $productId)) {
|
||||||
|
return Response::redirect($this->linksPagePath($productId));
|
||||||
|
}
|
||||||
|
|
||||||
|
$mapId = $this->resolvePositiveInt($request, ['mapId', 'map_id']);
|
||||||
|
$integrationId = $this->resolvePositiveInt($request, ['integration_id']);
|
||||||
|
$externalProductId = (string) $request->input('external_product_id', '');
|
||||||
|
$externalVariantId = $this->nullableText((string) $request->input('external_variant_id', ''));
|
||||||
|
$userId = $this->resolveUserId();
|
||||||
|
|
||||||
|
$result = $this->service->relink(
|
||||||
|
$productId,
|
||||||
|
$mapId,
|
||||||
|
$integrationId,
|
||||||
|
$externalProductId,
|
||||||
|
$externalVariantId,
|
||||||
|
$userId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (($result['ok'] ?? false) !== true) {
|
||||||
|
Flash::set('product_links_error', (string) ($result['message'] ?? $this->translator->get('products.links.flash.relink_failed')));
|
||||||
|
return Response::redirect($this->linksPagePath($productId));
|
||||||
|
}
|
||||||
|
|
||||||
|
Flash::set('product_links_success', $this->translator->get('products.links.flash.relinked'));
|
||||||
|
return Response::redirect($this->linksPagePath($productId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function unlink(Request $request): Response
|
||||||
|
{
|
||||||
|
$productId = $this->resolvePositiveInt($request, ['id', 'product_id']);
|
||||||
|
if (!$this->validateCsrf($request, $productId)) {
|
||||||
|
return Response::redirect($this->linksPagePath($productId));
|
||||||
|
}
|
||||||
|
|
||||||
|
$mapId = $this->resolvePositiveInt($request, ['mapId', 'map_id']);
|
||||||
|
$userId = $this->resolveUserId();
|
||||||
|
$result = $this->service->unlink($productId, $mapId, $userId);
|
||||||
|
|
||||||
|
if (($result['ok'] ?? false) !== true) {
|
||||||
|
Flash::set('product_links_error', (string) ($result['message'] ?? $this->translator->get('products.links.flash.unlink_failed')));
|
||||||
|
return Response::redirect($this->linksPagePath($productId));
|
||||||
|
}
|
||||||
|
|
||||||
|
Flash::set('product_links_success', $this->translator->get('products.links.flash.unlinked'));
|
||||||
|
return Response::redirect($this->linksPagePath($productId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateCsrf(Request $request, int $productId): bool
|
||||||
|
{
|
||||||
|
$csrfToken = (string) $request->input('_token', '');
|
||||||
|
if (Csrf::validate($csrfToken)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Flash::set('product_links_error', $this->translator->get('auth.errors.csrf_expired'));
|
||||||
|
if ($productId > 0) {
|
||||||
|
Flash::set('products_error', $this->translator->get('auth.errors.csrf_expired'));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveUserId(): ?int
|
||||||
|
{
|
||||||
|
$user = $this->auth->user();
|
||||||
|
if (!is_array($user)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = (int) ($user['id'] ?? 0);
|
||||||
|
return $id > 0 ? $id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nullableText(string $value): ?string
|
||||||
|
{
|
||||||
|
$trimmed = trim($value);
|
||||||
|
return $trimmed === '' ? null : $trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $keys
|
||||||
|
*/
|
||||||
|
private function resolvePositiveInt(Request $request, array $keys): int
|
||||||
|
{
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
$value = max(0, (int) $request->input($key, 0));
|
||||||
|
if ($value > 0) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function linksPagePath(int $productId): string
|
||||||
|
{
|
||||||
|
if ($productId > 0) {
|
||||||
|
return '/products/' . $productId . '/links';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '/products/links';
|
||||||
|
}
|
||||||
|
}
|
||||||
406
src/Modules/ProductLinks/ProductLinksRepository.php
Normal file
406
src/Modules/ProductLinks/ProductLinksRepository.php
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\ProductLinks;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
final class ProductLinksRepository
|
||||||
|
{
|
||||||
|
public function __construct(private readonly PDO $pdo)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function listByProductId(int $productId): array
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'SELECT pcm.id, pcm.product_id, pcm.channel_id, pcm.integration_id,
|
||||||
|
pcm.external_product_id, pcm.external_variant_id, pcm.sync_state,
|
||||||
|
pcm.link_type, pcm.link_status, pcm.confidence,
|
||||||
|
pcm.linked_at, pcm.linked_by_user_id, pcm.unlinked_at, pcm.unlinked_by_user_id,
|
||||||
|
pcm.sync_meta_json, pcm.last_sync_at, pcm.created_at, pcm.updated_at,
|
||||||
|
sc.code AS channel_code, sc.name AS channel_name,
|
||||||
|
i.name AS integration_name
|
||||||
|
FROM product_channel_map pcm
|
||||||
|
INNER JOIN sales_channels sc ON sc.id = pcm.channel_id
|
||||||
|
LEFT JOIN integrations i ON i.id = pcm.integration_id
|
||||||
|
WHERE pcm.product_id = :product_id
|
||||||
|
ORDER BY pcm.id DESC'
|
||||||
|
);
|
||||||
|
$statement->execute(['product_id' => $productId]);
|
||||||
|
$rows = $statement->fetchAll();
|
||||||
|
|
||||||
|
if (!is_array($rows)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_map([$this, 'mapLinkRow'], $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findById(int $id): ?array
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'SELECT pcm.id, pcm.product_id, pcm.channel_id, pcm.integration_id,
|
||||||
|
pcm.external_product_id, pcm.external_variant_id, pcm.sync_state,
|
||||||
|
pcm.link_type, pcm.link_status, pcm.confidence,
|
||||||
|
pcm.linked_at, pcm.linked_by_user_id, pcm.unlinked_at, pcm.unlinked_by_user_id,
|
||||||
|
pcm.sync_meta_json, pcm.last_sync_at, pcm.created_at, pcm.updated_at,
|
||||||
|
sc.code AS channel_code, sc.name AS channel_name,
|
||||||
|
i.name AS integration_name
|
||||||
|
FROM product_channel_map pcm
|
||||||
|
INNER JOIN sales_channels sc ON sc.id = pcm.channel_id
|
||||||
|
LEFT JOIN integrations i ON i.id = pcm.integration_id
|
||||||
|
WHERE pcm.id = :id
|
||||||
|
LIMIT 1'
|
||||||
|
);
|
||||||
|
$statement->execute(['id' => $id]);
|
||||||
|
|
||||||
|
$row = $statement->fetch();
|
||||||
|
if (!is_array($row)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->mapLinkRow($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findByProductAndIdentity(
|
||||||
|
int $productId,
|
||||||
|
int $integrationId,
|
||||||
|
string $externalProductId,
|
||||||
|
?string $externalVariantId
|
||||||
|
): ?array {
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'SELECT pcm.id, pcm.product_id, pcm.channel_id, pcm.integration_id,
|
||||||
|
pcm.external_product_id, pcm.external_variant_id, pcm.sync_state,
|
||||||
|
pcm.link_type, pcm.link_status, pcm.confidence,
|
||||||
|
pcm.linked_at, pcm.linked_by_user_id, pcm.unlinked_at, pcm.unlinked_by_user_id,
|
||||||
|
pcm.sync_meta_json, pcm.last_sync_at, pcm.created_at, pcm.updated_at,
|
||||||
|
sc.code AS channel_code, sc.name AS channel_name,
|
||||||
|
i.name AS integration_name
|
||||||
|
FROM product_channel_map pcm
|
||||||
|
INNER JOIN sales_channels sc ON sc.id = pcm.channel_id
|
||||||
|
LEFT JOIN integrations i ON i.id = pcm.integration_id
|
||||||
|
WHERE pcm.product_id = :product_id
|
||||||
|
AND pcm.integration_id = :integration_id
|
||||||
|
AND pcm.external_product_id = :external_product_id
|
||||||
|
AND (
|
||||||
|
(:external_variant_id_value IS NULL AND pcm.external_variant_id IS NULL)
|
||||||
|
OR pcm.external_variant_id = :external_variant_id_match
|
||||||
|
)
|
||||||
|
ORDER BY pcm.id DESC
|
||||||
|
LIMIT 1'
|
||||||
|
);
|
||||||
|
$statement->execute([
|
||||||
|
'product_id' => $productId,
|
||||||
|
'integration_id' => $integrationId,
|
||||||
|
'external_product_id' => trim($externalProductId),
|
||||||
|
'external_variant_id_value' => $this->normalizeNullableText($externalVariantId),
|
||||||
|
'external_variant_id_match' => $this->normalizeNullableText($externalVariantId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$row = $statement->fetch();
|
||||||
|
if (!is_array($row)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->mapLinkRow($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function insertLink(
|
||||||
|
int $productId,
|
||||||
|
int $channelId,
|
||||||
|
?int $integrationId,
|
||||||
|
string $externalProductId,
|
||||||
|
?string $externalVariantId,
|
||||||
|
string $syncState,
|
||||||
|
string $linkType,
|
||||||
|
string $linkStatus,
|
||||||
|
?int $confidence,
|
||||||
|
?int $linkedByUserId,
|
||||||
|
?string $syncMetaJson
|
||||||
|
): int {
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'INSERT INTO product_channel_map (
|
||||||
|
product_id, channel_id, integration_id, external_product_id, external_variant_id,
|
||||||
|
sync_state, link_type, link_status, confidence,
|
||||||
|
linked_at, linked_by_user_id, sync_meta_json, last_sync_at, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
:product_id, :channel_id, :integration_id, :external_product_id, :external_variant_id,
|
||||||
|
:sync_state, :link_type, :link_status, :confidence,
|
||||||
|
:linked_at, :linked_by_user_id, :sync_meta_json, :last_sync_at, :created_at, :updated_at
|
||||||
|
)'
|
||||||
|
);
|
||||||
|
$statement->execute([
|
||||||
|
'product_id' => $productId,
|
||||||
|
'channel_id' => $channelId,
|
||||||
|
'integration_id' => $integrationId,
|
||||||
|
'external_product_id' => trim($externalProductId),
|
||||||
|
'external_variant_id' => $this->normalizeNullableText($externalVariantId),
|
||||||
|
'sync_state' => trim($syncState),
|
||||||
|
'link_type' => trim($linkType),
|
||||||
|
'link_status' => trim($linkStatus),
|
||||||
|
'confidence' => $confidence,
|
||||||
|
'linked_at' => $now,
|
||||||
|
'linked_by_user_id' => $linkedByUserId,
|
||||||
|
'sync_meta_json' => $this->normalizeJson($syncMetaJson),
|
||||||
|
'last_sync_at' => $now,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (int) $this->pdo->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateLink(
|
||||||
|
int $mapId,
|
||||||
|
?int $integrationId,
|
||||||
|
string $externalProductId,
|
||||||
|
?string $externalVariantId,
|
||||||
|
string $syncState,
|
||||||
|
string $linkType,
|
||||||
|
string $linkStatus,
|
||||||
|
?int $confidence,
|
||||||
|
?int $linkedByUserId,
|
||||||
|
?string $syncMetaJson
|
||||||
|
): void {
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'UPDATE product_channel_map SET
|
||||||
|
integration_id = :integration_id,
|
||||||
|
external_product_id = :external_product_id,
|
||||||
|
external_variant_id = :external_variant_id,
|
||||||
|
sync_state = :sync_state,
|
||||||
|
link_type = :link_type,
|
||||||
|
link_status = :link_status,
|
||||||
|
confidence = :confidence,
|
||||||
|
linked_at = :linked_at,
|
||||||
|
linked_by_user_id = :linked_by_user_id,
|
||||||
|
unlinked_at = NULL,
|
||||||
|
unlinked_by_user_id = NULL,
|
||||||
|
sync_meta_json = :sync_meta_json,
|
||||||
|
last_sync_at = :last_sync_at,
|
||||||
|
updated_at = :updated_at
|
||||||
|
WHERE id = :id'
|
||||||
|
);
|
||||||
|
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$statement->execute([
|
||||||
|
'id' => $mapId,
|
||||||
|
'integration_id' => $integrationId,
|
||||||
|
'external_product_id' => trim($externalProductId),
|
||||||
|
'external_variant_id' => $this->normalizeNullableText($externalVariantId),
|
||||||
|
'sync_state' => trim($syncState),
|
||||||
|
'link_type' => trim($linkType),
|
||||||
|
'link_status' => trim($linkStatus),
|
||||||
|
'confidence' => $confidence,
|
||||||
|
'linked_at' => $now,
|
||||||
|
'linked_by_user_id' => $linkedByUserId,
|
||||||
|
'sync_meta_json' => $this->normalizeJson($syncMetaJson),
|
||||||
|
'last_sync_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markAsUnlinked(
|
||||||
|
int $mapId,
|
||||||
|
?int $userId,
|
||||||
|
string $linkStatus = 'inactive',
|
||||||
|
string $syncState = 'unlinked'
|
||||||
|
): void
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'UPDATE product_channel_map SET
|
||||||
|
sync_state = :sync_state,
|
||||||
|
link_status = :link_status,
|
||||||
|
unlinked_at = :unlinked_at,
|
||||||
|
unlinked_by_user_id = :unlinked_by_user_id,
|
||||||
|
updated_at = :updated_at
|
||||||
|
WHERE id = :id'
|
||||||
|
);
|
||||||
|
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$statement->execute([
|
||||||
|
'id' => $mapId,
|
||||||
|
'sync_state' => trim($syncState),
|
||||||
|
'link_status' => trim($linkStatus),
|
||||||
|
'unlinked_at' => $now,
|
||||||
|
'unlinked_by_user_id' => $userId,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteById(int $id): bool
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare('DELETE FROM product_channel_map WHERE id = :id LIMIT 1');
|
||||||
|
$statement->execute(['id' => $id]);
|
||||||
|
|
||||||
|
return $statement->rowCount() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function findActiveByExternalIdentity(
|
||||||
|
int $integrationId,
|
||||||
|
string $externalProductId,
|
||||||
|
?string $externalVariantId
|
||||||
|
): array {
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'SELECT id, product_id, channel_id, integration_id, external_product_id, external_variant_id,
|
||||||
|
sync_state, link_type, link_status, confidence, linked_at, linked_by_user_id,
|
||||||
|
unlinked_at, unlinked_by_user_id, sync_meta_json, last_sync_at, created_at, updated_at
|
||||||
|
FROM product_channel_map
|
||||||
|
WHERE integration_id = :integration_id
|
||||||
|
AND external_product_id = :external_product_id
|
||||||
|
AND (
|
||||||
|
(:external_variant_id_value IS NULL AND external_variant_id IS NULL)
|
||||||
|
OR external_variant_id = :external_variant_id_match
|
||||||
|
)
|
||||||
|
AND link_status = :link_status
|
||||||
|
ORDER BY id DESC'
|
||||||
|
);
|
||||||
|
$statement->execute([
|
||||||
|
'integration_id' => $integrationId,
|
||||||
|
'external_product_id' => trim($externalProductId),
|
||||||
|
'external_variant_id_value' => $this->normalizeNullableText($externalVariantId),
|
||||||
|
'external_variant_id_match' => $this->normalizeNullableText($externalVariantId),
|
||||||
|
'link_status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rows = $statement->fetchAll();
|
||||||
|
if (!is_array($rows)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_map([$this, 'mapLinkRow'], $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logEvent(
|
||||||
|
int $productChannelMapId,
|
||||||
|
string $eventType,
|
||||||
|
?array $before,
|
||||||
|
?array $after,
|
||||||
|
?int $createdByUserId
|
||||||
|
): int {
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'INSERT INTO product_link_events (
|
||||||
|
product_channel_map_id, event_type, before_json, after_json, created_by_user_id, created_at
|
||||||
|
) VALUES (
|
||||||
|
:product_channel_map_id, :event_type, :before_json, :after_json, :created_by_user_id, :created_at
|
||||||
|
)'
|
||||||
|
);
|
||||||
|
|
||||||
|
$beforeJson = $before === null ? null : json_encode($before, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
$afterJson = $after === null ? null : json_encode($after, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
|
$statement->execute([
|
||||||
|
'product_channel_map_id' => $productChannelMapId,
|
||||||
|
'event_type' => trim($eventType),
|
||||||
|
'before_json' => $beforeJson === false ? null : $beforeJson,
|
||||||
|
'after_json' => $afterJson === false ? null : $afterJson,
|
||||||
|
'created_by_user_id' => $createdByUserId,
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (int) $this->pdo->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function listEventsByMapId(int $productChannelMapId, int $limit = 50): array
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'SELECT id, product_channel_map_id, event_type, before_json, after_json, created_by_user_id, created_at
|
||||||
|
FROM product_link_events
|
||||||
|
WHERE product_channel_map_id = :product_channel_map_id
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT :limit'
|
||||||
|
);
|
||||||
|
$statement->bindValue(':product_channel_map_id', $productChannelMapId, PDO::PARAM_INT);
|
||||||
|
$statement->bindValue(':limit', max(1, $limit), PDO::PARAM_INT);
|
||||||
|
$statement->execute();
|
||||||
|
|
||||||
|
$rows = $statement->fetchAll();
|
||||||
|
if (!is_array($rows)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_map([$this, 'mapEventRow'], $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeNullableText(?string $value): ?string
|
||||||
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$trimmed = trim($value);
|
||||||
|
return $trimmed === '' ? null : $trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeJson(?string $json): ?string
|
||||||
|
{
|
||||||
|
if ($json === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$trimmed = trim($json);
|
||||||
|
if ($trimmed === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $row
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function mapLinkRow(array $row): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => (int) ($row['id'] ?? 0),
|
||||||
|
'product_id' => (int) ($row['product_id'] ?? 0),
|
||||||
|
'channel_id' => (int) ($row['channel_id'] ?? 0),
|
||||||
|
'integration_id' => $row['integration_id'] === null ? null : (int) $row['integration_id'],
|
||||||
|
'external_product_id' => (string) ($row['external_product_id'] ?? ''),
|
||||||
|
'external_variant_id' => isset($row['external_variant_id']) ? (string) $row['external_variant_id'] : null,
|
||||||
|
'sync_state' => (string) ($row['sync_state'] ?? ''),
|
||||||
|
'link_type' => (string) ($row['link_type'] ?? 'manual'),
|
||||||
|
'link_status' => (string) ($row['link_status'] ?? 'active'),
|
||||||
|
'confidence' => $row['confidence'] === null ? null : (int) $row['confidence'],
|
||||||
|
'linked_at' => isset($row['linked_at']) ? (string) $row['linked_at'] : null,
|
||||||
|
'linked_by_user_id' => $row['linked_by_user_id'] === null ? null : (int) $row['linked_by_user_id'],
|
||||||
|
'unlinked_at' => isset($row['unlinked_at']) ? (string) $row['unlinked_at'] : null,
|
||||||
|
'unlinked_by_user_id' => $row['unlinked_by_user_id'] === null ? null : (int) $row['unlinked_by_user_id'],
|
||||||
|
'sync_meta_json' => isset($row['sync_meta_json']) ? (string) $row['sync_meta_json'] : null,
|
||||||
|
'last_sync_at' => isset($row['last_sync_at']) ? (string) $row['last_sync_at'] : null,
|
||||||
|
'channel_code' => isset($row['channel_code']) ? (string) $row['channel_code'] : '',
|
||||||
|
'channel_name' => isset($row['channel_name']) ? (string) $row['channel_name'] : '',
|
||||||
|
'integration_name' => isset($row['integration_name']) ? (string) $row['integration_name'] : '',
|
||||||
|
'created_at' => (string) ($row['created_at'] ?? ''),
|
||||||
|
'updated_at' => (string) ($row['updated_at'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $row
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function mapEventRow(array $row): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => (int) ($row['id'] ?? 0),
|
||||||
|
'product_channel_map_id' => (int) ($row['product_channel_map_id'] ?? 0),
|
||||||
|
'event_type' => (string) ($row['event_type'] ?? ''),
|
||||||
|
'before_json' => isset($row['before_json']) ? (string) $row['before_json'] : null,
|
||||||
|
'after_json' => isset($row['after_json']) ? (string) $row['after_json'] : null,
|
||||||
|
'created_by_user_id' => $row['created_by_user_id'] === null ? null : (int) $row['created_by_user_id'],
|
||||||
|
'created_at' => (string) ($row['created_at'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
455
src/Modules/ProductLinks/ProductLinksService.php
Normal file
455
src/Modules/ProductLinks/ProductLinksService.php
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\ProductLinks;
|
||||||
|
|
||||||
|
use App\Modules\Settings\IntegrationRepository;
|
||||||
|
use PDO;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class ProductLinksService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ProductLinksRepository $links,
|
||||||
|
private readonly ChannelOffersRepository $offers,
|
||||||
|
private readonly IntegrationRepository $integrations,
|
||||||
|
private readonly LinkMatcherService $matcher,
|
||||||
|
private readonly PDO $pdo
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $product
|
||||||
|
* @return array{
|
||||||
|
* links:array<int, array<string, mixed>>,
|
||||||
|
* link_events_by_map:array<int, array<int, array<string, mixed>>>,
|
||||||
|
* integrations:array<int, array<string, mixed>>,
|
||||||
|
* selected_integration_id:int,
|
||||||
|
* search_query:string,
|
||||||
|
* offers:array<int, array<string, mixed>>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function buildProductLinksViewData(
|
||||||
|
int $productId,
|
||||||
|
array $product,
|
||||||
|
int $selectedIntegrationId,
|
||||||
|
string $searchQuery
|
||||||
|
): array {
|
||||||
|
$links = array_values(array_filter(
|
||||||
|
$this->links->listByProductId($productId),
|
||||||
|
static function (array $link): bool {
|
||||||
|
$integrationId = (int) ($link['integration_id'] ?? 0);
|
||||||
|
return $integrationId > 0;
|
||||||
|
}
|
||||||
|
));
|
||||||
|
$linkEventsByMap = [];
|
||||||
|
foreach ($links as $link) {
|
||||||
|
$mapId = (int) ($link['id'] ?? 0);
|
||||||
|
if ($mapId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$linkEventsByMap[$mapId] = $this->links->listEventsByMapId($mapId, 10);
|
||||||
|
}
|
||||||
|
$integrations = array_values(array_filter(
|
||||||
|
$this->integrations->listByType('shoppro'),
|
||||||
|
static fn (array $row): bool => ($row['is_active'] ?? false) === true
|
||||||
|
&& ($row['has_api_key'] ?? false) === true
|
||||||
|
));
|
||||||
|
|
||||||
|
$effectiveIntegrationId = $selectedIntegrationId;
|
||||||
|
if ($effectiveIntegrationId <= 0 && $integrations !== []) {
|
||||||
|
$effectiveIntegrationId = (int) ($integrations[0]['id'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = trim($searchQuery);
|
||||||
|
$offers = [];
|
||||||
|
if ($effectiveIntegrationId > 0) {
|
||||||
|
if ($query !== '') {
|
||||||
|
$offers = $this->offers->search($effectiveIntegrationId, null, $query, 30);
|
||||||
|
} else {
|
||||||
|
$offers = $this->offerSuggestions($effectiveIntegrationId, $product);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'links' => $links,
|
||||||
|
'link_events_by_map' => $linkEventsByMap,
|
||||||
|
'integrations' => $integrations,
|
||||||
|
'selected_integration_id' => $effectiveIntegrationId,
|
||||||
|
'search_query' => $query,
|
||||||
|
'offers' => $this->decorateOffersWithMatchHint($offers, $product),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{ok:bool,conflict:bool,message:string}
|
||||||
|
*/
|
||||||
|
public function createManualLink(
|
||||||
|
int $productId,
|
||||||
|
int $integrationId,
|
||||||
|
string $externalProductId,
|
||||||
|
?string $externalVariantId,
|
||||||
|
?int $userId
|
||||||
|
): array {
|
||||||
|
return $this->upsertManualLink(
|
||||||
|
$productId,
|
||||||
|
null,
|
||||||
|
$integrationId,
|
||||||
|
$externalProductId,
|
||||||
|
$externalVariantId,
|
||||||
|
$userId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{ok:bool,conflict:bool,message:string}
|
||||||
|
*/
|
||||||
|
public function relink(
|
||||||
|
int $productId,
|
||||||
|
int $mapId,
|
||||||
|
int $integrationId,
|
||||||
|
string $externalProductId,
|
||||||
|
?string $externalVariantId,
|
||||||
|
?int $userId
|
||||||
|
): array {
|
||||||
|
$existingMap = $this->links->findById($mapId);
|
||||||
|
if ($existingMap === null || (int) ($existingMap['product_id'] ?? 0) !== $productId) {
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'conflict' => false,
|
||||||
|
'message' => 'Nie znaleziono wskazanego powiazania produktu.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->upsertManualLink(
|
||||||
|
$productId,
|
||||||
|
$mapId,
|
||||||
|
$integrationId,
|
||||||
|
$externalProductId,
|
||||||
|
$externalVariantId,
|
||||||
|
$userId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{ok:bool,message:string}
|
||||||
|
*/
|
||||||
|
public function unlink(int $productId, int $mapId, ?int $userId): array
|
||||||
|
{
|
||||||
|
$existingMap = $this->links->findById($mapId);
|
||||||
|
if ($existingMap === null || (int) ($existingMap['product_id'] ?? 0) !== $productId) {
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'message' => 'Nie znaleziono wskazanego powiazania produktu.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->pdo->beginTransaction();
|
||||||
|
$this->links->markAsUnlinked($mapId, $userId, 'inactive', 'unlinked');
|
||||||
|
$after = $this->links->findById($mapId);
|
||||||
|
if ($after === null) {
|
||||||
|
throw new \RuntimeException('Nie udalo sie odlaczyc wskazanego powiazania.');
|
||||||
|
}
|
||||||
|
$this->links->logEvent($mapId, 'unlinked', $existingMap, $after, $userId);
|
||||||
|
if ($this->pdo->inTransaction()) {
|
||||||
|
$this->pdo->commit();
|
||||||
|
}
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
if ($this->pdo->inTransaction()) {
|
||||||
|
$this->pdo->rollBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'message' => $exception->getMessage(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => true,
|
||||||
|
'message' => '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{ok:bool,conflict:bool,message:string}
|
||||||
|
*/
|
||||||
|
private function upsertManualLink(
|
||||||
|
int $productId,
|
||||||
|
?int $mapId,
|
||||||
|
int $integrationId,
|
||||||
|
string $externalProductId,
|
||||||
|
?string $externalVariantId,
|
||||||
|
?int $userId
|
||||||
|
): array {
|
||||||
|
$normalizedExternalProductId = trim($externalProductId);
|
||||||
|
$normalizedExternalVariantId = $this->nullableText($externalVariantId);
|
||||||
|
|
||||||
|
if ($integrationId <= 0) {
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'conflict' => false,
|
||||||
|
'message' => 'Wybierz poprawna integracje.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if ($normalizedExternalProductId === '') {
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'conflict' => false,
|
||||||
|
'message' => 'Podaj poprawne external_product_id.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$offer = $this->offers->findByExternalIdentity(
|
||||||
|
$integrationId,
|
||||||
|
$normalizedExternalProductId,
|
||||||
|
$normalizedExternalVariantId
|
||||||
|
);
|
||||||
|
if ($offer === null) {
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'conflict' => false,
|
||||||
|
'message' => 'Nie znaleziono oferty w lokalnym cache. Najpierw wykonaj import ofert.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$channelId = (int) ($offer['channel_id'] ?? 0);
|
||||||
|
if ($channelId <= 0) {
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'conflict' => false,
|
||||||
|
'message' => 'Oferta ma niepoprawny channel_id.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$syncMetaJson = json_encode([
|
||||||
|
'offer_name' => (string) ($offer['name'] ?? ''),
|
||||||
|
'external_offer_id' => $offer['external_offer_id'] ?? null,
|
||||||
|
'offer_status' => (string) ($offer['offer_status'] ?? ''),
|
||||||
|
'source' => 'channel_offers',
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
if ($syncMetaJson === false) {
|
||||||
|
$syncMetaJson = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$activeMatches = $this->links->findActiveByExternalIdentity(
|
||||||
|
$integrationId,
|
||||||
|
$normalizedExternalProductId,
|
||||||
|
$normalizedExternalVariantId
|
||||||
|
);
|
||||||
|
$conflictingActive = null;
|
||||||
|
foreach ($activeMatches as $match) {
|
||||||
|
if ($mapId !== null && (int) ($match['id'] ?? 0) === $mapId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ((int) ($match['product_id'] ?? 0) !== $productId) {
|
||||||
|
$conflictingActive = $match;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetMap = $mapId === null
|
||||||
|
? $this->links->findByProductAndIdentity(
|
||||||
|
$productId,
|
||||||
|
$integrationId,
|
||||||
|
$normalizedExternalProductId,
|
||||||
|
$normalizedExternalVariantId
|
||||||
|
)
|
||||||
|
: $this->links->findById($mapId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->pdo->beginTransaction();
|
||||||
|
|
||||||
|
if ($conflictingActive !== null) {
|
||||||
|
$targetMapId = $targetMap === null
|
||||||
|
? $this->links->insertLink(
|
||||||
|
$productId,
|
||||||
|
$channelId,
|
||||||
|
$integrationId,
|
||||||
|
$normalizedExternalProductId,
|
||||||
|
$normalizedExternalVariantId,
|
||||||
|
'conflict',
|
||||||
|
'manual',
|
||||||
|
'conflict',
|
||||||
|
null,
|
||||||
|
$userId,
|
||||||
|
$syncMetaJson
|
||||||
|
)
|
||||||
|
: (int) ($targetMap['id'] ?? 0);
|
||||||
|
|
||||||
|
if ($targetMap !== null) {
|
||||||
|
$this->links->updateLink(
|
||||||
|
$targetMapId,
|
||||||
|
$integrationId,
|
||||||
|
$normalizedExternalProductId,
|
||||||
|
$normalizedExternalVariantId,
|
||||||
|
'conflict',
|
||||||
|
'manual',
|
||||||
|
'conflict',
|
||||||
|
null,
|
||||||
|
$userId,
|
||||||
|
$syncMetaJson
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$afterConflict = $this->links->findById($targetMapId);
|
||||||
|
$this->links->logEvent(
|
||||||
|
$targetMapId,
|
||||||
|
'conflict_detected',
|
||||||
|
$targetMap,
|
||||||
|
$afterConflict,
|
||||||
|
$userId
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($this->pdo->inTransaction()) {
|
||||||
|
$this->pdo->commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'conflict' => true,
|
||||||
|
'message' => 'Wykryto konflikt: oferta jest aktywnie powiazana z innym produktem.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($targetMap === null) {
|
||||||
|
$targetMapId = $this->links->insertLink(
|
||||||
|
$productId,
|
||||||
|
$channelId,
|
||||||
|
$integrationId,
|
||||||
|
$normalizedExternalProductId,
|
||||||
|
$normalizedExternalVariantId,
|
||||||
|
'linked',
|
||||||
|
'manual',
|
||||||
|
'active',
|
||||||
|
null,
|
||||||
|
$userId,
|
||||||
|
$syncMetaJson
|
||||||
|
);
|
||||||
|
|
||||||
|
$after = $this->links->findById($targetMapId);
|
||||||
|
$this->links->logEvent($targetMapId, 'linked', null, $after, $userId);
|
||||||
|
} else {
|
||||||
|
$targetMapId = (int) ($targetMap['id'] ?? 0);
|
||||||
|
$this->links->updateLink(
|
||||||
|
$targetMapId,
|
||||||
|
$integrationId,
|
||||||
|
$normalizedExternalProductId,
|
||||||
|
$normalizedExternalVariantId,
|
||||||
|
'linked',
|
||||||
|
'manual',
|
||||||
|
'active',
|
||||||
|
null,
|
||||||
|
$userId,
|
||||||
|
$syncMetaJson
|
||||||
|
);
|
||||||
|
|
||||||
|
$after = $this->links->findById($targetMapId);
|
||||||
|
$eventType = $mapId === null ? 'linked' : 'relinked';
|
||||||
|
$this->links->logEvent($targetMapId, $eventType, $targetMap, $after, $userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->pdo->inTransaction()) {
|
||||||
|
$this->pdo->commit();
|
||||||
|
}
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
if ($this->pdo->inTransaction()) {
|
||||||
|
$this->pdo->rollBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'conflict' => false,
|
||||||
|
'message' => $exception->getMessage(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => true,
|
||||||
|
'conflict' => false,
|
||||||
|
'message' => '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $product
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function offerSuggestions(int $integrationId, array $product): array
|
||||||
|
{
|
||||||
|
$sku = trim((string) ($product['sku'] ?? ''));
|
||||||
|
$ean = trim((string) ($product['ean'] ?? ''));
|
||||||
|
$combined = [];
|
||||||
|
|
||||||
|
if ($ean !== '') {
|
||||||
|
foreach ($this->offers->search($integrationId, null, $ean, 15) as $row) {
|
||||||
|
$combined[$this->offerKey($row)] = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($sku !== '') {
|
||||||
|
foreach ($this->offers->search($integrationId, null, $sku, 15) as $row) {
|
||||||
|
$combined[$this->offerKey($row)] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedSku = preg_replace('/[\s\-_]+/u', '', $sku) ?? '';
|
||||||
|
if ($normalizedSku !== '' && $normalizedSku !== $sku) {
|
||||||
|
foreach ($this->offers->search($integrationId, null, $normalizedSku, 15) as $row) {
|
||||||
|
$combined[$this->offerKey($row)] = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$offers = $this->decorateOffersWithMatchHint(array_values($combined), $product);
|
||||||
|
usort($offers, static function (array $a, array $b): int {
|
||||||
|
$left = (int) ($a['match_confidence'] ?? 0);
|
||||||
|
$right = (int) ($b['match_confidence'] ?? 0);
|
||||||
|
if ($left === $right) {
|
||||||
|
return strcmp((string) ($b['last_seen_at'] ?? ''), (string) ($a['last_seen_at'] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $right <=> $left;
|
||||||
|
});
|
||||||
|
|
||||||
|
return $offers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $offers
|
||||||
|
* @param array<string, mixed> $product
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function decorateOffersWithMatchHint(array $offers, array $product): array
|
||||||
|
{
|
||||||
|
return array_map(function (array $offer) use ($product): array {
|
||||||
|
$match = $this->matcher->match($product, $offer);
|
||||||
|
$offer['match_hint'] = (string) ($match['match_hint'] ?? '');
|
||||||
|
$offer['match_confidence'] = (int) ($match['confidence'] ?? 0);
|
||||||
|
$offer['suggested_link_type'] = (string) ($match['link_type'] ?? 'manual');
|
||||||
|
return $offer;
|
||||||
|
}, $offers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $offer
|
||||||
|
*/
|
||||||
|
private function offerKey(array $offer): string
|
||||||
|
{
|
||||||
|
return (string) ($offer['integration_id'] ?? 0)
|
||||||
|
. '|'
|
||||||
|
. (string) ($offer['external_product_id'] ?? '')
|
||||||
|
. '|'
|
||||||
|
. (string) ($offer['external_variant_id'] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nullableText(?string $value): ?string
|
||||||
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$trimmed = trim($value);
|
||||||
|
return $trimmed === '' ? null : $trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
628
src/Modules/Products/ProductRepository.php
Normal file
628
src/Modules/Products/ProductRepository.php
Normal file
@@ -0,0 +1,628 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Products;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
final class ProductRepository
|
||||||
|
{
|
||||||
|
public function __construct(private readonly PDO $pdo)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $filters
|
||||||
|
* @return array{items:array<int, array<string, mixed>>, total:int, page:int, per_page:int}
|
||||||
|
*/
|
||||||
|
public function paginate(array $filters, string $lang = 'pl'): array
|
||||||
|
{
|
||||||
|
$page = max(1, (int) ($filters['page'] ?? 1));
|
||||||
|
$perPage = max(1, min(100, (int) ($filters['per_page'] ?? 20)));
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
|
||||||
|
[$whereSql, $params] = $this->buildFilters($filters);
|
||||||
|
$sort = $this->resolveSort((string) ($filters['sort'] ?? 'id'));
|
||||||
|
$sortDir = strtoupper((string) ($filters['sort_dir'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC';
|
||||||
|
|
||||||
|
$countStmt = $this->pdo->prepare(
|
||||||
|
'SELECT COUNT(*)
|
||||||
|
FROM products p
|
||||||
|
LEFT JOIN product_translations pt ON pt.product_id = p.id AND pt.lang = :lang_count
|
||||||
|
' . $whereSql
|
||||||
|
);
|
||||||
|
$countStmt->execute(array_merge(['lang_count' => $lang], $params));
|
||||||
|
$total = (int) $countStmt->fetchColumn();
|
||||||
|
|
||||||
|
$listStmt = $this->pdo->prepare(
|
||||||
|
'SELECT p.id, p.type, p.sku, p.ean, p.status, p.promoted, p.price_brutto, p.quantity, p.updated_at,
|
||||||
|
COALESCE(pt.name, "") AS name,
|
||||||
|
(
|
||||||
|
SELECT pi.storage_path
|
||||||
|
FROM product_images pi
|
||||||
|
WHERE pi.product_id = p.id
|
||||||
|
ORDER BY pi.is_main DESC, pi.sort_order ASC, pi.id ASC
|
||||||
|
LIMIT 1
|
||||||
|
) AS main_image_path
|
||||||
|
FROM products p
|
||||||
|
LEFT JOIN product_translations pt ON pt.product_id = p.id AND pt.lang = :lang_list
|
||||||
|
' . $whereSql . '
|
||||||
|
ORDER BY ' . $sort . ' ' . $sortDir . '
|
||||||
|
LIMIT :limit OFFSET :offset'
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach (array_merge(['lang_list' => $lang], $params) as $key => $value) {
|
||||||
|
$listStmt->bindValue(':' . $key, $value);
|
||||||
|
}
|
||||||
|
$listStmt->bindValue(':limit', $perPage, PDO::PARAM_INT);
|
||||||
|
$listStmt->bindValue(':offset', $offset, PDO::PARAM_INT);
|
||||||
|
$listStmt->execute();
|
||||||
|
|
||||||
|
$rows = $listStmt->fetchAll();
|
||||||
|
if (!is_array($rows)) {
|
||||||
|
$rows = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'items' => array_map([$this, 'mapListRow'], $rows),
|
||||||
|
'total' => $total,
|
||||||
|
'page' => $page,
|
||||||
|
'per_page' => $perPage,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public function findById(int $id, string $lang = 'pl'): ?array
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->prepare(
|
||||||
|
'SELECT p.*, pt.name, pt.short_description, pt.description, pt.meta_title,
|
||||||
|
pt.meta_description, pt.meta_keywords, pt.seo_link
|
||||||
|
FROM products p
|
||||||
|
LEFT JOIN product_translations pt ON pt.product_id = p.id AND pt.lang = :lang
|
||||||
|
WHERE p.id = :id
|
||||||
|
LIMIT 1'
|
||||||
|
);
|
||||||
|
$stmt->execute([
|
||||||
|
'id' => $id,
|
||||||
|
'lang' => $lang,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
if (!is_array($row)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->mapDetailsRow($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function existsSku(string $sku, ?int $excludeProductId = null): bool
|
||||||
|
{
|
||||||
|
$normalized = trim($sku);
|
||||||
|
if ($normalized === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = 'SELECT 1 FROM products WHERE sku = :sku';
|
||||||
|
$params = ['sku' => $normalized];
|
||||||
|
|
||||||
|
if ($excludeProductId !== null) {
|
||||||
|
$sql .= ' AND id <> :exclude_id';
|
||||||
|
$params['exclude_id'] = $excludeProductId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= ' LIMIT 1';
|
||||||
|
|
||||||
|
$stmt = $this->pdo->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
|
||||||
|
return $stmt->fetchColumn() !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findIdBySku(string $sku): ?int
|
||||||
|
{
|
||||||
|
$normalized = trim($sku);
|
||||||
|
if ($normalized === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $this->pdo->prepare('SELECT id FROM products WHERE sku = :sku LIMIT 1');
|
||||||
|
$stmt->execute(['sku' => $normalized]);
|
||||||
|
$value = $stmt->fetchColumn();
|
||||||
|
|
||||||
|
return $value === false ? null : (int) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findIdByEan(string $ean): ?int
|
||||||
|
{
|
||||||
|
$normalized = trim($ean);
|
||||||
|
if ($normalized === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $this->pdo->prepare('SELECT id FROM products WHERE ean = :ean LIMIT 1');
|
||||||
|
$stmt->execute(['ean' => $normalized]);
|
||||||
|
$value = $stmt->fetchColumn();
|
||||||
|
|
||||||
|
return $value === false ? null : (int) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @param array<string, mixed> $translation
|
||||||
|
*/
|
||||||
|
public function create(array $payload, array $translation): int
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->prepare(
|
||||||
|
'INSERT INTO products (
|
||||||
|
uuid, type, sku, ean, status, promoted, vat, weight,
|
||||||
|
price_brutto, price_brutto_promo, price_netto, price_netto_promo,
|
||||||
|
quantity, producer_id, product_unit_id, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
:uuid, :type, :sku, :ean, :status, :promoted, :vat, :weight,
|
||||||
|
:price_brutto, :price_brutto_promo, :price_netto, :price_netto_promo,
|
||||||
|
:quantity, :producer_id, :product_unit_id, :created_at, :updated_at
|
||||||
|
)'
|
||||||
|
);
|
||||||
|
$stmt->execute($payload);
|
||||||
|
|
||||||
|
$productId = (int) $this->pdo->lastInsertId();
|
||||||
|
|
||||||
|
$translationStmt = $this->pdo->prepare(
|
||||||
|
'INSERT INTO product_translations (
|
||||||
|
product_id, lang, name, short_description, description,
|
||||||
|
meta_title, meta_description, meta_keywords, seo_link, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
:product_id, :lang, :name, :short_description, :description,
|
||||||
|
:meta_title, :meta_description, :meta_keywords, :seo_link, :created_at, :updated_at
|
||||||
|
)'
|
||||||
|
);
|
||||||
|
|
||||||
|
$translationStmt->execute(array_merge(['product_id' => $productId], $translation));
|
||||||
|
|
||||||
|
return $productId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @param array<string, mixed> $translation
|
||||||
|
*/
|
||||||
|
public function update(int $id, array $payload, array $translation): void
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->prepare(
|
||||||
|
'UPDATE products SET
|
||||||
|
type = :type,
|
||||||
|
sku = :sku,
|
||||||
|
ean = :ean,
|
||||||
|
status = :status,
|
||||||
|
promoted = :promoted,
|
||||||
|
vat = :vat,
|
||||||
|
weight = :weight,
|
||||||
|
price_brutto = :price_brutto,
|
||||||
|
price_brutto_promo = :price_brutto_promo,
|
||||||
|
price_netto = :price_netto,
|
||||||
|
price_netto_promo = :price_netto_promo,
|
||||||
|
quantity = :quantity,
|
||||||
|
producer_id = :producer_id,
|
||||||
|
product_unit_id = :product_unit_id,
|
||||||
|
updated_at = :updated_at
|
||||||
|
WHERE id = :id'
|
||||||
|
);
|
||||||
|
$stmt->execute(array_merge($payload, ['id' => $id]));
|
||||||
|
|
||||||
|
$translationUpsert = $this->pdo->prepare(
|
||||||
|
'INSERT INTO product_translations (
|
||||||
|
product_id, lang, name, short_description, description,
|
||||||
|
meta_title, meta_description, meta_keywords, seo_link, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
:product_id, :lang, :name, :short_description, :description,
|
||||||
|
:meta_title, :meta_description, :meta_keywords, :seo_link, :created_at, :updated_at
|
||||||
|
) ON DUPLICATE KEY UPDATE
|
||||||
|
name = VALUES(name),
|
||||||
|
short_description = VALUES(short_description),
|
||||||
|
description = VALUES(description),
|
||||||
|
meta_title = VALUES(meta_title),
|
||||||
|
meta_description = VALUES(meta_description),
|
||||||
|
meta_keywords = VALUES(meta_keywords),
|
||||||
|
seo_link = VALUES(seo_link),
|
||||||
|
updated_at = VALUES(updated_at)'
|
||||||
|
);
|
||||||
|
$translationUpsert->execute(array_merge(['product_id' => $id], $translation));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteById(int $id): bool
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->prepare('DELETE FROM products WHERE id = :id LIMIT 1');
|
||||||
|
$stmt->execute(['id' => $id]);
|
||||||
|
|
||||||
|
return $stmt->rowCount() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function findImagesByProductId(int $productId): array
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->prepare(
|
||||||
|
'SELECT id, product_id, storage_path, alt, sort_order, is_main, created_at, updated_at
|
||||||
|
FROM product_images
|
||||||
|
WHERE product_id = :product_id
|
||||||
|
ORDER BY is_main DESC, sort_order ASC, id ASC'
|
||||||
|
);
|
||||||
|
$stmt->execute(['product_id' => $productId]);
|
||||||
|
$rows = $stmt->fetchAll();
|
||||||
|
|
||||||
|
if (!is_array($rows)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_map(static function (array $row): array {
|
||||||
|
return [
|
||||||
|
'id' => (int) ($row['id'] ?? 0),
|
||||||
|
'product_id' => (int) ($row['product_id'] ?? 0),
|
||||||
|
'storage_path' => (string) ($row['storage_path'] ?? ''),
|
||||||
|
'alt' => isset($row['alt']) ? (string) $row['alt'] : null,
|
||||||
|
'sort_order' => (int) ($row['sort_order'] ?? 0),
|
||||||
|
'is_main' => (int) ($row['is_main'] ?? 0),
|
||||||
|
'created_at' => (string) ($row['created_at'] ?? ''),
|
||||||
|
'updated_at' => (string) ($row['updated_at'] ?? ''),
|
||||||
|
];
|
||||||
|
}, $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function existsVariantSku(string $sku, ?int $excludeProductId = null): bool
|
||||||
|
{
|
||||||
|
$normalized = trim($sku);
|
||||||
|
if ($normalized === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = 'SELECT 1
|
||||||
|
FROM product_variants pv
|
||||||
|
INNER JOIN products p ON p.id = pv.product_id
|
||||||
|
WHERE pv.sku = :sku
|
||||||
|
AND p.deleted_at IS NULL';
|
||||||
|
$params = ['sku' => $normalized];
|
||||||
|
|
||||||
|
if ($excludeProductId !== null && $excludeProductId > 0) {
|
||||||
|
$sql .= ' AND pv.product_id <> :exclude_product_id';
|
||||||
|
$params['exclude_product_id'] = $excludeProductId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= ' LIMIT 1';
|
||||||
|
$stmt = $this->pdo->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
|
||||||
|
return $stmt->fetchColumn() !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function findVariantsByProductId(int $productId, string $lang = 'pl'): array
|
||||||
|
{
|
||||||
|
$variantStmt = $this->pdo->prepare(
|
||||||
|
'SELECT id, product_id, permutation_hash, sku, ean, status,
|
||||||
|
price_brutto, price_brutto_promo, price_netto, price_netto_promo, weight,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM product_variants
|
||||||
|
WHERE product_id = :product_id
|
||||||
|
ORDER BY id ASC'
|
||||||
|
);
|
||||||
|
$variantStmt->execute(['product_id' => $productId]);
|
||||||
|
$variantRows = $variantStmt->fetchAll();
|
||||||
|
if (!is_array($variantRows) || $variantRows === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$variants = [];
|
||||||
|
foreach ($variantRows as $row) {
|
||||||
|
if (!is_array($row)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = (int) ($row['id'] ?? 0);
|
||||||
|
if ($id <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$variants[$id] = [
|
||||||
|
'id' => $id,
|
||||||
|
'product_id' => (int) ($row['product_id'] ?? 0),
|
||||||
|
'permutation_hash' => (string) ($row['permutation_hash'] ?? ''),
|
||||||
|
'sku' => isset($row['sku']) ? (string) $row['sku'] : null,
|
||||||
|
'ean' => isset($row['ean']) ? (string) $row['ean'] : null,
|
||||||
|
'status' => (int) ($row['status'] ?? 0),
|
||||||
|
'price_brutto' => $row['price_brutto'] === null ? null : (float) $row['price_brutto'],
|
||||||
|
'price_brutto_promo' => $row['price_brutto_promo'] === null ? null : (float) $row['price_brutto_promo'],
|
||||||
|
'price_netto' => $row['price_netto'] === null ? null : (float) $row['price_netto'],
|
||||||
|
'price_netto_promo' => $row['price_netto_promo'] === null ? null : (float) $row['price_netto_promo'],
|
||||||
|
'weight' => $row['weight'] === null ? null : (float) $row['weight'],
|
||||||
|
'created_at' => (string) ($row['created_at'] ?? ''),
|
||||||
|
'updated_at' => (string) ($row['updated_at'] ?? ''),
|
||||||
|
'attributes' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($variants === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$attributeStmt = $this->pdo->prepare(
|
||||||
|
'SELECT pva.variant_id, pva.attribute_id, pva.value_id,
|
||||||
|
COALESCE(at.name, CONCAT("Atrybut #", pva.attribute_id)) AS attribute_name,
|
||||||
|
COALESCE(avt.name, CONCAT("Wartosc #", pva.value_id)) AS value_name
|
||||||
|
FROM product_variant_attributes pva
|
||||||
|
LEFT JOIN attribute_translations at ON at.attribute_id = pva.attribute_id AND at.lang = :lang_attr
|
||||||
|
LEFT JOIN attribute_value_translations avt ON avt.value_id = pva.value_id AND avt.lang = :lang_value
|
||||||
|
WHERE pva.variant_id IN (' . implode(',', array_map('intval', array_keys($variants))) . ')
|
||||||
|
ORDER BY pva.variant_id ASC, pva.attribute_id ASC'
|
||||||
|
);
|
||||||
|
$attributeStmt->execute([
|
||||||
|
'lang_attr' => $lang,
|
||||||
|
'lang_value' => $lang,
|
||||||
|
]);
|
||||||
|
$attributeRows = $attributeStmt->fetchAll();
|
||||||
|
if (is_array($attributeRows)) {
|
||||||
|
foreach ($attributeRows as $row) {
|
||||||
|
if (!is_array($row)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$variantId = (int) ($row['variant_id'] ?? 0);
|
||||||
|
if ($variantId <= 0 || !isset($variants[$variantId])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$variants[$variantId]['attributes'][] = [
|
||||||
|
'attribute_id' => (int) ($row['attribute_id'] ?? 0),
|
||||||
|
'value_id' => (int) ($row['value_id'] ?? 0),
|
||||||
|
'attribute_name' => (string) ($row['attribute_name'] ?? ''),
|
||||||
|
'value_name' => (string) ($row['value_name'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values($variants);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public function findLatestImportWarning(int $productId): ?array
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->prepare(
|
||||||
|
'SELECT change_type, after_json, created_at
|
||||||
|
FROM product_change_log
|
||||||
|
WHERE product_id = :product_id
|
||||||
|
AND change_type IN ("product_import_warning", "product_import_warning_clear")
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1'
|
||||||
|
);
|
||||||
|
$stmt->execute(['product_id' => $productId]);
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
if (!is_array($row)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((string) ($row['change_type'] ?? '') === 'product_import_warning_clear') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payloadRaw = $row['after_json'] ?? null;
|
||||||
|
$payload = null;
|
||||||
|
if (is_string($payloadRaw) && trim($payloadRaw) !== '') {
|
||||||
|
$decoded = json_decode($payloadRaw, true);
|
||||||
|
if (is_array($decoded)) {
|
||||||
|
$payload = $decoded;
|
||||||
|
}
|
||||||
|
} elseif (is_array($payloadRaw)) {
|
||||||
|
$payload = $payloadRaw;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_array($payload)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'created_at' => (string) ($row['created_at'] ?? ''),
|
||||||
|
'title' => (string) ($payload['title'] ?? ''),
|
||||||
|
'messages' => array_values(array_filter(
|
||||||
|
(array) ($payload['messages'] ?? []),
|
||||||
|
static fn (mixed $item): bool => is_string($item) && trim($item) !== ''
|
||||||
|
)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createImage(int $productId, string $storagePath, ?string $alt, int $sortOrder, int $isMain): int
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->prepare(
|
||||||
|
'INSERT INTO product_images (product_id, storage_path, alt, sort_order, is_main, created_at, updated_at)
|
||||||
|
VALUES (:product_id, :storage_path, :alt, :sort_order, :is_main, :created_at, :updated_at)'
|
||||||
|
);
|
||||||
|
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$stmt->execute([
|
||||||
|
'product_id' => $productId,
|
||||||
|
'storage_path' => trim($storagePath),
|
||||||
|
'alt' => $alt,
|
||||||
|
'sort_order' => $sortOrder,
|
||||||
|
'is_main' => $isMain === 1 ? 1 : 0,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (int) $this->pdo->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteImageById(int $productId, int $imageId): ?string
|
||||||
|
{
|
||||||
|
$find = $this->pdo->prepare(
|
||||||
|
'SELECT storage_path
|
||||||
|
FROM product_images
|
||||||
|
WHERE id = :id AND product_id = :product_id
|
||||||
|
LIMIT 1'
|
||||||
|
);
|
||||||
|
$find->execute(['id' => $imageId, 'product_id' => $productId]);
|
||||||
|
$path = $find->fetchColumn();
|
||||||
|
if (!is_string($path) || trim($path) === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$delete = $this->pdo->prepare(
|
||||||
|
'DELETE FROM product_images
|
||||||
|
WHERE id = :id AND product_id = :product_id
|
||||||
|
LIMIT 1'
|
||||||
|
);
|
||||||
|
$delete->execute(['id' => $imageId, 'product_id' => $productId]);
|
||||||
|
|
||||||
|
if ($delete->rowCount() <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMainImage(int $productId, int $imageId): void
|
||||||
|
{
|
||||||
|
$reset = $this->pdo->prepare('UPDATE product_images SET is_main = 0 WHERE product_id = :product_id');
|
||||||
|
$reset->execute(['product_id' => $productId]);
|
||||||
|
|
||||||
|
$setMain = $this->pdo->prepare(
|
||||||
|
'UPDATE product_images
|
||||||
|
SET is_main = 1
|
||||||
|
WHERE product_id = :product_id AND id = :image_id
|
||||||
|
LIMIT 1'
|
||||||
|
);
|
||||||
|
$setMain->execute([
|
||||||
|
'product_id' => $productId,
|
||||||
|
'image_id' => $imageId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|null $before
|
||||||
|
* @param array<string, mixed>|null $after
|
||||||
|
*/
|
||||||
|
public function logChange(int $productId, ?int $userId, string $changeType, ?array $before, ?array $after): void
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->prepare(
|
||||||
|
'INSERT INTO product_change_log (product_id, user_id, change_type, before_json, after_json, created_at)
|
||||||
|
VALUES (:product_id, :user_id, :change_type, :before_json, :after_json, :created_at)'
|
||||||
|
);
|
||||||
|
|
||||||
|
$beforeJson = $before === null ? null : json_encode($before, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
$afterJson = $after === null ? null : json_encode($after, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
'product_id' => $productId,
|
||||||
|
'user_id' => $userId,
|
||||||
|
'change_type' => $changeType,
|
||||||
|
'before_json' => $beforeJson === false ? null : $beforeJson,
|
||||||
|
'after_json' => $afterJson === false ? null : $afterJson,
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $filters
|
||||||
|
* @return array{0:string,1:array<string,mixed>}
|
||||||
|
*/
|
||||||
|
private function buildFilters(array $filters): array
|
||||||
|
{
|
||||||
|
$where = ['p.deleted_at IS NULL'];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
$search = trim((string) ($filters['search'] ?? ''));
|
||||||
|
if ($search !== '') {
|
||||||
|
$where[] = '(pt.name LIKE :search OR p.sku LIKE :search OR p.ean LIKE :search)';
|
||||||
|
$params['search'] = '%' . $search . '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = (string) ($filters['status'] ?? '');
|
||||||
|
if ($status !== '' && in_array($status, ['0', '1'], true)) {
|
||||||
|
$where[] = 'p.status = :status';
|
||||||
|
$params['status'] = (int) $status;
|
||||||
|
}
|
||||||
|
|
||||||
|
$type = (string) ($filters['type'] ?? '');
|
||||||
|
if ($type !== '' && in_array($type, ['simple', 'variant_parent'], true)) {
|
||||||
|
$where[] = 'p.type = :type';
|
||||||
|
$params['type'] = $type;
|
||||||
|
}
|
||||||
|
|
||||||
|
$whereSql = 'WHERE ' . implode(' AND ', $where);
|
||||||
|
|
||||||
|
return [$whereSql, $params];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveSort(string $sort): string
|
||||||
|
{
|
||||||
|
return match ($sort) {
|
||||||
|
'name' => 'pt.name',
|
||||||
|
'sku' => 'p.sku',
|
||||||
|
'price_brutto' => 'p.price_brutto',
|
||||||
|
'quantity' => 'p.quantity',
|
||||||
|
'status' => 'p.status',
|
||||||
|
'updated_at' => 'p.updated_at',
|
||||||
|
default => 'p.id',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $row
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function mapListRow(array $row): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => (int) ($row['id'] ?? 0),
|
||||||
|
'name' => (string) ($row['name'] ?? ''),
|
||||||
|
'main_image_path' => (string) ($row['main_image_path'] ?? ''),
|
||||||
|
'type' => (string) ($row['type'] ?? 'simple'),
|
||||||
|
'sku' => (string) ($row['sku'] ?? ''),
|
||||||
|
'ean' => (string) ($row['ean'] ?? ''),
|
||||||
|
'status' => (int) ($row['status'] ?? 0),
|
||||||
|
'promoted' => (int) ($row['promoted'] ?? 0),
|
||||||
|
'price_brutto' => (float) ($row['price_brutto'] ?? 0),
|
||||||
|
'quantity' => (float) ($row['quantity'] ?? 0),
|
||||||
|
'updated_at' => (string) ($row['updated_at'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $row
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function mapDetailsRow(array $row): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => (int) ($row['id'] ?? 0),
|
||||||
|
'uuid' => (string) ($row['uuid'] ?? ''),
|
||||||
|
'type' => (string) ($row['type'] ?? 'simple'),
|
||||||
|
'sku' => (string) ($row['sku'] ?? ''),
|
||||||
|
'ean' => (string) ($row['ean'] ?? ''),
|
||||||
|
'status' => (int) ($row['status'] ?? 1),
|
||||||
|
'promoted' => (int) ($row['promoted'] ?? 0),
|
||||||
|
'vat' => $row['vat'] === null ? null : (float) $row['vat'],
|
||||||
|
'weight' => $row['weight'] === null ? null : (float) $row['weight'],
|
||||||
|
'price_brutto' => $row['price_brutto'] === null ? null : (float) $row['price_brutto'],
|
||||||
|
'price_brutto_promo' => $row['price_brutto_promo'] === null ? null : (float) $row['price_brutto_promo'],
|
||||||
|
'price_netto' => $row['price_netto'] === null ? null : (float) $row['price_netto'],
|
||||||
|
'price_netto_promo' => $row['price_netto_promo'] === null ? null : (float) $row['price_netto_promo'],
|
||||||
|
'quantity' => (float) ($row['quantity'] ?? 0),
|
||||||
|
'producer_id' => $row['producer_id'] === null ? null : (int) $row['producer_id'],
|
||||||
|
'product_unit_id' => $row['product_unit_id'] === null ? null : (int) $row['product_unit_id'],
|
||||||
|
'name' => (string) ($row['name'] ?? ''),
|
||||||
|
'short_description' => (string) ($row['short_description'] ?? ''),
|
||||||
|
'description' => (string) ($row['description'] ?? ''),
|
||||||
|
'meta_title' => (string) ($row['meta_title'] ?? ''),
|
||||||
|
'meta_description' => (string) ($row['meta_description'] ?? ''),
|
||||||
|
'meta_keywords' => (string) ($row['meta_keywords'] ?? ''),
|
||||||
|
'seo_link' => (string) ($row['seo_link'] ?? ''),
|
||||||
|
'created_at' => (string) ($row['created_at'] ?? ''),
|
||||||
|
'updated_at' => (string) ($row['updated_at'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
440
src/Modules/Products/ProductService.php
Normal file
440
src/Modules/Products/ProductService.php
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Products;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class ProductService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly PDO $pdo,
|
||||||
|
private readonly ProductRepository $products,
|
||||||
|
private readonly ProductValidator $validator
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $input
|
||||||
|
* @param array<string, mixed>|null $actor
|
||||||
|
* @return array{ok:bool, errors:array<int, string>, id?:int}
|
||||||
|
*/
|
||||||
|
public function create(array $input, ?array $actor): array
|
||||||
|
{
|
||||||
|
$errors = $this->validator->validate($input, false);
|
||||||
|
if ($errors !== []) {
|
||||||
|
return ['ok' => false, 'errors' => $errors];
|
||||||
|
}
|
||||||
|
|
||||||
|
$sku = trim((string) ($input['sku'] ?? ''));
|
||||||
|
if ($sku !== '' && $this->products->existsSku($sku)) {
|
||||||
|
return ['ok' => false, 'errors' => ['Podane SKU produktu jest juz zajete.']];
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = $this->normalizeForSave($input);
|
||||||
|
$updatePayload = $this->toUpdatePayload($normalized['product']);
|
||||||
|
$actorId = isset($actor['id']) ? (int) $actor['id'] : null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->pdo->beginTransaction();
|
||||||
|
$productId = $this->products->create($normalized['product'], $normalized['translation']);
|
||||||
|
$this->products->logChange($productId, $actorId, 'product_created', null, $normalized['audit']);
|
||||||
|
$this->pdo->commit();
|
||||||
|
|
||||||
|
return ['ok' => true, 'errors' => [], 'id' => $productId];
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
if ($this->pdo->inTransaction()) {
|
||||||
|
$this->pdo->rollBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['ok' => false, 'errors' => ['Nie udalo sie zapisac produktu: ' . $exception->getMessage()]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $input
|
||||||
|
* @param array<string, mixed>|null $actor
|
||||||
|
* @return array{ok:bool, errors:array<int, string>}
|
||||||
|
*/
|
||||||
|
public function update(int $id, array $input, ?array $actor): array
|
||||||
|
{
|
||||||
|
$existing = $this->products->findById($id, 'pl');
|
||||||
|
if ($existing === null) {
|
||||||
|
return ['ok' => false, 'errors' => ['Produkt nie istnieje.']];
|
||||||
|
}
|
||||||
|
|
||||||
|
$errors = $this->validator->validate($input, true);
|
||||||
|
if ($errors !== []) {
|
||||||
|
return ['ok' => false, 'errors' => $errors];
|
||||||
|
}
|
||||||
|
|
||||||
|
$sku = trim((string) ($input['sku'] ?? ''));
|
||||||
|
if ($sku !== '' && $this->products->existsSku($sku, $id)) {
|
||||||
|
return ['ok' => false, 'errors' => ['Podane SKU produktu jest juz zajete.']];
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = $this->normalizeForSave($input);
|
||||||
|
$updatePayload = $this->toUpdatePayload($normalized['product']);
|
||||||
|
$actorId = isset($actor['id']) ? (int) $actor['id'] : null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->pdo->beginTransaction();
|
||||||
|
$this->products->update($id, $updatePayload, $normalized['translation']);
|
||||||
|
|
||||||
|
$criticalBefore = $this->extractCriticalFields($existing);
|
||||||
|
$criticalAfter = $this->extractCriticalFields($normalized['audit']);
|
||||||
|
if ($criticalBefore !== $criticalAfter) {
|
||||||
|
$this->products->logChange($id, $actorId, 'product_updated', $criticalBefore, $criticalAfter);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->pdo->commit();
|
||||||
|
return ['ok' => true, 'errors' => []];
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
if ($this->pdo->inTransaction()) {
|
||||||
|
$this->pdo->rollBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['ok' => false, 'errors' => ['Nie udalo sie zaktualizowac produktu: ' . $exception->getMessage()]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|null $actor
|
||||||
|
* @return array{ok:bool, errors:array<int, string>}
|
||||||
|
*/
|
||||||
|
public function delete(int $id, ?array $actor): array
|
||||||
|
{
|
||||||
|
$existing = $this->products->findById($id, 'pl');
|
||||||
|
if ($existing === null) {
|
||||||
|
return ['ok' => false, 'errors' => ['Produkt nie istnieje.']];
|
||||||
|
}
|
||||||
|
|
||||||
|
$imagePaths = $this->findProductImageStoragePaths($id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->pdo->beginTransaction();
|
||||||
|
$deleted = $this->products->deleteById($id);
|
||||||
|
if (!$deleted) {
|
||||||
|
throw new \RuntimeException('Nie udalo sie usunac produktu.');
|
||||||
|
}
|
||||||
|
$this->pdo->commit();
|
||||||
|
$this->deleteProductImageFiles($imagePaths);
|
||||||
|
|
||||||
|
return ['ok' => true, 'errors' => []];
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
if ($this->pdo->inTransaction()) {
|
||||||
|
$this->pdo->rollBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['ok' => false, 'errors' => ['Nie udalo sie usunac produktu: ' . $exception->getMessage()]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $input
|
||||||
|
* @return array{product:array<string,mixed>, translation:array<string,mixed>, audit:array<string,mixed>}
|
||||||
|
*/
|
||||||
|
private function normalizeForSave(array $input): array
|
||||||
|
{
|
||||||
|
$vatRaw = trim((string) ($input['vat'] ?? ''));
|
||||||
|
$vat = $vatRaw === '' ? null : round((float) $vatRaw, 2);
|
||||||
|
|
||||||
|
$pricePair = $this->resolvePricePair(
|
||||||
|
trim((string) ($input['price_brutto'] ?? '')),
|
||||||
|
trim((string) ($input['price_netto'] ?? '')),
|
||||||
|
$vat,
|
||||||
|
(string) ($input['price_input_mode'] ?? 'brutto')
|
||||||
|
);
|
||||||
|
|
||||||
|
$promoPair = $this->resolvePricePair(
|
||||||
|
trim((string) ($input['price_brutto_promo'] ?? '')),
|
||||||
|
trim((string) ($input['price_netto_promo'] ?? '')),
|
||||||
|
$vat,
|
||||||
|
(string) ($input['price_input_mode'] ?? 'brutto'),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
|
||||||
|
$product = [
|
||||||
|
'uuid' => $this->uuidV4(),
|
||||||
|
'type' => (string) ($input['type'] ?? 'simple'),
|
||||||
|
'sku' => $this->nullableString($input['sku'] ?? null),
|
||||||
|
'ean' => $this->nullableString($input['ean'] ?? null),
|
||||||
|
'status' => (int) ($input['status'] ?? 1),
|
||||||
|
'promoted' => (int) ($input['promoted'] ?? 0),
|
||||||
|
'vat' => $vat,
|
||||||
|
'weight' => $this->nullableFloat($input['weight'] ?? null, 3),
|
||||||
|
'price_brutto' => $pricePair['brutto'] ?? 0.00,
|
||||||
|
'price_brutto_promo' => $promoPair['brutto'],
|
||||||
|
'price_netto' => $pricePair['netto'],
|
||||||
|
'price_netto_promo' => $promoPair['netto'],
|
||||||
|
'quantity' => round((float) ($input['quantity'] ?? 0), 3),
|
||||||
|
'producer_id' => $this->nullableInt($input['producer_id'] ?? null),
|
||||||
|
'product_unit_id' => $this->nullableInt($input['product_unit_id'] ?? null),
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
];
|
||||||
|
|
||||||
|
$translation = [
|
||||||
|
'lang' => 'pl',
|
||||||
|
'name' => trim((string) ($input['name'] ?? '')),
|
||||||
|
'short_description' => $this->nullableString($input['short_description'] ?? null),
|
||||||
|
'description' => $this->nullableString($input['description'] ?? null),
|
||||||
|
'meta_title' => $this->nullableString($input['meta_title'] ?? null),
|
||||||
|
'meta_description' => $this->nullableString($input['meta_description'] ?? null),
|
||||||
|
'meta_keywords' => $this->nullableString($input['meta_keywords'] ?? null),
|
||||||
|
'seo_link' => $this->nullableString($input['seo_link'] ?? null),
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
];
|
||||||
|
|
||||||
|
$audit = [
|
||||||
|
'type' => $product['type'],
|
||||||
|
'sku' => $product['sku'],
|
||||||
|
'ean' => $product['ean'],
|
||||||
|
'status' => $product['status'],
|
||||||
|
'promoted' => $product['promoted'],
|
||||||
|
'vat' => $product['vat'],
|
||||||
|
'price_brutto' => $product['price_brutto'],
|
||||||
|
'price_netto' => $product['price_netto'],
|
||||||
|
'price_brutto_promo' => $product['price_brutto_promo'],
|
||||||
|
'price_netto_promo' => $product['price_netto_promo'],
|
||||||
|
'quantity' => $product['quantity'],
|
||||||
|
'name' => $translation['name'],
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'product' => $product,
|
||||||
|
'translation' => $translation,
|
||||||
|
'audit' => $audit,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{brutto:float|null, netto:float|null}
|
||||||
|
*/
|
||||||
|
private function resolvePricePair(
|
||||||
|
string $bruttoRaw,
|
||||||
|
string $nettoRaw,
|
||||||
|
?float $vat,
|
||||||
|
string $mode,
|
||||||
|
bool $allowEmpty = false
|
||||||
|
): array {
|
||||||
|
if ($allowEmpty && $bruttoRaw === '' && $nettoRaw === '') {
|
||||||
|
return ['brutto' => null, 'netto' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
$multiplier = 1 + (($vat ?? 0.0) / 100);
|
||||||
|
|
||||||
|
if ($mode === 'netto') {
|
||||||
|
if ($nettoRaw === '' && $bruttoRaw !== '') {
|
||||||
|
$brutto = round((float) $bruttoRaw, 2);
|
||||||
|
$netto = $multiplier > 0 ? round($brutto / $multiplier, 2) : $brutto;
|
||||||
|
return ['brutto' => $brutto, 'netto' => $netto];
|
||||||
|
}
|
||||||
|
|
||||||
|
$netto = $nettoRaw === '' ? 0.0 : round((float) $nettoRaw, 2);
|
||||||
|
$brutto = round($netto * $multiplier, 2);
|
||||||
|
return ['brutto' => $brutto, 'netto' => $netto];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($bruttoRaw === '' && $nettoRaw !== '') {
|
||||||
|
$netto = round((float) $nettoRaw, 2);
|
||||||
|
$brutto = round($netto * $multiplier, 2);
|
||||||
|
return ['brutto' => $brutto, 'netto' => $netto];
|
||||||
|
}
|
||||||
|
|
||||||
|
$brutto = $bruttoRaw === '' ? 0.0 : round((float) $bruttoRaw, 2);
|
||||||
|
$netto = $multiplier > 0 ? round($brutto / $multiplier, 2) : $brutto;
|
||||||
|
|
||||||
|
return ['brutto' => $brutto, 'netto' => $netto];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $row
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function extractCriticalFields(array $row): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'sku' => $row['sku'] ?? null,
|
||||||
|
'ean' => $row['ean'] ?? null,
|
||||||
|
'status' => $row['status'] ?? null,
|
||||||
|
'promoted' => $row['promoted'] ?? null,
|
||||||
|
'price_brutto' => $row['price_brutto'] ?? null,
|
||||||
|
'price_netto' => $row['price_netto'] ?? null,
|
||||||
|
'price_brutto_promo' => $row['price_brutto_promo'] ?? null,
|
||||||
|
'price_netto_promo' => $row['price_netto_promo'] ?? null,
|
||||||
|
'quantity' => $row['quantity'] ?? null,
|
||||||
|
'name' => $row['name'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nullableString(mixed $value): ?string
|
||||||
|
{
|
||||||
|
$text = trim((string) $value);
|
||||||
|
return $text === '' ? null : $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nullableInt(mixed $value): ?int
|
||||||
|
{
|
||||||
|
$text = trim((string) $value);
|
||||||
|
if ($text === '' || !is_numeric($text)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nullableFloat(mixed $value, int $precision = 2): ?float
|
||||||
|
{
|
||||||
|
$text = trim((string) $value);
|
||||||
|
if ($text === '' || !is_numeric($text)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return round((float) $text, $precision);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function uuidV4(): string
|
||||||
|
{
|
||||||
|
$data = random_bytes(16);
|
||||||
|
$data[6] = chr((ord($data[6]) & 0x0f) | 0x40);
|
||||||
|
$data[8] = chr((ord($data[8]) & 0x3f) | 0x80);
|
||||||
|
|
||||||
|
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $product
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function toUpdatePayload(array $product): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => $product['type'] ?? 'simple',
|
||||||
|
'sku' => $product['sku'] ?? null,
|
||||||
|
'ean' => $product['ean'] ?? null,
|
||||||
|
'status' => $product['status'] ?? 1,
|
||||||
|
'promoted' => $product['promoted'] ?? 0,
|
||||||
|
'vat' => $product['vat'] ?? null,
|
||||||
|
'weight' => $product['weight'] ?? null,
|
||||||
|
'price_brutto' => $product['price_brutto'] ?? 0,
|
||||||
|
'price_brutto_promo' => $product['price_brutto_promo'] ?? null,
|
||||||
|
'price_netto' => $product['price_netto'] ?? null,
|
||||||
|
'price_netto_promo' => $product['price_netto_promo'] ?? null,
|
||||||
|
'quantity' => $product['quantity'] ?? 0,
|
||||||
|
'producer_id' => $product['producer_id'] ?? null,
|
||||||
|
'product_unit_id' => $product['product_unit_id'] ?? null,
|
||||||
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function findProductImageStoragePaths(int $productId): array
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->prepare('SELECT storage_path FROM product_images WHERE product_id = :product_id');
|
||||||
|
$stmt->execute(['product_id' => $productId]);
|
||||||
|
$rows = $stmt->fetchAll();
|
||||||
|
if (!is_array($rows)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$paths = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if (!is_array($row)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = trim((string) ($row['storage_path'] ?? ''));
|
||||||
|
if ($path !== '') {
|
||||||
|
$paths[] = $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($paths));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $storagePaths
|
||||||
|
*/
|
||||||
|
private function deleteProductImageFiles(array $storagePaths): void
|
||||||
|
{
|
||||||
|
foreach ($storagePaths as $storagePath) {
|
||||||
|
if ($this->storagePathHasOtherReferences($storagePath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolvedFilePath = $this->resolveLocalImageFilePath($storagePath);
|
||||||
|
if ($resolvedFilePath === null || !is_file($resolvedFilePath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
@unlink($resolvedFilePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function storagePathHasOtherReferences(string $storagePath): bool
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->prepare(
|
||||||
|
'SELECT 1 FROM product_images WHERE storage_path = :storage_path LIMIT 1'
|
||||||
|
);
|
||||||
|
$stmt->execute(['storage_path' => $storagePath]);
|
||||||
|
|
||||||
|
return $stmt->fetchColumn() !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveLocalImageFilePath(string $storagePath): ?string
|
||||||
|
{
|
||||||
|
$path = trim($storagePath);
|
||||||
|
if ($path === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#^https?://#i', $path) === 1 || str_starts_with($path, '//')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$projectRoot = dirname(__DIR__, 3);
|
||||||
|
$projectRootReal = realpath($projectRoot);
|
||||||
|
if ($projectRootReal === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$trimmed = ltrim(str_replace(['\\', '/'], DIRECTORY_SEPARATOR, $path), DIRECTORY_SEPARATOR);
|
||||||
|
$candidates = [];
|
||||||
|
|
||||||
|
if ($this->isAbsolutePath($path)) {
|
||||||
|
$candidates[] = str_replace(['\\', '/'], DIRECTORY_SEPARATOR, $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidates[] = $projectRoot . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR . $trimmed;
|
||||||
|
$candidates[] = $projectRoot . DIRECTORY_SEPARATOR . $trimmed;
|
||||||
|
|
||||||
|
foreach ($candidates as $candidate) {
|
||||||
|
$real = realpath($candidate);
|
||||||
|
if ($real === false || !is_file($real)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($real === $projectRootReal || str_starts_with($real, $projectRootReal . DIRECTORY_SEPARATOR)) {
|
||||||
|
return $real;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isAbsolutePath(string $path): bool
|
||||||
|
{
|
||||||
|
if ($path === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return preg_match('/^[A-Za-z]:[\\\\\\/]/', $path) === 1 || str_starts_with($path, DIRECTORY_SEPARATOR);
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/Modules/Products/ProductValidator.php
Normal file
102
src/Modules/Products/ProductValidator.php
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Products;
|
||||||
|
|
||||||
|
final class ProductValidator
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $input
|
||||||
|
* @param bool $isUpdate
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function validate(array $input, bool $isUpdate = false): array
|
||||||
|
{
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
$name = trim((string) ($input['name'] ?? ''));
|
||||||
|
if (mb_strlen($name) < 2) {
|
||||||
|
$errors[] = 'Nazwa produktu musi miec co najmniej 2 znaki.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$type = (string) ($input['type'] ?? 'simple');
|
||||||
|
if (!in_array($type, ['simple', 'variant_parent'], true)) {
|
||||||
|
$errors[] = 'Niepoprawny typ produktu.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$sku = trim((string) ($input['sku'] ?? ''));
|
||||||
|
if ($sku !== '' && mb_strlen($sku) > 128) {
|
||||||
|
$errors[] = 'SKU produktu moze miec maksymalnie 128 znakow.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$ean = trim((string) ($input['ean'] ?? ''));
|
||||||
|
if ($ean !== '' && !preg_match('/^[0-9]{8,14}$/', $ean)) {
|
||||||
|
$errors[] = 'EAN musi zawierac od 8 do 14 cyfr.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = (string) ($input['status'] ?? '1');
|
||||||
|
if (!in_array($status, ['0', '1'], true)) {
|
||||||
|
$errors[] = 'Status produktu jest niepoprawny.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$promoted = (string) ($input['promoted'] ?? '0');
|
||||||
|
if (!in_array($promoted, ['0', '1'], true)) {
|
||||||
|
$errors[] = 'Flaga promocji jest niepoprawna.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$priceInputMode = (string) ($input['price_input_mode'] ?? 'brutto');
|
||||||
|
if (!in_array($priceInputMode, ['brutto', 'netto'], true)) {
|
||||||
|
$errors[] = 'Tryb wprowadzania ceny jest niepoprawny.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$vat = trim((string) ($input['vat'] ?? ''));
|
||||||
|
if ($vat !== '' && !is_numeric($vat)) {
|
||||||
|
$errors[] = 'Stawka VAT musi byc liczba.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($vat !== '' && is_numeric($vat)) {
|
||||||
|
$vatValue = (float) $vat;
|
||||||
|
if ($vatValue < 0 || $vatValue > 100) {
|
||||||
|
$errors[] = 'Stawka VAT musi byc w zakresie 0-100.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$quantity = trim((string) ($input['quantity'] ?? '0'));
|
||||||
|
if ($quantity === '' || !is_numeric($quantity) || (float) $quantity < 0) {
|
||||||
|
$errors[] = 'Stan magazynowy musi byc liczba >= 0.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$weight = trim((string) ($input['weight'] ?? ''));
|
||||||
|
if ($weight !== '' && (!is_numeric($weight) || (float) $weight < 0)) {
|
||||||
|
$errors[] = 'Waga musi byc liczba >= 0.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$priceBrutto = trim((string) ($input['price_brutto'] ?? ''));
|
||||||
|
$priceNetto = trim((string) ($input['price_netto'] ?? ''));
|
||||||
|
|
||||||
|
if ($priceInputMode === 'brutto') {
|
||||||
|
if ($priceBrutto === '' || !is_numeric($priceBrutto) || (float) $priceBrutto < 0) {
|
||||||
|
$errors[] = 'Cena brutto jest wymagana i musi byc liczba >= 0.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($priceInputMode === 'netto') {
|
||||||
|
if ($priceNetto === '' || !is_numeric($priceNetto) || (float) $priceNetto < 0) {
|
||||||
|
$errors[] = 'Cena netto jest wymagana i musi byc liczba >= 0.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$priceBruttoPromo = trim((string) ($input['price_brutto_promo'] ?? ''));
|
||||||
|
$priceNettoPromo = trim((string) ($input['price_netto_promo'] ?? ''));
|
||||||
|
|
||||||
|
if ($priceBruttoPromo !== '' && (!is_numeric($priceBruttoPromo) || (float) $priceBruttoPromo < 0)) {
|
||||||
|
$errors[] = 'Cena promocyjna brutto musi byc liczba >= 0.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($priceNettoPromo !== '' && (!is_numeric($priceNettoPromo) || (float) $priceNettoPromo < 0)) {
|
||||||
|
$errors[] = 'Cena promocyjna netto musi byc liczba >= 0.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $errors;
|
||||||
|
}
|
||||||
|
}
|
||||||
1011
src/Modules/Products/ProductsController.php
Normal file
1011
src/Modules/Products/ProductsController.php
Normal file
File diff suppressed because it is too large
Load Diff
505
src/Modules/Settings/IntegrationRepository.php
Normal file
505
src/Modules/Settings/IntegrationRepository.php
Normal file
@@ -0,0 +1,505 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Settings;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class IntegrationRepository
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly PDO $pdo,
|
||||||
|
private readonly string $secret
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function listByType(string $type): array
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'SELECT id, type, name, base_url, timeout_seconds, is_active,
|
||||||
|
last_test_status, last_test_http_code, last_test_message, last_test_at,
|
||||||
|
created_at, updated_at,
|
||||||
|
CASE WHEN api_key_encrypted IS NULL OR api_key_encrypted = "" THEN 0 ELSE 1 END AS has_api_key
|
||||||
|
FROM integrations
|
||||||
|
WHERE type = :type
|
||||||
|
ORDER BY id DESC'
|
||||||
|
);
|
||||||
|
$statement->execute(['type' => $type]);
|
||||||
|
|
||||||
|
$rows = $statement->fetchAll();
|
||||||
|
if (!is_array($rows)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_map([$this, 'mapRow'], $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public function findById(int $id): ?array
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'SELECT id, type, name, base_url, timeout_seconds, is_active,
|
||||||
|
last_test_status, last_test_http_code, last_test_message, last_test_at,
|
||||||
|
created_at, updated_at,
|
||||||
|
CASE WHEN api_key_encrypted IS NULL OR api_key_encrypted = "" THEN 0 ELSE 1 END AS has_api_key
|
||||||
|
FROM integrations
|
||||||
|
WHERE id = :id
|
||||||
|
LIMIT 1'
|
||||||
|
);
|
||||||
|
$statement->execute(['id' => $id]);
|
||||||
|
|
||||||
|
$row = $statement->fetch();
|
||||||
|
if (!is_array($row)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->mapRow($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public function findApiCredentials(int $id): ?array
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'SELECT id, name, base_url, timeout_seconds, api_key_encrypted
|
||||||
|
FROM integrations
|
||||||
|
WHERE id = :id
|
||||||
|
LIMIT 1'
|
||||||
|
);
|
||||||
|
$statement->execute(['id' => $id]);
|
||||||
|
|
||||||
|
$row = $statement->fetch();
|
||||||
|
if (!is_array($row)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => (int) ($row['id'] ?? 0),
|
||||||
|
'name' => (string) ($row['name'] ?? ''),
|
||||||
|
'base_url' => (string) ($row['base_url'] ?? ''),
|
||||||
|
'timeout_seconds' => (int) ($row['timeout_seconds'] ?? 10),
|
||||||
|
'api_key' => $this->decryptApiKey((string) ($row['api_key_encrypted'] ?? '')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public function findActiveApiCredentialsByType(string $type): ?array
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'SELECT id, name, base_url, timeout_seconds, api_key_encrypted
|
||||||
|
FROM integrations
|
||||||
|
WHERE type = :type AND is_active = 1
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1'
|
||||||
|
);
|
||||||
|
$statement->execute(['type' => $type]);
|
||||||
|
|
||||||
|
$row = $statement->fetch();
|
||||||
|
if (!is_array($row)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => (int) ($row['id'] ?? 0),
|
||||||
|
'name' => (string) ($row['name'] ?? ''),
|
||||||
|
'base_url' => (string) ($row['base_url'] ?? ''),
|
||||||
|
'timeout_seconds' => (int) ($row['timeout_seconds'] ?? 10),
|
||||||
|
'api_key' => $this->decryptApiKey((string) ($row['api_key_encrypted'] ?? '')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(
|
||||||
|
string $type,
|
||||||
|
string $name,
|
||||||
|
string $baseUrl,
|
||||||
|
int $timeoutSeconds,
|
||||||
|
bool $isActive,
|
||||||
|
string $apiKey
|
||||||
|
): int {
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'INSERT INTO integrations (
|
||||||
|
type, name, base_url, api_key_encrypted, timeout_seconds, is_active, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
:type, :name, :base_url, :api_key_encrypted, :timeout_seconds, :is_active, :created_at, :updated_at
|
||||||
|
)'
|
||||||
|
);
|
||||||
|
$statement->execute([
|
||||||
|
'type' => $type,
|
||||||
|
'name' => $name,
|
||||||
|
'base_url' => $baseUrl,
|
||||||
|
'api_key_encrypted' => $this->encryptApiKey($apiKey),
|
||||||
|
'timeout_seconds' => $timeoutSeconds,
|
||||||
|
'is_active' => $isActive ? 1 : 0,
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (int) $this->pdo->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(
|
||||||
|
int $id,
|
||||||
|
string $name,
|
||||||
|
string $baseUrl,
|
||||||
|
int $timeoutSeconds,
|
||||||
|
bool $isActive,
|
||||||
|
?string $apiKey
|
||||||
|
): void {
|
||||||
|
$params = [
|
||||||
|
'id' => $id,
|
||||||
|
'name' => $name,
|
||||||
|
'base_url' => $baseUrl,
|
||||||
|
'timeout_seconds' => $timeoutSeconds,
|
||||||
|
'is_active' => $isActive ? 1 : 0,
|
||||||
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$sql = 'UPDATE integrations SET
|
||||||
|
name = :name,
|
||||||
|
base_url = :base_url,
|
||||||
|
timeout_seconds = :timeout_seconds,
|
||||||
|
is_active = :is_active,
|
||||||
|
updated_at = :updated_at';
|
||||||
|
|
||||||
|
if ($apiKey !== null && trim($apiKey) !== '') {
|
||||||
|
$sql .= ', api_key_encrypted = :api_key_encrypted';
|
||||||
|
$params['api_key_encrypted'] = $this->encryptApiKey($apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= ' WHERE id = :id';
|
||||||
|
|
||||||
|
$statement = $this->pdo->prepare($sql);
|
||||||
|
$statement->execute($params);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTestResult(
|
||||||
|
int $id,
|
||||||
|
string $status,
|
||||||
|
?int $httpCode,
|
||||||
|
string $message,
|
||||||
|
string $testedAt
|
||||||
|
): void {
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'UPDATE integrations SET
|
||||||
|
last_test_status = :status,
|
||||||
|
last_test_http_code = :http_code,
|
||||||
|
last_test_message = :message,
|
||||||
|
last_test_at = :tested_at,
|
||||||
|
updated_at = :updated_at
|
||||||
|
WHERE id = :id'
|
||||||
|
);
|
||||||
|
$statement->execute([
|
||||||
|
'id' => $id,
|
||||||
|
'status' => $status,
|
||||||
|
'http_code' => $httpCode,
|
||||||
|
'message' => mb_substr($message, 0, 255),
|
||||||
|
'tested_at' => $testedAt,
|
||||||
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logTest(
|
||||||
|
int $integrationId,
|
||||||
|
string $status,
|
||||||
|
?int $httpCode,
|
||||||
|
string $message,
|
||||||
|
string $endpointUrl,
|
||||||
|
string $testedAt
|
||||||
|
): void {
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'INSERT INTO integration_test_logs (
|
||||||
|
integration_id, status, http_code, message, endpoint_url, tested_at
|
||||||
|
) VALUES (
|
||||||
|
:integration_id, :status, :http_code, :message, :endpoint_url, :tested_at
|
||||||
|
)'
|
||||||
|
);
|
||||||
|
$statement->execute([
|
||||||
|
'integration_id' => $integrationId,
|
||||||
|
'status' => $status,
|
||||||
|
'http_code' => $httpCode,
|
||||||
|
'message' => mb_substr($message, 0, 255),
|
||||||
|
'endpoint_url' => mb_substr($endpointUrl, 0, 255),
|
||||||
|
'tested_at' => $testedAt,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function recentTests(int $integrationId, int $limit = 5): array
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'SELECT id, integration_id, status, http_code, message, endpoint_url, tested_at
|
||||||
|
FROM integration_test_logs
|
||||||
|
WHERE integration_id = :integration_id
|
||||||
|
ORDER BY tested_at DESC, id DESC
|
||||||
|
LIMIT :limit'
|
||||||
|
);
|
||||||
|
$statement->bindValue(':integration_id', $integrationId, PDO::PARAM_INT);
|
||||||
|
$statement->bindValue(':limit', max(1, $limit), PDO::PARAM_INT);
|
||||||
|
$statement->execute();
|
||||||
|
|
||||||
|
$rows = $statement->fetchAll();
|
||||||
|
if (!is_array($rows)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_map(
|
||||||
|
static fn (array $row): array => [
|
||||||
|
'id' => (int) ($row['id'] ?? 0),
|
||||||
|
'integration_id' => (int) ($row['integration_id'] ?? 0),
|
||||||
|
'status' => (string) ($row['status'] ?? ''),
|
||||||
|
'http_code' => $row['http_code'] === null ? null : (int) $row['http_code'],
|
||||||
|
'message' => (string) ($row['message'] ?? ''),
|
||||||
|
'endpoint_url' => (string) ($row['endpoint_url'] ?? ''),
|
||||||
|
'tested_at' => (string) ($row['tested_at'] ?? ''),
|
||||||
|
],
|
||||||
|
$rows
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function nameExists(string $type, string $name, ?int $excludeId = null): bool
|
||||||
|
{
|
||||||
|
$sql = 'SELECT 1 FROM integrations WHERE type = :type AND name = :name';
|
||||||
|
$params = [
|
||||||
|
'type' => $type,
|
||||||
|
'name' => $name,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($excludeId !== null) {
|
||||||
|
$sql .= ' AND id <> :exclude_id';
|
||||||
|
$params['exclude_id'] = $excludeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= ' LIMIT 1';
|
||||||
|
|
||||||
|
$statement = $this->pdo->prepare($sql);
|
||||||
|
$statement->execute($params);
|
||||||
|
|
||||||
|
return $statement->fetchColumn() !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ensureSalesChannelsSeeded(): void
|
||||||
|
{
|
||||||
|
$rows = [
|
||||||
|
['code' => 'shoppro', 'name' => 'shopPRO', 'type' => 'shop_instance'],
|
||||||
|
['code' => 'allegro', 'name' => 'Allegro', 'type' => 'marketplace'],
|
||||||
|
['code' => 'erli', 'name' => 'Erli', 'type' => 'marketplace'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'INSERT INTO sales_channels (code, name, type, status, created_at, updated_at)
|
||||||
|
VALUES (:code, :name, :type, 1, :created_at, :updated_at)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = VALUES(name),
|
||||||
|
type = VALUES(type),
|
||||||
|
updated_at = VALUES(updated_at)'
|
||||||
|
);
|
||||||
|
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$statement->execute([
|
||||||
|
'code' => $row['code'],
|
||||||
|
'name' => $row['name'],
|
||||||
|
'type' => $row['type'],
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findMappedProductId(string $channelCode, string $externalProductId, ?int $integrationId = null): ?int
|
||||||
|
{
|
||||||
|
$sql = 'SELECT pcm.product_id
|
||||||
|
FROM product_channel_map pcm
|
||||||
|
INNER JOIN sales_channels sc ON sc.id = pcm.channel_id
|
||||||
|
WHERE sc.code = :channel_code
|
||||||
|
AND pcm.external_product_id = :external_product_id';
|
||||||
|
$params = [
|
||||||
|
'channel_code' => $channelCode,
|
||||||
|
'external_product_id' => $externalProductId,
|
||||||
|
];
|
||||||
|
if ($integrationId !== null && $integrationId > 0) {
|
||||||
|
$sql .= ' AND pcm.integration_id = :integration_id';
|
||||||
|
$params['integration_id'] = $integrationId;
|
||||||
|
}
|
||||||
|
$sql .= ' LIMIT 1';
|
||||||
|
|
||||||
|
$statement = $this->pdo->prepare($sql);
|
||||||
|
$statement->execute($params);
|
||||||
|
|
||||||
|
$value = $statement->fetchColumn();
|
||||||
|
if ($value === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function upsertProductChannelMap(
|
||||||
|
int $productId,
|
||||||
|
string $channelCode,
|
||||||
|
string $syncState,
|
||||||
|
string $externalProductId = '',
|
||||||
|
string $externalVariantId = '',
|
||||||
|
?int $integrationId = null
|
||||||
|
): void {
|
||||||
|
$channelId = $this->findChannelIdByCode($channelCode);
|
||||||
|
if ($channelId === null) {
|
||||||
|
throw new RuntimeException('Brak kanalu sprzedazy: ' . $channelCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
$externalProductId = trim($externalProductId);
|
||||||
|
$externalVariantId = trim($externalVariantId);
|
||||||
|
$normalizedIntegrationId = $integrationId !== null && $integrationId > 0
|
||||||
|
? $integrationId
|
||||||
|
: null;
|
||||||
|
$linkType = 'manual';
|
||||||
|
$linkStatus = $externalProductId !== '' ? 'active' : 'unverified';
|
||||||
|
$linkedAt = $externalProductId !== '' ? date('Y-m-d H:i:s') : null;
|
||||||
|
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'INSERT INTO product_channel_map (
|
||||||
|
product_id, channel_id, integration_id, external_product_id, external_variant_id,
|
||||||
|
sync_state, link_type, link_status, linked_at, last_sync_at, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
:product_id, :channel_id, :integration_id, :external_product_id, :external_variant_id,
|
||||||
|
:sync_state, :link_type, :link_status, :linked_at, :last_sync_at, :created_at, :updated_at
|
||||||
|
) ON DUPLICATE KEY UPDATE
|
||||||
|
integration_id = VALUES(integration_id),
|
||||||
|
sync_state = VALUES(sync_state),
|
||||||
|
last_sync_at = VALUES(last_sync_at),
|
||||||
|
external_product_id = VALUES(external_product_id),
|
||||||
|
external_variant_id = VALUES(external_variant_id),
|
||||||
|
link_type = VALUES(link_type),
|
||||||
|
link_status = VALUES(link_status),
|
||||||
|
linked_at = VALUES(linked_at),
|
||||||
|
updated_at = VALUES(updated_at)'
|
||||||
|
);
|
||||||
|
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$statement->execute([
|
||||||
|
'product_id' => $productId,
|
||||||
|
'channel_id' => $channelId,
|
||||||
|
'integration_id' => $normalizedIntegrationId,
|
||||||
|
'external_product_id' => $externalProductId,
|
||||||
|
'external_variant_id' => $externalVariantId !== '' ? $externalVariantId : null,
|
||||||
|
'sync_state' => $syncState,
|
||||||
|
'link_type' => $linkType,
|
||||||
|
'link_status' => $linkStatus,
|
||||||
|
'linked_at' => $linkedAt,
|
||||||
|
'last_sync_at' => $now,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $row
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function mapRow(array $row): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => (int) ($row['id'] ?? 0),
|
||||||
|
'type' => (string) ($row['type'] ?? ''),
|
||||||
|
'name' => (string) ($row['name'] ?? ''),
|
||||||
|
'base_url' => (string) ($row['base_url'] ?? ''),
|
||||||
|
'timeout_seconds' => (int) ($row['timeout_seconds'] ?? 10),
|
||||||
|
'is_active' => (int) ($row['is_active'] ?? 0) === 1,
|
||||||
|
'last_test_status' => (string) ($row['last_test_status'] ?? ''),
|
||||||
|
'last_test_http_code' => $row['last_test_http_code'] === null ? null : (int) $row['last_test_http_code'],
|
||||||
|
'last_test_message' => (string) ($row['last_test_message'] ?? ''),
|
||||||
|
'last_test_at' => (string) ($row['last_test_at'] ?? ''),
|
||||||
|
'has_api_key' => (int) ($row['has_api_key'] ?? 0) === 1,
|
||||||
|
'created_at' => (string) ($row['created_at'] ?? ''),
|
||||||
|
'updated_at' => (string) ($row['updated_at'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function encryptApiKey(string $apiKey): string
|
||||||
|
{
|
||||||
|
$plain = trim($apiKey);
|
||||||
|
if ($plain === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$secret = trim($this->secret);
|
||||||
|
if ($secret === '') {
|
||||||
|
throw new RuntimeException('Brak INTEGRATIONS_SECRET w konfiguracji .env.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$iv = random_bytes(16);
|
||||||
|
$cipher = openssl_encrypt(
|
||||||
|
$plain,
|
||||||
|
'AES-256-CBC',
|
||||||
|
hash('sha256', $secret, true),
|
||||||
|
OPENSSL_RAW_DATA,
|
||||||
|
$iv
|
||||||
|
);
|
||||||
|
if ($cipher === false) {
|
||||||
|
throw new RuntimeException('Nie mozna zaszyfrowac klucza API.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return base64_encode($iv) . ':' . base64_encode($cipher);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decryptApiKey(string $payload): string
|
||||||
|
{
|
||||||
|
$serialized = trim($payload);
|
||||||
|
if ($serialized === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$secret = trim($this->secret);
|
||||||
|
if ($secret === '') {
|
||||||
|
throw new RuntimeException('Brak INTEGRATIONS_SECRET w konfiguracji .env.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = explode(':', $serialized, 2);
|
||||||
|
if (count($parts) !== 2) {
|
||||||
|
throw new RuntimeException('Niepoprawny format zapisanego klucza API.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$iv = base64_decode($parts[0], true);
|
||||||
|
$cipher = base64_decode($parts[1], true);
|
||||||
|
if ($iv === false || $cipher === false || strlen($iv) !== 16) {
|
||||||
|
throw new RuntimeException('Nie mozna odczytac zapisanego klucza API.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$plain = openssl_decrypt(
|
||||||
|
$cipher,
|
||||||
|
'AES-256-CBC',
|
||||||
|
hash('sha256', $secret, true),
|
||||||
|
OPENSSL_RAW_DATA,
|
||||||
|
$iv
|
||||||
|
);
|
||||||
|
if ($plain === false) {
|
||||||
|
throw new RuntimeException('Nie mozna odszyfrowac zapisanego klucza API.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $plain;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findChannelIdByCode(string $code): ?int
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare('SELECT id FROM sales_channels WHERE code = :code LIMIT 1');
|
||||||
|
$statement->execute(['code' => $code]);
|
||||||
|
$value = $statement->fetchColumn();
|
||||||
|
|
||||||
|
if ($value === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
334
src/Modules/Settings/ShopProClient.php
Normal file
334
src/Modules/Settings/ShopProClient.php
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Settings;
|
||||||
|
|
||||||
|
final class ShopProClient
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array{ok:bool,http_code:int|null,message:string,items:array<int,array<string,mixed>>,total:int,page:int,per_page:int}
|
||||||
|
*/
|
||||||
|
public function fetchProducts(
|
||||||
|
string $baseUrl,
|
||||||
|
string $apiKey,
|
||||||
|
int $timeoutSeconds,
|
||||||
|
int $page = 1,
|
||||||
|
int $perPage = 1
|
||||||
|
): array {
|
||||||
|
$normalizedBaseUrl = rtrim(trim($baseUrl), '/');
|
||||||
|
$query = http_build_query([
|
||||||
|
'endpoint' => 'products',
|
||||||
|
'action' => 'list',
|
||||||
|
'page' => max(1, $page),
|
||||||
|
'per_page' => max(1, min(100, $perPage)),
|
||||||
|
'sort' => 'id',
|
||||||
|
'sort_dir' => 'DESC',
|
||||||
|
'status' => '1',
|
||||||
|
]);
|
||||||
|
$endpointUrl = $normalizedBaseUrl . '/api.php?' . $query;
|
||||||
|
|
||||||
|
$response = $this->requestJson($endpointUrl, $apiKey, $timeoutSeconds);
|
||||||
|
if (($response['ok'] ?? false) !== true) {
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'http_code' => $response['http_code'] ?? null,
|
||||||
|
'message' => (string) ($response['message'] ?? 'Nie mozna pobrac listy produktow z shopPRO.'),
|
||||||
|
'items' => [],
|
||||||
|
'total' => 0,
|
||||||
|
'page' => max(1, $page),
|
||||||
|
'per_page' => max(1, min(100, $perPage)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = is_array($response['data'] ?? null) ? $response['data'] : [];
|
||||||
|
$items = isset($data['items']) && is_array($data['items']) ? $data['items'] : [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => true,
|
||||||
|
'http_code' => $response['http_code'] ?? null,
|
||||||
|
'message' => '',
|
||||||
|
'items' => $items,
|
||||||
|
'total' => (int) ($data['total'] ?? 0),
|
||||||
|
'page' => (int) ($data['page'] ?? max(1, $page)),
|
||||||
|
'per_page' => (int) ($data['per_page'] ?? max(1, min(100, $perPage))),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{ok:bool,http_code:int|null,message:string,product:array<string,mixed>|null}
|
||||||
|
*/
|
||||||
|
public function fetchProductById(string $baseUrl, string $apiKey, int $timeoutSeconds, int $productId): array
|
||||||
|
{
|
||||||
|
if ($productId <= 0) {
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'http_code' => null,
|
||||||
|
'message' => 'Niepoprawne ID produktu do importu.',
|
||||||
|
'product' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedBaseUrl = rtrim(trim($baseUrl), '/');
|
||||||
|
$query = http_build_query([
|
||||||
|
'endpoint' => 'products',
|
||||||
|
'action' => 'get',
|
||||||
|
'id' => $productId,
|
||||||
|
]);
|
||||||
|
$endpointUrl = $normalizedBaseUrl . '/api.php?' . $query;
|
||||||
|
|
||||||
|
$response = $this->requestJson($endpointUrl, $apiKey, $timeoutSeconds);
|
||||||
|
if (($response['ok'] ?? false) !== true) {
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'http_code' => $response['http_code'] ?? null,
|
||||||
|
'message' => (string) ($response['message'] ?? 'Nie mozna pobrac produktu z shopPRO.'),
|
||||||
|
'product' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = is_array($response['data'] ?? null) ? $response['data'] : null;
|
||||||
|
if ($data === null) {
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'http_code' => $response['http_code'] ?? null,
|
||||||
|
'message' => 'shopPRO zwrocil pusty payload produktu.',
|
||||||
|
'product' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => true,
|
||||||
|
'http_code' => $response['http_code'] ?? null,
|
||||||
|
'message' => '',
|
||||||
|
'product' => $data,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{ok:bool,status:string,http_code:int|null,message:string,endpoint_url:string,tested_at:string}
|
||||||
|
*/
|
||||||
|
public function testConnection(string $baseUrl, string $apiKey, int $timeoutSeconds): array
|
||||||
|
{
|
||||||
|
$normalizedBaseUrl = rtrim(trim($baseUrl), '/');
|
||||||
|
$endpointUrl = $normalizedBaseUrl . '/api.php?endpoint=dictionaries&action=statuses';
|
||||||
|
$testedAt = date('Y-m-d H:i:s');
|
||||||
|
$response = $this->requestJson($endpointUrl, $apiKey, $timeoutSeconds);
|
||||||
|
if (($response['ok'] ?? false) !== true) {
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'status' => 'error',
|
||||||
|
'http_code' => $response['http_code'] ?? null,
|
||||||
|
'message' => (string) ($response['message'] ?? 'Nieznany blad polaczenia.'),
|
||||||
|
'endpoint_url' => $endpointUrl,
|
||||||
|
'tested_at' => $testedAt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = is_array($response['payload'] ?? null) ? $response['payload'] : [];
|
||||||
|
$apiStatus = (string) ($payload['status'] ?? '');
|
||||||
|
$httpCode = isset($response['http_code']) ? (int) $response['http_code'] : null;
|
||||||
|
if ($httpCode === 200 && $apiStatus === 'ok') {
|
||||||
|
return [
|
||||||
|
'ok' => true,
|
||||||
|
'status' => 'ok',
|
||||||
|
'http_code' => $httpCode,
|
||||||
|
'message' => 'Polaczenie poprawne. Endpoint dictionaries/statuses zwrocil status=ok.',
|
||||||
|
'endpoint_url' => $endpointUrl,
|
||||||
|
'tested_at' => $testedAt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$errorCode = (string) ($payload['code'] ?? '');
|
||||||
|
$errorMessage = (string) ($payload['message'] ?? 'Brak szczegolow bledu.');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'status' => 'error',
|
||||||
|
'http_code' => $httpCode,
|
||||||
|
'message' => trim('shopPRO zwrocil blad. ' . $errorCode . ' ' . $errorMessage),
|
||||||
|
'endpoint_url' => $endpointUrl,
|
||||||
|
'tested_at' => $testedAt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{ok:bool,http_code:int|null,message:string,payload?:array<string,mixed>,data?:mixed}
|
||||||
|
*/
|
||||||
|
private function requestJson(string $endpointUrl, string $apiKey, int $timeoutSeconds): array
|
||||||
|
{
|
||||||
|
$timeout = max(3, min(60, $timeoutSeconds));
|
||||||
|
if (function_exists('curl_init')) {
|
||||||
|
[$httpCode, $body, $transportError, $contentType] = $this->requestByCurl($endpointUrl, $apiKey, $timeout);
|
||||||
|
} else {
|
||||||
|
[$httpCode, $body, $transportError, $contentType] = $this->requestByStream($endpointUrl, $apiKey, $timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($transportError !== '') {
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'http_code' => $httpCode,
|
||||||
|
'message' => 'Blad transportu: ' . $transportError,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$cleanBody = $this->stripUtf8Bom($body);
|
||||||
|
$bodyLength = strlen($cleanBody);
|
||||||
|
if ($bodyLength === 0) {
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'http_code' => $httpCode,
|
||||||
|
'message' => trim(
|
||||||
|
'Pusta odpowiedz z shopPRO. HTTP=' . (string) ($httpCode ?? 0)
|
||||||
|
. ' content_type="' . $contentType . '" body_length=0'
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = json_decode($cleanBody, true);
|
||||||
|
if (!is_array($payload)) {
|
||||||
|
$prefix = trim(mb_substr($cleanBody, 0, 180));
|
||||||
|
$looksLikeHtml = str_starts_with($prefix, '<');
|
||||||
|
$details = $looksLikeHtml
|
||||||
|
? 'Odpowiedz wyglada na HTML (np. redirect, blad serwera lub blokada).'
|
||||||
|
: 'Odpowiedz nie jest poprawnym JSON.';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'http_code' => $httpCode,
|
||||||
|
'message' => trim(
|
||||||
|
$details
|
||||||
|
. ' HTTP=' . (string) ($httpCode ?? 0)
|
||||||
|
. ' content_type="' . $contentType . '"'
|
||||||
|
. ' body_length=' . (string) $bodyLength
|
||||||
|
. ' fragment="' . $prefix . '"'
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = (string) ($payload['status'] ?? '');
|
||||||
|
if ($status !== 'ok') {
|
||||||
|
$errorCode = (string) ($payload['code'] ?? '');
|
||||||
|
$errorMessage = (string) ($payload['message'] ?? 'Brak szczegolow bledu.');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'http_code' => $httpCode,
|
||||||
|
'message' => trim('shopPRO zwrocil blad. ' . $errorCode . ' ' . $errorMessage),
|
||||||
|
'payload' => $payload,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => true,
|
||||||
|
'http_code' => $httpCode,
|
||||||
|
'message' => '',
|
||||||
|
'payload' => $payload,
|
||||||
|
'data' => $payload['data'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0:int|null,1:string,2:string,3:string}
|
||||||
|
*/
|
||||||
|
private function requestByCurl(string $url, string $apiKey, int $timeoutSeconds): array
|
||||||
|
{
|
||||||
|
$curl = curl_init($url);
|
||||||
|
if ($curl === false) {
|
||||||
|
return [null, '', 'Nie mozna zainicjalizowac cURL.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
curl_setopt_array($curl, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'X-Api-Key: ' . $apiKey,
|
||||||
|
'Accept: application/json',
|
||||||
|
],
|
||||||
|
CURLOPT_TIMEOUT => $timeoutSeconds,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => max(2, min(10, $timeoutSeconds)),
|
||||||
|
CURLOPT_SSL_VERIFYPEER => true,
|
||||||
|
CURLOPT_SSL_VERIFYHOST => 2,
|
||||||
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
|
CURLOPT_MAXREDIRS => 5,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$body = curl_exec($curl);
|
||||||
|
$httpCode = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||||
|
$contentType = (string) curl_getinfo($curl, CURLINFO_CONTENT_TYPE);
|
||||||
|
$error = curl_error($curl);
|
||||||
|
curl_close($curl);
|
||||||
|
|
||||||
|
return [
|
||||||
|
$httpCode > 0 ? $httpCode : null,
|
||||||
|
is_string($body) ? $body : '',
|
||||||
|
$error,
|
||||||
|
$contentType,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0:int|null,1:string,2:string,3:string}
|
||||||
|
*/
|
||||||
|
private function requestByStream(string $url, string $apiKey, int $timeoutSeconds): array
|
||||||
|
{
|
||||||
|
$context = stream_context_create([
|
||||||
|
'http' => [
|
||||||
|
'method' => 'GET',
|
||||||
|
'header' => "X-Api-Key: {$apiKey}\r\nAccept: application/json\r\n",
|
||||||
|
'timeout' => $timeoutSeconds,
|
||||||
|
'ignore_errors' => true,
|
||||||
|
'follow_location' => 1,
|
||||||
|
'max_redirects' => 5,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$body = @file_get_contents($url, false, $context);
|
||||||
|
$headers = is_array($http_response_header ?? null) ? $http_response_header : [];
|
||||||
|
$httpCode = $this->extractHttpStatusCode($headers);
|
||||||
|
$contentType = $this->extractContentType($headers);
|
||||||
|
$error = $body === false ? 'Nie mozna pobrac odpowiedzi HTTP.' : '';
|
||||||
|
|
||||||
|
return [
|
||||||
|
$httpCode,
|
||||||
|
$body === false ? '' : $body,
|
||||||
|
$error,
|
||||||
|
$contentType,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $headers
|
||||||
|
*/
|
||||||
|
private function extractHttpStatusCode(array $headers): ?int
|
||||||
|
{
|
||||||
|
foreach ($headers as $header) {
|
||||||
|
if (preg_match('/^HTTP\/\d\.\d\s+(\d{3})\b/i', $header, $matches) === 1) {
|
||||||
|
return (int) $matches[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $headers
|
||||||
|
*/
|
||||||
|
private function extractContentType(array $headers): string
|
||||||
|
{
|
||||||
|
foreach ($headers as $header) {
|
||||||
|
if (stripos($header, 'Content-Type:') === 0) {
|
||||||
|
return trim(substr($header, strlen('Content-Type:')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stripUtf8Bom(string $body): string
|
||||||
|
{
|
||||||
|
if (str_starts_with($body, "\xEF\xBB\xBF")) {
|
||||||
|
return substr($body, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $body;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,73 @@ final class UserRepository
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $filters
|
||||||
|
* @return array{items:array<int, array<string, mixed>>, total:int, page:int, per_page:int}
|
||||||
|
*/
|
||||||
|
public function paginate(array $filters): array
|
||||||
|
{
|
||||||
|
$page = max(1, (int) ($filters['page'] ?? 1));
|
||||||
|
$perPage = max(1, min(100, (int) ($filters['per_page'] ?? 20)));
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
|
||||||
|
$where = [];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
$search = trim((string) ($filters['search'] ?? ''));
|
||||||
|
if ($search !== '') {
|
||||||
|
$where[] = '(name LIKE :search OR email LIKE :search)';
|
||||||
|
$params['search'] = '%' . $search . '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
$whereSql = $where === [] ? '' : (' WHERE ' . implode(' AND ', $where));
|
||||||
|
|
||||||
|
$sort = (string) ($filters['sort'] ?? 'id');
|
||||||
|
$sortDir = strtoupper((string) ($filters['sort_dir'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC';
|
||||||
|
$sortColumn = match ($sort) {
|
||||||
|
'name' => 'name',
|
||||||
|
'email' => 'email',
|
||||||
|
'created_at' => 'created_at',
|
||||||
|
default => 'id',
|
||||||
|
};
|
||||||
|
|
||||||
|
$countStmt = $this->pdo->prepare('SELECT COUNT(*) FROM users' . $whereSql);
|
||||||
|
$countStmt->execute($params);
|
||||||
|
$total = (int) $countStmt->fetchColumn();
|
||||||
|
|
||||||
|
$listStmt = $this->pdo->prepare(
|
||||||
|
'SELECT id, name, email, created_at FROM users' . $whereSql
|
||||||
|
. ' ORDER BY ' . $sortColumn . ' ' . $sortDir
|
||||||
|
. ' LIMIT :limit OFFSET :offset'
|
||||||
|
);
|
||||||
|
foreach ($params as $key => $value) {
|
||||||
|
$listStmt->bindValue(':' . $key, $value);
|
||||||
|
}
|
||||||
|
$listStmt->bindValue(':limit', $perPage, PDO::PARAM_INT);
|
||||||
|
$listStmt->bindValue(':offset', $offset, PDO::PARAM_INT);
|
||||||
|
$listStmt->execute();
|
||||||
|
|
||||||
|
$rows = $listStmt->fetchAll();
|
||||||
|
if (!is_array($rows)) {
|
||||||
|
$rows = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'items' => array_map(
|
||||||
|
static fn (array $row): array => [
|
||||||
|
'id' => (int) ($row['id'] ?? 0),
|
||||||
|
'name' => (string) ($row['name'] ?? ''),
|
||||||
|
'email' => (string) ($row['email'] ?? ''),
|
||||||
|
'created_at' => (string) ($row['created_at'] ?? ''),
|
||||||
|
],
|
||||||
|
$rows
|
||||||
|
),
|
||||||
|
'total' => $total,
|
||||||
|
'page' => $page,
|
||||||
|
'per_page' => $perPage,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function hasAny(): bool
|
public function hasAny(): bool
|
||||||
{
|
{
|
||||||
$statement = $this->pdo->query('SELECT 1 FROM users LIMIT 1');
|
$statement = $this->pdo->query('SELECT 1 FROM users LIMIT 1');
|
||||||
|
|||||||
@@ -23,22 +23,83 @@ final class UsersController
|
|||||||
|
|
||||||
public function index(Request $request): Response
|
public function index(Request $request): Response
|
||||||
{
|
{
|
||||||
$records = array_map(
|
$filters = [
|
||||||
static fn (array $user): array => [
|
'search' => trim((string) $request->input('search', '')),
|
||||||
'id' => (int) ($user['id'] ?? 0),
|
'sort' => (string) $request->input('sort', 'id'),
|
||||||
'name' => (string) ($user['name'] ?? ''),
|
'sort_dir' => (string) $request->input('sort_dir', 'DESC'),
|
||||||
'email' => (string) ($user['email'] ?? ''),
|
'page' => max(1, (int) $request->input('page', 1)),
|
||||||
'created_at' => (string) ($user['created_at'] ?? ''),
|
'per_page' => max(1, min(100, (int) $request->input('per_page', 20))),
|
||||||
],
|
];
|
||||||
$this->users->all()
|
$result = $this->users->paginate($filters);
|
||||||
);
|
$totalPages = max(1, (int) ceil(((int) $result['total']) / max(1, (int) $result['per_page'])));
|
||||||
|
|
||||||
$html = $this->template->render('users/index', [
|
$html = $this->template->render('users/index', [
|
||||||
'title' => $this->translator->get('users.title'),
|
'title' => $this->translator->get('users.title'),
|
||||||
'activeMenu' => 'users',
|
'activeMenu' => 'users',
|
||||||
'user' => $this->auth->user(),
|
'user' => $this->auth->user(),
|
||||||
'csrfToken' => Csrf::token(),
|
'csrfToken' => Csrf::token(),
|
||||||
'users' => $records,
|
'tableList' => [
|
||||||
|
'list_key' => 'users',
|
||||||
|
'base_path' => '/users',
|
||||||
|
'query' => $filters,
|
||||||
|
'filters' => [
|
||||||
|
[
|
||||||
|
'key' => 'search',
|
||||||
|
'label' => $this->translator->get('users.filters.search'),
|
||||||
|
'type' => 'text',
|
||||||
|
'value' => $filters['search'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'sort',
|
||||||
|
'label' => $this->translator->get('users.filters.sort'),
|
||||||
|
'type' => 'select',
|
||||||
|
'value' => $filters['sort'],
|
||||||
|
'options' => [
|
||||||
|
'id' => 'ID',
|
||||||
|
'name' => $this->translator->get('users.fields.name'),
|
||||||
|
'email' => $this->translator->get('users.fields.email'),
|
||||||
|
'created_at' => $this->translator->get('users.fields.created_at'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'sort_dir',
|
||||||
|
'label' => $this->translator->get('users.filters.direction'),
|
||||||
|
'type' => 'select',
|
||||||
|
'value' => $filters['sort_dir'],
|
||||||
|
'options' => [
|
||||||
|
'DESC' => 'DESC',
|
||||||
|
'ASC' => 'ASC',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'per_page',
|
||||||
|
'label' => $this->translator->get('users.filters.per_page'),
|
||||||
|
'type' => 'select',
|
||||||
|
'value' => (string) $filters['per_page'],
|
||||||
|
'options' => [
|
||||||
|
'20' => '20',
|
||||||
|
'50' => '50',
|
||||||
|
'100' => '100',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'columns' => [
|
||||||
|
['key' => 'id', 'label' => 'ID', 'sortable' => true, 'sort_key' => 'id'],
|
||||||
|
['key' => 'name', 'label' => $this->translator->get('users.fields.name'), 'sortable' => true, 'sort_key' => 'name'],
|
||||||
|
['key' => 'email', 'label' => $this->translator->get('users.fields.email'), 'sortable' => true, 'sort_key' => 'email'],
|
||||||
|
['key' => 'created_at', 'label' => $this->translator->get('users.fields.created_at'), 'sortable' => true, 'sort_key' => 'created_at'],
|
||||||
|
],
|
||||||
|
'rows' => (array) ($result['items'] ?? []),
|
||||||
|
'pagination' => [
|
||||||
|
'page' => (int) ($result['page'] ?? 1),
|
||||||
|
'total_pages' => $totalPages,
|
||||||
|
'total' => (int) ($result['total'] ?? 0),
|
||||||
|
'per_page' => (int) ($result['per_page'] ?? 20),
|
||||||
|
],
|
||||||
|
'per_page_options' => [20, 50, 100],
|
||||||
|
'empty_message' => $this->translator->get('users.empty'),
|
||||||
|
'show_actions' => false,
|
||||||
|
],
|
||||||
'errorMessage' => (string) Flash::get('users_error', ''),
|
'errorMessage' => (string) Flash::get('users_error', ''),
|
||||||
'successMessage' => (string) Flash::get('users_success', ''),
|
'successMessage' => (string) Flash::get('users_success', ''),
|
||||||
'oldName' => (string) Flash::get('users_old_name', ''),
|
'oldName' => (string) Flash::get('users_old_name', ''),
|
||||||
|
|||||||
Reference in New Issue
Block a user