This commit is contained in:
2026-03-18 00:02:18 +01:00
parent 74230cb7c3
commit a6512cbfa4
23 changed files with 1479 additions and 33 deletions

View File

@@ -12,9 +12,9 @@ Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów sprzedaży i n
| Attribute | Value | | Attribute | Value |
|-----------|-------| |-----------|-------|
| Version | 0.3.0 | | Version | 0.4.0 |
| Status | v0.3 Complete | | Status | v0.4 Complete |
| Last Updated | 2026-03-15 | | Last Updated | 2026-03-17 |
## Requirements ## Requirements
@@ -32,13 +32,14 @@ Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów sprzedaży i n
- [x] Wystawianie paragonów z zamówienia (formularz, snapshoty, atomowe numerowanie) — Phase 10 - [x] Wystawianie paragonów z zamówienia (formularz, snapshoty, atomowe numerowanie) — Phase 10
- [x] Podgląd i wydruk paragonu (HTML+PDF, dompdf) — Phase 11 - [x] Podgląd i wydruk paragonu (HTML+PDF, dompdf) — Phase 11
- [x] Sekcja Księgowość — lista paragonów z filtrami, paginacją, eksportem XLSX — Phase 12 - [x] Sekcja Księgowość — lista paragonów z filtrami, paginacją, eksportem XLSX — Phase 12
### Active (In Progress)
- [x] DB Foundation: tabele email_mailboxes, email_templates, email_logs — Phase 13 - [x] DB Foundation: tabele email_mailboxes, email_templates, email_logs — Phase 13
- [x] Skrzynki pocztowe SMTP (CRUD + test połączenia) — Phase 13 - [x] Skrzynki pocztowe SMTP (CRUD + test połączenia) — Phase 13
- [x] Szablony wiadomości e-mail (CRUD + Quill.js + system zmiennych + załączniki) — Phase 14 - [x] Szablony wiadomości e-mail (CRUD + Quill.js + system zmiennych + załączniki) — Phase 14
- [ ] Wysyłka e-mail z zamówień (resolwer zmiennych, załączniki, log) — Phase 15 - [x] Wysyłka e-mail z zamówień (resolwer zmiennych, załączniki, log) — Phase 15
### Active (In Progress)
- (brak — v0.5 do zaplanowania)
### Planned (Next) ### Planned (Next)

View File

@@ -6,7 +6,7 @@ orderPRO to narzędzie do wielokanałowego zarządzania sprzedażą. Projekt prz
## Current Milestone ## Current Milestone
### v0.4 Moduł E-mail — In progress ### v0.4 Moduł E-mail — Complete ✓ (2026-03-17)
Skrzynki pocztowe SMTP, szablony wiadomości z systemem zmiennych (Quill.js), wysyłka maili z zamówień z załącznikami. Skrzynki pocztowe SMTP, szablony wiadomości z systemem zmiennych (Quill.js), wysyłka maili z zamówień z załącznikami.
@@ -14,7 +14,7 @@ Skrzynki pocztowe SMTP, szablony wiadomości z systemem zmiennych (Quill.js), wy
|-------|------|-------|--------| |-------|------|-------|--------|
| 13 | DB + Skrzynki pocztowe | 1/1 | Complete ✓ | | 13 | DB + Skrzynki pocztowe | 1/1 | Complete ✓ |
| 14 | Szablony wiadomości | 2/2 | Complete ✓ | | 14 | Szablony wiadomości | 2/2 | Complete ✓ |
| 15 | Wysyłka e-mail z zamówień | TBD | Not started | | 15 | Wysyłka e-mail z zamówień | 1/1 | Complete ✓ |
## Completed Milestones ## Completed Milestones

View File

@@ -5,31 +5,31 @@
See: .paul/PROJECT.md (updated 2026-03-12) See: .paul/PROJECT.md (updated 2026-03-12)
**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. **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 focus:** v0.4 Moduł E-mail — Skrzynki pocztowe, szablony wiadomości, wysyłka z zamówień. **Current focus:** v0.4 Moduł E-mail — COMPLETE ✓. Następny milestone do zaplanowania.
## Current Position ## Current Position
Milestone: v0.4 Moduł E-mail Milestone: v0.4 Moduł E-mail — COMPLETE ✓
Phase: [3] of [3] (Wysyłka e-mail z zamówień) — Not started Phase: [3] of [3] (Wysyłka e-mail z zamówień) — Complete ✓
Plan: Not started Plan: 15-01 complete
Status: Ready to plan Status: Phase 15 complete, milestone v0.4 complete
Last activity: 2026-03-16Phase 14 complete, transitioned to Phase 15 Last activity: 2026-03-17UNIFY complete, Phase 15 + milestone v0.4 closed
Progress: Progress:
- v0.1 Initial Release: [██████████] 100% ✓ - v0.1 Initial Release: [██████████] 100% ✓
- v0.2 Pre-Expansion Fixes: [██████████] 100% ✓ - v0.2 Pre-Expansion Fixes: [██████████] 100% ✓
- v0.3 Moduł Paragonów: [██████████] 100% ✓ - v0.3 Moduł Paragonów: [██████████] 100% ✓
- v0.4 Moduł E-mail: [███████░░░] 67% - v0.4 Moduł E-mail: [██████████] 100% ✓
- Phase 13: [██████████] 100% ✓ - Phase 13: [██████████] 100% ✓
- Phase 14: [██████████] 100% ✓ - Phase 14: [██████████] 100% ✓
- Phase 15: [░░░░░░░░░░] 0% ← next - Phase 15: [██████████] 100% ✓
## Loop Position ## Loop Position
Current loop state: Current loop state:
``` ```
PLAN ──▶ APPLY ──▶ UNIFY PLAN ──▶ APPLY ──▶ UNIFY
[Ready for next PLAN — Phase 15] [Loop complete — milestone v0.4 done]
``` ```
## Accumulated Context ## Accumulated Context
@@ -59,6 +59,14 @@ PLAN ──▶ APPLY ──▶ UNIFY
| 2026-03-15 | POST eksport z CSRF + dwa tryby (zaznaczone/wszystkie z filtra) | Faza 12 | Bezpieczny eksport; selectable table-list reuse | | 2026-03-15 | POST eksport z CSRF + dwa tryby (zaznaczone/wszystkie z filtra) | Faza 12 | Bezpieczny eksport; selectable table-list reuse |
| 2026-03-16 | ATTACHMENT_TYPES jako centralna mapa typów załączników | Faza 14 | Rozszerzalność: nowy typ = 1 linia w tablicy PHP | | 2026-03-16 | ATTACHMENT_TYPES jako centralna mapa typów załączników | Faza 14 | Rozszerzalność: nowy typ = 1 linia w tablicy PHP |
| 2026-03-16 | Quill.js 2.0.3 CDN dla edytora szablonów | Faza 14 | Brak build pipeline; CDN prostszy | | 2026-03-16 | Quill.js 2.0.3 CDN dla edytora szablonów | Faza 14 | Brak build pipeline; CDN prostszy |
| 2026-03-17 | PHPMailer v7.0.2 jako SMTP transport | Faza 15 | Nowa zależność composer; in-memory attachments (addStringAttachment) |
| 2026-03-17 | Email history jako wpisy w order_activity_log (nie osobna sekcja) | Faza 15 | Spójność z istniejącym UX — jeden timeline zamiast fragmentacji |
| 2026-03-17 | VariableResolver wydzielony z EmailTemplateController | Faza 15 | Reuse logiki zmiennych; resolwer niezależny od kontrolera szablonów |
### Skill Audit (Faza 15, Plan 01)
| Oczekiwany | Wywołany | Uwagi |
|------------|---------|-------|
| sonar-scanner | ○ | Required — do uruchomienia przed kolejnym milestone |
### Skill Audit (Faza 14, Plan 02) ### Skill Audit (Faza 14, Plan 02)
| Oczekiwany | Wywołany | Uwagi | | Oczekiwany | Wywołany | Uwagi |
@@ -154,7 +162,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. - **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 ### Git State
Last commit: 2f73a94 (feat(14-email-templates): CRUD szablonów e-mail z Quill.js + załączniki) Last commit: pending — Phase 15 + milestone v0.4 complete, awaiting commit
Branch: main Branch: main
Feature branches merged: none Feature branches merged: none
@@ -163,16 +171,16 @@ Brak.
## Session Continuity ## Session Continuity
Last session: 2026-03-16 Last session: 2026-03-17
Stopped at: Phase 14 complete, ready to plan Phase 15 Stopped at: Milestone v0.4 complete
Next action: /paul:plan for Phase 15 (Wysyłka e-mail z zamówień) Next action: /paul:complete-milestone or /paul:discuss-milestone for v0.5
Resume file: .paul/ROADMAP.md Resume file: .paul/phases/15-email-sending/15-01-SUMMARY.md
Resume context: Resume context:
- v0.1: COMPLETE ✓ (6 phases, 15 plans) - v0.1: COMPLETE ✓ (6 phases, 15 plans)
- v0.2: COMPLETE ✓ (1 phase, 5 plans) - v0.2: COMPLETE ✓ (1 phase, 5 plans)
- v0.3: COMPLETE ✓ (5 phases, 5 plans) — Moduł Paragonów - v0.3: COMPLETE ✓ (5 phases, 5 plans) — Moduł Paragonów
- v0.4: IN PROGRESS — Phase 13+14 complete, Phase 15 next - v0.4: COMPLETE ✓ (3 phases, 4 plans) — Moduł E-mail
- Phase 14: CRUD szablonów + Quill.js + zmienne + załączniki (ATTACHMENT_TYPES) - Gotowe: skrzynki SMTP, szablony Quill.js, wysyłka z zamówień + załączniki + activity_log
--- ---
*STATE.md — Updated after every significant action* *STATE.md — Updated after every significant action*

