Ver código fonte

Notifications

Alexander Musikhin 1 semana atrás
pai
commit
9765192f25
32 arquivos alterados com 1579 adições e 69 exclusões
  1. 35 0
      app/Events/SendPersistentNotificationEvent.php
  2. 46 0
      app/Http/Controllers/Admin/AdminNotificationLogController.php
  3. 5 1
      app/Http/Controllers/Controller.php
  4. 14 0
      app/Http/Controllers/FilterController.php
  5. 4 5
      app/Http/Controllers/OrderController.php
  6. 12 3
      app/Http/Controllers/ReclamationController.php
  7. 7 9
      app/Http/Controllers/ScheduleController.php
  8. 84 1
      app/Http/Controllers/UserController.php
  9. 86 0
      app/Http/Controllers/UserNotificationController.php
  10. 5 0
      app/Http/Requests/User/StoreUser.php
  11. 40 0
      app/Jobs/SendUserNotificationChannelJob.php
  12. 1 0
      app/Models/Import.php
  13. 44 0
      app/Models/NotificationDeliveryLog.php
  14. 1 1
      app/Models/Order.php
  15. 13 1
      app/Models/User.php
  16. 80 0
      app/Models/UserNotification.php
  17. 85 0
      app/Models/UserNotificationSetting.php
  18. 477 0
      app/Services/NotificationService.php
  19. 22 0
      database/migrations/2026_02_28_170000_add_notification_email_to_users_table.php
  20. 27 0
      database/migrations/2026_02_28_170100_create_user_notification_settings_table.php
  21. 32 0
      database/migrations/2026_02_28_170200_create_user_notifications_table.php
  22. 31 0
      database/migrations/2026_02_28_170300_create_notification_delivery_logs_table.php
  23. 127 35
      resources/js/custom.js
  24. 81 0
      resources/sass/app.scss
  25. 17 0
      resources/views/admin/notifications/log.blade.php
  26. 15 0
      resources/views/layouts/app.blade.php
  27. 1 0
      resources/views/layouts/menu.blade.php
  28. 66 0
      resources/views/notifications/index.blade.php
  29. 51 0
      resources/views/partials/notification-settings-table.blade.php
  30. 20 0
      resources/views/partials/table.blade.php
  31. 44 13
      resources/views/users/edit.blade.php
  32. 6 0
      routes/web.php

+ 35 - 0
app/Events/SendPersistentNotificationEvent.php

@@ -0,0 +1,35 @@
+<?php
+
+namespace App\Events;
+
+use Illuminate\Broadcasting\Channel;
+use Illuminate\Broadcasting\InteractsWithSockets;
+use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
+use Illuminate\Foundation\Events\Dispatchable;
+use Illuminate\Queue\SerializesModels;
+
+class SendPersistentNotificationEvent implements ShouldBroadcastNow
+{
+    use Dispatchable, InteractsWithSockets, SerializesModels;
+
+    public const CHANNEL = 'actions';
+
+    public function __construct(
+        private readonly int $userId,
+        private readonly array $notificationPayload,
+    ) {}
+
+    public function broadcastOn(): Channel
+    {
+        return new Channel(self::CHANNEL);
+    }
+
+    public function broadcastWith(): array
+    {
+        return [
+            'action' => 'persistent_notification',
+            'user_id' => $this->userId,
+            'notification' => $this->notificationPayload,
+        ];
+    }
+}

+ 46 - 0
app/Http/Controllers/Admin/AdminNotificationLogController.php

@@ -0,0 +1,46 @@
+<?php
+
+namespace App\Http\Controllers\Admin;
+
+use App\Http\Controllers\Controller;
+use App\Models\NotificationDeliveryLog;
+use Illuminate\Http\Request;
+
+class AdminNotificationLogController extends Controller
+{
+    protected array $data = [
+        'active' => 'notification_logs',
+        'title' => 'Журнал уведомлений',
+        'id' => 'notification_logs',
+        'header' => [
+            'created_at' => 'Дата и время',
+            'channel' => 'Канал',
+            'user_id' => 'Пользователь',
+            'status' => 'Статус',
+            'message' => 'Сообщение',
+        ],
+        'searchFields' => [
+            'message',
+            'error',
+        ],
+    ];
+
+    public function index(Request $request)
+    {
+        session(['gp_notification_logs' => $request->all()]);
+
+        $model = new NotificationDeliveryLog();
+        $this->createFilters($model, 'channel', 'status', 'user_id');
+        $this->createDateFilters($model, 'created_at');
+
+        $q = NotificationDeliveryLog::query()->with('user');
+        $this->acceptFilters($q, $request);
+        $this->acceptSearch($q, $request);
+        $this->setSortAndOrderBy($model, $request);
+        $this->applyStableSorting($q);
+
+        $this->data['logs'] = $q->paginate($this->data['per_page'])->withQueryString();
+
+        return view('admin.notifications.log', $this->data);
+    }
+}

+ 5 - 1
app/Http/Controllers/Controller.php

@@ -172,7 +172,11 @@ class Controller extends BaseController
         session(['per_page' => $this->data['per_page']]);
 
         // set order
-        $this->data['orderBy'] = (empty($request->order)) ? 'asc' : 'desc';
+        if ($request->has('order')) {
+            $this->data['orderBy'] = empty($request->order) ? 'asc' : 'desc';
+        } else {
+            $this->data['orderBy'] = defined($model::class . '::DEFAULT_ORDER_BY') ? $model::DEFAULT_ORDER_BY : 'asc';
+        }
     }
 
     protected function applyStableSorting(Builder $query, string $fallbackSortBy = 'id'): void

+ 14 - 0
app/Http/Controllers/FilterController.php

@@ -20,10 +20,12 @@ class FilterController extends Controller
         'contracts'     => 'contracts',
         'spare_parts'   => 'spare_parts_view',
         'spare_part_orders' => 'spare_part_orders_view',
