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:
2026-05-12 21:37:51 +02:00
parent 0227f2d072
commit 522c94a434
25 changed files with 1641 additions and 105 deletions

View 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>

View 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.