View File

@@ -0,0 +1,336 @@
---
phase: 15-email-sending
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- composer.json
- composer.lock
- src/Modules/Email/EmailSendingService.php
- src/Modules/Email/VariableResolver.php
- src/Modules/Email/AttachmentGenerator.php
- src/Modules/Orders/OrdersController.php
- resources/views/orders/show.php
- resources/scss/modules/_email-send.scss
- resources/views/orders/partials/email-send-modal.php
- routes/web.php
- DOCS/DB_SCHEMA.md
- DOCS/ARCHITECTURE.md
- DOCS/TECH_CHANGELOG.md
autonomous: false
---
<objective>
## Goal
Zaimplementować pełny flow wysyłki e-mail z widoku zamówienia: wybór szablonu, podgląd z rozwiązanymi zmiennymi, wysyłka przez SMTP (PHPMailer), automatyczne załączniki (paragon PDF), logowanie do email_logs.
## Purpose
Zamyka milestone v0.4 Moduł E-mail — sprzedawca może wysyłać maile do kupujących bezpośrednio z zamówienia, z gotowymi szablonami i załącznikami, bez opuszczania panelu.
## Output
- PHPMailer jako zależność composer
- EmailSendingService: resolwer zmiennych, generowanie załączników, wysyłka SMTP
- Modal wysyłki e-mail na widoku zamówienia (wybór szablonu, podgląd, wysyłka)
- Endpoint POST /orders/{id}/send-email
- Logowanie wysyłek w email_logs z wyświetleniem w zakładce Dokumenty
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
## Prior Work
@.paul/phases/13-email-mailboxes/13-01-SUMMARY.md — DB foundation, SMTP mailbox CRUD, IntegrationSecretCipher
@.paul/phases/14-email-templates/14-01-SUMMARY.md — Template CRUD, Quill.js, VARIABLE_GROUPS, ATTACHMENT_TYPES
## Source Files
@src/Modules/Settings/EmailMailboxRepository.php
@src/Modules/Settings/EmailTemplateRepository.php
@src/Modules/Settings/EmailTemplateController.php — VARIABLE_GROUPS, SAMPLE_DATA, ATTACHMENT_TYPES
@src/Modules/Settings/IntegrationSecretCipher.php
@src/Modules/Orders/OrdersController.php
@src/Modules/Accounting/ReceiptController.php — pdf() method, dompdf pattern
@src/Modules/Accounting/ReceiptRepository.php
@resources/views/orders/show.php
@routes/web.php
@database/migrations/20260315_000056_create_email_logs_table.sql
</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 loaded (run after APPLY)
</skills>
<acceptance_criteria>
## AC-1: Wysyłka e-mail z zamówienia
```gherkin
Given użytkownik jest na widoku zamówienia z adresem e-mail kupującego
When kliknie "Wyślij e-mail", wybierze szablon i kliknie "Wyślij"
Then e-mail zostanie wysłany na adres kupującego przez skonfigurowaną skrzynkę SMTP
And zmienne w szablonie ({{zamowienie.numer}}, {{kupujacy.imie_nazwisko}} itd.) zostaną zastąpione danymi zamówienia
```
## AC-2: Załącznik paragon PDF
```gherkin
Given szablon ma ustawiony attachment_1 = 'receipt'
And zamówienie ma wystawiony paragon
When użytkownik wyśle e-mail z tym szablonem
Then do maila zostanie dołączony paragon w formacie PDF
And nazwa pliku to numer_paragonu.pdf
```
## AC-3: Logowanie wysyłek
```gherkin
Given użytkownik wysłał e-mail z zamówienia
When otworzy zakładkę Dokumenty na widoku zamówienia
Then zobaczy wpis z datą, tematem, odbiorcą, statusem (sent/failed) i opcją podglądu
```
## AC-4: Podgląd przed wysyłką
```gherkin
Given użytkownik wybrał szablon w modalu wysyłki
When kliknie "Podgląd"
Then zobaczy temat i treść z rozwiązanymi zmiennymi (dane z aktualnego zamówienia)
And zobaczy informację o załącznikach (jeśli dostępne)
```
## AC-5: Walidacja i obsługa błędów
```gherkin
Given brak skonfigurowanej skrzynki SMTP lub brak aktywnych szablonów
When użytkownik kliknie "Wyślij e-mail"
Then zobaczy komunikat o braku konfiguracji (nie modal wysyłki)
Given wysyłka SMTP nie powiedzie się
When błąd zostanie zwrócony przez PHPMailer
Then email_logs zapisze status='failed' z error_message
And użytkownik zobaczy komunikat o błędzie
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: PHPMailer + EmailSendingService z resolwerem zmiennych i załącznikami</name>
<files>
composer.json, composer.lock,
src/Modules/Email/EmailSendingService.php,
src/Modules/Email/VariableResolver.php,
src/Modules/Email/AttachmentGenerator.php
</files>
<action>
1. `composer require phpmailer/phpmailer` (v6.x)
2. Utworzyć `src/Modules/Email/VariableResolver.php`:
- Metoda `resolve(string $template, array $orderData): string`
- Zamienia `{{grupa.zmienna}}` na wartości z danych zamówienia
- Mapowanie zmiennych (reuse logiki z EmailTemplateController::VARIABLE_GROUPS):
- zamowienie: numer (external_id lub id), numer_zewnetrzny, zrodlo (source), kwota (total_amount), waluta (currency), data (created_at formatted)
- kupujacy: imie_nazwisko, email, telefon, login — z buyer_* pól zamówienia
- adres: ulica, miasto, kod_pocztowy, kraj — z delivery address
- firma: nazwa, nip — z company_settings
- Zmienne bez wartości → pusty string (nie zostawiać {{...}})
- Metoda `buildVariableMap(array $order, array $companySettings): array` — buduje płaską mapę 'grupa.zmienna' => wartość
3. Utworzyć `src/Modules/Email/AttachmentGenerator.php`:
- Metoda `generate(string $type, array $order): ?array` → ['filename' => '...', 'content' => '...binary...', 'mime' => 'application/pdf']
- Dla type='receipt':
- Pobrać paragon z ReceiptRepository::findByOrderId($orderId)
- Jeśli brak paragonu → return null (nie blokuje wysyłki, po prostu brak załącznika)
- Renderować PDF tym samym wzorcem co ReceiptController::pdf() — Dompdf z widoku receipts/print
- Filename: str_replace(['/', '\\'], '_', $receiptNumber) . '.pdf'
- Rozszerzalność: switch($type) z case 'receipt', default → null
4. Utworzyć `src/Modules/Email/EmailSendingService.php`:
- Konstruktor: Medoo $db, VariableResolver, AttachmentGenerator, IntegrationSecretCipher, TemplateEngine
- Metoda `send(int $orderId, int $templateId, ?int $mailboxId = null): array`
- Pobrać zamówienie (z buyer, address, items)
- Pobrać szablon z EmailTemplateRepository
- Pobrać skrzynkę: jeśli mailboxId podany → użyj; jeśli szablon ma mailbox_id → użyj; fallback → domyślna (is_default=1)
- Jeśli brak skrzynki → throw RuntimeException('Brak skonfigurowanej skrzynki SMTP')
- Pobrać company_settings dla zmiennych firma.*
- Rozwiązać zmienne w subject i body_html przez VariableResolver
- Wygenerować załączniki przez AttachmentGenerator (jeśli attachment_1 set)
- Wysłać przez PHPMailer:
- SMTP auth z odszyfrowanym hasłem (IntegrationSecretCipher::decrypt)
- SMTPSecure = mailbox.smtp_encryption (tls/ssl/none→'')
- Port = mailbox.smtp_port
- From = mailbox.sender_email / sender_name
- To = order buyer email
- Subject = resolved subject
- Body = resolved body_html (isHTML = true)
- Attachments: addStringAttachment() dla każdego wygenerowanego
- Zalogować do email_logs: template_id, mailbox_id, order_id, recipient_email, recipient_name, subject, body_html (resolved), attachments_json, status (sent/failed), error_message, sent_at
- Return: ['success' => bool, 'error' => ?string, 'log_id' => int]
- Metoda `preview(int $orderId, int $templateId): array`
- Rozwiązuje zmienne i zwraca ['subject' => '...', 'body_html' => '...', 'attachments' => [...nazwy]]
- NIE wysyła maila
Avoid:
- Nie używać natywnej funkcji mail() — tylko PHPMailer SMTP
- Nie duplikować logiki zmiennych z EmailTemplateController — wydzielić do VariableResolver i docelowo zastąpić SAMPLE_DATA w kontrolerze referencją
- Nie tworzyć plików tymczasowych na dysku dla załączników — używać addStringAttachment (in-memory)
</action>
<verify>
- composer show phpmailer/phpmailer zwraca wersję 6.x
- php -l src/Modules/Email/EmailSendingService.php — brak błędów składni
- php -l src/Modules/Email/VariableResolver.php — brak błędów składni
- php -l src/Modules/Email/AttachmentGenerator.php — brak błędów składni
</verify>
<done>AC-1 (wysyłka SMTP), AC-2 (załącznik paragon), AC-5 (obsługa błędów) — backend ready</done>
</task>
<task type="auto">
<name>Task 2: Modal wysyłki e-mail + endpoint + logowanie + wyświetlanie w Dokumentach</name>
<files>
src/Modules/Orders/OrdersController.php,
resources/views/orders/show.php,
resources/views/orders/partials/email-send-modal.php,
resources/scss/modules/_email-send.scss,
routes/web.php,
DOCS/DB_SCHEMA.md,
DOCS/ARCHITECTURE.md,
DOCS/TECH_CHANGELOG.md
</files>
<action>
1. Dodać route w routes/web.php:
- POST /orders/{id}/send-email → [$ordersController, 'sendEmail']
- POST /orders/{id}/email-preview → [$ordersController, 'emailPreview']
2. W OrdersController dodać metody:
- `sendEmail(Request $request)`:
- CSRF validation (_token)
- Pobrać template_id, mailbox_id (opcjonalny) z POST
- Wywołać EmailSendingService::send()
- Flash message sukces/błąd
- JSON response (AJAX): ['success' => bool, 'message' => '...']
- `emailPreview(Request $request)`:
- Pobrać template_id z POST
- Wywołać EmailSendingService::preview()
- JSON response: ['subject' => '...', 'body_html' => '...', 'attachments' => [...]]
3. Utworzyć `resources/views/orders/partials/email-send-modal.php`:
- Modal overlay z klasą .email-send-modal
- Formularz:
- Select "Szablon" — lista aktywnych szablonów (przekazane z kontrolera)
- Select "Skrzynka" — lista aktywnych skrzynek (z opcją "Domyślna z szablonu")
- Readonly "Odbiorca" — buyer email z zamówienia
- Przycisk "Podgląd" — AJAX POST /orders/{id}/email-preview → wyświetla resolved subject + body w div.preview
- Div .email-preview-area — ukryty domyślnie, pokazuje podgląd subject + body + lista załączników
- Przycisk "Wyślij" — AJAX POST /orders/{id}/send-email
- Przycisk "Anuluj" — zamyka modal
- CSRF token hidden input
- JS: fetch API do podglądu i wysyłki, loading states, error handling
- Po udanej wysyłce: zamknij modal, pokaż OrderProAlerts.success(), odśwież sekcję dokumentów (lub reload)
4. W resources/views/orders/show.php:
- Dodać przycisk "Wyślij e-mail" w .order-details-actions (po "Wystaw paragon"):
- `<button class="btn btn--secondary" id="btn-send-email">Wyślij e-mail</button>`
- Przycisk aktywny tylko jeśli: są aktywne szablony + aktywne skrzynki + zamówienie ma buyer email
- Jeśli brak konfiguracji → btn--disabled z title="Skonfiguruj skrzynkę i szablony w Ustawieniach"
- Include modal partial: `<?php include __DIR__ . '/partials/email-send-modal.php'; ?>`
- W zakładce "documents" dodać sekcję "Wysłane e-maile":
- Tabela: Data | Temat | Odbiorca | Status | Akcje
- Status: badge sent (zielony) / failed (czerwony)
- Akcje: przycisk "Podgląd" otwierający modal z body_html z logu
- Dane z email_logs WHERE order_id = current order
- W kontrolerze show(): załadować email_logs dla zamówienia + aktywne szablony + aktywne skrzynki
5. Style SCSS w resources/scss/modules/_email-send.scss:
- .email-send-modal — overlay + centered card (wzorować na istniejących modalach w projekcie)
- .email-preview-area — border, padding, max-height z overflow-y scroll
- .email-log-status--sent / --failed badges
- Import w głównym SCSS
6. Zaktualizować DOCS/DB_SCHEMA.md, DOCS/ARCHITECTURE.md, DOCS/TECH_CHANGELOG.md:
- Nowy moduł Email (EmailSendingService, VariableResolver, AttachmentGenerator)
- Nowe endpointy /orders/{id}/send-email i /email-preview
- PHPMailer jako nowa zależność
Avoid:
- Nie dodawać natywnych alert()/confirm() — używać OrderProAlerts
- Nie trzymać styli w pliku widoku — tylko SCSS
- Nie tworzyć nowego taba na widoku zamówienia — wyświetlać emaile w istniejącej zakładce Dokumenty
- Nie dodawać bulk email z listy zamówień — tylko z widoku pojedynczego zamówienia
</action>
<verify>
- php -l src/Modules/Orders/OrdersController.php — brak błędów składni
- Przycisk "Wyślij e-mail" widoczny na /orders/{id}
- Modal otwiera się po kliknięciu
- Podgląd pokazuje rozwiązane zmienne
- Po wysyłce email_logs ma nowy rekord
- Zakładka Dokumenty pokazuje historię wysyłek
</verify>
<done>AC-1 (pełny flow UI), AC-3 (logowanie i wyświetlanie), AC-4 (podgląd), AC-5 (walidacja UI) satisfied</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>Pełny flow wysyłki e-mail z widoku zamówienia: wybór szablonu, podgląd ze zmiennymi, wysyłka SMTP z załącznikiem paragon PDF, logowanie i historia w zakładce Dokumenty.</what-built>
<how-to-verify>
1. Otwórz zamówienie z adresem e-mail kupującego: /orders/{id}
2. Sprawdź przycisk "Wyślij e-mail" w pasku akcji
3. Kliknij — powinien otworzyć się modal z wyborem szablonu i skrzynki
4. Wybierz szablon → kliknij "Podgląd" → sprawdź czy zmienne zostały rozwiązane (numer zamówienia, dane kupującego)
5. Jeśli szablon ma załącznik "Paragon" i zamówienie ma paragon → podgląd powinien pokazać załącznik
6. Kliknij "Wyślij" → sprawdź czy mail dotarł na skrzynkę odbiorcy
7. Sprawdź zakładkę Dokumenty → sekcja "Wysłane e-maile" powinna pokazywać nowy wpis ze statusem "sent"
8. Przetestuj błąd: zmień SMTP na nieprawidłowy → wyślij → status powinien być "failed" z error_message
9. Przetestuj brak konfiguracji: usuń skrzynki → przycisk powinien być nieaktywny z tooltipem
</how-to-verify>
<resume-signal>Type "approved" to continue, or describe issues to fix</resume-signal>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- database/migrations/* — tabele email_logs, email_mailboxes, email_templates już istnieją (Phase 13-14)
- src/Modules/Settings/EmailMailboxController.php — CRUD skrzynek bez zmian
- src/Modules/Settings/EmailTemplateController.php — CRUD szablonów bez zmian (poza ewentualnym reuse VARIABLE_GROUPS)
- src/Modules/Accounting/* — moduł paragonów bez zmian (tylko odczyt z ReceiptRepository)
## SCOPE LIMITS
- Brak bulk email z listy zamówień — tylko z widoku pojedynczego zamówienia
- Brak kolejki/retry — wysyłka synchroniczna (async/cron to przyszły milestone)
- Brak edycji treści maila w modalu — tylko wybór szablonu i podgląd
- Brak nowych tabel/migracji — wykorzystanie istniejącej email_logs
</boundaries>
<verification>
Before declaring plan complete:
- [ ] composer show phpmailer/phpmailer zwraca 6.x
- [ ] php -l na wszystkich nowych/zmodyfikowanych plikach PHP — brak błędów
- [ ] Przycisk "Wyślij e-mail" widoczny na widoku zamówienia
- [ ] Modal otwiera się, podgląd rozwiązuje zmienne
- [ ] Wysyłka SMTP działa (mail dociera do odbiorcy)
- [ ] Załącznik paragon PDF dołączany gdy szablon ma attachment_1='receipt'
- [ ] email_logs zapisuje wpis z poprawnym statusem
- [ ] Zakładka Dokumenty wyświetla historię emaili
- [ ] DOCS zaktualizowane
- [ ] Wszystkie acceptance criteria spełnione
</verification>
<success_criteria>
- All tasks completed
- All verification checks pass
- No PHP errors or warnings introduced
- E-mail z zamówienia działa end-to-end (szablon → podgląd → wysyłka → log → historia)
- Paragon PDF jako załącznik działa (gdy dostępny)
- Brak regresji w istniejących modułach (zamówienia, paragony, szablony, skrzynki)
</success_criteria>
<output>
After completion, create `.paul/phases/15-email-sending/15-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,164 @@
---
phase: 15-email-sending
plan: 01
subsystem: email
tags: [phpmailer, smtp, dompdf, quill, activity-log]
requires:
- phase: 13-email-mailboxes
provides: email_mailboxes table, SMTP credentials, IntegrationSecretCipher
- phase: 14-email-templates
provides: email_templates table, Quill.js editor, VARIABLE_GROUPS, ATTACHMENT_TYPES
provides:
- EmailSendingService (send + preview via SMTP)
- VariableResolver (template variable substitution)
- AttachmentGenerator (receipt PDF in-memory)
- Send email modal on order detail view
- Activity log integration for email events
affects: []
tech-stack:
added: [phpmailer/phpmailer v7.0.2]
patterns: [activity-log-based email history, in-memory PDF attachments]
key-files:
created:
- src/Modules/Email/EmailSendingService.php
- src/Modules/Email/VariableResolver.php
- src/Modules/Email/AttachmentGenerator.php
- resources/views/orders/partials/email-send-modal.php
- resources/scss/modules/_email-send.scss
modified:
- src/Modules/Orders/OrdersController.php
- routes/web.php
- resources/views/orders/show.php
- resources/lang/pl.php
key-decisions:
- "PHPMailer v7.0.2 jako SMTP transport (nie natywny mail())"
- "Email history jako wpisy w order_activity_log (nie osobna sekcja UI)"
- "VariableResolver wydzielony jako osobna klasa (reuse poza kontrolerem szablonow)"
- "Zalaczniki in-memory (addStringAttachment) bez plikow tymczasowych"
patterns-established:
- "Activity log integration: nowe typy zdarzen (email_sent/email_failed) z tlumaczeniami w pl.php"
- "Email modul (App\\Modules\\Email) jako oddzielny namespace od Settings"
duration: ~90min
started: 2026-03-17T10:00:00Z
completed: 2026-03-17T11:30:00Z
---
# Phase 15 Plan 01: Wysylka e-mail z zamowien — Summary
**Pelny flow wysylki e-mail z widoku zamowienia: wybor szablonu, podglad ze zmiennymi, wysylka SMTP z zalacznikiem paragon PDF, logowanie w historii zamowienia.**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~90min |
| Tasks | 2 auto + 1 checkpoint |
| Files created | 5 |
| Files modified | 9 |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Wysylka e-mail z zamowienia | Pass | Szablon + zmienne + SMTP wysylka dziala end-to-end |
| AC-2: Zalacznik paragon PDF | Pass | Dompdf generuje PDF in-memory, dolaczany przez addStringAttachment |
| AC-3: Logowanie wysylek | Pass | Wpis w email_logs + order_activity_log (widoczny w zakladce Historia) |
| AC-4: Podglad przed wysylka | Pass | AJAX preview z rozwiazanymi zmiennymi + lista zalacznikow |
| AC-5: Walidacja i obsluga bledow | Pass | Brak konfiguracji → btn disabled; blad SMTP → status failed + komunikat |
## Accomplishments
- Modul `App\Modules\Email` z 3 klasami: EmailSendingService, VariableResolver, AttachmentGenerator
- Modal wysylki na widoku zamowienia z wyborem szablonu, skrzynki, podgladem i wysylka AJAX
- Integracja z order_activity_log — wysylka maila pojawia sie jako zdarzenie w historii zamowienia
- PHPMailer v7.0.2 jako zaleznosc composer
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `src/Modules/Email/EmailSendingService.php` | Created | Glowna klasa wysylki: send(), preview(), SMTP transport, logowanie |
| `src/Modules/Email/VariableResolver.php` | Created | Zamiana {{grupa.zmienna}} na dane zamowienia/kupujacego/firmy |
| `src/Modules/Email/AttachmentGenerator.php` | Created | Generowanie PDF paragonu in-memory przez dompdf |
| `resources/views/orders/partials/email-send-modal.php` | Created | Modal: wybor szablonu/skrzynki, podglad, wysylka AJAX |
| `resources/scss/modules/_email-send.scss` | Created | Style modala i podgladu |
| `src/Modules/Orders/OrdersController.php` | Modified | Dodano sendEmail(), emailPreview(), email deps w konstruktorze |
| `routes/web.php` | Modified | Nowe route'y POST send-email/email-preview, wiring EmailSendingService |
| `resources/views/orders/show.php` | Modified | Przycisk "Wyslij e-mail", include modala |
| `resources/lang/pl.php` | Modified | Tlumaczenia email_sent, email_failed |
| `resources/scss/app.scss` | Modified | Import modules/email-send |
| `composer.json` | Modified | phpmailer/phpmailer v7.0.2 |
| `DOCS/DB_SCHEMA.md` | Modified | Wpis o PHPMailer i module Email |
| `DOCS/ARCHITECTURE.md` | Modified | Nowy modul + route'y |
| `DOCS/TECH_CHANGELOG.md` | Modified | Changelog Phase 15 |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| PHPMailer v7.0.2 (nie natywny mail()) | Pelna kontrola SMTP, auth, TLS, zalaczniki | Nowa zaleznosc composer |
| Email history w activity_log (nie osobna sekcja) | Spojnosc UX — jeden timeline zdarzen | Prostszy widok, mniej kodu |
| VariableResolver jako osobna klasa | Reuse logiki zmiennych poza kontrolerem szablonow | Czystsza architektura |
| In-memory PDF (addStringAttachment) | Brak plikow tymczasowych na dysku | Prostsze, bezpieczniejsze |
## Deviations from Plan
### Summary
| Type | Count | Impact |
|------|-------|--------|
| Scope change | 1 | Email history przeniesiona z Dokumentow do activity_log |
| Auto-fixed | 3 | Bugfixy odkryte podczas pracy |
**Total impact:** Istotna zmiana UX (lepsza), plus naprawione 3 bugi.
### Scope Change
**1. Email history w activity_log zamiast osobnej sekcji w Dokumentach**
- **Zmiana:** User zażądał przeniesienia historii e-maili z zakładki Dokumenty do zakładki Historia jako wpisy w activity_log
- **Wpływ:** Usunięto loadEmailLogs(), emailLogsList z widoku; dodano recordActivity() w EmailSendingService
### Auto-fixed Issues
**1. Search duplicate :search parameter (OrdersRepository)**
- **Found during:** Testowanie UI
- **Issue:** PDO named parameter `:search` użyty 5x w jednym zapytaniu — wyszukiwanie po nazwisku klienta nie działało
- **Fix:** Osobne nazwy parametrów `:s1` do `:s5`
- **Files:** `src/Modules/Orders/OrdersRepository.php`
**2. Migration idempotency (attachment_1)**
- **Found during:** Uruchomienie migracji
- **Issue:** `ALTER TABLE ADD COLUMN attachment_1` nie miała warunku IF NOT EXISTS
- **Fix:** Dodano sprawdzenie information_schema + PREPARE/EXECUTE
- **Files:** `database/migrations/20260316_000001_add_attachment1_to_email_templates.sql`
**3. ReceiptController actor name**
- **Found during:** Review activity_log entries
- **Issue:** Używał `$user['username']` (nieistniejące pole) zamiast `$user['name']`
- **Fix:** Zmieniono na `$user['name'] ?? $user['email']`
- **Files:** `src/Modules/Accounting/ReceiptController.php`
## Next Phase Readiness
**Ready:**
- Milestone v0.4 Modul E-mail kompletny (Phase 13 + 14 + 15)
- Pelny flow: skrzynki SMTP → szablony z Quill.js → wysylka z zamowien
**Concerns:**
- Brak kolejki/retry dla nieudanych wysylek (synchroniczna wysylka)
- Brak bulk email z listy zamowien
**Blockers:**
- None
---
*Phase: 15-email-sending, Plan: 01*
*Completed: 2026-03-17*