+        'notifications' => 'user_notifications',
     ];
 
     const SKIP_YEAR_FILTER = [
         'reclamations',
+        'notifications',
     ];
 
     /**
@@ -52,6 +54,18 @@ class FilterController extends Controller
      * Ключ — имя таблицы, затем реальный столбец.
      */
     const VALUE_MAP = [
+        'notifications' => [
+            'type' => [
+                'platform' => 'Площадки',
+                'reclamation' => 'Рекламации',
+                'schedule' => 'График монтажей',
+            ],
+            'event' => [
+                'created' => 'Создание',
+                'status_changed' => 'Смена статуса',
+                'schedule_added' => 'Добавлено в график',
+            ],
+        ],
         'spare_part_orders' => [
             'with_documents' => [
                 0 => 'Нет',

+ 4 - 5
app/Http/Controllers/OrderController.php

@@ -12,8 +12,6 @@ use App\Jobs\GenerateFilesPack;
 use App\Jobs\GenerateHandoverPack;
 use App\Jobs\GenerateInstallationPack;
 use App\Jobs\GenerateTtnPack;
-use App\Jobs\NotifyManagerChangeStatusJob;
-use App\Jobs\NotifyManagerNewOrderJob;
 use App\Models\Dictionary\Area;
 use App\Models\Dictionary\District;
 use App\Models\File;
@@ -28,6 +26,7 @@ use App\Models\Schedule;
 use App\Models\Ttn;
 use App\Models\User;
 use App\Services\FileService;
+use App\Services\NotificationService;
 use Illuminate\Http\RedirectResponse;
 use Illuminate\Http\Request;
 use Illuminate\Support\Facades\DB;
@@ -135,7 +134,7 @@ class OrderController extends Controller
     /**
      * Store a newly created resource in storage.
      */
-    public function store(StoreOrderRequest $request)
+    public function store(StoreOrderRequest $request, NotificationService $notificationService)
     {
         $data = $request->validated();
 
@@ -150,7 +149,7 @@ class OrderController extends Controller
             $order->update($data);
             $order->refresh();
             if($order->order_status_id != $status) {
-                NotifyManagerChangeStatusJob::dispatch($order);
+                $notificationService->notifyOrderStatusChanged($order);
             }
         } else {
             $data['order_status_id'] = Order::STATUS_NEW;
@@ -160,7 +159,7 @@ class OrderController extends Controller
                 . ' (' . $order->area->name . ')'
                 . ' - ' . $order->user->name;
             $order->update(['tg_group_name' => $tg_group_name]);
-            NotifyManagerNewOrderJob::dispatch($order);
+            $notificationService->notifyOrderCreated($order);
         }
 
         // меняем список товаров заказа только если статус новый

+ 12 - 3
app/Http/Controllers/ReclamationController.php

@@ -19,6 +19,7 @@ use App\Models\ReclamationView;
 use App\Models\Role;
 use App\Models\User;
 use App\Services\FileService;
+use App\Services\NotificationService;
 use App\Services\SparePartReservationService;
 use Illuminate\Http\Request;
 use Illuminate\Support\Carbon;
@@ -111,7 +112,7 @@ class ReclamationController extends Controller
             ->with(['success' => 'Задача экспорта рекламаций создана!']);
     }
 
-    public function create(CreateReclamationRequest $request, Order $order)
+    public function create(CreateReclamationRequest $request, Order $order, NotificationService $notificationService)
     {
         $reclamation = Reclamation::query()->create([
             'order_id' => $order->id,
@@ -122,6 +123,7 @@ class ReclamationController extends Controller
         ]);
         $skus = $request->validated('skus');
         $reclamation->skus()->attach($skus);
+        $notificationService->notifyReclamationCreated($reclamation->fresh(['order', 'status']));
         return redirect()->route('reclamations.show', ['reclamation' => $reclamation, 'previous_url' => url()->previous()]);
     }
 
@@ -139,10 +141,16 @@ class ReclamationController extends Controller
         return view('reclamations.edit', $this->data);
     }
 
-    public function update(StoreReclamationRequest $request, Reclamation $reclamation)
+    public function update(StoreReclamationRequest $request, Reclamation $reclamation, NotificationService $notificationService)
     {
         $data = $request->validated();
+        $oldStatusId = $reclamation->status_id;
         $reclamation->update($data);
+
+        if ((int) $oldStatusId !== (int) $reclamation->status_id) {
+            $notificationService->notifyReclamationStatusChanged($reclamation->fresh(['order', 'status']));
+        }
+
         $previousUrl = $this->previousUrlForRedirect($request, 'previous_url_reclamations');
         if (!empty($previousUrl)) {
             return redirect()->route('reclamations.show', [
@@ -154,7 +162,7 @@ class ReclamationController extends Controller
         return redirect()->route('reclamations.show', $reclamation->id);
     }
 
-    public function updateStatus(Request $request, Reclamation $reclamation)
+    public function updateStatus(Request $request, Reclamation $reclamation, NotificationService $notificationService)
     {
         if (!hasRole('admin,manager')) {
             abort(403);
@@ -165,6 +173,7 @@ class ReclamationController extends Controller
         ]);
 
         $reclamation->update(['status_id' => $validated['status_id']]);
+        $notificationService->notifyReclamationStatusChanged($reclamation->fresh(['order', 'status']));
 
         return response()->noContent();
     }

+ 7 - 9
app/Http/Controllers/ScheduleController.php

@@ -2,15 +2,12 @@
 
 namespace App\Http\Controllers;
 
-use App\Events\SendWebSocketMessageEvent;
 use App\Helpers\DateHelper;
 use App\Http\Requests\CreateScheduleFromOrderRequest;
 use App\Http\Requests\CreateScheduleFromReclamationRequest;
 use App\Http\Requests\ExportScheduleRequest;
 use App\Http\Requests\UpdateScheduleRequest;
 use App\Jobs\ExportScheduleJob;
-use App\Jobs\NotifyManagerNewOrderJob;
-use App\Jobs\NotifyOrderInScheduleJob;
 use App\Models\Dictionary\Area;
 use App\Models\Dictionary\District;
 use App\Models\Order;
@@ -20,6 +17,7 @@ use App\Models\ReclamationStatus;
 use App\Models\Role;
 use App\Models\Schedule;
 use App\Models\User;
+use App\Services\NotificationService;
 use Illuminate\Http\Request;
 use Illuminate\Support\Carbon;
 
@@ -146,7 +144,7 @@ class ScheduleController extends Controller
         return view('schedule.index', $this->data);
     }
 
-    public function createFromOrder(CreateScheduleFromOrderRequest $request)
+    public function createFromOrder(CreateScheduleFromOrderRequest $request, NotificationService $notificationService)
     {
         $validated = $request->validated();
 
@@ -215,14 +213,14 @@ class ScheduleController extends Controller
                 ]);
             if($first && isset($validated['send_notifications'])) {
                 $first = false;
-                NotifyOrderInScheduleJob::dispatch($schedule);
+                $notificationService->notifyScheduleAdded($schedule->fresh(['brigadier']));
             }
         }
         return redirect()->route('schedule.index');
 
     }
 
-    public function createFromReclamation(CreateScheduleFromReclamationRequest $request)
+    public function createFromReclamation(CreateScheduleFromReclamationRequest $request, NotificationService $notificationService)
     {
         $validated = $request->validated();
 
@@ -280,7 +278,7 @@ class ScheduleController extends Controller
                 ]);
             if($first && isset($validated['send_notifications'])) {
                 $first = false;
-                NotifyOrderInScheduleJob::dispatch($schedule);
+                $notificationService->notifyScheduleAdded($schedule->fresh(['brigadier']));
             }
         }
         return redirect()->route('schedule.index');
@@ -288,7 +286,7 @@ class ScheduleController extends Controller
     }
 
 
-    public function update(UpdateScheduleRequest $request)
+    public function update(UpdateScheduleRequest $request, NotificationService $notificationService)
     {
         $validated = $request->validated();
 
@@ -314,8 +312,8 @@ class ScheduleController extends Controller
                     'transport' => $validated['transport'] ?? null,
                     'admin_comment' => $validated['admin_comment'] ?? null,
                 ]);
+            $notificationService->notifyScheduleAdded($schedule->fresh(['brigadier']));
         }
-        NotifyOrderInScheduleJob::dispatch($schedule);
 
         return redirect()->back();
     }

+ 84 - 1
app/Http/Controllers/UserController.php

@@ -5,7 +5,11 @@ namespace App\Http\Controllers;
 use App\Http\Requests\User\DeleteUser;
 use App\Http\Requests\User\StoreProfile;
 use App\Http\Requests\User\StoreUser;
+use App\Models\Order;
+use App\Models\Reclamation;
+use App\Models\ReclamationStatus;
 use App\Models\User;
+use App\Models\UserNotificationSetting;
 use Illuminate\Http\Request;
 use Illuminate\Support\Facades\Auth;
 use Illuminate\Support\Facades\Hash;
@@ -63,6 +67,7 @@ class UserController extends Controller
     public function create()
     {
         $this->data['user'] = null;
+        $this->prepareNotificationSettingsData(null);
         return view('users.edit', $this->data);
     }
 
@@ -72,6 +77,9 @@ class UserController extends Controller
     public function store(StoreUser $request)
     {
         $validated = $request->validated();
+        $settingsData = $this->extractNotificationSettings($request);
+
+        unset($validated['notification_settings']);
 
         if(!empty($validated['password'])) {
             $validated['password'] = Hash::make($validated['password']);
@@ -79,13 +87,23 @@ class UserController extends Controller
             unset($validated['password']);
         }
 
+        $user = null;
         if(isset($validated['id'])) {
             User::query()
                 ->where('id', $validated['id'])
                 ->update($validated);
+            $user = User::query()->find($validated['id']);
         } else {
-            User::query()->create($validated);
+            $user = User::query()->create($validated);
+        }
+
+        if ($user) {
+            UserNotificationSetting::query()->updateOrCreate(
+                ['user_id' => $user->id],
+                $settingsData,
+            );
         }
+
         return redirect()->route('user.index')->with(['success' => 'Пользователь ' . $validated['name'] . ' сохранён!']);
     }
 
@@ -98,6 +116,8 @@ class UserController extends Controller
             ->where('id', $userId)
             ->withTrashed()
             ->first();
+        $this->prepareNotificationSettingsData($this->data['user']);
+
         return view('users.edit', $this->data);
     }
 
@@ -202,5 +222,68 @@ class UserController extends Controller
         return redirect()->route('user.index')->with(['success' => 'Вы вернулись в аккаунт администратора.']);
     }
 
+    private function prepareNotificationSettingsData(?User $user): void
+    {
+        $this->data['orderStatusOptions'] = Order::STATUS_NAMES;
+        $this->data['orderStatusColors'] = Order::STATUS_COLOR;
+        $this->data['reclamationStatusOptions'] = Reclamation::STATUS_NAMES;
+        $this->data['reclamationStatusColors'] = ReclamationStatus::STATUS_COLOR;
+        $this->data['scheduleSourceOptions'] = ['platform' => 'Площадки', 'reclamation' => 'Рекламации'];
+        $this->data['notificationChannels'] = ['browser' => 'Браузер', 'push' => 'Push', 'email' => 'Email'];
+
+        $this->data['disabledChannels'] = [
+            'browser' => false,
+            'push' => !$user || !$user->token_fcm,
+            'email' => !$user || !$user->notification_email || !filter_var($user->notification_email, FILTER_VALIDATE_EMAIL),
+        ];
+
+        if (!$user) {
+            $this->data['notificationSettings'] = UserNotificationSetting::defaultsForUser(0);
+            return;
+        }
+
+        $settings = UserNotificationSetting::query()->firstOrCreate(
+            ['user_id' => $user->id],
+            UserNotificationSetting::defaultsForUser($user->id),
+        );
+
+        $this->data['notificationSettings'] = $settings->toArray();
+    }
+
+    private function extractNotificationSettings(Request $request): array
+    {
+        $input = $request->input('notification_settings', []);
+        $settings = [
+            'order_settings' => [],
+            'reclamation_settings' => [],
+            'schedule_settings' => [],
+        ];
+
+        $orderStatuses = array_keys(Order::STATUS_NAMES);
+        $reclamationStatuses = array_keys(Reclamation::STATUS_NAMES);
+        $scheduleSources = ['platform', 'reclamation'];
+        $channels = ['browser', 'push', 'email'];
+
+        foreach ($orderStatuses as $statusId) {
+            foreach ($channels as $channel) {
+                $settings['order_settings'][$statusId][$channel] = isset($input['orders'][$statusId][$channel]);
+            }
+        }
+
+        foreach ($reclamationStatuses as $statusId) {
+            foreach ($channels as $channel) {
+                $settings['reclamation_settings'][$statusId][$channel] = isset($input['reclamations'][$statusId][$channel]);
+            }
+        }
+
+        foreach ($scheduleSources as $source) {
+            foreach ($channels as $channel) {
+                $settings['schedule_settings'][$source][$channel] = isset($input['schedule'][$source][$channel]);
+            }
+        }
+
+        return $settings;
+    }
+
 
 }

