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

@@ -27,6 +27,7 @@ use App\Modules\Sms\SmsConversationService;
use App\Modules\Sms\SmsMessageRepository;
use App\Modules\Sms\SmsTemplateRepository;
use App\Modules\Sms\SmsVariableResolver;
use RuntimeException;
use Throwable;
final class OrdersController
@@ -52,7 +53,8 @@ final class OrdersController
private readonly ?SmsConversationService $smsConversation = null,
private readonly ?SmsTemplateRepository $smsTemplates = 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'] : [];
$documents = is_array($details['documents'] ?? null) ? $details['documents'] : [];
$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'] : [];
$activityLog = is_array($details['activity_log'] ?? null) ? $details['activity_log'] : [];
$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() : [],
'documents' => $documents,
'notes' => $notes,
'userNotes' => $userNotes,
'currentUserId' => $currentUserId,
'history' => $resolvedHistory,
'activityLog' => $activityLog,
'statusLabel' => $this->statusLabel($statusCode, $statusLabelMap),
@@ -624,6 +631,136 @@ final class OrdersController
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
* @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>'
: '';
$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">'
. '<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>';
@@ -667,7 +811,7 @@ final class OrdersController
'order_ref' => '<div class="orders-ref">'
. '<div class="orders-ref__main">' . $previewBtn . '<a href="/orders/' . (int) ($row['id'] ?? 0) . '">'
. htmlspecialchars($internalOrderNumber !== '' ? $internalOrderNumber : ('#' . (string) ($row['id'] ?? 0)), ENT_QUOTES, 'UTF-8')
. '</a></div>'
. '</a>' . $notesBadge . '</div>'
. '<div class="orders-ref__meta">'
. '<span>' . htmlspecialchars($integrationName !== '' ? $integrationName : $this->sourceLabel($source), ENT_QUOTES, 'UTF-8') . '</span>'
. '<span>ID: ' . htmlspecialchars($sourceOrderId !== '' ? $sourceOrderId : $externalOrderId, ENT_QUOTES, 'UTF-8') . '</span>'