11 Angajamente d321e48527 ... 662dc6040b

Autor SHA1 Permisiunea de a trimite mesaje. Dacă este dezactivată, utilizatorul nu va putea trimite nici un fel de mesaj Data
  Alexander Musikhin 662dc6040b feat(chat): add user targeting and notification tracking for chat messages 1 lună în urmă
  Alexander Musikhin 55ca3ca0a6 fix styles 1 lună în urmă
  Alexander Musikhin d798e1fc12 chats 1 lună în urmă
  Alexander Musikhin 305f5a9f09 changed visibility scopes for brigadier 1 lună în urmă
  Alexander Musikhin 57577f5d2f export for manager 1 lună în urmă
  Alexander Musikhin 2d8ef6b0ae export with filters 1 lună în urmă
  Alexander Musikhin 08ec4cfc80 add reason to reclamation act 1 lună în urmă
  Alexander Musikhin 4bc649b3ce fix 403 1 lună în urmă
  Alexander Musikhin 929505a9ea read all notifications 1 lună în urmă
  Alexander Musikhin cde950b100 fixed search 1 lună în urmă
  Alexander Musikhin ccff65eac9 fixed sum in spare, css 1 lună în urmă
44 a modificat fișierele cu 2180 adăugiri și 193 ștergeri
  1. 0 17
      .mcp.json
  2. 229 0
      app/Http/Controllers/ChatMessageController.php
  3. 90 9
      app/Http/Controllers/OrderController.php
  4. 9 5
      app/Http/Controllers/ProductController.php
  5. 47 6
      app/Http/Controllers/ReclamationController.php
  6. 63 1
      app/Http/Controllers/ScheduleController.php
  7. 9 0
      app/Http/Controllers/UserController.php
  8. 17 0
      app/Http/Controllers/UserNotificationController.php
  9. 64 0
      app/Models/ChatMessage.php
  10. 17 1
      app/Models/Order.php
  11. 17 0
      app/Models/Reclamation.php
  12. 2 1
      app/Models/ReclamationView.php
  13. 6 6
      app/Models/SparePart.php
  14. 2 0
      app/Models/UserNotification.php
  15. 3 0
      app/Models/UserNotificationSetting.php
  16. 22 3
      app/Services/GenerateDocumentsService.php
  17. 8 11
      app/Services/Import/ImportSparePartsService.php
  18. 194 6
      app/Services/NotificationService.php
  19. 31 0
      database/migrations/2026_03_22_100000_create_chat_messages_table.php
  20. 25 0
      database/migrations/2026_03_22_100100_create_chat_message_file_table.php
  21. 22 0
      database/migrations/2026_03_22_100200_add_chat_settings_to_user_notification_settings_table.php
  22. 22 0
      database/migrations/2026_03_22_100300_create_chat_message_user_table.php
  23. 163 0
      resources/sass/app.scss
  24. 1 1
      resources/views/catalog/index.blade.php
  25. 66 10
      resources/views/notifications/index.blade.php
  26. 37 6
      resources/views/orders/index.blade.php
  27. 12 1
      resources/views/orders/show.blade.php
  28. 491 0
      resources/views/partials/chat.blade.php
  29. 1 1
      resources/views/partials/notification-settings-table.blade.php
  30. 1 1
      resources/views/partials/table.blade.php
  31. 24 5
      resources/views/reclamations/edit.blade.php
  32. 30 6
      resources/views/reclamations/index.blade.php
  33. 4 4
      resources/views/reports/index.blade.php
  34. 29 71
      resources/views/schedule/index.blade.php
  35. 16 16
      resources/views/spare_parts/edit.blade.php
  36. 3 1
      resources/views/spare_parts/index.blade.php
  37. 1 0
      resources/views/users/edit.blade.php
  38. 11 2
      routes/web.php
  39. 67 2
      tests/Feature/OrderControllerTest.php
  40. 44 0
      tests/Feature/ReclamationControllerTest.php
  41. 99 0
      tests/Feature/ScheduleControllerTest.php
  42. 61 0
      tests/Feature/UserNotificationControllerTest.php
  43. 22 0
      tests/Unit/Models/SparePartTest.php
  44. 98 0
      tests/Unit/Services/Import/ImportSparePartsServiceTest.php

+ 0 - 17
.mcp.json

@@ -1,17 +0,0 @@
-{
-  "mcpServers": {
-    "chromeDevtools": {
-      "command": "npx",
-      "args": [
-        "-y",
-        "chrome-devtools-mcp@latest",
-        "--no-usage-statistics"
-      ]
-    },
-    "github": {
-      "command": "npx",
-      "args": ["-y", "@modelcontextprotocol/server-github"],
-      "env": { "GITHUB_TOKEN": "${GITHUB_TOKEN}" }
-    }
-  }
-}

+ 229 - 0
app/Http/Controllers/ChatMessageController.php

@@ -0,0 +1,229 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\ChatMessage;
+use App\Models\Order;
+use App\Models\Reclamation;
+use App\Models\Role;
+use App\Models\User;
+use App\Services\FileService;
+use App\Services\NotificationService;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
+use Throwable;
+
+class ChatMessageController extends Controller
+{
+    public function storeForOrder(
+        Request $request,
+        Order $order,
+        FileService $fileService,
+        NotificationService $notificationService,
+    ): RedirectResponse {
+        $this->ensureCanViewOrder($order);
+
+        return $this->storeMessage(
+            $request,
+            $fileService,
+            $notificationService,
+            $order,
+            null,
+            'chat/orders/' . $order->id,
+        );
+    }
+
+    public function storeForReclamation(
+        Request $request,
+        Reclamation $reclamation,
+        FileService $fileService,
+        NotificationService $notificationService,
+    ): RedirectResponse {
+        $this->ensureCanViewReclamation($reclamation);
+
+        return $this->storeMessage(
+            $request,
+            $fileService,
+            $notificationService,
+            null,
+            $reclamation,
+            'chat/reclamations/' . $reclamation->id,
+        );
+    }
+
+    private function storeMessage(
+        Request $request,
+        FileService $fileService,
+        NotificationService $notificationService,
+        ?Order $order,
+        ?Reclamation $reclamation,
+        string $filePath,
+    ): RedirectResponse {
+        $isPrivileged = hasRole(Role::ADMIN . ',' . Role::MANAGER);
+
+        $validated = $request->validate([
+            'message' => 'nullable|string',
+            'notification_type' => 'nullable|string|in:none,responsibles,all,user',
+            'target_user_id' => 'nullable|integer|exists:users,id,deleted_at,NULL',
+            'target_user_ids' => 'nullable|array',
+            'target_user_ids.*' => 'integer|exists:users,id,deleted_at,NULL',
+            'attachments' => 'nullable|array|max:5',
+            'attachments.*' => 'file|max:10240',
+        ]);
+
+        $notificationType = $validated['notification_type'] ?? ChatMessage::NOTIFICATION_NONE;
+        if (!$isPrivileged) {
+            $notificationType = ChatMessage::NOTIFICATION_NONE;
+        }
+
+        $messageText = trim((string) ($validated['message'] ?? ''));
+        $attachments = $request->file('attachments', []);
+
+        if ($messageText === '' && empty($attachments)) {
+            return redirect()->back()->with(['danger' => 'Нужно указать сообщение или добавить файл.']);
+        }
+
+        $recipientIds = [];
+        $targetUserId = null;
+        if ($notificationType === ChatMessage::NOTIFICATION_USER) {
+            $targetUserId = (int) ($validated['target_user_id'] ?? 0);
+            if ($targetUserId < 1) {
+                return redirect()->back()->with(['danger' => 'Нужно выбрать пользователя для уведомления.']);
+            }
+
+            $recipientIds = [$targetUserId];
+        }
+
+        if (in_array($notificationType, [
+            ChatMessage::NOTIFICATION_RESPONSIBLES,
+            ChatMessage::NOTIFICATION_ALL,
+        ], true)) {
+            $recipientIds = $this->resolveTargetUserIds(
+                $notificationType,
+                $validated['target_user_ids'] ?? [],
+                $order,
+                $reclamation,
+                (int) auth()->id(),
+            );
+
+            if (empty($recipientIds)) {
+                return redirect()->back()->with(['danger' => 'Нужно выбрать хотя бы одного получателя уведомления.']);
+            }
+        }
+
+        if (!in_array($notificationType, [ChatMessage::NOTIFICATION_USER], true)) {
+            $targetUserId = null;
+        }
+
+        try {
+            $chatMessage = ChatMessage::query()->create([
+                'order_id' => $order?->id,
+                'reclamation_id' => $reclamation?->id,
+                'user_id' => (int) auth()->id(),
+                'target_user_id' => $targetUserId,
+                'notification_type' => $notificationType,
+                'message' => $messageText !== '' ? $messageText : null,
+            ]);
+
+            if (!empty($recipientIds)) {
+                $chatMessage->notifiedUsers()->syncWithoutDetaching($recipientIds);
+            }
+
+            $files = [];
+            foreach ($attachments as $attachment) {
+                $files[] = $fileService->saveUploadedFile($filePath, $attachment);
+            }
+
+            if (!empty($files)) {
+                $chatMessage->files()->syncWithoutDetaching(collect($files)->pluck('id')->all());
+            }
+
+            if ($notificationType !== ChatMessage::NOTIFICATION_NONE) {
+                $notificationService->notifyChatMessage($chatMessage->fresh([
+                    'user',
+                    'targetUser',
+                    'files',
+                    'order.user',
+                    'order.brigadier',
+                    'reclamation.order',
+                    'reclamation.user',
+                    'reclamation.brigadier',
+                ]), $recipientIds);
+            }
+        } catch (Throwable $exception) {
+            report($exception);
+
+            return redirect()->back()->with(['error' => 'Не удалось отправить сообщение в чат.']);
+        }
+
+        return redirect()->back()->with(['success' => 'Сообщение отправлено.']);
+    }
+
+    private function ensureCanViewOrder(Order $order): void
+    {
+        if (hasRole(Role::BRIGADIER)) {
+            $canView = (int) $order->brigadier_id === (int) auth()->id()
+                && in_array((int) $order->order_status_id, Order::visibleStatusIdsForBrigadier(), true);
+
+            if (!$canView) {
+                abort(403);
+            }
+        }
+    }
+
+    private function ensureCanViewReclamation(Reclamation $reclamation): void
+    {
+        if (hasRole(Role::BRIGADIER)) {
+            $canView = (int) $reclamation->brigadier_id === (int) auth()->id()
+                && in_array((int) $reclamation->status_id, Reclamation::visibleStatusIdsForBrigadier(), true);
+
+            if (!$canView) {
+                abort(403);
+            }
+        }
+    }
+
+    private function resolveTargetUserIds(
+        string $notificationType,
+        array $targetUserIds,
+        ?Order $order,
+        ?Reclamation $reclamation,
+        int $senderId,
+    ): array {
+        $selectedIds = array_values(array_unique(array_map(static fn ($id) => (int) $id, $targetUserIds)));
+        $allowedIds = match ($notificationType) {
+            ChatMessage::NOTIFICATION_RESPONSIBLES => $this->chatResponsibleRecipientIds($order, $reclamation),
+            ChatMessage::NOTIFICATION_ALL => User::query()->pluck('id')->map(static fn ($id) => (int) $id)->all(),
+            default => [],
+        };
+
+        $selectedIds = array_values(array_intersect($selectedIds, $allowedIds));
+
+        return array_values(array_diff($selectedIds, [$senderId]));
+    }
+
+    private function chatResponsibleRecipientIds(?Order $order, ?Reclamation $reclamation): array
+    {
+        $adminIds = User::query()
+            ->where('role', Role::ADMIN)
+            ->pluck('id')
+            ->map(static fn ($id) => (int) $id)
+            ->all();
+
+        if ($order) {
+            return array_values(array_unique(array_filter(array_merge($adminIds, [
+                $order->user_id ? (int) $order->user_id : null,
+                $order->brigadier_id ? (int) $order->brigadier_id : null,
+            ]))));
+        }
+
+        if ($reclamation) {
+            return array_values(array_unique(array_filter(array_merge($adminIds, [
+                $reclamation->user_id ? (int) $reclamation->user_id : null,
+                $reclamation->brigadier_id ? (int) $reclamation->brigadier_id : null,
+            ]))));
+        }
+
+        return $adminIds;
+    }
+}

+ 90 - 9
app/Http/Controllers/OrderController.php

@@ -29,8 +29,8 @@ use App\Services\FileService;
 use App\Services\NotificationService;
 use Illuminate\Http\RedirectResponse;
 use Illuminate\Http\Request;
-use Illuminate\Support\Str;
 use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Str;
 use Illuminate\Support\Facades\Storage;
 use Symfony\Component\HttpFoundation\BinaryFileResponse;
 use Throwable;
@@ -104,7 +104,8 @@ class OrderController extends Controller
         $this->setSortAndOrderBy($model, $request);
 
         if(hasRole('brigadier')) {
-            $q->where('brigadier_id', auth()->id());
+            $q->where('brigadier_id', auth()->id())
+                ->whereIn('order_status_id', Order::visibleStatusIdsForBrigadier());
         }
 
         if(hasRole(Role::WAREHOUSE_HEAD)) {
@@ -260,7 +261,15 @@ class OrderController extends Controller
      */
     public function show(Request $request, int $order)
     {
-        $this->data['order'] = Order::query()->withoutGlobalScope(\App\Models\Scopes\YearScope::class)->find($order);
+        $this->data['order'] = Order::query()
+            ->withoutGlobalScope(\App\Models\Scopes\YearScope::class)
+            ->with([
+                'chatMessages.user',
+                'chatMessages.targetUser',
+                'chatMessages.notifiedUsers',
+                'chatMessages.files',
+            ])
+            ->find($order);
 
         if ($request->boolean('sync_year') && $this->data['order']) {
             $previousYear = year();
@@ -272,11 +281,40 @@ class OrderController extends Controller
             }
         }
 
+        if (hasRole('brigadier') && $this->data['order']) {
+            $canView = (int)$this->data['order']->brigadier_id === (int)auth()->id()
+                && in_array((int)$this->data['order']->order_status_id, Order::visibleStatusIdsForBrigadier(), true);
+
+            if (!$canView) {
+                abort(403);
+            }
+        }
+
         $this->data['previous_url'] = $this->resolvePreviousUrl(
             $request,
             'previous_url_orders',
             route('order.index', session('gp_orders'))
         );
+        $orderModel = $this->data['order'];
+        $chatUsers = User::query()->orderBy('name')->get(['id', 'name', 'role']);
+        $responsibleUserIds = User::query()
+            ->where('role', Role::ADMIN)
+            ->pluck('id')
+            ->map(static fn ($id) => (int) $id)
+            ->all();
+        $responsibleUserIds = array_values(array_unique(array_filter(array_merge($responsibleUserIds, [
+            $orderModel?->user_id ? (int) $orderModel->user_id : null,
+            $orderModel?->brigadier_id ? (int) $orderModel->brigadier_id : null,
+        ]))));
+
+        $this->data['chatUsers'] = $chatUsers->map(fn ($u) => [
+            'id' => $u->id,
+            'name' => $u->name,
+            'role' => $u->role,
+        ])->keyBy('id');
+        $this->data['chatResponsibleUserIds'] = $responsibleUserIds;
+        $this->data['chatManagerUserId'] = $orderModel?->user_id ? (int) $orderModel->user_id : null;
+        $this->data['chatBrigadierUserId'] = $orderModel?->brigadier_id ? (int) $orderModel->brigadier_id : null;
         return view('orders.show', $this->data);
     }
 
