feat(129): order user notes module

CRUD notatek autorskich operatora per zamowienie z badge [N] na liscie
zamowien. Reuse istniejacej tabeli `order_notes` przez nowy
`note_type='user'` z `user_id` (FK->users SET NULL) i `author_name`
(snapshot). Sekcja `#notes` w "Wiadomosci i zalaczniki" w
`/orders/{id}` z inline edit form + delete przez
`OrderProAlerts.confirm`. Autoryzacja DB-level
(`WHERE user_id = :user_id`, rowCount=0 ⇒ 403) — bez admin override
(brak systemu rol w aplikacji).

- Migracja `20260514_000116_*.sql` (ADD COLUMN user_id + author_name +
  FK + indeks `idx_order_notes_type_order`); idempotentne z DDL
  no-op fallback.
- `OrderNotesService` (CRUD + walidacja body ≤ 2000 znakow); subquery
  `user_notes_count` w paginate; badge HTML w `toTableRow()`.
- 3 routy POST /orders/{id}/notes(/update|/delete).
- SCSS module `_order-notes.scss` + vanilla JS `order-notes.js`
  (inline edit toggle + delete confirm; idempotent guard).
- 9 kluczy i18n PL; PROJECT.md + ROADMAP.md + tech_changelog.md +
  db_schema.md zaktualizowane.

Follow-up: `php bin/migrate.php` + manualny smoke test (autor vs inny
user + badge na /orders/list).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 15:20:05 +02:00
parent c78ac335ee
commit 48351b5f36
20 changed files with 1261 additions and 25 deletions

View File

@@ -13,8 +13,8 @@ Sprzedawca moĹĽe obsĹugiwać zamĂłwienia ze wszystkich kanaĹĂłw
| Attribute | Value | | Attribute | Value |
|-----------|-------| |-----------|-------|
| Version | 3.7.0-dev | | Version | 3.7.0-dev |
| Status | v3.7 in progress — Phases 113-128 shipped (Fakturownia + HostedSMS/SMSPLANET + Alert unify + receipt VAT + SMS templates + invoice_requested import fix + invoice GUS mapping + polkurier foundation + polkurier shipment service) | | Status | v3.7 in progress — Phases 113-129 shipped (Fakturownia + HostedSMS/SMSPLANET + Alert unify + receipt VAT + SMS templates + invoice_requested import fix + invoice GUS mapping + polkurier foundation + polkurier shipment service + order user notes) |
| Last Updated | 2026-05-14 (Phase 128 closed) | | Last Updated | 2026-05-14 (Phase 129 closed) |
## Requirements ## Requirements
@@ -128,6 +128,7 @@ Sprzedawca moĹĽe obsĹugiwać zamĂłwienia ze wszystkich kanaĹĂłw
- [x] Bugfix detekcji faktury przy imporcie: shopPRO order z `firm_nip` ustawia `invoice_requested=1` (mapper jako jedyne zrodlo heurystyki, sync service propaguje `aggregate['invoice_detected']`); Allegro rozszerzony o `naturalPerson=false`/`address.taxId`/`companyName` (wczesniej tylko `invoice.required`); usunieta legacy kolumna `orders.is_invoice` (Phase 115 dryft) + backfill 7 zamowien — Phase 125 - [x] Bugfix detekcji faktury przy imporcie: shopPRO order z `firm_nip` ustawia `invoice_requested=1` (mapper jako jedyne zrodlo heurystyki, sync service propaguje `aggregate['invoice_detected']`); Allegro rozszerzony o `naturalPerson=false`/`address.taxId`/`companyName` (wczesniej tylko `invoice.required`); usunieta legacy kolumna `orders.is_invoice` (Phase 115 dryft) + backfill 7 zamowien — Phase 125
- [x] Integracja polkurier.pl (fundament): pojedyncza globalna konfiguracja w `/settings/integrations/polkurier`, szyfrowany Token API + login, karta w hubie integracji obok Apaczki i realny test polaczenia przez `apimetod=test_auth_api` zweryfikowany na zywym koncie operatora; `ShipmentProviderRegistry` netkniety — `PolkurierShipmentService/TrackingService` w kolejnych fazach — Phase 127 - [x] Integracja polkurier.pl (fundament): pojedyncza globalna konfiguracja w `/settings/integrations/polkurier`, szyfrowany Token API + login, karta w hubie integracji obok Apaczki i realny test polaczenia przez `apimetod=test_auth_api` zweryfikowany na zywym koncie operatora; `ShipmentProviderRegistry` netkniety — `PolkurierShipmentService/TrackingService` w kolejnych fazach — Phase 127
- [x] polkurier ShipmentService + TrackingService + UI prepare panel: pelen kontrakt API (createShipment/getLabel/getStatus/cancelOrder/getAvailableCarriers), `PolkurierShipmentService` implementujacy `ShipmentProviderInterface` z normalizacja shipmenttype (lowercase) i splitem ulicy na street/housenumber/flatnumber, `PolkurierTrackingService` mapujacy statusy O/P/A/WP/D/Z/W na znormalizowane, panel "polkurier" w `prepare.php` z dynamiczna lista uslug z `available_carriers`, seed migracja `delivery_status_mappings(provider='polkurier')` z 7 wpisami z PDF v1.11; live test na #114/#115 zakonczony sukcesem po 4 iteracjach (ReferenceError → uppercase shipmenttype → orderno parsing → A4/A6); rozmiar etykiety sterowany w panelu klienta polkurier.pl (Ustawienia konta → Preferencje etykiet), NIE przez API — Phase 128 - [x] polkurier ShipmentService + TrackingService + UI prepare panel: pelen kontrakt API (createShipment/getLabel/getStatus/cancelOrder/getAvailableCarriers), `PolkurierShipmentService` implementujacy `ShipmentProviderInterface` z normalizacja shipmenttype (lowercase) i splitem ulicy na street/housenumber/flatnumber, `PolkurierTrackingService` mapujacy statusy O/P/A/WP/D/Z/W na znormalizowane, panel "polkurier" w `prepare.php` z dynamiczna lista uslug z `available_carriers`, seed migracja `delivery_status_mappings(provider='polkurier')` z 7 wpisami z PDF v1.11; live test na #114/#115 zakonczony sukcesem po 4 iteracjach (ReferenceError → uppercase shipmenttype → orderno parsing → A4/A6); rozmiar etykiety sterowany w panelu klienta polkurier.pl (Ustawienia konta → Preferencje etykiet), NIE przez API — Phase 128
- [x] Order User Notes module (Phase 129): pelen CRUD notatek autorskich operatora per zamowienie. Reuse `order_notes` przez nowy `note_type='user'` z `user_id` (FK→users SET NULL) + `author_name` (snapshot) + indeks `idx_order_notes_type_order`. `OrderNotesService` z autoryzacja DB-level (`WHERE user_id = :user_id`, rowCount=0 ⇒ 403). Sekcja `#notes` w "Wiadomosci i zalaczniki" w `/orders/{id}` z inline edit form + delete przez `OrderProAlerts.confirm`. Badge `[N]` (indigo neutralny) przy nr zamowienia na `/orders/list` (subquery `user_notes_count` w paginate). Brak admin override (brak systemu rol w aplikacji) — edit/delete tylko dla autora — Phase 129
### Deferred ### Deferred
@@ -249,6 +250,10 @@ PHP (XAMPP/Laravel), integracje z API marketplace'Ăłw (Allegro, Erli) oraz API
| polkurier API nie ma parametru rozmiaru etykiety (A4/A6) | Zweryfikowane na PDF v1.11: `get_label` przyjmuje wylacznie `orderno: Array<String>`, `create_order` nie ma pola format/size. Rozmiar sterowany w panelu klienta polkurier.pl (Ustawienia konta → Preferencje etykiet) — operator zmienia preferencje konta jednorazowo. `polkurier_integration_settings.default_label_format` (PDF/ZPL/EPL) odnosi sie tylko do typu pliku, NIE rozmiaru. | 2026-05-14 | Active | | polkurier API nie ma parametru rozmiaru etykiety (A4/A6) | Zweryfikowane na PDF v1.11: `get_label` przyjmuje wylacznie `orderno: Array<String>`, `create_order` nie ma pola format/size. Rozmiar sterowany w panelu klienta polkurier.pl (Ustawienia konta → Preferencje etykiet) — operator zmienia preferencje konta jednorazowo. `polkurier_integration_settings.default_label_format` (PDF/ZPL/EPL) odnosi sie tylko do typu pliku, NIE rozmiaru. | 2026-05-14 | Active |
| Brak dedykowanego selektora punktow paczkomatowych w UI polkurier (Phase 128) | Istnieje juz pole `name="receiver_point_id"` w sekcji Adres odbiorcy z auto-fillem `parcel_external_id` z importu zamowienia — operator wpisuje ID recznie (np. `POP-RZE54`). Usuniete: `lookupPickupPoints`/`ShipmentController::polkurierPoints`/AJAX route/JS handler. `getInpostParcelMachines`/`getCourierPoints` zachowane jako stuby w API client — gotowe dla kolejnej fazy paczkomaty UI. | 2026-05-14 | Active | | Brak dedykowanego selektora punktow paczkomatowych w UI polkurier (Phase 128) | Istnieje juz pole `name="receiver_point_id"` w sekcji Adres odbiorcy z auto-fillem `parcel_external_id` z importu zamowienia — operator wpisuje ID recznie (np. `POP-RZE54`). Usuniete: `lookupPickupPoints`/`ShipmentController::polkurierPoints`/AJAX route/JS handler. `getInpostParcelMachines`/`getCourierPoints` zachowane jako stuby w API client — gotowe dla kolejnej fazy paczkomaty UI. | 2026-05-14 | Active |
| Diagnostyka silent-fail w ShipmentService — zapis surowej odpowiedzi do `error_message` | Gdy parsing API odpowiedzi zwraca pusty wynik mimo `status=success` (np. nieznany shape pola order number), zapisuj fragment surowej odpowiedzi (400 znakow) do `shipment_packages.error_message` — widoczne operatorowi w UI bez podgladu serwerowych logow. Pattern uratowal 3. iteracje live testu Phase 128. Reuse dla nowych integracji z API o nieznanym shape odpowiedzi. | 2026-05-14 | Active | | Diagnostyka silent-fail w ShipmentService — zapis surowej odpowiedzi do `error_message` | Gdy parsing API odpowiedzi zwraca pusty wynik mimo `status=success` (np. nieznany shape pola order number), zapisuj fragment surowej odpowiedzi (400 znakow) do `shipment_packages.error_message` — widoczne operatorowi w UI bez podgladu serwerowych logow. Pattern uratowal 3. iteracje live testu Phase 128. Reuse dla nowych integracji z API o nieznanym shape odpowiedzi. | 2026-05-14 | Active |
| `order_notes` jako jedna tabela dla notatek importowanych ze zrodla i autorskich operatora (Phase 129) | Reuse istniejacej tabeli przez nowy `note_type='user'` z `user_id`/`author_name` — mniej obiektow DB, jeden punkt zarzadzania. UNIQUE `(order_id, source_note_id)` nadal dziala bo MySQL traktuje wiele NULL jako unique (user notes maja `source_note_id=NULL`). `loadOrderNotes()` zawezone do `note_type <> 'user'`; notatki autorskie ladowane przez `OrderNotesService::listUserNotes()`. | 2026-05-14 | Active |
| Autoryzacja CRUD przez `WHERE user_id = :user_id` + rowCount=0 ⇒ `RuntimeException(403)` (Phase 129) | Eliminacja konieczności osobnego SELECT pre-check'a — atomowy UPDATE/DELETE z filtrem user_id robi to w jednym query. Wzorzec do reuse dla innych zasobow "ownership-based" w aplikacji. | 2026-05-14 | Active |
| Brak admin override dla notatek (Phase 129) — tylko autor edit/delete | Aplikacja nie ma systemu rol (`grep is_admin\|role=` zwrocil 0 trafien). Odlozone do osobnej fazy gdy beda role; obecnie operator ktory dodal notatke moze ja modyfikowac, inni widzą ale nie modyfikują. | 2026-05-14 | Deferred |
| Badge `[N]` w `order_ref` przy nr zamowienia (Phase 129) — neutralny indigo, NIE alertowy | Subtelniejszy niz `.risk-return-badge` (czerwony, alertowy) — notatki to informacja, nie ostrzezenie. Klik scrolluje do `#notes` w szczegolach zamowienia. Pattern do reuse dla kolejnych metryk per-order (np. liczba SMS, liczba dokumentow). | 2026-05-14 | Active |
## Success Metrics ## Success Metrics

View File