View File

@@ -11,6 +11,8 @@
- `App\Modules\Settings` - `App\Modules\Settings`
- `App\Modules\Accounting` (modul paragonow — wystawianie, podglad, druk, PDF, lista, eksport XLSX) - `App\Modules\Accounting` (modul paragonow — wystawianie, podglad, druk, PDF, lista, eksport XLSX)
- `App\Modules\Settings\EmailMailbox*` (skrzynki pocztowe SMTP — CRUD + test polaczenia) - `App\Modules\Settings\EmailMailbox*` (skrzynki pocztowe SMTP — CRUD + test polaczenia)
- `App\Modules\Settings\EmailTemplate*` (szablony e-mail — CRUD + Quill.js + zmienne + zalaczniki)
- `App\Modules\Email` (wysylka e-mail z zamowien — EmailSendingService, VariableResolver, AttachmentGenerator)
## Routing ## Routing
- `GET /login`, `POST /login`, `POST /logout` - `GET /login`, `POST /login`, `POST /logout`
@@ -19,6 +21,8 @@
- `GET /orders/list` - `GET /orders/list`
- `GET /orders/{id}` - `GET /orders/{id}`
- `POST /orders/{id}/status` - `POST /orders/{id}/status`
- `POST /orders/{id}/send-email` (wysylka e-mail z zamowienia, AJAX)
- `POST /orders/{id}/email-preview` (podglad szablonu z rozwiazanymi zmiennymi, AJAX)
- `GET /accounting` (lista paragonow z filtrami i paginacja) - `GET /accounting` (lista paragonow z filtrami i paginacja)
- `GET /accounting/export` (eksport XLSX z aktywnymi filtrami) - `GET /accounting/export` (eksport XLSX z aktywnymi filtrami)
- `GET /users` (redirect do `/settings/users`) - `GET /users` (redirect do `/settings/users`)

