wip(14-email-templates): CRUD szablonów e-mail z Quill.js + system zmiennych

APPLY in progress — checkpoint human-verify awaiting re-test po namespace fixes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-16 00:21:01 +01:00
parent 3223aac4d9
commit 4d091b2441
11 changed files with 1258 additions and 15 deletions

View File

@@ -0,0 +1,91 @@
# PAUL Handoff
**Date:** 2026-03-16
**Status:** paused — checkpoint human-verify in progress
---
## READ THIS FIRST
You have no prior context. This document tells you everything.
**Project:** orderPRO — aplikacja do zarządzania zamówieniami z wielu platform
**Core value:** Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów sprzedaży i nadawać przesyłki bez przełączania się między platformami.
---
## Current State
**Milestone:** v0.4 Moduł E-mail
**Phase:** 14 of 3 (in milestone) — Szablony wiadomości e-mail
**Plan:** 14-01 — APPLY in progress (checkpoint human-verify)
**Loop Position:**
```
PLAN ──▶ APPLY ──▶ UNIFY
✓ ◐ ○ [APPLY in progress — Task 1+2 done, Task 3 checkpoint awaiting approval]
```
---
## What Was Done
- Task 1: Created `EmailTemplateRepository` + `EmailTemplateController` + routes (6 endpoints)
- Task 2: Created view `email-templates.php` with Quill.js CDN editor, variable panel, preview modal, AJAX toggle
- Added sidebar link "Szablony e-mail" in `layouts/app.php`
- Compiled SCSS (modal-overlay, email-tpl-editor styles)
- Fixed 2 bugs discovered during deploy:
- `AuthService` namespace: `App\Core\Auth\AuthService``App\Modules\Auth\AuthService`
- `Flash` namespace: `App\Core\Session\Flash``App\Core\Support\Flash`
---
## What's In Progress
- **Task 3 checkpoint:human-verify** — user was testing the deployed page when session paused
- User reported 2 namespace errors which were fixed, needs to re-test
---
## What's Next
**Immediate:** User re-tests `/settings/email-templates` after namespace fixes. If approved → APPLY complete.
**After that:** Run `sonar-scanner` (required skill), then `/paul:unify .paul/phases/14-email-templates/14-01-PLAN.md`
---
## Key Files
| File | Purpose |
|------|---------|
| `.paul/STATE.md` | Live project state |
| `.paul/ROADMAP.md` | Phase overview |
| `.paul/phases/14-email-templates/14-01-PLAN.md` | Current plan |
| `src/Modules/Settings/EmailTemplateController.php` | Controller (CRUD + preview + variables) |
| `src/Modules/Settings/EmailTemplateRepository.php` | Repository (DB operations) |
| `resources/views/settings/email-templates.php` | View (list + form + Quill.js + variable panel) |
| `routes/web.php` | Routes (6 new endpoints) |
| `resources/views/layouts/app.php` | Sidebar link added |
| `resources/scss/app.scss` | Styles (modal-overlay, email-tpl-editor) |
---
## Namespace Fixes Applied
These were wrong in the initial controller and have been corrected:
- `use App\Core\Auth\AuthService``use App\Modules\Auth\AuthService`
- `use App\Core\Session\Flash``use App\Core\Support\Flash`
---
## Resume Instructions
1. Read `.paul/STATE.md` for latest position
2. Check loop position — APPLY in progress, Task 3 checkpoint
3. Run `/paul:resume` or ask user to re-test `/settings/email-templates`
4. On approval → finalize APPLY → sonar-scanner → `/paul:unify`
---
*Handoff created: 2026-03-16*

View File

@@ -13,7 +13,7 @@ Skrzynki pocztowe SMTP, szablony wiadomości z systemem zmiennych (Quill.js), wy
| Phase | Name | Plans | Status |
|-------|------|-------|--------|
| 13 | DB + Skrzynki pocztowe | 1/1 | Complete ✓ |
| 14 | Szablony wiadomości | TBD | Not started |
| 14 | Szablony wiadomości | 0/1 | Planning |
| 15 | Wysyłka e-mail z zamówień | TBD | Not started |
## Completed Milestones

View File