@@ -454,7 +492,7 @@ class OrderController extends Controller
         $ret = [];
         $s = $request->get('s');
         $searchFields = $this->data['searchFields'];
-        $result = Order::query();
+        $result = OrderView::query();
         if($s) {
             $result->where(function ($query) use ($searchFields, $s) {
                 foreach ($searchFields as $searchField) {
@@ -640,14 +678,57 @@ class OrderController extends Controller
 
     public function export(Request $request)
     {
+        $request->validate([
+            'withFilter' => 'nullable',
+            'filters' => 'nullable|array',
+            's' => 'nullable|string',
+        ]);
 
-        $schedules = Order::query()
-            ->get();
+        $orders = Order::query();
+
+        if ($request->boolean('withFilter')) {
+            $filterRequest = new Request(array_filter([
+                'filters' => $request->input('filters', []),
+                's' => $request->input('s'),
+                'sortBy' => $request->input('sortBy'),
+                'order' => $request->input('order'),
+            ], static fn ($value) => $value !== null));
+
+            $model = new OrderView;
+            $this->createFilters($model, 'user_name', 'district_name', 'area_name', 'object_type_name', 'brigadier_name', 'order_status_name', 'ready_to_mount');
+            $this->createDateFilters($model, 'installation_date', 'ready_date');
+            $this->data['ranges'] = [];
+
+            $q = $model::query();
+            $this->acceptFilters($q, $filterRequest);
+            $this->acceptSearch($q, $filterRequest);
+            $this->setSortAndOrderBy($model, $filterRequest);
+
+            if (hasRole('brigadier')) {
+                $q->where('brigadier_id', auth()->id())
+                    ->whereIn('order_status_id', Order::visibleStatusIdsForBrigadier());
+            }
 
-        ExportOrdersJob::dispatch($schedules, $request->user()->id);
-        return redirect()->route('order.index')
-            ->with(['success' => 'Задача выгрузки создана!']);
+            if (hasRole(Role::WAREHOUSE_HEAD)) {
+                $q->whereNotNull('brigadier_id');
+                $q->whereNotNull('installation_date');
+            }
 
+            $this->applyStableSorting($q);
+            $orderIds = $q->pluck('id');
+
+            $orders = Order::query()
+                ->whereIn('id', $orderIds)
+                ->get()
+                ->sortBy(static fn (Order $order) => $orderIds->search($order->id))
+                ->values();
+        } else {
+            $orders = $orders->get();
+        }
+
+        ExportOrdersJob::dispatch($orders, $request->user()->id);
+        return redirect()->route('order.index', session('gp_orders'))
+            ->with(['success' => 'Задача выгрузки создана!']);
     }
     public function exportOne(Order $order, Request $request)
     {

+ 9 - 5
app/Http/Controllers/ProductController.php

@@ -134,17 +134,21 @@ class ProductController extends Controller
         $request->validate([
             'withFilter'    => 'nullable',
             'filters'       => 'nullable|array',
+            's'             => 'nullable|string',
         ]);
 
-        // load and save file
-        $filters = ($request->withFilter) ? $request->filters ?? [] : [];
+        $filters = [];
+        if ($request->boolean('withFilter')) {
+            $filters = $request->filters ?? [];
+            if ($request->filled('s')) {
+                $filters['s'] = $request->string('s')->toString();
+            }
+        }
 
-        // dispatch job
         $filters['year'] = year();
-        ExportCatalog::dispatch($filters , $request->user()->id);
+        ExportCatalog::dispatch($filters, $request->user()->id);
         Log::info('ExportCatalog job created!');
 
-
         return redirect()->route('catalog.index', session('gp_products'))->with(['success' => 'Задача экспорта успешно создана!']);
     }
 

+ 47 - 6
app/Http/Controllers/ReclamationController.php

@@ -80,7 +80,8 @@ class ReclamationController extends Controller
         $this->setSortAndOrderBy($model, $request);
 
         if (hasRole(Role::BRIGADIER)) {
-            $q->where('brigadier_id', auth()->id());
+            $q->where('brigadier_id', auth()->id())
+                ->whereIn('status_id', Reclamation::visibleStatusIdsForBrigadier());
         }
 
         $this->applyStableSorting($q);
@@ -91,8 +92,18 @@ class ReclamationController extends Controller
 
     public function export(Request $request)
     {
-        $gp = session('gp_reclamations') ?? [];
-        $filterRequest = new Request($gp);
+        $request->validate([
+            'withFilter' => 'nullable',
+            'filters' => 'nullable|array',
+            's' => 'nullable|string',
+        ]);
+
+        $filterRequest = $request->boolean('withFilter')
+            ? new Request(array_filter([
+                'filters' => $request->input('filters', []),
+                's' => $request->input('s'),
+            ], static fn ($value) => $value !== null))
+            : new Request();
 
         $model = new ReclamationView();
         $this->createFilters($model, 'user_name', 'status_name');
@@ -132,7 +143,32 @@ class ReclamationController extends Controller
         $this->ensureCanViewReclamation($reclamation);
 
         $this->data['brigadiers'] = User::query()->where('role', Role::BRIGADIER)->get()->pluck('name', 'id');
-        $this->data['reclamation'] = $reclamation;
+        $this->data['reclamation'] = $reclamation->load([
+            'order',
+            'chatMessages.user',
+            'chatMessages.targetUser',
+            'chatMessages.notifiedUsers',
+            'chatMessages.files',
+        ]);
+        $chatUsers = User::query()->orderBy('name')->get(['id', 'name', 'role']);
+        $responsibleUserIds = User::query()
+            ->where('role', Role::ADMIN)
+            ->pluck('id')
+            ->map(static fn ($id) => (int) $id)
+            ->all();
+        $responsibleUserIds = array_values(array_unique(array_filter(array_merge($responsibleUserIds, [
+            $reclamation->user_id ? (int) $reclamation->user_id : null,
+            $reclamation->brigadier_id ? (int) $reclamation->brigadier_id : null,
+        ]))));
+
+        $this->data['chatUsers'] = $chatUsers->map(fn ($u) => [
+            'id' => $u->id,
+            'name' => $u->name,
+            'role' => $u->role,
+        ])->keyBy('id');
+        $this->data['chatResponsibleUserIds'] = $responsibleUserIds;
+        $this->data['chatManagerUserId'] = $reclamation->user_id ? (int) $reclamation->user_id : null;
+        $this->data['chatBrigadierUserId'] = $reclamation->brigadier_id ? (int) $reclamation->brigadier_id : null;
         $this->data['previous_url'] = $this->resolvePreviousUrl(
             $request,
             'previous_url_reclamations',
@@ -547,8 +583,13 @@ class ReclamationController extends Controller
 
     private function ensureCanViewReclamation(Reclamation $reclamation): void
     {
-        if (hasRole(Role::BRIGADIER) && (int)$reclamation->brigadier_id !== (int)auth()->id()) {
-            abort(403);
+        if (hasRole(Role::BRIGADIER)) {
+            $canView = (int)$reclamation->brigadier_id === (int)auth()->id()
+                && in_array((int)$reclamation->status_id, Reclamation::visibleStatusIdsForBrigadier(), true);
+
+            if (!$canView) {
+                abort(403);
+            }
         }
     }
 

+ 63 - 1
app/Http/Controllers/ScheduleController.php

@@ -18,6 +18,8 @@ use App\Models\Role;
 use App\Models\Schedule;
 use App\Models\User;
 use App\Services\NotificationService;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Support\Collection;
 use Illuminate\Http\Request;
 use Illuminate\Support\Carbon;
 
@@ -33,7 +35,11 @@ class ScheduleController extends Controller
     {
         $this->data['districts'] = District::query()->get()->pluck('name', 'id');
         $this->data['areas'] = Area::query()->get()->pluck('name', 'id');
-        $this->data['brigadiers'] = User::query()->where('role', Role::BRIGADIER)->get()->pluck('name', 'id');
+        $this->data['brigadiers'] = User::query()
+            ->where('role', Role::BRIGADIER)
+            ->when(hasRole(Role::BRIGADIER), fn (Builder $query) => $query->whereKey(auth()->id()))
+            ->get()
+            ->pluck('name', 'id');
 
         $this->data['scheduleYear'] = (int)$request->get('year', date('Y'));
         if ($this->data['scheduleYear'] < 2000 || $this->data['scheduleYear'] > 2100) {
@@ -62,8 +68,10 @@ class ScheduleController extends Controller
         }
         $result = Schedule::query()
             ->whereBetween('installation_date', [$weekDates['mon'], $weekDates['sun']])
+            ->when(hasRole(Role::BRIGADIER), fn (Builder $query) => $query->where('brigadier_id', auth()->id()))
             ->with(['brigadier', 'district', 'area'])
             ->get();
+        $result = $this->filterSchedulesForCurrentUser($result);
         $this->data['scheduleStatusMap'] = $this->buildScheduleStatusMap($result);
 
         foreach ($result as $schedule) {
@@ -104,8 +112,10 @@ class ScheduleController extends Controller
 
         $monthSchedules = Schedule::query()
             ->whereBetween('installation_date', [$monthStart->toDateString(), $monthEnd->toDateString()])
+            ->when(hasRole(Role::BRIGADIER), fn (Builder $query) => $query->where('brigadier_id', auth()->id()))
             ->with(['brigadier'])
             ->get();
+        $monthSchedules = $this->filterSchedulesForCurrentUser($monthSchedules);
 
         $monthScheduleColors = [];
         $monthBrigadierLegend = [];
@@ -345,6 +355,58 @@ class ScheduleController extends Controller
 
     }
 
+    private function filterSchedulesForCurrentUser(Collection $schedules): Collection
+    {
+        if (!hasRole(Role::BRIGADIER)) {
+            return $schedules;
+        }
+
+        $brigadierSchedules = $schedules
+            ->where('brigadier_id', auth()->id())
+            ->values();
+
+        if ($brigadierSchedules->isEmpty()) {
+            return $brigadierSchedules;
+        }
+
+        $visibleOrderIds = Order::query()
+            ->withoutGlobalScope(\App\Models\Scopes\YearScope::class)
+            ->whereIn('id', $brigadierSchedules->pluck('order_id')->filter()->unique()->all())
+            ->where('brigadier_id', auth()->id())
+            ->whereIn('order_status_id', Order::visibleStatusIdsForBrigadier())
+            ->pluck('id')
+            ->all();
+
+        $reclamationIds = $brigadierSchedules
+            ->filter(fn (Schedule $schedule) => $schedule->source === 'Рекламации')
+            ->map(fn (Schedule $schedule) => $this->extractReclamationId((string)$schedule->address_code))
+            ->filter()
+            ->unique()
+            ->values();
+
+        $visibleReclamationIds = Reclamation::query()
+            ->withoutGlobalScope(\App\Models\Scopes\YearScope::class)
+            ->whereIn('id', $reclamationIds->all())
+            ->where('brigadier_id', auth()->id())
+            ->whereIn('status_id', Reclamation::visibleStatusIdsForBrigadier())
+            ->pluck('id')
+            ->all();
+
+        return $brigadierSchedules
+            ->filter(function (Schedule $schedule) use ($visibleOrderIds, $visibleReclamationIds) {
+                if ($schedule->source === 'Площадки') {
+                    return in_array((int)$schedule->order_id, $visibleOrderIds, true);
+                }
+
+                if ($schedule->source === 'Рекламации') {
+                    return in_array($this->extractReclamationId((string)$schedule->address_code), $visibleReclamationIds, true);
+                }
+
+                return true;
+            })
+            ->values();
+    }
+
     private function buildScheduleStatusMap($schedules): array
     {
         $statusMap = [];

+ 9 - 0
app/Http/Controllers/UserController.php

@@ -234,6 +234,7 @@ class UserController extends Controller
         $this->data['reclamationStatusOptions'] = Reclamation::STATUS_NAMES;
         $this->data['reclamationStatusColors'] = ReclamationStatus::STATUS_COLOR;
         $this->data['scheduleSourceOptions'] = ['platform' => 'Площадки', 'reclamation' => 'Рекламации'];
+        $this->data['chatSourceOptions'] = ['platform' => 'Площадки', 'reclamation' => 'Рекламации'];
         $this->data['notificationChannels'] = ['browser' => 'Браузер', 'push' => 'Push', 'email' => 'Email'];
 
         $this->data['disabledChannels'] = [
@@ -262,11 +263,13 @@ class UserController extends Controller
             'order_settings' => [],
             'reclamation_settings' => [],
             'schedule_settings' => [],
+            'chat_settings' => [],
         ];
 
         $orderStatuses = array_keys(Order::STATUS_NAMES);
         $reclamationStatuses = array_keys(Reclamation::STATUS_NAMES);
         $scheduleSources = ['platform', 'reclamation'];
+        $chatSources = ['platform', 'reclamation'];
         $channels = ['browser', 'push', 'email'];
 
         foreach ($orderStatuses as $statusId) {
@@ -287,6 +290,12 @@ class UserController extends Controller
             }
         }
 
+        foreach ($chatSources as $source) {
+            foreach ($channels as $channel) {
+                $settings['chat_settings'][$source][$channel] = isset($input['chat'][$source][$channel]);
+            }
+        }
+
         return $settings;
     }
 

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

@@ -76,6 +76,23 @@ class UserNotificationController extends Controller
         ]);
     }
 
+    public function markAllRead(Request $request): JsonResponse
+    {
+        $now = now();
+
+        $updated = UserNotification::query()
+            ->where('user_id', $request->user()->id)
+            ->whereNull('read_at')
+            ->update(['read_at' => $now]);
+
+        return response()->json([
+            'ok' => true,
+            'updated' => $updated,
+            'read_at' => $now->format('d.m.Y H:i:s'),
+            'unread' => 0,
+        ]);
+    }
+
     public function unreadCount(Request $request): JsonResponse
     {
         return response()->json([

+ 64 - 0
app/Models/ChatMessage.php

@@ -0,0 +1,64 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\BelongsToMany;
+
+class ChatMessage extends Model
+{
+    use HasFactory;
+
+    public const NOTIFICATION_NONE = 'none';
+    public const NOTIFICATION_RESPONSIBLES = 'responsibles';
+    public const NOTIFICATION_ALL = 'all';
+    public const NOTIFICATION_USER = 'user';
+
+    public const NOTIFICATION_TYPE_NAMES = [
+        self::NOTIFICATION_NONE => 'Нет',
+        self::NOTIFICATION_RESPONSIBLES => 'Админы, менеджер, бригадир',
+        self::NOTIFICATION_ALL => 'Все',
+        self::NOTIFICATION_USER => 'Конкретному пользователю',
+    ];
+
+    protected $fillable = [
+        'order_id',
+        'reclamation_id',
+        'user_id',
+        'target_user_id',
+        'notification_type',
+        'message',
+    ];
+
+    public function order(): BelongsTo
+    {
+        return $this->belongsTo(Order::class);
+    }
+
+    public function reclamation(): BelongsTo
+    {
+        return $this->belongsTo(Reclamation::class);
+    }
+
+    public function user(): BelongsTo
+    {
+        return $this->belongsTo(User::class);
+    }
+
+    public function targetUser(): BelongsTo
+    {
+        return $this->belongsTo(User::class, 'target_user_id');
+    }
+
+    public function notifiedUsers(): BelongsToMany
+    {
+        return $this->belongsToMany(User::class, 'chat_message_user');
+    }
+
+    public function files(): BelongsToMany
+    {
+        return $this->belongsToMany(File::class, 'chat_message_file')->withTimestamps();
+    }
+}

+ 17 - 1
app/Models/Order.php

@@ -69,6 +69,10 @@ class Order extends Model
         self::STATUS_PROBLEM => 'danger',
     ];
 
+    public const BRIGADIER_HIDDEN_STATUS_IDS = [
+        self::STATUS_HANDED_OVER,
+    ];
+
 
     // set year attribute to current selected year
     protected static function boot(): void
@@ -169,6 +173,19 @@ class Order extends Model
         return $this->hasMany(Reclamation::class);
     }
 
+    public function chatMessages(): HasMany
+    {
+        return $this->hasMany(ChatMessage::class)->orderBy('created_at');
+    }
+
+    public static function visibleStatusIdsForBrigadier(): array
+    {
+        return array_values(array_diff(
+            array_keys(self::STATUS_NAMES),
+            self::BRIGADIER_HIDDEN_STATUS_IDS,
+        ));
+    }
+
 
 
     public function getNeeds(): array
@@ -301,4 +318,3 @@ class Order extends Model
     }
 
 }
-

+ 17 - 0
app/Models/Reclamation.php

@@ -39,6 +39,13 @@ class Reclamation extends Model
         self::STATUS_CLOSED_NO_PAY => 'Закрыта, не оплачивается',
     ];
 
+    public const BRIGADIER_VISIBLE_STATUS_IDS = [
+        self::STATUS_NEW,
+        self::STATUS_WAIT,
+        self::STATUS_IN_WORK,
+        self::STATUS_SUBSCRIBE_ACT,
+    ];
+
     protected $fillable = [
         'order_id',
         'user_id',
@@ -149,4 +156,14 @@ class Reclamation extends Model
         return $this->hasMany(Shortage::class)->where('status', Shortage::STATUS_OPEN);
     }
 
+    public function chatMessages(): HasMany
+    {
+        return $this->hasMany(ChatMessage::class)->orderBy('created_at');
+    }
+
+    public static function visibleStatusIdsForBrigadier(): array
+    {
+        return self::BRIGADIER_VISIBLE_STATUS_IDS;
+    }
+
 }

+ 2 - 1
app/Models/ReclamationView.php

@@ -9,7 +9,8 @@ class ReclamationView extends Model
 {
     use SoftDeletes;
 
-    const DEFAULT_SORT_BY = 'created_at';
+    const DEFAULT_SORT_BY = 'create_date';
+    const DEFAULT_ORDER_BY = 'desc';
 
     protected $table = 'reclamations_view';
 

+ 6 - 6
app/Models/SparePart.php

@@ -71,24 +71,24 @@ class SparePart extends Model
     protected function purchasePrice(): Attribute
     {
         return Attribute::make(
-            get: fn($value) => $value ? $value / 100 : null,
-            set: fn($value) => $value ? round($value * 100) : null,
+            get: fn($value) => $value === null ? null : $value / 100,
+            set: fn($value) => $value === null || $value === '' ? null : round($value * 100),
         );
     }
 
     protected function customerPrice(): Attribute
     {
         return Attribute::make(
-            get: fn($value) => $value ? $value / 100 : null,
-            set: fn($value) => $value ? round($value * 100) : null,
+            get: fn($value) => $value === null ? null : $value / 100,
+            set: fn($value) => $value === null || $value === '' ? null : round($value * 100),
         );
     }
 
     protected function expertisePrice(): Attribute
     {
         return Attribute::make(
-            get: fn($value) => $value ? $value / 100 : null,
-            set: fn($value) => $value ? round($value * 100) : null,
+            get: fn($value) => $value === null ? null : $value / 100,
+            set: fn($value) => $value === null || $value === '' ? null : round($value * 100),
         );
     }
 

+ 2 - 0
app/Models/UserNotification.php

@@ -29,11 +29,13 @@ class UserNotification extends Model
     public const EVENT_CREATED = 'created';
     public const EVENT_STATUS_CHANGED = 'status_changed';
     public const EVENT_SCHEDULE_ADDED = 'schedule_added';
+    public const EVENT_CHAT_MESSAGE = 'chat_message';
 
     public const EVENT_NAMES = [
         self::EVENT_CREATED => 'Создание',
         self::EVENT_STATUS_CHANGED => 'Смена статуса',
         self::EVENT_SCHEDULE_ADDED => 'Добавлено в график',
+        self::EVENT_CHAT_MESSAGE => 'Сообщение в чате',
     ];
 
     public const DEFAULT_SORT_BY = 'created_at';

+ 3 - 0
app/Models/UserNotificationSetting.php

@@ -15,6 +15,7 @@ class UserNotificationSetting extends Model
         'order_settings',
         'reclamation_settings',
         'schedule_settings',
+        'chat_settings',
     ];
 
     protected function casts(): array
@@ -23,6 +24,7 @@ class UserNotificationSetting extends Model
             'order_settings' => 'array',
             'reclamation_settings' => 'array',
             'schedule_settings' => 'array',
+            'chat_settings' => 'array',
         ];
     }
 
