diff --git a/.paul/ROADMAP.md b/.paul/ROADMAP.md
index 6e928f2..fad371f 100644
--- a/.paul/ROADMAP.md
+++ b/.paul/ROADMAP.md
@@ -16,6 +16,7 @@ Wersja mobilna aplikacji, modul po module. Cel: pelna uzywalnosc orderPRO na tel
| 53 | Mobile Status Panel Toggle | 1/1 | Complete |
| 54 | Order Detail Image Hover | 1/1 | Complete |
| 55 | Desktop Collapsed Sidebar Fix | 1/1 | Complete |
+| 56 | Order Payments | 0/1 | Planning |
| TBD | Mobile Orders List | - | Not started |
| TBD | Mobile Order Details | - | Not started |
| TBD | Mobile Settings | - | Not started |
diff --git a/.paul/STATE.md b/.paul/STATE.md
index f9d6ca6..2dbde6f 100644
--- a/.paul/STATE.md
+++ b/.paul/STATE.md
@@ -5,34 +5,34 @@
See: .paul/PROJECT.md (updated 2026-03-28)
**Core value:** Sprzedawca moze obslugiwac zamowienia ze wszystkich kanalow sprzedazy i nadawac przesylki bez przelaczania sie miedzy platformami.
-**Current focus:** Milestone v3.0 Mobile Responsive — Phase 52 (Mobile Main Menu) planning
+**Current focus:** Milestone v3.0 Mobile Responsive — Phase 56 (Order Payments) planning
## Current Position
Milestone: v3.0 Mobile Responsive — In progress
-Phase: 4 of N (55 - Desktop Collapsed Sidebar Fix) — Complete
-Plan: 55-01 complete
-Status: Loop complete — phase 55 done, ready for next PLAN
-Last activity: 2026-03-29 — UNIFY closed for 55-01
+Phase: 5 of N (56 - Order Payments) — Planning
+Plan: 56-01 created, awaiting approval
+Status: PLAN created, ready for APPLY
+Last activity: 2026-03-30 — Created .paul/phases/56-order-payments/56-01-PLAN.md
Progress:
- Milestone: [####░░░░░░] ~40%
-- Phase 55: [##########] 100%
+- Phase 56: [░░░░░░░░░░] 0%
## Loop Position
Current loop state:
```
PLAN ──▶ APPLY ──▶ UNIFY
- ✓ ✓ ✓ [Loop complete - ready for next PLAN]
+ ✓ ○ ○ [Plan created, awaiting approval]
```
## Session Continuity
-Last session: 2026-03-29
-Stopped at: Phase 55 complete
-Next action: /paul:plan dla kolejnego modulu
-Resume file: .paul/phases/55-desktop-collapsed-sidebar-fix/55-01-SUMMARY.md
+Last session: 2026-03-30
+Stopped at: Plan 56-01 created
+Next action: Review and approve plan, then run /paul:apply .paul/phases/56-order-payments/56-01-PLAN.md
+Resume file: .paul/phases/56-order-payments/56-01-PLAN.md
## Accumulated Context
@@ -41,9 +41,10 @@ Resume file: .paul/phases/55-desktop-collapsed-sidebar-fix/55-01-SUMMARY.md
|------|----------|--------|
| 2026-03-29 | Mobile menu jako slide-in overlay (nie horizontal scroll) | Pelna nawigacja na mobile bez kompromisow |
| 2026-03-29 | Hamburger w topbarze, sidebar fixed z transform slide | Plynna animacja CSS, zero zaleznosci JS |
+| 2026-03-30 | Push set_paid do shopPRO API po dodaniu platnosci w orderPRO | Synchronizacja statusu platnosci bez dodatkowego endpointu w shopPRO |
## Git State
-Last commit: cbc2058
+Last commit: 70662af
Branch: main
Feature branches merged: none
diff --git a/.paul/phases/56-order-payments/56-01-PLAN.md b/.paul/phases/56-order-payments/56-01-PLAN.md
new file mode 100644
index 0000000..d1771af
--- /dev/null
+++ b/.paul/phases/56-order-payments/56-01-PLAN.md
@@ -0,0 +1,248 @@
+---
+phase: 56-order-payments
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - database/migrations/20260330_000073_create_order_payments_table.sql
+ - src/Modules/Orders/OrdersController.php
+ - src/Modules/Orders/OrdersRepository.php
+ - src/Modules/Settings/ShopproApiClient.php
+ - resources/views/orders/show.php
+ - resources/lang/pl.php
+ - resources/scss/modules/_order-details.scss
+ - public/assets/css/app.css
+ - routes/web.php
+ - DOCS/DB_SCHEMA.md
+ - DOCS/ARCHITECTURE.md
+ - DOCS/TECH_CHANGELOG.md
+autonomous: false
+---
+
+
+## Goal
+Uruchomienie funkcji dodawania platnosci do zamowienia z poziomu zakladki Platnosci w widoku zamowienia. Po dodaniu platnosci — automatyczny push statusu platnosci do shopPRO API (dla zamowien source=shoppro).
+
+## Purpose
+Sprzedawca moze oznaczyc zamowienie jako oplacone bezposrednio z orderPRO i ta informacja jest synchronizowana do sklepu shopPRO.
+
+## Output
+- Tabela `order_payments` w bazie (migracja)
+- Formularz dodawania platnosci w zakladce Platnosci
+- Endpoint POST `/orders/{id}/payment/add`
+- Push `set_paid` do shopPRO API po dodaniu platnosci (source=shoppro)
+
+
+
+## Project Context
+@.paul/PROJECT.md
+@.paul/ROADMAP.md
+@.paul/STATE.md
+
+## Source Files
+@database/drafts/20260302_orders_schema_v1.sql (linie 102-121 — draft schemat order_payments)
+@src/Modules/Orders/OrdersController.php
+@src/Modules/Orders/OrdersRepository.php (loadOrderPayments, findDetails)
+@src/Modules/Orders/OrderImportRepository.php (replacePayments)
+@src/Modules/Settings/ShopproPaymentStatusSyncService.php (wzorzec update + push)
+@src/Modules/Settings/ShopproApiClient.php (requestJsonPut)
+@src/Modules/Settings/ShopproIntegrationsRepository.php
+@resources/views/orders/show.php (zakladka Platnosci linie 559-648)
+@routes/web.php (pattern: /orders/{id}/...)
+
+
+
+
+## AC-1: Tabela order_payments istnieje w bazie
+```gherkin
+Given migracja 20260330_000073 zostala uruchomiona
+When wykonam SHOW CREATE TABLE order_payments
+Then tabela zawiera kolumny: id, order_id, source_payment_id, external_payment_id, payment_type_id, payment_date, amount, currency, comment, payload_json, created_at, updated_at
+And istnieje FK na orders(id) z ON DELETE CASCADE
+```
+
+## AC-2: Formularz dodawania platnosci w zakladce Platnosci
+```gherkin
+Given otwieram zamowienie /orders/130
+When klikam zakladke Platnosci
+Then widze przycisk "Dodaj platnosc" nad tabela platnosci
+And po kliknieciu pojawia sie formularz inline z polami: kwota, typ platnosci (select), data platnosci, komentarz
+And formularz zawiera przyciski Zapisz i Anuluj
+```
+
+## AC-3: Zapis platnosci przez AJAX
+```gherkin
+Given wypelniam formularz platnosci (kwota, typ, data)
+When klikam Zapisz
+Then platnosc jest zapisana w tabeli order_payments
+And kolumny orders.payment_status i orders.total_paid sa zaktualizowane
+And zakladka Platnosci odswieza sie z nowa platnoscia na liscie
+And pojawia sie komunikat sukcesu
+```
+
+## AC-4: Push set_paid do shopPRO po dodaniu platnosci
+```gherkin
+Given zamowienie ma source=shoppro i polaczona integracje
+When dodaje platnosc pokrywajaca pelna kwote zamowienia
+Then orderPRO wywoluje PUT shopPRO API /api.php?endpoint=orders&action=set_paid&id={source_order_id}
+And w activity_log zapisuje informacje o pushu
+```
+
+## AC-5: Walidacja formularza
+```gherkin
+Given otwieram formularz dodawania platnosci
+When probuje zapisac bez kwoty lub z kwota <= 0
+Then formularz nie jest wysylany i pojawia sie komunikat bledu walidacji
+```
+
+
+
+
+
+
+ Task 1: Migracja order_payments + update orders columns
+ database/migrations/20260330_000073_create_order_payments_table.sql, DOCS/DB_SCHEMA.md
+
+ Utworz migracje tworzaca tabele order_payments na podstawie draftu (database/drafts/20260302_orders_schema_v1.sql linie 102-121).
+ Uzyj CREATE TABLE IF NOT EXISTS — migracja idempotentna.
+ Schemat: id, order_id, source_payment_id, external_payment_id, payment_type_id, payment_date, amount, currency, comment, payload_json, created_at, updated_at.
+ Indexy: UNIQUE (order_id, source_payment_id), INDEX (order_id), INDEX (payment_date).
+ FK: order_payments_order_fk -> orders(id) ON DELETE CASCADE ON UPDATE CASCADE.
+
+ Sprawdz tez czy kolumny payment_status, total_paid, external_payment_type_id istnieja w tabeli orders. Jezeli nie — dodaj ALTER TABLE (idempotentnie przez IF NOT EXISTS pattern z prepared statements).
+
+ Zaktualizuj DOCS/DB_SCHEMA.md o nowa tabele.
+
+ Uruchom migracje na serwerze: sprawdz SHOW CREATE TABLE order_payments
+ AC-1 satisfied: tabela order_payments istnieje z poprawnym schematem
+
+
+
+ Task 2: Backend — endpoint dodawania platnosci + push do shopPRO
+ src/Modules/Orders/OrdersController.php, src/Modules/Orders/OrdersRepository.php, src/Modules/Settings/ShopproApiClient.php, routes/web.php, resources/lang/pl.php, DOCS/ARCHITECTURE.md
+
+ 1. W routes/web.php dodaj route:
+ POST /orders/{id}/payment/add -> OrdersController->addPayment
+
+ 2. W OrdersRepository dodaj metode addPayment(int $orderId, array $data): int
+ - INSERT INTO order_payments (order_id, payment_type_id, payment_date, amount, currency, comment)
+ - Przelicz total_paid: SELECT SUM(amount) FROM order_payments WHERE order_id
+ - UPDATE orders SET total_paid = sum, payment_status = (2 jesli sum >= total_with_tax, 1 jesli sum > 0, 0 jesli sum = 0)
+ - Zwroc nowy payment ID
+
+ 3. W OrdersController dodaj metode addPayment(Request $request): Response
+ - Walidacja: orderId > 0, amount > 0, payment_type_id niepusty
+ - CSRF token
+ - Wywolaj OrdersRepository::addPayment()
+ - Jezeli zamowienie ma source=shoppro i payment_status stalo sie 2 (oplacone):
+ - Pobierz dane integracji (integration_id z zamowienia)
+ - Wywolaj ShopproApiClient::pushPaymentPaid() do set_paid
+ - Zaloguj w activity_log
+ - Return JSON response {ok, payment, payment_status, total_paid}
+
+ 4. W ShopproApiClient dodaj metode pushPaymentPaid(string $baseUrl, string $apiKey, int $timeout, string $sourceOrderId): array
+ - PUT {baseUrl}/api.php?endpoint=orders&action=set_paid&id={sourceOrderId}
+ - Body: {"send_email": 0}
+ - Uzyj istniejacego requestJsonPut()
+
+ 5. Zaktualizuj resources/lang/pl.php o klucze bledow/komunikatow platnosci.
+
+ 6. Zaktualizuj DOCS/ARCHITECTURE.md.
+
+
+ curl -X POST /orders/130/payment/add z danymi platnosci — odpowiedz 200 z JSON
+
+ AC-3, AC-4, AC-5 satisfied: platnosc zapisywana, push do shopPRO, walidacja
+
+
+
+ Task 3: Frontend — formularz platnosci w zakladce + AJAX
+ resources/views/orders/show.php, resources/scss/modules/_order-details.scss, public/assets/css/app.css
+
+ 1. W show.php, w sekcji data-order-tab-panel="payments" (linia ~606):
+ - Dodaj przycisk "Dodaj platnosc" (klasa btn btn--sm btn--primary) nad tabela/komunikatem "Brak platnosci"
+ - Po kliknieciu — pokaz formularz inline (ukryty domyslnie):
+ - Kwota (input number, step=0.01, required) — domyslna: total_with_tax - total_paid
+ - Typ platnosci (select: ONLINE, TRANSFER, CASH_ON_DELIVERY)
+ - Data platnosci (input date, domyslna: dzisiaj)
+ - Komentarz (input text, opcjonalny)
+ - Przyciski: Zapisz (submit AJAX), Anuluj (ukryj formularz)
+
+ 2. JavaScript na dole widoku (pattern jak inne formularze AJAX w tym pliku):
+ - POST /orders/{orderId}/payment/add
+ - Wysylaj JSON: {amount, payment_type_id, payment_date, comment, _token}
+ - On success: window.location.reload() dla odswiezenia karty
+ - On error: pokaz blad przez OrderProAlerts.error()
+
+ 3. Style w _order-details.scss:
+ - .payment-add-form — kompaktowy formularz inline
+ - .payment-add-form__row — flex row z gap
+ - Responsywnosc (na mobile kolumny zawijaja sie)
+
+ 4. Zbuduj SCSS: npm run build (lub sass compile)
+
+
+ Otworz /orders/130, kliknij Platnosci, kliknij "Dodaj platnosc", wypelnij formularz, zapisz — platnosc pojawia sie na liscie
+
+ AC-2, AC-3 satisfied: formularz widoczny, zapis dziala, lista odswieza sie
+
+
+
+ Formularz dodawania platnosci + push do shopPRO
+
+ 1. Otworz: https://orderpro.projectpro.pl/orders/130
+ 2. Kliknij zakladke "Platnosci"
+ 3. Kliknij "Dodaj platnosc"
+ 4. Wypelnij kwote (np. 100.00), wybierz typ "Platnosc online", data dzisiejsza
+ 5. Kliknij "Zapisz"
+ 6. Sprawdz: platnosc pojawia sie w tabeli, status platnosci zaktualizowal sie
+ 7. Sprawdz shopPRO: czy zamowienie zostalo oznaczone jako oplacone
+ 8. Sprobuj dodac platnosc z kwota 0 — powinien byc blad walidacji
+
+ Type "approved" to continue, or describe issues to fix
+
+
+
+
+
+
+## DO NOT CHANGE
+- src/Modules/Settings/ShopproPaymentStatusSyncService.php (istniejacy cron sync — nie modyfikowac)
+- src/Modules/Orders/OrderImportRepository.php (import platnosci z API — nie modyfikowac)
+- database/migrations/ istniejace migracje (nie modyfikowac)
+- src/Modules/Cron/* (nie modyfikowac handlerow crona)
+
+## SCOPE LIMITS
+- Tylko dodawanie platnosci — nie edycja ani usuwanie
+- Nie implementujemy zwrotow (refunds)
+- Push do shopPRO tylko przez istniejace API set_paid — nie dodajemy nowego endpointu w shopPRO
+- Nie modyfikujemy synchronizacji platnosci Allegro
+- Nie dodajemy nowych cron jobow
+
+
+
+
+Before declaring plan complete:
+- [ ] Migracja uruchomiona — tabela order_payments istnieje
+- [ ] POST /orders/{id}/payment/add zwraca 200 z poprawnymi danymi
+- [ ] Formularz w UI wyswietla sie poprawnie
+- [ ] Platnosc zapisana w order_payments
+- [ ] orders.payment_status i total_paid zaktualizowane
+- [ ] Push set_paid do shopPRO (source=shoppro) dziala
+- [ ] Walidacja formularza — kwota > 0, typ niepusty
+- [ ] DOCS zaktualizowane
+- [ ] SCSS zbudowany do CSS
+
+
+
+- Wszystkie taski ukonczone
+- Wszystkie AC spelnione
+- Platnosc dodana z UI pojawia sie w zakladce Platnosci
+- shopPRO otrzymuje informacje o oplaceniu
+- Brak bledow PHP/JS w konsoli
+
+
+
diff --git a/.vscode/ftp-kr.sync.cache.json b/.vscode/ftp-kr.sync.cache.json
index aeea95f..79a867b 100644
--- a/.vscode/ftp-kr.sync.cache.json
+++ b/.vscode/ftp-kr.sync.cache.json
@@ -1824,8 +1824,8 @@
"css": {
"app.css": {
"type": "-",
- "size": 45416,
- "lmtime": 1774702916830,
+ "size": 57250,
+ "lmtime": 1774820920578,
"modified": false
},
"app.css.map": {
@@ -1897,8 +1897,8 @@
"lang": {
"pl.php": {
"type": "-",
- "size": 62881,
- "lmtime": 1773788074010,
+ "size": 62845,
+ "lmtime": 1774861982010,
"modified": false
}
},
@@ -1933,8 +1933,8 @@
},
"app.scss": {
"type": "-",
- "size": 43784,
- "lmtime": 1774701658193,
+ "size": 45727,
+ "lmtime": 1774820913052,
"modified": false
},
"login.css": {
@@ -2013,17 +2013,17 @@
}
},
"components": {
- "table-list.php": {
- "type": "-",
- "size": 21805,
- "lmtime": 1771925480312,
- "modified": false
- },
"order-status-panel.php": {
"type": "-",
- "size": 1743,
- "lmtime": 1772497361423,
+ "size": 2682,
+ "lmtime": 1774819455980,
"modified": false
+ },
+ "table-list.php": {
+ "type": "-",
+ "size": 21914,
+ "lmtime": 1771925480312,
+ "modified": true
}
},
"dashboard": {
@@ -2037,8 +2037,8 @@
"layouts": {
"app.php": {
"type": "-",
- "size": 9591,
- "lmtime": 1774566330881,
+ "size": 11645,
+ "lmtime": 1774818596878,
"modified": false
},
"auth.php": {
@@ -2091,8 +2091,8 @@
},
"show.php": {
"type": "-",
- "size": 40747,
- "lmtime": 1774474658482,
+ "size": 41726,
+ "lmtime": 1774820344044,
"modified": false
}
},
diff --git a/DOCS/ARCHITECTURE.md b/DOCS/ARCHITECTURE.md
index 9ff7b91..f7f9117 100644
--- a/DOCS/ARCHITECTURE.md
+++ b/DOCS/ARCHITECTURE.md
@@ -41,6 +41,7 @@
- `POST /orders/{id}/status`
- `POST /orders/{id}/send-email` (wysylka e-mail z zamowienia, AJAX)
- `POST /orders/{id}/email-preview` (podglad szablonu z rozwiazanymi zmiennymi, AJAX)
+- `POST /orders/{id}/payment/add` (dodanie platnosci recznej, AJAX + push set_paid do shopPRO)
- `GET /accounting` (lista paragonow z filtrami i paginacja)
- `GET /accounting/export` (eksport XLSX z aktywnymi filtrami)
- `GET /users` (redirect do `/settings/users`)
diff --git a/DOCS/DB_SCHEMA.md b/DOCS/DB_SCHEMA.md
index b69b2be..78c7c7c 100644
--- a/DOCS/DB_SCHEMA.md
+++ b/DOCS/DB_SCHEMA.md
@@ -223,6 +223,26 @@ Migracje z prefiksem `ensure_` to migracje kompensujące — zostały dodane
- `allegro_order_status_mappings_code_unique` (UNIQUE: `allegro_status_code`),
- `allegro_order_status_mappings_orderpro_code_idx` (`orderpro_status_code`).
+### `order_payments`
+- Platnosci zamowien (z importu API lub reczne).
+- Kolumny:
+ - `id` (PK, bigint unsigned, AI),
+ - `order_id` (bigint unsigned, FK -> `orders.id`, CASCADE),
+ - `source_payment_id` (varchar 64, nullable),
+ - `external_payment_id` (varchar 64, nullable),
+ - `payment_type_id` (varchar 64, NOT NULL) — typ: ONLINE, TRANSFER, CASH_ON_DELIVERY,
+ - `payment_date` (datetime, nullable),
+ - `amount` (decimal 12,2, nullable),
+ - `currency` (char 3, nullable),
+ - `comment` (varchar 255, nullable),
+ - `payload_json` (json, nullable),
+ - `created_at`, `updated_at`.
+- Indeksy:
+ - `order_payments_order_source_payment_unique` (UNIQUE: `order_id`, `source_payment_id`),
+ - `order_payments_order_idx` (`order_id`),
+ - `order_payments_date_idx` (`payment_date`).
+- Migracja: `20260330_000073_create_order_payments_table.sql`
+
### `order_activity_log`
- Uniwersalny log aktywnosci zamowienia (zmiany statusow, platnosci, przesylki, faktury, wiadomosci itp.).
- Kolumny:
diff --git a/DOCS/TECH_CHANGELOG.md b/DOCS/TECH_CHANGELOG.md
index aa7d8c0..a79fd06 100644
--- a/DOCS/TECH_CHANGELOG.md
+++ b/DOCS/TECH_CHANGELOG.md
@@ -1,5 +1,15 @@
# Tech Changelog
+## 2026-03-30 (Phase 56 - Order Payments, Plan 01)
+- Migracja `20260330_000073_create_order_payments_table.sql`: tabela `order_payments` (id, order_id, source_payment_id, external_payment_id, payment_type_id, payment_date, amount, currency, comment, payload_json) + idempotentne dodanie kolumn `total_with_tax`, `total_paid`, `external_payment_type_id` do `orders`.
+- `OrdersRepository::addPayment()`: INSERT do `order_payments`, przeliczenie `total_paid` i `payment_status` na `orders`.
+- `OrdersRepository::findOrderSourceInfo()`: pobiera `source`, `integration_id`, `source_order_id` dla push do shopPRO.
+- `OrdersController::addPayment()`: POST `/orders/{id}/payment/add` — walidacja, zapis platnosci, activity log, push do shopPRO.
+- `OrdersController::pushPaymentToShoppro()`: po dodaniu platnosci pokrywajacej calosc — PUT `set_paid` do shopPRO API.
+- `ShopproApiClient::setOrderPaid()`: PUT `/api.php?endpoint=orders&action=set_paid&id={sourceOrderId}`.
+- `resources/views/orders/show.php`: przycisk "Dodaj platnosc", formularz inline (kwota, typ, data, komentarz), AJAX submit z reload.
+- Style: `.payment-add-form` w `resources/scss/app.scss`.
+
## 2026-03-28 (Phase 51 - Email HTML Layout, Plan 01)
- Migracja `20260328_000001_add_html_layout_to_email_mailboxes.sql`: kolumny `header_html` TEXT NULL i `footer_html` TEXT NULL w `email_mailboxes`.
- `EmailMailboxRepository::save()`: zapis `header_html`/`footer_html` w INSERT i UPDATE.
diff --git a/database/migrations/20260304_000023_create_allegro_integration_settings_table.sql b/database/migrations/20260304_000023_create_allegro_integration_settings_table.sql
index 4646b56..af03c4e 100644
--- a/database/migrations/20260304_000023_create_allegro_integration_settings_table.sql
+++ b/database/migrations/20260304_000023_create_allegro_integration_settings_table.sql
@@ -1,5 +1,5 @@
CREATE TABLE IF NOT EXISTS allegro_integration_settings (
- id TINYINT UNSIGNED NOT NULL PRIMARY KEY,
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
environment VARCHAR(16) NOT NULL DEFAULT 'sandbox',
client_id VARCHAR(128) NULL,
client_secret_encrypted TEXT NULL,
diff --git a/database/migrations/20260330_000073_create_order_payments_table.sql b/database/migrations/20260330_000073_create_order_payments_table.sql
new file mode 100644
index 0000000..f20688c
--- /dev/null
+++ b/database/migrations/20260330_000073_create_order_payments_table.sql
@@ -0,0 +1,67 @@
+-- =============================================================
+-- Migracja: Tabela order_payments
+-- Cel: Przechowywanie platnosci zamowien (recznych i z importu)
+-- Zrodlo schematu: database/drafts/20260302_orders_schema_v1.sql
+-- Idempotentna: CREATE TABLE IF NOT EXISTS
+-- =============================================================
+
+CREATE TABLE IF NOT EXISTS order_payments (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ order_id BIGINT UNSIGNED NOT NULL,
+ source_payment_id VARCHAR(64) NULL,
+ external_payment_id VARCHAR(64) NULL,
+ payment_type_id VARCHAR(64) NOT NULL,
+ payment_date DATETIME NULL,
+ amount DECIMAL(12,2) NULL,
+ currency CHAR(3) NULL,
+ comment VARCHAR(255) 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 order_payments_order_source_payment_unique (order_id, source_payment_id),
+ KEY order_payments_order_idx (order_id),
+ KEY order_payments_date_idx (payment_date),
+ CONSTRAINT order_payments_order_fk
+ FOREIGN KEY (order_id) REFERENCES orders(id)
+ ON DELETE CASCADE ON UPDATE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- Kolumny payment w orders — idempotentne dodanie (mogly byc juz zaladowane z deploy scriptu)
+SET @sql := (
+ SELECT IF(COUNT(*) = 0,
+ 'ALTER TABLE `orders` ADD COLUMN `total_with_tax` DECIMAL(12,2) NULL AFTER `total_net`',
+ 'SELECT 1')
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = 'orders'
+ AND column_name = 'total_with_tax'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @sql := (
+ SELECT IF(COUNT(*) = 0,
+ 'ALTER TABLE `orders` ADD COLUMN `total_paid` DECIMAL(12,2) NULL AFTER `total_with_tax`',
+ 'SELECT 1')
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = 'orders'
+ AND column_name = 'total_paid'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @sql := (
+ SELECT IF(COUNT(*) = 0,
+ 'ALTER TABLE `orders` ADD COLUMN `external_payment_type_id` VARCHAR(128) NULL AFTER `payment_status`',
+ 'SELECT 1')
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = 'orders'
+ AND column_name = 'external_payment_type_id'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
diff --git a/public/assets/css/app.css b/public/assets/css/app.css
index 839746e..677dd06 100644
--- a/public/assets/css/app.css
+++ b/public/assets/css/app.css
@@ -545,6 +545,7 @@
flex-wrap: wrap;
gap: 8px;
align-items: center;
+ margin-top: 16px;
margin-bottom: 16px;
}
@@ -2355,6 +2356,45 @@ details[open] > .order-statuses-side__title .order-statuses-side__arrow {
color: #0f172a;
}
+.payment-add-form {
+ background: #f8fafc;
+ border: 1px solid #e2e8f0;
+ border-radius: 6px;
+ padding: 12px;
+ max-width: 700px;
+}
+
+.payment-add-form__row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+}
+
+.payment-add-form__field {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ flex: 1 1 140px;
+ min-width: 120px;
+}
+.payment-add-form__field label {
+ font-size: 11px;
+ color: #64748b;
+ font-weight: 500;
+}
+.payment-add-form__field input, .payment-add-form__field select {
+ font-size: 12px;
+ padding: 4px 8px;
+ border: 1px solid #cbd5e1;
+ border-radius: 4px;
+ height: 30px;
+}
+
+.payment-add-form__actions {
+ display: flex;
+ gap: 8px;
+}
+
.order-kv dt {
color: #64748b;
}
diff --git a/resources/lang/pl.php b/resources/lang/pl.php
index e754fb3..6e38a27 100644
--- a/resources/lang/pl.php
+++ b/resources/lang/pl.php
@@ -24,7 +24,7 @@ return [
'orders' => 'Zamowienia',
'orders_list' => 'Lista zamowien',
'marketplace' => 'Marketplace',
- 'cron' => 'Cron',
+ 'cron' => 'Harmonogram',
'dashboard' => 'Dashboard',
'settings' => 'Ustawienia',
'statuses' => 'Statusy',
@@ -1038,7 +1038,7 @@ return [
],
],
'cron' => [
- 'title' => 'Cron',
+ 'title' => 'Harmonogram',
'run_on_web_title' => 'Uruchamianie crona podczas nawigacji',
'run_on_web_description' => 'Po wlaczeniu worker cron uruchamia sie automatycznie podczas poruszania po panelu.',
'run_on_web_label' => 'Wlacz uruchamianie crona podczas requestow HTTP',
diff --git a/resources/scss/app.css b/resources/scss/app.css
index 9bceac3..677dd06 100644
--- a/resources/scss/app.css
+++ b/resources/scss/app.css
@@ -2,6 +2,8 @@
:root {
--c-primary: #6690f4;
--c-primary-dark: #3164db;
+ --c-action-primary: #0f766e;
+ --c-action-primary-dark: #0b5f59;
--c-bg: #f4f6f9;
--c-surface: #ffffff;
--c-text: #4e5e6a;
@@ -10,6 +12,7 @@
--c-border: #b0bec5;
--c-danger: #cc0000;
--focus-ring: 0 0 0 3px rgba(102, 144, 244, 0.15);
+ --focus-ring-action: 0 0 0 3px rgba(15, 118, 110, 0.18);
--shadow-card: 0 1px 4px rgba(0, 0, 0, 0.06);
}
@@ -30,11 +33,11 @@
.btn--primary {
color: #ffffff;
- background: var(--c-primary);
+ background: var(--c-action-primary);
}
.btn--primary:hover {
- background: var(--c-primary-dark);
+ background: var(--c-action-primary-dark);
}
.btn--secondary {
@@ -69,22 +72,28 @@
width: 100%;
}
+.btn--disabled {
+ opacity: 0.3;
+ cursor: not-allowed;
+ pointer-events: none;
+}
+
.btn:active {
transform: translateY(1px);
}
.btn:focus-visible {
outline: none;
- box-shadow: var(--focus-ring);
- border-color: var(--c-primary);
+ box-shadow: var(--focus-ring-action);
+ border-color: var(--c-action-primary);
}
.form-control {
width: 100%;
- min-height: 34px;
+ min-height: 30px;
border: 1px solid var(--c-border);
- border-radius: 8px;
- padding: 5px 10px;
+ border-radius: 6px;
+ padding: 4px 8px;
font: inherit;
color: var(--c-text-strong);
background: #ffffff;
@@ -239,6 +248,518 @@
background: #edf2ff;
}
+.receipt-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ margin-bottom: 16px;
+ padding-bottom: 12px;
+ border-bottom: 2px solid var(--c-text-strong);
+}
+.receipt-header__seller {
+ flex: 1;
+}
+.receipt-header__seller strong {
+ font-size: 14px;
+ display: block;
+ margin-bottom: 4px;
+}
+.receipt-header__title {
+ text-align: right;
+}
+.receipt-header__title h1 {
+ font-size: 18px;
+ font-weight: 700;
+ margin-bottom: 4px;
+}
+
+.receipt-print {
+ max-width: 700px;
+ margin: 0 auto;
+}
+@media print {
+ .receipt-print {
+ max-width: 100%;
+ }
+}
+
+.email-send-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 1000;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.email-send-modal {
+ background: var(--c-card-bg, #fff);
+ border-radius: 8px;
+ width: 580px;
+ max-width: 95vw;
+ max-height: 90vh;
+ display: flex;
+ flex-direction: column;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
+}
+.email-send-modal__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--c-border, #e0e0e0);
+}
+.email-send-modal__header h3 {
+ margin: 0;
+ font-size: 15px;
+}
+.email-send-modal__close {
+ background: none;
+ border: none;
+ font-size: 20px;
+ cursor: pointer;
+ color: var(--c-text-muted, #888);
+ padding: 0 4px;
+}
+.email-send-modal__close:hover {
+ color: var(--c-text, #333);
+}
+.email-send-modal__body {
+ padding: 16px;
+ overflow-y: auto;
+ flex: 1;
+}
+.email-send-modal__field {
+ margin-bottom: 10px;
+}
+.email-send-modal__field label {
+ display: block;
+ font-size: 12px;
+ font-weight: 600;
+ margin-bottom: 4px;
+ color: var(--c-text-muted, #666);
+}
+.email-send-modal__field .input {
+ width: 100%;
+}
+.email-send-modal__actions-top {
+ margin-bottom: 10px;
+}
+.email-send-modal__footer {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ padding: 12px 16px;
+ border-top: 1px solid var(--c-border, #e0e0e0);
+}
+
+.email-send-preview {
+ border: 1px solid var(--c-border, #e0e0e0);
+ border-radius: 4px;
+ padding: 12px;
+ max-height: 280px;
+ overflow-y: auto;
+ background: var(--c-bg, #fafafa);
+}
+.email-send-preview__subject {
+ font-weight: 600;
+ font-size: 13px;
+ margin-bottom: 8px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid var(--c-border, #e0e0e0);
+}
+.email-send-preview__body {
+ font-size: 13px;
+ line-height: 1.5;
+}
+.email-send-preview__body p {
+ margin: 0 0 8px;
+}
+.email-send-preview__attachments {
+ margin-top: 8px;
+ padding-top: 8px;
+ border-top: 1px solid var(--c-border, #e0e0e0);
+ font-size: 12px;
+ color: var(--c-text-muted, #666);
+}
+
+.section-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.automation-row {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ padding: 10px 12px;
+ background: var(--c-surface, #f8f9fa);
+ border: 1px solid var(--c-border, #dee2e6);
+ border-radius: 6px;
+}
+.automation-row__fields {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.automation-row__type {
+ max-width: 280px;
+}
+.automation-row__config {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+.automation-row__config .form-control {
+ min-width: 200px;
+ max-width: 300px;
+}
+.automation-row__remove {
+ flex-shrink: 0;
+ margin-top: 2px;
+ line-height: 1;
+ font-size: 16px;
+ padding: 2px 8px;
+}
+
+.checkbox-group {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px 16px;
+}
+
+.checkbox-label {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 13px;
+ cursor: pointer;
+ white-space: nowrap;
+}
+.checkbox-label input[type=checkbox] {
+ margin: 0;
+}
+
+.automation-actions-cell {
+ white-space: nowrap;
+}
+
+.automation-inline-form {
+ display: inline;
+}
+
+.automation-history-filters {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
+ gap: 8px;
+ align-items: end;
+}
+.automation-history-filters .form-field {
+ margin: 0;
+}
+.automation-history-filters .field-label {
+ font-size: 12px;
+ margin-bottom: 4px;
+}
+.automation-history-filters .form-control {
+ min-height: 34px;
+}
+.automation-history-filters__actions {
+ display: flex;
+ gap: 6px;
+ align-items: center;
+ justify-content: flex-start;
+ padding-bottom: 1px;
+}
+
+.print-status-badge {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 3px;
+ font-size: 0.75rem;
+ font-weight: 600;
+ line-height: 1.4;
+}
+.print-status-badge--pending {
+ background-color: #fff3cd;
+ color: #856404;
+}
+.print-status-badge--completed {
+ background-color: #d4edda;
+ color: #155724;
+}
+.print-status-badge--failed {
+ background-color: #f8d7da;
+ color: #721c24;
+}
+
+.print-queue-filters {
+ display: flex;
+ gap: 4px;
+}
+
+.print-queue-table td, .print-queue-table th {
+ padding: 6px 8px;
+ font-size: 0.85rem;
+}
+
+.print-queue-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.print-queue-delete-form {
+ margin: 0;
+}
+
+.btn--outline-primary {
+ background: transparent;
+ border: 1px solid var(--c-action-primary);
+ color: var(--c-action-primary);
+ cursor: pointer;
+ border-radius: 3px;
+ font-size: 0.75rem;
+ padding: 3px 8px;
+ transition: background-color 0.15s, color 0.15s;
+}
+.btn--outline-primary:hover {
+ background-color: var(--c-action-primary);
+ color: #fff;
+}
+.btn--outline-primary:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+.btn--outline-primary.is-success {
+ border-color: #28a745;
+ color: #28a745;
+}
+
+.shipment-presets {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ align-items: center;
+ margin-top: 16px;
+ margin-bottom: 16px;
+}
+
+.shipment-presets__btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 6px 14px;
+ border: none;
+ border-radius: 6px;
+ background: var(--preset-color, #3b82f6);
+ color: #fff;
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: opacity 0.15s;
+ line-height: 1.4;
+}
+.shipment-presets__btn:hover {
+ opacity: 0.85;
+}
+
+.shipment-presets__add {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 6px 14px;
+ border: 1px dashed #ccc;
+ border-radius: 6px;
+ background: transparent;
+ color: #666;
+ font-size: 13px;
+ cursor: pointer;
+ transition: border-color 0.15s, color 0.15s;
+ line-height: 1.4;
+}
+.shipment-presets__add:hover {
+ border-color: #999;
+ color: #444;
+}
+
+.preset-modal {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.4);
+ z-index: 1000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.preset-modal__content {
+ background: #fff;
+ border-radius: 8px;
+ padding: 24px;
+ min-width: 360px;
+ max-width: 420px;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
+}
+.preset-modal__content h3 {
+ margin: 0 0 4px;
+ font-size: 16px;
+}
+
+.preset-modal__colors {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.preset-modal__color-swatch {
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ cursor: pointer;
+ border: 2px solid transparent;
+ transition: border-color 0.15s;
+}
+.preset-modal__color-swatch:hover {
+ border-color: #aaa;
+}
+.preset-modal__color-swatch.is-selected {
+ border-color: #333;
+}
+
+.shipment-presets__btn-wrap {
+ position: relative;
+ display: inline-flex;
+}
+.shipment-presets__btn-wrap:hover .shipment-presets__edit-icon {
+ opacity: 1;
+}
+
+.shipment-presets__edit-icon {
+ position: absolute;
+ top: -6px;
+ right: -6px;
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ background: #fff;
+ border: 1px solid #ddd;
+ font-size: 10px;
+ line-height: 16px;
+ text-align: center;
+ cursor: pointer;
+ opacity: 0;
+ transition: opacity 0.15s;
+ padding: 0;
+ color: #666;
+ z-index: 2;
+}
+.shipment-presets__edit-icon:hover {
+ background: #f3f4f6;
+ border-color: #999;
+}
+
+.shipment-presets__dropdown {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ margin-top: 4px;
+ background: #fff;
+ border: 1px solid #ddd;
+ border-radius: 6px;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
+ z-index: 100;
+ min-width: 200px;
+ padding: 4px 0;
+}
+
+.shipment-presets__dropdown-item {
+ padding: 6px 14px;
+ font-size: 13px;
+ cursor: pointer;
+ white-space: nowrap;
+}
+.shipment-presets__dropdown-item:hover {
+ background: #f3f4f6;
+}
+.shipment-presets__dropdown-item.is-danger {
+ color: #ef4444;
+}
+.shipment-presets__dropdown-item.is-danger:hover {
+ background: #fef2f2;
+}
+
+.delivery-badge {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 3px;
+ font-size: 0.8em;
+ font-weight: 500;
+ white-space: nowrap;
+}
+.delivery-badge--unknown {
+ background: #f5f5f5;
+ color: #999;
+}
+.delivery-badge--created {
+ background: #e3f2fd;
+ color: #1565c0;
+}
+.delivery-badge--confirmed {
+ background: #bbdefb;
+ color: #0d47a1;
+}
+.delivery-badge--in_transit {
+ background: #fff3e0;
+ color: #e65100;
+}
+.delivery-badge--out_for_delivery {
+ background: #ffe0b2;
+ color: #bf360c;
+}
+.delivery-badge--ready_for_pickup {
+ background: #f3e5f5;
+ color: #6a1b9a;
+}
+.delivery-badge--delivered {
+ background: #e8f5e9;
+ color: #2e7d32;
+}
+.delivery-badge--returned {
+ background: #ffebee;
+ color: #c62828;
+}
+.delivery-badge--cancelled {
+ background: #e0e0e0;
+ color: #616161;
+}
+.delivery-badge--problem {
+ background: #fff8e1;
+ color: #f57f17;
+}
+
+.tracking-link {
+ margin-left: 4px;
+ text-decoration: none;
+ font-size: 0.85em;
+}
+
+.dsm-row--custom {
+ background: rgba(59, 130, 246, 0.06);
+}
+
+.dsm-raw-status {
+ font-size: 0.82rem;
+ background: var(--surface-alt, #f1f5f9);
+ padding: 2px 6px;
+ border-radius: 3px;
+ white-space: nowrap;
+}
+
* {
box-sizing: border-box;
}
@@ -281,6 +802,33 @@ a {
.sidebar.is-collapsed {
width: 52px;
min-width: 52px;
+ padding: 18px 0;
+}
+.sidebar.is-collapsed .sidebar__brand-text {
+ display: none;
+}
+.sidebar.is-collapsed .sidebar__brand {
+ justify-content: center;
+ margin: 4px 0 16px;
+}
+.sidebar.is-collapsed .sidebar__label {
+ display: none;
+}
+.sidebar.is-collapsed .sidebar__toggle-arrow {
+ display: none;
+}
+.sidebar.is-collapsed .sidebar__link,
+.sidebar.is-collapsed .sidebar__group-toggle {
+ justify-content: center;
+ padding: 9px;
+ border-radius: 8px;
+ margin: 0 6px;
+}
+.sidebar.is-collapsed .sidebar__group-links {
+ display: none;
+}
+.sidebar.is-collapsed .sidebar__icon {
+ margin: 0;
}
.sidebar__brand {
@@ -342,8 +890,12 @@ a {
}
.sidebar__link {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ white-space: nowrap;
border-radius: 8px;
- padding: 10px 12px;
+ padding: 9px 10px;
text-decoration: none;
color: #cbd5e1;
font-weight: 600;
@@ -674,7 +1226,7 @@ h4.section-title::before {
.filters-actions {
display: flex;
- align-items: end;
+ align-items: center;
gap: 8px;
}
@@ -692,18 +1244,21 @@ h4.section-title::before {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
+ align-items: start;
}
.form-grid-3 {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
+ align-items: start;
}
.form-grid-4 {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
+ align-items: start;
}
.form-actions {
@@ -1017,6 +1572,7 @@ h4.section-title::before {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
+ align-items: end;
}
.table-col-toggle-wrapper {
@@ -1286,6 +1842,59 @@ h4.section-title::before {
align-items: center;
gap: 5px;
flex-wrap: wrap;
+ cursor: pointer;
+}
+.orders-status-wrap .order-tag {
+ cursor: pointer;
+}
+
+.orders-status-dropdown {
+ position: fixed;
+ z-index: 9999;
+ min-width: 180px;
+ max-height: 280px;
+ overflow-y: auto;
+ background: #fff;
+ border: 1px solid #d8e1ef;
+ border-radius: 6px;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
+ padding: 4px 0;
+}
+.orders-status-dropdown__group-header {
+ padding: 6px 12px 2px;
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: #94a3b8;
+}
+.orders-status-dropdown__group-header:not(:first-child) {
+ border-top: 1px solid #f1f5f9;
+ margin-top: 2px;
+ padding-top: 8px;
+}
+.orders-status-dropdown__item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 5px 12px;
+ font-size: 13px;
+ color: #334155;
+ cursor: pointer;
+ white-space: nowrap;
+}
+.orders-status-dropdown__item:hover {
+ background: #f1f5f9;
+}
+.orders-status-dropdown__item.is-current {
+ font-weight: 700;
+ background: #f8fafc;
+}
+.orders-status-dropdown__color-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ flex-shrink: 0;
}
.order-tag {
@@ -1525,6 +2134,22 @@ h4.section-title::before {
font-weight: 700;
color: #0f172a;
margin-bottom: 8px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ list-style: none;
+}
+.order-statuses-side__title::-webkit-details-marker {
+ display: none;
+}
+.order-statuses-side__arrow {
+ display: none;
+ flex-shrink: 0;
+ opacity: 0.5;
+ transition: transform 0.2s ease;
+}
+details[open] > .order-statuses-side__title .order-statuses-side__arrow {
+ transform: rotate(180deg);
}
.order-status-group {
@@ -1731,6 +2356,45 @@ h4.section-title::before {
color: #0f172a;
}
+.payment-add-form {
+ background: #f8fafc;
+ border: 1px solid #e2e8f0;
+ border-radius: 6px;
+ padding: 12px;
+ max-width: 700px;
+}
+
+.payment-add-form__row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+}
+
+.payment-add-form__field {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ flex: 1 1 140px;
+ min-width: 120px;
+}
+.payment-add-form__field label {
+ font-size: 11px;
+ color: #64748b;
+ font-weight: 500;
+}
+.payment-add-form__field input, .payment-add-form__field select {
+ font-size: 12px;
+ padding: 4px 8px;
+ border: 1px solid #cbd5e1;
+ border-radius: 4px;
+ height: 30px;
+}
+
+.payment-add-form__actions {
+ display: flex;
+ gap: 8px;
+}
+
.order-kv dt {
color: #64748b;
}
@@ -1779,6 +2443,15 @@ h4.section-title::before {
display: block;
}
+.manual-tracking-form {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+.manual-tracking-form .form-control {
+ max-width: 220px;
+}
+
.order-empty-placeholder {
border: 1px dashed #cbd5e1;
border-radius: 8px;
@@ -2355,32 +3028,74 @@ h4.section-title::before {
color: #334155;
}
+.topbar__hamburger {
+ display: none;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
+ padding: 0;
+ background: transparent;
+ border: none;
+ color: var(--c-text-strong);
+ cursor: pointer;
+ border-radius: 6px;
+ flex-shrink: 0;
+}
+.topbar__hamburger:hover {
+ background: var(--c-bg-subtle, #f1f5f9);
+}
+
+.sidebar-backdrop {
+ display: none;
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.5);
+ z-index: 999;
+ opacity: 0;
+ transition: opacity 0.25s ease;
+}
+.sidebar-backdrop.is-visible {
+ display: block;
+ opacity: 1;
+}
+
+body.no-scroll {
+ overflow: hidden;
+}
+
@media (max-width: 768px) {
- .app-shell {
- flex-direction: column;
+ .topbar__hamburger {
+ display: flex;
}
.sidebar {
- width: 100% !important;
- min-width: 0 !important;
- border-right: 0;
- border-bottom: 1px solid #243041;
- padding: 14px;
- overflow-x: auto;
+ position: fixed;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ width: 280px;
+ min-width: 280px;
+ z-index: 1000;
+ transform: translateX(-100%);
+ transition: transform 0.25s ease;
+ border-right: 1px solid #243041;
+ overflow-y: auto;
+ }
+ .sidebar.is-mobile-open {
+ transform: translateX(0);
}
.sidebar__brand {
- margin: 0 0 10px;
- font-size: 22px;
+ margin: 4px 4px 12px;
}
.sidebar__collapse-btn {
- display: none;
+ display: flex;
+ }
+ .sidebar__collapse-icon {
+ transform: rotate(180deg);
}
.sidebar__nav {
- display: flex;
- gap: 8px;
- overflow-x: auto;
- }
- .sidebar__link {
- white-space: nowrap;
+ display: grid;
+ gap: 4px;
}
.topbar {
padding: 0 14px;
@@ -2410,6 +3125,12 @@ h4.section-title::before {
position: static;
top: auto;
}
+ .order-statuses-side__title {
+ cursor: pointer;
+ }
+ .order-statuses-side__arrow {
+ display: block;
+ }
.order-details-actions {
justify-content: flex-start;
}
@@ -2462,4 +3183,141 @@ h4.section-title::before {
.modal--image-preview {
width: min(92vw, 100%);
}
+ .email-tpl-editor-wrap {
+ flex-direction: column;
+ }
+ .email-tpl-var-panel {
+ min-width: 200px;
+ }
+ .modal-box {
+ width: 95vw;
+ max-height: 90vh;
+ }
+}
+.email-tpl-editor-wrap {
+ display: flex;
+ flex-direction: column;
+ border: 1px solid var(--c-border);
+ border-radius: 6px;
+ overflow: visible;
+}
+
+.email-tpl-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 8px;
+ background: var(--c-bg-subtle, #f8f9fa);
+ border-bottom: 1px solid var(--c-border);
+}
+
+.email-tpl-var-dropdown {
+ position: relative;
+}
+
+.email-tpl-var-panel {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ z-index: 300;
+ min-width: 260px;
+ max-height: 320px;
+ overflow-y: auto;
+ background: var(--c-bg);
+ border: 1px solid var(--c-border);
+ border-radius: 6px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
+ padding: 6px;
+ margin-top: 4px;
+}
+
+.email-var-group:not(:first-child) {
+ margin-top: 6px;
+ padding-top: 6px;
+ border-top: 1px solid var(--c-border);
+}
+.email-var-group__label {
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ color: var(--c-text-muted);
+ padding: 2px 4px;
+ letter-spacing: 0.03em;
+}
+
+.email-var-item {
+ display: block;
+ width: 100%;
+ text-align: left;
+ padding: 3px 6px;
+ margin: 1px 0;
+ border: none;
+ background: none;
+ font-size: 12px;
+ font-family: "Roboto Mono", monospace;
+ color: var(--c-text);
+ border-radius: 3px;
+ cursor: pointer;
+}
+.email-var-item:hover {
+ background: var(--c-primary);
+ color: #fff;
+}
+
+#js-quill-editor {
+ min-height: 200px;
+}
+#js-quill-editor .ql-editor {
+ min-height: 200px;
+ font-size: 13px;
+}
+
+.modal-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 1000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(0, 0, 0, 0.45);
+}
+
+.modal-box {
+ width: min(680px, 90vw);
+ max-height: 80vh;
+ background: var(--c-bg);
+ border-radius: 8px;
+ box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+.modal-box__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 16px;
+ border-bottom: 1px solid var(--c-border);
+}
+.modal-box__title {
+ margin: 0;
+ font-size: 15px;
+ font-weight: 600;
+}
+.modal-box__close {
+ background: none;
+ border: none;
+ font-size: 22px;
+ line-height: 1;
+ cursor: pointer;
+ color: var(--c-text-muted);
+ padding: 0 4px;
+}
+.modal-box__close:hover {
+ color: var(--c-text);
+}
+.modal-box__body {
+ padding: 12px 16px;
+ overflow-y: auto;
+ flex: 1;
}
diff --git a/resources/scss/app.scss b/resources/scss/app.scss
index ab50a3f..6473d35 100644
--- a/resources/scss/app.scss
+++ b/resources/scss/app.scss
@@ -1645,6 +1645,47 @@ details[open] > .order-statuses-side__title .order-statuses-side__arrow {
color: #0f172a;
}
+.payment-add-form {
+ background: #f8fafc;
+ border: 1px solid #e2e8f0;
+ border-radius: 6px;
+ padding: 12px;
+ max-width: 700px;
+}
+
+.payment-add-form__row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+}
+
+.payment-add-form__field {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ flex: 1 1 140px;
+ min-width: 120px;
+
+ label {
+ font-size: 11px;
+ color: #64748b;
+ font-weight: 500;
+ }
+
+ input, select {
+ font-size: 12px;
+ padding: 4px 8px;
+ border: 1px solid #cbd5e1;
+ border-radius: 4px;
+ height: 30px;
+ }
+}
+
+.payment-add-form__actions {
+ display: flex;
+ gap: 8px;
+}
+
.order-kv dt {
color: #64748b;
}
diff --git a/resources/scss/modules/_shipment-presets.scss b/resources/scss/modules/_shipment-presets.scss
index a4bdbc7..c17993e 100644
--- a/resources/scss/modules/_shipment-presets.scss
+++ b/resources/scss/modules/_shipment-presets.scss
@@ -3,6 +3,7 @@
flex-wrap: wrap;
gap: 8px;
align-items: center;
+ margin-top: 16px;
margin-bottom: 16px;
}
diff --git a/resources/views/orders/show.php b/resources/views/orders/show.php
index 35459cd..915e714 100644
--- a/resources/views/orders/show.php
+++ b/resources/views/orders/show.php
@@ -444,12 +444,20 @@ foreach ($addressesList as $address) {
Dodana recznie
-
+
= $e($pkgStatus) ?>
+
+
+
- = $e($pkgError) ?>
+ = $e($pkgError) ?>
@@ -462,13 +470,13 @@ foreach ($addressesList as $address) {
?>
= $e($pkgDeliveryLabel) ?>
|
-
+ |
= $e($pkgTracking !== '' ? $pkgTracking : '-') ?> 🔗
|
= $e($pkgCarrierId !== '' ? $pkgCarrierId : 'Reczna') ?>= $e($pkgProviderLabel) ?> → = $e($pkgCarrierId) ?>= $e($pkgProviderLabel) ?>- |
-
+ |
—
@@ -595,8 +603,42 @@ foreach ($addressesList as $address) {
+
+
+
+
+
+
- Brak zarejestrowanych płatności.
+ Brak zarejestrowanych płatności.
@@ -739,41 +781,41 @@ foreach ($addressesList as $address) {
try { savedTab = localStorage.getItem(storageKey); } catch (e) {}
setActiveTab(forceTab || savedTab || 'details');
- // Print label button handler
- document.querySelectorAll('.btn-print-label').forEach(function (btn) {
- btn.addEventListener('click', function () {
- var packageId = btn.getAttribute('data-package-id');
- if (!packageId) return;
- btn.disabled = true;
- var originalText = btn.innerHTML;
- btn.innerHTML = 'Wysylam...';
- var csrfInput = document.querySelector('input[name="_token"]');
- var csrf = csrfInput ? csrfInput.value : '= $e($csrfToken ?? '') ?>';
+ // Print label button handler (delegated for dynamically added buttons)
+ document.addEventListener('click', function (e) {
+ var btn = e.target.closest('.btn-print-label');
+ if (!btn) return;
+ var packageId = btn.getAttribute('data-package-id');
+ if (!packageId) return;
+ btn.disabled = true;
+ var originalText = btn.innerHTML;
+ btn.innerHTML = 'Wysylam...';
+ var csrfInput = document.querySelector('input[name="_token"]');
+ var csrf = csrfInput ? csrfInput.value : '= $e($csrfToken ?? '') ?>';
- fetch('/api/print/jobs', {
- method: 'POST',
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
- body: '_token=' + encodeURIComponent(csrf) + '&package_id=' + encodeURIComponent(packageId)
- })
- .then(function (r) { return r.json().then(function (d) { return { status: r.status, data: d }; }); })
- .then(function (res) {
- if (res.status === 201 || res.status === 409) {
- btn.innerHTML = 'W kolejce';
- btn.disabled = true;
- btn.classList.remove('btn--secondary');
- btn.classList.add('btn--danger');
- } else {
- var msg = (res.data && res.data.error) ? res.data.error : 'Nieznany blad';
- if (window.OrderProAlerts) { window.OrderProAlerts.show({ message: msg, type: 'error' }); }
- btn.innerHTML = originalText;
- btn.disabled = false;
- }
- })
- .catch(function () {
- if (window.OrderProAlerts) { window.OrderProAlerts.show({ message: 'Blad sieci.', type: 'error' }); }
+ fetch('/api/print/jobs', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: '_token=' + encodeURIComponent(csrf) + '&package_id=' + encodeURIComponent(packageId)
+ })
+ .then(function (r) { return r.json().then(function (d) { return { status: r.status, data: d }; }); })
+ .then(function (res) {
+ if (res.status === 201 || res.status === 409) {
+ btn.innerHTML = 'W kolejce';
+ btn.disabled = true;
+ btn.classList.remove('btn--secondary');
+ btn.classList.add('btn--danger');
+ } else {
+ var msg = (res.data && res.data.error) ? res.data.error : 'Nieznany blad';
+ if (window.OrderProAlerts) { window.OrderProAlerts.show({ message: msg, type: 'error' }); }
btn.innerHTML = originalText;
btn.disabled = false;
- });
+ }
+ })
+ .catch(function () {
+ if (window.OrderProAlerts) { window.OrderProAlerts.show({ message: 'Blad sieci.', type: 'error' }); }
+ btn.innerHTML = originalText;
+ btn.disabled = false;
});
});
@@ -805,4 +847,184 @@ foreach ($addressesList as $address) {
})();
+
+
+
+
diff --git a/resources/views/settings/cron.php b/resources/views/settings/cron.php
index e394bb9..0ef4f3e 100644
--- a/resources/views/settings/cron.php
+++ b/resources/views/settings/cron.php
@@ -90,42 +90,6 @@ $pastTotal = max(0, (int) ($pastPagination['total'] ?? 0));
-
- = $e($t('settings.cron.future_jobs_title')) ?>
-
-
-
-
- | ID |
- = $e($t('settings.cron.fields.job_type')) ?> |
- = $e($t('settings.cron.fields.status')) ?> |
- = $e($t('settings.cron.fields.priority')) ?> |
- = $e($t('settings.cron.fields.scheduled_at')) ?> |
- = $e($t('settings.cron.fields.attempts')) ?> |
- = $e($t('settings.cron.fields.last_error')) ?> |
-
-
-
-
- | = $e($t('settings.cron.empty_future_jobs')) ?> |
-
-
-
- | = $e((string) ($item['id'] ?? 0)) ?> |
- = $e((string) ($item['job_type'] ?? '')) ?> |
- = $e((string) ($item['status'] ?? '')) ?> |
- = $e((string) ($item['priority'] ?? '')) ?> |
- = $e((string) ($item['scheduled_at'] ?? '')) ?> |
- = $e((string) ($item['attempts'] ?? 0) . '/' . (string) ($item['max_attempts'] ?? 0)) ?> |
- = $e((string) ($item['last_error'] ?? '')) ?> |
-
-
-
-
-
-
-
-
= $e($t('settings.cron.past_jobs_title')) ?>
diff --git a/routes/web.php b/routes/web.php
index 85fe6e2..c306fde 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -261,7 +261,7 @@ return static function (Application $app): void {
$shipmentPackageRepositoryForOrders
);
$printJobRepository = new PrintJobRepository($app->db());
- $ordersController = new OrdersController($template, $translator, $auth, $app->orders(), $shipmentPackageRepositoryForOrders, $receiptRepository, $receiptConfigRepository, $emailSendingService, $emailTemplateRepository, $emailMailboxRepository, $app->basePath('storage'), $printJobRepository);
+ $ordersController = new OrdersController($template, $translator, $auth, $app->orders(), $shipmentPackageRepositoryForOrders, $receiptRepository, $receiptConfigRepository, $emailSendingService, $emailTemplateRepository, $emailMailboxRepository, $app->basePath('storage'), $printJobRepository, $shopproIntegrationsRepository);
$receiptController = new ReceiptController(
$template,
$translator,
@@ -483,6 +483,7 @@ return static function (Application $app): void {
$router->get('/orders/{id}/shipment/{packageId}/status', [$shipmentController, 'checkStatus'], [$authMiddleware]);
$router->post('/orders/{id}/shipment/{packageId}/label', [$shipmentController, 'label'], [$authMiddleware]);
$router->post('/orders/{id}/shipment/manual', [$shipmentController, 'createManual'], [$authMiddleware]);
+ $router->post('/orders/{id}/payment/add', [$ordersController, 'addPayment'], [$authMiddleware]);
// --- Printing module ---
$printApiKeyRepository = new PrintApiKeyRepository($app->db());
diff --git a/src/Modules/Cron/CronHandlerFactory.php b/src/Modules/Cron/CronHandlerFactory.php
index 130e544..72abf97 100644
--- a/src/Modules/Cron/CronHandlerFactory.php
+++ b/src/Modules/Cron/CronHandlerFactory.php
@@ -157,8 +157,7 @@ final class CronHandlerFactory
new ApaczkaIntegrationRepository($this->db, $this->integrationSecret)
),
new AllegroTrackingService(
- $apiClient,
- $tokenManager
+ new InpostIntegrationRepository($this->db, $this->integrationSecret)
),
]),
new ShipmentPackageRepository($this->db),
diff --git a/src/Modules/Orders/OrdersController.php b/src/Modules/Orders/OrdersController.php
index 405b5c2..cba87b3 100644
--- a/src/Modules/Orders/OrdersController.php
+++ b/src/Modules/Orders/OrdersController.php
@@ -16,6 +16,8 @@ use App\Modules\Email\EmailSendingService;
use App\Modules\Settings\EmailMailboxRepository;
use App\Modules\Settings\EmailTemplateRepository;
use App\Modules\Settings\ReceiptConfigRepository;
+use App\Modules\Settings\ShopproApiClient;
+use App\Modules\Settings\ShopproIntegrationsRepository;
use App\Modules\Shipments\ShipmentPackageRepository;
final class OrdersController
@@ -32,7 +34,8 @@ final class OrdersController
private readonly ?EmailTemplateRepository $emailTemplateRepo = null,
private readonly ?EmailMailboxRepository $emailMailboxRepo = null,
private readonly string $storagePath = '',
- private readonly ?\App\Modules\Printing\PrintJobRepository $printJobRepo = null
+ private readonly ?\App\Modules\Printing\PrintJobRepository $printJobRepo = null,
+ private readonly ?ShopproIntegrationsRepository $shopproIntegrations = null
) {
}
@@ -781,4 +784,102 @@ final class OrdersController
return Response::json($preview);
}
+ public function addPayment(Request $request): Response
+ {
+ $orderId = max(0, (int) $request->input('id', 0));
+ if ($orderId <= 0) {
+ return Response::json(['ok' => false, 'error' => 'Nieprawidłowe ID zamówienia.'], 400);
+ }
+
+ if (!Csrf::verify((string) $request->input('_token', ''))) {
+ return Response::json(['ok' => false, 'error' => 'Nieprawidłowy token CSRF.'], 403);
+ }
+
+ $amount = (float) $request->input('amount', 0);
+ $paymentTypeId = trim((string) $request->input('payment_type_id', ''));
+ $paymentDate = trim((string) $request->input('payment_date', ''));
+ $comment = trim((string) $request->input('comment', ''));
+
+ if ($amount <= 0) {
+ return Response::json(['ok' => false, 'error' => 'Kwota musi być większa od 0.'], 422);
+ }
+ if ($paymentTypeId === '') {
+ return Response::json(['ok' => false, 'error' => 'Wybierz typ płatności.'], 422);
+ }
+
+ $result = $this->orders->addPayment($orderId, [
+ 'amount' => $amount,
+ 'payment_type_id' => $paymentTypeId,
+ 'payment_date' => $paymentDate !== '' ? $paymentDate . ' ' . date('H:i:s') : '',
+ 'comment' => $comment,
+ ]);
+
+ if ($result === null) {
+ return Response::json(['ok' => false, 'error' => 'Nie udało się zapisać płatności.'], 500);
+ }
+
+ $this->orders->recordActivity(
+ $orderId,
+ 'payment',
+ 'Dodano płatność: ' . number_format($amount, 2, '.', ' ') . ' PLN (' . $paymentTypeId . ')',
+ ['payment_id' => $result['id'], 'amount' => $amount, 'type' => $paymentTypeId],
+ 'user',
+ $this->auth->user()['name'] ?? null
+ );
+
+ $this->pushPaymentToShoppro($orderId, $result['payment_status']);
+
+ return Response::json([
+ 'ok' => true,
+ 'payment_id' => $result['id'],
+ 'payment_status' => $result['payment_status'],
+ 'total_paid' => $result['total_paid'],
+ ]);
+ }
+
+ private function pushPaymentToShoppro(int $orderId, int $paymentStatus): void
+ {
+ if ($paymentStatus !== 2 || $this->shopproIntegrations === null) {
+ return;
+ }
+
+ try {
+ $orderStmt = $this->orders->findOrderSourceInfo($orderId);
+ if ($orderStmt === null || ($orderStmt['source'] ?? '') !== 'shoppro') {
+ return;
+ }
+
+ $integrationId = (int) ($orderStmt['integration_id'] ?? 0);
+ $sourceOrderId = trim((string) ($orderStmt['source_order_id'] ?? ''));
+ if ($integrationId <= 0 || $sourceOrderId === '') {
+ return;
+ }
+
+ $integration = $this->shopproIntegrations->findIntegration($integrationId);
+ if ($integration === null || empty($integration['is_active']) || empty($integration['has_api_key'])) {
+ return;
+ }
+
+ $baseUrl = trim((string) ($integration['base_url'] ?? ''));
+ $apiKey = $this->shopproIntegrations->getApiKeyDecrypted($integrationId);
+ if ($baseUrl === '' || $apiKey === null || trim($apiKey) === '') {
+ return;
+ }
+
+ $client = new ShopproApiClient();
+ $pushResult = $client->setOrderPaid($baseUrl, $apiKey, 10, $sourceOrderId);
+
+ $this->orders->recordActivity(
+ $orderId,
+ 'sync',
+ $pushResult['ok']
+ ? 'Wysłano status płatności do shopPRO (opłacone)'
+ : 'Błąd push płatności do shopPRO: ' . ($pushResult['message'] ?? 'unknown'),
+ ['direction' => 'push', 'target' => 'shoppro', 'ok' => $pushResult['ok']],
+ 'system'
+ );
+ } catch (\Throwable) {
+ }
+ }
+
}
diff --git a/src/Modules/Orders/OrdersRepository.php b/src/Modules/Orders/OrdersRepository.php
index 4257b0c..e3deb5d 100644
--- a/src/Modules/Orders/OrdersRepository.php
+++ b/src/Modules/Orders/OrdersRepository.php
@@ -847,6 +847,94 @@ final class OrdersRepository
], $actorType, $actorName);
}
+ /**
+ * @param array $data Keys: payment_type_id, amount, payment_date, comment, currency
+ * @return array{id:int, payment_status:int, total_paid:float}|null
+ */
+ /**
+ * @return array{source:string, integration_id:int, source_order_id:string}|null
+ */
+ public function findOrderSourceInfo(int $orderId): ?array
+ {
+ if ($orderId <= 0) {
+ return null;
+ }
+ $stmt = $this->pdo->prepare('SELECT source, integration_id, source_order_id FROM orders WHERE id = :id LIMIT 1');
+ $stmt->execute(['id' => $orderId]);
+ $row = $stmt->fetch(PDO::FETCH_ASSOC);
+ return is_array($row) ? $row : null;
+ }
+
+ /**
+ * @param array $data Keys: payment_type_id, amount, payment_date, comment, currency
+ * @return array{id:int, payment_status:int, total_paid:float}|null
+ */
+ public function addPayment(int $orderId, array $data): ?array
+ {
+ if ($orderId <= 0) {
+ return null;
+ }
+
+ $stmt = $this->pdo->prepare('SELECT id, total_with_tax, currency FROM orders WHERE id = :id LIMIT 1');
+ $stmt->execute(['id' => $orderId]);
+ $order = $stmt->fetch(PDO::FETCH_ASSOC);
+ if (!is_array($order)) {
+ return null;
+ }
+
+ $amount = round((float) ($data['amount'] ?? 0), 2);
+ $paymentTypeId = trim((string) ($data['payment_type_id'] ?? ''));
+ $paymentDate = trim((string) ($data['payment_date'] ?? ''));
+ $comment = trim((string) ($data['comment'] ?? ''));
+ $currency = trim((string) ($data['currency'] ?? $order['currency'] ?? 'PLN'));
+
+ if ($amount <= 0 || $paymentTypeId === '') {
+ return null;
+ }
+
+ $sourcePaymentId = 'manual_' . $orderId . '_' . time();
+
+ $insert = $this->pdo->prepare(
+ 'INSERT INTO order_payments (order_id, source_payment_id, payment_type_id, payment_date, amount, currency, comment, created_at, updated_at)
+ VALUES (:order_id, :source_payment_id, :payment_type_id, :payment_date, :amount, :currency, :comment, NOW(), NOW())'
+ );
+ $insert->execute([
+ 'order_id' => $orderId,
+ 'source_payment_id' => $sourcePaymentId,
+ 'payment_type_id' => $paymentTypeId,
+ 'payment_date' => $paymentDate !== '' ? $paymentDate : date('Y-m-d H:i:s'),
+ 'amount' => $amount,
+ 'currency' => $currency,
+ 'comment' => $comment !== '' ? $comment : null,
+ ]);
+ $paymentId = (int) $this->pdo->lastInsertId();
+
+ $sumStmt = $this->pdo->prepare('SELECT COALESCE(SUM(amount), 0) FROM order_payments WHERE order_id = :order_id');
+ $sumStmt->execute(['order_id' => $orderId]);
+ $totalPaid = round((float) $sumStmt->fetchColumn(), 2);
+
+ $totalWithTax = $order['total_with_tax'] !== null ? (float) $order['total_with_tax'] : null;
+ $paymentStatus = 0;
+ if ($totalPaid > 0 && $totalWithTax !== null && $totalPaid >= $totalWithTax) {
+ $paymentStatus = 2;
+ } elseif ($totalPaid > 0) {
+ $paymentStatus = 1;
+ }
+
+ $update = $this->pdo->prepare('UPDATE orders SET payment_status = :payment_status, total_paid = :total_paid, updated_at = NOW() WHERE id = :id');
+ $update->execute([
+ 'payment_status' => $paymentStatus,
+ 'total_paid' => $totalPaid,
+ 'id' => $orderId,
+ ]);
+
+ return [
+ 'id' => $paymentId,
+ 'payment_status' => $paymentStatus,
+ 'total_paid' => $totalPaid,
+ ];
+ }
+
public function updateOrderStatus(int $orderId, string $newStatusCode, string $actorType = 'user', ?string $actorName = null): bool
{
try {
diff --git a/src/Modules/Settings/AllegroIntegrationController.php b/src/Modules/Settings/AllegroIntegrationController.php
index 296e8cb..d3edf5a 100644
--- a/src/Modules/Settings/AllegroIntegrationController.php
+++ b/src/Modules/Settings/AllegroIntegrationController.php
@@ -36,6 +36,7 @@ final class AllegroIntegrationController
];
private const OAUTH_SCOPES = [
AllegroOAuthClient::ORDERS_READ_SCOPE,
+ AllegroOAuthClient::ORDERS_WRITE_SCOPE,
AllegroOAuthClient::SALE_OFFERS_READ_SCOPE,
AllegroOAuthClient::SHIPMENTS_READ_SCOPE,
AllegroOAuthClient::SHIPMENTS_WRITE_SCOPE,
diff --git a/src/Modules/Settings/AllegroOAuthClient.php b/src/Modules/Settings/AllegroOAuthClient.php
index 0a8398b..d335896 100644
--- a/src/Modules/Settings/AllegroOAuthClient.php
+++ b/src/Modules/Settings/AllegroOAuthClient.php
@@ -8,6 +8,7 @@ use App\Core\Exceptions\AllegroOAuthException;
final class AllegroOAuthClient
{
public const ORDERS_READ_SCOPE = 'allegro:api:orders:read';
+ public const ORDERS_WRITE_SCOPE = 'allegro:api:orders:write';
public const SALE_OFFERS_READ_SCOPE = 'allegro:api:sale:offers:read';
public const SHIPMENTS_READ_SCOPE = 'allegro:api:shipments:read';
public const SHIPMENTS_WRITE_SCOPE = 'allegro:api:shipments:write';
diff --git a/src/Modules/Settings/ShopproApiClient.php b/src/Modules/Settings/ShopproApiClient.php
index c44bce5..d9f3a55 100644
--- a/src/Modules/Settings/ShopproApiClient.php
+++ b/src/Modules/Settings/ShopproApiClient.php
@@ -249,6 +249,34 @@ final class ShopproApiClient
];
}
+ /**
+ * @return array{ok:bool,http_code:int|null,message:string}
+ */
+ public function setOrderPaid(
+ string $baseUrl,
+ string $apiKey,
+ int $timeoutSeconds,
+ string $sourceOrderId
+ ): array {
+ if ($sourceOrderId === '') {
+ return ['ok' => false, 'http_code' => null, 'message' => 'Brak source_order_id.'];
+ }
+
+ $url = rtrim(trim($baseUrl), '/') . '/api.php?' . http_build_query([
+ 'endpoint' => 'orders',
+ 'action' => 'set_paid',
+ 'id' => $sourceOrderId,
+ ]);
+
+ $response = $this->requestJsonPut($url, $apiKey, $timeoutSeconds, json_encode(['send_email' => 0], JSON_THROW_ON_ERROR));
+
+ return [
+ 'ok' => ($response['ok'] ?? false) === true,
+ 'http_code' => $response['http_code'] ?? null,
+ 'message' => (string) ($response['message'] ?? ''),
+ ];
+ }
+
/**
* @return array{ok:bool,http_code:int|null,message:string,data:array|array|null}
*/
diff --git a/src/Modules/Shipments/AllegroShipmentService.php b/src/Modules/Shipments/AllegroShipmentService.php
index c0c5d1a..ed25699 100644
--- a/src/Modules/Shipments/AllegroShipmentService.php
+++ b/src/Modules/Shipments/AllegroShipmentService.php
@@ -86,7 +86,7 @@ final class AllegroShipmentService implements ShipmentProviderInterface
'deliveryMethodId' => $deliveryMethodId,
'sender' => $senderAddress,
'receiver' => $receiverAddress,
- 'referenceNumber' => $sourceOrderId !== '' ? $sourceOrderId : (string) $orderId,
+ 'referenceNumber' => substr($sourceOrderId !== '' ? $sourceOrderId : (string) $orderId, 0, 35),
'packages' => [[
'type' => $packageType,
'length' => ['value' => $lengthCm, 'unit' => 'CENTIMETER'],
@@ -140,7 +140,7 @@ final class AllegroShipmentService implements ShipmentProviderInterface
'label_format' => $labelFormat,
'receiver_point_id' => trim((string) ($formData['receiver_point_id'] ?? '')),
'sender_point_id' => trim((string) ($formData['sender_point_id'] ?? '')),
- 'reference_number' => $sourceOrderId !== '' ? $sourceOrderId : (string) $orderId,
+ 'reference_number' => substr($sourceOrderId !== '' ? $sourceOrderId : (string) $orderId, 0, 35),
'payload_json' => $apiPayload,
]);
@@ -196,10 +196,11 @@ final class AllegroShipmentService implements ShipmentProviderInterface
if ($status === 'SUCCESS' && $shipmentId !== '') {
$details = $this->apiClient->getShipmentDetails($env, $accessToken, $shipmentId);
- $trackingNumber = trim((string) ($details['waybill'] ?? ''));
+ $detailPackages = is_array($details['packages'] ?? null) ? $details['packages'] : [];
+ $trackingNumber = trim((string) ($detailPackages[0]['waybill'] ?? ''));
$carrierId = trim((string) ($package['carrier_id'] ?? ''));
if ($carrierId === '') {
- $carrierId = trim((string) ($details['carrierId'] ?? ''));
+ $carrierId = trim((string) ($details['carrier'] ?? ''));
}
$this->packages->update($packageId, [
@@ -264,7 +265,7 @@ final class AllegroShipmentService implements ShipmentProviderInterface
[$accessToken, $env] = $this->tokenManager->resolveToken();
$labelFormat = trim((string) ($package['label_format'] ?? 'PDF'));
- $pageSize = $labelFormat === 'ZPL' ? 'A6' : 'A4';
+ $pageSize = 'A6';
$binary = $this->apiClient->getShipmentLabel($env, $accessToken, [$shipmentId], $pageSize);
$dir = rtrim($storagePath, '/\\') . '/labels';
@@ -286,10 +287,11 @@ final class AllegroShipmentService implements ShipmentProviderInterface
if (trim((string) ($package['tracking_number'] ?? '')) === '') {
try {
$details = $this->apiClient->getShipmentDetails($env, $accessToken, $shipmentId);
- $trackingNumber = trim((string) ($details['waybill'] ?? ''));
+ $detailPackages = is_array($details['packages'] ?? null) ? $details['packages'] : [];
+ $trackingNumber = trim((string) ($detailPackages[0]['waybill'] ?? ''));
$carrierId = trim((string) ($package['carrier_id'] ?? ''));
if ($carrierId === '') {
- $carrierId = trim((string) ($details['carrierId'] ?? ''));
+ $carrierId = trim((string) ($details['carrier'] ?? ''));
}
if ($trackingNumber !== '') {
diff --git a/src/Modules/Shipments/AllegroTrackingService.php b/src/Modules/Shipments/AllegroTrackingService.php
index 4f8cbfe..50e4018 100644
--- a/src/Modules/Shipments/AllegroTrackingService.php
+++ b/src/Modules/Shipments/AllegroTrackingService.php
@@ -3,15 +3,16 @@ declare(strict_types=1);
namespace App\Modules\Shipments;
-use App\Modules\Settings\AllegroApiClient;
-use App\Modules\Settings\AllegroTokenManager;
+use App\Modules\Settings\InpostIntegrationRepository;
use Throwable;
final class AllegroTrackingService implements ShipmentTrackingInterface
{
+ private const INPOST_API_PRODUCTION = 'https://api-shipx-pl.easypack24.net/v1';
+ private const INPOST_API_SANDBOX = 'https://sandbox-api-shipx-pl.easypack24.net/v1';
+
public function __construct(
- private readonly AllegroApiClient $apiClient,
- private readonly AllegroTokenManager $tokenManager
+ private readonly InpostIntegrationRepository $inpostRepository
) {
}
@@ -22,32 +23,122 @@ final class AllegroTrackingService implements ShipmentTrackingInterface
public function getDeliveryStatus(array $package): ?array
{
- $shipmentId = trim((string) ($package['shipment_id'] ?? ''));
- if ($shipmentId === '') {
+ $trackingNumber = trim((string) ($package['tracking_number'] ?? ''));
+ if ($trackingNumber === '') {
return null;
}
- return $this->fetchStatus($shipmentId);
+ $carrierId = strtolower(trim((string) ($package['carrier_id'] ?? '')));
+
+ if (str_contains($carrierId, 'inpost') || str_contains($carrierId, 'paczkomat')) {
+ return $this->fetchInpostStatus($trackingNumber);
+ }
+
+ // Allegro Delivery (One Kurier), DHL, DPD via Allegro — brak publicznego API trackingu
+ return null;
}
- private function fetchStatus(string $shipmentId): ?array
+ private function fetchInpostStatus(string $trackingNumber): ?array
{
try {
- [$accessToken, $env] = $this->tokenManager->resolveToken();
- $details = $this->apiClient->getShipmentDetails($env, $accessToken, $shipmentId);
+ $token = $this->resolveInpostToken();
+ if ($token === null) {
+ return null;
+ }
- $rawStatus = strtoupper(trim((string) ($details['status'] ?? '')));
+ $settings = $this->inpostRepository->getSettings();
+ $env = (string) ($settings['environment'] ?? 'sandbox');
+ $baseUrl = strtolower(trim($env)) === 'production'
+ ? self::INPOST_API_PRODUCTION
+ : self::INPOST_API_SANDBOX;
+
+ $url = $baseUrl . '/tracking/' . rawurlencode($trackingNumber);
+ $response = $this->apiRequest($url, $token);
+
+ $details = is_array($response['tracking_details'] ?? null) ? $response['tracking_details'] : [];
+ if ($details === []) {
+ return null;
+ }
+
+ $rawStatus = strtolower(trim((string) ($details[0]['status'] ?? '')));
if ($rawStatus === '') {
return null;
}
return [
- 'status' => DeliveryStatus::normalize('allegro_wza', $rawStatus),
+ 'status' => DeliveryStatus::normalize('inpost', $rawStatus),
'status_raw' => $rawStatus,
- 'description' => DeliveryStatus::description('allegro_wza', $rawStatus),
+ 'description' => DeliveryStatus::description('inpost', $rawStatus),
];
} catch (Throwable) {
return null;
}
}
+
+ private function resolveInpostToken(): ?string
+ {
+ try {
+ $token = $this->inpostRepository->getDecryptedToken();
+ return ($token !== null && trim($token) !== '') ? trim($token) : null;
+ } catch (Throwable) {
+ return null;
+ }
+ }
+
+ /**
+ * @return array
+ */
+ private function apiRequest(string $url, string $token): array
+ {
+ $ch = curl_init($url);
+ if ($ch === false) {
+ return [];
+ }
+
+ $opts = [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => 15,
+ CURLOPT_CONNECTTIMEOUT => 5,
+ CURLOPT_SSL_VERIFYPEER => true,
+ CURLOPT_SSL_VERIFYHOST => 2,
+ CURLOPT_HTTPHEADER => [
+ 'Authorization: Bearer ' . $token,
+ 'Accept: application/json',
+ ],
+ ];
+
+ $caPath = $this->getCaBundlePath();
+ if ($caPath !== null) {
+ $opts[CURLOPT_CAINFO] = $caPath;
+ }
+
+ curl_setopt_array($ch, $opts);
+ $body = curl_exec($ch);
+ $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ $ch = null;
+
+ if ($body === false || $httpCode < 200 || $httpCode >= 300) {
+ return [];
+ }
+
+ $json = json_decode((string) $body, true);
+ return is_array($json) ? $json : [];
+ }
+
+ private function getCaBundlePath(): ?string
+ {
+ $candidates = [
+ (string) ($_ENV['CURL_CA_BUNDLE_PATH'] ?? ''),
+ (string) ini_get('curl.cainfo'),
+ 'C:/xampp/apache/bin/curl-ca-bundle.crt',
+ 'C:/xampp/php/extras/ssl/cacert.pem',
+ '/etc/ssl/certs/ca-certificates.crt',
+ ];
+ foreach ($candidates as $path) {
+ if ($path !== '' && is_file($path)) {
+ return $path;
+ }
+ }
+ return null;
+ }
}
diff --git a/src/Modules/Shipments/DeliveryStatus.php b/src/Modules/Shipments/DeliveryStatus.php
index 721956a..6091340 100644
--- a/src/Modules/Shipments/DeliveryStatus.php
+++ b/src/Modules/Shipments/DeliveryStatus.php
@@ -308,10 +308,6 @@ final class DeliveryStatus
return 'https://inpost.pl/sledzenie-przesylek?number=' . $encoded;
}
- if ($provider === 'allegro_wza') {
- return 'https://allegro.pl/przesylka/' . $encoded;
- }
-
if ($carrierId !== '') {
$url = self::matchCarrierByName($encoded, strtolower(trim($carrierId)));
if ($url !== null) {
@@ -319,6 +315,10 @@ final class DeliveryStatus
}
}
+ if ($provider === 'allegro_wza') {
+ return 'https://allegro.pl/allegrodelivery/sledzenie-paczki?numer=' . $encoded;
+ }
+
return 'https://www.google.com/search?q=' . $encoded . '+sledzenie+przesylki';
}
@@ -348,6 +348,9 @@ final class DeliveryStatus
if (str_contains($carrier, 'gls')) {
return 'https://gls-group.com/PL/pl/sledzenie-paczek?match=' . $encoded;
}
+ if ($carrier === 'allegro') {
+ return 'https://allegro.pl/allegrodelivery/sledzenie-paczki?numer=' . $encoded;
+ }
return null;
}
|