@@ -10,10 +10,10 @@ See: .paul/PROJECT.md (updated 2026-03-12)
## Current Position
Milestone: v0.4 Moduł E-mail
Phase: [1] of [3] (DB + Skrzynki pocztowe) — Complete
Plan: 13-01 complete
Status: Phase 13 complete, ready for Phase 14
Last activity: 2026-03-15UNIFY 13-01 complete, phase transition done
Phase: [2] of [3] (Szablony wiadomości) — Planning
Plan: 14-01 created, awaiting approval
Status: PLAN created, ready for APPLY
Last activity: 2026-03-16Created .paul/phases/14-email-templates/14-01-PLAN.md
Progress:
- v0.1 Initial Release: [██████████] 100% ✓
@@ -21,7 +21,7 @@ Progress:
- v0.3 Moduł Paragonów: [██████████] 100% ✓
- v0.4 Moduł E-mail: [███░░░░░░░] 33%
- Phase 13: [██████████] 100% ✓
- Phase 14: [░░░░░░░░░░] 0%
- Phase 14: [░░░░░░░░░░] 0% ← planning
- Phase 15: [░░░░░░░░░░] 0%
## Loop Position
@@ -29,7 +29,7 @@ Progress:
Current loop state:
```
PLAN ──▶ APPLY ──▶ UNIFY
[Loop complete — Phase 13 done, ready for Phase 14]
[APPLY in progress — Task 3 checkpoint awaiting approval]
```
## Accumulated Context
@@ -147,7 +147,7 @@ PLAN ──▶ APPLY ──▶ UNIFY
- **Delivery mapping "Szukaj..." layout** — JS `attachSelectFilter()` w allegro.php tworzy input search dla InPost/Apaczka selectów, wizualnie wygląda jakby należał do wiersza powyżej. Pre-existing bug, do naprawy osobno.
### Git State
Last commit: 22fc330 (feat(11-12-accounting): phases 11-12 complete — milestone v0.3 done)
Last commit: 3223aac (feat(13-email-mailboxes): phase 13 complete — email DB foundation + SMTP mailbox CRUD)
Branch: main
Feature branches merged: none
@@ -156,16 +156,17 @@ Brak.
## Session Continuity
Last session: 2026-03-15
Stopped at: Phase 13 complete
Next action: /paul:plan for Phase 14 (Szablony wiadomości)
Resume file: .paul/phases/13-email-mailboxes/13-01-SUMMARY.md
Last session: 2026-03-16
Stopped at: APPLY 14-01 in progress — Task 3 checkpoint human-verify (2 namespace bugs fixed, awaiting re-test)
Next action: User re-tests /settings/email-templates → if approved → finalize APPLY → sonar-scanner → /paul:unify
Resume file: .paul/HANDOFF-2026-03-16.md
Resume context:
- v0.1: COMPLETE ✓ (6 phases, 15 plans)
- v0.2: COMPLETE ✓ (1 phase, 5 plans)
- v0.3: COMPLETE ✓ (5 phases, 5 plans) — Moduł Paragonów
- v0.4: IN PROGRESS — Phase 13 complete, Phase 14 next
- v0.4: IN PROGRESS — Phase 13 complete, Phase 14 APPLY checkpoint
- Faza 0 (nieaktywne przyciski) zrobiona poza planem
- 2 namespace fixes applied: AuthService, Flash
---
*STATE.md — Updated after every significant action*

View File