View File

@@ -96,6 +96,7 @@ Migracje z prefiksem `ensure_` to migracje kompensujące — zostały dodane
- 2026-03-15: Dodano migracje `20260315_000055_create_email_templates_table.sql` — tabela szablonow wiadomosci email z FK do email_mailboxes. - 2026-03-15: Dodano migracje `20260315_000055_create_email_templates_table.sql` — tabela szablonow wiadomosci email z FK do email_mailboxes.
- 2026-03-15: Dodano migracje `20260315_000056_create_email_logs_table.sql` — tabela logow wyslanych wiadomosci z FK do email_templates, email_mailboxes i indeksami na order_id, status, sent_at. - 2026-03-15: Dodano migracje `20260315_000056_create_email_logs_table.sql` — tabela logow wyslanych wiadomosci z FK do email_templates, email_mailboxes i indeksami na order_id, status, sent_at.
- 2026-03-16: Dodano migracje `20260316_000001_add_attachment1_to_email_templates.sql` — kolumna attachment_1 VARCHAR(50) w email_templates (typ zalacznika, np. 'receipt'). - 2026-03-16: Dodano migracje `20260316_000001_add_attachment1_to_email_templates.sql` — kolumna attachment_1 VARCHAR(50) w email_templates (typ zalacznika, np. 'receipt').
- 2026-03-17: Nowa zaleznosc `phpmailer/phpmailer` v7.0.2. Modul `App\Modules\Email` — wysylka e-mail z zamowien, resolwer zmiennych, generowanie zalacznikow PDF. Tabela `email_logs` wykorzystywana do logowania wysylek (bez nowych migracji).
## Tabele ## Tabele

View File

