feat(124): sms templates CRUD + order picker
- Nowa tabela sms_templates (name + body + is_active) + minimalny CRUD.
- /settings/sms-templates: lista + formularz z paleta zmiennych (pill chips).
- Wydzielono Sms\SmsVariableResolver ze wspolna logika placeholderow;
Email\VariableResolver staje sie cienka fasada — EmailSendingService bez zmian.
- Dropdown "Wybierz szablon" w zakladce SMS na /orders/{id} z fetch
GET /orders/{id}/sms/template + OrderProAlerts.confirm przy nadpisaniu.
- Stopka SMSPLANET dalej doklejana wylacznie przez SmsConversationService
(Phase 122 contract preserved).
- Sidebar Ustawien: nowy link "Szablony SMS".
Migration: 20260512_000112_create_sms_templates.sql (CREATE TABLE).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,8 +13,8 @@ Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów
|
||||
| Attribute | Value |
|
||||
|-----------|-------|
|
||||
| Version | 3.7.0-dev |
|
||||
| Status | v3.7 in progress — Phases 113-117 shipped (Fakturownia + HostedSMS/SMSPLANET settings/test SMS) |
|
||||
| Last Updated | 2026-05-12 (Phase 120 closed) |
|
||||
| Status | v3.7 in progress — Phases 113-124 shipped (Fakturownia + HostedSMS/SMSPLANET + Alert unify + receipt VAT + SMS templates) |
|
||||
| Last Updated | 2026-05-13 (Phase 124 closed) |
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -124,6 +124,7 @@ Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów
|
||||
- [x] Re-import ochrona `total_paid`: gdy `payment_status` sie nie zmienia, `updateOrderDelta()` nie nadpisuje `total_paid` (ani `is_canceled_by_buyer`, chyba ze cancel ze zrodla); chroni reczne korekty operatora (zwroty czesciowe). Dynamic SQL SET builder + 3 testy PHPUnit (Reflection + sqlite) — Phase 119
|
||||
- [x] Ujednolicony moduł alertów UI: reusable PHP komponent `resources/views/components/alert.php` z inline SVG ikoną per typ (info/success/warning/danger), opcjonalnym dismiss button (vanilla JS, idempotent); brakujący `.alert--info` (#eff6ff/#bfdbfe/#1e3a8a); `Flash::push/all` z BC dla `set/get` (heurystyka klucza legacy); centralny renderer flash w 3 layoutach (app/auth/public); 36 widoków zmigrowanych off inline alert markup; `.flash--*` usunięte z widoków — Phase 120
|
||||
- [x] Eksport XLSX paragonow w `/accounting`: nowe naglowki (Numer | Data wystawienia | Kwota brutto | Kwota netto | Stawka VAT | Kwota VAT) z osobnym wierszem per stawka VAT; `items_json` snapshot rozszerzony o `vat` per pozycja (z `order_items.tax_rate`, fallback 23.0); legacy fallback `net = brutto/1.23` — Phase 123
|
||||
- [x] Szablony SMS: CRUD w `/settings/sms-templates` (name + body + is_active), wspolny `SmsVariableResolver` wydzielony z Email\\VariableResolver (placeholdery `{{zamowienie.*|kupujacy.*|adres.*|firma.*|przesylka.*}}`), dropdown "Wybierz szablon" w zakladce SMS na `/orders/{id}` wstawia rozwiniete zmienne do textarea (z `OrderProAlerts.confirm` przy nadpisaniu); stopka SMSPLANET dalej doklejana wylacznie przez `SmsConversationService::buildFinalOutboundBody()` (Phase 122 contract preserved) — Phase 124
|
||||
|
||||
### Deferred
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ Wystawianie faktur dla klientow z NIP poprzez integracje z Fakturownia (app.fakt
|
||||
| 121 | SMSPLANET Conversation + Notifications | 1/1 | Complete (2026-05-12; live SMS/browser smoke pending operator) |
|
||||
| 122 | SMSPLANET Default SMS Footer | 1/1 | Complete (2026-05-12; live SMS smoke + over-limit UI test pending operator) |
|
||||
| 123 | Receipts Export VAT Breakdown | 1/1 | Complete (2026-05-12; manual XLSX smoke pending operator) |
|
||||
| 124 | SMS Templates | 1/1 | Complete (2026-05-13; migration + manual SMS smoke pending operator) |
|
||||
|
||||
Planowane kolejne fazy v3.7 (kandydaci, do rozplanowania):
|
||||
- Eksport XLSX listy wystawionych faktur (analogicznie do paragonow)
|
||||
|
||||
@@ -5,19 +5,19 @@
|
||||
See: .paul/PROJECT.md (updated 2026-05-07)
|
||||
|
||||
**Core value:** Sprzedawca moze obslugiwac zamowienia ze wszystkich kanalow sprzedazy i nadawac przesylki bez przelaczania sie miedzy platformami.
|
||||
**Current focus:** v3.7 Invoices + operational integrations - Phase 123 Receipts Export VAT Breakdown complete (UNIFY closed).
|
||||
**Current focus:** v3.7 Invoices + operational integrations - Phase 124 SMS Templates complete (UNIFY closed).
|
||||
|
||||
## Current Position
|
||||
|
||||
Milestone: v3.7 Invoices (Fakturownia integration) - In progress
|
||||
Phase: 123 of TBD (Receipts Export VAT Breakdown) - Complete
|
||||
Plan: 123-01 complete
|
||||
Phase: 124 of TBD (SMS Templates) - Complete
|
||||
Plan: 124-01 complete
|
||||
Status: UNIFY complete, ready to plan next phase
|
||||
Last activity: 2026-05-12 23:00:00 - UNIFY closed for .paul/phases/123-receipts-export-vat-breakdown/123-01-PLAN.md
|
||||
Last activity: 2026-05-13 00:30:00 - UNIFY closed for .paul/phases/124-sms-templates/124-01-PLAN.md
|
||||
|
||||
Progress:
|
||||
- Milestone v3.7: [#########-] ~97% (Phase 113-123 complete)
|
||||
- Phase 123: [##########] 100%
|
||||
- Milestone v3.7: [##########] ~98% (Phase 113-124 complete)
|
||||
- Phase 124: [##########] 100%
|
||||
|
||||
## Loop Position
|
||||
|
||||
@@ -29,9 +29,9 @@ PLAN -> APPLY -> UNIFY
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-05-12 22:00:00
|
||||
Stopped at: Phase 122 UNIFY closed (transition + git commit pending; worktree dirty with Phase 118 files)
|
||||
Next action: Resolve Phase 118 UNIFY/commit, then scope a clean Phase 122 commit and pick next v3.7 phase
|
||||
Last session: 2026-05-13 00:30:00
|
||||
Stopped at: Phase 124 UNIFY closed (UI fixes accepted by operator)
|
||||
Next action: Pick next v3.7 phase (kandydaci w ROADMAP) or transition do v3.8
|
||||
Resume file: .paul/ROADMAP.md
|
||||
|
||||
## Pending parallel work
|
||||
@@ -58,6 +58,9 @@ Note: routes/web.php, DOCS/* i .paul/codebase/* zawierały zmiany z 118+121+122
|
||||
- Phase 122 follow-up: manually verify settings save/reload and real SMSPLANET test/order sends with non-empty and empty footer; manually trigger over-limit final body rejection in UI.
|
||||
- Phase 123 follow-up: wystaw nowy paragon i potwierdz `items_json` zawiera `vat` per pozycja; eksport XLSX z paragonem multi-rate (np. mix 23% + 8%) — sprawdz osobne wiersze; eksport "wybrane paragony" zachowuje breakdown.
|
||||
- Phase 123 deferred: RECEIPT-NET-FIX (`ReceiptService::issue()` zapisuje `total_net=total_gross`) — udokumentowane w `.paul/codebase/todo.md`.
|
||||
- Phase 124 follow-up: `php bin/migrate.php` (XAMPP MySQL online) — utworzy `sms_templates`. Operator nastepnie tworzy szablony manualnie z `/settings/sms-templates`.
|
||||
- Phase 124 follow-up: real smoke wysylki SMS z szablonu (zamowienie z paczka + skonfigurowana stopka SMSPLANET) — sprawdzic ze `sms_messages.body` ma stopke raz, finalna tresc <= 918 znakow.
|
||||
- Phase 124 follow-up: regresja Email — wyslij e-mail z istniejacym szablonem aby potwierdzic ze refaktor `Email\VariableResolver` na fasade nie zlamal `EmailSendingService`.
|
||||
- Phase 121 transition note (rozwiązane): commit 360eef1 obejmuje Phase 121 i Phase 122 razem; per-faza hunk-split nie wykonany ze względu na nakładkowe modyfikacje plików.
|
||||
|
||||
## Deferred to Next Milestones
|
||||
|
||||
37
.paul/changelog/2026-05-13.md
Normal file
37
.paul/changelog/2026-05-13.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# 2026-05-13
|
||||
|
||||
## Co zrobiono
|
||||
|
||||
- [Phase 124, Plan 01] Wdrozono szablony SMS: CRUD w `/settings/sms-templates` (name + body + is_active) plus dropdown "Wybierz szablon" w zakladce SMS na `/orders/{id}` wstawiajacy tresc z rozwinietymi zmiennymi `{{zamowienie.numer}}`, `{{kupujacy.imie_nazwisko}}`, `{{przesylka.numer}}` itd. do textarea.
|
||||
- [Phase 124, Plan 01] Wydzielono `Sms\SmsVariableResolver` ze wspolna logika `buildVariableMap` + `resolve`; `Email\VariableResolver` zostal cienka fasada delegujaca — `EmailSendingService` niezmieniony, kontrakt Phase 14 zachowany.
|
||||
- [Phase 124, Plan 01] Dodano endpoint `GET /orders/{id}/sms/template?template_id=N` (JSON z rozwinietym body per zamowienie); JS module `sms-template-picker.js` z idempotentnym guard i `OrderProAlerts.confirm` (options-object API) przy nadpisaniu niepustej textarea.
|
||||
- [Phase 124, Plan 01] Migracja `20260512_000112_create_sms_templates.sql` (CREATE TABLE, DDL). Stopka SMSPLANET dalej doklejana wylacznie przez `SmsConversationService::buildFinalOutboundBody()` (Phase 122 contract preserved).
|
||||
- [Phase 124, Plan 01] UI fixes po UAT operatora: paleta zmiennych przeniesiona pod textarea z pill chipami `{{var}} + opis` (border-radius 999px, hover indigo); akcje w liscie szablonow uzywaja `display: flex; flex-wrap: nowrap` zamiast `white-space: nowrap` (form-children byly blokowe).
|
||||
- [Phase 124, Plan 01] UNIFY zamkniety; `php bin/migrate.php` i real smoke wysylki SMS z szablonu zalezne od XAMPP MySQL online.
|
||||
|
||||
## Zmienione pliki
|
||||
|
||||
- `database/migrations/20260512_000112_create_sms_templates.sql`
|
||||
- `src/Modules/Sms/SmsTemplateRepository.php`
|
||||
- `src/Modules/Sms/SmsVariableResolver.php`
|
||||
- `src/Modules/Email/VariableResolver.php`
|
||||
- `src/Modules/Settings/SmsTemplateController.php`
|
||||
- `src/Modules/Orders/OrdersController.php`
|
||||
- `routes/web.php`
|
||||
- `resources/views/settings/sms-templates.php`
|
||||
- `resources/views/settings/sms-templates-form.php`
|
||||
- `resources/views/orders/show.php`
|
||||
- `resources/views/layouts/app.php`
|
||||
- `resources/lang/pl.php`
|
||||
- `resources/scss/app.scss`
|
||||
- `resources/scss/modules/_sms-templates.scss`
|
||||
- `public/assets/css/app.css`
|
||||
- `public/assets/js/modules/sms-template-picker.js`
|
||||
- `.paul/codebase/db_schema.md`
|
||||
- `.paul/codebase/architecture.md`
|
||||
- `.paul/codebase/tech_changelog.md`
|
||||
- `.paul/PROJECT.md`
|
||||
- `.paul/ROADMAP.md`
|
||||
- `.paul/STATE.md`
|
||||
- `.paul/phases/124-sms-templates/124-01-PLAN.md`
|
||||
- `.paul/phases/124-sms-templates/124-01-SUMMARY.md`
|
||||
@@ -414,3 +414,61 @@ tests/
|
||||
- Edycja przez `<a href=".../edit?id=N">`, toggle/delete przez `<form>` z `_token` i `js-confirm-delete`.
|
||||
- Wspolny pattern miedzy `accounting-receipts.php` i `accounting-invoices.php` (faktury maja dodatkowe kolumny: Tryb, Konto Fakturowni).
|
||||
|
||||
|
||||
## Phase 124 — SMS Templates
|
||||
|
||||
### SmsTemplateRepository (`src/Modules/Sms/SmsTemplateRepository.php`)
|
||||
- CRUD na `sms_templates` (PDO prepared statements, ToggleableRepositoryTrait).
|
||||
- `listAll()` (cala lista alfabetycznie po `name`), `listActive()` (tylko is_active=1, kolumny `id|name|body` do dropdownu w UI).
|
||||
- `save(array): int` waliduje wymagane `name` + `body` (rzuca `RuntimeException` gdy puste); wykonuje INSERT albo UPDATE wg obecnosci `id` w payloadzie; zwraca id rekordu.
|
||||
- `delete(int)`, `toggleStatus(int)` przez `toggleActive('sms_templates', $id)`.
|
||||
|
||||
### SmsVariableResolver (`src/Modules/Sms/SmsVariableResolver.php`)
|
||||
- Wydzielony z `Email\VariableResolver` — wspolna logika zmiennych dla Email i SMS.
|
||||
- `buildVariableMap(order, addresses, companySettings)` zwraca mape placeholderow: `zamowienie.*`, `kupujacy.*`, `adres.*`, `firma.*`, `przesylka.*` (`przesylka.numer`/`przesylka.link_sledzenia` z najnowszej paczki przez `ShipmentPackageRepository::findLatestByOrderId` + `DeliveryStatus::trackingUrl`).
|
||||
- `resolve(template, variableMap)` zastepuje `{{group.var}}` wartoscia z mapy (puste gdy brak klucza).
|
||||
|
||||
### Email\VariableResolver (refaktor)
|
||||
- Pozostaje final class z tym samym API publicznym (`buildVariableMap`/`resolve`) — `EmailSendingService` niezmieniony.
|
||||
- Konstruktor: `(ShipmentPackageRepository $repo, ?SmsVariableResolver $inner = null)`. Gdy `$inner` nie podany, sam tworzy SmsVariableResolver — backward compat dla starego wiringu.
|
||||
- Metody publiczne deleguja do `$this->inner` — zero duplikacji logiki zmiennych.
|
||||
|
||||
### SmsTemplateController (`src/Modules/Settings/SmsTemplateController.php`)
|
||||
- Mirror `EmailTemplateController` bez Quill/skrzynki/zalacznika/duplikacji.
|
||||
- Akcje: `index` (lista), `create`/`edit`/`save` (form CRUD), `delete`, `toggleStatus` (AJAX JSON), `getVariables` (JSON paleta dla ewentualnego dynamic palette).
|
||||
- `VARIABLE_GROUPS` jako stala klasy — pelne 5 grup (zamowienie/kupujacy/adres/firma/przesylka) zgodnie ze wspolnym SmsVariableResolver.
|
||||
- Routy: `/settings/sms-templates`, `/create`, `/edit`, `/save`, `/delete`, `/toggle`, `/variables`. CSRF `_token` na POST. Flash `settings.sms_templates.success|error`.
|
||||
|
||||
### OrdersController (rozszerzenie)
|
||||
- Dodane optional params konstruktora: `?SmsTemplateRepository $smsTemplates`, `?SmsVariableResolver $smsVariableResolver`, `?CompanySettingsRepository $companySettingsRepo` (po istniejacych SMS params; default null = backward compat).
|
||||
- `show()` przekazuje `$smsTemplates` (list active) do widoku jako `smsTemplates`.
|
||||
- Nowa metoda `smsTemplate(Request)` -> `GET /orders/{id}/sms/template?template_id=N` -> JSON `{ok, body, name}` z rozwinietymi zmiennymi. 400/404/500 dla nieprawidlowych parametrow/braku rekordu.
|
||||
|
||||
### Widok `orders/show.php`
|
||||
- Nad textarea `name="message"` (`#js-sms-message`) dodany conditional `<select data-sms-template-picker data-order-id data-message-target="js-sms-message">` z opcja domyslna + aktywne szablony (renderowany tylko gdy `$smsTemplatesList !== []`).
|
||||
- Textarea ma teraz `id="js-sms-message"` — JS target.
|
||||
|
||||
### Frontend module `public/assets/js/modules/sms-template-picker.js`
|
||||
- Vanilla JS, idempotent guard `window.__smsTemplatePickerBound` + per-element `dataset.smsPickerBound`.
|
||||
- Na `change` selecta: fetch `/orders/{id}/sms/template?template_id=N`, podstaw body do textarea, fire `input` event.
|
||||
- Gdy textarea ma juz tresc -> `OrderProAlerts.confirm({...})` options-object API (Phase 114 pattern). Po zatwierdzeniu nadpisuje, po anulowaniu resetuje select. Fallback na natywny `confirm()`.
|
||||
- Ladowany globalnie z `layouts/app.php` (linia po `notifications.js`).
|
||||
|
||||
### Wspolny resolver — wiring DI (`routes/web.php`)
|
||||
- `$smsVariableResolver = new SmsVariableResolver($shipmentPackageRepositoryForOrders);`
|
||||
- `$variableResolver = new VariableResolver($shipmentPackageRepositoryForOrders, $smsVariableResolver);` (drugi argument opcjonalny dla BC).
|
||||
- `$smsTemplateRepository = new SmsTemplateRepository($app->db());`
|
||||
- `$smsTemplateController = new SmsTemplateController($template, $translator, $auth, $smsTemplateRepository);`
|
||||
- `$ordersController` rozszerzony o 3 trailing params (smsTemplateRepository, smsVariableResolver, companySettingsRepository).
|
||||
|
||||
### SCSS — `_sms-templates.scss`
|
||||
- Nowy partial `resources/scss/modules/_sms-templates.scss` z klasami `.sms-template-*` (active label, counter, body grid) oraz `.sms-var-panel/.sms-var-group/.sms-var-item` dla palety zmiennych.
|
||||
- Import w `app.scss` po `customer-risk-alert`.
|
||||
|
||||
### Stopka — preserved Phase 122 contract
|
||||
- Szablony SMS NIE zawieraja `default_footer` — operator wpisuje sama tresc.
|
||||
- `SmsConversationService::buildFinalOutboundBody()` dokleja stopke raz przy `sendFromOrder()` (po wstawieniu szablonu i ewentualnej edycji przez operatora). Walidacja `MAX_SMS_LENGTH = 918` obowiazuje na finalnej tresci.
|
||||
|
||||
### BREAKING / migration
|
||||
- Migracja `20260512_000112_create_sms_templates.sql` — `CREATE TABLE IF NOT EXISTS sms_templates` (DDL, brak SELECT no-op).
|
||||
- Brak innych zmian schematu. `OrdersController` ctor: 3 NEW optional params (default null) — backwards compatible.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Database Schema
|
||||
|
||||
**Updated:** 2026-05-12 | **Total tables:** 60 | **Engine:** InnoDB | **Charset:** utf8mb4_unicode_ci
|
||||
**Updated:** 2026-05-12 | **Total tables:** 61 | **Engine:** InnoDB | **Charset:** utf8mb4_unicode_ci
|
||||
|
||||
---
|
||||
|
||||
@@ -617,6 +617,18 @@ UNIQUE: `(integration_id)` - one global SMSPLANET settings row.
|
||||
|
||||
**notifications** - Global notification center (Phase 121): stores type, title, body, target URL, related order/SMS references, `read_at`, and `created_at`. Indexes support unread polling by `(read_at, created_at)` and relation lookups.
|
||||
|
||||
**sms_templates** — SMS templates for quick send from order detail (Phase 124)
|
||||
| Column | Type | Nullable | Notes |
|
||||
|--------|------|----------|-------|
|
||||
| `id` | INT UNSIGNED | NO | PK, AUTO_INCREMENT |
|
||||
| `name` | VARCHAR(200) | NO | Display name in template picker |
|
||||
| `body` | TEXT | NO | Template body with `{{group.var}}` placeholders (e.g. `{{zamowienie.numer}}`, `{{przesylka.numer}}`). Footer NOT included — appended automatically by `SmsConversationService::buildFinalOutboundBody()` |
|
||||
| `is_active` | TINYINT(1) | NO | DEFAULT 1 — filters listActive() for picker dropdown |
|
||||
| `created_at` | DATETIME | NO | DEFAULT CURRENT_TIMESTAMP |
|
||||
| `updated_at` | DATETIME | NO | DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP |
|
||||
|
||||
Indexes: `sms_templates_active_name_idx (is_active, name)` — supports active-templates dropdown query.
|
||||
|
||||
---
|
||||
|
||||
## Accounting / Receipts
|
||||
|
||||
@@ -1,5 +1,31 @@
|
||||
# Technical Changelog
|
||||
|
||||
## 2026-05-12 - Phase 124 Plan 01: SMS Templates
|
||||
|
||||
**Co zrobiono:**
|
||||
- Nowa tabela `sms_templates(id, name, body, is_active, created_at, updated_at)` + indeks `(is_active, name)` — migracja `20260512_000112_create_sms_templates.sql` (DDL).
|
||||
- `Sms\SmsTemplateRepository` z minimalnym CRUD (`listAll/listActive/findById/save/delete/toggleStatus`); walidacja name+body w `save()`.
|
||||
- `Sms\SmsVariableResolver` (wydzielony z `Email\VariableResolver`) — wspolna logika `buildVariableMap` + `resolve` dla Email i SMS (placeholdery `{{zamowienie.*|kupujacy.*|adres.*|firma.*|przesylka.*}}`).
|
||||
- `Email\VariableResolver` zrefaktorowany na fasade — konstruktor opcjonalnie przyjmuje SmsVariableResolver; metody publiczne deleguja. `EmailSendingService` bez zmian.
|
||||
- `Settings\SmsTemplateController` + widoki `settings/sms-templates.php` (lista) + `settings/sms-templates-form.php` (CRUD form z paleta zmiennych po prawej, licznikiem znakow, walidacja maxlength 918).
|
||||
- 7 nowych rout: `GET/POST /settings/sms-templates`, `/create`, `/edit`, `/save`, `/delete`, `/toggle`, `/variables`.
|
||||
- Dropdown "Wybierz szablon" w zakladce SMS na `/orders/{id}` (renderowany tylko gdy istnieja aktywne szablony) -> JS module `sms-template-picker.js` fetchuje `GET /orders/{id}/sms/template?template_id=N` i wkleja rozwiniete body do textarea. Przy niepustej textarea pyta przez `OrderProAlerts.confirm({...})` (options-object API).
|
||||
- `OrdersController::smsTemplate()` — nowy endpoint JSON; rozszerzony konstruktor o `?SmsTemplateRepository`, `?SmsVariableResolver`, `?CompanySettingsRepository` (default null = BC).
|
||||
- Sidebar Ustawien rozszerzony o link "Szablony SMS" (active state na `currentSettings === 'sms-templates'`).
|
||||
- Nowy SCSS partial `modules/_sms-templates.scss` (paleta zmiennych, licznik znakow). Import w `app.scss`.
|
||||
- Tlumaczenia `orders.details.sms.template_picker(_placeholder)` w `resources/lang/pl.php`.
|
||||
|
||||
**Dlaczego:**
|
||||
- Operator wysyla powtarzalne SMS-y (numer sledzenia, przypomnienie o platnosci, prosba o opinie). Szablony eliminuja recznie wpisywanie tekstu i tracking number, redukujac wysylke do dropdown + ewentualnej korekty.
|
||||
- Wspolny VariableResolver bo dokladnie te same placeholdery sa potrzebne w Email i SMS (DRY); zachowanie kontraktu `Email\VariableResolver` jako fasady = zero ryzyka regresji w EmailSendingService.
|
||||
- Stopka SMSPLANET pozostaje doklejana wylacznie przez `SmsConversationService::buildFinalOutboundBody()` (Phase 122) — nie duplikujemy jej w szablonach, walidacja 918 znakow obowiazuje na finalnej tresci.
|
||||
|
||||
**Migracja:**
|
||||
- `php bin/migrate.php` po wlaczeniu MySQL — utworzy `sms_templates`.
|
||||
- Operator po wdrozeniu tworzy szablony manualnie z `/settings/sms-templates`.
|
||||
|
||||
**BREAKING:** brak. `OrdersController` ctor: nowe params optional. `Email\VariableResolver` ctor: nowy opcjonalny drugi argument (default null = self-construct SmsVariableResolver).
|
||||
|
||||
## 2026-05-12 - Phase 123 Plan 01: Receipts Export VAT Breakdown
|
||||
|
||||
**Co zrobiono:**
|
||||
|
||||
302
.paul/phases/124-sms-templates/124-01-PLAN.md
Normal file
302
.paul/phases/124-sms-templates/124-01-PLAN.md
Normal file
@@ -0,0 +1,302 @@
|
||||
---
|
||||
phase: 124-sms-templates
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- database/migrations/20260512_000112_create_sms_templates.sql
|
||||
- src/Modules/Sms/SmsTemplateRepository.php
|
||||
- src/Modules/Sms/SmsVariableResolver.php
|
||||
- src/Modules/Settings/SmsTemplateController.php
|
||||
- src/Modules/Email/VariableResolver.php
|
||||
- src/Modules/Orders/OrdersController.php
|
||||
- routes/web.php
|
||||
- resources/views/settings/sms-templates.php
|
||||
- resources/views/settings/sms-templates-form.php
|
||||
- resources/views/orders/show.php
|
||||
- resources/views/layouts/app.php
|
||||
- resources/lang/pl/orders.php
|
||||
- public/assets/js/modules/sms-template-picker.js
|
||||
- .paul/codebase/db_schema.md
|
||||
- .paul/codebase/architecture.md
|
||||
- .paul/codebase/tech_changelog.md
|
||||
autonomous: true
|
||||
delegation: off
|
||||
---
|
||||
|
||||
<objective>
|
||||
## Goal
|
||||
Dodać moduł szablonów wiadomości SMS (CRUD w Ustawieniach) oraz dropdown "Wybierz szablon" w zakładce SMS na `/orders/{id}`, który wstawia treść z rozwiniętymi zmiennymi (`{{zamowienie.numer}}`, `{{kupujacy.imie_nazwisko}}`, `{{przesylka.numer}}` itd.) do textarea formularza wysyłki SMS.
|
||||
|
||||
## Purpose
|
||||
Operator zamawia SMS-y typu „przypomnienie o płatności", „numer śledzenia", „prośba o opinię" wielokrotnie. Dziś za każdym razem ręcznie wpisuje tekst i tracking number. Szablony skracają flow do jednego kliknięcia + ewentualnej edycji, analogicznie jak szablony e-mail (`/settings/email-templates`).
|
||||
|
||||
## Output
|
||||
- Tabela `sms_templates` (name, body, is_active, timestamps).
|
||||
- Sekcja `/settings/sms-templates` z listą + formularzem CRUD (parytetowo z email-templates).
|
||||
- Współdzielony resolver zmiennych (wydzielony z `Email\VariableResolver`) używany przez wysyłkę e-mail i SMS.
|
||||
- Dropdown w zakładce SMS na szczegółach zamówienia — wybór szablonu wstawia rozwiniętą treść do `<textarea>`.
|
||||
- Auto-stopka SMSPLANET (`default_footer`) doklejana dalej tylko przez `SmsConversationService` — szablon body NIE zawiera stopki.
|
||||
</objective>
|
||||
|
||||
<context>
|
||||
<clarifications>
|
||||
- **Pola szablonu** — Jakie pola powinien mieć szablon SMS?
|
||||
→ Odpowiedź: Nazwa + treść + is_active (minimalnie). Bez subject/mailbox_id/attachment, bez duplikacji, bez sort_order.
|
||||
- **Zmienne** — Czy szablony SMS mają używać tego samego systemu zmiennych co email?
|
||||
→ Odpowiedź: Tak — reuse VariableResolver z Email (wydzielić warstwę wspólną; Email\VariableResolver pozostaje aliasem-fasadą dla wstecznej zgodności).
|
||||
- **UX wstawiania** — Jak operator ma używać szablonu w zakładce SMS?
|
||||
→ Odpowiedź: Dropdown 'Wybierz szablon' nad textarea — wstawia treść z rozwiniętymi zmiennymi dla danego zamówienia. Operator może dalej edytować przed wysyłką.
|
||||
- **Stopka** — Czy w treści szablonu trzymać stopkę (default_footer SMSPLANET)?
|
||||
→ Odpowiedź: Nie — szablon zawiera tylko body; stopka jest doklejana automatycznie przez `SmsConversationService::buildFinalOutboundBody()` (Phase 122). Walidacja 918 znaków obowiązuje na finalnej treści ze stopką.
|
||||
</clarifications>
|
||||
|
||||
## Project Context
|
||||
@.paul/PROJECT.md
|
||||
@.paul/ROADMAP.md
|
||||
@.paul/STATE.md
|
||||
@.paul/codebase/architecture.md
|
||||
@.paul/codebase/db_schema.md
|
||||
|
||||
## Prior Work
|
||||
- Phase 14 (`email_templates` CRUD + Quill) — wzorzec UI i kontraktu repo.
|
||||
- Phase 117 (`SMSPLANET integration`) — globalna konfiguracja, `SmsplanetApiClient`.
|
||||
- Phase 121 (SMS conversation) — `SmsConversationService::sendFromOrder()`, `sms_messages`, zakładka SMS na `/orders/{id}`.
|
||||
- Phase 122 (default footer) — `default_footer` doklejany w `buildFinalOutboundBody()`; walidacja 918 znaków na FINALNEJ treści.
|
||||
|
||||
## Source Files (do przejrzenia przed implementacją)
|
||||
@src/Modules/Settings/EmailTemplateRepository.php
|
||||
@src/Modules/Settings/EmailTemplateController.php
|
||||
@src/Modules/Email/VariableResolver.php
|
||||
@src/Modules/Sms/SmsConversationService.php
|
||||
@src/Modules/Orders/OrdersController.php
|
||||
@resources/views/settings/email-templates.php
|
||||
@resources/views/settings/email-templates-form.php
|
||||
@resources/views/orders/show.php
|
||||
@routes/web.php
|
||||
</context>
|
||||
|
||||
<acceptance_criteria>
|
||||
|
||||
## AC-1: CRUD szablonów SMS
|
||||
```gherkin
|
||||
Given operator jest zalogowany
|
||||
When wchodzi na `/settings/sms-templates`
|
||||
Then widzi listę szablonów (kolumny: Nazwa, Treść skrócona, Status, Akcje [Edytuj/Aktywuj-Dezaktywuj/Usuń])
|
||||
And może utworzyć nowy szablon (`/settings/sms-templates/create`) podając: Nazwa, Treść (textarea), aktywny (checkbox)
|
||||
And formularz pokazuje paletę zmiennych `{{zamowienie.numer}}`, `{{kupujacy.imie_nazwisko}}`, `{{przesylka.numer}}` itd. — kliknięcie wkleja placeholder w pozycji kursora
|
||||
And zapis trafia do tabeli `sms_templates` z `body` (TEXT NOT NULL), `name` (VARCHAR(200)), `is_active` (TINYINT(1) DEFAULT 1)
|
||||
```
|
||||
|
||||
## AC-2: Walidacja zapisu szablonu
|
||||
```gherkin
|
||||
Given operator tworzy szablon SMS
|
||||
When `name` jest pusty albo `body` jest pusty
|
||||
Then formularz zwraca błąd walidacji (Flash danger) i nie tworzy rekordu w DB
|
||||
And po sukcesie zapisu Flash success "Szablon SMS zapisany" + redirect na listę
|
||||
```
|
||||
|
||||
## AC-3: Wspólny VariableResolver (refaktor)
|
||||
```gherkin
|
||||
Given istnieje `App\Modules\Email\VariableResolver` używany przez `EmailSendingService`
|
||||
When refaktoryzuję wspólną logikę zmiennych do `App\Modules\Sms\SmsVariableResolver` (lub nadrzędnego namespace)
|
||||
Then `Email\VariableResolver` przekierowuje do nowej klasy (kompozycja albo extends jeśli klasa nie była `final`; obecna jest `final` → preferowana kompozycja z forwardingiem `buildVariableMap`/`resolve`)
|
||||
And `SmsConversationService` otrzymuje konstruktorowo `SmsVariableResolver` i ma nową metodę `renderTemplate(int $orderId, string $body): string` która zwraca treść z rozwiniętymi zmiennymi
|
||||
And żaden istniejący test/feature emaila się nie psuje (smoke: wysyłka e-mail z zamówienia dalej rozwiązuje zmienne)
|
||||
```
|
||||
|
||||
## AC-4: Wstawianie szablonu w zakładce SMS
|
||||
```gherkin
|
||||
Given operator otwiera `/orders/{id}?tab=sms` z istniejącymi aktywnymi szablonami SMS
|
||||
When widzi nad `<textarea name="message">` element `<select name="template_id">` z opcjami: "— Wybierz szablon —" + aktywne szablony (sort po nazwie)
|
||||
And wybiera szablon "Numer śledzenia"
|
||||
Then JS wykonuje `GET /orders/{id}/sms/template/{templateId}` (lub `?template_id=N`) i otrzymuje JSON `{body: "Twój numer śledzenia to ABC12345"}` z rozwiniętymi zmiennymi dla tego zamówienia
|
||||
And treść trafia do `<textarea>` (overwrite jeśli pusta; przy niepustej → confirm przez `window.OrderProAlerts.confirm` z opcjami `{title, message, onConfirm, danger:false}`)
|
||||
And operator może dalej edytować treść przed wysyłką
|
||||
```
|
||||
|
||||
## AC-5: Brak duplikacji stopki + walidacja długości
|
||||
```gherkin
|
||||
Given szablon SMS w bazie zawiera tylko body bez stopki
|
||||
When operator wybiera szablon i wysyła SMS przez `/orders/{id}/sms/send`
|
||||
Then `SmsConversationService::sendFromOrder()` dokleja `default_footer` jeden raz (jak teraz, Phase 122)
|
||||
And walidacja `MAX_SMS_LENGTH = 918` obowiązuje na finalnej treści (body + stopka)
|
||||
And rekord w `sms_messages.body` zawiera końcową treść (body + "\n\n" + stopka)
|
||||
```
|
||||
|
||||
## AC-6: Menu / nawigacja
|
||||
```gherkin
|
||||
Given operator jest w sekcji Ustawienia
|
||||
When otwiera sidebar Ustawień
|
||||
Then widzi link "Szablony SMS" obok "Szablony e-mail" (sublink, `currentSettings === 'sms-templates'` aktywny stan)
|
||||
And klik prowadzi na `/settings/sms-templates`
|
||||
```
|
||||
|
||||
</acceptance_criteria>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Migracja + repozytorium SMS templates</name>
|
||||
<files>database/migrations/20260512_000112_create_sms_templates.sql, src/Modules/Sms/SmsTemplateRepository.php, .paul/codebase/db_schema.md</files>
|
||||
<action>
|
||||
1. Migracja DDL: `CREATE TABLE sms_templates (id INT UNSIGNED PK AUTO_INCREMENT, name VARCHAR(200) NOT NULL, body TEXT NOT NULL, is_active 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) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;` + INDEX `idx_sms_templates_active_name (is_active, name)`.
|
||||
2. `SmsTemplateRepository` mirroruje `EmailTemplateRepository` ale BEZ subject/mailbox_id/attachment_1/duplicate: `listAll(): list<array>`, `listActive(): list<array>`, `findById(int): ?array`, `save(array): int`, `delete(int): void`, `toggleStatus(int): void` (przez `ToggleableRepositoryTrait::toggleActive('sms_templates', $id)`).
|
||||
3. Wszystkie zapytania prepared statements (Medoo nie jest tu wymagane — repo używa PDO bezpośrednio, jak EmailTemplateRepository).
|
||||
4. Zaktualizuj `.paul/codebase/db_schema.md` — sekcja "SMS / Notifications", dodaj `sms_templates` z pełną specyfikacją kolumn.
|
||||
Avoid: dodawania `mailbox_id`/`subject`/`attachment_1` (decyzja: pola minimalne); używania `SELECT 1;` jako no-op (rule: zawsze DDL).
|
||||
</action>
|
||||
<verify>`php bin/migrate.php` przechodzi bez błędów; `DESCRIBE sms_templates` pokazuje 6 kolumn + 2 indexy (PK + idx_sms_templates_active_name); manualny INSERT/SELECT przez Medoo działa.</verify>
|
||||
<done>AC-1 (schemat) + AC-2 (repo wspiera walidację — pusty name/body rzuca `RuntimeException`).</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Wspólny VariableResolver dla SMS</name>
|
||||
<files>src/Modules/Sms/SmsVariableResolver.php, src/Modules/Email/VariableResolver.php, src/Modules/Sms/SmsConversationService.php, routes/web.php</files>
|
||||
<action>
|
||||
1. Utwórz `App\Modules\Sms\SmsVariableResolver` (final class) — przenieś logikę `buildVariableMap()` i `resolve()` z `Email\VariableResolver`. Konstruktor: `ShipmentPackageRepository $shipmentPackageRepository`. Public API:
|
||||
- `buildVariableMap(array $order, array $addresses, array $companySettings): array<string,string>`
|
||||
- `resolve(string $template, array $variableMap): string`
|
||||
- NOWA: `renderForOrder(int $orderId, string $template, OrdersRepository $orders, CompanySettingsRepository $company): string` — fetcher: order + addresses + company → buildVariableMap → resolve. (Albo prościej: helper zewnętrzny w `SmsConversationService::renderTemplate()`.)
|
||||
2. `Email\VariableResolver` staje się cienką fasadą: konstruktor przyjmuje `SmsVariableResolver` (lub samodzielnie tworzy), `buildVariableMap`/`resolve` deleguje 1:1. Zachowaj kontrakt publiczny (`EmailSendingService` nie wymaga zmian).
|
||||
3. `SmsConversationService` dostaje dodatkowy konstruktor param `SmsVariableResolver` + nową publiczną metodę `renderTemplate(array $order, array $addresses, array $companySettings, string $body): string`. NIE zmieniaj kontraktu `sendFromOrder()` — pozostaje przyjmować już-rozwinięty `$body` (operator może edytować po wstawieniu, więc resolwowanie zmiennych odbywa się w controllerze przy fetchu szablonu, nie podczas wysyłki).
|
||||
4. Wiring w `routes/web.php`: `$smsVariableResolver = new SmsVariableResolver($shipmentPackageRepository);` → przekaż do `EmailSendingService` (poprzez `Email\VariableResolver` lub bezpośrednio jeśli refaktorujesz `EmailSendingService`) i do `SmsConversationService`.
|
||||
Avoid: kopiowania logiki zmiennych (DRY); zmiany istniejących publicznych metod `Email\VariableResolver` (mogą być w użyciu w testach/automation).
|
||||
</action>
|
||||
<verify>Manualny smoke test: `php -r "require 'bootstrap/app.php'; $resolver = ...; var_dump($resolver->resolve('Zam {{zamowienie.numer}}', ['zamowienie.numer' => 'OP1']));"` → "Zam OP1". Smoke wysyłki e-maila z zamówienia (zakładka Wiadomości) dalej rozwiązuje `{{kupujacy.imie_nazwisko}}` poprawnie.</verify>
|
||||
<done>AC-3 satisfied: wspólny resolver, Email niezmieniony funkcjonalnie, SmsConversationService ma `renderTemplate()`.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Controller + widoki + routy CRUD `/settings/sms-templates`</name>
|
||||
<files>src/Modules/Settings/SmsTemplateController.php, resources/views/settings/sms-templates.php, resources/views/settings/sms-templates-form.php, resources/views/layouts/app.php, routes/web.php</files>
|
||||
<action>
|
||||
1. `SmsTemplateController` mirror `EmailTemplateController` (uproszczony — bez Quill, bez mailbox select, bez attachment): metody `index`, `create`, `edit`, `save`, `delete`, `toggleStatus`, `preview` (POST z `order_id` opcjonalnie do podglądu z rozwiniętymi zmiennymi — dla podglądu na formularzu), `getVariables` (GET zwraca JSON dostępnych zmiennych dla palety).
|
||||
2. `VARIABLE_GROUPS` jako stała klasy — kopia z `EmailTemplateController::VARIABLE_GROUPS` ale BEZ `firma` (SMS-y nie używają nazwy firmy domyślnie; opcjonalnie zostaw — patrz odpowiedź pytania 2: pełny zestaw zmiennych). KOPIA pełna (wszystkie 5 grup) — operator decyduje czego używa.
|
||||
3. `sms-templates.php` — lista (analog `email-templates.php`): tabela z kolumnami Nazwa | Treść (skrócona substr(0,80) + ellipsis) | Status | Akcje. Bez kolumny Skrzynka/Załącznik. Bez "Duplikuj".
|
||||
4. `sms-templates-form.php` — analog `email-templates-form.php`:
|
||||
- Pola: `name` (text), `body` (textarea, rows=6, maxlength=918), `is_active` (checkbox).
|
||||
- Paleta zmiennych po prawej stronie — kliknięcie wstawia `{{group.var}}` w pozycji kursora w textarea (vanilla JS).
|
||||
- Licznik znaków pod textarea (live update); ostrzega gdy >800 (margines na stopkę).
|
||||
- Bez wyboru skrzynki, bez Quill, bez sekcji "Załącznik".
|
||||
- Kolumna lewa: formularz; kolumna prawa: panel pomocy "Dostępne zmienne" + przykład podglądu (opcjonalny GET na pierwsze zamówienie z bazy).
|
||||
5. Routy w `routes/web.php` (po blocku email-templates, ~linia 647):
|
||||
```
|
||||
$router->get('/settings/sms-templates', [$smsTemplateController, 'index'], [$authMiddleware]);
|
||||
$router->get('/settings/sms-templates/create', [$smsTemplateController, 'create'], [$authMiddleware]);
|
||||
$router->get('/settings/sms-templates/edit', [$smsTemplateController, 'edit'], [$authMiddleware]);
|
||||
$router->post('/settings/sms-templates/save', [$smsTemplateController, 'save'], [$authMiddleware]);
|
||||
$router->post('/settings/sms-templates/delete', [$smsTemplateController, 'delete'], [$authMiddleware]);
|
||||
$router->post('/settings/sms-templates/toggle', [$smsTemplateController, 'toggleStatus'], [$authMiddleware]);
|
||||
$router->get('/settings/sms-templates/variables', [$smsTemplateController, 'getVariables'], [$authMiddleware]);
|
||||
```
|
||||
6. Wiring DI w `routes/web.php`: `$smsTemplateRepository = new SmsTemplateRepository($app->db()); $smsTemplateController = new SmsTemplateController($template, $translator, $auth, $smsTemplateRepository);`
|
||||
7. Sidebar w `layouts/app.php` (~linia 121): dodaj `sublink` "Szablony SMS" → `/settings/sms-templates`, aktywny gdy `$currentSettings === 'sms-templates'`. Przekaż `$currentSettings` z `SmsTemplateController` jako lokal w renderze.
|
||||
8. Confirm-delete: użyj globalnego `confirm-delete.js` (Phase 114) — markery `js-confirm-delete` na `<form>`.
|
||||
Avoid: pisania własnego modal confirm (jest globalny z `data-confirm-bound='1'` guard); inline CSS w widokach (rule projektu — wszystko SCSS).
|
||||
</action>
|
||||
<verify>Manualny smoke: `/settings/sms-templates` zwraca 200, lista pusta z CTA "Dodaj szablon". Tworzysz "Test SMS" z body "Witaj {{kupujacy.imie_nazwisko}}, nr {{zamowienie.numer}}", is_active=1. Zapisuje się w `sms_templates`. Edycja działa. Toggle (AJAX) zmienia status bez reload. Usuń pokazuje confirm OrderProAlerts.</verify>
|
||||
<done>AC-1, AC-2, AC-6 satisfied.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Dropdown szablonów w zakładce SMS + endpoint fetch</name>
|
||||
<files>src/Modules/Orders/OrdersController.php, resources/views/orders/show.php, public/assets/js/modules/sms-template-picker.js, resources/lang/pl/orders.php, routes/web.php</files>
|
||||
<action>
|
||||
1. `OrdersController::__construct` rozszerz o parametry: `SmsTemplateRepository $smsTemplateRepository`, `SmsVariableResolver $smsVariableResolver`, `CompanySettingsRepository $companySettingsRepository` (jeśli nie jest jeszcze wstrzyknięty — sprawdź; jeśli `EmailSendingService` go używa, controller już może go mieć przekazany pośrednio). Dodaj nowe metody:
|
||||
- `smsTemplate(Request)`: handler dla `GET /orders/{id}/sms/template?template_id=N`. Sprawdź uprawnienia (authMiddleware już chroni). Fetch order + addresses + company → `SmsVariableResolver::buildVariableMap()` → `resolve()` na `body` szablonu → zwróć JSON `{ok:true, body: "..."}` lub `{ok:false, error: "..."}` (404 gdy template/order nie istnieje).
|
||||
- Method action `index` (renderShowOrder) — przekaż do widoku `$smsTemplates = $smsTemplateRepository->listActive();`.
|
||||
2. W `routes/web.php` (po `/orders/{id}/sms/send`, ~linia 556):
|
||||
```
|
||||
$router->get('/orders/{id}/sms/template', [$ordersController, 'smsTemplate'], [$authMiddleware]);
|
||||
```
|
||||
Zaktualizuj wiring `$ordersController = new OrdersController(...)` — dodaj 3 nowe trailing params (`$smsTemplateRepository`, `$smsVariableResolver`, ewentualnie `$companySettingsRepository` jeśli nie jest tam już).
|
||||
3. `resources/views/orders/show.php` — sekcja SMS form (~linia 1017-1033):
|
||||
- Nad `<label class="form-field">` z textarea wstaw `<label class="form-field">` z `<select data-sms-template-picker data-order-id="<?= $orderId ?>">` z opcją domyślną i pętlą po `$smsTemplates`:
|
||||
```html
|
||||
<option value="">— Wybierz szablon —</option>
|
||||
<?php foreach ($smsTemplates as $tpl): ?>
|
||||
<option value="<?= (int) $tpl['id'] ?>"><?= $e((string) $tpl['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
```
|
||||
- Dodaj atrybut `data-sms-message-target` na textarea aby JS go odnalazł.
|
||||
4. `public/assets/js/modules/sms-template-picker.js` — vanilla JS, idempotent guard `window.__smsTemplatePickerBound`. Na `change` selecta:
|
||||
- Pobierz `data-order-id` + wybrany `template_id`.
|
||||
- Fetch `GET /orders/{orderId}/sms/template?template_id={id}`.
|
||||
- Jeśli textarea pusta → wstaw body bezpośrednio.
|
||||
- Jeśli textarea ma treść → `window.OrderProAlerts.confirm({title:'Zamiana treści', message:'Tekst w polu zostanie nadpisany. Kontynuować?', onConfirm: () => textarea.value = body, danger: false})`.
|
||||
- Reset selecta do "" po wstawieniu/anulowaniu.
|
||||
- Po wstawieniu fire `input` event aby ewentualny licznik znaków się zaktualizował.
|
||||
5. Załaduj skrypt globalnie w `layouts/app.php` (po istniejących modułach).
|
||||
6. `resources/lang/pl/orders.php` — dodaj klucz `details.sms.template_picker = 'Wybierz szablon'` i `details.sms.template_picker_placeholder = '— Wybierz szablon —'`.
|
||||
Avoid: AJAX-em wysyłania zamiast wstawiania (decyzja UX: edytowalny preview); pomijania confirm gdy textarea ma treść (utrata pracy operatora); używania `alert()`/`confirm()` natywnych.
|
||||
</action>
|
||||
<verify>Manualny smoke na `/orders/{id}?tab=sms`: dropdown widoczny, opcje aktywnych szablonów. Wybierz "Test SMS" → textarea wypełnia się `"Witaj Jan Kowalski, nr OP000001234"`. Z niepustą textarea pojawia się OrderProAlerts confirm. Po wstawieniu wysyłka SMS (`/orders/{id}/sms/send`) działa jak wcześniej, stopka SMSPLANET doklejona raz (sprawdź `sms_messages.body` ostatniego rekordu).</verify>
|
||||
<done>AC-4, AC-5 satisfied.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 5: Aktualizacja dokumentacji codebase</name>
|
||||
<files>.paul/codebase/architecture.md, .paul/codebase/tech_changelog.md, .paul/codebase/db_schema.md</files>
|
||||
<action>
|
||||
1. `architecture.md` — nowa sekcja "## Phase 124 — SMS Templates" opisująca: `SmsTemplateRepository`, `SmsTemplateController` (mirror EmailTemplate bez subject/mailbox/attachment), `SmsVariableResolver` (wydzielony z Email\VariableResolver — wspólna logika zmiennych), endpoint `GET /orders/{id}/sms/template`, JS `sms-template-picker.js`. Zaktualizuj inwentaryzację modułów `Sms` (3 → 5 plików: dodaj `SmsTemplateRepository`, `SmsVariableResolver`).
|
||||
2. `tech_changelog.md` — wpis chronologiczny: data 2026-05-12 (lub data wdrożenia), opis "Phase 124: szablony SMS — CRUD `/settings/sms-templates`, wspólny resolver zmiennych z e-mail, dropdown wstawiania szablonu w zakładce SMS na `/orders/{id}`".
|
||||
3. `db_schema.md` — finalizuj sekcję `sms_templates` (jeśli niedodana w Task 1) oraz zwiększ Total tables o 1.
|
||||
Avoid: duplikowania opisu modułu w `architecture.md` z PROJECT.md (PROJECT trzyma `Validated (Shipped)` listę — dodać tam jedną pozycję bullet).
|
||||
</action>
|
||||
<verify>`grep -i 'sms_templates' .paul/codebase/*.md` zwraca trafienia w db_schema.md i architecture.md. `git diff --stat .paul/codebase/` pokazuje 3 zmienione pliki.</verify>
|
||||
<done>Dokumentacja zsynchronizowana — wymóg CLAUDE.md "Przy kazdej nowej funkcji ... zaktualizuj odpowiednie sekcje".</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<boundaries>
|
||||
|
||||
## DO NOT CHANGE
|
||||
- `EmailSendingService` — kontrakt publiczny i konstruktor pozostają niezmienione (Email\VariableResolver delegate-only).
|
||||
- `SmsConversationService::sendFromOrder()` — body otrzymywane od controllera już rozwinięte; nie dodawaj drugiego przejścia rozwijającego zmienne (uniknij podwójnego escape'u dla treści edytowanej przez operatora).
|
||||
- `SmsConversationService::buildFinalOutboundBody()` — stopka SMSPLANET doklejana dokładnie raz (Phase 122).
|
||||
- `automation_rules` / `automation_actions` — szablony SMS nie integrują się jeszcze z automatyzacją (deferred do osobnej fazy: `automation_action_type='send_sms'`).
|
||||
- `sms_messages` schema — bez zmian; szablon nie wpływa na strukturę wiadomości po wysłaniu.
|
||||
- Phase 121 webhook `/webhooks/smsplanet/inbound` — bez zmian.
|
||||
|
||||
## SCOPE LIMITS
|
||||
- Brak akcji automatyzacji `send_sms` (osobna faza, kandydat z ROADMAP).
|
||||
- Brak duplikacji szablonu (różnica vs email — UX prostsze, operator tworzy nowy ręcznie jeśli potrzebuje).
|
||||
- Brak sort_order / kategorii / tagów (decyzja: minimalne pola).
|
||||
- Brak HostedSMS — moduł szablonów jest provider-agnostyczny, ale wysyłka idzie przez SmsConversationService (SMSPLANET). HostedSMS w ogóle nie jest podpięty do `/orders/{id}/sms/send` na ten moment.
|
||||
- Brak Quill/WYSIWYG — textarea (SMS to plain text).
|
||||
- Brak walidacji długości w bazie ponad VARCHAR/TEXT — walidacja 918 obowiązuje na finalnej treści w `SmsConversationService` (Phase 122).
|
||||
- Brak migracji do alembica / fixturek seedowych — operator tworzy swoje szablony ręcznie.
|
||||
|
||||
</boundaries>
|
||||
|
||||
<verification>
|
||||
Before declaring plan complete:
|
||||
- [ ] `php bin/migrate.php` przechodzi (migracja 20260512_000112).
|
||||
- [ ] `php -l src/Modules/Sms/SmsTemplateRepository.php src/Modules/Sms/SmsVariableResolver.php src/Modules/Settings/SmsTemplateController.php src/Modules/Email/VariableResolver.php src/Modules/Sms/SmsConversationService.php src/Modules/Orders/OrdersController.php routes/web.php` — wszystkie No syntax errors.
|
||||
- [ ] `/settings/sms-templates` lista + create + edit + toggle + delete działają (manualny smoke).
|
||||
- [ ] `/settings/email-templates` flow wysyłki e-mail z zamówienia dalej działa (regression test).
|
||||
- [ ] `/orders/{id}?tab=sms`: dropdown szablonów wstawia rozwinięte zmienne; confirm OrderProAlerts przy niepustej textarea; wysyłka SMS dokleja stopkę raz.
|
||||
- [ ] Sidebar Ustawień ma podkreślenie aktywne na `/settings/sms-templates`.
|
||||
- [ ] `.paul/codebase/db_schema.md`, `architecture.md`, `tech_changelog.md` zaktualizowane.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Wszystkie zadania (1-5) ukończone, AC-1..AC-6 zaspokojone.
|
||||
- Brak regresji w wysyłce e-mail (VariableResolver refaktor zachowuje kontrakt).
|
||||
- Brak duplikacji stopki w wysłanych SMS-ach (Phase 122 contract preserved).
|
||||
- Zero `alert()` / `confirm()` natywnych — wyłącznie `window.OrderProAlerts.confirm({...})` options-object API.
|
||||
- Zero inline CSS w widokach — style w SCSS jeśli wymagane (najpewniej reuse istniejących `.form-field`, `.form-control`, `.btn`).
|
||||
- Brak zmian w `automation_*` (akcja `send_sms` poza zakresem).
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.paul/phases/124-sms-templates/124-01-SUMMARY.md` opisujący:
|
||||
- Zmienione pliki i krótki opis każdego.
|
||||
- Nowe routy.
|
||||
- Schema diff (`sms_templates`).
|
||||
- Wszelkie odchylenia od planu i decyzje implementacyjne.
|
||||
- Pending manual smoke tests (jeśli XAMPP niedostępny w trakcie APPLY).
|
||||
</output>
|
||||
169
.paul/phases/124-sms-templates/124-01-SUMMARY.md
Normal file
169
.paul/phases/124-sms-templates/124-01-SUMMARY.md
Normal file
@@ -0,0 +1,169 @@
|
||||
---
|
||||
phase: 124-sms-templates
|
||||
plan: 01
|
||||
subsystem: sms
|
||||
tags: [sms, templates, smsplanet, orders, settings]
|
||||
requires:
|
||||
- phase: 117-smsplanet-integration
|
||||
provides: SmsplanetIntegrationRepository, SmsplanetApiClient
|
||||
- phase: 121-smsplanet-conversation-notifications
|
||||
provides: SmsConversationService::sendFromOrder, SMS tab w /orders/{id}
|
||||
- phase: 122-smsplanet-default-footer
|
||||
provides: default_footer + MAX_SMS_LENGTH = 918 validation on final body
|
||||
- phase: 14-email-templates
|
||||
provides: email_templates CRUD pattern, VARIABLE_GROUPS structure
|
||||
provides:
|
||||
- sms_templates DB table + CRUD repository
|
||||
- /settings/sms-templates panel (lista + formularz)
|
||||
- shared SmsVariableResolver (extracted from Email\VariableResolver)
|
||||
- GET /orders/{id}/sms/template endpoint (JSON, variables resolved per-order)
|
||||
- Dropdown "Wybierz szablon" w zakladce SMS na /orders/{id}
|
||||
- sms-template-picker.js (vanilla JS, OrderProAlerts.confirm pattern)
|
||||
affects: [future SMS automation (send_sms action), invoice.created event]
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Shared VariableResolver pattern: Email\\VariableResolver as thin facade delegating to Sms\\SmsVariableResolver"
|
||||
- "Idempotent JS module guard: window.__smsTemplatePickerBound + dataset.smsPickerBound"
|
||||
- "Pill-style variable chips with monospace code + descriptive label (.sms-var-item)"
|
||||
key-files:
|
||||
created:
|
||||
- database/migrations/20260512_000112_create_sms_templates.sql
|
||||
- src/Modules/Sms/SmsTemplateRepository.php
|
||||
- src/Modules/Sms/SmsVariableResolver.php
|
||||
- src/Modules/Settings/SmsTemplateController.php
|
||||
- resources/views/settings/sms-templates.php
|
||||
- resources/views/settings/sms-templates-form.php
|
||||
- resources/scss/modules/_sms-templates.scss
|
||||
- public/assets/js/modules/sms-template-picker.js
|
||||
modified:
|
||||
- src/Modules/Email/VariableResolver.php
|
||||
- src/Modules/Orders/OrdersController.php
|
||||
- routes/web.php
|
||||
- resources/views/orders/show.php
|
||||
- resources/views/layouts/app.php
|
||||
- resources/lang/pl.php
|
||||
- resources/scss/app.scss
|
||||
key-decisions:
|
||||
- "SMS template fields minimal: name + body + is_active (no subject/mailbox/attachment)"
|
||||
- "Footer NOT in template — appended by SmsConversationService (Phase 122 contract preserved)"
|
||||
- "Email\\VariableResolver becomes facade — preserves EmailSendingService contract, zero regression risk"
|
||||
- "Variable picker = pill chips with {{var}} + description on same row (not dropdown like email)"
|
||||
- "Action column uses flex+gap not inline-flex on td — robust against block-display <form> children"
|
||||
patterns-established:
|
||||
- "Shared resolver: keep facade in original namespace for BC, move logic to new namespace"
|
||||
- "Idempotent JS modules: window.__moduleBound + dataset.moduleBound guards"
|
||||
- "Template picker UX: dropdown insert + OrderProAlerts.confirm on non-empty target"
|
||||
duration: ~90min
|
||||
started: 2026-05-12T23:30:00Z
|
||||
completed: 2026-05-13T00:30:00Z
|
||||
---
|
||||
|
||||
# Phase 124 Plan 01 — SMS Templates — SUMMARY
|
||||
|
||||
**SMS templates CRUD w `/settings/sms-templates` + dropdown "Wybierz szablon" na zakladce SMS w szczegolach zamowienia — wstawia tresc z rozwinietymi zmiennymi `{{kupujacy.imie_nazwisko}}`, `{{przesylka.numer}}` itp. Wspolny `SmsVariableResolver` wydzielony z Email\\VariableResolver bez regresji w wysylce e-mail.**
|
||||
|
||||
## Performance
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Duration | ~90 min (PLAN + APPLY + UI fixes + UNIFY) |
|
||||
| Started | 2026-05-12T23:30:00Z |
|
||||
| Completed | 2026-05-13T00:30:00Z |
|
||||
| Tasks | 5/5 ukonczone |
|
||||
| Files modified | 17 (8 created + 9 modified) |
|
||||
|
||||
**Status:** UNIFY complete. Operator zaakceptowal UI po dwoch iteracjach UI fixes (chipy zmiennych + nowrap akcji w liscie).
|
||||
|
||||
## Files modified
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `database/migrations/20260512_000112_create_sms_templates.sql` | NEW — CREATE TABLE `sms_templates` + idx_active_name |
|
||||
| `src/Modules/Sms/SmsTemplateRepository.php` | NEW — CRUD PDO repo z `ToggleableRepositoryTrait` |
|
||||
| `src/Modules/Sms/SmsVariableResolver.php` | NEW — wspolny resolver wydzielony z Email\VariableResolver |
|
||||
| `src/Modules/Email/VariableResolver.php` | REFACTOR — final class staje sie cienka fasada delegujaca do SmsVariableResolver; konstruktor optional 2nd arg dla BC |
|
||||
| `src/Modules/Settings/SmsTemplateController.php` | NEW — index/create/edit/save/delete/toggleStatus/getVariables |
|
||||
| `src/Modules/Orders/OrdersController.php` | EXTENDED — 3 optional ctor params + `smsTemplate()` endpoint + `smsTemplates` w show() view payload |
|
||||
| `routes/web.php` | NEW use'y (SmsTemplateController, SmsTemplateRepository, SmsVariableResolver); wiring DI; 7 rout `/settings/sms-templates/*` + 1 ruta `/orders/{id}/sms/template`; OrdersController wiring +3 params |
|
||||
| `resources/views/settings/sms-templates.php` | NEW — lista z toggle AJAX + js-confirm-delete |
|
||||
| `resources/views/settings/sms-templates-form.php` | NEW — formularz CRUD z paleta zmiennych + licznikiem znakow |
|
||||
| `resources/views/orders/show.php` | EXTENDED — dropdown `<select data-sms-template-picker>` nad textarea; textarea ma id `js-sms-message` |
|
||||
| `resources/views/layouts/app.php` | sidebar sublink "Szablony SMS"; `<script>` tag dla sms-template-picker.js |
|
||||
| `resources/lang/pl.php` | klucze `orders.details.sms.template_picker(_placeholder)` |
|
||||
| `public/assets/js/modules/sms-template-picker.js` | NEW — vanilla JS, idempotent guard, fetch endpoint + OrderProAlerts.confirm przy nadpisaniu |
|
||||
| `resources/scss/modules/_sms-templates.scss` | NEW — `.sms-template-*`, `.sms-var-*` klasy |
|
||||
| `resources/scss/app.scss` | import nowego partiala |
|
||||
| `.paul/codebase/db_schema.md` | sekcja `sms_templates` + Total tables 60 -> 61 |
|
||||
| `.paul/codebase/architecture.md` | sekcja "Phase 124 — SMS Templates" |
|
||||
| `.paul/codebase/tech_changelog.md` | wpis Phase 124 |
|
||||
|
||||
## Verification
|
||||
|
||||
- `php -l` przeszedl bez bledow na wszystkich zmienionych plikach PHP (11 plikow).
|
||||
- `php bin/migrate.php` **nie uruchomione** — MySQL niedostepny w trakcie APPLY (sandbox/XAMPP offline). DDL jest prostym `CREATE TABLE IF NOT EXISTS`; operator uruchomi rownolegle z innymi pending migracjami.
|
||||
- Browser smoke (CRUD `/settings/sms-templates`, dropdown na `/orders/{id}?tab=sms`, fetch endpoint, nadpisanie z confirm, wysylka SMS ze stopka raz) **pending operator** — wymaga zywego XAMPP + zalogowanego usera.
|
||||
- SCSS `app.css` build **pending operator** (`npm run scss` / manualny rebuild) — `resources/scss/app.scss` ma nowy `@use modules/sms-templates`; bez rebuildu klasy `.sms-template-*` i `.sms-var-*` nie maja styli (form pozostaje funkcjonalny, ale paleta zmiennych bedzie bez ramki/koloru).
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
| AC | Status |
|
||||
|----|--------|
|
||||
| AC-1: CRUD szablonow SMS w `/settings/sms-templates` | DONE (kod) — manual UAT pending |
|
||||
| AC-2: Walidacja zapisu (puste name/body) | DONE — `SmsTemplateRepository::save()` rzuca RuntimeException; controller Flash danger + redirect |
|
||||
| AC-3: Wspolny VariableResolver — bez regresji w Email | DONE — `Email\VariableResolver` deleguje, `EmailSendingService` niezmieniony |
|
||||
| AC-4: Wstawianie szablonu w zakladce SMS z rozwinietymi zmiennymi | DONE — endpoint JSON + JS module z OrderProAlerts.confirm przy nadpisaniu |
|
||||
| AC-5: Stopka doklejana raz, walidacja 918 znakow na finalnej tresci | DONE — szablon nie zawiera stopki; `SmsConversationService` niezmieniony (Phase 122 contract preserved) |
|
||||
| AC-6: Sidebar link "Szablony SMS" z active state | DONE |
|
||||
|
||||
## Deviations from PLAN
|
||||
|
||||
| Type | Count | Impact |
|
||||
|------|-------|--------|
|
||||
| Scope additions (UI fixes po UAT) | 2 | Visual polish — bez zmiany kontraktu |
|
||||
| Deferred | 0 | Brak |
|
||||
| Plan items pominiete (low value) | 2 | Zero ryzyka |
|
||||
|
||||
### Deviations w trakcie APPLY
|
||||
|
||||
1. **`SmsConversationService::renderTemplate()` NIE zostala dodana.** Plan Task 2 zakladal nowa metode w Service, ale to nie dodaje wartosci — controller (`OrdersController::smsTemplate()`) uzywa `SmsVariableResolver` bezposrednio. Zachowuje kontrakt Service (Phase 122 contract preserved bez zmian sygnatury konstruktora). Zysk: mniej zmian w SmsConversationService, brak ryzyka regresji w Phase 121/122.
|
||||
2. **`OrdersController` dostal `?CompanySettingsRepository` jako trzeci nowy optional param** — OrdersController nie mial wczesniej tego repo; zostal wstrzykniety razem z SmsTemplateRepository i SmsVariableResolver. Wiring w `routes/web.php` przekazuje `$companySettingsRepository` (juz istnial dla company-settings flow).
|
||||
3. **Brak `preview` endpointu w SmsTemplateController** (plan wspominal o ewentualnym podgladzie z `order_id`). Pominiete — paleta zmiennych pod textarea + licznik znakow wystarcza.
|
||||
|
||||
### UI fixes po UAT (scope additions)
|
||||
|
||||
1. **Layout palety zmiennych** — pierwsza wersja (grid 50/50 z form-grid-2: textarea | paleta side-by-side) operator ocenil jako "kiepsko". Refactor: textarea na pelna szerokosc, paleta przeniesiona ponizej jako pelnoszerokie chipy w grupach oddzielonych dashed separatorem. Chipy zmienione z prostego `inline-block` na `border-radius: 999px` (pill) z `{{var}}` w `<code>` + opisem `<span class="sms-var-item__desc">` w jednym wierszu. Hover indigo (`#eef2ff`/`#6366f1`). Plik: `resources/scss/modules/_sms-templates.scss`, `resources/views/settings/sms-templates-form.php`.
|
||||
2. **Akcje w liscie szablonow zawijaja sie** — `td.sms-template-actions` z `white-space: nowrap` nie wystarczyl, bo wewnetrzna `<form>` (delete) jest blokowa (klasa `inline-form` nie istnieje globalnie w SCSS). Fix: `.sms-template-actions { display: flex; flex-wrap: nowrap; gap: 6px; }` + `.sms-template-actions > form { display: inline-flex; margin: 0; }`. Plik: `resources/scss/modules/_sms-templates.scss`.
|
||||
|
||||
SCSS przebudowany `npx sass --style=compressed` po obu fixach. Operator zaakceptowal final UI ("jest ok").
|
||||
|
||||
## Pending Actions (operator)
|
||||
|
||||
- `php bin/migrate.php` (XAMPP MySQL online) — utworzy `sms_templates`.
|
||||
- SCSS rebuild (`npm run scss` lub workflow operatora) dla `_sms-templates.scss`.
|
||||
- UAT manualny:
|
||||
1. `/settings/sms-templates/create` — utworz "Numer sledzenia": body `"Czesc {{kupujacy.imie_nazwisko}}, Twoja przesylka {{przesylka.numer}} jest w drodze. Sledzenie: {{przesylka.link_sledzenia}}"`. Zapisz.
|
||||
2. Toggle status (AJAX) — dezaktywuj, ponownie aktywuj.
|
||||
3. Edycja — zmien body, zapisz.
|
||||
4. Duplikuj? — brak akcji (zgodnie z planem; minimalne pola).
|
||||
5. Usun (potwierdzenie OrderProAlerts).
|
||||
6. `/orders/{id}?tab=sms` z zamowieniem majacym paczke — dropdown widoczny, wybierz szablon, body wypelnia textarea z rozwinietymi `{{kupujacy.imie_nazwisko}}` i `{{przesylka.numer}}`.
|
||||
7. Wpisz cos w textarea, wybierz inny szablon — pojawia sie OrderProAlerts confirm.
|
||||
8. Wyslij SMS — sprawdz w `sms_messages.body` ze stopka SMSPLANET doklejona raz (jezeli skonfigurowana).
|
||||
9. Regresja Email: wyslij e-mail z zamowienia z istniejacym szablonem — placeholders `{{kupujacy.imie_nazwisko}}` i `{{zamowienie.numer}}` rozwiazane normalnie.
|
||||
|
||||
## SonarQube
|
||||
- CLI niedostepne (per Phase 116/117/121/122 history). Skipped, log w STATE Pending Actions.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
**Ready:**
|
||||
- `SmsTemplateRepository::listActive()` + `SmsVariableResolver` gotowe do reuse w przyszlej automatyzacji SMS (akcja `send_sms` analogiczna do `send_email` z Phase 16).
|
||||
- Wzorzec dropdown + JSON endpoint + OrderProAlerts.confirm = template do innych quick-insert mechanizmow (np. szablony notatek do zamowienia).
|
||||
- Sidebar Settings ma teraz blok email-templates + sms-templates obok — naturalne miejsce na kolejne typy szablonow.
|
||||
|
||||
**Concerns:**
|
||||
- Brak preview z realnymi danymi w formularzu (paleta tylko inline). Jezeli operator bedzie chcial weryfikowac wynik przed zapisaniem, dodac endpoint `POST /settings/sms-templates/preview` z `order_id` (mirror EmailTemplateController::preview).
|
||||
- `SmsTemplateRepository::save()` rzuca `RuntimeException` zamiast zwracac strukturalne bledy — controller wlapuje przez `Throwable` i przekazuje message do Flash. Dla wiekszej UX precyzji rozwazyc `IntegrationConfigException` w przyszlosci.
|
||||
|
||||
**Blockers:** None.
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE IF NOT EXISTS sms_templates (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
is_active 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 sms_templates_active_name_idx (is_active, name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
File diff suppressed because one or more lines are too long
96
public/assets/js/modules/sms-template-picker.js
Normal file
96
public/assets/js/modules/sms-template-picker.js
Normal file
@@ -0,0 +1,96 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
if (window.__smsTemplatePickerBound) return;
|
||||
window.__smsTemplatePickerBound = true;
|
||||
|
||||
function findTarget(picker) {
|
||||
var targetId = picker.getAttribute('data-message-target');
|
||||
if (!targetId) return null;
|
||||
return document.getElementById(targetId);
|
||||
}
|
||||
|
||||
function applyBody(textarea, body) {
|
||||
textarea.value = body;
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
textarea.focus();
|
||||
}
|
||||
|
||||
function bind(picker) {
|
||||
if (picker.dataset.smsPickerBound === '1') return;
|
||||
picker.dataset.smsPickerBound = '1';
|
||||
|
||||
picker.addEventListener('change', function () {
|
||||
var templateId = parseInt(picker.value, 10);
|
||||
var orderId = parseInt(picker.getAttribute('data-order-id'), 10);
|
||||
if (!templateId || !orderId) {
|
||||
return;
|
||||
}
|
||||
var textarea = findTarget(picker);
|
||||
if (!textarea) {
|
||||
picker.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
var doFetch = function () {
|
||||
picker.disabled = true;
|
||||
fetch('/orders/' + orderId + '/sms/template?template_id=' + templateId, {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (data && data.ok && typeof data.body === 'string') {
|
||||
applyBody(textarea, data.body);
|
||||
} else if (window.OrderProAlerts && window.OrderProAlerts.alert) {
|
||||
window.OrderProAlerts.alert({
|
||||
title: 'Nie udalo sie wczytac szablonu',
|
||||
message: (data && data.error) ? String(data.error) : 'Sprobuj ponownie.'
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(function () {})
|
||||
.finally(function () {
|
||||
picker.disabled = false;
|
||||
picker.value = '';
|
||||
});
|
||||
};
|
||||
|
||||
var current = (textarea.value || '').trim();
|
||||
if (current === '') {
|
||||
doFetch();
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.OrderProAlerts && typeof window.OrderProAlerts.confirm === 'function') {
|
||||
var triggered = false;
|
||||
var run = function () { if (!triggered) { triggered = true; doFetch(); } };
|
||||
var result = window.OrderProAlerts.confirm({
|
||||
title: 'Zamiana tresci',
|
||||
message: 'Tekst w polu wiadomosci zostanie nadpisany trescia szablonu. Kontynuowac?',
|
||||
confirmLabel: 'Wstaw szablon',
|
||||
danger: false,
|
||||
onConfirm: run,
|
||||
onCancel: function () { picker.value = ''; }
|
||||
});
|
||||
if (result && typeof result.then === 'function') {
|
||||
result.then(function (ok) { if (ok) run(); else picker.value = ''; });
|
||||
}
|
||||
} else if (window.confirm('Tekst w polu wiadomosci zostanie nadpisany. Kontynuowac?')) {
|
||||
doFetch();
|
||||
} else {
|
||||
picker.value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
document.querySelectorAll('[data-sms-template-picker]').forEach(bind);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
@@ -176,6 +176,8 @@ return [
|
||||
'message' => 'Tresc SMS',
|
||||
'footer_note' => 'Skonfigurowana stopka SMSPLANET zostanie dodana automatycznie.',
|
||||
'send' => 'Wyslij SMS',
|
||||
'template_picker' => 'Wybierz szablon',
|
||||
'template_picker_placeholder' => '— Wybierz szablon —',
|
||||
],
|
||||
'items_title' => 'Pozycje',
|
||||
'item_name' => 'Nazwa',
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
@use "modules/order-preview-modal";
|
||||
@use "modules/project-mappings";
|
||||
@use "modules/customer-risk-alert";
|
||||
@use "modules/sms-templates";
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
|
||||
137
resources/scss/modules/_sms-templates.scss
Normal file
137
resources/scss/modules/_sms-templates.scss
Normal file
@@ -0,0 +1,137 @@
|
||||
.sms-template-active-field {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sms-template-active-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sms-template-active-text {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sms-template-counter {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sms-template-counter-warning {
|
||||
color: #b45309;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.sms-template-actions {
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sms-template-actions > form {
|
||||
display: inline-flex;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sms-var-panel {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #f9fafb;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.sms-var-panel__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sms-var-panel__title {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #374151;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sms-var-panel__hint {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sms-var-group {
|
||||
padding: 8px 0;
|
||||
border-top: 1px dashed #e5e7eb;
|
||||
}
|
||||
|
||||
.sms-var-group:first-of-type {
|
||||
border-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.sms-var-group__label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #6b7280;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.sms-var-group__items {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sms-var-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
color: #1f2937;
|
||||
line-height: 1.4;
|
||||
transition: background-color 0.12s ease, border-color 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
|
||||
.sms-var-item code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
color: #4338ca;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sms-var-item__desc {
|
||||
color: #6b7280;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sms-var-item:hover {
|
||||
background: #eef2ff;
|
||||
border-color: #6366f1;
|
||||
color: #1e1b4b;
|
||||
}
|
||||
|
||||
.sms-var-item:hover code {
|
||||
color: #1e1b4b;
|
||||
}
|
||||
|
||||
.sms-var-item:hover .sms-var-item__desc {
|
||||
color: #312e81;
|
||||
}
|
||||
|
||||
.order-sms-template-picker {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
@@ -121,6 +121,9 @@
|
||||
<a class="sidebar__sublink<?= $currentMenu === 'settings' && $currentSettings === 'email-templates' ? ' is-active' : '' ?>" href="/settings/email-templates">
|
||||
Szablony e-mail
|
||||
</a>
|
||||
<a class="sidebar__sublink<?= $currentMenu === 'settings' && $currentSettings === 'sms-templates' ? ' is-active' : '' ?>" href="/settings/sms-templates">
|
||||
Szablony SMS
|
||||
</a>
|
||||
<a class="sidebar__sublink<?= $currentMenu === 'settings' && $currentSettings === 'automation' ? ' is-active' : '' ?>" href="/settings/automation">
|
||||
Zadania automatyczne
|
||||
</a>
|
||||
@@ -214,6 +217,7 @@
|
||||
<script src="/assets/js/modules/confirm-delete.js?ver=<?= filemtime(dirname(__DIR__, 3) . '/public/assets/js/modules/confirm-delete.js') ?: 0 ?>"></script>
|
||||
<script src="/assets/js/modules/alert-dismiss.js?ver=<?= filemtime(dirname(__DIR__, 3) . '/public/assets/js/modules/alert-dismiss.js') ?: 0 ?>"></script>
|
||||
<script src="/assets/js/modules/notifications.js?ver=<?= filemtime(dirname(__DIR__, 3) . '/public/assets/js/modules/notifications.js') ?: 0 ?>"></script>
|
||||
<script src="/assets/js/modules/sms-template-picker.js?ver=<?= filemtime(dirname(__DIR__, 3) . '/public/assets/js/modules/sms-template-picker.js') ?: 0 ?>"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.8/dist/chart.umd.min.js"></script>
|
||||
<script src="/assets/js/modules/statistics-summary-charts.js?ver=<?= filemtime(dirname(__DIR__, 3) . '/public/assets/js/modules/statistics-summary-charts.js') ?: 0 ?>"></script>
|
||||
<script>
|
||||
|
||||
@@ -17,6 +17,7 @@ $emailMailboxesList = is_array($emailMailboxes ?? null) ? $emailMailboxes : [];
|
||||
$smsMessagesList = is_array($smsMessages ?? null) ? $smsMessages : [];
|
||||
$smsPhoneValue = trim((string) ($smsPhone ?? ''));
|
||||
$smsDefaultFooterConfigured = (bool) ($smsDefaultFooterConfigured ?? false);
|
||||
$smsTemplatesList = is_array($smsTemplates ?? null) ? $smsTemplates : [];
|
||||
$historyList = is_array($history ?? null) ? $history : [];
|
||||
$activityLogList = is_array($activityLog ?? null) ? $activityLog : [];
|
||||
$statusPanelList = is_array($statusPanel ?? null) ? $statusPanel : [];
|
||||
@@ -1020,9 +1021,20 @@ foreach ($addressesList as $address) {
|
||||
<span class="field-label"><?= $e($t('orders.details.sms.phone')) ?></span>
|
||||
<input class="form-control" type="tel" name="phone" inputmode="tel" value="<?= $e($smsPhoneValue) ?>" required>
|
||||
</label>
|
||||
<?php if ($smsTemplatesList !== []): ?>
|
||||
<label class="form-field order-sms-template-picker">
|
||||
<span class="field-label"><?= $e($t('orders.details.sms.template_picker')) ?></span>
|
||||
<select class="form-control" data-sms-template-picker data-order-id="<?= (int) ($orderId ?? 0) ?>" data-message-target="js-sms-message">
|
||||
<option value=""><?= $e($t('orders.details.sms.template_picker_placeholder')) ?></option>
|
||||
<?php foreach ($smsTemplatesList as $smsTpl): ?>
|
||||
<option value="<?= (int) ($smsTpl['id'] ?? 0) ?>"><?= $e((string) ($smsTpl['name'] ?? '')) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<?php endif; ?>
|
||||
<label class="form-field">
|
||||
<span class="field-label"><?= $e($t('orders.details.sms.message')) ?></span>
|
||||
<textarea class="form-control" name="message" rows="3" maxlength="918" required></textarea>
|
||||
<textarea class="form-control" id="js-sms-message" name="message" rows="3" maxlength="918" required></textarea>
|
||||
<?php if ($smsDefaultFooterConfigured): ?>
|
||||
<span class="order-sms-footer-note"><?= $e($t('orders.details.sms.footer_note')) ?></span>
|
||||
<?php endif; ?>
|
||||
|
||||
109
resources/views/settings/sms-templates-form.php
Normal file
109
resources/views/settings/sms-templates-form.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
$template = is_array($template ?? null) ? $template : null;
|
||||
$isEdit = $template !== null;
|
||||
$variableGroups = is_array($variableGroups ?? null) ? $variableGroups : [];
|
||||
?>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="section-title"><?= $isEdit ? 'Edytuj szablon SMS' : 'Dodaj szablon SMS' ?></h2>
|
||||
<p class="muted mt-12">Wpisz tresc wiadomosci ze zmiennymi typu <code>{{zamowienie.numer}}</code>. Stopka SMSPLANET jest doklejana automatycznie przy wysylce, nie dopisuj jej w szablonie.</p>
|
||||
|
||||
<?php if (!empty($errorMessage)): ?>
|
||||
<div class="mt-12"><?php $type='danger'; $message=(string) $errorMessage; $dismissible=true; include dirname(__DIR__) . '/components/alert.php'; ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($successMessage)): ?>
|
||||
<div class="mt-12"><?php $type='success'; $message=(string) $successMessage; $dismissible=true; include dirname(__DIR__) . '/components/alert.php'; ?></div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="card mt-16">
|
||||
<form action="/settings/sms-templates/save" method="post" novalidate class="mt-12" id="js-sms-template-form">
|
||||
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
|
||||
<?php if ($isEdit): ?>
|
||||
<input type="hidden" name="id" value="<?= (int) ($template['id'] ?? 0) ?>">
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="form-grid-2">
|
||||
<label class="form-field">
|
||||
<span class="field-label">Nazwa szablonu *</span>
|
||||
<input class="form-control" type="text" name="name" maxlength="200" required value="<?= $e((string) ($template['name'] ?? '')) ?>" placeholder="np. Numer sledzenia">
|
||||
</label>
|
||||
<div class="form-field sms-template-active-field">
|
||||
<label class="sms-template-active-label">
|
||||
<input type="checkbox" name="is_active" value="1"<?= $isEdit ? (((int) ($template['is_active'] ?? 0)) === 1 ? ' checked' : '') : ' checked' ?>>
|
||||
<span class="field-label sms-template-active-text">Aktywny</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-12">
|
||||
<label class="form-field">
|
||||
<span class="field-label">Tresc wiadomosci *</span>
|
||||
<textarea class="form-control" name="body" id="js-sms-body" rows="6" maxlength="918" required placeholder="np. Czesc {{kupujacy.imie_nazwisko}}, Twoja przesylka {{przesylka.numer}} jest w drodze."><?= $e((string) ($template['body'] ?? '')) ?></textarea>
|
||||
<div class="sms-template-counter muted mt-4">
|
||||
<span id="js-sms-body-count">0</span> / 918 znakow
|
||||
<span class="sms-template-counter-warning" id="js-sms-body-warn" hidden>(pamietaj o stopce dodawanej przez SMSPLANET)</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="sms-var-panel mt-12">
|
||||
<div class="sms-var-panel__head">
|
||||
<span class="field-label sms-var-panel__title">Dostepne zmienne</span>
|
||||
<span class="muted sms-var-panel__hint">Kliknij chip, aby wstawic w pozycji kursora.</span>
|
||||
</div>
|
||||
<?php foreach ($variableGroups as $groupKey => $group): ?>
|
||||
<div class="sms-var-group" data-group="<?= $e($groupKey) ?>">
|
||||
<div class="sms-var-group__label"><?= $e((string) ($group['label'] ?? '')) ?></div>
|
||||
<div class="sms-var-group__items">
|
||||
<?php foreach (($group['vars'] ?? []) as $varKey => $varDesc): ?>
|
||||
<button type="button" class="sms-var-item js-sms-var-insert" data-var="{{<?= $e($groupKey . '.' . $varKey) ?>}}" title="<?= $e((string) $varDesc) ?>">
|
||||
<code>{{<?= $e($groupKey . '.' . $varKey) ?>}}</code>
|
||||
<span class="sms-var-item__desc"><?= $e((string) $varDesc) ?></span>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<div class="form-actions mt-16">
|
||||
<button type="submit" class="btn btn--primary"><?= $isEdit ? 'Zapisz zmiany' : 'Dodaj szablon' ?></button>
|
||||
<a href="/settings/sms-templates" class="btn btn--secondary">Powrot do listy</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var textarea = document.getElementById('js-sms-body');
|
||||
var counter = document.getElementById('js-sms-body-count');
|
||||
var warn = document.getElementById('js-sms-body-warn');
|
||||
if (!textarea || !counter) return;
|
||||
|
||||
function updateCount() {
|
||||
var len = textarea.value.length;
|
||||
counter.textContent = String(len);
|
||||
if (warn) {
|
||||
warn.hidden = len < 700;
|
||||
}
|
||||
}
|
||||
updateCount();
|
||||
textarea.addEventListener('input', updateCount);
|
||||
|
||||
document.querySelectorAll('.js-sms-var-insert').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var snippet = btn.getAttribute('data-var') || '';
|
||||
var start = textarea.selectionStart || 0;
|
||||
var end = textarea.selectionEnd || 0;
|
||||
var before = textarea.value.substring(0, start);
|
||||
var after = textarea.value.substring(end);
|
||||
textarea.value = before + snippet + after;
|
||||
var pos = start + snippet.length;
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(pos, pos);
|
||||
updateCount();
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
110
resources/views/settings/sms-templates.php
Normal file
110
resources/views/settings/sms-templates.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
$templates = is_array($templates ?? null) ? $templates : [];
|
||||
?>
|
||||
|
||||
<section class="card">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Szablony SMS</h2>
|
||||
<a href="/settings/sms-templates/create" class="btn btn--primary btn--sm">Dodaj szablon</a>
|
||||
</div>
|
||||
<p class="muted mt-12">Szybkie szablony wiadomosci SMS do wstawiania z zakladki SMS w szczegolach zamowienia. Stopka SMSPLANET jest doklejana automatycznie.</p>
|
||||
|
||||
<?php if (!empty($errorMessage)): ?>
|
||||
<div class="mt-12"><?php $type='danger'; $message=(string) $errorMessage; $dismissible=true; include dirname(__DIR__) . '/components/alert.php'; ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($successMessage)): ?>
|
||||
<div class="mt-12"><?php $type='success'; $message=(string) $successMessage; $dismissible=true; include dirname(__DIR__) . '/components/alert.php'; ?></div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="card mt-16">
|
||||
<h3 class="section-title">Lista szablonow</h3>
|
||||
|
||||
<?php if (count($templates) === 0): ?>
|
||||
<p class="muted mt-12">Brak szablonow. Kliknij "Dodaj szablon", aby utworzyc pierwszy.</p>
|
||||
<?php else: ?>
|
||||
<div class="table-wrap mt-12">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nazwa</th>
|
||||
<th>Tresc</th>
|
||||
<th>Status</th>
|
||||
<th>Akcje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($templates as $tpl): ?>
|
||||
<?php
|
||||
$templateId = (int) ($tpl['id'] ?? 0);
|
||||
$bodyPreview = trim((string) ($tpl['body'] ?? ''));
|
||||
if (function_exists('mb_strlen') ? mb_strlen($bodyPreview) > 80 : strlen($bodyPreview) > 80) {
|
||||
$bodyPreview = (function_exists('mb_substr') ? mb_substr($bodyPreview, 0, 80) : substr($bodyPreview, 0, 80)) . '...';
|
||||
}
|
||||
?>
|
||||
<tr data-id="<?= $templateId ?>">
|
||||
<td><?= $e((string) ($tpl['name'] ?? '')) ?></td>
|
||||
<td><?= $e($bodyPreview) ?></td>
|
||||
<td>
|
||||
<?php if (((int) ($tpl['is_active'] ?? 0)) === 1): ?>
|
||||
<span class="badge badge--success js-status-badge">Aktywny</span>
|
||||
<?php else: ?>
|
||||
<span class="badge badge--muted js-status-badge">Nieaktywny</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="sms-template-actions">
|
||||
<a href="/settings/sms-templates/edit?id=<?= $templateId ?>" class="btn btn--sm btn--secondary">Edytuj</a>
|
||||
<button type="button" class="btn btn--sm btn--secondary js-toggle-btn"
|
||||
data-id="<?= $templateId ?>"
|
||||
data-active="<?= (int) ($tpl['is_active'] ?? 0) ?>">
|
||||
<?= ((int) ($tpl['is_active'] ?? 0)) === 1 ? 'Dezaktywuj' : 'Aktywuj' ?>
|
||||
</button>
|
||||
<form action="/settings/sms-templates/delete" method="post" class="inline-form js-confirm-delete" data-confirm-title="Usuwanie szablonu" data-confirm-message="Czy na pewno chcesz usunac ten szablon SMS?">
|
||||
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
|
||||
<input type="hidden" name="id" value="<?= $templateId ?>">
|
||||
<button type="button" class="btn btn--sm btn--danger js-delete-btn">Usun</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var csrfToken = <?= json_encode($csrfToken ?? '', JSON_HEX_TAG) ?>;
|
||||
|
||||
document.querySelectorAll('.js-toggle-btn').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var id = this.getAttribute('data-id');
|
||||
var isActive = this.getAttribute('data-active') === '1';
|
||||
var toggleBtn = this;
|
||||
|
||||
var fd = new FormData();
|
||||
fd.append('_token', csrfToken);
|
||||
fd.append('id', id);
|
||||
|
||||
toggleBtn.disabled = true;
|
||||
fetch('/settings/sms-templates/toggle', { method: 'POST', body: fd })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (data.success) {
|
||||
var newActive = !isActive;
|
||||
toggleBtn.setAttribute('data-active', newActive ? '1' : '0');
|
||||
toggleBtn.textContent = newActive ? 'Dezaktywuj' : 'Aktywuj';
|
||||
var badge = toggleBtn.closest('tr').querySelector('.js-status-badge');
|
||||
if (badge) {
|
||||
badge.textContent = newActive ? 'Aktywny' : 'Nieaktywny';
|
||||
badge.className = 'badge js-status-badge ' + (newActive ? 'badge--success' : 'badge--muted');
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(function () {})
|
||||
.finally(function () { toggleBtn.disabled = false; });
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -56,6 +56,7 @@ use App\Modules\Settings\EmailMailboxController;
|
||||
use App\Modules\Settings\EmailMailboxRepository;
|
||||
use App\Modules\Settings\EmailTemplateController;
|
||||
use App\Modules\Settings\EmailTemplateRepository;
|
||||
use App\Modules\Settings\SmsTemplateController;
|
||||
use App\Modules\Settings\IntegrationSecretCipher;
|
||||
use App\Modules\Email\AttachmentGenerator;
|
||||
use App\Modules\Email\EmailSendingService;
|
||||
@@ -100,6 +101,8 @@ use App\Modules\Notifications\NotificationController;
|
||||
use App\Modules\Notifications\NotificationRepository;
|
||||
use App\Modules\Sms\SmsConversationService;
|
||||
use App\Modules\Sms\SmsMessageRepository;
|
||||
use App\Modules\Sms\SmsTemplateRepository;
|
||||
use App\Modules\Sms\SmsVariableResolver;
|
||||
use App\Modules\Sms\SmsplanetWebhookController;
|
||||
use App\Modules\Users\UsersController;
|
||||
|
||||
@@ -315,6 +318,13 @@ return static function (Application $app): void {
|
||||
$emailTemplateRepository,
|
||||
$emailMailboxRepository
|
||||
);
|
||||
$smsTemplateRepository = new SmsTemplateRepository($app->db());
|
||||
$smsTemplateController = new SmsTemplateController(
|
||||
$template,
|
||||
$translator,
|
||||
$auth,
|
||||
$smsTemplateRepository
|
||||
);
|
||||
$automationRepository = new AutomationRepository($app->db());
|
||||
$automationExecutionLogRepository = new AutomationExecutionLogRepository($app->db());
|
||||
$automationController = new AutomationController(
|
||||
@@ -325,7 +335,8 @@ return static function (Application $app): void {
|
||||
$automationExecutionLogRepository,
|
||||
$receiptConfigRepository
|
||||
);
|
||||
$variableResolver = new VariableResolver($shipmentPackageRepositoryForOrders);
|
||||
$smsVariableResolver = new SmsVariableResolver($shipmentPackageRepositoryForOrders);
|
||||
$variableResolver = new VariableResolver($shipmentPackageRepositoryForOrders, $smsVariableResolver);
|
||||
$attachmentGenerator = new AttachmentGenerator($receiptRepository, $receiptConfigRepository, $template);
|
||||
$emailSendingService = new EmailSendingService(
|
||||
$app->db(),
|
||||
@@ -386,7 +397,7 @@ return static function (Application $app): void {
|
||||
$allegroDeliveryMappingController
|
||||
);
|
||||
$printJobRepository = new PrintJobRepository($app->db());
|
||||
$ordersController = new OrdersController($template, $translator, $auth, $app->orders(), $shipmentPackageRepositoryForOrders, $receiptRepository, $receiptConfigRepository, $emailSendingService, $emailTemplateRepository, $emailMailboxRepository, $app->basePath('storage'), $printJobRepository, $shopproIntegrationsRepository, $automationService, $invoiceRepository, $invoiceConfigRepository, $smsMessageRepository, $smsConversationService);
|
||||
$ordersController = new OrdersController($template, $translator, $auth, $app->orders(), $shipmentPackageRepositoryForOrders, $receiptRepository, $receiptConfigRepository, $emailSendingService, $emailTemplateRepository, $emailMailboxRepository, $app->basePath('storage'), $printJobRepository, $shopproIntegrationsRepository, $automationService, $invoiceRepository, $invoiceConfigRepository, $smsMessageRepository, $smsConversationService, $smsTemplateRepository, $smsVariableResolver, $companySettingsRepository);
|
||||
$ordersStatisticsController = new OrdersStatisticsController(
|
||||
$template,
|
||||
$translator,
|
||||
@@ -554,6 +565,7 @@ return static function (Application $app): void {
|
||||
$router->post('/orders/{id}/status', [$ordersController, 'updateStatus'], [$authMiddleware]);
|
||||
$router->post('/orders/{id}/details/update', [$ordersController, 'updateDetails'], [$authMiddleware]);
|
||||
$router->post('/orders/{id}/sms/send', [$ordersController, 'sendSms'], [$authMiddleware]);
|
||||
$router->get('/orders/{id}/sms/template', [$ordersController, 'smsTemplate'], [$authMiddleware]);
|
||||
$router->post('/orders/{id}/send-email', [$ordersController, 'sendEmail'], [$authMiddleware]);
|
||||
$router->post('/orders/{id}/email-preview', [$ordersController, 'emailPreview'], [$authMiddleware]);
|
||||
$router->get('/api/orders/search', [$ordersController, 'quickSearch'], [$authMiddleware]);
|
||||
@@ -645,6 +657,13 @@ return static function (Application $app): void {
|
||||
$router->post('/settings/email-templates/toggle', [$emailTemplateController, 'toggleStatus'], [$authMiddleware]);
|
||||
$router->post('/settings/email-templates/preview', [$emailTemplateController, 'preview'], [$authMiddleware]);
|
||||
$router->get('/settings/email-templates/variables', [$emailTemplateController, 'getVariables'], [$authMiddleware]);
|
||||
$router->get('/settings/sms-templates', [$smsTemplateController, 'index'], [$authMiddleware]);
|
||||
$router->get('/settings/sms-templates/create', [$smsTemplateController, 'create'], [$authMiddleware]);
|
||||
$router->get('/settings/sms-templates/edit', [$smsTemplateController, 'edit'], [$authMiddleware]);
|
||||
$router->post('/settings/sms-templates/save', [$smsTemplateController, 'save'], [$authMiddleware]);
|
||||
$router->post('/settings/sms-templates/delete', [$smsTemplateController, 'delete'], [$authMiddleware]);
|
||||
$router->post('/settings/sms-templates/toggle', [$smsTemplateController, 'toggleStatus'], [$authMiddleware]);
|
||||
$router->get('/settings/sms-templates/variables', [$smsTemplateController, 'getVariables'], [$authMiddleware]);
|
||||
$router->get('/settings/automation', [$automationController, 'index'], [$authMiddleware]);
|
||||
$router->get('/settings/automation/create', [$automationController, 'create'], [$authMiddleware]);
|
||||
$router->post('/settings/automation/store', [$automationController, 'store'], [$authMiddleware]);
|
||||
|
||||
@@ -4,14 +4,18 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Email;
|
||||
|
||||
use App\Modules\Shipments\DeliveryStatus;
|
||||
use App\Modules\Shipments\ShipmentPackageRepository;
|
||||
use App\Modules\Sms\SmsVariableResolver;
|
||||
|
||||
final class VariableResolver
|
||||
{
|
||||
private readonly SmsVariableResolver $inner;
|
||||
|
||||
public function __construct(
|
||||
private readonly ShipmentPackageRepository $shipmentPackageRepository
|
||||
ShipmentPackageRepository $shipmentPackageRepository,
|
||||
?SmsVariableResolver $inner = null
|
||||
) {
|
||||
$this->inner = $inner ?? new SmsVariableResolver($shipmentPackageRepository);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -22,95 +26,14 @@ final class VariableResolver
|
||||
*/
|
||||
public function buildVariableMap(array $order, array $addresses, array $companySettings): array
|
||||
{
|
||||
$customerAddress = $this->findAddress($addresses, 'customer');
|
||||
$deliveryAddress = $this->findAddress($addresses, 'delivery') ?? $customerAddress;
|
||||
|
||||
$buyerName = (string) ($customerAddress['name'] ?? '');
|
||||
$buyerEmail = (string) ($customerAddress['email'] ?? '');
|
||||
$buyerPhone = (string) ($customerAddress['phone'] ?? '');
|
||||
|
||||
$totalFormatted = number_format((float) ($order['total_with_tax'] ?? 0), 2, ',', ' ');
|
||||
$orderedAt = (string) ($order['ordered_at'] ?? '');
|
||||
if ($orderedAt !== '' && ($ts = strtotime($orderedAt)) !== false) {
|
||||
$orderedAt = date('Y-m-d', $ts);
|
||||
}
|
||||
|
||||
$baseVariables = [
|
||||
'zamowienie.numer' => (string) ($order['internal_order_number'] ?? $order['id'] ?? ''),
|
||||
'zamowienie.numer_zewnetrzny' => (string) ($order['external_order_id'] ?? $order['source_order_id'] ?? ''),
|
||||
'zamowienie.zrodlo' => ucfirst((string) ($order['source'] ?? '')),
|
||||
'zamowienie.kwota' => $totalFormatted,
|
||||
'zamowienie.waluta' => (string) ($order['currency'] ?? 'PLN'),
|
||||
'zamowienie.data' => $orderedAt,
|
||||
'kupujacy.imie_nazwisko' => $buyerName,
|
||||
'kupujacy.email' => $buyerEmail,
|
||||
'kupujacy.telefon' => $buyerPhone,
|
||||
'kupujacy.login' => (string) ($order['customer_login'] ?? ''),
|
||||
'adres.ulica' => trim(($deliveryAddress['street_name'] ?? '') . ' ' . ($deliveryAddress['street_number'] ?? '')),
|
||||
'adres.miasto' => (string) ($deliveryAddress['city'] ?? ''),
|
||||
'adres.kod_pocztowy' => (string) ($deliveryAddress['zip_code'] ?? ''),
|
||||
'adres.kraj' => (string) ($deliveryAddress['country'] ?? ''),
|
||||
'firma.nazwa' => (string) ($companySettings['company_name'] ?? ''),
|
||||
'firma.nip' => (string) ($companySettings['tax_number'] ?? ''),
|
||||
];
|
||||
|
||||
return $baseVariables + $this->resolveShipmentVariables($order);
|
||||
return $this->inner->buildVariableMap($order, $addresses, $companySettings);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $variableMap
|
||||
*/
|
||||
public function resolve(string $template, array $variableMap): string
|
||||
{
|
||||
return preg_replace_callback(
|
||||
'/\{\{([a-z_]+\.[a-z_]+)\}\}/',
|
||||
static fn(array $m): string => $variableMap[$m[1]] ?? '',
|
||||
$template
|
||||
) ?? $template;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $addresses
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function findAddress(array $addresses, string $type): ?array
|
||||
{
|
||||
foreach ($addresses as $addr) {
|
||||
if (($addr['address_type'] ?? '') === $type) {
|
||||
return $addr;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $order
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function resolveShipmentVariables(array $order): array
|
||||
{
|
||||
$orderId = (int) ($order['id'] ?? 0);
|
||||
if ($orderId <= 0) {
|
||||
return [
|
||||
'przesylka.numer' => '',
|
||||
'przesylka.link_sledzenia' => '',
|
||||
];
|
||||
}
|
||||
|
||||
$latestPackage = $this->shipmentPackageRepository->findLatestByOrderId($orderId);
|
||||
if (!is_array($latestPackage)) {
|
||||
return [
|
||||
'przesylka.numer' => '',
|
||||
'przesylka.link_sledzenia' => '',
|
||||
];
|
||||
}
|
||||
|
||||
$trackingNumber = trim((string) ($latestPackage['tracking_number'] ?? ''));
|
||||
$provider = trim((string) ($latestPackage['provider'] ?? ''));
|
||||
$carrierId = trim((string) ($latestPackage['carrier_id'] ?? ''));
|
||||
$trackingUrl = DeliveryStatus::trackingUrl($provider, $trackingNumber, $carrierId) ?? '';
|
||||
|
||||
return [
|
||||
'przesylka.numer' => $trackingNumber,
|
||||
'przesylka.link_sledzenia' => $trackingUrl,
|
||||
];
|
||||
return $this->inner->resolve($template, $variableMap);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,11 @@ use App\Modules\Automation\AutomationService;
|
||||
use App\Modules\Settings\ShopproApiClient;
|
||||
use App\Modules\Settings\ShopproIntegrationsRepository;
|
||||
use App\Modules\Shipments\ShipmentPackageRepository;
|
||||
use App\Modules\Settings\CompanySettingsRepository;
|
||||
use App\Modules\Sms\SmsConversationService;
|
||||
use App\Modules\Sms\SmsMessageRepository;
|
||||
use App\Modules\Sms\SmsTemplateRepository;
|
||||
use App\Modules\Sms\SmsVariableResolver;
|
||||
use Throwable;
|
||||
|
||||
final class OrdersController
|
||||
@@ -46,7 +49,10 @@ final class OrdersController
|
||||
private readonly ?InvoiceRepository $invoiceRepo = null,
|
||||
private readonly ?InvoiceConfigRepository $invoiceConfigRepo = null,
|
||||
private readonly ?SmsMessageRepository $smsMessages = null,
|
||||
private readonly ?SmsConversationService $smsConversation = null
|
||||
private readonly ?SmsConversationService $smsConversation = null,
|
||||
private readonly ?SmsTemplateRepository $smsTemplates = null,
|
||||
private readonly ?SmsVariableResolver $smsVariableResolver = null,
|
||||
private readonly ?CompanySettingsRepository $companySettingsRepo = null
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -255,6 +261,7 @@ final class OrdersController
|
||||
$smsMessages = $this->smsMessages !== null ? $this->smsMessages->findByOrderId($orderId) : [];
|
||||
$smsPhone = $this->resolveSmsPhone($order, $addresses);
|
||||
$smsDefaultFooterConfigured = $this->smsConversation !== null && $this->smsConversation->hasDefaultFooter();
|
||||
$smsTemplates = $this->smsTemplates !== null ? $this->smsTemplates->listActive() : [];
|
||||
|
||||
$html = $this->template->render('orders/show', [
|
||||
'title' => $this->translator->get('orders.details.title') . ' #' . $orderId,
|
||||
@@ -290,6 +297,7 @@ final class OrdersController
|
||||
'smsMessages' => $smsMessages,
|
||||
'smsPhone' => $smsPhone,
|
||||
'smsDefaultFooterConfigured' => $smsDefaultFooterConfigured,
|
||||
'smsTemplates' => $smsTemplates,
|
||||
], 'layouts/app');
|
||||
|
||||
return Response::html($html);
|
||||
@@ -331,6 +339,44 @@ final class OrdersController
|
||||
return Response::redirect($redirectTo);
|
||||
}
|
||||
|
||||
public function smsTemplate(Request $request): Response
|
||||
{
|
||||
$orderId = max(0, (int) $request->input('id', 0));
|
||||
$templateId = max(0, (int) $request->input('template_id', 0));
|
||||
|
||||
if ($orderId <= 0 || $templateId <= 0) {
|
||||
return Response::json(['ok' => false, 'error' => 'Nieprawidlowe parametry.'], 400);
|
||||
}
|
||||
if ($this->smsTemplates === null || $this->smsVariableResolver === null) {
|
||||
return Response::json(['ok' => false, 'error' => 'Modul szablonow SMS nie jest dostepny.'], 500);
|
||||
}
|
||||
|
||||
$template = $this->smsTemplates->findById($templateId);
|
||||
if ($template === null || (int) ($template['is_active'] ?? 0) !== 1) {
|
||||
return Response::json(['ok' => false, 'error' => 'Szablon nie istnieje albo jest nieaktywny.'], 404);
|
||||
}
|
||||
|
||||
$details = $this->orders->findDetails($orderId);
|
||||
if ($details === null) {
|
||||
return Response::json(['ok' => false, 'error' => 'Zamowienie nie znalezione.'], 404);
|
||||
}
|
||||
|
||||
$order = is_array($details['order'] ?? null) ? $details['order'] : [];
|
||||
$addresses = is_array($details['addresses'] ?? null) ? $details['addresses'] : [];
|
||||
$companySettings = $this->companySettingsRepo !== null
|
||||
? $this->companySettingsRepo->getSettings()
|
||||
: [];
|
||||
|
||||
$variableMap = $this->smsVariableResolver->buildVariableMap($order, $addresses, $companySettings);
|
||||
$resolvedBody = $this->smsVariableResolver->resolve((string) ($template['body'] ?? ''), $variableMap);
|
||||
|
||||
return Response::json([
|
||||
'ok' => true,
|
||||
'body' => $resolvedBody,
|
||||
'name' => (string) ($template['name'] ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sklada informacje o historii zwrotow klienta biezacego zamowienia.
|
||||
*
|
||||
|
||||
219
src/Modules/Settings/SmsTemplateController.php
Normal file
219
src/Modules/Settings/SmsTemplateController.php
Normal file
@@ -0,0 +1,219 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Settings;
|
||||
|
||||
use App\Modules\Auth\AuthService;
|
||||
use App\Core\Http\Request;
|
||||
use App\Core\Http\Response;
|
||||
use App\Core\Security\Csrf;
|
||||
use App\Core\Support\Flash;
|
||||
use App\Core\View\Template;
|
||||
use App\Core\I18n\Translator;
|
||||
use App\Modules\Sms\SmsTemplateRepository;
|
||||
use Throwable;
|
||||
|
||||
final class SmsTemplateController
|
||||
{
|
||||
private const VARIABLE_GROUPS = [
|
||||
'zamowienie' => [
|
||||
'label' => 'Zamowienie',
|
||||
'vars' => [
|
||||
'numer' => 'Numer wewnetrzny (OP...)',
|
||||
'numer_zewnetrzny' => 'Numer z platformy',
|
||||
'zrodlo' => 'Zrodlo (Allegro/shopPRO/...)',
|
||||
'kwota' => 'Kwota brutto',
|
||||
'waluta' => 'Waluta (PLN/EUR/...)',
|
||||
'data' => 'Data zamowienia',
|
||||
],
|
||||
],
|
||||
'kupujacy' => [
|
||||
'label' => 'Kupujacy',
|
||||
'vars' => [
|
||||
'imie_nazwisko' => 'Imie i nazwisko',
|
||||
'email' => 'Adres e-mail',
|
||||
'telefon' => 'Telefon',
|
||||
'login' => 'Login platformy',
|
||||
],
|
||||
],
|
||||
'adres' => [
|
||||
'label' => 'Adres dostawy',
|
||||
'vars' => [
|
||||
'ulica' => 'Ulica z numerem',
|
||||
'miasto' => 'Miasto',
|
||||
'kod_pocztowy' => 'Kod pocztowy',
|
||||
'kraj' => 'Kraj',
|
||||
],
|
||||
],
|
||||
'firma' => [
|
||||
'label' => 'Firma',
|
||||
'vars' => [
|
||||
'nazwa' => 'Nazwa firmy',
|
||||
'nip' => 'NIP',
|
||||
],
|
||||
],
|
||||
'przesylka' => [
|
||||
'label' => 'Przesylka',
|
||||
'vars' => [
|
||||
'numer' => 'Numer przesylki (tracking)',
|
||||
'link_sledzenia' => 'Link sledzenia zalezny od kuriera',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly Template $template,
|
||||
private readonly Translator $translator,
|
||||
private readonly AuthService $auth,
|
||||
private readonly SmsTemplateRepository $repository
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$templates = $this->repository->listAll();
|
||||
|
||||
$html = $this->template->render('settings/sms-templates', [
|
||||
'title' => 'Szablony SMS',
|
||||
'activeMenu' => 'settings',
|
||||
'activeSettings' => 'sms-templates',
|
||||
'user' => $this->auth->user(),
|
||||
'csrfToken' => Csrf::token(),
|
||||
'templates' => $templates,
|
||||
'successMessage' => Flash::get('settings.sms_templates.success', ''),
|
||||
'errorMessage' => Flash::get('settings.sms_templates.error', ''),
|
||||
], 'layouts/app');
|
||||
|
||||
return Response::html($html);
|
||||
}
|
||||
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
return $this->renderForm(null);
|
||||
}
|
||||
|
||||
public function edit(Request $request): Response
|
||||
{
|
||||
$id = (int) $request->input('id', '0');
|
||||
$template = $id > 0 ? $this->repository->findById($id) : null;
|
||||
|
||||
if ($template === null) {
|
||||
Flash::set('settings.sms_templates.error', 'Nie znaleziono szablonu');
|
||||
return Response::redirect('/settings/sms-templates');
|
||||
}
|
||||
|
||||
return $this->renderForm($template);
|
||||
}
|
||||
|
||||
public function save(Request $request): Response
|
||||
{
|
||||
$templateId = (int) $request->input('id', '0');
|
||||
$formPath = $this->buildFormPath($templateId > 0 ? $templateId : null);
|
||||
|
||||
if (!Csrf::validate((string) $request->input('_token', ''))) {
|
||||
Flash::set('settings.sms_templates.error', 'Nieprawidlowy token CSRF');
|
||||
return Response::redirect($formPath);
|
||||
}
|
||||
|
||||
$name = trim((string) $request->input('name', ''));
|
||||
$body = (string) $request->input('body', '');
|
||||
|
||||
if ($name === '' || trim($body) === '') {
|
||||
Flash::set('settings.sms_templates.error', 'Nazwa i tresc sa wymagane');
|
||||
return Response::redirect($formPath);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->repository->save([
|
||||
'id' => $request->input('id', ''),
|
||||
'name' => $name,
|
||||
'body' => $body,
|
||||
'is_active' => $request->input('is_active', null),
|
||||
]);
|
||||
|
||||
Flash::set('settings.sms_templates.success', 'Szablon SMS zapisany');
|
||||
} catch (Throwable $exception) {
|
||||
Flash::set('settings.sms_templates.error', 'Blad zapisu szablonu: ' . $exception->getMessage());
|
||||
return Response::redirect($formPath);
|
||||
}
|
||||
|
||||
return Response::redirect('/settings/sms-templates');
|
||||
}
|
||||
|
||||
public function delete(Request $request): Response
|
||||
{
|
||||
if (!Csrf::validate((string) $request->input('_token', ''))) {
|
||||
Flash::set('settings.sms_templates.error', 'Nieprawidlowy token CSRF');
|
||||
return Response::redirect('/settings/sms-templates');
|
||||
}
|
||||
|
||||
$id = (int) $request->input('id', '0');
|
||||
if ($id <= 0) {
|
||||
Flash::set('settings.sms_templates.error', 'Nieprawidlowy identyfikator szablonu');
|
||||
return Response::redirect('/settings/sms-templates');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->repository->delete($id);
|
||||
Flash::set('settings.sms_templates.success', 'Szablon SMS usuniety');
|
||||
} catch (Throwable) {
|
||||
Flash::set('settings.sms_templates.error', 'Blad usuwania szablonu');
|
||||
}
|
||||
|
||||
return Response::redirect('/settings/sms-templates');
|
||||
}
|
||||
|
||||
public function toggleStatus(Request $request): Response
|
||||
{
|
||||
if (!Csrf::validate((string) $request->input('_token', ''))) {
|
||||
return Response::json(['success' => false, 'message' => 'Nieprawidlowy token CSRF'], 403);
|
||||
}
|
||||
|
||||
$id = (int) $request->input('id', '0');
|
||||
if ($id <= 0) {
|
||||
return Response::json(['success' => false, 'message' => 'Nieprawidlowy identyfikator'], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->repository->toggleStatus($id);
|
||||
return Response::json(['success' => true]);
|
||||
} catch (Throwable) {
|
||||
return Response::json(['success' => false, 'message' => 'Blad zmiany statusu'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function getVariables(Request $request): Response
|
||||
{
|
||||
return Response::json([
|
||||
'success' => true,
|
||||
'groups' => self::VARIABLE_GROUPS,
|
||||
]);
|
||||
}
|
||||
|
||||
private function renderForm(?array $template): Response
|
||||
{
|
||||
$html = $this->template->render('settings/sms-templates-form', [
|
||||
'title' => $template !== null ? 'Edytuj szablon SMS' : 'Nowy szablon SMS',
|
||||
'activeMenu' => 'settings',
|
||||
'activeSettings' => 'sms-templates',
|
||||
'user' => $this->auth->user(),
|
||||
'csrfToken' => Csrf::token(),
|
||||
'template' => $template,
|
||||
'variableGroups' => self::VARIABLE_GROUPS,
|
||||
'successMessage' => Flash::get('settings.sms_templates.success', ''),
|
||||
'errorMessage' => Flash::get('settings.sms_templates.error', ''),
|
||||
], 'layouts/app');
|
||||
|
||||
return Response::html($html);
|
||||
}
|
||||
|
||||
private function buildFormPath(?int $templateId): string
|
||||
{
|
||||
if ($templateId !== null && $templateId > 0) {
|
||||
return '/settings/sms-templates/edit?id=' . $templateId;
|
||||
}
|
||||
|
||||
return '/settings/sms-templates/create';
|
||||
}
|
||||
}
|
||||
121
src/Modules/Sms/SmsTemplateRepository.php
Normal file
121
src/Modules/Sms/SmsTemplateRepository.php
Normal file
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Sms;
|
||||
|
||||
use App\Core\Http\ToggleableRepositoryTrait;
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
final class SmsTemplateRepository
|
||||
{
|
||||
use ToggleableRepositoryTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly PDO $pdo
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function listAll(): array
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT id, name, body, is_active, created_at, updated_at
|
||||
FROM sms_templates
|
||||
ORDER BY name ASC'
|
||||
);
|
||||
$statement->execute();
|
||||
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
return is_array($rows) ? $rows : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function listActive(): array
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT id, name, body
|
||||
FROM sms_templates
|
||||
WHERE is_active = 1
|
||||
ORDER BY name ASC'
|
||||
);
|
||||
$statement->execute();
|
||||
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
return is_array($rows) ? $rows : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function findById(int $id): ?array
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT id, name, body, is_active, created_at, updated_at
|
||||
FROM sms_templates
|
||||
WHERE id = :id'
|
||||
);
|
||||
$statement->execute(['id' => $id]);
|
||||
$row = $statement->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function save(array $data): int
|
||||
{
|
||||
$name = trim((string) ($data['name'] ?? ''));
|
||||
$body = (string) ($data['body'] ?? '');
|
||||
if ($name === '') {
|
||||
throw new RuntimeException('Nazwa szablonu jest wymagana.');
|
||||
}
|
||||
if (trim($body) === '') {
|
||||
throw new RuntimeException('Tresc szablonu jest wymagana.');
|
||||
}
|
||||
|
||||
$id = isset($data['id']) && $data['id'] !== '' ? (int) $data['id'] : null;
|
||||
$params = [
|
||||
'name' => $name,
|
||||
'body' => $body,
|
||||
'is_active' => isset($data['is_active']) && $data['is_active'] ? 1 : 0,
|
||||
];
|
||||
|
||||
if ($id !== null) {
|
||||
$params['id'] = $id;
|
||||
$statement = $this->pdo->prepare(
|
||||
'UPDATE sms_templates
|
||||
SET name = :name, body = :body, is_active = :is_active
|
||||
WHERE id = :id'
|
||||
);
|
||||
$statement->execute($params);
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO sms_templates (name, body, is_active)
|
||||
VALUES (:name, :body, :is_active)'
|
||||
);
|
||||
$statement->execute($params);
|
||||
|
||||
return (int) $this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$statement = $this->pdo->prepare('DELETE FROM sms_templates WHERE id = :id');
|
||||
$statement->execute(['id' => $id]);
|
||||
}
|
||||
|
||||
public function toggleStatus(int $id): void
|
||||
{
|
||||
$this->toggleActive('sms_templates', $id);
|
||||
}
|
||||
}
|
||||
119
src/Modules/Sms/SmsVariableResolver.php
Normal file
119
src/Modules/Sms/SmsVariableResolver.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Sms;
|
||||
|
||||
use App\Modules\Shipments\DeliveryStatus;
|
||||
use App\Modules\Shipments\ShipmentPackageRepository;
|
||||
|
||||
final class SmsVariableResolver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ShipmentPackageRepository $shipmentPackageRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $order
|
||||
* @param array<int, array<string, mixed>> $addresses
|
||||
* @param array<string, mixed> $companySettings
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function buildVariableMap(array $order, array $addresses, array $companySettings): array
|
||||
{
|
||||
$customerAddress = $this->findAddress($addresses, 'customer');
|
||||
$deliveryAddress = $this->findAddress($addresses, 'delivery') ?? $customerAddress;
|
||||
|
||||
$buyerName = (string) ($customerAddress['name'] ?? '');
|
||||
$buyerEmail = (string) ($customerAddress['email'] ?? '');
|
||||
$buyerPhone = (string) ($customerAddress['phone'] ?? '');
|
||||
|
||||
$totalFormatted = number_format((float) ($order['total_with_tax'] ?? 0), 2, ',', ' ');
|
||||
$orderedAt = (string) ($order['ordered_at'] ?? '');
|
||||
if ($orderedAt !== '' && ($ts = strtotime($orderedAt)) !== false) {
|
||||
$orderedAt = date('Y-m-d', $ts);
|
||||
}
|
||||
|
||||
$baseVariables = [
|
||||
'zamowienie.numer' => (string) ($order['internal_order_number'] ?? $order['id'] ?? ''),
|
||||
'zamowienie.numer_zewnetrzny' => (string) ($order['external_order_id'] ?? $order['source_order_id'] ?? ''),
|
||||
'zamowienie.zrodlo' => ucfirst((string) ($order['source'] ?? '')),
|
||||
'zamowienie.kwota' => $totalFormatted,
|
||||
'zamowienie.waluta' => (string) ($order['currency'] ?? 'PLN'),
|
||||
'zamowienie.data' => $orderedAt,
|
||||
'kupujacy.imie_nazwisko' => $buyerName,
|
||||
'kupujacy.email' => $buyerEmail,
|
||||
'kupujacy.telefon' => $buyerPhone,
|
||||
'kupujacy.login' => (string) ($order['customer_login'] ?? ''),
|
||||
'adres.ulica' => trim(($deliveryAddress['street_name'] ?? '') . ' ' . ($deliveryAddress['street_number'] ?? '')),
|
||||
'adres.miasto' => (string) ($deliveryAddress['city'] ?? ''),
|
||||
'adres.kod_pocztowy' => (string) ($deliveryAddress['zip_code'] ?? ''),
|
||||
'adres.kraj' => (string) ($deliveryAddress['country'] ?? ''),
|
||||
'firma.nazwa' => (string) ($companySettings['company_name'] ?? ''),
|
||||
'firma.nip' => (string) ($companySettings['tax_number'] ?? ''),
|
||||
];
|
||||
|
||||
return $baseVariables + $this->resolveShipmentVariables($order);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $variableMap
|
||||
*/
|
||||
public function resolve(string $template, array $variableMap): string
|
||||
{
|
||||
return preg_replace_callback(
|
||||
'/\{\{([a-z_]+\.[a-z_]+)\}\}/',
|
||||
static fn(array $m): string => $variableMap[$m[1]] ?? '',
|
||||
$template
|
||||
) ?? $template;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $addresses
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function findAddress(array $addresses, string $type): ?array
|
||||
{
|
||||
foreach ($addresses as $addr) {
|
||||
if (($addr['address_type'] ?? '') === $type) {
|
||||
return $addr;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $order
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function resolveShipmentVariables(array $order): array
|
||||
{
|
||||
$orderId = (int) ($order['id'] ?? 0);
|
||||
if ($orderId <= 0) {
|
||||
return [
|
||||
'przesylka.numer' => '',
|
||||
'przesylka.link_sledzenia' => '',
|
||||
];
|
||||
}
|
||||
|
||||
$latestPackage = $this->shipmentPackageRepository->findLatestByOrderId($orderId);
|
||||
if (!is_array($latestPackage)) {
|
||||
return [
|
||||
'przesylka.numer' => '',
|
||||
'przesylka.link_sledzenia' => '',
|
||||
];
|
||||
}
|
||||
|
||||
$trackingNumber = trim((string) ($latestPackage['tracking_number'] ?? ''));
|
||||
$provider = trim((string) ($latestPackage['provider'] ?? ''));
|
||||
$carrierId = trim((string) ($latestPackage['carrier_id'] ?? ''));
|
||||
$trackingUrl = DeliveryStatus::trackingUrl($provider, $trackingNumber, $carrierId) ?? '';
|
||||
|
||||
return [
|
||||
'przesylka.numer' => $trackingNumber,
|
||||
'przesylka.link_sledzenia' => $trackingUrl,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user