Alexander Musikhin hace 1 mes
padre
commit
d798e1fc12

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

@@ -0,0 +1,225 @@
+<?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,
+            ]);
+
+            $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;
+    }
+}

+ 22 - 1
app/Http/Controllers/OrderController.php

@@ -261,7 +261,14 @@ 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.files',
+            ])
+            ->find($order);
 
         if ($request->boolean('sync_year') && $this->data['order']) {
             $previousYear = year();
@@ -287,6 +294,20 @@ class OrderController extends Controller
             '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->pluck('name', 'id');
+        $this->data['chatResponsibleUserIds'] = $responsibleUserIds;
         return view('orders.show', $this->data);
     }
 

+ 19 - 1
app/Http/Controllers/ReclamationController.php

@@ -143,7 +143,25 @@ 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.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->pluck('name', 'id');
+        $this->data['chatResponsibleUserIds'] = $responsibleUserIds;
         $this->data['previous_url'] = $this->resolvePreviousUrl(
             $request,
             'previous_url_reclamations',

+ 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;
     }
 

+ 59 - 0
app/Models/ChatMessage.php

@@ -0,0 +1,59 @@
+<?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 files(): BelongsToMany
+    {
+        return $this->belongsToMany(File::class, 'chat_message_file')->withTimestamps();
+    }
+}

+ 5 - 0
app/Models/Order.php

@@ -173,6 +173,11 @@ 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(

+ 5 - 0
app/Models/Reclamation.php

@@ -156,6 +156,11 @@ 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 - 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' => [],
         ];
     }
 

+ 188 - 0
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] ?? '-');
@@ -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');
+        });
+    }
+};

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

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

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