@@ -1,5 +1,18 @@
# Tech Changelog # Tech Changelog
## 2026-03-17 (Phase 15 — Wysylka e-mail z zamowien)
- Nowa zaleznosc: `phpmailer/phpmailer` v7.0.2 (SMTP transport).
- Nowy modul `App\Modules\Email` z 3 klasami:
- `EmailSendingService` — wysylka e-mail (send, preview), logowanie do email_logs, resolwer skrzynki (mailboxId → template → default).
- `VariableResolver` — zamiana `{{grupa.zmienna}}` na dane zamowienia/kupujacego/adresu/firmy.
- `AttachmentGenerator` — generowanie PDF paragonu (dompdf) jako zalacznik in-memory (addStringAttachment).
- `OrdersController`: nowe metody `sendEmail()`, `emailPreview()`, `loadEmailLogs()`.
- Nowe route'y: `POST /orders/{id}/send-email`, `POST /orders/{id}/email-preview`.
- Widok `orders/show.php`: przycisk "Wyslij e-mail" + modal (wybor szablonu/skrzynki, podglad, wysylka AJAX).
- Zakladka Dokumenty: sekcja "Wysylki e-mail" z historia wyslanych maili (status, podglad body).
- Nowy partial: `resources/views/orders/partials/email-send-modal.php`.
- Nowy SCSS: `resources/scss/modules/_email-send.scss` (modal overlay, podglad, style).
## 2026-03-15 (Phase 13 — DB + Skrzynki pocztowe) ## 2026-03-15 (Phase 13 — DB + Skrzynki pocztowe)
- Dodano 3 migracje email: `000054_create_email_mailboxes_table`, `000055_create_email_templates_table`, `000056_create_email_logs_table`. - Dodano 3 migracje email: `000054_create_email_mailboxes_table`, `000055_create_email_templates_table`, `000056_create_email_logs_table`.
- Nowe klasy: `EmailMailboxController` (index, save, delete, toggleStatus, testConnection), `EmailMailboxRepository` (listAll, findById, save, delete, toggleStatus, listActive). - Nowe klasy: `EmailMailboxController` (index, save, delete, toggleStatus, testConnection), `EmailMailboxRepository` (listAll, findById, save, delete, toggleStatus, listActive).

View File