@@ -38,6 +40,7 @@ class UserNotificationSetting extends Model
             'order_settings' => [],
             'reclamation_settings' => [],
             'schedule_settings' => [],
+            'chat_settings' => [],
         ];
     }
 

+ 22 - 3
app/Services/GenerateDocumentsService.php

@@ -380,16 +380,35 @@ class GenerateDocumentsService
      */
     public function generateReclamationPack(Reclamation $reclamation, int $userId): string
     {
-        Storage::disk('public')->makeDirectory('reclamations/' . $reclamation->id . '/tmp/' . fileName($reclamation->order->object_address) . '/ФОТО НАРУШЕНИЯ/');
+        $reclamation->loadMissing([
+            'order',
+            'photos_before',
+            'documents',
+            'skus.product',
+        ]);
+
+        $baseDir = 'reclamations/' . $reclamation->id . '/tmp/' . fileName($reclamation->order->object_address);
+        $photosDir = $baseDir . '/ФОТО НАРУШЕНИЯ';
+
+        Storage::disk('public')->makeDirectory($photosDir);
+
         // copy photos
         foreach ($reclamation->photos_before as $photo) {
             $from = $photo->path;
-            $to = 'reclamations/' . $reclamation->id . '/tmp/' . fileName($reclamation->order->object_address) . '/ФОТО НАРУШЕНИЯ/' . $photo->original_name;
+            $to = $photosDir . '/' . $photo->original_name;
             if (!Storage::disk('public')->exists($to)) {
                 Storage::disk('public')->copy($from, $to);
             }
         }
 
+        foreach ($reclamation->documents as $document) {
+            if ($this->shouldSkipArchiveDocument($document)) {
+                continue;
+            }
+
+            $this->copyFileToDir($document->path, $baseDir, $document->original_name);
+        }
+
         // create xls and pdf
         $this->generateReclamationOrder($reclamation);
         $this->generateReclamationAct($reclamation);
@@ -520,7 +539,7 @@ class GenerateDocumentsService
 
         $sheet->setCellValue('A17', $reclamation->order->object_address);
         $sheet->setCellValue('A22', implode('; ', $mafs));
-        $sheet->setCellValue('A27', $reclamation->whats_done);
+        $sheet->setCellValue('A27', $reclamation->whats_done . ' (' . $reclamation->reason . ')');
 
         $i = 24;
         $n = 1;

+ 8 - 11
app/Services/Import/ImportSparePartsService.php

@@ -178,25 +178,22 @@ class ImportSparePartsService
     }
 
     /**
-     * Парсинг цены
-     * Если цена уже в копейках (целое число > 1000), оставляем как есть
-     * Если цена в рублях (дробное или < 1000), конвертируем в копейки
+     * Импорт читает цены из Excel в рублях.
+     * Дальше модель SparePart сама конвертирует их в копейки при сохранении.
      */