@@ -0,0 +1,522 @@
+@php
+    /** @var \Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Collection $messages */
+    $messages = $messages ?? collect();
+    $users = $users ?? collect();
+    $responsibleUserIds = array_map('intval', $responsibleUserIds ?? []);
+    $contextKey = $contextKey ?? 'chat';
+    $title = $title ?? 'Чат';
+    $submitLabel = $submitLabel ?? 'Отправить';
+    $canSendNotifications = hasRole('admin,manager');
+    $notificationValue = old('notification_type', \App\Models\ChatMessage::NOTIFICATION_NONE);
+    $selectedTargetUserIds = collect(old('target_user_ids', []))
+        ->map(static fn ($id) => (int) $id)
+        ->filter()
+        ->unique()
+        ->values()
+        ->all();
+@endphp
+
+@once
+    <style>
+        .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;
+        }
+    </style>
+@endonce
+
+<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($message->notification_type === \App\Models\ChatMessage::NOTIFICATION_RESPONSIBLES)
+                                    <span class="badge text-bg-light border">Уведомления: админы/менеджер/бригадир</span>
+                                @elseif($message->notification_type === \App\Models\ChatMessage::NOTIFICATION_ALL)
+                                    <span class="badge text-bg-light border">Уведомления: выбранные получатели</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
+
+        <div class="row g-2 align-items-start">
+            <div class="col-12 col-lg-5">
+                <label class="form-label" for="chat-message-{{ $contextKey }}">Сообщение</label>
+                <textarea
+                    class="form-control"
+                    id="chat-message-{{ $contextKey }}"
+                    name="message"
+                    rows="3"
+                    placeholder="Введите сообщение"
+                >{{ old('message') }}</textarea>
+            </div>
+
+            <div class="col-12 col-md-4 col-lg-3">
+                <label class="form-label" for="chat-notification-{{ $contextKey }}">Уведомление</label>
+                <select
+                    class="form-select chat-notification-type"
+                    id="chat-notification-{{ $contextKey }}"
+                    name="notification_type"
+                    data-chat-context-key="{{ $contextKey }}"
+                    @disabled(!$canSendNotifications)
+                >
+                    <option value="{{ \App\Models\ChatMessage::NOTIFICATION_NONE }}" @selected($notificationValue === \App\Models\ChatMessage::NOTIFICATION_NONE)>Нет</option>
+                    @if($canSendNotifications)
+                        <option value="{{ \App\Models\ChatMessage::NOTIFICATION_RESPONSIBLES }}" @selected($notificationValue === \App\Models\ChatMessage::NOTIFICATION_RESPONSIBLES)>Админы, менеджер, бригадир</option>
+                        <option value="{{ \App\Models\ChatMessage::NOTIFICATION_ALL }}" @selected($notificationValue === \App\Models\ChatMessage::NOTIFICATION_ALL)>Все</option>
+                    @endif
+                </select>
+                @if(!$canSendNotifications)
+                    <input type="hidden" name="notification_type" value="{{ \App\Models\ChatMessage::NOTIFICATION_NONE }}">
+                @endif
+            </div>
+
+            <div class="col-12 col-md-4 col-lg-2">
+                <label class="form-label" for="chat-attachments-{{ $contextKey }}">Файлы</label>
+                <input class="form-control" id="chat-attachments-{{ $contextKey }}" type="file" name="attachments[]" multiple>
+            </div>
+
+            <div class="col-12">
+                <div class="row g-2 align-items-center {{ $canSendNotifications && $notificationValue !== \App\Models\ChatMessage::NOTIFICATION_NONE ? '' : 'd-none' }}"
+                     data-chat-recipient-picker-wrap>
+                    <div class="col-12 col-md-auto">
+                        <button
+                            type="button"
+                            class="btn btn-outline-secondary btn-sm"
+                            data-chat-open-recipient-modal
+                            data-bs-toggle="modal"
+                            data-bs-target="#chatRecipientsModal-{{ $contextKey }}"
+                        >
+                            Выбрать получателей
+                        </button>
+                    </div>
+                    <div class="col-12 col-md">
+                        <div class="small text-muted chat-recipient-summary" data-chat-recipient-summary>
+                            Получатели не выбраны
+                        </div>
+                    </div>
+                </div>
+                <div data-chat-hidden-targets>
+                    @foreach($selectedTargetUserIds as $selectedTargetUserId)
+                        <input type="hidden" name="target_user_ids[]" value="{{ $selectedTargetUserId }}">
+                    @endforeach
+                </div>
+            </div>
+
+            <div class="col-12 text-end">
+                <button class="btn btn-primary btn-sm" type="submit">{{ $submitLabel }}</button>
+            </div>
+        </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 gap-2 mb-2 flex-wrap">
+                            <div class="small text-muted" data-chat-recipient-hint></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($users as $userId => $userName)
+                                <label class="form-check mb-2 chat-recipient-item"
+                                       data-chat-recipient-item
+                                       data-user-id="{{ $userId }}"
+                                       data-user-name="{{ $userName }}"
+                                       data-chat-responsible="{{ in_array((int) $userId, $responsibleUserIds, true) ? '1' : '0' }}">
+                                    <input
+                                        class="form-check-input"
+                                        type="checkbox"
+                                        value="{{ $userId }}"
+                                        data-chat-recipient-checkbox
+                                        @checked(in_array((int) $userId, $selectedTargetUserIds, true))
+                                    >
+                                    <span class="form-check-label">{{ $userName }}</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 notificationType = block.querySelector('.chat-notification-type');
+                const recipientPickerWrap = block.querySelector('[data-chat-recipient-picker-wrap]');
+                const hiddenTargets = block.querySelector('[data-chat-hidden-targets]');
+                const summary = block.querySelector('[data-chat-recipient-summary]');
+                const modal = block.querySelector('.modal');
+
+                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 || !notificationType) {
+                        return;
+                    }
+
+                    if (notificationType.value === 'none') {
+                        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 applyRecipientFilter = (preserveSelection = true) => {
+                    if (!notificationType || !modal) {
+                        return;
+                    }
+
+                    const isResponsibles = notificationType.value === 'responsibles';
+                    const isAll = notificationType.value === 'all';
+                    const recipientItems = Array.from(block.querySelectorAll('[data-chat-recipient-item]'));
+                    const selectedIds = new Set(getSelectedIds());
+                    const visibleIds = [];
+
+                    recipientItems.forEach((item) => {
+                        const isResponsible = item.dataset.chatResponsible === '1';
+                        const visible = isAll || (isResponsibles && isResponsible);
+                        const checkbox = item.querySelector('[data-chat-recipient-checkbox]');
+
+                        item.hidden = !visible;
+                        checkbox.disabled = !visible;
+
+                        if (visible) {
+                            visibleIds.push(Number(item.dataset.userId));
+                        } else {
+                            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 && visibleIds.length) {
+                            recipientItems.forEach((item) => {
+                                const checkbox = item.querySelector('[data-chat-recipient-checkbox]');
+                                if (!checkbox.disabled) {
+                                    checkbox.checked = true;
+                                }
+                            });
+                        }
+                    }
+
+                    const hint = block.querySelector('[data-chat-recipient-hint]');
+                    if (hint) {
+                        hint.textContent = isResponsibles
+                            ? 'Доступны только админы, менеджер и бригадир текущей сущности.'
+                            : 'Доступны все пользователи.';
+                    }
+                };
+
+                const commitRecipientSelection = () => {
+                    if (!notificationType || notificationType.value === 'none') {
+                        setSelectedIds([]);
+                        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);
+                    syncRecipientSummary();
+                };
+
+                if (messages) {
+                    requestAnimationFrame(() => {
+                        scrollToBottom(true);
+                        syncScrollButton();
+                    });
+                    setTimeout(() => {
+                        scrollToBottom(true);
+                        syncScrollButton();
+                    }, 150);
+                    messages.addEventListener('scroll', syncScrollButton);
+                }
+
+                if (scrollButton) {
+                    scrollButton.addEventListener('click', () => scrollToBottom(true));
+                }
+
+                if (notificationType) {
+                    notificationType.addEventListener('change', (event) => {
+                        const enabled = event.target.value !== 'none';
+
+                        if (recipientPickerWrap) {
+                            recipientPickerWrap.classList.toggle('d-none', !enabled);
+                        }
+
+                        if (!enabled) {
+                            setSelectedIds([]);
+                            syncRecipientSummary();
+                            return;
+                        }
+
+                        applyRecipientFilter(false);
+                        commitRecipientSelection();
+
+                        if (modal) {
+                            bootstrap.Modal.getOrCreateInstance(modal).show();
+                        }
+                    });
+                }
+
+                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 (notificationType && notificationType.value !== 'none') {
+                        commitRecipientSelection();
+                    }
+                });
+
+                form?.addEventListener('submit', () => {
+                    if (notificationType && notificationType.value !== 'none') {
+                        commitRecipientSelection();
+                    }
+                });
+
+                applyRecipientFilter(true);
+                if (notificationType && notificationType.value !== 'none') {
+                    commitRecipientSelection();
+                } else {
+                    syncRecipientSummary();
+                }
+            }
+
+            document.querySelectorAll('[data-chat-block]').forEach(initChatBlock);
+        </script>
+    @endpush
+@endonce

+ 15 - 0
resources/views/reclamations/edit.blade.php

@@ -602,6 +602,16 @@
                         @endforeach
                     </div>
                 </div>
+
+                @include('partials.chat', [
+                    'title' => 'Чат рекламации',
+                    'messages' => $reclamation->chatMessages,
+                    'users' => $chatUsers,
+                    'responsibleUserIds' => $chatResponsibleUserIds,
+                    'action' => route('reclamations.chat-messages.store', $reclamation),
+                    'contextKey' => 'reclamation-' . $reclamation->id,
+                    'submitLabel' => 'Отправить в чат',
+                ])
             </div>
         </div>
     </div>
@@ -764,6 +774,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));

+ 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>

+ 3 - 0
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;
@@ -213,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');
 
@@ -273,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');