+ 86 - 0
app/Http/Controllers/UserNotificationController.php

@@ -0,0 +1,86 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\UserNotification;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+
+class UserNotificationController extends Controller
+{
+    protected array $data = [
+        'active' => 'notifications',
+        'title' => 'Уведомления',
+        'id' => 'notifications',
+        'header' => [
+            'created_at'  => 'Дата',
+            'type'        => 'Тип',
+            'event'       => 'Событие',
+            'message'     => 'Сообщение',
+            'read_at'     => 'Прочитано',
+        ],
+        'searchFields' => [
+            'message',
+        ],
+        'ranges' => [],
+        'filters' => [],
+    ];
+
+    public function index(Request $request)
+    {
+        session(['gp_notifications' => $request->query()]);
+
+        $model = new UserNotification;
+
+        $userId = $request->user()->id;
+        $q = $model::query()->where('user_id', $userId);
+
+        $this->data['filters']['type'] = [
+            'title' => 'Тип',
+            'values' => UserNotification::TYPE_NAMES,
+        ];
+        $this->data['filters']['event'] = [
+            'title' => 'Событие',
+            'values' => UserNotification::EVENT_NAMES,
+        ];
+        $this->createDateFilters($model, 'created_at');
+
+        $this->acceptFilters($q, $request);
+        $this->acceptSearch($q, $request);
+        $this->setSortAndOrderBy($model, $request);
+        $this->applyStableSorting($q);
+
+        $this->data['notifications'] = $q->paginate($this->data['per_page'])->withQueryString();
+
+        return view('notifications.index', $this->data);
+    }
+
+    public function markRead(Request $request, UserNotification $notification): JsonResponse
+    {
+        if ($notification->user_id !== $request->user()->id) {
+            abort(403);
+        }
+
+        if (!$notification->isRead()) {
+            $notification->update(['read_at' => now()]);
+        }
+
+        return response()->json([
+            'ok' => true,
+            'unread' => UserNotification::query()
+                ->where('user_id', $request->user()->id)
+                ->whereNull('read_at')
+                ->count(),
+        ]);
+    }
+
+    public function unreadCount(Request $request): JsonResponse
+    {
+        return response()->json([
+            'count' => UserNotification::query()
+                ->where('user_id', $request->user()->id)
+                ->whereNull('read_at')
+                ->count(),
+        ]);
+    }
+}

+ 5 - 0
app/Http/Requests/User/StoreUser.php

@@ -3,6 +3,8 @@
 namespace App\Http\Requests\User;
 
 use App\Models\Role;
+use App\Models\Reclamation;
+use App\Models\Order;
 use Illuminate\Foundation\Http\FormRequest;
 
 class StoreUser extends FormRequest
@@ -30,6 +32,9 @@ class StoreUser extends FormRequest
             'password'  => 'required_without:id|nullable|string|min:4',
             'role'      => 'nullable|string|in:' . implode(',', Role::VALID_ROLES),
             'color'     => 'nullable|string',
+            'notification_email' => 'nullable|email',
+
+            'notification_settings' => 'nullable|array',
         ];
     }
 

+ 40 - 0
app/Jobs/SendUserNotificationChannelJob.php

@@ -0,0 +1,40 @@
+<?php
+
+namespace App\Jobs;
+
+use App\Services\NotificationService;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Queue\Queueable;
+use Throwable;
+
+class SendUserNotificationChannelJob implements ShouldQueue
+{
+    use Queueable;
+
+    public int $tries = 3;
+    public int $backoff = 60;
+
+    public function __construct(
+        private readonly int $userNotificationId,
+        private readonly string $channel,
+    ) {}
+
+    public function handle(NotificationService $notificationService): void
+    {
+        $notificationService->deliverChannel(
+            $this->userNotificationId,
+            $this->channel,
+            $this->attempts(),
+        );
+    }
+
+    public function failed(?Throwable $exception): void
+    {
+        app(NotificationService::class)->markDeadLetter(
+            $this->userNotificationId,
+            $this->channel,
+            $this->attempts(),
+            $exception?->getMessage(),
+        );
+    }
+}

+ 1 - 0
app/Models/Import.php