-    private function parsePrice($value): ?int
+    private function parsePrice($value): ?float
     {
-        if (empty($value)) {
+        if ($value === null || $value === '') {
             return null;
         }
 
-        $price = (float)$value;
+        $normalized = str_replace([' ', ','], ['', '.'], trim((string) $value));
 
-        // Если число целое и большое (скорее всего уже в копейках)
-        if ($price == (int)$price && $price >= 1000) {
-            return (int)$price;
+        if ($normalized === '') {
+            return null;
         }
 
-        // Иначе конвертируем из рублей в копейки
-        return round($price * 100);
+        return round((float) $normalized, 2);
     }
 
     private function syncPricingCodes(SparePart $sparePart, array $codeStrings): void

+ 194 - 6
app/Services/NotificationService.php

@@ -5,6 +5,7 @@ namespace App\Services;
 use App\Events\SendPersistentNotificationEvent;
 use App\Helpers\DateHelper;
 use App\Jobs\SendUserNotificationChannelJob;
+use App\Models\ChatMessage;
 use App\Models\NotificationDeliveryLog;
 use App\Models\Order;
 use App\Models\Reclamation;
@@ -20,6 +21,53 @@ use Illuminate\Support\Str;
 
 class NotificationService
 {
+    public function notifyChatMessage(ChatMessage $chatMessage, array $recipientIds = []): void
+    {
+        $chatMessage->loadMissing([
+            'user',
+            'targetUser',
+            'order.user',
+            'order.brigadier',
+            'reclamation.order',
+            'reclamation.user',
+            'reclamation.brigadier',
+        ]);
+
+        $type = $chatMessage->order_id ? UserNotification::TYPE_PLATFORM : UserNotification::TYPE_RECLAMATION;
+        $sourceKey = $chatMessage->order_id ? 'platform' : 'reclamation';
+        $title = $chatMessage->order_id ? 'Чат площадки' : 'Чат рекламации';
+
+        [$message, $messageHtml, $payload] = $this->buildChatNotificationContent($chatMessage, $type);
+
+        foreach ($this->chatRecipients($chatMessage, $recipientIds) as $user) {
+            $settings = $this->settingsForUser($user->id);
+            if (!$settings->isSectionEnabled('chat_settings')) {
+                continue;
+            }
+
+            $channels = $settings->getChannelsForKey('chat_settings', $sourceKey);
+            if (empty($channels)) {
+                continue;
+            }
+
+            $notification = $this->createInAppNotification(
+                $user,
+                $type,
+                UserNotification::EVENT_CHAT_MESSAGE,
+                $title,
+                $message,
+                $messageHtml,
+                $payload,
+            );
+
+            $this->dispatchDeliveryJobs($notification, [
+                NotificationDeliveryLog::CHANNEL_BROWSER => !empty($channels['browser']),
+                NotificationDeliveryLog::CHANNEL_PUSH => !empty($channels['push']),
+                NotificationDeliveryLog::CHANNEL_EMAIL => !empty($channels['email']),
+            ]);
+        }
+    }
+
     public function notifyOrderCreated(Order $order): void
     {
         $statusName = $order->orderStatus?->name ?? (Order::STATUS_NAMES[$order->order_status_id] ?? '-');
@@ -31,7 +79,7 @@ class NotificationService
             sprintf('Добавлена новая площадка %s', $order->object_address),
             sprintf(
                 'Добавлена новая площадка <a href="%s">%s</a>.',
-                route('order.show', ['order' => $order->id]),
+                route('order.show', ['order' => $order->id, 'sync_year' => 1]),
                 e($order->object_address)
             ),
             $statusName,
@@ -49,7 +97,7 @@ class NotificationService
             sprintf('Статус площадки %s изменен на %s', $order->object_address, $statusName),
             sprintf(
                 'Статус площадки <a href="%s">%s</a> изменен на %s.',
-                route('order.show', ['order' => $order->id]),
+                route('order.show', ['order' => $order->id, 'sync_year' => 1]),
                 e($order->object_address),
                 e($statusName)
             ),
@@ -72,7 +120,7 @@ class NotificationService
 
         $messageHtml = sprintf(
             'Добавлена новая рекламация по адресу <a href="%s">%s</a> <a href="%s">#%d</a>.',
-            route('order.show', ['order' => $order->id]),
+            route('order.show', ['order' => $order->id, 'sync_year' => 1]),
             e($order->object_address),
             route('reclamations.show', ['reclamation' => $reclamation->id]),
             $reclamation->id,
@@ -106,7 +154,7 @@ class NotificationService
 
         $messageHtml = sprintf(
             'Статус рекламации по адресу <a href="%s">%s</a> <a href="%s">#%d</a> изменен на %s.',
-            route('order.show', ['order' => $order->id]),
+            route('order.show', ['order' => $order->id, 'sync_year' => 1]),
             e($order->object_address),
             route('reclamations.show', ['reclamation' => $reclamation->id]),
             $reclamation->id,
@@ -148,7 +196,7 @@ class NotificationService
                 ? route('reclamations.show', ['reclamation' => $reclamationId])
                 : route('schedule.index');
             $orderLink = $schedule->order_id
-                ? route('order.show', ['order' => $schedule->order_id])
+                ? route('order.show', ['order' => $schedule->order_id, 'sync_year' => 1])
                 : null;
 
             $addressHtml = $orderLink
@@ -172,7 +220,7 @@ class NotificationService
             );
 
             $orderLink = $schedule->order_id
-                ? route('order.show', ['order' => $schedule->order_id])
+                ? route('order.show', ['order' => $schedule->order_id, 'sync_year' => 1])
                 : route('schedule.index');
 
             $messageHtml = sprintf(
@@ -506,4 +554,144 @@ class NotificationService
             default => null,
         };
     }
+
+    private function chatRecipients(ChatMessage $chatMessage, array $recipientIds = []): Collection
+    {
+        $recipientIds = array_values(array_unique(array_map(static fn ($id) => (int) $id, $recipientIds)));
+        $recipientIds = array_values(array_diff($recipientIds, [(int) $chatMessage->user_id]));
+
+        if (!empty($recipientIds)) {
+            return User::query()
+                ->whereIn('id', $recipientIds)
+                ->get();
+        }
+
+        if ($chatMessage->notification_type === ChatMessage::NOTIFICATION_USER) {
+            if (!$chatMessage->target_user_id || (int) $chatMessage->target_user_id === (int) $chatMessage->user_id) {
+                return new Collection();
+            }
+
+            return User::query()
+                ->where('id', $chatMessage->target_user_id)
+                ->get();
+        }
+
+        if (!in_array($chatMessage->notification_type, [
+            ChatMessage::NOTIFICATION_ALL,
+            ChatMessage::NOTIFICATION_RESPONSIBLES,
+        ], true)) {
+            return new Collection();
+        }
+
+        $recipientIds = [];
+
+        if ($chatMessage->order) {
+            $recipientIds = $chatMessage->notification_type === ChatMessage::NOTIFICATION_ALL
+                ? $this->allChatRecipientIds()
+                : $this->chatResponsibleRecipientIdsForOrder($chatMessage->order);
+        }
+
+        if ($chatMessage->reclamation) {
+            $recipientIds = $chatMessage->notification_type === ChatMessage::NOTIFICATION_ALL
+                ? $this->allChatRecipientIds()
+                : $this->chatResponsibleRecipientIdsForReclamation($chatMessage->reclamation);
+        }
+
+        $recipientIds = array_values(array_unique(array_filter($recipientIds)));
+        $recipientIds = array_values(array_diff($recipientIds, [(int) $chatMessage->user_id]));
+
+        if (empty($recipientIds)) {
+            return new Collection();
+        }
+
+        return User::query()
+            ->whereIn('id', $recipientIds)
+            ->get();
+    }
+
+    private function allChatRecipientIds(): array
+    {
+        return User::query()
+            ->pluck('id')
+            ->map(static fn ($id) => (int) $id)
+            ->all();
+    }
+
+    private function chatResponsibleRecipientIdsForOrder(Order $order): array
+    {
+        $adminIds = User::query()
+            ->where('role', Role::ADMIN)
+            ->pluck('id')
+            ->map(static fn ($id) => (int) $id)
+            ->all();
+
+        return array_merge($adminIds, [
+            $order->user_id ? (int) $order->user_id : null,
+            $order->brigadier_id ? (int) $order->brigadier_id : null,
+        ]);
+    }
+
+    private function chatResponsibleRecipientIdsForReclamation(Reclamation $reclamation): array
+    {
+        $adminIds = User::query()
+            ->where('role', Role::ADMIN)
+            ->pluck('id')
+            ->map(static fn ($id) => (int) $id)
+            ->all();
+
+        return array_merge($adminIds, [
+            $reclamation->user_id ? (int) $reclamation->user_id : null,
+            $reclamation->brigadier_id ? (int) $reclamation->brigadier_id : null,
+        ]);
+    }
+
+    private function buildChatNotificationContent(ChatMessage $chatMessage, string $type): array
+    {
+        $senderName = $chatMessage->user?->name ?? 'Пользователь';
+        $text = trim((string) $chatMessage->message);
+        $text = $text !== '' ? Str::limit($text, 200) : 'Вложение';
+
+        if ($type === UserNotification::TYPE_PLATFORM) {
+            $order = $chatMessage->order;
+            $address = $order?->object_address ?? '-';
+            $orderUrl = $order ? route('order.show', ['order' => $order->id, 'sync_year' => 1]) : route('order.index');
+
+            $message = sprintf('Новое сообщение в чате площадки %s от %s: %s', $address, $senderName, $text);
+            $messageHtml = sprintf(
+                'Новое сообщение в <a href="%s">чате площадки %s</a> от %s: %s',
+                $orderUrl,
+                e($address),
+                e($senderName),
+                e($text)
+            );
+
+            return [$message, $messageHtml, [
+                'chat_message_id' => $chatMessage->id,
+                'order_id' => $order?->id,
+            ]];
+        }
+
+        $reclamation = $chatMessage->reclamation;
+        $address = $reclamation?->order?->object_address ?? '-';
+        $reclamationUrl = $reclamation
+            ? route('reclamations.show', ['reclamation' => $reclamation->id, 'sync_year' => 1])
+            : route('reclamations.index');
+
+        $reclamationNumber = $reclamation?->id ? ('#' . $reclamation->id) : '#-';
+
+        $message = sprintf('Новое сообщение в чате рекламации %s по адресу %s от %s: %s', $reclamationNumber, $address, $senderName, $text);
+        $messageHtml = sprintf(
+            'Новое сообщение в <a href="%s">чате рекламации %s</a> по адресу %s от %s: %s',
+            $reclamationUrl,
+            e($reclamationNumber),
+            e($address),
+            e($senderName),
+            e($text)
+        );
+
+        return [$message, $messageHtml, [
+            'chat_message_id' => $chatMessage->id,
+            'reclamation_id' => $reclamation?->id,
+        ]];
+    }
 }

+ 31 - 0
database/migrations/2026_03_22_100000_create_chat_messages_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('chat_messages', function (Blueprint $table) {
+            $table->id();
+            $table->foreignId('order_id')->nullable()->constrained()->cascadeOnDelete();
+            $table->foreignId('reclamation_id')->nullable()->constrained()->cascadeOnDelete();
+            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
+            $table->foreignId('target_user_id')->nullable()->constrained('users')->nullOnDelete();
+            $table->string('notification_type')->default('none'); // none | all | user
+            $table->text('message')->nullable();
+            $table->timestamps();
+
+            $table->index(['order_id', 'created_at']);
+            $table->index(['reclamation_id', 'created_at']);
+            $table->index(['user_id', 'created_at']);
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('chat_messages');
+    }
+};

+ 25 - 0
database/migrations/2026_03_22_100100_create_chat_message_file_table.php

@@ -0,0 +1,25 @@
+<?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('chat_message_file', function (Blueprint $table) {
+            $table->id();
+            $table->foreignId('chat_message_id')->constrained()->cascadeOnDelete();
+            $table->foreignId('file_id')->constrained()->cascadeOnDelete();
+            $table->timestamps();
+
+            $table->unique(['chat_message_id', 'file_id']);
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('chat_message_file');
+    }
+};

+ 22 - 0
database/migrations/2026_03_22_100200_add_chat_settings_to_user_notification_settings_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('user_notification_settings', function (Blueprint $table) {
+            $table->json('chat_settings')->nullable()->after('schedule_settings');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('user_notification_settings', function (Blueprint $table) {
+            $table->dropColumn('chat_settings');
+        });
+    }
+};

+ 22 - 0
database/migrations/2026_03_22_100300_create_chat_message_user_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::create('chat_message_user', function (Blueprint $table) {
+            $table->foreignId('chat_message_id')->constrained()->cascadeOnDelete();
+            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
+            $table->primary(['chat_message_id', 'user_id']);
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('chat_message_user');
+    }
+};

+ 163 - 0
resources/sass/app.scss

@@ -17,6 +17,9 @@
   font-weight: bold;
 }
 
+td p {
+  margin-bottom: 0;
+}
 
 .alerts{
 
@@ -629,3 +632,163 @@
     margin-left: auto;
   }
 }
+
+// Chat component styles (from partials/chat.blade.php)
+.chat-block {
+  --chat-height: 420px;
+}
+
+.chat-card {
+  border: 1px solid var(--bs-border-color);
+  border-radius: 0.75rem;
+  background: var(--bs-light-bg-subtle, #f8f9fa);
+}
+
+.chat-messages-wrap {
+  position: relative;
+  padding: 0.75rem;
+}
+
+.chat-messages {
+  max-height: var(--chat-height);
+  overflow-y: auto;
+  padding-right: 0.35rem;
+  scroll-behavior: smooth;
+}
+
+.chat-message {
+  border: 1px solid rgba(0, 0, 0, 0.08);
+  border-radius: 0.75rem;
+  background: #fff;
+  padding: 0.75rem 0.875rem;
+  margin-bottom: 0.625rem;
+  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
+}
+
+.chat-message:last-child {
+  margin-bottom: 0;
+}
+
+.chat-message-header {
+  display: flex;
+  justify-content: space-between;
+  gap: 0.75rem;
+  flex-wrap: wrap;
+  font-size: 0.875rem;
+}
+
+.chat-message-text {
+  margin-top: 0.5rem;
+  white-space: pre-wrap;
+  line-height: 1.35;
+}
+
+.chat-message-files {
+  margin-top: 0.625rem;
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0.5rem;
+}
+
+.chat-message-files img {
+  width: 84px;
+  height: 84px;
+  object-fit: cover;
+}
+
+.chat-scroll-bottom {
+  position: absolute;
+  right: 1rem;
+  bottom: 1rem;
+  z-index: 2;
+  border-radius: 999px;
+  box-shadow: 0 0.25rem 1rem rgba(0, 0, 0, 0.18);
+}
+
+.chat-recipient-list {
+  max-height: 320px;
+  overflow-y: auto;
+  border: 1px solid var(--bs-border-color);
+  border-radius: 0.5rem;
+  padding: 0.5rem 0.75rem;
+}
+
+.chat-recipient-item[hidden] {
+  display: none !important;
+}
+
+.chat-recipient-summary {
+  min-height: 1.25rem;
+}
+
+// Schedule calendar styles (from schedule/index.blade.php)
+.schedule-month {
+  table-layout: fixed;
+}
+
+.schedule-day {
+  height: 110px;
+  vertical-align: top;
+  position: relative;
+}
+
+.schedule-day-link {
+  display: inline-block;
+  font-weight: 600;
+  text-decoration: none;
+  color: #0d6efd;
+}
+
+.schedule-dots {
+  margin-top: 6px;
+  display: flex;
+  flex-wrap: wrap;
+  gap: 4px;
+}
+
+.schedule-dot {
+  width: 12px;
+  height: 12px;
+  border-radius: 50%;
+  display: inline-block;
+  border: 1px solid rgba(0, 0, 0, 0.1);
+}
+
+.schedule-outside {
+  background: #f8f9fa;
+  color: #9aa0a6;
+}
+
+.schedule-today {
+  outline: 2px solid rgba(13, 110, 253, 0.5);
+  outline-offset: -2px;
+}
+
+.schedule-legend-items {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px 16px;
+  align-items: center;
+}
+
+.schedule-legend-item {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  font-size: 0.9rem;
+}
+
+.schedule-legend-name {
+  white-space: nowrap;
+}
+
+// Table sortable header styles (from reports/index.blade.php)
+.sortable-header {
+  cursor: pointer;
+  user-select: none;
+}
+
+// Notification settings table (from partials/notification-settings-table.blade.php)
+.section-header-row {
+  background-color: #d4edda;
+}

+ 1 - 1
resources/views/catalog/index.blade.php

@@ -65,7 +65,7 @@
                         @include('partials.checkbox', ['title' => 'С учётом текущего фильтра и поиска', 'name' => 'withFilter', 'type' => 'checkbox', 'value' => 'yes', 'checked' => false])
                         <div class="d-none">
                             @if(request()->s)
-                                @include('partials.input', ['name' => 'filters[s]', 'title' => 'поиск', 'value' => request()->s])
+                                @include('partials.input', ['name' => 's', 'title' => 'поиск', 'value' => request()->s])
                             @endif
                             @if(request()->filters)
                                 @foreach(request()->filters as $filterName => $filterValue)

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

@@ -2,6 +2,15 @@
 
 @section('content')
 
+    <div class="d-flex justify-content-end mb-3">
+        <button type="button"
+                id="mark-all-notifications-read"
+                class="btn btn-outline-primary"
+                @disabled(auth()->user()->unreadUserNotifications()->count() === 0)>
+            Прочитано все
+        </button>
+    </div>
+
     @include('partials.table', [
         'id'      => $id,
         'header'  => $header,
@@ -20,6 +29,26 @@
             const safeCount = Math.max(0, parseInt(count || 0, 10));
             badge.dataset.count = String(safeCount);
             badge.classList.toggle('d-none', safeCount === 0);
+
+            const markAllButton = document.getElementById('mark-all-notifications-read');
+            if (markAllButton) {
+                markAllButton.disabled = safeCount === 0;
+            }
+        }
+
+        function applyReadState($row, readAt) {
+            if (!$row || !$row.length) {
+                return;
+            }
+
+            $row.removeClass('notification-unread');
+            const typeClass = $row.data('read-class');
+            if (typeClass) $row.addClass(typeClass);
+            $row.data('notification-read', '1');
+
+            if (readAt) {
+                $row.find('.column_read_at').text(readAt);
+            }
         }
 
         async function markNotificationRead(id, $row) {
@@ -36,17 +65,36 @@
                 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');
-
-                    if (data.read_at) {
-                        $row.find('.column_read_at').text(data.read_at);
-                    }
-                }
+                applyReadState($row, data.read_at);
+            }
+        }
+
+        async function markAllNotificationsRead() {
+            const response = await fetch(`{{ route('notifications.read-all') }}`, {
+                method: 'POST',
+                headers: {
+                    'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
+                    'Accept': 'application/json',
+                },
+            });
+
+            if (!response.ok) {
+                return;
+            }
+
+            const data = await response.json();
+            if (typeof data.unread !== 'undefined') {
+                updateNotificationBadge(data.unread);
             }
+
+            $('#tbl tbody tr[data-notification-id]').each(function () {
+                const $row = $(this);
+                const isRead = $row.data('notification-read') === '1' || $row.data('notification-read') === 1;
+
+                if (!isRead) {
+                    applyReadState($row, data.read_at);
+                }
+            });
         }
 
         $(document).on('dblclick', '#tbl tbody tr[data-notification-id]', function (e) {
@@ -59,5 +107,13 @@
                 markNotificationRead(id, $row);
             }
         });
+
+        $(document).on('click', '#mark-all-notifications-read', function () {
+            if (this.disabled) {
+                return;
+            }
+
+            markAllNotificationsRead();
+        });
     </script>
 @endpush

+ 37 - 6
resources/views/orders/index.blade.php

@@ -13,14 +13,9 @@
                 <a href="{{ route('order.create') }}" class="btn btn-sm btn-primary">Создать</a>
             @endif
             @if(hasRole('admin,manager'))
-                <a href="#" class="btn btn-sm btn-primary" onclick="$('#export-orders').submit()">Экспорт</a>
+                <button type="button" class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#exportOrdersModal">Экспорт</button>
             @endif
         </div>
-        @if(hasRole('admin,manager'))
-            <form class="d-none" method="post" action="{{ route('order.export') }}" id="export-orders">
-                @csrf
-            </form>
-        @endif
     </div>
     @include('partials.table', [
         'id'        => $id,
@@ -31,4 +26,40 @@
     ])
 
     @include('partials.pagination', ['items' => $orders])
+
+    @if(hasRole('admin,manager'))
+        <div class="modal fade" id="exportOrdersModal" tabindex="-1" aria-labelledby="exportOrdersModalLabel" aria-hidden="true">
+            <div class="modal-dialog modal-fullscreen-sm-down modal-lg">
+                <div class="modal-content">
+                    <div class="modal-header">
+                        <h1 class="modal-title fs-5" id="exportOrdersModalLabel">Экспорт площадок</h1>
+                        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
+                    </div>
+                    <div class="modal-body">
+                        <form action="{{ route('order.export') }}" method="post">
+                            @csrf
+                            @include('partials.checkbox', ['title' => 'С учётом текущего фильтра и поиска', 'name' => 'withFilter', 'type' => 'checkbox', 'value' => 'yes', 'checked' => false])
+                            <div class="d-none">
+                                @if(request()->s)
+                                    @include('partials.input', ['name' => 's', 'title' => 'поиск', 'value' => request()->s])
+                                @endif
+                                @if(request()->sortBy)
+                                    @include('partials.input', ['name' => 'sortBy', 'title' => 'sortBy', 'value' => request()->sortBy])
+                                @endif
+                                @if(request()->has('order'))
+                                    @include('partials.input', ['name' => 'order', 'title' => 'order', 'value' => request()->order])
+                                @endif
+                                @if(request()->filters)
+                                    @foreach(request()->filters as $filterName => $filterValue)
+                                        @include('partials.input', ['name' => 'filters[' . $filterName .']', 'title' => $filterName, 'value' => $filterValue])
+                                    @endforeach
+                                @endif
+                            </div>
+                            @include('partials.submit', ['name' => 'Экспорт'])
+                        </form>
+                    </div>
+                </div>
+            </div>
+        </div>
+    @endif
 @endsection

+ 12 - 1
resources/views/orders/show.blade.php

@@ -344,6 +344,18 @@
                             </a>
                         </div>
                     @endif
+
+                    @include('partials.chat', [
+                        'title' => 'Чат площадки',
+                        'messages' => $order->chatMessages,
+                        'users' => $chatUsers,
+                        'responsibleUserIds' => $chatResponsibleUserIds,
+                        'managerUserId' => $chatManagerUserId ?? null,
+                        'brigadierUserId' => $chatBrigadierUserId ?? null,
+                        'action' => route('order.chat-messages.store', $order),
+                        'contextKey' => 'order-' . $order->id,
+                        'submitLabel' => 'Отправить в чат',
+                    ])
                 </div>
 
             </div>
@@ -553,6 +565,5 @@
                 }
             );
         });
-
     </script>
 @endpush

+ 491 - 0
resources/views/partials/chat.blade.php