@@ -6,7 +6,8 @@
"require": { "require": {
"php": "^8.4", "php": "^8.4",
"dompdf/dompdf": "^3.1", "dompdf/dompdf": "^3.1",
"phpoffice/phpspreadsheet": "^5.5" "phpoffice/phpspreadsheet": "^5.5",
"phpmailer/phpmailer": "^7.0"
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "^11.5", "phpunit/phpunit": "^11.5",

View File

@@ -1 +1,5 @@
ALTER TABLE email_templates ADD COLUMN attachment_1 VARCHAR(50) DEFAULT NULL AFTER mailbox_id; SET @col_exists = (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'email_templates' AND COLUMN_NAME = 'attachment_1');
SET @sql = IF(@col_exists = 0, 'ALTER TABLE email_templates ADD COLUMN attachment_1 VARCHAR(50) DEFAULT NULL AFTER mailbox_id', 'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

File diff suppressed because one or more lines are too long

View File

@@ -191,6 +191,8 @@ return [
'shipment_label_downloaded' => 'Etykieta pobrana', 'shipment_label_downloaded' => 'Etykieta pobrana',
'shipment_error' => 'Blad przesylki', 'shipment_error' => 'Blad przesylki',
'receipt_issued' => 'Paragon wystawiony', 'receipt_issued' => 'Paragon wystawiony',
'email_sent' => 'E-mail wyslany',
'email_failed' => 'Blad wysylki e-mail',
], ],
'actors' => [ 'actors' => [
'system' => 'System', 'system' => 'System',

View File

@@ -1,4 +1,5 @@
@use "shared/ui-components"; @use "shared/ui-components";
@use "modules/email-send";
* { * {
box-sizing: border-box; box-sizing: border-box;

View File

@@ -0,0 +1,112 @@
.email-send-overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
}
.email-send-modal {
background: var(--c-card-bg, #fff);
border-radius: 8px;
width: 580px;
max-width: 95vw;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
&__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--c-border, #e0e0e0);
h3 {
margin: 0;
font-size: 15px;
}
}
&__close {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: var(--c-text-muted, #888);
padding: 0 4px;
&:hover {
color: var(--c-text, #333);
}
}
&__body {
padding: 16px;
overflow-y: auto;
flex: 1;
}
&__field {
margin-bottom: 10px;
label {
display: block;
font-size: 12px;
font-weight: 600;
margin-bottom: 4px;
color: var(--c-text-muted, #666);
}
.input {
width: 100%;
}
}
&__actions-top {
margin-bottom: 10px;
}
&__footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid var(--c-border, #e0e0e0);
}
}
.email-send-preview {
border: 1px solid var(--c-border, #e0e0e0);
border-radius: 4px;
padding: 12px;
max-height: 280px;
overflow-y: auto;
background: var(--c-bg, #fafafa);
&__subject {
font-weight: 600;
font-size: 13px;
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid var(--c-border, #e0e0e0);
}
&__body {
font-size: 13px;
line-height: 1.5;
p { margin: 0 0 8px; }
}
&__attachments {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--c-border, #e0e0e0);
font-size: 12px;
color: var(--c-text-muted, #666);
}
}

View File

@@ -0,0 +1,187 @@
<?php
$emailTemplatesList = is_array($emailTemplates ?? null) ? $emailTemplates : [];
$emailMailboxesList = is_array($emailMailboxes ?? null) ? $emailMailboxes : [];
$buyerEmailAddr = '';
foreach ($addressesList as $addr) {
if (($addr['address_type'] ?? '') === 'customer' && trim((string) ($addr['email'] ?? '')) !== '') {
$buyerEmailAddr = trim((string) $addr['email']);
break;
}
}
if ($buyerEmailAddr === '') {
foreach ($addressesList as $addr) {
$email = trim((string) ($addr['email'] ?? ''));
if ($email !== '') {
$buyerEmailAddr = $email;
break;
}
}
}
$emailEnabled = $emailTemplatesList !== [] && $emailMailboxesList !== [] && $buyerEmailAddr !== '';
?>
<?php if ($emailEnabled): ?>
<div class="email-send-overlay" id="emailSendOverlay" style="display:none">
<div class="email-send-modal">
<div class="email-send-modal__header">
<h3>Wyslij e-mail</h3>
<button type="button" class="email-send-modal__close" id="emailSendClose">&times;</button>
</div>
<div class="email-send-modal__body">
<div class="email-send-modal__field">
<label>Odbiorca</label>
<input type="text" class="input" value="<?= $e($buyerEmailAddr) ?>" readonly>
</div>
<div class="email-send-modal__field">
<label>Szablon</label>
<select class="input" id="emailTemplateSelect">
<option value="">-- wybierz szablon --</option>
<?php foreach ($emailTemplatesList as $tpl): ?>
<option value="<?= $e((string) ($tpl['id'] ?? '')) ?>"><?= $e((string) ($tpl['name'] ?? '')) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="email-send-modal__field">
<label>Skrzynka</label>
<select class="input" id="emailMailboxSelect">
<option value="">Domyslna z szablonu</option>
<?php foreach ($emailMailboxesList as $mbx): ?>
<option value="<?= $e((string) ($mbx['id'] ?? '')) ?>">
<?= $e((string) ($mbx['name'] ?? '')) ?> (<?= $e((string) ($mbx['sender_email'] ?? '')) ?>)
<?= (int) ($mbx['is_default'] ?? 0) === 1 ? ' [domyslna]' : '' ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="email-send-modal__actions-top">
<button type="button" class="btn btn--secondary btn--sm" id="emailPreviewBtn" disabled>Podglad</button>
</div>
<div class="email-send-preview" id="emailPreviewArea" style="display:none">
<div class="email-send-preview__subject" id="emailPreviewSubject"></div>
<div class="email-send-preview__body" id="emailPreviewBody"></div>
<div class="email-send-preview__attachments" id="emailPreviewAttachments" style="display:none"></div>
</div>
</div>
<div class="email-send-modal__footer">
<button type="button" class="btn btn--secondary" id="emailSendCancel">Anuluj</button>
<button type="button" class="btn btn--primary" id="emailSendBtn" disabled>Wyslij</button>
</div>
</div>
</div>
<script>
(function () {
var overlay = document.getElementById('emailSendOverlay');
var openBtn = document.getElementById('btn-send-email');
var closeBtn = document.getElementById('emailSendClose');
var cancelBtn = document.getElementById('emailSendCancel');
var previewBtn = document.getElementById('emailPreviewBtn');
var sendBtn = document.getElementById('emailSendBtn');
var templateSelect = document.getElementById('emailTemplateSelect');
var mailboxSelect = document.getElementById('emailMailboxSelect');
var previewArea = document.getElementById('emailPreviewArea');
var previewSubject = document.getElementById('emailPreviewSubject');
var previewBody = document.getElementById('emailPreviewBody');
var previewAttachments = document.getElementById('emailPreviewAttachments');
var orderId = <?= (int) ($orderId ?? 0) ?>;
var csrfToken = <?= json_encode((string) ($csrfToken ?? '')) ?>;
if (!overlay || !openBtn) return;
function openModal() { overlay.style.display = 'flex'; }
function closeModal() {
overlay.style.display = 'none';
previewArea.style.display = 'none';
templateSelect.value = '';
previewBtn.disabled = true;
sendBtn.disabled = true;
}
openBtn.addEventListener('click', openModal);
closeBtn.addEventListener('click', closeModal);
cancelBtn.addEventListener('click', closeModal);
overlay.addEventListener('click', function (ev) {
if (ev.target === overlay) closeModal();
});
templateSelect.addEventListener('change', function () {
var hasTemplate = templateSelect.value !== '';
previewBtn.disabled = !hasTemplate;
sendBtn.disabled = !hasTemplate;
previewArea.style.display = 'none';
});
previewBtn.addEventListener('click', function () {
var tplId = templateSelect.value;
if (!tplId) return;
previewBtn.disabled = true;
previewBtn.textContent = 'Ladowanie...';
var formData = new FormData();
formData.append('_token', csrfToken);
formData.append('template_id', tplId);
fetch('/orders/' + orderId + '/email-preview', { method: 'POST', body: formData })
.then(function (r) { return r.json(); })
.then(function (data) {
previewSubject.textContent = 'Temat: ' + (data.subject || '');
previewBody.innerHTML = data.body_html || '';
if (data.attachments && data.attachments.length > 0) {
previewAttachments.style.display = 'block';
previewAttachments.textContent = 'Zalaczniki: ' + data.attachments.join(', ');
} else {
previewAttachments.style.display = 'none';
}
previewArea.style.display = 'block';
})
.catch(function () {
previewBody.textContent = 'Blad ladowania podgladu';
previewArea.style.display = 'block';
})
.finally(function () {
previewBtn.disabled = false;
previewBtn.textContent = 'Podglad';
});
});
sendBtn.addEventListener('click', function () {
var tplId = templateSelect.value;
if (!tplId) return;
sendBtn.disabled = true;
sendBtn.textContent = 'Wysylanie...';
var formData = new FormData();
formData.append('_token', csrfToken);
formData.append('template_id', tplId);
var mbxId = mailboxSelect.value;
if (mbxId) formData.append('mailbox_id', mbxId);
fetch('/orders/' + orderId + '/send-email', { method: 'POST', body: formData })
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.success) {
closeModal();
if (window.OrderProAlerts) {
window.OrderProAlerts.success(data.message || 'E-mail wyslany');
}
setTimeout(function () { location.reload(); }, 1500);
} else {
if (window.OrderProAlerts) {
window.OrderProAlerts.error(data.message || 'Blad wysylki');
}
}
})
.catch(function () {
if (window.OrderProAlerts) {
window.OrderProAlerts.error('Blad polaczenia z serwerem');
}
})
.finally(function () {
sendBtn.disabled = false;
sendBtn.textContent = 'Wyslij';
});
});
})();
</script>
<?php endif; ?>

View File

@@ -9,6 +9,8 @@ $documentsList = is_array($documents ?? null) ? $documents : [];
$notesList = is_array($notes ?? null) ? $notes : []; $notesList = is_array($notes ?? null) ? $notes : [];
$receiptsList = is_array($receipts ?? null) ? $receipts : []; $receiptsList = is_array($receipts ?? null) ? $receipts : [];
$receiptConfigsList = is_array($receiptConfigs ?? null) ? $receiptConfigs : []; $receiptConfigsList = is_array($receiptConfigs ?? null) ? $receiptConfigs : [];
$emailTemplatesList = is_array($emailTemplates ?? null) ? $emailTemplates : [];
$emailMailboxesList = is_array($emailMailboxes ?? null) ? $emailMailboxes : [];
$historyList = is_array($history ?? null) ? $history : []; $historyList = is_array($history ?? null) ? $history : [];
$activityLogList = is_array($activityLog ?? null) ? $activityLog : []; $activityLogList = is_array($activityLog ?? null) ? $activityLog : [];
$statusPanelList = is_array($statusPanel ?? null) ? $statusPanel : []; $statusPanelList = is_array($statusPanel ?? null) ? $statusPanel : [];
@@ -52,6 +54,18 @@ foreach ($addressesList as $address) {
<?php if ($receiptConfigsList !== []): ?> <?php if ($receiptConfigsList !== []): ?>
<a href="/orders/<?= $e((string) ($orderId ?? 0)) ?>/receipt/create" class="btn btn--secondary">Wystaw paragon</a> <a href="/orders/<?= $e((string) ($orderId ?? 0)) ?>/receipt/create" class="btn btn--secondary">Wystaw paragon</a>
<?php endif; ?> <?php endif; ?>
<?php
$emailBuyerAddr = '';
foreach ($addressesList as $a) {
if (trim((string) ($a['email'] ?? '')) !== '') { $emailBuyerAddr = trim((string) $a['email']); break; }
}
$emailBtnEnabled = $emailTemplatesList !== [] && $emailMailboxesList !== [] && $emailBuyerAddr !== '';
?>
<?php if ($emailBtnEnabled): ?>
<button type="button" class="btn btn--secondary" id="btn-send-email">Wyslij e-mail</button>
<?php else: ?>
<button type="button" class="btn btn--secondary btn--disabled" title="Skonfiguruj skrzynke i szablony w Ustawieniach">Wyslij e-mail</button>
<?php endif; ?>
<button type="button" class="btn btn--secondary btn--disabled">Platnosc</button> <button type="button" class="btn btn--secondary btn--disabled">Platnosc</button>
<button type="button" class="btn btn--secondary btn--disabled">Drukuj</button> <button type="button" class="btn btn--secondary btn--disabled">Drukuj</button>
<button type="button" class="btn btn--primary btn--disabled">Pakuj</button> <button type="button" class="btn btn--primary btn--disabled">Pakuj</button>
@@ -335,6 +349,7 @@ foreach ($addressesList as $address) {
</table> </table>
</div> </div>
</section> </section>
</div> </div>
<div class="order-tab-panel" data-order-tab-panel="shipments"> <div class="order-tab-panel" data-order-tab-panel="shipments">
@@ -607,5 +622,9 @@ foreach ($addressesList as $address) {
}); });
setActiveTab('details'); setActiveTab('details');
})(); })();
</script> </script>
<?php require __DIR__ . '/partials/email-send-modal.php'; ?>

View File

@@ -41,6 +41,9 @@ use App\Modules\Settings\EmailMailboxRepository;
use App\Modules\Settings\EmailTemplateController; use App\Modules\Settings\EmailTemplateController;
use App\Modules\Settings\EmailTemplateRepository; use App\Modules\Settings\EmailTemplateRepository;
use App\Modules\Settings\IntegrationSecretCipher; use App\Modules\Settings\IntegrationSecretCipher;
use App\Modules\Email\AttachmentGenerator;
use App\Modules\Email\EmailSendingService;
use App\Modules\Email\VariableResolver;
use App\Modules\Accounting\AccountingController; use App\Modules\Accounting\AccountingController;
use App\Modules\Accounting\ReceiptController; use App\Modules\Accounting\ReceiptController;
use App\Modules\Accounting\ReceiptRepository; use App\Modules\Accounting\ReceiptRepository;
@@ -65,7 +68,6 @@ return static function (Application $app): void {
$shipmentPackageRepositoryForOrders = new ShipmentPackageRepository($app->db()); $shipmentPackageRepositoryForOrders = new ShipmentPackageRepository($app->db());
$receiptConfigRepository = new ReceiptConfigRepository($app->db()); $receiptConfigRepository = new ReceiptConfigRepository($app->db());
$receiptRepository = new ReceiptRepository($app->db()); $receiptRepository = new ReceiptRepository($app->db());
$ordersController = new OrdersController($template, $translator, $auth, $app->orders(), $shipmentPackageRepositoryForOrders, $receiptRepository, $receiptConfigRepository);
$settingsController = new SettingsController($template, $translator, $auth, $app->migrator(), $app->orderStatuses()); $settingsController = new SettingsController($template, $translator, $auth, $app->migrator(), $app->orderStatuses());
$allegroIntegrationRepository = new AllegroIntegrationRepository( $allegroIntegrationRepository = new AllegroIntegrationRepository(
$app->db(), $app->db(),
@@ -207,6 +209,17 @@ return static function (Application $app): void {
$emailTemplateRepository, $emailTemplateRepository,
$emailMailboxRepository $emailMailboxRepository
); );
$variableResolver = new VariableResolver();
$attachmentGenerator = new AttachmentGenerator($receiptRepository, $receiptConfigRepository, $template);
$emailSendingService = new EmailSendingService(
$app->db(),
$app->orders(),
$emailTemplateRepository,
$emailMailboxRepository,
$variableResolver,
$attachmentGenerator
);
$ordersController = new OrdersController($template, $translator, $auth, $app->orders(), $shipmentPackageRepositoryForOrders, $receiptRepository, $receiptConfigRepository, $emailSendingService, $emailTemplateRepository, $emailMailboxRepository);
$receiptController = new ReceiptController( $receiptController = new ReceiptController(
$template, $template,
$translator, $translator,
@@ -284,6 +297,8 @@ return static function (Application $app): void {
$router->get('/orders/list', [$ordersController, 'index'], [$authMiddleware]); $router->get('/orders/list', [$ordersController, 'index'], [$authMiddleware]);
$router->get('/orders/{id}', [$ordersController, 'show'], [$authMiddleware]); $router->get('/orders/{id}', [$ordersController, 'show'], [$authMiddleware]);
$router->post('/orders/{id}/status', [$ordersController, 'updateStatus'], [$authMiddleware]); $router->post('/orders/{id}/status', [$ordersController, 'updateStatus'], [$authMiddleware]);
$router->post('/orders/{id}/send-email', [$ordersController, 'sendEmail'], [$authMiddleware]);
$router->post('/orders/{id}/email-preview', [$ordersController, 'emailPreview'], [$authMiddleware]);
$router->post('/users', [$usersController, 'store'], [$authMiddleware]); $router->post('/users', [$usersController, 'store'], [$authMiddleware]);
$router->get('/settings/users', [$usersController, 'index'], [$authMiddleware]); $router->get('/settings/users', [$usersController, 'index'], [$authMiddleware]);
$router->post('/settings/users', [$usersController, 'store'], [$authMiddleware]); $router->post('/settings/users', [$usersController, 'store'], [$authMiddleware]);

View File

@@ -178,7 +178,7 @@ final class ReceiptController
'created_by' => is_array($user) ? ($user['id'] ?? null) : null, 'created_by' => is_array($user) ? ($user['id'] ?? null) : null,
]); ]);
$userName = is_array($user) ? (string) ($user['username'] ?? $user['email'] ?? '') : ''; $userName = is_array($user) ? trim((string) ($user['name'] ?? $user['email'] ?? '')) : '';
$this->orders->recordActivity( $this->orders->recordActivity(
$orderId, $orderId,
'receipt_issued', 'receipt_issued',

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace App\Modules\Email;
use App\Core\View\Template;
use App\Modules\Accounting\ReceiptRepository;
use App\Modules\Settings\ReceiptConfigRepository;
use Dompdf\Dompdf;
final class AttachmentGenerator
{
public function __construct(
private readonly ReceiptRepository $receipts,
private readonly ReceiptConfigRepository $receiptConfigs,
private readonly Template $template
) {
}
/**
* @param array<string, mixed> $order
* @return array{filename: string, content: string, mime: string}|null
*/
public function generate(string $type, array $order): ?array
{
return match ($type) {
'receipt' => $this->generateReceiptPdf($order),
default => null,
};
}
/**
* @param array<string, mixed> $order
* @return array{filename: string, content: string, mime: string}|null
*/
private function generateReceiptPdf(array $order): ?array
{
$orderId = (int) ($order['id'] ?? 0);
if ($orderId <= 0) {
return null;
}
$receipts = $this->receipts->findByOrderId($orderId);
if ($receipts === []) {
return null;
}
$receipt = $receipts[0];
$receiptId = (int) ($receipt['id'] ?? 0);
$fullReceipt = $this->receipts->findById($receiptId);
if ($fullReceipt === null) {
return null;
}
$data = $this->buildReceiptViewData($fullReceipt);
$html = $this->template->render('receipts/print', $data);
$dompdf = new Dompdf();
$dompdf->loadHtml($html);
$dompdf->setPaper('A4');
$dompdf->render();
$pdfContent = $dompdf->output();
if ($pdfContent === null || $pdfContent === '') {
return null;
}
$filename = str_replace(['/', '\\'], '_', (string) ($fullReceipt['receipt_number'] ?? 'paragon')) . '.pdf';
return [
'filename' => $filename,
'content' => $pdfContent,
'mime' => 'application/pdf',
];
}
/**
* @param array<string, mixed> $receipt
* @return array<string, mixed>
*/
private function buildReceiptViewData(array $receipt): array
{
$seller = json_decode((string) ($receipt['seller_data_json'] ?? '{}'), true);
$buyer = ($receipt['buyer_data_json'] ?? null) !== null
? json_decode((string) $receipt['buyer_data_json'], true)
: null;
$items = json_decode((string) ($receipt['items_json'] ?? '[]'), true);
$configName = '';
$config = $this->receiptConfigs->findById((int) ($receipt['config_id'] ?? 0));
if ($config !== null) {
$configName = (string) ($config['name'] ?? '');
}
return [
'receipt' => $receipt,
'seller' => is_array($seller) ? $seller : [],
'buyer' => is_array($buyer) ? $buyer : null,
'items' => is_array($items) ? $items : [],
'configName' => $configName,
];
}
}

View File

@@ -0,0 +1,336 @@
<?php
declare(strict_types=1);
namespace App\Modules\Email;
use App\Modules\Orders\OrdersRepository;
use App\Modules\Settings\EmailMailboxRepository;
use App\Modules\Settings\EmailTemplateRepository;
use PDO;
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception as PHPMailerException;
use RuntimeException;
use Throwable;
final class EmailSendingService
{
public function __construct(
private readonly PDO $pdo,
private readonly OrdersRepository $orders,
private readonly EmailTemplateRepository $templates,
private readonly EmailMailboxRepository $mailboxes,
private readonly VariableResolver $variableResolver,
private readonly AttachmentGenerator $attachmentGenerator
) {
}
/**
* @return array{success: bool, error: ?string, log_id: int}
*/
public function send(int $orderId, int $templateId, ?int $mailboxId = null, ?string $actorName = null): array
{
$details = $this->orders->findDetails($orderId);
if ($details === null) {
return ['success' => false, 'error' => 'Zamowienie nie znalezione', 'log_id' => 0];
}
$order = is_array($details['order'] ?? null) ? $details['order'] : [];
$addresses = is_array($details['addresses'] ?? null) ? $details['addresses'] : [];
$template = $this->templates->findById($templateId);
if ($template === null) {
return ['success' => false, 'error' => 'Szablon nie znaleziony', 'log_id' => 0];
}
$mailbox = $this->resolveMailbox($mailboxId, $template);
if ($mailbox === null) {
return ['success' => false, 'error' => 'Brak skonfigurowanej skrzynki SMTP', 'log_id' => 0];
}
$recipientEmail = $this->findRecipientEmail($addresses);
if ($recipientEmail === '') {
return ['success' => false, 'error' => 'Brak adresu e-mail kupujacego', 'log_id' => 0];
}
$recipientName = $this->findRecipientName($addresses);
$companySettings = $this->loadCompanySettings();
$variableMap = $this->variableResolver->buildVariableMap($order, $addresses, $companySettings);
$resolvedSubject = $this->variableResolver->resolve((string) ($template['subject'] ?? ''), $variableMap);
$resolvedBody = $this->variableResolver->resolve((string) ($template['body_html'] ?? ''), $variableMap);
$attachments = [];
$attachmentType = (string) ($template['attachment_1'] ?? '');
if ($attachmentType !== '') {
$attachment = $this->attachmentGenerator->generate($attachmentType, $order);
if ($attachment !== null) {
$attachments[] = $attachment;
}
}
$status = 'sent';
$errorMessage = null;
$sentAt = null;
try {
$this->sendViaSMTP($mailbox, $recipientEmail, $recipientName, $resolvedSubject, $resolvedBody, $attachments);
$sentAt = date('Y-m-d H:i:s');
} catch (Throwable $e) {
$status = 'failed';
$errorMessage = $e->getMessage();
}
$logId = $this->logEmail(
$templateId,
(int) ($mailbox['id'] ?? 0),
$orderId,
$recipientEmail,
$recipientName,
$resolvedSubject,
$resolvedBody,
$attachments,
$status,
$errorMessage,
$sentAt
);
$templateName = (string) ($template['name'] ?? '');
$activitySummary = $status === 'sent'
? 'Wyslano e-mail "' . $resolvedSubject . '" do ' . $recipientEmail
: 'Blad wysylki e-mail "' . $resolvedSubject . '" do ' . $recipientEmail . ': ' . ($errorMessage ?? '');
$this->orders->recordActivity(
$orderId,
'email_' . $status,
$activitySummary,
['template' => $templateName, 'recipient' => $recipientEmail, 'log_id' => $logId],
'user',
$actorName
);
return [
'success' => $status === 'sent',
'error' => $errorMessage,
'log_id' => $logId,
];
}
/**
* @return array{subject: string, body_html: string, attachments: list<string>}
*/
public function preview(int $orderId, int $templateId): array
{
$details = $this->orders->findDetails($orderId);
if ($details === null) {
return ['subject' => '', 'body_html' => '<p>Zamowienie nie znalezione</p>', 'attachments' => []];
}
$order = is_array($details['order'] ?? null) ? $details['order'] : [];
$addresses = is_array($details['addresses'] ?? null) ? $details['addresses'] : [];
$template = $this->templates->findById($templateId);
if ($template === null) {
return ['subject' => '', 'body_html' => '<p>Szablon nie znaleziony</p>', 'attachments' => []];
}
$companySettings = $this->loadCompanySettings();
$variableMap = $this->variableResolver->buildVariableMap($order, $addresses, $companySettings);
$resolvedSubject = $this->variableResolver->resolve((string) ($template['subject'] ?? ''), $variableMap);
$resolvedBody = $this->variableResolver->resolve((string) ($template['body_html'] ?? ''), $variableMap);
$attachmentNames = [];
$attachmentType = (string) ($template['attachment_1'] ?? '');
if ($attachmentType !== '') {
$attachment = $this->attachmentGenerator->generate($attachmentType, $order);
if ($attachment !== null) {
$attachmentNames[] = $attachment['filename'];
}
}
return [
'subject' => $resolvedSubject,
'body_html' => $resolvedBody,
'attachments' => $attachmentNames,
];
}
/**
* @param array<string, mixed>|null $template
* @return array<string, mixed>|null
*/
private function resolveMailbox(?int $mailboxId, ?array $template): ?array
{
if ($mailboxId !== null && $mailboxId > 0) {
$mailbox = $this->mailboxes->findById($mailboxId);
if ($mailbox !== null && (int) ($mailbox['is_active'] ?? 0) === 1) {
return $mailbox;
}
}
$templateMailboxId = (int) ($template['mailbox_id'] ?? 0);
if ($templateMailboxId > 0) {
$mailbox = $this->mailboxes->findById($templateMailboxId);
if ($mailbox !== null && (int) ($mailbox['is_active'] ?? 0) === 1) {
return $mailbox;
}
}
$active = $this->mailboxes->listActive();
foreach ($active as $m) {
if ((int) ($m['is_default'] ?? 0) === 1) {
return $this->mailboxes->findById((int) $m['id']);
}
}
return $active !== [] ? $this->mailboxes->findById((int) $active[0]['id']) : null;
}
/**
* @param array<int, array<string, mixed>> $addresses
*/
private function findRecipientEmail(array $addresses): string
{
foreach (['customer', 'delivery', 'invoice'] as $type) {
foreach ($addresses as $addr) {
if (($addr['address_type'] ?? '') === $type) {
$email = trim((string) ($addr['email'] ?? ''));
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) !== false) {
return $email;
}
}
}
}
return '';
}
/**
* @param array<int, array<string, mixed>> $addresses
*/
private function findRecipientName(array $addresses): string
{
foreach (['customer', 'delivery'] as $type) {
foreach ($addresses as $addr) {
if (($addr['address_type'] ?? '') === $type) {
$name = trim((string) ($addr['name'] ?? ''));
if ($name !== '') {
return $name;
}
}
}
}
return '';
}
/**
* @return array<string, mixed>
*/
private function loadCompanySettings(): array
{
$stmt = $this->pdo->prepare('SELECT * FROM company_settings LIMIT 1');
$stmt->execute();
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return is_array($row) ? $row : [];
}
/**
* @param array<string, mixed> $mailbox
* @param list<array{filename: string, content: string, mime: string}> $attachments
*/
private function sendViaSMTP(
array $mailbox,
string $recipientEmail,
string $recipientName,
string $subject,
string $body,
array $attachments
): void {
$mail = new PHPMailer(true);
$mail->isSMTP();
$mail->Host = (string) ($mailbox['smtp_host'] ?? '');
$mail->Port = (int) ($mailbox['smtp_port'] ?? 587);
$mail->SMTPAuth = true;
$mail->Username = (string) ($mailbox['smtp_username'] ?? '');
$mail->Password = (string) ($mailbox['smtp_password_decrypted'] ?? '');
$mail->CharSet = PHPMailer::CHARSET_UTF8;
$encryption = (string) ($mailbox['smtp_encryption'] ?? 'tls');
if ($encryption === 'tls') {
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
} elseif ($encryption === 'ssl') {
$mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
} else {
$mail->SMTPSecure = '';
$mail->SMTPAutoTLS = false;
}
$senderEmail = (string) ($mailbox['sender_email'] ?? $mailbox['smtp_username'] ?? '');
$senderName = (string) ($mailbox['sender_name'] ?? '');
$mail->setFrom($senderEmail, $senderName);
$mail->addAddress($recipientEmail, $recipientName);
$mail->isHTML(true);
$mail->Subject = $subject;
$mail->Body = $body;
foreach ($attachments as $att) {
$mail->addStringAttachment($att['content'], $att['filename'], PHPMailer::ENCODING_BASE64, $att['mime']);
}
$mail->send();
}
/**
* @param list<array{filename: string, content: string, mime: string}> $attachments
*/
private function logEmail(
int $templateId,
int $mailboxId,
int $orderId,
string $recipientEmail,
string $recipientName,
string $subject,
string $bodyHtml,
array $attachments,
string $status,
?string $errorMessage,
?string $sentAt
): int {
$attachmentsJson = [];
foreach ($attachments as $att) {
$attachmentsJson[] = [
'name' => $att['filename'],
'type' => $att['mime'],
];
}
$stmt = $this->pdo->prepare(
'INSERT INTO email_logs (
template_id, mailbox_id, order_id, recipient_email, recipient_name,
subject, body_html, attachments_json, status, error_message, sent_at, created_at
) VALUES (
:template_id, :mailbox_id, :order_id, :recipient_email, :recipient_name,
:subject, :body_html, :attachments_json, :status, :error_message, :sent_at, NOW()
)'
);
$stmt->execute([
'template_id' => $templateId,
'mailbox_id' => $mailboxId,
'order_id' => $orderId,
'recipient_email' => $recipientEmail,
'recipient_name' => $recipientName,
'subject' => $subject,
'body_html' => $bodyHtml,
'attachments_json' => json_encode($attachmentsJson, JSON_UNESCAPED_UNICODE),
'status' => $status,
'error_message' => $errorMessage,
'sent_at' => $sentAt,
]);
return (int) $this->pdo->lastInsertId();
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Modules\Email;
final class VariableResolver
{
/**
* @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);
}
return [
'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'] ?? ''),
];
}
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;
}
}

View File

@@ -12,6 +12,9 @@ use App\Core\Support\Flash;
use App\Core\Support\StringHelper; use App\Core\Support\StringHelper;
use App\Modules\Accounting\ReceiptRepository; use App\Modules\Accounting\ReceiptRepository;
use App\Modules\Auth\AuthService; use App\Modules\Auth\AuthService;
use App\Modules\Email\EmailSendingService;
use App\Modules\Settings\EmailMailboxRepository;
use App\Modules\Settings\EmailTemplateRepository;
use App\Modules\Settings\ReceiptConfigRepository; use App\Modules\Settings\ReceiptConfigRepository;
use App\Modules\Shipments\ShipmentPackageRepository; use App\Modules\Shipments\ShipmentPackageRepository;
@@ -24,7 +27,10 @@ final class OrdersController
private readonly OrdersRepository $orders, private readonly OrdersRepository $orders,
private readonly ?ShipmentPackageRepository $shipmentPackages = null, private readonly ?ShipmentPackageRepository $shipmentPackages = null,
private readonly ?ReceiptRepository $receiptRepo = null, private readonly ?ReceiptRepository $receiptRepo = null,
private readonly ?ReceiptConfigRepository $receiptConfigRepo = null private readonly ?ReceiptConfigRepository $receiptConfigRepo = null,
private readonly ?EmailSendingService $emailService = null,
private readonly ?EmailTemplateRepository $emailTemplateRepo = null,
private readonly ?EmailMailboxRepository $emailMailboxRepo = null
) { ) {
} }
@@ -178,6 +184,9 @@ final class OrdersController
); );
} }
$emailTemplates = $this->emailTemplateRepo !== null ? $this->emailTemplateRepo->listActive() : [];
$emailMailboxes = $this->emailMailboxRepo !== null ? $this->emailMailboxRepo->listActive() : [];
$flashSuccess = (string) Flash::get('order.success', ''); $flashSuccess = (string) Flash::get('order.success', '');
$flashError = (string) Flash::get('order.error', ''); $flashError = (string) Flash::get('order.error', '');
@@ -206,6 +215,8 @@ final class OrdersController
'flashError' => $flashError, 'flashError' => $flashError,
'receipts' => $receipts, 'receipts' => $receipts,
'receiptConfigs' => $activeReceiptConfigs, 'receiptConfigs' => $activeReceiptConfigs,
'emailTemplates' => $emailTemplates,
'emailMailboxes' => $emailMailboxes,
], 'layouts/app'); ], 'layouts/app');
return Response::html($html); return Response::html($html);
@@ -676,4 +687,51 @@ final class OrdersController
return $entry; return $entry;
}, $history); }, $history);
} }
public function sendEmail(Request $request): Response
{
$orderId = max(0, (int) $request->input('id', 0));
if ($orderId <= 0) {
return Response::json(['success' => false, 'message' => 'Nieprawidlowe zamowienie'], 400);
}
$csrfToken = (string) $request->input('_token', '');
if (!Csrf::validate($csrfToken)) {
return Response::json(['success' => false, 'message' => 'Sesja wygasla, odswiez strone'], 403);
}
if ($this->emailService === null) {
return Response::json(['success' => false, 'message' => 'Modul e-mail nie jest skonfigurowany'], 500);
}
$templateId = max(0, (int) $request->input('template_id', 0));
if ($templateId <= 0) {
return Response::json(['success' => false, 'message' => 'Wybierz szablon'], 400);
}
$mailboxId = (int) $request->input('mailbox_id', 0);
$user = $this->auth->user();
$userName = is_array($user) ? trim((string) ($user['name'] ?? $user['email'] ?? '')) : '';
$result = $this->emailService->send($orderId, $templateId, $mailboxId > 0 ? $mailboxId : null, $userName !== '' ? $userName : null);
return Response::json([
'success' => $result['success'],
'message' => $result['success'] ? 'E-mail wyslany pomyslnie' : ('Blad wysylki: ' . ($result['error'] ?? 'nieznany')),
]);
}
public function emailPreview(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 || $this->emailService === null) {
return Response::json(['subject' => '', 'body_html' => '', 'attachments' => []], 400);
}
$preview = $this->emailService->preview($orderId, $templateId);
return Response::json($preview);
}
} }

View File

@@ -95,8 +95,13 @@ final class OrdersRepository
$search = trim((string) ($filters['search'] ?? '')); $search = trim((string) ($filters['search'] ?? ''));
if ($search !== '') { if ($search !== '') {
$where[] = '(o.source_order_id LIKE :search OR o.external_order_id LIKE :search OR o.customer_login LIKE :search OR a.name LIKE :search OR a.email LIKE :search)'; $where[] = '(o.source_order_id LIKE :s1 OR o.external_order_id LIKE :s2 OR o.customer_login LIKE :s3 OR a.name LIKE :s4 OR a.email LIKE :s5)';
$params['search'] = '%' . $search . '%'; $searchVal = '%' . $search . '%';
$params['s1'] = $searchVal;
$params['s2'] = $searchVal;
$params['s3'] = $searchVal;
$params['s4'] = $searchVal;
$params['s5'] = $searchVal;
} }
$source = trim((string) ($filters['source'] ?? '')); $source = trim((string) ($filters['source'] ?? ''));