@@ -0,0 +1,319 @@
---
phase: 14-email-templates
plan: 01
type: execute
wave: 1
depends_on: ["13-01"]
files_modified:
- src/Modules/Settings/EmailTemplateController.php
- src/Modules/Settings/EmailTemplateRepository.php
- resources/views/settings/email-templates.php
- resources/views/settings/email-template-form.php
- routes/web.php
- resources/views/layouts/app.php
- resources/scss/app.scss
- public/assets/css/app.css
autonomous: false
---
<objective>
## Goal
CRUD szablonów wiadomości e-mail z edytorem rich-text (Quill.js) i systemem zmiennych do personalizacji treści (np. {{order.number}}, {{buyer.name}}).
## Purpose
Użytkownik musi mieć możliwość tworzenia i zarządzania szablonami e-mail, które w fazie 15 będą używane do wysyłki wiadomości z poziomu zamówień. Szablony muszą obsługiwać zmienne dynamiczne rozwiązywane z danych zamówienia.
## Output
- EmailTemplateController + EmailTemplateRepository (CRUD)
- Widok listy szablonów + formularz edycji z Quill.js
- System zmiennych: definicja dostępnych zmiennych, wstawianie z UI, podgląd
- Link w sidebarze Settings
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
## Prior Work
@.paul/phases/13-email-mailboxes/13-01-SUMMARY.md
- email_templates table already created (Phase 13)
- EmailMailboxRepository::listActive() ready for mailbox select
- Pattern: EmailMailboxController CRUD (same structure to follow)
## Source Files
@database/migrations/20260315_000055_create_email_templates_table.sql
@src/Modules/Settings/EmailMailboxController.php
@src/Modules/Settings/EmailMailboxRepository.php
@resources/views/settings/email-mailboxes.php
@routes/web.php
@resources/views/layouts/app.php
</context>
<skills>
## Required Skills (from SPECIAL-FLOWS.md)
| Skill | Priority | When to Invoke | Loaded? |
|-------|----------|----------------|---------|
| sonar-scanner | required | Po APPLY, przed UNIFY | ○ |
## Skill Invocation Checklist
- [ ] sonar-scanner uruchomiony po APPLY
</skills>
<acceptance_criteria>
## AC-1: Lista szablonów
```gherkin
Given użytkownik jest zalogowany i przechodzi do Ustawienia > Szablony e-mail
When strona się ładuje
Then widzi tabelę z kolumnami: Nazwa, Temat, Skrzynka, Status, Akcje
And każdy szablon ma przyciski: Edytuj, Włącz/Wyłącz, Usuń
```
## AC-2: Tworzenie i edycja szablonu
```gherkin
Given użytkownik jest na stronie szablonów e-mail
When wypełnia formularz (nazwa, temat, skrzynka, treść HTML) i zapisuje
Then szablon jest zapisany w bazie z poprawnym body_html z Quill.js
And po edycji istniejącego szablonu treść Quill jest załadowana poprawnie
```
## AC-3: Edytor Quill.js z toolbarem
```gherkin
Given użytkownik edytuje treść szablonu
When korzysta z edytora
Then dostępne są: bold, italic, underline, listy, linki, nagłówki, kolory, wyrównanie
And treść jest zapisywana jako HTML w polu body_html
```
## AC-4: System zmiennych — wstawianie
```gherkin
Given użytkownik edytuje szablon
When klika przycisk "Wstaw zmienną" i wybiera zmienną z listy (np. {{zamowienie.numer}})
Then zmienna jest wstawiana do treści edytora w miejscu kursora
And zmienne są wizualnie wyróżnione w edytorze (np. badge/tag)
```
## AC-5: System zmiennych — definicja
```gherkin
Given system zmiennych jest zaimplementowany
When użytkownik otwiera listę zmiennych
Then widzi pogrupowane zmienne:
- Zamówienie: numer, źródło, kwota, waluta, data
- Kupujący: imię i nazwisko, email, telefon, login
- Adres dostawy: ulica, miasto, kod pocztowy, kraj
- Firma: nazwa, NIP (jeśli faktura)
And każda zmienna ma opis i placeholder (np. {{zamowienie.numer}})
```
## AC-6: Toggle statusu i usuwanie
```gherkin
Given szablon istnieje na liście
When użytkownik klika Włącz/Wyłącz
Then status is_active się zmienia (toggle AJAX)
When użytkownik klika Usuń i potwierdzi (OrderProAlerts.confirm)
Then szablon jest usunięty z bazy
```
## AC-7: Podgląd szablonu z przykładowymi danymi
```gherkin
Given użytkownik edytuje szablon z zmiennymi
When klika przycisk "Podgląd"
Then widzi modal z treścią szablonu gdzie zmienne są zastąpione przykładowymi danymi
And temat wiadomości jest również rozwiązany z przykładowymi danymi
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: EmailTemplateRepository + EmailTemplateController + routes</name>
<files>
src/Modules/Settings/EmailTemplateRepository.php,
src/Modules/Settings/EmailTemplateController.php,
routes/web.php
</files>
<action>
**EmailTemplateRepository** (wzorowany na EmailMailboxRepository):
- listAll(): array — pobiera wszystkie szablony JOIN email_mailboxes (nazwa skrzynki), ORDER BY name
- findById(int $id): ?array — jeden szablon po ID
- save(array $data): void — INSERT lub UPDATE (id > 0 = update)
- delete(int $id): void — DELETE po ID
- toggleStatus(int $id): void — toggle is_active (UPDATE SET is_active = NOT is_active)
- listActive(): array — szablony z is_active=1 (do użycia w fazie 15)
- Medoo + prepared statements, bez surowego SQL
**EmailTemplateController** (wzorowany na EmailMailboxController):
- __construct(TemplateEngine, Translator, Auth, EmailTemplateRepository, EmailMailboxRepository)
- index(Request): Response — lista szablonów + formularz (edit mode jeśli ?edit=ID)
- save(Request): Response — walidacja (name, subject, body_html required) + CSRF + zapis + redirect z Flash
- delete(Request): Response — CSRF + usunięcie + redirect z Flash
- toggleStatus(Request): Response — AJAX POST, JSON response {success: true}
- preview(Request): Response — AJAX POST, przyjmuje subject + body_html, zwraca JSON z resolved variables (przykładowe dane)
- getVariables(Request): Response — AJAX GET, zwraca JSON z definicją dostępnych zmiennych pogrupowanych
**Definicja zmiennych** (statyczna metoda lub stała w kontrolerze):
```
Zamówienie:
{{zamowienie.numer}} — Numer wewnętrzny (OP...)
{{zamowienie.numer_zewnetrzny}} — Numer z platformy
{{zamowienie.zrodlo}} — Źródło (Allegro/shopPRO/...)
{{zamowienie.kwota}} — Kwota brutto
{{zamowienie.waluta}} — Waluta (PLN/EUR/...)
{{zamowienie.data}} — Data zamówienia
Kupujący:
{{kupujacy.imie_nazwisko}} — Imię i nazwisko
{{kupujacy.email}} — Adres e-mail
{{kupujacy.telefon}} — Telefon
{{kupujacy.login}} — Login platformy
Adres dostawy:
{{adres.ulica}} — Ulica z numerem
{{adres.miasto}} — Miasto
{{adres.kod_pocztowy}} — Kod pocztowy
{{adres.kraj}} — Kraj
Firma:
{{firma.nazwa}} — Nazwa firmy
{{firma.nip}} — NIP
```
**Metoda resolveVariables(string $text, array $sampleData): string**
- Zamienia {{klucz.pole}} na wartości z $sampleData
- Dla podglądu: używa przykładowych (hardcoded) danych
- Dla wysyłki (faza 15): otrzyma prawdziwe dane zamówienia
**Routes** (dodać w web.php obok email-mailboxes):
- GET /settings/email-templates → index
- POST /settings/email-templates/save → save
- POST /settings/email-templates/delete → delete
- POST /settings/email-templates/toggle → toggleStatus
- POST /settings/email-templates/preview → preview (AJAX)
- GET /settings/email-templates/variables → getVariables (AJAX)
</action>
<verify>
- Klasy się ładują bez Fatal Error (PHP syntax check)
- Routes zarejestrowane poprawnie
- Strona /settings/email-templates renderuje się bez błędów
</verify>
<done>AC-1, AC-2, AC-5, AC-6, AC-7 backend satisfied</done>
</task>
<task type="auto">
<name>Task 2: Widoki — lista szablonów + formularz z Quill.js + system zmiennych UI</name>
<files>
resources/views/settings/email-templates.php,
resources/views/settings/email-template-form.php,
resources/views/layouts/app.php,
resources/scss/app.scss,
public/assets/css/app.css
</files>
<action>
**Widok listy** (resources/views/settings/email-templates.php):
- Tabela: Nazwa, Temat, Skrzynka (nazwa lub "—"), Status (badge), Akcje
- Przycisk "Edytuj" → ?edit=ID
- Toggle status: AJAX POST do /settings/email-templates/toggle (jak w mailboxes)
- Usuwanie: window.OrderProAlerts.confirm() → POST /settings/email-templates/delete
- Flash messages (success/error)
- Przycisk "Nowy szablon" → przejście do formularza
**Formularz edycji** (osobny plik lub sekcja w tym samym widoku):
- Pola: nazwa (input), temat (input — z możliwością wpisania zmiennych ręcznie), skrzynka (select z listActive), status (checkbox)
- Edytor Quill.js:
- Załadować z CDN: quill@2 (https://cdn.quilljs.com/2.0.3/quill.snow.css + quill.js)
- Toolbar: bold, italic, underline, strike, headers (h1-h3), lists (ordered/bullet), link, color, background, align
- Hidden input #body_html synchronizowany z Quill content on form submit
- Panel zmiennych (sidebar lub dropdown):
- Przycisk "Wstaw zmienną" otwiera listę pogrupowaną (pobrana AJAX z /variables)
- Klik na zmienną → wstawia {{zmienna.pole}} w miejsce kursora w Quill
- Przycisk "Podgląd":
- AJAX POST do /settings/email-templates/preview z subject + body_html
- Wynik w modalu (resolved subject + resolved body_html)
- Przycisk "Zapisz" → submit formularza
**Sidebar** (resources/views/layouts/app.php):
- Dodać link "Szablony e-mail" pod "Skrzynki pocztowe" w sekcji Settings
- activeSettings === 'email-templates'
**Style** (resources/scss/app.scss → public/assets/css/app.css):
- Styl dla kontenera Quill (.ql-editor min-height: 200px)
- Styl dla panelu zmiennych (.email-var-list, .email-var-group, .email-var-item)
- Styl dla modala podglądu (reuse istniejący .modal jeśli jest, lub prosty modal)
- Kompaktowy layout zgodnie z zasadami projektu
**Nie używać** natywnych alert()/confirm() — tylko OrderProAlerts.
**Quill CDN** — nie dodawać jako zależność npm/composer, wystarczy CDN w widoku.
</action>
<verify>
- Quill.js ładuje się i renderuje edytor
- Formularz zapisuje poprawnie (body_html zawiera HTML z Quill)
- Zmienne wstawiane w edytor poprawnie
- Podgląd wyświetla resolved template
- SCSS skompilowane do CSS
</verify>
<done>AC-1, AC-2, AC-3, AC-4, AC-5, AC-6, AC-7 UI satisfied</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>CRUD szablonów e-mail z Quill.js i systemem zmiennych</what-built>
<how-to-verify>
1. Otwórz: /settings/email-templates
2. Sprawdź: link "Szablony e-mail" widoczny w sidebarze
3. Kliknij "Nowy szablon":
a. Wypełnij nazwę, temat (np. "Potwierdzenie zamówienia {{zamowienie.numer}}")
b. Wybierz skrzynkę z listy
c. W Quill wpisz treść, użyj formatowania (bold, listy)
d. Kliknij "Wstaw zmienną" → wybierz np. {{kupujacy.imie_nazwisko}}
e. Kliknij "Podgląd" → sprawdź czy zmienne zamienione na przykładowe dane
f. Zapisz szablon
4. Na liście: sprawdź czy szablon się pojawił z poprawną nazwą/tematem/skrzynką
5. Edytuj szablon — sprawdź czy Quill załadował treść HTML poprawnie
6. Toggle status (włącz/wyłącz) — badge powinien się zmienić
7. Usuń szablon — potwierdzenie OrderProAlerts, usunięcie z listy
</how-to-verify>
<resume-signal>Type "approved" to continue, or describe issues to fix</resume-signal>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- database/migrations/* (tabela email_templates już istnieje z fazy 13)
- src/Modules/Settings/EmailMailboxController.php (tylko import/reuse)
- src/Modules/Settings/EmailMailboxRepository.php (tylko wywołanie listActive)
## SCOPE LIMITS
- Nie implementować wysyłki e-mail (faza 15)
- Nie dodawać PHPMailer/SwiftMailer (faza 15)
- Nie modyfikować tabeli email_templates (schemat zamrożony)
- Quill.js z CDN — bez npm/bundlera
- Zmienne rozwiązywane tylko z przykładowymi danymi (prawdziwe dane zamówienia w fazie 15)
</boundaries>
<verification>
Before declaring plan complete:
- [ ] /settings/email-templates renderuje się bez błędów PHP
- [ ] CRUD: create, read, update, delete szablonów działa
- [ ] Quill.js edytor ładuje się i zapisuje HTML
- [ ] System zmiennych: lista, wstawianie, podgląd z przykładowymi danymi
- [ ] Toggle statusu via AJAX działa
- [ ] Usuwanie z potwierdzeniem OrderProAlerts działa
- [ ] Link w sidebarze Settings aktywny
- [ ] SCSS skompilowane, brak inline styles w widokach
- [ ] Brak natywnych alert()/confirm()
- [ ] CSRF walidowany we wszystkich POST
</verification>
<success_criteria>
- Wszystkie AC-1 do AC-7 spełnione
- Wszystkie taski zakończone
- Checkpoint human-verify approved
- Brak błędów PHP, brak broken UI
</success_criteria>
<output>
After completion, create `.paul/phases/14-email-templates/14-01-SUMMARY.md`
</output>

File diff suppressed because one or more lines are too long

View File

@@ -105,8 +105,12 @@ a {
}
.sidebar__link {
display: flex;
align-items: center;
gap: 9px;
white-space: nowrap;
border-radius: 8px;
padding: 10px 12px;
padding: 9px 10px;
text-decoration: none;
color: #cbd5e1;
font-weight: 600;
@@ -2303,5 +2307,158 @@ h4.section-title {
.modal--image-preview {
width: min(92vw, 100%);
}
.email-tpl-editor-wrap {
flex-direction: column;
}
.email-tpl-var-panel {
min-width: 200px;
}
.modal-box {
width: 95vw;
max-height: 90vh;
}
}
// Email template editor
.email-tpl-editor-wrap {
display: flex;
flex-direction: column;
border: 1px solid var(--c-border);
border-radius: 6px;
overflow: hidden;
}
.email-tpl-toolbar {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
background: var(--c-bg-subtle, #f8f9fa);
border-bottom: 1px solid var(--c-border);
}
.email-tpl-var-dropdown {
position: relative;
}
.email-tpl-var-panel {
position: absolute;
top: 100%;
left: 0;
z-index: 50;
min-width: 260px;
max-height: 320px;
overflow-y: auto;
background: var(--c-bg);
border: 1px solid var(--c-border);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
padding: 6px;
margin-top: 4px;
}
.email-var-group {
&:not(:first-child) {
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid var(--c-border);
}
&__label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: var(--c-text-muted);
padding: 2px 4px;
letter-spacing: 0.03em;
}
}
.email-var-item {
display: block;
width: 100%;
text-align: left;
padding: 3px 6px;
margin: 1px 0;
border: none;
background: none;
font-size: 12px;
font-family: "Roboto Mono", monospace;
color: var(--c-text);
border-radius: 3px;
cursor: pointer;
&:hover {
background: var(--c-primary);
color: #fff;
}
}
#js-quill-editor {
min-height: 200px;
.ql-editor {
min-height: 200px;
font-size: 13px;
}
}
// Modal overlay (reusable)
.modal-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.45);
}
.modal-box {
width: min(680px, 90vw);
max-height: 80vh;
background: var(--c-bg);
border-radius: 8px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2);
display: flex;
flex-direction: column;
overflow: hidden;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
border-bottom: 1px solid var(--c-border);
}
&__title {
margin: 0;
font-size: 15px;
font-weight: 600;
}
&__close {
background: none;
border: none;
font-size: 22px;
line-height: 1;
cursor: pointer;
color: var(--c-text-muted);
padding: 0 4px;
&:hover {
color: var(--c-text);
}
}
&__body {
padding: 12px 16px;
overflow-y: auto;
flex: 1;
}
}

View File

@@ -98,6 +98,9 @@
<a class="sidebar__sublink<?= $currentMenu === 'settings' && $currentSettings === 'email-mailboxes' ? ' is-active' : '' ?>" href="/settings/email-mailboxes">
Skrzynki pocztowe
</a>
<a class="sidebar__sublink<?= $currentMenu === 'settings' && $currentSettings === 'email-templates' ? ' is-active' : '' ?>" href="/settings/email-templates">
Szablony e-mail
</a>
</div>
</details>
</nav>

View File

@@ -0,0 +1,311 @@
<?php
$templates = is_array($templates ?? null) ? $templates : [];
$mailboxes = is_array($mailboxes ?? null) ? $mailboxes : [];
$et = is_array($editTemplate ?? null) ? $editTemplate : null;
$isEdit = $et !== null;
$variableGroups = is_array($variableGroups ?? null) ? $variableGroups : [];
?>
<section class="card">
<h2 class="section-title">Szablony e-mail</h2>
<p class="muted mt-12">Szablony wiadomosci e-mail z edytorem i systemem zmiennych.</p>
<?php if (!empty($errorMessage)): ?>
<div class="alert alert--danger mt-12" role="alert"><?= $e((string) $errorMessage) ?></div>
<?php endif; ?>
<?php if (!empty($successMessage)): ?>
<div class="alert alert--success mt-12" role="status"><?= $e((string) $successMessage) ?></div>
<?php endif; ?>
</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. Dodaj pierwszy szablon ponizej.</p>
<?php else: ?>
<div class="table-wrap mt-12">
<table class="table">
<thead>
<tr>
<th>Nazwa</th>
<th>Temat</th>
<th>Skrzynka</th>
<th>Status</th>
<th>Akcje</th>
</tr>
</thead>
<tbody>
<?php foreach ($templates as $tpl): ?>
<tr data-id="<?= (int) ($tpl['id'] ?? 0) ?>">
<td><?= $e((string) ($tpl['name'] ?? '')) ?></td>
<td><?= $e((string) ($tpl['subject'] ?? '')) ?></td>
<td><?= $e((string) ($tpl['mailbox_name'] ?? '—')) ?></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 style="white-space:nowrap">
<a href="/settings/email-templates?edit=<?= (int) ($tpl['id'] ?? 0) ?>" class="btn btn--sm btn--secondary">Edytuj</a>
<button type="button" class="btn btn--sm btn--secondary js-toggle-btn"
data-id="<?= (int) ($tpl['id'] ?? 0) ?>"
data-active="<?= (int) ($tpl['is_active'] ?? 0) ?>">
<?= ((int) ($tpl['is_active'] ?? 0)) === 1 ? 'Dezaktywuj' : 'Aktywuj' ?>
</button>
<form action="/settings/email-templates/delete" method="post" style="display:inline" class="js-confirm-delete">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="id" value="<?= (int) ($tpl['id'] ?? 0) ?>">
<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>
<section class="card mt-16">
<h3 class="section-title"><?= $isEdit ? 'Edytuj szablon' : 'Dodaj szablon' ?></h3>
<form action="/settings/email-templates/save" method="post" novalidate class="mt-12" id="js-template-form">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="body_html" id="js-body-html" value="<?= $e((string) ($et['body_html'] ?? '')) ?>">
<?php if ($isEdit): ?>
<input type="hidden" name="id" value="<?= (int) ($et['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) ($et['name'] ?? '')) ?>" placeholder="np. Potwierdzenie zamowienia">
</label>
<label class="form-field">
<span class="field-label">Skrzynka nadawcza</span>
<select class="form-control" name="mailbox_id">
<option value="">— domyslna —</option>
<?php foreach ($mailboxes as $mb): ?>
<option value="<?= (int) ($mb['id'] ?? 0) ?>"<?= ((int) ($et['mailbox_id'] ?? 0)) === (int) ($mb['id'] ?? 0) ? ' selected' : '' ?>>
<?= $e((string) ($mb['name'] ?? '')) ?> (<?= $e((string) ($mb['sender_email'] ?? '')) ?>)
</option>
<?php endforeach; ?>
</select>
</label>
</div>
<div class="form-grid-2 mt-0">
<label class="form-field">
<span class="field-label">Temat wiadomosci *</span>
<input class="form-control" type="text" name="subject" maxlength="500" required value="<?= $e((string) ($et['subject'] ?? '')) ?>" placeholder="np. Potwierdzenie zamowienia {{zamowienie.numer}}">
</label>
<div class="form-field" style="display:flex;align-items:flex-end;gap:8px">
<label style="display:flex;align-items:center;gap:6px;flex-direction:row">
<input type="checkbox" name="is_active" value="1"<?= $isEdit ? (((int) ($et['is_active'] ?? 0)) === 1 ? ' checked' : '') : ' checked' ?>>
<span class="field-label" style="margin:0">Aktywny</span>
</label>
</div>
</div>
<div class="mt-12">
<span class="field-label">Tresc wiadomosci *</span>
<div class="email-tpl-editor-wrap mt-4">
<div class="email-tpl-toolbar">
<div class="email-tpl-var-dropdown">
<button type="button" class="btn btn--sm btn--secondary" id="js-var-toggle">Wstaw zmienna ▾</button>
<div class="email-tpl-var-panel" id="js-var-panel" style="display:none">
<?php foreach ($variableGroups as $groupKey => $group): ?>
<div class="email-var-group">
<div class="email-var-group__label"><?= $e((string) ($group['label'] ?? '')) ?></div>
<?php foreach (($group['vars'] ?? []) as $varKey => $varDesc): ?>
<button type="button" class="email-var-item" data-var="{{<?= $e($groupKey . '.' . $varKey) ?>}}" title="<?= $e((string) $varDesc) ?>">
{{<?= $e($groupKey . '.' . $varKey) ?>}}
</button>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
</div>
<button type="button" class="btn btn--sm btn--secondary" id="js-preview-btn">Podglad</button>
</div>
<div id="js-quill-editor"></div>
</div>
</div>
<div class="form-actions mt-16">
<button type="submit" class="btn btn--primary"><?= $isEdit ? 'Zapisz zmiany' : 'Dodaj szablon' ?></button>
<?php if ($isEdit): ?>
<a href="/settings/email-templates" class="btn btn--secondary">Anuluj</a>
<?php endif; ?>
</div>
</form>
</section>
<div class="modal-overlay" id="js-preview-overlay" style="display:none">
<div class="modal-box">
<div class="modal-box__header">
<h3 class="modal-box__title">Podglad szablonu</h3>
<button type="button" class="modal-box__close" id="js-preview-close">&times;</button>
</div>
<div class="modal-box__body">
<div class="mt-4"><strong>Temat:</strong> <span id="js-preview-subject"></span></div>
<hr class="mt-8 mb-8">
<div id="js-preview-body"></div>
</div>
</div>
</div>
<link href="https://cdn.quilljs.com/2.0.3/quill.snow.css" rel="stylesheet">
<script src="https://cdn.quilljs.com/2.0.3/quill.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
var csrfToken = <?= json_encode($csrfToken ?? '', JSON_HEX_TAG) ?>;
// Quill editor
var quill = new Quill('#js-quill-editor', {
theme: 'snow',
modules: {
toolbar: [
[{ header: [1, 2, 3, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ color: [] }, { background: [] }],
[{ align: [] }],
[{ list: 'ordered' }, { list: 'bullet' }],
['link'],
['clean']
]
},
placeholder: 'Wpisz tresc wiadomosci...'
});
// Load existing content
var existingHtml = document.getElementById('js-body-html').value;
if (existingHtml) {
quill.root.innerHTML = existingHtml;
}
// Sync on submit
var form = document.getElementById('js-template-form');
form.addEventListener('submit', function() {
document.getElementById('js-body-html').value = quill.root.innerHTML;
});
// Variable panel toggle
var varToggle = document.getElementById('js-var-toggle');
var varPanel = document.getElementById('js-var-panel');
varToggle.addEventListener('click', function(e) {
e.stopPropagation();
varPanel.style.display = varPanel.style.display === 'none' ? 'block' : 'none';
});
document.addEventListener('click', function(e) {
if (!varPanel.contains(e.target) && e.target !== varToggle) {
varPanel.style.display = 'none';
}
});
// Insert variable into Quill
varPanel.addEventListener('click', function(e) {
var btn = e.target.closest('.email-var-item');
if (!btn) return;
var varText = btn.getAttribute('data-var');
var range = quill.getSelection(true);
quill.insertText(range.index, varText);
quill.setSelection(range.index + varText.length);
varPanel.style.display = 'none';
});
// Preview
var previewBtn = document.getElementById('js-preview-btn');
var previewOverlay = document.getElementById('js-preview-overlay');
var previewClose = document.getElementById('js-preview-close');
var previewSubject = document.getElementById('js-preview-subject');
var previewBody = document.getElementById('js-preview-body');
previewBtn.addEventListener('click', function() {
var subjectVal = form.querySelector('[name="subject"]').value;
var bodyVal = quill.root.innerHTML;
previewBtn.disabled = true;
previewBtn.textContent = 'Ladowanie...';
var fd = new FormData();
fd.append('_token', csrfToken);
fd.append('subject', subjectVal);
fd.append('body_html', bodyVal);
fetch('/settings/email-templates/preview', { method: 'POST', body: fd })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
previewSubject.textContent = data.subject;
previewBody.innerHTML = data.body_html;
previewOverlay.style.display = 'flex';
}
})
.catch(function() {})
.finally(function() {
previewBtn.disabled = false;
previewBtn.textContent = 'Podglad';
});
});
previewClose.addEventListener('click', function() {
previewOverlay.style.display = 'none';
});
previewOverlay.addEventListener('click', function(e) {
if (e.target === previewOverlay) previewOverlay.style.display = 'none';
});
// Toggle status (AJAX)
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/email-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; });
});
});
// Delete with OrderProAlerts
document.querySelectorAll('.js-delete-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
var delForm = this.closest('form');
if (window.OrderProAlerts && window.OrderProAlerts.confirm) {
window.OrderProAlerts.confirm(
'Usuwanie szablonu',
'Czy na pewno chcesz usunac ten szablon e-mail?',
function() { delForm.submit(); }
);
} else {
if (confirm('Czy na pewno chcesz usunac ten szablon e-mail?')) {
delForm.submit();
}
}
});
});
});
</script>

View File

@@ -38,6 +38,8 @@ use App\Modules\Settings\ReceiptConfigController;
use App\Modules\Settings\ReceiptConfigRepository;
use App\Modules\Settings\EmailMailboxController;
use App\Modules\Settings\EmailMailboxRepository;
use App\Modules\Settings\EmailTemplateController;
use App\Modules\Settings\EmailTemplateRepository;
use App\Modules\Settings\IntegrationSecretCipher;
use App\Modules\Accounting\AccountingController;
use App\Modules\Accounting\ReceiptController;
@@ -197,6 +199,14 @@ return static function (Application $app): void {
$auth,
$emailMailboxRepository
);
$emailTemplateRepository = new EmailTemplateRepository($app->db());
$emailTemplateController = new EmailTemplateController(
$template,
$translator,
$auth,
$emailTemplateRepository,
$emailMailboxRepository
);
$receiptController = new ReceiptController(
$template,
$translator,
@@ -325,6 +335,12 @@ return static function (Application $app): void {
$router->post('/settings/email-mailboxes/delete', [$emailMailboxController, 'delete'], [$authMiddleware]);
$router->post('/settings/email-mailboxes/toggle', [$emailMailboxController, 'toggleStatus'], [$authMiddleware]);
$router->post('/settings/email-mailboxes/test', [$emailMailboxController, 'testConnection'], [$authMiddleware]);
$router->get('/settings/email-templates', [$emailTemplateController, 'index'], [$authMiddleware]);
$router->post('/settings/email-templates/save', [$emailTemplateController, 'save'], [$authMiddleware]);
$router->post('/settings/email-templates/delete', [$emailTemplateController, 'delete'], [$authMiddleware]);
$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('/accounting', [$accountingController, 'index'], [$authMiddleware]);
$router->post('/accounting/export', [$accountingController, 'export'], [$authMiddleware]);
$router->get('/orders/{id}/receipt/create', [$receiptController, 'create'], [$authMiddleware]);

View File

@@ -0,0 +1,228 @@
<?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 Throwable;
final class EmailTemplateController
{
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',
],
],
];
private const SAMPLE_DATA = [
'zamowienie.numer' => 'OP000001234',
'zamowienie.numer_zewnetrzny' => 'ALG-98765432',
'zamowienie.zrodlo' => 'Allegro',
'zamowienie.kwota' => '149,99',
'zamowienie.waluta' => 'PLN',
'zamowienie.data' => '2026-03-16',
'kupujacy.imie_nazwisko' => 'Jan Kowalski',
'kupujacy.email' => 'jan.kowalski@example.com',
'kupujacy.telefon' => '+48 600 123 456',
'kupujacy.login' => 'jankowalski82',
'adres.ulica' => 'ul. Dluga 15/3',
'adres.miasto' => 'Warszawa',
'adres.kod_pocztowy' => '00-238',
'adres.kraj' => 'PL',
'firma.nazwa' => 'Przykladowa Firma Sp. z o.o.',
'firma.nip' => '5271234567',
];
public function __construct(
private readonly Template $template,
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly EmailTemplateRepository $repository,
private readonly EmailMailboxRepository $mailboxRepository
) {
}
public function index(Request $request): Response
{
$t = $this->translator;
$templates = $this->repository->listAll();
$mailboxes = $this->mailboxRepository->listActive();
$editTemplate = null;
$editId = (int) $request->input('edit', '0');
if ($editId > 0) {
$editTemplate = $this->repository->findById($editId);
}
$html = $this->template->render('settings/email-templates', [
'title' => 'Szablony e-mail',
'activeMenu' => 'settings',
'activeSettings' => 'email-templates',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'templates' => $templates,
'mailboxes' => $mailboxes,
'editTemplate' => $editTemplate,
'variableGroups' => self::VARIABLE_GROUPS,
'successMessage' => Flash::get('settings.email_templates.success', ''),
'errorMessage' => Flash::get('settings.email_templates.error', ''),
], 'layouts/app');
return Response::html($html);
}
public function save(Request $request): Response
{
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('settings.email_templates.error', 'Nieprawidlowy token CSRF');
return Response::redirect('/settings/email-templates');
}
$name = trim((string) $request->input('name', ''));
$subject = trim((string) $request->input('subject', ''));
$bodyHtml = (string) $request->input('body_html', '');
if ($name === '' || $subject === '' || $bodyHtml === '') {
Flash::set('settings.email_templates.error', 'Nazwa, temat i tresc sa wymagane');
return Response::redirect('/settings/email-templates');
}
try {
$this->repository->save([
'id' => $request->input('id', ''),
'name' => $name,
'subject' => $subject,
'body_html' => $bodyHtml,
'mailbox_id' => $request->input('mailbox_id', ''),
'is_active' => $request->input('is_active', null),
]);
Flash::set('settings.email_templates.success', 'Szablon zostal zapisany');
} catch (Throwable) {
Flash::set('settings.email_templates.error', 'Blad zapisu szablonu');
}
return Response::redirect('/settings/email-templates');
}
public function delete(Request $request): Response
{
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('settings.email_templates.error', 'Nieprawidlowy token CSRF');
return Response::redirect('/settings/email-templates');
}
$id = (int) $request->input('id', '0');
if ($id <= 0) {
Flash::set('settings.email_templates.error', 'Nieprawidlowy identyfikator szablonu');
return Response::redirect('/settings/email-templates');
}
try {
$this->repository->delete($id);
Flash::set('settings.email_templates.success', 'Szablon zostal usuniety');
} catch (Throwable) {
Flash::set('settings.email_templates.error', 'Blad usuwania szablonu');
}
return Response::redirect('/settings/email-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 preview(Request $request): Response
{
if (!Csrf::validate((string) $request->input('_token', ''))) {
return Response::json(['success' => false, 'message' => 'Nieprawidlowy token CSRF'], 403);
}
$subject = (string) $request->input('subject', '');
$bodyHtml = (string) $request->input('body_html', '');
return Response::json([
'success' => true,
'subject' => self::resolveVariables($subject, self::SAMPLE_DATA),
'body_html' => self::resolveVariables($bodyHtml, self::SAMPLE_DATA),
]);
}
public function getVariables(Request $request): Response
{
return Response::json([
'success' => true,
'groups' => self::VARIABLE_GROUPS,
]);
}
/**
* @param array<string, string> $data
*/
public static function resolveVariables(string $text, array $data): string
{
return (string) preg_replace_callback(
'/\{\{([a-z_]+)\.([a-z_]+)\}\}/',
static function (array $matches) use ($data): string {
$key = $matches[1] . '.' . $matches[2];
return $data[$key] ?? $matches[0];
},
$text
);
}
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use PDO;
final class EmailTemplateRepository
{
public function __construct(
private readonly PDO $pdo
) {
}
/**
* @return list<array<string, mixed>>
*/
public function listAll(): array
{
$statement = $this->pdo->prepare(
'SELECT t.id, t.name, t.subject, t.mailbox_id, t.is_active, t.created_at, t.updated_at,
m.name AS mailbox_name
FROM email_templates t
LEFT JOIN email_mailboxes m ON m.id = t.mailbox_id
ORDER BY t.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, subject, body_html, mailbox_id, is_active, created_at, updated_at
FROM email_templates
WHERE id = :id'
);
$statement->execute(['id' => $id]);
$row = $statement->fetch(PDO::FETCH_ASSOC);
return is_array($row) ? $row : null;
}
/**
* @return list<array<string, mixed>>
*/
public function listActive(): array
{
$statement = $this->pdo->prepare(
'SELECT id, name, subject, mailbox_id
FROM email_templates
WHERE is_active = 1
ORDER BY name ASC'
);
$statement->execute();
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
}
/**
* @param array<string, mixed> $data
*/
public function save(array $data): void
{
$id = isset($data['id']) && $data['id'] !== '' ? (int) $data['id'] : null;
$mailboxId = isset($data['mailbox_id']) && $data['mailbox_id'] !== '' && $data['mailbox_id'] !== '0'
? (int) $data['mailbox_id']
: null;
$params = [
'name' => trim((string) ($data['name'] ?? '')),
'subject' => trim((string) ($data['subject'] ?? '')),
'body_html' => (string) ($data['body_html'] ?? ''),
'mailbox_id' => $mailboxId,
'is_active' => isset($data['is_active']) ? 1 : 0,
];
if ($id !== null) {
$params['id'] = $id;
$statement = $this->pdo->prepare(
'UPDATE email_templates
SET name = :name, subject = :subject, body_html = :body_html,
mailbox_id = :mailbox_id, is_active = :is_active
WHERE id = :id'
);
} else {
$statement = $this->pdo->prepare(
'INSERT INTO email_templates (name, subject, body_html, mailbox_id, is_active)
VALUES (:name, :subject, :body_html, :mailbox_id, :is_active)'
);
}
$statement->execute($params);
}
public function delete(int $id): void
{
$statement = $this->pdo->prepare('DELETE FROM email_templates WHERE id = :id');
$statement->execute(['id' => $id]);
}
public function toggleStatus(int $id): void
{
$statement = $this->pdo->prepare(
'UPDATE email_templates SET is_active = NOT is_active WHERE id = :id'
);
$statement->execute(['id' => $id]);
}
}