@@ -0,0 +1,491 @@
+@php
+    /** @var \Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Collection $messages */
+    $messages = $messages ?? collect();
+    $users = $users ?? collect();
+    $responsibleUserIds = array_map('intval', $responsibleUserIds ?? []);
+    $managerUserId = isset($managerUserId) ? (int) $managerUserId : null;
+    $brigadierUserId = isset($brigadierUserId) ? (int) $brigadierUserId : null;
+    $currentUserId = (int) auth()->id();
+    $contextKey = $contextKey ?? 'chat';
+    $title = $title ?? 'Чат';
+    $submitLabel = $submitLabel ?? 'Отправить';
+    $canSendNotifications = hasRole('admin,manager');
+    $notificationValue = old('notification_type', \App\Models\ChatMessage::NOTIFICATION_NONE);
+    $notificationEnabled = $canSendNotifications && $notificationValue !== \App\Models\ChatMessage::NOTIFICATION_NONE;
+    $showAllUsers = $notificationValue === \App\Models\ChatMessage::NOTIFICATION_ALL;
+    $selectedTargetUserIds = collect(old('target_user_ids', []))
+        ->map(static fn ($id) => (int) $id)
+        ->filter()
+        ->unique()
+        ->values()
+        ->all();
+
+    // Роли в порядке сортировки
+    $roleOrder = [\App\Models\Role::ADMIN, \App\Models\Role::MANAGER, \App\Models\Role::BRIGADIER];
+    $roleNames = \App\Models\Role::NAMES;
+
+    // Сортировка пользователей: админы -> менеджеры -> бригадиры -> остальные
+    $sortedUsers = $users->sortBy(function ($user) use ($roleOrder) {
+        $role = $user['role'] ?? '';
+        $index = array_search($role, $roleOrder, true);
+        return $index === false ? 999 : $index;
+    });
+@endphp
+
+<div class="chat-block mt-3" data-chat-block data-context-key="{{ $contextKey }}">
+    <hr>
+    <h5>{{ $title }}</h5>
+
+    <div class="chat-card">
+        <div class="chat-messages-wrap">
+            <div class="chat-messages" data-chat-messages>
+                @forelse($messages as $message)
+                    <div class="chat-message">
+                        <div class="chat-message-header">
+                            <div>
+                                <strong>{{ $message->user?->name ?? 'Пользователь' }}</strong>
+                                @if($message->notification_type === \App\Models\ChatMessage::NOTIFICATION_USER && $message->targetUser)
+                                    <span class="text-muted">для {{ $message->targetUser->name }}</span>
+                                @elseif(in_array($message->notification_type, [\App\Models\ChatMessage::NOTIFICATION_RESPONSIBLES, \App\Models\ChatMessage::NOTIFICATION_ALL], true))
+                                    <span class="badge text-bg-light border">Уведомления: {{ $message->notifiedUsers->pluck('name')->join(', ') ?: 'получатели' }}</span>
+                                @endif
+                            </div>
+                            <small class="text-muted">{{ $message->created_at?->format('d.m.Y H:i') }}</small>
+                        </div>
+
+                        @if(!empty($message->message))
+                            <div class="chat-message-text">{{ $message->message }}</div>
+                        @endif
+
+                        @if($message->files->isNotEmpty())
+                            <div class="chat-message-files">
+                                @foreach($message->files as $file)
+                                    @if(\Illuminate\Support\Str::startsWith((string) $file->mime_type, 'image/'))
+                                        <a href="{{ $file->link }}" target="_blank" data-toggle="lightbox" data-gallery="chat-{{ $contextKey }}" data-size="fullscreen">
+                                            <img src="{{ $file->link }}" alt="{{ $file->original_name }}" class="img-thumbnail">
+                                        </a>
+                                    @else
+                                        <a href="{{ $file->link }}" target="_blank" class="btn btn-sm btn-outline-secondary">
+                                            <i class="bi bi-paperclip"></i> {{ $file->original_name }}
+                                        </a>
+                                    @endif
+                                @endforeach
+                            </div>
+                        @endif
+                    </div>
+                @empty
+                    <div class="text-muted px-1">Сообщений пока нет.</div>
+                @endforelse
+            </div>
+
+            <button type="button" class="btn btn-primary btn-sm chat-scroll-bottom d-none" data-chat-scroll-bottom>
+                <i class="bi bi-arrow-down"></i>
+            </button>
+        </div>
+    </div>
+
+    <form action="{{ $action }}" method="post" enctype="multipart/form-data" class="mt-3" data-chat-form>
+        @csrf
+
+        {{-- Уведомления: свитч + саммари --}}
+        @if($canSendNotifications)
+            <div class="d-flex align-items-center gap-3 mb-2">
+
+                <div class="form-check form-switch mb-0">
+                    <input
+                        class="form-check-input"
+                        type="checkbox"
+                        role="switch"
+                        id="chat-notify-toggle-{{ $contextKey }}"
+                        data-chat-notify-toggle
+                        @checked($notificationEnabled)
+                    >
+                    <label class="form-check-label small" for="chat-notify-toggle-{{ $contextKey }}">
+                        <i class="bi bi-bell"></i> Уведомить
+                    </label>
+                </div>
+
+                <div class="small text-muted {{ $notificationEnabled ? '' : 'd-none' }}" data-chat-recipient-summary-wrap>
+                    <a href="#" class="text-decoration-none" data-chat-open-recipient-modal data-bs-toggle="modal" data-bs-target="#chatRecipientsModal-{{ $contextKey }}">
+                        <span data-chat-recipient-summary>Получатели не выбраны</span>
+                        <i class="bi bi-pencil-square ms-1"></i>
+                    </a>
+                </div>
+            </div>
+
+            {{-- Скрытый select для notification_type (значение управляется JS) --}}
+            <input type="hidden" name="notification_type" value="{{ $notificationValue }}" data-chat-notification-type>
+        @else
+            <input type="hidden" name="notification_type" value="{{ \App\Models\ChatMessage::NOTIFICATION_NONE }}">
+        @endif
+
+        {{-- Строка ввода: textarea + иконка файла + кнопка отправить --}}
+        <div class="d-flex align-items-center gap-2">
+            <div class="flex-grow-1">
+                <textarea
+                    class="form-control"
+                    id="chat-message-{{ $contextKey }}"
+                    name="message"
+                    rows="2"
+                    placeholder="Введите сообщение"
+                >{{ old('message') }}</textarea>
+            </div>
+
+            <input class="d-none" id="chat-attachments-{{ $contextKey }}" type="file" name="attachments[]" multiple data-chat-file-input>
+
+            <button type="button" class="btn btn-outline-secondary position-relative d-flex align-items-center justify-content-center" style="width: 38px; height: 38px; padding: 0;" title="Прикрепить файл" data-chat-attach-btn>
+                <i class="bi bi-paperclip"></i>
+                <span class="d-none position-absolute top-0 start-100 translate-middle badge rounded-pill bg-primary" style="font-size: .65em;" data-chat-file-count></span>
+            </button>
+
+            <button class="btn btn-primary d-flex align-items-center justify-content-center" style="width: 38px; height: 38px; padding: 0;" type="submit" title="{{ $submitLabel }}">
+                <i class="bi bi-send"></i>
+            </button>
+        </div>
+
+        <div data-chat-hidden-targets>
+            @foreach($selectedTargetUserIds as $selectedTargetUserId)
+                <input type="hidden" name="target_user_ids[]" value="{{ $selectedTargetUserId }}">
+            @endforeach
+        </div>
+    </form>
+
+    @if($canSendNotifications)
+        <div class="modal fade" id="chatRecipientsModal-{{ $contextKey }}" tabindex="-1" aria-labelledby="chatRecipientsModalLabel-{{ $contextKey }}" aria-hidden="true">
+            <div class="modal-dialog modal-dialog-scrollable">
+                <div class="modal-content">
+                    <div class="modal-header">
+                        <h1 class="modal-title fs-5" id="chatRecipientsModalLabel-{{ $contextKey }}">Получатели уведомления</h1>
+                        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
+                    </div>
+                    <div class="modal-body" data-chat-recipient-modal-body>
+                        {{-- Свитч: показать всех пользователей --}}
+                        <div class="d-flex justify-content-between align-items-center mb-3">
+                            <div class="form-check form-switch mb-0">
+                                <input
+                                    class="form-check-input"
+                                    type="checkbox"
+                                    role="switch"
+                                    id="chat-show-all-toggle-{{ $contextKey }}"
+                                    data-chat-show-all-toggle
+                                    @checked($showAllUsers)
+                                >
+                                <label class="form-check-label" for="chat-show-all-toggle-{{ $contextKey }}">
+                                    Показать всех
+                                </label>
+                            </div>
+                            <div class="d-flex gap-2">
+                                <button type="button" class="btn btn-sm btn-outline-primary" data-chat-check-visible>Выбрать всех</button>
+                                <button type="button" class="btn btn-sm btn-outline-secondary" data-chat-uncheck-visible>Снять всех</button>
+                            </div>
+                        </div>
+
+                        <div class="chat-recipient-list">
+                            @foreach($sortedUsers as $userId => $userData)
+                                @php
+                                    $userRole = $userData['role'] ?? '';
+                                    $isManagerOfEntity = $managerUserId && (int) $userId === $managerUserId;
+                                    $isBrigadierOfEntity = $brigadierUserId && (int) $userId === $brigadierUserId;
+                                    $roleLabel = $roleNames[$userRole] ?? '';
+                                    $displayLabel = $userData['name'];
+                                    if ($roleLabel) {
+                                        $displayLabel .= ' (' . $roleLabel . ')';
+                                    }
+                                    if ($isManagerOfEntity) {
+                                        $displayLabel .= ' — менеджер площадки';
+                                    }
+                                    if ($isBrigadierOfEntity) {
+                                        $displayLabel .= ' — бригадир площадки';
+                                    }
+                                    $isSelf = (int) $userId === $currentUserId;
+                                @endphp
+                                <label class="form-check mb-2 chat-recipient-item"
+                                       data-chat-recipient-item
+                                       data-user-id="{{ $userId }}"
+                                       data-user-name="{{ $displayLabel }}"
+                                       data-chat-responsible="{{ in_array((int) $userId, $responsibleUserIds, true) ? '1' : '0' }}"
+                                       data-chat-self="{{ $isSelf ? '1' : '0' }}">
+                                    <input
+                                        class="form-check-input"
+                                        type="checkbox"
+                                        value="{{ $userId }}"
+                                        data-chat-recipient-checkbox
+                                        @disabled($isSelf)
+                                        @checked(!$isSelf && in_array((int) $userId, $selectedTargetUserIds, true))
+                                    >
+                                    <span class="form-check-label {{ $isSelf ? 'text-muted' : '' }}">{{ $displayLabel }}@if($isSelf) (вы)@endif</span>
+                                </label>
+                            @endforeach
+                        </div>
+                    </div>
+                    <div class="modal-footer">
+                        <button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Закрыть</button>
+                        <button type="button" class="btn btn-primary btn-sm" data-chat-apply-recipients data-bs-dismiss="modal">Применить</button>
+                    </div>
+                </div>
+            </div>
+        </div>
+    @endif
+</div>
+
+@once
+    @push('scripts')
+        <script type="module">
+            function initChatBlock(block) {
+                const messages = block.querySelector('[data-chat-messages]');
+                const scrollButton = block.querySelector('[data-chat-scroll-bottom]');
+                const form = block.querySelector('[data-chat-form]');
+                const notifyToggle = block.querySelector('[data-chat-notify-toggle]');
+                const notificationType = block.querySelector('[data-chat-notification-type]');
+                const showAllToggle = block.querySelector('[data-chat-show-all-toggle]');
+                const summaryWrap = block.querySelector('[data-chat-recipient-summary-wrap]');
+                const summary = block.querySelector('[data-chat-recipient-summary]');
+                const hiddenTargets = block.querySelector('[data-chat-hidden-targets]');
+                const modal = block.querySelector('.modal');
+                const fileInput = block.querySelector('[data-chat-file-input]');
+                const attachBtn = block.querySelector('[data-chat-attach-btn]');
+                const fileCount = block.querySelector('[data-chat-file-count]');
+
+                // --- Файлы ---
+                if (attachBtn && fileInput) {
+                    attachBtn.addEventListener('click', () => fileInput.click());
+                    fileInput.addEventListener('change', () => {
+                        const count = fileInput.files?.length || 0;
+                        if (fileCount) {
+                            fileCount.textContent = String(count);
+                            fileCount.classList.toggle('d-none', count === 0);
+                        }
+                    });
+                }
+
+                // --- Скролл ---
+                const scrollToBottom = (force = false) => {
+                    if (!messages) return;
+                    const isNearBottom = messages.scrollHeight - messages.scrollTop - messages.clientHeight < 48;
+                    if (force || isNearBottom) {
+                        messages.scrollTop = messages.scrollHeight;
+                    }
+                };
+
+                const syncScrollButton = () => {
+                    if (!messages || !scrollButton) return;
+                    const shouldShow = messages.scrollHeight - messages.scrollTop - messages.clientHeight > 80;
+                    scrollButton.classList.toggle('d-none', !shouldShow);
+                };
+
+                // --- Получатели ---
+                const getSelectedIds = () => Array.from(hiddenTargets.querySelectorAll('input[name="target_user_ids[]"]'))
+                    .map((input) => Number(input.value))
+                    .filter((value) => value > 0);
+
+                const setSelectedIds = (ids) => {
+                    hiddenTargets.innerHTML = '';
+                    ids.forEach((id) => {
+                        const input = document.createElement('input');
+                        input.type = 'hidden';
+                        input.name = 'target_user_ids[]';
+                        input.value = String(id);
+                        hiddenTargets.appendChild(input);
+                    });
+                };
+
+                const visibleRecipientItems = () => Array.from(block.querySelectorAll('[data-chat-recipient-item]'))
+                    .filter((item) => !item.hidden);
+
+                const syncRecipientSummary = () => {
+                    if (!summary) return;
+
+                    if (!notifyToggle || !notifyToggle.checked) {
+                        summary.textContent = 'Уведомления выключены';
+                        return;
+                    }
+
+                    const selectedIds = getSelectedIds();
+                    if (!selectedIds.length) {
+                        summary.textContent = 'Получатели не выбраны';
+                        return;
+                    }
+
+                    const names = selectedIds
+                        .map((id) => block.querySelector('[data-chat-recipient-item][data-user-id="' + id + '"]'))
+                        .filter(Boolean)
+                        .map((item) => item.dataset.userName);
+
+                    summary.textContent = 'Получатели: ' + names.join(', ');
+                };
+
+                const syncNotificationType = () => {
+                    if (!notificationType) return;
+                    if (!notifyToggle || !notifyToggle.checked) {
+                        notificationType.value = 'none';
+                    } else if (showAllToggle && showAllToggle.checked) {
+                        notificationType.value = 'all';
+                    } else {
+                        notificationType.value = 'responsibles';
+                    }
+                };
+
+                const applyRecipientFilter = (preserveSelection = true) => {
+                    if (!modal) return;
+
+                    const isAll = showAllToggle && showAllToggle.checked;
+                    const recipientItems = Array.from(block.querySelectorAll('[data-chat-recipient-item]'));
+                    const selectedIds = new Set(getSelectedIds());
+
+                    recipientItems.forEach((item) => {
+                        const isSelf = item.dataset.chatSelf === '1';
+                        const isResponsible = item.dataset.chatResponsible === '1';
+                        const visible = isAll || isResponsible;
+                        const checkbox = item.querySelector('[data-chat-recipient-checkbox]');
+
+                        item.hidden = !visible;
+                        checkbox.disabled = !visible || isSelf;
+                        checkbox.checked = checkbox.checked && !isSelf;
+
+                        if (!visible || isSelf) {
+                            checkbox.checked = false;
+                        }
+                    });
+
+                    if (!preserveSelection) {
+                        recipientItems.forEach((item) => {
+                            const checkbox = item.querySelector('[data-chat-recipient-checkbox]');
+                            if (!checkbox.disabled) {
+                                checkbox.checked = true;
+                            }
+                        });
+                    } else {
+                        recipientItems.forEach((item) => {
+                            const checkbox = item.querySelector('[data-chat-recipient-checkbox]');
+                            checkbox.checked = !checkbox.disabled && selectedIds.has(Number(item.dataset.userId));
+                        });
+
+                        const hasVisibleSelected = recipientItems.some((item) => {
+                            const checkbox = item.querySelector('[data-chat-recipient-checkbox]');
+                            return !checkbox.disabled && checkbox.checked;
+                        });
+
+                        if (!hasVisibleSelected) {
+                            recipientItems.forEach((item) => {
+                                const checkbox = item.querySelector('[data-chat-recipient-checkbox]');
+                                if (!checkbox.disabled) {
+                                    checkbox.checked = true;
+                                }
+                            });
+                        }
+                    }
+                };
+
+                const commitRecipientSelection = () => {
+                    if (!notifyToggle || !notifyToggle.checked) {
+                        setSelectedIds([]);
+                        syncNotificationType();
+                        syncRecipientSummary();
+                        return;
+                    }
+
+                    const ids = visibleRecipientItems()
+                        .map((item) => item.querySelector('[data-chat-recipient-checkbox]'))
+                        .filter((checkbox) => checkbox && checkbox.checked)
+                        .map((checkbox) => Number(checkbox.value))
+                        .filter((value) => value > 0);
+
+                    setSelectedIds(ids);
+                    syncNotificationType();
+                    syncRecipientSummary();
+                };
+
+                // --- Инициализация скролла ---
+                if (messages) {
+                    requestAnimationFrame(() => { scrollToBottom(true); syncScrollButton(); });
+                    setTimeout(() => { scrollToBottom(true); syncScrollButton(); }, 150);
+                    messages.addEventListener('scroll', syncScrollButton);
+                }
+
+                if (scrollButton) {
+                    scrollButton.addEventListener('click', () => scrollToBottom(true));
+                }
+
+                // --- Свитч уведомлений ---
+                if (notifyToggle) {
+                    notifyToggle.addEventListener('change', () => {
+                        const enabled = notifyToggle.checked;
+
+                        if (summaryWrap) {
+                            summaryWrap.classList.toggle('d-none', !enabled);
+                        }
+
+                        if (!enabled) {
+                            setSelectedIds([]);
+                            syncNotificationType();
+                            syncRecipientSummary();
+                            return;
+                        }
+
+                        // Включили — сразу открываем модалку с ответственными
+                        applyRecipientFilter(false);
+                        commitRecipientSelection();
+
+                        if (modal) {
+                            bootstrap.Modal.getOrCreateInstance(modal).show();
+                        }
+                    });
+                }
+
+                // --- Свитч "Показать всех" в модалке ---
+                if (showAllToggle) {
+                    showAllToggle.addEventListener('change', () => {
+                        applyRecipientFilter(false);
+                        syncNotificationType();
+                    });
+                }
+
+                // --- Открытие модалки вручную ---
+                block.querySelector('[data-chat-open-recipient-modal]')?.addEventListener('click', () => {
+                    applyRecipientFilter(true);
+                });
+
+                // --- Выбрать/снять всех ---
+                block.querySelector('[data-chat-check-visible]')?.addEventListener('click', () => {
+                    visibleRecipientItems().forEach((item) => {
+                        const checkbox = item.querySelector('[data-chat-recipient-checkbox]');
+                        if (checkbox) checkbox.checked = true;
+                    });
+                });
+
+                block.querySelector('[data-chat-uncheck-visible]')?.addEventListener('click', () => {
+                    visibleRecipientItems().forEach((item) => {
+                        const checkbox = item.querySelector('[data-chat-recipient-checkbox]');
+                        if (checkbox) checkbox.checked = false;
+                    });
+                });
+
+                // --- Применить / закрыть модалку ---
+                block.querySelector('[data-chat-apply-recipients]')?.addEventListener('click', commitRecipientSelection);
+                modal?.addEventListener('hidden.bs.modal', () => {
+                    if (notifyToggle && notifyToggle.checked) {
+                        commitRecipientSelection();
+                    }
+                });
+
+                // --- Сабмит формы ---
+                form?.addEventListener('submit', () => {
+                    if (notifyToggle && notifyToggle.checked) {
+                        commitRecipientSelection();
+                    }
+                });
+
+                // --- Начальное состояние ---
+                applyRecipientFilter(true);
+                if (notifyToggle && notifyToggle.checked) {
+                    commitRecipientSelection();
+                } else {
+                    syncNotificationType();
+                    syncRecipientSummary();
+                }
+            }
+
+            document.querySelectorAll('[data-chat-block]').forEach(initChatBlock);
+        </script>
+    @endpush
+@endonce

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