@@ -9,6 +9,7 @@ class Import extends Model
 {
     use HasFactory;
     const DEFAULT_SORT_BY = 'created_at';
+    const DEFAULT_ORDER_BY = 'desc';
 
     const STATUS_PENDING = 'pending';
     const STATUS_COMPLETED = 'completed';

+ 44 - 0
app/Models/NotificationDeliveryLog.php

@@ -0,0 +1,44 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+class NotificationDeliveryLog extends Model
+{
+    use HasFactory;
+
+    public const CHANNEL_IN_APP = 'in_app';
+    public const CHANNEL_BROWSER = 'browser';
+    public const CHANNEL_PUSH = 'push';
+    public const CHANNEL_EMAIL = 'email';
+
+    public const STATUS_SENT = 'sent';
+    public const STATUS_FAILED = 'failed';
+    public const STATUS_SKIPPED = 'skipped';
+    public const STATUS_DEAD_LETTER = 'dead_letter';
+
+    public const DEFAULT_SORT_BY = 'created_at';
+
+    protected $fillable = [
+        'user_notification_id',
+        'user_id',
+        'channel',
+        'status',
+        'attempt',
+        'message',
+        'error',
+    ];
+
+    public function user(): BelongsTo
+    {
+        return $this->belongsTo(User::class);
+    }
+
+    public function userNotification(): BelongsTo
+    {
+        return $this->belongsTo(UserNotification::class);
+    }
+}

+ 1 - 1
app/Models/Order.php

@@ -57,7 +57,7 @@ class Order extends Model
     const STATUS_COLOR = [
         self::STATUS_NEW => 'dark',
         self::STATUS_NOT_READY => 'dark',
-        self::STATUS_READY_NO_MAF => '',
+        self::STATUS_READY_NO_MAF => 'light',
         self::STATUS_READY_TO_MOUNT => 'primary',
         self::STATUS_IN_MOUNT => 'info',
         self::STATUS_DUTY => 'warning',

+ 13 - 1
app/Models/User.php

@@ -4,6 +4,7 @@ namespace App\Models;
 
 use Illuminate\Contracts\Auth\MustVerifyEmail;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Relations\HasMany;
 use Illuminate\Database\Eloquent\SoftDeletes;
 use Illuminate\Foundation\Auth\User as Authenticatable;
 use Illuminate\Notifications\Notifiable;
@@ -22,6 +23,7 @@ class User extends Authenticatable implements MustVerifyEmail
     protected $fillable = [
         'name',
         'email',
+        'notification_email',
         'phone',
         'password',
         'role',
@@ -59,6 +61,16 @@ class User extends Authenticatable implements MustVerifyEmail
      */
     public function routeNotificationForFcm(): string
     {
-        return $this->token_fcm;
+        return (string)$this->token_fcm;
+    }
+
+    public function userNotifications(): HasMany
+    {
+        return $this->hasMany(UserNotification::class);
+    }
+
+    public function unreadUserNotifications(): HasMany
+    {
+        return $this->userNotifications()->whereNull('read_at');
     }
 }

+ 80 - 0
app/Models/UserNotification.php

@@ -0,0 +1,80 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+class UserNotification extends Model
+{
+    use HasFactory;
+
+    public const TYPE_PLATFORM = 'platform';
+    public const TYPE_RECLAMATION = 'reclamation';
+    public const TYPE_SCHEDULE = 'schedule';
+
+    public const TYPE_NAMES = [
+        self::TYPE_PLATFORM => 'Площадки',
+        self::TYPE_RECLAMATION => 'Рекламации',
+        self::TYPE_SCHEDULE => 'График монтажей',
+    ];
+
+    public const TYPE_COLORS = [
+        self::TYPE_PLATFORM => 'primary',
+        self::TYPE_RECLAMATION => 'success',
+        self::TYPE_SCHEDULE => 'warning',
+    ];
+
+    public const EVENT_CREATED = 'created';
+    public const EVENT_STATUS_CHANGED = 'status_changed';
+    public const EVENT_SCHEDULE_ADDED = 'schedule_added';
+
+    public const EVENT_NAMES = [
+        self::EVENT_CREATED => 'Создание',
+        self::EVENT_STATUS_CHANGED => 'Смена статуса',
+        self::EVENT_SCHEDULE_ADDED => 'Добавлено в график',
+    ];
+
+    public const DEFAULT_SORT_BY = 'created_at';
+    public const DEFAULT_ORDER_BY = 'desc';
+
+    protected $fillable = [
+        'user_id',
+        'type',
+        'event',
+        'title',
+        'message',
+        'message_html',
+        'data',
+        'read_at',
+    ];
+
+    protected function casts(): array
+    {
+        return [
+            'data' => 'array',
+            'read_at' => 'datetime',
+        ];
+    }
+
+    public function user(): BelongsTo
+    {
+        return $this->belongsTo(User::class);
+    }
+
+    public function isRead(): bool
+    {
+        return $this->read_at !== null;
+    }
+
+    public function getTypeNameAttribute(): string
+    {
+        return self::TYPE_NAMES[$this->type] ?? $this->type;
+    }
+
+    public function getEventNameAttribute(): string
+    {
+        return self::EVENT_NAMES[$this->event] ?? $this->event;
+    }
+}

+ 85 - 0
app/Models/UserNotificationSetting.php

@@ -0,0 +1,85 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+class UserNotificationSetting extends Model
+{
+    use HasFactory;
+
+    protected $fillable = [
+        'user_id',
+        'order_settings',
+        'reclamation_settings',
+        'schedule_settings',
+    ];
+
+    protected function casts(): array
+    {
+        return [
+            'order_settings' => 'array',
+            'reclamation_settings' => 'array',
+            'schedule_settings' => 'array',
+        ];
+    }
+
+    public function user(): BelongsTo
+    {
+        return $this->belongsTo(User::class);
+    }
+
+    public static function defaultsForUser(int $userId): array
+    {
+        return [
+            'user_id' => $userId,
+            'order_settings' => [],
+            'reclamation_settings' => [],
+            'schedule_settings' => [],
+        ];
+    }
+
+    /**
+     * Проверяет, включены ли уведомления хотя бы для одного статуса/источника в секции.
+     * Секция считается активной если есть хотя бы один true-канал.
+     */
+    public function isSectionEnabled(string $settingsKey): bool
+    {
+        $settings = $this->{$settingsKey};
+        if (!is_array($settings) || empty($settings)) {
+            return false;
+        }
+
+        foreach ($settings as $channels) {
+            if (is_array($channels) && array_filter($channels)) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * Возвращает включённые каналы для конкретного статуса/источника.
+     * @return array<string, bool> ['browser' => true, 'push' => false, 'email' => true]
+     */
+    public function getChannelsForKey(string $settingsKey, int|string $key): array
+    {
+        $settings = $this->{$settingsKey};
+        if (!is_array($settings) || !isset($settings[$key])) {
+            return [];
+        }
+
+        return array_filter($settings[$key]);
+    }
+
+    /**
+     * Проверяет, включён ли хотя бы один канал для конкретного статуса/источника.
+     */
+    public function hasEnabledChannelForKey(string $settingsKey, int|string $key): bool
+    {
+        return !empty($this->getChannelsForKey($settingsKey, $key));
+    }
+}

+ 477 - 0
app/Services/NotificationService.php

@@ -0,0 +1,477 @@
+<?php
+
+namespace App\Services;
+
+use App\Events\SendPersistentNotificationEvent;
+use App\Helpers\DateHelper;
+use App\Jobs\SendUserNotificationChannelJob;
+use App\Models\NotificationDeliveryLog;
+use App\Models\Order;
+use App\Models\Reclamation;
+use App\Models\Role;
+use App\Models\Schedule;
+use App\Models\User;
+use App\Models\UserNotification;
+use App\Models\UserNotificationSetting;
+use App\Notifications\FireBaseNotification;
+use Illuminate\Database\Eloquent\Collection;
+use Illuminate\Support\Facades\Mail;
+use Illuminate\Support\Str;
+
+class NotificationService
+{
+    public function notifyOrderCreated(Order $order): void
+    {
+        $statusName = $order->orderStatus?->name ?? (Order::STATUS_NAMES[$order->order_status_id] ?? '-');
+
+        $this->notifyOrderEvent(
+            $order,
+            UserNotification::EVENT_CREATED,
+            'Площадки',
+            sprintf('Добавлена новая площадка %s', $order->object_address),
+            sprintf(
+                'Добавлена новая площадка <a href="%s">%s</a>.',
+                route('order.show', ['order' => $order->id]),
+                e($order->object_address)
+            ),
+            $statusName,
+        );
+    }
+
+    public function notifyOrderStatusChanged(Order $order): void
+    {
+        $statusName = $order->orderStatus?->name ?? (Order::STATUS_NAMES[$order->order_status_id] ?? '-');
+
+        $this->notifyOrderEvent(
+            $order,
+            UserNotification::EVENT_STATUS_CHANGED,
+            'Площадки',
+            sprintf('Статус площадки %s изменен на %s', $order->object_address, $statusName),
+            sprintf(
+                'Статус площадки <a href="%s">%s</a> изменен на %s.',
+                route('order.show', ['order' => $order->id]),
+                e($order->object_address),
+                e($statusName)
+            ),
+            $statusName,
+        );
+    }
+
+    public function notifyReclamationCreated(Reclamation $reclamation): void
+    {
+        $order = $reclamation->order;
+        if (!$order) {
+            return;
+        }
+
+        $message = sprintf(
+            'Добавлена новая рекламация по адресу %s #%d',
+            $order->object_address,
+            $reclamation->id,
+        );
+
+        $messageHtml = sprintf(
+            'Добавлена новая рекламация по адресу <a href="%s">%s</a> <a href="%s">#%d</a>.',
+            route('order.show', ['order' => $order->id]),
+            e($order->object_address),
+            route('reclamations.show', ['reclamation' => $reclamation->id]),
+            $reclamation->id,
+        );
+
+        $this->notifyReclamationEvent(
+            $reclamation,
+            UserNotification::EVENT_CREATED,
+            'Рекламации',
+            $message,
+            $messageHtml,
+            (int)$reclamation->status_id,
+        );
+    }
+
+    public function notifyReclamationStatusChanged(Reclamation $reclamation): void
+    {
+        $order = $reclamation->order;
+        if (!$order) {
+            return;
+        }
+
+        $statusName = $reclamation->status?->name ?? (Reclamation::STATUS_NAMES[$reclamation->status_id] ?? '-');
+
+        $message = sprintf(
+            'Статус рекламации %s #%d изменен на %s',
+            $order->object_address,
+            $reclamation->id,
+            $statusName,
+        );
+
+        $messageHtml = sprintf(
+            'Статус рекламации по адресу <a href="%s">%s</a> <a href="%s">#%d</a> изменен на %s.',
+            route('order.show', ['order' => $order->id]),
+            e($order->object_address),
+            route('reclamations.show', ['reclamation' => $reclamation->id]),
+            $reclamation->id,
+            e($statusName),
+        );
+
+        $this->notifyReclamationEvent(
+            $reclamation,
+            UserNotification::EVENT_STATUS_CHANGED,
+            'Рекламации',
+            $message,
+            $messageHtml,
+            (int)$reclamation->status_id,
+        );
+    }
+
+    public function notifyScheduleAdded(Schedule $schedule): void
+    {
+        $sourceKey = $this->sourceToSettingKey((string)$schedule->source);
+        if (!$sourceKey) {
+            return;
+        }
+
+        $brigadierName = $schedule->brigadier?->name ?? '-';
+        $date = DateHelper::getHumanDate((string)$schedule->installation_date, true);
+
+        $message = sprintf(
+            '%s добавлено в график монтажей на %s, Бригадир %s.',
+            $schedule->object_address,
+            $date,
+            $brigadierName,
+        );
+
+        $orderLink = $schedule->order_id
+            ? route('order.show', ['order' => $schedule->order_id])
+            : route('schedule.index');
+
+        $messageHtml = sprintf(
+            '<a href="%s">%s</a> добавлено в график монтажей на %s, Бригадир %s.',
+            $orderLink,
+            e($schedule->object_address),
+            e($date),
+            e($brigadierName),
+        );
+
+        $users = $this->scheduleRecipients($schedule);
+        foreach ($users as $user) {
+            $settings = $this->settingsForUser($user->id);
+            if (!$settings->isSectionEnabled('schedule_settings')) {
+                continue;
+            }
+
+            $channels = $settings->getChannelsForKey('schedule_settings', $sourceKey);
+            if (empty($channels)) {
+                continue;
+            }
+
+            $notification = $this->createInAppNotification(
+                $user,
+                UserNotification::TYPE_SCHEDULE,
+                UserNotification::EVENT_SCHEDULE_ADDED,
+                'График монтажей',
+                $message,
+                $messageHtml,
+                [
+                    'schedule_id' => $schedule->id,
+                    'source' => $schedule->source,
+                ],
+            );
+
+            $this->dispatchDeliveryJobs($notification, [
+                NotificationDeliveryLog::CHANNEL_BROWSER => !empty($channels['browser']),
+                NotificationDeliveryLog::CHANNEL_PUSH => !empty($channels['push']),
+                NotificationDeliveryLog::CHANNEL_EMAIL => !empty($channels['email']),
+            ]);
+        }
+    }
+
+    public function deliverChannel(int $userNotificationId, string $channel, int $attempt): void
+    {
+        $notification = UserNotification::query()->with('user')->find($userNotificationId);
+        if (!$notification || !$notification->user) {
+            return;
+        }
+
+        try {
+            if ($channel === NotificationDeliveryLog::CHANNEL_BROWSER) {
+                event(new SendPersistentNotificationEvent($notification->user_id, [
+                    'id' => $notification->id,
+                    'type' => $notification->type,
+                    'title' => $notification->title,
+                    'message' => $notification->message,
+                    'message_html' => $notification->message_html,
+                    'created_at' => $notification->created_at?->toDateTimeString(),
+                ]));
+
+                $this->createLog($notification, $channel, NotificationDeliveryLog::STATUS_SENT, $attempt, null);
+                return;
+            }
+
+            if ($channel === NotificationDeliveryLog::CHANNEL_PUSH) {
+                if (!$notification->user->token_fcm) {
+                    $this->createLog($notification, $channel, NotificationDeliveryLog::STATUS_SKIPPED, $attempt, 'Отсутствует token_fcm');
+                    return;
+                }
+
+                $notification->user->notify(new FireBaseNotification($notification->title, Str::limit(strip_tags($notification->message), 200)));
+                $this->createLog($notification, $channel, NotificationDeliveryLog::STATUS_SENT, $attempt, null);
+                return;
+            }
+
+            if ($channel === NotificationDeliveryLog::CHANNEL_EMAIL) {
+                $email = $notification->user->notification_email;
+                if (!$email || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
+                    $this->createLog($notification, $channel, NotificationDeliveryLog::STATUS_SKIPPED, $attempt, 'Отсутствует валидный notification_email');
+                    return;
+                }
+
+                Mail::raw($notification->message, function ($mail) use ($email, $notification) {
+                    $mail->to($email)
+                        ->subject($notification->title);
+                });
+
+                $this->createLog($notification, $channel, NotificationDeliveryLog::STATUS_SENT, $attempt, null);
+                return;
+            }
+
+            $this->createLog($notification, $channel, NotificationDeliveryLog::STATUS_SKIPPED, $attempt, 'Неизвестный канал');
+        } catch (\Throwable $exception) {
+            $this->createLog($notification, $channel, NotificationDeliveryLog::STATUS_FAILED, $attempt, $exception->getMessage());
+            throw $exception;
+        }
+    }
+
+    public function markDeadLetter(int $userNotificationId, string $channel, int $attempt, ?string $error = null): void
+    {
+        $notification = UserNotification::query()->find($userNotificationId);
+        if (!$notification) {
+            return;
+        }
+
+        $this->createLog($notification, $channel, NotificationDeliveryLog::STATUS_DEAD_LETTER, $attempt, $error);
+    }
+
+    private function notifyOrderEvent(
+        Order $order,
+        string $event,
+        string $title,
+        string $message,
+        string $messageHtml,
+        string $statusName,
+    ): void {
+        $users = $this->orderRecipients($order);
+        $statusId = (int) $order->order_status_id;
+
+        foreach ($users as $user) {
+            $settings = $this->settingsForUser($user->id);
+            if (!$settings->isSectionEnabled('order_settings')) {
+                continue;
+            }
+
+            $channels = $settings->getChannelsForKey('order_settings', $statusId);
+            if (empty($channels)) {
+                continue;
+            }
+
+            $notification = $this->createInAppNotification(
+                $user,
+                UserNotification::TYPE_PLATFORM,
+                $event,
+                $title,
+                $message,
+                $messageHtml,
+                [
+                    'order_id' => $order->id,
+                    'status' => $statusName,
+                ],
+            );
+
+            $this->dispatchDeliveryJobs($notification, [
+                NotificationDeliveryLog::CHANNEL_BROWSER => !empty($channels['browser']),
+                NotificationDeliveryLog::CHANNEL_PUSH => !empty($channels['push']),
+                NotificationDeliveryLog::CHANNEL_EMAIL => !empty($channels['email']),
+            ]);
+        }
+    }
+
+    private function notifyReclamationEvent(
+        Reclamation $reclamation,
+        string $event,
+        string $title,
+        string $message,
+        string $messageHtml,
+        int $statusId,
+    ): void {
+        $users = $this->reclamationRecipients($reclamation);
+
+        foreach ($users as $user) {
+            $settings = $this->settingsForUser($user->id);
+            if (!$settings->isSectionEnabled('reclamation_settings')) {
+                continue;
+            }
+
+            $channels = $settings->getChannelsForKey('reclamation_settings', $statusId);
+            if (empty($channels)) {
+                continue;
+            }
+
+            $notification = $this->createInAppNotification(
+                $user,
+                UserNotification::TYPE_RECLAMATION,
+                $event,
+                $title,
+                $message,
+                $messageHtml,
+                [
+                    'reclamation_id' => $reclamation->id,
+                    'status_id' => $statusId,
+                ],
+            );
+
+            $this->dispatchDeliveryJobs($notification, [
+                NotificationDeliveryLog::CHANNEL_BROWSER => !empty($channels['browser']),
+                NotificationDeliveryLog::CHANNEL_PUSH => !empty($channels['push']),
+                NotificationDeliveryLog::CHANNEL_EMAIL => !empty($channels['email']),
+            ]);
+        }
+    }
+
+    private function dispatchDeliveryJobs(UserNotification $notification, array $channels): void
+    {
+        foreach ($channels as $channel => $enabled) {
+            if (!$enabled) {
+                continue;
+            }
+
+            SendUserNotificationChannelJob::dispatch($notification->id, $channel);
+        }
+    }
+
+    private function createInAppNotification(
+        User $user,
+        string $type,
+        string $event,
+        string $title,
+        string $message,
+        string $messageHtml,
+        array $payload,
+    ): UserNotification {
+        $notification = UserNotification::query()->create([
+            'user_id' => $user->id,
+            'type' => $type,
+            'event' => $event,
+            'title' => $title,
+            'message' => $message,
+            'message_html' => $messageHtml,
+            'data' => $payload,
+        ]);
+
+        $this->createLog($notification, NotificationDeliveryLog::CHANNEL_IN_APP, NotificationDeliveryLog::STATUS_SENT, 1, null);
+
+        return $notification;
+    }
+
+    private function createLog(
+        UserNotification $notification,
+        string $channel,
+        string $status,
+        int $attempt,
+        ?string $error,
+    ): void {
+        NotificationDeliveryLog::query()->create([
+            'user_notification_id' => $notification->id,
+            'user_id' => $notification->user_id,
+            'channel' => $channel,
+            'status' => $status,
+            'attempt' => $attempt,
+            'message' => $notification->message,
+            'error' => $error,
+        ]);
+    }
+
+    private function settingsForUser(int $userId): UserNotificationSetting
+    {
+        return UserNotificationSetting::query()->firstOrCreate(
+            ['user_id' => $userId],
+            UserNotificationSetting::defaultsForUser($userId),
+        );
+    }
+
+    private function orderRecipients(Order $order): Collection
+    {
+        $query = User::query()
+            ->whereIn('role', [Role::ADMIN, Role::ASSISTANT_HEAD, Role::WAREHOUSE_HEAD]);
+
+        if ($order->user_id) {
+            $query->orWhere('id', $order->user_id);
+        }
+
+        return $query->distinct()->get();
+    }
+
+    private function reclamationRecipients(Reclamation $reclamation): Collection
+    {
+        $query = User::query()
+            ->whereIn('role', [Role::ADMIN, Role::ASSISTANT_HEAD, Role::WAREHOUSE_HEAD]);
+
+        $managerId = $reclamation->order?->user_id ?: $reclamation->user_id;
+        if ($managerId) {
+            $query->orWhere('id', $managerId);
+        }
+
+        return $query->distinct()->get();
+    }
+
+    private function scheduleRecipients(Schedule $schedule): Collection
+    {
+        $query = User::query()
+            ->whereIn('role', [Role::ADMIN, Role::ASSISTANT_HEAD, Role::WAREHOUSE_HEAD]);
+
+        if ($schedule->brigadier_id) {
+            $query->orWhere('id', $schedule->brigadier_id);
+        }
+
+        $managerId = null;
+        if ((string)$schedule->source === 'Площадки' && $schedule->order_id) {
+            $managerId = Order::query()
+                ->withoutGlobalScope(\App\Models\Scopes\YearScope::class)
+                ->where('id', $schedule->order_id)
+                ->value('user_id');
+        }
+
+        if ((string)$schedule->source === 'Рекламации') {
+            $reclamationId = $this->extractReclamationId((string)$schedule->address_code);
+            if ($reclamationId) {
+                $reclamation = Reclamation::query()
+                    ->withoutGlobalScope(\App\Models\Scopes\YearScope::class)
+                    ->with('order')
+                    ->find($reclamationId);
+                $managerId = $reclamation?->order?->user_id ?: $reclamation?->user_id;
+            }
+        }
+
+        if ($managerId) {
+            $query->orWhere('id', $managerId);
+        }
+
+        return $query->distinct()->get();
+    }
+
+    private function extractReclamationId(string $addressCode): ?int
+    {
+        if (preg_match('/^РЕКЛ-(\d+)$/u', $addressCode, $matches)) {
+            return (int)$matches[1];
+        }
+
+        return null;
+    }
+
+    private function sourceToSettingKey(string $source): ?string
+    {
+        return match ($source) {
+            'Площадки' => 'platform',
+            'Рекламации' => 'reclamation',
+            default => null,
+        };
+    }
+}

+ 22 - 0
database/migrations/2026_02_28_170000_add_notification_email_to_users_table.php

@@ -0,0 +1,22 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::table('users', function (Blueprint $table) {
+            $table->string('notification_email')->nullable()->after('email');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('users', function (Blueprint $table) {
+            $table->dropColumn('notification_email');
+        });
+    }
+};

+ 27 - 0
database/migrations/2026_02_28_170100_create_user_notification_settings_table.php

@@ -0,0 +1,27 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::create('user_notification_settings', function (Blueprint $table) {
+            $table->id();
+            $table->foreignId('user_id')->constrained()->cascadeOnDelete()->unique();
+
+            $table->json('order_settings')->nullable();
+            $table->json('reclamation_settings')->nullable();
+            $table->json('schedule_settings')->nullable();
+
+            $table->timestamps();
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('user_notification_settings');
+    }
+};

+ 32 - 0
database/migrations/2026_02_28_170200_create_user_notifications_table.php

@@ -0,0 +1,32 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::create('user_notifications', function (Blueprint $table) {
+            $table->id();
+            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
+            $table->string('type'); // platform | reclamation | schedule
+            $table->string('event'); // created | status_changed | schedule_added
+            $table->string('title');
+            $table->text('message');
+            $table->text('message_html')->nullable();
+            $table->json('data')->nullable();
+            $table->timestamp('read_at')->nullable();
+            $table->timestamps();
+
+            $table->index(['user_id', 'read_at']);
+            $table->index(['type', 'event']);
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('user_notifications');
+    }
+};

+ 31 - 0
database/migrations/2026_02_28_170300_create_notification_delivery_logs_table.php

@@ -0,0 +1,31 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::create('notification_delivery_logs', function (Blueprint $table) {
+            $table->id();
+            $table->foreignId('user_notification_id')->nullable()->constrained('user_notifications')->nullOnDelete();
+            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
+            $table->string('channel'); // in_app | browser | push | email
+            $table->string('status'); // sent | failed | skipped | dead_letter
+            $table->unsignedTinyInteger('attempt')->default(1);
+            $table->text('message');
+            $table->text('error')->nullable();
+            $table->timestamps();
+
+            $table->index(['user_id', 'created_at']);
+            $table->index(['channel', 'status']);
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('notification_delivery_logs');
+    }
+};

+ 127 - 35
resources/js/custom.js

@@ -1,6 +1,5 @@
 $(document).ready(function () {
     function cleanupStaleModalState() {
-        // If no modal is visible, remove stale Bootstrap backdrop and body lock.
         if (!document.querySelector('.modal.show')) {
             document.querySelectorAll('.modal-backdrop').forEach(function (backdrop) {
                 backdrop.remove();
@@ -12,69 +11,162 @@ $(document).ready(function () {
         }
     }
 
+    function getNotificationBadge() {
+        return document.getElementById('notification-badge');
+    }
+
+    function updateNotificationBadge(count) {
+        const badge = getNotificationBadge();
+        if (!badge) {
+            return;
+        }
+
+        const safeCount = Math.max(0, parseInt(count || 0, 10));
+        badge.dataset.count = String(safeCount);
+        badge.classList.toggle('d-none', safeCount === 0);
+    }
+
+    function incrementNotificationBadge() {
+        const badge = getNotificationBadge();
+        if (!badge) {
+            return;
+        }
+
+        const current = parseInt(badge.dataset.count || '0', 10);
+        updateNotificationBadge(current + 1);
+    }
+
+    function markNotificationRead(notificationId) {
+        if (!notificationId || !window.notificationsReadUrlTemplate) {
+            return;
+        }
+
+        fetch(window.notificationsReadUrlTemplate.replace('__id__', String(notificationId)), {
+            method: 'POST',
+            headers: {
+                'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
+                'Accept': 'application/json',
+            },
+            keepalive: true,
+        })
+            .then(function (response) {
+                if (!response.ok) {
+                    return null;
+                }
+                return response.json();
+            })
+            .then(function (data) {
+                if (data && typeof data.unread !== 'undefined') {
+                    updateNotificationBadge(data.unread);
+                }
+            })
+            .catch(function () {
+                // noop
+            });
+    }
+
+    function appendWsPopup(notification) {
+        const container = document.getElementById('ws-notification-container');
+        if (!container || !notification) {
+            return;
+        }
+
+        const popup = document.createElement('div');
+        popup.className = 'ws-notification-popup';
+        popup.dataset.id = String(notification.id || '');
+
+        const safeTitle = notification.title || 'Уведомление';
+        const bodyHtml = notification.message_html || notification.message || '';
+
+        popup.innerHTML =
+            '<div class="ws-notification-header">' +
+            '<strong>' + safeTitle + '</strong>' +
+            '<button type="button" class="btn-close btn-sm ws-notification-close" aria-label="Закрыть"></button>' +
+            '</div>' +
+            '<div class="ws-notification-body">' + bodyHtml + '</div>';
+
+        const closeBtn = popup.querySelector('.ws-notification-close');
+        if (closeBtn) {
+            closeBtn.addEventListener('click', function (event) {
+                event.stopPropagation();
+                markNotificationRead(notification.id);
+                popup.remove();
+            });
+        }
+
+        container.appendChild(popup);
+    }
+
     cleanupStaleModalState();
     window.addEventListener('pageshow', cleanupStaleModalState);
 
     if ($('.main-alert').length) {
         setTimeout(function () {
             $('.main-alert').fadeTo(2000, 500).slideUp(500, function () {
-                $(".main-alert").slideUp(500);
-            })
+                $('.main-alert').slideUp(500);
+            });
         }, 3000);
     }
 
-    let user = localStorage.getItem('user');
+    const user = localStorage.getItem('user');
     if (user > 0) {
-        let socket = new WebSocket(localStorage.getItem('socketAddress'));
+        const socket = new WebSocket(localStorage.getItem('socketAddress'));
         socket.onopen = function () {
-            console.log("[WS] Connected. Listen messages for user " + user);
+            console.log('[WS] Connected. Listen messages for user ' + user);
         };
 
         socket.onmessage = function (event) {
-            let received = JSON.parse(event.data);
-            if (parseInt(received.data.user_id) === parseInt(user)) {
-                console.log(received);
-                console.log(`[WS] Received data action: ${received.data.action}. Message: ${received.data.message}`);
-                if(received.data.payload.download) {
-                   document.location.href = '/storage/export/' + received.data.payload.download;
-                }
-                if(received.data.payload.link) {
-                    document.location.href = received.data.payload.link;
-                    setTimeout(function () {
-                        document.location.reload();
-                    }, 2000);
+            const received = JSON.parse(event.data);
+            if (parseInt(received.data.user_id, 10) !== parseInt(user, 10)) {
+                return;
+            }
 
-                }
+            const action = received.data.action;
+            if (action === 'persistent_notification') {
+                incrementNotificationBadge();
+                appendWsPopup(received.data.notification);
+                return;
+            }
 
-                setTimeout(function () {
-                        if (received.data.payload.error) {
-                            $('.alerts').append('<div class="main-alert2 alert alert-danger" role="alert">' + received.data.message + '</div>');
-                        } else {
-                            $('.alerts').append('<div class="main-alert2 alert alert-success" role="alert">' + received.data.message + '</div>');
-                        }
-                        setTimeout(function () {
-                            $('.main-alert2').fadeTo(2000, 500).slideUp(500, function () {
-                                $(".main-alert2").slideUp(500);
-                            })
-                        }, 3000);
-                    }, 1000
-                );
+            if (action !== 'message') {
+                return;
+            }
 
+            if (received.data.payload.download) {
+                document.location.href = '/storage/export/' + received.data.payload.download;
+            }
 
+            if (received.data.payload.link) {
+                document.location.href = received.data.payload.link;
+                setTimeout(function () {
+                    document.location.reload();
+                }, 2000);
             }
 
+            setTimeout(function () {
+                if (received.data.payload.error) {
+                    $('.alerts').append('<div class="main-alert2 alert alert-danger" role="alert">' + received.data.message + '</div>');
+                } else {
+                    $('.alerts').append('<div class="main-alert2 alert alert-success" role="alert">' + received.data.message + '</div>');
+                }
+                setTimeout(function () {
+                    $('.main-alert2').fadeTo(2000, 500).slideUp(500, function () {
+                        $('.main-alert2').slideUp(500);
+                    });
+                }, 3000);
+            }, 1000);
         };
 
         socket.onclose = function (event) {
             if (event.wasClean) {
-                console.log(`[WS] Closed clear, code=${event.code} reason=${event.reason}`);
+                console.log('[WS] Closed clear, code=' + event.code + ' reason=' + event.reason);
             } else {
                 console.log('[WS] Connection lost', event);
             }
         };
+
         socket.onerror = function (error) {
-            console.log(`[error] ${error}`);
+            console.log('[error]', error);
         };
-
     }
 });

+ 81 - 0
resources/sass/app.scss

@@ -547,3 +547,84 @@
     justify-content: flex-start;
   }
 }
+
+.notification-bell-link {
+  position: relative;
+}
+
+.notification-badge-dot {
+  position: absolute;
+  top: -2px;
+  right: -2px;
+  width: 9px;
+  height: 9px;
+  border-radius: 50%;
+  background: #dc3545;
+}
+
+.notifications-list {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.notification-item {
+  border: 1px solid #ddd;
+  border-radius: 8px;
+  padding: 10px 12px;
+  cursor: pointer;
+}
+
+.notification-unread {
+  background: #fce7ea;
+}
+
+.notification-read-platform {
+  background: #e7f1ff;
+}
+
+.notification-read-reclamation {
+  background: #e9f8ef;
+}
+
+.notification-read-schedule {
+  background: #fff4e6;
+}
+
+.ws-notification-container {
+  position: fixed;
+  right: 16px;
+  bottom: 16px;
+  z-index: 2000;
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  width: min(360px, calc(100vw - 24px));
+}
+
+.ws-notification-popup {
+  border: 1px solid #d8d8d8;
+  border-radius: 8px;
+  background: #fff;
+  box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15);
+  padding: 10px;
+}
+
+.ws-notification-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 6px;
+}
+
+.ws-notification-body {
+  font-size: 14px;
+}
+
+@media (max-width: 767.98px) {
+  .notification-bell-link {
+    display: inline-flex !important;
+    margin-left: auto;
+  }
+}

+ 17 - 0
resources/views/admin/notifications/log.blade.php

@@ -0,0 +1,17 @@
+@extends('layouts.app')
+
+@section('content')
+    <div class="row mb-2">
+        <div class="col-md-6">
+            <h3>Журнал уведомлений</h3>
+        </div>
+    </div>
+
+    @include('partials.table', [
+        'id' => $id,
+        'header' => $header,
+        'strings' => $logs,
+    ])
+
+    @include('partials.pagination', ['items' => $logs])
+@endsection

+ 15 - 0
resources/views/layouts/app.blade.php

@@ -23,6 +23,9 @@
     @vite(['resources/sass/app.scss', 'resources/js/app.js'])
 </head>
 <body>
+    @php
+        $unreadNotificationsCount = auth()->check() ? auth()->user()->unreadUserNotifications()->count() : 0;
+    @endphp
     <div class="alerts">
         @if($message = session('success'))
             @php
@@ -84,6 +87,16 @@
                 <a class="navbar-brand" href="{{ url('/') }}">
                     {{ config('app.name', 'Laravel') }}
                 </a>
+                @auth
+                    <a href="{{ route('notifications.index') }}" class="nav-link notification-bell-link d-flex align-items-center ms-auto me-2">
+                        <span class="position-relative d-inline-block">
+                            <i class="bi bi-bell fs-5"></i>
+                            <span id="notification-badge"
+                                  data-count="{{ $unreadNotificationsCount }}"
+                                  class="notification-badge-dot @if($unreadNotificationsCount < 1) d-none @endif"></span>
+                        </span>
+                    </a>
+                @endauth
                 <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Открыть меню">
                     <span class="navbar-toggler-icon"></span>
                 </button>
@@ -160,6 +173,7 @@
             </div>
         </main>
     </div>
+    <div id="ws-notification-container" class="ws-notification-container"></div>
 
     @include('partials.customAlert')
 
@@ -167,6 +181,7 @@
         // Глобальные настройки (без jQuery)
         var user = {{ auth()->user()?->id ?? 0}};
         var socketAddress = '{{ config('app.ws_addr') }}';
+        var notificationsReadUrlTemplate = '{{ url('notifications/__id__/read') }}';
         var customConfirmCallback = null;
         localStorage.setItem('user', user);
         localStorage.setItem('socketAddress', socketAddress);

+ 1 - 0
resources/views/layouts/menu.blade.php

@@ -38,6 +38,7 @@
                 <li class="dropdown-item"><a class="nav-link" href="{{ route('admin.settings.index') }}">Настройки</a></li>
                 <li class="dropdown-item"><a class="nav-link" href="{{ route('admin.district.index') }}">Округа</a></li>
                 <li class="dropdown-item"><a class="nav-link" href="{{ route('admin.area.index') }}">Районы</a></li>
+                <li class="dropdown-item"><a class="nav-link" href="{{ route('admin.notifications.log') }}">Журнал уведомлений</a></li>
                 <li class="dropdown-item"><a class="nav-link" href="{{ route('import.index', session('gp_import')) }}">Импорт</a></li>
                 <li class="dropdown-item"><a class="nav-link" href="{{ route('year-data.index') }}">Экспорт/Импорт года</a></li>
                 <li class="dropdown-item"><a class="nav-link" href="{{ route('clear-data.index') }}">Удалить данные</a></li>

+ 66 - 0
resources/views/notifications/index.blade.php

@@ -0,0 +1,66 @@
+@extends('layouts.app')
+
+@section('content')
+
+    @include('partials.table', [
+        'id'      => $id,
+        'header'  => $header,
+        'strings' => $notifications,
+    ])
+
+    @include('partials.pagination', ['items' => $notifications])
+
+@endsection
+
+@push('scripts')
+    <script type="module">
+        function updateNotificationBadge(count) {
+            const badge = document.getElementById('notification-badge');
+            if (!badge) return;
+            const safeCount = Math.max(0, parseInt(count || 0, 10));
+            badge.dataset.count = String(safeCount);
+            badge.classList.toggle('d-none', safeCount === 0);
+        }
+
+        async function markNotificationRead(id, $row) {
+            const response = await fetch(`/notifications/${id}/read`, {
+                method: 'POST',
+                headers: {
+                    'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
+                    'Accept': 'application/json',
+                },
+            });
+
+            if (response.ok) {
+                const data = await response.json();
+                if (typeof data.unread !== 'undefined') {
+                    updateNotificationBadge(data.unread);
+                }
+                if ($row) {
+                    $row.removeClass('notification-unread');
+                    const typeClass = $row.data('read-class');
+                    if (typeClass) $row.addClass(typeClass);
+                    $row.data('notification-read', '1');
+                }
+            }
+        }
+
+        $(document).on('click', '#tbl tbody tr[data-notification-id]', function (e) {
+            const $row = $(this);
+            const id = $row.data('notification-id');
+            const isRead = $row.data('notification-read') === '1' || $row.data('notification-read') === 1;
+
+            if (!isRead && id) {
+                const link = $(e.target).closest('a');
+                if (link.length && link.attr('href')) {
+                    e.preventDefault();
+                    markNotificationRead(id, $row).then(() => {
+                        window.location.href = link.attr('href');
+                    });
+                    return;
+                }
+                markNotificationRead(id, $row);
+            }
+        });
+    </script>
+@endpush

+ 51 - 0
resources/views/partials/notification-settings-table.blade.php

@@ -0,0 +1,51 @@
+
+<div class="table-responsive">
+    <table class="table table-bordered">
+        <thead>
+            <tr>
+                <th>Статус/Источник</th>
+                @foreach($channels as $channelKey => $channelName)
+                    <th class="text-center">
+                        {{ $channelName }}
+                        @if(!empty($disabledChannels[$channelKey]))
+                            <br><small class="text-muted">недоступен</small>
+                        @endif
+                    </th>
+                @endforeach
+            </tr>
+        </thead>
+        <tbody>
+            @foreach($sections as $section)
+                <tr>
+                    <td colspan="{{ count($channels) + 1 }}" style="background-color: #d4edda;">
+                        <strong>{{ $section['title'] }}</strong>
+                    </td>
+                </tr>
+                @foreach($section['options'] as $optionKey => $optionName)
+                    <tr>
+                        <td>
+                            @if(!empty($section['colors'][$optionKey]))
+                                <span class="badge fs-6 text-bg-{{ $section['colors'][$optionKey] }}">{{ $optionName }}</span>
+                            @else
+                                {{ $optionName }}
+                            @endif
+                        </td>
+                        @foreach($channels as $channelKey => $channelName)
+                            @php($isDisabled = !empty($disabledChannels[$channelKey]))
+                            <td class="text-center">
+                                <input class="form-check-input" type="checkbox"
+                                       name="notification_settings[{{ $section['settingsKey'] }}][{{ $optionKey }}][{{ $channelKey }}]"
+                                       value="1"
+                                       @disabled($isDisabled)
+                                       @if(!$isDisabled)
+                                           @checked(old("notification_settings.{$section['settingsKey']}.{$optionKey}.{$channelKey}", $section['settings'][$optionKey][$channelKey] ?? false))
+                                       @endif
+                                >
+                            </td>
+                        @endforeach
+                    </tr>
+                @endforeach
+            @endforeach
+        </tbody>
+    </table>
+</div>

+ 20 - 0
resources/views/partials/table.blade.php

@@ -88,6 +88,12 @@
             <tr
                 @if($rowAnchor) id="{{ $rowAnchor }}" data-row-id="{{ $rowId }}" @endif
                 @if($rowHref) data-row-href="{{ $rowHref }}" @endif
+                @if($id === 'notifications')
+                    data-notification-id="{{ $string->id }}"
+                    data-notification-read="{{ $string->isRead() ? '1' : '0' }}"
+                    data-read-class="{{ match($string->type) { 'reclamation' => 'notification-read-reclamation', 'platform' => 'notification-read-platform', 'schedule' => 'notification-read-schedule', default => 'notification-read-platform' } }}"
+                    class="{{ $string->isRead() ? match($string->type) { 'reclamation' => 'notification-read-reclamation', 'platform' => 'notification-read-platform', 'schedule' => 'notification-read-schedule', default => 'notification-read-platform' } : 'notification-unread' }}"
+                @endif
             >
                 @foreach($header as $headerName => $headerTitle)
                     <td class="column_{{$headerName}}"
@@ -208,6 +214,20 @@
                                 @method('DELETE')
                                 <button type="submit" class="btn btn-sm btn-danger">Удалить</button>
                             </form>
+                        @elseif($id === 'notifications' && $headerName === 'type')
+                            <span class="badge text-bg-{{ \App\Models\UserNotification::TYPE_COLORS[$string->type] ?? 'secondary' }}">{{ $string->type_name }}</span>
+                        @elseif($id === 'notifications' && $headerName === 'event')
+                            {{ $string->event_name }}
+                        @elseif($id === 'notifications' && $headerName === 'message')
+                            {!! $string->message_html ?: e($string->message) !!}
+                        @elseif($id === 'notifications' && $headerName === 'read_at')
+                            @if($string->read_at)
+                                {{ $string->read_at->format('d.m.Y H:i') }}
+                            @else
+                                <span class="text-muted">—</span>
+                            @endif
+                        @elseif($id === 'notifications' && $headerName === 'created_at')
+                            {{ $string->created_at?->format('d.m.Y H:i') }}
                         @elseif($headerName === 'actions' && isset($routeName) && isset($string->id))
                             <a href="{{ route($routeName, $string->id) }}" class="btn btn-sm btn-outline-primary">
                                 Редактировать

+ 44 - 13
resources/views/users/edit.blade.php

@@ -10,22 +10,53 @@
                     <input type="hidden" name="id" value="{{ $user->id }}">
                 @endif
 
-                @include('partials.input', ['name' => 'email',
-                                            'type' => 'text',
-                                            'title' => 'Логин/email',
-                                            'required' => true,
-                                            'value' => $user->email ?? '',
-                                            'disabled' => ($user && $user->email_verified_at),
-                                            ])
-                @include('partials.input', ['name' => 'name', 'title' => 'Имя', 'required' => true, 'value' => $user->name ?? ''])
-                @include('partials.input', ['name' => 'phone', 'title' => 'Телефон', 'value' => $user->phone ?? ''])
+                <ul class="nav nav-tabs mb-3" role="tablist">
+                    <li class="nav-item" role="presentation">
+                        <button class="nav-link active" id="main-tab" data-bs-toggle="tab" data-bs-target="#main-pane" type="button" role="tab">Основное</button>
+                    </li>
+                    <li class="nav-item" role="presentation">
+                        <button class="nav-link" id="notifications-tab" data-bs-toggle="tab" data-bs-target="#notifications-pane" type="button" role="tab">Уведомления</button>
+                    </li>
+                </ul>
 
-{{--                @include('partials.avatars', ['user' => $user])--}}
-                @include('partials.input', ['name' => 'password', 'type' => 'password', 'title' => 'Пароль'])
+                <div class="tab-content">
+                    <div class="tab-pane fade show active" id="main-pane" role="tabpanel" aria-labelledby="main-tab">
+                        @include('partials.input', ['name' => 'email',
+                                                    'type' => 'text',
+                                                    'title' => 'Логин/email',
+                                                    'required' => true,
+                                                    'value' => $user->email ?? '',
+                                                    'disabled' => ($user && $user->email_verified_at),
+                                                    ])
+                        @include('partials.input', ['name' => 'notification_email',
+                                                    'type' => 'email',
+                                                    'title' => 'Email для уведомлений',
+                                                    'value' => old('notification_email', $user->notification_email ?? '')])
+                        @include('partials.input', ['name' => 'name', 'title' => 'Имя', 'required' => true, 'value' => $user->name ?? ''])
+                        @include('partials.input', ['name' => 'phone', 'title' => 'Телефон', 'value' => $user->phone ?? ''])
 
-                @include('partials.select', ['name' => 'role', 'title' => 'Роль', 'options' => getRoles(), 'value' => $user->role ?? \App\Models\Role::MANAGER])
+                        @include('partials.input', ['name' => 'password', 'type' => 'password', 'title' => 'Пароль'])
+
+                        @include('partials.select', ['name' => 'role', 'title' => 'Роль', 'options' => getRoles(), 'value' => $user->role ?? \App\Models\Role::MANAGER])
+
+                        @include('partials.input', ['name' => 'color', 'title' => 'Цвет', 'value' => $user->color ?? '#FFFFFF', 'type' => 'color'])
+                    </div>
+
+                    <div class="tab-pane fade" id="notifications-pane" role="tabpanel" aria-labelledby="notifications-tab">
+                        @php($settings = $notificationSettings ?? [])
+
+                        @include('partials.notification-settings-table', [
+                            'channels' => $notificationChannels,
+                            'disabledChannels' => $disabledChannels ?? [],
+                            'sections' => [
+                                ['title' => 'Площадки', 'settingsKey' => 'orders', 'options' => $orderStatusOptions, 'colors' => $orderStatusColors, 'settings' => $settings['order_settings'] ?? []],
+                                ['title' => 'Рекламации', 'settingsKey' => 'reclamations', 'options' => $reclamationStatusOptions, 'colors' => $reclamationStatusColors, 'settings' => $settings['reclamation_settings'] ?? []],
+                                ['title' => 'График монтажей', 'settingsKey' => 'schedule', 'options' => $scheduleSourceOptions, 'colors' => [], 'settings' => $settings['schedule_settings'] ?? []],
+                            ],
+                        ])
+                    </div>
+                </div>
 
-                @include('partials.input', ['name' => 'color', 'title' => 'Цвет', 'value' => $user->color ?? '#FFFFFF', 'type' => 'color'])
                 @if($user && !is_null($user->deleted_at))
                     <div class="col-12 text-center">
                         <div class="text-danger">ПОЛЬЗОВАТЕЛЬ УДАЛЁН!!!</div>

+ 6 - 0
routes/web.php

@@ -2,6 +2,7 @@
 
 use App\Http\Controllers\Admin\AdminAreaController;
 use App\Http\Controllers\Admin\AdminDistrictController;
+use App\Http\Controllers\Admin\AdminNotificationLogController;
 use App\Http\Controllers\Admin\AdminSettingsController;
 use App\Http\Controllers\AreaController;
 use App\Http\Controllers\ClearDataController;
@@ -23,6 +24,7 @@ use App\Http\Controllers\SparePartInventoryController;
 use App\Http\Controllers\SparePartOrderController;
 use App\Http\Controllers\SparePartReservationController;
 use App\Http\Controllers\UserController;
+use App\Http\Controllers\UserNotificationController;
 use App\Models\PricingCode;
 use App\Models\Role;
 use Illuminate\Http\Request;
@@ -100,6 +102,7 @@ Route::middleware('auth:web')->group(function () {
             Route::get('', [AdminSettingsController::class, 'index'])->name('admin.settings.index');
             Route::post('', [AdminSettingsController::class, 'store'])->name('admin.settings.store');
         });
+        Route::get('notifications/log', [AdminNotificationLogController::class, 'index'])->name('admin.notifications.log');
 
         // Справочник округов
         Route::prefix('districts')->group(function (){
@@ -129,6 +132,9 @@ Route::middleware('auth:web')->group(function () {
     Route::post('profile/store', [UserController::class, 'storeProfile'])->name('profile.store');
     Route::delete('profile', [UserController::class, 'deleteProfile'])->name('profile.delete');
     Route::post('impersonate/leave', [UserController::class, 'leaveImpersonation'])->name('user.impersonate.leave');
+    Route::get('notifications', [UserNotificationController::class, 'index'])->name('notifications.index');
+    Route::post('notifications/{notification}/read', [UserNotificationController::class, 'markRead'])->name('notifications.read');
+    Route::get('notifications/unread/count', [UserNotificationController::class, 'unreadCount'])->name('notifications.unread-count');
 
     Route::get('get-filters', [FilterController::class, 'getFilters'])->name('getFilters');