| | | 1 | | @model OzonOrdersWeb.Areas.Studio2.ViewModels.OzonChatViewModels.OzonChatIndexViewModel |
| | | 2 | | @{ |
| | 0 | 3 | | ViewData["Title"] = "Чаты Ozon"; |
| | 0 | 4 | | Layout = "~/Views/Shared/_Layout.cshtml"; |
| | | 5 | | } |
| | | 6 | | |
| | | 7 | | <div class="container-fluid"> |
| | | 8 | | |
| | | 9 | | |
| | | 10 | | <div class="col-12"> |
| | | 11 | | <!-- Вкладки --> |
| | | 12 | | <ul class="nav nav-tabs mb-3" id="chatTabs" role="tablist"> |
| | | 13 | | <li class="nav-item" role="presentation"> |
| | | 14 | | <button class="nav-link active" id="chats-tab" data-bs-toggle="tab" data-bs-target="#chats-content" |
| | | 15 | | type="button" role="tab" aria-controls="chats-content" aria-selected="true"> |
| | | 16 | | <i class="fas fa-comments me-1"></i>Чаты |
| | | 17 | | </button> |
| | | 18 | | </li> |
| | | 19 | | <li class="nav-item" role="presentation"> |
| | | 20 | | <button class="nav-link" id="response-stats-tab" data-bs-toggle="tab" |
| | | 21 | | data-bs-target="#response-stats-content" |
| | | 22 | | type="button" role="tab" aria-controls="response-stats-content" aria-selected="false"> |
| | | 23 | | <i class="fas fa-chart-bar me-1"></i>Статистика ответов |
| | | 24 | | </button> |
| | | 25 | | </li> |
| | | 26 | | </ul> |
| | | 27 | | |
| | | 28 | | <div class="tab-content"> |
| | | 29 | | <!-- Вкладка Чаты --> |
| | | 30 | | |
| | | 31 | | |
| | | 32 | | <div class="tab-pane fade show active" id="chats-content" role="tabpanel" aria-labelledby="chats-tab"> |
| | | 33 | | <div class="row g-3"> |
| | | 34 | | |
| | | 35 | | <!-- Заголовок + выбор клиента --> |
| | | 36 | | <div class="col-12 col-lg-3"> |
| | | 37 | | <div class="card shadow-sm border-0"> |
| | | 38 | | |
| | | 39 | | <!-- Заголовок --> |
| | | 40 | | <div class="card-header card-header-filter border-bottom py-2"> |
| | | 41 | | <h4 class="mb-0"> |
| | | 42 | | <i class="fas fa-filter opacity-85 text-primary me-3"></i>Фильтры |
| | | 43 | | </h4> |
| | | 44 | | </div> |
| | | 45 | | |
| | | 46 | | <div class="card-body bg-filter-card"> |
| | 0 | 47 | | @if (TempData["ErrorMessage"] != null) |
| | | 48 | | { |
| | | 49 | | <div class="alert alert-danger alert-dismissible fade show" role="alert"> |
| | 0 | 50 | | <i class="fas fa-exclamation-triangle me-2"></i>@TempData["ErrorMessage"] |
| | | 51 | | <button type="button" class="btn-close" data-bs-dismiss="alert"></button> |
| | | 52 | | </div> |
| | | 53 | | } |
| | | 54 | | |
| | 0 | 55 | | @if (!Model.OzonClients.Any()) |
| | | 56 | | { |
| | | 57 | | <div class="text-center py-5"> |
| | | 58 | | <i class="fas fa-users fa-4x text-muted mb-3"></i> |
| | | 59 | | <h5 class="text-muted">Нет настроенных клиентов Ozon</h5> |
| | | 60 | | <p class="text-muted">Для работы с чатами необходимо настроить клиентов Ozon с |
| | | 61 | | API-ключами.</p> |
| | | 62 | | </div> |
| | | 63 | | } |
| | | 64 | | else |
| | | 65 | | { |
| | | 66 | | <!-- Клиент --> |
| | | 67 | | <div class="mb-3"> |
| | | 68 | | <label class="form-label small text-muted">Клиент</label> |
| | | 69 | | <select id="sel-client" class="form-select form-select-sm custom-select-init"> |
| | | 70 | | <option value="0" selected>Все клиенты</option> |
| | 0 | 71 | | @foreach (var c in Model.OzonClients) |
| | | 72 | | { |
| | 0 | 73 | | <option value="@c.Id">@c.Name</option> |
| | | 74 | | } |
| | | 75 | | </select> |
| | | 76 | | </div> |
| | | 77 | | <!-- Статус --> |
| | | 78 | | <div class="mb-3"> |
| | | 79 | | <label class="form-label small text-muted">Статус</label> |
| | | 80 | | <select id="sel-status" class="form-select form-select-sm custom-select-init"> |
| | | 81 | | <option value="All">Все чаты</option> |
| | | 82 | | <option value="Opened" selected>Открытые</option> |
| | | 83 | | <option value="Closed">Закрытые</option> |
| | | 84 | | </select> |
| | | 85 | | </div> |
| | | 86 | | <!-- Тип --> |
| | | 87 | | <div class="mb-3"> |
| | | 88 | | <label class="form-label small text-muted">Тип чата</label> |
| | | 89 | | <select id="sel-type" class="form-select form-select-sm custom-select-init"> |
| | | 90 | | <option value="">Все типы</option> |
| | | 91 | | <option value="Seller_Support">Чат с поддержкой</option> |
| | | 92 | | <option value="Buyer_Seller" selected>Чат с покупателем</option> |
| | | 93 | | </select> |
| | | 94 | | </div> |
| | | 95 | | |
| | | 96 | | <!-- Сортировка --> |
| | | 97 | | <div class="mb-3"> |
| | | 98 | | <label class="form-label small text-muted">Сортировка</label> |
| | | 99 | | |
| | | 100 | | <div class="row g-2"> |
| | | 101 | | <div class="col-7"> |
| | | 102 | | <select id="sel-sort-field" class="form-select form-select-sm custom-sel |
| | | 103 | | <option value="createdAt">Дата создания</option> |
| | | 104 | | <option value="lastMessage">Посл. сообщение</option> |
| | | 105 | | </select> |
| | | 106 | | </div> |
| | | 107 | | |
| | | 108 | | <div class="col-5"> |
| | | 109 | | <select id="sel-sort-order" class="form-select form-select-sm custom-sel |
| | | 110 | | <option value="desc">Сначала новые</option> |
| | | 111 | | <option value="asc">Сначала старые</option> |
| | | 112 | | </select> |
| | | 113 | | </div> |
| | | 114 | | </div> |
| | | 115 | | </div> |
| | | 116 | | |
| | | 117 | | <!-- На странице --> |
| | | 118 | | <div class="mb-4"> |
| | | 119 | | <label class="form-label small text-muted">Кол-во на странице</label> |
| | | 120 | | <select id="sel-pagesize" class="form-select form-select-sm custom-select-init"> |
| | | 121 | | <option value="10">10</option> |
| | | 122 | | <option value="30" selected>30</option> |
| | | 123 | | <option value="50">50</option> |
| | | 124 | | <option value="100">100</option> |
| | | 125 | | </select> |
| | | 126 | | </div> |
| | | 127 | | |
| | | 128 | | <!-- Непрочитанные --> |
| | | 129 | | <div class="form-check mb-3"> |
| | | 130 | | <input class="form-check-input" type="checkbox" id="chk-unread"> |
| | | 131 | | <label class="form-check-label" for="chk-unread"> |
| | | 132 | | Только непрочитанные |
| | | 133 | | </label> |
| | | 134 | | </div> |
| | | 135 | | |
| | | 136 | | <hr class="border-top border-secondary opacity-25 my-3"> |
| | | 137 | | |
| | | 138 | | <!-- Кнопки --> |
| | | 139 | | <div class="d-flex gap-2"> |
| | | 140 | | <button id="btn-load" class="btn btn-primary py-2 rounded-3"> |
| | | 141 | | <i class="fas fa-search me-2"></i>Загрузить |
| | | 142 | | </button> |
| | | 143 | | <button id="btn-export" class="btn btn-outline-secondary py-1 d-none"> |
| | | 144 | | <i class="fas fa-file-excel me-2"></i>CSV |
| | | 145 | | </button> |
| | | 146 | | </div> |
| | | 147 | | } |
| | | 148 | | |
| | | 149 | | <hr class="border-top border-secondary opacity-25 my-3"> |
| | | 150 | | |
| | | 151 | | <div class="d-flex row align-items-center gap-2"> |
| | | 152 | | @* <span id="badge-unread" class="d-flex align-items-center gap-1 d-none"> *@ |
| | | 153 | | @* <i class="fas fa-envelope fa-fw me-2 text-secondary"></i> *@ |
| | | 154 | | @* <div> *@ |
| | | 155 | | @* <span>Без ответа:</span> *@ |
| | | 156 | | @* <span id="unread-text" class="badge badge-danger-soft"></span> *@ |
| | | 157 | | @* </div> *@ |
| | | 158 | | @* </span> *@ |
| | | 159 | | <span id="badge-total" class="d-none d-flex align-items-center gap-1"> |
| | | 160 | | <i class="fas fa-comments fa-fw me-2 text-secondary"></i> |
| | | 161 | | <div> |
| | | 162 | | <span>Всего чатов:</span> |
| | | 163 | | <span id="total-text" class="badge badge-buyer"></span> |
| | | 164 | | </div> |
| | | 165 | | </span> |
| | | 166 | | </div> |
| | | 167 | | </div> |
| | | 168 | | </div> |
| | | 169 | | </div> |
| | | 170 | | |
| | | 171 | | <div class="col-12 col-lg-9"> |
| | | 172 | | |
| | | 173 | | <!-- Статистика --> |
| | | 174 | | <div id="stats-row" |
| | | 175 | | class="d-flex card-header-filter align-items-center justify-content-between mb-3 px-3 py-2 |
| | | 176 | | <h4 class="mb-0"> |
| | | 177 | | <i class="fas fa-comments fa-fw text-primary me-3"></i> |
| | | 178 | | <span class="text-dark">Чаты Ozon</span> |
| | | 179 | | </h4> |
| | | 180 | | |
| | | 181 | | <div class="d-flex gap-4 small text-dark"> |
| | | 182 | | <div class="d-flex align-items-center gap-2"> |
| | | 183 | | <i class="fas fa-comments text-secondary"></i> |
| | | 184 | | <div> |
| | | 185 | | <span>Всего чатов:</span> |
| | | 186 | | <span id="stat-total" class="badge badge-closed text-dark">0</span> |
| | | 187 | | </div> |
| | | 188 | | </div> |
| | | 189 | | |
| | | 190 | | @* <div class="d-flex align-items-center gap-2"> *@ |
| | | 191 | | @* <i class="fas fa-envelope text-secondary"></i> *@ |
| | | 192 | | @* <div> *@ |
| | | 193 | | @* <span>Без ответа:</span> *@ |
| | | 194 | | @* <span id="stat-unread" class="badge badge-closed text-dark">0</span> *@ |
| | | 195 | | @* </div> *@ |
| | | 196 | | @* </div> *@ |
| | | 197 | | |
| | | 198 | | <div class="d-flex align-items-center gap-2 text-dark"> |
| | | 199 | | <i class="fas fa-list text-secondary"></i> |
| | | 200 | | <div> |
| | | 201 | | <span>На странице:</span> |
| | | 202 | | <span id="stat-page" class="badge badge-closed text-dark">0</span> |
| | | 203 | | </div> |
| | | 204 | | </div> |
| | | 205 | | </div> |
| | | 206 | | </div> |
| | | 207 | | |
| | | 208 | | <!-- Поиск по артикулу / товару / номеру заказа --> |
| | | 209 | | <div class="mb-3 position-relative"> |
| | | 210 | | <div class="input-group shadow-sm"> |
| | | 211 | | <span class="input-group-text bg-white border-end-0"> |
| | | 212 | | <i class="fas fa-search text-muted"></i> |
| | | 213 | | </span> |
| | | 214 | | <input type="text" id="chat-search-input" class="form-control border-start-0 ps-0" |
| | | 215 | | placeholder="Поиск по артикулу (3000990266=SHS), товару или номеру заказа (351817 |
| | | 216 | | autocomplete="off"> |
| | | 217 | | <button id="btn-clear-search" class="btn btn-outline-secondary d-none" type="button" |
| | | 218 | | title="Очистить поиск"> |
| | | 219 | | <i class="fas fa-times"></i> |
| | | 220 | | </button> |
| | | 221 | | </div> |
| | | 222 | | <!-- Выпадающий список подсказок --> |
| | | 223 | | <div id="search-suggestions-box" class="d-none border rounded shadow-sm bg-white" |
| | | 224 | | style="position:absolute;top:100%;left:0;right:0;z-index:1050;max-height:320px;overflow |
| | | 225 | | </div> |
| | | 226 | | <div id="search-mode-badge" class="d-none mt-1"> |
| | | 227 | | <span class="badge badge-buyer me-1"><i |
| | | 228 | | class="fas fa-search me-1"></i>Режим поиска</span> |
| | | 229 | | <span id="search-mode-query" class="text-muted small"></span> |
| | | 230 | | <span id="search-result-count" class="badge badge-closed ms-1"></span> |
| | | 231 | | </div> |
| | | 232 | | </div> |
| | | 233 | | |
| | | 234 | | <!-- Таблица чатов --> |
| | | 235 | | <div id="chats-section" class="card d-none shadow-sm border-0"> |
| | | 236 | | <div class="card-body p-0"> |
| | | 237 | | <!-- Спиннер --> |
| | | 238 | | <div id="loading-spinner" class="text-center py-5 d-none"> |
| | | 239 | | <div class="spinner-border text-primary" role="status"> |
| | | 240 | | <span class="visually-hidden">Загрузка...</span> |
| | | 241 | | </div> |
| | | 242 | | <div class="text-muted mt-2">Загрузка чатов...</div> |
| | | 243 | | </div> |
| | | 244 | | |
| | | 245 | | <!-- Ошибка --> |
| | | 246 | | <div id="error-box" class="alert alert-danger m-3 d-none"></div> |
| | | 247 | | |
| | | 248 | | <!-- Пустой список --> |
| | | 249 | | <div id="empty-box" class="text-center py-5 d-none"> |
| | | 250 | | <i class="fas fa-comments fa-4x text-muted mb-3"></i> |
| | | 251 | | <h5 class="text-muted">Нет чатов для выбранных фильтров</h5> |
| | | 252 | | </div> |
| | | 253 | | |
| | | 254 | | <!-- Таблица --> |
| | | 255 | | <div id="table-wrap" class="table-responsive d-none"> |
| | | 256 | | <table class="table table-hover align-middle mb-0"> |
| | | 257 | | <thead class="table-light"> |
| | | 258 | | <tr> |
| | | 259 | | <th>Клиент</th> |
| | | 260 | | <th style="min-width: 300px;">Последнее сообщение</th> |
| | | 261 | | <th>Статус</th> |
| | | 262 | | <th>Тип</th> |
| | | 263 | | <th>Дата создания</th> |
| | | 264 | | <th>Посл. сообщение</th> |
| | | 265 | | <th class="text-center">Без ответа</th> |
| | | 266 | | <th style="width:200px">Действия</th> |
| | | 267 | | </tr> |
| | | 268 | | </thead> |
| | | 269 | | <tbody id="chats-tbody"></tbody> |
| | | 270 | | </table> |
| | | 271 | | </div> |
| | | 272 | | |
| | | 273 | | <!-- Пагинация --> |
| | | 274 | | <div id="pagination-wrap" class="d-none border-top p-3"> |
| | | 275 | | <div class="d-flex justify-content-between align-items-center"> |
| | | 276 | | <div class="text-muted small" id="page-info"></div> |
| | | 277 | | <ul class="pagination mb-0" id="pagination-list"></ul> |
| | | 278 | | </div> |
| | | 279 | | </div> |
| | | 280 | | </div> |
| | | 281 | | </div> |
| | | 282 | | </div> |
| | | 283 | | </div> |
| | | 284 | | </div> |
| | | 285 | | <!-- Конец вкладки Чаты --> |
| | | 286 | | |
| | | 287 | | <!-- Вкладка Статистика ответов --> |
| | | 288 | | <div class="tab-pane fade" id="response-stats-content" role="tabpanel" |
| | | 289 | | aria-labelledby="response-stats-tab"> |
| | | 290 | | <div class="row g-3"> |
| | | 291 | | |
| | | 292 | | <!-- Фильтры статистики --> |
| | | 293 | | |
| | | 294 | | <div class="col-12 col-lg-3"> |
| | | 295 | | <div class="card shadow-sm border-0"> |
| | | 296 | | |
| | | 297 | | <!-- Заголовок --> |
| | | 298 | | <div class="card-header card-header-filter border-bottom py-2"> |
| | | 299 | | <h4 class="mb-0"> |
| | | 300 | | <i class="fas fa-chart-bar opacity-85 text-primary me-3"></i>Фильтры |
| | | 301 | | </h4> |
| | | 302 | | </div> |
| | | 303 | | |
| | | 304 | | <div class="card-body bg-filter-card"> |
| | | 305 | | |
| | | 306 | | <!-- Клиент --> |
| | | 307 | | <div class="mb-3"> |
| | | 308 | | <label class="form-label small text-muted">Клиент</label> |
| | | 309 | | <select id="stat-sel-client" class="form-select form-select-sm custom-select-init"> |
| | | 310 | | <option value="0" selected>Все клиенты</option> |
| | 0 | 311 | | @foreach (var c in Model.OzonClients) |
| | | 312 | | { |
| | 0 | 313 | | <option value="@c.Id">@c.Name</option> |
| | | 314 | | } |
| | | 315 | | </select> |
| | | 316 | | </div> |
| | | 317 | | |
| | | 318 | | <!-- Дата с --> |
| | | 319 | | <div class="mb-3"> |
| | | 320 | | <label class="form-label small text-muted">Дата с</label> |
| | | 321 | | <input type="date" |
| | | 322 | | id="stat-date-from" |
| | | 323 | | class="form-control form-control-sm" |
| | | 324 | | value="@DateTime.Now.AddDays(-30).ToString("yyyy-MM-dd")"> |
| | | 325 | | </div> |
| | | 326 | | |
| | | 327 | | <!-- Дата по --> |
| | | 328 | | <div class="mb-3"> |
| | | 329 | | <label class="form-label small text-muted">Дата по</label> |
| | | 330 | | <input type="date" |
| | | 331 | | id="stat-date-to" |
| | | 332 | | class="form-control form-control-sm" |
| | | 333 | | value="@DateTime.Now.ToString("yyyy-MM-dd")"> |
| | | 334 | | </div> |
| | | 335 | | |
| | | 336 | | <!-- Менеджер --> |
| | | 337 | | <div class="mb-4"> |
| | | 338 | | <label class="form-label small text-muted">Менеджер</label> |
| | | 339 | | <select id="stat-sel-manager" class="form-select form-select-sm custom-select-init"> |
| | | 340 | | <option value="" selected>Все менеджеры</option> |
| | | 341 | | </select> |
| | | 342 | | </div> |
| | | 343 | | |
| | | 344 | | <hr class="border-top border-secondary opacity-25 my-3"> |
| | | 345 | | |
| | | 346 | | <!-- Кнопка --> |
| | | 347 | | <div class="d-grid"> |
| | | 348 | | <button id="btn-load-stats" class="btn btn-primary py-2 rounded-3"> |
| | | 349 | | <i class="fas fa-chart-bar me-2"></i>Загрузить |
| | | 350 | | </button> |
| | | 351 | | </div> |
| | | 352 | | </div> |
| | | 353 | | </div> |
| | | 354 | | </div> |
| | | 355 | | |
| | | 356 | | |
| | | 357 | | <div class="col-12 col-lg-9"> |
| | | 358 | | <!-- Сводка --> |
| | | 359 | | <div id="stats-summary" class="row mb-3 g-3 d-none"> |
| | | 360 | | <div class="col-12 col-sm-6 col-xl-3"> |
| | | 361 | | <div class="card stats-card stats-card-blue"> |
| | | 362 | | <div class="card-body d-flex align-items-center gap-3"> |
| | | 363 | | <div class="icon"> |
| | | 364 | | <i class="fas fa-reply"></i> |
| | | 365 | | </div> |
| | | 366 | | <div> |
| | | 367 | | <div class="text-muted small">Всего ответов</div> |
| | | 368 | | <div class="fw-semibold fs-5" id="summary-total">0</div> |
| | | 369 | | </div> |
| | | 370 | | </div> |
| | | 371 | | </div> |
| | | 372 | | </div> |
| | | 373 | | <div class="col-12 col-sm-6 col-xl-3"> |
| | | 374 | | <div class="card stats-card stats-card-green"> |
| | | 375 | | <div class="card-body d-flex align-items-center gap-3"> |
| | | 376 | | <div class="icon"> |
| | | 377 | | <i class="fas fa-clock"></i> |
| | | 378 | | </div> |
| | | 379 | | <div> |
| | | 380 | | <div class="text-muted small stat-line-height">Среднее время ответа</div> |
| | | 381 | | <div class="fw-semibold fs-5 stat-line-height" id="summary-avg-time">0</div> |
| | | 382 | | </div> |
| | | 383 | | </div> |
| | | 384 | | </div> |
| | | 385 | | </div> |
| | | 386 | | <div class="col-12 col-sm-6 col-xl-3"> |
| | | 387 | | <div class="card stats-card stats-card-slate"> |
| | | 388 | | <div class="card-body d-flex align-items-center gap-3"> |
| | | 389 | | <div class="icon"> |
| | | 390 | | <i class="fas fa-calendar"></i> |
| | | 391 | | </div> |
| | | 392 | | <div> |
| | | 393 | | <div class="text-muted small mb-1">Период</div> |
| | | 394 | | <div class="fw-semibold fs-5 stat-line-height" id="summary-period">0</div> |
| | | 395 | | </div> |
| | | 396 | | </div> |
| | | 397 | | </div> |
| | | 398 | | </div> |
| | | 399 | | <div class="col-12 col-sm-6 col-xl-3"> |
| | | 400 | | <div class="card stats-card stats-card-amber"> |
| | | 401 | | <div class="card-body d-flex align-items-center gap-3"> |
| | | 402 | | <div class="icon"> |
| | | 403 | | <i class="fas fa-users"></i> |
| | | 404 | | </div> |
| | | 405 | | <div> |
| | | 406 | | <div class="text-muted small">Менеджеров</div> |
| | | 407 | | <div class="fw-semibold fs-5" id="summary-managers">0</div> |
| | | 408 | | </div> |
| | | 409 | | </div> |
| | | 410 | | </div> |
| | | 411 | | </div> |
| | | 412 | | </div> |
| | | 413 | | |
| | | 414 | | <!-- Статистика по менеджерам --> |
| | | 415 | | <div id="managers-stats" class="card card-soft mb-3 d-none"> |
| | | 416 | | <div class="card-header card-header-filter text-muted"> |
| | | 417 | | <h6 class="mb-0"><i class="fas fa-user-tie me-2"></i>Статистика по менеджерам</h6> |
| | | 418 | | </div> |
| | | 419 | | <div class="card-body p-0"> |
| | | 420 | | <div class="table-responsive"> |
| | | 421 | | <table class="table table-hover mb-0"> |
| | | 422 | | <thead class="table-light"> |
| | | 423 | | <tr> |
| | | 424 | | <th>Менеджер</th> |
| | | 425 | | <th class="text-center">Ответов</th> |
| | | 426 | | <th class="text-center">Среднее время</th> |
| | | 427 | | <th style="width: 40%">Прогресс</th> |
| | | 428 | | </tr> |
| | | 429 | | </thead> |
| | | 430 | | <tbody id="managers-tbody"></tbody> |
| | | 431 | | </table> |
| | | 432 | | </div> |
| | | 433 | | </div> |
| | | 434 | | </div> |
| | | 435 | | |
| | | 436 | | <!-- Таблица ответов --> |
| | | 437 | | <div id="responses-table-card" class="card card-soft d-none"> |
| | | 438 | | <div class="card-header card-header-filter d-flex justify-content-between align-items-center |
| | | 439 | | "> |
| | | 440 | | <h6 class="mb-0"><i class="fas fa-list me-2"></i>История ответов</h6> |
| | | 441 | | <small class="text-muted" id="responses-count"></small> |
| | | 442 | | </div> |
| | | 443 | | <div class="card-body p-0"> |
| | | 444 | | <!-- Спиннер --> |
| | | 445 | | <div id="stats-loading" class="text-center py-5 d-none"> |
| | | 446 | | <div class="spinner-border text-primary"></div> |
| | | 447 | | <div class="text-muted mt-2">Загрузка статистики...</div> |
| | | 448 | | </div> |
| | | 449 | | <!-- Пустой список --> |
| | | 450 | | <div id="stats-empty" class="d-none"><div class="st2-empty-state"><img class="st2-empty- |
| | | 451 | | <!-- Таблица --> |
| | | 452 | | <div id="responses-table-wrap" class="table-responsive d-none"> |
| | | 453 | | <table class="table table-hover table-sm mb-0"> |
| | | 454 | | <thead class="table-light"> |
| | | 455 | | <tr> |
| | | 456 | | <th>Дата ответа</th> |
| | | 457 | | <th>Менеджер</th> |
| | | 458 | | <th style="min-width: 250px;">Текст ответа</th> |
| | | 459 | | <th>Время ответа</th> |
| | | 460 | | <th>Чат</th> |
| | | 461 | | </tr> |
| | | 462 | | </thead> |
| | | 463 | | <tbody id="responses-tbody"></tbody> |
| | | 464 | | </table> |
| | | 465 | | </div> |
| | | 466 | | <!-- Пагинация --> |
| | | 467 | | <div id="stats-pagination-wrap" class="d-none border-top p-3"> |
| | | 468 | | <div class="d-flex justify-content-between align-items-center"> |
| | | 469 | | <div class="text-muted small" id="stats-page-info"></div> |
| | | 470 | | <ul class="pagination pagination-sm mb-0" id="stats-pagination-list"></ul> |
| | | 471 | | </div> |
| | | 472 | | </div> |
| | | 473 | | |
| | | 474 | | <!-- === Время трекинга === --> |
| | | 475 | | <hr/> |
| | | 476 | | <h5 class="mt-3">Трек времени</h5> |
| | | 477 | | <div id="time-summary" class="row mb-3 d-none"> |
| | | 478 | | <div class="col-md-3"> |
| | | 479 | | <div class="card border-0 bg-primary text-white"> |
| | | 480 | | <div class="card-body py-3 px-4 d-flex align-items-center"> |
| | | 481 | | <i class="fas fa-list fs-2 me-3"></i> |
| | | 482 | | <div> |
| | | 483 | | <div class="small opacity-75">Записей</div> |
| | | 484 | | <div class="fw-bold fs-4" id="time-summary-total">0</div> |
| | | 485 | | </div> |
| | | 486 | | </div> |
| | | 487 | | </div> |
| | | 488 | | </div> |
| | | 489 | | <div class="col-md-3"> |
| | | 490 | | <div class="card border-0 bg-success text-white"> |
| | | 491 | | <div class="card-body py-3 px-4 d-flex align-items-center"> |
| | | 492 | | <i class="fas fa-clock fs-2 me-3"></i> |
| | | 493 | | <div> |
| | | 494 | | <div class="small opacity-75">Общее время</div> |
| | | 495 | | <div class="fw-bold fs-4" id="time-summary-totaltime">0</div> |
| | | 496 | | </div> |
| | | 497 | | </div> |
| | | 498 | | </div> |
| | | 499 | | </div> |
| | | 500 | | </div> |
| | | 501 | | <div id="time-loading" class="text-center py-2 d-none"> |
| | | 502 | | <div class="spinner-border text-primary" role="status"><span |
| | | 503 | | class="visually-hidden">Загрузка...</span> |
| | | 504 | | </div> |
| | | 505 | | </div> |
| | | 506 | | <div id="time-empty" class="d-none"><div class="st2-empty-state"><img class="st2-empty-s |
| | | 507 | | <div id="time-table-wrap" class="table-responsive d-none"> |
| | | 508 | | <table class="table table-hover table-sm mb-0"> |
| | | 509 | | <thead class="table-light"> |
| | | 510 | | <tr> |
| | | 511 | | <th>Дата</th> |
| | | 512 | | <th>Пользователь</th> |
| | | 513 | | <th>Время</th> |
| | | 514 | | <th>Чат</th> |
| | | 515 | | <th>Клиент</th> |
| | | 516 | | </tr> |
| | | 517 | | </thead> |
| | | 518 | | <tbody id="time-tbody"></tbody> |
| | | 519 | | </table> |
| | | 520 | | </div> |
| | | 521 | | <div id="time-pagination-wrap" class="d-none border-top p-3"> |
| | | 522 | | <div class="d-flex justify-content-between align-items-center"> |
| | | 523 | | <div class="text-muted small" id="time-page-info"></div> |
| | | 524 | | <ul class="pagination pagination-sm mb-0" id="time-pagination-list"></ul> |
| | | 525 | | </div> |
| | | 526 | | </div> |
| | | 527 | | </div> |
| | | 528 | | </div> |
| | | 529 | | </div> |
| | | 530 | | </div> |
| | | 531 | | </div> |
| | | 532 | | <!-- Конец вкладки Статистика --> |
| | | 533 | | </div> |
| | | 534 | | <!-- Конец tab-content --> |
| | | 535 | | </div> |
| | | 536 | | |
| | | 537 | | </div> |
| | | 538 | | |
| | | 539 | | <!-- Модальное окно истории чата --> |
| | | 540 | | <div class="modal fade" id="chatHistoryModal" tabindex="-1"> |
| | | 541 | | <div class="modal-dialog modal-xl modal-dialog-scrollable"> |
| | | 542 | | <div class="modal-content" style="height:85vh"> |
| | | 543 | | <div class="modal-header py-2"> |
| | | 544 | | <div> |
| | | 545 | | <h4 class="modal-title mb-2"> |
| | | 546 | | <i class="fas fa-comments text-primary me-3"></i> |
| | | 547 | | Чат <code class="chat-code text-sm" id="hist-chat-id"></code> |
| | | 548 | | </h4> |
| | | 549 | | <span class="text-muted" id="hist-chat-meta"></span> |
| | | 550 | | </div> |
| | | 551 | | <div class="d-flex align-items-center gap-2"> |
| | | 552 | | <button id="btn-mark-read" class="btn btn-sm btn-success d-none" onclick="markAsRead()"> |
| | | 553 | | <i class="fas fa-check-double me-1"></i>Прочитано |
| | | 554 | | </button> |
| | | 555 | | <button type="button" class="btn-close me-2" data-bs-dismiss="modal"></button> |
| | | 556 | | </div> |
| | | 557 | | </div> |
| | | 558 | | <div class="modal-body p-0 d-flex flex-column" style="overflow:hidden; min-height:0;"> |
| | | 559 | | <!-- Спиннер загрузки --> |
| | | 560 | | <div id="hist-spinner" class="text-center py-5"> |
| | | 561 | | <div class="spinner-border text-primary"></div> |
| | | 562 | | <div class="text-muted mt-2">Загрузка сообщений...</div> |
| | | 563 | | </div> |
| | | 564 | | <!-- Ошибка --> |
| | | 565 | | <div id="hist-error" class="alert alert-danger m-3 d-none"></div> |
| | | 566 | | <!-- Кнопка «Загрузить ранее» --> |
| | | 567 | | <div id="hist-load-older" class="text-center py-2 border-bottom d-none"> |
| | | 568 | | <button class="btn btn-sm btn-outline-secondary" onclick="loadOlderMessages()"> |
| | | 569 | | <i class="fas fa-arrow-up me-1"></i>Загрузить ранние сообщения |
| | | 570 | | </button> |
| | | 571 | | </div> |
| | | 572 | | <!-- Область сообщений --> |
| | | 573 | | <div id="hist-messages" class="flex-grow-1 p-3" style="overflow-y:auto; min-height:0;"></div> |
| | | 574 | | |
| | | 575 | | <!-- Шаблонные сообщения --> |
| | | 576 | | <div id="templates-section" class="px-3 py-2 border-top bg-light"> |
| | | 577 | | <div class="d-flex flex-wrap align-items-center gap-1"> |
| | | 578 | | <button class="btn btn-sm template-btn" onclick="insertTemplate('Здравствуйте!')"> |
| | | 579 | | Здравствуйте! |
| | | 580 | | </button> |
| | | 581 | | <button class="btn btn-sm template-btn" |
| | | 582 | | onclick="insertTemplate('Здравствуйте, для проверки необходим VIN номер Вашего авто')"> |
| | | 583 | | Здравствуйте, для проверки необходим VIN номер Вашего авто |
| | | 584 | | </button> |
| | | 585 | | <button class="btn btn-sm template-btn" |
| | | 586 | | onclick="insertTemplate('Заявка на рассмотрении, ожидайте')"> |
| | | 587 | | Заявка на рассмотрении, ожидайте |
| | | 588 | | </button> |
| | | 589 | | <button class="btn btn-sm template-btn" onclick="insertTemplate('Оформите возврат')"> |
| | | 590 | | Оформите возврат |
| | | 591 | | </button> |
| | | 592 | | </div> |
| | | 593 | | </div> |
| | | 594 | | </div> |
| | | 595 | | <div class="modal-footer border-top"> |
| | | 596 | | <div class="w-100"> |
| | | 597 | | <!-- Прикрепленный файл --> |
| | | 598 | | <div id="attached-file-info" class="alert alert-info py-2 px-3 mb-2" style="display:none;"> |
| | | 599 | | <div class="d-flex justify-content-between align-items-center"> |
| | | 600 | | <div> |
| | | 601 | | <i class="fas fa-paperclip me-2"></i> |
| | | 602 | | <span id="attached-file-name"></span> |
| | | 603 | | </div> |
| | | 604 | | <button type="button" class="btn btn-sm btn-link text-danger p-0" |
| | | 605 | | onclick="clearAttachedFile()"> |
| | | 606 | | <i class="fas fa-times"></i> |
| | | 607 | | </button> |
| | | 608 | | </div> |
| | | 609 | | </div> |
| | | 610 | | |
| | | 611 | | <!-- Трекинг времени ответа (часы:минуты) --> |
| | | 612 | | <div id="resp-time-section"> |
| | | 613 | | <label class="form-label small mb-1">Затраченное время</label> |
| | | 614 | | <div class="input-group input-group-sm" style="max-width:240px;"> |
| | | 615 | | <input type="number" id="resp-time-hours" class="form-control" min="0" placeholder="часы" |
| | | 616 | | style="max-width:80px"/> |
| | | 617 | | <span class="input-group-text">:</span> |
| | | 618 | | <input type="number" id="resp-time-minutes" class="form-control" min="0" max="59" |
| | | 619 | | placeholder="мин" style="max-width:80px"/> |
| | | 620 | | <button class="btn btn-sm template-btn ms-2" type="button" onclick="saveResponseTime()"> |
| | | 621 | | Сохранить |
| | | 622 | | </button> |
| | | 623 | | </div> |
| | | 624 | | <div id="resp-time-list" class="small text-muted"> </div> |
| | | 625 | | </div> |
| | | 626 | | |
| | | 627 | | <!-- Поле ввода с кнопками --> |
| | | 628 | | <div class="input-group align-items-end gap-2"> |
| | | 629 | | <!-- Кнопка прикрепления файла --> |
| | | 630 | | <button class="btn-link text-secondary no-outline" type="button" |
| | | 631 | | onclick="document.getElementById('file-input').click()"> |
| | | 632 | | <i class="fas fa-paperclip fa-lg"></i> |
| | | 633 | | </button> |
| | | 634 | | <!-- Кнопка выбора эмодзи --> |
| | | 635 | | <div class="btn-group dropup" role="group"> |
| | | 636 | | <button class="btn-link text-secondary no-outline dropdown-toggle" type="button" |
| | | 637 | | id="emoji-picker-btn" |
| | | 638 | | data-bs-display="static" |
| | | 639 | | data-bs-toggle="dropdown" aria-expanded="false" title="Вставить смайлик"> |
| | | 640 | | <i class="far fa-smile fa-lg"></i> |
| | | 641 | | </button> |
| | | 642 | | <div class="dropdown-menu emoji-picker-dropdown p-2" data-bs-popper="static" |
| | | 643 | | aria-labelledby="emoji-picker-btn"> |
| | | 644 | | <div class="emoji-picker-grid"> |
| | | 645 | | <button type="button" class="emoji-item" onclick="insertEmoji('😀')">😀</button> |
| | | 646 | | <button type="button" class="emoji-item" onclick="insertEmoji('😃')">😃</button> |
| | | 647 | | <button type="button" class="emoji-item" onclick="insertEmoji('😄')">😄</button> |
| | | 648 | | <button type="button" class="emoji-item" onclick="insertEmoji('😁')">😁</button> |
| | | 649 | | <button type="button" class="emoji-item" onclick="insertEmoji('😊')">😊</button> |
| | | 650 | | <button type="button" class="emoji-item" onclick="insertEmoji('😍')">😍</button> |
| | | 651 | | <button type="button" class="emoji-item" onclick="insertEmoji('🥰')">🥰</button> |
| | | 652 | | <button type="button" class="emoji-item" onclick="insertEmoji('😘')">😘</button> |
| | | 653 | | <button type="button" class="emoji-item" onclick="insertEmoji('😗')">😗</button> |
| | | 654 | | <button type="button" class="emoji-item" onclick="insertEmoji('😙')">😙</button> |
| | | 655 | | <button type="button" class="emoji-item" onclick="insertEmoji('😚')">😚</button> |
| | | 656 | | <button type="button" class="emoji-item" onclick="insertEmoji('🤗')">🤗</button> |
| | | 657 | | <button type="button" class="emoji-item" onclick="insertEmoji('🤩')">🤩</button> |
| | | 658 | | <button type="button" class="emoji-item" onclick="insertEmoji('🤔')">🤔</button> |
| | | 659 | | <button type="button" class="emoji-item" onclick="insertEmoji('🤨')">🤨</button> |
| | | 660 | | <button type="button" class="emoji-item" onclick="insertEmoji('😐')">😐</button> |
| | | 661 | | <button type="button" class="emoji-item" onclick="insertEmoji('😑')">😑</button> |
| | | 662 | | <button type="button" class="emoji-item" onclick="insertEmoji('😶')">😶</button> |
| | | 663 | | <button type="button" class="emoji-item" onclick="insertEmoji('😏')">😏</button> |
| | | 664 | | <button type="button" class="emoji-item" onclick="insertEmoji('😒')">😒</button> |
| | | 665 | | <button type="button" class="emoji-item" onclick="insertEmoji('🙄')">🙄</button> |
| | | 666 | | <button type="button" class="emoji-item" onclick="insertEmoji('😬')">😬</button> |
| | | 667 | | <button type="button" class="emoji-item" onclick="insertEmoji('😔')">😔</button> |
| | | 668 | | <button type="button" class="emoji-item" onclick="insertEmoji('😪')">😪</button> |
| | | 669 | | <button type="button" class="emoji-item" onclick="insertEmoji('😴')">😴</button> |
| | | 670 | | <button type="button" class="emoji-item" onclick="insertEmoji('😷')">😷</button> |
| | | 671 | | <button type="button" class="emoji-item" onclick="insertEmoji('🤒')">🤒</button> |
| | | 672 | | <button type="button" class="emoji-item" onclick="insertEmoji('🤕')">🤕</button> |
| | | 673 | | <button type="button" class="emoji-item" onclick="insertEmoji('🤢')">🤢</button> |
| | | 674 | | <button type="button" class="emoji-item" onclick="insertEmoji('😢')">😢</button> |
| | | 675 | | <button type="button" class="emoji-item" onclick="insertEmoji('😭')">😭</button> |
| | | 676 | | <button type="button" class="emoji-item" onclick="insertEmoji('😤')">😤</button> |
| | | 677 | | <button type="button" class="emoji-item" onclick="insertEmoji('😠')">😠</button> |
| | | 678 | | <button type="button" class="emoji-item" onclick="insertEmoji('😡')">😡</button> |
| | | 679 | | <button type="button" class="emoji-item" onclick="insertEmoji('🤬')">🤬</button> |
| | | 680 | | <button type="button" class="emoji-item" onclick="insertEmoji('😱')">😱</button> |
| | | 681 | | <button type="button" class="emoji-item" onclick="insertEmoji('😨')">😨</button> |
| | | 682 | | <button type="button" class="emoji-item" onclick="insertEmoji('😰')">😰</button> |
| | | 683 | | <button type="button" class="emoji-item" onclick="insertEmoji('😥')">😥</button> |
| | | 684 | | <button type="button" class="emoji-item" onclick="insertEmoji('😓')">😓</button> |
| | | 685 | | <button type="button" class="emoji-item" onclick="insertEmoji('🤯')">🤯</button> |
| | | 686 | | <button type="button" class="emoji-item" onclick="insertEmoji('😳')">😳</button> |
| | | 687 | | <button type="button" class="emoji-item" onclick="insertEmoji('🥺')">🥺</button> |
| | | 688 | | <button type="button" class="emoji-item" onclick="insertEmoji('👍')">👍</button> |
| | | 689 | | <button type="button" class="emoji-item" onclick="insertEmoji('👎')">👎</button> |
| | | 690 | | <button type="button" class="emoji-item" onclick="insertEmoji('👌')">👌</button> |
| | | 691 | | <button type="button" class="emoji-item" onclick="insertEmoji('✌️')">✌️</button> |
| | | 692 | | <button type="button" class="emoji-item" onclick="insertEmoji('🤞')">🤞</button> |
| | | 693 | | <button type="button" class="emoji-item" onclick="insertEmoji('🤝')">🤝</button> |
| | | 694 | | <button type="button" class="emoji-item" onclick="insertEmoji('🙏')">🙏</button> |
| | | 695 | | <button type="button" class="emoji-item" onclick="insertEmoji('❤️')">❤️</button> |
| | | 696 | | <button type="button" class="emoji-item" onclick="insertEmoji('💕')">💕</button> |
| | | 697 | | <button type="button" class="emoji-item" onclick="insertEmoji('💯')">💯</button> |
| | | 698 | | <button type="button" class="emoji-item" onclick="insertEmoji('✔️')">✔️</button> |
| | | 699 | | <button type="button" class="emoji-item" onclick="insertEmoji('❌')">❌</button> |
| | | 700 | | <button type="button" class="emoji-item" onclick="insertEmoji('⚠️')">⚠️</button> |
| | | 701 | | <button type="button" class="emoji-item" onclick="insertEmoji('🔥')">🔥</button> |
| | | 702 | | <button type="button" class="emoji-item" onclick="insertEmoji('⭐')">⭐</button> |
| | | 703 | | <button type="button" class="emoji-item" onclick="insertEmoji('🎉')">🎉</button> |
| | | 704 | | <button type="button" class="emoji-item" onclick="insertEmoji('🎊')">🎊</button> |
| | | 705 | | </div> |
| | | 706 | | </div> |
| | | 707 | | </div> |
| | | 708 | | <!-- Текстовое поле --> |
| | | 709 | | <textarea id="message-text" class="form-control" |
| | | 710 | | placeholder="Введите сообщение или прикрепите файл..." |
| | | 711 | | rows="1" maxlength="1000" style="resize:none"></textarea> |
| | | 712 | | <!-- Кнопка отправки --> |
| | | 713 | | <button id="btn-send" class="btn btn-primary px-3" onclick="sendMessageOrFile()"> |
| | | 714 | | <i class="fas fa-paper-plane me-2"></i>Отправить |
| | | 715 | | </button> |
| | | 716 | | </div> |
| | | 717 | | <input type="file" id="file-input" class="d-none" accept="image/*,.pdf,.doc,.docx,.txt" |
| | | 718 | | onchange="handleFileSelect()"> |
| | | 719 | | |
| | | 720 | | <div class="d-flex justify-content-between mt-1"> |
| | | 721 | | <small class="text-muted"> |
| | | 722 | | <span id="char-count">0</span>/1000 символов |
| | | 723 | | </small> |
| | | 724 | | <small class="text-muted"> |
| | | 725 | | Файлы: JPG, PNG, PDF, DOC, DOCX, TXT (макс 10 МБ) |
| | | 726 | | </small> |
| | | 727 | | </div> |
| | | 728 | | </div> |
| | | 729 | | </div> |
| | | 730 | | </div> |
| | | 731 | | </div> |
| | | 732 | | </div> |
| | | 733 | | |
| | | 734 | | <!-- Форма экспорта (скрытая) --> |
| | | 735 | | <form id="export-form" method="post" action="@Url.Action("ExportToExcel")" class="d-none"> |
| | | 736 | | <input type="hidden" name="clientId" id="exp-clientId"/> |
| | | 737 | | <input type="hidden" name="status" id="exp-status"/> |
| | | 738 | | <input type="hidden" name="unreadOnly" id="exp-unread"/> |
| | | 739 | | </form> |
| | | 740 | | |
| | | 741 | | <style> |
| | | 742 | | |
| | | 743 | | .loading-dots { |
| | | 744 | | display: inline-flex; |
| | | 745 | | font-size: 1.5rem; |
| | | 746 | | color: #6c757d; |
| | | 747 | | } |
| | | 748 | | |
| | | 749 | | .loading-dots .dot { |
| | | 750 | | animation: dotPulse 1.4s infinite ease-in-out; |
| | | 751 | | animation-fill-mode: both; |
| | | 752 | | } |
| | | 753 | | |
| | | 754 | | .loading-dots .dot:nth-child(1) { |
| | | 755 | | animation-delay: -0.32s; |
| | | 756 | | } |
| | | 757 | | |
| | | 758 | | .loading-dots .dot:nth-child(2) { |
| | | 759 | | animation-delay: -0.16s; |
| | | 760 | | } |
| | | 761 | | |
| | | 762 | | .loading-dots .dot:nth-child(3) { |
| | | 763 | | animation-delay: 0s; |
| | | 764 | | } |
| | | 765 | | |
| | | 766 | | @@keyframes dotPulse { |
| | | 767 | | 0%, 80%, 100% { |
| | | 768 | | opacity: 0.2; |
| | | 769 | | transform: scale(0.8); |
| | | 770 | | } |
| | | 771 | | 40% { |
| | | 772 | | opacity: 1; |
| | | 773 | | transform: scale(1.2); |
| | | 774 | | } |
| | | 775 | | } |
| | | 776 | | |
| | | 777 | | .last-msg-cell { |
| | | 778 | | max-width: 350px; |
| | | 779 | | } |
| | | 780 | | |
| | | 781 | | .last-msg-cell .msg-content { |
| | | 782 | | display: -webkit-box; |
| | | 783 | | -webkit-line-clamp: 2; |
| | | 784 | | -webkit-box-orient: vertical; |
| | | 785 | | overflow: hidden; |
| | | 786 | | text-overflow: ellipsis; |
| | | 787 | | word-break: break-word; |
| | | 788 | | } |
| | | 789 | | |
| | | 790 | | .last-msg-cell .msg-sender { |
| | | 791 | | font-weight: 500; |
| | | 792 | | margin-right: 0.25rem; |
| | | 793 | | } |
| | | 794 | | |
| | | 795 | | .last-msg-cell .msg-sender.customer { |
| | | 796 | | background-color: #E0F2FE; |
| | | 797 | | color: #075985; |
| | | 798 | | } |
| | | 799 | | |
| | | 800 | | .last-msg-cell .msg-sender.seller { |
| | | 801 | | background-color: #DCFCE7; |
| | | 802 | | color: #166534; |
| | | 803 | | } |
| | | 804 | | |
| | | 805 | | .last-msg-cell .msg-sender.system { |
| | | 806 | | background-color: #e5e7eb; |
| | | 807 | | color: #4b4b4b; |
| | | 808 | | } |
| | | 809 | | |
| | | 810 | | .last-msg-cell .msg-sender.support { |
| | | 811 | | background-color: #FEF3C7; |
| | | 812 | | color: #92400E; |
| | | 813 | | } |
| | | 814 | | |
| | | 815 | | .last-msg-cell .msg-time { |
| | | 816 | | font-size: 0.75rem; |
| | | 817 | | color: #6c757d; |
| | | 818 | | margin-left: 0.5rem; |
| | | 819 | | } |
| | | 820 | | |
| | | 821 | | /* Поле ввода сообщения */ |
| | | 822 | | #message-text { |
| | | 823 | | border-radius: 8px; |
| | | 824 | | border: 1px solid #ced4da; |
| | | 825 | | padding: 10px 12px; |
| | | 826 | | font-size: 0.95rem; |
| | | 827 | | line-height: 1.4; |
| | | 828 | | background-color: #f8f9fa; |
| | | 829 | | |
| | | 830 | | min-height: calc(1.4em * 1 + 20px); |
| | | 831 | | max-height: calc(1.4em * 5 + 20px); /* максимум 5 строк + padding */ |
| | | 832 | | overflow-y: hidden; |
| | | 833 | | resize: none; |
| | | 834 | | |
| | | 835 | | transition: border-color 0.2s, box-shadow 0.2s; |
| | | 836 | | } |
| | | 837 | | |
| | | 838 | | #message-text::-webkit-scrollbar-track { |
| | | 839 | | background: transparent; |
| | | 840 | | border-radius: 12px; |
| | | 841 | | } |
| | | 842 | | |
| | | 843 | | #message-text::-webkit-scrollbar-thumb { |
| | | 844 | | background-color: #b0b0b0; |
| | | 845 | | border-radius: 12px; |
| | | 846 | | } |
| | | 847 | | |
| | | 848 | | #btn-send { |
| | | 849 | | border-radius: 8px; |
| | | 850 | | box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08); |
| | | 851 | | margin-bottom: 2px; |
| | | 852 | | } |
| | | 853 | | |
| | | 854 | | /* Кнопки чата Приклепление файла и Выбор эмоджи */ |
| | | 855 | | .no-outline { |
| | | 856 | | margin-bottom: 9px; |
| | | 857 | | border: none; |
| | | 858 | | background: none; |
| | | 859 | | outline: none; |
| | | 860 | | box-shadow: none; |
| | | 861 | | cursor: pointer; |
| | | 862 | | } |
| | | 863 | | |
| | | 864 | | .no-outline:focus, |
| | | 865 | | .no-outline:active { |
| | | 866 | | outline: none; |
| | | 867 | | box-shadow: none; |
| | | 868 | | } |
| | | 869 | | |
| | | 870 | | /* Hover-эффект на иконках */ |
| | | 871 | | .btn-link i { |
| | | 872 | | transition: transform 0.2s, color 0.2s; |
| | | 873 | | } |
| | | 874 | | |
| | | 875 | | .btn-link:hover i { |
| | | 876 | | transform: scale(1.2); |
| | | 877 | | color: #343a40; |
| | | 878 | | } |
| | | 879 | | |
| | | 880 | | /* Emoji Picker Styles */ |
| | | 881 | | .emoji-picker-dropdown { |
| | | 882 | | max-width: 320px; |
| | | 883 | | max-height: 280px; |
| | | 884 | | overflow-y: auto; |
| | | 885 | | |
| | | 886 | | top: 100%; |
| | | 887 | | left: 0; |
| | | 888 | | width: 320px; |
| | | 889 | | padding: 8px; |
| | | 890 | | border-radius: 8px; |
| | | 891 | | background-color: #fff; |
| | | 892 | | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); |
| | | 893 | | } |
| | | 894 | | |
| | | 895 | | .emoji-picker-grid { |
| | | 896 | | display: grid; |
| | | 897 | | grid-template-columns: repeat(auto-fill, minmax(36px, 1fr)); |
| | | 898 | | gap: 4px; |
| | | 899 | | justify-items: center; |
| | | 900 | | } |
| | | 901 | | |
| | | 902 | | .emoji-item { |
| | | 903 | | background: none; |
| | | 904 | | border: none; |
| | | 905 | | outline: none; |
| | | 906 | | font-size: 1.6rem; |
| | | 907 | | padding: 3px; |
| | | 908 | | cursor: pointer; |
| | | 909 | | border-radius: 6px; |
| | | 910 | | transition: transform 0.2s, background-color 0.2s; |
| | | 911 | | } |
| | | 912 | | |
| | | 913 | | .emoji-item:hover { |
| | | 914 | | background-color: #f1f3f5; |
| | | 915 | | transform: scale(1.25); |
| | | 916 | | } |
| | | 917 | | |
| | | 918 | | .emoji-item:active { |
| | | 919 | | transform: scale(1.1); |
| | | 920 | | } |
| | | 921 | | |
| | | 922 | | .dropup .dropdown-menu[data-bs-popper] { |
| | | 923 | | margin-bottom: 15px; |
| | | 924 | | } |
| | | 925 | | |
| | | 926 | | |
| | | 927 | | .badge-small { |
| | | 928 | | font-size: 0.85em; |
| | | 929 | | } |
| | | 930 | | |
| | | 931 | | .badge-open, |
| | | 932 | | .badge-closed, |
| | | 933 | | .badge-other, |
| | | 934 | | .badge-seller, |
| | | 935 | | .badge-buyer, |
| | | 936 | | .badge-danger-soft { |
| | | 937 | | padding: 6px 12px; |
| | | 938 | | font-weight: 500; |
| | | 939 | | font-size: 0.85em; |
| | | 940 | | } |
| | | 941 | | |
| | | 942 | | .badge-open { |
| | | 943 | | background: #DCFCE7; |
| | | 944 | | color: #166534; |
| | | 945 | | border: 1px solid #BBF7D0; |
| | | 946 | | } |
| | | 947 | | |
| | | 948 | | .badge-closed { |
| | | 949 | | background: #e5e7eb; |
| | | 950 | | color: #4b4b4b; |
| | | 951 | | border: 1px solid #E2E8F0; |
| | | 952 | | } |
| | | 953 | | |
| | | 954 | | .badge-other { |
| | | 955 | | background: #FEF3C7; |
| | | 956 | | color: #92400E; |
| | | 957 | | border: 1px solid #FDE68A; |
| | | 958 | | } |
| | | 959 | | |
| | | 960 | | .badge-seller { |
| | | 961 | | background: #CFFAFE; |
| | | 962 | | color: #155E75; |
| | | 963 | | border: 1px solid #A5F3FC; |
| | | 964 | | } |
| | | 965 | | |
| | | 966 | | .badge-buyer { |
| | | 967 | | background: #E0F2FE; |
| | | 968 | | color: #075985; |
| | | 969 | | border: 1px solid #BAE6FD; |
| | | 970 | | } |
| | | 971 | | |
| | | 972 | | .badge-danger-soft { |
| | | 973 | | background: #EF4444; |
| | | 974 | | color: #FFFFFF; |
| | | 975 | | } |
| | | 976 | | |
| | | 977 | | .bg-filter-card { |
| | | 978 | | background-color: #FFFFFF; |
| | | 979 | | box-shadow: 0 4px 20px rgba(15, 23, 42, 0.04); |
| | | 980 | | } |
| | | 981 | | |
| | | 982 | | .card-header-filter { |
| | | 983 | | background: #F1F5F9; |
| | | 984 | | color: #334155; |
| | | 985 | | } |
| | | 986 | | |
| | | 987 | | .table { |
| | | 988 | | background: #FFFFFF; |
| | | 989 | | } |
| | | 990 | | |
| | | 991 | | .table thead { |
| | | 992 | | background: #F1F5F9; |
| | | 993 | | } |
| | | 994 | | |
| | | 995 | | .table-row-highlight { |
| | | 996 | | background-color: #F5F7FF; |
| | | 997 | | } |
| | | 998 | | |
| | | 999 | | .table tbody tr:hover { |
| | | 1000 | | background-color: #E0E7FF; |
| | | 1001 | | transition: background-color 0.15s ease; |
| | | 1002 | | } |
| | | 1003 | | |
| | | 1004 | | .suggestion-item:hover { |
| | | 1005 | | background-color: #e8f0fe !important; |
| | | 1006 | | } |
| | | 1007 | | |
| | | 1008 | | #search-suggestions-box mark { |
| | | 1009 | | background: #FDE68A; |
| | | 1010 | | padding: 0 1px; |
| | | 1011 | | border-radius: 2px; |
| | | 1012 | | } |
| | | 1013 | | |
| | | 1014 | | .btn-table-light { |
| | | 1015 | | background-color: #e5e7eb; |
| | | 1016 | | color: #4b4b4b; |
| | | 1017 | | border: 2px solid #e0e0e0; |
| | | 1018 | | padding: 4px 10px; |
| | | 1019 | | font-size: 0.85em; |
| | | 1020 | | font-weight: 500; |
| | | 1021 | | border-radius: 0.5rem; |
| | | 1022 | | transition: all 0.2s ease-in-out; |
| | | 1023 | | } |
| | | 1024 | | |
| | | 1025 | | .btn-table-light:hover { |
| | | 1026 | | background-color: #e0e0e0; |
| | | 1027 | | border: 2px solid #ffffff; |
| | | 1028 | | color: #2c2c2c; |
| | | 1029 | | box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08); |
| | | 1030 | | border-radius: 0.5rem; |
| | | 1031 | | } |
| | | 1032 | | |
| | | 1033 | | .btn-table-light:focus { |
| | | 1034 | | outline: none; |
| | | 1035 | | box-shadow: 0 0 0 2px rgba(180, 180, 180, 0.25); |
| | | 1036 | | } |
| | | 1037 | | |
| | | 1038 | | .client-card { |
| | | 1039 | | transition: transform .2s, box-shadow .2s; |
| | | 1040 | | cursor: pointer; |
| | | 1041 | | } |
| | | 1042 | | |
| | | 1043 | | .client-card:hover { |
| | | 1044 | | transform: translateY(-2px); |
| | | 1045 | | box-shadow: 0 8px 25px rgba(0, 0, 0, .15) !important; |
| | | 1046 | | } |
| | | 1047 | | |
| | | 1048 | | .chat-message-content { |
| | | 1049 | | word-break: break-word; |
| | | 1050 | | line-height: 1.55; |
| | | 1051 | | } |
| | | 1052 | | |
| | | 1053 | | .chat-message-content strong { |
| | | 1054 | | font-weight: 600; |
| | | 1055 | | } |
| | | 1056 | | |
| | | 1057 | | .chat-message-content a { |
| | | 1058 | | color: #0d6efd; |
| | | 1059 | | text-decoration: underline; |
| | | 1060 | | word-break: break-all; |
| | | 1061 | | } |
| | | 1062 | | |
| | | 1063 | | .chat-message-content a:hover { |
| | | 1064 | | color: #0a58ca; |
| | | 1065 | | } |
| | | 1066 | | |
| | | 1067 | | .chat-message-content ul { |
| | | 1068 | | margin-top: .25rem; |
| | | 1069 | | margin-bottom: .25rem; |
| | | 1070 | | } |
| | | 1071 | | |
| | | 1072 | | .chat-message-content li { |
| | | 1073 | | margin-bottom: .15rem; |
| | | 1074 | | } |
| | | 1075 | | |
| | | 1076 | | .chat-code { |
| | | 1077 | | background: #e5e7eb; |
| | | 1078 | | color: #4b4b4b; |
| | | 1079 | | border: 1px solid #E2E8F0; |
| | | 1080 | | padding: 2px 8px; |
| | | 1081 | | border-radius: 8px; |
| | | 1082 | | } |
| | | 1083 | | |
| | | 1084 | | #btn-mark-read.btn-success { |
| | | 1085 | | background-color: #DCFCE7; |
| | | 1086 | | border-color: #BBF7D0; |
| | | 1087 | | color: #166534; |
| | | 1088 | | font-weight: 500; |
| | | 1089 | | } |
| | | 1090 | | |
| | | 1091 | | #btn-mark-read.btn-success:hover { |
| | | 1092 | | background-color: #9EE5B7; /* чуть темнее, но гармонично с #DCFCE7 */ |
| | | 1093 | | border-color: #6FD9A0; |
| | | 1094 | | color: #166534; |
| | | 1095 | | } |
| | | 1096 | | |
| | | 1097 | | .chat-bubble { |
| | | 1098 | | max-width: 70%; |
| | | 1099 | | min-width: 200px; |
| | | 1100 | | padding: 8px 16px 2px 16px; |
| | | 1101 | | border-radius: 16px; |
| | | 1102 | | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); |
| | | 1103 | | transition: all 0.2s ease; |
| | | 1104 | | } |
| | | 1105 | | |
| | | 1106 | | .chat-bubble:hover { |
| | | 1107 | | transform: translateY(-1px); |
| | | 1108 | | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); |
| | | 1109 | | } |
| | | 1110 | | |
| | | 1111 | | /* Продавец (справа) */ |
| | | 1112 | | .chat-seller { |
| | | 1113 | | background-color: #F1F5F9; |
| | | 1114 | | border-radius: 16px 16px 4px 16px; |
| | | 1115 | | position: relative; |
| | | 1116 | | margin-left: auto; |
| | | 1117 | | margin-right: 16px; |
| | | 1118 | | } |
| | | 1119 | | |
| | | 1120 | | /* Покупатель (слева) */ |
| | | 1121 | | .chat-customer { |
| | | 1122 | | background-color: #F0F9FF; |
| | | 1123 | | border-radius: 16px 16px 16px 4px; |
| | | 1124 | | position: relative; |
| | | 1125 | | margin-right: auto; |
| | | 1126 | | margin-left: 16px; |
| | | 1127 | | } |
| | | 1128 | | |
| | | 1129 | | /* Система */ |
| | | 1130 | | .chat-system { |
| | | 1131 | | background-color: #fff3cd; |
| | | 1132 | | border: 1px solid #ffe69c; |
| | | 1133 | | } |
| | | 1134 | | |
| | | 1135 | | /* Хвостик у сообщений */ |
| | | 1136 | | .chat-seller::after, |
| | | 1137 | | .chat-customer::after { |
| | | 1138 | | content: ""; |
| | | 1139 | | position: absolute; |
| | | 1140 | | bottom: 0; |
| | | 1141 | | width: 0; |
| | | 1142 | | height: 0; |
| | | 1143 | | border-top: 20px solid transparent; |
| | | 1144 | | border-bottom: 0 solid transparent; |
| | | 1145 | | } |
| | | 1146 | | |
| | | 1147 | | .chat-seller::after { |
| | | 1148 | | right: -16px; |
| | | 1149 | | border-left: 20px solid #F1F5F9; |
| | | 1150 | | filter: drop-shadow(1px 1px 2px rgba(0, 0, 0, 0.05)); |
| | | 1151 | | } |
| | | 1152 | | |
| | | 1153 | | .chat-customer::after { |
| | | 1154 | | left: -16px; |
| | | 1155 | | border-right: 20px solid #F0F9FF; |
| | | 1156 | | filter: drop-shadow(-1px 1px 2px rgba(0, 0, 0, 0.05)); |
| | | 1157 | | } |
| | | 1158 | | |
| | | 1159 | | .chat-badge-seller, |
| | | 1160 | | .chat-badge-customer, |
| | | 1161 | | .chat-badge-system { |
| | | 1162 | | font-size: 0.85rem; |
| | | 1163 | | font-weight: 600; |
| | | 1164 | | border-radius: 8px; |
| | | 1165 | | margin: 0; |
| | | 1166 | | } |
| | | 1167 | | |
| | | 1168 | | .chat-badge-seller, |
| | | 1169 | | .stats-card-green .icon { |
| | | 1170 | | background-color: #DCFCE7; |
| | | 1171 | | color: #166534; |
| | | 1172 | | } |
| | | 1173 | | |
| | | 1174 | | .chat-badge-customer, |
| | | 1175 | | .stats-card-blue .icon { |
| | | 1176 | | background-color: #E0F2FE; |
| | | 1177 | | color: #075985; |
| | | 1178 | | } |
| | | 1179 | | |
| | | 1180 | | .chat-badge-system, |
| | | 1181 | | .stats-card-amber .icon { |
| | | 1182 | | background-color: #FEF3C7; |
| | | 1183 | | color: #92400E; |
| | | 1184 | | } |
| | | 1185 | | |
| | | 1186 | | |
| | | 1187 | | .stats-card-slate .icon { |
| | | 1188 | | background: #CFFAFE; |
| | | 1189 | | color: #155E75; |
| | | 1190 | | } |
| | | 1191 | | |
| | | 1192 | | /* Разделить для новых сообщений */ |
| | | 1193 | | .new-message-divider { |
| | | 1194 | | text-align: center; |
| | | 1195 | | margin-bottom: 16px; |
| | | 1196 | | position: relative; |
| | | 1197 | | font-size: 14px; |
| | | 1198 | | } |
| | | 1199 | | |
| | | 1200 | | .new-message-divider::before, |
| | | 1201 | | .new-message-divider::after { |
| | | 1202 | | content: ""; |
| | | 1203 | | display: inline-block; |
| | | 1204 | | width: 40%; |
| | | 1205 | | height: 1px; |
| | | 1206 | | background-color: #d1d5db; |
| | | 1207 | | vertical-align: middle; |
| | | 1208 | | margin: 0 8px; |
| | | 1209 | | } |
| | | 1210 | | |
| | | 1211 | | /* кнопки шаблонов */ |
| | | 1212 | | .template-btn, |
| | | 1213 | | #resp-time-section .input-group .template-btn { |
| | | 1214 | | border-radius: 10px; |
| | | 1215 | | background-color: #FFFFFF; |
| | | 1216 | | color: #1E293B; |
| | | 1217 | | border: 2px solid #E2E8F0; |
| | | 1218 | | padding: 4px 14px; |
| | | 1219 | | transition: all 0.15s ease; |
| | | 1220 | | font-weight: 500; |
| | | 1221 | | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.02); |
| | | 1222 | | } |
| | | 1223 | | |
| | | 1224 | | .template-btn:hover, |
| | | 1225 | | #resp-time-section .input-group .template-btn:hover { |
| | | 1226 | | background-color: #FFFFFF; |
| | | 1227 | | border-color: #60A5FA; |
| | | 1228 | | color: #2563EB; |
| | | 1229 | | box-shadow: 0 4px 8px rgba(37, 99, 235, 0.1); |
| | | 1230 | | transform: translateY(-1px); |
| | | 1231 | | } |
| | | 1232 | | |
| | | 1233 | | /* Трекинг времени */ |
| | | 1234 | | #resp-time-section { |
| | | 1235 | | margin: -10px 0 0 8px; |
| | | 1236 | | } |
| | | 1237 | | |
| | | 1238 | | #resp-time-section .form-label { |
| | | 1239 | | color: #334155; |
| | | 1240 | | font-weight: 600; |
| | | 1241 | | font-size: 0.75rem; |
| | | 1242 | | text-transform: uppercase; |
| | | 1243 | | letter-spacing: 0.5px; |
| | | 1244 | | margin-bottom: 4px; |
| | | 1245 | | } |
| | | 1246 | | |
| | | 1247 | | #resp-time-section .input-group { |
| | | 1248 | | flex-wrap: nowrap; |
| | | 1249 | | } |
| | | 1250 | | |
| | | 1251 | | #resp-time-section .input-group-text { |
| | | 1252 | | border: none; |
| | | 1253 | | background-color: white; |
| | | 1254 | | font-weight: 600; |
| | | 1255 | | } |
| | | 1256 | | |
| | | 1257 | | #resp-time-section .form-control { |
| | | 1258 | | background: #F8FAFC; |
| | | 1259 | | border: 1px solid #E2E8F0; |
| | | 1260 | | border-radius: 7px; |
| | | 1261 | | height: 32px; |
| | | 1262 | | padding: 2px 5px; |
| | | 1263 | | font-size: 0.85rem; |
| | | 1264 | | } |
| | | 1265 | | |
| | | 1266 | | #resp-time-list { |
| | | 1267 | | background: transparent; |
| | | 1268 | | padding: 4px 0; |
| | | 1269 | | border: none; |
| | | 1270 | | font-size: 0.85rem; |
| | | 1271 | | } |
| | | 1272 | | |
| | | 1273 | | /* Статистика ответов */ |
| | | 1274 | | .stats-card { |
| | | 1275 | | border: 1px solid #E5E7EB; |
| | | 1276 | | border-radius: 12px; |
| | | 1277 | | background-color: #FFFFFF; |
| | | 1278 | | transition: 0.2s ease; |
| | | 1279 | | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); |
| | | 1280 | | } |
| | | 1281 | | |
| | | 1282 | | .stats-card:hover { |
| | | 1283 | | box-shadow: 0 6px 18px rgba(0, 0, 0, 0.1); |
| | | 1284 | | } |
| | | 1285 | | |
| | | 1286 | | .stats-card .icon { |
| | | 1287 | | width: 42px; |
| | | 1288 | | height: 42px; |
| | | 1289 | | flex: 0 0 42px; |
| | | 1290 | | min-width: 42px; |
| | | 1291 | | border-radius: 10px; |
| | | 1292 | | display: flex; |
| | | 1293 | | align-items: center; |
| | | 1294 | | justify-content: center; |
| | | 1295 | | } |
| | | 1296 | | |
| | | 1297 | | #summary-period { |
| | | 1298 | | white-space: normal; |
| | | 1299 | | line-height: 1.2; |
| | | 1300 | | } |
| | | 1301 | | |
| | | 1302 | | .progress-green { |
| | | 1303 | | background-color: #DCFCE7; /* светлый зелёный фон */ |
| | | 1304 | | } |
| | | 1305 | | |
| | | 1306 | | .progress-bar-green { |
| | | 1307 | | background-color: #166534; /* тёмный зелёный */ |
| | | 1308 | | color: #fff; |
| | | 1309 | | font-weight: 500; |
| | | 1310 | | display: flex; |
| | | 1311 | | align-items: center; |
| | | 1312 | | justify-content: center; |
| | | 1313 | | } |
| | | 1314 | | |
| | | 1315 | | .card-soft { |
| | | 1316 | | border: none; |
| | | 1317 | | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); |
| | | 1318 | | } |
| | | 1319 | | |
| | | 1320 | | .link-chat { |
| | | 1321 | | color: #075985; |
| | | 1322 | | font-weight: 500; |
| | | 1323 | | } |
| | | 1324 | | |
| | | 1325 | | .link-chat:hover { |
| | | 1326 | | color: #0c4a6e; |
| | | 1327 | | text-decoration: underline; |
| | | 1328 | | } |
| | | 1329 | | |
| | | 1330 | | </style> |
| | | 1331 | | |
| | | 1332 | | <script> |
| | | 1333 | | (function () { |
| | | 1334 | | // === Элементы === |
| | | 1335 | | const selClient = document.getElementById('sel-client'); |
| | | 1336 | | const selStatus = document.getElementById('sel-status'); |
| | | 1337 | | const selType = document.getElementById('sel-type'); |
| | | 1338 | | const chkUnread = document.getElementById('chk-unread'); |
| | | 1339 | | const selPageSize = document.getElementById('sel-pagesize'); |
| | | 1340 | | const btnLoad = document.getElementById('btn-load'); |
| | | 1341 | | const btnExport = document.getElementById('btn-export'); |
| | | 1342 | | |
| | | 1343 | | // === Вставка эмодзи в текстовое поле === |
| | | 1344 | | window.insertEmoji = function (emoji) { |
| | | 1345 | | const messageText = document.getElementById('message-text'); |
| | | 1346 | | if (!messageText) return; |
| | | 1347 | | |
| | | 1348 | | const start = messageText.selectionStart; |
| | | 1349 | | const end = messageText.selectionEnd; |
| | | 1350 | | const text = messageText.value; |
| | | 1351 | | |
| | | 1352 | | const before = text.substring(0, start); |
| | | 1353 | | const after = text.substring(end, text.length); |
| | | 1354 | | messageText.value = before + emoji + after; |
| | | 1355 | | |
| | | 1356 | | const newPosition = start + emoji.length; |
| | | 1357 | | messageText.selectionStart = newPosition; |
| | | 1358 | | messageText.selectionEnd = newPosition; |
| | | 1359 | | messageText.focus(); |
| | | 1360 | | |
| | | 1361 | | const charCount = document.getElementById('char-count'); |
| | | 1362 | | if (charCount) charCount.textContent = messageText.value.length; |
| | | 1363 | | }; |
| | | 1364 | | |
| | | 1365 | | // Функция для растягивания поля воода сообщения |
| | | 1366 | | function autoResizeTextarea(element) { |
| | | 1367 | | element.style.height = 'auto'; |
| | | 1368 | | |
| | | 1369 | | const maxHeight = parseInt(getComputedStyle(element).maxHeight); |
| | | 1370 | | |
| | | 1371 | | if (element.scrollHeight > maxHeight) { |
| | | 1372 | | element.style.height = maxHeight + 'px'; |
| | | 1373 | | element.style.overflowY = 'auto'; |
| | | 1374 | | } else { |
| | | 1375 | | element.style.height = element.scrollHeight + 'px'; |
| | | 1376 | | element.style.overflowY = 'hidden'; |
| | | 1377 | | } |
| | | 1378 | | } |
| | | 1379 | | |
| | | 1380 | | // Добавляем обработчик события input |
| | | 1381 | | const textarea = document.getElementById('message-text'); |
| | | 1382 | | textarea.addEventListener('input', function () { |
| | | 1383 | | autoResizeTextarea(this); |
| | | 1384 | | }); |
| | | 1385 | | |
| | | 1386 | | // === Вставка шаблонного сообщения === |
| | | 1387 | | window.insertTemplate = function (templateText) { |
| | | 1388 | | const messageText = document.getElementById('message-text'); |
| | | 1389 | | if (!messageText) return; |
| | | 1390 | | |
| | | 1391 | | const start = messageText.selectionStart; |
| | | 1392 | | const end = messageText.selectionEnd; |
| | | 1393 | | const text = messageText.value; |
| | | 1394 | | |
| | | 1395 | | const before = text.substring(0, start); |
| | | 1396 | | const after = text.substring(end, text.length); |
| | | 1397 | | |
| | | 1398 | | // Добавляем пробел перед шаблоном если нужно |
| | | 1399 | | const needsSpaceBefore = before.length > 0 && !before.endsWith(' '); |
| | | 1400 | | const finalText = needsSpaceBefore ? ' ' + templateText + ' ' : templateText + ' '; |
| | | 1401 | | |
| | | 1402 | | messageText.value = before + finalText + after; |
| | | 1403 | | |
| | | 1404 | | const newPosition = start + finalText.length; |
| | | 1405 | | messageText.selectionStart = newPosition; |
| | | 1406 | | messageText.selectionEnd = newPosition; |
| | | 1407 | | messageText.focus(); |
| | | 1408 | | |
| | | 1409 | | // Вызываем функцию растягивания напрямую |
| | | 1410 | | autoResizeTextarea(messageText); |
| | | 1411 | | }; |
| | | 1412 | | |
| | | 1413 | | const statsRow = document.getElementById('stats-row'); |
| | | 1414 | | const chatsSection = document.getElementById('chats-section'); |
| | | 1415 | | const spinner = document.getElementById('loading-spinner'); |
| | | 1416 | | const errorBox = document.getElementById('error-box'); |
| | | 1417 | | const emptyBox = document.getElementById('empty-box'); |
| | | 1418 | | const tableWrap = document.getElementById('table-wrap'); |
| | | 1419 | | const tbody = document.getElementById('chats-tbody'); |
| | | 1420 | | const pagWrap = document.getElementById('pagination-wrap'); |
| | | 1421 | | const pagList = document.getElementById('pagination-list'); |
| | | 1422 | | const pageInfo = document.getElementById('page-info'); |
| | | 1423 | | |
| | | 1424 | | @* const badgeUnread = document.getElementById('badge-unread'); *@ |
| | | 1425 | | const badgeTotal = document.getElementById('badge-total'); |
| | | 1426 | | @* const unreadText = document.getElementById('unread-text'); *@ |
| | | 1427 | | const totalText = document.getElementById('total-text'); |
| | | 1428 | | |
| | | 1429 | | const statTotal = document.getElementById('stat-total'); |
| | | 1430 | | @* const statUnread = document.getElementById('stat-unread'); *@ |
| | | 1431 | | const statPage = document.getElementById('stat-page'); |
| | | 1432 | | |
| | | 1433 | | let currentPage = 1; |
| | | 1434 | | |
| | | 1435 | | // === ПОИСК ПО АРТИКУЛУ / ТОВАРУ / НОМЕРУ ЗАКАЗА === |
| | | 1436 | | const searchInput = document.getElementById('chat-search-input'); |
| | | 1437 | | const suggestionsBox = document.getElementById('search-suggestions-box'); |
| | | 1438 | | const btnClearSearch = document.getElementById('btn-clear-search'); |
| | | 1439 | | const searchModeBadge = document.getElementById('search-mode-badge'); |
| | | 1440 | | const searchModeQuery = document.getElementById('search-mode-query'); |
| | | 1441 | | const searchResultCount = document.getElementById('search-result-count'); |
| | | 1442 | | |
| | | 1443 | | let searchDebounce = null; |
| | | 1444 | | let isSearchMode = false; |
| | | 1445 | | let loadedChats = []; // чаты текущей страницы для поиска |
| | | 1446 | | let searchGen = 0; // поколение поиска — для отмены предыдущего |
| | | 1447 | | const chatMetaCache = new Map(); // chatId → { sku, orderNumber } — заполняется во время поиска |
| | | 1448 | | let preScanRunning = false; // флаг фонового предсканирования |
| | | 1449 | | |
| | | 1450 | | // Фоновое сканирование всех чатов для заполнения кэша SKU/orderNumber |
| | | 1451 | | function startPreScan() { |
| | | 1452 | | if (preScanRunning || !loadedChats.length) return; |
| | | 1453 | | preScanRunning = true; |
| | | 1454 | | const CONCURRENCY = 5; |
| | | 1455 | | let idx = 0; |
| | | 1456 | | |
| | | 1457 | | async function nextScan() { |
| | | 1458 | | while (idx < loadedChats.length) { |
| | | 1459 | | const c = loadedChats[idx++]; |
| | | 1460 | | if (chatMetaCache.has(c.chatId)) continue; // уже есть |
| | | 1461 | | try { |
| | | 1462 | | const params = new URLSearchParams({ |
| | | 1463 | | clientId: c.clientId, |
| | | 1464 | | chatId: c.chatId, |
| | | 1465 | | limit: 20, |
| | | 1466 | | direction: 'Backward' |
| | | 1467 | | }); |
| | 0 | 1468 | | const r = await fetch(`@Url.Action("GetHistoryJson")?${params}`); |
| | | 1469 | | const hdata = await r.json(); |
| | | 1470 | | if (hdata.success && hdata.messages) { |
| | | 1471 | | const meta = {clientName: c.clientName || ''}; |
| | | 1472 | | hdata.messages.forEach(m => { |
| | | 1473 | | const txt = (m.data || []).filter(Boolean).join(' '); |
| | | 1474 | | if (m.sku && !meta.sku) { |
| | | 1475 | | meta.sku = m.sku; |
| | | 1476 | | meta.skuSnippet = txt.substring(0, 60) || ''; |
| | | 1477 | | } |
| | | 1478 | | if (m.orderNumber && !meta.orderNumber) { |
| | | 1479 | | meta.orderNumber = m.orderNumber; |
| | | 1480 | | meta.orderSnippet = txt.substring(0, 60) || ''; |
| | | 1481 | | } |
| | | 1482 | | }); |
| | | 1483 | | chatMetaCache.set(c.chatId, meta); |
| | | 1484 | | } |
| | | 1485 | | } catch (e) { /* не блокируем */ |
| | | 1486 | | } |
| | | 1487 | | } |
| | | 1488 | | } |
| | | 1489 | | |
| | | 1490 | | const workers = []; |
| | | 1491 | | for (let i = 0; i < CONCURRENCY; i++) workers.push(nextScan()); |
| | | 1492 | | Promise.all(workers).finally(() => { |
| | | 1493 | | preScanRunning = false; |
| | | 1494 | | }); |
| | | 1495 | | } |
| | | 1496 | | |
| | | 1497 | | if (searchInput) { |
| | | 1498 | | searchInput.addEventListener('focus', () => { |
| | | 1499 | | startPreScan(); |
| | | 1500 | | showAllSuggestions(); |
| | | 1501 | | }); |
| | | 1502 | | |
| | | 1503 | | searchInput.addEventListener('input', () => { |
| | | 1504 | | const q = searchInput.value.trim(); |
| | | 1505 | | btnClearSearch.classList.toggle('d-none', !q); |
| | | 1506 | | clearTimeout(searchDebounce); |
| | | 1507 | | if (!q) { |
| | | 1508 | | if (isSearchMode) exitSearchMode(); // сброс результатов при ручной очистке |
| | | 1509 | | showAllSuggestions(); |
| | | 1510 | | return; |
| | | 1511 | | } |
| | | 1512 | | if (q.length < 2) { |
| | | 1513 | | hideSuggestions(); |
| | | 1514 | | return; |
| | | 1515 | | } |
| | | 1516 | | searchDebounce = setTimeout(() => fetchSuggestions(q), 300); |
| | | 1517 | | }); |
| | | 1518 | | |
| | | 1519 | | searchInput.addEventListener('keydown', e => { |
| | | 1520 | | if (e.key === 'Enter') { |
| | | 1521 | | e.preventDefault(); |
| | | 1522 | | hideSuggestions(); |
| | | 1523 | | const q = searchInput.value.trim(); |
| | | 1524 | | if (!q) { |
| | | 1525 | | loadChats(); |
| | | 1526 | | return; |
| | | 1527 | | } // пустое поле — стандартная таблица |
| | | 1528 | | // Если это похоже на PostingNumber — сразу ищем только по нему (без перебора страниц) |
| | | 1529 | | if (/^\d{4,12}-\d{1,6}(-\d{1,6})?$/.test(q)) { |
| | | 1530 | | const clientId = selClient ? selClient.value : 0; |
| | 0 | 1531 | | fetch(`@Url.Action("GetChatSearchSuggestions")?clientId=${clientId}&query=${encodeURIComponent(q |
| | | 1532 | | .then(r => r.json()) |
| | | 1533 | | .then(data => { |
| | | 1534 | | if (data.success && data.found && data.record && data.record.chatId && data.record.clien |
| | | 1535 | | // Только рендерим строку, не открываем чат |
| | | 1536 | | renderSingleOrderRow(data.record); |
| | | 1537 | | // После рендера строки — подгружаем последнее сообщение и время |
| | | 1538 | | const chatId = data.record.chatId; |
| | | 1539 | | const clientId = data.record.clientId; |
| | 0 | 1540 | | fetch(`@Url.Action("GetLastMessageJson")?clientId=${clientId}&chatId=${encodeURIComp |
| | | 1541 | | .then(r => r.json()) |
| | | 1542 | | .then(msgData => { |
| | | 1543 | | const cell = document.querySelector(`.last-msg-cell[data-chat-id="${chatId}" |
| | | 1544 | | const timeCell = document.querySelector(`.last-msg-time-cell[data-chat-id="$ |
| | | 1545 | | if (msgData.success && msgData.hasMessage) { |
| | | 1546 | | // Заполняем содержимое ячейки сообщения |
| | | 1547 | | if (cell) { |
| | | 1548 | | let msgHtml = ''; |
| | | 1549 | | if (msgData.isCustomer) { |
| | | 1550 | | msgHtml += `<span class="msg-sender customer">Покупатель:</span> |
| | | 1551 | | } else { |
| | | 1552 | | msgHtml += `<span class="msg-sender seller">Продавец:</span> `; |
| | | 1553 | | } |
| | | 1554 | | msgHtml += esc(msgData.text || ''); |
| | | 1555 | | cell.innerHTML = `<div class="msg-content">${msgHtml}</div>`; |
| | | 1556 | | } |
| | | 1557 | | // Заполняем время с разницей (timeAgo) |
| | | 1558 | | if (timeCell) { |
| | | 1559 | | const ago = timeAgo(msgData.createdAt); |
| | | 1560 | | timeCell.innerHTML = `<span title="${esc(msgData.createdAtFormatted) |
| | | 1561 | | } |
| | | 1562 | | } |
| | | 1563 | | }); |
| | | 1564 | | } else { |
| | | 1565 | | doSearch(q); |
| | | 1566 | | } |
| | | 1567 | | }) |
| | | 1568 | | .catch(() => doSearch(q)); |
| | | 1569 | | return; |
| | | 1570 | | } |
| | | 1571 | | |
| | | 1572 | | // Рендер одного заказа (поиск по номеру отправления) |
| | | 1573 | | function renderSingleOrderRow(order) { |
| | | 1574 | | // Скрыть пагинацию и показать таблицу |
| | | 1575 | | document.getElementById('pagination-wrap').classList.add('d-none'); |
| | | 1576 | | document.getElementById('table-wrap').classList.remove('d-none'); |
| | | 1577 | | document.getElementById('chats-section').classList.remove('d-none'); |
| | | 1578 | | document.getElementById('loading-spinner').classList.add('d-none'); |
| | | 1579 | | document.getElementById('empty-box').classList.add('d-none'); |
| | | 1580 | | document.getElementById('error-box').classList.add('d-none'); |
| | | 1581 | | // Очищаем тело таблицы |
| | | 1582 | | const tbody = document.getElementById('chats-tbody'); |
| | | 1583 | | tbody.innerHTML = ''; |
| | | 1584 | | // Получаем имя клиента по clientId, если не пришло в order.clientName |
| | | 1585 | | let clientName = order.clientName || ''; |
| | | 1586 | | if (!clientName && order.clientId) { |
| | | 1587 | | const sel = document.getElementById('sel-client'); |
| | | 1588 | | if (sel) { |
| | | 1589 | | const opt = sel.querySelector(`option[value="${order.clientId}"]`); |
| | | 1590 | | if (opt) clientName = opt.textContent.trim(); |
| | | 1591 | | } |
| | | 1592 | | } |
| | | 1593 | | // Формируем строку |
| | | 1594 | | const tr = document.createElement('tr'); |
| | | 1595 | | tr.style.cursor = 'pointer'; |
| | | 1596 | | tr.innerHTML = ` |
| | | 1597 | | <td><span class="badge badge-closed">${esc(clientName)}</span></td> |
| | | 1598 | | <td class="last-msg-cell" data-chat-id="${esc(order.chatId)}" data-client-id="${orde |
| | | 1599 | | <div class="msg-content"> |
| | | 1600 | | <span class="msg-sender seller">Номер отправления:</span> ${esc(order.label) |
| | | 1601 | | </div></td> |
| | | 1602 | | <td><span class="badge badge-open">Открыт</span></td> |
| | | 1603 | | <td><span class="badge badge-buyer">Чат с покупателем</span></td> |
| | | 1604 | | <td><span>${new Date().toLocaleDateString('ru-RU')}</span></td> |
| | | 1605 | | <td class="last-msg-time-cell" data-chat-id="${esc(order.chatId)}"><span class="text |
| | | 1606 | | <td class="text-center"><span class="text-muted">—</span></td> |
| | | 1607 | | <td> |
| | | 1608 | | <button class="btn btn-sm btn-table-light me-1" onclick="viewChat('${esc(order.c |
| | | 1609 | | <button class="btn btn-sm btn-table-light" onclick="copyId('${esc(order.chatId)} |
| | | 1610 | | </td>`; |
| | | 1611 | | tr.addEventListener('click', e => { |
| | | 1612 | | if (e.target.closest('button')) return; |
| | | 1613 | | viewChat(order.chatId, order.clientId); |
| | | 1614 | | }); |
| | | 1615 | | tbody.appendChild(tr); |
| | | 1616 | | } |
| | | 1617 | | |
| | | 1618 | | doSearch(q); |
| | | 1619 | | } |
| | | 1620 | | if (e.key === 'Escape') { |
| | | 1621 | | hideSuggestions(); |
| | | 1622 | | } |
| | | 1623 | | }); |
| | | 1624 | | |
| | | 1625 | | // Скрываем подсказки при клике вне |
| | | 1626 | | document.addEventListener('click', e => { |
| | | 1627 | | if (!searchInput.contains(e.target) && !suggestionsBox.contains(e.target)) |
| | | 1628 | | hideSuggestions(); |
| | | 1629 | | }); |
| | | 1630 | | } |
| | | 1631 | | |
| | | 1632 | | if (btnClearSearch) { |
| | | 1633 | | btnClearSearch.addEventListener('click', () => { |
| | | 1634 | | searchInput.value = ''; |
| | | 1635 | | btnClearSearch.classList.add('d-none'); |
| | | 1636 | | hideSuggestions(); |
| | | 1637 | | loadChats(); // возвращаем стандартную таблицу |
| | | 1638 | | }); |
| | | 1639 | | } |
| | | 1640 | | |
| | | 1641 | | // Показать все доступные подсказки из кэша (без фильтра) |
| | | 1642 | | function showAllSuggestions() { |
| | | 1643 | | const clientId = selClient ? selClient.value : 0; |
| | | 1644 | | // Показываем локальные + PostingNumber из БД |
| | 0 | 1645 | | fetch(`@Url.Action("GetChatSearchSuggestions")?clientId=${clientId}&query=`) |
| | | 1646 | | .then(r => r.json()) |
| | | 1647 | | .then(data => { |
| | | 1648 | | let all = getLocalSuggestions(''); |
| | | 1649 | | if (data.success && data.suggestions && data.suggestions.length) { |
| | | 1650 | | const localLabels = new Set(all.map(s => s.label.toLowerCase())); |
| | | 1651 | | all = [...all, ...data.suggestions.filter(s => !localLabels.has(s.label.toLowerCase()))]; |
| | | 1652 | | } |
| | | 1653 | | if (!all.length) return; |
| | | 1654 | | renderSuggestions(all, ''); |
| | | 1655 | | }) |
| | | 1656 | | .catch(() => { |
| | | 1657 | | const all = getLocalSuggestions(''); |
| | | 1658 | | if (all.length) renderSuggestions(all, ''); |
| | | 1659 | | }); |
| | | 1660 | | } |
| | | 1661 | | |
| | | 1662 | | function fetchSuggestions(q) { |
| | | 1663 | | const clientId = selClient ? selClient.value : 0; |
| | | 1664 | | const localSugg = getLocalSuggestions(q); |
| | | 1665 | | |
| | 0 | 1666 | | fetch(`@Url.Action("GetChatSearchSuggestions")?clientId=${clientId}&query=${encodeURIComponent(q)}`) |
| | | 1667 | | .then(r => r.json()) |
| | | 1668 | | .then(data => { |
| | | 1669 | | // Если найдено точное совпадение (found/record), сразу открываем чат |
| | | 1670 | | if (data.success && data.found && data.record && data.record.chatId && data.record.clientId) { |
| | | 1671 | | hideSuggestions(); |
| | | 1672 | | // viewChat(data.record.chatId, data.record.clientId); |
| | | 1673 | | return; |
| | | 1674 | | } |
| | | 1675 | | let all = [...localSugg]; |
| | | 1676 | | if (data.success && data.suggestions && data.suggestions.length) { |
| | | 1677 | | const localLabels = new Set(localSugg.map(s => s.label.toLowerCase())); |
| | | 1678 | | all = [...localSugg, ...data.suggestions.filter(s => !localLabels.has(s.label.toLowerCase()))]; |
| | | 1679 | | } |
| | | 1680 | | if (!all.length) { |
| | | 1681 | | hideSuggestions(); |
| | | 1682 | | return; |
| | | 1683 | | } |
| | | 1684 | | renderSuggestions(all, q); |
| | | 1685 | | }) |
| | | 1686 | | .catch(() => { |
| | | 1687 | | if (localSugg.length) renderSuggestions(localSugg, q); |
| | | 1688 | | else hideSuggestions(); |
| | | 1689 | | }); |
| | | 1690 | | } |
| | | 1691 | | |
| | | 1692 | | // Локальные подсказки из кэша (SKU и orderNumber из уже загруженных историй) |
| | | 1693 | | // q === '' → вернуть всё, иначе — фильтровать |
| | | 1694 | | function getLocalSuggestions(q) { |
| | | 1695 | | const qLow = q.toLowerCase(); |
| | | 1696 | | const orderNums = []; |
| | | 1697 | | const skus = []; |
| | | 1698 | | const seenOrder = new Set(); |
| | | 1699 | | const seenSku = new Set(); |
| | | 1700 | | chatMetaCache.forEach(meta => { |
| | | 1701 | | if (meta.orderNumber && !seenOrder.has(meta.orderNumber) && |
| | | 1702 | | (!qLow || meta.orderNumber.toLowerCase().includes(qLow))) { |
| | | 1703 | | seenOrder.add(meta.orderNumber); |
| | | 1704 | | orderNums.push({ |
| | | 1705 | | type: 'ordernum', label: meta.orderNumber, |
| | | 1706 | | icon: 'fas fa-receipt', hint: 'Номер заказа', |
| | | 1707 | | preview: meta.orderSnippet || '', |
| | | 1708 | | client: meta.clientName || '' |
| | | 1709 | | }); |
| | | 1710 | | } |
| | | 1711 | | if (meta.sku && !seenSku.has(meta.sku) && |
| | | 1712 | | (!qLow || meta.sku.toLowerCase().includes(qLow))) { |
| | | 1713 | | seenSku.add(meta.sku); |
| | | 1714 | | skus.push({ |
| | | 1715 | | type: 'sku', label: meta.sku, |
| | | 1716 | | icon: 'fas fa-barcode', hint: 'SKU', |
| | | 1717 | | preview: meta.skuSnippet || '', |
| | | 1718 | | client: meta.clientName || '' |
| | | 1719 | | }); |
| | | 1720 | | } |
| | | 1721 | | }); |
| | | 1722 | | return [...orderNums.slice(0, 15), ...skus.slice(0, 15)]; |
| | | 1723 | | } |
| | | 1724 | | |
| | | 1725 | | function renderSuggestions(suggestions, q) { |
| | | 1726 | | suggestionsBox.innerHTML = ''; |
| | | 1727 | | // Порядок групп: номера заказов → артикулы/товары → SKU |
| | | 1728 | | const groupOrder = ['ordernum', 'order', 'article', 'product', 'sku']; |
| | | 1729 | | const groups = {}; |
| | | 1730 | | groupOrder.forEach(k => groups[k] = []); |
| | | 1731 | | suggestions.forEach(s => { |
| | | 1732 | | if (groups[s.type] !== undefined) groups[s.type].push(s); |
| | | 1733 | | }); |
| | | 1734 | | |
| | | 1735 | | const groupLabels = { |
| | | 1736 | | ordernum: 'Номера заказов', |
| | | 1737 | | order: 'Номера заказов (БД)', |
| | | 1738 | | article: 'Артикулы', |
| | | 1739 | | product: 'Товары', |
| | | 1740 | | sku: 'SKU' |
| | | 1741 | | }; |
| | | 1742 | | let hasAny = false; |
| | | 1743 | | |
| | | 1744 | | for (const type of groupOrder) { |
| | | 1745 | | const items = groups[type]; |
| | | 1746 | | if (!items.length) continue; |
| | | 1747 | | hasAny = true; |
| | | 1748 | | const header = document.createElement('div'); |
| | | 1749 | | header.className = 'px-3 py-1 text-muted small fw-semibold border-bottom'; |
| | | 1750 | | header.style.background = '#f8fafc'; |
| | | 1751 | | header.textContent = groupLabels[type] || type; |
| | | 1752 | | suggestionsBox.appendChild(header); |
| | | 1753 | | |
| | | 1754 | | items.forEach(s => { |
| | | 1755 | | const item = document.createElement('div'); |
| | | 1756 | | item.className = 'px-3 py-2 suggestion-item d-flex align-items-start gap-2'; |
| | | 1757 | | item.style.cursor = 'pointer'; |
| | | 1758 | | // Подсветка совпадения — только если есть что искать |
| | | 1759 | | const escapedLabel = esc(s.label); |
| | | 1760 | | const highlighted = q |
| | | 1761 | | ? escapedLabel.replace(new RegExp(q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'), m => `<mark>$ |
| | | 1762 | | : escapedLabel; |
| | | 1763 | | // Сниппет сообщения и клиент |
| | | 1764 | | const previewHtml = s.preview |
| | | 1765 | | ? `<div class="text-muted" style="font-size:0.72em;line-height:1.3;margin-top:2px;white-space:no |
| | | 1766 | | : ''; |
| | | 1767 | | const clientHtml = s.client |
| | | 1768 | | ? `<span class="badge bg-secondary ms-auto" style="font-size:0.65em;flex-shrink:0">${esc(s.clien |
| | | 1769 | | : `<small class="text-muted ms-auto" style="flex-shrink:0">${esc(s.hint)}</small>`; |
| | | 1770 | | item.innerHTML = `<i class="${esc(s.icon)} text-secondary fa-fw mt-1"></i><div class="flex-grow-1 ov |
| | | 1771 | | item.addEventListener('mouseenter', () => item.style.background = '#e8f0fe'); |
| | | 1772 | | item.addEventListener('mouseleave', () => item.style.background = ''); |
| | | 1773 | | item.addEventListener('click', () => { |
| | | 1774 | | searchInput.value = s.label; |
| | | 1775 | | btnClearSearch.classList.remove('d-none'); |
| | | 1776 | | hideSuggestions(); |
| | | 1777 | | doSearchBySuggestion(s); |
| | | 1778 | | }); |
| | | 1779 | | suggestionsBox.appendChild(item); |
| | | 1780 | | }); |
| | | 1781 | | } |
| | | 1782 | | |
| | | 1783 | | if (!hasAny) { |
| | | 1784 | | hideSuggestions(); |
| | | 1785 | | return; |
| | | 1786 | | } |
| | | 1787 | | suggestionsBox.classList.remove('d-none'); |
| | | 1788 | | } |
| | | 1789 | | |
| | | 1790 | | function hideSuggestions() { |
| | | 1791 | | if (suggestionsBox) suggestionsBox.classList.add('d-none'); |
| | | 1792 | | } |
| | | 1793 | | |
| | | 1794 | | // Вызывается при клике по подсказке — для sku/ordernum ищет только подходящие чаты |
| | | 1795 | | function doSearchBySuggestion(s) { |
| | | 1796 | | // Если suggestion содержит прямой chatId (точный PostingNumber из БД) — используем его |
| | | 1797 | | if (s.chatId) { |
| | | 1798 | | const subset = loadedChats.filter(c => c.chatId === s.chatId); |
| | | 1799 | | if (subset.length) { |
| | | 1800 | | doSearch(s.label, subset); |
| | | 1801 | | return; |
| | | 1802 | | } |
| | | 1803 | | // Если в текущем page нет нужного чата, всё равно передаём label — контроллер найдёт по PostingNumber |
| | | 1804 | | doSearch(s.label); |
| | | 1805 | | return; |
| | | 1806 | | } |
| | | 1807 | | |
| | | 1808 | | if (s.type === 'sku' || s.type === 'ordernum') { |
| | | 1809 | | const field = s.type === 'sku' ? 'sku' : 'orderNumber'; |
| | | 1810 | | // Находим chatId из кэша по точному значению |
| | | 1811 | | const matchedIds = new Set(); |
| | | 1812 | | chatMetaCache.forEach((meta, chatId) => { |
| | | 1813 | | if (meta[field] && meta[field] === s.label) matchedIds.add(chatId); |
| | | 1814 | | }); |
| | | 1815 | | if (matchedIds.size > 0) { |
| | | 1816 | | const subset = loadedChats.filter(c => matchedIds.has(c.chatId)); |
| | | 1817 | | if (subset.length) { |
| | | 1818 | | doSearch(s.label, subset); |
| | | 1819 | | return; |
| | | 1820 | | } |
| | | 1821 | | } |
| | | 1822 | | // если кэш ещё не заполнен — полный поиск как запасной вариант |
| | | 1823 | | doSearch(s.label); |
| | | 1824 | | } else { |
| | | 1825 | | // Для артикулов, товаров, номеров заказа (БД) — полный поиск |
| | | 1826 | | doSearch(s.label); |
| | | 1827 | | } |
| | | 1828 | | } |
| | | 1829 | | |
| | | 1830 | | function doSearch(q, chatsSubset) { |
| | | 1831 | | if (!q || q.length < 2) return; |
| | | 1832 | | const targetChats = chatsSubset || loadedChats; |
| | | 1833 | | if (!targetChats.length) { |
| | | 1834 | | if (!loadedChats.length) { |
| | | 1835 | | errorBox.textContent = 'Сначала загрузите чаты кнопкой «Загрузить»'; |
| | | 1836 | | show(chatsSection); |
| | | 1837 | | show(errorBox); |
| | | 1838 | | } |
| | | 1839 | | return; |
| | | 1840 | | } |
| | | 1841 | | |
| | | 1842 | | const gen = ++searchGen; // уникальный ключ — при новом поиске старый прекратит запись |
| | | 1843 | | isSearchMode = true; |
| | | 1844 | | |
| | | 1845 | | show(chatsSection); |
| | | 1846 | | hide(spinner); |
| | | 1847 | | hide(errorBox); |
| | | 1848 | | hide(emptyBox); |
| | | 1849 | | hide(pagWrap); |
| | | 1850 | | hide(statsRow); |
| | | 1851 | | |
| | | 1852 | | // Бейдж поиска |
| | | 1853 | | searchModeQuery.textContent = `"${q}"`; |
| | | 1854 | | searchResultCount.textContent = '...'; |
| | | 1855 | | show(searchModeBadge); |
| | | 1856 | | |
| | | 1857 | | // Подготовка таблицы + строка прогресса |
| | | 1858 | | tbody.innerHTML = ''; |
| | | 1859 | | show(tableWrap); |
| | | 1860 | | const progressRow = document.createElement('tr'); |
| | | 1861 | | progressRow.id = 'search-progress-row'; |
| | | 1862 | | progressRow.innerHTML = `<td colspan="8" class="py-2"> |
| | | 1863 | | <i class="fas fa-circle-notch fa-spin text-primary me-2"></i> |
| | | 1864 | | <span id="search-progress-text" class="text-muted small">Проверяю 0 из ${targetChats.length} чатов...</s |
| | | 1865 | | <span id="search-found-count" class="ms-3 text-success fw-semibold small"></span> |
| | | 1866 | | </td>`; |
| | | 1867 | | tbody.appendChild(progressRow); |
| | | 1868 | | |
| | | 1869 | | const normalizedQ = q.toLowerCase(); |
| | | 1870 | | const escapedQ = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
| | | 1871 | | let checked = 0; |
| | | 1872 | | let found = 0; |
| | | 1873 | | const total = targetChats.length; |
| | | 1874 | | |
| | | 1875 | | // Вспомогательная функция: обработать один чат |
| | | 1876 | | async function processChat(c) { |
| | | 1877 | | const params = new URLSearchParams({ |
| | | 1878 | | clientId: c.clientId, |
| | | 1879 | | chatId: c.chatId, |
| | | 1880 | | limit: 100, |
| | | 1881 | | direction: 'Backward' |
| | | 1882 | | }); |
| | | 1883 | | try { |
| | 0 | 1884 | | const r = await fetch(`@Url.Action("GetHistoryJson")?${params}`); |
| | | 1885 | | const hdata = await r.json(); |
| | | 1886 | | |
| | | 1887 | | if (gen !== searchGen) return; // отменён |
| | | 1888 | | |
| | | 1889 | | checked++; |
| | | 1890 | | const progressText = document.getElementById('search-progress-text'); |
| | | 1891 | | if (progressText) progressText.textContent = `Проверяю ${checked} из ${total} чатов...`; |
| | | 1892 | | |
| | | 1893 | | if (hdata.success && hdata.messages && hdata.messages.length > 0) { |
| | | 1894 | | // Текст сообщения хранится в массиве data[] — объединяем для поиска |
| | | 1895 | | const getMsgText = m => (m.data || []).filter(Boolean).join(' '); |
| | | 1896 | | |
| | | 1897 | | // Заполняем кэш meta-данных этого чата (sku + orderNumber) |
| | | 1898 | | hdata.messages.forEach(m => { |
| | | 1899 | | const existing = chatMetaCache.get(c.chatId) || {}; |
| | | 1900 | | let changed = false; |
| | | 1901 | | if (m.sku && !existing.sku) { |
| | | 1902 | | existing.sku = m.sku; |
| | | 1903 | | changed = true; |
| | | 1904 | | } |
| | | 1905 | | if (m.orderNumber && !existing.orderNumber) { |
| | | 1906 | | existing.orderNumber = m.orderNumber; |
| | | 1907 | | changed = true; |
| | | 1908 | | } |
| | | 1909 | | if (changed) chatMetaCache.set(c.chatId, existing); |
| | | 1910 | | }); |
| | | 1911 | | |
| | | 1912 | | // Совпадение: текст, SKU или номер заказа |
| | | 1913 | | const matches = hdata.messages.filter(m => |
| | | 1914 | | getMsgText(m).toLowerCase().includes(normalizedQ) || |
| | | 1915 | | (m.sku || '').toLowerCase().includes(normalizedQ) || |
| | | 1916 | | (m.orderNumber || '').toLowerCase().includes(normalizedQ) |
| | | 1917 | | ); |
| | | 1918 | | if (!matches.length) return; |
| | | 1919 | | |
| | | 1920 | | found++; |
| | | 1921 | | const foundEl = document.getElementById('search-found-count'); |
| | | 1922 | | if (foundEl) foundEl.textContent = `Найдено: ${found}`; |
| | | 1923 | | searchResultCount.textContent = `${found} чат(ов)`; |
| | | 1924 | | |
| | | 1925 | | // Лучшее совпадение — первое найденное (самое раннее) |
| | | 1926 | | const bestMsg = matches[matches.length - 1]; |
| | | 1927 | | const msgPreview = getMsgText(bestMsg).substring(0, 200); |
| | | 1928 | | const highlighted = esc(msgPreview).replace(new RegExp(escapedQ, 'gi'), m => `<mark class="bg-wa |
| | | 1929 | | const isCustomer = (bestMsg.userType || '').toLowerCase().includes('customer'); |
| | | 1930 | | const senderClass = isCustomer ? 'customer' : 'seller'; |
| | | 1931 | | const senderLabel = isCustomer ? 'Покупатель' : 'Продавец'; |
| | | 1932 | | |
| | | 1933 | | // orderNumber из контекста первого сообщения |
| | | 1934 | | const ctxMsg = hdata.messages[hdata.messages.length - 1]; |
| | | 1935 | | const orderNum = (ctxMsg && ctxMsg.orderNumber) || c.postingNumber || ''; |
| | | 1936 | | const sku = (ctxMsg && ctxMsg.sku) || ''; |
| | | 1937 | | |
| | | 1938 | | const tr = document.createElement('tr'); |
| | | 1939 | | if (c.unreadCount > 0) tr.className = 'table-row-highlight'; |
| | | 1940 | | tr.style.cursor = 'pointer'; |
| | | 1941 | | tr.addEventListener('click', e => { |
| | | 1942 | | if (e.target.closest('button')) return; |
| | | 1943 | | viewChat(c.chatId, c.clientId); |
| | | 1944 | | }); |
| | | 1945 | | tr.innerHTML = ` |
| | | 1946 | | <td><span class="badge badge-closed">${esc(c.clientName || '')}</span></td> |
| | | 1947 | | <td class="last-msg-cell"> |
| | | 1948 | | <div class="msg-content"> |
| | | 1949 | | <span class="msg-sender ${senderClass}">${senderLabel}:</span> ${highlighted} |
| | | 1950 | | </div> |
| | | 1951 | | ${orderNum ? `<div class="mt-1"><small class="text-muted"><i class="fas fa-box me-1"></i |
| | | 1952 | | ${sku ? `<div><small class="text-muted"><i class="fas fa-barcode me-1"></i>SKU: ${esc(sk |
| | | 1953 | | <div><small class="text-muted"><i class="fas fa-comment me-1"></i>${matches.length} совп |
| | | 1954 | | </td> |
| | | 1955 | | <td><span class="${statusBadge(c.chatStatus)}">${statusLabel(c.chatStatus)}</span></td> |
| | | 1956 | | <td><span class="${typeBadge(c.chatType)}">${typeLabel(c.chatType)}</span></td> |
| | | 1957 | | <td><span title="${esc(c.createdAt)}">${esc(c.createdAtFormatted)}</span></td> |
| | | 1958 | | <td>${bestMsg.createdAtFormatted ? `<span>${esc(bestMsg.createdAtFormatted)}</span>` : '<spa |
| | | 1959 | | <td class="text-center">${c.unreadCount > 0 ? `<span class="badge badge-danger-soft">${c.unr |
| | | 1960 | | <td> |
| | | 1961 | | <button class="btn btn-sm btn-table-light me-1" onclick="viewChat('${esc(c.chatId)}', ${ |
| | | 1962 | | <button class="btn btn-sm btn-table-light" onclick="copyId('${esc(c.chatId)}')"><i class |
| | | 1963 | | </td>`; |
| | | 1964 | | |
| | | 1965 | | // Вставляем перед строкой прогресса |
| | | 1966 | | const pr = document.getElementById('search-progress-row'); |
| | | 1967 | | if (pr) pr.before(tr); else tbody.appendChild(tr); |
| | | 1968 | | } |
| | | 1969 | | } catch (e) { |
| | | 1970 | | if (gen !== searchGen) return; |
| | | 1971 | | checked++; |
| | | 1972 | | const progressText = document.getElementById('search-progress-text'); |
| | | 1973 | | if (progressText) progressText.textContent = `Проверяю ${checked} из ${total} чатов...`; |
| | | 1974 | | } |
| | | 1975 | | } |
| | | 1976 | | |
| | | 1977 | | // Запускаем пулом по 5 параллельных запросов |
| | | 1978 | | async function runPool() { |
| | | 1979 | | const CONCURRENCY = 5; |
| | | 1980 | | let idx = 0; |
| | | 1981 | | |
| | | 1982 | | async function next() { |
| | | 1983 | | while (idx < targetChats.length) { |
| | | 1984 | | if (gen !== searchGen) return; |
| | | 1985 | | const c = targetChats[idx++]; |
| | | 1986 | | await processChat(c); |
| | | 1987 | | } |
| | | 1988 | | } |
| | | 1989 | | |
| | | 1990 | | const workers = []; |
| | | 1991 | | for (let i = 0; i < CONCURRENCY; i++) workers.push(next()); |
| | | 1992 | | await Promise.all(workers); |
| | | 1993 | | |
| | | 1994 | | if (gen !== searchGen) return; |
| | | 1995 | | |
| | | 1996 | | // Завершение |
| | | 1997 | | const pr = document.getElementById('search-progress-row'); |
| | | 1998 | | if (pr) pr.remove(); |
| | | 1999 | | searchResultCount.textContent = `${found} чат(ов)`; |
| | | 2000 | | |
| | | 2001 | | if (found === 0) { |
| | | 2002 | | emptyBox.innerHTML = St2EmptyState.markup({ kind: 'search', title: `Ничего не найдено по запросу «${ |
| | | 2003 | | hide(tableWrap); |
| | | 2004 | | show(emptyBox); |
| | | 2005 | | } |
| | | 2006 | | } |
| | | 2007 | | |
| | | 2008 | | runPool(); |
| | | 2009 | | } |
| | | 2010 | | |
| | | 2011 | | function exitSearchMode() { |
| | | 2012 | | isSearchMode = false; |
| | | 2013 | | hide(searchModeBadge); |
| | | 2014 | | // Очищаем таблицу (она заполнится при следующей загрузке) |
| | | 2015 | | if (tbody) tbody.innerHTML = ''; |
| | | 2016 | | hide(tableWrap); |
| | | 2017 | | hide(chatsSection); |
| | | 2018 | | hide(statsRow); |
| | | 2019 | | } |
| | | 2020 | | |
| | | 2021 | | // === КОНЕЦ ПОИСКА === |
| | | 2022 | | |
| | | 2023 | | |
| | | 2024 | | if (selClient) { |
| | | 2025 | | selClient.addEventListener('change', () => { |
| | | 2026 | | currentPage = 1; |
| | | 2027 | | loadChats(); |
| | | 2028 | | }); |
| | | 2029 | | } |
| | | 2030 | | |
| | | 2031 | | // === Загрузка по кнопке === |
| | | 2032 | | if (btnLoad) { |
| | | 2033 | | btnLoad.addEventListener('click', () => { |
| | | 2034 | | currentPage = 1; |
| | | 2035 | | loadChats(); |
| | | 2036 | | }); |
| | | 2037 | | } |
| | | 2038 | | |
| | | 2039 | | // === Авто-перезагрузка при смене фильтров === |
| | | 2040 | | [selStatus, selType, selPageSize].forEach(el => { |
| | | 2041 | | if (el) el.addEventListener('change', () => { |
| | | 2042 | | currentPage = 1; |
| | | 2043 | | loadChats(); |
| | | 2044 | | }); |
| | | 2045 | | }); |
| | | 2046 | | if (chkUnread) chkUnread.addEventListener('change', () => { |
| | | 2047 | | currentPage = 1; |
| | | 2048 | | loadChats(); |
| | | 2049 | | }); |
| | | 2050 | | |
| | | 2051 | | // === Экспорт === |
| | | 2052 | | if (btnExport) { |
| | | 2053 | | btnExport.addEventListener('click', () => { |
| | | 2054 | | document.getElementById('exp-clientId').value = selClient.value; |
| | | 2055 | | document.getElementById('exp-status').value = selStatus.value; |
| | | 2056 | | document.getElementById('exp-unread').value = chkUnread.checked; |
| | | 2057 | | document.getElementById('export-form').submit(); |
| | | 2058 | | }); |
| | | 2059 | | } |
| | | 2060 | | |
| | | 2061 | | // === Загрузка чатов === |
| | | 2062 | | function loadChats() { |
| | | 2063 | | if (!selClient || !selStatus || !selType || !chkUnread || !selPageSize) return; |
| | | 2064 | | |
| | | 2065 | | // Выходим из режима поиска при обычной загрузке |
| | | 2066 | | if (isSearchMode) { |
| | | 2067 | | isSearchMode = false; |
| | | 2068 | | hide(searchModeBadge); |
| | | 2069 | | } |
| | | 2070 | | |
| | | 2071 | | const clientId = selClient.value; |
| | | 2072 | | |
| | | 2073 | | show(chatsSection); |
| | | 2074 | | if (window.SkeletonLoader) { |
| | | 2075 | | SkeletonLoader.table(tbody, { columns: 8, rows: 7 }); |
| | | 2076 | | hide(spinner); |
| | | 2077 | | } else show(spinner); |
| | | 2078 | | hide(errorBox); |
| | | 2079 | | hide(emptyBox); |
| | | 2080 | | hide(tableWrap); |
| | | 2081 | | hide(pagWrap); |
| | | 2082 | | if (window.SkeletonLoader) show(tableWrap); |
| | | 2083 | | |
| | | 2084 | | |
| | | 2085 | | const sortField = document.getElementById('sel-sort-field')?.value || 'createdAt'; |
| | | 2086 | | const sortOrder = document.getElementById('sel-sort-order')?.value || 'desc'; |
| | | 2087 | | const params = new URLSearchParams({ |
| | | 2088 | | clientId, |
| | | 2089 | | status: selStatus.value, |
| | | 2090 | | chatType: selType.value, |
| | | 2091 | | unreadOnly: chkUnread.checked, |
| | | 2092 | | page: currentPage, |
| | | 2093 | | pageSize: selPageSize.value, |
| | | 2094 | | sortField, |
| | | 2095 | | sortOrder |
| | | 2096 | | }); |
| | | 2097 | | // Сортировка — обработчики |
| | | 2098 | | const selSortField = document.getElementById('sel-sort-field'); |
| | | 2099 | | const selSortOrder = document.getElementById('sel-sort-order'); |
| | | 2100 | | if (selSortField) selSortField.addEventListener('change', () => { |
| | | 2101 | | currentPage = 1; |
| | | 2102 | | loadChats(); |
| | | 2103 | | }); |
| | | 2104 | | if (selSortOrder) selSortOrder.addEventListener('change', () => { |
| | | 2105 | | currentPage = 1; |
| | | 2106 | | loadChats(); |
| | | 2107 | | }); |
| | | 2108 | | |
| | 0 | 2109 | | fetch(`@Url.Action("GetChatsJson")?${params}`) |
| | | 2110 | | .then(r => r.json()) |
| | | 2111 | | .then(data => { |
| | | 2112 | | hide(spinner); |
| | | 2113 | | if (window.SkeletonLoader) SkeletonLoader.finish(tbody); |
| | | 2114 | | if (!data.success) { |
| | | 2115 | | hide(tableWrap); |
| | | 2116 | | errorBox.textContent = data.message || 'Неизвестная ошибка'; |
| | | 2117 | | show(errorBox); |
| | | 2118 | | return; |
| | | 2119 | | } |
| | | 2120 | | |
| | | 2121 | | // Бейджи в шапке |
| | | 2122 | | @* if (data.totalUnreadCount > 0) { *@ |
| | | 2123 | | @* unreadText.textContent = data.totalUnreadCount; *@ |
| | | 2124 | | @* show(badgeUnread); *@ |
| | | 2125 | | @* } else { *@ |
| | | 2126 | | @* hide(badgeUnread); *@ |
| | | 2127 | | @* } *@ |
| | | 2128 | | totalText.textContent = data.totalChatsCount; |
| | | 2129 | | show(badgeTotal); |
| | | 2130 | | |
| | | 2131 | | // Статистика |
| | | 2132 | | statTotal.textContent = data.totalChatsCount; |
| | | 2133 | | @* statUnread.textContent = data.totalUnreadCount; *@ |
| | | 2134 | | statPage.textContent = data.chats.length; |
| | | 2135 | | show(statsRow); |
| | | 2136 | | |
| | | 2137 | | // Обновляем бейдж непрочитанных в навигации |
| | | 2138 | | const navBadge = document.getElementById('ozon-chat-unread-badge'); |
| | | 2139 | | if (navBadge) { |
| | | 2140 | | if (data.totalUnreadCount > 0) { |
| | | 2141 | | navBadge.textContent = data.totalUnreadCount; |
| | | 2142 | | navBadge.style.display = 'inline-block'; |
| | | 2143 | | } else { |
| | | 2144 | | navBadge.style.display = 'none'; |
| | | 2145 | | } |
| | | 2146 | | } |
| | | 2147 | | |
| | | 2148 | | // Кнопка экспорта |
| | | 2149 | | btnExport.classList.remove('d-none'); |
| | | 2150 | | |
| | | 2151 | | if (!data.chats || data.chats.length === 0) { |
| | | 2152 | | show(emptyBox); |
| | | 2153 | | hide(pagWrap); |
| | | 2154 | | return; |
| | | 2155 | | } |
| | | 2156 | | |
| | | 2157 | | // Рендер таблицы |
| | | 2158 | | tbody.innerHTML = ''; |
| | | 2159 | | data.chats.forEach(c => { |
| | | 2160 | | const tr = document.createElement('tr'); |
| | | 2161 | | if (c.unreadCount > 0) tr.className = 'table-row-highlight'; |
| | | 2162 | | tr.style.cursor = 'pointer'; |
| | | 2163 | | tr.addEventListener('click', e => { |
| | | 2164 | | if (e.target.closest('button')) return; |
| | | 2165 | | viewChat(c.chatId, c.clientId); |
| | | 2166 | | }); |
| | | 2167 | | tr.innerHTML = ` |
| | | 2168 | | <td><span class="badge badge-closed">${esc(c.clientName || '')}</span></td> |
| | | 2169 | | <td class="last-msg-cell" data-chat-id="${esc(c.chatId)}" data-client-id="${c.clientId}"> |
| | | 2170 | | <div class="loading-dots"> |
| | | 2171 | | <span class="dot">.</span><span class="dot">.</span><span class="dot">.</span> |
| | | 2172 | | </div> |
| | | 2173 | | </td> |
| | | 2174 | | <td><span class="${statusBadge(c.chatStatus)}">${statusLabel(c.chatStatus)}</span></td> |
| | | 2175 | | <td><span class="${typeBadge(c.chatType)}">${typeLabel(c.chatType)}</span></td> |
| | | 2176 | | <td><span title="${esc(c.createdAt)}">${esc(c.createdAtFormatted)}</span></td> |
| | | 2177 | | <td class="last-msg-time-cell" data-chat-id="${esc(c.chatId)}"><span class="text-muted">--</span |
| | | 2178 | | <td class="text-center">${c.unreadCount > 0 ? `<span class="badge badge-danger-soft">${c.unreadC |
| | | 2179 | | <td> |
| | | 2180 | | <button class="btn btn-sm btn-table-light me-1" onclick="viewChat('${esc(c.chatId)}', ${c.cl |
| | | 2181 | | <button class="btn btn-sm btn-table-light" onclick="copyId('${esc(c.chatId)}')"><i class="fa |
| | | 2182 | | </td>`; |
| | | 2183 | | tbody.appendChild(tr); |
| | | 2184 | | }); |
| | | 2185 | | loadedChats = data.chats.slice(); // сохраняем для поиска |
| | | 2186 | | chatMetaCache.clear(); // сбрасываем кэш при новой загрузке |
| | | 2187 | | preScanRunning = false; |
| | | 2188 | | startPreScan(); // фоновое заполнение кэша SKU/orderNumber |
| | | 2189 | | show(tableWrap); |
| | | 2190 | | |
| | | 2191 | | // Загружаем последние сообщения для каждого чата |
| | | 2192 | | loadLastMessages(); |
| | | 2193 | | |
| | | 2194 | | // Пагинация |
| | | 2195 | | renderPagination(data.currentPage, data.totalPages, data.totalChatsCount); |
| | | 2196 | | }) |
| | | 2197 | | .catch(err => { |
| | | 2198 | | hide(spinner); |
| | | 2199 | | if (window.SkeletonLoader) SkeletonLoader.finish(tbody); |
| | | 2200 | | hide(tableWrap); |
| | | 2201 | | errorBox.textContent = `Ошибка сети: ${err.message}`; |
| | | 2202 | | show(errorBox); |
| | | 2203 | | }); |
| | | 2204 | | } |
| | | 2205 | | |
| | | 2206 | | // === Рендер пагинации === |
| | | 2207 | | function renderPagination(page, totalPages, totalChats) { |
| | | 2208 | | if (totalPages <= 1) { |
| | | 2209 | | hide(pagWrap); |
| | | 2210 | | return; |
| | | 2211 | | } |
| | | 2212 | | show(pagWrap); |
| | | 2213 | | pageInfo.textContent = `Страница ${page} из ${totalPages} (всего чатов: ${totalChats})`; |
| | | 2214 | | |
| | | 2215 | | pagList.innerHTML = ''; |
| | | 2216 | | |
| | | 2217 | | addPageItem('«', 1, page === 1); |
| | | 2218 | | addPageItem('‹', page - 1, page === 1); |
| | | 2219 | | |
| | | 2220 | | let start = Math.max(1, page - 3); |
| | | 2221 | | let end = Math.min(totalPages, page + 3); |
| | | 2222 | | |
| | | 2223 | | if (start > 1) { |
| | | 2224 | | addPageItem('1', 1); |
| | | 2225 | | if (start > 2) addPageItem('…', null, true); |
| | | 2226 | | } |
| | | 2227 | | for (let i = start; i <= end; i++) addPageItem(String(i), i, false, i === page); |
| | | 2228 | | if (end < totalPages) { |
| | | 2229 | | if (end < totalPages - 1) addPageItem('…', null, true); |
| | | 2230 | | addPageItem(String(totalPages), totalPages); |
| | | 2231 | | } |
| | | 2232 | | |
| | | 2233 | | addPageItem('›', page + 1, page >= totalPages); |
| | | 2234 | | addPageItem('»', totalPages, page >= totalPages); |
| | | 2235 | | } |
| | | 2236 | | |
| | | 2237 | | function addPageItem(label, pageNum, disabled, active) { |
| | | 2238 | | const li = document.createElement('li'); |
| | | 2239 | | li.className = `page-item${disabled ? ' disabled' : ''}${active ? ' active' : ''}`; |
| | | 2240 | | const a = document.createElement('a'); |
| | | 2241 | | a.className = 'page-link'; |
| | | 2242 | | a.href = '#'; |
| | | 2243 | | a.textContent = label; |
| | | 2244 | | if (!disabled && pageNum != null && !active) { |
| | | 2245 | | a.addEventListener('click', e => { |
| | | 2246 | | e.preventDefault(); |
| | | 2247 | | currentPage = pageNum; |
| | | 2248 | | loadChats(); |
| | | 2249 | | }); |
| | | 2250 | | } |
| | | 2251 | | li.appendChild(a); |
| | | 2252 | | pagList.appendChild(li); |
| | | 2253 | | } |
| | | 2254 | | |
| | | 2255 | | // === Хелперы статусов/типов === |
| | | 2256 | | function statusLabel(s) { |
| | | 2257 | | switch ((s || '').toUpperCase()) { |
| | | 2258 | | case 'OPENED': |
| | | 2259 | | return 'Открыт'; |
| | | 2260 | | case 'CLOSED': |
| | | 2261 | | return 'Закрыт'; |
| | | 2262 | | case 'UNSPECIFIED': |
| | | 2263 | | return 'Не определён'; |
| | | 2264 | | default: |
| | | 2265 | | return s; |
| | | 2266 | | } |
| | | 2267 | | } |
| | | 2268 | | |
| | | 2269 | | function statusBadge(s) { |
| | | 2270 | | switch ((s || '').toUpperCase()) { |
| | | 2271 | | case 'OPENED': |
| | | 2272 | | return 'badge badge-open'; |
| | | 2273 | | case 'CLOSED': |
| | | 2274 | | return 'badge badge-closed'; |
| | | 2275 | | default: |
| | | 2276 | | return 'badge badge-other'; |
| | | 2277 | | } |
| | | 2278 | | } |
| | | 2279 | | |
| | | 2280 | | function typeLabel(t) { |
| | | 2281 | | switch ((t || '').toUpperCase()) { |
| | | 2282 | | case 'SELLER_SUPPORT': |
| | | 2283 | | return 'Чат с поддержкой'; |
| | | 2284 | | case 'BUYER_SELLER': |
| | | 2285 | | return 'Чат с покупателем'; |
| | | 2286 | | case 'UNSPECIFIED': |
| | | 2287 | | return 'Не определён'; |
| | | 2288 | | default: |
| | | 2289 | | return t; |
| | | 2290 | | } |
| | | 2291 | | } |
| | | 2292 | | |
| | | 2293 | | function typeBadge(t) { |
| | | 2294 | | switch ((t || '').toUpperCase()) { |
| | | 2295 | | case 'SELLER_SUPPORT': |
| | | 2296 | | return 'badge badge-seller'; |
| | | 2297 | | case 'BUYER_SELLER': |
| | | 2298 | | return 'badge badge-buyer'; |
| | | 2299 | | default: |
| | | 2300 | | return 'badge badge-other'; |
| | | 2301 | | } |
| | | 2302 | | } |
| | | 2303 | | |
| | | 2304 | | // === Утилиты === |
| | | 2305 | | function esc(s) { |
| | | 2306 | | if (s === null || s === undefined) return ''; |
| | | 2307 | | const d = document.createElement('div'); |
| | | 2308 | | d.textContent = s; |
| | | 2309 | | return d.innerHTML; |
| | | 2310 | | } |
| | | 2311 | | |
| | | 2312 | | function escapeHtml(s) { |
| | | 2313 | | return esc(s); |
| | | 2314 | | } |
| | | 2315 | | |
| | | 2316 | | // === Прокси URL для файлов Ozon (требуют авторизации) === |
| | | 2317 | | function getProxyUrl(originalUrl, clientId) { |
| | | 2318 | | if (!originalUrl) return ''; |
| | | 2319 | | if (originalUrl.includes('api-seller.ozon.ru')) { |
| | 0 | 2320 | | return `@Url.Action("GetChatFile")?clientId=${encodeURIComponent(clientId)}&fileUrl=${encodeURIComponent |
| | | 2321 | | } |
| | | 2322 | | return originalUrl; |
| | | 2323 | | } |
| | | 2324 | | |
| | | 2325 | | // === Функция для отображения "сколько времени назад" === |
| | | 2326 | | function timeAgo(dateString) { |
| | | 2327 | | if (!dateString) return ''; |
| | | 2328 | | const date = new Date(dateString); |
| | | 2329 | | const now = new Date(); |
| | | 2330 | | const diffMs = now - date; |
| | | 2331 | | const diffSec = Math.floor(diffMs / 1000); |
| | | 2332 | | const diffMin = Math.floor(diffSec / 60); |
| | | 2333 | | const diffHour = Math.floor(diffMin / 60); |
| | | 2334 | | const diffDay = Math.floor(diffHour / 24); |
| | | 2335 | | |
| | | 2336 | | if (diffSec < 60) return 'только что'; |
| | | 2337 | | if (diffMin < 60) { |
| | | 2338 | | return `${diffMin} мин назад`; |
| | | 2339 | | } |
| | | 2340 | | if (diffHour < 24) { |
| | | 2341 | | return `${diffHour} ч назад`; |
| | | 2342 | | } |
| | | 2343 | | if (diffDay < 30) { |
| | | 2344 | | return `${diffDay} дн назад`; |
| | | 2345 | | } |
| | | 2346 | | return 'более месяца назад'; |
| | | 2347 | | } |
| | | 2348 | | |
| | | 2349 | | function renderMarkdown(text, clientId) { |
| | | 2350 | | let html = esc(text); |
| | | 2351 | | html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); |
| | | 2352 | | // Ссылки |
| | | 2353 | | html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</ |
| | | 2354 | | // Ozon картинки через прокси с лоадером и увеличением |
| | | 2355 | | html = html.replace(/!\[[^\]]*\]\((https:\/\/api-seller\.ozon\.ru[^)]+)\)/gi, function (match, url) { |
| | | 2356 | | if (clientId) { |
| | | 2357 | | const proxied = getProxyUrl(url, clientId); |
| | | 2358 | | const loader = '<div class="img-loader" style="display:inline-block;width:48px;height:48px;vertical- |
| | | 2359 | | return `<span class="img-wrapper" style="display:inline-block;position:relative;max-width:100%;max-h |
| | | 2360 | | } |
| | | 2361 | | return match; |
| | | 2362 | | }); |
| | | 2363 | | // Обычные картинки (не Ozon) с лоадером и увеличением |
| | | 2364 | | html = html.replace(/!\[[^\]]*\]\(([^)]+)\)/g, function (match, url) { |
| | | 2365 | | const loader = '<div class="img-loader" style="display:inline-block;width:48px;height:48px;vertical-alig |
| | | 2366 | | return `<span class="img-wrapper" style="display:inline-block;position:relative;max-width:100%;max-heigh |
| | | 2367 | | }); |
| | | 2368 | | const lines = html.split('\n'); |
| | | 2369 | | let result = []; |
| | | 2370 | | let inList = false; |
| | | 2371 | | for (let i = 0; i < lines.length; i++) { |
| | | 2372 | | const line = lines[i]; |
| | | 2373 | | const listMatch = line.match(/^\s*(?:\*|•)\s+(.+)/); |
| | | 2374 | | if (listMatch) { |
| | | 2375 | | if (!inList) { |
| | | 2376 | | result.push('<ul class="mb-1 ps-3">'); |
| | | 2377 | | inList = true; |
| | | 2378 | | } |
| | | 2379 | | result.push('<li>' + listMatch[1] + '</li>'); |
| | | 2380 | | } else { |
| | | 2381 | | if (inList) { |
| | | 2382 | | result.push('</ul>'); |
| | | 2383 | | inList = false; |
| | | 2384 | | } |
| | | 2385 | | result.push(line); |
| | | 2386 | | } |
| | | 2387 | | } |
| | | 2388 | | if (inList) result.push('</ul>'); |
| | | 2389 | | html = result.join('\n'); |
| | | 2390 | | html = html.replace(/\n(?!<\/?[uo]l|<li)/g, '<br>'); |
| | | 2391 | | return html; |
| | | 2392 | | } |
| | | 2393 | | |
| | | 2394 | | function show(el) { |
| | | 2395 | | if (el) el.classList.remove('d-none'); |
| | | 2396 | | } |
| | | 2397 | | |
| | | 2398 | | function hide(el) { |
| | | 2399 | | if (el) el.classList.add('d-none'); |
| | | 2400 | | } |
| | | 2401 | | |
| | | 2402 | | // === Загрузка последних сообщений для ячеек таблицы === |
| | | 2403 | | function loadLastMessages() { |
| | | 2404 | | const cells = document.querySelectorAll('.last-msg-cell'); |
| | | 2405 | | cells.forEach(cell => { |
| | | 2406 | | const chatId = cell.dataset.chatId; |
| | | 2407 | | const clientId = cell.dataset.clientId; |
| | | 2408 | | if (!chatId || !clientId) return; |
| | | 2409 | | |
| | 0 | 2410 | | fetch(`@Url.Action("GetLastMessageJson")?clientId=${clientId}&chatId=${encodeURIComponent(chatId)}`) |
| | | 2411 | | .then(r => r.json()) |
| | | 2412 | | .then(data => { |
| | | 2413 | | const timeCell = document.querySelector(`.last-msg-time-cell[data-chat-id="${chatId}"]`); |
| | | 2414 | | |
| | | 2415 | | if (data.success && data.hasMessage) { |
| | | 2416 | | const senderClass = (data.senderType || 'unknown').toLowerCase(); |
| | | 2417 | | cell.innerHTML = ` |
| | | 2418 | | <div class="msg-content"> |
| | | 2419 | | <span class="msg-sender ${esc(senderClass)}">${esc(data.sender)}:</span> |
| | | 2420 | | ${esc(data.text)} |
| | | 2421 | | </div>`; |
| | | 2422 | | |
| | | 2423 | | if (timeCell && data.createdAt) { |
| | | 2424 | | const ago = timeAgo(data.createdAt); |
| | | 2425 | | timeCell.innerHTML = `<span title="${esc(data.createdAtFormatted)}">${esc(data.createdAt |
| | | 2426 | | } |
| | | 2427 | | } else if (data.success && !data.hasMessage) { |
| | | 2428 | | const ozonUrl = `https://seller.ozon.ru/app/messenger/?group=customers_v2&id=${encodeURIComp |
| | | 2429 | | cell.innerHTML = `<span class="text-muted fst-italic">Обработать на <a href="${ozonUrl}" tar |
| | | 2430 | | if (timeCell) timeCell.innerHTML = '<span class="text-muted">—</span>'; |
| | | 2431 | | } else { |
| | | 2432 | | cell.innerHTML = `<span class="text-danger small">Ошибка загрузки</span>`; |
| | | 2433 | | if (timeCell) timeCell.innerHTML = '<span class="text-muted">—</span>'; |
| | | 2434 | | } |
| | | 2435 | | }) |
| | | 2436 | | .catch(() => { |
| | | 2437 | | cell.innerHTML = '<span class="text-danger small">Ошибка сети</span>'; |
| | | 2438 | | const timeCell = document.querySelector(`.last-msg-time-cell[data-chat-id="${cell.dataset.chatId |
| | | 2439 | | if (timeCell) timeCell.innerHTML = '<span class="text-muted">—</span>'; |
| | | 2440 | | }); |
| | | 2441 | | }); |
| | | 2442 | | } |
| | | 2443 | | |
| | | 2444 | | |
| | | 2445 | | // === Глобальные функции === |
| | | 2446 | | let histChatId = ''; |
| | | 2447 | | let histClientId = null; |
| | | 2448 | | let histOldestMessageId = null; |
| | | 2449 | | let histNewestMessageId = null; |
| | | 2450 | | let histHasNext = false; |
| | | 2451 | | let histHasUnread = false; |
| | | 2452 | | |
| | | 2453 | | window.viewChat = function (chatId, clientId) { |
| | | 2454 | | histChatId = chatId; |
| | | 2455 | | histClientId = clientId || selClient.value; |
| | | 2456 | | histOldestMessageId = null; |
| | | 2457 | | histNewestMessageId = null; |
| | | 2458 | | histHasNext = false; |
| | | 2459 | | histHasUnread = false; |
| | | 2460 | | |
| | | 2461 | | const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('chatHistoryModal')); |
| | | 2462 | | document.getElementById('hist-chat-id').textContent = chatId; |
| | | 2463 | | document.getElementById('hist-chat-meta').textContent = ''; |
| | | 2464 | | document.getElementById('hist-messages').innerHTML = ''; |
| | | 2465 | | document.getElementById('hist-spinner').classList.remove('d-none'); |
| | | 2466 | | document.getElementById('hist-error').classList.add('d-none'); |
| | | 2467 | | document.getElementById('hist-load-older').classList.add('d-none'); |
| | | 2468 | | document.getElementById('btn-mark-read').classList.add('d-none'); |
| | | 2469 | | document.getElementById('message-text').value = ''; |
| | | 2470 | | document.getElementById('char-count').textContent = '0'; |
| | | 2471 | | document.getElementById('file-input').value = ''; |
| | | 2472 | | clearAttachedFile(); |
| | | 2473 | | |
| | | 2474 | | // очистка полей трекинга |
| | | 2475 | | const hoursEl = document.getElementById('resp-time-hours'); |
| | | 2476 | | const minutesEl = document.getElementById('resp-time-minutes'); |
| | | 2477 | | if (hoursEl) hoursEl.value = ''; |
| | | 2478 | | if (minutesEl) minutesEl.value = ''; |
| | | 2479 | | modal.show(); |
| | | 2480 | | |
| | | 2481 | | // загрузить предыдущие записи времени |
| | | 2482 | | loadResponseTimes(); |
| | | 2483 | | |
| | | 2484 | | loadHistory(chatId, null); |
| | | 2485 | | }; |
| | | 2486 | | |
| | | 2487 | | |
| | | 2488 | | let newMsgDividerInserted = false; // флаг, чтобы вставить только один раз разделить новых сообщений |
| | | 2489 | | |
| | | 2490 | | function loadHistory(chatId, fromMessageId) { |
| | | 2491 | | |
| | | 2492 | | // сброс флага только если это initial load |
| | | 2493 | | if (!fromMessageId) { |
| | | 2494 | | newMsgDividerInserted = false; |
| | | 2495 | | } |
| | | 2496 | | |
| | | 2497 | | const clientId = histClientId || selClient.value; |
| | | 2498 | | if (!clientId) return; |
| | | 2499 | | |
| | | 2500 | | const historyContainer = document.getElementById('hist-messages'); |
| | | 2501 | | if (!fromMessageId && window.SkeletonLoader) { |
| | | 2502 | | SkeletonLoader.lines(historyContainer, { count: 7 }); |
| | | 2503 | | document.getElementById('hist-spinner').classList.add('d-none'); |
| | | 2504 | | } |
| | | 2505 | | |
| | | 2506 | | const params = new URLSearchParams({clientId, chatId, limit: 100, direction: 'Backward'}); |
| | | 2507 | | if (fromMessageId) params.set('fromMessageId', fromMessageId); |
| | | 2508 | | |
| | 0 | 2509 | | fetch(`@Url.Action("GetHistoryJson")?${params}`) |
| | | 2510 | | .then(r => r.json()) |
| | | 2511 | | .then(data => { |
| | | 2512 | | document.getElementById('hist-spinner').classList.add('d-none'); |
| | | 2513 | | if (!fromMessageId && window.SkeletonLoader) SkeletonLoader.finish(historyContainer); |
| | | 2514 | | |
| | | 2515 | | if (!data.success) { |
| | | 2516 | | const errEl = document.getElementById('hist-error'); |
| | | 2517 | | // Если сообщение содержит HTML (например, ссылка), используем innerHTML, иначе textContent |
| | | 2518 | | if (data.premiumRequired && data.message && data.message.includes('<a')) { |
| | | 2519 | | errEl.innerHTML = data.message; |
| | | 2520 | | } else { |
| | | 2521 | | errEl.textContent = data.message || 'Ошибка загрузки'; |
| | | 2522 | | } |
| | | 2523 | | errEl.classList.remove('d-none'); |
| | | 2524 | | return; |
| | | 2525 | | } |
| | | 2526 | | |
| | | 2527 | | histHasNext = data.hasNext; |
| | | 2528 | | |
| | | 2529 | | if (data.messages && data.messages.length > 0) { |
| | | 2530 | | const reversed = [...data.messages].reverse(); |
| | | 2531 | | const container = document.getElementById('hist-messages'); |
| | | 2532 | | const isInitialLoad = !fromMessageId; |
| | | 2533 | | const frag = document.createDocumentFragment(); |
| | | 2534 | | |
| | | 2535 | | if (isInitialLoad) { |
| | | 2536 | | container.innerHTML = ''; // Очищаем контейнер сообщений при первой загрузке |
| | | 2537 | | } |
| | | 2538 | | |
| | | 2539 | | reversed.forEach(m => { |
| | | 2540 | | frag.appendChild(createMessageBubble(m)); |
| | | 2541 | | if (!histOldestMessageId || m.messageId < histOldestMessageId) { |
| | | 2542 | | histOldestMessageId = m.messageId; |
| | | 2543 | | } |
| | | 2544 | | if (!histNewestMessageId || m.messageId > histNewestMessageId) { |
| | | 2545 | | histNewestMessageId = m.messageId; |
| | | 2546 | | } |
| | | 2547 | | if (!m.isRead) histHasUnread = true; |
| | | 2548 | | }); |
| | | 2549 | | |
| | | 2550 | | if (isInitialLoad) { |
| | | 2551 | | container.appendChild(frag); |
| | | 2552 | | container.scrollTop = container.scrollHeight; |
| | | 2553 | | } |
| | | 2554 | | |
| | | 2555 | | |
| | | 2556 | | const first = data.messages[data.messages.length - 1]; |
| | | 2557 | | if (first.orderNumber || first.sku) { |
| | | 2558 | | let meta = []; |
| | | 2559 | | if (first.orderNumber) meta.push(`Заказ: ${first.orderNumber}`); |
| | | 2560 | | if (first.sku) meta.push(`SKU: ${first.sku}`); |
| | | 2561 | | document.getElementById('hist-chat-meta').textContent = meta.join(' | '); |
| | | 2562 | | } |
| | | 2563 | | } |
| | | 2564 | | |
| | | 2565 | | if (histHasNext) { |
| | | 2566 | | document.getElementById('hist-load-older').classList.remove('d-none'); |
| | | 2567 | | } else { |
| | | 2568 | | document.getElementById('hist-load-older').classList.add('d-none'); |
| | | 2569 | | } |
| | | 2570 | | |
| | | 2571 | | if (histHasUnread) { |
| | | 2572 | | document.getElementById('btn-mark-read').classList.remove('d-none'); |
| | | 2573 | | } |
| | | 2574 | | |
| | | 2575 | | if (!data.messages || data.messages.length === 0) { |
| | | 2576 | | if (!fromMessageId) { |
| | | 2577 | | const histMessages = document.getElementById('hist-messages'); |
| | | 2578 | | const chatId = histChatId || (typeof data.ozonUrl === 'string' && data.ozonUrl.split('id=')[ |
| | | 2579 | | const ozonUrl = `https://seller.ozon.ru/app/messenger/?group=customers_v2&id=${encodeURIComp |
| | | 2580 | | histMessages.innerHTML = `<div class="text-center text-muted py-5"><i class="fas fa-lock fa- |
| | | 2581 | | } |
| | | 2582 | | } |
| | | 2583 | | }) |
| | | 2584 | | .catch(err => { |
| | | 2585 | | document.getElementById('hist-spinner').classList.add('d-none'); |
| | | 2586 | | if (!fromMessageId && window.SkeletonLoader) SkeletonLoader.finish(historyContainer); |
| | | 2587 | | const errEl = document.getElementById('hist-error'); |
| | | 2588 | | errEl.textContent = `Ошибка сети: ${err.message}`; |
| | | 2589 | | errEl.classList.remove('d-none'); |
| | | 2590 | | }); |
| | | 2591 | | } |
| | | 2592 | | |
| | | 2593 | | window.loadOlderMessages = function () { |
| | | 2594 | | if (!histHasNext || !histOldestMessageId) return; |
| | | 2595 | | document.getElementById('hist-load-older').classList.add('d-none'); |
| | | 2596 | | loadHistory(histChatId, histOldestMessageId); |
| | | 2597 | | }; |
| | | 2598 | | |
| | | 2599 | | function createMessageBubble(m) { |
| | | 2600 | | const isSeller = (m.userType || '').toLowerCase().includes('seller'); |
| | | 2601 | | const isSystem = (m.userType || '').toLowerCase().includes('system'); |
| | | 2602 | | |
| | | 2603 | | const wrapper = document.createElement('div'); |
| | | 2604 | | wrapper.className = `d-flex flex-column mb-3 ${isSeller ? 'justify-content-end' : 'justify-content-start'}` |
| | | 2605 | | |
| | | 2606 | | const bubble = document.createElement('div'); |
| | | 2607 | | |
| | | 2608 | | bubble.className = `chat-bubble ${isSeller ? 'chat-seller' : isSystem ? 'chat-system' : 'chat-customer'}`; |
| | | 2609 | | |
| | | 2610 | | const header = document.createElement('div'); |
| | | 2611 | | header.className = `d-flex justify-content-between align-items-center mb-2 ${isSeller ? 'flex-row-reverse' : |
| | | 2612 | | const userBadge = document.createElement('span'); |
| | | 2613 | | userBadge.className = `badge ${isSeller ? 'chat-badge-seller' : isSystem ? 'chat-badge-system' : 'chat-badge |
| | | 2614 | | userBadge.textContent = userTypeLabel(m.userType); |
| | | 2615 | | header.appendChild(userBadge); |
| | | 2616 | | |
| | | 2617 | | |
| | | 2618 | | if (m.orderNumber || m.sku) { |
| | | 2619 | | const ctx = document.createElement('div'); |
| | | 2620 | | ctx.className = `small text-muted ${isSeller ? 'me-4' : 'ms-4'}`; |
| | | 2621 | | let parts = []; |
| | | 2622 | | if (m.orderNumber) parts.push(`<i class="fas fa-box me-1"></i>Заказ: ${esc(m.orderNumber)}`); |
| | | 2623 | | if (m.sku) parts.push(`<i class="fas fa-barcode me-1"></i>SKU: ${esc(m.sku)}`); |
| | | 2624 | | ctx.innerHTML = parts.join(' | '); |
| | | 2625 | | header.appendChild(ctx); |
| | | 2626 | | } |
| | | 2627 | | |
| | | 2628 | | if (!m.isRead && !newMsgDividerInserted) { |
| | | 2629 | | const divider = document.createElement('div'); |
| | | 2630 | | divider.className = 'new-message-divider text-secondary'; |
| | | 2631 | | divider.textContent = 'Новые сообщения'; |
| | | 2632 | | wrapper.appendChild(divider); |
| | | 2633 | | newMsgDividerInserted = true; |
| | | 2634 | | } |
| | | 2635 | | |
| | | 2636 | | bubble.appendChild(header); |
| | | 2637 | | |
| | | 2638 | | if (m.data && m.data.length > 0) { |
| | | 2639 | | m.data.forEach(text => { |
| | | 2640 | | const p = document.createElement('div'); |
| | | 2641 | | p.className = 'mb-1 chat-message-content'; |
| | | 2642 | | p.innerHTML = renderMarkdown(text, m.clientId || histClientId); |
| | | 2643 | | bubble.appendChild(p); |
| | | 2644 | | }); |
| | | 2645 | | } |
| | | 2646 | | |
| | | 2647 | | const time = document.createElement('div'); |
| | | 2648 | | time.className = 'text-muted small text-end'; |
| | | 2649 | | time.textContent = m.createdAtFormatted; |
| | | 2650 | | |
| | | 2651 | | bubble.appendChild(time); |
| | | 2652 | | |
| | | 2653 | | wrapper.appendChild(bubble); |
| | | 2654 | | return wrapper; |
| | | 2655 | | } |
| | | 2656 | | |
| | | 2657 | | function userTypeLabel(type) { |
| | | 2658 | | switch ((type || '').toLowerCase()) { |
| | | 2659 | | case 'seller': |
| | | 2660 | | return 'Продавец'; |
| | | 2661 | | case 'customer': |
| | | 2662 | | return 'Покупатель'; |
| | | 2663 | | case 'system': |
| | | 2664 | | return 'Система'; |
| | | 2665 | | case 'support': |
| | | 2666 | | return 'Поддержка'; |
| | | 2667 | | default: |
| | | 2668 | | return type || 'Неизвестный'; |
| | | 2669 | | } |
| | | 2670 | | } |
| | | 2671 | | |
| | | 2672 | | window.copyId = function (chatId) { |
| | | 2673 | | navigator.clipboard.writeText(chatId).then(() => { |
| | | 2674 | | showToastMessage('ID скопирован', true); |
| | | 2675 | | }); |
| | | 2676 | | }; |
| | | 2677 | | |
| | | 2678 | | function showToastMessage(message, isSuccess = true) { |
| | | 2679 | | const toast = document.createElement('div'); |
| | | 2680 | | toast.className = `toast align-items-center text-white ${isSuccess ? 'bg-success' : 'bg-danger'} border-0 po |
| | | 2681 | | toast.style.zIndex = '99999'; |
| | | 2682 | | const icon = isSuccess ? 'fa-check-circle' : 'fa-exclamation-circle'; |
| | | 2683 | | toast.innerHTML = `<div class="d-flex"><div class="toast-body"><i class="fas ${icon} me-2"></i>${message}</d |
| | | 2684 | | document.body.appendChild(toast); |
| | | 2685 | | const bsToast = new bootstrap.Toast(toast); |
| | | 2686 | | bsToast.show(); |
| | | 2687 | | toast.addEventListener('hidden.bs.toast', () => document.body.removeChild(toast)); |
| | | 2688 | | } |
| | | 2689 | | |
| | | 2690 | | window.markAsRead = function () { |
| | | 2691 | | const clientId = histClientId || selClient.value; |
| | | 2692 | | if (!clientId || !histChatId) return; |
| | | 2693 | | |
| | | 2694 | | const fromMessageId = histNewestMessageId || null; |
| | | 2695 | | |
| | | 2696 | | const btn = document.getElementById('btn-mark-read'); |
| | | 2697 | | btn.disabled = true; |
| | | 2698 | | btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Отправка...'; |
| | | 2699 | | |
| | | 2700 | | const body = JSON.stringify({clientId: parseInt(clientId), chatId: histChatId, fromMessageId}); |
| | | 2701 | | |
| | 0 | 2702 | | fetch(`@Url.Action("MarkAsRead")`, { |
| | | 2703 | | method: 'POST', |
| | | 2704 | | headers: {'Content-Type': 'application/json'}, |
| | | 2705 | | body: body |
| | | 2706 | | }) |
| | | 2707 | | .then(r => r.json()) |
| | | 2708 | | .then(data => { |
| | | 2709 | | btn.disabled = false; |
| | | 2710 | | btn.innerHTML = '<i class="fas fa-check-double me-1"></i>Прочитано'; |
| | | 2711 | | |
| | | 2712 | | if (data.success) { |
| | | 2713 | | showToastMessage(`${data.message} (без ответа: ${data.unreadCount})`, true); |
| | | 2714 | | |
| | | 2715 | | // Сбрасываем флаг полоски Новых сообщений для будущих загрузок |
| | | 2716 | | newMsgDividerInserted = false; |
| | | 2717 | | |
| | | 2718 | | if (data.unreadCount === 0) { |
| | | 2719 | | btn.classList.add('d-none'); |
| | | 2720 | | } |
| | | 2721 | | |
| | | 2722 | | loadChats(); |
| | | 2723 | | } else { |
| | | 2724 | | showToastMessage(data.message || 'Ошибка при отметке', false); |
| | | 2725 | | } |
| | | 2726 | | }) |
| | | 2727 | | .catch(err => { |
| | | 2728 | | btn.disabled = false; |
| | | 2729 | | btn.innerHTML = '<i class="fas fa-check-double me-1"></i>Прочитано'; |
| | | 2730 | | showToastMessage(`Ошибка сети: ${err.message}`, false); |
| | | 2731 | | }); |
| | | 2732 | | }; |
| | | 2733 | | |
| | | 2734 | | // загрузить ранее сохранённые временные записи для текущего чата |
| | | 2735 | | function loadResponseTimes() { |
| | | 2736 | | if (!histChatId) return; |
| | 0 | 2737 | | fetch(`@Url.Action("GetResponseTimes")?chatId=${encodeURIComponent(histChatId)}`) |
| | | 2738 | | .then(r => r.json()) |
| | | 2739 | | .then(data => { |
| | | 2740 | | const listEl = document.getElementById('resp-time-list'); |
| | | 2741 | | if (data.success && data.times) { |
| | | 2742 | | if (data.times.length) { |
| | | 2743 | | listEl.innerHTML = data.times.map(t => { |
| | | 2744 | | const h = Math.floor(t.minutes / 60); |
| | | 2745 | | const m = t.minutes % 60; |
| | | 2746 | | const hm = h ? `${h} ч ${m} мин` : `${m} мин`; |
| | | 2747 | | return `<div>${t.recordedAt} — <strong>${hm}</strong> <span class="text-muted">[${escape |
| | | 2748 | | }).join(''); |
| | | 2749 | | } else { |
| | | 2750 | | listEl.innerHTML = '<em>Нет записей</em>'; |
| | | 2751 | | } |
| | | 2752 | | } else { |
| | | 2753 | | listEl.innerHTML = '<span class="text-danger">Ошибка загрузки</span>'; |
| | | 2754 | | } |
| | | 2755 | | }) |
| | | 2756 | | .catch(() => { |
| | | 2757 | | const listEl = document.getElementById('resp-time-list'); |
| | | 2758 | | if (listEl) listEl.innerHTML = '<span class="text-danger">Ошибка сети</span>'; |
| | | 2759 | | }); |
| | | 2760 | | } |
| | | 2761 | | |
| | | 2762 | | // сохранить вводимое время ответа |
| | | 2763 | | window.saveResponseTime = function () { |
| | | 2764 | | const hoursEl = document.getElementById('resp-time-hours'); |
| | | 2765 | | const minutesEl = document.getElementById('resp-time-minutes'); |
| | | 2766 | | if (!hoursEl || !minutesEl) return; |
| | | 2767 | | |
| | | 2768 | | const h = parseInt(hoursEl.value) || 0; |
| | | 2769 | | const m = parseInt(minutesEl.value) || 0; |
| | | 2770 | | const total = h * 60 + m; |
| | | 2771 | | if (total <= 0) { |
| | | 2772 | | alert('Укажите ненулевое время в часах или минутах'); |
| | | 2773 | | return; |
| | | 2774 | | } |
| | | 2775 | | if (!histChatId || !histClientId) { |
| | | 2776 | | alert('Не выбран чат'); |
| | | 2777 | | return; |
| | | 2778 | | } |
| | | 2779 | | |
| | | 2780 | | const payload = { |
| | | 2781 | | ChatId: histChatId, |
| | | 2782 | | ClientId: parseInt(histClientId), |
| | | 2783 | | Minutes: total |
| | | 2784 | | }; |
| | | 2785 | | |
| | | 2786 | | // выключаем кнопку на время отправки |
| | | 2787 | | const btn = this || document.querySelector('#resp-time-section button'); |
| | | 2788 | | if (btn) { |
| | | 2789 | | btn.disabled = true; |
| | | 2790 | | btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Сохранение...'; |
| | | 2791 | | } |
| | | 2792 | | |
| | 0 | 2793 | | fetch(`@Url.Action("AddResponseTime")`, { |
| | | 2794 | | method: 'POST', |
| | | 2795 | | headers: {'Content-Type': 'application/json'}, |
| | | 2796 | | body: JSON.stringify(payload) |
| | | 2797 | | }) |
| | | 2798 | | .then(r => r.json()) |
| | | 2799 | | .then(data => { |
| | | 2800 | | if (btn) { |
| | | 2801 | | btn.disabled = false; |
| | | 2802 | | btn.innerHTML = 'Сохранить'; |
| | | 2803 | | } |
| | | 2804 | | if (data.success) { |
| | | 2805 | | hoursEl.value = ''; |
| | | 2806 | | minutesEl.value = ''; |
| | | 2807 | | loadResponseTimes(); |
| | | 2808 | | showToastMessage('Время успешно сохранено', true); |
| | | 2809 | | } else { |
| | | 2810 | | showToastMessage(data.message || 'Ошибка при сохранении', false); |
| | | 2811 | | } |
| | | 2812 | | }) |
| | | 2813 | | .catch(err => { |
| | | 2814 | | if (btn) { |
| | | 2815 | | btn.disabled = false; |
| | | 2816 | | btn.innerHTML = 'Сохранить'; |
| | | 2817 | | } |
| | | 2818 | | showToastMessage(`Ошибка сети: ${err.message}`, false); |
| | | 2819 | | }); |
| | | 2820 | | } |
| | | 2821 | | |
| | | 2822 | | window.handleFileSelect = function () { |
| | | 2823 | | const fileInput = document.getElementById('file-input'); |
| | | 2824 | | const file = fileInput.files[0]; |
| | | 2825 | | |
| | | 2826 | | if (file) { |
| | | 2827 | | const maxSize = 10 * 1024 * 1024; |
| | | 2828 | | if (file.size > maxSize) { |
| | | 2829 | | showToastMessage('Размер файла не должен превышать 10 МБ', false); |
| | | 2830 | | fileInput.value = ''; |
| | | 2831 | | return; |
| | | 2832 | | } |
| | | 2833 | | |
| | | 2834 | | document.getElementById('attached-file-name').textContent = file.name; |
| | | 2835 | | document.getElementById('attached-file-info').style.display = 'block'; |
| | | 2836 | | } else { |
| | | 2837 | | clearAttachedFile(); |
| | | 2838 | | } |
| | | 2839 | | }; |
| | | 2840 | | |
| | | 2841 | | window.clearAttachedFile = function () { |
| | | 2842 | | document.getElementById('file-input').value = ''; |
| | | 2843 | | document.getElementById('attached-file-info').style.display = 'none'; |
| | | 2844 | | document.getElementById('attached-file-name').textContent = ''; |
| | | 2845 | | }; |
| | | 2846 | | |
| | | 2847 | | window.sendMessageOrFile = function () { |
| | | 2848 | | const clientId = histClientId || selClient.value; |
| | | 2849 | | if (!clientId || !histChatId) return; |
| | | 2850 | | |
| | | 2851 | | const messageTextarea = document.getElementById('message-text'); |
| | | 2852 | | const text = messageTextarea.value.trim(); |
| | | 2853 | | const fileInput = document.getElementById('file-input'); |
| | | 2854 | | const file = fileInput.files[0]; |
| | | 2855 | | |
| | | 2856 | | if (!text && !file) { |
| | | 2857 | | showToastMessage('Введите сообщение или прикрепите файл', false); |
| | | 2858 | | return; |
| | | 2859 | | } |
| | | 2860 | | |
| | | 2861 | | if (text && text.length > 1000) { |
| | | 2862 | | showToastMessage('Сообщение не должно превышать 1000 символов', false); |
| | | 2863 | | return; |
| | | 2864 | | } |
| | | 2865 | | |
| | | 2866 | | const btn = document.getElementById('btn-send'); |
| | | 2867 | | btn.disabled = true; |
| | | 2868 | | btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Отправка...'; |
| | | 2869 | | |
| | | 2870 | | const promises = []; |
| | | 2871 | | |
| | | 2872 | | if (text) { |
| | | 2873 | | const messageBody = JSON.stringify({clientId: parseInt(clientId), chatId: histChatId, text}); |
| | | 2874 | | promises.push( |
| | 0 | 2875 | | fetch(`@Url.Action("SendMessage")`, { |
| | | 2876 | | method: 'POST', |
| | | 2877 | | headers: {'Content-Type': 'application/json'}, |
| | | 2878 | | body: messageBody |
| | | 2879 | | }) |
| | | 2880 | | .then(r => r.json()) |
| | | 2881 | | .then(data => { |
| | | 2882 | | if (!data.success) { |
| | | 2883 | | throw new Error(data.message || 'Ошибка при отправке сообщения'); |
| | | 2884 | | } |
| | | 2885 | | return data; |
| | | 2886 | | }) |
| | | 2887 | | ); |
| | | 2888 | | } |
| | | 2889 | | |
| | | 2890 | | if (file) { |
| | | 2891 | | const filePromise = new Promise((resolve, reject) => { |
| | | 2892 | | const reader = new FileReader(); |
| | | 2893 | | reader.onload = function (e) { |
| | | 2894 | | const base64String = e.target.result.split(',')[1]; |
| | | 2895 | | const fileBody = JSON.stringify({ |
| | | 2896 | | clientId: parseInt(clientId), |
| | | 2897 | | chatId: histChatId, |
| | | 2898 | | base64Content: base64String, |
| | | 2899 | | fileName: file.name |
| | | 2900 | | }); |
| | | 2901 | | |
| | 0 | 2902 | | fetch(`@Url.Action("SendFile")`, { |
| | | 2903 | | method: 'POST', |
| | | 2904 | | headers: {'Content-Type': 'application/json'}, |
| | | 2905 | | body: fileBody |
| | | 2906 | | }) |
| | | 2907 | | .then(r => r.json()) |
| | | 2908 | | .then(data => { |
| | | 2909 | | if (!data.success) { |
| | | 2910 | | reject(new Error(data.message || 'Ошибка при отправке файла')); |
| | | 2911 | | } else { |
| | | 2912 | | resolve(data); |
| | | 2913 | | } |
| | | 2914 | | }) |
| | | 2915 | | .catch(reject); |
| | | 2916 | | }; |
| | | 2917 | | reader.onerror = function () { |
| | | 2918 | | reject(new Error('Ошибка при чтении файла')); |
| | | 2919 | | }; |
| | | 2920 | | reader.readAsDataURL(file); |
| | | 2921 | | }); |
| | | 2922 | | promises.push(filePromise); |
| | | 2923 | | } |
| | | 2924 | | |
| | | 2925 | | Promise.all(promises) |
| | | 2926 | | .then(() => { |
| | | 2927 | | btn.disabled = false; |
| | | 2928 | | btn.innerHTML = '<i class="fas fa-paper-plane me-1"></i>Отправить'; |
| | | 2929 | | |
| | | 2930 | | messageTextarea.value = ''; |
| | | 2931 | | document.getElementById('char-count').textContent = '0'; |
| | | 2932 | | clearAttachedFile(); |
| | | 2933 | | |
| | | 2934 | | histOldestMessageId = null; |
| | | 2935 | | histNewestMessageId = null; |
| | | 2936 | | document.getElementById('hist-messages').innerHTML = ''; |
| | | 2937 | | document.getElementById('hist-spinner').classList.remove('d-none'); |
| | | 2938 | | loadHistory(histChatId, null); |
| | | 2939 | | |
| | | 2940 | | setTimeout(() => { |
| | 0 | 2941 | | fetch(`@Url.Action("MarkAsRead")`, { |
| | | 2942 | | method: 'POST', |
| | | 2943 | | headers: {'Content-Type': 'application/json'}, |
| | | 2944 | | body: JSON.stringify({ |
| | | 2945 | | clientId: parseInt(clientId), |
| | | 2946 | | chatId: histChatId, |
| | | 2947 | | fromMessageId: null |
| | | 2948 | | }) |
| | | 2949 | | }).then(() => loadChats()); |
| | | 2950 | | }, 500); |
| | | 2951 | | }) |
| | | 2952 | | .catch(err => { |
| | | 2953 | | btn.disabled = false; |
| | | 2954 | | btn.innerHTML = '<i class="fas fa-paper-plane me-1"></i>Отправить'; |
| | | 2955 | | showToastMessage(err.message || 'Ошибка при отправке', false); |
| | | 2956 | | }); |
| | | 2957 | | }; |
| | | 2958 | | |
| | | 2959 | | const messageTextarea = document.getElementById('message-text'); |
| | | 2960 | | if (messageTextarea) { |
| | | 2961 | | messageTextarea.addEventListener('input', () => { |
| | | 2962 | | document.getElementById('char-count').textContent = messageTextarea.value.length; |
| | | 2963 | | }); |
| | | 2964 | | |
| | | 2965 | | messageTextarea.addEventListener('keydown', (e) => { |
| | | 2966 | | if (e.key === 'Enter' && !e.shiftKey) { |
| | | 2967 | | e.preventDefault(); |
| | | 2968 | | sendMessageOrFile(); |
| | | 2969 | | } |
| | | 2970 | | }); |
| | | 2971 | | } |
| | | 2972 | | |
| | | 2973 | | |
| | | 2974 | | const chatModalEl = document.getElementById('chatHistoryModal'); |
| | | 2975 | | |
| | | 2976 | | chatModalEl.addEventListener('shown.bs.modal', function () { |
| | | 2977 | | if (histChatId) { |
| | | 2978 | | loadHistory(histChatId, null); |
| | | 2979 | | } |
| | | 2980 | | }); |
| | | 2981 | | |
| | | 2982 | | // === СТАТИСТИКА ОТВЕТОВ === |
| | | 2983 | | let statsCurrentPage = 1; |
| | | 2984 | | let timeCurrentPage = 1; |
| | | 2985 | | const statsPageSize = 50; |
| | | 2986 | | |
| | | 2987 | | const btnLoadStats = document.getElementById('btn-load-stats'); |
| | | 2988 | | const statSelClient = document.getElementById('stat-sel-client'); |
| | | 2989 | | const statDateFrom = document.getElementById('stat-date-from'); |
| | | 2990 | | const statDateTo = document.getElementById('stat-date-to'); |
| | | 2991 | | const statSelManager = document.getElementById('stat-sel-manager'); |
| | | 2992 | | |
| | | 2993 | | if (btnLoadStats) { |
| | | 2994 | | btnLoadStats.addEventListener('click', () => { |
| | | 2995 | | loadResponseStats(1); |
| | | 2996 | | loadTimeStats(1); |
| | | 2997 | | }); |
| | | 2998 | | } |
| | | 2999 | | |
| | | 3000 | | const responseStatsTab = document.getElementById('response-stats-tab'); |
| | | 3001 | | if (responseStatsTab) { |
| | | 3002 | | responseStatsTab.addEventListener('shown.bs.tab', () => { |
| | | 3003 | | const summaryDiv = document.getElementById('stats-summary'); |
| | | 3004 | | if (summaryDiv && summaryDiv.classList.contains('d-none')) { |
| | | 3005 | | loadResponseStats(1); |
| | | 3006 | | loadTimeStats(1); |
| | | 3007 | | } |
| | | 3008 | | }); |
| | | 3009 | | } |
| | | 3010 | | |
| | | 3011 | | function loadResponseStats(page) { |
| | | 3012 | | statsCurrentPage = page; |
| | | 3013 | | |
| | | 3014 | | const clientId = statSelClient?.value || 0; |
| | | 3015 | | const dateFrom = statDateFrom?.value || ''; |
| | | 3016 | | const dateTo = statDateTo?.value || ''; |
| | | 3017 | | const managerName = statSelManager?.value || ''; |
| | | 3018 | | |
| | | 3019 | | const loading = document.getElementById('stats-loading'); |
| | | 3020 | | const empty = document.getElementById('stats-empty'); |
| | | 3021 | | const tableWrap = document.getElementById('responses-table-wrap'); |
| | | 3022 | | const tableCard = document.getElementById('responses-table-card'); |
| | | 3023 | | const summaryDiv = document.getElementById('stats-summary'); |
| | | 3024 | | const managersDiv = document.getElementById('managers-stats'); |
| | | 3025 | | |
| | | 3026 | | if (loading) loading.classList.remove('d-none'); |
| | | 3027 | | if (empty) empty.classList.add('d-none'); |
| | | 3028 | | if (tableWrap) tableWrap.classList.add('d-none'); |
| | | 3029 | | if (tableCard) tableCard.classList.remove('d-none'); |
| | | 3030 | | |
| | 0 | 3031 | | let url = `@Url.Action("GetResponseStatistics")?clientId=${clientId}&page=${page}&pageSize=${statsPageSize}` |
| | | 3032 | | if (dateFrom) url += `&from=${dateFrom}`; |
| | | 3033 | | if (dateTo) url += `&to=${dateTo}`; |
| | | 3034 | | if (managerName) url += `&responderName=${encodeURIComponent(managerName)}`; |
| | | 3035 | | |
| | | 3036 | | fetch(url) |
| | | 3037 | | .then(r => r.json()) |
| | | 3038 | | .then(data => { |
| | | 3039 | | if (loading) loading.classList.add('d-none'); |
| | | 3040 | | |
| | | 3041 | | if (!data.success) { |
| | | 3042 | | alert(data.message || 'Ошибка загрузки статистики'); |
| | | 3043 | | return; |
| | | 3044 | | } |
| | | 3045 | | |
| | | 3046 | | if (statSelManager && data.allResponders && data.allResponders.length > 0) { |
| | | 3047 | | const currentVal = statSelManager.value; |
| | | 3048 | | statSelManager.innerHTML = '<option value="" selected>Все менеджеры</option>' + |
| | | 3049 | | data.allResponders.map(m => `<option value="${escapeHtml(m)}">${escapeHtml(m)}</option>`).jo |
| | | 3050 | | if (currentVal) statSelManager.value = currentVal; |
| | | 3051 | | } |
| | | 3052 | | |
| | | 3053 | | if (summaryDiv) { |
| | | 3054 | | summaryDiv.classList.remove('d-none'); |
| | | 3055 | | document.getElementById('summary-total').textContent = data.summary.totalResponses; |
| | | 3056 | | document.getElementById('summary-avg-time').textContent = data.summary.avgResponseTimeFormatted; |
| | | 3057 | | document.getElementById('summary-period').textContent = `${data.summary.dateFrom} - ${data.summa |
| | | 3058 | | document.getElementById('summary-managers').textContent = data.summary.responders?.length || 0; |
| | | 3059 | | } |
| | | 3060 | | |
| | | 3061 | | if (managersDiv && data.summary.responders && data.summary.responders.length > 0) { |
| | | 3062 | | managersDiv.classList.remove('d-none'); |
| | | 3063 | | const maxCount = Math.max(...data.summary.responders.map(r => r.count)); |
| | | 3064 | | const managersTbody = document.getElementById('managers-tbody'); |
| | | 3065 | | managersTbody.innerHTML = data.summary.responders.map(r => { |
| | | 3066 | | const pct = Math.round((r.count / data.summary.totalResponses) * 100); |
| | | 3067 | | const avgTime = formatSecondsJs(Math.round(r.avgResponseTime)); |
| | | 3068 | | return ` |
| | | 3069 | | <tr> |
| | | 3070 | | <td> |
| | | 3071 | | <i class="fas fa-user-circle text-secondary me-2"></i> |
| | | 3072 | | <strong>${escapeHtml(r.name)}</strong> |
| | | 3073 | | </td> |
| | | 3074 | | <td class="text-center"> |
| | | 3075 | | <span class="badge badge-buyer">${r.count}</span> |
| | | 3076 | | </td> |
| | | 3077 | | <td class="text-center"> |
| | | 3078 | | <span class="text-muted">${avgTime}</span> |
| | | 3079 | | </td> |
| | | 3080 | | <td> |
| | | 3081 | | <div class="progress progress-green" style="height: 20px;"> |
| | | 3082 | | <div class="progress-bar progress-bar-green" role="progressbar" |
| | | 3083 | | style="width: ${pct}%;" aria-valuenow="${pct}" aria-valuemin="0" aria-v |
| | | 3084 | | ${pct}% |
| | | 3085 | | </div> |
| | | 3086 | | </div> |
| | | 3087 | | </td> |
| | | 3088 | | </tr>`; |
| | | 3089 | | }).join(''); |
| | | 3090 | | } else if (managersDiv) { |
| | | 3091 | | managersDiv.classList.add('d-none'); |
| | | 3092 | | } |
| | | 3093 | | |
| | | 3094 | | if (data.statistics && data.statistics.length > 0) { |
| | | 3095 | | if (tableWrap) tableWrap.classList.remove('d-none'); |
| | | 3096 | | if (empty) empty.classList.add('d-none'); |
| | | 3097 | | |
| | | 3098 | | document.getElementById('responses-count').textContent = |
| | | 3099 | | `Показано ${data.statistics.length} из ${data.pagination.totalItems}`; |
| | | 3100 | | |
| | | 3101 | | const tbody = document.getElementById('responses-tbody'); |
| | | 3102 | | tbody.innerHTML = data.statistics.map(s => ` |
| | | 3103 | | <tr> |
| | | 3104 | | <td> |
| | | 3105 | | <small class="text-muted">${s.sellerResponseDate || '—'}</small> |
| | | 3106 | | </td> |
| | | 3107 | | <td> |
| | | 3108 | | <span class="badge badge-closed">${escapeHtml(s.responderName)}</span> |
| | | 3109 | | </td> |
| | | 3110 | | <td> |
| | | 3111 | | <div class="text-truncate" style="max-width: 250px;" |
| | | 3112 | | title="${escapeHtml(s.sellerResponseText || '')}"> |
| | | 3113 | | ${escapeHtml(s.sellerResponseText || '—')} |
| | | 3114 | | </div> |
| | | 3115 | | </td> |
| | | 3116 | | <td> |
| | | 3117 | | <span class="badge ${s.responseTimeSeconds > 3600 ? 'badge-other ' : 'badge-open '}" |
| | | 3118 | | ${s.responseTimeFormatted} |
| | | 3119 | | </span> |
| | | 3120 | | </td> |
| | | 3121 | | <td> |
| | | 3122 | | |
| | | 3123 | | <a href="#" onclick="viewChat('${s.chatId}', ${s.clientId}); return false;" |
| | | 3124 | | class="link-chat d-inline-flex align-items-center gap-1"> |
| | | 3125 | | <i class="fas fa-comments me-2"></i>${s.chatId?.substring(0, 12) || '—'}... |
| | | 3126 | | </a> |
| | | 3127 | | </td> |
| | | 3128 | | </tr> |
| | | 3129 | | `).join(''); |
| | | 3130 | | |
| | | 3131 | | renderStatsPagination(data.pagination); |
| | | 3132 | | } else { |
| | | 3133 | | if (tableWrap) tableWrap.classList.add('d-none'); |
| | | 3134 | | if (empty) empty.classList.remove('d-none'); |
| | | 3135 | | } |
| | | 3136 | | }) |
| | | 3137 | | .catch(err => { |
| | | 3138 | | if (loading) loading.classList.add('d-none'); |
| | | 3139 | | console.error('Ошибка загрузки статистики:', err); |
| | | 3140 | | alert('Ошибка загрузки статистики'); |
| | | 3141 | | }); |
| | | 3142 | | } |
| | | 3143 | | |
| | | 3144 | | function renderStatsPagination(pagination) { |
| | | 3145 | | const wrap = document.getElementById('stats-pagination-wrap'); |
| | | 3146 | | const list = document.getElementById('stats-pagination-list'); |
| | | 3147 | | const info = document.getElementById('stats-page-info'); |
| | | 3148 | | |
| | | 3149 | | if (!pagination || pagination.totalPages <= 1) { |
| | | 3150 | | if (wrap) wrap.classList.add('d-none'); |
| | | 3151 | | return; |
| | | 3152 | | } |
| | | 3153 | | |
| | | 3154 | | if (wrap) wrap.classList.remove('d-none'); |
| | | 3155 | | |
| | | 3156 | | const start = (pagination.currentPage - 1) * pagination.pageSize + 1; |
| | | 3157 | | const end = Math.min(pagination.currentPage * pagination.pageSize, pagination.totalItems); |
| | | 3158 | | if (info) info.textContent = `${start}-${end} из ${pagination.totalItems}`; |
| | | 3159 | | |
| | | 3160 | | let html = ''; |
| | | 3161 | | |
| | | 3162 | | html += `<li class="page-item ${pagination.currentPage === 1 ? 'disabled' : ''}"> |
| | | 3163 | | <a class="page-link" href="#" onclick="loadResponseStats(${pagination.currentPage - 1}); return |
| | | 3164 | | </li>`; |
| | | 3165 | | |
| | | 3166 | | const maxPages = 5; |
| | | 3167 | | let startPage = Math.max(1, pagination.currentPage - Math.floor(maxPages / 2)); |
| | | 3168 | | let endPage = Math.min(pagination.totalPages, startPage + maxPages - 1); |
| | | 3169 | | |
| | | 3170 | | if (endPage - startPage < maxPages - 1) { |
| | | 3171 | | startPage = Math.max(1, endPage - maxPages + 1); |
| | | 3172 | | } |
| | | 3173 | | |
| | | 3174 | | for (let i = startPage; i <= endPage; i++) { |
| | | 3175 | | html += `<li class="page-item ${i === pagination.currentPage ? 'active' : ''}"> |
| | | 3176 | | <a class="page-link" href="#" onclick="loadResponseStats(${i}); return false;">${i}</a> |
| | | 3177 | | </li>`; |
| | | 3178 | | } |
| | | 3179 | | |
| | | 3180 | | html += `<li class="page-item ${pagination.currentPage === pagination.totalPages ? 'disabled' : ''}"> |
| | | 3181 | | <a class="page-link" href="#" onclick="loadResponseStats(${pagination.currentPage + 1}); return |
| | | 3182 | | </li>`; |
| | | 3183 | | |
| | | 3184 | | if (list) list.innerHTML = html; |
| | | 3185 | | } |
| | | 3186 | | |
| | | 3187 | | // === функции для статистики времени === |
| | | 3188 | | function loadTimeStats(page) { |
| | | 3189 | | timeCurrentPage = page; |
| | | 3190 | | |
| | | 3191 | | const clientId = statSelClient?.value || 0; |
| | | 3192 | | const dateFrom = statDateFrom?.value || ''; |
| | | 3193 | | const dateTo = statDateTo?.value || ''; |
| | | 3194 | | |
| | | 3195 | | const loading = document.getElementById('time-loading'); |
| | | 3196 | | const empty = document.getElementById('time-empty'); |
| | | 3197 | | const tableWrap = document.getElementById('time-table-wrap'); |
| | | 3198 | | |
| | | 3199 | | if (loading) loading.classList.remove('d-none'); |
| | | 3200 | | if (empty) empty.classList.add('d-none'); |
| | | 3201 | | if (tableWrap) tableWrap.classList.add('d-none'); |
| | | 3202 | | |
| | 0 | 3203 | | let url = `@Url.Action("GetResponseTimeStats")?clientId=${clientId}&page=${page}&pageSize=${statsPageSize}`; |
| | | 3204 | | if (dateFrom) url += `&from=${dateFrom}`; |
| | | 3205 | | if (dateTo) url += `&to=${dateTo}`; |
| | | 3206 | | |
| | | 3207 | | fetch(url) |
| | | 3208 | | .then(r => r.json()) |
| | | 3209 | | .then(data => { |
| | | 3210 | | if (loading) loading.classList.add('d-none'); |
| | | 3211 | | if (!data.success) { |
| | | 3212 | | alert(data.message || 'Ошибка загрузки времени'); |
| | | 3213 | | return; |
| | | 3214 | | } |
| | | 3215 | | if (data.items && data.items.length > 0) { |
| | | 3216 | | if (tableWrap) tableWrap.classList.remove('d-none'); |
| | | 3217 | | if (empty) empty.classList.add('d-none'); |
| | | 3218 | | |
| | | 3219 | | document.getElementById('time-page-info').textContent = |
| | | 3220 | | `Показано ${data.items.length} из ${data.pagination.totalItems}`; |
| | | 3221 | | |
| | | 3222 | | // summary cards |
| | | 3223 | | if (data.summary) { |
| | | 3224 | | const sumDiv = document.getElementById('time-summary'); |
| | | 3225 | | if (sumDiv) sumDiv.classList.remove('d-none'); |
| | | 3226 | | const totRec = document.getElementById('time-summary-total'); |
| | | 3227 | | const totTime = document.getElementById('time-summary-totaltime'); |
| | | 3228 | | if (totRec) totRec.textContent = data.summary.totalRecords; |
| | | 3229 | | if (totTime) { |
| | | 3230 | | const tmin = data.summary.totalMinutes || 0; |
| | | 3231 | | const h = Math.floor(tmin / 60); |
| | | 3232 | | const m = tmin % 60; |
| | | 3233 | | totTime.textContent = h ? `${h} ч ${m} мин` : `${m} мин`; |
| | | 3234 | | } |
| | | 3235 | | } |
| | | 3236 | | |
| | | 3237 | | const tbody = document.getElementById('time-tbody'); |
| | | 3238 | | tbody.innerHTML = data.items.map(t => { |
| | | 3239 | | const h = Math.floor(t.minutes / 60); |
| | | 3240 | | const m = t.minutes % 60; |
| | | 3241 | | const hm = h ? `${h} ч ${m} мин` : `${m} мин`; |
| | | 3242 | | return ` |
| | | 3243 | | <tr> |
| | | 3244 | | <td><small class="text-muted">${t.recordedAt}</small></td> |
| | | 3245 | | <td><span class="badge bg-secondary">${escapeHtml(t.userName)}</span></td> |
| | | 3246 | | <td>${hm}</td> |
| | | 3247 | | <td><a href="#" onclick="viewChat('${t.chatId}', ${t.clientId}); return false;" class="t |
| | | 3248 | | <td>${escapeHtml(t.clientName || '')}</td> |
| | | 3249 | | </tr>`; |
| | | 3250 | | }).join(''); |
| | | 3251 | | |
| | | 3252 | | renderTimeStatsPagination(data.pagination); |
| | | 3253 | | } else { |
| | | 3254 | | if (tableWrap) tableWrap.classList.add('d-none'); |
| | | 3255 | | if (empty) empty.classList.remove('d-none'); |
| | | 3256 | | } |
| | | 3257 | | }) |
| | | 3258 | | .catch(err => { |
| | | 3259 | | if (loading) loading.classList.add('d-none'); |
| | | 3260 | | console.error('Ошибка загрузки времени:', err); |
| | | 3261 | | alert('Ошибка загрузки времени'); |
| | | 3262 | | }); |
| | | 3263 | | } |
| | | 3264 | | |
| | | 3265 | | function renderTimeStatsPagination(pagination) { |
| | | 3266 | | const wrap = document.getElementById('time-pagination-wrap'); |
| | | 3267 | | const list = document.getElementById('time-pagination-list'); |
| | | 3268 | | const info = document.getElementById('time-page-info'); |
| | | 3269 | | |
| | | 3270 | | if (!pagination || pagination.totalPages <= 1) { |
| | | 3271 | | if (wrap) wrap.classList.add('d-none'); |
| | | 3272 | | return; |
| | | 3273 | | } |
| | | 3274 | | if (wrap) wrap.classList.remove('d-none'); |
| | | 3275 | | const start = (pagination.currentPage - 1) * pagination.pageSize + 1; |
| | | 3276 | | const end = Math.min(pagination.currentPage * pagination.pageSize, pagination.totalItems); |
| | | 3277 | | if (info) info.textContent = `${start}-${end} из ${pagination.totalItems}`; |
| | | 3278 | | let html = ''; |
| | | 3279 | | html += `<li class="page-item ${pagination.currentPage === 1 ? 'disabled' : ''}"> |
| | | 3280 | | <a class="page-link" href="#" onclick="loadTimeStats(${pagination.currentPage - 1}); return fals |
| | | 3281 | | </li>`; |
| | | 3282 | | const maxPages = 5; |
| | | 3283 | | let startPage = Math.max(1, pagination.currentPage - Math.floor(maxPages / 2)); |
| | | 3284 | | let endPage = Math.min(pagination.totalPages, startPage + maxPages - 1); |
| | | 3285 | | if (endPage - startPage < maxPages - 1) { |
| | | 3286 | | startPage = Math.max(1, endPage - maxPages + 1); |
| | | 3287 | | } |
| | | 3288 | | for (let i = startPage; i <= endPage; i++) { |
| | | 3289 | | html += `<li class="page-item ${i === pagination.currentPage ? 'active' : ''}"> |
| | | 3290 | | <a class="page-link" href="#" onclick="loadTimeStats(${i}); return false;">${i}</a> |
| | | 3291 | | </li>`; |
| | | 3292 | | } |
| | | 3293 | | html += `<li class="page-item ${pagination.currentPage === pagination.totalPages ? 'disabled' : ''}"> |
| | | 3294 | | <a class="page-link" href="#" onclick="loadTimeStats(${pagination.currentPage + 1}); return fals |
| | | 3295 | | </li>`; |
| | | 3296 | | if (list) list.innerHTML = html; |
| | | 3297 | | } |
| | | 3298 | | |
| | | 3299 | | function formatSecondsJs(totalSeconds) { |
| | | 3300 | | if (!totalSeconds || totalSeconds <= 0) return '0 сек'; |
| | | 3301 | | if (totalSeconds < 60) return totalSeconds + ' сек'; |
| | | 3302 | | if (totalSeconds < 3600) return Math.floor(totalSeconds / 60) + ' мин'; |
| | | 3303 | | const hours = Math.floor(totalSeconds / 3600); |
| | | 3304 | | const minutes = Math.floor((totalSeconds % 3600) / 60); |
| | | 3305 | | return hours + ' ч ' + minutes + ' мин'; |
| | | 3306 | | } |
| | | 3307 | | |
| | | 3308 | | window.loadResponseStats = loadResponseStats; |
| | | 3309 | | |
| | | 3310 | | // === Авто-загрузка при открытии страницы === |
| | | 3311 | | const urlParams = new URLSearchParams(window.location.search); |
| | | 3312 | | const urlChatId = urlParams.get('chatId'); |
| | | 3313 | | const urlClientId = urlParams.get('clientId'); |
| | | 3314 | | |
| | | 3315 | | if (urlChatId && urlClientId && selClient) { |
| | | 3316 | | selClient.value = urlClientId; |
| | | 3317 | | loadChats(); |
| | | 3318 | | setTimeout(() => { |
| | | 3319 | | viewChat(urlChatId, urlClientId); |
| | | 3320 | | }, 500); |
| | | 3321 | | } else { |
| | | 3322 | | loadChats(); |
| | | 3323 | | } |
| | | 3324 | | })(); |
| | | 3325 | | </script> |