@@ -17,7 +17,7 @@
         <tbody>
             @foreach($sections as $section)
                 <tr>
-                    <td colspan="{{ count($channels) + 1 }}" style="background-color: #d4edda;">
+                    <td colspan="{{ count($channels) + 1 }}" class="section-header-row">
                         <strong>{{ $section['title'] }}</strong>
                     </td>
                 </tr>

+ 1 - 1
resources/views/partials/table.blade.php

@@ -99,7 +99,7 @@
                 @endif
             >
                 @foreach($header as $headerName => $headerTitle)
-                    <td class="column_{{$headerName}}"
+                    <td class="column_{{$headerName}} align-middle"
                     >
                         @if(str_contains($headerName, '-'))
                             @php

+ 24 - 5
resources/views/reclamations/edit.blade.php

@@ -86,11 +86,13 @@
                                 </td>
                                 <td>
                                     @if(hasRole('admin,manager'))
-                                    <a href="{{ route('product_sku.show', $p) }}">
-                                        {!! $p->product->article !!}
-                                    </a>
-                                    <br>
-                                    <a class="small" href="{{ route('catalog.show', $p->product) }}">каталог</a>
+                                        <a href="{{ route('product_sku.show', $p) }}">
+                                            {{ $p->product->article }}
+                                        </a>
+                                        <br>
+                                        <a class="small" href="{{ route('catalog.show', $p->product) }}">каталог</a>
+                                    @else
+                                        {{ $p->product->article }}
                                     @endif
                                 </td>
                                 <td>{!! $p->product->nomenclature_number !!}</td>
@@ -600,6 +602,18 @@
                         @endforeach
                     </div>
                 </div>
+
+                @include('partials.chat', [
+                    'title' => 'Чат рекламации',
+                    'messages' => $reclamation->chatMessages,
+                    'users' => $chatUsers,
+                    'responsibleUserIds' => $chatResponsibleUserIds,
+                    'managerUserId' => $chatManagerUserId ?? null,
+                    'brigadierUserId' => $chatBrigadierUserId ?? null,
+                    'action' => route('reclamations.chat-messages.store', $reclamation),
+                    'contextKey' => 'reclamation-' . $reclamation->id,
+                    'submitLabel' => 'Отправить в чат',
+                ])
             </div>
         </div>
     </div>
@@ -762,6 +776,11 @@
             });
         }
 
+        $('.chat-notification-type').on('change', function () {
+            const target = $(this).data('chat-target');
+            $(target).toggleClass('d-none', $(this).val() !== 'user');
+        }).trigger('change');
+
         // Инициализация для существующих строк
         $('.spare-part-row').not('.spare-part-template').each(function() {
             initSparePartAutocomplete($(this));

+ 30 - 6
resources/views/reclamations/index.blade.php

@@ -7,17 +7,41 @@
             <h3>Рекламации</h3>
         </div>
         <div class="col-md-6 text-end">
-            @if(hasRole('admin'))
-                <a href="#" class="btn btn-sm btn-primary" onclick="$('#export-reclamations').submit()">Экспорт</a>
+            @if(hasRole('admin,manager'))
+                <button type="button" class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#exportReclamationsModal">Экспорт</button>
             @endif
 
         </div>
     </div>
 
-    @if(hasRole('admin'))
-        <form class="d-none" method="post" action="{{ route('reclamations.export') }}" id="export-reclamations">
-            @csrf
-        </form>
+    @if(hasRole('admin,manager'))
+        <div class="modal fade" id="exportReclamationsModal" tabindex="-1" aria-labelledby="exportReclamationsModalLabel" aria-hidden="true">
+            <div class="modal-dialog modal-fullscreen-sm-down modal-lg">
+                <div class="modal-content">
+                    <div class="modal-header">
+                        <h1 class="modal-title fs-5" id="exportReclamationsModalLabel">Экспорт рекламаций</h1>
+                        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
+                    </div>
+                    <div class="modal-body">
+                        <form method="post" action="{{ route('reclamations.export') }}">
+                            @csrf
+                            @include('partials.checkbox', ['title' => 'С учётом текущего фильтра и поиска', 'name' => 'withFilter', 'type' => 'checkbox', 'value' => 'yes', 'checked' => false])
+                            <div class="d-none">
+                                @if(request()->s)
+                                    @include('partials.input', ['name' => 's', 'title' => 'поиск', 'value' => request()->s])
+                                @endif
+                                @if(request()->filters)
+                                    @foreach(request()->filters as $filterName => $filterValue)
+                                        @include('partials.input', ['name' => 'filters[' . $filterName .']', 'title' => $filterName, 'value' => $filterValue])
+                                    @endforeach
+                                @endif
+                            </div>
+                            @include('partials.submit', ['name' => 'Экспорт'])
+                        </form>
+                    </div>
+                </div>
+            </div>
+        </div>
     @endif
 
     @include('partials.table', [

+ 4 - 4
resources/views/reports/index.blade.php

@@ -250,8 +250,8 @@
                         <table class="table js-sortable-table" id="table-mafs">
                             <thead>
                                 <tr>
-                                    <th class="sortable" data-col="0" data-dir="asc" style="cursor:pointer;user-select:none">Артикул МАФ <span class="sort-icon"></span></th>
-                                    <th class="sortable" data-col="1" data-dir="asc" style="cursor:pointer;user-select:none">Кол-во <span class="sort-icon"></span></th>
+                                    <th class="sortable" data-col="0" data-dir="asc">Артикул МАФ <span class="sort-icon"></span></th>
+                                    <th class="sortable" data-col="1" data-dir="asc">Кол-во <span class="sort-icon"></span></th>
                                 </tr>
                             </thead>
                             <tbody>
@@ -274,8 +274,8 @@
                         <table class="table js-sortable-table" id="table-details">
                             <thead>
                                 <tr>
-                                    <th class="sortable" data-col="0" data-dir="asc" style="cursor:pointer;user-select:none">Деталь <span class="sort-icon"></span></th>
-                                    <th class="sortable" data-col="1" data-dir="asc" style="cursor:pointer;user-select:none">Кол-во <span class="sort-icon"></span></th>
+                                    <th class="sortable" data-col="0" data-dir="asc">Деталь <span class="sort-icon"></span></th>
+                                    <th class="sortable" data-col="1" data-dir="asc">Кол-во <span class="sort-icon"></span></th>
                                 </tr>
                             </thead>
                             <tbody>

+ 29 - 71
resources/views/schedule/index.blade.php

@@ -100,19 +100,20 @@
             </thead>
             <tbody>
             @foreach($schedules as $dow => $schs)
-                <tr>
-                    <td rowspan="{{ ($schs) ? count($schs) : '1' }}"
-                        class="vertical">{{ \App\Helpers\DateHelper::getHumanDayOfWeek($dow) }}
-                        @if(hasRole('admin'))
-                            <i class="bi bi-calendar-plus text-primary ms-2 createSchedule"
-                               title="Новая запись" data-schedule-date="{{ $dow }}"></i>
-                        @endif
-                    </td>
-                    <td rowspan="{{ ($schs) ? count($schs) : '1' }}"
-                        class="vertical">{{ \App\Helpers\DateHelper::getHumanDate($dow) }}</td>
-                    @if($schs)
-                        @foreach($schs as $schedule)
-                            {!! (!$loop->first) ? '<tr>':'' !!}
+                @if($schs)
+                    @foreach($schs as $schedule)
+                        <tr>
+                            @if($loop->first)
+                                <td rowspan="{{ count($schs) }}"
+                                    class="vertical">{{ \App\Helpers\DateHelper::getHumanDayOfWeek($dow) }}
+                                    @if(hasRole('admin'))
+                                        <i class="bi bi-calendar-plus text-primary ms-2 createSchedule"
+                                           title="Новая запись" data-schedule-date="{{ $dow }}"></i>
+                                    @endif
+                                </td>
+                                <td rowspan="{{ count($schs) }}"
+                                    class="vertical">{{ \App\Helpers\DateHelper::getHumanDate($dow) }}</td>
+                            @endif
                             <td style="background: {{ $schedule->brigadier->color }}"
                                 class="align-middle code-{{ $schedule->id }}">
                                 @php
@@ -182,11 +183,21 @@
                                     </div>
                                 </td>
                             @endif
-                </tr>
-            @endforeach
-            @endif
-            {!! (!$schs) ? '</tr>': '' !!}
-
+                        </tr>
+                    @endforeach
+                @else
+                    <tr>
+                        <td rowspan="1"
+                            class="vertical">{{ \App\Helpers\DateHelper::getHumanDayOfWeek($dow) }}
+                            @if(hasRole('admin'))
+                                <i class="bi bi-calendar-plus text-primary ms-2 createSchedule"
+                                   title="Новая запись" data-schedule-date="{{ $dow }}"></i>
+                            @endif
+                        </td>
+                        <td rowspan="1"
+                            class="vertical">{{ \App\Helpers\DateHelper::getHumanDate($dow) }}</td>
+                    </tr>
+                @endif
             @endforeach
 
             </tbody>
@@ -272,59 +283,6 @@
             @endif
         @endif
 
-        <style>
-            .schedule-month {
-                table-layout: fixed;
-            }
-            .schedule-day {
-                height: 110px;
-                vertical-align: top;
-                position: relative;
-            }
-            .schedule-day-link {
-                display: inline-block;
-                font-weight: 600;
-                text-decoration: none;
-                color: #0d6efd;
-            }
-            .schedule-dots {
-                margin-top: 6px;
-                display: flex;
-                flex-wrap: wrap;
-                gap: 4px;
-            }
-            .schedule-dot {
-                width: 12px;
-                height: 12px;
-                border-radius: 50%;
-                display: inline-block;
-                border: 1px solid rgba(0, 0, 0, 0.1);
-            }
-            .schedule-outside {
-                background: #f8f9fa;
-                color: #9aa0a6;
-            }
-            .schedule-today {
-                outline: 2px solid rgba(13, 110, 253, 0.5);
-                outline-offset: -2px;
-            }
-            .schedule-legend-items {
-                display: flex;
-                flex-wrap: wrap;
-                gap: 10px 16px;
-                align-items: center;
-            }
-            .schedule-legend-item {
-                display: inline-flex;
-                align-items: center;
-                gap: 6px;
-                font-size: 0.9rem;
-            }
-            .schedule-legend-name {
-                white-space: nowrap;
-            }
-        </style>
-
         @if(hasRole('admin'))
             <!-- Модальное окно редактирования графика -->
             <div class="modal fade" id="copySchedule" tabindex="-1" aria-labelledby="copyScheduleLabel" aria-hidden="true">

+ 16 - 16
resources/views/spare_parts/edit.blade.php

@@ -45,7 +45,7 @@
                                    id="article"
                                    name="article"
                                    value="{{ old('article', $spare_part->article ?? '') }}"
-                                   {{ hasRole('admin') ? '' : 'readonly' }}
+                                   {{ hasRole('admin,manager') ? '' : 'readonly' }}
                                    required>
                         </div>
 
@@ -56,7 +56,7 @@
                                    id="used_in_maf"
                                    name="used_in_maf"
                                    value="{{ old('used_in_maf', $spare_part->used_in_maf ?? '') }}"
-                                   {{ hasRole('admin') ? '' : 'readonly' }}>
+                                   {{ hasRole('admin,manager') ? '' : 'readonly' }}>
                         </div>
 
                         <div class="mb-3">
@@ -65,7 +65,7 @@
                                       id="note"
                                       name="note"
                                       rows="3"
-                                      {{ hasRole('admin') ? '' : 'readonly' }}>{{ old('note', $spare_part->note ?? '') }}</textarea>
+                                      {{ hasRole('admin,manager') ? '' : 'readonly' }}>{{ old('note', $spare_part->note ?? '') }}</textarea>
                         </div>
                     </div>
 
@@ -91,7 +91,7 @@
                                    name="customer_price"
                                    step="0.01"
                                    value="{{ old('customer_price', $spare_part->customer_price ?? '') }}"
-                                   {{ hasRole('admin') ? '' : 'readonly' }}>
+                                   {{ hasRole('admin,manager') ? '' : 'readonly' }}>
                         </div>
 
                         <div class="mb-3">
@@ -102,7 +102,7 @@
                                    name="expertise_price"
                                    step="0.01"
                                    value="{{ old('expertise_price', $spare_part->expertise_price ?? '') }}"
-                                   {{ hasRole('admin') ? '' : 'readonly' }}>
+                                   {{ hasRole('admin,manager') ? '' : 'readonly' }}>
                         </div>
 
                         <div class="mb-3">
@@ -115,7 +115,7 @@
                                            name="tsn_number"
                                            value="{{ old('tsn_number', $spare_part->tsn_number ?? '') }}"
                                            autocomplete="off"
-                                           {{ hasRole('admin') ? '' : 'readonly' }}>
+                                           {{ hasRole('admin,manager') ? '' : 'readonly' }}>
                                     <div class="autocomplete-dropdown" id="tsn_number_dropdown"></div>
                                 </div>
                                 <div class="col-md-7">
@@ -124,7 +124,7 @@
                                            id="tsn_number_description"
                                            name="tsn_number_description"
                                            placeholder="Расшифровка"
-                                           {{ hasRole('admin') ? '' : 'readonly' }}>
+                                           {{ hasRole('admin,manager') ? '' : 'readonly' }}>
                                 </div>
                             </div>
                             <div class="form-text" id="tsn_number_hint"></div>
@@ -145,7 +145,7 @@
                                                    value="{{ $code }}"
                                                    autocomplete="off"
                                                    placeholder="Код"
-                                                   {{ hasRole('admin') ? '' : 'readonly' }}>
+                                                   {{ hasRole('admin,manager') ? '' : 'readonly' }}>
                                             <div class="autocomplete-dropdown pricing-code-dropdown"></div>
                                         </div>
                                         <div class="col-md-5">
@@ -153,10 +153,10 @@
                                                    class="form-control pricing-code-description-input"
                                                    name="pricing_codes_descriptions[]"
                                                    placeholder="Расшифровка"
-                                                   {{ hasRole('admin') ? '' : 'readonly' }}>
+                                                   {{ hasRole('admin,manager') ? '' : 'readonly' }}>
                                         </div>
                                         <div class="col-md-2">
-                                            @if(hasRole('admin'))
+                                            @if(hasRole('admin,manager'))
                                                 <button type="button" class="btn btn-outline-danger btn-remove-pricing-code" title="Удалить">
                                                     <i class="bi bi-trash"></i>
                                                 </button>
@@ -173,7 +173,7 @@
                                                    value=""
                                                    autocomplete="off"
                                                    placeholder="Код"
-                                                   {{ hasRole('admin') ? '' : 'readonly' }}>
+                                                   {{ hasRole('admin,manager') ? '' : 'readonly' }}>
                                             <div class="autocomplete-dropdown pricing-code-dropdown"></div>
                                         </div>
                                         <div class="col-md-5">
@@ -181,10 +181,10 @@
                                                    class="form-control pricing-code-description-input"
                                                    name="pricing_codes_descriptions[]"
                                                    placeholder="Расшифровка"
-                                                   {{ hasRole('admin') ? '' : 'readonly' }}>
+                                                   {{ hasRole('admin,manager') ? '' : 'readonly' }}>
                                         </div>
                                         <div class="col-md-2">
-                                            @if(hasRole('admin'))
+                                            @if(hasRole('admin,manager'))
                                                 <button type="button" class="btn btn-outline-danger btn-remove-pricing-code" title="Удалить">
                                                     <i class="bi bi-trash"></i>
                                                 </button>
@@ -194,7 +194,7 @@
                                     </div>
                                 @endforelse
                             </div>
-                            @if(hasRole('admin'))
+                            @if(hasRole('admin,manager'))
                                 <button type="button" class="btn btn-sm btn-outline-primary" id="btn_add_pricing_code">
                                     <i class="bi bi-plus-circle"></i> Добавить код
                                 </button>
@@ -208,14 +208,14 @@
                                    id="min_stock"
                                    name="min_stock"
                                    value="{{ old('min_stock', $spare_part->min_stock ?? 0) }}"
-                                   {{ hasRole('admin') ? '' : 'readonly' }}>
+                                   {{ hasRole('admin,manager') ? '' : 'readonly' }}>
                         </div>
                     </div>
                 </div>
 
                 <div class="row">
                     <div class="col-12">
-                        @if(hasRole('admin'))
+                        @if(hasRole('admin,manager'))
                             <button type="submit" class="btn btn-sm btn-success">Сохранить</button>
                         @endif
                         <a href="{{ $previous_url ?? route('spare_parts.index') }}" class="btn btn-sm btn-secondary">Назад</a>

+ 3 - 1
resources/views/spare_parts/index.blade.php

@@ -44,8 +44,10 @@
             @if(($tab ?? 'catalog') === 'catalog')
                 {{-- Кнопки управления --}}
                 <div class="mb-3">
-                    @if(hasRole('admin'))
+                    @if(hasRole('admin,manager'))
                         <a href="{{ route('spare_parts.create') }}" class="btn btn-sm btn-primary">Добавить запчасть</a>
+                    @endif
+                    @if(hasRole('admin'))
                         <form action="{{ route('spare_parts.export') }}" method="POST" class="d-inline">
                             @csrf
                             <button type="submit" class="btn btn-sm btn-success">Экспорт</button>

+ 1 - 0
resources/views/users/edit.blade.php

@@ -56,6 +56,7 @@
                                 ['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'] ?? []],
+                                ['title' => 'Чат', 'settingsKey' => 'chat', 'options' => $chatSourceOptions, 'colors' => [], 'settings' => $settings['chat_settings'] ?? []],
                             ],
                         ])
                     </div>