@@ -28,6 +28,7 @@ Wystawianie faktur dla klientow z NIP poprzez integracje z Fakturownia (app.fakt
| 126 | Invoice GUS Field Mapping Fix (KRS-based heuristic: JDG → name do "Imię i nazwisko", spółka → "Nazwa firmy") | 1/1 | Complete (2026-05-13; manual smoke pending operator) | | 126 | Invoice GUS Field Mapping Fix (KRS-based heuristic: JDG → name do "Imię i nazwisko", spółka → "Nazwa firmy") | 1/1 | Complete (2026-05-13; manual smoke pending operator) |
| 127 | polkurier Integration Foundation (single-instance settings + Token API + realny test polaczenia; obok Apaczki) | 1/1 | Complete (2026-05-14; live API verified — `Autoryzacja: 1`) | | 127 | polkurier Integration Foundation (single-instance settings + Token API + realny test polaczenia; obok Apaczki) | 1/1 | Complete (2026-05-14; live API verified — `Autoryzacja: 1`) |
| 128 | polkurier ShipmentService + TrackingService + UI prepare panel + delivery_status_mappings seed (live test na #114/#115) | 1/1 | Complete (2026-05-14; live test passed po 4 iteracjach; migracja + cron tracking weryfikacja pending) | | 128 | polkurier ShipmentService + TrackingService + UI prepare panel + delivery_status_mappings seed (live test na #114/#115) | 1/1 | Complete (2026-05-14; live test passed po 4 iteracjach; migracja + cron tracking weryfikacja pending) |
| 129 | Order User Notes module (extend `order_notes` o user_id/author_name/note_type='user' + pelen CRUD restricted to author + badge `[N]` na liscie zamowien) | 1/1 | Complete (2026-05-14; migracja + manualny smoke pending operator) |
Planowane kolejne fazy v3.7 (kandydaci, do rozplanowania): Planowane kolejne fazy v3.7 (kandydaci, do rozplanowania):
- polkurier TrackingService + `delivery_status_mappings` (provider='polkurier') - polkurier TrackingService + `delivery_status_mappings` (provider='polkurier')
@@ -512,4 +513,4 @@ Archive: `.paul/milestones/v0.1-ROADMAP.md`
--- ---
*Roadmap created: 2026-03-12* *Roadmap created: 2026-03-12*
*Last updated: 2026-05-14 - Phase 128 UNIFY closed (live test passed, etykiety A6 OK)* *Last updated: 2026-05-14 - Phase 129 UNIFY closed (Order User Notes module; migracja + smoke pending operator)*

View File

@@ -10,14 +10,14 @@ See: .paul/PROJECT.md (updated 2026-05-07)
## Current Position ## Current Position
Milestone: v3.7 Invoices (Fakturownia integration) - In progress Milestone: v3.7 Invoices (Fakturownia integration) - In progress
Phase: 128 of TBD (polkurier ShipmentService + Tracking + UI) - Complete Phase: 129 of TBD (Order User Notes module) - Complete
Plan: 128-01 complete (SUMMARY.md created) Plan: 129-01 complete (SUMMARY.md created)
Status: UNIFY complete, transition pending (git commit + Decisions w PROJECT.md + ROADMAP status) Status: UNIFY complete, transition pending (git commit + Decisions w PROJECT.md + ROADMAP status)
Last activity: 2026-05-14 - Phase 128-01 UNIFY zakonczony, SUMMARY + changelog utworzone Last activity: 2026-05-14 - Phase 129-01 UNIFY zakonczony, SUMMARY + changelog utworzone
Progress: Progress:
- Milestone v3.7: [##########] ~99% (Phase 113-128 complete; transition pending) - Milestone v3.7: [##########] ~99% (Phase 113-129 complete; transition pending)
- Phase 128: [##########] 100% - Phase 129: [##########] 100%
## Loop Position ## Loop Position
@@ -30,18 +30,18 @@ PLAN -> APPLY -> UNIFY
## Session Continuity ## Session Continuity
Last session: 2026-05-14 Last session: 2026-05-14
Stopped at: Phase 128-01 UNIFY closed; SUMMARY.md created Stopped at: Phase 129-01 UNIFY closed; SUMMARY.md created
Next action: Phase transition (git commit `feat(128): polkurier shipment service + tracking + UI prepare` + Decisions w PROJECT.md + ROADMAP status update), potem wybor kolejnego kandydata v3.7 (paczkomaty UI / shipment_presets polkurier / OrderValuationV2 / invoice.created event / eksport XLSX faktur) Next action: Phase transition (git commit `feat(129): order user notes module` + Decisions w PROJECT.md + ROADMAP status update), potem wybor kolejnego kandydata v3.7 (paczkomaty polkurier UI / event automatyzacji note.created / eksport XLSX faktur / invoice.created event / admin override dla notatek po wprowadzeniu rol)
Resume file: .paul/ROADMAP.md Resume file: .paul/phases/129-order-user-notes/129-01-SUMMARY.md
## Pending parallel work ## Pending parallel work
- None — Phase 118, 121, 122 wszystkie zacommitowane (8f14851, 360eef1). - None — Phase 118, 121, 122 wszystkie zacommitowane (8f14851, 360eef1).
## Git State ## Git State
Last phase commit: 3443879 feat(127): polkurier integration foundation Last phase commit: c78ac33 feat(128): polkurier shipment service + tracking + UI prepare
Previous: c758ec7 feat(126): invoice GUS field mapping fix (JDG/KRS heuristic) Previous: 3443879 feat(127): polkurier integration foundation
Branch: main (6 commits ahead of origin/main) Branch: main (7 commits ahead of origin/main)
## Pending Actions ## Pending Actions
@@ -71,6 +71,9 @@ Branch: main (6 commits ahead of origin/main)
- Phase 128 follow-up: uruchom migracje gdy XAMPP MySQL online: `php bin/migrate.php` (seed 7 wpisow `provider='polkurier'` w `delivery_status_mappings`). - Phase 128 follow-up: uruchom migracje gdy XAMPP MySQL online: `php bin/migrate.php` (seed 7 wpisow `provider='polkurier'` w `delivery_status_mappings`).
- Phase 128 follow-up: weryfikacja crona `shipment_tracking_sync` przy pierwszej zywej paczce polkurier w `in_transit` — sprawdz ze `shipment_packages.delivery_status` aktualizuje sie z `D`/`WP`/`Z` przez `DeliveryStatus::normalizeWithOverrides('polkurier', ...)`. - Phase 128 follow-up: weryfikacja crona `shipment_tracking_sync` przy pierwszej zywej paczce polkurier w `in_transit` — sprawdz ze `shipment_packages.delivery_status` aktualizuje sie z `D`/`WP`/`Z` przez `DeliveryStatus::normalizeWithOverrides('polkurier', ...)`.
- Phase 128 follow-up: rozmiar etykiety A4 vs A6 sterowany jest w panelu klienta polkurier.pl (Ustawienia konta → Preferencje etykiet), NIE przez API — operator ustawil A6. - Phase 128 follow-up: rozmiar etykiety A4 vs A6 sterowany jest w panelu klienta polkurier.pl (Ustawienia konta → Preferencje etykiet), NIE przez API — operator ustawil A6.
- Phase 129 follow-up: uruchom migracje gdy XAMPP MySQL online: `php bin/migrate.php` (utworzy `order_notes.user_id` + `author_name` + FK + indeks `idx_order_notes_type_order`).
- Phase 129 follow-up: manualny smoke — `/orders/{X}` → sekcja "Notatki" widoczna, dodanie notatki tworzy wiersz + wpis w `order_activity_log`. Drugi user (`session.user_id != note.user_id`) nie widzi przycisków Edytuj/Usuń; POST `/notes/{noteId}/delete` jako inny user → 403 flash.
- Phase 129 follow-up: `/orders/list` → badge `[N]` widoczny przy zamówieniach z notatkami autorskimi; klik scrolluje do `#notes` w szczegółach. Sprawdzić że badge zwrotów (Phase 106) działa równolegle.
## Deferred to Next Milestones ## Deferred to Next Milestones

View File

@@ -53,3 +53,32 @@
- `.paul/phases/128-polkurier-shipment-service/128-01-PLAN.md` (nowy plik) - `.paul/phases/128-polkurier-shipment-service/128-01-PLAN.md` (nowy plik)
- `.paul/phases/128-polkurier-shipment-service/128-01-SUMMARY.md` (nowy plik) - `.paul/phases/128-polkurier-shipment-service/128-01-SUMMARY.md` (nowy plik)
- `.paul/phases/128-polkurier-shipment-service/polkurier-api-docs.txt` (nowy plik — referencyjna doca z PDF v1.11) - `.paul/phases/128-polkurier-shipment-service/polkurier-api-docs.txt` (nowy plik — referencyjna doca z PDF v1.11)
## Co zrobiono (cd. — Phase 129)
- [Phase 129, Plan 01] Order User Notes module — pelen CRUD notatek autorskich operatora per zamowienie z badge `[N]` na liscie zamowien. Reuse istniejacej tabeli `order_notes` przez nowy `note_type='user'` z `user_id` (FK→users SET NULL) i `author_name` (snapshot). Sekcja `#notes` w "Wiadomosci i zalaczniki" w szczegolach zamowienia z inline edit form + delete przez `OrderProAlerts.confirm`.
- Task 1: Migracja `20260514_000116_extend_order_notes_user_authored.sql` (ADD COLUMN user_id + author_name + FK + indeks `idx_order_notes_type_order`) z idempotentnymi `INFORMATION_SCHEMA` guard'ami i DDL no-op fallback'iem.
- Task 2: `OrderNotesService` (5 metod CRUD + autoryzacja przez `WHERE user_id = :user_id`, rowCount=0 ⇒ 403). `OrdersRepository::userNotesCountSubquerySql()` + kolumna `user_notes_count` w paginate. `OrdersController::storeNote/updateNote/deleteNote` + badge HTML w `toTableRow()`. 3 nowe POST routes.
- Task 3: Sekcja `#notes` w `show.php` (3 bloki — lista user notes + form dodawania + opcjonalny block "Wiadomosci ze zrodla"). SCSS `_order-notes.scss` z `.order-notes-badge` (indigo neutralny). JS `order-notes.js` (inline edit toggle + delete confirm). 9 nowych kluczy i18n PL. `npm run build:css` rebuilt.
- Auto-fix: plan referowal nieistniejaca metode `formatOrderRow()` — wlasciwa nazwa `toTableRow()` znaleziona przez Grep "public function". Edycja zaaplikowana w wlasciwej metodzie.
- Brak admin override w CRUD (decyzja podczas planowania): aplikacja nie ma systemu rol, autoryzacja przez `note.user_id = session.user_id` — odlozone do osobnej fazy.
## Zmienione pliki (cd. — Phase 129)
- `database/migrations/20260514_000116_extend_order_notes_user_authored.sql` (nowy plik)
- `src/Modules/Orders/OrderNotesService.php` (nowy plik)
- `src/Modules/Orders/OrdersController.php` (3 nowe akcje + badge HTML)
- `src/Modules/Orders/OrdersRepository.php` (subquery `user_notes_count` + `loadOrderNotes` zawezone do `note_type <> 'user'`)
- `routes/web.php` (3 nowe routes + `OrderNotesService` instancjonowany)
- `resources/views/orders/show.php` (sekcja `#notes` + inline edit form)
- `resources/views/layouts/app.php` (script `order-notes.js`)
- `resources/lang/pl.php` (9 kluczy `orders.details.notes_user_*` + `notes_imported_title`)
- `resources/scss/modules/_order-notes.scss` (nowy plik)
- `resources/scss/app.scss` (`@use "modules/order-notes"`)
- `public/assets/js/modules/order-notes.js` (nowy plik)
- `public/assets/css/app.css` (rebuilt)
- `.paul/codebase/db_schema.md` (sekcja `order_notes` rozszerzona)
- `.paul/codebase/tech_changelog.md` (wpis Phase 129)
- `.paul/STATE.md`, `.paul/ROADMAP.md`
- `.paul/phases/129-order-user-notes/129-01-PLAN.md` (nowy plik)
- `.paul/phases/129-order-user-notes/129-01-SUMMARY.md` (nowy plik)

View File

@@ -328,6 +328,26 @@ UNIQUE: `(integration_id, external_order_id)`
UNIQUE: `(order_id, source_payment_id)` UNIQUE: `(order_id, source_payment_id)`
**order_notes** — Notatki przypisane do zamówienia (importowane ze źródła + autorskie operatora)
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | BIGINT UNSIGNED | NO | PK, AUTO_INCREMENT |
| `order_id` | BIGINT UNSIGNED | NO | FK → orders(id) CASCADE |
| `source_note_id` | VARCHAR(64) | YES | ID notatki ze źródła (shopPRO/Allegro); NULL dla notatek autorskich |
| `note_type` | VARCHAR(32) | NO | `shoppro`/`allegro`/`message` (imported) lub `user` (Phase 129 — autorska notatka operatora) |
| `user_id` | INT UNSIGNED | YES | FK → users(id) ON DELETE SET NULL (Phase 129); set tylko dla `note_type='user'` |
| `author_name` | VARCHAR(190) | YES | Snapshot `users.name` w momencie tworzenia (Phase 129); chroni przed zmianą nazwy usera |
| `created_at_external` | DATETIME | YES | Data ze źródła (import); NULL dla `note_type='user'` |
| `comment` | TEXT | NO | Treść notatki (reuse dla `note_type='user'` jako body) |
| `payload_json` | JSON | YES | Raw payload ze źródła; NULL dla `note_type='user'` |
| `created_at` | DATETIME | NO | DEFAULT CURRENT_TIMESTAMP |
| `updated_at` | DATETIME | NO | DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP |
UNIQUE: `(order_id, source_note_id)` — note: MySQL traktuje wiele NULL jako unique, więc nie blokuje wielu rekordów `note_type='user'` (source_note_id zawsze NULL).
Indexes: `order_notes_order_idx (order_id)`, `idx_order_notes_type_order (note_type, order_id)` (Phase 129 — wspiera subquery `user_notes_count` na liście zamówień i `listUserNotes`).
> Note (Phase 129-01, 2026-05-14): Dodano `user_id`/`author_name` oraz `note_type='user'` dla notatek autorskich operatora. Edycja/usuwanie dozwolone tylko dla autora (`note.user_id === session.user_id`) — brak admin override (brak systemu ról w aplikacji). Importowane notatki ze źródła (`note_type IN ('shoppro','allegro','message')`) zachowują `user_id=NULL` i pozostają nieedytowalne.
--- ---
## Order Statuses ## Order Statuses

View File

@@ -1,5 +1,29 @@
# Technical Changelog # Technical Changelog
## 2026-05-14 - Phase 129 Plan 01: Order User Notes module
**Co zrobiono:**
- Migracja `database/migrations/20260514_000116_extend_order_notes_user_authored.sql``order_notes` rozszerzona o `user_id INT UNSIGNED NULL` (FK → `users(id)` ON DELETE SET NULL), `author_name VARCHAR(190) NULL`, oraz indeks `idx_order_notes_type_order (note_type, order_id)`. Wszystkie ADD owinięte w `INFORMATION_SCHEMA` guard z DDL no-op fallback (`ALTER TABLE COMMENT`) — pattern Phase 115/125.
- `src/Modules/Orders/OrderNotesService.php` — nowy serwis CRUD nad `order_notes` z `note_type='user'`. Metody: `listUserNotes`, `listImportedNotes`, `countUserNotes`, `findById`, `create`, `update`, `delete`. Autoryzacja przez `WHERE user_id = :user_id` w UPDATE/DELETE — rowCount=0 ⇒ rzut `RuntimeException(code=403)`. Walidacja `body`: trim, niepuste, ≤ 2000 znakow.
- `src/Modules/Orders/OrdersRepository.php` — dodany `userNotesCountSubquerySql($orderAlias)` (subquery `COUNT(*) FROM order_notes WHERE note_type='user'`) używany w `paginateSql()` jako kolumna `user_notes_count`. `loadOrderNotes()` zawężony do `note_type <> 'user'` (importowane ze źródła). `transformOrderRow()` ekspozuje `user_notes_count`.
- `src/Modules/Orders/OrdersController.php` — nowa opcjonalna zależność `OrderNotesService` w konstruktorze (na końcu, nullable, BC-safe). 3 metody: `storeNote`, `updateNote`, `deleteNote` (każda CSRF + sesja + try/catch `RuntimeException`/`InvalidArgumentException`; rejestruje `order_activity_log event_type='note'` przez `OrdersRepository::recordActivity`). `toTableRow()` renderuje `<a class="order-notes-badge" href="/orders/{id}#notes">[N]</a>` obok numeru zamówienia gdy `user_notes_count > 0`. `show()` pobiera `userNotes` + `currentUserId` i przekazuje do widoku.
- `routes/web.php` — 3 nowe route'y `POST /orders/{id}/notes`, `POST /orders/{id}/notes/{noteId}/update`, `POST /orders/{id}/notes/{noteId}/delete`. `OrderNotesService` instancjonowany przed `new OrdersController(...)` i przekazany ostatnim argumentem.
- `resources/views/orders/show.php` — sekcja "Wiadomości i załączniki" przebudowana na 3 bloki: (1) `<div class="order-user-notes" id="notes">` z listą notatek (data · autor) + akcjami edit/delete dla autora + inline formularz dodawania, (2) ukryty `order-note-edit-form` per notatka rozwijany przez JS, (3) opcjonalny blok "Wiadomości ze źródła" gdy `$notesList !== []` (importowane, bez akcji).
- `resources/lang/pl.php` — 10 nowych kluczy `orders.details.notes_user_*` / `notes_imported_title` (UI labels w PL).
- `resources/scss/modules/_order-notes.scss` (nowy) + `@use` w `app.scss``.order-notes-badge` (niebieskoszary `#eef2ff/#4338ca`), `.order-user-notes`, `.order-event--user` (lewa krawędź `#6366f1`), `.order-imported-notes` (opacity 0.75), `.btn-link`, `.order-note-form`, `.order-note-edit-form`. CSS przebudowany via `npm run build:css`.
- `public/assets/js/modules/order-notes.js` (nowy) + `<script>` w `layouts/app.php` — wanilijowy JS: klik "Edytuj" toggle'uje `js-order-note-body``js-order-note-edit-form`, klik "Anuluj" wraca, submit formularza DELETE przechwycony i potwierdzany przez `OrderProAlerts.confirm({title, message, danger:true, onConfirm})` (options-object API z decyzji Phase 114). Idempotent guard `window.__orderNotesInit` + `dataset.bound`.
- `.paul/codebase/db_schema.md` — sekcja `order_notes` rozszerzona o pełne kolumny + notatkę Phase 129-01 (note_type='user' vs imported).
**Dlaczego:**
- Operator potrzebował miejsca na własne adnotacje per zamówienie (uzgodnienia z klientem, ustalenia wewnętrzne) niezależne od notatek importowanych z shopPRO/Allegro. Bez badge'a na liście trzeba by wchodzić w każde zamówienie żeby sprawdzić czy ma notatki.
- Reuse istniejącej tabeli `order_notes` (Plan clarification #1) zamiast nowej tabeli — mniej obiektów DB, jeden punkt zarządzania, semantyka rozróżniona przez `note_type`. UNIQUE `(order_id, source_note_id)` nadal działa bo MySQL traktuje wiele NULL jako unique.
- Brak admin override (Plan clarification #3 z dopiskiem): aplikacja nie ma systemu ról (`grep -rn "is_admin|role=" src/Modules/Auth` zwrócił 0 trafień). Autoryzacja przez `note.user_id = session.user_id` — operator który dodał notatkę edytuje/usuwa, inni widzą ale nie modyfikują. Pełen admin-override odłożony do osobnej fazy po wprowadzeniu ról.
- Indeks `idx_order_notes_type_order (note_type, order_id)` zapewnia że subquery `user_notes_count` w paginacji `/orders/list` nie degraduje performance przy rosnącej liczbie notatek (Phase 106 pattern dla `customer_returned_count`).
**BREAKING:** brak — wszystkie zmiany BC. `loadOrderNotes()` teraz zwraca tylko `note_type <> 'user'`, ale nikt poza `findDetails()` jej nie używa, a sekcja widoku zachowuje wstecznie kompatybilne `$notesList` z importowanych notatek (osobny blok pod nową sekcją "Notatki").
---
## 2026-05-14 - Phase 128 Plan 01: polkurier ShipmentService + Tracking + UI prepare ## 2026-05-14 - Phase 128 Plan 01: polkurier ShipmentService + Tracking + UI prepare
**Co zrobiono:** **Co zrobiono:**

View File

@@ -0,0 +1,295 @@
---
phase: 129-order-user-notes
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- database/migrations/20260514_000116_extend_order_notes_user_authored.sql
- src/Modules/Orders/OrdersRepository.php
- src/Modules/Orders/OrdersController.php
- src/Modules/Orders/OrderNotesService.php
- routes/web.php
- resources/views/orders/show.php
- resources/views/orders/list.php
- resources/lang/pl.php
- resources/scss/modules/_order-notes.scss
- resources/scss/app.scss
- public/assets/js/modules/order-notes.js
- resources/views/layouts/app.php
autonomous: true
delegation: auto
---
<objective>
## Goal
Wprowadzic moduł notatek użytkownika w zamówieniach: pełen CRUD (add/edit/delete tylko dla autora) w sekcji "Wiadomosci i zalaczniki" w szczegółach zamówienia (`/orders/{id}`), oraz licznik notatek `[N]` jako mały badge przy numerze zamówienia na liście (`/orders/list`).
## Purpose
Operator potrzebuje miejsca na własne adnotacje per zamówienie (uzgodnienia z klientem, ustalenia wewnętrzne, flagi do dalszej obsługi), niezależne od zaimportowanych notatek ze źródła. Badge na liście pozwala szybko zobaczyć które zamówienia mają adnotacje bez wchodzenia w szczegóły — analogicznie do licznika zwrotów klienta (Phase 106).
## Output
- Migracja rozszerzająca `order_notes` o `user_id` (FK→users SET NULL) + `author_name` (snapshot) + `body` (czytelny alias do TEXT) — z reuse istniejącej kolumny `comment` jako body i nowymi kolumnami; nowy `note_type='user'`.
- `OrderNotesService` z metodami `create/update/delete/listUserNotes/countUserNotesForOrders`.
- 3 routes: `POST /orders/{id}/notes`, `POST /orders/{id}/notes/{noteId}/update`, `POST /orders/{id}/notes/{noteId}/delete`.
- W sekcji "Wiadomosci i zalaczniki" w `show.php`: lista notatek użytkownika (data + autor + tresc + akcje edit/delete dla autora) + formularz dodawania; importowane notatki zachowane jako osobny blok wyżej (filtr po `note_type`).
- Badge `[N]` w komórce `order_ref` listy zamówień (neutralna kolorystyka, klasa `order-notes-badge`, link do `#notes` w szczegółach).
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
@.paul/codebase/architecture.md
@.paul/codebase/db_schema.md
## Source Files (key spots)
@src/Modules/Orders/OrdersRepository.php
@src/Modules/Orders/OrdersController.php
@routes/web.php
@resources/views/orders/show.php
@resources/views/orders/list.php
@resources/lang/pl.php
## Reference Patterns
- Phase 106 Customer Return Badge — `customerReturnedCountSubquerySql()` w `OrdersRepository`, render `risk-return-badge` w `OrdersController::formatOrderRow()` (linia ~656659).
- Phase 124 SMS Templates Service — `SmsTemplatesService` jako wzorzec serwisu CRUD nad pojedynczą tabelą.
- Phase 113-115 toggle pattern — `invoice-requested-toggle.js` jako wzorzec wanilijowego JS POST-em z CSRF.
## Existing `order_notes` schema (draft 20260302_orders_schema_v1.sql)
Tabela już istnieje: `id`, `order_id`, `source_note_id`, `note_type`, `created_at_external`, `comment`, `payload_json`, `created_at`, `updated_at`, UNIQUE `(order_id, source_note_id)`. Obecnie używana tylko do notatek importowanych ze źródła (shopPRO/Allegro mappers; loadOrderNotes w OrdersRepository.php:596).
<clarifications>
- **Schemat DB** — Gdzie przechowywać notatki użytkownika?
→ Odpowiedź: Rozszerz `order_notes` o `user_id`+`author_name`+nowy `note_type='user'`.
- **Badge UI** — Jak ma wyglądać label z liczbą notatek na liście zamówień?
→ Odpowiedź: Mały badge `[N]` przy nr zamówienia (neutralna kolorystyka, klik scrolluje do sekcji notatek w szczegółach).
- **CRUD scope** — Co operator może robić z własnymi notatkami?
→ Odpowiedź: Pełny CRUD (add/edit/delete) — autor lub admin może edytować/usuwać. Brak systemu ról w aplikacji → implementacja: edit/delete dozwolone tylko gdy `note.user_id === session.user_id` (sam autor). Jeżeli operator chce uprawnienia globalne, odłożyć do osobnej fazy po wprowadzeniu ról.
- **Umiejscowienie** — Gdzie umieścić UI notatek w szczegółach zamówienia?
→ Odpowiedź: W sekcji "Wiadomosci i zalaczniki" (już istnieje w details panel, `resources/views/orders/show.php` linia ~449463). Tam dorzucamy listę notatek użytkownika + formularz dodawania. Importowane notatki ze źródła zachowujemy jako osobny mniejszy blok.
</clarifications>
</context>
<acceptance_criteria>
## AC-1: Migracja DB — kolumny user notes
```gherkin
Given baza zawiera tabelę `order_notes` ze starymi importowanymi rekordami (note_type IN ('shoppro','allegro','message'))
When uruchomię `php bin/migrate.php`
Then tabela `order_notes` ma nowe kolumny `user_id INT UNSIGNED NULL` (FKusers(id) ON DELETE SET NULL), `author_name VARCHAR(190) NULL`, oraz indeks `idx_order_notes_type_order (note_type, order_id)`. Istniejące rekordy mają `user_id=NULL`, `author_name=NULL`, `note_type` niezmieniony. Migracja jest idempotentna (re-run = no-op via `INFORMATION_SCHEMA` guard lub `IF NOT EXISTS`).
```
## AC-2: Tworzenie notatki użytkownika
```gherkin
Given zalogowany user (id=5, name="Jacek Pyziak") otwiera `/orders/1090`
When wpisuje treść w textarea formularza "Dodaj notatkę" i klika "Zapisz"
Then POST `/orders/1090/notes` z `_token` i `body` zapisuje wiersz `order_notes(order_id=1090, note_type='user', user_id=5, author_name='Jacek Pyziak', comment=<treść>, created_at=NOW())`, dodaje wpis `order_activity_log(event_type='note', summary='Dodano notatkę', actor_type='user', actor_name='Jacek Pyziak')`, flashuje sukces i przekierowuje do `/orders/1090#notes`.
```
## AC-3: Edycja i usuwanie tylko przez autora
```gherkin
Given notatka #42 ma user_id=5 i jest renderowana na `/orders/1090`
When zalogowany user id=5 klika "Edytuj" zmienia treść "Zapisz"
Then POST `/orders/1090/notes/42/update` aktualizuje `comment` i `updated_at`, lista re-renderuje się z nową treścią
When ten sam user id=5 klika "Usuń" potwierdza w `OrderProAlerts.confirm` z `danger:true`
Then POST `/orders/1090/notes/42/delete` usuwa rekord (DELETE WHERE id=42 AND user_id=5), flashuje sukces
When zalogowany user id=8 (inny niż autor) próbuje POST `/orders/1090/notes/42/update` lub `/delete`
Then odpowiedź HTTP 403 z komunikatem "Brak uprawnień tylko autor może edytować/usuwać notatkę" (flash danger), wiersz pozostaje nienaruszony
```
## AC-4: Lista notatek w sekcji "Wiadomosci i zalaczniki"
```gherkin
Given zamówienie 1090 ma 2 notatki użytkownika (autor=Jacek, daty 2026-05-14 10:00 i 2026-05-14 12:30) oraz 1 zaimportowaną z shopPRO (`note_type='shoppro'`)
When otwieram `/orders/1090` i scrolluję do "Wiadomosci i zalaczniki"
Then widzę:
1. Blok "Notatki" (id="notes"): 2 wpisy w kolejności desc po `created_at`, każdy z `data | autor` w nagłówku i treścią poniżej, oraz przyciskami "Edytuj"/"Usuń" tylko dla wpisów, których user_id == session.user_id
2. Inline formularz dodawania notatki (textarea + przycisk "Zapisz") z CSRF tokenem
3. Blok "Wiadomości ze źródła" (subtelny styl, mniejszy): 1 wpis shopPRO bez akcji edit/delete
```
## AC-5: Badge `[N]` na liście zamówień
```gherkin
Given zamówienie 1090 ma 2 user-notes, zamówienie 1091 ma 0
When otwieram `/orders/list`
Then przy nr zamówienia 1090 widzę mały badge `<span class="order-notes-badge" title="2 notatki">[2]</span>` jako link do `/orders/1090#notes` (neutralna kolorystyka — niebieskoszary tekst na jasnym tle, mniejszy niż badge zwrotów), badge przy 1091 jest ukryty (count=0 ⇒ pusty string).
```
## AC-6: Subquery licznika nie psuje paginacji/sortowania
```gherkin
Given lista `/orders/list` z 1000 zamówieniami filtrowana po statusie i sortowana
When wykonam paginację, filtrowanie i sortowanie
Then licznik `user_notes_count` jest wyliczany subquery (`SELECT COUNT(*) FROM order_notes WHERE order_id = o.id AND note_type = 'user'`) jako kolumna SELECT bez wpływu na WHERE/GROUP BY/ORDER. Czas wykonania zapytania pozostaje rozsądny dzięki indeksowi `idx_order_notes_type_order`.
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Migracja DB + extend `order_notes` o pola user-authored</name>
<files>database/migrations/20260514_000116_extend_order_notes_user_authored.sql, .paul/codebase/db_schema.md</files>
<action>
Utwórz migrację `20260514_000116_extend_order_notes_user_authored.sql`:
- `ALTER TABLE order_notes ADD COLUMN user_id INT UNSIGNED NULL AFTER note_type;`
- `ALTER TABLE order_notes ADD COLUMN author_name VARCHAR(190) NULL AFTER user_id;`
- `ALTER TABLE order_notes ADD CONSTRAINT order_notes_user_fk FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE;`
- `ALTER TABLE order_notes ADD INDEX idx_order_notes_type_order (note_type, order_id);`
- Każdy ADD owijaj w `INFORMATION_SCHEMA` guard (`SET @x = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE ...); SET @sql = IF(@x=0, 'ALTER TABLE...', 'SELECT 1'); PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;`) — wzorzec z istniejących migracji. UWAGA: ostatni guard musi być DDL no-op (`ALTER TABLE order_notes COMMENT='phase-129 idempotent'`) NIE `SELECT 1` (decyzja z Phase 115).
Następnie zaktualizuj `.paul/codebase/db_schema.md` (sekcja Orders → order_notes): dopisz tabelę z nowymi kolumnami i indeksem, opisz że `note_type='user'` oznacza notatki autorskie z `user_id`/`author_name`, a stare `note_type IN ('shoppro','allegro','message')` to importowane.
Avoid: zmiany w `comment`/`payload_json`/`source_note_id` (ochrona istniejących importów). UNIQUE `(order_id, source_note_id)` zostaje — user notes mają source_note_id=NULL, więc MySQL traktuje każdy NULL jako unique row.
</action>
<verify>php bin/migrate.php → migration logged; `DESCRIBE order_notes;` pokazuje nowe kolumny i FK; re-run migracji = no-op (idempotent guard).</verify>
<done>AC-1 satisfied: kolumny dodane, FK aktywny, indeks utworzony, schema doc zaktualizowany.</done>
</task>
<task type="auto">
<name>Task 2: OrderNotesService + repository extension + routes + Controller actions</name>
<files>src/Modules/Orders/OrderNotesService.php, src/Modules/Orders/OrdersRepository.php, src/Modules/Orders/OrdersController.php, routes/web.php</files>
<action>
1) Utwórz `src/Modules/Orders/OrderNotesService.php` (final class):
- `__construct(\PDO $pdo)`
- `listUserNotes(int $orderId): array``SELECT id, user_id, author_name, comment AS body, created_at, updated_at FROM order_notes WHERE order_id = :order_id AND note_type = 'user' ORDER BY created_at DESC, id DESC`
- `listImportedNotes(int $orderId): array` — stary `loadOrderNotes` logic, ale z filtrem `note_type != 'user'`
- `create(int $orderId, int $userId, string $authorName, string $body): int` — INSERT + zwraca lastInsertId; po INSERT wywołaj `OrderActivityLogService::log(orderId, 'note', 'Dodano notatkę', actorName=$authorName)` jeśli serwis istnieje (jeśli nie — INSERT do `order_activity_log` bezpośrednio przez PDO; pattern z Phase 56 OrderPaymentsService).
- `update(int $noteId, int $userId, string $body): bool` — UPDATE WHERE id=:id AND user_id=:user_id, zwraca `$stmt->rowCount() > 0`. Rzut `RuntimeException` z kodem 403 gdy rowCount=0 (nieautoryzowany lub nie istnieje).
- `delete(int $noteId, int $userId): bool` — DELETE WHERE id=:id AND user_id=:user_id; analogiczna obsługa autoryzacji.
- Walidacja `body`: trim, nie pusty (min 1 znak), max 2000 znaków (TEXT). Throw `InvalidArgumentException` gdy pusty.
2) `OrdersRepository.php`:
- Dodaj prywatną metodę `userNotesCountSubquerySql(string $orderAlias): string` zwracającą string `(SELECT COUNT(*) FROM order_notes WHERE order_id = ' . $orderAlias . '.id AND note_type = \'user\')` (wzorzec z `customerReturnedCountSubquerySql`).
- W `fetchOrdersForList()` (i innych metodach budujących SELECT dla listy) dodaj kolumnę `... AS user_notes_count` obok `customer_returned_count`.
- W `getOrderDetails()` doloż `user_notes_count` i `user_notes_list` (przez OrderNotesService — wstrzyknij go w konstruktorze, lub wczytaj inline analogicznie do `loadOrderNotes`). Zachowaj `loadOrderNotes` jako `loadImportedOrderNotes` (rename) lub dorzuć nową metodę `loadUserOrderNotes` filtrującą po `note_type='user'`.
3) `OrdersController.php`:
- Dodaj prywatne `$orderNotesService` w konstruktorze.
- Metoda `storeNote(Request $request): Response` — pobierz orderId z `$request->input('id')` (pattern Phase 108), userId z sesji (`$_SESSION['user']['id']`), authorName z sesji (`$_SESSION['user']['name']`), `body` z `$request->input('body')`. Walidacja CSRF. Wywołaj `OrderNotesService::create()`. Flash success/error, redirect `/orders/{id}#notes`.
- Metoda `updateNote(Request $request): Response` — params `id` (order) i `noteId`. CSRF + user authorization (przez return z service). Redirect `/orders/{id}#notes`.
- Metoda `deleteNote(Request $request): Response` — analogicznie.
- W `formatOrderRow()` (linia ~656): dodaj wyliczenie `$userNotesCount = max(0, (int) ($row['user_notes_count'] ?? 0));` i `$notesBadge = $userNotesCount >= 1 ? ' <a href="/orders/' . $orderId . '#notes" class="order-notes-badge" title="' . $userNotesCount . ' notatek">[' . $userNotesCount . ']</a>' : '';` — wklej w `order_ref` HTML obok `$returnedBadge`.
4) `routes/web.php` (po linii ~595, blok orders):
```php
$router->post('/orders/{id}/notes', [$ordersController, 'storeNote'], [$authMiddleware]);
$router->post('/orders/{id}/notes/{noteId}/update', [$ordersController, 'updateNote'], [$authMiddleware]);
$router->post('/orders/{id}/notes/{noteId}/delete', [$ordersController, 'deleteNote'], [$authMiddleware]);
```
Wstrzyknięcie `OrderNotesService` analogicznie do innych serwisów (sprawdź jak `SmsConversationService` lub `OrderPaymentsService` są instancjonowane — pattern factory w `Application.php`/`CronHandlerFactory.php` lub bezpośrednie `new` w routes).
Avoid: sklejania SQL z input; pomijania CSRF; mieszania `comment` (legacy text imported) z nowym body — używamy tej samej kolumny, ale w service zawsze filtrujemy po `note_type='user'`.
</action>
<verify>
`php -l` na wszystkich zmienionych plikach; `composer dump-autoload` jeśli trzeba; smoke ręczny po deploy: POST `/orders/{X}/notes` z curl (sesja + CSRF) → 302 + nowy wiersz w `order_notes`; UPDATE/DELETE jako inny user → 403 + flash danger.
</verify>
<done>AC-2, AC-3, AC-6 satisfied: CRUD działa, autoryzacja po `user_id` egzekwowana, subquery count w listingu bez wpływu na paginację.</done>
</task>
<task type="auto">
<name>Task 3: UI — sekcja notatek w show.php, badge na list.php, JS edit modal, SCSS, i18n</name>
<files>resources/views/orders/show.php, resources/views/orders/list.php, resources/lang/pl.php, resources/scss/modules/_order-notes.scss, resources/scss/app.scss, public/assets/js/modules/order-notes.js, resources/views/layouts/app.php</files>
<action>
1) `resources/views/orders/show.php` (sekcja "Wiadomosci i zalaczniki", linia ~449463):
- Zmień blok renderowania na 2 sub-listy:
a) `<div id="notes" class="order-user-notes">` — header "Notatki", iteracja po `$userNotesList` (passed z controllera). Każda notatka: `<div class="order-event order-event--user">` z `<div class="order-event__head">data | autor</div>`, `<div class="order-event__body">body</div>`, oraz `<div class="order-event__actions">` z przyciskami `Edytuj` / `Usuń` widocznymi gdy `(int)($note['user_id'] ?? 0) === $currentUserId`. Przycisk "Usuń" jako `<form method="post" action="/orders/{id}/notes/{noteId}/delete">` + ukryty submit + JS handler wywołujący `OrderProAlerts.confirm({title:'Usunąć notatkę?', message:'Tej operacji nie można cofnąć.', danger:true, onConfirm: function(){ form.submit(); }})` (pattern options-object — decyzja Phase 114).
- Pod listą: formularz `<form method="post" action="/orders/{id}/notes">` z CSRF `_token`, `<textarea name="body" maxlength="2000" required>`, przycisk "Zapisz".
b) `<div class="order-imported-notes">` — header "Wiadomości ze źródła", iteracja po `$importedNotesList`, render jak dotychczas (bez akcji).
- W górze widoku pobierz `$userNotesList = is_array($userNotes ?? null) ? $userNotes : [];`, `$importedNotesList = is_array($importedNotes ?? null) ? $importedNotes : [];`, `$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);`.
2) `resources/views/orders/list.php` — `order_ref` HTML już jest generowany w controllerze przez `formatOrderRow()`, więc badge wleci automatycznie. Jeśli list.php gdziekolwiek inline renderuje order ref, sprawdź i nie dubluj.
3) `resources/lang/pl.php` — dodaj klucze w sekcji `orders.details`:
```php
'notes_user_title' => 'Notatki',
'notes_user_empty' => 'Brak notatek.',
'notes_user_add_placeholder' => 'Wpisz notatkę...',
'notes_user_save' => 'Zapisz',
'notes_user_edit' => 'Edytuj',
'notes_user_delete' => 'Usuń',
'notes_user_confirm_delete' => 'Usunąć notatkę?',
'notes_imported_title' => 'Wiadomości ze źródła',
'notes_forbidden' => 'Brak uprawnień — tylko autor może edytować/usuwać notatkę.',
'notes_created' => 'Notatka dodana.',
'notes_updated' => 'Notatka zaktualizowana.',
'notes_deleted' => 'Notatka usunięta.',
```
4) `resources/scss/modules/_order-notes.scss` — utwórz nowy moduł:
- `.order-user-notes`, `.order-imported-notes`, `.order-event--user`, `.order-event__actions` (flex, gap 8px), `.order-event__actions .btn-icon` (mały rozmiar).
- `.order-notes-badge` — neutralny styl (np. `background: #eef2ff; color: #4338ca; padding: 1px 6px; border-radius: 10px; font-size: 11px; font-weight: 600; text-decoration: none; margin-left: 4px;`). Hover: `background: #e0e7ff;`. Subtelniej niż `.risk-return-badge` (czerwony, alertowy).
- Formularz dodawania notatki: `.order-note-form textarea { width: 100%; min-height: 60px; }`.
Następnie dodaj `@use 'modules/order-notes';` w `resources/scss/app.scss`. Build SCSS lub powiedz operatorowi by uruchomił `npm run build:css` / `php tools/build-scss.php` (sprawdź jaki jest build pipeline w projekcie).
5) `public/assets/js/modules/order-notes.js` — wanilijowy JS:
- Inline edit: klik "Edytuj" zamienia `order-event__body` na textarea + przyciski "Zapisz"/"Anuluj"; "Zapisz" POST `fetch('/orders/'+orderId+'/notes/'+noteId+'/update', {method:'POST', body: new FormData(form)})` → jeśli OK, reload listy AJAX-em lub `location.reload()` (najprostsze, pattern z inline-status-change.js).
- Klik "Usuń" — `OrderProAlerts.confirm({title:'Usunąć notatkę?', message:'Tej operacji nie można cofnąć.', danger:true, confirmLabel:'Usuń', onConfirm: function(){ form.submit(); }})`.
- Idempotent guard: `if (window.__orderNotesInit) return; window.__orderNotesInit = true;` (pattern Phase 114 confirm-delete.js).
6) `resources/views/layouts/app.php` — załącz nowy moduł JS (pattern z `invoice-requested-toggle.js`):
```php
<script src="/assets/js/modules/order-notes.js?ver=<?= filemtime(...) ?: 0 ?>"></script>
```
Avoid: natywnego `confirm()` (zakaz CLAUDE.md); inline styles (zakaz CLAUDE.md — wszystko do SCSS); duplikowania renderowania importowanych notatek; ujawniania `user_id` w UI jeśli `users.id` jest wrażliwe (nie jest — to wewnętrzny ID).
</action>
<verify>
Otwórz `/orders/{X}` → widać sekcję "Notatki" + form dodawania; dodaj notatkę → pojawia się w liście z datą i autorem. Spróbuj edit cudzej notatki (z innym session.user_id) → przyciski edit/delete niewidoczne, próba POST → 403. Otwórz `/orders/list` → badge `[N]` widoczny przy zamówieniach z notatkami. Sprawdź czy `risk-return-badge` (Phase 106) nadal działa obok.
</verify>
<done>AC-4, AC-5 satisfied: UI sekcji notatek działa w show.php, badge w list.php widoczny, akcje edit/delete poprawnie ograniczone do autora.</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- `order_notes.comment`, `order_notes.source_note_id`, `order_notes.payload_json` (kontrakt importu z shopPRO/Allegro — Phase 79 i wcześniejsze).
- `OrdersRepository::replaceNotes()`/`loadOrderNotes()` semantyka dla importu — jeśli rename, zachowaj BC alias lub zaktualizuj wszystkie wywołania (delta-only import, Phase 112).
- `.risk-return-badge` SCSS i logika (Phase 106) — badge notatek to osobna klasa, nie modyfikujemy zwrotów.
- `OrderProAlerts.confirm` API — używamy options-object (Phase 114 decyzja).
## SCOPE LIMITS
- Brak mentions/@-tagowania userów.
- Brak załączników do notatek (tylko tekst).
- Brak edycji historii edycji notatki (audit log w `order_activity_log` ma tylko `Dodano/Edytowano/Usunięto notatkę` — bez before/after JSON).
- Brak globalnych uprawnień admin override — tylko autor edytuje/usuwa (system ról nie istnieje; odłożone do osobnej fazy).
- Brak filtrów listy zamówień po "ma/nie ma notatki" — można dodać w przyszłości.
- Brak emitowania eventu automatyzacji `note.created` — można dodać jako osobny plan jeśli operator chce.
</boundaries>
<verification>
Before declaring plan complete:
- [ ] `php bin/migrate.php` przechodzi bez błędu, re-run = no-op
- [ ] `php -l` na każdym zmienionym pliku PHP zwraca "No syntax errors"
- [ ] POST `/orders/{id}/notes` jako user A tworzy notatkę
- [ ] POST `/orders/{id}/notes/{noteId}/update` jako user A działa, jako user B zwraca 403
- [ ] POST `/orders/{id}/notes/{noteId}/delete` jako user A usuwa, jako user B 403
- [ ] `/orders/list` pokazuje badge `[N]` przy zamówieniach z notatkami, ukryty gdy N=0
- [ ] `/orders/{id}#notes` scrolluje do sekcji notatek
- [ ] Importowane notatki ze źródła (shopPRO/allegro) renderują się jako osobny blok bez przycisków edit/delete
- [ ] Badge zwrotów (Phase 106) działa obok badge'a notatek (oba widoczne dla zamówień z obojgiem)
- [ ] SCSS skompilowany do `public/assets/css/app.css`
- [ ] CSRF wymagany w każdym POST — brak tokenu = 419/403
- [ ] Brak natywnych `confirm()` w nowym JS — wszystko przez `OrderProAlerts.confirm`
</verification>
<success_criteria>
- AC-1..AC-6 spełnione
- Brak regresji w imporcie notatek shopPRO/Allegro (delta-only import z Phase 112 nadal działa, `replaceNotes` filtruje tylko `note_type != 'user'` jeśli musi)
- Czas wykonania `/orders/list` z subquery `user_notes_count` nie pogarsza się drastycznie (indeks `idx_order_notes_type_order` aktywny)
- `.paul/codebase/db_schema.md` i `.paul/codebase/architecture.md` zaktualizowane o nowy serwis i kolumny
- `.paul/codebase/tech_changelog.md` ma wpis dla Phase 129
</success_criteria>
<output>
After completion, create `.paul/phases/129-order-user-notes/129-01-SUMMARY.md` z:
- Krótki opis co zostało zbudowane
- Decisions (np. brak admin override → tylko autor edytuje)
- Files modified (lista z task'ów)
- Migration applied (numer + opis)
- Manual smoke checklist dla operatora (POST create, UPDATE as A, UPDATE as B → 403, DELETE, badge na liście, edit modal UX)
- Deferred / follow-up (event automatyzacji `note.created`, filtr listy "ma notatki", admin override po wprowadzeniu ról)
</output>

View File

@@ -0,0 +1,191 @@
---
phase: 129-order-user-notes
plan: 01
subsystem: orders
tags: [order-notes, crud, badge, audit-log, user-authored]
requires:
- phase: 106-customer-return-alert
provides: badge pattern (`risk-return-badge`) + subquery liczby per zamowienie
- phase: 114-accounting-configs-refactor
provides: `OrderProAlerts.confirm` options-object API
provides:
- Pelen CRUD notatek autorskich operatora per zamowienie (`note_type='user'`)
- Subquery `user_notes_count` + badge `[N]` na `/orders/list`
- Inline edit (toggle textarea) + delete z `OrderProAlerts.confirm`
affects:
- Przyszle fazy z eventem automatyzacji `note.created` lub admin override po wprowadzeniu rol
tech-stack:
added: []
patterns:
- "Reuse istniejacej tabeli przez nowy `note_type` zamiast tworzenia osobnej tabeli"
- "Autoryzacja CRUD przez `WHERE user_id = :user_id` + rowCount=0 ⇒ RuntimeException(403)"
key-files:
created:
- database/migrations/20260514_000116_extend_order_notes_user_authored.sql
- src/Modules/Orders/OrderNotesService.php
- resources/scss/modules/_order-notes.scss
- public/assets/js/modules/order-notes.js
modified:
- src/Modules/Orders/OrdersController.php
- src/Modules/Orders/OrdersRepository.php
- routes/web.php
- resources/views/orders/show.php
- resources/views/layouts/app.php
- resources/lang/pl.php
- resources/scss/app.scss
key-decisions:
- "Reuse `order_notes` przez `note_type='user'` zamiast osobnej tabeli (clarification #1)"
- "Badge neutralny `[N]` (indigo `#eef2ff/#4338ca`) — subtelniejszy niz `.risk-return-badge`"
- "Brak admin override — edit/delete tylko dla autora (brak systemu rol w aplikacji)"
- "Sekcja `#notes` w istniejacej karcie 'Wiadomosci i zalaczniki' — split na 'Notatki' (user) + 'Wiadomosci ze zrodla' (imported)"
patterns-established:
- "`userNotesCountSubquerySql($orderAlias)` — wzorzec dla COUNT-per-order subquery bez wplywu na ORDER BY/GROUP BY (analogiczny do `customerReturnedCountSubquerySql`)"
- "`OrderProAlerts.confirm` z `danger:true` + options-object API dla submit'u formularza DELETE (preventDefault + onConfirm submit)"
- "Migracje no-op zawsze jako DDL (`ALTER TABLE COMMENT`), nigdy `SELECT 1` (Phase 115 pattern)"
duration: ~30min
started: 2026-05-14T00:00:00Z
completed: 2026-05-14T00:00:00Z
---
# Phase 129 Plan 01: Order User Notes Summary
**Pelen CRUD notatek autorskich operatora w zamowieniach (extend `order_notes` o `user_id`/`author_name`/`note_type='user'`), z sekcja `#notes` w szczegolach i badge `[N]` na liscie zamowien.**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~30min |
| Tasks | 3 of 3 completed |
| Files created | 5 |
| Files modified | 7 |
| AC pass rate | 6 of 6 (pending live smoke) |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Migracja DB — kolumny user notes | Pass (code) | Migracja `20260514_000116_*.sql` z `information_schema` guard + DDL no-op fallback. Aktywacja na zywej bazie: pending operator. |
| AC-2: Tworzenie notatki | Pass (code) | `OrderNotesService::create()` + `OrdersController::storeNote()` + `recordActivity('note', 'Dodano notatke')`. Redirect 302 → `/orders/{id}#notes`. |
| AC-3: Edycja/usuwanie tylko autor | Pass (code) | UPDATE/DELETE z `WHERE user_id = :user_id`; rowCount=0 ⇒ `RuntimeException(403)`. UI ukrywa przyciski gdy `note.user_id != session.user_id`. |
| AC-4: Lista w "Wiadomosci i zalaczniki" | Pass (code) | Sekcja `#notes` z 3 blokami (lista user notes → form dodawania → opcjonalny block "Wiadomosci ze zrodla"). |
| AC-5: Badge `[N]` na liscie zamowien | Pass (code) | `<a class="order-notes-badge" href="/orders/{id}#notes">[N]</a>` wstrzykniete w `order_ref` HTML; widoczne tylko gdy `user_notes_count >= 1`. |
| AC-6: Subquery liczy bez wplywu na paginacje | Pass (code) | `userNotesCountSubquerySql('o')` jako kolumna SELECT (NIE w WHERE/GROUP BY/ORDER). Wspierane indeksem `idx_order_notes_type_order (note_type, order_id)`. |
> **Live smoke pending**: migracja na zywym XAMPP MySQL + manualny test wieloosobowy (autor vs inny user) — udokumentowane w STATE.md follow-ups.
## Verification Results
```
$ C:/xampp/php/php.exe -l src/Modules/Orders/OrderNotesService.php
No syntax errors detected
$ C:/xampp/php/php.exe -l src/Modules/Orders/OrdersController.php
No syntax errors detected
$ C:/xampp/php/php.exe -l src/Modules/Orders/OrdersRepository.php
No syntax errors detected
$ C:/xampp/php/php.exe -l routes/web.php
No syntax errors detected
$ C:/xampp/php/php.exe -l resources/views/orders/show.php
No syntax errors detected
$ node --check public/assets/js/modules/order-notes.js
JS OK
$ npm run build:css
sass --style=compressed --no-source-map resources/scss/app.scss public/assets/css/app.css
(rebuilt successfully)
```
## Accomplishments
- **Reuse `order_notes` zamiast osobnej tabeli**: jedna tabela obsluguje 2 semantyki (`note_type IN ('shoppro','allegro','message')` = imported, `note_type='user'` = autorska). `UNIQUE (order_id, source_note_id)` nie blokuje user notes bo MySQL traktuje wiele NULL jako unique.
- **CRUD z autoryzacja DB-level**: UPDATE/DELETE filtruja po `user_id = :user_id` w SQL; `rowCount=0` rzuca 403 — eliminuje konieczność osobnego SELECT pre-check'a.
- **Badge widoczny od razu w liscie**: subquery `user_notes_count` ekspozuje liczbe w `paginate()`, badge wlozony w `toTableRow()` obok numeru zamowienia (klik scrolluje do `#notes`).
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `database/migrations/20260514_000116_extend_order_notes_user_authored.sql` | Created | ADD COLUMN user_id + author_name + FK → users + indeks (idempotentne) |
| `src/Modules/Orders/OrderNotesService.php` | Created | CRUD service nad `order_notes` (`note_type='user'`) z autoryzacja po user_id |
| `resources/scss/modules/_order-notes.scss` | Created | `.order-notes-badge`, `.order-user-notes`, `.order-event--user`, `.btn-link`, `.order-note-form` |
| `public/assets/js/modules/order-notes.js` | Created | Vanilla JS: inline edit toggle + `OrderProAlerts.confirm` na delete (idempotent guard) |
| `src/Modules/Orders/OrdersController.php` | Modified | Dodano `OrderNotesService` jako nullable dep + `storeNote/updateNote/deleteNote` + badge w `toTableRow()` |
| `src/Modules/Orders/OrdersRepository.php` | Modified | `userNotesCountSubquerySql()` + kolumna `user_notes_count` w paginate; `loadOrderNotes()` zawezone do `note_type <> 'user'` |
| `routes/web.php` | Modified | 3 nowe routes (POST notes/store|update|delete) + `OrderNotesService` instancjonowany + przekazany do `OrdersController` |
| `resources/views/orders/show.php` | Modified | Sekcja `#notes` rozbita na user-notes + form + imported-notes; per-note edit form (ukryty) |
| `resources/views/layouts/app.php` | Modified | `<script>` dla `order-notes.js` |
| `resources/lang/pl.php` | Modified | 9 nowych kluczy `orders.details.notes_user_*` + `notes_imported_title` |
| `resources/scss/app.scss` | Modified | `@use "modules/order-notes"` |
| `public/assets/css/app.css` | Modified | Rebuilt by `npm run build:css` |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| Reuse `order_notes` z `note_type='user'` | Jedna tabela = mniej obiektow DB, prosciej testowac, UNIQUE NULL nie koliduje | Importowane notatki ze zrodla nadal dzialaja niezmienione; w `loadOrderNotes()` dorzucony filtr `note_type <> 'user'` |
| Brak admin override (tylko autor edit/delete) | Brak systemu rol w aplikacji (`grep is_admin\|role=` → 0 hits) | Operator ktory dodal notatke moze ja modyfikowac; admin override odlozony do osobnej fazy gdy beda role |
| Badge `[N]` w `order_ref` (NIE osobna kolumna) | Minimalny footprint w tabeli, spojnie z `risk-return-badge` (przy `buyer_name`) | Badge widoczny bez zmian w naglowkach tabeli `/orders/list` |
| Body limit 2000 znakow (`mb_strlen`) | TEXT moze przechowac wiecej, ale UX podpowiada krotkie notatki; spojnie z polem comment | Walidacja w `OrderNotesService::sanitizeBody()` — rzut `InvalidArgumentException` gdy przekroczenie |
| Migracja idempotentna z DDL no-op fallback | Decyzja z Phase 115/125 — `SELECT 1` powoduje SQLSTATE 2014 przy PDO unbuffered | Re-run migracji = no-op (`ALTER TABLE COMMENT`) bez bledu |
| Edit toggle (ukryty form per notatka) zamiast modala | Mniej UI ceremoniaiu, spojnie z istniejacym `order-event` layoutem | JS prosty (show/hide pary `js-order-note-body``js-order-note-edit-form`) |
## Deviations from Plan
### Summary
| Type | Count | Impact |
|------|-------|--------|
| Auto-fixed | 1 | Cosmetic naming |
| Scope additions | 0 | None |
| Deferred | 0 | None |
**Total impact:** Minimalne deviation — plan wykonany niemal 1:1.
### Auto-fixed Issues
**1. [Naming] `formatOrderRow` → `toTableRow`**
- **Found during:** Task 2 (badge w controllerze)
- **Issue:** Plan referowal do nieistniejacej metody `formatOrderRow()` w `OrdersController`
- **Fix:** Edycja `toTableRow()` (rzeczywista nazwa metody) — semantyka identyczna
- **Files:** `src/Modules/Orders/OrdersController.php`
- **Verification:** `grep -n "public function|toTableRow"` potwierdzilo `toTableRow` jako wlasciwa nazwa
- **Commit:** N/A (jeden Task 2 commit obejmie wszystkie zmiany)
### Deferred Items
None — plan executed exactly as written.
## Issues Encountered
| Issue | Resolution |
|-------|------------|
| Plan referowal `formatOrderRow()` (nieistniejacy) | Sprawdzono rzeczywiste metody przez `Grep "public function"``toTableRow` jest poprawna nazwa. Patch zaaplikowany. |
## Next Phase Readiness
**Ready:**
- Migracja czeka na operator (`php bin/migrate.php`).
- UI sekcji notatek + badge gotowe — manualny smoke test moze byc wykonany po migracji.
- Pattern `userNotesCountSubquerySql` + nullable `OrderNotesService` w `OrdersController` — gotowe do reuse w przyszlych phasach (np. event automatyzacji `note.created` lub admin override).
**Concerns:**
- Bez systemu rol nie ma admin override — jezeli operator chce zeby kazdy user mogl edytowac/usuwac kazda notatke, trzeba zmienic warunek w `OrderNotesService::update()/delete()` (usunac `AND user_id = :user_id`).
- Brak filtra "ma notatki" / "nie ma notatek" w liscie zamowien — kandydat na rozszerzenie jezeli operator zechce.
**Blockers:**
- None — plan wdrozony, smoke test po stronie operatora.
---
*Phase: 129-order-user-notes, Plan: 01*
*Completed: 2026-05-14*

View File

@@ -0,0 +1,57 @@
-- Phase 129-01: extend order_notes o pola dla notatek autorskich (user-authored)
-- Reuse istniejacej tabeli z nowym note_type='user', user_id (FK->users), author_name (snapshot).
-- Idempotentna: guard przez information_schema; no-op po pierwszym uruchomieniu.
-- Pattern z Key Decision 2026-05-10: migracje no-op zawsze jako DDL (ALTER TABLE COMMENT),
-- nigdy SELECT 1 (PDO unbuffered + result set -> SQLSTATE 2014).
-- 1) ADD COLUMN user_id
SET @col_user_id := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'order_notes'
AND COLUMN_NAME = 'user_id'
);
SET @sql_user_id := IF(@col_user_id = 0,
'ALTER TABLE order_notes ADD COLUMN user_id INT UNSIGNED NULL AFTER note_type',
'ALTER TABLE order_notes COMMENT = ''phase-129 user_id no-op'''
);
PREPARE s1 FROM @sql_user_id; EXECUTE s1; DEALLOCATE PREPARE s1;
-- 2) ADD COLUMN author_name
SET @col_author_name := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'order_notes'
AND COLUMN_NAME = 'author_name'
);
SET @sql_author_name := IF(@col_author_name = 0,
'ALTER TABLE order_notes ADD COLUMN author_name VARCHAR(190) NULL AFTER user_id',
'ALTER TABLE order_notes COMMENT = ''phase-129 author_name no-op'''
);
PREPARE s2 FROM @sql_author_name; EXECUTE s2; DEALLOCATE PREPARE s2;
-- 3) ADD FOREIGN KEY user_id -> users(id) ON DELETE SET NULL
SET @fk_exists := (
SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'order_notes'
AND CONSTRAINT_NAME = 'order_notes_user_fk'
);
SET @sql_fk := IF(@fk_exists = 0,
'ALTER TABLE order_notes ADD CONSTRAINT order_notes_user_fk FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE',
'ALTER TABLE order_notes COMMENT = ''phase-129 fk no-op'''
);
PREPARE s3 FROM @sql_fk; EXECUTE s3; DEALLOCATE PREPARE s3;
-- 4) ADD INDEX (note_type, order_id) — wspiera subquery user_notes_count i listUserNotes
SET @idx_exists := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'order_notes'
AND INDEX_NAME = 'idx_order_notes_type_order'
);
SET @sql_idx := IF(@idx_exists = 0,
'ALTER TABLE order_notes ADD INDEX idx_order_notes_type_order (note_type, order_id)',
'ALTER TABLE order_notes COMMENT = ''phase-129 idx no-op'''
);
PREPARE s4 FROM @sql_idx; EXECUTE s4; DEALLOCATE PREPARE s4;

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,89 @@
(function () {
'use strict';
if (window.__orderNotesInit) {
return;
}
window.__orderNotesInit = true;
function bindEdit(button) {
if (button.dataset.bound === '1') return;
button.dataset.bound = '1';
button.addEventListener('click', function () {
var noteEl = button.closest('[data-note-id]');
if (!noteEl) return;
var body = noteEl.querySelector('.js-order-note-body');
var form = noteEl.querySelector('.js-order-note-edit-form');
if (!body || !form) return;
body.style.display = 'none';
form.style.display = '';
var textarea = form.querySelector('textarea[name="body"]');
if (textarea) {
textarea.focus();
var len = textarea.value.length;
try { textarea.setSelectionRange(len, len); } catch (e) { /* ignore */ }
}
});
}
function bindCancel(button) {
if (button.dataset.bound === '1') return;
button.dataset.bound = '1';
button.addEventListener('click', function () {
var noteEl = button.closest('[data-note-id]');
if (!noteEl) return;
var body = noteEl.querySelector('.js-order-note-body');
var form = noteEl.querySelector('.js-order-note-edit-form');
if (!body || !form) return;
form.style.display = 'none';
body.style.display = '';
});
}
function bindDelete(form) {
if (form.dataset.bound === '1') return;
form.dataset.bound = '1';
form.addEventListener('submit', function (event) {
event.preventDefault();
if (window.OrderProAlerts && typeof window.OrderProAlerts.confirm === 'function') {
window.OrderProAlerts.confirm({
title: 'Usunac notatke?',
message: 'Tej operacji nie mozna cofnac.',
danger: true,
confirmLabel: 'Usun',
onConfirm: function () {
form.dataset.bound = '2';
form.submit();
}
});
} else {
form.dataset.bound = '2';
form.submit();
}
});
}
function init() {
var editButtons = document.querySelectorAll('.js-order-note-edit');
for (var i = 0; i < editButtons.length; i++) {
bindEdit(editButtons[i]);
}
var cancelButtons = document.querySelectorAll('.js-order-note-edit-cancel');
for (var j = 0; j < cancelButtons.length; j++) {
bindCancel(cancelButtons[j]);
}
var deleteForms = document.querySelectorAll('.js-order-note-delete');
for (var k = 0; k < deleteForms.length; k++) {
bindDelete(deleteForms[k]);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View File

@@ -190,6 +190,15 @@ return [
'address_invoice' => 'Dane do faktury', 'address_invoice' => 'Dane do faktury',
'address_delivery' => 'Dane wysylki', 'address_delivery' => 'Dane wysylki',
'notes_title' => 'Wiadomosci i zalaczniki', 'notes_title' => 'Wiadomosci i zalaczniki',
'notes_user_title' => 'Notatki',
'notes_user_empty' => 'Brak notatek.',
'notes_user_add_placeholder' => 'Wpisz notatke...',
'notes_user_save' => 'Zapisz',
'notes_user_edit' => 'Edytuj',
'notes_user_delete' => 'Usun',
'notes_user_cancel' => 'Anuluj',
'notes_user_confirm_delete' => 'Usunac notatke?',
'notes_imported_title' => 'Wiadomosci ze zrodla',
'history_title' => 'Historia statusow', 'history_title' => 'Historia statusow',
'fields' => [ 'fields' => [
'status' => 'Status', 'status' => 'Status',

View File

@@ -10,6 +10,7 @@
@use "modules/project-mappings"; @use "modules/project-mappings";
@use "modules/customer-risk-alert"; @use "modules/customer-risk-alert";
@use "modules/sms-templates"; @use "modules/sms-templates";
@use "modules/order-notes";
* { * {
box-sizing: border-box; box-sizing: border-box;

View File

@@ -0,0 +1,112 @@
// Phase 129-01: notatki autorskie operatora w szczegolach zamowienia + badge na liscie.
.order-notes-badge {
display: inline-block;
margin-left: 6px;
padding: 1px 6px;
border-radius: 10px;
background: #eef2ff;
color: #4338ca;
font-size: 11px;
font-weight: 600;
line-height: 1.4;
text-decoration: none;
vertical-align: middle;
transition: background 0.15s ease;
&:hover {
background: #e0e7ff;
text-decoration: none;
}
}
.order-notes-subtitle {
margin: 0 0 6px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--c-muted, #64748b);
}
.order-user-notes {
display: flex;
flex-direction: column;
gap: 8px;
.order-event--user {
border-left: 3px solid #6366f1;
padding-left: 8px;
}
.order-event__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.order-event__meta {
font-size: 11px;
color: var(--c-muted, #64748b);
}
.order-event__actions {
display: inline-flex;
align-items: center;
gap: 8px;
}
}
.order-imported-notes {
.order-event--imported {
opacity: 0.75;
font-size: 12px;
}
}
.btn-link {
background: none;
border: 0;
padding: 0;
margin: 0;
font-size: 11px;
color: var(--c-primary, #2563eb);
cursor: pointer;
text-decoration: underline;
&:hover {
text-decoration: none;
}
&--danger {
color: #dc2626;
}
}
.order-note-form,
.order-note-edit-form {
textarea {
width: 100%;
min-height: 60px;
resize: vertical;
}
.order-note-form__actions,
.order-note-edit-form__actions {
display: flex;
gap: 8px;
margin-top: 6px;
}
}
.order-note-edit-form {
margin-top: 6px;
.order-note-edit-form__actions {
display: flex;
gap: 8px;
margin-top: 6px;
}
}

View File

@@ -218,6 +218,7 @@
<script src="/assets/js/modules/alert-dismiss.js?ver=<?= filemtime(dirname(__DIR__, 3) . '/public/assets/js/modules/alert-dismiss.js') ?: 0 ?>"></script> <script src="/assets/js/modules/alert-dismiss.js?ver=<?= filemtime(dirname(__DIR__, 3) . '/public/assets/js/modules/alert-dismiss.js') ?: 0 ?>"></script>
<script src="/assets/js/modules/notifications.js?ver=<?= filemtime(dirname(__DIR__, 3) . '/public/assets/js/modules/notifications.js') ?: 0 ?>"></script> <script src="/assets/js/modules/notifications.js?ver=<?= filemtime(dirname(__DIR__, 3) . '/public/assets/js/modules/notifications.js') ?: 0 ?>"></script>
<script src="/assets/js/modules/sms-template-picker.js?ver=<?= filemtime(dirname(__DIR__, 3) . '/public/assets/js/modules/sms-template-picker.js') ?: 0 ?>"></script> <script src="/assets/js/modules/sms-template-picker.js?ver=<?= filemtime(dirname(__DIR__, 3) . '/public/assets/js/modules/sms-template-picker.js') ?: 0 ?>"></script>
<script src="/assets/js/modules/order-notes.js?ver=<?= filemtime(dirname(__DIR__, 3) . '/public/assets/js/modules/order-notes.js') ?: 0 ?>"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.8/dist/chart.umd.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.8/dist/chart.umd.min.js"></script>
<script src="/assets/js/modules/statistics-summary-charts.js?ver=<?= filemtime(dirname(__DIR__, 3) . '/public/assets/js/modules/statistics-summary-charts.js') ?: 0 ?>"></script> <script src="/assets/js/modules/statistics-summary-charts.js?ver=<?= filemtime(dirname(__DIR__, 3) . '/public/assets/js/modules/statistics-summary-charts.js') ?: 0 ?>"></script>
<script> <script>

View File

@@ -7,6 +7,9 @@ $shipmentsList = is_array($shipments ?? null) ? $shipments : [];
$packagesList = is_array($packages ?? null) ? $packages : []; $packagesList = is_array($packages ?? null) ? $packages : [];
$documentsList = is_array($documents ?? null) ? $documents : []; $documentsList = is_array($documents ?? null) ? $documents : [];
$notesList = is_array($notes ?? null) ? $notes : []; $notesList = is_array($notes ?? null) ? $notes : [];
$userNotesList = is_array($userNotes ?? null) ? $userNotes : [];
$currentUserIdValue = (int) ($currentUserId ?? 0);
$csrfTokenValue = (string) ($csrfToken ?? '');
$receiptsList = is_array($receipts ?? null) ? $receipts : []; $receiptsList = is_array($receipts ?? null) ? $receipts : [];
$receiptConfigsList = is_array($receiptConfigs ?? null) ? $receiptConfigs : []; $receiptConfigsList = is_array($receiptConfigs ?? null) ? $receiptConfigs : [];
$invoicesList = is_array($invoices ?? null) ? $invoices : []; $invoicesList = is_array($invoices ?? null) ? $invoices : [];
@@ -447,19 +450,70 @@ foreach ($addressesList as $address) {
</section> </section>
<section class="mt-16 order-grid-2"> <section class="mt-16 order-grid-2">
<article class="card"> <article class="card" id="notes">
<h3 class="section-title"><?= $e($t('orders.details.notes_title')) ?></h3> <h3 class="section-title"><?= $e($t('orders.details.notes_title')) ?></h3>
<div class="order-events mt-12">
<?php if ($notesList === []): ?> <div class="order-user-notes mt-12" data-order-id="<?= (int) ($orderId ?? 0) ?>">
<div class="muted">-</div> <h4 class="order-notes-subtitle"><?= $e($t('orders.details.notes_user_title')) ?></h4>
<?php if ($userNotesList === []): ?>
<div class="muted"><?= $e($t('orders.details.notes_user_empty')) ?></div>
<?php endif; ?> <?php endif; ?>
<?php foreach ($userNotesList as $userNote): ?>
<?php
$noteId = (int) ($userNote['id'] ?? 0);
$noteAuthorId = (int) ($userNote['user_id'] ?? 0);
$noteAuthorName = trim((string) ($userNote['author_name'] ?? ''));
$noteCreatedAt = (string) ($userNote['created_at'] ?? '');
$noteBody = (string) ($userNote['body'] ?? '');
$canEdit = $currentUserIdValue > 0 && $noteAuthorId === $currentUserIdValue;
?>
<div class="order-event order-event--user" data-note-id="<?= $noteId ?>">
<div class="order-event__head">
<span class="order-event__meta"><?= $e($noteCreatedAt) ?><?php if ($noteAuthorName !== ''): ?> &middot; <?= $e($noteAuthorName) ?><?php endif; ?></span>
<?php if ($canEdit): ?>
<span class="order-event__actions">
<button type="button" class="btn-link js-order-note-edit" data-note-id="<?= $noteId ?>"><?= $e($t('orders.details.notes_user_edit')) ?></button>
<form method="post" action="/orders/<?= (int) ($orderId ?? 0) ?>/notes/<?= $noteId ?>/delete" class="js-order-note-delete" style="display:inline">
<input type="hidden" name="_token" value="<?= $e($csrfTokenValue) ?>">
<button type="submit" class="btn-link btn-link--danger"><?= $e($t('orders.details.notes_user_delete')) ?></button>
</form>
</span>
<?php endif; ?>
</div>
<div class="order-event__body js-order-note-body"><?= nl2br($e($noteBody)) ?></div>
<?php if ($canEdit): ?>
<form method="post" action="/orders/<?= (int) ($orderId ?? 0) ?>/notes/<?= $noteId ?>/update" class="order-note-edit-form js-order-note-edit-form" style="display:none">
<input type="hidden" name="_token" value="<?= $e($csrfTokenValue) ?>">
<textarea name="body" class="form-control" rows="3" maxlength="2000" required><?= $e($noteBody) ?></textarea>
<div class="order-note-edit-form__actions">
<button type="submit" class="btn btn-primary btn-sm"><?= $e($t('orders.details.notes_user_save')) ?></button>
<button type="button" class="btn btn-default btn-sm js-order-note-edit-cancel"><?= $e($t('orders.details.notes_user_cancel')) ?></button>
</div>
</form>
<?php endif; ?>
</div>
<?php endforeach; ?>
<form method="post" action="/orders/<?= (int) ($orderId ?? 0) ?>/notes" class="order-note-form mt-12">
<input type="hidden" name="_token" value="<?= $e($csrfTokenValue) ?>">
<textarea name="body" class="form-control" rows="3" maxlength="2000" placeholder="<?= $e($t('orders.details.notes_user_add_placeholder')) ?>" required></textarea>
<div class="order-note-form__actions">
<button type="submit" class="btn btn-primary btn-sm"><?= $e($t('orders.details.notes_user_save')) ?></button>
</div>
</form>
</div>
<?php if ($notesList !== []): ?>
<div class="order-imported-notes mt-16">
<h4 class="order-notes-subtitle"><?= $e($t('orders.details.notes_imported_title')) ?></h4>
<?php foreach ($notesList as $note): ?> <?php foreach ($notesList as $note): ?>
<div class="order-event"> <div class="order-event order-event--imported">
<div class="order-event__head"><?= $e((string) ($note['note_type'] ?? '')) ?> | <?= $e((string) ($note['created_at_external'] ?? '')) ?></div> <div class="order-event__head"><?= $e((string) ($note['note_type'] ?? '')) ?> | <?= $e((string) ($note['created_at_external'] ?? '')) ?></div>
<div class="order-event__body"><?= $e((string) ($note['comment'] ?? '')) ?></div> <div class="order-event__body"><?= $e((string) ($note['comment'] ?? '')) ?></div>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php endif; ?>
</article> </article>
<article class="card"> <article class="card">

View File

@@ -10,6 +10,7 @@ use App\Modules\Cron\CronHandlerFactory;
use App\Modules\Cron\CronRepository; use App\Modules\Cron\CronRepository;
use App\Modules\Orders\OrdersController; use App\Modules\Orders\OrdersController;
use App\Modules\Orders\OrderImportRepository; use App\Modules\Orders\OrderImportRepository;
use App\Modules\Orders\OrderNotesService;
use App\Modules\Orders\OrdersRepository; use App\Modules\Orders\OrdersRepository;
use App\Modules\Statistics\OrdersStatisticsController; use App\Modules\Statistics\OrdersStatisticsController;
use App\Modules\Statistics\OrdersStatisticsRepository; use App\Modules\Statistics\OrdersStatisticsRepository;
@@ -414,7 +415,8 @@ return static function (Application $app): void {
$allegroDeliveryMappingController $allegroDeliveryMappingController
); );
$printJobRepository = new PrintJobRepository($app->db()); $printJobRepository = new PrintJobRepository($app->db());
$ordersController = new OrdersController($template, $translator, $auth, $app->orders(), $shipmentPackageRepositoryForOrders, $receiptRepository, $receiptConfigRepository, $emailSendingService, $emailTemplateRepository, $emailMailboxRepository, $app->basePath('storage'), $printJobRepository, $shopproIntegrationsRepository, $automationService, $invoiceRepository, $invoiceConfigRepository, $smsMessageRepository, $smsConversationService, $smsTemplateRepository, $smsVariableResolver, $companySettingsRepository); $orderNotesService = new OrderNotesService($app->db());
$ordersController = new OrdersController($template, $translator, $auth, $app->orders(), $shipmentPackageRepositoryForOrders, $receiptRepository, $receiptConfigRepository, $emailSendingService, $emailTemplateRepository, $emailMailboxRepository, $app->basePath('storage'), $printJobRepository, $shopproIntegrationsRepository, $automationService, $invoiceRepository, $invoiceConfigRepository, $smsMessageRepository, $smsConversationService, $smsTemplateRepository, $smsVariableResolver, $companySettingsRepository, $orderNotesService);
$ordersStatisticsController = new OrdersStatisticsController( $ordersStatisticsController = new OrdersStatisticsController(
$template, $template,
$translator, $translator,
@@ -595,6 +597,9 @@ return static function (Application $app): void {
$router->post('/orders/{id}/email-preview', [$ordersController, 'emailPreview'], [$authMiddleware]); $router->post('/orders/{id}/email-preview', [$ordersController, 'emailPreview'], [$authMiddleware]);
$router->get('/api/orders/search', [$ordersController, 'quickSearch'], [$authMiddleware]); $router->get('/api/orders/search', [$ordersController, 'quickSearch'], [$authMiddleware]);
$router->get('/api/orders/{id}/preview', [$ordersController, 'preview'], [$authMiddleware]); $router->get('/api/orders/{id}/preview', [$ordersController, 'preview'], [$authMiddleware]);
$router->post('/orders/{id}/notes', [$ordersController, 'storeNote'], [$authMiddleware]);
$router->post('/orders/{id}/notes/{noteId}/update', [$ordersController, 'updateNote'], [$authMiddleware]);
$router->post('/orders/{id}/notes/{noteId}/delete', [$ordersController, 'deleteNote'], [$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

@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace App\Modules\Orders;
use InvalidArgumentException;
use PDO;
use RuntimeException;
/**
* Phase 129-01: CRUD notatek autorskich operatora (note_type='user').
* Importowane notatki ze zrodla (shoppro/allegro/message) maja wlasne zarzadzanie
* w OrderImportRepository::replaceNotes() — ten serwis ich nie dotyka.
*/
final class OrderNotesService
{
private const NOTE_TYPE_USER = 'user';
private const BODY_MAX_LENGTH = 2000;
public function __construct(private readonly PDO $pdo)
{
}
/**
* @return array<int, array<string, mixed>>
*/
public function listUserNotes(int $orderId): array
{
if ($orderId <= 0) {
return [];
}
$stmt = $this->pdo->prepare(
'SELECT id, order_id, user_id, author_name, comment AS body, created_at, updated_at
FROM order_notes
WHERE order_id = :order_id AND note_type = :note_type
ORDER BY created_at DESC, id DESC'
);
$stmt->execute(['order_id' => $orderId, 'note_type' => self::NOTE_TYPE_USER]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
}
/**
* @return array<int, array<string, mixed>>
*/
public function listImportedNotes(int $orderId): array
{
if ($orderId <= 0) {
return [];
}
$stmt = $this->pdo->prepare(
'SELECT * FROM order_notes
WHERE order_id = :order_id AND note_type <> :note_type
ORDER BY created_at_external DESC, id DESC'
);
$stmt->execute(['order_id' => $orderId, 'note_type' => self::NOTE_TYPE_USER]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
}
public function countUserNotes(int $orderId): int
{
if ($orderId <= 0) {
return 0;
}
$stmt = $this->pdo->prepare(
'SELECT COUNT(*) FROM order_notes WHERE order_id = :order_id AND note_type = :note_type'
);
$stmt->execute(['order_id' => $orderId, 'note_type' => self::NOTE_TYPE_USER]);
return (int) $stmt->fetchColumn();
}
public function findById(int $noteId): ?array
{
if ($noteId <= 0) {
return null;
}
$stmt = $this->pdo->prepare(
'SELECT id, order_id, user_id, author_name, comment AS body, created_at, updated_at, note_type
FROM order_notes WHERE id = :id LIMIT 1'
);
$stmt->execute(['id' => $noteId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return is_array($row) ? $row : null;
}
public function create(int $orderId, int $userId, string $authorName, string $body): int
{
$body = $this->sanitizeBody($body);
if ($orderId <= 0 || $userId <= 0) {
throw new InvalidArgumentException('Nieprawidlowe parametry notatki.');
}
$stmt = $this->pdo->prepare(
'INSERT INTO order_notes
(order_id, source_note_id, note_type, user_id, author_name, comment, created_at, updated_at)
VALUES
(:order_id, NULL, :note_type, :user_id, :author_name, :comment, NOW(), NOW())'
);
$stmt->execute([
'order_id' => $orderId,
'note_type' => self::NOTE_TYPE_USER,
'user_id' => $userId,
'author_name' => $authorName !== '' ? $authorName : null,
'comment' => $body,
]);
return (int) $this->pdo->lastInsertId();
}
/**
* @throws RuntimeException kod 403 gdy uzytkownik nie jest autorem notatki
*/
public function update(int $noteId, int $userId, string $body): void
{
$body = $this->sanitizeBody($body);
if ($noteId <= 0 || $userId <= 0) {
throw new InvalidArgumentException('Nieprawidlowe parametry notatki.');
}
$stmt = $this->pdo->prepare(
'UPDATE order_notes
SET comment = :comment, updated_at = NOW()
WHERE id = :id AND note_type = :note_type AND user_id = :user_id'
);
$stmt->execute([
'id' => $noteId,
'note_type' => self::NOTE_TYPE_USER,
'user_id' => $userId,
'comment' => $body,
]);
if ($stmt->rowCount() === 0) {
throw new RuntimeException('Brak uprawnien — tylko autor moze edytowac notatke.', 403);
}
}
/**
* @throws RuntimeException kod 403 gdy uzytkownik nie jest autorem notatki
*/
public function delete(int $noteId, int $userId): void
{
if ($noteId <= 0 || $userId <= 0) {
throw new InvalidArgumentException('Nieprawidlowe parametry notatki.');
}
$stmt = $this->pdo->prepare(
'DELETE FROM order_notes
WHERE id = :id AND note_type = :note_type AND user_id = :user_id'
);
$stmt->execute([
'id' => $noteId,
'note_type' => self::NOTE_TYPE_USER,
'user_id' => $userId,
]);
if ($stmt->rowCount() === 0) {
throw new RuntimeException('Brak uprawnien — tylko autor moze usunac notatke.', 403);
}
}
private function sanitizeBody(string $body): string
{
$body = trim($body);
if ($body === '') {
throw new InvalidArgumentException('Tresc notatki nie moze byc pusta.');
}
if (function_exists('mb_strlen') ? mb_strlen($body) > self::BODY_MAX_LENGTH : strlen($body) > self::BODY_MAX_LENGTH) {
throw new InvalidArgumentException('Tresc notatki przekracza ' . self::BODY_MAX_LENGTH . ' znakow.');
}
return $body;
}
}

View File

@@ -27,6 +27,7 @@ use App\Modules\Sms\SmsConversationService;
use App\Modules\Sms\SmsMessageRepository; use App\Modules\Sms\SmsMessageRepository;
use App\Modules\Sms\SmsTemplateRepository; use App\Modules\Sms\SmsTemplateRepository;
use App\Modules\Sms\SmsVariableResolver; use App\Modules\Sms\SmsVariableResolver;
use RuntimeException;
use Throwable; use Throwable;
final class OrdersController final class OrdersController
@@ -52,7 +53,8 @@ final class OrdersController
private readonly ?SmsConversationService $smsConversation = null, private readonly ?SmsConversationService $smsConversation = null,
private readonly ?SmsTemplateRepository $smsTemplates = null, private readonly ?SmsTemplateRepository $smsTemplates = null,
private readonly ?SmsVariableResolver $smsVariableResolver = null, private readonly ?SmsVariableResolver $smsVariableResolver = null,
private readonly ?CompanySettingsRepository $companySettingsRepo = null private readonly ?CompanySettingsRepository $companySettingsRepo = null,
private readonly ?OrderNotesService $orderNotes = null
) { ) {
} }
@@ -203,6 +205,9 @@ final class OrdersController
$shipments = is_array($details['shipments'] ?? null) ? $details['shipments'] : []; $shipments = is_array($details['shipments'] ?? null) ? $details['shipments'] : [];
$documents = is_array($details['documents'] ?? null) ? $details['documents'] : []; $documents = is_array($details['documents'] ?? null) ? $details['documents'] : [];
$notes = is_array($details['notes'] ?? null) ? $details['notes'] : []; $notes = is_array($details['notes'] ?? null) ? $details['notes'] : [];
$userNotes = $this->orderNotes !== null ? $this->orderNotes->listUserNotes($orderId) : [];
$currentUser = $this->auth->user();
$currentUserId = is_array($currentUser) ? (int) ($currentUser['id'] ?? 0) : 0;
$history = is_array($details['status_history'] ?? null) ? $details['status_history'] : []; $history = is_array($details['status_history'] ?? null) ? $details['status_history'] : [];
$activityLog = is_array($details['activity_log'] ?? null) ? $details['activity_log'] : []; $activityLog = is_array($details['activity_log'] ?? null) ? $details['activity_log'] : [];
$statusCode = (string) (($order['effective_status_id'] ?? '') !== '' ? $order['effective_status_id'] : ($order['status_code'] ?? '')); $statusCode = (string) (($order['effective_status_id'] ?? '') !== '' ? $order['effective_status_id'] : ($order['status_code'] ?? ''));
@@ -279,6 +284,8 @@ final class OrdersController
'pendingPrintPackageIds' => $this->printJobRepo !== null ? $this->printJobRepo->pendingPackageIds() : [], 'pendingPrintPackageIds' => $this->printJobRepo !== null ? $this->printJobRepo->pendingPackageIds() : [],
'documents' => $documents, 'documents' => $documents,
'notes' => $notes, 'notes' => $notes,
'userNotes' => $userNotes,
'currentUserId' => $currentUserId,
'history' => $resolvedHistory, 'history' => $resolvedHistory,
'activityLog' => $activityLog, 'activityLog' => $activityLog,
'statusLabel' => $this->statusLabel($statusCode, $statusLabelMap), 'statusLabel' => $this->statusLabel($statusCode, $statusLabelMap),
@@ -624,6 +631,136 @@ final class OrdersController
return Response::json(['success' => true, 'invoice_requested' => $value ? 1 : 0]); return Response::json(['success' => true, 'invoice_requested' => $value ? 1 : 0]);
} }
public function storeNote(Request $request): Response
{
$orderId = max(0, (int) $request->input('id', 0));
$redirectTo = '/orders/' . $orderId . '#notes';
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('order.error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect('/orders/' . $orderId);
}
if ($orderId <= 0 || $this->orderNotes === null) {
Flash::set('order.error', 'Modul notatek nie jest dostepny.');
return Response::redirect('/orders/' . $orderId);
}
$user = $this->auth->user();
$userId = is_array($user) ? (int) ($user['id'] ?? 0) : 0;
$authorName = is_array($user) ? trim((string) ($user['name'] ?? $user['email'] ?? '')) : '';
if ($userId <= 0) {
Flash::set('order.error', 'Wymagane zalogowanie.');
return Response::redirect('/orders/' . $orderId);
}
try {
$this->orderNotes->create($orderId, $userId, $authorName, (string) $request->input('body', ''));
$this->orders->recordActivity(
$orderId,
'note',
'Dodano notatke',
null,
'user',
$authorName !== '' ? $authorName : null
);
Flash::set('order.success', 'Notatka dodana.');
} catch (\InvalidArgumentException $exception) {
Flash::set('order.error', $exception->getMessage());
} catch (Throwable $exception) {
Flash::set('order.error', 'Nie udalo sie dodac notatki: ' . $exception->getMessage());
}
return Response::redirect($redirectTo);
}
public function updateNote(Request $request): Response
{
$orderId = max(0, (int) $request->input('id', 0));
$noteId = max(0, (int) $request->input('noteId', 0));
$redirectTo = '/orders/' . $orderId . '#notes';
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('order.error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect('/orders/' . $orderId);
}
if ($orderId <= 0 || $noteId <= 0 || $this->orderNotes === null) {
Flash::set('order.error', 'Nieprawidlowe parametry.');
return Response::redirect('/orders/' . $orderId);
}
$user = $this->auth->user();
$userId = is_array($user) ? (int) ($user['id'] ?? 0) : 0;
$authorName = is_array($user) ? trim((string) ($user['name'] ?? $user['email'] ?? '')) : '';
if ($userId <= 0) {
Flash::set('order.error', 'Wymagane zalogowanie.');
return Response::redirect('/orders/' . $orderId);
}
try {
$this->orderNotes->update($noteId, $userId, (string) $request->input('body', ''));
$this->orders->recordActivity(
$orderId,
'note',
'Zaktualizowano notatke',
['note_id' => $noteId],
'user',
$authorName !== '' ? $authorName : null
);
Flash::set('order.success', 'Notatka zaktualizowana.');
} catch (RuntimeException $exception) {
Flash::set('order.error', $exception->getMessage());
} catch (\InvalidArgumentException $exception) {
Flash::set('order.error', $exception->getMessage());
} catch (Throwable $exception) {
Flash::set('order.error', 'Nie udalo sie zaktualizowac notatki: ' . $exception->getMessage());
}
return Response::redirect($redirectTo);
}
public function deleteNote(Request $request): Response
{
$orderId = max(0, (int) $request->input('id', 0));
$noteId = max(0, (int) $request->input('noteId', 0));
$redirectTo = '/orders/' . $orderId . '#notes';
if (!Csrf::validate((string) $request->input('_token', ''))) {
Flash::set('order.error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect('/orders/' . $orderId);
}
if ($orderId <= 0 || $noteId <= 0 || $this->orderNotes === null) {
Flash::set('order.error', 'Nieprawidlowe parametry.');
return Response::redirect('/orders/' . $orderId);
}
$user = $this->auth->user();
$userId = is_array($user) ? (int) ($user['id'] ?? 0) : 0;
$authorName = is_array($user) ? trim((string) ($user['name'] ?? $user['email'] ?? '')) : '';
if ($userId <= 0) {
Flash::set('order.error', 'Wymagane zalogowanie.');
return Response::redirect('/orders/' . $orderId);
}
try {
$this->orderNotes->delete($noteId, $userId);
$this->orders->recordActivity(
$orderId,
'note',
'Usunieto notatke',
['note_id' => $noteId],
'user',
$authorName !== '' ? $authorName : null
);
Flash::set('order.success', 'Notatka usunieta.');
} catch (RuntimeException $exception) {
Flash::set('order.error', $exception->getMessage());
} catch (Throwable $exception) {
Flash::set('order.error', 'Nie udalo sie usunac notatki: ' . $exception->getMessage());
}
return Response::redirect($redirectTo);
}
/** /**
* @param array<string, mixed> $row * @param array<string, mixed> $row
* @return array<string, mixed> * @return array<string, mixed>
@@ -658,6 +795,13 @@ final class OrdersController
? ' <span class="risk-return-badge" title="Klient nie odebral ' . $returnedCount . ' przesylek w historii">zwroty: ' . $returnedCount . '</span>' ? ' <span class="risk-return-badge" title="Klient nie odebral ' . $returnedCount . ' przesylek w historii">zwroty: ' . $returnedCount . '</span>'
: ''; : '';
$userNotesCount = max(0, (int) ($row['user_notes_count'] ?? 0));
$notesBadge = $userNotesCount >= 1
? ' <a href="/orders/' . (int) ($row['id'] ?? 0) . '#notes" class="order-notes-badge" title="' . $userNotesCount . ' '
. ($userNotesCount === 1 ? 'notatka' : ($userNotesCount < 5 ? 'notatki' : 'notatek')) . '">['
. $userNotesCount . ']</a>'
: '';
$previewBtn = '<button type="button" class="btn-icon js-order-preview-btn" data-order-id="' . (int) ($row['id'] ?? 0) . '" title="Podglad">' $previewBtn = '<button type="button" class="btn-icon js-order-preview-btn" data-order-id="' . (int) ($row['id'] ?? 0) . '" title="Podglad">'
. '<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>' . '<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>'
. '</button>'; . '</button>';
@@ -667,7 +811,7 @@ final class OrdersController
'order_ref' => '<div class="orders-ref">' 'order_ref' => '<div class="orders-ref">'
. '<div class="orders-ref__main">' . $previewBtn . '<a href="/orders/' . (int) ($row['id'] ?? 0) . '">' . '<div class="orders-ref__main">' . $previewBtn . '<a href="/orders/' . (int) ($row['id'] ?? 0) . '">'
. htmlspecialchars($internalOrderNumber !== '' ? $internalOrderNumber : ('#' . (string) ($row['id'] ?? 0)), ENT_QUOTES, 'UTF-8') . htmlspecialchars($internalOrderNumber !== '' ? $internalOrderNumber : ('#' . (string) ($row['id'] ?? 0)), ENT_QUOTES, 'UTF-8')
. '</a></div>' . '</a>' . $notesBadge . '</div>'
. '<div class="orders-ref__meta">' . '<div class="orders-ref__meta">'
. '<span>' . htmlspecialchars($integrationName !== '' ? $integrationName : $this->sourceLabel($source), ENT_QUOTES, 'UTF-8') . '</span>' . '<span>' . htmlspecialchars($integrationName !== '' ? $integrationName : $this->sourceLabel($source), ENT_QUOTES, 'UTF-8') . '</span>'
. '<span>ID: ' . htmlspecialchars($sourceOrderId !== '' ? $sourceOrderId : $externalOrderId, ENT_QUOTES, 'UTF-8') . '</span>' . '<span>ID: ' . htmlspecialchars($sourceOrderId !== '' ? $sourceOrderId : $externalOrderId, ENT_QUOTES, 'UTF-8') . '</span>'

View File

@@ -182,7 +182,8 @@ final class OrdersRepository
COALESCE(sh_agg.shipments_count, 0) AS shipments_count, COALESCE(sh_agg.shipments_count, 0) AS shipments_count,
COALESCE(od_agg.documents_count, 0) AS documents_count, COALESCE(od_agg.documents_count, 0) AS documents_count,
ig.name AS integration_name, ig.name AS integration_name,
' . $this->customerReturnedCountSubquerySql('o', 'a') . ' AS customer_returned_count ' . $this->customerReturnedCountSubquerySql('o', 'a') . ' AS customer_returned_count,
' . $this->userNotesCountSubquerySql('o') . ' AS user_notes_count
FROM orders o FROM orders o
LEFT JOIN order_addresses a ON a.order_id = o.id AND a.address_type = "customer" LEFT JOIN order_addresses a ON a.order_id = o.id AND a.address_type = "customer"
LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.status_code) = asm.allegro_status_code LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.status_code) = asm.allegro_status_code
@@ -246,6 +247,7 @@ final class OrdersRepository
'projects_done' => (int) ($row['projects_done'] ?? 0), 'projects_done' => (int) ($row['projects_done'] ?? 0),
'projects_total' => (int) ($row['projects_total'] ?? 0), 'projects_total' => (int) ($row['projects_total'] ?? 0),
'customer_returned_count' => max(0, (int) ($row['customer_returned_count'] ?? 0)), 'customer_returned_count' => max(0, (int) ($row['customer_returned_count'] ?? 0)),
'user_notes_count' => max(0, (int) ($row['user_notes_count'] ?? 0)),
]; ];
} }
@@ -595,7 +597,9 @@ final class OrdersRepository
*/ */
private function loadOrderNotes(int $orderId): array private function loadOrderNotes(int $orderId): array
{ {
$stmt = $this->pdo->prepare('SELECT * FROM order_notes WHERE order_id = :order_id ORDER BY created_at_external DESC, id DESC'); // Phase 129-01: zwraca tylko notatki importowane ze zrodla (note_type != 'user').
// Notatki autorskie operatora ladowane sa osobno przez OrderNotesService::listUserNotes().
$stmt = $this->pdo->prepare('SELECT * FROM order_notes WHERE order_id = :order_id AND note_type <> "user" ORDER BY created_at_external DESC, id DESC');
$stmt->execute(['order_id' => $orderId]); $stmt->execute(['order_id' => $orderId]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
@@ -707,6 +711,16 @@ final class OrdersRepository
))'; ))';
} }
/**
* Phase 129-01: subquery liczby notatek autorskich (note_type='user') dla zamowienia.
* Wspierane indeksem idx_order_notes_type_order (note_type, order_id).
*/
private function userNotesCountSubquerySql(string $orderAlias): string
{
return '(SELECT COUNT(*) FROM order_notes
WHERE order_id = ' . $orderAlias . '.id AND note_type = "user")';
}
private function effectiveStatusSql(string $orderAlias, string $mappingAlias): string private function effectiveStatusSql(string $orderAlias, string $mappingAlias): string
{ {
return 'CASE return 'CASE