+ 11 - 2
routes/web.php

@@ -4,6 +4,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\ChatMessageController;
 use App\Http\Controllers\AreaController;
 use App\Http\Controllers\ClearDataController;
 use App\Http\Controllers\YearDataController;
@@ -133,6 +134,7 @@ Route::middleware('auth:web')->group(function () {
     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/read-all', [UserNotificationController::class, 'markAllRead'])->name('notifications.read-all');
     Route::post('notifications/{notification}/read', [UserNotificationController::class, 'markRead'])->name('notifications.read');
     Route::get('notifications/unread/count', [UserNotificationController::class, 'unreadCount'])->name('notifications.unread-count');
 
@@ -193,6 +195,9 @@ Route::middleware('auth:web')->group(function () {
         Route::post('reclamations/update/{reclamation}', [ReclamationController::class, 'update'])->name('reclamations.update');
         Route::post('reclamations/{reclamation}/update-status', [ReclamationController::class, 'updateStatus'])->name('reclamations.update-status');
         Route::post('reclamations/{reclamation}/upload-document', [ReclamationController::class, 'uploadDocument'])->name('reclamations.upload-document');
+        Route::delete('reclamations/delete-document/{reclamation}/{file}', [ReclamationController::class, 'deleteDocument'])
+            ->name('reclamations.delete-document')
+            ->middleware('role:admin,manager');
         Route::delete('reclamations/delete-photo-before/{reclamation}/{file}', [ReclamationController::class, 'deletePhotoBefore'])->name('reclamations.delete-photo-before');
         Route::delete('reclamations/delete-photo-after/{reclamation}/{file}', [ReclamationController::class, 'deletePhotoAfter'])->name('reclamations.delete-photo-after');
         Route::post('reclamations/{reclamation}/update-details', [ReclamationController::class, 'updateDetails'])->name('reclamations.update-details');
@@ -209,6 +214,7 @@ Route::middleware('auth:web')->group(function () {
     Route::get('order', [OrderController::class, 'index'])->name('order.index');
     Route::get('order/show/{order}', [OrderController::class, 'show'])->name('order.show');
     Route::post('order/{order}/upload-photo', [OrderController::class, 'uploadPhoto'])->name('order.upload-photo');
+    Route::post('order/{order}/chat-messages', [ChatMessageController::class, 'storeForOrder'])->name('order.chat-messages.store');
     Route::get('order/generate-photos-pack/{order}', [OrderController::class, 'generatePhotosPack'])->name('order.generate-photos-pack');
     Route::get('order/download-tech-docs/{order}', [OrderController::class, 'downloadTechDocs'])->name('order.download-tech-docs');
 
@@ -219,7 +225,6 @@ Route::middleware('auth:web')->group(function () {
         Route::delete('catalog/{product}', [ProductController::class, 'delete'])->name('catalog.delete');
 
         Route::post('catalog-export', [ProductController::class, 'export'])->name('catalog.export');
-        Route::post('reclamations/export', [ReclamationController::class, 'export'])->name('reclamations.export');
 
         Route::post('mafs-import', [ProductSKUController::class, 'importMaf'])->name('mafs.import');
         Route::post('mafs-export', [ProductSKUController::class, 'exportMaf'])->name('mafs.export');
@@ -257,9 +262,12 @@ Route::middleware('auth:web')->group(function () {
         Route::delete('catalog/delete-certificate/{product}/{file}', [ProductController::class, 'deleteCertificate'])->name('catalog.delete-certificate');
         Route::delete('product_sku/delete-passport/{product_sku}/{file}', [ProductSKUController::class, 'deletePassport'])->name('product-sku.delete-passport');
 
-        Route::delete('reclamations/delete-document/{reclamation}/{file}', [ReclamationController::class, 'deleteDocument'])->name('reclamations.delete-document');
     });
 
+    Route::post('reclamations/export', [ReclamationController::class, 'export'])
+        ->name('reclamations.export')
+        ->middleware('role:admin,manager');
+
 
 
 
@@ -267,6 +275,7 @@ Route::middleware('auth:web')->group(function () {
 
     Route::get('reclamations', [ReclamationController::class, 'index'])->name('reclamations.index');
     Route::get('reclamations/show/{reclamation}', [ReclamationController::class, 'show'])->name('reclamations.show');
+    Route::post('reclamations/{reclamation}/chat-messages', [ChatMessageController::class, 'storeForReclamation'])->name('reclamations.chat-messages.store');
 
 
     Route::post('reclamations/{reclamation}/upload-photo-before', [ReclamationController::class, 'uploadPhotoBefore'])->name('reclamations.upload-photo-before');

+ 67 - 2
tests/Feature/OrderControllerTest.php

@@ -59,7 +59,9 @@ class OrderControllerTest extends TestCase
 
     public function test_orders_index_displays_orders(): void
     {
-        $order = Order::factory()->create();
+        $order = Order::factory()->create([
+            'object_address' => 'ул. Проверочная, д. 101',
+        ]);
 
         $response = $this->actingAs($this->managerUser)
             ->get(route('order.index'));
@@ -72,10 +74,12 @@ class OrderControllerTest extends TestCase
     {
         $assignedOrder = Order::factory()->create([
             'brigadier_id' => $this->brigadierUser->id,
+            'object_address' => 'ул. Бригадирская, д. 10',
         ]);
 
         $otherOrder = Order::factory()->create([
             'brigadier_id' => User::factory()->create(['role' => Role::BRIGADIER])->id,
+            'object_address' => 'ул. Чужая, д. 20',
         ]);
 
         $response = $this->actingAs($this->brigadierUser)
@@ -86,6 +90,28 @@ class OrderControllerTest extends TestCase
         $response->assertDontSee($otherOrder->object_address);
     }
 
+    public function test_brigadier_does_not_see_handed_over_orders_in_index(): void
+    {
+        $visibleOrder = Order::factory()->create([
+            'brigadier_id' => $this->brigadierUser->id,
+            'order_status_id' => Order::STATUS_IN_MOUNT,
+            'object_address' => 'ул. Видимая, д. 11',
+        ]);
+
+        $hiddenOrder = Order::factory()->create([
+            'brigadier_id' => $this->brigadierUser->id,
+            'order_status_id' => Order::STATUS_HANDED_OVER,
+            'object_address' => 'ул. Сданная, д. 12',
+        ]);
+
+        $response = $this->actingAs($this->brigadierUser)
+            ->get(route('order.index'));
+
+        $response->assertStatus(200);
+        $response->assertSee($visibleOrder->object_address);
+        $response->assertDontSee($hiddenOrder->object_address);
+    }
+
     // ==================== Create ====================
 
     public function test_can_view_create_order_form(): void
@@ -172,7 +198,9 @@ class OrderControllerTest extends TestCase
 
     public function test_can_view_order_details(): void
     {
-        $order = Order::factory()->create();
+        $order = Order::factory()->create([
+            'object_address' => 'ул. Детальная, д. 5',
+        ]);
 
         $response = $this->actingAs($this->managerUser)
             ->get(route('order.show', $order));
@@ -182,6 +210,19 @@ class OrderControllerTest extends TestCase
         $response->assertSee($order->object_address);
     }
 
+    public function test_brigadier_cannot_view_handed_over_order_details(): void
+    {
+        $order = Order::factory()->create([
+            'brigadier_id' => $this->brigadierUser->id,
+            'order_status_id' => Order::STATUS_HANDED_OVER,
+        ]);
+
+        $response = $this->actingAs($this->brigadierUser)
+            ->get(route('order.show', $order));
+
+        $response->assertStatus(403);
+    }
+
     // ==================== Edit ====================
 
     public function test_can_view_edit_order_form(): void
@@ -246,6 +287,30 @@ class OrderControllerTest extends TestCase
         $response->assertDontSee($otherOrder->object_address);
     }
 
+    public function test_order_search_route_can_search_by_manager_name(): void
+    {
+        $manager = User::factory()->create([
+            'role' => Role::MANAGER,
+            'name' => 'Менеджер Поиска',
+        ]);
+
+        $matchedOrder = Order::factory()->create([
+            'user_id' => $manager->id,
+            'object_address' => 'ул. Найденная, д. 7',
+        ]);
+
+        $otherOrder = Order::factory()->create([
+            'object_address' => 'ул. Не должна попасть, д. 8',
+        ]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->getJson(route('order.search', ['s' => 'Менеджер Поиска']));
+
+        $response->assertOk();
+        $response->assertJsonPath((string) $matchedOrder->id, $matchedOrder->common_name);
+        $response->assertJsonMissing([$otherOrder->id => $otherOrder->common_name]);
+    }
+
     // ==================== MAF Operations ====================
 
     public function test_get_maf_to_order_assigns_available_maf(): void

+ 44 - 0
tests/Feature/ReclamationControllerTest.php

@@ -26,6 +26,7 @@ class ReclamationControllerTest extends TestCase
 
     private User $adminUser;
     private User $managerUser;
+    private User $brigadierUser;
 
     protected function setUp(): void
     {
@@ -33,6 +34,7 @@ class ReclamationControllerTest extends TestCase
 
         $this->adminUser = User::factory()->create(['role' => Role::ADMIN]);
         $this->managerUser = User::factory()->create(['role' => Role::MANAGER]);
+        $this->brigadierUser = User::factory()->create(['role' => Role::BRIGADIER]);
     }
 
     // ==================== Authentication ====================
@@ -65,6 +67,35 @@ class ReclamationControllerTest extends TestCase
         $response->assertStatus(200);
     }
 
+    public function test_brigadier_sees_only_assigned_reclamations_with_allowed_statuses(): void
+    {
+        $visibleReclamation = Reclamation::factory()->create([
+            'brigadier_id' => $this->brigadierUser->id,
+            'status_id' => Reclamation::STATUS_IN_WORK,
+            'reason' => 'Видимая рекламация',
+        ]);
+
+        $hiddenByStatus = Reclamation::factory()->create([
+            'brigadier_id' => $this->brigadierUser->id,
+            'status_id' => Reclamation::STATUS_DONE,
+            'reason' => 'Скрытая по статусу',
+        ]);
+
+        $hiddenByBrigadier = Reclamation::factory()->create([
+            'brigadier_id' => User::factory()->create(['role' => Role::BRIGADIER])->id,
+            'status_id' => Reclamation::STATUS_IN_WORK,
+            'reason' => 'Скрытая по бригадиру',
+        ]);
+
+        $response = $this->actingAs($this->brigadierUser)
+            ->get(route('reclamations.index'));
+
+        $response->assertStatus(200);
+        $response->assertSee($visibleReclamation->reason);
+        $response->assertDontSee($hiddenByStatus->reason);
+        $response->assertDontSee($hiddenByBrigadier->reason);
+    }
+
     // ==================== Create ====================
 
     public function test_can_create_reclamation_for_order(): void
@@ -125,6 +156,19 @@ class ReclamationControllerTest extends TestCase
         $response->assertViewIs('reclamations.edit');
     }
 
+    public function test_brigadier_cannot_view_reclamation_details_with_hidden_status(): void
+    {
+        $reclamation = Reclamation::factory()->create([
+            'brigadier_id' => $this->brigadierUser->id,
+            'status_id' => Reclamation::STATUS_DONE,
+        ]);
+
+        $response = $this->actingAs($this->brigadierUser)
+            ->get(route('reclamations.show', $reclamation));
+
+        $response->assertStatus(403);
+    }
+
     // ==================== Update ====================
 
     public function test_can_update_reclamation(): void

+ 99 - 0
tests/Feature/ScheduleControllerTest.php

@@ -113,6 +113,105 @@ class ScheduleControllerTest extends TestCase
         $response->assertStatus(200);
     }
 
+    public function test_brigadier_sees_only_visible_platform_and_reclamation_schedules(): void
+    {
+        $otherBrigadier = User::factory()->create(['role' => Role::BRIGADIER]);
+
+        $visibleOrder = Order::factory()->create([
+            'brigadier_id' => $this->brigadierUser->id,
+            'order_status_id' => Order::STATUS_IN_MOUNT,
+            'object_address' => 'ул. Площадка видимая, д. 1',
+        ]);
+        $hiddenOrder = Order::factory()->create([
+            'brigadier_id' => $this->brigadierUser->id,
+            'order_status_id' => Order::STATUS_HANDED_OVER,
+            'object_address' => 'ул. Площадка скрытая, д. 2',
+        ]);
+        $visibleReclamation = Reclamation::factory()->create([
+            'order_id' => $visibleOrder->id,
+            'brigadier_id' => $this->brigadierUser->id,
+            'status_id' => Reclamation::STATUS_IN_WORK,
+            'reason' => 'Рекламация видимая',
+        ]);
+        $hiddenReclamation = Reclamation::factory()->create([
+            'order_id' => $hiddenOrder->id,
+            'brigadier_id' => $this->brigadierUser->id,
+            'status_id' => Reclamation::STATUS_DONE,
+            'reason' => 'Рекламация скрытая',
+        ]);
+        $foreignOrder = Order::factory()->create([
+            'brigadier_id' => $otherBrigadier->id,
+            'order_status_id' => Order::STATUS_IN_MOUNT,
+            'object_address' => 'ул. Чужая площадка, д. 3',
+        ]);
+        $foreignReclamation = Reclamation::factory()->create([
+            'order_id' => $foreignOrder->id,
+            'brigadier_id' => $otherBrigadier->id,
+            'status_id' => Reclamation::STATUS_IN_WORK,
+            'reason' => 'Чужая рекламация',
+        ]);
+
+        Schedule::factory()->create([
+            'source' => 'Площадки',
+            'order_id' => $visibleOrder->id,
+            'brigadier_id' => $this->brigadierUser->id,
+            'installation_date' => '2026-03-16',
+            'object_address' => $visibleOrder->object_address,
+        ]);
+        Schedule::factory()->create([
+            'source' => 'Площадки',
+            'order_id' => $hiddenOrder->id,
+            'brigadier_id' => $this->brigadierUser->id,
+            'installation_date' => '2026-03-16',
+            'object_address' => $hiddenOrder->object_address,
+        ]);
+        Schedule::factory()->create([
+            'source' => 'Рекламации',
+            'address_code' => 'РЕКЛ-' . $visibleReclamation->id,
+            'order_id' => $visibleOrder->id,
+            'brigadier_id' => $this->brigadierUser->id,
+            'installation_date' => '2026-03-17',
+            'object_address' => $visibleOrder->object_address,
+            'object_type' => $visibleReclamation->reason,
+        ]);
+        Schedule::factory()->create([
+            'source' => 'Рекламации',
+            'address_code' => 'РЕКЛ-' . $hiddenReclamation->id,
+            'order_id' => $hiddenOrder->id,
+            'brigadier_id' => $this->brigadierUser->id,
+            'installation_date' => '2026-03-18',
+            'object_address' => $hiddenOrder->object_address,
+            'object_type' => $hiddenReclamation->reason,
+        ]);
+        Schedule::factory()->create([
+            'source' => 'Площадки',
+            'order_id' => $foreignOrder->id,
+            'brigadier_id' => $otherBrigadier->id,
+            'installation_date' => '2026-03-19',
+            'object_address' => $foreignOrder->object_address,
+        ]);
+        Schedule::factory()->create([
+            'source' => 'Рекламации',
+            'address_code' => 'РЕКЛ-' . $foreignReclamation->id,
+            'order_id' => $foreignOrder->id,
+            'brigadier_id' => $otherBrigadier->id,
+            'installation_date' => '2026-03-20',
+            'object_address' => $foreignOrder->object_address,
+            'object_type' => $foreignReclamation->reason,
+        ]);
+
+        $response = $this->actingAs($this->brigadierUser)
+            ->get(route('schedule.index', ['year' => 2026, 'week' => 12]));
+
+        $response->assertStatus(200);
+        $response->assertSee($visibleOrder->object_address);
+        $response->assertSee($visibleReclamation->reason);
+        $response->assertDontSee($hiddenOrder->object_address);
+        $response->assertDontSee($hiddenReclamation->reason);
+        $response->assertDontSee($foreignOrder->object_address);
+        $response->assertDontSee($foreignReclamation->reason);
+    }
+
     // ==================== Update (create manual schedule) ====================
 
     public function test_admin_can_create_manual_schedule(): void

+ 61 - 0
tests/Feature/UserNotificationControllerTest.php

@@ -0,0 +1,61 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Models\User;
+use App\Models\UserNotification;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Tests\TestCase;
+
+class UserNotificationControllerTest extends TestCase
+{
+    use RefreshDatabase;
+
+    public function test_user_can_mark_all_own_notifications_as_read(): void
+    {
+        $user = User::factory()->create();
+        $otherUser = User::factory()->create();
+
+        $unreadNotifications = collect([
+            $this->createNotification($user->id),
+            $this->createNotification($user->id),
+        ]);
+
+        $alreadyReadAt = now()->subDay();
+        $alreadyReadNotification = $this->createNotification($user->id, $alreadyReadAt);
+        $otherUserNotification = $this->createNotification($otherUser->id);
+
+        $response = $this->actingAs($user)->postJson(route('notifications.read-all'));
+
+        $response
+            ->assertOk()
+            ->assertJson([
+                'ok' => true,
+                'updated' => 2,
+                'unread' => 0,
+            ]);
+
+        foreach ($unreadNotifications as $notification) {
+            $this->assertNotNull($notification->fresh()->read_at);
+        }
+
+        $this->assertSame(
+            $alreadyReadAt->format('Y-m-d H:i:s'),
+            $alreadyReadNotification->fresh()->read_at?->format('Y-m-d H:i:s')
+        );
+        $this->assertNull($otherUserNotification->fresh()->read_at);
+    }
+
+    private function createNotification(int $userId, $readAt = null): UserNotification
+    {
+        return UserNotification::query()->create([
+            'user_id' => $userId,
+            'type' => UserNotification::TYPE_PLATFORM,
+            'event' => UserNotification::EVENT_CREATED,
+            'title' => 'Тестовое уведомление',
+            'message' => 'Тестовое сообщение',
+            'message_html' => '<p>Тестовое сообщение</p>',
+            'read_at' => $readAt,
+        ]);
+    }
+}

+ 22 - 0
tests/Unit/Models/SparePartTest.php

@@ -25,11 +25,33 @@ class SparePartTest extends TestCase
 
         $sparePart->refresh();
 
+        $this->assertSame(15050, $sparePart->getRawOriginal('purchase_price'));
+        $this->assertSame(20000, $sparePart->getRawOriginal('customer_price'));
+        $this->assertSame(18025, $sparePart->getRawOriginal('expertise_price'));
         $this->assertEquals(150.50, $sparePart->purchase_price);
         $this->assertEquals(200.00, $sparePart->customer_price);
         $this->assertEquals(180.25, $sparePart->expertise_price);
     }
 
+    public function test_zero_prices_are_stored_and_displayed_as_zero(): void
+    {
+        $sparePart = SparePart::factory()->create([
+            'purchase_price' => 0,
+            'customer_price' => 0,
+            'expertise_price' => 0,
+        ]);
+
+        $sparePart->refresh();
+
+        $this->assertSame(0, $sparePart->getRawOriginal('purchase_price'));
+        $this->assertSame(0, $sparePart->getRawOriginal('customer_price'));
+        $this->assertSame(0, $sparePart->getRawOriginal('expertise_price'));
+        $this->assertEquals(0.0, $sparePart->purchase_price);
+        $this->assertEquals(0.0, $sparePart->customer_price);
+        $this->assertEquals(0.0, $sparePart->expertise_price);
+        $this->assertSame('0.00₽', $sparePart->purchase_price_txt);
+    }
+
     public function test_price_txt_accessors_format_prices(): void
     {
         $sparePart = SparePart::factory()->create([

+ 98 - 0
tests/Unit/Services/Import/ImportSparePartsServiceTest.php

@@ -0,0 +1,98 @@
+<?php
+
+namespace Tests\Unit\Services\Import;
+
+use App\Models\PricingCode;
+use App\Models\SparePart;
+use App\Services\Import\ImportSparePartsService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use PhpOffice\PhpSpreadsheet\Spreadsheet;
+use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
+use Tests\TestCase;
+
+class ImportSparePartsServiceTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected $seed = true;
+
+    private string $tempFilePath;
+
+    protected function tearDown(): void
+    {
+        if (isset($this->tempFilePath) && file_exists($this->tempFilePath)) {
+            unlink($this->tempFilePath);
+        }
+
+        parent::tearDown();
+    }
+
+    public function test_import_stores_prices_in_kopecks_from_rubles(): void
+    {
+        $this->createTestFile([
+            ['SP-IMPORT-001', 'Для теста', 'Заметка', 1500, 1999.99, 0, 'ТСН-1', 'PC-001', 3],
+        ]);
+
+        $service = new ImportSparePartsService($this->tempFilePath, 1);
+        $result = $service->handle();
+
+        $this->assertTrue($result['success']);
+
+        $sparePart = SparePart::where('article', 'SP-IMPORT-001')->firstOrFail();
+
+        $this->assertSame(150000, $sparePart->getRawOriginal('purchase_price'));
+        $this->assertSame(199999, $sparePart->getRawOriginal('customer_price'));
+        $this->assertSame(0, $sparePart->getRawOriginal('expertise_price'));
+        $this->assertEquals(1500.0, $sparePart->purchase_price);
+        $this->assertEquals(1999.99, $sparePart->customer_price);
+        $this->assertEquals(0.0, $sparePart->expertise_price);
+        $this->assertDatabaseHas('pricing_codes', [
+            'type' => PricingCode::TYPE_PRICING_CODE,
+            'code' => 'PC-001',
+        ]);
+    }
+
+    private function createTestFile(array $rows): void
+    {
+        $spreadsheet = new Spreadsheet();
+        $sheet = $spreadsheet->getActiveSheet();
+
+        $sheet->setCellValue('A1', 'ID');
+        $sheet->setCellValue('B1', 'Артикул');
+        $sheet->setCellValue('C1', 'Где используется');
+        $sheet->setCellValue('D1', 'Кол-во без док');
+        $sheet->setCellValue('E1', 'Кол-во с док');
+        $sheet->setCellValue('F1', 'Кол-во общее');
+        $sheet->setCellValue('G1', 'Примечание');
+        $sheet->setCellValue('H1', 'Цена закупки');
+        $sheet->setCellValue('I1', 'Цена для заказчика');
+        $sheet->setCellValue('J1', 'Цена экспертизы');
+        $sheet->setCellValue('K1', '№ по ТСН');
+        $sheet->setCellValue('L1', 'Шифры расценки');
+        $sheet->setCellValue('M1', 'Мин. остаток');
+
+        $rowNum = 2;
+        foreach ($rows as $row) {
+            $sheet->setCellValue('B' . $rowNum, $row[0] ?? '');
+            $sheet->setCellValue('C' . $rowNum, $row[1] ?? '');
+            $sheet->setCellValue('G' . $rowNum, $row[2] ?? '');
+            $sheet->setCellValue('H' . $rowNum, $row[3] ?? '');
+            $sheet->setCellValue('I' . $rowNum, $row[4] ?? '');
+            $sheet->setCellValue('J' . $rowNum, $row[5] ?? '');
+            $sheet->setCellValue('K' . $rowNum, $row[6] ?? '');
+            $sheet->setCellValue('L' . $rowNum, $row[7] ?? '');
+            $sheet->setCellValue('M' . $rowNum, $row[8] ?? 0);
+            $rowNum++;
+        }
+
+        $pricingCodesSheet = $spreadsheet->createSheet(1);
+        $pricingCodesSheet->setCellValue('A1', 'ID');
+        $pricingCodesSheet->setCellValue('B1', 'Тип');
+        $pricingCodesSheet->setCellValue('C1', 'Код');
+        $pricingCodesSheet->setCellValue('D1', 'Расшифровка');
+
+        $this->tempFilePath = sys_get_temp_dir() . '/test_spare_parts_' . uniqid() . '.xlsx';
+        $writer = new Xlsx($spreadsheet);
+        $writer->save($this->tempFilePath);
+    }
+}