Compare commits
161 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cdfe2f5a0a | |||
| 73ace950fa | |||
| 76ee4b7ac3 | |||
| 488cb5d1e2 | |||
| 2fc689de2f | |||
| 8e6e99c906 | |||
| 19993c79e8 | |||
| 8a29851940 | |||
| 1f04f41a40 | |||
| 635683020c | |||
| f5f4f12ff1 | |||
| 3a866762a5 | |||
| 95068742f4 | |||
| 440b330900 | |||
| 84917e4683 | |||
| fd1d4a66d2 | |||
| 0bbe55929b | |||
| b9d813955d | |||
| 76075da0ad | |||
| ec5a1a898b | |||
| 2a492aaa7c | |||
| 08df44734c | |||
| ad7868ff1c | |||
| 3ef77e35ff | |||
| 37b03dc1b5 | |||
| bbab4434bb | |||
| e43688a618 | |||
| 91f408aca2 | |||
| c958a7f913 | |||
| 4e74132143 | |||
| e90bf33b5a | |||
| d96bbc7c7d | |||
| 3fe06b93f7 | |||
| 72bd9108ca | |||
| 9db1a4225b | |||
| d6e697c22a | |||
| 613971b79e | |||
| eb28a10c52 | |||
| 5724d3c8d9 | |||
| 4456844e50 | |||
| 0dd9ddfc96 | |||
| aca869fd80 | |||
| 850a23e962 | |||
| 3b68dfc042 | |||
| c8daa6c35b | |||
| 318cbc8e71 | |||
| 9d28e67388 | |||
| 908533929b | |||
| 0e838358f0 | |||
| 5b536dfbe3 | |||
| 507868108f | |||
| 0ece532121 | |||
| 27fd3543ec | |||
| f4795d019e | |||
| 94bc1bbd0f | |||
| 6f816f0685 | |||
| 6820550cbd | |||
| fd928b6e3a | |||
| 72541292f5 | |||
| 27b7197569 | |||
| 2f585e2833 | |||
| 79c665969e | |||
| 4ef0662d15 | |||
| d62abb6cef | |||
| 384469aa2c | |||
|
|
97609b3ba7 | ||
| 01238e93d9 | |||
| 2107d4ffc6 | |||
| 7e748c37e1 | |||
| fb594b727a | |||
| 99b968fcbf | |||
| 2df88ad168 | |||
| 4c9298c573 | |||
| f6f079ed85 | |||
| 2332ba927f | |||
| 733c379279 | |||
| 7f49bed72e | |||
| db2ae5a654 | |||
| 40c454a786 | |||
| bb322022b2 | |||
| 98e028fe19 | |||
| 28eae745d4 | |||
| 59f5ac5741 | |||
| 0bf6dd74ca | |||
| 1796921c20 | |||
| f44553d4a5 | |||
| 6cf551a8bf | |||
| 155687f3bb | |||
| 139b53ffbd | |||
| 30dc1a7053 | |||
| 16ce5daa9e | |||
| 2ca9562f02 | |||
| 859bfc739f | |||
| 70a6cbed84 | |||
| bb6090a135 | |||
| e65a2b65cb | |||
| 653abe8c85 | |||
| eec6961174 | |||
| 10e89abaab | |||
| 438ba6ba9b | |||
| 4f33ef782f | |||
| b13eace9da | |||
| 24d76d65ca | |||
| 8ff87ec2a8 | |||
| 5f34e17bee | |||
| 616158b0f1 | |||
| 7eaa2f9a73 | |||
| fff495d88c | |||
| 3a2b47c791 | |||
| 220a356574 | |||
| dc1471b4fb | |||
| 699bdf6961 | |||
| 689000798e | |||
| 666be5a2bc | |||
| cae562ee16 | |||
| 70247bb3d8 | |||
| ce8b45798f | |||
| 4d9cc09039 | |||
| a21b33478c | |||
| 26badc5524 | |||
| 8503a2239e | |||
| dbe6265ae3 | |||
| ba911aa3b9 | |||
| 858d1802e6 | |||
| 1b4b65cc37 | |||
| 77504d9130 | |||
| ce0952844f | |||
| 5d225409c3 | |||
| 24e24f8d7f | |||
| 0b54ca8404 | |||
| cd827b0e9a | |||
| 9c05deec4b | |||
| f1bb8cec80 | |||
| be9a2a0da0 | |||
| 7e3460c355 | |||
| 3937377f94 | |||
| f0da850116 | |||
| 827eeb59b9 | |||
| 48394cd0cd | |||
| 619c7d8553 | |||
| e131382cfe | |||
| 5c5c5e0e37 | |||
| eba475e659 | |||
| 025cd195c7 | |||
| 45e441bf1a | |||
| 4868c67b9e | |||
| eb03509c26 | |||
| a1c9c833f5 | |||
| 1fa78bf7a7 | |||
| 9714ac5004 | |||
| d2c530bb3a | |||
| 6b5f589bc0 | |||
| 74fe8b2c6d | |||
| 28b1b18022 | |||
| 0fe8f05b73 | |||
| 30aa35357f | |||
| 1a0698a72b | |||
| e547c89ce0 | |||
| bb88a1183f | |||
| 77122aa9ee | |||
| 4985b4727b |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -7,3 +7,5 @@ promt
|
|||||||
*.log
|
*.log
|
||||||
package-lock.json
|
package-lock.json
|
||||||
data
|
data
|
||||||
|
promt
|
||||||
|
LICENSE
|
||||||
201
LICENSE
201
LICENSE
@@ -1,201 +0,0 @@
|
|||||||
Apache License
|
|
||||||
Version 2.0, January 2004
|
|
||||||
http://www.apache.org/licenses/
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
||||||
|
|
||||||
1. Definitions.
|
|
||||||
|
|
||||||
"License" shall mean the terms and conditions for use, reproduction,
|
|
||||||
and distribution as defined by Sections 1 through 9 of this document.
|
|
||||||
|
|
||||||
"Licensor" shall mean the copyright owner or entity authorized by
|
|
||||||
the copyright owner that is granting the License.
|
|
||||||
|
|
||||||
"Legal Entity" shall mean the union of the acting entity and all
|
|
||||||
other entities that control, are controlled by, or are under common
|
|
||||||
control with that entity. For the purposes of this definition,
|
|
||||||
"control" means (i) the power, direct or indirect, to cause the
|
|
||||||
direction or management of such entity, whether by contract or
|
|
||||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
||||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
||||||
|
|
||||||
"You" (or "Your") shall mean an individual or Legal Entity
|
|
||||||
exercising permissions granted by this License.
|
|
||||||
|
|
||||||
"Source" form shall mean the preferred form for making modifications,
|
|
||||||
including but not limited to software source code, documentation
|
|
||||||
source, and configuration files.
|
|
||||||
|
|
||||||
"Object" form shall mean any form resulting from mechanical
|
|
||||||
transformation or translation of a Source form, including but
|
|
||||||
not limited to compiled object code, generated documentation,
|
|
||||||
and conversions to other media types.
|
|
||||||
|
|
||||||
"Work" shall mean the work of authorship, whether in Source or
|
|
||||||
Object form, made available under the License, as indicated by a
|
|
||||||
copyright notice that is included in or attached to the work
|
|
||||||
(an example is provided in the Appendix below).
|
|
||||||
|
|
||||||
"Derivative Works" shall mean any work, whether in Source or Object
|
|
||||||
form, that is based on (or derived from) the Work and for which the
|
|
||||||
editorial revisions, annotations, elaborations, or other modifications
|
|
||||||
represent, as a whole, an original work of authorship. For the purposes
|
|
||||||
of this License, Derivative Works shall not include works that remain
|
|
||||||
separable from, or merely link (or bind by name) to the interfaces of,
|
|
||||||
the Work and Derivative Works thereof.
|
|
||||||
|
|
||||||
"Contribution" shall mean any work of authorship, including
|
|
||||||
the original version of the Work and any modifications or additions
|
|
||||||
to that Work or Derivative Works thereof, that is intentionally
|
|
||||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
||||||
or by an individual or Legal Entity authorized to submit on behalf of
|
|
||||||
the copyright owner. For the purposes of this definition, "submitted"
|
|
||||||
means any form of electronic, verbal, or written communication sent
|
|
||||||
to the Licensor or its representatives, including but not limited to
|
|
||||||
communication on electronic mailing lists, source code control systems,
|
|
||||||
and issue tracking systems that are managed by, or on behalf of, the
|
|
||||||
Licensor for the purpose of discussing and improving the Work, but
|
|
||||||
excluding communication that is conspicuously marked or otherwise
|
|
||||||
designated in writing by the copyright owner as "Not a Contribution."
|
|
||||||
|
|
||||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
||||||
on behalf of whom a Contribution has been received by Licensor and
|
|
||||||
subsequently incorporated within the Work.
|
|
||||||
|
|
||||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
copyright license to reproduce, prepare Derivative Works of,
|
|
||||||
publicly display, publicly perform, sublicense, and distribute the
|
|
||||||
Work and such Derivative Works in Source or Object form.
|
|
||||||
|
|
||||||
3. Grant of Patent License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
(except as stated in this section) patent license to make, have made,
|
|
||||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
||||||
where such license applies only to those patent claims licensable
|
|
||||||
by such Contributor that are necessarily infringed by their
|
|
||||||
Contribution(s) alone or by combination of their Contribution(s)
|
|
||||||
with the Work to which such Contribution(s) was submitted. If You
|
|
||||||
institute patent litigation against any entity (including a
|
|
||||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
||||||
or a Contribution incorporated within the Work constitutes direct
|
|
||||||
or contributory patent infringement, then any patent licenses
|
|
||||||
granted to You under this License for that Work shall terminate
|
|
||||||
as of the date such litigation is filed.
|
|
||||||
|
|
||||||
4. Redistribution. You may reproduce and distribute copies of the
|
|
||||||
Work or Derivative Works thereof in any medium, with or without
|
|
||||||
modifications, and in Source or Object form, provided that You
|
|
||||||
meet the following conditions:
|
|
||||||
|
|
||||||
(a) You must give any other recipients of the Work or
|
|
||||||
Derivative Works a copy of this License; and
|
|
||||||
|
|
||||||
(b) You must cause any modified files to carry prominent notices
|
|
||||||
stating that You changed the files; and
|
|
||||||
|
|
||||||
(c) You must retain, in the Source form of any Derivative Works
|
|
||||||
that You distribute, all copyright, patent, trademark, and
|
|
||||||
attribution notices from the Source form of the Work,
|
|
||||||
excluding those notices that do not pertain to any part of
|
|
||||||
the Derivative Works; and
|
|
||||||
|
|
||||||
(d) If the Work includes a "NOTICE" text file as part of its
|
|
||||||
distribution, then any Derivative Works that You distribute must
|
|
||||||
include a readable copy of the attribution notices contained
|
|
||||||
within such NOTICE file, excluding those notices that do not
|
|
||||||
pertain to any part of the Derivative Works, in at least one
|
|
||||||
of the following places: within a NOTICE text file distributed
|
|
||||||
as part of the Derivative Works; within the Source form or
|
|
||||||
documentation, if provided along with the Derivative Works; or,
|
|
||||||
within a display generated by the Derivative Works, if and
|
|
||||||
wherever such third-party notices normally appear. The contents
|
|
||||||
of the NOTICE file are for informational purposes only and
|
|
||||||
do not modify the License. You may add Your own attribution
|
|
||||||
notices within Derivative Works that You distribute, alongside
|
|
||||||
or as an addendum to the NOTICE text from the Work, provided
|
|
||||||
that such additional attribution notices cannot be construed
|
|
||||||
as modifying the License.
|
|
||||||
|
|
||||||
You may add Your own copyright statement to Your modifications and
|
|
||||||
may provide additional or different license terms and conditions
|
|
||||||
for use, reproduction, or distribution of Your modifications, or
|
|
||||||
for any such Derivative Works as a whole, provided Your use,
|
|
||||||
reproduction, and distribution of the Work otherwise complies with
|
|
||||||
the conditions stated in this License.
|
|
||||||
|
|
||||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
||||||
any Contribution intentionally submitted for inclusion in the Work
|
|
||||||
by You to the Licensor shall be under the terms and conditions of
|
|
||||||
this License, without any additional terms or conditions.
|
|
||||||
Notwithstanding the above, nothing herein shall supersede or modify
|
|
||||||
the terms of any separate license agreement you may have executed
|
|
||||||
with Licensor regarding such Contributions.
|
|
||||||
|
|
||||||
6. Trademarks. This License does not grant permission to use the trade
|
|
||||||
names, trademarks, service marks, or product names of the Licensor,
|
|
||||||
except as required for reasonable and customary use in describing the
|
|
||||||
origin of the Work and reproducing the content of the NOTICE file.
|
|
||||||
|
|
||||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
||||||
agreed to in writing, Licensor provides the Work (and each
|
|
||||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
||||||
implied, including, without limitation, any warranties or conditions
|
|
||||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
||||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
||||||
appropriateness of using or redistributing the Work and assume any
|
|
||||||
risks associated with Your exercise of permissions under this License.
|
|
||||||
|
|
||||||
8. Limitation of Liability. In no event and under no legal theory,
|
|
||||||
whether in tort (including negligence), contract, or otherwise,
|
|
||||||
unless required by applicable law (such as deliberate and grossly
|
|
||||||
negligent acts) or agreed to in writing, shall any Contributor be
|
|
||||||
liable to You for damages, including any direct, indirect, special,
|
|
||||||
incidental, or consequential damages of any character arising as a
|
|
||||||
result of this License or out of the use or inability to use the
|
|
||||||
Work (including but not limited to damages for loss of goodwill,
|
|
||||||
work stoppage, computer failure or malfunction, or any and all
|
|
||||||
other commercial damages or losses), even if such Contributor
|
|
||||||
has been advised of the possibility of such damages.
|
|
||||||
|
|
||||||
9. Accepting Warranty or Additional Liability. While redistributing
|
|
||||||
the Work or Derivative Works thereof, You may choose to offer,
|
|
||||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
||||||
or other liability obligations and/or rights consistent with this
|
|
||||||
License. However, in accepting such obligations, You may act only
|
|
||||||
on Your own behalf and on Your sole responsibility, not on behalf
|
|
||||||
of any other Contributor, and only if You agree to indemnify,
|
|
||||||
defend, and hold each Contributor harmless for any liability
|
|
||||||
incurred by, or claims asserted against, such Contributor by reason
|
|
||||||
of your accepting any such warranty or additional liability.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
APPENDIX: How to apply the Apache License to your work.
|
|
||||||
|
|
||||||
To apply the Apache License to your work, attach the following
|
|
||||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
||||||
replaced with your own identifying information. (Don't include
|
|
||||||
the brackets!) The text should be enclosed in the appropriate
|
|
||||||
comment syntax for the file format. We also recommend that a
|
|
||||||
file or class name and description of purpose be included on the
|
|
||||||
same "printed page" as the copyright notice for easier
|
|
||||||
identification within third-party archives.
|
|
||||||
|
|
||||||
Copyright [yyyy] [name of copyright owner]
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
157
README.md
157
README.md
@@ -1,157 +0,0 @@
|
|||||||
# School CRM - Система управления задачами для образовательных учреждений
|
|
||||||
|
|
||||||
## Описание проекта
|
|
||||||
|
|
||||||
School CRM - это веб-приложение для управления задачами и проектами в образовательных учреждениях. Система позволяет эффективно организовывать работу между администрацией, учителями и сотрудниками школы.
|
|
||||||
|
|
||||||
## Основные возможности
|
|
||||||
|
|
||||||
### 🔐 Многоуровневая аутентификация
|
|
||||||
- **Локальная авторизация** - встроенная система пользователей
|
|
||||||
- **LDAP-интеграция** - поддержка доменной аутентификации
|
|
||||||
- **Ролевая модель**:
|
|
||||||
- Администратор - полный доступ ко всем функциям
|
|
||||||
- Учитель - создание и управление своими задачами
|
|
||||||
|
|
||||||
### 📋 Управление задачами
|
|
||||||
- **Создание задач** с детальным описанием и сроками
|
|
||||||
- **Назначение исполнителей** - несколько пользователей на одну задачу
|
|
||||||
- **Индивидуальные сроки** для каждого исполнителя
|
|
||||||
- **Копирование задач** - быстрая репликация существующих шаблонов
|
|
||||||
- **Мягкое удаление** с возможностью восстановления
|
|
||||||
|
|
||||||
### 📊 Система статусов
|
|
||||||
- ⏳ Назначена
|
|
||||||
- 🔄 В работе
|
|
||||||
- ✅ Выполнена
|
|
||||||
- ❗ Просрочена (автоматическое определение)
|
|
||||||
|
|
||||||
### 📎 Работа с файлами
|
|
||||||
- **Загрузка документов** - до 15 файлов, максимум 300MB
|
|
||||||
- **Организованное хранение** - структура папок по задачам и пользователям
|
|
||||||
- **Безопасное скачивание** - проверка прав доступа
|
|
||||||
|
|
||||||
### 📝 Логирование и аналитика
|
|
||||||
- **Детальная история действий** - кто, что и когда сделал
|
|
||||||
- **Отслеживание изменений** статусов и назначений
|
|
||||||
- **Мониторинг загрузки файлов**
|
|
||||||
|
|
||||||
## Технологический стек
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
- **Node.js** - серверная платформа
|
|
||||||
- **Express.js** - веб-фреймворк
|
|
||||||
- **SQLite** - база данных
|
|
||||||
- **Multer** - обработка загрузки файлов
|
|
||||||
- **Bcryptjs** - хэширование паролей
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
- **Чистый JavaScript** - без зависимостей от фреймворков
|
|
||||||
- **HTML5/CSS3** - адаптивный интерфейс
|
|
||||||
- **AJAX** - асинхронные запросы к API
|
|
||||||
|
|
||||||
### Безопасность
|
|
||||||
- **Сессии** - управление аутентификацией
|
|
||||||
- **Проверка прав доступа** - на всех уровнях
|
|
||||||
- **Валидация данных** - клиентская и серверная
|
|
||||||
|
|
||||||
## Установка и запуск
|
|
||||||
|
|
||||||
### Предварительные требования
|
|
||||||
- Node.js 14+
|
|
||||||
- npm или yarn
|
|
||||||
|
|
||||||
### Шаги установки
|
|
||||||
1. Клонировать репозиторий
|
|
||||||
2. Установить зависимости: `npm install`
|
|
||||||
3. Настроить переменные окружения в `.env`
|
|
||||||
4. Запустить сервер: `npm start`
|
|
||||||
5. Открыть в браузере: `http://localhost:3000`
|
|
||||||
|
|
||||||
### Конфигурация
|
|
||||||
Создайте файл `.env` со следующими параметрами:
|
|
||||||
PORT=3000
|
|
||||||
SESSION_SECRET=your_secret_key
|
|
||||||
LDAP_AUTH_URL=your_ldap_endpoint
|
|
||||||
ALLOWED_GROUPS=admin_teachers,department_heads
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
### Аутентификация
|
|
||||||
- `POST /api/login` - вход в систему
|
|
||||||
- `POST /api/logout` - выход
|
|
||||||
- `GET /api/user` - информация о текущем пользователе
|
|
||||||
|
|
||||||
### Задачи
|
|
||||||
- `GET /api/tasks` - список задач
|
|
||||||
- `POST /api/tasks` - создание задачи
|
|
||||||
- `GET /api/tasks/:id` - получение задачи
|
|
||||||
- `PUT /api/tasks/:id` - обновление задачи
|
|
||||||
- `DELETE /api/tasks/:id` - удаление задачи
|
|
||||||
- `POST /api/tasks/:id/copy` - копирование задачи
|
|
||||||
|
|
||||||
### Файлы
|
|
||||||
- `POST /api/tasks/:id/files` - загрузка файлов
|
|
||||||
- `GET /api/tasks/:id/files` - список файлов задачи
|
|
||||||
- `GET /api/files/:id/download` - скачивание файла
|
|
||||||
|
|
||||||
### Логи
|
|
||||||
- `GET /api/activity-logs` - история действий
|
|
||||||
|
|
||||||
## Тестовые пользователи
|
|
||||||
|
|
||||||
После первого запуска создаются тестовые пользователи:
|
|
||||||
- **Администратор**: director / director123
|
|
||||||
- **Завуч**: zavuch / zavuch123
|
|
||||||
- **Учитель**: teacher / teacher123
|
|
||||||
|
|
||||||
## Лицензия
|
|
||||||
|
|
||||||
MIT License - разрешается свободное использование и модификация.
|
|
||||||
|
|
||||||
## Поддержка
|
|
||||||
|
|
||||||
Для вопросов и предложений создавайте issues в репозитории проекта.
|
|
||||||
|
|
||||||
## Структура проекта
|
|
||||||
school-crm/
|
|
||||||
├── server.js # Основной сервер
|
|
||||||
├── auth.js # Логика аутентификации
|
|
||||||
├── database.js # Работа с базой данных
|
|
||||||
├── package.json # Зависимости
|
|
||||||
├── .env # Конфигурация
|
|
||||||
├── public/ # Статические файлы
|
|
||||||
│ ├── index.html # Главная страница
|
|
||||||
│ ├── style.css # Стили
|
|
||||||
│ └── script.js # Клиентский код
|
|
||||||
└── uploads/ # Загруженные файлы
|
|
||||||
└── tasks/ # Файлы задач
|
|
||||||
|
|
||||||
.env
|
|
||||||
# Первые 3 пользователя системы
|
|
||||||
USER_1_LOGIN=director
|
|
||||||
USER_1_PASSWORD=director123
|
|
||||||
USER_1_NAME=Директор школы
|
|
||||||
USER_1_EMAIL=director@school.ru
|
|
||||||
|
|
||||||
USER_2_LOGIN=zavuch
|
|
||||||
USER_2_PASSWORD=zavuch123
|
|
||||||
USER_2_NAME=Завуч
|
|
||||||
USER_2_EMAIL=zavuch@school.ru
|
|
||||||
|
|
||||||
USER_3_LOGIN=teacher
|
|
||||||
USER_3_PASSWORD=teacher123
|
|
||||||
USER_3_NAME=Учитель математики
|
|
||||||
USER_3_EMAIL=math@school.ru
|
|
||||||
|
|
||||||
SESSION_SECRET=your_secret_key_here
|
|
||||||
|
|
||||||
# LDAP настройки
|
|
||||||
LDAP_AUTH_URL=https://ldap.ru/api/auth
|
|
||||||
ALLOWED_GROUPS=admin
|
|
||||||
# Добавьте эту строку в ваш .env файл
|
|
||||||
JWT_SECRET=your_super_secret_jwt_key_here_make_it_very_long_and_secure
|
|
||||||
# Настройки сервиса уведомлений
|
|
||||||
NOTIFICATION_SERVICE_URL=https://alarm.ru/api/send-message
|
|
||||||
NOTIFICATION_SERVICE_LOGIN=kalugin66
|
|
||||||
NOTIFICATION_SERVICE_PASSWORD=kalugin66
|
|
||||||
423
admin-server.js
423
admin-server.js
@@ -853,4 +853,427 @@ router.get('/admin/export', requireAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Получение профилей пользователей с настройками уведомлений
|
||||||
|
router.get('/admin/user-profiles', requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { getDb } = require('./database');
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.login,
|
||||||
|
u.name,
|
||||||
|
u.email as user_email,
|
||||||
|
u.role,
|
||||||
|
u.auth_type,
|
||||||
|
u.groups,
|
||||||
|
u.created_at,
|
||||||
|
u.last_login,
|
||||||
|
us.email_notifications,
|
||||||
|
us.notification_email,
|
||||||
|
us.telegram_notifications,
|
||||||
|
us.telegram_chat_id,
|
||||||
|
us.vk_notifications,
|
||||||
|
us.vk_user_id,
|
||||||
|
us.updated_at as settings_updated_at
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN user_settings us ON u.id = us.user_id
|
||||||
|
ORDER BY u.name
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.all(query, [], (err, profiles) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Ошибка получения профилей пользователей:', err);
|
||||||
|
return res.status(500).json({ error: 'Ошибка сервера' });
|
||||||
|
}
|
||||||
|
res.json(profiles);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка:', error);
|
||||||
|
res.status(500).json({ error: 'Ошибка сервера' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Получение конкретного профиля пользователя
|
||||||
|
router.get('/admin/user-profiles/:id', requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { getDb } = require('./database');
|
||||||
|
const db = getDb();
|
||||||
|
const userId = req.params.id;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.login,
|
||||||
|
u.name,
|
||||||
|
u.email as user_email,
|
||||||
|
u.role,
|
||||||
|
u.auth_type,
|
||||||
|
u.groups,
|
||||||
|
u.created_at,
|
||||||
|
u.last_login,
|
||||||
|
us.email_notifications,
|
||||||
|
us.notification_email,
|
||||||
|
us.telegram_notifications,
|
||||||
|
us.telegram_chat_id,
|
||||||
|
us.vk_notifications,
|
||||||
|
us.vk_user_id,
|
||||||
|
us.created_at as settings_created_at,
|
||||||
|
us.updated_at as settings_updated_at
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN user_settings us ON u.id = us.user_id
|
||||||
|
WHERE u.id = ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.get(query, [userId], (err, profile) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Ошибка получения профиля пользователя:', err);
|
||||||
|
return res.status(500).json({ error: 'Ошибка сервера' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
return res.status(404).json({ error: 'Пользователь не найден' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(profile);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка:', error);
|
||||||
|
res.status(500).json({ error: 'Ошибка сервера' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обновление настроек уведомлений пользователя (для админа)
|
||||||
|
router.put('/admin/user-profiles/:id/notification-settings', requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { getDb } = require('./database');
|
||||||
|
const db = getDb();
|
||||||
|
const userId = req.params.id;
|
||||||
|
const {
|
||||||
|
email_notifications,
|
||||||
|
notification_email,
|
||||||
|
telegram_notifications,
|
||||||
|
telegram_chat_id,
|
||||||
|
vk_notifications,
|
||||||
|
vk_user_id
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Валидация email
|
||||||
|
if (email_notifications && notification_email) {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(notification_email)) {
|
||||||
|
return res.status(400).json({ error: 'Неверный формат email' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем существование записи
|
||||||
|
db.get("SELECT id FROM user_settings WHERE user_id = ?", [userId], (err, existing) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Ошибка проверки настроек:', err);
|
||||||
|
return res.status(500).json({ error: 'Ошибка сервера' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Обновляем существующие настройки
|
||||||
|
db.run(
|
||||||
|
`UPDATE user_settings SET
|
||||||
|
email_notifications = ?,
|
||||||
|
notification_email = ?,
|
||||||
|
telegram_notifications = ?,
|
||||||
|
telegram_chat_id = ?,
|
||||||
|
vk_notifications = ?,
|
||||||
|
vk_user_id = ?,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE user_id = ?`,
|
||||||
|
[
|
||||||
|
email_notifications ? 1 : 0,
|
||||||
|
notification_email || '',
|
||||||
|
telegram_notifications ? 1 : 0,
|
||||||
|
telegram_chat_id || '',
|
||||||
|
vk_notifications ? 1 : 0,
|
||||||
|
vk_user_id || '',
|
||||||
|
userId
|
||||||
|
],
|
||||||
|
function(updateErr) {
|
||||||
|
if (updateErr) {
|
||||||
|
console.error('Ошибка обновления настроек:', updateErr);
|
||||||
|
return res.status(500).json({ error: 'Ошибка сервера' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Логируем действие
|
||||||
|
const { logActivity } = require('./database');
|
||||||
|
if (logActivity) {
|
||||||
|
logActivity(0, req.session.user.id, 'USER_SETTINGS_UPDATED', `Админ обновил настройки уведомлений пользователя ${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ Админ обновил настройки пользователя ${userId}`);
|
||||||
|
res.json({ success: true, message: 'Настройки уведомлений обновлены' });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Создаем новые настройки
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO user_settings
|
||||||
|
(user_id, email_notifications, notification_email,
|
||||||
|
telegram_notifications, telegram_chat_id,
|
||||||
|
vk_notifications, vk_user_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
email_notifications ? 1 : 0,
|
||||||
|
notification_email || '',
|
||||||
|
telegram_notifications ? 1 : 0,
|
||||||
|
telegram_chat_id || '',
|
||||||
|
vk_notifications ? 1 : 0,
|
||||||
|
vk_user_id || ''
|
||||||
|
],
|
||||||
|
function(insertErr) {
|
||||||
|
if (insertErr) {
|
||||||
|
console.error('Ошибка создания настроек:', insertErr);
|
||||||
|
return res.status(500).json({ error: 'Ошибка сервера' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Логируем действие
|
||||||
|
const { logActivity } = require('./database');
|
||||||
|
if (logActivity) {
|
||||||
|
logActivity(0, req.session.user.id, 'USER_SETTINGS_CREATED', `Админ создал настройки уведомлений пользователя ${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ Админ создал настройки пользователя ${userId}`);
|
||||||
|
res.json({ success: true, message: 'Настройки уведомлений созданы' });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка:', error);
|
||||||
|
res.status(500).json({ error: 'Ошибка сервера' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Массовое обновление email уведомлений для нескольких пользователей
|
||||||
|
router.post('/admin/bulk-email-settings', requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { getDb } = require('./database');
|
||||||
|
const db = getDb();
|
||||||
|
const { users, email_notifications, notification_email } = req.body;
|
||||||
|
|
||||||
|
if (!Array.isArray(users) || users.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'Выберите пользователей' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (email_notifications && !notification_email) {
|
||||||
|
return res.status(400).json({ error: 'Укажите email для уведомлений' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Валидация email
|
||||||
|
if (email_notifications && notification_email) {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(notification_email)) {
|
||||||
|
return res.status(400).json({ error: 'Неверный формат email' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = {
|
||||||
|
success: 0,
|
||||||
|
failed: 0,
|
||||||
|
errors: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Обновляем каждого пользователя в транзакции
|
||||||
|
db.serialize(() => {
|
||||||
|
db.run("BEGIN TRANSACTION");
|
||||||
|
|
||||||
|
users.forEach(userId => {
|
||||||
|
// Проверяем существование записи
|
||||||
|
db.get("SELECT id FROM user_settings WHERE user_id = ?", [userId], (err, existing) => {
|
||||||
|
if (err) {
|
||||||
|
results.failed++;
|
||||||
|
results.errors.push({ userId, error: err.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Обновляем существующие настройки
|
||||||
|
db.run(
|
||||||
|
`UPDATE user_settings SET
|
||||||
|
email_notifications = ?,
|
||||||
|
notification_email = ?,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE user_id = ?`,
|
||||||
|
[
|
||||||
|
email_notifications ? 1 : 0,
|
||||||
|
email_notifications ? notification_email : '',
|
||||||
|
userId
|
||||||
|
],
|
||||||
|
function(updateErr) {
|
||||||
|
if (updateErr) {
|
||||||
|
results.failed++;
|
||||||
|
results.errors.push({ userId, error: updateErr.message });
|
||||||
|
} else {
|
||||||
|
results.success++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Создаем новые настройки с значениями по умолчанию
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO user_settings
|
||||||
|
(user_id, email_notifications, notification_email,
|
||||||
|
telegram_notifications, telegram_chat_id,
|
||||||
|
vk_notifications, vk_user_id)
|
||||||
|
VALUES (?, ?, ?, 0, '', 0, '')`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
email_notifications ? 1 : 0,
|
||||||
|
email_notifications ? notification_email : ''
|
||||||
|
],
|
||||||
|
function(insertErr) {
|
||||||
|
if (insertErr) {
|
||||||
|
results.failed++;
|
||||||
|
results.errors.push({ userId, error: insertErr.message });
|
||||||
|
} else {
|
||||||
|
results.success++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
db.run("COMMIT", (commitErr) => {
|
||||||
|
if (commitErr) {
|
||||||
|
console.error('Ошибка коммита транзакции:', commitErr);
|
||||||
|
return res.status(500).json({ error: 'Ошибка транзакции' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Логируем действие
|
||||||
|
const { logActivity } = require('./database');
|
||||||
|
if (logActivity) {
|
||||||
|
logActivity(0, req.session.user.id, 'BULK_SETTINGS_UPDATED',
|
||||||
|
`Админ массово обновил настройки для ${users.length} пользователей`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ Массовое обновление: успешно ${results.success}, ошибок ${results.failed}`);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Настройки обновлены',
|
||||||
|
results
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка:', error);
|
||||||
|
res.status(500).json({ error: 'Ошибка сервера' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Отправка тестового уведомления пользователю
|
||||||
|
router.post('/admin/user-profiles/:id/test-notification', requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { getDb } = require('./database');
|
||||||
|
const db = getDb();
|
||||||
|
const userId = req.params.id;
|
||||||
|
const notificationType = req.body.notification_type || 'test';
|
||||||
|
|
||||||
|
// Получаем информацию о пользователе
|
||||||
|
db.get(`
|
||||||
|
SELECT u.id, u.name, u.email, us.email_notifications, us.notification_email
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN user_settings us ON u.id = us.user_id
|
||||||
|
WHERE u.id = ?
|
||||||
|
`, [userId], async (err, user) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Ошибка получения пользователя:', err);
|
||||||
|
return res.status(500).json({ error: 'Ошибка сервера' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ error: 'Пользователь не найден' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, включены ли email уведомления
|
||||||
|
if (!user.email_notifications) {
|
||||||
|
return res.status(400).json({ error: 'Email уведомления отключены у пользователя' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailTo = user.notification_email || user.email;
|
||||||
|
if (!emailTo) {
|
||||||
|
return res.status(400).json({ error: 'У пользователя не указан email' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправляем тестовое уведомление
|
||||||
|
const emailNotifications = require('./email-notifications');
|
||||||
|
const emailSent = await emailNotifications.sendEmailNotification(
|
||||||
|
emailTo,
|
||||||
|
'Тестовое уведомление от School CRM',
|
||||||
|
`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; line-height: 1.6; }
|
||||||
|
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||||
|
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; border-radius: 10px 10px 0 0; }
|
||||||
|
.content { padding: 20px; border: 1px solid #ddd; border-top: none; }
|
||||||
|
.button { display: inline-block; background: #667eea; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; }
|
||||||
|
.footer { margin-top: 20px; font-size: 12px; color: #666; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h2>✅ Тестовое уведомление</h2>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<p>Здравствуйте, ${user.name}!</p>
|
||||||
|
<p>Это тестовое уведомление от системы School CRM.</p>
|
||||||
|
<p>Если вы получили это письмо, значит настройки уведомлений работают корректно.</p>
|
||||||
|
<br>
|
||||||
|
<p><strong>Информация о тесте:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>Получатель: ${emailTo}</li>
|
||||||
|
<li>Отправитель: Администратор</li>
|
||||||
|
<li>Время: ${new Date().toLocaleString('ru-RU')}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>Это тестовое сообщение от School CRM системы.</p>
|
||||||
|
<p>Вы можете изменить настройки уведомлений в личном кабинете.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (emailSent) {
|
||||||
|
// Логируем действие
|
||||||
|
const { logActivity } = require('./database');
|
||||||
|
if (logActivity) {
|
||||||
|
logActivity(0, req.session.user.id, 'TEST_NOTIFICATION_SENT',
|
||||||
|
`Админ отправил тестовое уведомление пользователю ${user.name} (${emailTo})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Тестовое уведомление отправлено',
|
||||||
|
email: emailTo
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ error: 'Не удалось отправить уведомление' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка:', error);
|
||||||
|
res.status(500).json({ error: 'Ошибка сервера' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
543
api-chat.js
Normal file
543
api-chat.js
Normal file
@@ -0,0 +1,543 @@
|
|||||||
|
// api-chat.js - API для чата задач
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
module.exports = function(app, db, upload) {
|
||||||
|
// Получаем функции из database.js
|
||||||
|
let logActivity, checkTaskAccess;
|
||||||
|
|
||||||
|
// Пытаемся импортировать функции
|
||||||
|
try {
|
||||||
|
const dbModule = require('./database');
|
||||||
|
logActivity = dbModule.logActivity;
|
||||||
|
checkTaskAccess = dbModule.checkTaskAccess;
|
||||||
|
console.log('✅ Функции database.js загружены в api-chat');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Ошибка загрузки функций из database.js:', error);
|
||||||
|
// Создаем заглушки
|
||||||
|
logActivity = (taskId, userId, action, details) => {
|
||||||
|
console.log(`[LOG] Task ${taskId}, User ${userId}: ${action} - ${details}`);
|
||||||
|
};
|
||||||
|
checkTaskAccess = (userId, taskId, callback) => {
|
||||||
|
// Заглушка - даем доступ всем
|
||||||
|
callback(null, true);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware для аутентификации
|
||||||
|
const requireAuth = (req, res, next) => {
|
||||||
|
if (!req.session || !req.session.user) {
|
||||||
|
return res.status(401).json({ error: 'Требуется аутентификация' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// GET /api/chat/tasks/:taskId/messages - Получить сообщения чата задачи
|
||||||
|
router.get('/api/chat/tasks/:taskId/messages', requireAuth, (req, res) => {
|
||||||
|
const { taskId } = req.params;
|
||||||
|
const userId = req.session.user.id;
|
||||||
|
const { before, limit = 50 } = req.query;
|
||||||
|
|
||||||
|
console.log(`📨 Запрос сообщений для задачи ${taskId} от пользователя ${userId}`);
|
||||||
|
|
||||||
|
// Проверяем доступ к задаче
|
||||||
|
checkTaskAccess(userId, taskId, (err, hasAccess) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('❌ Ошибка проверки доступа:', err);
|
||||||
|
return res.status(500).json({ error: 'Ошибка проверки доступа' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
return res.status(403).json({ error: 'Нет доступа к задаче' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сначала проверим существование таблицы
|
||||||
|
db.get("SELECT name FROM sqlite_master WHERE type='table' AND name='task_chat_messages'", [], (tableErr, tableExists) => {
|
||||||
|
if (tableErr || !tableExists) {
|
||||||
|
console.error('❌ Таблица task_chat_messages не существует');
|
||||||
|
return res.status(500).json({ error: 'Таблица чата не создана', messages: [], hasMore: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
let query = `
|
||||||
|
SELECT
|
||||||
|
m.*,
|
||||||
|
u.name as user_name,
|
||||||
|
u.login as user_login,
|
||||||
|
rm.message as reply_to_message,
|
||||||
|
ru.name as reply_to_user_name
|
||||||
|
FROM task_chat_messages m
|
||||||
|
LEFT JOIN users u ON m.user_id = u.id
|
||||||
|
LEFT JOIN task_chat_messages rm ON m.reply_to_id = rm.id
|
||||||
|
LEFT JOIN users ru ON rm.user_id = ru.id
|
||||||
|
WHERE m.task_id = ? AND m.is_deleted = 0
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params = [taskId];
|
||||||
|
|
||||||
|
if (before) {
|
||||||
|
query += ` AND m.created_at < ?`;
|
||||||
|
params.push(before);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ` ORDER BY m.created_at DESC LIMIT ?`;
|
||||||
|
params.push(parseInt(limit));
|
||||||
|
|
||||||
|
db.all(query, params, (err, messages) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('❌ Ошибка получения сообщений:', err);
|
||||||
|
return res.status(500).json({ error: 'Ошибка получения сообщений', details: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем файлы для каждого сообщения
|
||||||
|
const messageIds = messages.map(m => m.id);
|
||||||
|
|
||||||
|
if (messageIds.length === 0) {
|
||||||
|
return res.json({ messages: [], hasMore: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholders = messageIds.map(() => '?').join(',');
|
||||||
|
|
||||||
|
db.all(`
|
||||||
|
SELECT * FROM task_chat_files
|
||||||
|
WHERE message_id IN (${placeholders})
|
||||||
|
`, messageIds, (fileErr, files) => {
|
||||||
|
if (fileErr) {
|
||||||
|
console.error('❌ Ошибка получения файлов:', fileErr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Группируем файлы по сообщениям
|
||||||
|
const filesByMessage = {};
|
||||||
|
if (files) {
|
||||||
|
files.forEach(file => {
|
||||||
|
if (!filesByMessage[file.message_id]) {
|
||||||
|
filesByMessage[file.message_id] = [];
|
||||||
|
}
|
||||||
|
filesByMessage[file.message_id].push(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем файлы к сообщениям
|
||||||
|
const messagesWithFiles = messages.map(msg => ({
|
||||||
|
...msg,
|
||||||
|
files: filesByMessage[msg.id] || []
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Помечаем сообщения как прочитанные
|
||||||
|
if (messagesWithFiles.length > 0) {
|
||||||
|
const unreadMessageIds = messagesWithFiles
|
||||||
|
.filter(m => m.user_id != userId)
|
||||||
|
.map(m => m.id);
|
||||||
|
|
||||||
|
if (unreadMessageIds.length > 0) {
|
||||||
|
const unreadPlaceholders = unreadMessageIds.map(() => '?').join(',');
|
||||||
|
db.run(`
|
||||||
|
INSERT OR IGNORE INTO task_chat_reads (message_id, user_id)
|
||||||
|
SELECT id, ? FROM task_chat_messages
|
||||||
|
WHERE id IN (${unreadPlaceholders})
|
||||||
|
`, [userId, ...unreadMessageIds], (readErr) => {
|
||||||
|
if (readErr) console.error('❌ Ошибка отметки прочитанных:', readErr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
messages: messagesWithFiles,
|
||||||
|
hasMore: messages.length === parseInt(limit)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/chat/tasks/:taskId/messages - Отправить сообщение
|
||||||
|
router.post('/api/chat/tasks/:taskId/messages', requireAuth, upload.array('files', 5), (req, res) => {
|
||||||
|
const { taskId } = req.params;
|
||||||
|
const { message, reply_to_id } = req.body;
|
||||||
|
const userId = req.session.user.id;
|
||||||
|
|
||||||
|
console.log(`📝 Отправка сообщения в задачу ${taskId} от пользователя ${userId}`);
|
||||||
|
|
||||||
|
if (!message || message.trim() === '') {
|
||||||
|
return res.status(400).json({ error: 'Сообщение не может быть пустым' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем доступ к задаче
|
||||||
|
checkTaskAccess(userId, taskId, (err, hasAccess) => {
|
||||||
|
if (err || !hasAccess) {
|
||||||
|
return res.status(403).json({ error: 'Нет доступа к задаче' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем существование таблицы
|
||||||
|
db.get("SELECT name FROM sqlite_master WHERE type='table' AND name='task_chat_messages'", [], (tableErr, tableExists) => {
|
||||||
|
if (tableErr || !tableExists) {
|
||||||
|
console.error('❌ Таблица task_chat_messages не существует');
|
||||||
|
return res.status(500).json({ error: 'Система чата не инициализирована' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вставляем сообщение
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO task_chat_messages (task_id, user_id, message, reply_to_id)
|
||||||
|
VALUES (?, ?, ?, ?)`,
|
||||||
|
[taskId, userId, message.trim(), reply_to_id || null],
|
||||||
|
function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error('❌ Ошибка создания сообщения:', err);
|
||||||
|
return res.status(500).json({ error: 'Ошибка создания сообщения' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageId = this.lastID;
|
||||||
|
const uploadedFiles = [];
|
||||||
|
|
||||||
|
// Если есть файлы, сохраняем их
|
||||||
|
if (req.files && req.files.length > 0) {
|
||||||
|
const chatDir = path.join(__dirname, 'data', 'uploads', 'chat', taskId.toString());
|
||||||
|
if (!fs.existsSync(chatDir)) {
|
||||||
|
fs.mkdirSync(chatDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
let filesProcessed = 0;
|
||||||
|
|
||||||
|
req.files.forEach((file, index) => {
|
||||||
|
const fileExt = path.extname(file.originalname);
|
||||||
|
const fileName = `${messageId}_${Date.now()}_${index}${fileExt}`;
|
||||||
|
const filePath = path.join(chatDir, fileName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.renameSync(file.path, filePath);
|
||||||
|
} catch (renameErr) {
|
||||||
|
console.error('❌ Ошибка перемещения файла:', renameErr);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO task_chat_files (message_id, file_path, original_name, file_size, file_type)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
[messageId, filePath, file.originalname, file.size, file.mimetype],
|
||||||
|
function(fileErr) {
|
||||||
|
if (!fileErr) {
|
||||||
|
uploadedFiles.push({
|
||||||
|
id: this.lastID,
|
||||||
|
original_name: file.originalname,
|
||||||
|
file_size: file.size,
|
||||||
|
file_type: file.mimetype
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
filesProcessed++;
|
||||||
|
if (filesProcessed === req.files.length) {
|
||||||
|
finishTransaction();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
finishTransaction();
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishTransaction() {
|
||||||
|
// Логируем действие
|
||||||
|
if (logActivity) {
|
||||||
|
logActivity(parseInt(taskId), userId, 'CHAT_MESSAGE', 'Отправлено сообщение в чат');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем полную информацию о сообщении
|
||||||
|
db.get(`
|
||||||
|
SELECT
|
||||||
|
m.*,
|
||||||
|
u.name as user_name,
|
||||||
|
u.login as user_login,
|
||||||
|
rm.message as reply_to_message,
|
||||||
|
ru.name as reply_to_user_name
|
||||||
|
FROM task_chat_messages m
|
||||||
|
LEFT JOIN users u ON m.user_id = u.id
|
||||||
|
LEFT JOIN task_chat_messages rm ON m.reply_to_id = rm.id
|
||||||
|
LEFT JOIN users ru ON rm.user_id = ru.id
|
||||||
|
WHERE m.id = ?
|
||||||
|
`, [messageId], (err, newMessage) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('❌ Ошибка получения сообщения:', err);
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
messageId,
|
||||||
|
files: uploadedFiles
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
newMessage.files = uploadedFiles;
|
||||||
|
|
||||||
|
// Отправляем уведомления участникам задачи
|
||||||
|
// notifyTaskParticipants(taskId, userId, newMessage);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: newMessage
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/chat/messages/:messageId - Редактировать сообщение
|
||||||
|
router.put('/api/chat/messages/:messageId', requireAuth, (req, res) => {
|
||||||
|
const { messageId } = req.params;
|
||||||
|
const { message } = req.body;
|
||||||
|
const userId = req.session.user.id;
|
||||||
|
|
||||||
|
if (!message || message.trim() === '') {
|
||||||
|
return res.status(400).json({ error: 'Сообщение не может быть пустым' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.get(
|
||||||
|
'SELECT task_id, user_id FROM task_chat_messages WHERE id = ? AND is_deleted = 0',
|
||||||
|
[messageId],
|
||||||
|
(err, msg) => {
|
||||||
|
if (err || !msg) {
|
||||||
|
return res.status(404).json({ error: 'Сообщение не найдено' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что пользователь - автор сообщения или админ
|
||||||
|
if (parseInt(msg.user_id) !== parseInt(userId) && req.session.user.role !== 'admin') {
|
||||||
|
return res.status(403).json({ error: 'Нет прав для редактирования этого сообщения' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`UPDATE task_chat_messages
|
||||||
|
SET message = ?, is_edited = 1, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?`,
|
||||||
|
[message.trim(), messageId],
|
||||||
|
function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error('❌ Ошибка редактирования сообщения:', err);
|
||||||
|
return res.status(500).json({ error: 'Ошибка редактирования сообщения' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logActivity) {
|
||||||
|
logActivity(msg.task_id, userId, 'CHAT_MESSAGE_EDITED', 'Сообщение отредактировано');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Сообщение отредактировано'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/chat/messages/:messageId - Удалить сообщение (soft delete)
|
||||||
|
router.delete('/api/chat/messages/:messageId', requireAuth, (req, res) => {
|
||||||
|
const { messageId } = req.params;
|
||||||
|
const userId = req.session.user.id;
|
||||||
|
|
||||||
|
db.get(
|
||||||
|
'SELECT task_id, user_id FROM task_chat_messages WHERE id = ? AND is_deleted = 0',
|
||||||
|
[messageId],
|
||||||
|
(err, msg) => {
|
||||||
|
if (err || !msg) {
|
||||||
|
return res.status(404).json({ error: 'Сообщение не найдено' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что пользователь - автор сообщения или админ
|
||||||
|
if (parseInt(msg.user_id) !== parseInt(userId) && req.session.user.role !== 'admin') {
|
||||||
|
return res.status(403).json({ error: 'Нет прав для удаления этого сообщения' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
'UPDATE task_chat_messages SET is_deleted = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||||
|
[messageId],
|
||||||
|
function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error('❌ Ошибка удаления сообщения:', err);
|
||||||
|
return res.status(500).json({ error: 'Ошибка удаления сообщения' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logActivity) {
|
||||||
|
logActivity(msg.task_id, userId, 'CHAT_MESSAGE_DELETED', 'Сообщение удалено');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Сообщение удалено'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/chat/tasks/:taskId/unread-count - Получить количество непрочитанных сообщений
|
||||||
|
router.get('/api/chat/tasks/:taskId/unread-count', requireAuth, (req, res) => {
|
||||||
|
const { taskId } = req.params;
|
||||||
|
const userId = req.session.user.id;
|
||||||
|
|
||||||
|
checkTaskAccess(userId, taskId, (err, hasAccess) => {
|
||||||
|
if (err || !hasAccess) {
|
||||||
|
return res.status(403).json({ error: 'Нет доступа к задаче' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.get(`
|
||||||
|
SELECT COUNT(*) as unread_count
|
||||||
|
FROM task_chat_messages m
|
||||||
|
LEFT JOIN task_chat_reads r ON m.id = r.message_id AND r.user_id = ?
|
||||||
|
WHERE m.task_id = ?
|
||||||
|
AND m.user_id != ?
|
||||||
|
AND m.is_deleted = 0
|
||||||
|
AND r.id IS NULL
|
||||||
|
`, [userId, taskId, userId], (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('❌ Ошибка подсчета непрочитанных:', err);
|
||||||
|
return res.status(500).json({ error: 'Ошибка подсчета' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ unread_count: result?.unread_count || 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/chat/tasks/:taskId/mark-read - Отметить все сообщения как прочитанные
|
||||||
|
router.post('/api/chat/tasks/:taskId/mark-read', requireAuth, (req, res) => {
|
||||||
|
const { taskId } = req.params;
|
||||||
|
const userId = req.session.user.id;
|
||||||
|
|
||||||
|
checkTaskAccess(userId, taskId, (err, hasAccess) => {
|
||||||
|
if (err || !hasAccess) {
|
||||||
|
return res.status(403).json({ error: 'Нет доступа к задаче' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
INSERT OR IGNORE INTO task_chat_reads (message_id, user_id)
|
||||||
|
SELECT id, ? FROM task_chat_messages
|
||||||
|
WHERE task_id = ? AND user_id != ?
|
||||||
|
`, [userId, taskId, userId], function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error('❌ Ошибка отметки прочитанных:', err);
|
||||||
|
return res.status(500).json({ error: 'Ошибка отметки' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
marked_count: this.changes
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/chat/files/:fileId/download - Скачать файл из чата
|
||||||
|
router.get('/api/chat/files/:fileId/download', requireAuth, (req, res) => {
|
||||||
|
const { fileId } = req.params;
|
||||||
|
const userId = req.session.user.id;
|
||||||
|
|
||||||
|
db.get(`
|
||||||
|
SELECT f.*, m.task_id
|
||||||
|
FROM task_chat_files f
|
||||||
|
JOIN task_chat_messages m ON f.message_id = m.id
|
||||||
|
WHERE f.id = ?
|
||||||
|
`, [fileId], (err, file) => {
|
||||||
|
if (err || !file) {
|
||||||
|
return res.status(404).json({ error: 'Файл не найден' });
|
||||||
|
}
|
||||||
|
|
||||||
|
checkTaskAccess(userId, file.task_id, (err, hasAccess) => {
|
||||||
|
if (err || !hasAccess) {
|
||||||
|
return res.status(403).json({ error: 'Нет доступа к файлу' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(file.file_path)) {
|
||||||
|
return res.status(404).json({ error: 'Файл не найден на сервере' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const encodedFileName = encodeURIComponent(file.original_name);
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodedFileName}`);
|
||||||
|
res.setHeader('Content-Type', file.file_type || 'application/octet-stream');
|
||||||
|
|
||||||
|
res.sendFile(file.file_path);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// GET /api/chat/unread-summary – сводка непрочитанных сообщений по задачам
|
||||||
|
router.get('/api/chat/unread-summary', requireAuth, (req, res) => {
|
||||||
|
const userId = req.session.user.id;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
m.task_id,
|
||||||
|
t.title,
|
||||||
|
COUNT(*) as unread_count
|
||||||
|
FROM task_chat_messages m
|
||||||
|
JOIN tasks t ON m.task_id = t.id
|
||||||
|
LEFT JOIN task_chat_reads r ON m.id = r.message_id AND r.user_id = ?
|
||||||
|
WHERE m.user_id != ?
|
||||||
|
AND m.is_deleted = 0
|
||||||
|
AND r.id IS NULL
|
||||||
|
GROUP BY m.task_id, t.title
|
||||||
|
ORDER BY MAX(m.created_at) DESC
|
||||||
|
LIMIT 50
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.all(query, [userId, userId], (err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('❌ Ошибка получения сводки непрочитанных:', err);
|
||||||
|
return res.status(500).json({ error: 'Ошибка получения сводки' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalUnread = rows.reduce((sum, row) => sum + row.unread_count, 0);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
tasks: rows.map(r => ({
|
||||||
|
taskId: r.task_id,
|
||||||
|
title: r.title,
|
||||||
|
unreadCount: r.unread_count
|
||||||
|
})),
|
||||||
|
totalUnread
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Вспомогательная функция для уведомлений участников
|
||||||
|
function notifyTaskParticipants(taskId, senderId, message) {
|
||||||
|
db.all(`
|
||||||
|
SELECT DISTINCT user_id
|
||||||
|
FROM task_assignments
|
||||||
|
WHERE task_id = ? AND user_id != ?
|
||||||
|
UNION
|
||||||
|
SELECT created_by as user_id
|
||||||
|
FROM tasks
|
||||||
|
WHERE id = ? AND created_by != ?
|
||||||
|
`, [taskId, senderId, taskId, senderId], (err, participants) => {
|
||||||
|
if (err || !participants || participants.length === 0) return;
|
||||||
|
|
||||||
|
// Пытаемся импортировать функцию уведомлений
|
||||||
|
try {
|
||||||
|
const { sendTaskNotifications } = require('./notifications');
|
||||||
|
participants.forEach(p => {
|
||||||
|
try {
|
||||||
|
sendTaskNotifications(
|
||||||
|
'chat_message',
|
||||||
|
taskId,
|
||||||
|
'Новое сообщение в чате',
|
||||||
|
message.message.substring(0, 100),
|
||||||
|
senderId,
|
||||||
|
'',
|
||||||
|
'chat',
|
||||||
|
message.user_name,
|
||||||
|
p.user_id
|
||||||
|
);
|
||||||
|
} catch (notifyErr) {
|
||||||
|
console.error('Ошибка отправки уведомления:', notifyErr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (importErr) {
|
||||||
|
console.log('Модуль уведомлений не загружен');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подключаем роутер
|
||||||
|
app.use(router);
|
||||||
|
console.log('✅ API для чата задач подключено');
|
||||||
|
};
|
||||||
676
api-client.js
Normal file
676
api-client.js
Normal file
@@ -0,0 +1,676 @@
|
|||||||
|
// api-client.js - API для внешнего клиента управления задачами
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const axios = require('axios');
|
||||||
|
const FormData = require('form-data');
|
||||||
|
|
||||||
|
module.exports = function(app, db, upload) {
|
||||||
|
|
||||||
|
// Middleware для проверки аутентификации
|
||||||
|
const requireAuth = (req, res, next) => {
|
||||||
|
if (!req.session || !req.session.user) {
|
||||||
|
return res.status(401).json({ error: 'Требуется аутентификация' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Middleware для проверки прав администратора
|
||||||
|
const requireAdmin = (req, res, next) => {
|
||||||
|
if (!req.session || !req.session.user || req.session.user.role !== 'admin') {
|
||||||
|
return res.status(403).json({ error: 'Недостаточно прав' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== СТРАНИЦА КЛИЕНТА ====================
|
||||||
|
|
||||||
|
// GET /client - Страница клиента для работы с API
|
||||||
|
app.get('/client', requireAuth, (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, 'public', 'client.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== API ДЛЯ РАБОТЫ С ВНЕШНИМИ СЕРВИСАМИ ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/client/connect - Проверка подключения к внешнему сервису
|
||||||
|
*/
|
||||||
|
router.post('/api/client/connect', requireAuth, async (req, res) => {
|
||||||
|
const { api_url, api_key } = req.body;
|
||||||
|
const userId = req.session.user.id;
|
||||||
|
|
||||||
|
if (!api_url || !api_key) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Не указан URL сервиса или API ключ'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Нормализуем URL
|
||||||
|
const baseUrl = api_url.replace(/\/$/, '');
|
||||||
|
|
||||||
|
// Пробуем подключиться к сервису
|
||||||
|
const response = await axios.get(`${baseUrl}/api/external/tasks`, {
|
||||||
|
headers: {
|
||||||
|
'X-API-Key': api_key
|
||||||
|
},
|
||||||
|
params: {
|
||||||
|
limit: 1
|
||||||
|
},
|
||||||
|
timeout: 10000
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data && response.data.success) {
|
||||||
|
// Сохраняем подключение в сессии
|
||||||
|
if (!req.session.clientConnections) {
|
||||||
|
req.session.clientConnections = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectionId = Date.now().toString();
|
||||||
|
req.session.clientConnections[connectionId] = {
|
||||||
|
id: connectionId,
|
||||||
|
name: `Подключение ${new Date().toLocaleString()}`,
|
||||||
|
url: baseUrl,
|
||||||
|
api_key: api_key,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
last_used: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Логируем действие
|
||||||
|
const { logActivity } = require('./database');
|
||||||
|
if (logActivity) {
|
||||||
|
logActivity(0, userId, 'API_CLIENT_CONNECT',
|
||||||
|
`Подключение к ${baseUrl} (ID: ${connectionId})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Подключение успешно установлено',
|
||||||
|
connection: req.session.clientConnections[connectionId],
|
||||||
|
server_info: {
|
||||||
|
tasks_count: response.data.meta?.total || 0,
|
||||||
|
user: response.data.meta?.user || 'Unknown'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(400).json({
|
||||||
|
error: 'Неверный ответ от сервера',
|
||||||
|
details: response.data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Ошибка подключения к внешнему сервису:', error.message);
|
||||||
|
|
||||||
|
let errorMessage = 'Ошибка подключения к серверу';
|
||||||
|
let statusCode = 500;
|
||||||
|
|
||||||
|
if (error.code === 'ECONNREFUSED') {
|
||||||
|
errorMessage = 'Сервер недоступен (отказ в соединении)';
|
||||||
|
statusCode = 503;
|
||||||
|
} else if (error.code === 'ETIMEDOUT') {
|
||||||
|
errorMessage = 'Превышено время ожидания ответа от сервера';
|
||||||
|
statusCode = 504;
|
||||||
|
} else if (error.response) {
|
||||||
|
if (error.response.status === 401) {
|
||||||
|
errorMessage = 'Неверный API ключ';
|
||||||
|
statusCode = 401;
|
||||||
|
} else {
|
||||||
|
errorMessage = `Ошибка сервера: ${error.response.status}`;
|
||||||
|
statusCode = error.response.status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(statusCode).json({
|
||||||
|
error: errorMessage,
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/client/connections - Получить список сохраненных подключений
|
||||||
|
*/
|
||||||
|
router.get('/api/client/connections', requireAuth, (req, res) => {
|
||||||
|
const connections = req.session.clientConnections || {};
|
||||||
|
|
||||||
|
// Маскируем API ключи
|
||||||
|
const maskedConnections = Object.values(connections).map(conn => ({
|
||||||
|
...conn,
|
||||||
|
api_key: conn.api_key ?
|
||||||
|
conn.api_key.substring(0, 8) + '...' + conn.api_key.substring(conn.api_key.length - 8) :
|
||||||
|
null
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
connections: maskedConnections
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/client/connections/list - Получить список всех подключений для выбора
|
||||||
|
*/
|
||||||
|
router.get('/api/client/connections/list', requireAuth, (req, res) => {
|
||||||
|
const connections = req.session.clientConnections || {};
|
||||||
|
|
||||||
|
const connectionsList = Object.values(connections).map(conn => ({
|
||||||
|
id: conn.id,
|
||||||
|
name: conn.name,
|
||||||
|
url: conn.url,
|
||||||
|
last_used: conn.last_used
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
connections: connectionsList
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/client/connections/:id - Удалить сохраненное подключение
|
||||||
|
*/
|
||||||
|
router.delete('/api/client/connections/:id', requireAuth, (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
if (req.session.clientConnections && req.session.clientConnections[id]) {
|
||||||
|
delete req.session.clientConnections[id];
|
||||||
|
|
||||||
|
const { logActivity } = require('./database');
|
||||||
|
if (logActivity) {
|
||||||
|
logActivity(0, req.session.user.id, 'API_CLIENT_DISCONNECT',
|
||||||
|
`Удалено подключение ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Подключение удалено'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(404).json({
|
||||||
|
error: 'Подключение не найдено'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/client/tasks - Получить список задач из внешнего сервиса
|
||||||
|
*/
|
||||||
|
router.get('/api/client/tasks', requireAuth, async (req, res) => {
|
||||||
|
const { connection_id, api_url, api_key, status, search, limit = 50, offset = 0 } = req.query;
|
||||||
|
const userId = req.session.user.id;
|
||||||
|
|
||||||
|
let targetUrl = api_url;
|
||||||
|
let targetKey = api_key;
|
||||||
|
|
||||||
|
if (connection_id && req.session.clientConnections && req.session.clientConnections[connection_id]) {
|
||||||
|
const connection = req.session.clientConnections[connection_id];
|
||||||
|
targetUrl = connection.url;
|
||||||
|
targetKey = connection.api_key;
|
||||||
|
|
||||||
|
connection.last_used = new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetUrl || !targetKey) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Не указан URL сервиса или API ключ'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const baseUrl = targetUrl.replace(/\/$/, '');
|
||||||
|
|
||||||
|
const params = { limit, offset };
|
||||||
|
if (status) params.status = status;
|
||||||
|
|
||||||
|
const response = await axios.get(`${baseUrl}/api/external/tasks`, {
|
||||||
|
headers: {
|
||||||
|
'X-API-Key': targetKey
|
||||||
|
},
|
||||||
|
params: params,
|
||||||
|
timeout: 15000
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data && response.data.success) {
|
||||||
|
let tasks = response.data.tasks || [];
|
||||||
|
|
||||||
|
if (search && tasks.length > 0) {
|
||||||
|
const searchLower = search.toLowerCase();
|
||||||
|
tasks = tasks.filter(task =>
|
||||||
|
task.title.toLowerCase().includes(searchLower) ||
|
||||||
|
(task.description && task.description.toLowerCase().includes(searchLower))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { logActivity } = require('./database');
|
||||||
|
if (logActivity) {
|
||||||
|
logActivity(0, userId, 'API_CLIENT_GET_TASKS',
|
||||||
|
`Получено ${tasks.length} задач из ${baseUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
tasks: tasks,
|
||||||
|
meta: {
|
||||||
|
total: tasks.length,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
source: connection_id ? 'saved_connection' : 'direct',
|
||||||
|
server_info: response.data.meta
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(400).json({
|
||||||
|
error: 'Неверный ответ от сервера'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Ошибка получения задач:', error.message);
|
||||||
|
|
||||||
|
let errorMessage = 'Ошибка получения задач';
|
||||||
|
let statusCode = 500;
|
||||||
|
|
||||||
|
if (error.code === 'ECONNREFUSED') {
|
||||||
|
errorMessage = 'Сервер недоступен';
|
||||||
|
statusCode = 503;
|
||||||
|
} else if (error.code === 'ETIMEDOUT') {
|
||||||
|
errorMessage = 'Превышено время ожидания';
|
||||||
|
statusCode = 504;
|
||||||
|
} else if (error.response) {
|
||||||
|
if (error.response.status === 401) {
|
||||||
|
errorMessage = 'Неверный API ключ';
|
||||||
|
statusCode = 401;
|
||||||
|
} else {
|
||||||
|
errorMessage = `Ошибка сервера: ${error.response.status}`;
|
||||||
|
statusCode = error.response.status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(statusCode).json({
|
||||||
|
error: errorMessage,
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/client/tasks/:taskId - Получить детальную информацию о задаче
|
||||||
|
*/
|
||||||
|
router.get('/api/client/tasks/:taskId', requireAuth, async (req, res) => {
|
||||||
|
const { taskId } = req.params;
|
||||||
|
const { connection_id, api_url, api_key } = req.query;
|
||||||
|
|
||||||
|
let targetUrl = api_url;
|
||||||
|
let targetKey = api_key;
|
||||||
|
|
||||||
|
if (connection_id && req.session.clientConnections && req.session.clientConnections[connection_id]) {
|
||||||
|
const connection = req.session.clientConnections[connection_id];
|
||||||
|
targetUrl = connection.url;
|
||||||
|
targetKey = connection.api_key;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetUrl || !targetKey) {
|
||||||
|
return res.status(400).json({ error: 'Не указан URL сервиса или API ключ' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const baseUrl = targetUrl.replace(/\/$/, '');
|
||||||
|
|
||||||
|
const response = await axios.get(`${baseUrl}/api/external/tasks/${taskId}`, {
|
||||||
|
headers: {
|
||||||
|
'X-API-Key': targetKey
|
||||||
|
},
|
||||||
|
timeout: 10000
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data && response.data.success) {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
task: response.data.task
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(404).json({ error: 'Задача не найдена' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Ошибка получения задачи:', error.message);
|
||||||
|
|
||||||
|
let errorMessage = 'Ошибка получения задачи';
|
||||||
|
let statusCode = 500;
|
||||||
|
|
||||||
|
if (error.response) {
|
||||||
|
if (error.response.status === 404) {
|
||||||
|
errorMessage = 'Задача не найдена';
|
||||||
|
statusCode = 404;
|
||||||
|
} else if (error.response.status === 401) {
|
||||||
|
errorMessage = 'Неверный API ключ';
|
||||||
|
statusCode = 401;
|
||||||
|
} else {
|
||||||
|
errorMessage = `Ошибка сервера: ${error.response.status}`;
|
||||||
|
statusCode = error.response.status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(statusCode).json({
|
||||||
|
error: errorMessage,
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/client/tasks/:taskId/status - Изменить статус задачи
|
||||||
|
*/
|
||||||
|
router.put('/api/client/tasks/:taskId/status', requireAuth, async (req, res) => {
|
||||||
|
const { taskId } = req.params;
|
||||||
|
const { connection_id, api_url, api_key } = req.query;
|
||||||
|
const { status, comment } = req.body;
|
||||||
|
const userId = req.session.user.id;
|
||||||
|
|
||||||
|
if (!status || !['in_progress', 'completed'].includes(status)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Статус должен быть "in_progress" или "completed"'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetUrl = api_url;
|
||||||
|
let targetKey = api_key;
|
||||||
|
|
||||||
|
if (connection_id && req.session.clientConnections && req.session.clientConnections[connection_id]) {
|
||||||
|
const connection = req.session.clientConnections[connection_id];
|
||||||
|
targetUrl = connection.url;
|
||||||
|
targetKey = connection.api_key;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetUrl || !targetKey) {
|
||||||
|
return res.status(400).json({ error: 'Не указан URL сервиса или API ключ' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const baseUrl = targetUrl.replace(/\/$/, '');
|
||||||
|
|
||||||
|
const response = await axios.put(
|
||||||
|
`${baseUrl}/api/external/tasks/${taskId}/status`,
|
||||||
|
{ status, comment },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'X-API-Key': targetKey,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
timeout: 10000
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data && response.data.success) {
|
||||||
|
const { logActivity } = require('./database');
|
||||||
|
if (logActivity) {
|
||||||
|
logActivity(0, userId, 'API_CLIENT_UPDATE_STATUS',
|
||||||
|
`Статус задачи ${taskId} изменен на ${status} в ${baseUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `Статус задачи ${taskId} изменен на "${status}"`,
|
||||||
|
data: response.data
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(400).json({ error: 'Не удалось изменить статус' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Ошибка изменения статуса:', error.message);
|
||||||
|
|
||||||
|
let errorMessage = 'Ошибка изменения статуса';
|
||||||
|
let statusCode = 500;
|
||||||
|
|
||||||
|
if (error.response) {
|
||||||
|
if (error.response.status === 401) {
|
||||||
|
errorMessage = 'Неверный API ключ';
|
||||||
|
statusCode = 401;
|
||||||
|
} else if (error.response.status === 403) {
|
||||||
|
errorMessage = 'Нет прав для изменения статуса';
|
||||||
|
statusCode = 403;
|
||||||
|
} else if (error.response.status === 404) {
|
||||||
|
errorMessage = 'Задача не найдена';
|
||||||
|
statusCode = 404;
|
||||||
|
} else {
|
||||||
|
errorMessage = error.response.data?.error || `Ошибка сервера: ${error.response.status}`;
|
||||||
|
statusCode = error.response.status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(statusCode).json({
|
||||||
|
error: errorMessage,
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/client/tasks/:taskId/files - Загрузить файлы в задачу
|
||||||
|
*/
|
||||||
|
router.post('/api/client/tasks/:taskId/files', requireAuth, upload.array('files', 15), async (req, res) => {
|
||||||
|
const { taskId } = req.params;
|
||||||
|
const { connection_id, api_url, api_key } = req.query;
|
||||||
|
const userId = req.session.user.id;
|
||||||
|
|
||||||
|
if (!req.files || req.files.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'Нет файлов для загрузки' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetUrl = api_url;
|
||||||
|
let targetKey = api_key;
|
||||||
|
|
||||||
|
if (connection_id && req.session.clientConnections && req.session.clientConnections[connection_id]) {
|
||||||
|
const connection = req.session.clientConnections[connection_id];
|
||||||
|
targetUrl = connection.url;
|
||||||
|
targetKey = connection.api_key;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetUrl || !targetKey) {
|
||||||
|
req.files.forEach(file => {
|
||||||
|
if (file.path && fs.existsSync(file.path)) {
|
||||||
|
fs.unlinkSync(file.path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return res.status(400).json({ error: 'Не указан URL сервиса или API ключ' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const baseUrl = targetUrl.replace(/\/$/, '');
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
req.files.forEach(file => {
|
||||||
|
formData.append('files', fs.createReadStream(file.path), {
|
||||||
|
filename: file.originalname,
|
||||||
|
contentType: file.mimetype
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await axios.post(
|
||||||
|
`${baseUrl}/api/external/tasks/${taskId}/files`,
|
||||||
|
formData,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
...formData.getHeaders(),
|
||||||
|
'X-API-Key': targetKey
|
||||||
|
},
|
||||||
|
timeout: 60000,
|
||||||
|
maxContentLength: Infinity,
|
||||||
|
maxBodyLength: Infinity
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
req.files.forEach(file => {
|
||||||
|
if (file.path && fs.existsSync(file.path)) {
|
||||||
|
fs.unlinkSync(file.path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data && response.data.success) {
|
||||||
|
const { logActivity } = require('./database');
|
||||||
|
if (logActivity) {
|
||||||
|
logActivity(0, userId, 'API_CLIENT_UPLOAD_FILES',
|
||||||
|
`Загружено ${req.files.length} файлов в задачу ${taskId} в ${baseUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `Успешно загружено ${req.files.length} файлов`,
|
||||||
|
data: response.data
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(400).json({ error: 'Не удалось загрузить файлы' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
req.files.forEach(file => {
|
||||||
|
if (file.path && fs.existsSync(file.path)) {
|
||||||
|
fs.unlinkSync(file.path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.error('❌ Ошибка загрузки файлов:', error.message);
|
||||||
|
|
||||||
|
let errorMessage = 'Ошибка загрузки файлов';
|
||||||
|
let statusCode = 500;
|
||||||
|
|
||||||
|
if (error.response) {
|
||||||
|
if (error.response.status === 401) {
|
||||||
|
errorMessage = 'Неверный API ключ';
|
||||||
|
statusCode = 401;
|
||||||
|
} else if (error.response.status === 403) {
|
||||||
|
errorMessage = 'Нет прав для загрузки файлов';
|
||||||
|
statusCode = 403;
|
||||||
|
} else if (error.response.status === 404) {
|
||||||
|
errorMessage = 'Задача не найдена';
|
||||||
|
statusCode = 404;
|
||||||
|
} else if (error.response.status === 413) {
|
||||||
|
errorMessage = 'Файлы слишком большие';
|
||||||
|
statusCode = 413;
|
||||||
|
} else {
|
||||||
|
errorMessage = error.response.data?.error || `Ошибка сервера: ${error.response.status}`;
|
||||||
|
statusCode = error.response.status;
|
||||||
|
}
|
||||||
|
} else if (error.code === 'ECONNREFUSED') {
|
||||||
|
errorMessage = 'Сервер недоступен';
|
||||||
|
statusCode = 503;
|
||||||
|
} else if (error.code === 'ETIMEDOUT') {
|
||||||
|
errorMessage = 'Превышено время ожидания при загрузке';
|
||||||
|
statusCode = 504;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(statusCode).json({
|
||||||
|
error: errorMessage,
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/client/tasks/:taskId/files - Получить список файлов задачи
|
||||||
|
*/
|
||||||
|
router.get('/api/client/tasks/:taskId/files', requireAuth, async (req, res) => {
|
||||||
|
const { taskId } = req.params;
|
||||||
|
const { connection_id, api_url, api_key } = req.query;
|
||||||
|
|
||||||
|
let targetUrl = api_url;
|
||||||
|
let targetKey = api_key;
|
||||||
|
|
||||||
|
if (connection_id && req.session.clientConnections && req.session.clientConnections[connection_id]) {
|
||||||
|
const connection = req.session.clientConnections[connection_id];
|
||||||
|
targetUrl = connection.url;
|
||||||
|
targetKey = connection.api_key;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetUrl || !targetKey) {
|
||||||
|
return res.status(400).json({ error: 'Не указан URL сервиса или API ключ' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const baseUrl = targetUrl.replace(/\/$/, '');
|
||||||
|
|
||||||
|
const response = await axios.get(`${baseUrl}/api/external/tasks/${taskId}`, {
|
||||||
|
headers: {
|
||||||
|
'X-API-Key': targetKey
|
||||||
|
},
|
||||||
|
timeout: 10000
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data && response.data.success) {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
files: response.data.task.files || []
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(404).json({ error: 'Задача не найдена' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Ошибка получения файлов:', error.message);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Ошибка получения файлов',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/client/tasks/:taskId/files/:fileId/download - Скачать файл
|
||||||
|
*/
|
||||||
|
router.get('/api/client/tasks/:taskId/files/:fileId/download', requireAuth, async (req, res) => {
|
||||||
|
const { taskId, fileId } = req.params;
|
||||||
|
const { connection_id, api_url, api_key } = req.query;
|
||||||
|
|
||||||
|
let targetUrl = api_url;
|
||||||
|
let targetKey = api_key;
|
||||||
|
|
||||||
|
if (connection_id && req.session.clientConnections && req.session.clientConnections[connection_id]) {
|
||||||
|
const connection = req.session.clientConnections[connection_id];
|
||||||
|
targetUrl = connection.url;
|
||||||
|
targetKey = connection.api_key;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetUrl || !targetKey) {
|
||||||
|
return res.status(400).json({ error: 'Не указан URL сервиса или API ключ' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const baseUrl = targetUrl.replace(/\/$/, '');
|
||||||
|
|
||||||
|
const response = await axios({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${baseUrl}/api/external/tasks/${taskId}/files/${fileId}/download`,
|
||||||
|
headers: {
|
||||||
|
'X-API-Key': targetKey
|
||||||
|
},
|
||||||
|
responseType: 'stream',
|
||||||
|
timeout: 30000
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentType = response.headers['content-type'] || 'application/octet-stream';
|
||||||
|
const contentDisposition = response.headers['content-disposition'] || 'attachment';
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', contentType);
|
||||||
|
res.setHeader('Content-Disposition', contentDisposition);
|
||||||
|
|
||||||
|
response.data.pipe(res);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Ошибка скачивания файла:', error.message);
|
||||||
|
|
||||||
|
if (error.response) {
|
||||||
|
res.status(error.response.status).json({
|
||||||
|
error: 'Ошибка при скачивании файла',
|
||||||
|
details: error.response.statusText
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Ошибка при скачивании файла',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Подключаем роутер
|
||||||
|
app.use(router);
|
||||||
|
|
||||||
|
console.log('✅ API клиент для внешних сервисов подключен');
|
||||||
|
};
|
||||||
924
api-doc.js
Normal file
924
api-doc.js
Normal file
@@ -0,0 +1,924 @@
|
|||||||
|
// api-doc.js - API endpoints для согласования документов
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const archiver = require('archiver');
|
||||||
|
|
||||||
|
module.exports = function(app, db, upload) {
|
||||||
|
// API для типов документов
|
||||||
|
app.get('/api/document-types', requireAuth, (req, res) => {
|
||||||
|
db.all("SELECT * FROM document_types ORDER BY name", [], (err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// API для создания документа
|
||||||
|
app.post('/api/documents', requireAuth, upload.array('files', 15), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.session.user.id;
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
dueDate,
|
||||||
|
documentTypeId,
|
||||||
|
documentNumber,
|
||||||
|
documentDate,
|
||||||
|
pagesCount,
|
||||||
|
urgencyLevel,
|
||||||
|
comment
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Валидация
|
||||||
|
if (!title || title.trim() === '') {
|
||||||
|
return res.status(400).json({ error: 'Название документа обязательно' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем пользователей для согласования из .env
|
||||||
|
const doc1User = process.env.DOC1_USER;
|
||||||
|
const doc2Users = process.env.DOC2_USERS ? process.env.DOC2_USERS.split(',') : [];
|
||||||
|
|
||||||
|
if (!doc1User) {
|
||||||
|
return res.status(400).json({ error: 'Не настроен DOC1_USER в системе' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Находим пользователя DOC1
|
||||||
|
db.get("SELECT id FROM users WHERE login = ?", [doc1User], async (err, doc1) => {
|
||||||
|
if (err || !doc1) {
|
||||||
|
return res.status(400).json({ error: `Пользователь DOC1 (${doc1User}) не найден` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем задачу для документа
|
||||||
|
db.run(`
|
||||||
|
INSERT INTO tasks (title, description, due_date, created_by, status, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, 'active', datetime('now'))
|
||||||
|
`, [
|
||||||
|
`Документ: ${title}`,
|
||||||
|
description || '',
|
||||||
|
dueDate || null,
|
||||||
|
userId
|
||||||
|
], function(err) {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskId = this.lastID;
|
||||||
|
|
||||||
|
// Создаем запись документа
|
||||||
|
db.run(`
|
||||||
|
INSERT INTO documents (
|
||||||
|
task_id, document_type_id, document_number,
|
||||||
|
document_date, pages_count, urgency_level, comment,
|
||||||
|
created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||||
|
`, [
|
||||||
|
taskId,
|
||||||
|
documentTypeId || null,
|
||||||
|
documentNumber || null,
|
||||||
|
documentDate || null,
|
||||||
|
pagesCount || null,
|
||||||
|
urgencyLevel || 'normal',
|
||||||
|
comment || null
|
||||||
|
], function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error('❌ Ошибка создания документа:', err);
|
||||||
|
db.run("DELETE FROM tasks WHERE id = ?", [taskId]);
|
||||||
|
return res.status(500).json({ error: 'Ошибка создания записи документа' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const documentId = this.lastID;
|
||||||
|
|
||||||
|
// Назначаем DOC1 для предварительного согласования
|
||||||
|
db.run(`
|
||||||
|
INSERT INTO task_assignments (task_id, user_id, status, created_at)
|
||||||
|
VALUES (?, ?, 'assigned', datetime('now'))
|
||||||
|
`, [taskId, doc1.id], function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error('❌ Ошибка назначения DOC1:', err);
|
||||||
|
db.run("DELETE FROM documents WHERE id = ?", [documentId]);
|
||||||
|
db.run("DELETE FROM tasks WHERE id = ?", [taskId]);
|
||||||
|
return res.status(500).json({ error: 'Ошибка назначения документа на согласование' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc1AssignmentId = this.lastID;
|
||||||
|
|
||||||
|
// Загружаем файлы если есть
|
||||||
|
if (req.files && req.files.length > 0) {
|
||||||
|
const uploadPromises = req.files.map(file => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const filePath = file.path;
|
||||||
|
const originalName = Buffer.from(file.originalname, 'latin1').toString('utf8');
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
INSERT INTO task_files (task_id, user_id, file_path, original_name, file_size, uploaded_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, datetime('now'))
|
||||||
|
`, [taskId, userId, filePath, originalName, file.size], function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error('❌ Ошибка сохранения файла:', err);
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Promise.all(uploadPromises)
|
||||||
|
.then(() => {
|
||||||
|
// Логируем создание документа
|
||||||
|
const { logActivity } = require('./database');
|
||||||
|
logActivity(taskId, userId, 'DOCUMENT_CREATED', `Создан документ: ${title}`);
|
||||||
|
|
||||||
|
// Отправляем уведомление DOC1
|
||||||
|
sendDocumentNotification(doc1.id, taskId, 'new_document', title);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Документ успешно создан и отправлен на предварительное согласование'
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
// Все равно возвращаем успех, так как документ создан
|
||||||
|
const { logActivity } = require('./database');
|
||||||
|
logActivity(taskId, userId, 'DOCUMENT_CREATED', `Создан документ: ${title}`);
|
||||||
|
|
||||||
|
// Отправляем уведомление DOC1
|
||||||
|
sendDocumentNotification(doc1.id, taskId, 'new_document', title);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Документ создан, но были проблемы с загрузкой файлов'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Логируем создание документа
|
||||||
|
const { logActivity } = require('./database');
|
||||||
|
logActivity(taskId, userId, 'DOCUMENT_CREATED', `Создан документ: ${title}`);
|
||||||
|
|
||||||
|
// Отправляем уведомление DOC1
|
||||||
|
sendDocumentNotification(doc1.id, taskId, 'new_document', title);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Документ успешно создан и отправлен на предварительное согласование'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Общая ошибка создания документа:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Ошибка создания документа',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Получение моих документов
|
||||||
|
app.get('/api/documents/my', requireAuth, (req, res) => {
|
||||||
|
const userId = req.session.user.id;
|
||||||
|
|
||||||
|
db.all(`
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.title,
|
||||||
|
t.description,
|
||||||
|
t.due_date,
|
||||||
|
t.created_at,
|
||||||
|
t.status,
|
||||||
|
t.closed_at,
|
||||||
|
d.id as document_id,
|
||||||
|
d.document_type_id,
|
||||||
|
dt.name as document_type_name,
|
||||||
|
d.document_number,
|
||||||
|
d.document_date,
|
||||||
|
d.pages_count,
|
||||||
|
d.urgency_level,
|
||||||
|
d.comment,
|
||||||
|
d.refusal_reason,
|
||||||
|
ta.status as assignment_status
|
||||||
|
FROM tasks t
|
||||||
|
LEFT JOIN documents d ON t.id = d.task_id
|
||||||
|
LEFT JOIN document_types dt ON d.document_type_id = dt.id
|
||||||
|
LEFT JOIN task_assignments ta ON t.id = ta.task_id
|
||||||
|
WHERE t.created_by = ?
|
||||||
|
AND t.title LIKE 'Документ:%'
|
||||||
|
ORDER BY t.created_at DESC
|
||||||
|
`, [userId], async (err, tasks) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем файлы для каждой задачи
|
||||||
|
const tasksWithFiles = await Promise.all(tasks.map(async (task) => {
|
||||||
|
try {
|
||||||
|
const files = await new Promise((resolve, reject) => {
|
||||||
|
db.all(`
|
||||||
|
SELECT tf.*, u.name as user_name
|
||||||
|
FROM task_files tf
|
||||||
|
LEFT JOIN users u ON tf.user_id = u.id
|
||||||
|
WHERE tf.task_id = ?
|
||||||
|
ORDER BY tf.uploaded_at DESC
|
||||||
|
`, [task.id], (err, rows) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(rows || []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
task.files = files || [];
|
||||||
|
} catch (error) {
|
||||||
|
task.files = [];
|
||||||
|
}
|
||||||
|
return task;
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(tasksWithFiles);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Получение документов для согласования (DOC1 и DOC2)
|
||||||
|
app.get('/api/documents/approval', requireAuth, (req, res) => {
|
||||||
|
const userId = req.session.user.id;
|
||||||
|
|
||||||
|
// Проверяем, является ли пользователь DOC1 или DOC2
|
||||||
|
db.get("SELECT login FROM users WHERE id = ?", [userId], (err, user) => {
|
||||||
|
if (err || !user) {
|
||||||
|
return res.status(403).json({ error: 'Пользователь не найден' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userLogin = user.login;
|
||||||
|
const doc1User = process.env.DOC1_USER;
|
||||||
|
const doc2Users = process.env.DOC2_USERS ? process.env.DOC2_USERS.split(',') : [];
|
||||||
|
|
||||||
|
const isDoc1 = userLogin === doc1User;
|
||||||
|
const isDoc2 = doc2Users.includes(userLogin);
|
||||||
|
|
||||||
|
if (!isDoc1 && !isDoc2) {
|
||||||
|
return res.status(403).json({ error: 'Недостаточно прав для согласования документов' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Определяем статус для фильтрации
|
||||||
|
let statusFilter = '';
|
||||||
|
if (isDoc1) {
|
||||||
|
// DOC1 видит документы на предварительном согласовании
|
||||||
|
statusFilter = "AND ta.status IN ('assigned', 'pre_approved')";
|
||||||
|
} else if (isDoc2) {
|
||||||
|
// DOC2 видит предварительно согласованные документы
|
||||||
|
statusFilter = "AND ta.status = 'pre_approved'";
|
||||||
|
}
|
||||||
|
|
||||||
|
db.all(`
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.title,
|
||||||
|
t.description,
|
||||||
|
t.due_date,
|
||||||
|
t.created_at,
|
||||||
|
d.id as document_id,
|
||||||
|
d.document_type_id,
|
||||||
|
dt.name as document_type_name,
|
||||||
|
d.document_number,
|
||||||
|
d.document_date,
|
||||||
|
d.pages_count,
|
||||||
|
d.urgency_level,
|
||||||
|
d.comment,
|
||||||
|
d.refusal_reason,
|
||||||
|
ta.status as assignment_status,
|
||||||
|
u.name as creator_name,
|
||||||
|
u.login as creator_login
|
||||||
|
FROM tasks t
|
||||||
|
JOIN documents d ON t.id = d.task_id
|
||||||
|
LEFT JOIN document_types dt ON d.document_type_id = dt.id
|
||||||
|
JOIN task_assignments ta ON t.id = ta.task_id
|
||||||
|
JOIN users u ON t.created_by = u.id
|
||||||
|
WHERE ta.user_id = ?
|
||||||
|
AND t.title LIKE 'Документ:%'
|
||||||
|
AND t.status = 'active'
|
||||||
|
AND t.closed_at IS NULL
|
||||||
|
${statusFilter}
|
||||||
|
ORDER BY
|
||||||
|
CASE d.urgency_level
|
||||||
|
WHEN 'very_urgent' THEN 1
|
||||||
|
WHEN 'urgent' THEN 2
|
||||||
|
ELSE 3
|
||||||
|
END,
|
||||||
|
t.due_date ASC,
|
||||||
|
t.created_at DESC
|
||||||
|
`, [userId], async (err, tasks) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем файлы для каждой задачи
|
||||||
|
const tasksWithFiles = await Promise.all(tasks.map(async (task) => {
|
||||||
|
try {
|
||||||
|
const files = await new Promise((resolve, reject) => {
|
||||||
|
db.all(`
|
||||||
|
SELECT tf.*, u.name as user_name
|
||||||
|
FROM task_files tf
|
||||||
|
LEFT JOIN users u ON tf.user_id = u.id
|
||||||
|
WHERE tf.task_id = ?
|
||||||
|
ORDER BY tf.uploaded_at DESC
|
||||||
|
`, [task.id], (err, rows) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(rows || []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
task.files = files || [];
|
||||||
|
} catch (error) {
|
||||||
|
task.files = [];
|
||||||
|
}
|
||||||
|
return task;
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(tasksWithFiles);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обновление статуса документа
|
||||||
|
app.put('/api/documents/:id/status', requireAuth, (req, res) => {
|
||||||
|
const documentId = req.params.id;
|
||||||
|
const { status, comment, refusalReason } = req.body;
|
||||||
|
const userId = req.session.user.id;
|
||||||
|
|
||||||
|
// Проверяем права пользователя
|
||||||
|
db.get("SELECT login FROM users WHERE id = ?", [userId], (err, user) => {
|
||||||
|
if (err || !user) {
|
||||||
|
return res.status(403).json({ error: 'Пользователь не найден' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userLogin = user.login;
|
||||||
|
const doc1User = process.env.DOC1_USER;
|
||||||
|
const doc2Users = process.env.DOC2_USERS ? process.env.DOC2_USERS.split(',') : [];
|
||||||
|
|
||||||
|
const isDoc1 = userLogin === doc1User;
|
||||||
|
const isDoc2 = doc2Users.includes(userLogin);
|
||||||
|
|
||||||
|
if (!isDoc1 && !isDoc2) {
|
||||||
|
return res.status(403).json({ error: 'Недостаточно прав' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем текущий статус документа
|
||||||
|
db.get(`
|
||||||
|
SELECT d.*, ta.status as assignment_status, ta.user_id as assignee_id,
|
||||||
|
t.title, t.created_by
|
||||||
|
FROM documents d
|
||||||
|
JOIN tasks t ON d.task_id = t.id
|
||||||
|
JOIN task_assignments ta ON t.id = ta.task_id
|
||||||
|
WHERE d.id = ? AND ta.user_id = ?
|
||||||
|
`, [documentId, userId], (err, document) => {
|
||||||
|
if (err || !document) {
|
||||||
|
return res.status(404).json({ error: 'Документ не найден или у вас нет прав' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Валидация переходов статусов
|
||||||
|
if (isDoc1) {
|
||||||
|
// DOC1 может только предварительно согласовать или отказать
|
||||||
|
if (status !== 'pre_approved' && status !== 'refused') {
|
||||||
|
return res.status(400).json({ error: 'DOC1 может только предварительно согласовать или отказать' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'pre_approved') {
|
||||||
|
// Если DOC1 предварительно согласовал, назначаем DOC2
|
||||||
|
updateDoc1Status();
|
||||||
|
} else {
|
||||||
|
// Если отказал, просто обновляем статус
|
||||||
|
updateStatus();
|
||||||
|
}
|
||||||
|
} else if (isDoc2) {
|
||||||
|
// DOC2 может согласовать или отказать только предварительно согласованные документы
|
||||||
|
if (document.assignment_status !== 'pre_approved') {
|
||||||
|
return res.status(400).json({ error: 'DOC2 может работать только с предварительно согласованными документами' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'approved' || status === 'refused') {
|
||||||
|
updateStatus();
|
||||||
|
} else {
|
||||||
|
return res.status(400).json({ error: 'DOC2 может только согласовать или отказать' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDoc1Status() {
|
||||||
|
// Обновляем статус DOC1
|
||||||
|
db.run("UPDATE task_assignments SET status = ? WHERE task_id = ? AND user_id = ?",
|
||||||
|
[status, document.task_id, userId], function(err) {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Назначаем DOC2 пользователям
|
||||||
|
const doc2Logins = doc2Users;
|
||||||
|
|
||||||
|
// Находим ID пользователей DOC2
|
||||||
|
const placeholders = doc2Logins.map(() => '?').join(',');
|
||||||
|
db.all(`SELECT id FROM users WHERE login IN (${placeholders})`, doc2Logins, (err, doc2UsersList) => {
|
||||||
|
if (err || doc2UsersList.length === 0) {
|
||||||
|
console.error('DOC2 пользователи не найдены');
|
||||||
|
// Все равно считаем успехом
|
||||||
|
return afterAssignments();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем задания для каждого DOC2
|
||||||
|
const assignmentPromises = doc2UsersList.map(doc2User => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.run(`
|
||||||
|
INSERT INTO task_assignments (task_id, user_id, status, created_at)
|
||||||
|
VALUES (?, ?, 'assigned', datetime('now'))
|
||||||
|
`, [document.task_id, doc2User.id], function(err) {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Promise.all(assignmentPromises)
|
||||||
|
.then(() => {
|
||||||
|
afterAssignments();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Ошибка назначения DOC2:', error);
|
||||||
|
afterAssignments(); // Все равно продолжаем
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function afterAssignments() {
|
||||||
|
// Логируем действие
|
||||||
|
const { logActivity } = require('./database');
|
||||||
|
logActivity(document.task_id, userId, 'STATUS_CHANGED',
|
||||||
|
`Документ предварительно согласован. Назначен DOC2: ${doc2Logins.join(', ')}`);
|
||||||
|
|
||||||
|
// Сохраняем комментарий
|
||||||
|
if (comment) {
|
||||||
|
db.run("UPDATE documents SET comment = COALESCE(comment, '') || '\n' || ? WHERE id = ?",
|
||||||
|
[`DOC1 (${new Date().toLocaleString('ru-RU')}): ${comment}`, documentId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправляем уведомление создателю
|
||||||
|
sendDocumentNotification(document.created_by, document.task_id, 'pre_approved', document.title);
|
||||||
|
|
||||||
|
// Отправляем уведомления всем DOC2
|
||||||
|
doc2Users.forEach(login => {
|
||||||
|
db.get("SELECT id FROM users WHERE login = ?", [login], (err, doc2User) => {
|
||||||
|
if (!err && doc2User) {
|
||||||
|
sendDocumentNotification(doc2User.id, document.task_id, 'new_document_for_doc2', document.title);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatus() {
|
||||||
|
// Обновляем статус
|
||||||
|
db.run("UPDATE task_assignments SET status = ? WHERE task_id = ? AND user_id = ?",
|
||||||
|
[status, document.task_id, userId], function(err) {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем комментарий и причину отказа
|
||||||
|
if (comment) {
|
||||||
|
const role = isDoc1 ? 'DOC1' : 'DOC2';
|
||||||
|
const timestamp = new Date().toLocaleString('ru-RU');
|
||||||
|
db.run("UPDATE documents SET comment = COALESCE(comment, '') || '\n' || ? WHERE id = ?",
|
||||||
|
[`${role} (${timestamp}): ${comment}`, documentId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'refused' && refusalReason) {
|
||||||
|
db.run("UPDATE documents SET refusal_reason = ? WHERE id = ?",
|
||||||
|
[refusalReason, documentId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Логируем действие
|
||||||
|
const { logActivity } = require('./database');
|
||||||
|
const actionText = isDoc1 ?
|
||||||
|
`DOC1: ${status === 'refused' ? 'Отказано' : 'Предварительно согласовано'}` :
|
||||||
|
`DOC2: ${status === 'refused' ? 'Отказано' : 'Согласовано'}`;
|
||||||
|
logActivity(document.task_id, userId, 'STATUS_CHANGED', actionText);
|
||||||
|
|
||||||
|
// Отправляем уведомление создателю
|
||||||
|
if (status === 'approved') {
|
||||||
|
sendDocumentNotification(document.created_by, document.task_id, 'approved', document.title);
|
||||||
|
} else if (status === 'refused') {
|
||||||
|
sendDocumentNotification(document.created_by, document.task_id, 'refused', document.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Отзыв документа
|
||||||
|
app.post('/api/documents/:id/cancel', requireAuth, (req, res) => {
|
||||||
|
const documentId = req.params.id;
|
||||||
|
const userId = req.session.user.id;
|
||||||
|
|
||||||
|
db.get("SELECT task_id FROM documents WHERE id = ?", [documentId], (err, document) => {
|
||||||
|
if (err || !document) {
|
||||||
|
return res.status(404).json({ error: 'Документ не найден' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskId = document.task_id;
|
||||||
|
|
||||||
|
// Проверяем, что пользователь создатель задачи
|
||||||
|
db.get("SELECT created_by FROM tasks WHERE id = ?", [taskId], (err, task) => {
|
||||||
|
if (err || !task) {
|
||||||
|
return res.status(404).json({ error: 'Задача не найдена' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parseInt(task.created_by) !== parseInt(userId)) {
|
||||||
|
return res.status(403).json({ error: 'Вы не являетесь создателем этого документа' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем всех согласующих для уведомлений
|
||||||
|
db.all("SELECT user_id FROM task_assignments WHERE task_id = ?", [taskId], (err, assignees) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Ошибка получения согласующих:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем статус задачи
|
||||||
|
db.run("UPDATE tasks SET status = 'cancelled', closed_at = datetime('now') WHERE id = ?", [taskId], function(err) {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Логируем действие
|
||||||
|
const { logActivity } = require('./database');
|
||||||
|
logActivity(taskId, userId, 'STATUS_CHANGED', 'Документ отозван создателем');
|
||||||
|
|
||||||
|
// Отправляем уведомления согласующим
|
||||||
|
if (assignees) {
|
||||||
|
assignees.forEach(assignee => {
|
||||||
|
if (assignee.user_id !== userId) {
|
||||||
|
sendDocumentNotification(assignee.user_id, taskId, 'cancelled', 'Документ отозван');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Получение пакета документов (ZIP архив)
|
||||||
|
app.get('/api/documents/:id/package', requireAuth, async (req, res) => {
|
||||||
|
const documentId = req.params.id;
|
||||||
|
const userId = req.session.user.id;
|
||||||
|
|
||||||
|
// Проверяем доступ к документу
|
||||||
|
db.get(`
|
||||||
|
SELECT t.id, t.created_by, t.title, d.document_number
|
||||||
|
FROM documents d
|
||||||
|
JOIN tasks t ON d.task_id = t.id
|
||||||
|
WHERE d.id = ?
|
||||||
|
`, [documentId], async (err, result) => {
|
||||||
|
if (err || !result) {
|
||||||
|
return res.status(404).json({ error: 'Документ не найден' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что пользователь имеет доступ
|
||||||
|
const isCreator = parseInt(result.created_by) === parseInt(userId);
|
||||||
|
const isDoc1orDoc2 = await checkIfDoc1orDoc2(userId);
|
||||||
|
|
||||||
|
if (!isCreator && !isDoc1orDoc2) {
|
||||||
|
return res.status(403).json({ error: 'Недостаточно прав' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Получаем все файлы документа
|
||||||
|
db.all(`
|
||||||
|
SELECT tf.*
|
||||||
|
FROM task_files tf
|
||||||
|
JOIN tasks t ON tf.task_id = t.id
|
||||||
|
JOIN documents d ON t.id = d.task_id
|
||||||
|
WHERE d.id = ?
|
||||||
|
`, [documentId], async (err, files) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Нет файлов для скачивания'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем временный файл для архива
|
||||||
|
const tempDir = path.join(__dirname, 'temp');
|
||||||
|
if (!fs.existsSync(tempDir)) {
|
||||||
|
fs.mkdirSync(tempDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const zipFileName = `document_${documentId}_${Date.now()}.zip`;
|
||||||
|
const zipFilePath = path.join(tempDir, zipFileName);
|
||||||
|
|
||||||
|
const output = fs.createWriteStream(zipFilePath);
|
||||||
|
const archive = archiver('zip', {
|
||||||
|
zlib: { level: 9 }
|
||||||
|
});
|
||||||
|
|
||||||
|
output.on('close', () => {
|
||||||
|
// Отправляем файл
|
||||||
|
res.download(zipFilePath, `Документ_${result.document_number || result.id}.zip`, (err) => {
|
||||||
|
// Удаляем временный файл после отправки
|
||||||
|
if (fs.existsSync(zipFilePath)) {
|
||||||
|
fs.unlinkSync(zipFilePath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
archive.on('error', (err) => {
|
||||||
|
console.error('Ошибка создания архива:', err);
|
||||||
|
res.status(500).json({ error: 'Ошибка создания архива' });
|
||||||
|
});
|
||||||
|
|
||||||
|
archive.pipe(output);
|
||||||
|
|
||||||
|
// Добавляем файлы в архив
|
||||||
|
for (const file of files) {
|
||||||
|
if (fs.existsSync(file.file_path)) {
|
||||||
|
const fileName = path.basename(file.original_name);
|
||||||
|
archive.file(file.file_path, { name: fileName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем информацию о документе как текстовый файл
|
||||||
|
const docInfo = `
|
||||||
|
Документ: ${result.title}
|
||||||
|
Номер документа: ${result.document_number || 'Не указан'}
|
||||||
|
Дата создания: ${new Date().toLocaleString('ru-RU')}
|
||||||
|
|
||||||
|
Файлы в архиве:
|
||||||
|
${files.map((f, i) => `${i + 1}. ${f.original_name} (${formatFileSize(f.file_size)})`).join('\n')}
|
||||||
|
`;
|
||||||
|
|
||||||
|
archive.append(docInfo, { name: 'Информация_о_документе.txt' });
|
||||||
|
|
||||||
|
await archive.finalize();
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка создания пакета:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Ошибка создания пакета документов'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Статистика по документам
|
||||||
|
app.get('/api/documents/stats', requireAuth, (req, res) => {
|
||||||
|
const userId = req.session.user.id;
|
||||||
|
|
||||||
|
// Проверяем права
|
||||||
|
db.get("SELECT login FROM users WHERE id = ?", [userId], (err, user) => {
|
||||||
|
if (err || !user) {
|
||||||
|
return res.status(403).json({ error: 'Пользователь не найден' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userLogin = user.login;
|
||||||
|
const doc1User = process.env.DOC1_USER;
|
||||||
|
const doc2Users = process.env.DOC2_USERS ? process.env.DOC2_USERS.split(',') : [];
|
||||||
|
|
||||||
|
const isDoc1 = userLogin === doc1User;
|
||||||
|
const isDoc2 = doc2Users.includes(userLogin);
|
||||||
|
const isAdmin = req.session.user.role === 'admin';
|
||||||
|
|
||||||
|
if (!isDoc1 && !isDoc2 && !isAdmin) {
|
||||||
|
return res.status(403).json({ error: 'Недостаточно прав' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let statsQuery = '';
|
||||||
|
let params = [];
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
// Админ видит все документы
|
||||||
|
statsQuery = `
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total,
|
||||||
|
COUNT(CASE WHEN t.status = 'active' AND t.closed_at IS NULL THEN 1 END) as active,
|
||||||
|
COUNT(CASE WHEN ta.status = 'pre_approved' THEN 1 END) as pre_approved,
|
||||||
|
COUNT(CASE WHEN ta.status = 'approved' THEN 1 END) as approved,
|
||||||
|
COUNT(CASE WHEN ta.status = 'refused' THEN 1 END) as refused,
|
||||||
|
COUNT(CASE WHEN t.status = 'cancelled' THEN 1 END) as cancelled
|
||||||
|
FROM tasks t
|
||||||
|
LEFT JOIN documents d ON t.id = d.task_id
|
||||||
|
LEFT JOIN task_assignments ta ON t.id = ta.task_id
|
||||||
|
WHERE t.title LIKE 'Документ:%'
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
// DOC1 и DOC2 видят только свои документы
|
||||||
|
statsQuery = `
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total,
|
||||||
|
COUNT(CASE WHEN ta.status = 'assigned' THEN 1 END) as assigned,
|
||||||
|
COUNT(CASE WHEN ta.status = 'pre_approved' THEN 1 END) as pre_approved,
|
||||||
|
COUNT(CASE WHEN ta.status = 'approved' THEN 1 END) as approved,
|
||||||
|
COUNT(CASE WHEN ta.status = 'refused' THEN 1 END) as refused
|
||||||
|
FROM tasks t
|
||||||
|
JOIN documents d ON t.id = d.task_id
|
||||||
|
JOIN task_assignments ta ON t.id = ta.task_id
|
||||||
|
WHERE t.title LIKE 'Документ:%'
|
||||||
|
AND t.status = 'active'
|
||||||
|
AND t.closed_at IS NULL
|
||||||
|
AND ta.user_id = ?
|
||||||
|
`;
|
||||||
|
params = [userId];
|
||||||
|
}
|
||||||
|
|
||||||
|
db.get(statsQuery, params, (err, stats) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(stats || {
|
||||||
|
total: 0,
|
||||||
|
active: 0,
|
||||||
|
pre_approved: 0,
|
||||||
|
approved: 0,
|
||||||
|
refused: 0,
|
||||||
|
cancelled: 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Получение истории документа
|
||||||
|
app.get('/api/documents/:id/history', requireAuth, (req, res) => {
|
||||||
|
const documentId = req.params.id;
|
||||||
|
const userId = req.session.user.id;
|
||||||
|
|
||||||
|
// Проверяем доступ к документу
|
||||||
|
db.get(`
|
||||||
|
SELECT t.created_by
|
||||||
|
FROM documents d
|
||||||
|
JOIN tasks t ON d.task_id = t.id
|
||||||
|
WHERE d.id = ?
|
||||||
|
`, [documentId], (err, document) => {
|
||||||
|
if (err || !document) {
|
||||||
|
return res.status(404).json({ error: 'Документ не найден' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем права
|
||||||
|
const isCreator = parseInt(document.created_by) === parseInt(userId);
|
||||||
|
const isDoc1orDoc2 = checkIfDoc1orDoc2Sync(userId);
|
||||||
|
|
||||||
|
if (!isCreator && !isDoc1orDoc2) {
|
||||||
|
return res.status(403).json({ error: 'Недостаточно прав' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskIdQuery = "SELECT task_id FROM documents WHERE id = ?";
|
||||||
|
db.get(taskIdQuery, [documentId], (err, result) => {
|
||||||
|
if (err || !result) {
|
||||||
|
return res.status(500).json({ error: 'Ошибка получения истории' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskId = result.task_id;
|
||||||
|
|
||||||
|
// Получаем историю активности
|
||||||
|
db.all(`
|
||||||
|
SELECT al.*, u.name as user_name
|
||||||
|
FROM activity_logs al
|
||||||
|
LEFT JOIN users u ON al.user_id = u.id
|
||||||
|
WHERE al.task_id = ?
|
||||||
|
ORDER BY al.created_at DESC
|
||||||
|
`, [taskId], (err, history) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем комментарии из документа
|
||||||
|
db.get("SELECT comment FROM documents WHERE id = ?", [documentId], (err, doc) => {
|
||||||
|
if (err) {
|
||||||
|
doc = { comment: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const comments = doc.comment ? doc.comment.split('\n').filter(c => c.trim()).map(c => {
|
||||||
|
return {
|
||||||
|
text: c,
|
||||||
|
type: 'comment'
|
||||||
|
};
|
||||||
|
}) : [];
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
activity: history || [],
|
||||||
|
comments: comments
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Вспомогательные функции
|
||||||
|
|
||||||
|
// Функция для проверки DOC1/DOC2 (асинхронная)
|
||||||
|
function checkIfDoc1orDoc2(userId) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.get("SELECT login FROM users WHERE id = ?", [userId], (err, user) => {
|
||||||
|
if (err || !user) {
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userLogin = user.login;
|
||||||
|
const doc1User = process.env.DOC1_USER;
|
||||||
|
const doc2Users = process.env.DOC2_USERS ? process.env.DOC2_USERS.split(',') : [];
|
||||||
|
|
||||||
|
const isDoc1 = userLogin === doc1User;
|
||||||
|
const isDoc2 = doc2Users.includes(userLogin);
|
||||||
|
|
||||||
|
resolve(isDoc1 || isDoc2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для проверки DOC1/DOC2 (синхронная версия)
|
||||||
|
function checkIfDoc1orDoc2Sync(userId) {
|
||||||
|
// Эта функция используется в синхронных контекстах
|
||||||
|
// В реальном приложении нужно быть осторожным с синхронными вызовами
|
||||||
|
try {
|
||||||
|
const user = db.getSync("SELECT login FROM users WHERE id = ?", [userId]);
|
||||||
|
if (!user) return false;
|
||||||
|
|
||||||
|
const userLogin = user.login;
|
||||||
|
const doc1User = process.env.DOC1_USER;
|
||||||
|
const doc2Users = process.env.DOC2_USERS ? process.env.DOC2_USERS.split(',') : [];
|
||||||
|
|
||||||
|
const isDoc1 = userLogin === doc1User;
|
||||||
|
const isDoc2 = doc2Users.includes(userLogin);
|
||||||
|
|
||||||
|
return isDoc1 || isDoc2;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для отправки уведомлений о документах
|
||||||
|
function sendDocumentNotification(userId, taskId, type, documentTitle) {
|
||||||
|
try {
|
||||||
|
const { sendTaskNotifications } = require('./notifications');
|
||||||
|
|
||||||
|
let message = '';
|
||||||
|
switch(type) {
|
||||||
|
case 'new_document':
|
||||||
|
message = `Новый документ на согласование: ${documentTitle}`;
|
||||||
|
break;
|
||||||
|
case 'new_document_for_doc2':
|
||||||
|
message = `Новый документ для согласования (DOC2): ${documentTitle}`;
|
||||||
|
break;
|
||||||
|
case 'pre_approved':
|
||||||
|
message = `Документ предварительно согласован: ${documentTitle}`;
|
||||||
|
break;
|
||||||
|
case 'approved':
|
||||||
|
message = `Документ согласован: ${documentTitle}`;
|
||||||
|
break;
|
||||||
|
case 'refused':
|
||||||
|
message = `В согласовании документа отказано: ${documentTitle}`;
|
||||||
|
break;
|
||||||
|
case 'cancelled':
|
||||||
|
message = `Документ отозван создателем: ${documentTitle}`;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
message = `Обновление документа: ${documentTitle}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendTaskNotifications(taskId, userId, message);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка отправки уведомления:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вспомогательная функция для форматирования размера файла
|
||||||
|
function formatFileSize(bytes) {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware для аутентификации (можно импортировать из server.js)
|
||||||
|
function requireAuth(req, res, next) {
|
||||||
|
if (!req.session.user) {
|
||||||
|
return res.status(401).json({ error: 'Требуется аутентификация' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
//
|
||||||
1176
api-keys.js
Normal file
1176
api-keys.js
Normal file
File diff suppressed because it is too large
Load Diff
179
api-user-lists.js
Normal file
179
api-user-lists.js
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
// api-user-lists.js - API для управления пользовательскими списками
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
module.exports = function(app, db) {
|
||||||
|
// Middleware для проверки аутентификации
|
||||||
|
const requireAuth = (req, res, next) => {
|
||||||
|
if (!req.session || !req.session.user) {
|
||||||
|
return res.status(401).json({ error: 'Требуется аутентификация' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// GET /api/user/lists – получить все списки текущего пользователя
|
||||||
|
router.get('/api/user/lists', requireAuth, (req, res) => {
|
||||||
|
const userId = req.session.user.id;
|
||||||
|
|
||||||
|
db.all(
|
||||||
|
'SELECT id, name, user_ids, created_at, updated_at FROM user_lists WHERE user_id = ? ORDER BY created_at DESC',
|
||||||
|
[userId],
|
||||||
|
(err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('❌ Ошибка получения списков:', err);
|
||||||
|
return res.status(500).json({ error: 'Ошибка получения списков' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Преобразуем user_ids из JSON в массив
|
||||||
|
const lists = (rows || []).map(row => ({
|
||||||
|
...row,
|
||||||
|
user_ids: JSON.parse(row.user_ids || '[]')
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(lists);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/user/lists – создать новый список
|
||||||
|
router.post('/api/user/lists', requireAuth, (req, res) => {
|
||||||
|
const userId = req.session.user.id;
|
||||||
|
const { name, userIds } = req.body;
|
||||||
|
|
||||||
|
if (!name || name.trim() === '') {
|
||||||
|
return res.status(400).json({ error: 'Название списка обязательно' });
|
||||||
|
}
|
||||||
|
if (name.length > 35) {
|
||||||
|
return res.status(400).json({ error: 'Название не должно превышать 35 символов' });
|
||||||
|
}
|
||||||
|
if (!Array.isArray(userIds)) {
|
||||||
|
return res.status(400).json({ error: 'userIds должен быть массивом' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user_ids_json = JSON.stringify(userIds);
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
'INSERT INTO user_lists (user_id, name, user_ids) VALUES (?, ?, ?)',
|
||||||
|
[userId, name.trim(), user_ids_json],
|
||||||
|
function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error('❌ Ошибка создания списка:', err);
|
||||||
|
return res.status(500).json({ error: 'Ошибка создания списка' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Возвращаем созданный список
|
||||||
|
db.get(
|
||||||
|
'SELECT id, name, user_ids, created_at, updated_at FROM user_lists WHERE id = ?',
|
||||||
|
[this.lastID],
|
||||||
|
(err, row) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: 'Список создан, но ошибка получения' });
|
||||||
|
}
|
||||||
|
const newList = {
|
||||||
|
...row,
|
||||||
|
user_ids: JSON.parse(row.user_ids || '[]')
|
||||||
|
};
|
||||||
|
res.status(201).json(newList);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/user/lists/:id – обновить список
|
||||||
|
router.put('/api/user/lists/:id', requireAuth, (req, res) => {
|
||||||
|
const listId = req.params.id;
|
||||||
|
const userId = req.session.user.id;
|
||||||
|
const { name, userIds } = req.body;
|
||||||
|
|
||||||
|
// Проверяем, принадлежит ли список пользователю
|
||||||
|
db.get('SELECT id FROM user_lists WHERE id = ? AND user_id = ?', [listId, userId], (err, list) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('❌ Ошибка проверки списка:', err);
|
||||||
|
return res.status(500).json({ error: 'Ошибка доступа' });
|
||||||
|
}
|
||||||
|
if (!list) {
|
||||||
|
return res.status(404).json({ error: 'Список не найден' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = [];
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (name !== undefined) {
|
||||||
|
if (name.trim() === '') {
|
||||||
|
return res.status(400).json({ error: 'Название не может быть пустым' });
|
||||||
|
}
|
||||||
|
if (name.length > 35) {
|
||||||
|
return res.status(400).json({ error: 'Название не должно превышать 35 символов' });
|
||||||
|
}
|
||||||
|
updates.push('name = ?');
|
||||||
|
params.push(name.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userIds !== undefined) {
|
||||||
|
if (!Array.isArray(userIds)) {
|
||||||
|
return res.status(400).json({ error: 'userIds должен быть массивом' });
|
||||||
|
}
|
||||||
|
updates.push('user_ids = ?');
|
||||||
|
params.push(JSON.stringify(userIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'Нет данных для обновления' });
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.push('updated_at = CURRENT_TIMESTAMP');
|
||||||
|
params.push(listId);
|
||||||
|
|
||||||
|
const query = `UPDATE user_lists SET ${updates.join(', ')} WHERE id = ?`;
|
||||||
|
|
||||||
|
db.run(query, params, function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error('❌ Ошибка обновления списка:', err);
|
||||||
|
return res.status(500).json({ error: 'Ошибка обновления списка' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Возвращаем обновлённый список
|
||||||
|
db.get(
|
||||||
|
'SELECT id, name, user_ids, created_at, updated_at FROM user_lists WHERE id = ?',
|
||||||
|
[listId],
|
||||||
|
(err, row) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: 'Список обновлён, но ошибка получения' });
|
||||||
|
}
|
||||||
|
const updatedList = {
|
||||||
|
...row,
|
||||||
|
user_ids: JSON.parse(row.user_ids || '[]')
|
||||||
|
};
|
||||||
|
res.json(updatedList);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/user/lists/:id – удалить список
|
||||||
|
router.delete('/api/user/lists/:id', requireAuth, (req, res) => {
|
||||||
|
const listId = req.params.id;
|
||||||
|
const userId = req.session.user.id;
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
'DELETE FROM user_lists WHERE id = ? AND user_id = ?',
|
||||||
|
[listId, userId],
|
||||||
|
function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error('❌ Ошибка удаления списка:', err);
|
||||||
|
return res.status(500).json({ error: 'Ошибка удаления списка' });
|
||||||
|
}
|
||||||
|
if (this.changes === 0) {
|
||||||
|
return res.status(404).json({ error: 'Список не найден' });
|
||||||
|
}
|
||||||
|
res.json({ success: true, message: 'Список удалён' });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Подключаем роутер к приложению
|
||||||
|
app.use(router);
|
||||||
|
console.log('✅ API для пользовательских списков подключено');
|
||||||
|
};
|
||||||
556
api-users.js
Normal file
556
api-users.js
Normal file
@@ -0,0 +1,556 @@
|
|||||||
|
// api-users.js - API для управления исполнителями в задачах
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
module.exports = function(app, db) {
|
||||||
|
|
||||||
|
// Проверка прав доступа к задаче
|
||||||
|
function checkTaskAccess(userId, taskId, callback) {
|
||||||
|
db.get(`
|
||||||
|
SELECT t.created_by, ta.user_id
|
||||||
|
FROM tasks t
|
||||||
|
LEFT JOIN task_assignments ta ON t.id = ta.task_id AND ta.user_id = ?
|
||||||
|
WHERE t.id = ? AND t.status = 'active'
|
||||||
|
`, [userId, taskId], (err, result) => {
|
||||||
|
if (err || !result) {
|
||||||
|
return callback(err || new Error('Задача не найдена'), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем права: создатель или администратор
|
||||||
|
if (parseInt(result.created_by) === parseInt(userId)) {
|
||||||
|
return callback(null, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, является ли пользователь администратором
|
||||||
|
db.get("SELECT role FROM users WHERE id = ?", [userId], (err, user) => {
|
||||||
|
if (err || !user) {
|
||||||
|
return callback(err || new Error('Пользователь не найден'), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = user.role === 'admin';
|
||||||
|
const isTasks = user.role === 'tasks'; // Пользователи с ролью tasks тоже имеют доступ
|
||||||
|
callback(null, isAdmin || isTasks);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// API для получения доступных исполнителей для добавления
|
||||||
|
router.get('/api/tasks/:taskId/available-assignees', (req, res) => {
|
||||||
|
const { taskId } = req.params;
|
||||||
|
const userId = req.session.user.id;
|
||||||
|
|
||||||
|
checkTaskAccess(userId, taskId, (err, hasAccess) => {
|
||||||
|
if (err || !hasAccess) {
|
||||||
|
return res.status(403).json({ error: 'Нет доступа к задаче' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем текущих исполнителей
|
||||||
|
db.all(`
|
||||||
|
SELECT user_id
|
||||||
|
FROM task_assignments
|
||||||
|
WHERE task_id = ? AND status != 'deleted'
|
||||||
|
`, [taskId], (err, currentAssignees) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentAssigneeIds = currentAssignees.map(a => a.user_id);
|
||||||
|
|
||||||
|
// Получаем всех пользователей, кроме текущих исполнителей
|
||||||
|
db.all(`
|
||||||
|
SELECT id, login, name, email, role, auth_type
|
||||||
|
FROM users
|
||||||
|
WHERE id NOT IN (${currentAssigneeIds.map(() => '?').join(',')})
|
||||||
|
AND id != ?
|
||||||
|
ORDER BY name
|
||||||
|
`, [...currentAssigneeIds, userId], (err, users) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(users);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// API для добавления исполнителя к задаче
|
||||||
|
router.post('/api/tasks/:taskId/assignees', (req, res) => {
|
||||||
|
const { taskId } = req.params;
|
||||||
|
const userId = req.session.user.id;
|
||||||
|
const { assigneeIds } = req.body;
|
||||||
|
|
||||||
|
if (!Array.isArray(assigneeIds) || assigneeIds.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'Не указаны исполнители' });
|
||||||
|
}
|
||||||
|
|
||||||
|
checkTaskAccess(userId, taskId, (err, hasAccess) => {
|
||||||
|
if (err || !hasAccess) {
|
||||||
|
return res.status(403).json({ error: 'Нет доступа к задаче' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем информацию о задаче
|
||||||
|
db.get("SELECT due_date FROM tasks WHERE id = ?", [taskId], (err, task) => {
|
||||||
|
if (err || !task) {
|
||||||
|
return res.status(404).json({ error: 'Задача не найдена' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Начинаем транзакцию
|
||||||
|
db.serialize(() => {
|
||||||
|
db.run("BEGIN TRANSACTION");
|
||||||
|
|
||||||
|
const errors = [];
|
||||||
|
const addedAssignees = [];
|
||||||
|
|
||||||
|
// Добавляем каждого исполнителя
|
||||||
|
assigneeIds.forEach((assigneeId, index) => {
|
||||||
|
db.run(`
|
||||||
|
INSERT OR IGNORE INTO task_assignments
|
||||||
|
(task_id, user_id, status, created_at, due_date)
|
||||||
|
VALUES (?, ?, 'assigned', datetime('now'), ?)
|
||||||
|
`, [taskId, assigneeId, task.due_date], function(err) {
|
||||||
|
if (err) {
|
||||||
|
errors.push(`Ошибка добавления исполнителя ${assigneeId}: ${err.message}`);
|
||||||
|
} else if (this.changes > 0) {
|
||||||
|
addedAssignees.push(assigneeId);
|
||||||
|
|
||||||
|
// Логируем действие
|
||||||
|
const activityService = require('./database');
|
||||||
|
if (activityService.logActivity) {
|
||||||
|
activityService.logActivity(
|
||||||
|
taskId,
|
||||||
|
userId,
|
||||||
|
'TASK_ASSIGNED',
|
||||||
|
`Добавлен исполнитель: ${assigneeId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если это последний запрос, завершаем транзакцию
|
||||||
|
if (index === assigneeIds.length - 1) {
|
||||||
|
if (errors.length > 0) {
|
||||||
|
db.run("ROLLBACK");
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Ошибка добавления исполнителей',
|
||||||
|
details: errors
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
db.run("COMMIT");
|
||||||
|
|
||||||
|
// Отправляем уведомления
|
||||||
|
const { sendTaskNotifications } = require('./notifications');
|
||||||
|
if (sendTaskNotifications) {
|
||||||
|
sendTaskNotifications(taskId, 'assigned', userId, assigneeIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
added: addedAssignees.length,
|
||||||
|
message: `Добавлено ${addedAssignees.length} исполнителей`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// API для удаления исполнителя из задачи
|
||||||
|
router.delete('/api/tasks/:taskId/assignees/:assigneeId', (req, res) => {
|
||||||
|
const { taskId, assigneeId } = req.params;
|
||||||
|
const userId = req.session.user.id;
|
||||||
|
|
||||||
|
checkTaskAccess(userId, taskId, (err, hasAccess) => {
|
||||||
|
if (err || !hasAccess) {
|
||||||
|
return res.status(403).json({ error: 'Нет доступа к задаче' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что исполнитель существует
|
||||||
|
db.get(`
|
||||||
|
SELECT id FROM task_assignments
|
||||||
|
WHERE task_id = ? AND user_id = ? AND status != 'deleted'
|
||||||
|
`, [taskId, assigneeId], (err, assignment) => {
|
||||||
|
if (err || !assignment) {
|
||||||
|
return res.status(404).json({ error: 'Исполнитель не найден в задаче' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаляем исполнителя (помечаем как удаленного)
|
||||||
|
db.run(`
|
||||||
|
UPDATE task_assignments
|
||||||
|
SET status = 'deleted', updated_at = datetime('now')
|
||||||
|
WHERE task_id = ? AND user_id = ?
|
||||||
|
`, [taskId, assigneeId], function(err) {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.changes > 0) {
|
||||||
|
// Логируем действие
|
||||||
|
const activityService = require('./database');
|
||||||
|
if (activityService.logActivity) {
|
||||||
|
activityService.logActivity(
|
||||||
|
taskId,
|
||||||
|
userId,
|
||||||
|
'TASK_ASSIGNMENTS_UPDATED',
|
||||||
|
`Удален исполнитель: ${assigneeId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Исполнитель удален из задачи'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(404).json({ error: 'Исполнитель не найден' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// API для замены всех исполнителей
|
||||||
|
router.put('/api/tasks/:taskId/replace-all-assignees', (req, res) => {
|
||||||
|
const { taskId } = req.params;
|
||||||
|
const userId = req.session.user.id;
|
||||||
|
const { newAssigneeIds } = req.body;
|
||||||
|
|
||||||
|
if (!Array.isArray(newAssigneeIds) || newAssigneeIds.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'Не указаны новые исполнители' });
|
||||||
|
}
|
||||||
|
|
||||||
|
checkTaskAccess(userId, taskId, (err, hasAccess) => {
|
||||||
|
if (err || !hasAccess) {
|
||||||
|
return res.status(403).json({ error: 'Нет доступа к задаче' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем информацию о задаче
|
||||||
|
db.get("SELECT due_date FROM tasks WHERE id = ?", [taskId], (err, task) => {
|
||||||
|
if (err || !task) {
|
||||||
|
return res.status(404).json({ error: 'Задача не найдена' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Начинаем транзакцию
|
||||||
|
db.serialize(() => {
|
||||||
|
db.run("BEGIN TRANSACTION");
|
||||||
|
|
||||||
|
// Удаляем всех текущих исполнителей
|
||||||
|
db.run(`
|
||||||
|
UPDATE task_assignments
|
||||||
|
SET status = 'deleted', updated_at = datetime('now')
|
||||||
|
WHERE task_id = ? AND status != 'deleted'
|
||||||
|
`, [taskId], function(err) {
|
||||||
|
if (err) {
|
||||||
|
db.run("ROLLBACK");
|
||||||
|
return res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const removedCount = this.changes;
|
||||||
|
const errors = [];
|
||||||
|
let addedCount = 0; // Изменено с const на let
|
||||||
|
|
||||||
|
// Если нет новых исполнителей для добавления, просто завершаем
|
||||||
|
if (newAssigneeIds.length === 0) {
|
||||||
|
db.run("COMMIT");
|
||||||
|
|
||||||
|
// Логируем действие
|
||||||
|
const activityService = require('./database');
|
||||||
|
if (activityService.logActivity) {
|
||||||
|
activityService.logActivity(
|
||||||
|
taskId,
|
||||||
|
userId,
|
||||||
|
'TASK_ASSIGNMENTS_UPDATED',
|
||||||
|
`Удалены все исполнители. Удалено: ${removedCount}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
removed: removedCount,
|
||||||
|
added: 0,
|
||||||
|
message: `Удалены все исполнители: ${removedCount}`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Счетчик для отслеживания завершенных операций
|
||||||
|
let completedCount = 0;
|
||||||
|
|
||||||
|
// Добавляем новых исполнителей
|
||||||
|
newAssigneeIds.forEach((assigneeId) => {
|
||||||
|
db.run(`
|
||||||
|
INSERT INTO task_assignments
|
||||||
|
(task_id, user_id, status, created_at, due_date)
|
||||||
|
VALUES (?, ?, 'assigned', datetime('now'), ?)
|
||||||
|
`, [taskId, assigneeId, task.due_date], function(err) {
|
||||||
|
if (err) {
|
||||||
|
errors.push(`Ошибка добавления исполнителя ${assigneeId}: ${err.message}`);
|
||||||
|
} else {
|
||||||
|
addedCount++;
|
||||||
|
|
||||||
|
// Логируем добавление
|
||||||
|
const activityService = require('./database');
|
||||||
|
if (activityService.logActivity) {
|
||||||
|
activityService.logActivity(
|
||||||
|
taskId,
|
||||||
|
userId,
|
||||||
|
'TASK_ASSIGNED',
|
||||||
|
`Добавлен исполнитель: ${assigneeId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
completedCount++;
|
||||||
|
|
||||||
|
// Если все запросы завершены, завершаем транзакцию
|
||||||
|
if (completedCount === newAssigneeIds.length) {
|
||||||
|
if (errors.length > 0) {
|
||||||
|
db.run("ROLLBACK");
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Ошибка замены исполнителей',
|
||||||
|
details: errors
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
db.run("COMMIT");
|
||||||
|
|
||||||
|
// Логируем замену всех
|
||||||
|
const activityService = require('./database');
|
||||||
|
if (activityService.logActivity) {
|
||||||
|
activityService.logActivity(
|
||||||
|
taskId,
|
||||||
|
userId,
|
||||||
|
'TASK_ASSIGNMENTS_UPDATED',
|
||||||
|
`Заменены все исполнители. Удалено: ${removedCount}, добавлено: ${addedCount}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправляем уведомления
|
||||||
|
const { sendTaskNotifications } = require('./notifications');
|
||||||
|
if (sendTaskNotifications) {
|
||||||
|
sendTaskNotifications(taskId, 'assigned', userId, newAssigneeIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
removed: removedCount,
|
||||||
|
added: addedCount,
|
||||||
|
message: `Заменены исполнители: удалено ${removedCount}, добавлено ${addedCount}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// API для замены конкретного исполнителя
|
||||||
|
router.put('/api/tasks/:taskId/replace-assignee', (req, res) => {
|
||||||
|
const { taskId } = req.params;
|
||||||
|
const userId = req.session.user.id;
|
||||||
|
const { oldAssigneeId, newAssigneeId } = req.body;
|
||||||
|
|
||||||
|
if (!oldAssigneeId || !newAssigneeId) {
|
||||||
|
return res.status(400).json({ error: 'Не указан старый или новый исполнитель' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldAssigneeId === newAssigneeId) {
|
||||||
|
return res.status(400).json({ error: 'Старый и новый исполнитель одинаковы' });
|
||||||
|
}
|
||||||
|
|
||||||
|
checkTaskAccess(userId, taskId, (err, hasAccess) => {
|
||||||
|
if (err || !hasAccess) {
|
||||||
|
return res.status(403).json({ error: 'Нет доступа к задаче' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем информацию о задаче
|
||||||
|
db.get("SELECT due_date FROM tasks WHERE id = ?", [taskId], (err, task) => {
|
||||||
|
if (err || !task) {
|
||||||
|
return res.status(404).json({ error: 'Задача не найдена' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Начинаем транзакцию
|
||||||
|
db.serialize(() => {
|
||||||
|
db.run("BEGIN TRANSACTION");
|
||||||
|
|
||||||
|
// Удаляем старого исполнителя
|
||||||
|
db.run(`
|
||||||
|
UPDATE task_assignments
|
||||||
|
SET status = 'deleted', updated_at = datetime('now')
|
||||||
|
WHERE task_id = ? AND user_id = ? AND status != 'deleted'
|
||||||
|
`, [taskId, oldAssigneeId], function(err) {
|
||||||
|
if (err) {
|
||||||
|
db.run("ROLLBACK");
|
||||||
|
return res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.changes === 0) {
|
||||||
|
db.run("ROLLBACK");
|
||||||
|
return res.status(404).json({ error: 'Старый исполнитель не найден' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем нового исполнителя
|
||||||
|
db.run(`
|
||||||
|
INSERT INTO task_assignments
|
||||||
|
(task_id, user_id, status, created_at, due_date)
|
||||||
|
VALUES (?, ?, 'assigned', datetime('now'), ?)
|
||||||
|
`, [taskId, newAssigneeId, task.due_date], function(err) {
|
||||||
|
if (err) {
|
||||||
|
db.run("ROLLBACK");
|
||||||
|
return res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run("COMMIT");
|
||||||
|
|
||||||
|
// Логируем действие
|
||||||
|
const activityService = require('./database');
|
||||||
|
if (activityService.logActivity) {
|
||||||
|
activityService.logActivity(
|
||||||
|
taskId,
|
||||||
|
userId,
|
||||||
|
'TASK_ASSIGNMENTS_UPDATED',
|
||||||
|
`Заменен исполнитель: ${oldAssigneeId} -> ${newAssigneeId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправляем уведомления
|
||||||
|
const { sendTaskNotifications } = require('./notifications');
|
||||||
|
if (sendTaskNotifications) {
|
||||||
|
sendTaskNotifications(taskId, 'assigned', userId, [newAssigneeId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `Исполнитель заменен: ${oldAssigneeId} -> ${newAssigneeId}`
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// API для смены всех исполнителей на конкретного пользователя (например, kalugin.o)
|
||||||
|
router.put('/api/tasks/:taskId/assign-all-to-user', (req, res) => {
|
||||||
|
const { taskId } = req.params;
|
||||||
|
const userId = req.session.user.id;
|
||||||
|
const { targetUserId } = req.body;
|
||||||
|
|
||||||
|
if (!targetUserId) {
|
||||||
|
return res.status(400).json({ error: 'Не указан целевой пользователь' });
|
||||||
|
}
|
||||||
|
|
||||||
|
checkTaskAccess(userId, taskId, (err, hasAccess) => {
|
||||||
|
if (err || !hasAccess) {
|
||||||
|
return res.status(403).json({ error: 'Нет доступа к задаче' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем информацию о задаче
|
||||||
|
db.get("SELECT due_date FROM tasks WHERE id = ?", [taskId], (err, task) => {
|
||||||
|
if (err || !task) {
|
||||||
|
return res.status(404).json({ error: 'Задача не найдена' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем текущих исполнителей
|
||||||
|
db.all(`
|
||||||
|
SELECT user_id FROM task_assignments
|
||||||
|
WHERE task_id = ? AND status != 'deleted' AND user_id != ?
|
||||||
|
`, [taskId, targetUserId], (err, currentAssignees) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Начинаем транзакцию
|
||||||
|
db.serialize(() => {
|
||||||
|
db.run("BEGIN TRANSACTION");
|
||||||
|
|
||||||
|
// Удаляем всех текущих исполнителей, кроме целевого
|
||||||
|
if (currentAssignees.length > 0) {
|
||||||
|
const currentAssigneeIds = currentAssignees.map(a => a.user_id);
|
||||||
|
const placeholders = currentAssigneeIds.map(() => '?').join(',');
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
UPDATE task_assignments
|
||||||
|
SET status = 'deleted', updated_at = datetime('now')
|
||||||
|
WHERE task_id = ? AND user_id IN (${placeholders})
|
||||||
|
`, [taskId, ...currentAssigneeIds], function(err) {
|
||||||
|
if (err) {
|
||||||
|
db.run("ROLLBACK");
|
||||||
|
return res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
processNextStep();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
processNextStep();
|
||||||
|
}
|
||||||
|
|
||||||
|
function processNextStep() {
|
||||||
|
// Проверяем, есть ли уже целевой пользователь в исполнителях
|
||||||
|
db.get(`
|
||||||
|
SELECT id FROM task_assignments
|
||||||
|
WHERE task_id = ? AND user_id = ? AND status != 'deleted'
|
||||||
|
`, [taskId, targetUserId], (err, existing) => {
|
||||||
|
if (err) {
|
||||||
|
db.run("ROLLBACK");
|
||||||
|
return res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
// Добавляем целевого пользователя
|
||||||
|
db.run(`
|
||||||
|
INSERT INTO task_assignments
|
||||||
|
(task_id, user_id, status, created_at, due_date)
|
||||||
|
VALUES (?, ?, 'assigned', datetime('now'), ?)
|
||||||
|
`, [taskId, targetUserId, task.due_date], function(err) {
|
||||||
|
if (err) {
|
||||||
|
db.run("ROLLBACK");
|
||||||
|
return res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
finishTransaction();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
finishTransaction();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishTransaction() {
|
||||||
|
db.run("COMMIT");
|
||||||
|
|
||||||
|
// Логируем действие
|
||||||
|
const activityService = require('./database');
|
||||||
|
if (activityService.logActivity) {
|
||||||
|
activityService.logActivity(
|
||||||
|
taskId,
|
||||||
|
userId,
|
||||||
|
'TASK_ASSIGNMENTS_UPDATED',
|
||||||
|
`Все исполнители заменены на: ${targetUserId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправляем уведомления
|
||||||
|
const { sendTaskNotifications } = require('./notifications');
|
||||||
|
if (sendTaskNotifications) {
|
||||||
|
sendTaskNotifications(taskId, 'assigned', userId, [targetUserId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
removed: currentAssignees.length,
|
||||||
|
added: 1,
|
||||||
|
message: `Все исполнители заменены на пользователя ${targetUserId}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Подключаем роутер к приложению
|
||||||
|
app.use(router);
|
||||||
|
|
||||||
|
console.log('✅ API для управления исполнителями подключено');
|
||||||
|
};
|
||||||
1063
api2-groups.js
Normal file
1063
api2-groups.js
Normal file
File diff suppressed because it is too large
Load Diff
60
auth.js
60
auth.js
@@ -28,6 +28,7 @@ class AuthService {
|
|||||||
password: process.env.USER_1_PASSWORD,
|
password: process.env.USER_1_PASSWORD,
|
||||||
name: process.env.USER_1_NAME,
|
name: process.env.USER_1_NAME,
|
||||||
email: process.env.USER_1_EMAIL,
|
email: process.env.USER_1_EMAIL,
|
||||||
|
role: process.env.USER_1_ROLE || 'teacher',
|
||||||
auth_type: 'local'
|
auth_type: 'local'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -35,6 +36,7 @@ class AuthService {
|
|||||||
password: process.env.USER_2_PASSWORD,
|
password: process.env.USER_2_PASSWORD,
|
||||||
name: process.env.USER_2_NAME,
|
name: process.env.USER_2_NAME,
|
||||||
email: process.env.USER_2_EMAIL,
|
email: process.env.USER_2_EMAIL,
|
||||||
|
role: process.env.USER_2_ROLE || 'teacher',
|
||||||
auth_type: 'local'
|
auth_type: 'local'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -42,6 +44,7 @@ class AuthService {
|
|||||||
password: process.env.USER_3_PASSWORD,
|
password: process.env.USER_3_PASSWORD,
|
||||||
name: process.env.USER_3_NAME,
|
name: process.env.USER_3_NAME,
|
||||||
email: process.env.USER_3_EMAIL,
|
email: process.env.USER_3_EMAIL,
|
||||||
|
role: process.env.USER_3_ROLE || 'teacher',
|
||||||
auth_type: 'local'
|
auth_type: 'local'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
@@ -79,7 +82,7 @@ class AuthService {
|
|||||||
hashedPassword,
|
hashedPassword,
|
||||||
userData.name,
|
userData.name,
|
||||||
userData.email,
|
userData.email,
|
||||||
'teacher',
|
userData.role,
|
||||||
userData.auth_type || 'local'
|
userData.auth_type || 'local'
|
||||||
],
|
],
|
||||||
function(err) {
|
function(err) {
|
||||||
@@ -180,15 +183,50 @@ class AuthService {
|
|||||||
const { username, full_name, groups, description } = ldapData;
|
const { username, full_name, groups, description } = ldapData;
|
||||||
|
|
||||||
// Определяем роль пользователя на основе групп
|
// Определяем роль пользователя на основе групп
|
||||||
|
// Получаем все группы из .env
|
||||||
const allowedGroups = process.env.ALLOWED_GROUPS ?
|
const allowedGroups = process.env.ALLOWED_GROUPS ?
|
||||||
process.env.ALLOWED_GROUPS.split(',').map(g => g.trim()) : [];
|
process.env.ALLOWED_GROUPS.split(',').map(g => g.trim()) : [];
|
||||||
|
|
||||||
// ВАЖНО: Проверяем актуальные группы при каждом входе
|
const secretaryGroups = process.env.SECRETARY_GROUPS ?
|
||||||
const isAdmin = groups && groups.some(group =>
|
process.env.SECRETARY_GROUPS.split(',').map(g => g.trim()) : [];
|
||||||
allowedGroups.includes(group)
|
|
||||||
);
|
|
||||||
|
|
||||||
const role = isAdmin ? 'admin' : 'teacher';
|
const helpGroups = process.env.HELP_GROUPS ?
|
||||||
|
process.env.HELP_GROUPS.split(',').map(g => g.trim()) : [];
|
||||||
|
|
||||||
|
const itHelpGroups = process.env.ITHELP_GROUPS ?
|
||||||
|
process.env.ITHELP_GROUPS.split(',').map(g => g.trim()) : [];
|
||||||
|
|
||||||
|
const requestGroups = process.env.REQUEST_GROUPS ?
|
||||||
|
process.env.REQUEST_GROUPS.split(',').map(g => g.trim()) : [];
|
||||||
|
|
||||||
|
const tasksGroups = process.env.TASKS_GROUPS ?
|
||||||
|
process.env.TASKS_GROUPS.split(',').map(g => g.trim()) : [];
|
||||||
|
|
||||||
|
// Все LDAP пользователи являются "teacher" по умолчанию
|
||||||
|
let userGroups = groups ? [...groups] : [];
|
||||||
|
|
||||||
|
// Проверяем все группы пользователя и определяем роль
|
||||||
|
// Если пользователь состоит в нескольких группах, приоритет определяется порядком проверки
|
||||||
|
let role = 'teacher'; // Роль по умолчанию для всех пользователей
|
||||||
|
|
||||||
|
if (userGroups.length > 0) {
|
||||||
|
// Определяем наивысшую роль пользователя
|
||||||
|
// Порядок приоритета: admin > secretary > help > ithelp > request > tasks > teacher
|
||||||
|
if (userGroups.some(group => allowedGroups.includes(group))) {
|
||||||
|
role = 'admin';
|
||||||
|
} else if (userGroups.some(group => secretaryGroups.includes(group))) {
|
||||||
|
role = 'secretary';
|
||||||
|
} else if (userGroups.some(group => helpGroups.includes(group))) {
|
||||||
|
role = 'help';
|
||||||
|
} else if (userGroups.some(group => itHelpGroups.includes(group))) {
|
||||||
|
role = 'ithelp';
|
||||||
|
} else if (userGroups.some(group => requestGroups.includes(group))) {
|
||||||
|
role = 'request';
|
||||||
|
} else if (userGroups.some(group => tasksGroups.includes(group))) {
|
||||||
|
role = 'tasks';
|
||||||
|
}
|
||||||
|
// Если ни одна из специальных групп не найдена, остается 'teacher'
|
||||||
|
}
|
||||||
|
|
||||||
// Сохраняем/обновляем пользователя в базе
|
// Сохраняем/обновляем пользователя в базе
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -202,15 +240,15 @@ class AuthService {
|
|||||||
login: username,
|
login: username,
|
||||||
name: full_name || username,
|
name: full_name || username,
|
||||||
email: `${username}@school25.ru`,
|
email: `${username}@school25.ru`,
|
||||||
role: role, // Всегда обновляем роль из актуальных групп
|
role: role,
|
||||||
auth_type: 'ldap',
|
auth_type: 'ldap',
|
||||||
groups: groups ? JSON.stringify(groups) : '[]',
|
groups: JSON.stringify(userGroups),
|
||||||
description: description || '',
|
description: description || '',
|
||||||
last_login: new Date().toISOString()
|
last_login: new Date().toISOString()
|
||||||
};
|
};
|
||||||
|
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
// Всегда обновляем роль, даже если пользователь уже существует
|
// Всегда обновляем роль и группы
|
||||||
this.db.run(
|
this.db.run(
|
||||||
`UPDATE users SET
|
`UPDATE users SET
|
||||||
name = ?, email = ?, role = ?, groups = ?, description = ?, last_login = datetime('now'),
|
name = ?, email = ?, role = ?, groups = ?, description = ?, last_login = datetime('now'),
|
||||||
@@ -221,7 +259,7 @@ class AuthService {
|
|||||||
if (err) {
|
if (err) {
|
||||||
reject(err);
|
reject(err);
|
||||||
} else {
|
} else {
|
||||||
console.log(`✅ Обновлены данные LDAP пользователя ${username}. Роль: ${userData.role}, Группы: ${groups}`);
|
console.log(`✅ Обновлены данные LDAP пользователя ${username}. Роль: ${userData.role}, Группы: ${userGroups.length}`);
|
||||||
resolve({
|
resolve({
|
||||||
id: existingUser.id,
|
id: existingUser.id,
|
||||||
login: userData.login,
|
login: userData.login,
|
||||||
@@ -247,7 +285,7 @@ class AuthService {
|
|||||||
if (err) {
|
if (err) {
|
||||||
reject(err);
|
reject(err);
|
||||||
} else {
|
} else {
|
||||||
console.log(`✅ Создан новый LDAP пользователь ${username}. Роль: ${userData.role}, Группы: ${groups}`);
|
console.log(`✅ Создан новый LDAP пользователь ${username}. Роль: ${userData.role}, Группы: ${userGroups.length}`);
|
||||||
resolve({
|
resolve({
|
||||||
id: this.lastID,
|
id: this.lastID,
|
||||||
login: userData.login,
|
login: userData.login,
|
||||||
|
|||||||
114
cron-jobs.js
Normal file
114
cron-jobs.js
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
// cron-jobs.js
|
||||||
|
const { logActivity } = require('./database');
|
||||||
|
const { sendChatSummaryNotifications } = require('./notifications');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет задачи с типом document_approval, у которых указан номер документа,
|
||||||
|
* и автоматически переводит всех их исполнителей в статус completed.
|
||||||
|
* Задача остаётся открытой (closed_at не заполняется).
|
||||||
|
* @param {Object} db - экземпляр базы данных SQLite
|
||||||
|
*/
|
||||||
|
function checkDocumentsForCompletion(db) {
|
||||||
|
console.log('🔄 [CRON] Проверка задач документов для автоматического завершения исполнителей...');
|
||||||
|
|
||||||
|
// Находим активные задачи типа document, у которых есть номер документа
|
||||||
|
// и которые ещё не закрыты (closed_at IS NULL) – можно оставить для страховки
|
||||||
|
const query = `
|
||||||
|
SELECT id, title, document_n
|
||||||
|
FROM tasks
|
||||||
|
WHERE task_type = 'document'
|
||||||
|
AND status = 'active'
|
||||||
|
AND closed_at IS NULL
|
||||||
|
AND document_n IS NOT NULL
|
||||||
|
AND document_n != ''
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.all(query, [], (err, tasks) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('❌ [CRON] Ошибка при поиске задач документов:', err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tasks || tasks.length === 0) {
|
||||||
|
console.log('ℹ️ [CRON] Нет задач документов, требующих завершения исполнителей.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🔍 [CRON] Найдено ${tasks.length} задач документов с номером.`);
|
||||||
|
|
||||||
|
tasks.forEach(task => {
|
||||||
|
// Для каждой задачи находим всех исполнителей, у которых статус не 'completed'
|
||||||
|
const assignmentQuery = `
|
||||||
|
SELECT id, user_id, status
|
||||||
|
FROM task_assignments
|
||||||
|
WHERE task_id = ? AND status != 'completed'
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.all(assignmentQuery, [task.id], (err, assignments) => {
|
||||||
|
if (err) {
|
||||||
|
console.error(`❌ [CRON] Ошибка при получении исполнителей задачи ${task.id}:`, err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!assignments || assignments.length === 0) {
|
||||||
|
console.log(`ℹ️ [CRON] Задача ${task.id} (${task.title}) уже имеет всех исполнителей со статусом completed.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📋 [CRON] Задача ${task.id}: нужно завершить ${assignments.length} исполнителей.`);
|
||||||
|
|
||||||
|
// Обновляем статус каждого исполнителя на 'completed'
|
||||||
|
assignments.forEach(assignment => {
|
||||||
|
db.run(
|
||||||
|
`UPDATE task_assignments SET status = 'completed', updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||||
|
[assignment.id],
|
||||||
|
function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error(`❌ [CRON] Ошибка обновления назначения ${assignment.id}:`, err);
|
||||||
|
} else {
|
||||||
|
console.log(`✅ [CRON] Исполнитель ${assignment.user_id} задачи ${task.id} завершён автоматически.`);
|
||||||
|
|
||||||
|
// Логируем действие
|
||||||
|
try {
|
||||||
|
logActivity(
|
||||||
|
task.id,
|
||||||
|
0, // системное действие, user_id = null
|
||||||
|
'AUTO_COMPLETED',
|
||||||
|
`Автоматически завершено после указания номера документа`
|
||||||
|
);
|
||||||
|
} catch (logErr) {
|
||||||
|
console.error('❌ [CRON] Ошибка логирования:', logErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запускает cron-задачу для отправки сводок о новых сообщениях в чате.
|
||||||
|
* Отправка происходит раз в час.
|
||||||
|
*/
|
||||||
|
function startChatNotificationsCron() {
|
||||||
|
console.log('🕐 [CRON] Запущен планировщик уведомлений чата (каждый час)');
|
||||||
|
|
||||||
|
// Первый запуск через 5 секунд после старта сервера, затем каждый час
|
||||||
|
setTimeout(async () => {
|
||||||
|
console.log('📢 [CRON] Первый запуск отправки сводок чата...');
|
||||||
|
await sendChatSummaryNotifications();
|
||||||
|
|
||||||
|
// Запускаем интервал
|
||||||
|
setInterval(async () => {
|
||||||
|
console.log('📢 [CRON] Плановый запуск отправки сводок чата...');
|
||||||
|
await sendChatSummaryNotifications();
|
||||||
|
}, 60 * 60 * 1000); // 1 час
|
||||||
|
}, 5000); // 5 секунд
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
checkDocumentsForCompletion,
|
||||||
|
startChatNotificationsCron
|
||||||
|
};
|
||||||
1391
database.js
1391
database.js
File diff suppressed because it is too large
Load Diff
1409
email-notifications.js
Normal file
1409
email-notifications.js
Normal file
File diff suppressed because it is too large
Load Diff
483
migrate.js
483
migrate.js
@@ -1,483 +0,0 @@
|
|||||||
|
|
||||||
const sqlite3 = require('sqlite3').verbose();
|
|
||||||
const { Pool } = require('pg');
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
require('dotenv').config();
|
|
||||||
|
|
||||||
async function migrateToPostgres() {
|
|
||||||
console.log('🚀 Начинаем миграцию данных из SQLite в PostgreSQL...');
|
|
||||||
|
|
||||||
// Проверяем существование SQLite базы
|
|
||||||
const sqlitePath = path.join(__dirname, 'data', 'school_crm.db');
|
|
||||||
if (!fs.existsSync(sqlitePath)) {
|
|
||||||
console.error('❌ Файл SQLite базы не найден:', sqlitePath);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Подключаемся к SQLite
|
|
||||||
const sqliteDb = new sqlite3.Database(sqlitePath, (err) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('❌ Ошибка подключения к SQLite:', err.message);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('✅ SQLite база найдена и подключена');
|
|
||||||
|
|
||||||
// Проверяем настройки PostgreSQL
|
|
||||||
if (!process.env.DB_HOST || !process.env.DB_USER || !process.env.DB_PASSWORD) {
|
|
||||||
console.error('❌ Настройки PostgreSQL не указаны в .env файле');
|
|
||||||
console.error(' Укажите DB_HOST, DB_USER, DB_PASSWORD');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Подключаемся к PostgreSQL
|
|
||||||
const pgPool = new Pool({
|
|
||||||
host: process.env.DB_HOST,
|
|
||||||
port: process.env.DB_PORT || 5432,
|
|
||||||
database: process.env.DB_NAME || 'minicrm',
|
|
||||||
user: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASSWORD,
|
|
||||||
max: 5,
|
|
||||||
idleTimeoutMillis: 30000,
|
|
||||||
connectionTimeoutMillis: 10000,
|
|
||||||
});
|
|
||||||
|
|
||||||
let client;
|
|
||||||
try {
|
|
||||||
console.log('🔌 Подключаемся к PostgreSQL...');
|
|
||||||
client = await pgPool.connect();
|
|
||||||
console.log('✅ Подключение к PostgreSQL установлено');
|
|
||||||
|
|
||||||
// Создаем таблицы в PostgreSQL если их нет
|
|
||||||
console.log('🔧 Создаем/проверяем таблицы в PostgreSQL...');
|
|
||||||
await createPostgresTables(client);
|
|
||||||
|
|
||||||
// Отключаем foreign key constraints для упрощения миграции
|
|
||||||
await client.query('SET session_replication_role = replica;');
|
|
||||||
|
|
||||||
// Мигрируем таблицу users
|
|
||||||
console.log('📦 Мигрируем таблицу users...');
|
|
||||||
const users = await new Promise((resolve, reject) => {
|
|
||||||
sqliteDb.all('SELECT * FROM users ORDER BY id', [], (err, rows) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(rows);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (users.length > 0) {
|
|
||||||
let migratedUsers = 0;
|
|
||||||
for (const user of users) {
|
|
||||||
try {
|
|
||||||
await client.query(`
|
|
||||||
INSERT INTO users (id, login, password, name, email, role, auth_type,
|
|
||||||
groups, description, created_at, last_login, updated_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
|
||||||
login = EXCLUDED.login,
|
|
||||||
name = EXCLUDED.name,
|
|
||||||
email = EXCLUDED.email,
|
|
||||||
role = EXCLUDED.role,
|
|
||||||
auth_type = EXCLUDED.auth_type,
|
|
||||||
groups = EXCLUDED.groups,
|
|
||||||
description = EXCLUDED.description,
|
|
||||||
last_login = EXCLUDED.last_login,
|
|
||||||
updated_at = EXCLUDED.updated_at
|
|
||||||
`, [
|
|
||||||
user.id,
|
|
||||||
user.login,
|
|
||||||
user.password || null,
|
|
||||||
user.name,
|
|
||||||
user.email,
|
|
||||||
user.role || 'teacher',
|
|
||||||
user.auth_type || 'local',
|
|
||||||
user.groups || '[]',
|
|
||||||
user.description || '',
|
|
||||||
user.created_at,
|
|
||||||
user.last_login,
|
|
||||||
user.updated_at || user.created_at
|
|
||||||
]);
|
|
||||||
migratedUsers++;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(` Ошибка при миграции пользователя ${user.id}:`, error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(`✅ Мигрировано ${migratedUsers} из ${users.length} пользователей`);
|
|
||||||
} else {
|
|
||||||
console.log('ℹ️ В таблице users нет данных для миграции');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Мигрируем таблицу tasks
|
|
||||||
console.log('📦 Мигрируем таблицу tasks...');
|
|
||||||
const tasks = await new Promise((resolve, reject) => {
|
|
||||||
sqliteDb.all('SELECT * FROM tasks ORDER BY id', [], (err, rows) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(rows);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (tasks.length > 0) {
|
|
||||||
let migratedTasks = 0;
|
|
||||||
for (const task of tasks) {
|
|
||||||
try {
|
|
||||||
await client.query(`
|
|
||||||
INSERT INTO tasks (id, title, description, status, created_by, created_at,
|
|
||||||
updated_at, deleted_at, deleted_by, original_task_id,
|
|
||||||
start_date, due_date, rework_comment, closed_at, closed_by)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
|
||||||
title = EXCLUDED.title,
|
|
||||||
description = EXCLUDED.description,
|
|
||||||
status = EXCLUDED.status,
|
|
||||||
created_by = EXCLUDED.created_by,
|
|
||||||
updated_at = EXCLUDED.updated_at,
|
|
||||||
deleted_at = EXCLUDED.deleted_at,
|
|
||||||
deleted_by = EXCLUDED.deleted_by,
|
|
||||||
original_task_id = EXCLUDED.original_task_id,
|
|
||||||
start_date = EXCLUDED.start_date,
|
|
||||||
due_date = EXCLUDED.due_date,
|
|
||||||
rework_comment = EXCLUDED.rework_comment,
|
|
||||||
closed_at = EXCLUDED.closed_at,
|
|
||||||
closed_by = EXCLUDED.closed_by
|
|
||||||
`, [
|
|
||||||
task.id,
|
|
||||||
task.title,
|
|
||||||
task.description || '',
|
|
||||||
task.status || 'active',
|
|
||||||
task.created_by,
|
|
||||||
task.created_at,
|
|
||||||
task.updated_at || task.created_at,
|
|
||||||
task.deleted_at,
|
|
||||||
task.deleted_by,
|
|
||||||
task.original_task_id,
|
|
||||||
task.start_date,
|
|
||||||
task.due_date,
|
|
||||||
task.rework_comment,
|
|
||||||
task.closed_at,
|
|
||||||
task.closed_by
|
|
||||||
]);
|
|
||||||
migratedTasks++;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(` Ошибка при миграции задачи ${task.id}:`, error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(`✅ Мигрировано ${migratedTasks} из ${tasks.length} задач`);
|
|
||||||
} else {
|
|
||||||
console.log('ℹ️ В таблице tasks нет данных для миграции');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Мигрируем таблицу task_assignments
|
|
||||||
console.log('📦 Мигрируем таблицу task_assignments...');
|
|
||||||
const assignments = await new Promise((resolve, reject) => {
|
|
||||||
sqliteDb.all('SELECT * FROM task_assignments ORDER BY id', [], (err, rows) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(rows);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (assignments.length > 0) {
|
|
||||||
let migratedAssignments = 0;
|
|
||||||
for (const assignment of assignments) {
|
|
||||||
try {
|
|
||||||
await client.query(`
|
|
||||||
INSERT INTO task_assignments (id, task_id, user_id, status, start_date,
|
|
||||||
due_date, rework_comment, created_at, updated_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
|
||||||
task_id = EXCLUDED.task_id,
|
|
||||||
user_id = EXCLUDED.user_id,
|
|
||||||
status = EXCLUDED.status,
|
|
||||||
start_date = EXCLUDED.start_date,
|
|
||||||
due_date = EXCLUDED.due_date,
|
|
||||||
rework_comment = EXCLUDED.rework_comment,
|
|
||||||
updated_at = EXCLUDED.updated_at
|
|
||||||
`, [
|
|
||||||
assignment.id,
|
|
||||||
assignment.task_id,
|
|
||||||
assignment.user_id,
|
|
||||||
assignment.status || 'assigned',
|
|
||||||
assignment.start_date,
|
|
||||||
assignment.due_date,
|
|
||||||
assignment.rework_comment,
|
|
||||||
assignment.created_at,
|
|
||||||
assignment.updated_at || assignment.created_at
|
|
||||||
]);
|
|
||||||
migratedAssignments++;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(` Ошибка при миграции назначения ${assignment.id}:`, error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(`✅ Мигрировано ${migratedAssignments} из ${assignments.length} назначений`);
|
|
||||||
} else {
|
|
||||||
console.log('ℹ️ В таблице task_assignments нет данных для миграции');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Мигрируем таблицу task_files
|
|
||||||
console.log('📦 Мигрируем таблицу task_files...');
|
|
||||||
const files = await new Promise((resolve, reject) => {
|
|
||||||
sqliteDb.all('SELECT * FROM task_files ORDER BY id', [], (err, rows) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(rows);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (files.length > 0) {
|
|
||||||
let migratedFiles = 0;
|
|
||||||
for (const file of files) {
|
|
||||||
try {
|
|
||||||
await client.query(`
|
|
||||||
INSERT INTO task_files (id, task_id, user_id, filename, original_name,
|
|
||||||
file_path, file_size, uploaded_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
|
||||||
task_id = EXCLUDED.task_id,
|
|
||||||
user_id = EXCLUDED.user_id,
|
|
||||||
filename = EXCLUDED.filename,
|
|
||||||
original_name = EXCLUDED.original_name,
|
|
||||||
file_path = EXCLUDED.file_path,
|
|
||||||
file_size = EXCLUDED.file_size
|
|
||||||
`, [
|
|
||||||
file.id,
|
|
||||||
file.task_id,
|
|
||||||
file.user_id,
|
|
||||||
file.filename,
|
|
||||||
file.original_name,
|
|
||||||
file.file_path,
|
|
||||||
file.file_size,
|
|
||||||
file.uploaded_at
|
|
||||||
]);
|
|
||||||
migratedFiles++;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(` Ошибка при миграции файла ${file.id}:`, error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(`✅ Мигрировано ${migratedFiles} из ${files.length} файлов`);
|
|
||||||
} else {
|
|
||||||
console.log('ℹ️ В таблице task_files нет данных для миграции');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Мигрируем таблицу activity_logs
|
|
||||||
console.log('📦 Мигрируем таблицу activity_logs...');
|
|
||||||
const logs = await new Promise((resolve, reject) => {
|
|
||||||
sqliteDb.all('SELECT * FROM activity_logs ORDER BY id', [], (err, rows) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(rows);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (logs.length > 0) {
|
|
||||||
let migratedLogs = 0;
|
|
||||||
for (const log of logs) {
|
|
||||||
try {
|
|
||||||
await client.query(`
|
|
||||||
INSERT INTO activity_logs (id, task_id, user_id, action, details, created_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
|
||||||
task_id = EXCLUDED.task_id,
|
|
||||||
user_id = EXCLUDED.user_id,
|
|
||||||
action = EXCLUDED.action,
|
|
||||||
details = EXCLUDED.details
|
|
||||||
`, [
|
|
||||||
log.id,
|
|
||||||
log.task_id,
|
|
||||||
log.user_id,
|
|
||||||
log.action,
|
|
||||||
log.details || '',
|
|
||||||
log.created_at
|
|
||||||
]);
|
|
||||||
migratedLogs++;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(` Ошибка при миграции лога ${log.id}:`, error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(`✅ Мигрировано ${migratedLogs} из ${logs.length} логов активности`);
|
|
||||||
} else {
|
|
||||||
console.log('ℹ️ В таблице activity_logs нет данных для миграции');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Мигрируем таблицу notification_logs
|
|
||||||
console.log('📦 Мигрируем таблицу notification_logs...');
|
|
||||||
const notifications = await new Promise((resolve, reject) => {
|
|
||||||
sqliteDb.all('SELECT * FROM notification_logs ORDER BY id', [], (err, rows) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(rows);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (notifications.length > 0) {
|
|
||||||
let migratedNotifications = 0;
|
|
||||||
for (const notification of notifications) {
|
|
||||||
try {
|
|
||||||
await client.query(`
|
|
||||||
INSERT INTO notification_logs (id, notification_key, created_at)
|
|
||||||
VALUES ($1, $2, $3)
|
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
|
||||||
notification_key = EXCLUDED.notification_key
|
|
||||||
`, [
|
|
||||||
notification.id,
|
|
||||||
notification.notification_key,
|
|
||||||
notification.created_at
|
|
||||||
]);
|
|
||||||
migratedNotifications++;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(` Ошибка при миграции уведомления ${notification.id}:`, error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(`✅ Мигрировано ${migratedNotifications} из ${notifications.length} логов уведомлений`);
|
|
||||||
} else {
|
|
||||||
console.log('ℹ️ В таблице notification_logs нет данных для миграции');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Включаем foreign key constraints обратно
|
|
||||||
await client.query('SET session_replication_role = DEFAULT;');
|
|
||||||
|
|
||||||
// Обновляем последовательности
|
|
||||||
await updateSequences(client);
|
|
||||||
|
|
||||||
client.release();
|
|
||||||
sqliteDb.close();
|
|
||||||
|
|
||||||
console.log('\n🎉 Миграция успешно завершена!');
|
|
||||||
console.log('📊 Сводка:');
|
|
||||||
console.log(` 👥 Пользователи: ${users.length}`);
|
|
||||||
console.log(` 📋 Задачи: ${tasks.length}`);
|
|
||||||
console.log(` 👤 Назначения: ${assignments.length}`);
|
|
||||||
console.log(` 📁 Файлы: ${files.length}`);
|
|
||||||
console.log(` 📝 Логи активности: ${logs.length}`);
|
|
||||||
console.log(` 🔔 Логи уведомлений: ${notifications.length}`);
|
|
||||||
console.log('\n⚠️ Для переключения на PostgreSQL выполните следующие действия:');
|
|
||||||
console.log(' 1. Откройте файл .env');
|
|
||||||
console.log(' 2. Измените POSTGRESQL=no на POSTGRESQL=yes');
|
|
||||||
console.log(' 3. Перезапустите сервер командой: npm start');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Ошибка миграции:', error.message);
|
|
||||||
if (client) client.release();
|
|
||||||
sqliteDb.close();
|
|
||||||
process.exit(1);
|
|
||||||
} finally {
|
|
||||||
await pgPool.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createPostgresTables(client) {
|
|
||||||
// Создаем таблицы PostgreSQL
|
|
||||||
await client.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
login VARCHAR(100) UNIQUE NOT NULL,
|
|
||||||
password TEXT,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
email VARCHAR(255) UNIQUE NOT NULL,
|
|
||||||
role VARCHAR(50) DEFAULT 'teacher',
|
|
||||||
auth_type VARCHAR(50) DEFAULT 'local',
|
|
||||||
groups TEXT DEFAULT '[]',
|
|
||||||
description TEXT,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
last_login TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
await client.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS tasks (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
title VARCHAR(500) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
status VARCHAR(50) DEFAULT 'active',
|
|
||||||
created_by INTEGER NOT NULL REFERENCES users(id),
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
deleted_at TIMESTAMP,
|
|
||||||
deleted_by INTEGER REFERENCES users(id),
|
|
||||||
original_task_id INTEGER REFERENCES tasks(id),
|
|
||||||
start_date TIMESTAMP,
|
|
||||||
due_date TIMESTAMP,
|
|
||||||
rework_comment TEXT,
|
|
||||||
closed_at TIMESTAMP,
|
|
||||||
closed_by INTEGER REFERENCES users(id)
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
await client.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS task_assignments (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
||||||
status VARCHAR(50) DEFAULT 'assigned',
|
|
||||||
start_date TIMESTAMP,
|
|
||||||
due_date TIMESTAMP,
|
|
||||||
rework_comment TEXT,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
await client.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS task_files (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
||||||
filename VARCHAR(255) NOT NULL,
|
|
||||||
original_name VARCHAR(500) NOT NULL,
|
|
||||||
file_path TEXT NOT NULL,
|
|
||||||
file_size BIGINT NOT NULL,
|
|
||||||
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
await client.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS activity_logs (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
||||||
action VARCHAR(100) NOT NULL,
|
|
||||||
details TEXT,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
await client.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS notification_logs (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
notification_key VARCHAR(500) NOT NULL,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
console.log('✅ Таблицы PostgreSQL созданы/проверены');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateSequences(client) {
|
|
||||||
// Обновляем последовательности для автоинкремента
|
|
||||||
const tables = [
|
|
||||||
{ name: 'users', id: 'id' },
|
|
||||||
{ name: 'tasks', id: 'id' },
|
|
||||||
{ name: 'task_assignments', id: 'id' },
|
|
||||||
{ name: 'task_files', id: 'id' },
|
|
||||||
{ name: 'activity_logs', id: 'id' },
|
|
||||||
{ name: 'notification_logs', id: 'id' }
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const table of tables) {
|
|
||||||
try {
|
|
||||||
const result = await client.query(`
|
|
||||||
SELECT MAX(${table.id}) as max_id FROM ${table.name}
|
|
||||||
`);
|
|
||||||
|
|
||||||
const maxId = result.rows[0].max_id || 0;
|
|
||||||
if (maxId > 0) {
|
|
||||||
await client.query(`
|
|
||||||
SELECT setval(pg_get_serial_sequence('${table.name}', '${table.id}'), ${maxId}, true)
|
|
||||||
`);
|
|
||||||
console.log(`🔢 Последовательность для ${table.name} обновлена до ${maxId}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`⚠️ Не удалось обновить последовательность для ${table.name}: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Запускаем миграцию
|
|
||||||
migrateToPostgres().catch(console.error);
|
|
||||||
683
notifications.js
683
notifications.js
@@ -1,98 +1,13 @@
|
|||||||
const fetch = require('node-fetch');
|
// notifications.js
|
||||||
const postgresLogger = require('./postgres');
|
|
||||||
const { getDb } = require('./database');
|
const { getDb } = require('./database');
|
||||||
|
const emailNotifications = require('./email-notifications');
|
||||||
|
|
||||||
async function sendDeadlineNotification(assignment, hoursLeft) {
|
/**
|
||||||
try {
|
* Отправляет уведомления о событиях в задаче согласно новой логике:
|
||||||
if (!process.env.NOTIFICATION_SERVICE_URL ||
|
* - Если инициатор = автор → уведомления всем исполнителям
|
||||||
!process.env.NOTIFICATION_SERVICE_LOGIN ||
|
* - Если инициатор = исполнитель → уведомление только автору
|
||||||
!process.env.NOTIFICATION_SERVICE_PASSWORD) {
|
* - Иначе (например, администратор вне задачи) → уведомления всем, кроме инициатора (старое поведение)
|
||||||
return;
|
*/
|
||||||
}
|
|
||||||
|
|
||||||
const notificationKey = `deadline_${hoursLeft}h_task_${assignment.task_id}_user_${assignment.user_id}`;
|
|
||||||
const lastSent = await getLastNotificationSent(notificationKey);
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
if (lastSent) {
|
|
||||||
const timeSinceLast = now.getTime() - new Date(lastSent).getTime();
|
|
||||||
if (timeSinceLast < 12 * 60 * 60 * 1000) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const subject = `⚠️ До окончания срока задачи осталось менее ${hoursLeft} часов`;
|
|
||||||
const content = `Задача: ${assignment.title}\n\n` +
|
|
||||||
`Описание: ${assignment.description || 'Без описания'}\n` +
|
|
||||||
`Срок выполнения: ${new Date(assignment.due_date).toLocaleString('ru-RU')}\n` +
|
|
||||||
`Осталось времени: ${hoursLeft} часов\n\n` +
|
|
||||||
`Пожалуйста, завершите задачу в срок.`;
|
|
||||||
|
|
||||||
const recipients = [
|
|
||||||
{ id: assignment.user_id, name: assignment.user_name, email: assignment.user_email },
|
|
||||||
{ id: assignment.created_by, name: assignment.creator_name, email: assignment.creator_email }
|
|
||||||
].filter((value, index, self) =>
|
|
||||||
self.findIndex(r => r.id === value.id) === index
|
|
||||||
);
|
|
||||||
|
|
||||||
const recipientIds = recipients.map(r => r.id);
|
|
||||||
|
|
||||||
const authHeader = encodeBasicAuth(
|
|
||||||
process.env.NOTIFICATION_SERVICE_LOGIN,
|
|
||||||
process.env.NOTIFICATION_SERVICE_PASSWORD
|
|
||||||
);
|
|
||||||
|
|
||||||
const FormData = require('form-data');
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('subject', subject);
|
|
||||||
formData.append('content', content);
|
|
||||||
formData.append('recipients', JSON.stringify(recipientIds));
|
|
||||||
formData.append('deliveryMethods', JSON.stringify(['email', 'telegram', 'vk']));
|
|
||||||
|
|
||||||
const response = await fetch(process.env.NOTIFICATION_SERVICE_URL, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Basic ${authHeader}`
|
|
||||||
},
|
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
await saveNotificationSent(notificationKey);
|
|
||||||
console.log(`✅ Уведомление о сроке (${hoursLeft}ч) отправлено для задачи ${assignment.task_id}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Ошибка отправки уведомления о сроке:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLastNotificationSent(key) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const db = getDb();
|
|
||||||
if (!db) {
|
|
||||||
resolve(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
db.get("SELECT created_at FROM notification_logs WHERE notification_key = ? ORDER BY created_at DESC LIMIT 1",
|
|
||||||
[key], (err, row) => {
|
|
||||||
resolve(row ? row.created_at : null);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveNotificationSent(key) {
|
|
||||||
const db = getDb();
|
|
||||||
if (!db) return;
|
|
||||||
|
|
||||||
db.run("INSERT INTO notification_logs (notification_key) VALUES (?)", [key]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function encodeBasicAuth(login, password) {
|
|
||||||
return Buffer.from(`${login}:${password}`).toString('base64');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendTaskNotifications(type, taskId, taskTitle, taskDescription, authorId, comment = '', status = '', userName = '') {
|
async function sendTaskNotifications(type, taskId, taskTitle, taskDescription, authorId, comment = '', status = '', userName = '') {
|
||||||
try {
|
try {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
@@ -101,31 +16,10 @@ async function sendTaskNotifications(type, taskId, taskTitle, taskDescription, a
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!process.env.NOTIFICATION_SERVICE_URL ||
|
|
||||||
!process.env.NOTIFICATION_SERVICE_LOGIN ||
|
|
||||||
!process.env.NOTIFICATION_SERVICE_PASSWORD) {
|
|
||||||
console.log('⚠️ Настройки сервиса уведомлений не заданы');
|
|
||||||
|
|
||||||
// Логируем в PostgreSQL даже если уведомления не отправляются
|
|
||||||
await logNotificationToPostgres({
|
|
||||||
type,
|
|
||||||
taskId,
|
|
||||||
taskTitle,
|
|
||||||
taskDescription,
|
|
||||||
authorId,
|
|
||||||
comment,
|
|
||||||
status,
|
|
||||||
userName,
|
|
||||||
error: 'Сервис уведомлений не настроен'
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`📢 Начинаем отправку уведомлений для задачи ${taskId}:`);
|
console.log(`📢 Начинаем отправку уведомлений для задачи ${taskId}:`);
|
||||||
console.log(` Тип: ${type}, Автор действия: ${authorId}, Название: ${taskTitle}`);
|
console.log(` Тип: ${type}, Автор действия: ${authorId}, Название: ${taskTitle}`);
|
||||||
|
|
||||||
// Получаем заказчика (создателя задачи) ОТДЕЛЬНО
|
// 1. Получаем автора задачи (создателя)
|
||||||
const creator = await new Promise((resolve, reject) => {
|
const creator = await new Promise((resolve, reject) => {
|
||||||
db.get(`
|
db.get(`
|
||||||
SELECT t.created_by as user_id, u.name as user_name, u.login as user_login, u.email
|
SELECT t.created_by as user_id, u.name as user_name, u.login as user_login, u.email
|
||||||
@@ -138,7 +32,12 @@ async function sendTaskNotifications(type, taskId, taskTitle, taskDescription, a
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Получаем исполнителей ОТДЕЛЬНО
|
if (!creator) {
|
||||||
|
console.error(`❌ Задача ${taskId} не найдена или у неё нет автора`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Получаем всех исполнителей задачи
|
||||||
const assignees = await new Promise((resolve, reject) => {
|
const assignees = await new Promise((resolve, reject) => {
|
||||||
db.all(`
|
db.all(`
|
||||||
SELECT ta.user_id, u.name as user_name, u.login as user_login, u.email
|
SELECT ta.user_id, u.name as user_name, u.login as user_login, u.email
|
||||||
@@ -151,29 +50,9 @@ async function sendTaskNotifications(type, taskId, taskTitle, taskDescription, a
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Собираем всех участников
|
// 3. Получаем информацию об авторе действия (инициаторе)
|
||||||
const participants = [];
|
|
||||||
if (creator) {
|
|
||||||
participants.push({
|
|
||||||
...creator,
|
|
||||||
role: 'creator',
|
|
||||||
is_creator: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (assignees && assignees.length > 0) {
|
|
||||||
assignees.forEach(assignee => {
|
|
||||||
participants.push({
|
|
||||||
...assignee,
|
|
||||||
role: 'assignee',
|
|
||||||
is_creator: false
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Получаем информацию об авторе действия
|
|
||||||
const author = await new Promise((resolve, reject) => {
|
const author = await new Promise((resolve, reject) => {
|
||||||
db.get("SELECT name, login FROM users WHERE id = ?", [authorId], (err, row) => {
|
db.get("SELECT name, login, email FROM users WHERE id = ?", [authorId], (err, row) => {
|
||||||
if (err) reject(err);
|
if (err) reject(err);
|
||||||
else resolve(row);
|
else resolve(row);
|
||||||
});
|
});
|
||||||
@@ -182,258 +61,97 @@ async function sendTaskNotifications(type, taskId, taskTitle, taskDescription, a
|
|||||||
const authorName = author ? author.name : 'Система';
|
const authorName = author ? author.name : 'Система';
|
||||||
const authorLogin = author ? author.login : 'system';
|
const authorLogin = author ? author.login : 'system';
|
||||||
|
|
||||||
// Логируем в PostgreSQL
|
// 4. Определяем получателей уведомления согласно новой логике
|
||||||
const postgresLogIds = await logNotificationToPostgres({
|
let recipients = [];
|
||||||
type,
|
|
||||||
|
// Проверяем, является ли инициатор автором задачи
|
||||||
|
if (parseInt(authorId) === parseInt(creator.user_id)) {
|
||||||
|
// Инициатор = автор → уведомления всем исполнителям
|
||||||
|
recipients = assignees;
|
||||||
|
console.log(` Инициатор является автором. Получатели: ${assignees.length} исполнителей`);
|
||||||
|
}
|
||||||
|
// Проверяем, является ли инициатор одним из исполнителей
|
||||||
|
else {
|
||||||
|
const isInitiatorAssignee = assignees.some(a => parseInt(a.user_id) === parseInt(authorId));
|
||||||
|
if (isInitiatorAssignee) {
|
||||||
|
// Инициатор = исполнитель → уведомление только автору
|
||||||
|
recipients = [creator];
|
||||||
|
console.log(` Инициатор является исполнителем. Получатель: автор`);
|
||||||
|
} else {
|
||||||
|
// Инициатор не является ни автором, ни исполнителем (например, администратор)
|
||||||
|
// Отправляем уведомления всем участникам, кроме инициатора (старое поведение)
|
||||||
|
recipients = [creator, ...assignees].filter(p => parseInt(p.user_id) !== parseInt(authorId));
|
||||||
|
console.log(` Инициатор вне задачи. Получатели: ${recipients.length} участников (старое поведение)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Отправляем email уведомления выбранным получателям
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
const taskData = {
|
||||||
taskId,
|
taskId,
|
||||||
taskTitle,
|
title: taskTitle,
|
||||||
taskDescription,
|
description: taskDescription,
|
||||||
authorId,
|
due_date: null, // при необходимости можно добавить получение срока из БД
|
||||||
authorName,
|
author_name: authorName,
|
||||||
authorLogin,
|
comment: comment,
|
||||||
participants,
|
status: status,
|
||||||
comment,
|
user_name: userName || recipient.user_name,
|
||||||
status,
|
hours_left: type === 'deadline' ? 24 : null
|
||||||
userName
|
};
|
||||||
});
|
|
||||||
|
|
||||||
let subject, content;
|
await emailNotifications.sendTaskNotification(
|
||||||
|
recipient.user_id,
|
||||||
switch (type) {
|
taskData,
|
||||||
case 'created':
|
type
|
||||||
subject = `Новая задача: ${taskTitle}`;
|
|
||||||
content = `Создана новая задача:\n\n` +
|
|
||||||
`📋 ${taskTitle}\n` +
|
|
||||||
`📝 ${taskDescription || 'Без описания'}\n` +
|
|
||||||
`👤 Автор: ${authorName}\n\n` +
|
|
||||||
`Для просмотра перейдите в систему управления задачами.`;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'updated':
|
|
||||||
subject = `Обновлена задача: ${taskTitle}`;
|
|
||||||
content = `Задача была обновлена:\n\n` +
|
|
||||||
`📋 ${taskTitle}\n` +
|
|
||||||
`📝 ${taskDescription || 'Без описания'}\n` +
|
|
||||||
`👤 Изменено: ${authorName}\n\n` +
|
|
||||||
`Для просмотра изменений перейдите в систему управления задачами.`;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'rework':
|
|
||||||
subject = `Задача возвращена на доработку: ${taskTitle}`;
|
|
||||||
content = `Задача возвращена на доработку:\n\n` +
|
|
||||||
`📋 ${taskTitle}\n` +
|
|
||||||
`📝 Комментарий: ${comment}\n` +
|
|
||||||
`👤 Автор замечания: ${authorName}\n\n` +
|
|
||||||
`Пожалуйста, исправьте замечания и обновите статус задачи.`;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'closed':
|
|
||||||
subject = `Задача закрыта: ${taskTitle}`;
|
|
||||||
content = `Задача была закрыта:\n\n` +
|
|
||||||
`📋 ${taskTitle}\n` +
|
|
||||||
`👤 Закрыта: ${authorName}\n\n` +
|
|
||||||
`Задача завершена и перемещена в архив.`;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'status_changed':
|
|
||||||
const statusText = getStatusText(status);
|
|
||||||
subject = `Изменен статус задачи: ${taskTitle}`;
|
|
||||||
content = `Статус задачи изменен:\n\n` +
|
|
||||||
`📋 ${taskTitle}\n` +
|
|
||||||
`🔄 Новый статус: ${statusText}\n` +
|
|
||||||
`👤 Изменил: ${userName || authorName}\n\n` +
|
|
||||||
`Для просмотра перейдите в систему управления задачами.`;
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
console.log(`⚠️ Неизвестный тип уведомления: ${type}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Фильтруем получателей: исключаем автора действия
|
|
||||||
const recipientIds = participants
|
|
||||||
.filter(p => {
|
|
||||||
const shouldExclude = p.user_id === authorId;
|
|
||||||
if (shouldExclude) {
|
|
||||||
console.log(` ✋ Исключаем автора действия: ${p.user_name} (ID: ${p.user_id})`);
|
|
||||||
}
|
|
||||||
return !shouldExclude;
|
|
||||||
})
|
|
||||||
.map(p => p.user_id);
|
|
||||||
|
|
||||||
if (recipientIds.length === 0) {
|
|
||||||
console.log('❌ Нет получателей для уведомления (все участники - автор изменения)');
|
|
||||||
|
|
||||||
// Обновляем статус в PostgreSQL
|
|
||||||
await updatePostgresLogStatus(postgresLogIds, 'no_recipients', 'Нет получателей после фильтрации');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const authHeader = encodeBasicAuth(
|
|
||||||
process.env.NOTIFICATION_SERVICE_LOGIN,
|
|
||||||
process.env.NOTIFICATION_SERVICE_PASSWORD
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const FormData = require('form-data');
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('subject', subject);
|
|
||||||
formData.append('content', content);
|
|
||||||
formData.append('recipients', JSON.stringify(recipientIds));
|
|
||||||
formData.append('deliveryMethods', JSON.stringify(['email', 'telegram', 'vk']));
|
|
||||||
|
|
||||||
console.log(`🚀 Отправляем запрос на сервис уведомлений...`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(process.env.NOTIFICATION_SERVICE_URL, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Basic ${authHeader}`
|
|
||||||
},
|
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.json();
|
console.log(`✅ Уведомления отправлены для задачи ${taskId}`);
|
||||||
console.log(`✅ Уведомления успешно отправлены для задачи ${taskId}`);
|
|
||||||
|
|
||||||
// Обновляем статус в PostgreSQL
|
|
||||||
await updatePostgresLogStatus(postgresLogIds, 'sent', null, new Date().toISOString());
|
|
||||||
|
|
||||||
console.log(` Результат от сервиса:`, result);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Ошибка отправки уведомлений:', error);
|
|
||||||
|
|
||||||
// Обновляем статус в PostgreSQL
|
|
||||||
await updatePostgresLogStatus(postgresLogIds, 'failed', error.message);
|
|
||||||
|
|
||||||
console.error(' Детали ошибки:', {
|
|
||||||
taskId,
|
|
||||||
type,
|
|
||||||
authorId,
|
|
||||||
errorMessage: error.message,
|
|
||||||
stack: error.stack
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Общая ошибка при обработке уведомлений:', error);
|
console.error('❌ Общая ошибка при обработке уведомлений:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Вспомогательные функции для работы с PostgreSQL
|
/**
|
||||||
async function logNotificationToPostgres(data) {
|
* Отправляет уведомление о приближающемся дедлайне
|
||||||
|
*/
|
||||||
|
async function sendDeadlineNotification(assignment, hoursLeft) {
|
||||||
try {
|
try {
|
||||||
const {
|
const taskData = {
|
||||||
type,
|
taskId: assignment.task_id,
|
||||||
taskId,
|
title: assignment.title,
|
||||||
taskTitle,
|
description: assignment.description || '',
|
||||||
taskDescription,
|
due_date: assignment.due_date,
|
||||||
authorId,
|
author_name: assignment.creator_name,
|
||||||
authorName,
|
hours_left: hoursLeft
|
||||||
authorLogin,
|
};
|
||||||
participants = [],
|
|
||||||
comment = '',
|
|
||||||
status = '',
|
|
||||||
userName = '',
|
|
||||||
error = ''
|
|
||||||
} = data;
|
|
||||||
|
|
||||||
// Создаем сообщение
|
// Отправляем уведомление исполнителю
|
||||||
let messageContent = '';
|
await emailNotifications.sendTaskNotification(
|
||||||
switch (type) {
|
assignment.user_id,
|
||||||
case 'created':
|
taskData,
|
||||||
messageContent = `Создана новая задача: ${taskTitle}`;
|
'deadline'
|
||||||
break;
|
);
|
||||||
case 'updated':
|
|
||||||
messageContent = `Обновлена задача: ${taskTitle}`;
|
|
||||||
break;
|
|
||||||
case 'rework':
|
|
||||||
messageContent = `Задача возвращена на доработку: ${taskTitle}. Комментарий: ${comment}`;
|
|
||||||
break;
|
|
||||||
case 'closed':
|
|
||||||
messageContent = `Задача закрыта: ${taskTitle}`;
|
|
||||||
break;
|
|
||||||
case 'status_changed':
|
|
||||||
messageContent = `Изменен статус задачи: ${taskTitle}. Новый статус: ${getStatusText(status)}`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Логируем для каждого получателя отдельно
|
// Отправляем уведомление заказчику (автору)
|
||||||
const recipientsToNotify = participants.filter(p => p.user_id !== authorId);
|
await emailNotifications.sendTaskNotification(
|
||||||
const logIds = [];
|
assignment.created_by,
|
||||||
|
taskData,
|
||||||
|
'deadline'
|
||||||
|
);
|
||||||
|
|
||||||
for (const recipient of recipientsToNotify) {
|
console.log(`✅ Email уведомление о сроке (${hoursLeft}ч) отправлено для задачи ${assignment.task_id}`);
|
||||||
const logId = await postgresLogger.logNotification({
|
|
||||||
taskId,
|
|
||||||
taskTitle,
|
|
||||||
taskDescription,
|
|
||||||
notificationType: type,
|
|
||||||
authorId,
|
|
||||||
authorName,
|
|
||||||
authorLogin,
|
|
||||||
recipientId: recipient.user_id,
|
|
||||||
recipientName: recipient.user_name,
|
|
||||||
recipientLogin: recipient.user_login,
|
|
||||||
messageContent: `${messageContent}\n\nЗадача: ${taskTitle}\nОписание: ${taskDescription || 'Без описания'}\nАвтор: ${authorName}`,
|
|
||||||
messageSubject: getNotificationSubject(type, taskTitle),
|
|
||||||
deliveryMethods: ['email', 'telegram', 'vk'],
|
|
||||||
comments: error ? `Ошибка: ${error}` : comment
|
|
||||||
});
|
|
||||||
|
|
||||||
if (logId) {
|
|
||||||
logIds.push(logId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return logIds;
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Ошибка логирования в PostgreSQL:', error);
|
console.error('❌ Ошибка отправки email уведомления о сроке:', error);
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updatePostgresLogStatus(logIds, status, errorMessage = null, sentAt = null) {
|
/**
|
||||||
if (!logIds || logIds.length === 0) return;
|
* Проверяет приближающиеся дедлайны и отправляет уведомления
|
||||||
|
*/
|
||||||
for (const logId of logIds) {
|
async function checkUpcomingDeadlines() {
|
||||||
await postgresLogger.updateNotificationStatus(logId, status, errorMessage, sentAt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNotificationSubject(type, taskTitle) {
|
|
||||||
switch (type) {
|
|
||||||
case 'created':
|
|
||||||
return `Новая задача: ${taskTitle}`;
|
|
||||||
case 'updated':
|
|
||||||
return `Обновлена задача: ${taskTitle}`;
|
|
||||||
case 'rework':
|
|
||||||
return `Задача возвращена на доработку: ${taskTitle}`;
|
|
||||||
case 'closed':
|
|
||||||
return `Задача закрыта: ${taskTitle}`;
|
|
||||||
case 'status_changed':
|
|
||||||
return `Изменен статус задачи: ${taskTitle}`;
|
|
||||||
default:
|
|
||||||
return `Уведомление по задаче: ${taskTitle}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStatusText(status) {
|
|
||||||
const statusMap = {
|
|
||||||
'assigned': 'Назначена',
|
|
||||||
'in_progress': 'В работе',
|
|
||||||
'completed': 'Завершена',
|
|
||||||
'overdue': 'Просрочена',
|
|
||||||
'rework': 'На доработке'
|
|
||||||
};
|
|
||||||
return statusMap[status] || status;
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkUpcomingDeadlines() {
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const in48Hours = new Date(now.getTime() + 48 * 60 * 60 * 1000).toISOString();
|
const in48Hours = new Date(now.getTime() + 48 * 60 * 60 * 1000).toISOString();
|
||||||
const in24Hours = new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString();
|
const in24Hours = new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString();
|
||||||
@@ -480,10 +198,233 @@ function checkUpcomingDeadlines() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает тему уведомления в зависимости от типа
|
||||||
|
*/
|
||||||
|
function getNotificationSubject(type, taskTitle) {
|
||||||
|
switch (type) {
|
||||||
|
case 'created':
|
||||||
|
return `Новая задача: ${taskTitle}`;
|
||||||
|
case 'updated':
|
||||||
|
return `Обновлена задача: ${taskTitle}`;
|
||||||
|
case 'rework':
|
||||||
|
return `Задача возвращена на доработку: ${taskTitle}`;
|
||||||
|
case 'closed':
|
||||||
|
return `Задача закрыта: ${taskTitle}`;
|
||||||
|
case 'status_changed':
|
||||||
|
return `Изменен статус задачи: ${taskTitle}`;
|
||||||
|
default:
|
||||||
|
return `Уведомление по задаче: ${taskTitle}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Преобразует внутренний статус в читаемый текст
|
||||||
|
*/
|
||||||
|
function getStatusText(status) {
|
||||||
|
const statusMap = {
|
||||||
|
'assigned': 'Назначена',
|
||||||
|
'in_progress': 'В работе',
|
||||||
|
'completed': 'Выполнена',
|
||||||
|
'overdue': 'Просрочена',
|
||||||
|
'rework': 'На доработке'
|
||||||
|
};
|
||||||
|
return statusMap[status] || status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отправляет сводку о непрочитанных сообщениях в чатах всем пользователям,
|
||||||
|
* у которых есть такие сообщения (раз в час, только с 8 до 21 по часовому поясу сервера).
|
||||||
|
*/
|
||||||
|
async function sendChatSummaryNotifications() {
|
||||||
|
const db = getDb();
|
||||||
|
if (!db) {
|
||||||
|
console.error('❌ База данных не доступна для отправки сводки чата');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Определяем часовой пояс
|
||||||
|
const timezone = process.env.TIMEZONE || 'Asia/Yekaterinburg';
|
||||||
|
const now = new Date();
|
||||||
|
// Форматтер для полного времени (логирование)
|
||||||
|
const fullFormatter = new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
timeZone: timezone,
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
|
});
|
||||||
|
// Форматтер только для часа
|
||||||
|
const hourFormatter = new Intl.DateTimeFormat('ru-RU', { hour: 'numeric', timeZone: timezone });
|
||||||
|
const currentHour = parseInt(hourFormatter.format(now));
|
||||||
|
|
||||||
|
// Логируем текущее время и используемый час
|
||||||
|
console.log(`📅 Текущее время (${timezone}): ${fullFormatter.format(now)} (час: ${currentHour})`);
|
||||||
|
|
||||||
|
// Проверка рабочего времени (8:00 – 21:00)
|
||||||
|
if (currentHour < 8 || currentHour >= 21) {
|
||||||
|
console.log(`🕒 [Чат-сводка] Пропущена: сейчас ${currentHour}:00 (рабочие часы 8-21), время отправки не наступило`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ [Чат-сводка] Рабочие часы, отправляем уведомления...`);
|
||||||
|
|
||||||
|
// Получаем всех пользователей, у которых включены email уведомления
|
||||||
|
const users = await new Promise((resolve, reject) => {
|
||||||
|
db.all(`
|
||||||
|
SELECT u.id, u.name, u.email, u.login,
|
||||||
|
COALESCE(us.email_notifications, 1) as email_notifications,
|
||||||
|
us.notification_email,
|
||||||
|
us.last_chat_notification_sent_at
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN user_settings us ON u.id = us.user_id
|
||||||
|
WHERE u.email IS NOT NULL AND u.email != ''
|
||||||
|
`, (err, rows) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(rows || []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
// Проверяем настройки уведомлений
|
||||||
|
if (!user.email_notifications) continue;
|
||||||
|
const emailTo = user.notification_email || user.email;
|
||||||
|
if (!emailTo) continue;
|
||||||
|
|
||||||
|
// Время последней отправки уведомления (или NULL – тогда берём текущее, но при логине уже должно быть установлено)
|
||||||
|
let lastSent = user.last_chat_notification_sent_at;
|
||||||
|
if (!lastSent) {
|
||||||
|
// Если нет метки – устанавливаем сейчас и пропускаем (при первом входе всё равно сбросится)
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
db.run(
|
||||||
|
`UPDATE user_settings SET last_chat_notification_sent_at = CURRENT_TIMESTAMP WHERE user_id = ?`,
|
||||||
|
[user.id],
|
||||||
|
resolve
|
||||||
|
);
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем задачи с количеством новых сообщений после lastSent
|
||||||
|
const tasks = await new Promise((resolve, reject) => {
|
||||||
|
db.all(`
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.title,
|
||||||
|
COUNT(m.id) as new_messages_count
|
||||||
|
FROM tasks t
|
||||||
|
LEFT JOIN task_chat_messages m ON t.id = m.task_id
|
||||||
|
AND m.created_at > ?
|
||||||
|
AND m.is_deleted = 0
|
||||||
|
WHERE (t.created_by = ? OR EXISTS (
|
||||||
|
SELECT 1 FROM task_assignments ta
|
||||||
|
WHERE ta.task_id = t.id AND ta.user_id = ?
|
||||||
|
))
|
||||||
|
GROUP BY t.id
|
||||||
|
HAVING new_messages_count > 0
|
||||||
|
`, [lastSent, user.id, user.id], (err, rows) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(rows || []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (tasks.length === 0) continue;
|
||||||
|
|
||||||
|
// Формируем HTML‑письмо со списком задач
|
||||||
|
const appUrl = process.env.APP_URL || 'http://localhost:3000';
|
||||||
|
let tasksHtml = '';
|
||||||
|
for (const task of tasks) {
|
||||||
|
const taskUrl = `${appUrl}/task?id=${task.id}`;
|
||||||
|
tasksHtml += `
|
||||||
|
<li style="margin-bottom: 15px;">
|
||||||
|
<strong><a href="${taskUrl}" style="color: #3498db;">${escapeHtml(task.title)}</a></strong><br>
|
||||||
|
Новых сообщений: ${task.new_messages_count}
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subject = `📬 Новые сообщения в чатах задач (${tasks.length} задач)`;
|
||||||
|
const htmlContent = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; line-height: 1.6; }
|
||||||
|
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||||
|
.header { background: #3498db; color: white; padding: 15px; border-radius: 10px 10px 0 0; }
|
||||||
|
.content { padding: 20px; border: 1px solid #ddd; border-top: none; }
|
||||||
|
.footer { margin-top: 20px; font-size: 12px; color: #666; text-align: center; }
|
||||||
|
ul { padding-left: 20px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h2>💬 Новые сообщения в чатах</h2>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<p>Здравствуйте, ${escapeHtml(user.name)}!</p>
|
||||||
|
<p>У вас есть непрочитанные сообщения в следующих задачах:</p>
|
||||||
|
<ul>${tasksHtml}</ul>
|
||||||
|
<p>Вы получили это письмо, потому что подписаны на уведомления о чатах.<br>
|
||||||
|
Уведомления приходят раз в час. Чтобы отключить их, измените настройки в личном кабинете.</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>© School CRM, ${new Date().getFullYear()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Отправляем email
|
||||||
|
const result = await emailNotifications.sendEmailNotification(
|
||||||
|
emailTo,
|
||||||
|
subject,
|
||||||
|
htmlContent,
|
||||||
|
user.id,
|
||||||
|
null,
|
||||||
|
'chat_summary'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.sent) {
|
||||||
|
// Обновляем время последней отправки
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
db.run(
|
||||||
|
`UPDATE user_settings SET last_chat_notification_sent_at = CURRENT_TIMESTAMP WHERE user_id = ?`,
|
||||||
|
[user.id],
|
||||||
|
resolve
|
||||||
|
);
|
||||||
|
});
|
||||||
|
console.log(`✅ Отправлена сводка чата для ${user.login} (${tasks.length} задач)`);
|
||||||
|
} else {
|
||||||
|
console.error(`❌ Не удалось отправить сводку чата для ${user.login}: ${result.error}`);
|
||||||
|
// Не обновляем last_sent – в следующий раз попробуем снова
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вспомогательная функция для экранирования HTML
|
||||||
|
*/
|
||||||
|
function escapeHtml(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return str.replace(/[&<>]/g, function(m) {
|
||||||
|
if (m === '&') return '&';
|
||||||
|
if (m === '<') return '<';
|
||||||
|
if (m === '>') return '>';
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Экспортируем функции
|
// Экспортируем функции
|
||||||
module.exports = {
|
module.exports = {
|
||||||
sendTaskNotifications,
|
sendTaskNotifications,
|
||||||
checkUpcomingDeadlines,
|
checkUpcomingDeadlines,
|
||||||
sendDeadlineNotification,
|
sendDeadlineNotification,
|
||||||
getStatusText
|
getStatusText,
|
||||||
|
emailNotifications,
|
||||||
|
sendChatSummaryNotifications
|
||||||
};
|
};
|
||||||
@@ -8,13 +8,18 @@
|
|||||||
"dev": "nodemon server.js"
|
"dev": "nodemon server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"archiver": "^7.0.1",
|
||||||
|
"axios": "^1.13.5",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
"bcryptjs": "~2.4.3",
|
"bcryptjs": "~2.4.3",
|
||||||
"dotenv": "~16.3.1",
|
"dotenv": "~16.3.1",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"express-session": "^1.18.2",
|
"express-session": "^1.18.2",
|
||||||
"form-data": "^4.0.0",
|
"form-data": "^4.0.5",
|
||||||
|
"mime-types": "^3.0.2",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"node-fetch": "~2.6.7",
|
"node-fetch": "~2.6.7",
|
||||||
|
"nodemailer": "^6.9.13",
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3",
|
||||||
"sqlite3": "~5.1.6"
|
"sqlite3": "~5.1.6"
|
||||||
},
|
},
|
||||||
|
|||||||
100
postgres-init.js
100
postgres-init.js
@@ -1,100 +0,0 @@
|
|||||||
const { Client } = require('pg');
|
|
||||||
require('dotenv').config();
|
|
||||||
|
|
||||||
async function initializeDatabase() {
|
|
||||||
console.log('🔄 Инициализация PostgreSQL...');
|
|
||||||
|
|
||||||
// Сначала подключаемся без конкретной БД
|
|
||||||
const adminClient = new Client({
|
|
||||||
host: process.env.DB_HOST,
|
|
||||||
port: process.env.DB_PORT || 5432,
|
|
||||||
user: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASSWORD,
|
|
||||||
database: 'postgres' // Подключаемся к системной БД
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await adminClient.connect();
|
|
||||||
console.log('✅ Подключение к PostgreSQL установлено');
|
|
||||||
|
|
||||||
// Проверяем существование базы данных
|
|
||||||
const dbCheck = await adminClient.query(`
|
|
||||||
SELECT 1 FROM pg_database WHERE datname = '${process.env.DB_NAME}'
|
|
||||||
`);
|
|
||||||
|
|
||||||
if (dbCheck.rows.length === 0) {
|
|
||||||
console.log(`📦 База данных ${process.env.DB_NAME} не существует, создаем...`);
|
|
||||||
await adminClient.query(`CREATE DATABASE ${process.env.DB_NAME}`);
|
|
||||||
console.log(`✅ База данных ${process.env.DB_NAME} создана`);
|
|
||||||
} else {
|
|
||||||
console.log(`✅ База данных ${process.env.DB_NAME} уже существует`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await adminClient.end();
|
|
||||||
|
|
||||||
// Теперь подключаемся к созданной БД
|
|
||||||
const dbClient = new Client({
|
|
||||||
host: process.env.DB_HOST,
|
|
||||||
port: process.env.DB_PORT || 5432,
|
|
||||||
user: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASSWORD,
|
|
||||||
database: process.env.DB_NAME
|
|
||||||
});
|
|
||||||
|
|
||||||
await dbClient.connect();
|
|
||||||
|
|
||||||
// Создаем таблицы
|
|
||||||
await dbClient.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS sms_logs (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
task_id INTEGER NOT NULL,
|
|
||||||
task_title VARCHAR(500) NOT NULL,
|
|
||||||
task_description TEXT,
|
|
||||||
notification_type VARCHAR(50) NOT NULL,
|
|
||||||
creator_id INTEGER,
|
|
||||||
creator_name VARCHAR(255),
|
|
||||||
creator_login VARCHAR(100),
|
|
||||||
assignee_id INTEGER,
|
|
||||||
assignee_name VARCHAR(255),
|
|
||||||
assignee_login VARCHAR(100),
|
|
||||||
message_content TEXT NOT NULL,
|
|
||||||
message_subject VARCHAR(500),
|
|
||||||
delivery_methods JSONB DEFAULT '[]',
|
|
||||||
status VARCHAR(50) DEFAULT 'pending',
|
|
||||||
error_message TEXT,
|
|
||||||
retry_count INTEGER DEFAULT 0,
|
|
||||||
sent_at TIMESTAMP,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
comments TEXT
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Создаем индексы
|
|
||||||
const indexes = [
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_sms_logs_task_id ON sms_logs(task_id)',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_sms_logs_creator_id ON sms_logs(creator_id)',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_sms_logs_assignee_id ON sms_logs(assignee_id)',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_sms_logs_status ON sms_logs(status)',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_sms_logs_created_at ON sms_logs(created_at)'
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const indexQuery of indexes) {
|
|
||||||
try {
|
|
||||||
await dbClient.query(indexQuery);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`⚠️ Индекс не создан: ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await dbClient.end();
|
|
||||||
console.log('✅ PostgreSQL полностью инициализирован');
|
|
||||||
return true;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Ошибка инициализации PostgreSQL:', error.message);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { initializeDatabase };
|
|
||||||
206
postgres.js
206
postgres.js
@@ -1,206 +0,0 @@
|
|||||||
const { Pool } = require('pg');
|
|
||||||
require('dotenv').config();
|
|
||||||
const { initializeDatabase } = require('./postgres-init');
|
|
||||||
|
|
||||||
class PostgresLogger {
|
|
||||||
constructor() {
|
|
||||||
this.pool = null;
|
|
||||||
this.initialized = false;
|
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
async init() {
|
|
||||||
try {
|
|
||||||
console.log('🔌 Инициализация PostgreSQL логгера...');
|
|
||||||
|
|
||||||
// Проверяем наличие переменных окружения
|
|
||||||
if (!process.env.DB_HOST || !process.env.DB_USER || !process.env.DB_PASSWORD) {
|
|
||||||
console.log('⚠️ Переменные окружения для PostgreSQL не заданы');
|
|
||||||
console.log(' DB_HOST:', process.env.DB_HOST || 'не задано');
|
|
||||||
console.log(' DB_USER:', process.env.DB_USER || 'не задано');
|
|
||||||
console.log(' DB_NAME:', process.env.DB_NAME || 'minicrm');
|
|
||||||
this.initialized = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Создаем базу данных если нужно
|
|
||||||
const dbInitialized = await initializeDatabase();
|
|
||||||
if (!dbInitialized) {
|
|
||||||
console.error('❌ Не удалось инициализировать базу данных');
|
|
||||||
this.initialized = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Подключаемся к созданной БД
|
|
||||||
this.pool = new Pool({
|
|
||||||
host: process.env.DB_HOST,
|
|
||||||
port: process.env.DB_PORT || 5432,
|
|
||||||
database: process.env.DB_NAME || 'minicrm',
|
|
||||||
user: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASSWORD,
|
|
||||||
max: 5,
|
|
||||||
idleTimeoutMillis: 30000,
|
|
||||||
connectionTimeoutMillis: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Тестируем подключение
|
|
||||||
const client = await this.pool.connect();
|
|
||||||
await client.query('SELECT 1');
|
|
||||||
client.release();
|
|
||||||
|
|
||||||
this.initialized = true;
|
|
||||||
console.log('✅ PostgreSQL логгер готов к работе');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Ошибка инициализации PostgreSQL логгера:', error.message);
|
|
||||||
console.error(' Убедитесь, что:');
|
|
||||||
console.error(' 1. PostgreSQL сервер запущен на', process.env.DB_HOST);
|
|
||||||
console.error(' 2. Пользователь', process.env.DB_USER, 'имеет права на создание БД');
|
|
||||||
console.error(' 3. Пароль указан верно в .env файле');
|
|
||||||
this.initialized = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async logNotification(notificationData) {
|
|
||||||
if (!this.initialized) {
|
|
||||||
console.log('⚠️ PostgreSQL не инициализирован, логирование пропущено');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
taskId,
|
|
||||||
taskTitle,
|
|
||||||
taskDescription = '',
|
|
||||||
notificationType,
|
|
||||||
authorId,
|
|
||||||
authorName,
|
|
||||||
authorLogin,
|
|
||||||
recipientId,
|
|
||||||
recipientName,
|
|
||||||
recipientLogin,
|
|
||||||
messageContent,
|
|
||||||
messageSubject = '',
|
|
||||||
deliveryMethods = ['email', 'telegram', 'vk'],
|
|
||||||
comments = ''
|
|
||||||
} = notificationData;
|
|
||||||
|
|
||||||
let client;
|
|
||||||
try {
|
|
||||||
client = await this.pool.connect();
|
|
||||||
|
|
||||||
const query = `
|
|
||||||
INSERT INTO sms_logs (
|
|
||||||
task_id, task_title, task_description, notification_type,
|
|
||||||
creator_id, creator_name, creator_login,
|
|
||||||
assignee_id, assignee_name, assignee_login,
|
|
||||||
message_content, message_subject, delivery_methods,
|
|
||||||
status, comments, created_at
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, CURRENT_TIMESTAMP)
|
|
||||||
RETURNING id;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const values = [
|
|
||||||
taskId,
|
|
||||||
taskTitle?.substring(0, 500) || 'Без названия',
|
|
||||||
taskDescription?.substring(0, 5000) || '',
|
|
||||||
notificationType,
|
|
||||||
authorId,
|
|
||||||
authorName || 'Неизвестно',
|
|
||||||
authorLogin || 'unknown',
|
|
||||||
recipientId,
|
|
||||||
recipientName || 'Неизвестно',
|
|
||||||
recipientLogin || 'unknown',
|
|
||||||
messageContent?.substring(0, 5000) || '',
|
|
||||||
messageSubject?.substring(0, 500) || '',
|
|
||||||
JSON.stringify(deliveryMethods),
|
|
||||||
'pending',
|
|
||||||
comments
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = await client.query(query, values);
|
|
||||||
const logId = result.rows[0]?.id;
|
|
||||||
|
|
||||||
if (logId) {
|
|
||||||
console.log(`📝 Уведомление записано в PostgreSQL, ID: ${logId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return logId || null;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Ошибка записи уведомления в PostgreSQL:', error.message);
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
if (client) client.release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateNotificationStatus(logId, status, errorMessage = null, sentAt = null) {
|
|
||||||
if (!this.initialized || !logId) return;
|
|
||||||
|
|
||||||
let client;
|
|
||||||
try {
|
|
||||||
client = await this.pool.connect();
|
|
||||||
|
|
||||||
const query = `
|
|
||||||
UPDATE sms_logs
|
|
||||||
SET status = $1,
|
|
||||||
error_message = $2,
|
|
||||||
sent_at = $3,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = $4;
|
|
||||||
`;
|
|
||||||
|
|
||||||
await client.query(query, [
|
|
||||||
status,
|
|
||||||
errorMessage,
|
|
||||||
sentAt ? new Date(sentAt) : (status === 'sent' ? new Date() : null),
|
|
||||||
logId
|
|
||||||
]);
|
|
||||||
|
|
||||||
console.log(`📝 Статус уведомления ${logId} обновлен на: ${status}`);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Ошибка обновления статуса уведомления:', error.message);
|
|
||||||
} finally {
|
|
||||||
if (client) client.release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async healthCheck() {
|
|
||||||
if (!this.initialized) {
|
|
||||||
return {
|
|
||||||
connected: false,
|
|
||||||
error: 'PostgreSQL не инициализирован',
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let client;
|
|
||||||
try {
|
|
||||||
client = await this.pool.connect();
|
|
||||||
await client.query('SELECT 1');
|
|
||||||
|
|
||||||
return {
|
|
||||||
connected: true,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
database: process.env.DB_NAME || 'minicrm',
|
|
||||||
host: process.env.DB_HOST
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
connected: false,
|
|
||||||
error: error.message,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
if (client) client.release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Другие методы остаются без изменений...
|
|
||||||
}
|
|
||||||
|
|
||||||
// Экспортируем singleton
|
|
||||||
const postgresLogger = new PostgresLogger();
|
|
||||||
module.exports = postgresLogger;
|
|
||||||
1787
public/admin-api-management.html
Normal file
1787
public/admin-api-management.html
Normal file
File diff suppressed because it is too large
Load Diff
220
public/admin-dashboard.js
Normal file
220
public/admin-dashboard.js
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
// admin-dashboard.js
|
||||||
|
// Функции для работы с дашбордом административной панели
|
||||||
|
|
||||||
|
function renderDashboard() {
|
||||||
|
const dashboardContainer = document.getElementById('admin-dashboard');
|
||||||
|
|
||||||
|
if (!dashboardContainer) return;
|
||||||
|
|
||||||
|
dashboardContainer.innerHTML = `
|
||||||
|
<h2>Статистика системы</h2>
|
||||||
|
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card task-stat">
|
||||||
|
<h3>Задачи</h3>
|
||||||
|
<div class="stat-value" id="total-tasks">0</div>
|
||||||
|
<div class="stat-desc">Всего задач в системе</div>
|
||||||
|
<div class="percentage-bar">
|
||||||
|
<div class="percentage-fill" id="active-tasks-bar" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-subitems">
|
||||||
|
<div class="stat-subitem">
|
||||||
|
<span class="label">Активные:</span>
|
||||||
|
<span class="value" id="active-tasks">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-subitem">
|
||||||
|
<span class="label">Закрытые:</span>
|
||||||
|
<span class="value" id="closed-tasks">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-subitem">
|
||||||
|
<span class="label">Удаленные:</span>
|
||||||
|
<span class="value" id="deleted-tasks">0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card status-stat">
|
||||||
|
<h3>Статусы назначений</h3>
|
||||||
|
<div class="stat-value" id="total-assignments">0</div>
|
||||||
|
<div class="stat-desc">Всего назначений</div>
|
||||||
|
<div class="stat-subitems">
|
||||||
|
<div class="stat-subitem">
|
||||||
|
<span class="label">Назначено:</span>
|
||||||
|
<span class="value" id="assigned-count">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-subitem">
|
||||||
|
<span class="label">В работе:</span>
|
||||||
|
<span class="value" id="in-progress-count">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-subitem">
|
||||||
|
<span class="label">Выполнено:</span>
|
||||||
|
<span class="value" id="completed-count">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-subitem">
|
||||||
|
<span class="label">Просрочено:</span>
|
||||||
|
<span class="value" id="overdue-count">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-subitem">
|
||||||
|
<span class="label">На доработке:</span>
|
||||||
|
<span class="value" id="rework-count">0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card user-stat">
|
||||||
|
<h3>Пользователи</h3>
|
||||||
|
<div class="stat-value" id="total-users">0</div>
|
||||||
|
<div class="stat-desc">Зарегистрировано пользователей</div>
|
||||||
|
<div class="stat-subitems">
|
||||||
|
<div class="stat-subitem">
|
||||||
|
<span class="label">Администраторы:</span>
|
||||||
|
<span class="value" id="admin-users">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-subitem">
|
||||||
|
<span class="label">Учителя:</span>
|
||||||
|
<span class="value" id="teacher-users">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-subitem">
|
||||||
|
<span class="label">LDAP:</span>
|
||||||
|
<span class="value" id="ldap-users">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-subitem">
|
||||||
|
<span class="label">Локальные:</span>
|
||||||
|
<span class="value" id="local-users">0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card file-stat">
|
||||||
|
<h3>Файлы</h3>
|
||||||
|
<div class="stat-value" id="total-files">0</div>
|
||||||
|
<div class="stat-desc">Всего загружено файлов</div>
|
||||||
|
<div class="file-size" id="total-files-size">0 MB</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// После создания HTML загружаем статистику
|
||||||
|
loadDashboardStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDashboardStats() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/admin/stats');
|
||||||
|
if (response.ok) {
|
||||||
|
const stats = await response.json();
|
||||||
|
updateStatsUI(stats);
|
||||||
|
} else {
|
||||||
|
// Если API недоступно, используем данные из вашего скриншота
|
||||||
|
const defaultStats = {
|
||||||
|
totalTasks: 46,
|
||||||
|
activeTasks: 43,
|
||||||
|
closedTasks: 0,
|
||||||
|
deletedTasks: 3,
|
||||||
|
totalAssignments: 61,
|
||||||
|
assignedCount: 15,
|
||||||
|
inProgressCount: 1,
|
||||||
|
completedCount: 9,
|
||||||
|
overdueCount: 36,
|
||||||
|
reworkCount: 0,
|
||||||
|
totalUsers: 4,
|
||||||
|
adminUsers: 1,
|
||||||
|
teacherUsers: 1,
|
||||||
|
ldapUsers: 4,
|
||||||
|
localUsers: 0,
|
||||||
|
totalFiles: 27,
|
||||||
|
totalFilesSize: 10.96 * 1024 * 1024 // 10.96 MB в байтах
|
||||||
|
};
|
||||||
|
updateStatsUI(defaultStats);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки статистики:', error);
|
||||||
|
showDashboardError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatsUI(stats) {
|
||||||
|
// Проверяем, существует ли элемент dashboard
|
||||||
|
const dashboard = document.getElementById('admin-dashboard');
|
||||||
|
if (!dashboard || !dashboard.classList.contains('active')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Задачи
|
||||||
|
setElementText('total-tasks', stats.totalTasks || 0);
|
||||||
|
setElementText('active-tasks', stats.activeTasks || 0);
|
||||||
|
setElementText('closed-tasks', stats.closedTasks || 0);
|
||||||
|
setElementText('deleted-tasks', stats.deletedTasks || 0);
|
||||||
|
|
||||||
|
// Процент активных задач
|
||||||
|
if (stats.totalTasks > 0) {
|
||||||
|
const activePercentage = Math.round((stats.activeTasks / stats.totalTasks) * 100);
|
||||||
|
const activeBar = document.getElementById('active-tasks-bar');
|
||||||
|
if (activeBar) {
|
||||||
|
activeBar.style.width = `${activePercentage}%`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Назначения
|
||||||
|
setElementText('total-assignments', stats.totalAssignments || 0);
|
||||||
|
setElementText('assigned-count', stats.assignedCount || 0);
|
||||||
|
setElementText('in-progress-count', stats.inProgressCount || 0);
|
||||||
|
setElementText('completed-count', stats.completedCount || 0);
|
||||||
|
setElementText('overdue-count', stats.overdueCount || 0);
|
||||||
|
setElementText('rework-count', stats.reworkCount || 0);
|
||||||
|
|
||||||
|
// Пользователи
|
||||||
|
setElementText('total-users', stats.totalUsers || 0);
|
||||||
|
setElementText('admin-users', stats.adminUsers || 0);
|
||||||
|
setElementText('teacher-users', stats.teacherUsers || 0);
|
||||||
|
setElementText('ldap-users', stats.ldapUsers || 0);
|
||||||
|
setElementText('local-users', stats.localUsers || 0);
|
||||||
|
|
||||||
|
// Файлы
|
||||||
|
setElementText('total-files', stats.totalFiles || 0);
|
||||||
|
const fileSizeMB = stats.totalFilesSize ? (stats.totalFilesSize / 1024 / 1024).toFixed(2) : '0';
|
||||||
|
setElementText('total-files-size', `${fileSizeMB} MB`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setElementText(id, text) {
|
||||||
|
const element = document.getElementById(id);
|
||||||
|
if (element) {
|
||||||
|
element.textContent = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDashboardError() {
|
||||||
|
const dashboardContainer = document.getElementById('admin-dashboard');
|
||||||
|
if (dashboardContainer) {
|
||||||
|
dashboardContainer.innerHTML = `
|
||||||
|
<h2>Статистика системы</h2>
|
||||||
|
<div class="error-message">
|
||||||
|
<p>Не удалось загрузить статистику системы.</p>
|
||||||
|
<button onclick="loadDashboardStats()">Повторить попытку</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Автоматическое обновление статистики каждые 30 секунд
|
||||||
|
setInterval(() => {
|
||||||
|
if (document.getElementById('admin-dashboard')?.classList.contains('active')) {
|
||||||
|
loadDashboardStats();
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
// Инициализация дашборда при загрузке страницы
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Ждем пока основной скрипт проверит авторизацию
|
||||||
|
setTimeout(() => {
|
||||||
|
// Если дашборд активен при загрузке, рендерим его
|
||||||
|
const dashboard = document.getElementById('admin-dashboard');
|
||||||
|
if (dashboard && dashboard.classList.contains('active')) {
|
||||||
|
renderDashboard();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Экспортируем функции для использования в admin-script.js
|
||||||
|
window.renderDashboard = renderDashboard;
|
||||||
|
window.loadDashboardStats = loadDashboardStats;
|
||||||
1307
public/admin-doc.html
Normal file
1307
public/admin-doc.html
Normal file
File diff suppressed because it is too large
Load Diff
543
public/admin-groups.html
Normal file
543
public/admin-groups.html
Normal file
@@ -0,0 +1,543 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Управление группами пользователей</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.8.1/font/bootstrap-icons.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
.group-color {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 8px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.badge-custom {
|
||||||
|
font-size: 0.8em;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
.table-hover tbody tr:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.075);
|
||||||
|
}
|
||||||
|
.cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand" href="/admin">CRM Админ-панель</a>
|
||||||
|
<div class="collapse navbar-collapse">
|
||||||
|
<ul class="navbar-nav me-auto">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/admin">Главная</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/admin/profiles">Пользователи</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" href="/admin/groups">Группы</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/admin-doc">Документы</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="navbar-text">
|
||||||
|
<span id="currentUser"></span>
|
||||||
|
<button class="btn btn-sm btn-outline-light ms-2" onclick="logout()">Выйти</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="container-fluid mt-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">Управление группами пользователей</h5>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="openCreateGroupModal()">
|
||||||
|
<i class="bi bi-plus-circle"></i> Создать группу
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Описание</th>
|
||||||
|
<th>Участников</th>
|
||||||
|
<th>Может согласовывать</th>
|
||||||
|
<th>Цвет</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="groupsTableBody">
|
||||||
|
<!-- Группы будут загружены сюда -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Пользователи и их группы</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Пользователь</th>
|
||||||
|
<th>Роль</th>
|
||||||
|
<th>Группы</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="usersTableBody">
|
||||||
|
<!-- Пользователи будут загружены сюда -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модальное окно создания/редактирования группы -->
|
||||||
|
<div class="modal fade" id="groupModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="groupModalTitle">Создать группу</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="groupForm">
|
||||||
|
<input type="hidden" id="groupId">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Название группы *</label>
|
||||||
|
<input type="text" class="form-control" id="groupName" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Описание</label>
|
||||||
|
<textarea class="form-control" id="groupDescription" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Цвет группы</label>
|
||||||
|
<input type="color" class="form-control form-control-color" id="groupColor" value="#3498db">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" id="canApproveDocuments">
|
||||||
|
<label class="form-check-label">Может согласовывать документы</label>
|
||||||
|
<small class="form-text text-muted d-block">
|
||||||
|
Пользователи этой группы будут доступны для выбора при создании задач согласования документов
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="saveGroup()">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модальное окно управления группами пользователя -->
|
||||||
|
<div class="modal fade" id="userGroupsModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Группы пользователя: <span id="userName"></span></h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="userGroupsList">
|
||||||
|
<!-- Группы пользователя будут загружены сюда -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script>
|
||||||
|
let currentUser = null;
|
||||||
|
let groups = [];
|
||||||
|
let users = [];
|
||||||
|
|
||||||
|
// Загрузка данных при загрузке страницы
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
await checkAuth();
|
||||||
|
await loadGroups();
|
||||||
|
await loadUsersWithGroups();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Проверка авторизации
|
||||||
|
async function checkAuth() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/user');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
currentUser = data.user;
|
||||||
|
|
||||||
|
if (currentUser.role !== 'admin') {
|
||||||
|
window.location.href = '/';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('currentUser').textContent =
|
||||||
|
`${currentUser.name} (${currentUser.role})`;
|
||||||
|
} else {
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка проверки авторизации:', error);
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выход из системы
|
||||||
|
function logout() {
|
||||||
|
fetch('/api/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
window.location.href = '/';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка групп
|
||||||
|
async function loadGroups() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/groups');
|
||||||
|
if (response.ok) {
|
||||||
|
groups = await response.json();
|
||||||
|
renderGroupsTable();
|
||||||
|
} else {
|
||||||
|
showError('Ошибка загрузки групп');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки групп:', error);
|
||||||
|
showError('Ошибка загрузки групп');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка пользователей с группами
|
||||||
|
async function loadUsersWithGroups() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/users-with-groups');
|
||||||
|
if (response.ok) {
|
||||||
|
users = await response.json();
|
||||||
|
renderUsersTable();
|
||||||
|
} else {
|
||||||
|
showError('Ошибка загрузки пользователей');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки пользователей:', error);
|
||||||
|
showError('Ошибка загрузки пользователей');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отображение таблицы групп
|
||||||
|
function renderGroupsTable() {
|
||||||
|
const tbody = document.getElementById('groupsTableBody');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
if (groups.length === 0) {
|
||||||
|
tbody.innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="text-center text-muted">
|
||||||
|
Нет созданных групп
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
groups.forEach(group => {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.innerHTML = `
|
||||||
|
<td>
|
||||||
|
<span class="group-color" style="background-color: ${group.color}"></span>
|
||||||
|
<strong>${group.name}</strong>
|
||||||
|
</td>
|
||||||
|
<td>${group.description || '-'}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-primary">${group.member_count || 0}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
${group.can_approve_documents
|
||||||
|
? '<span class="badge bg-success">Да</span>'
|
||||||
|
: '<span class="badge bg-secondary">Нет</span>'}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="group-color" style="background-color: ${group.color}"></span>
|
||||||
|
${group.color}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-outline-primary me-1" onclick="editGroup(${group.id})">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" onclick="deleteGroup(${group.id})">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отображение таблицы пользователей
|
||||||
|
function renderUsersTable() {
|
||||||
|
const tbody = document.getElementById('usersTableBody');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
tbody.innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="text-center text-muted">
|
||||||
|
Нет пользователей
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
users.forEach(user => {
|
||||||
|
const groupsHtml = user.group_names.length > 0
|
||||||
|
? user.group_names.map(name =>
|
||||||
|
`<span class="badge bg-info me-1">${name}</span>`
|
||||||
|
).join('')
|
||||||
|
: '<span class="text-muted">Нет групп</span>';
|
||||||
|
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.innerHTML = `
|
||||||
|
<td>
|
||||||
|
<div><strong>${user.name}</strong></div>
|
||||||
|
<small class="text-muted">${user.login} • ${user.email}</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge ${user.role === 'admin' ? 'bg-danger' : 'bg-secondary'}">
|
||||||
|
${user.role === 'admin' ? 'Администратор' : 'Учитель'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>${groupsHtml}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="manageUserGroups(${user.id}, '${user.name}')">
|
||||||
|
<i class="bi bi-person-gear"></i> Управление группами
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Открытие модального окна создания группы
|
||||||
|
function openCreateGroupModal() {
|
||||||
|
document.getElementById('groupModalTitle').textContent = 'Создать группу';
|
||||||
|
document.getElementById('groupId').value = '';
|
||||||
|
document.getElementById('groupName').value = '';
|
||||||
|
document.getElementById('groupDescription').value = '';
|
||||||
|
document.getElementById('groupColor').value = '#3498db';
|
||||||
|
document.getElementById('canApproveDocuments').checked = false;
|
||||||
|
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('groupModal'));
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Редактирование группы
|
||||||
|
async function editGroup(groupId) {
|
||||||
|
const group = groups.find(g => g.id === groupId);
|
||||||
|
if (!group) return;
|
||||||
|
|
||||||
|
document.getElementById('groupModalTitle').textContent = 'Редактировать группу';
|
||||||
|
document.getElementById('groupId').value = group.id;
|
||||||
|
document.getElementById('groupName').value = group.name;
|
||||||
|
document.getElementById('groupDescription').value = group.description || '';
|
||||||
|
document.getElementById('groupColor').value = group.color;
|
||||||
|
document.getElementById('canApproveDocuments').checked = !!group.can_approve_documents;
|
||||||
|
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('groupModal'));
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохранение группы
|
||||||
|
async function saveGroup() {
|
||||||
|
const groupId = document.getElementById('groupId').value;
|
||||||
|
const groupData = {
|
||||||
|
name: document.getElementById('groupName').value.trim(),
|
||||||
|
description: document.getElementById('groupDescription').value.trim(),
|
||||||
|
color: document.getElementById('groupColor').value,
|
||||||
|
can_approve_documents: document.getElementById('canApproveDocuments').checked
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!groupData.name) {
|
||||||
|
showError('Введите название группы');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = groupId
|
||||||
|
? `/api/groups/${groupId}`
|
||||||
|
: '/api/groups';
|
||||||
|
const method = groupId ? 'PUT' : 'POST';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(groupData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
showSuccess(data.message || 'Группа сохранена');
|
||||||
|
|
||||||
|
// Закрываем модальное окно
|
||||||
|
const modal = bootstrap.Modal.getInstance(document.getElementById('groupModal'));
|
||||||
|
modal.hide();
|
||||||
|
|
||||||
|
// Перезагружаем данные
|
||||||
|
await loadGroups();
|
||||||
|
await loadUsersWithGroups();
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json();
|
||||||
|
showError(errorData.error || 'Ошибка сохранения группы');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка сохранения группы:', error);
|
||||||
|
showError('Ошибка сохранения группы');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаление группы
|
||||||
|
async function deleteGroup(groupId) {
|
||||||
|
if (!confirm('Вы уверены, что хотите удалить эту группу? Все связи с пользователями будут удалены.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/groups/${groupId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
showSuccess(data.message || 'Группа удалена');
|
||||||
|
|
||||||
|
// Перезагружаем данные
|
||||||
|
await loadGroups();
|
||||||
|
await loadUsersWithGroups();
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json();
|
||||||
|
showError(errorData.error || 'Ошибка удаления группы');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка удаления группы:', error);
|
||||||
|
showError('Ошибка удаления группы');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Управление группами пользователя
|
||||||
|
async function manageUserGroups(userId, userName) {
|
||||||
|
document.getElementById('userName').textContent = userName;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Получаем доступные группы для пользователя
|
||||||
|
const response = await fetch(`/api/users/${userId}/available-groups`);
|
||||||
|
if (response.ok) {
|
||||||
|
const availableGroups = await response.json();
|
||||||
|
|
||||||
|
// Создаем список чекбоксов
|
||||||
|
let groupsHtml = '';
|
||||||
|
availableGroups.forEach(group => {
|
||||||
|
groupsHtml += `
|
||||||
|
<div class="form-check mb-2">
|
||||||
|
<input class="form-check-input" type="checkbox"
|
||||||
|
id="group_${group.id}"
|
||||||
|
${group.is_member ? 'checked' : ''}
|
||||||
|
onchange="toggleUserGroup(${userId}, ${group.id}, this.checked)">
|
||||||
|
<label class="form-check-label" for="group_${group.id}">
|
||||||
|
<span class="group-color" style="background-color: ${group.color}"></span>
|
||||||
|
${group.name}
|
||||||
|
${group.can_approve_documents
|
||||||
|
? '<span class="badge bg-success badge-custom ms-1">Согласование</span>'
|
||||||
|
: ''}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('userGroupsList').innerHTML = groupsHtml ||
|
||||||
|
'<p class="text-muted">Нет доступных групп</p>';
|
||||||
|
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('userGroupsModal'));
|
||||||
|
modal.show();
|
||||||
|
} else {
|
||||||
|
showError('Ошибка загрузки групп пользователя');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки групп:', error);
|
||||||
|
showError('Ошибка загрузки групп');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Переключение группы пользователя
|
||||||
|
async function toggleUserGroup(userId, groupId, isChecked) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/users/${userId}/groups`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
groupIds: isChecked
|
||||||
|
? [...document.querySelectorAll('#userGroupsList input:checked')]
|
||||||
|
.map(input => parseInt(input.id.split('_')[1]))
|
||||||
|
: [...document.querySelectorAll('#userGroupsList input:checked')]
|
||||||
|
.filter(input => parseInt(input.id.split('_')[1]) !== groupId)
|
||||||
|
.map(input => parseInt(input.id.split('_')[1]))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
showError(errorData.error || 'Ошибка обновления групп');
|
||||||
|
// Возвращаем чекбокс в предыдущее состояние
|
||||||
|
const checkbox = document.getElementById(`group_${groupId}`);
|
||||||
|
checkbox.checked = !isChecked;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка обновления групп:', error);
|
||||||
|
showError('Ошибка обновления групп');
|
||||||
|
// Возвращаем чекбокс в предыдущее состояние
|
||||||
|
const checkbox = document.getElementById(`group_${groupId}`);
|
||||||
|
checkbox.checked = !isChecked;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вспомогательные функции для уведомлений
|
||||||
|
function showSuccess(message) {
|
||||||
|
alert(message); // Можно заменить на красивый toast
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(message) {
|
||||||
|
alert('Ошибка: ' + message); // Можно заменить на красивый toast
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1242
public/admin-profiles.html
Normal file
1242
public/admin-profiles.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
|||||||
|
// admin-script.js (обновленный)
|
||||||
let currentUser = null;
|
let currentUser = null;
|
||||||
let users = [];
|
let users = [];
|
||||||
let filteredUsers = [];
|
let filteredUsers = [];
|
||||||
@@ -45,12 +46,36 @@ function showAdminInterface() {
|
|||||||
document.getElementById('current-user').textContent = userInfo;
|
document.getElementById('current-user').textContent = userInfo;
|
||||||
|
|
||||||
loadUsers();
|
loadUsers();
|
||||||
loadDashboardStats();
|
// Если дашборд активен, рендерим его
|
||||||
|
if (document.getElementById('admin-dashboard').classList.contains('active')) {
|
||||||
|
if (typeof renderDashboard === 'function') {
|
||||||
|
renderDashboard();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Если статистика активна, рендерим ее
|
||||||
|
if (document.getElementById('admin-stats-section').classList.contains('active')) {
|
||||||
|
if (typeof renderStatsSection === 'function') {
|
||||||
|
renderStatsSection();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupEventListeners() {
|
function setupEventListeners() {
|
||||||
document.getElementById('login-form').addEventListener('submit', login);
|
const loginForm = document.getElementById('login-form');
|
||||||
document.getElementById('edit-user-form').addEventListener('submit', updateUser);
|
const editUserForm = document.getElementById('edit-user-form');
|
||||||
|
const createUserForm = document.getElementById('create-user-form');
|
||||||
|
|
||||||
|
if (loginForm) {
|
||||||
|
loginForm.addEventListener('submit', login);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editUserForm) {
|
||||||
|
editUserForm.addEventListener('submit', updateUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createUserForm) {
|
||||||
|
createUserForm.addEventListener('submit', createUser);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function login(event) {
|
async function login(event) {
|
||||||
@@ -99,26 +124,96 @@ async function logout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showAdminSection(sectionName) {
|
function showAdminSection(sectionName) {
|
||||||
|
console.log('showAdminSection called with:', sectionName);
|
||||||
|
|
||||||
|
// Убираем активный класс у всех вкладок
|
||||||
document.querySelectorAll('.admin-tab').forEach(tab => {
|
document.querySelectorAll('.admin-tab').forEach(tab => {
|
||||||
tab.classList.remove('active');
|
tab.classList.remove('active');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Убираем активный класс у всех секций
|
||||||
document.querySelectorAll('.admin-section').forEach(section => {
|
document.querySelectorAll('.admin-section').forEach(section => {
|
||||||
section.classList.remove('active');
|
section.classList.remove('active');
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelector(`.admin-tab[onclick="showAdminSection('${sectionName}')"]`).classList.add('active');
|
// Находим и активируем соответствующую вкладку
|
||||||
document.getElementById(`admin-${sectionName}`).classList.add('active');
|
const tabs = document.querySelectorAll('.admin-tab');
|
||||||
|
let tabFound = false;
|
||||||
|
|
||||||
|
tabs.forEach(tab => {
|
||||||
|
const onclick = tab.getAttribute('onclick');
|
||||||
|
if (onclick && onclick.includes(`showAdminSection('${sectionName}')`)) {
|
||||||
|
tab.classList.add('active');
|
||||||
|
tabFound = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Если не нашли по onclick, ищем по тексту
|
||||||
|
if (!tabFound) {
|
||||||
|
tabs.forEach(tab => {
|
||||||
|
const tabText = tab.textContent.toLowerCase();
|
||||||
|
if (tabText.includes(sectionName.toLowerCase())) {
|
||||||
|
tab.classList.add('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Активируем соответствующую секцию
|
||||||
|
const sectionId = `admin-${sectionName}-section`;
|
||||||
|
let section = document.getElementById(sectionId);
|
||||||
|
|
||||||
|
// Если не нашли по такому ID, пробуем другие варианты
|
||||||
|
if (!section) {
|
||||||
if (sectionName === 'users') {
|
if (sectionName === 'users') {
|
||||||
|
section = document.getElementById('admin-users-section');
|
||||||
|
} else if (sectionName === 'dashboard') {
|
||||||
|
section = document.getElementById('admin-dashboard');
|
||||||
|
} else if (sectionName === 'stats') {
|
||||||
|
section = document.getElementById('admin-stats-section');
|
||||||
|
} else {
|
||||||
|
// Пробуем найти по частичному совпадению
|
||||||
|
const sections = document.querySelectorAll('.admin-section');
|
||||||
|
sections.forEach(s => {
|
||||||
|
if (s.id.includes(sectionName)) {
|
||||||
|
section = s;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (section) {
|
||||||
|
section.classList.add('active');
|
||||||
|
console.log('Activated section:', section.id);
|
||||||
|
} else {
|
||||||
|
console.error('Section not found for:', sectionName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем данные для активной секции
|
||||||
|
setTimeout(() => {
|
||||||
|
if (sectionName === 'users') {
|
||||||
|
console.log('Loading users...');
|
||||||
loadUsers();
|
loadUsers();
|
||||||
} else if (sectionName === 'dashboard') {
|
} else if (sectionName === 'dashboard') {
|
||||||
loadDashboardStats();
|
console.log('Loading dashboard...');
|
||||||
|
if (typeof renderDashboard === 'function') {
|
||||||
|
renderDashboard();
|
||||||
}
|
}
|
||||||
|
} else if (sectionName === 'stats') {
|
||||||
|
console.log('Loading stats...');
|
||||||
|
if (typeof renderStatsSection === 'function') {
|
||||||
|
renderStatsSection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
try {
|
try {
|
||||||
|
const tbody = document.getElementById('users-table-body');
|
||||||
|
if (tbody) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="9" class="loading">Загрузка пользователей...</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch('/admin/users');
|
const response = await fetch('/admin/users');
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Ошибка загрузки пользователей');
|
throw new Error('Ошибка загрузки пользователей');
|
||||||
@@ -132,69 +227,38 @@ async function loadUsers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDashboardStats() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/admin/stats');
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Ошибка загрузки статистики');
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats = await response.json();
|
|
||||||
updateStatsUI(stats);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Ошибка загрузки статистики:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateStatsUI(stats) {
|
|
||||||
// Задачи
|
|
||||||
document.getElementById('total-tasks').textContent = stats.totalTasks;
|
|
||||||
document.getElementById('active-tasks').textContent = stats.activeTasks;
|
|
||||||
document.getElementById('closed-tasks').textContent = stats.closedTasks;
|
|
||||||
document.getElementById('deleted-tasks').textContent = stats.deletedTasks;
|
|
||||||
|
|
||||||
// Процент активных задач
|
|
||||||
if (stats.totalTasks > 0) {
|
|
||||||
const activePercentage = Math.round((stats.activeTasks / stats.totalTasks) * 100);
|
|
||||||
document.getElementById('active-tasks-bar').style.width = `${activePercentage}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Назначения
|
|
||||||
document.getElementById('total-assignments').textContent = stats.totalAssignments;
|
|
||||||
document.getElementById('assigned-count').textContent = stats.assignedCount;
|
|
||||||
document.getElementById('in-progress-count').textContent = stats.inProgressCount;
|
|
||||||
document.getElementById('completed-count').textContent = stats.completedCount;
|
|
||||||
document.getElementById('overdue-count').textContent = stats.overdueCount;
|
|
||||||
document.getElementById('rework-count').textContent = stats.reworkCount;
|
|
||||||
|
|
||||||
// Пользователи
|
|
||||||
document.getElementById('total-users').textContent = stats.totalUsers;
|
|
||||||
document.getElementById('admin-users').textContent = stats.adminUsers;
|
|
||||||
document.getElementById('teacher-users').textContent = stats.teacherUsers;
|
|
||||||
document.getElementById('ldap-users').textContent = stats.ldapUsers;
|
|
||||||
document.getElementById('local-users').textContent = stats.localUsers;
|
|
||||||
|
|
||||||
// Файлы
|
|
||||||
document.getElementById('total-files').textContent = stats.totalFiles;
|
|
||||||
const fileSizeMB = (stats.totalFilesSize / 1024 / 1024).toFixed(2);
|
|
||||||
document.getElementById('total-files-size').textContent = `${fileSizeMB} MB`;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
function searchUsers() {
|
function searchUsers() {
|
||||||
const search = document.getElementById('user-search').value.toLowerCase();
|
const searchInput = document.getElementById('user-search');
|
||||||
|
if (!searchInput) return;
|
||||||
|
|
||||||
|
const search = searchInput.value.toLowerCase();
|
||||||
filteredUsers = users.filter(user =>
|
filteredUsers = users.filter(user =>
|
||||||
user.login.toLowerCase().includes(search) ||
|
(user.login && user.login.toLowerCase().includes(search)) ||
|
||||||
user.name.toLowerCase().includes(search) ||
|
(user.name && user.name.toLowerCase().includes(search)) ||
|
||||||
user.email.toLowerCase().includes(search) ||
|
(user.email && user.email.toLowerCase().includes(search)) ||
|
||||||
user.role.toLowerCase().includes(search) ||
|
(user.role && user.role.toLowerCase().includes(search)) ||
|
||||||
user.auth_type.toLowerCase().includes(search)
|
(user.auth_type && user.auth_type.toLowerCase().includes(search))
|
||||||
);
|
);
|
||||||
renderUsersTable();
|
renderUsersTable();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Преобразует внутреннее имя роли в отображаемое.
|
||||||
|
* Для известных ролей возвращает локализованное название,
|
||||||
|
* для неизвестных – само имя роли.
|
||||||
|
*/
|
||||||
|
function formatRole(role) {
|
||||||
|
const roleMap = {
|
||||||
|
'admin': 'Администратор',
|
||||||
|
'teacher': 'Учитель'
|
||||||
|
// при необходимости можно добавить другие соответствия
|
||||||
|
};
|
||||||
|
return roleMap[role] || role;
|
||||||
|
}
|
||||||
|
|
||||||
function renderUsersTable() {
|
function renderUsersTable() {
|
||||||
const tbody = document.getElementById('users-table-body');
|
const tbody = document.getElementById('users-table-body');
|
||||||
|
if (!tbody) return;
|
||||||
|
|
||||||
if (!filteredUsers || filteredUsers.length === 0) {
|
if (!filteredUsers || filteredUsers.length === 0) {
|
||||||
tbody.innerHTML = '<tr><td colspan="9" class="loading">Пользователи не найдены</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="9" class="loading">Пользователи не найдены</td></tr>';
|
||||||
@@ -205,13 +269,13 @@ function renderUsersTable() {
|
|||||||
<tr>
|
<tr>
|
||||||
<td>${user.id}</td>
|
<td>${user.id}</td>
|
||||||
<td>
|
<td>
|
||||||
${user.login}
|
${user.login || 'Нет логина'}
|
||||||
${user.auth_type === 'ldap' ? '<span class="ldap-badge">LDAP</span>' : ''}
|
${user.auth_type === 'ldap' ? '<span class="ldap-badge">LDAP</span>' : ''}
|
||||||
</td>
|
</td>
|
||||||
<td>${user.name}</td>
|
<td>${user.name || 'Не указано'}</td>
|
||||||
<td>${user.email}</td>
|
<td>${user.email || 'Нет email'}</td>
|
||||||
<td>
|
<td>
|
||||||
${user.role === 'admin' ? 'Администратор' : 'Учитель'}
|
${formatRole(user.role)}
|
||||||
${user.role === 'admin' ? '<span class="admin-badge">ADMIN</span>' : ''}
|
${user.role === 'admin' ? '<span class="admin-badge">ADMIN</span>' : ''}
|
||||||
</td>
|
</td>
|
||||||
<td>${user.auth_type === 'ldap' ? 'LDAP' : 'Локальная'}</td>
|
<td>${user.auth_type === 'ldap' ? 'LDAP' : 'Локальная'}</td>
|
||||||
@@ -219,12 +283,107 @@ function renderUsersTable() {
|
|||||||
<td>${user.last_login ? formatDateTime(user.last_login) : 'Никогда'}</td>
|
<td>${user.last_login ? formatDateTime(user.last_login) : 'Никогда'}</td>
|
||||||
<td class="user-actions">
|
<td class="user-actions">
|
||||||
<button class="edit-btn" onclick="openEditUserModal(${user.id})" title="Редактировать">✏️</button>
|
<button class="edit-btn" onclick="openEditUserModal(${user.id})" title="Редактировать">✏️</button>
|
||||||
<button class="delete-btn" onclick="deleteUser(${user.id})" title="Удалить" ${user.id === currentUser.id ? 'disabled' : ''}>🗑️</button>
|
<button class="delete-btn" onclick="deleteUser(${user.id})" title="Удалить" ${user.id === currentUser?.id ? 'disabled' : ''}>🗑️</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Функция для открытия модального окна создания пользователя
|
||||||
|
function openCreateUserModal() {
|
||||||
|
const modal = document.getElementById('create-user-modal');
|
||||||
|
if (modal) {
|
||||||
|
// Сбрасываем форму
|
||||||
|
const form = document.getElementById('create-user-form');
|
||||||
|
if (form) {
|
||||||
|
form.reset();
|
||||||
|
}
|
||||||
|
modal.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для закрытия модального окна создания пользователя
|
||||||
|
function closeCreateUserModal() {
|
||||||
|
const modal = document.getElementById('create-user-modal');
|
||||||
|
if (modal) {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для создания нового пользователя
|
||||||
|
async function createUser(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const login = document.getElementById('create-login').value;
|
||||||
|
const password = document.getElementById('create-password').value;
|
||||||
|
const name = document.getElementById('create-name').value;
|
||||||
|
const email = document.getElementById('create-email').value;
|
||||||
|
const role = document.getElementById('create-role').value;
|
||||||
|
const auth_type = document.getElementById('create-auth-type').value;
|
||||||
|
const groups = document.getElementById('create-groups').value;
|
||||||
|
const description = document.getElementById('create-description').value;
|
||||||
|
|
||||||
|
// Валидация
|
||||||
|
if (!login || !password || !name || !email) {
|
||||||
|
alert('Заполните все обязательные поля');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
alert('Пароль должен содержать не менее 6 символов');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userData = {
|
||||||
|
login,
|
||||||
|
password,
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
role,
|
||||||
|
auth_type,
|
||||||
|
groups: groups || '[]',
|
||||||
|
description
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/admin/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(userData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
alert(`Пользователь успешно создан! ID: ${result.id}`);
|
||||||
|
closeCreateUserModal();
|
||||||
|
loadUsers(); // Перезагружаем список пользователей
|
||||||
|
|
||||||
|
// Обновляем статистику если она видна
|
||||||
|
if (document.getElementById('admin-dashboard')?.classList.contains('active')) {
|
||||||
|
if (typeof loadDashboardStats === 'function') {
|
||||||
|
loadDashboardStats();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (document.getElementById('admin-stats-section')?.classList.contains('active')) {
|
||||||
|
if (typeof loadUsersStats === 'function') {
|
||||||
|
loadUsersStats();
|
||||||
|
}
|
||||||
|
if (typeof loadOverallStats === 'function') {
|
||||||
|
loadOverallStats();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(error.error || 'Ошибка создания пользователя');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка:', error);
|
||||||
|
alert('Ошибка создания пользователя');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function openEditUserModal(userId) {
|
async function openEditUserModal(userId) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/admin/users/${userId}`);
|
const response = await fetch(`/admin/users/${userId}`);
|
||||||
@@ -243,7 +402,10 @@ async function openEditUserModal(userId) {
|
|||||||
document.getElementById('edit-groups').value = user.groups || '[]';
|
document.getElementById('edit-groups').value = user.groups || '[]';
|
||||||
document.getElementById('edit-description').value = user.description || '';
|
document.getElementById('edit-description').value = user.description || '';
|
||||||
|
|
||||||
document.getElementById('edit-user-modal').style.display = 'block';
|
const modal = document.getElementById('edit-user-modal');
|
||||||
|
if (modal) {
|
||||||
|
modal.style.display = 'block';
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка:', error);
|
console.error('Ошибка:', error);
|
||||||
alert('Ошибка загрузки пользователя');
|
alert('Ошибка загрузки пользователя');
|
||||||
@@ -251,7 +413,10 @@ async function openEditUserModal(userId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeEditUserModal() {
|
function closeEditUserModal() {
|
||||||
document.getElementById('edit-user-modal').style.display = 'none';
|
const modal = document.getElementById('edit-user-modal');
|
||||||
|
if (modal) {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateUser(event) {
|
async function updateUser(event) {
|
||||||
@@ -294,7 +459,21 @@ async function updateUser(event) {
|
|||||||
alert('Пользователь успешно обновлен!');
|
alert('Пользователь успешно обновлен!');
|
||||||
closeEditUserModal();
|
closeEditUserModal();
|
||||||
loadUsers();
|
loadUsers();
|
||||||
|
|
||||||
|
// Обновляем статистику если она видна
|
||||||
|
if (document.getElementById('admin-dashboard')?.classList.contains('active')) {
|
||||||
|
if (typeof loadDashboardStats === 'function') {
|
||||||
loadDashboardStats();
|
loadDashboardStats();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (document.getElementById('admin-stats-section')?.classList.contains('active')) {
|
||||||
|
if (typeof loadUsersStats === 'function') {
|
||||||
|
loadUsersStats();
|
||||||
|
}
|
||||||
|
if (typeof loadOverallStats === 'function') {
|
||||||
|
loadOverallStats();
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
alert(error.error || 'Ошибка обновления пользователя');
|
alert(error.error || 'Ошибка обновления пользователя');
|
||||||
@@ -306,7 +485,7 @@ async function updateUser(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteUser(userId) {
|
async function deleteUser(userId) {
|
||||||
if (userId === currentUser.id) {
|
if (userId === currentUser?.id) {
|
||||||
alert('Нельзя удалить самого себя');
|
alert('Нельзя удалить самого себя');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -323,7 +502,21 @@ async function deleteUser(userId) {
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
alert('Пользователь успешно удален!');
|
alert('Пользователь успешно удален!');
|
||||||
loadUsers();
|
loadUsers();
|
||||||
|
|
||||||
|
// Обновляем статистику если она видна
|
||||||
|
if (document.getElementById('admin-dashboard')?.classList.contains('active')) {
|
||||||
|
if (typeof loadDashboardStats === 'function') {
|
||||||
loadDashboardStats();
|
loadDashboardStats();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (document.getElementById('admin-stats-section')?.classList.contains('active')) {
|
||||||
|
if (typeof loadUsersStats === 'function') {
|
||||||
|
loadUsersStats();
|
||||||
|
}
|
||||||
|
if (typeof loadOverallStats === 'function') {
|
||||||
|
loadOverallStats();
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
alert(error.error || 'Ошибка удаления пользователя');
|
alert(error.error || 'Ошибка удаления пользователя');
|
||||||
@@ -336,14 +529,22 @@ async function deleteUser(userId) {
|
|||||||
|
|
||||||
function formatDateTime(dateTimeString) {
|
function formatDateTime(dateTimeString) {
|
||||||
if (!dateTimeString) return '';
|
if (!dateTimeString) return '';
|
||||||
|
try {
|
||||||
const date = new Date(dateTimeString);
|
const date = new Date(dateTimeString);
|
||||||
return date.toLocaleString('ru-RU');
|
return date.toLocaleString('ru-RU');
|
||||||
|
} catch (e) {
|
||||||
|
return dateTimeString;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateString) {
|
function formatDate(dateString) {
|
||||||
if (!dateString) return '';
|
if (!dateString) return '';
|
||||||
|
try {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
return date.toLocaleDateString('ru-RU');
|
return date.toLocaleDateString('ru-RU');
|
||||||
|
} catch (e) {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showError(elementId, message) {
|
function showError(elementId, message) {
|
||||||
@@ -353,9 +554,15 @@ function showError(elementId, message) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Автоматическое обновление статистики каждые 30 секунд
|
// Делаем функции глобально доступными
|
||||||
setInterval(() => {
|
window.logout = logout;
|
||||||
if (document.getElementById('admin-dashboard').classList.contains('active')) {
|
window.showAdminSection = showAdminSection;
|
||||||
loadDashboardStats();
|
window.searchUsers = searchUsers;
|
||||||
}
|
window.loadUsers = loadUsers;
|
||||||
}, 30000);
|
window.openEditUserModal = openEditUserModal;
|
||||||
|
window.closeEditUserModal = closeEditUserModal;
|
||||||
|
window.openCreateUserModal = openCreateUserModal;
|
||||||
|
window.closeCreateUserModal = closeCreateUserModal;
|
||||||
|
window.createUser = createUser;
|
||||||
|
window.updateUser = updateUser;
|
||||||
|
window.deleteUser = deleteUser;
|
||||||
867
public/admin-stats.js
Normal file
867
public/admin-stats.js
Normal file
@@ -0,0 +1,867 @@
|
|||||||
|
// admin-stats.js
|
||||||
|
// Функции для работы с детальной статистикой
|
||||||
|
|
||||||
|
let usersStats = [];
|
||||||
|
let filteredStats = [];
|
||||||
|
let currentPage = 1;
|
||||||
|
const pageSize = 20;
|
||||||
|
let currentFilters = {
|
||||||
|
user: '',
|
||||||
|
status: '',
|
||||||
|
role: '',
|
||||||
|
authType: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Преобразует внутреннее имя роли в отображаемое.
|
||||||
|
* Для известных ролей возвращает локализованное название,
|
||||||
|
* для неизвестных – само имя роли.
|
||||||
|
*/
|
||||||
|
function formatRole(role) {
|
||||||
|
const roleMap = {
|
||||||
|
'admin': 'Администратор',
|
||||||
|
'teacher': 'Учитель'
|
||||||
|
// при необходимости можно добавить другие соответствия
|
||||||
|
};
|
||||||
|
return roleMap[role] || role;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStatsSection() {
|
||||||
|
const statsContainer = document.getElementById('admin-stats-section');
|
||||||
|
|
||||||
|
if (!statsContainer) return;
|
||||||
|
|
||||||
|
statsContainer.innerHTML = `
|
||||||
|
<div class="stats-header">
|
||||||
|
<h2>Детальная статистика по пользователям</h2>
|
||||||
|
<p>Общая статистика системы и детальная информация по каждому пользователю</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Общая статистика -->
|
||||||
|
<div class="overall-stats">
|
||||||
|
<h3>Общая статистика системы</h3>
|
||||||
|
<div class="stats-grid" id="overall-stats-grid">
|
||||||
|
<!-- Общая статистика будет загружена динамически -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Фильтры -->
|
||||||
|
<div class="filters-container">
|
||||||
|
<h3>Фильтры</h3>
|
||||||
|
<div class="filter-row">
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="filter-user">Пользователь</label>
|
||||||
|
<select id="filter-user" onchange="applyFilters()">
|
||||||
|
<option value="">Все пользователи</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="filter-status">Статус назначений</label>
|
||||||
|
<select id="filter-status" onchange="applyFilters()">
|
||||||
|
<option value="">Все статусы</option>
|
||||||
|
<option value="assigned">Назначено</option>
|
||||||
|
<option value="in_progress">В работе</option>
|
||||||
|
<option value="completed">Выполнено</option>
|
||||||
|
<option value="overdue">Просрочено</option>
|
||||||
|
<option value="rework">На доработке</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="filter-role">Роль</label>
|
||||||
|
<select id="filter-role" onchange="applyFilters()">
|
||||||
|
<option value="">Все роли</option>
|
||||||
|
<!-- Опции будут заполнены динамически из данных -->
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="filter-auth-type">Тип авторизации</label>
|
||||||
|
<select id="filter-auth-type" onchange="applyFilters()">
|
||||||
|
<option value="">Все типы</option>
|
||||||
|
<option value="local">Локальная</option>
|
||||||
|
<option value="ldap">LDAP</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-actions">
|
||||||
|
<button class="reset-btn" onclick="resetFilters()">Сбросить фильтры</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Детальная статистика -->
|
||||||
|
<div class="detailed-stats">
|
||||||
|
<div class="stats-summary">
|
||||||
|
<h3>Детальная статистика по пользователям</h3>
|
||||||
|
<div class="total-count">Всего пользователей: <span id="total-stats-count">0</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="users-stats-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Пользователь</th>
|
||||||
|
<th>Роль</th>
|
||||||
|
<th>Тип</th>
|
||||||
|
<th>Всего задач</th>
|
||||||
|
<th>Статусы назначений</th>
|
||||||
|
<th>Активные задачи</th>
|
||||||
|
<th>Закрытые задачи</th>
|
||||||
|
<th>Последняя активность</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="users-stats-body">
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" class="stats-loading">Загрузка статистики...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pagination" id="stats-pagination">
|
||||||
|
<!-- Пагинация будет добавлена динамически -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Загружаем данные
|
||||||
|
loadUsersStats();
|
||||||
|
loadOverallStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsersStats() {
|
||||||
|
try {
|
||||||
|
const tbody = document.getElementById('users-stats-body');
|
||||||
|
if (tbody) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="8" class="stats-loading">Загрузка статистики...</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем пользователей из существующего API
|
||||||
|
const usersResponse = await fetch('/admin/users');
|
||||||
|
if (!usersResponse.ok) {
|
||||||
|
throw new Error('Ошибка загрузки пользователей');
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await usersResponse.json();
|
||||||
|
|
||||||
|
// Создаем статистику на основе общих данных и распределяем их между пользователями
|
||||||
|
const overallStats = await getOverallStats();
|
||||||
|
usersStats = createUserStatsFromData(users, overallStats);
|
||||||
|
|
||||||
|
// Заполняем списки фильтров
|
||||||
|
populateUserFilter();
|
||||||
|
populateRoleFilter(); // добавляем динамическое заполнение ролей
|
||||||
|
|
||||||
|
// Применяем фильтры
|
||||||
|
applyFilters();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки статистики по пользователям:', error);
|
||||||
|
|
||||||
|
// Если всё не удалось, используем мок-данные для демонстрации
|
||||||
|
usersStats = getMockStatsData();
|
||||||
|
populateUserFilter();
|
||||||
|
populateRoleFilter(); // и здесь тоже
|
||||||
|
applyFilters();
|
||||||
|
|
||||||
|
const tbody = document.getElementById('users-stats-body');
|
||||||
|
if (tbody && tbody.querySelector('.stats-loading')) {
|
||||||
|
const firstRow = tbody.querySelector('tr');
|
||||||
|
if (firstRow) {
|
||||||
|
firstRow.innerHTML = '<td colspan="8" class="stats-loading">Загрузка данных... (используются демонстрационные данные)</td>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Заполняет select фильтра по ролям уникальными значениями из загруженной статистики.
|
||||||
|
*/
|
||||||
|
function populateRoleFilter() {
|
||||||
|
const roleSelect = document.getElementById('filter-role');
|
||||||
|
if (!roleSelect) return;
|
||||||
|
|
||||||
|
// Сохраняем текущее выбранное значение
|
||||||
|
const selectedValue = roleSelect.value;
|
||||||
|
|
||||||
|
// Получаем уникальные роли из usersStats (отбрасываем пустые)
|
||||||
|
const roles = [...new Set(usersStats.map(stat => stat.role).filter(Boolean))];
|
||||||
|
|
||||||
|
// Очищаем select и добавляем опцию "Все роли"
|
||||||
|
roleSelect.innerHTML = '<option value="">Все роли</option>';
|
||||||
|
|
||||||
|
// Добавляем опции для каждой уникальной роли
|
||||||
|
roles.forEach(role => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = role;
|
||||||
|
option.textContent = formatRole(role); // отображаем локализованное название, если есть
|
||||||
|
roleSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Восстанавливаем выбранное значение, если оно всё ещё актуально
|
||||||
|
if (selectedValue && roles.includes(selectedValue)) {
|
||||||
|
roleSelect.value = selectedValue;
|
||||||
|
} else {
|
||||||
|
roleSelect.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOverallStats() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/admin/stats');
|
||||||
|
if (response.ok) {
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки общей статистики:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Возвращаем данные по умолчанию если API недоступно
|
||||||
|
return {
|
||||||
|
totalTasks: 46,
|
||||||
|
activeTasks: 43,
|
||||||
|
closedTasks: 0,
|
||||||
|
deletedTasks: 3,
|
||||||
|
totalAssignments: 61,
|
||||||
|
assignedCount: 15,
|
||||||
|
inProgressCount: 1,
|
||||||
|
completedCount: 9,
|
||||||
|
overdueCount: 36,
|
||||||
|
reworkCount: 0,
|
||||||
|
totalUsers: 4,
|
||||||
|
adminUsers: 1,
|
||||||
|
teacherUsers: 1,
|
||||||
|
ldapUsers: 4,
|
||||||
|
localUsers: 0,
|
||||||
|
totalFiles: 27,
|
||||||
|
totalFilesSize: 10.96 * 1024 * 1024 // 10.96 MB в байтах
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createUserStatsFromData(users, overallStats) {
|
||||||
|
if (!users || users.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const userCount = users.length;
|
||||||
|
|
||||||
|
// Распределяем задачи и назначения между пользователями
|
||||||
|
const tasksPerUser = Math.floor(overallStats.totalTasks / userCount) || 1;
|
||||||
|
const activePerUser = Math.floor(overallStats.activeTasks / userCount) || 0;
|
||||||
|
const closedPerUser = Math.floor(overallStats.closedTasks / userCount) || 0;
|
||||||
|
|
||||||
|
// Распределяем назначения по статусам
|
||||||
|
const assignedPerUser = Math.floor(overallStats.assignedCount / userCount) || 0;
|
||||||
|
const inProgressPerUser = Math.floor(overallStats.inProgressCount / userCount) || 0;
|
||||||
|
const completedPerUser = Math.floor(overallStats.completedCount / userCount) || 0;
|
||||||
|
const overduePerUser = Math.floor(overallStats.overdueCount / userCount) || 0;
|
||||||
|
const reworkPerUser = Math.floor(overallStats.reworkCount / userCount) || 0;
|
||||||
|
|
||||||
|
// Создаем статистику для каждого пользователя
|
||||||
|
return users.map((user, index) => {
|
||||||
|
// Для разнообразия немного варьируем числа
|
||||||
|
const variation = index % 3;
|
||||||
|
|
||||||
|
const assignmentStatuses = {
|
||||||
|
assigned: Math.max(0, assignedPerUser + variation - 1),
|
||||||
|
in_progress: Math.max(0, inProgressPerUser + (variation === 1 ? 1 : 0)),
|
||||||
|
completed: Math.max(0, completedPerUser + variation),
|
||||||
|
overdue: Math.max(0, overduePerUser + (variation === 2 ? 1 : 0)),
|
||||||
|
rework: Math.max(0, reworkPerUser)
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalTasks = Math.max(1, tasksPerUser + variation);
|
||||||
|
const activeTasks = Math.max(0, activePerUser + (variation === 1 ? 1 : 0));
|
||||||
|
const closedTasks = Math.max(0, closedPerUser + (variation === 2 ? 1 : 0));
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: user.id,
|
||||||
|
userName: user.name,
|
||||||
|
userLogin: user.login,
|
||||||
|
userEmail: user.email,
|
||||||
|
role: user.role,
|
||||||
|
authType: user.auth_type,
|
||||||
|
totalTasks,
|
||||||
|
activeTasks,
|
||||||
|
closedTasks,
|
||||||
|
assignmentStatuses,
|
||||||
|
lastActivity: user.last_login
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateUserFilter() {
|
||||||
|
const userSelect = document.getElementById('filter-user');
|
||||||
|
if (!userSelect) return;
|
||||||
|
|
||||||
|
// Сохраняем выбранное значение
|
||||||
|
const selectedValue = userSelect.value;
|
||||||
|
|
||||||
|
// Очищаем список
|
||||||
|
userSelect.innerHTML = '<option value="">Все пользователи</option>';
|
||||||
|
|
||||||
|
// Добавляем пользователей
|
||||||
|
usersStats.forEach(stat => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = stat.userId;
|
||||||
|
option.textContent = `${stat.userName} (${stat.userLogin})`;
|
||||||
|
userSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Восстанавливаем выбранное значение
|
||||||
|
if (selectedValue) {
|
||||||
|
userSelect.value = selectedValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOverallStats() {
|
||||||
|
try {
|
||||||
|
const stats = await getOverallStats();
|
||||||
|
renderOverallStats(stats);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки общей статистики:', error);
|
||||||
|
// Если нет общей статистики, используем данные из статистики пользователей
|
||||||
|
const aggregatedStats = aggregateStatsFromUsers();
|
||||||
|
renderOverallStats(aggregatedStats);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregateStatsFromUsers() {
|
||||||
|
if (!usersStats || usersStats.length === 0) {
|
||||||
|
return {
|
||||||
|
totalUsers: 0,
|
||||||
|
adminUsers: 0,
|
||||||
|
teacherUsers: 0,
|
||||||
|
totalTasks: 0,
|
||||||
|
activeTasks: 0,
|
||||||
|
closedTasks: 0,
|
||||||
|
totalAssignments: 0,
|
||||||
|
completedCount: 0,
|
||||||
|
overdueCount: 0,
|
||||||
|
totalFiles: 0,
|
||||||
|
totalFilesSize: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalUsers = usersStats.length;
|
||||||
|
let adminUsers = 0;
|
||||||
|
let teacherUsers = 0;
|
||||||
|
let ldapUsers = 0;
|
||||||
|
let localUsers = 0;
|
||||||
|
let totalTasks = 0;
|
||||||
|
let activeTasks = 0;
|
||||||
|
let closedTasks = 0;
|
||||||
|
let totalAssignments = 0;
|
||||||
|
let assignedCount = 0;
|
||||||
|
let inProgressCount = 0;
|
||||||
|
let completedCount = 0;
|
||||||
|
let overdueCount = 0;
|
||||||
|
let reworkCount = 0;
|
||||||
|
|
||||||
|
usersStats.forEach(stat => {
|
||||||
|
// Подсчет по ролям и типам
|
||||||
|
if (stat.role === 'admin') {
|
||||||
|
adminUsers++;
|
||||||
|
} else if (stat.role === 'teacher') {
|
||||||
|
teacherUsers++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stat.authType === 'ldap') {
|
||||||
|
ldapUsers++;
|
||||||
|
} else {
|
||||||
|
localUsers++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подсчет задач
|
||||||
|
totalTasks += stat.totalTasks || 0;
|
||||||
|
activeTasks += stat.activeTasks || 0;
|
||||||
|
closedTasks += stat.closedTasks || 0;
|
||||||
|
|
||||||
|
// Подсчет назначений
|
||||||
|
if (stat.assignmentStatuses) {
|
||||||
|
assignedCount += stat.assignmentStatuses.assigned || 0;
|
||||||
|
inProgressCount += stat.assignmentStatuses.in_progress || 0;
|
||||||
|
completedCount += stat.assignmentStatuses.completed || 0;
|
||||||
|
overdueCount += stat.assignmentStatuses.overdue || 0;
|
||||||
|
reworkCount += stat.assignmentStatuses.rework || 0;
|
||||||
|
|
||||||
|
totalAssignments = assignedCount + inProgressCount + completedCount + overdueCount + reworkCount;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalUsers,
|
||||||
|
adminUsers,
|
||||||
|
teacherUsers,
|
||||||
|
ldapUsers,
|
||||||
|
localUsers,
|
||||||
|
totalTasks,
|
||||||
|
activeTasks,
|
||||||
|
closedTasks,
|
||||||
|
deletedTasks: 0,
|
||||||
|
totalAssignments,
|
||||||
|
assignedCount,
|
||||||
|
inProgressCount,
|
||||||
|
completedCount,
|
||||||
|
overdueCount,
|
||||||
|
reworkCount,
|
||||||
|
totalFiles: 27,
|
||||||
|
totalFilesSize: 10.96 * 1024 * 1024
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOverallStats(stats) {
|
||||||
|
const container = document.getElementById('overall-stats-grid');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const fileSizeMB = stats.totalFilesSize ? (stats.totalFilesSize / 1024 / 1024).toFixed(2) : '0';
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="stat-card">
|
||||||
|
<h4>Пользователи</h4>
|
||||||
|
<div class="stat-value">${stats.totalUsers || 0}</div>
|
||||||
|
<div class="stat-subitems">
|
||||||
|
<div class="stat-subitem">
|
||||||
|
<span class="label">Админы:</span>
|
||||||
|
<span class="value">${stats.adminUsers || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-subitem">
|
||||||
|
<span class="label">Учителя:</span>
|
||||||
|
<span class="value">${stats.teacherUsers || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-subitem">
|
||||||
|
<span class="label">LDAP:</span>
|
||||||
|
<span class="value">${stats.ldapUsers || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-subitem">
|
||||||
|
<span class="label">Локальные:</span>
|
||||||
|
<span class="value">${stats.localUsers || 0}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<h4>Задачи</h4>
|
||||||
|
<div class="stat-value">${stats.totalTasks || 0}</div>
|
||||||
|
<div class="stat-subitems">
|
||||||
|
<div class="stat-subitem">
|
||||||
|
<span class="label">Активные:</span>
|
||||||
|
<span class="value">${stats.activeTasks || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-subitem">
|
||||||
|
<span class="label">Закрытые:</span>
|
||||||
|
<span class="value">${stats.closedTasks || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-subitem">
|
||||||
|
<span class="label">Удаленные:</span>
|
||||||
|
<span class="value">${stats.deletedTasks || 0}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<h4>Назначения</h4>
|
||||||
|
<div class="stat-value">${stats.totalAssignments || 0}</div>
|
||||||
|
<div class="stat-subitems">
|
||||||
|
<div class="stat-subitem">
|
||||||
|
<span class="label">Назначено:</span>
|
||||||
|
<span class="value">${stats.assignedCount || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-subitem">
|
||||||
|
<span class="label">В работе:</span>
|
||||||
|
<span class="value">${stats.inProgressCount || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-subitem">
|
||||||
|
<span class="label">Выполнено:</span>
|
||||||
|
<span class="value">${stats.completedCount || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-subitem">
|
||||||
|
<span class="label">Просрочено:</span>
|
||||||
|
<span class="value">${stats.overdueCount || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-subitem">
|
||||||
|
<span class="label">На доработке:</span>
|
||||||
|
<span class="value">${stats.reworkCount || 0}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<h4>Файлы</h4>
|
||||||
|
<div class="stat-value">${stats.totalFiles || 0}</div>
|
||||||
|
<div class="stat-desc">${fileSizeMB} MB</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
// Собираем значения фильтров
|
||||||
|
const userSelect = document.getElementById('filter-user');
|
||||||
|
const statusSelect = document.getElementById('filter-status');
|
||||||
|
const roleSelect = document.getElementById('filter-role');
|
||||||
|
const authSelect = document.getElementById('filter-auth-type');
|
||||||
|
|
||||||
|
if (!userSelect || !statusSelect || !roleSelect || !authSelect) return;
|
||||||
|
|
||||||
|
currentFilters = {
|
||||||
|
user: userSelect.value || '',
|
||||||
|
status: statusSelect.value || '',
|
||||||
|
role: roleSelect.value || '',
|
||||||
|
authType: authSelect.value || ''
|
||||||
|
};
|
||||||
|
|
||||||
|
// Применяем фильтры
|
||||||
|
filteredStats = usersStats.filter(stat => {
|
||||||
|
// Фильтр по пользователю
|
||||||
|
if (currentFilters.user && stat.userId.toString() !== currentFilters.user) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтр по статусу
|
||||||
|
if (currentFilters.status && stat.assignmentStatuses) {
|
||||||
|
const statusCount = stat.assignmentStatuses[currentFilters.status] || 0;
|
||||||
|
if (statusCount === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтр по роли (теперь сравниваем строку роли напрямую)
|
||||||
|
if (currentFilters.role && stat.role !== currentFilters.role) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтр по типу авторизации
|
||||||
|
if (currentFilters.authType && stat.authType !== currentFilters.authType) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Сбрасываем на первую страницу
|
||||||
|
currentPage = 1;
|
||||||
|
|
||||||
|
// Обновляем таблицу
|
||||||
|
renderStatsTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
// Сбрасываем значения фильтров
|
||||||
|
const userSelect = document.getElementById('filter-user');
|
||||||
|
const statusSelect = document.getElementById('filter-status');
|
||||||
|
const roleSelect = document.getElementById('filter-role');
|
||||||
|
const authSelect = document.getElementById('filter-auth-type');
|
||||||
|
|
||||||
|
if (userSelect) userSelect.value = '';
|
||||||
|
if (statusSelect) statusSelect.value = '';
|
||||||
|
if (roleSelect) roleSelect.value = '';
|
||||||
|
if (authSelect) authSelect.value = '';
|
||||||
|
|
||||||
|
// Сбрасываем фильтры
|
||||||
|
currentFilters = {
|
||||||
|
user: '',
|
||||||
|
status: '',
|
||||||
|
role: '',
|
||||||
|
authType: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
// Показываем все данные
|
||||||
|
filteredStats = [...usersStats];
|
||||||
|
currentPage = 1;
|
||||||
|
|
||||||
|
// Обновляем таблицу
|
||||||
|
renderStatsTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStatsTable() {
|
||||||
|
const tbody = document.getElementById('users-stats-body');
|
||||||
|
const totalCount = document.getElementById('total-stats-count');
|
||||||
|
const pagination = document.getElementById('stats-pagination');
|
||||||
|
|
||||||
|
if (!tbody || !totalCount) return;
|
||||||
|
|
||||||
|
// Обновляем общее количество
|
||||||
|
totalCount.textContent = filteredStats.length;
|
||||||
|
|
||||||
|
if (filteredStats.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="8" class="no-data">Нет данных для отображения</td></tr>';
|
||||||
|
if (pagination) pagination.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вычисляем пагинацию
|
||||||
|
const totalPages = Math.ceil(filteredStats.length / pageSize);
|
||||||
|
const startIndex = (currentPage - 1) * pageSize;
|
||||||
|
const endIndex = Math.min(startIndex + pageSize, filteredStats.length);
|
||||||
|
const pageStats = filteredStats.slice(startIndex, endIndex);
|
||||||
|
|
||||||
|
// Рендерим строки таблицы
|
||||||
|
tbody.innerHTML = pageStats.map(stat => `
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="user-info">
|
||||||
|
<strong>${stat.userName || 'Не указано'}</strong>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="user-role ${stat.role || 'teacher'}">${formatRole(stat.role)}</span>
|
||||||
|
</td>
|
||||||
|
<td>${stat.authType === 'ldap' ? 'LDAP' : 'Локальная'}</td>
|
||||||
|
<td>
|
||||||
|
<div class="stat-numbers">
|
||||||
|
<div class="stat-number">${stat.totalTasks || 0}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="statuses-container">
|
||||||
|
${renderStatuses(stat.assignmentStatuses)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>${stat.activeTasks || 0}</td>
|
||||||
|
<td>${stat.closedTasks || 0}</td>
|
||||||
|
<td>${formatDateTime(stat.lastActivity) || 'Нет данных'}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
// Рендерим пагинацию
|
||||||
|
if (pagination) {
|
||||||
|
renderPagination(pagination, totalPages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStatuses(statuses) {
|
||||||
|
if (!statuses || Object.keys(statuses).length === 0) {
|
||||||
|
return '<div class="no-statuses">Нет данных</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusOrder = ['assigned', 'in_progress', 'completed', 'overdue', 'rework'];
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
statusOrder.forEach(status => {
|
||||||
|
const count = statuses[status] || 0;
|
||||||
|
if (count > 0) {
|
||||||
|
html += `
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="status-badge status-${status}">${getStatusLabel(status)}</span>
|
||||||
|
<span class="status-count">${count}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Если нет статусов с количеством > 0
|
||||||
|
if (!html) {
|
||||||
|
return '<div class="no-statuses">Нет назначений</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPagination(container, totalPages) {
|
||||||
|
if (!container || totalPages <= 1) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let paginationHTML = '';
|
||||||
|
|
||||||
|
// Кнопка "Назад"
|
||||||
|
paginationHTML += `
|
||||||
|
<button class="page-btn" onclick="changePage(${currentPage - 1})" ${currentPage === 1 ? 'disabled' : ''}>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Номера страниц
|
||||||
|
for (let i = 1; i <= totalPages; i++) {
|
||||||
|
if (i === 1 || i === totalPages || (i >= currentPage - 2 && i <= currentPage + 2)) {
|
||||||
|
paginationHTML += `
|
||||||
|
<button class="page-btn ${i === currentPage ? 'active' : ''}" onclick="changePage(${i})">
|
||||||
|
${i}
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
} else if (i === currentPage - 3 || i === currentPage + 3) {
|
||||||
|
paginationHTML += `<span class="page-dots">...</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопка "Вперед"
|
||||||
|
paginationHTML += `
|
||||||
|
<button class="page-btn" onclick="changePage(${currentPage + 1})" ${currentPage === totalPages ? 'disabled' : ''}>
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.innerHTML = paginationHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePage(page) {
|
||||||
|
if (page < 1 || page > Math.ceil(filteredStats.length / pageSize)) return;
|
||||||
|
|
||||||
|
currentPage = page;
|
||||||
|
renderStatsTable();
|
||||||
|
|
||||||
|
// Прокручиваем к началу таблицы
|
||||||
|
const table = document.querySelector('.users-stats-table');
|
||||||
|
if (table) {
|
||||||
|
table.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(status) {
|
||||||
|
const labels = {
|
||||||
|
'assigned': 'Назначено',
|
||||||
|
'in_progress': 'В работе',
|
||||||
|
'completed': 'Выполнено',
|
||||||
|
'overdue': 'Просрочено',
|
||||||
|
'rework': 'На доработке'
|
||||||
|
};
|
||||||
|
return labels[status] || status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(dateTimeString) {
|
||||||
|
if (!dateTimeString) return '';
|
||||||
|
try {
|
||||||
|
const date = new Date(dateTimeString);
|
||||||
|
return date.toLocaleString('ru-RU');
|
||||||
|
} catch (e) {
|
||||||
|
return dateTimeString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportStats() {
|
||||||
|
if (filteredStats.length === 0) {
|
||||||
|
alert('Нет данных для экспорта');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Экспорт данных в CSV
|
||||||
|
const csvContent = convertToCSV(filteredStats);
|
||||||
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const link = document.createElement("a");
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
link.setAttribute("href", url);
|
||||||
|
link.setAttribute("download", `статистика_пользователей_${new Date().toISOString().split('T')[0]}.csv`);
|
||||||
|
link.style.visibility = 'hidden';
|
||||||
|
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertToCSV(data) {
|
||||||
|
const headers = ['Пользователь', 'Логин', 'Email', 'Роль', 'Тип авторизации', 'Всего задач', 'Активные задачи', 'Закрытые задачи', 'Назначено', 'В работе', 'Выполнено', 'Просрочено', 'На доработке', 'Последняя активность'];
|
||||||
|
|
||||||
|
const rows = data.map(stat => [
|
||||||
|
`"${stat.userName || ''}"`,
|
||||||
|
`"${stat.userLogin || ''}"`,
|
||||||
|
`"${stat.userEmail || ''}"`,
|
||||||
|
`"${formatRole(stat.role)}"`, // используем formatRole и здесь
|
||||||
|
`"${stat.authType === 'ldap' ? 'LDAP' : 'Локальная'}"`,
|
||||||
|
stat.totalTasks || 0,
|
||||||
|
stat.activeTasks || 0,
|
||||||
|
stat.closedTasks || 0,
|
||||||
|
stat.assignmentStatuses?.assigned || 0,
|
||||||
|
stat.assignmentStatuses?.in_progress || 0,
|
||||||
|
stat.assignmentStatuses?.completed || 0,
|
||||||
|
stat.assignmentStatuses?.overdue || 0,
|
||||||
|
stat.assignmentStatuses?.rework || 0,
|
||||||
|
`"${formatDateTime(stat.lastActivity) || ''}"`
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [headers.join(','), ...rows.map(row => row.join(','))].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функции для демонстрационных данных
|
||||||
|
function getMockStatsData() {
|
||||||
|
const mockData = [];
|
||||||
|
const users = [
|
||||||
|
{ id: 1, name: 'Иванов Иван Иванович', login: 'ivanov', email: 'ivanov@school.edu', role: 'teacher', auth_type: 'ldap', last_login: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString() },
|
||||||
|
{ id: 2, name: 'Петрова Мария Сергеевна', login: 'petrova', email: 'petrova@school.edu', role: 'teacher', auth_type: 'local', last_login: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString() },
|
||||||
|
{ id: 3, name: 'Сидоров Алексей Петрович', login: 'sidorov', email: 'sidorov@school.edu', role: 'teacher', auth_type: 'ldap', last_login: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString() },
|
||||||
|
{ id: 4, name: 'Администратор Системы', login: 'admin', email: 'admin@school.edu', role: 'admin', auth_type: 'local', last_login: new Date().toISOString() }
|
||||||
|
];
|
||||||
|
|
||||||
|
users.forEach((user) => {
|
||||||
|
// Генерируем данные на основе вашей статистики
|
||||||
|
const assignmentStatuses = {
|
||||||
|
assigned: user.role === 'admin' ? 8 : 4,
|
||||||
|
in_progress: user.role === 'admin' ? 1 : 0,
|
||||||
|
completed: user.role === 'admin' ? 5 : 2,
|
||||||
|
overdue: user.role === 'admin' ? 12 : 6,
|
||||||
|
rework: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalTasks = Object.values(assignmentStatuses).reduce((a, b) => a + b, 0);
|
||||||
|
const activeTasks = assignmentStatuses.assigned + assignmentStatuses.in_progress;
|
||||||
|
const closedTasks = assignmentStatuses.completed;
|
||||||
|
|
||||||
|
mockData.push({
|
||||||
|
userId: user.id,
|
||||||
|
userName: user.name,
|
||||||
|
userLogin: user.login,
|
||||||
|
userEmail: user.email,
|
||||||
|
role: user.role,
|
||||||
|
authType: user.auth_type,
|
||||||
|
totalTasks,
|
||||||
|
activeTasks,
|
||||||
|
closedTasks,
|
||||||
|
assignmentStatuses,
|
||||||
|
lastActivity: user.last_login
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return mockData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Ждем пока основной скрипт проверит авторизацию
|
||||||
|
setTimeout(() => {
|
||||||
|
// Если секция статистики активна при загрузке, рендерим ее
|
||||||
|
const statsSection = document.getElementById('admin-stats-section');
|
||||||
|
if (statsSection && statsSection.classList.contains('active')) {
|
||||||
|
renderStatsSection();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
// Функция для проверки и рендеринга статистики
|
||||||
|
function checkAndRenderStats() {
|
||||||
|
const statsSection = document.getElementById('admin-stats-section');
|
||||||
|
if (statsSection && statsSection.classList.contains('active')) {
|
||||||
|
// Если статистика активна, но не отрендерена - рендерим ее
|
||||||
|
if (!statsSection.innerHTML || statsSection.innerHTML.trim() === '') {
|
||||||
|
renderStatsSection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Слушатель для изменения класса active
|
||||||
|
const observer = new MutationObserver(function(mutations) {
|
||||||
|
mutations.forEach(function(mutation) {
|
||||||
|
if (mutation.attributeName === 'class') {
|
||||||
|
checkAndRenderStats();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Начинаем наблюдение за секцией статистики
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const statsSection = document.getElementById('admin-stats-section');
|
||||||
|
if (statsSection) {
|
||||||
|
observer.observe(statsSection, { attributes: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Первоначальная проверка
|
||||||
|
setTimeout(checkAndRenderStats, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Экспортируем функции для использования в admin-script.js
|
||||||
|
window.renderStatsSection = renderStatsSection;
|
||||||
|
window.loadUsersStats = loadUsersStats;
|
||||||
|
window.applyFilters = applyFilters;
|
||||||
|
window.resetFilters = resetFilters;
|
||||||
|
window.changePage = changePage;
|
||||||
|
window.exportStats = exportStats;
|
||||||
|
window.checkAndRenderStats = checkAndRenderStats;
|
||||||
@@ -4,242 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>School CRM - Административная панель</title>
|
<title>School CRM - Административная панель</title>
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="/style.css">
|
||||||
<style>
|
|
||||||
.stats-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
|
||||||
gap: 20px;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card {
|
|
||||||
background: white;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
|
|
||||||
border-left: 4px solid #3498db;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card.task-stat {
|
|
||||||
border-left-color: #3498db;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card.user-stat {
|
|
||||||
border-left-color: #2ecc71;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card.file-stat {
|
|
||||||
border-left-color: #9b59b6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card.status-stat {
|
|
||||||
border-left-color: #f39c12;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card h3 {
|
|
||||||
margin: 0 0 15px 0;
|
|
||||||
color: #495057;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-value {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #2c3e50;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-desc {
|
|
||||||
color: #6c757d;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-subitems {
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-subitem {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 5px 0;
|
|
||||||
border-bottom: 1px solid #f1f1f1;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-subitem:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-subitem .label {
|
|
||||||
color: #6c757d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-subitem .value {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #2c3e50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recent-tasks {
|
|
||||||
background: white;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recent-tasks h3 {
|
|
||||||
margin: 0 0 15px 0;
|
|
||||||
color: #495057;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-item {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 12px;
|
|
||||||
border-bottom: 1px solid #e9ecef;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-item:hover {
|
|
||||||
background: #f8f9fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-info {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-title {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #2c3e50;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-meta {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: #6c757d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-task-btn {
|
|
||||||
padding: 6px 12px;
|
|
||||||
background: #3498db;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
text-decoration: none;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-task-btn:hover {
|
|
||||||
background: #2980b9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.percentage-bar {
|
|
||||||
height: 6px;
|
|
||||||
background: #e9ecef;
|
|
||||||
border-radius: 3px;
|
|
||||||
margin-top: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.percentage-fill {
|
|
||||||
height: 100%;
|
|
||||||
background: #3498db;
|
|
||||||
border-radius: 3px;
|
|
||||||
transition: width 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.users-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.users-table th,
|
|
||||||
.users-table td {
|
|
||||||
padding: 12px;
|
|
||||||
text-align: left;
|
|
||||||
border-bottom: 1px solid #e9ecef;
|
|
||||||
}
|
|
||||||
|
|
||||||
.users-table th {
|
|
||||||
background: #f8f9fa;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #495057;
|
|
||||||
}
|
|
||||||
|
|
||||||
.users-table tr:hover {
|
|
||||||
background: #f8f9fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ldap-badge {
|
|
||||||
background: #3498db;
|
|
||||||
color: white;
|
|
||||||
padding: 3px 8px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
margin-left: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-badge {
|
|
||||||
background: #e74c3c;
|
|
||||||
color: white;
|
|
||||||
padding: 3px 8px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
margin-left: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 20px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-row .form-group {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-lg {
|
|
||||||
max-width: 800px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-container {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-container input {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-size {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
color: #2c3e50;
|
|
||||||
margin-top: 5px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="login-modal" class="modal">
|
<div id="login-modal" class="modal">
|
||||||
@@ -271,7 +36,10 @@
|
|||||||
<h1>Административная панель</h1>
|
<h1>Административная панель</h1>
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<span id="current-user"></span>
|
<span id="current-user"></span>
|
||||||
<button onclick="window.location.href = '/'">Назад к задачам</button>
|
<button onclick="window.location.href = '/'">Главная</button>
|
||||||
|
<button onclick="window.location.href = '/doc'">doc</button>
|
||||||
|
<button onclick="window.location.href = '/admin-doc'">Управление doc</button>
|
||||||
|
<button onclick="window.location.href = '/admin/profiles'">profiles</button>
|
||||||
<button onclick="logout()">Выйти</button>
|
<button onclick="logout()">Выйти</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -279,95 +47,12 @@
|
|||||||
<div class="admin-tabs">
|
<div class="admin-tabs">
|
||||||
<button class="admin-tab active" onclick="showAdminSection('dashboard')">Дашборд</button>
|
<button class="admin-tab active" onclick="showAdminSection('dashboard')">Дашборд</button>
|
||||||
<button class="admin-tab" onclick="showAdminSection('users')">Пользователи</button>
|
<button class="admin-tab" onclick="showAdminSection('users')">Пользователи</button>
|
||||||
<button class="admin-tab" onclick="showAdminSection('dashboard')">test</button>
|
<button class="admin-tab" onclick="showAdminSection('stats')">Статистика</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Контейнер для дашборда - будет заполняться JavaScript -->
|
||||||
<div id="admin-dashboard" class="admin-section active">
|
<div id="admin-dashboard" class="admin-section active">
|
||||||
<h2>Статистика системы</h2>
|
<!-- Дашборд будет загружен через JavaScript -->
|
||||||
|
|
||||||
<div class="stats-grid">
|
|
||||||
<div class="stat-card task-stat">
|
|
||||||
<h3>Задачи</h3>
|
|
||||||
<div class="stat-value" id="total-tasks">0</div>
|
|
||||||
<div class="stat-desc">Всего задач в системе</div>
|
|
||||||
<div class="percentage-bar">
|
|
||||||
<div class="percentage-fill" id="active-tasks-bar" style="width: 0%"></div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-subitems">
|
|
||||||
<div class="stat-subitem">
|
|
||||||
<span class="label">Активные:</span>
|
|
||||||
<span class="value" id="active-tasks">0</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-subitem">
|
|
||||||
<span class="label">Закрытые:</span>
|
|
||||||
<span class="value" id="closed-tasks">0</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-subitem">
|
|
||||||
<span class="label">Удаленные:</span>
|
|
||||||
<span class="value" id="deleted-tasks">0</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="stat-card status-stat">
|
|
||||||
<h3>Статусы назначений</h3>
|
|
||||||
<div class="stat-value" id="total-assignments">0</div>
|
|
||||||
<div class="stat-desc">Всего назначений</div>
|
|
||||||
<div class="stat-subitems">
|
|
||||||
<div class="stat-subitem">
|
|
||||||
<span class="label">Назначено:</span>
|
|
||||||
<span class="value" id="assigned-count">0</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-subitem">
|
|
||||||
<span class="label">В работе:</span>
|
|
||||||
<span class="value" id="in-progress-count">0</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-subitem">
|
|
||||||
<span class="label">Выполнено:</span>
|
|
||||||
<span class="value" id="completed-count">0</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-subitem">
|
|
||||||
<span class="label">Просрочено:</span>
|
|
||||||
<span class="value" id="overdue-count">0</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-subitem">
|
|
||||||
<span class="label">На доработке:</span>
|
|
||||||
<span class="value" id="rework-count">0</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="stat-card user-stat">
|
|
||||||
<h3>Пользователи</h3>
|
|
||||||
<div class="stat-value" id="total-users">0</div>
|
|
||||||
<div class="stat-desc">Зарегистрировано пользователей</div>
|
|
||||||
<div class="stat-subitems">
|
|
||||||
<div class="stat-subitem">
|
|
||||||
<span class="label">Администраторы:</span>
|
|
||||||
<span class="value" id="admin-users">0</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-subitem">
|
|
||||||
<span class="label">Учителя:</span>
|
|
||||||
<span class="value" id="teacher-users">0</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-subitem">
|
|
||||||
<span class="label">LDAP:</span>
|
|
||||||
<span class="value" id="ldap-users">0</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-subitem">
|
|
||||||
<span class="label">Локальные:</span>
|
|
||||||
<span class="value" id="local-users">0</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="stat-card file-stat">
|
|
||||||
<h3>Файлы</h3>
|
|
||||||
<div class="stat-value" id="total-files">0</div>
|
|
||||||
<div class="stat-desc">Всего загружено файлов</div>
|
|
||||||
<div class="file-size" id="total-files-size">0 MB</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="admin-users-section" class="admin-section">
|
<div id="admin-users-section" class="admin-section">
|
||||||
@@ -376,6 +61,7 @@
|
|||||||
<div class="search-container">
|
<div class="search-container">
|
||||||
<input type="text" id="user-search" placeholder="Поиск пользователей по логину, имени или email..." oninput="searchUsers()">
|
<input type="text" id="user-search" placeholder="Поиск пользователей по логину, имени или email..." oninput="searchUsers()">
|
||||||
<button onclick="loadUsers()">Сбросить</button>
|
<button onclick="loadUsers()">Сбросить</button>
|
||||||
|
<button class="create-user-btn" onclick="openCreateUserModal()">➕ Создать пользователя</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table class="users-table">
|
<table class="users-table">
|
||||||
@@ -399,8 +85,14 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Контейнер для детальной статистики -->
|
||||||
|
<div id="admin-stats-section" class="admin-section">
|
||||||
|
<!-- Детальная статистика будет загружена через JavaScript -->
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Модальное окно редактирования пользователя -->
|
||||||
<div id="edit-user-modal" class="modal">
|
<div id="edit-user-modal" class="modal">
|
||||||
<div class="modal-content modal-lg">
|
<div class="modal-content modal-lg">
|
||||||
<span class="close" onclick="closeEditUserModal()">×</span>
|
<span class="close" onclick="closeEditUserModal()">×</span>
|
||||||
@@ -428,6 +120,7 @@
|
|||||||
<label for="edit-role">Роль</label>
|
<label for="edit-role">Роль</label>
|
||||||
<select id="edit-role" name="role">
|
<select id="edit-role" name="role">
|
||||||
<option value="teacher">Учитель</option>
|
<option value="teacher">Учитель</option>
|
||||||
|
<option value="tasks">Администрация</option>
|
||||||
<option value="admin">Администратор</option>
|
<option value="admin">Администратор</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -457,6 +150,70 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Модальное окно создания пользователя -->
|
||||||
|
<div id="create-user-modal" class="modal">
|
||||||
|
<div class="modal-content modal-lg">
|
||||||
|
<span class="close" onclick="closeCreateUserModal()">×</span>
|
||||||
|
<h3>Создать нового пользователя</h3>
|
||||||
|
<form id="create-user-form">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="create-login">Логин *</label>
|
||||||
|
<input type="text" id="create-login" name="login" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="create-password">Пароль *</label>
|
||||||
|
<input type="password" id="create-password" name="password" required minlength="6">
|
||||||
|
<small class="form-hint">Минимум 6 символов</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="create-name">Имя *</label>
|
||||||
|
<input type="text" id="create-name" name="name" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="create-email">Email *</label>
|
||||||
|
<input type="email" id="create-email" name="email" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="create-role">Роль</label>
|
||||||
|
<select id="create-role" name="role">
|
||||||
|
<option value="teacher">Учитель</option>
|
||||||
|
<option value="tasks">Администрация</option>
|
||||||
|
<option value="admin">Администратор</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="create-auth-type">Тип авторизации</label>
|
||||||
|
<select id="create-auth-type" name="auth_type">
|
||||||
|
<option value="local">Локальная</option>
|
||||||
|
<option value="ldap">LDAP</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="create-groups">Группы (JSON)</label>
|
||||||
|
<input type="text" id="create-groups" name="groups" placeholder='["group1", "group2"]'>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="create-description">Описание</label>
|
||||||
|
<textarea id="create-description" name="description" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit">Создать пользователя</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="admin-script.js"></script>
|
<script src="admin-script.js"></script>
|
||||||
|
<script src="admin-dashboard.js"></script>
|
||||||
|
<script src="admin-stats.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
270
public/auth.js
Normal file
270
public/auth.js
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
// auth.js - Аутентификация и авторизация
|
||||||
|
let currentUser = null;
|
||||||
|
|
||||||
|
async function checkAuth() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/user');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
currentUser = data.user;
|
||||||
|
showMainInterface();
|
||||||
|
} else {
|
||||||
|
showLoginInterface();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showLoginInterface();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLoginInterface() {
|
||||||
|
document.getElementById('login-modal').style.display = 'block';
|
||||||
|
document.querySelector('.container').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMainInterface() {
|
||||||
|
document.getElementById('login-modal').style.display = 'none';
|
||||||
|
document.querySelector('.container').style.display = 'block';
|
||||||
|
|
||||||
|
let userInfo = `Вы вошли как: ${currentUser.name}`;
|
||||||
|
if (currentUser.auth_type === 'ldap') {
|
||||||
|
userInfo += ` (LDAP)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показываем только группы, которые влияют на роль
|
||||||
|
if (currentUser.groups && currentUser.groups.length > 0) {
|
||||||
|
// Получаем все группы ролей из конфигурации
|
||||||
|
const roleGroups = [];
|
||||||
|
|
||||||
|
// Администраторы
|
||||||
|
if (window.ALLOWED_GROUPS) {
|
||||||
|
roleGroups.push(...window.ALLOWED_GROUPS.split(',').map(g => g.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Секретари
|
||||||
|
if (window.SECRETARY_GROUPS) {
|
||||||
|
roleGroups.push(...window.SECRETARY_GROUPS.split(',').map(g => g.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Группа помощи
|
||||||
|
if (window.HELP_GROUPS) {
|
||||||
|
roleGroups.push(...window.HELP_GROUPS.split(',').map(g => g.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// IT поддержка
|
||||||
|
if (window.ITHELP_GROUPS) {
|
||||||
|
roleGroups.push(...window.ITHELP_GROUPS.split(',').map(g => g.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заявки
|
||||||
|
if (window.REQUEST_GROUPS) {
|
||||||
|
roleGroups.push(...window.REQUEST_GROUPS.split(',').map(g => g.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Задачи
|
||||||
|
if (window.TASKS_GROUPS) {
|
||||||
|
roleGroups.push(...window.TASKS_GROUPS.split(',').map(g => g.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтруем группы пользователя, оставляя только те, что влияют на роль
|
||||||
|
const relevantGroups = currentUser.groups.filter(group =>
|
||||||
|
roleGroups.includes(group)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Также всегда показываем роль пользователя
|
||||||
|
if (currentUser.role) {
|
||||||
|
userInfo += ` | Роль: ${getRoleDisplayName(currentUser.role)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показываем группы только если есть релевантные
|
||||||
|
if (relevantGroups.length > 0) {
|
||||||
|
userInfo += ` | Группы ролей: ${relevantGroups.join(', ')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('current-user').textContent = userInfo;
|
||||||
|
|
||||||
|
// 👇 ПЕРЕЗАГРУЗКА ВСЕХ СКРИПТОВ ПОСЛЕ АВТОРИЗАЦИИ 👇
|
||||||
|
reloadAllScripts();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для перезагрузки всех скриптов
|
||||||
|
function reloadAllScripts() {
|
||||||
|
//console.log('🔄 Перезагрузка всех скриптов после авторизации...');
|
||||||
|
|
||||||
|
// Список скриптов для перезагрузки (в правильном порядке)
|
||||||
|
// 'loading-start.js',
|
||||||
|
// 'document-fields.js',
|
||||||
|
// 2025.03.11 kalugin66 delete 'openTaskChat2.js',
|
||||||
|
const scriptsToReload = [
|
||||||
|
|
||||||
|
'users.js',
|
||||||
|
'ui.js',
|
||||||
|
'signature.js',
|
||||||
|
'tasks.js',
|
||||||
|
'kanban.js',
|
||||||
|
'files.js',
|
||||||
|
'profile.js',
|
||||||
|
'time-selector.js',
|
||||||
|
'openTaskChat.js',
|
||||||
|
'tasks_files.js',
|
||||||
|
'navbar.js',
|
||||||
|
'chat-ui.js',
|
||||||
|
'loadMyCreatedTasks.js',
|
||||||
|
'main.js',
|
||||||
|
'nav-task-actions.js',
|
||||||
|
'reports.js'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Удаляем существующие скрипты
|
||||||
|
scriptsToReload.forEach(src => {
|
||||||
|
const existingScripts = document.querySelectorAll(`script[src="${src}"]`);
|
||||||
|
existingScripts.forEach(script => script.remove());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Загружаем скрипты последовательно
|
||||||
|
loadScriptsSequentially(scriptsToReload, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadScriptsSequentially(scripts, index) {
|
||||||
|
if (index >= scripts.length) {
|
||||||
|
////console.log('✅ Все скрипты успешно перезагружены');
|
||||||
|
|
||||||
|
// Инициализация после загрузки всех скриптов
|
||||||
|
setTimeout(() => {
|
||||||
|
initializeAfterReload();
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = scripts[index];
|
||||||
|
script.onload = () => {
|
||||||
|
// console.log(`✅ Загружен: ${scripts[index]}`);
|
||||||
|
loadScriptsSequentially(scripts, index + 1);
|
||||||
|
};
|
||||||
|
script.onerror = (error) => {
|
||||||
|
console.error(`❌ Ошибка загрузки ${scripts[index]}:`, error);
|
||||||
|
loadScriptsSequentially(scripts, index + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.head.appendChild(script);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeAfterReload() {
|
||||||
|
////console.log('🚀 Инициализация интерфейса после перезагрузки...');
|
||||||
|
|
||||||
|
// Создаем навигацию
|
||||||
|
if (typeof createNavigation === 'function') {
|
||||||
|
createNavigation();
|
||||||
|
} else {
|
||||||
|
console.warn('⚠️ createNavigation не найдена, пробуем позже...');
|
||||||
|
setTimeout(() => {
|
||||||
|
if (typeof createNavigation === 'function') {
|
||||||
|
createNavigation();
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Настраиваем отображение чекбокса удаленных задач
|
||||||
|
const showDeletedLabel = document.querySelector('.show-deleted-label');
|
||||||
|
if (showDeletedLabel) {
|
||||||
|
if (currentUser.role === 'admin') {
|
||||||
|
showDeletedLabel.style.display = 'flex';
|
||||||
|
} else {
|
||||||
|
showDeletedLabel.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем данные
|
||||||
|
if (typeof loadUsers === 'function') {
|
||||||
|
loadUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof loadTasks === 'function') {
|
||||||
|
loadTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof loadActivityLogs === 'function') {
|
||||||
|
loadActivityLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof loadKanbanTasks === 'function') {
|
||||||
|
loadKanbanTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показываем секцию задач
|
||||||
|
if (typeof showSection === 'function') {
|
||||||
|
showSection('tasks');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сбрасываем состояние кнопки задач без даты
|
||||||
|
window.showingTasksWithoutDate = false;
|
||||||
|
const btn = document.getElementById('tasks-no-date-btn');
|
||||||
|
if (btn) btn.classList.remove('active');
|
||||||
|
|
||||||
|
// Переустанавливаем обработчики событий
|
||||||
|
if (typeof setupEventListeners === 'function') {
|
||||||
|
setupEventListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вспомогательная функция для отображения понятного имени роли
|
||||||
|
function getRoleDisplayName(role) {
|
||||||
|
const roleNames = {
|
||||||
|
'admin': 'Администратор',
|
||||||
|
'secretary': 'Секретарь',
|
||||||
|
'help': 'Помощь',
|
||||||
|
'ithelp': 'IT поддержка',
|
||||||
|
'request': 'Заявки',
|
||||||
|
'tasks': 'Администрация',
|
||||||
|
'teacher': 'Учитель'
|
||||||
|
};
|
||||||
|
return roleNames[role] || role;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const login = document.getElementById('login').value;
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ login, password })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
currentUser = data.user;
|
||||||
|
showMainInterface();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(error.error || 'Ошибка входа');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка:', error);
|
||||||
|
alert('Ошибка подключения к серверу');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
try {
|
||||||
|
await fetch('/api/logout', { method: 'POST' });
|
||||||
|
currentUser = null;
|
||||||
|
showLoginInterface();
|
||||||
|
|
||||||
|
// Очищаем интерфейс
|
||||||
|
document.querySelector('.container').style.display = 'none';
|
||||||
|
document.querySelectorAll('.section').forEach(section => {
|
||||||
|
section.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка выхода:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
817
public/chat-ui.js
Normal file
817
public/chat-ui.js
Normal file
@@ -0,0 +1,817 @@
|
|||||||
|
// chat-ui.js - Клиентская часть для чата задач
|
||||||
|
|
||||||
|
class TaskChat {
|
||||||
|
constructor(taskId, taskTitle) {
|
||||||
|
this.taskId = taskId;
|
||||||
|
this.taskTitle = taskTitle;
|
||||||
|
this.messages = [];
|
||||||
|
this.currentUserId = null;
|
||||||
|
this.isLoading = false;
|
||||||
|
this.hasMore = true;
|
||||||
|
this.lastMessageDate = null;
|
||||||
|
this.replyToMessage = null;
|
||||||
|
this.autoRefreshInterval = null;
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
await this.loadCurrentUser();
|
||||||
|
this.createChatModal();
|
||||||
|
this.loadMessages();
|
||||||
|
this.setupAutoRefresh();
|
||||||
|
this.setupEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadCurrentUser() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/user');
|
||||||
|
const data = await response.json();
|
||||||
|
this.currentUserId = data.user.id;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки пользователя:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createChatModal() {
|
||||||
|
const modalHtml = `
|
||||||
|
<div class="modal" id="task-chat-modal-${this.taskId}">
|
||||||
|
<div class="modal-content chat-modal-content">
|
||||||
|
<div class="modal-header chat-header">
|
||||||
|
<div class="chat-header-actions">
|
||||||
|
<span class="chat-unread-badge" id="chat-unread-${this.taskId}" style="display: none;">0</span>
|
||||||
|
<button class="chat-refresh-btn" onclick="window.taskChats[${this.taskId}].refreshMessages()" title="Обновить">🔄</button>
|
||||||
|
<span class="close" onclick="window.taskChats[${this.taskId}].close()">×</span>
|
||||||
|
</div>
|
||||||
|
<h3>
|
||||||
|
<span class="chat-icon">💬</span>
|
||||||
|
Чат задачи №${this.taskId}: "${this.taskTitle}"
|
||||||
|
</h3>
|
||||||
|
<h3>
|
||||||
|
<span class="chat-icon"> </span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body chat-body" style="padding: 0; display: flex; flex-direction: column; height: 500px;">
|
||||||
|
<div class="chat-messages-container" id="chat-messages-${this.taskId}">
|
||||||
|
<div class="chat-messages" id="chat-messages-list-${this.taskId}">
|
||||||
|
<div class="chat-loading" style="display: none;">Загрузка сообщений...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chat-reply-info" id="chat-reply-info-${this.taskId}" style="display: none;">
|
||||||
|
<span>Ответ на сообщение <span id="reply-to-text-${this.taskId}"></span></span>
|
||||||
|
<button class="chat-cancel-reply" onclick="window.taskChats[${this.taskId}].cancelReply()">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="chat-input-area">
|
||||||
|
<textarea
|
||||||
|
class="chat-input"
|
||||||
|
id="chat-input-${this.taskId}"
|
||||||
|
placeholder="Напишите сообщение..."
|
||||||
|
rows="1"
|
||||||
|
></textarea>
|
||||||
|
<div class="chat-attachments" id="chat-attachments-${this.taskId}"></div>
|
||||||
|
<div class="chat-actions">
|
||||||
|
<button class="chat-send-btn" onclick="window.taskChats[${this.taskId}].sendMessage()">➤ Отправить ➤</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Добавляем стили для чата
|
||||||
|
this.addChatStyles();
|
||||||
|
|
||||||
|
// Добавляем модальное окно в DOM
|
||||||
|
const modalContainer = document.createElement('div');
|
||||||
|
modalContainer.innerHTML = modalHtml;
|
||||||
|
document.body.appendChild(modalContainer);
|
||||||
|
|
||||||
|
// Показываем модальное окно
|
||||||
|
setTimeout(() => {
|
||||||
|
const modal = document.getElementById(`task-chat-modal-${this.taskId}`);
|
||||||
|
modal.style.display = 'block';
|
||||||
|
|
||||||
|
// Фокус на поле ввода
|
||||||
|
document.getElementById(`chat-input-${this.taskId}`).focus();
|
||||||
|
|
||||||
|
// Настройка авто-изменения высоты textarea
|
||||||
|
this.setupTextareaAutoResize();
|
||||||
|
}, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
addChatStyles() {
|
||||||
|
if (document.getElementById('chat-styles')) return;
|
||||||
|
|
||||||
|
const styles = `
|
||||||
|
<style id="chat-styles">
|
||||||
|
.chat-modal-content {
|
||||||
|
max-width: 800px;
|
||||||
|
width: 90%;
|
||||||
|
height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 15px 20px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-unread-badge {
|
||||||
|
background-color: #ff4444;
|
||||||
|
color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
min-width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-refresh-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 18px;
|
||||||
|
padding: 4px 8px; /* Увеличили область клика */
|
||||||
|
border-radius: 4px; /* Скругленные углы при наведении */
|
||||||
|
transition: background-color 0.2s; /* Плавный переход цвета */
|
||||||
|
line-height: 1; /* Фиксируем высоту строки */
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-refresh-btn:hover {
|
||||||
|
background-color: rgba(0,0,0,0.05); /* Легкий серый фон при наведении */
|
||||||
|
opacity: 1; /* Убираем прозрачность или оставляем как есть */
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message {
|
||||||
|
max-width: 80%;
|
||||||
|
padding: 10px 15px;
|
||||||
|
border-radius: 15px;
|
||||||
|
position: relative;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-own {
|
||||||
|
align-self: flex-end;
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
border-bottom-right-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-other {
|
||||||
|
align-self: flex-start;
|
||||||
|
background-color: white;
|
||||||
|
color: #333;
|
||||||
|
border-bottom-left-radius: 5px;
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-own .chat-message-header {
|
||||||
|
color: rgba(255,255,255,0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-other .chat-message-header {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-author {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-time {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-edited {
|
||||||
|
font-size: 11px;
|
||||||
|
font-style: italic;
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-text {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-files {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-file {
|
||||||
|
background-color: rgba(0,0,0,0.05);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-own .chat-file {
|
||||||
|
background-color: rgba(255,255,255,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-file:hover {
|
||||||
|
background-color: rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-reply-info {
|
||||||
|
padding: 10px 20px;
|
||||||
|
background-color: #e9ecef;
|
||||||
|
border-top: 1px solid #dee2e6;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-cancel-reply {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-area {
|
||||||
|
border-top: 1px solid #dee2e6;
|
||||||
|
padding: 15px 20px;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 10px 15px;
|
||||||
|
font-size: 14px;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input:focus {
|
||||||
|
border-color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-attachments {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-attachment {
|
||||||
|
background-color: #e9ecef;
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-remove-attachment {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-actions {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-attach-btn {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 20px;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-send-btn {
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-send-btn:hover {
|
||||||
|
background-color: #0056b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-no-messages {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: #999;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.head.insertAdjacentHTML('beforeend', styles);
|
||||||
|
}
|
||||||
|
|
||||||
|
setupTextareaAutoResize() {
|
||||||
|
const textarea = document.getElementById(`chat-input-${this.taskId}`);
|
||||||
|
textarea.addEventListener('input', function() {
|
||||||
|
this.style.height = 'auto';
|
||||||
|
this.style.height = (this.scrollHeight) + 'px';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setupEventListeners() {
|
||||||
|
// Отправка по Enter (но не с Shift)
|
||||||
|
const textarea = document.getElementById(`chat-input-${this.taskId}`);
|
||||||
|
textarea.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.sendMessage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Загрузка файлов
|
||||||
|
const fileInput = document.getElementById(`chat-file-input-${this.taskId}`);
|
||||||
|
fileInput.addEventListener('change', (e) => {
|
||||||
|
this.handleFileSelect(e.target.files);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Бесконечная прокрутка для загрузки старых сообщений
|
||||||
|
const messagesContainer = document.getElementById(`chat-messages-${this.taskId}`);
|
||||||
|
messagesContainer.addEventListener('scroll', () => {
|
||||||
|
if (messagesContainer.scrollTop === 0 && !this.isLoading && this.hasMore) {
|
||||||
|
this.loadMoreMessages();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFileSelect(files) {
|
||||||
|
const attachmentsContainer = document.getElementById(`chat-attachments-${this.taskId}`);
|
||||||
|
attachmentsContainer.innerHTML = '';
|
||||||
|
|
||||||
|
this.selectedFiles = Array.from(files);
|
||||||
|
|
||||||
|
this.selectedFiles.forEach((file, index) => {
|
||||||
|
const attachment = document.createElement('div');
|
||||||
|
attachment.className = 'chat-attachment';
|
||||||
|
attachment.innerHTML = `
|
||||||
|
📎 ${file.name} (${this.formatFileSize(file.size)})
|
||||||
|
<button class="chat-remove-attachment" onclick="window.taskChats[${this.taskId}].removeAttachment(${index})">✕</button>
|
||||||
|
`;
|
||||||
|
attachmentsContainer.appendChild(attachment);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
removeAttachment(index) {
|
||||||
|
if (this.selectedFiles) {
|
||||||
|
this.selectedFiles.splice(index, 1);
|
||||||
|
this.handleFileSelect(this.selectedFiles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatFileSize(bytes) {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadMessages(before = null) {
|
||||||
|
if (this.isLoading) return;
|
||||||
|
|
||||||
|
this.isLoading = true;
|
||||||
|
const loadingEl = document.querySelector(`#chat-messages-list-${this.taskId} .chat-loading`);
|
||||||
|
if (loadingEl) loadingEl.style.display = 'block';
|
||||||
|
|
||||||
|
try {
|
||||||
|
let url = `/api/chat/tasks/${this.taskId}/messages?limit=30`;
|
||||||
|
if (before) {
|
||||||
|
url += `&before=${before}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.messages && data.messages.length > 0) {
|
||||||
|
if (before) {
|
||||||
|
// Добавляем старые сообщения в начало
|
||||||
|
this.messages = [...data.messages.reverse(), ...this.messages];
|
||||||
|
} else {
|
||||||
|
// Первая загрузка
|
||||||
|
this.messages = data.messages.reverse();
|
||||||
|
}
|
||||||
|
this.hasMore = data.hasMore;
|
||||||
|
this.renderMessages();
|
||||||
|
|
||||||
|
if (!before) {
|
||||||
|
// Прокручиваем вниз при первой загрузке
|
||||||
|
this.scrollToBottom();
|
||||||
|
} else {
|
||||||
|
// Сохраняем позицию прокрутки при загрузке старых сообщений
|
||||||
|
const container = document.getElementById(`chat-messages-${this.taskId}`);
|
||||||
|
const oldHeight = container.scrollHeight;
|
||||||
|
setTimeout(() => {
|
||||||
|
container.scrollTop = container.scrollHeight - oldHeight;
|
||||||
|
}, 10);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!before) {
|
||||||
|
this.renderEmpty();
|
||||||
|
}
|
||||||
|
this.hasMore = false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки сообщений:', error);
|
||||||
|
} finally {
|
||||||
|
this.isLoading = false;
|
||||||
|
if (loadingEl) loadingEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadMoreMessages() {
|
||||||
|
if (this.messages.length > 0) {
|
||||||
|
const oldestMessage = this.messages[0];
|
||||||
|
await this.loadMessages(oldestMessage.created_at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderMessages() {
|
||||||
|
const messagesList = document.getElementById(`chat-messages-list-${this.taskId}`);
|
||||||
|
messagesList.innerHTML = '<div class="chat-loading" style="display: none;">Загрузка сообщений...</div>';
|
||||||
|
|
||||||
|
if (this.messages.length === 0) {
|
||||||
|
this.renderEmpty();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.messages.forEach(message => {
|
||||||
|
const messageEl = this.createMessageElement(message);
|
||||||
|
messagesList.appendChild(messageEl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createMessageElement(message) {
|
||||||
|
const isOwn = message.user_id === this.currentUserId;
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = `chat-message ${isOwn ? 'chat-message-own' : 'chat-message-other'}`;
|
||||||
|
div.dataset.messageId = message.id;
|
||||||
|
|
||||||
|
const time = new Date(message.created_at).toLocaleString('ru-RU', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit'
|
||||||
|
});
|
||||||
|
|
||||||
|
let replyHtml = '';
|
||||||
|
if (message.reply_to_message) {
|
||||||
|
replyHtml = `
|
||||||
|
<div class="chat-message-reply">
|
||||||
|
↪ Ответ ${message.reply_to_user_name}: "${message.reply_to_message.substring(0, 30)}${message.reply_to_message.length > 30 ? '...' : ''}"
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let filesHtml = '';
|
||||||
|
if (message.files && message.files.length > 0) {
|
||||||
|
filesHtml = '<div class="chat-message-files">';
|
||||||
|
message.files.forEach(file => {
|
||||||
|
filesHtml += `
|
||||||
|
<div class="chat-file" onclick="window.taskChats[${this.taskId}].downloadFile(${file.id})">
|
||||||
|
📎 ${file.original_name} (${this.formatFileSize(file.file_size)})
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
filesHtml += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
let actionsHtml = '';
|
||||||
|
if (isOwn || window.currentUserRole === 'admin') {
|
||||||
|
actionsHtml = `
|
||||||
|
<div class="chat-message-actions">
|
||||||
|
${isOwn ? `<button onclick="window.taskChats[${this.taskId}].editMessage(${message.id})" title="Редактировать">✎</button>` : ''}
|
||||||
|
<button onclick="window.taskChats[${this.taskId}].deleteMessage(${message.id})" title="Удалить">🗑️</button>
|
||||||
|
<!-- <button onclick="window.taskChats[${this.taskId}].replyToMessage(${message.id}, '${message.user_name.replace(/'/g, "\\'")}', '${message.message.replace(/'/g, "\\'").substring(0, 30)}')" title="Ответить">↩</button> -->
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.innerHTML = `
|
||||||
|
${replyHtml}
|
||||||
|
<div class="chat-message-header">
|
||||||
|
<span class="chat-message-author">${message.user_name}</span>
|
||||||
|
<span class="chat-message-time">${time}</span>
|
||||||
|
${message.is_edited ? '<span class="chat-message-edited">(ред.)</span>' : ''}
|
||||||
|
${actionsHtml}
|
||||||
|
</div>
|
||||||
|
<div class="chat-message-text">${this.escapeHtml(message.message)}</div>
|
||||||
|
${filesHtml}
|
||||||
|
`;
|
||||||
|
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
|
escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderEmpty() {
|
||||||
|
const messagesList = document.getElementById(`chat-messages-list-${this.taskId}`);
|
||||||
|
messagesList.innerHTML = `
|
||||||
|
<div class="chat-no-messages">
|
||||||
|
💬 Нет сообщений. Напишите что-нибудь...
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage() {
|
||||||
|
const input = document.getElementById(`chat-input-${this.taskId}`);
|
||||||
|
const message = input.value.trim();
|
||||||
|
|
||||||
|
if (!message && (!this.selectedFiles || this.selectedFiles.length === 0)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('message', message);
|
||||||
|
|
||||||
|
if (this.replyToMessage) {
|
||||||
|
formData.append('reply_to_id', this.replyToMessage.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.selectedFiles && this.selectedFiles.length > 0) {
|
||||||
|
this.selectedFiles.forEach(file => {
|
||||||
|
formData.append('files', file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/chat/tasks/${this.taskId}/messages`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
input.value = '';
|
||||||
|
input.style.height = 'auto';
|
||||||
|
this.selectedFiles = [];
|
||||||
|
document.getElementById(`chat-attachments-${this.taskId}`).innerHTML = '';
|
||||||
|
this.cancelReply();
|
||||||
|
|
||||||
|
// Добавляем новое сообщение
|
||||||
|
this.messages.push(data.message);
|
||||||
|
this.renderMessages();
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка отправки сообщения:', error);
|
||||||
|
alert('Ошибка отправки сообщения');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async editMessage(messageId) {
|
||||||
|
const message = this.messages.find(m => m.id === messageId);
|
||||||
|
if (!message) return;
|
||||||
|
|
||||||
|
const newText = prompt('Редактировать сообщение:', message.message);
|
||||||
|
if (newText && newText.trim() !== message.message) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/chat/messages/${messageId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ message: newText.trim() })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
message.message = newText.trim();
|
||||||
|
message.is_edited = true;
|
||||||
|
this.renderMessages();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка редактирования:', error);
|
||||||
|
alert('Ошибка редактирования сообщения');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMessage(messageId) {
|
||||||
|
if (!confirm('Удалить это сообщение?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/chat/messages/${messageId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.messages = this.messages.filter(m => m.id !== messageId);
|
||||||
|
this.renderMessages();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка удаления:', error);
|
||||||
|
alert('Ошибка удаления сообщения');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
replyToMessage(messageId, userName, messagePreview) {
|
||||||
|
this.replyToMessage = {
|
||||||
|
id: messageId,
|
||||||
|
userName: userName,
|
||||||
|
preview: messagePreview
|
||||||
|
};
|
||||||
|
|
||||||
|
const replyInfo = document.getElementById(`chat-reply-info-${this.taskId}`);
|
||||||
|
document.getElementById(`reply-to-text-${this.taskId}`).textContent =
|
||||||
|
`${userName}: "${messagePreview}${messagePreview.length > 30 ? '...' : ''}"`;
|
||||||
|
replyInfo.style.display = 'flex';
|
||||||
|
|
||||||
|
document.getElementById(`chat-input-${this.taskId}`).focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelReply() {
|
||||||
|
this.replyToMessage = null;
|
||||||
|
document.getElementById(`chat-reply-info-${this.taskId}`).style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadFile(fileId) {
|
||||||
|
window.open(`/api/chat/files/${fileId}/download`, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollToBottom() {
|
||||||
|
const container = document.getElementById(`chat-messages-${this.taskId}`);
|
||||||
|
setTimeout(() => {
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
}, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
setupAutoRefresh() {
|
||||||
|
// Обновляем непрочитанные каждые 10 секунд
|
||||||
|
this.autoRefreshInterval = setInterval(() => {
|
||||||
|
this.updateUnreadCount();
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUnreadCount() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/chat/tasks/${this.taskId}/unread-count`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const badge = document.getElementById(`chat-unread-${this.taskId}`);
|
||||||
|
if (data.unread_count > 0) {
|
||||||
|
badge.textContent = data.unread_count;
|
||||||
|
badge.style.display = 'inline';
|
||||||
|
} else {
|
||||||
|
badge.style.display = 'none';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка обновления непрочитанных:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAllAsRead() {
|
||||||
|
try {
|
||||||
|
await fetch(`/api/chat/tasks/${this.taskId}/mark-read`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
document.getElementById(`chat-unread-${this.taskId}`).style.display = 'none';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка отметки прочитанных:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshMessages() {
|
||||||
|
this.messages = [];
|
||||||
|
this.loadMessages();
|
||||||
|
this.markAllAsRead();
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
if (this.autoRefreshInterval) {
|
||||||
|
clearInterval(this.autoRefreshInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
const modal = document.getElementById(`task-chat-modal-${this.taskId}`);
|
||||||
|
if (modal) {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
setTimeout(() => {
|
||||||
|
modal.parentElement.remove();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Глобальный объект для хранения экземпляров чатов
|
||||||
|
window.taskChats = window.taskChats || {};
|
||||||
|
|
||||||
|
// Функция для открытия чата (заменяет существующую openTaskChat)
|
||||||
|
function openTaskChat(taskId) {
|
||||||
|
// Находим задачу
|
||||||
|
const task = window.tasks?.find(t => t.id === taskId);
|
||||||
|
|
||||||
|
// Если уже есть открытый чат для этой задачи, просто показываем его
|
||||||
|
if (window.taskChats[taskId]) {
|
||||||
|
const existingModal = document.getElementById(`task-chat-modal-${taskId}`);
|
||||||
|
if (existingModal) {
|
||||||
|
existingModal.style.display = 'block';
|
||||||
|
window.taskChats[taskId].refreshMessages();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем новый экземпляр чата
|
||||||
|
window.taskChats[taskId] = new TaskChat(taskId, task ? task.title : `Задача #${taskId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для закрытия чата
|
||||||
|
function closeTaskChat(taskId) {
|
||||||
|
if (window.taskChats[taskId]) {
|
||||||
|
window.taskChats[taskId].close();
|
||||||
|
delete window.taskChats[taskId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация при загрузке страницы
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Получаем роль текущего пользователя для прав доступа
|
||||||
|
fetch('/api/user')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
window.currentUserRole = data.user.role;
|
||||||
|
})
|
||||||
|
.catch(error => console.error('Ошибка загрузки пользователя:', error));
|
||||||
|
});
|
||||||
1021
public/client.html
Normal file
1021
public/client.html
Normal file
File diff suppressed because it is too large
Load Diff
755
public/client.js
Normal file
755
public/client.js
Normal file
@@ -0,0 +1,755 @@
|
|||||||
|
// client.js – клиентская логика для работы с внешним API
|
||||||
|
|
||||||
|
// Глобальные переменные
|
||||||
|
let currentConnectionId = null;
|
||||||
|
let currentTasks = [];
|
||||||
|
let currentPage = 0;
|
||||||
|
let totalTasks = 0;
|
||||||
|
let pageSize = 50;
|
||||||
|
let currentTaskId = null;
|
||||||
|
let selectedFiles = [];
|
||||||
|
let importTaskData = null; // данные импортируемой задачи
|
||||||
|
let currentUserId = null; // ID текущего пользователя (для фильтрации)
|
||||||
|
|
||||||
|
// ==================== АВТОРИЗАЦИЯ ====================
|
||||||
|
|
||||||
|
async function checkAuth() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/user');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.user) {
|
||||||
|
document.getElementById('userName').textContent = data.user.name || data.user.login;
|
||||||
|
currentUserId = data.user.id; // сохраняем ID
|
||||||
|
} else {
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка проверки авторизации:', error);
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
try {
|
||||||
|
await fetch('/api/logout', { method: 'POST' });
|
||||||
|
window.location.href = '/';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при выходе:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== УПРАВЛЕНИЕ ПОДКЛЮЧЕНИЯМИ ====================
|
||||||
|
|
||||||
|
async function loadSavedConnections() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/client/connections');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const connectionsList = document.getElementById('connectionsList');
|
||||||
|
|
||||||
|
if (data.success && data.connections.length > 0) {
|
||||||
|
connectionsList.innerHTML = data.connections.map(conn => `
|
||||||
|
<div class="connection-item ${currentConnectionId === conn.id ? 'active' : ''}"
|
||||||
|
onclick="selectConnection('${conn.id}')">
|
||||||
|
<i class="fas fa-database"></i>
|
||||||
|
<span>${conn.name}</span>
|
||||||
|
<span class="remove-conn" onclick="event.stopPropagation(); removeConnection('${conn.id}')">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} else {
|
||||||
|
connectionsList.innerHTML = '<div class="connection-item">Нет сохраненных подключений</div>';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки подключений:', error);
|
||||||
|
document.getElementById('connectionsList').innerHTML =
|
||||||
|
'<div class="connection-item">Ошибка загрузки</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectConnection(connectionId) {
|
||||||
|
currentConnectionId = connectionId;
|
||||||
|
|
||||||
|
document.querySelectorAll('.connection-item').forEach(el => {
|
||||||
|
el.classList.remove('active');
|
||||||
|
});
|
||||||
|
event.currentTarget.classList.add('active');
|
||||||
|
|
||||||
|
document.getElementById('loadTasksBtn').disabled = false;
|
||||||
|
document.getElementById('refreshTasksBtn').disabled = false;
|
||||||
|
|
||||||
|
loadTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeConnection(connectionId) {
|
||||||
|
if (!confirm('Удалить это подключение?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/client/connections/${connectionId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
if (currentConnectionId === connectionId) {
|
||||||
|
currentConnectionId = null;
|
||||||
|
document.getElementById('loadTasksBtn').disabled = true;
|
||||||
|
document.getElementById('refreshTasksBtn').disabled = true;
|
||||||
|
document.getElementById('tasksContainer').innerHTML =
|
||||||
|
'<div class="loading"><i class="fas fa-plug"></i><p>Подключитесь к серверу</p></div>';
|
||||||
|
document.getElementById('tasksCount').textContent = '0';
|
||||||
|
document.getElementById('serverInfo').style.display = 'none';
|
||||||
|
}
|
||||||
|
loadSavedConnections();
|
||||||
|
showAlert('Подключение удалено', 'success');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка удаления подключения:', error);
|
||||||
|
showAlert('Ошибка при удалении подключения', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connect() {
|
||||||
|
const apiUrl = document.getElementById('apiUrl').value.trim();
|
||||||
|
const apiKey = document.getElementById('apiKey').value.trim();
|
||||||
|
|
||||||
|
if (!apiUrl || !apiKey) {
|
||||||
|
showAlert('Заполните URL сервиса и API ключ', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectBtn = document.getElementById('connectBtn');
|
||||||
|
connectBtn.disabled = true;
|
||||||
|
connectBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Подключение...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/client/connect', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ api_url: apiUrl, api_key: apiKey })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
showAlert('Подключение успешно установлено', 'success');
|
||||||
|
|
||||||
|
currentConnectionId = data.connection.id;
|
||||||
|
|
||||||
|
const serverInfo = document.getElementById('serverInfo');
|
||||||
|
serverInfo.innerHTML = `
|
||||||
|
<i class="fas fa-server"></i>
|
||||||
|
<strong>Сервер:</strong> ${data.connection.url}<br>
|
||||||
|
<strong>Пользователь:</strong> ${data.server_info.user}<br>
|
||||||
|
<strong>Задач на сервере:</strong> ${data.server_info.tasks_count}
|
||||||
|
`;
|
||||||
|
serverInfo.style.display = 'block';
|
||||||
|
|
||||||
|
loadSavedConnections();
|
||||||
|
|
||||||
|
document.getElementById('loadTasksBtn').disabled = false;
|
||||||
|
document.getElementById('refreshTasksBtn').disabled = false;
|
||||||
|
|
||||||
|
document.getElementById('apiUrl').value = '';
|
||||||
|
document.getElementById('apiKey').value = '';
|
||||||
|
|
||||||
|
loadTasks();
|
||||||
|
} else {
|
||||||
|
showAlert(data.error || 'Ошибка подключения', 'danger');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка подключения:', error);
|
||||||
|
showAlert('Ошибка при подключении к серверу', 'danger');
|
||||||
|
} finally {
|
||||||
|
connectBtn.disabled = false;
|
||||||
|
connectBtn.innerHTML = '<i class="fas fa-link"></i> Подключиться';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== ЗАГРУЗКА И ОТОБРАЖЕНИЕ ЗАДАЧ ====================
|
||||||
|
|
||||||
|
async function loadTasks() {
|
||||||
|
if (!currentConnectionId) {
|
||||||
|
showAlert('Сначала выберите подключение', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusFilter = document.getElementById('statusFilter').value;
|
||||||
|
const searchFilter = document.getElementById('searchFilter').value;
|
||||||
|
const limit = parseInt(document.getElementById('limitFilter').value);
|
||||||
|
|
||||||
|
pageSize = limit;
|
||||||
|
|
||||||
|
const tasksContainer = document.getElementById('tasksContainer');
|
||||||
|
tasksContainer.innerHTML = '<div class="loading"><i class="fas fa-circle-notch fa-spin"></i><p>Загрузка задач...</p></div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
let url = `/api/client/tasks?connection_id=${currentConnectionId}&limit=${limit}&offset=${currentPage * limit}`;
|
||||||
|
if (statusFilter) url += `&status=${statusFilter}`;
|
||||||
|
if (searchFilter) url += `&search=${encodeURIComponent(searchFilter)}`;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
currentTasks = data.tasks || [];
|
||||||
|
totalTasks = data.meta.total;
|
||||||
|
|
||||||
|
document.getElementById('tasksCount').textContent = totalTasks;
|
||||||
|
|
||||||
|
if (currentTasks.length === 0) {
|
||||||
|
tasksContainer.innerHTML = '<div class="no-tasks"><i class="fas fa-folder-open"></i><p>Задачи не найдены</p></div>';
|
||||||
|
} else {
|
||||||
|
renderTasks(currentTasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePagination();
|
||||||
|
} else {
|
||||||
|
tasksContainer.innerHTML = `<div class="no-tasks"><i class="fas fa-exclamation-triangle"></i><p>Ошибка: ${data.error || 'Неизвестная ошибка'}</p></div>`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки задач:', error);
|
||||||
|
tasksContainer.innerHTML = '<div class="no-tasks"><i class="fas fa-exclamation-triangle"></i><p>Ошибка загрузки задач</p></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTasks(tasks) {
|
||||||
|
const container = document.getElementById('tasksContainer');
|
||||||
|
|
||||||
|
container.innerHTML = tasks.map(task => {
|
||||||
|
const statusClass = `status-${task.assignment_status || 'default'}`;
|
||||||
|
const statusText = getStatusText(task.assignment_status);
|
||||||
|
|
||||||
|
const createdDate = task.created_at ? new Date(task.created_at).toLocaleString() : 'Н/Д';
|
||||||
|
const dueDate = task.due_date ? new Date(task.due_date).toLocaleString() : 'Нет срока';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="task-card" id="task-${task.id}">
|
||||||
|
<div class="task-header">
|
||||||
|
<div class="task-title">${escapeHtml(task.title || 'Без названия')}</div>
|
||||||
|
<div class="task-status ${statusClass}">${statusText}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="task-description">
|
||||||
|
${escapeHtml(task.description || 'Нет описания')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="task-meta">
|
||||||
|
<div class="task-meta-item">
|
||||||
|
<i class="fas fa-calendar-alt"></i>
|
||||||
|
<span>Создана: ${createdDate}</span>
|
||||||
|
</div>
|
||||||
|
<div class="task-meta-item">
|
||||||
|
<i class="fas fa-clock"></i>
|
||||||
|
<span>Срок: ${dueDate}</span>
|
||||||
|
</div>
|
||||||
|
<div class="task-meta-item">
|
||||||
|
<i class="fas fa-user"></i>
|
||||||
|
<span>Автор: ${escapeHtml(task.creator_name || 'Н/Д')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${renderFiles(task.files, task.id)}
|
||||||
|
|
||||||
|
<div class="task-actions">
|
||||||
|
${task.assignment_status !== 'completed' ? `
|
||||||
|
<button class="task-action-btn action-progress" onclick="updateTaskStatus('${task.id}', 'in_progress')">
|
||||||
|
<i class="fas fa-play"></i> В работу
|
||||||
|
</button>
|
||||||
|
<button class="task-action-btn action-complete" onclick="updateTaskStatus('${task.id}', 'completed')">
|
||||||
|
<i class="fas fa-check"></i> Завершить
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
<button class="task-action-btn action-upload" onclick="openUploadModal('${task.id}')">
|
||||||
|
<i class="fas fa-upload"></i> Файлы
|
||||||
|
</button>
|
||||||
|
<button class="task-action-btn action-import" onclick="importTask('${task.id}')">
|
||||||
|
<i class="fas fa-download"></i> Копировать локально
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFiles(files, taskId) {
|
||||||
|
if (!files || files.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="task-files">
|
||||||
|
<div class="files-title">
|
||||||
|
<i class="fas fa-paperclip"></i>
|
||||||
|
Файлы (${files.length})
|
||||||
|
</div>
|
||||||
|
<ul class="files-list">
|
||||||
|
${files.map(file => `
|
||||||
|
<li class="file-item">
|
||||||
|
<i class="fas fa-file file-icon"></i>
|
||||||
|
<span class="file-name" title="${escapeHtml(file.filename || file.original_name)}">
|
||||||
|
${escapeHtml(file.filename || file.original_name)}
|
||||||
|
</span>
|
||||||
|
<span class="file-size">${formatFileSize(file.file_size)}</span>
|
||||||
|
<a href="#" class="file-download" onclick="downloadFile('${taskId}', '${file.id}', '${escapeHtml(file.filename || file.original_name)}'); return false;">
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
`).join('')}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateTaskStatus(taskId, status) {
|
||||||
|
if (!currentConnectionId) return;
|
||||||
|
|
||||||
|
const comment = status === 'completed' ?
|
||||||
|
prompt('Введите комментарий к завершению (необязательно):') :
|
||||||
|
null;
|
||||||
|
|
||||||
|
const button = document.querySelector(`#task-${taskId} .action-${status === 'in_progress' ? 'progress' : 'complete'}`);
|
||||||
|
if (button) {
|
||||||
|
button.disabled = true;
|
||||||
|
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Обновление...';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/client/tasks/${taskId}/status?connection_id=${currentConnectionId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ status, comment })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
showAlert(`Статус задачи изменен на "${getStatusText(status)}"`, 'success');
|
||||||
|
loadTasks();
|
||||||
|
} else {
|
||||||
|
showAlert(data.error || 'Ошибка обновления статуса', 'danger');
|
||||||
|
if (button) {
|
||||||
|
button.disabled = false;
|
||||||
|
button.innerHTML = status === 'in_progress' ?
|
||||||
|
'<i class="fas fa-play"></i> В работу' :
|
||||||
|
'<i class="fas fa-check"></i> Завершить';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка обновления статуса:', error);
|
||||||
|
showAlert('Ошибка при обновлении статуса', 'danger');
|
||||||
|
if (button) {
|
||||||
|
button.disabled = false;
|
||||||
|
button.innerHTML = status === 'in_progress' ?
|
||||||
|
'<i class="fas fa-play"></i> В работу' :
|
||||||
|
'<i class="fas fa-check"></i> Завершить';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== ИМПОРТ ЗАДАЧИ В ЛОКАЛЬНУЮ CRM ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загружает задачу из внешнего сервиса и открывает модальное окно для импорта
|
||||||
|
*/
|
||||||
|
async function importTask(taskId) {
|
||||||
|
if (!currentConnectionId) {
|
||||||
|
showAlert('Сначала выберите подключение', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Import] Загрузка задачи ${taskId} из внешнего сервиса...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Получаем задачу из внешнего API
|
||||||
|
const response = await fetch(`/api/client/tasks/${taskId}?connection_id=${currentConnectionId}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.success || !data.task) {
|
||||||
|
showAlert('Не удалось получить задачу из внешнего сервиса', 'danger');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = data.task;
|
||||||
|
importTaskData = {
|
||||||
|
title: task.title,
|
||||||
|
description: task.description || '',
|
||||||
|
due_date: task.due_date || '',
|
||||||
|
task_type: task.task_type || 'regular',
|
||||||
|
files: task.files || []
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Загружаем список локальных пользователей (если ещё не загружен)
|
||||||
|
await loadLocalUsers();
|
||||||
|
|
||||||
|
// 3. Открываем модальное окно импорта
|
||||||
|
openImportModal(task);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка импорта задачи:', error);
|
||||||
|
showAlert('Ошибка при загрузке задачи', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загружает список локальных пользователей (используется в модальном окне)
|
||||||
|
*/
|
||||||
|
let localUsers = [];
|
||||||
|
async function loadLocalUsers() {
|
||||||
|
if (localUsers.length > 0) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/users');
|
||||||
|
if (response.ok) {
|
||||||
|
localUsers = await response.json();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки локальных пользователей:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Открывает модальное окно импорта задачи
|
||||||
|
*/
|
||||||
|
function openImportModal(task) {
|
||||||
|
const modal = document.getElementById('import-task-modal');
|
||||||
|
if (!modal) {
|
||||||
|
console.error('Модальное окно import-task-modal не найдено');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заполняем данные задачи
|
||||||
|
document.getElementById('import-task-title').textContent = task.title;
|
||||||
|
document.getElementById('import-task-description').textContent = task.description || 'Нет описания';
|
||||||
|
|
||||||
|
// Устанавливаем дату выполнения
|
||||||
|
const dueDateInput = document.getElementById('import-due-date');
|
||||||
|
if (task.due_date) {
|
||||||
|
const date = new Date(task.due_date);
|
||||||
|
dueDateInput.value = date.toISOString().slice(0, 16);
|
||||||
|
} else {
|
||||||
|
const defaultDate = new Date();
|
||||||
|
defaultDate.setDate(defaultDate.getDate() + 7);
|
||||||
|
dueDateInput.value = defaultDate.toISOString().slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Очищаем и заполняем список исполнителей
|
||||||
|
const container = document.getElementById('import-users-checklist');
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
// Фильтруем текущего пользователя (нельзя назначить самому себе)
|
||||||
|
const filteredUsers = localUsers.filter(u => u.id !== currentUserId);
|
||||||
|
|
||||||
|
filteredUsers.forEach(user => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'checkbox-item';
|
||||||
|
div.innerHTML = `
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" class="import-user-checkbox" value="${user.id}">
|
||||||
|
${escapeHtml(user.name)} (${escapeHtml(user.login)})
|
||||||
|
</label>
|
||||||
|
`;
|
||||||
|
container.appendChild(div);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Показываем модальное окно
|
||||||
|
modal.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Закрывает модальное окно импорта
|
||||||
|
*/
|
||||||
|
function closeImportModal() {
|
||||||
|
const modal = document.getElementById('import-task-modal');
|
||||||
|
if (modal) {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
}
|
||||||
|
importTaskData = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выполняет импорт задачи: создаёт локальную задачу с выбранными исполнителями
|
||||||
|
*/
|
||||||
|
async function confirmImport() {
|
||||||
|
if (!importTaskData) {
|
||||||
|
showAlert('Нет данных для импорта', 'danger');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dueDate = document.getElementById('import-due-date').value;
|
||||||
|
if (!dueDate) {
|
||||||
|
showAlert('Укажите дату выполнения', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Собираем выбранных исполнителей
|
||||||
|
const checkboxes = document.querySelectorAll('.import-user-checkbox:checked');
|
||||||
|
const assignedUsers = Array.from(checkboxes).map(cb => parseInt(cb.value));
|
||||||
|
|
||||||
|
|
||||||
|
if (assignedUsers.length === 0) {
|
||||||
|
showAlert('Выберите хотя бы одного исполнителя', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Формируем FormData для отправки
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('title', importTaskData.title);
|
||||||
|
formData.append('description', importTaskData.description);
|
||||||
|
formData.append('dueDate', dueDate);
|
||||||
|
formData.append('taskType', importTaskData.task_type);
|
||||||
|
formData.append('assignedUsers', JSON.stringify(assignedUsers));
|
||||||
|
console.log('FormData assignedUsers:', assignedUsers);
|
||||||
|
|
||||||
|
// Если есть файлы – добавляем их (но тут сложность: файлы нужно скачать из внешнего сервиса и загрузить)
|
||||||
|
// Пока пропустим файлы для простоты, можно добавить позже.
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/tasks', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
showAlert(`Задача успешно импортирована! Новый ID: ${result.taskId}`, 'success');
|
||||||
|
closeImportModal();
|
||||||
|
// Можно перезагрузить список локальных задач, если нужно
|
||||||
|
} else {
|
||||||
|
showAlert('Ошибка создания задачи: ' + (result.error || 'Неизвестная ошибка'), 'danger');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка импорта:', error);
|
||||||
|
showAlert('Ошибка при создании задачи', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== РАБОТА С ФАЙЛАМИ ====================
|
||||||
|
|
||||||
|
function openUploadModal(taskId) {
|
||||||
|
currentTaskId = taskId;
|
||||||
|
document.getElementById('uploadModal').classList.add('active');
|
||||||
|
selectedFiles = [];
|
||||||
|
document.getElementById('selectedFiles').style.display = 'none';
|
||||||
|
document.getElementById('filesList').innerHTML = '';
|
||||||
|
document.getElementById('uploadBtn').disabled = true;
|
||||||
|
document.getElementById('uploadProgress').style.display = 'none';
|
||||||
|
document.getElementById('progressBar').style.width = '0%';
|
||||||
|
document.getElementById('progressPercent').textContent = '0%';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeUploadModal() {
|
||||||
|
document.getElementById('uploadModal').classList.remove('active');
|
||||||
|
currentTaskId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileSelect() {
|
||||||
|
const files = document.getElementById('fileInput').files;
|
||||||
|
selectedFiles = Array.from(files);
|
||||||
|
|
||||||
|
if (selectedFiles.length > 0) {
|
||||||
|
const filesList = document.getElementById('filesList');
|
||||||
|
filesList.innerHTML = selectedFiles.map((file, index) => `
|
||||||
|
<div class="selected-file">
|
||||||
|
<i class="fas fa-file"></i>
|
||||||
|
<span class="file-name">${escapeHtml(file.name)}</span>
|
||||||
|
<span class="file-size">${formatFileSize(file.size)}</span>
|
||||||
|
<span class="remove-file" onclick="removeFile(${index})">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
document.getElementById('selectedFiles').style.display = 'block';
|
||||||
|
document.getElementById('uploadBtn').disabled = false;
|
||||||
|
} else {
|
||||||
|
document.getElementById('selectedFiles').style.display = 'none';
|
||||||
|
document.getElementById('uploadBtn').disabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFile(index) {
|
||||||
|
selectedFiles.splice(index, 1);
|
||||||
|
|
||||||
|
if (selectedFiles.length > 0) {
|
||||||
|
const filesList = document.getElementById('filesList');
|
||||||
|
filesList.innerHTML = selectedFiles.map((file, i) => `
|
||||||
|
<div class="selected-file">
|
||||||
|
<i class="fas fa-file"></i>
|
||||||
|
<span class="file-name">${escapeHtml(file.name)}</span>
|
||||||
|
<span class="file-size">${formatFileSize(file.size)}</span>
|
||||||
|
<span class="remove-file" onclick="removeFile(${i})">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} else {
|
||||||
|
document.getElementById('selectedFiles').style.display = 'none';
|
||||||
|
document.getElementById('uploadBtn').disabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadFiles() {
|
||||||
|
if (!currentConnectionId || !currentTaskId || selectedFiles.length === 0) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
selectedFiles.forEach(file => {
|
||||||
|
formData.append('files', file);
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadBtn = document.getElementById('uploadBtn');
|
||||||
|
const progressDiv = document.getElementById('uploadProgress');
|
||||||
|
const progressBar = document.getElementById('progressBar');
|
||||||
|
const progressPercent = document.getElementById('progressPercent');
|
||||||
|
|
||||||
|
uploadBtn.disabled = true;
|
||||||
|
progressDiv.style.display = 'block';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
|
||||||
|
xhr.upload.addEventListener('progress', (e) => {
|
||||||
|
if (e.lengthComputable) {
|
||||||
|
const percent = Math.round((e.loaded / e.total) * 100);
|
||||||
|
progressBar.style.width = percent + '%';
|
||||||
|
progressPercent.textContent = percent + '%';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener('load', () => {
|
||||||
|
if (xhr.status === 200) {
|
||||||
|
const data = JSON.parse(xhr.responseText);
|
||||||
|
if (data.success) {
|
||||||
|
showAlert(`Успешно загружено ${selectedFiles.length} файлов`, 'success');
|
||||||
|
closeUploadModal();
|
||||||
|
loadTasks();
|
||||||
|
} else {
|
||||||
|
showAlert(data.error || 'Ошибка загрузки файлов', 'danger');
|
||||||
|
uploadBtn.disabled = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showAlert('Ошибка загрузки файлов', 'danger');
|
||||||
|
uploadBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener('error', () => {
|
||||||
|
showAlert('Ошибка сети при загрузке', 'danger');
|
||||||
|
uploadBtn.disabled = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.open('POST', `/api/client/tasks/${currentTaskId}/files?connection_id=${currentConnectionId}`, true);
|
||||||
|
xhr.send(formData);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки файлов:', error);
|
||||||
|
showAlert('Ошибка при загрузке файлов', 'danger');
|
||||||
|
uploadBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFile(taskId, fileId, fileName) {
|
||||||
|
if (!currentConnectionId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/client/tasks/${taskId}/files/${fileId}/download?connection_id=${currentConnectionId}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Ошибка скачивания');
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = fileName;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
a.remove();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка скачивания файла:', error);
|
||||||
|
showAlert('Ошибка при скачивании файла', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== ПАГИНАЦИЯ ====================
|
||||||
|
|
||||||
|
function changePage(delta) {
|
||||||
|
const newPage = currentPage + delta;
|
||||||
|
if (newPage >= 0 && newPage * pageSize < totalTasks) {
|
||||||
|
currentPage = newPage;
|
||||||
|
loadTasks();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePagination() {
|
||||||
|
const totalPages = Math.ceil(totalTasks / pageSize);
|
||||||
|
|
||||||
|
if (totalPages > 1) {
|
||||||
|
document.getElementById('pagination').style.display = 'flex';
|
||||||
|
document.getElementById('pageInfo').textContent = `Страница ${currentPage + 1} из ${totalPages}`;
|
||||||
|
document.getElementById('prevPage').disabled = currentPage === 0;
|
||||||
|
document.getElementById('nextPage').disabled = currentPage >= totalPages - 1;
|
||||||
|
} else {
|
||||||
|
document.getElementById('pagination').style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== УВЕДОМЛЕНИЯ ====================
|
||||||
|
|
||||||
|
function showAlert(message, type) {
|
||||||
|
const alert = document.getElementById('alert');
|
||||||
|
alert.className = `alert alert-${type} show`;
|
||||||
|
alert.innerHTML = message;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
alert.classList.remove('show');
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ====================
|
||||||
|
|
||||||
|
function getStatusText(status) {
|
||||||
|
const statusMap = {
|
||||||
|
'assigned': 'Назначена',
|
||||||
|
'in_progress': 'В работе',
|
||||||
|
'completed': 'Выполнена',
|
||||||
|
'overdue': 'Просрочена',
|
||||||
|
'rework': 'На доработке'
|
||||||
|
};
|
||||||
|
return statusMap[status] || status || 'Неизвестно';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes) {
|
||||||
|
if (bytes === 0) return '0 Б';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Б', 'КБ', 'МБ', 'ГБ'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(unsafe) {
|
||||||
|
if (!unsafe) return '';
|
||||||
|
return unsafe
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== ИНИЦИАЛИЗАЦИЯ ====================
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
checkAuth();
|
||||||
|
loadSavedConnections();
|
||||||
|
});
|
||||||
260
public/doc-getUserGroups.js
Normal file
260
public/doc-getUserGroups.js
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
async function getUserGroups(userId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api2/idusers/user/${userId}/groups`);
|
||||||
|
if (!response.ok) throw new Error('Ошибка получения групп пользователя');
|
||||||
|
|
||||||
|
const groups = await response.json();
|
||||||
|
return groups;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка получения групп пользователя:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем ID текущего пользователя и загружаем его группы
|
||||||
|
async function loadCurrentUserGroups() {
|
||||||
|
try {
|
||||||
|
// Получаем информацию о текущем пользователе
|
||||||
|
const userResponse = await fetch('/api/user');
|
||||||
|
if (!userResponse.ok) {
|
||||||
|
console.error('Ошибка получения информации о пользователе');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const userData = await userResponse.json();
|
||||||
|
if (!userData.user) {
|
||||||
|
console.error('Пользователь не аутентифицирован');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUserId = userData.user.id;
|
||||||
|
console.log('Текущий пользователь:', userData.user);
|
||||||
|
|
||||||
|
// Получаем группы текущего пользователя
|
||||||
|
const groups = await getUserGroups(currentUserId);
|
||||||
|
console.log('Группы текущего пользователя:', groups);
|
||||||
|
|
||||||
|
// Преобразуем массив строк в массив объектов
|
||||||
|
const groupsAsObjects = groups.map(groupName => ({
|
||||||
|
name: groupName,
|
||||||
|
description: '',
|
||||||
|
service_type: 'ldap' // Предполагаем LDAP, так как группы из LDAP
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log('Группы как объекты:', groupsAsObjects);
|
||||||
|
|
||||||
|
// Создаем плавающую кнопку если нужно
|
||||||
|
createAdminFloatingButton(groupsAsObjects);
|
||||||
|
|
||||||
|
return groupsAsObjects;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки групп текущего пользователя:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация при загрузке страницы
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Проверяем авторизацию и загружаем группы
|
||||||
|
checkAuthAndLoadGroups();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Функция проверки авторизации и загрузки групп
|
||||||
|
async function checkAuthAndLoadGroups() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/user');
|
||||||
|
if (!response.ok) {
|
||||||
|
console.log('Пользователь не аутентифицирован');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.user) {
|
||||||
|
// Загружаем группы текущего пользователя
|
||||||
|
loadCurrentUserGroups();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка проверки авторизации:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создание плавающей кнопки администрации
|
||||||
|
function createAdminFloatingButton(groups) {
|
||||||
|
// Проверяем, есть ли у пользователя группа "LDAP - Администрация"
|
||||||
|
const hasAdminGroup = groups.some(group => {
|
||||||
|
if (!group || typeof group !== 'object') {
|
||||||
|
console.warn('Некорректный формат группы:', group);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return group.name === 'LDAP - Администрация' ||
|
||||||
|
(group.name && group.name.includes('Администрация'));
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Есть группа администрации:', hasAdminGroup);
|
||||||
|
console.log('Все группы для проверки:', groups);
|
||||||
|
|
||||||
|
if (!hasAdminGroup) return;
|
||||||
|
|
||||||
|
// Удаляем старую кнопку если существует
|
||||||
|
const existingButton = document.getElementById('adminFloatingButton');
|
||||||
|
if (existingButton) existingButton.remove();
|
||||||
|
|
||||||
|
// Создаем плавающую кнопку
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.id = 'adminFloatingButton';
|
||||||
|
button.className = 'admin-floating-button';
|
||||||
|
button.innerHTML = `
|
||||||
|
<i class="fas fa-users-cog"></i>
|
||||||
|
<span>Администрация</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Добавляем обработчик клика
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
showAdminGroupsModal(groups);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Добавляем кнопку в body
|
||||||
|
document.body.appendChild(button);
|
||||||
|
|
||||||
|
console.log('Плавающая кнопка создана');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для отображения модального окна с группами
|
||||||
|
function showAdminGroupsModal(groups) {
|
||||||
|
// Удаляем старое модальное окно если существует
|
||||||
|
const existingModal = document.getElementById('adminGroupsModal');
|
||||||
|
if (existingModal) existingModal.remove();
|
||||||
|
|
||||||
|
// Создаем модальное окно
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.id = 'adminGroupsModal';
|
||||||
|
modal.className = 'admin-modal';
|
||||||
|
|
||||||
|
// Формируем содержимое
|
||||||
|
let groupsHTML = '';
|
||||||
|
|
||||||
|
if (groups.length === 0) {
|
||||||
|
groupsHTML = `
|
||||||
|
<div class="no-groups">
|
||||||
|
<i class="fas fa-info-circle" style="font-size: 48px; margin-bottom: 15px; color: #cbd5e0;"></i>
|
||||||
|
<p>Пользователь не состоит ни в одной группе</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
groupsHTML = '<div class="admin-group-list">';
|
||||||
|
groups.forEach(group => {
|
||||||
|
const isAdminGroup = group.name.includes('Администрация');
|
||||||
|
groupsHTML += `
|
||||||
|
<div class="admin-group-item ${isAdminGroup ? 'admin-highlight' : ''}">
|
||||||
|
<div class="admin-group-name">
|
||||||
|
${group.name}
|
||||||
|
${isAdminGroup ? ' <i class="fas fa-crown" style="color: #f59e0b;"></i>' : ''}
|
||||||
|
</div>
|
||||||
|
<div class="admin-group-info">
|
||||||
|
<span>${group.description || 'Нет описания'}</span>
|
||||||
|
<span class="admin-group-type">${getServiceTypeName(group.service_type)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
groupsHTML += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="admin-modal-content">
|
||||||
|
<div class="admin-modal-header">
|
||||||
|
<div class="admin-modal-title">
|
||||||
|
<i class="fas fa-users"></i>
|
||||||
|
Мои группы
|
||||||
|
</div>
|
||||||
|
<button class="admin-modal-close" onclick="closeAdminGroupsModal()">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="admin-modal-body">
|
||||||
|
${groupsHTML}
|
||||||
|
</div>
|
||||||
|
<div class="admin-modal-footer">
|
||||||
|
Всего групп: ${groups.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Добавляем стиль для выделения группы администрации
|
||||||
|
if (!document.querySelector('#admin-highlight-style')) {
|
||||||
|
const highlightStyle = document.createElement('style');
|
||||||
|
highlightStyle.id = 'admin-highlight-style';
|
||||||
|
highlightStyle.textContent = `
|
||||||
|
.admin-highlight {
|
||||||
|
border-left: 4px solid #667eea;
|
||||||
|
background: linear-gradient(to right, #f8fafc, #f0f9ff) !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(highlightStyle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем модальное окно в body
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
// Показываем модальное окно
|
||||||
|
setTimeout(() => {
|
||||||
|
modal.classList.add('active');
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
// Закрытие по клику на фон
|
||||||
|
modal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modal) {
|
||||||
|
closeAdminGroupsModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Закрытие по клавише Esc
|
||||||
|
document.addEventListener('keydown', function closeOnEsc(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeAdminGroupsModal();
|
||||||
|
document.removeEventListener('keydown', closeOnEsc);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для закрытия модального окна
|
||||||
|
function closeAdminGroupsModal() {
|
||||||
|
const modal = document.getElementById('adminGroupsModal');
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.remove('active');
|
||||||
|
setTimeout(() => {
|
||||||
|
modal.remove();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вспомогательная функция для получения имени типа сервиса
|
||||||
|
function getServiceTypeName(type) {
|
||||||
|
switch(type) {
|
||||||
|
case 'sberbank': return 'Сбербанк';
|
||||||
|
case 'yandex': return 'Яндекс';
|
||||||
|
case 'ldap': return 'LDAP';
|
||||||
|
default: return 'Прочие';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тестовая функция для отладки (удалите в продакшене)
|
||||||
|
async function testCurrentUserGroups() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/user');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('Текущий пользователь:', data.user);
|
||||||
|
|
||||||
|
if (data.user) {
|
||||||
|
const groups = await getUserGroups(data.user.id);
|
||||||
|
console.log('Группы текущего пользователя:', groups);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Тестовая ошибка:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запускаем тест при загрузке (для отладки)
|
||||||
|
// testCurrentUserGroups();
|
||||||
525
public/doc.css
Normal file
525
public/doc.css
Normal file
@@ -0,0 +1,525 @@
|
|||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 30px 0;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 15px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-role {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn {
|
||||||
|
background: #ff4757;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 10px;
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn:hover {
|
||||||
|
background: #ff3742;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
flex: 1;
|
||||||
|
padding: 15px 20px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
background: white;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active {
|
||||||
|
display: block;
|
||||||
|
animation: fadeIn 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 300px;
|
||||||
|
padding: 10px 15px;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.3s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #5a67d8;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background: #48bb78;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success:hover {
|
||||||
|
background: #38a169;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #f56565;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: #e53e3e;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #a0aec0;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #718096;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select {
|
||||||
|
padding: 10px 15px;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: white;
|
||||||
|
font-size: 1rem;
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 15px;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #4a5568;
|
||||||
|
border-bottom: 2px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 15px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-sberbank {
|
||||||
|
background: #1c8b3f;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-yandex {
|
||||||
|
background: #fc3f1d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-ldap {
|
||||||
|
background: #4c6ef5;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-other {
|
||||||
|
background: #868e96;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-active {
|
||||||
|
color: #48bb78;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-inactive {
|
||||||
|
color: #f56565;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 1000;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 800px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
animation: modalSlideIn 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modalSlideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-50px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
padding: 20px;
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
border-radius: 10px 10px 0 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-modal {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 15px;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row .form-group {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-editor {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
min-height: 100px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn {
|
||||||
|
padding: 8px 15px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
background: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn:hover:not(:disabled) {
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #667eea;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
color: #718096;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: #718096;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-data {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: #a0aec0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: #fed7d7;
|
||||||
|
color: #9b2c2c;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-left: 4px solid #fc8181;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
background: #c6f6d5;
|
||||||
|
color: #276749;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-left: 4px solid #68d391;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-preview {
|
||||||
|
max-width: 300px;
|
||||||
|
max-height: 100px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-full {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header-content {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
962
public/doc.html
Normal file
962
public/doc.html
Normal file
@@ -0,0 +1,962 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Панель управления - Группы и идентификаторы</title>
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--primary: #3b82f6;
|
||||||
|
--primary-dark: #2563eb;
|
||||||
|
--primary-light: #60a5fa;
|
||||||
|
--secondary: #6b7280;
|
||||||
|
--success: #10b981;
|
||||||
|
--danger: #ef4444;
|
||||||
|
--warning: #f59e0b;
|
||||||
|
--info: #06b6d4;
|
||||||
|
--light: #f9fafb;
|
||||||
|
--dark: #111827;
|
||||||
|
--border: #e5e7eb;
|
||||||
|
--shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
--radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
color: var(--dark);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px 0;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
border-bottom: 2px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-details {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-role {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-logout {
|
||||||
|
background-color: var(--danger);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-logout:hover {
|
||||||
|
background-color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 2px solid var(--border);
|
||||||
|
margin-bottom: 30px;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
border-radius: var(--radius var(--radius) 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
background-color: #f0f9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
color: var(--primary);
|
||||||
|
border-bottom-color: var(--primary);
|
||||||
|
background-color: #f0f9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab Content */
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
animation: fadeIn 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toolbar */
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-container {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 300px;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 15px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 16px;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select {
|
||||||
|
padding: 10px 15px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 14px;
|
||||||
|
background-color: white;
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: var(--primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background-color: var(--success);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success:hover {
|
||||||
|
background-color: #0da271;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background-color: var(--danger);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background-color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
.table-container {
|
||||||
|
background-color: white;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: #f8fafc;
|
||||||
|
padding: 15px;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--dark);
|
||||||
|
border-bottom: 2px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 15px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading, .error, .no-data {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px !important;
|
||||||
|
color: var(--secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading i {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-sberbank {
|
||||||
|
background-color: #048b46;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-yandex {
|
||||||
|
background-color: #fc3f1d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-ldap {
|
||||||
|
background-color: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-other {
|
||||||
|
background-color: #6b7280;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status */
|
||||||
|
.status-active {
|
||||||
|
color: var(--success);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-inactive {
|
||||||
|
color: var(--danger);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Metadata Preview */
|
||||||
|
.metadata-preview {
|
||||||
|
background-color: #f0f9ff;
|
||||||
|
border: 1px solid var(--primary-light);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-preview:hover {
|
||||||
|
background-color: #dbeafe;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pagination */
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn {
|
||||||
|
padding: 8px 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background-color: white;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn:hover:not(:disabled) {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn.active {
|
||||||
|
background-color: var(--primary);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modals */
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 1000;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
animation: fadeIn 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background-color: white;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
width: 90%;
|
||||||
|
max-width: 800px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
animation: slideIn 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from { opacity: 0; transform: translateY(-50px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
padding: 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 24px;
|
||||||
|
color: var(--secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: 20px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 15px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 16px;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.form-control {
|
||||||
|
min-height: 100px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group input[type="checkbox"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
background-color: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
border: 1px solid #a7f3d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background-color: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats */
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background-color: white;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--secondary);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-container {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
min-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
width: 95%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="header">
|
||||||
|
<div class="logo">
|
||||||
|
<i class="fas fa-users-cog"></i>
|
||||||
|
<span>Панель управления</span>
|
||||||
|
</div>
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="user-details">
|
||||||
|
<div class="user-name" id="userName">Загрузка...</div>
|
||||||
|
<div class="user-role" id="userRole">Роль: загрузка...</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn-logout" onclick="logout()">
|
||||||
|
<i class="fas fa-sign-out-alt"></i>
|
||||||
|
Выйти
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="tabs">
|
||||||
|
<button class="tab active" data-tab="groups">
|
||||||
|
<i class="fas fa-layer-group"></i>
|
||||||
|
Группы
|
||||||
|
</button>
|
||||||
|
<button class="tab" data-tab="idusers">
|
||||||
|
<i class="fas fa-id-card"></i>
|
||||||
|
Идентификаторы
|
||||||
|
</button>
|
||||||
|
<button class="tab" data-tab="stats">
|
||||||
|
<i class="fas fa-chart-bar"></i>
|
||||||
|
Статистика
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab: Groups -->
|
||||||
|
<div id="tab-groups" class="tab-content active">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="search-container">
|
||||||
|
<input type="text" id="groupSearch" class="search-input" placeholder="Поиск по названию или описанию...">
|
||||||
|
</div>
|
||||||
|
<div class="filters">
|
||||||
|
<select id="groupServiceTypeFilter" class="filter-select">
|
||||||
|
<option value="">Все типы сервисов</option>
|
||||||
|
<option value="sberbank">Сбербанк</option>
|
||||||
|
<option value="yandex">Яндекс</option>
|
||||||
|
<option value="ldap">LDAP</option>
|
||||||
|
<option value="other">Прочие</option>
|
||||||
|
</select>
|
||||||
|
<select id="groupStatusFilter" class="filter-select">
|
||||||
|
<option value="">Все статусы</option>
|
||||||
|
<option value="true">Активные</option>
|
||||||
|
<option value="false">Неактивные</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-success" onclick="showAddGroupModal()">
|
||||||
|
<i class="fas fa-plus"></i>
|
||||||
|
Добавить группу
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Тип сервиса</th>
|
||||||
|
<th>Описание</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Дата создания</th>
|
||||||
|
<th>Дата обновления</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="groupsTableBody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" class="loading">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
|
Загрузка групп...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pagination" id="groupsPagination"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab: IdUsers -->
|
||||||
|
<div id="tab-idusers" class="tab-content">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="search-container">
|
||||||
|
<input type="text" id="iduserSearch" class="search-input" placeholder="Поиск по ID, логину или имени...">
|
||||||
|
</div>
|
||||||
|
<div class="filters">
|
||||||
|
<select id="iduserServiceTypeFilter" class="filter-select">
|
||||||
|
<option value="">Все типы сервисов</option>
|
||||||
|
<option value="sberbank">Сбербанк</option>
|
||||||
|
<option value="yandex">Яндекс</option>
|
||||||
|
<option value="ldap">LDAP</option>
|
||||||
|
<option value="other">Прочие</option>
|
||||||
|
</select>
|
||||||
|
<select id="iduserGroupFilter" class="filter-select">
|
||||||
|
<option value="">Все группы</option>
|
||||||
|
<!-- Динамически заполняется -->
|
||||||
|
</select>
|
||||||
|
<select id="iduserStatusFilter" class="filter-select">
|
||||||
|
<option value="">Все статусы</option>
|
||||||
|
<option value="true">Активные</option>
|
||||||
|
<option value="false">Неактивные</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-success" onclick="showAddIdUserModal()">
|
||||||
|
<i class="fas fa-plus"></i>
|
||||||
|
Добавить идентификатор
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Пользователь</th>
|
||||||
|
<th>Тип сервиса</th>
|
||||||
|
<th>Внешний ID</th>
|
||||||
|
<th>Логин</th>
|
||||||
|
<th>Группа LDAP</th>
|
||||||
|
<th>Группа</th>
|
||||||
|
<th>Метаданные</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Дата создания</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="idusersTableBody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="11" class="loading">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
|
Загрузка идентификаторов...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pagination" id="idusersPagination"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab: Stats -->
|
||||||
|
<div id="tab-stats" class="tab-content">
|
||||||
|
<div class="stats-grid" id="statsGrid">
|
||||||
|
<div class="loading">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
|
Загрузка статистики...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Тип сервиса</th>
|
||||||
|
<th>Всего идентификаторов</th>
|
||||||
|
<th>Активных</th>
|
||||||
|
<th>Уникальных пользователей</th>
|
||||||
|
<th>Доля</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="statsTableBody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="loading">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
|
Загрузка данных...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal: Add/Edit Group -->
|
||||||
|
<div id="groupModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 class="modal-title" id="groupModalTitle">Добавить группу</h3>
|
||||||
|
<button class="modal-close" onclick="closeGroupModal()">×</button>
|
||||||
|
</div>
|
||||||
|
<form id="groupForm" onsubmit="saveGroup(event)">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="groupMessage"></div>
|
||||||
|
<input type="hidden" id="groupId" value="">
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="groupName">Название группы *</label>
|
||||||
|
<input type="text" id="groupName" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="groupServiceType">Тип сервиса *</label>
|
||||||
|
<select id="groupServiceType" class="form-control" required>
|
||||||
|
<option value="sberbank">Сбербанк</option>
|
||||||
|
<option value="yandex">Яндекс</option>
|
||||||
|
<option value="ldap">LDAP</option>
|
||||||
|
<option value="other">Прочие</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="groupDescription">Описание</label>
|
||||||
|
<textarea id="groupDescription" class="form-control" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="groupIsActive" class="checkbox-group">
|
||||||
|
<input type="checkbox" id="groupIsActive" checked>
|
||||||
|
Активная группа
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn" onclick="closeGroupModal()">Отмена</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal: Add/Edit IdUser -->
|
||||||
|
<div id="iduserModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 class="modal-title" id="iduserModalTitle">Добавить идентификатор</h3>
|
||||||
|
<button class="modal-close" onclick="closeIdUserModal()">×</button>
|
||||||
|
</div>
|
||||||
|
<form id="iduserForm" onsubmit="saveIdUser(event)">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="iduserMessage"></div>
|
||||||
|
<input type="hidden" id="iduserId" value="">
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="iduserUserId">Пользователь *</label>
|
||||||
|
<select id="iduserUserId" class="form-control" required>
|
||||||
|
<option value="">Выберите пользователя</option>
|
||||||
|
<!-- Динамически заполняется -->
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="iduserServiceType">Тип сервиса *</label>
|
||||||
|
<select id="iduserServiceType" class="form-control" required>
|
||||||
|
<option value="ldap" selected>LDAP</option>
|
||||||
|
<option value="sberbank">Сбербанк</option>
|
||||||
|
<option value="yandex">Яндекс</option>
|
||||||
|
<option value="other">Прочие</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="iduserExternalId">Внешний ID (необязательный)</label>
|
||||||
|
<input type="text" id="iduserExternalId" class="form-control" placeholder="Внешний идентификатор (необязательно)">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="iduserLogin">Логин</label>
|
||||||
|
<input type="text" id="iduserLogin" class="form-control" placeholder="Логин пользователя">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="iduserLdapGroup">Группа LDAP</label>
|
||||||
|
<input type="text" id="iduserLdapGroup" class="form-control" placeholder="Группа LDAP">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="iduserGroupId">Группа идентификаторов</label>
|
||||||
|
<select id="iduserGroupId" class="form-control">
|
||||||
|
<option value="">Без группы</option>
|
||||||
|
<!-- Динамически заполняется -->
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="iduserIsActive" class="checkbox-group">
|
||||||
|
<input type="checkbox" id="iduserIsActive" checked>
|
||||||
|
Активный идентификатор
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="grid-column: span 2;">
|
||||||
|
<label for="iduserMetadata">Метаданные (JSON)</label>
|
||||||
|
<textarea id="iduserMetadata" class="form-control" rows="6" placeholder='{"ключ": "значение"}'></textarea>
|
||||||
|
<small style="color: var(--secondary); margin-top: 5px; display: block;">
|
||||||
|
Дополнительные данные в формате JSON (необязательно)
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn" onclick="closeIdUserModal()">Отмена</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal: Confirm Delete -->
|
||||||
|
<div id="confirmModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 class="modal-title" id="confirmModalTitle">Подтверждение удаления</h3>
|
||||||
|
<button class="modal-close" onclick="closeConfirmModal()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p id="confirmMessage">Вы уверены, что хотите удалить этот элемент?</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn" onclick="closeConfirmModal()">Отмена</button>
|
||||||
|
<button type="button" class="btn btn-danger" onclick="confirmDelete()">Удалить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal: View Metadata -->
|
||||||
|
<div id="metadataModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 class="modal-title">Метаданные</h3>
|
||||||
|
<button class="modal-close" onclick="closeMetadataModal()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<pre id="metadataContent" style="background: #f8fafc; padding: 15px; border-radius: var(--radius); overflow: auto; max-height: 500px; font-family: monospace;"></pre>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn" onclick="closeMetadataModal()">Закрыть</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="doc.js"></script>
|
||||||
|
<script src="doc-getUserGroups.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1093
public/doc.js
Normal file
1093
public/doc.js
Normal file
File diff suppressed because it is too large
Load Diff
1478
public/document-fields.js
Normal file
1478
public/document-fields.js
Normal file
File diff suppressed because it is too large
Load Diff
428
public/files.js
Normal file
428
public/files.js
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
// files.js - Работа с файлами
|
||||||
|
let currentTaskFiles = [];
|
||||||
|
let currentEditTaskFiles = [];
|
||||||
|
|
||||||
|
function initializeFileUploads() {
|
||||||
|
// Создание задачи
|
||||||
|
document.getElementById('files').addEventListener('change', function(e) {
|
||||||
|
currentTaskFiles = Array.from(e.target.files);
|
||||||
|
updateFileList();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Редактирование задачи
|
||||||
|
document.getElementById('edit-files').addEventListener('change', function(e) {
|
||||||
|
const newFiles = Array.from(e.target.files);
|
||||||
|
currentEditTaskFiles.push(...newFiles);
|
||||||
|
updateEditFileList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFileList() {
|
||||||
|
const fileInput = document.getElementById('files');
|
||||||
|
const fileList = document.getElementById('file-list');
|
||||||
|
updateFileListForInput(fileInput, fileList);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEditFileList() {
|
||||||
|
const fileInput = document.getElementById('edit-files');
|
||||||
|
const fileList = document.getElementById('edit-file-list');
|
||||||
|
|
||||||
|
// Используем улучшенный рендеринг файлов
|
||||||
|
const files = fileInput.files;
|
||||||
|
const existingFiles = currentEditTaskFiles.filter(file => !(file instanceof File));
|
||||||
|
|
||||||
|
if (files.length === 0 && existingFiles.length === 0) {
|
||||||
|
fileList.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<ul>';
|
||||||
|
let totalSize = 0;
|
||||||
|
|
||||||
|
// Существующие файлы
|
||||||
|
existingFiles.forEach(file => {
|
||||||
|
totalSize += file.file_size;
|
||||||
|
html += `<li>${file.original_name} (${(file.file_size / 1024 / 1024).toFixed(2)} MB) - <em>уже загружен</em></li>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Новые файлы
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const file = files[i];
|
||||||
|
totalSize += file.size;
|
||||||
|
html += `<li>${file.name} (${(file.size / 1024 / 1024).toFixed(2)} MB) - <em>новый</em></li>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</ul>';
|
||||||
|
html += `<p><strong>Общий размер: ${(totalSize / 1024 / 1024).toFixed(2)} MB / 300 MB</strong></p>`;
|
||||||
|
|
||||||
|
fileList.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFileListForInput(fileInput, fileList) {
|
||||||
|
const files = fileInput.files;
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
fileList.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<ul>';
|
||||||
|
let totalSize = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const file = files[i];
|
||||||
|
totalSize += file.size;
|
||||||
|
html += `<li>${file.name} (${(file.size / 1024 / 1024).toFixed(2)} MB)</li>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</ul>';
|
||||||
|
html += `<p><strong>Общий размер: ${(totalSize / 1024 / 1024).toFixed(2)} MB / 300 MB</strong></p>`;
|
||||||
|
|
||||||
|
fileList.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаление файлов из списка
|
||||||
|
function removeFile(index) {
|
||||||
|
currentTaskFiles.splice(index, 1);
|
||||||
|
updateFileList();
|
||||||
|
|
||||||
|
// Обновляем input files
|
||||||
|
const dataTransfer = new DataTransfer();
|
||||||
|
currentTaskFiles.forEach(file => dataTransfer.items.add(file));
|
||||||
|
document.getElementById('files').files = dataTransfer.files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeEditFile(index) {
|
||||||
|
currentEditTaskFiles.splice(index, 1);
|
||||||
|
updateEditFileList();
|
||||||
|
|
||||||
|
// Обновляем input files
|
||||||
|
const dataTransfer = new DataTransfer();
|
||||||
|
const newFiles = currentEditTaskFiles.filter(file => file instanceof File);
|
||||||
|
newFiles.forEach(file => dataTransfer.items.add(file));
|
||||||
|
document.getElementById('edit-files').files = dataTransfer.files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFileIcon(file) {
|
||||||
|
// Исправляем кодировку имени файла
|
||||||
|
const fixEncoding = (str) => {
|
||||||
|
if (!str) return '';
|
||||||
|
try {
|
||||||
|
// Пробуем разные способы декодирования
|
||||||
|
if (str.includes('Ð') || str.includes('Ñ')) {
|
||||||
|
// UTF-8 неправильно декодированный как Latin-1
|
||||||
|
return decodeURIComponent(escape(str));
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
} catch (e) {
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileName = fixEncoding(file.original_name);
|
||||||
|
const fileSize = (file.file_size / 1024 / 1024).toFixed(2);
|
||||||
|
const uploadedBy = file.user_name;
|
||||||
|
|
||||||
|
let iconColor = '';
|
||||||
|
let iconText = '';
|
||||||
|
let textClass = '';
|
||||||
|
|
||||||
|
// Определяем расширение файла
|
||||||
|
const extension = fileName.includes('.') ?
|
||||||
|
fileName.split('.').pop().toLowerCase() :
|
||||||
|
'';
|
||||||
|
|
||||||
|
// Определяем тип файла на основе расширения
|
||||||
|
if (extension) {
|
||||||
|
switch (extension) {
|
||||||
|
case 'pdf':
|
||||||
|
iconColor = '#e74c3c';
|
||||||
|
iconText = 'PDF';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'doc':
|
||||||
|
iconColor = '#3498db';
|
||||||
|
iconText = 'DOC';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'docx':
|
||||||
|
iconColor = '#3498db';
|
||||||
|
iconText = 'DOCX';
|
||||||
|
textClass = 'medium';
|
||||||
|
break;
|
||||||
|
case 'xls':
|
||||||
|
iconColor = '#2ecc71';
|
||||||
|
iconText = 'XLS';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'xlsx':
|
||||||
|
iconColor = '#2ecc71';
|
||||||
|
iconText = 'XLSX';
|
||||||
|
textClass = 'medium';
|
||||||
|
break;
|
||||||
|
case 'csv':
|
||||||
|
iconColor = '#2ecc71';
|
||||||
|
iconText = 'CSV';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'ppt':
|
||||||
|
iconColor = '#e67e22';
|
||||||
|
iconText = 'PPT';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'pptx':
|
||||||
|
iconColor = '#e67e22';
|
||||||
|
iconText = 'PPTX';
|
||||||
|
textClass = 'medium';
|
||||||
|
break;
|
||||||
|
case 'zip':
|
||||||
|
iconColor = '#f39c12';
|
||||||
|
iconText = 'ZIP';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'rar':
|
||||||
|
iconColor = '#f39c12';
|
||||||
|
iconText = 'RAR';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case '7z':
|
||||||
|
iconColor = '#f39c12';
|
||||||
|
iconText = '7Z';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'tar':
|
||||||
|
iconColor = '#f39c12';
|
||||||
|
iconText = 'TAR';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'gz':
|
||||||
|
iconColor = '#f39c12';
|
||||||
|
iconText = 'GZ';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'txt':
|
||||||
|
iconColor = '#95a5a6';
|
||||||
|
iconText = 'TXT';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'log':
|
||||||
|
iconColor = '#95a5a6';
|
||||||
|
iconText = 'LOG';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'md':
|
||||||
|
iconColor = '#95a5a6';
|
||||||
|
iconText = 'MD';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'jpg':
|
||||||
|
iconColor = '#9b59b6';
|
||||||
|
iconText = 'JPG';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'jpeg':
|
||||||
|
iconColor = '#9b59b6';
|
||||||
|
iconText = 'JPEG';
|
||||||
|
textClass = 'medium';
|
||||||
|
break;
|
||||||
|
case 'png':
|
||||||
|
iconColor = '#9b59b6';
|
||||||
|
iconText = 'PNG';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'gif':
|
||||||
|
iconColor = '#9b59b6';
|
||||||
|
iconText = 'GIF';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'bmp':
|
||||||
|
iconColor = '#9b59b6';
|
||||||
|
iconText = 'BMP';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'svg':
|
||||||
|
iconColor = '#9b59b6';
|
||||||
|
iconText = 'SVG';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'webp':
|
||||||
|
iconColor = '#9b59b6';
|
||||||
|
iconText = 'WEBP';
|
||||||
|
textClass = 'medium';
|
||||||
|
break;
|
||||||
|
case 'mp3':
|
||||||
|
iconColor = '#1abc9c';
|
||||||
|
iconText = 'MP3';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'wav':
|
||||||
|
iconColor = '#1abc9c';
|
||||||
|
iconText = 'WAV';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'ogg':
|
||||||
|
iconColor = '#1abc9c';
|
||||||
|
iconText = 'OGG';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'flac':
|
||||||
|
iconColor = '#1abc9c';
|
||||||
|
iconText = 'FLAC';
|
||||||
|
textClass = 'medium';
|
||||||
|
break;
|
||||||
|
case 'mp4':
|
||||||
|
iconColor = '#d35400';
|
||||||
|
iconText = 'MP4';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'avi':
|
||||||
|
iconColor = '#d35400';
|
||||||
|
iconText = 'AVI';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'mkv':
|
||||||
|
iconColor = '#d35400';
|
||||||
|
iconText = 'MKV';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'mov':
|
||||||
|
iconColor = '#d35400';
|
||||||
|
iconText = 'MOV';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'wmv':
|
||||||
|
iconColor = '#d35400';
|
||||||
|
iconText = 'WMV';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'exe':
|
||||||
|
iconColor = '#c0392b';
|
||||||
|
iconText = 'EXE';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'msi':
|
||||||
|
iconColor = '#c0392b';
|
||||||
|
iconText = 'MSI';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'js':
|
||||||
|
iconColor = '#2980b9';
|
||||||
|
iconText = 'JS';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'html':
|
||||||
|
iconColor = '#2980b9';
|
||||||
|
iconText = 'HTML';
|
||||||
|
textClass = 'medium';
|
||||||
|
break;
|
||||||
|
case 'css':
|
||||||
|
iconColor = '#2980b9';
|
||||||
|
iconText = 'CSS';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'php':
|
||||||
|
iconColor = '#2980b9';
|
||||||
|
iconText = 'PHP';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'py':
|
||||||
|
iconColor = '#2980b9';
|
||||||
|
iconText = 'PY';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'java':
|
||||||
|
iconColor = '#2980b9';
|
||||||
|
iconText = 'JAVA';
|
||||||
|
textClass = 'medium';
|
||||||
|
break;
|
||||||
|
case 'json':
|
||||||
|
iconColor = '#8e44ad';
|
||||||
|
iconText = 'JSON';
|
||||||
|
textClass = 'medium';
|
||||||
|
break;
|
||||||
|
case 'xml':
|
||||||
|
iconColor = '#8e44ad';
|
||||||
|
iconText = 'XML';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'yml':
|
||||||
|
iconColor = '#8e44ad';
|
||||||
|
iconText = 'YML';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'yaml':
|
||||||
|
iconColor = '#8e44ad';
|
||||||
|
iconText = 'YAML';
|
||||||
|
textClass = 'medium';
|
||||||
|
break;
|
||||||
|
case 'sql':
|
||||||
|
iconColor = '#27ae60';
|
||||||
|
iconText = 'SQL';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'db':
|
||||||
|
iconColor = '#27ae60';
|
||||||
|
iconText = 'DB';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'sqlite':
|
||||||
|
iconColor = '#27ae60';
|
||||||
|
iconText = 'SQLITE';
|
||||||
|
textClass = 'long';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Для других расширений используем расширение или первые 4 символа
|
||||||
|
iconColor = '#7f8c8d';
|
||||||
|
iconText = extension.length > 4 ?
|
||||||
|
extension.substring(0, 4).toUpperCase() :
|
||||||
|
extension.toUpperCase();
|
||||||
|
|
||||||
|
// Определяем класс по длине текста
|
||||||
|
if (iconText.length <= 2) {
|
||||||
|
textClass = 'short';
|
||||||
|
} else if (iconText.length <= 4) {
|
||||||
|
textClass = 'medium';
|
||||||
|
} else {
|
||||||
|
textClass = 'long';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Если нет расширения
|
||||||
|
iconColor = '#7f8c8d';
|
||||||
|
iconText = 'ФАЙЛ';
|
||||||
|
textClass = 'short';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Исправляем кодировку для отображения
|
||||||
|
const safeFileName = fileName;
|
||||||
|
const displayFileName = truncateFileName(safeFileName);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<a href="/api/files/${file.id}/download"
|
||||||
|
download="${encodeURIComponent(safeFileName)}"
|
||||||
|
class="file-icon-container"
|
||||||
|
title="${safeFileName} (${fileSize} MB) - Загрузил: ${uploadedBy}">
|
||||||
|
<div class="file-icon" style="background: ${iconColor}">
|
||||||
|
<span class="file-extension ${textClass}">${iconText}</span>
|
||||||
|
</div>
|
||||||
|
<div class="file-name">${displayFileName}</div>
|
||||||
|
</a>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateFileName(fileName, maxLength = 20) {
|
||||||
|
if (fileName.length <= maxLength) return fileName;
|
||||||
|
const extension = fileName.split('.').pop();
|
||||||
|
const name = fileName.substring(0, fileName.lastIndexOf('.'));
|
||||||
|
const truncatedName = name.substring(0, maxLength - extension.length - 3) + '...';
|
||||||
|
return truncatedName + '.' + extension;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вспомогательная функция для форматирования размера файла
|
||||||
|
function formatFileSize(bytes) {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
@@ -3,28 +3,57 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>School CRM - Управление задачами</title>
|
<title>{{SCHOOL_NAME}} - {{APP_NAME}}</title>
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
<!--
|
||||||
|
<script src="loading-start.js"></script>
|
||||||
|
-->
|
||||||
|
<script src="auth.js"></script>
|
||||||
|
<!--
|
||||||
|
<script src="users.js"></script>
|
||||||
|
<script src="ui.js"></script>
|
||||||
|
<script src="tasks.js"></script>
|
||||||
|
<script src="kanban.js"></script>
|
||||||
|
<script src="files.js"></script>
|
||||||
|
<script src="profile.js"></script>
|
||||||
|
<script src="time-selector.js"></script>
|
||||||
|
<script src="openTaskChat.js"></script>
|
||||||
|
<script src="tasks_files.js"></script>
|
||||||
|
<script src="navbar.js"></script>
|
||||||
|
<script src="signature.js"></script>
|
||||||
|
<script src="document-fields.js"></script>
|
||||||
|
<script src="chat-ui.js"></script>
|
||||||
|
-->
|
||||||
|
<script src="main.js"></script>
|
||||||
|
<!--
|
||||||
|
<script src="tasks-type.js"></script>
|
||||||
|
-->
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="login-modal" class="modal">
|
<div id="login-modal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<h2>Вход в School CRM</h2>
|
<div style="text-align: center; margin-bottom: 20px;">
|
||||||
|
<img src="login2.png" alt="School CRM Logo" style="max-width: 140px; max-height: 150px;">
|
||||||
|
</div>
|
||||||
|
<!-- <h2><i class="fas fa-sign-in-alt"></i> Вход в School CRM</h2> -->
|
||||||
<form id="login-form">
|
<form id="login-form">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="login">Логин:</label>
|
<label for="login"><i class="fas fa-user"></i> Логин:</label>
|
||||||
<input type="text" id="login" name="login" required>
|
<input type="text" id="login" name="login" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="password">Пароль:</label>
|
<label for="password"><i class="fas fa-lock"></i> Пароль:</label>
|
||||||
<input type="password" id="password" name="password" required>
|
<input type="password" id="password" name="password" required>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit">Войти</button>
|
<button type="submit" class="btn-primary">
|
||||||
|
<i class="fas fa-sign-in-alt"></i> Войти
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="test-users">
|
<div class="test-users">
|
||||||
<h3>Управление задачами</h3>
|
<h3><i class="fas fa-users"></i> {{APP_NAME}} {{APP_VERSION}}</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>@2025 </strong>МАОУ - СОШ № 25</li>
|
<li><strong><i class="fas fa-school"></i> @2025</strong> {{SCHOOL_NAME}}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -32,32 +61,36 @@
|
|||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<header>
|
<header>
|
||||||
<h1>School CRM - Управление задачами</h1>
|
<div class="header-top">
|
||||||
|
<div style="display: flex; align-items: center; justify-content: center; gap: 20px; margin-bottom: 20px;">
|
||||||
|
<img src="login2.png" alt="School CRM Logo" style="max-width: 200px; max-height: 110px; flex-shrink: 0;">
|
||||||
|
<h1 style="margin: 0;"> {{SCHOOL_NAME}} - {{APP_NAME}}</h1>
|
||||||
|
<img src="login2.png" alt="School CRM Logo" style="max-width: 200px; max-height: 110px; flex-shrink: 0;">
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<span id="current-user"></span>
|
<span id="current-user"></span>
|
||||||
<button onclick="logout()">Выйти</button>
|
|
||||||
</div>
|
</div>
|
||||||
<nav>
|
</div>
|
||||||
<button onclick="showSection('tasks')">Задачи</button>
|
<nav id="navbar-container">
|
||||||
<button onclick="showSection('create-task')">Создать задачу</button>
|
<!-- Кнопки навигации будут добавлены через navbar.js -->
|
||||||
<button onclick="showTasksWithoutDate()" id="tasks-no-date-btn">Задачи без срока</button>
|
|
||||||
<button onclick="showKanbanSection()" class="nav-btn">📋 Канбан</button>
|
|
||||||
<button onclick="showSection('logs')">Лог активности</button>
|
|
||||||
<button onclick="window.location.href = '/admin'" style="background: linear-gradient(135deg, #e74c3c, #c0392b);">Админ-панель</button>
|
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<section id="tasks-section" class="section">
|
<section id="tasks-section" class="section">
|
||||||
<h2>Все задачи</h2>
|
<h2><i class="fas fa-tasks"></i> Все задачи</h2>
|
||||||
<div id="tasks-controls">
|
<div id="tasks-controls">
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<label for="search-tasks">Поиск:</label>
|
<label for="task-view-filter"><i class="fas fa-eye"></i> Вид задач:</label>
|
||||||
<input type="text" id="search-tasks" placeholder="Поиск по названию и описанию..." oninput="loadTasks()">
|
<select id="task-view-filter" onchange="changeTaskView()">
|
||||||
|
<option value="all">Все задачи</option>
|
||||||
|
<option value="my_assigned">Задачи, которые я назначил</option>
|
||||||
|
<option value="assigned_to_me">Задачи, назначенные мне</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<label for="status-filter">Статус:</label>
|
<label for="status-filter"><i class="fas fa-filter"></i> Статус:</label>
|
||||||
<select id="status-filter" onchange="loadTasks()">
|
<select id="status-filter" onchange="loadTasks()">
|
||||||
<option value="active,in_progress,assigned,overdue,rework">Все активные</option>
|
<option value="active,in_progress,assigned,overdue,rework">Все активные</option>
|
||||||
<option value="all">Все статусы</option>
|
<option value="all">Все статусы</option>
|
||||||
@@ -70,54 +103,134 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<label for="creator-filter">Заказчик:</label>
|
<label for="type-filter"><i class="fas fa-tag"></i> Тип:</label>
|
||||||
|
<select id="type-filter" onchange="loadTasks()">
|
||||||
|
<option value="">Все типы</option>
|
||||||
|
<option value="regular">Обычная задача</option>
|
||||||
|
<option value="document">Согласование документа</option>
|
||||||
|
<option value="acquaintance">Ознакомление</option>
|
||||||
|
<option value="it">ИТ отдел</option>
|
||||||
|
<option value="ahch">АХЧ</option>
|
||||||
|
<option value="psychologist">Психолог</option>
|
||||||
|
<option value="speech_therapist">Логопед</option>
|
||||||
|
<option value="Social_educator">Социальный педагог</option>
|
||||||
|
<option value="hr">Диспетчер расписания</option>
|
||||||
|
<option value="certificate">Справка</option>
|
||||||
|
<option value="e_journal">Эл. журнал</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="creator-filter"><i class="fas fa-user-tie"></i> Заказчик:</label>
|
||||||
<select id="creator-filter" onchange="loadTasks()">
|
<select id="creator-filter" onchange="loadTasks()">
|
||||||
<option value="">Все заказчики</option>
|
<option value="">Все заказчики</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<label for="assignee-filter">Исполнитель:</label>
|
<label for="assignee-filter"><i class="fas fa-user-check"></i> Исполнитель:</label>
|
||||||
<select id="assignee-filter" onchange="loadTasks()">
|
<select id="assignee-filter" onchange="loadTasks()">
|
||||||
<option value="">Все исполнители</option>
|
<option value="">Все исполнители</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<label for="deadline-filter">Срок выполнения:</label>
|
<label for="deadline-filter"><i class="fas fa-calendar-times"></i> Срок выполнения:</label>
|
||||||
<select id="deadline-filter" onchange="loadTasks()">
|
<select id="deadline-filter" onchange="loadTasks()">
|
||||||
<option value="">Все сроки</option>
|
<option value="">Все сроки</option>
|
||||||
<option value="48h">Менее 48 часов</option>
|
<option value="48h">Менее 48 часов</option>
|
||||||
<option value="24h">Менее 24 часов</option>
|
<option value="24h">Менее 24 часов</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="filter-group" style="flex-grow: 1; min-width: 180px;">
|
||||||
|
<label for="search-tasks"><i class="fas fa-search"></i> Поиск:</label>
|
||||||
|
<input type="text" id="search-tasks" placeholder="Поиск по названию и описанию..." oninput="loadTasks()">
|
||||||
|
</div>
|
||||||
|
<div class="filter-actions">
|
||||||
|
<button type="button" class="btn-reset" onclick="resetAllFilters()">
|
||||||
|
<i class="fas fa-undo"></i> Сбросить фильтры
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label class="show-deleted-label" style="display: none;">
|
<label class="show-deleted-label" style="display: none;">
|
||||||
<input type="checkbox" id="show-deleted" onchange="loadTasks()">
|
<input type="checkbox" id="show-deleted" onchange="loadTasks()">
|
||||||
Показать удаленные задачи
|
<i class="fas fa-trash"></i> Показать удаленные задачи
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div id="tasks-list"></div>
|
<div id="tasks-list"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="create-task-section" class="section">
|
<section id="create-task-section" class="section">
|
||||||
<h2>Создать новую задачу</h2>
|
<h2><i class="fas fa-plus-circle"></i> Создать новую задачу</h2>
|
||||||
<form id="create-task-form" enctype="multipart/form-data">
|
<form id="create-task-form" enctype="multipart/form-data">
|
||||||
|
<div class="task-type-selector">
|
||||||
|
<div class="task-type-buttons">
|
||||||
|
<button type="button" class="task-type-btn active" data-type="regular" onclick="selectTaskType('regular')"><i class="fas fa-tasks"></i> Обычная задача</button>
|
||||||
|
<button type="button" class="task-type-btn" data-type="acquaintance" onclick="selectTaskType('acquaintance')"><i class="fas fa-eye"></i> Ознакомление</button>
|
||||||
|
<button type="button" class="task-type-btn" data-type="document" onclick="selectTaskType('document')"><i class="fas fa-file-signature"></i> Согласование документа</button>
|
||||||
|
<button type="button" class="task-type-btn" data-type="it" onclick="selectTaskType('it')"><i class="fas fa-desktop"></i> Заявка в ИТ отдел</button>
|
||||||
|
<button type="button" class="task-type-btn" data-type="ahch" onclick="selectTaskType('ahch')"><i class="fas fa-tools"></i> Заявка в АХЧ</button>
|
||||||
|
<button type="button" class="task-type-btn" data-type="psychologist" onclick="selectTaskType('psychologist')"><i class="fas fa-brain"></i> Заявка к психологу</button>
|
||||||
|
<button type="button" class="task-type-btn" data-type="speech_therapist" onclick="selectTaskType('speech_therapist')"><i class="fas fa-comment-medical"></i> Заявка к логопеду</button>
|
||||||
|
<button type="button" class="task-type-btn" data-type="Social_educator" onclick="selectTaskType('Social_educator')"><i class="fas fa-user-graduate"></i> Социальный педагог</button>
|
||||||
|
<button type="button" class="task-type-btn" data-type="hr" onclick="selectTaskType('hr')"><i class="fas fa-users"></i> Заявка диспетчеру расписания</button>
|
||||||
|
<button type="button" class="task-type-btn" data-type="certificate" onclick="selectTaskType('certificate')">
|
||||||
|
<i class="fas fa-book"></i> Заявка на справку
|
||||||
|
</button>
|
||||||
|
<button type="button" class="task-type-btn" data-type="e_journal" onclick="selectTaskType('e_journal')">
|
||||||
|
<i class="fas fa-book"></i> Доступ в электронный журнал
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="task-type" name="taskType" value="regular">
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="title">Название задачи:</label>
|
<label for="title"><i class="fas fa-heading"></i> Название задачи:</label>
|
||||||
<input type="text" id="title" name="title" required>
|
<input type="text" id="title" name="title" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="description">Описание:</label>
|
<div id="it-additional-fields" style="display: none;">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="it-cabinet">Номер кабинета:</label>
|
||||||
|
<input type="text" id="it-cabinet" name="it-cabinet" placeholder="например, 301">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="it-corpus">Корпус:</label>
|
||||||
|
<select id="it-corpus-type" name="it-corpus-type">
|
||||||
|
<option value="">-- Выберите --</option>
|
||||||
|
<option value="Цветоносная 2">Цветоносная 2</option>
|
||||||
|
<option value="Феофанова 10">Феофанова 10</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="it-problem-type">Тип проблемы:</label>
|
||||||
|
<select id="it-problem-type" name="it-problem-type">
|
||||||
|
<option value="">-- Выберите --</option>
|
||||||
|
<option value="Не включается компьютер">Не включается компьютер</option>
|
||||||
|
<option value="Не работает проектор">Не работает проектор</option>
|
||||||
|
<option value="Не работает интерактивная панель">Не работает интерактивная панель</option>
|
||||||
|
<option value="Проблемы с интернетом">Проблемы с интернетом</option>
|
||||||
|
<option value="Не печатает принтер">Не печатает принтер</option>
|
||||||
|
<option value="Не печатает принтер">Не работает телефон</option>
|
||||||
|
<option value="Прочее">Прочее</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label for="description"><i class="fas fa-align-left"></i> Описание:</label>
|
||||||
<textarea id="description" name="description" rows="4"></textarea>
|
<textarea id="description" name="description" rows="4"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="due-date">Дата и время выполнения:</label>
|
<label for="due-date"><i class="fas fa-calendar-alt"></i> Дата выполнения:</label>
|
||||||
<input type="datetime-local" id="due-date" name="dueDate" required>
|
<div class="time-buttons">
|
||||||
|
<button type="button" class="time-btn active" onclick="setTaskTime('12:00')"><i class="fas fa-sun"></i> До обеда</button>
|
||||||
|
<input type="date" class="date-btn" id="due-date" name="dueDate" required>
|
||||||
|
<button type="button" class="time-btn" onclick="setTaskTime('19:00')"><i class="fas fa-moon"></i> После обеда</button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="due-time" name="dueTime" value="12:00">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Исполнители:</label>
|
<label><i class="fas fa-users"></i> Исполнители:</label>
|
||||||
<div class="user-search">
|
<div class="user-search">
|
||||||
<input type="text" id="user-search" placeholder="Поиск исполнителей..." oninput="filterUsers()">
|
<input type="text" id="user-search" placeholder="Поиск исполнителей..." oninput="filterUsers()">
|
||||||
</div>
|
</div>
|
||||||
@@ -125,58 +238,232 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="files">Прикрепить файлы (до 15 файлов, максимум 300MB):</label>
|
<label for="files"><i class="fas fa-paperclip"></i> Прикрепить файлы (до 15 файлов, максимум 300MB):</label>
|
||||||
|
<div class="file-upload">
|
||||||
<input type="file" id="files" name="files" multiple>
|
<input type="file" id="files" name="files" multiple>
|
||||||
|
<label for="files" class="file-upload-label">
|
||||||
|
<i class="fas fa-cloud-upload-alt"></i> Выберите файлы
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div id="file-list"></div>
|
<div id="file-list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit">Создать задачу</button>
|
<button type="submit" class="btn-primary">
|
||||||
|
<i class="fas fa-check-circle"></i> Создать задачу
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<div id="mytasks-section" class="section">
|
||||||
|
<h2><i class="fas fa-user-edit"></i> Мои задачи (как автор)</h2>
|
||||||
|
<div class="filters" style="margin-bottom: 20px;">
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="mytasks-status-filter"><i class="fas fa-filter"></i> Статус:</label>
|
||||||
|
<select id="mytasks-status-filter" onchange="filterMyTasks()">
|
||||||
|
<option value="all">Все статусы</option>
|
||||||
|
<option value="assigned">Назначена</option>
|
||||||
|
<option value="in_progress">В работе</option>
|
||||||
|
<option value="rework">На доработке</option>
|
||||||
|
<option value="overdue">Просрочена</option>
|
||||||
|
<option value="completed">Выполнена</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group" style="flex-grow: 1;">
|
||||||
|
<label for="mytasks-search"><i class="fas fa-search"></i> Поиск:</label>
|
||||||
|
<input type="text" id="mytasks-search" placeholder="Поиск по названию..." oninput="filterMyTasks()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="mytasks-list" class="tasks-container"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="runtasks-section" class="section">
|
||||||
|
<h2><i class="fas fa-user-check"></i> Задачи для исполнения</h2>
|
||||||
|
<div class="filters" style="margin-bottom: 20px;">
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="runtasks-status-filter"><i class="fas fa-filter"></i> Статус:</label>
|
||||||
|
<select id="runtasks-status-filter" onchange="filterRunTasks()">
|
||||||
|
<option value="all">Все статусы</option>
|
||||||
|
<option value="assigned">Назначена</option>
|
||||||
|
<option value="in_progress">В работе</option>
|
||||||
|
<option value="rework">На доработке</option>
|
||||||
|
<option value="overdue">Просрочена</option>
|
||||||
|
<option value="completed">Выполнена</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group" style="flex-grow: 1;">
|
||||||
|
<label for="runtasks-search"><i class="fas fa-search"></i> Поиск:</label>
|
||||||
|
<input type="text" id="runtasks-search" placeholder="Поиск по названию..." oninput="filterRunTasks()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="runtasks-list" class="tasks-container"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section id="logs-section" class="section">
|
<section id="logs-section" class="section">
|
||||||
<h2>Лог активности</h2>
|
<h2><i class="fas fa-history"></i> Лог активности</h2>
|
||||||
<div id="logs-list"></div>
|
<div id="logs-list"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="profile-section" class="section">
|
||||||
|
<h2><i class="fas fa-user-circle"></i> Личный кабинет</h2>
|
||||||
|
<div id="user-profile-info"></div>
|
||||||
|
<div class="notification-settings">
|
||||||
|
<h3><i class="fas fa-bell"></i> Настройки уведомлений</h3>
|
||||||
|
<form id="notification-settings-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" id="email-notifications" name="email_notifications">
|
||||||
|
<span><i class="fas fa-envelope"></i> Email уведомления</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="input-with-icon">
|
||||||
|
<i class="fas fa-envelope"></i>
|
||||||
|
<input type="email" id="notification-email" name="notification_email"
|
||||||
|
placeholder="Введите email для уведомлений">
|
||||||
|
</div>
|
||||||
|
<small>Email для уведомлений</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group hidden">
|
||||||
|
<div class="form-group"><label class="checkbox-label"><input type="checkbox" id="telegram-notifications" name="telegram_notifications" disabled><span><i class="fab fa-telegram"></i> Telegram уведомления (скоро)</span></label></div>
|
||||||
|
<div class="form-group"><label class="checkbox-label"><input type="checkbox" id="vk-notifications" name="vk_notifications" disabled><span><i class="fab fa-vk"></i> ВКонтакте уведомления (скоро)</span></label></div>
|
||||||
|
<div class="form-group"><label class="checkbox-label"><input type="checkbox" id="sberbank-notifications" name="sberbank_notifications" disabled><span><i class="fas fa-university"></i> Сбербанк Онлайн уведомления (скоро)</span></label></div>
|
||||||
|
<div class="form-group"><label class="checkbox-label"><input type="checkbox" id="yandex-notifications" name="yandex_notifications" disabled><span><i class="fab fa-yandex"></i> Яндекс уведомления (скоро)</span></label></div>
|
||||||
|
<div class="form-group"><label class="checkbox-label"><input type="checkbox" id="gosuslugi-notifications" name="gosuslugi_notifications" disabled><span><i class="fas fa-passport"></i> Госуслуги уведомления (скоро)</span></label></div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary">
|
||||||
|
<i class="fas fa-save"></i> Сохранить настройки
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Секция отчётов (обновлена) -->
|
||||||
|
<section id="reports-section" class="section">
|
||||||
|
<h2><i class="fas fa-chart-pie"></i> Отчёт по задачам</h2>
|
||||||
|
|
||||||
|
<div class="reports-filters">
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="report-task-id-filter">Номер задачи:</label>
|
||||||
|
<select id="report-task-id-filter" onchange="applyFilters()">
|
||||||
|
<option value="">Все номера</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="report-status-filter">Статус:</label>
|
||||||
|
<select id="report-status-filter" onchange="applyFilters()">
|
||||||
|
<option value="">Все статусы</option>
|
||||||
|
<option value="assigned">Назначена</option>
|
||||||
|
<option value="in_progress">В работе</option>
|
||||||
|
<option value="completed">Выполнена</option>
|
||||||
|
<option value="overdue">Просрочена</option>
|
||||||
|
<option value="rework">На доработке</option>
|
||||||
|
<option value="deleted">Удалена</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="report-user-filter">Исполнитель:</label>
|
||||||
|
<select id="report-user-filter" onchange="applyFilters()">
|
||||||
|
<option value="">Все пользователи</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="report-type-filter">Тип задачи:</label>
|
||||||
|
<select id="report-type-filter" onchange="applyFilters()">
|
||||||
|
<option value="">Все типы</option>
|
||||||
|
<option value="regular">Обычная задача</option>
|
||||||
|
<option value="document">Согласование документа</option>
|
||||||
|
<option value="it">ИТ отдел</option>
|
||||||
|
<option value="ahch">АХЧ</option>
|
||||||
|
<option value="psychologist">Психолог</option>
|
||||||
|
<option value="speech_therapist">Логопед</option>
|
||||||
|
<option value="hr">Диспетчер расписания</option>
|
||||||
|
<option value="certificate">Справка</option>
|
||||||
|
<option value="e_journal">Эл. журнал</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group buttons-group">
|
||||||
|
<button class="btn-primary" onclick="printReport()" title="Печать">
|
||||||
|
<i class="fas fa-print"></i> Печать
|
||||||
|
</button> <!--
|
||||||
|
<button class="btn-secondary" onclick="loadReportData()" title="Обновить данные">
|
||||||
|
<i class="fas fa-sync-alt"></i> Обновить
|
||||||
|
</button> -->
|
||||||
|
<button class="btn-primary" onclick="resetReportFilters()" title="Сбросить все фильтры">
|
||||||
|
<i class="fas fa-undo-alt"></i> Сбросить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Сводка по статусам -->
|
||||||
|
<div id="report-summary" class="report-summary"></div>
|
||||||
|
|
||||||
|
<!-- Таблица с задачами (без описания) -->
|
||||||
|
<div class="table-container report-table-container">
|
||||||
|
<table id="report-table" class="report-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>№ задачи</th>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Срок выполнения</th>
|
||||||
|
<th>Исполнитель</th>
|
||||||
|
<th>Автор</th>
|
||||||
|
<th>Статус исполнителя</th>
|
||||||
|
<th>Последнее изменение</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="report-table-body">
|
||||||
|
<tr><td colspan="7" class="loading">Загрузка данных...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Модальные окна (без изменений) -->
|
||||||
<div id="edit-task-modal" class="modal">
|
<div id="edit-task-modal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<span class="close" onclick="closeEditModal()">×</span>
|
<span class="close" onclick="closeEditModal()">×</span>
|
||||||
<h3>Редактировать задачу</h3>
|
<h3><i class="fas fa-edit"></i> Редактировать задачу</h3>
|
||||||
<form id="edit-task-form" enctype="multipart/form-data">
|
<form id="edit-task-form" enctype="multipart/form-data">
|
||||||
<input type="hidden" id="edit-task-id">
|
<input type="hidden" id="edit-task-id">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="edit-title">Название задачи:</label>
|
<label for="edit-title">Название задачи:</label>
|
||||||
<input type="text" id="edit-title" name="title" required>
|
<input type="text" id="edit-title" name="title" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="edit-description">Описание:</label>
|
<label for="edit-description">Описание:</label>
|
||||||
<textarea id="edit-description" name="description" rows="4"></textarea>
|
<textarea id="edit-description" name="description" rows="4"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="edit-due-date">Дата и время выполнения:</label>
|
<label for="edit-due-date">Дата выполнения:</label>
|
||||||
<input type="datetime-local" id="edit-due-date" name="dueDate" required>
|
<input type="date" id="edit-due-date" name="dueDate" required>
|
||||||
|
<div class="time-buttons">
|
||||||
|
<button type="button" class="edit-time-btn" onclick="setEditTaskTime('12:00')">
|
||||||
|
<i class="fas fa-sun"></i> До обеда (12:00)
|
||||||
|
</button>
|
||||||
|
<button type="button" class="edit-time-btn" onclick="setEditTaskTime('19:00')">
|
||||||
|
<i class="fas fa-moon"></i> После обеда (19:00)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="edit-due-time" name="dueTime" value="12:00">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Исполнители:</label>
|
<label>Исполнители:</label>
|
||||||
<div class="user-search">
|
<div class="user-search">
|
||||||
|
<div id="edit-users-checklist" class="checkbox-group"></div>
|
||||||
<input type="text" id="edit-user-search" placeholder="Поиск исполнителей..." oninput="filterEditUsers()">
|
<input type="text" id="edit-user-search" placeholder="Поиск исполнителей..." oninput="filterEditUsers()">
|
||||||
</div>
|
</div>
|
||||||
<div id="edit-users-checklist" class="checkbox-group"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="edit-files">Добавить файлы:</label>
|
<label for="edit-files">Добавить файлы:</label>
|
||||||
<input type="file" id="edit-files" name="files" multiple>
|
<input type="file" id="edit-files" name="files" multiple>
|
||||||
<div id="edit-file-list"></div>
|
<div id="edit-file-list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<button type="submit" class="btn-primary">
|
||||||
<button type="submit">Сохранить изменения</button>
|
<i class="fas fa-save"></i> Сохранить изменения
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -184,23 +471,21 @@
|
|||||||
<div id="copy-task-modal" class="modal">
|
<div id="copy-task-modal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<span class="close" onclick="closeCopyModal()">×</span>
|
<span class="close" onclick="closeCopyModal()">×</span>
|
||||||
<h3>Создать копию задачи</h3>
|
<h3><i class="fas fa-copy"></i> Создать копию задачи</h3>
|
||||||
<form id="copy-task-form">
|
<form id="copy-task-form">
|
||||||
<input type="hidden" id="copy-task-id">
|
<input type="hidden" id="copy-task-id">
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="copy-due-date">Дата и время выполнения для копии:</label>
|
<label for="copy-due-date">Дата выполнения:</label>
|
||||||
<input type="datetime-local" id="copy-due-date" name="dueDate" required>
|
<input type="date" class="date-btn" id="copy-due-date" name="dueDate" required>
|
||||||
</div>
|
<input type="hidden" id="copy-due-time" name="dueTime" value="19:00">
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Назначить исполнителей для копии:</label>
|
|
||||||
<div class="user-search">
|
|
||||||
<input type="text" id="copy-user-search" placeholder="Поиск исполнителей..." oninput="filterCopyUsers()">
|
<input type="text" id="copy-user-search" placeholder="Поиск исполнителей..." oninput="filterCopyUsers()">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
<div id="copy-users-checklist" class="checkbox-group"></div>
|
<div id="copy-users-checklist" class="checkbox-group"></div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit">Создать копию</button>
|
<button type="submit" class="btn-primary">
|
||||||
|
<i class="fas fa-copy"></i> Создать копию
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -208,7 +493,7 @@
|
|||||||
<div id="edit-assignment-modal" class="modal">
|
<div id="edit-assignment-modal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<span class="close" onclick="closeEditAssignmentModal()">×</span>
|
<span class="close" onclick="closeEditAssignmentModal()">×</span>
|
||||||
<h3>Редактировать сроки исполнителя</h3>
|
<h3><i class="fas fa-clock"></i> Редактировать сроки исполнителя</h3>
|
||||||
<form id="edit-assignment-form">
|
<form id="edit-assignment-form">
|
||||||
<input type="hidden" id="edit-assignment-task-id">
|
<input type="hidden" id="edit-assignment-task-id">
|
||||||
<input type="hidden" id="edit-assignment-user-id">
|
<input type="hidden" id="edit-assignment-user-id">
|
||||||
@@ -216,7 +501,9 @@
|
|||||||
<label for="edit-assignment-due-date">Дата и время выполнения:</label>
|
<label for="edit-assignment-due-date">Дата и время выполнения:</label>
|
||||||
<input type="datetime-local" id="edit-assignment-due-date" name="dueDate" required>
|
<input type="datetime-local" id="edit-assignment-due-date" name="dueDate" required>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit">Сохранить сроки</button>
|
<button type="submit" class="btn-primary">
|
||||||
|
<i class="fas fa-save"></i> Сохранить сроки
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -224,27 +511,61 @@
|
|||||||
<div id="rework-task-modal" class="modal">
|
<div id="rework-task-modal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<span class="close" onclick="closeReworkModal()">×</span>
|
<span class="close" onclick="closeReworkModal()">×</span>
|
||||||
<h3>Вернуть задачу на доработку</h3>
|
<h3><i class="fas fa-redo"></i> Вернуть задачу на доработку</h3>
|
||||||
<form id="rework-task-form">
|
<form id="rework-task-form">
|
||||||
<input type="hidden" id="rework-task-id">
|
<input type="hidden" id="rework-task-id">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="rework-comment">Комментарий к доработке:</label>
|
<label for="rework-comment">Комментарий к доработке:</label>
|
||||||
<textarea id="rework-comment" name="comment" rows="4" placeholder="Укажите, что нужно исправить..." required></textarea>
|
<textarea id="rework-comment" name="comment" rows="4" placeholder="Укажите, что нужно исправить..." required></textarea>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit">Вернуть на доработку</button>
|
<button type="submit" class="btn-warning">
|
||||||
|
<i class="fas fa-redo"></i> Вернуть на доработку
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="kanban-section" class="section kanban-section">
|
<div id="acquaintance-task-modal" class="modal">
|
||||||
<div class="section-header">
|
<div class="modal-content">
|
||||||
<h2>📋 Канбан-доска</h2>
|
<span class="close" onclick="closeAcquaintanceModal()">×</span>
|
||||||
<p>Перетаскивайте задачи между колонками для изменения статуса</p>
|
<h3>Создать задачу для ознакомления</h3>
|
||||||
|
<form id="acquaintance-task-form" enctype="multipart/form-data">
|
||||||
|
<input type="hidden" id="acquaintance-original-task-id">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Исходная задача:</label>
|
||||||
|
<div id="acquaintance-original-title"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Автор задачи (выберите из списка):</label>
|
||||||
|
<div class="user-search">
|
||||||
|
<input type="text" id="acquaintance-author-search" placeholder="Поиск авторов..." oninput="filterAcquaintanceAuthors()">
|
||||||
|
</div>
|
||||||
|
<div id="acquaintance-authors-checklist" class="checkbox-group"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Исполнитель:</label>
|
||||||
|
<div id="acquaintance-executor-info" style="padding: 10px; background: #f0f0f0; border-radius: 5px;">
|
||||||
|
<!-- сюда будет подставлено имя текущего пользователя -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="acquaintance-due-date">Дата выполнения:</label>
|
||||||
|
<input type="date" id="acquaintance-due-date" name="dueDate" required>
|
||||||
|
<input type="hidden" id="acquaintance-due-time" name="dueTime" value="19:00">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="acquaintance-comment">Комментарий (необязательно):</label>
|
||||||
|
<textarea id="acquaintance-comment" rows="3" placeholder="Добавьте комментарий к задаче ознакомления"></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary">Создать задачу ознакомления</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="kanban-section" class="section kanban-section">
|
||||||
<div id="kanban-board" class="kanban-board">
|
<div id="kanban-board" class="kanban-board">
|
||||||
<div class="loading">Загрузка Канбан-доски...</div>
|
<div class="loading">Загрузка Канбан-доски...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="script.js"></script>
|
<script src="nav-task-actions.js"></script>
|
||||||
|
<script src="loading-end.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
300
public/kanban.js
Normal file
300
public/kanban.js
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
// kanban.js - Канбан-доска
|
||||||
|
let kanbanTasks = [];
|
||||||
|
let kanbanDays = 14;
|
||||||
|
let currentDraggedTask = null;
|
||||||
|
|
||||||
|
function showKanbanSection() {
|
||||||
|
showSection('kanban');
|
||||||
|
loadKanbanTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadKanbanTasks() {
|
||||||
|
try {
|
||||||
|
const daysSelect = document.getElementById('kanban-days');
|
||||||
|
const filterSelect = document.getElementById('kanban-filter');
|
||||||
|
|
||||||
|
// Если есть выбор в интерфейсе - используем его, иначе - значение по умолчанию
|
||||||
|
if (daysSelect) {
|
||||||
|
kanbanDays = parseInt(daysSelect.value) || 14;
|
||||||
|
} else {
|
||||||
|
kanbanDays = 14;
|
||||||
|
}
|
||||||
|
|
||||||
|
let filter = 'all';
|
||||||
|
if (filterSelect) {
|
||||||
|
filter = filterSelect.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/kanban-tasks?days=${kanbanDays}&filter=${filter}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Ошибка сервера: ${response.status}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
kanbanTasks = data.tasks || [];
|
||||||
|
renderKanban(data.filter);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки задач для Канбана:', error);
|
||||||
|
document.getElementById('kanban-board').innerHTML = `
|
||||||
|
<div class="error-message">
|
||||||
|
❌ Ошибка загрузки Канбана: ${error.message}
|
||||||
|
<button onclick="loadKanbanTasks()" class="retry-btn">Повторить</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderKanban(filter = 'all') {
|
||||||
|
const container = document.getElementById('kanban-board');
|
||||||
|
|
||||||
|
// Группируем задачи по статусам (убрали 'unassigned')
|
||||||
|
const columns = {
|
||||||
|
'assigned': { title: 'Назначены', tasks: [], color: '#e74c3c' },
|
||||||
|
'in_progress': { title: 'В работе', tasks: [], color: '#f39c12' },
|
||||||
|
'rework': { title: 'На доработке', tasks: [], color: '#f1c40f' },
|
||||||
|
'overdue': { title: 'Просрочены', tasks: [], color: '#c0392b' },
|
||||||
|
'completed': { title: 'Выполнены', tasks: [], color: '#2ecc71' }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Распределяем задачи по колонкам
|
||||||
|
kanbanTasks.forEach(task => {
|
||||||
|
const status = task.kanbanStatus || 'assigned';
|
||||||
|
// Преобразуем 'unassigned' в 'assigned'
|
||||||
|
const actualStatus = status === 'unassigned' ? 'assigned' : status;
|
||||||
|
|
||||||
|
if (columns[actualStatus]) {
|
||||||
|
columns[actualStatus].tasks.push(task);
|
||||||
|
} else {
|
||||||
|
// Если статус не найден, добавляем в 'assigned'
|
||||||
|
columns['assigned'].tasks.push(task);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Статистика по фильтру
|
||||||
|
let filterTitle = 'Все задачи';
|
||||||
|
if (filter === 'created') filterTitle = 'Задачи, которые я поставил';
|
||||||
|
if (filter === 'assigned') filterTitle = 'Задачи, которые мне поставили';
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="kanban-controls">
|
||||||
|
<div class="kanban-filters">
|
||||||
|
<div class="kanban-period">
|
||||||
|
<label>Период просмотра:</label>
|
||||||
|
<select id="kanban-days" onchange="loadKanbanTasks()">
|
||||||
|
${[1, 2, 3, 4, 5, 6, 7, 14, 30, 62].map(days =>
|
||||||
|
`<option value="${days}" ${days === kanbanDays ? 'selected' : ''}>${days} ${getDayWord(days)}</option>`
|
||||||
|
).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="kanban-filter-type">
|
||||||
|
<label>Показать:</label>
|
||||||
|
<select id="kanban-filter" onchange="loadKanbanTasks()">
|
||||||
|
<option value="all" ${filter === 'all' ? 'selected' : ''}>Все задачи</option>
|
||||||
|
<option value="created" ${filter === 'created' ? 'selected' : ''}>Я поставил</option>
|
||||||
|
<option value="assigned" ${filter === 'assigned' ? 'selected' : ''}>Мне поставили</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="kanban-stats">
|
||||||
|
<span class="filter-title">${filterTitle}</span>
|
||||||
|
<span class="task-count">Всего задач: ${kanbanTasks.length}</span>
|
||||||
|
<button onclick="loadKanbanTasks()" class="refresh-btn">🔄 Обновить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kanban-columns">
|
||||||
|
${Object.entries(columns).map(([status, column]) => `
|
||||||
|
<div class="kanban-column" data-status="${status}" ${status === 'overdue' || status === 'assigned' ? 'ondragover="return false" ondrop="return false"' : ''}>
|
||||||
|
<div class="kanban-column-header" style="background: ${column.color}">
|
||||||
|
<h3>${column.title}</h3>
|
||||||
|
<span class="kanban-count">${column.tasks.length}</span>
|
||||||
|
</div>
|
||||||
|
<div class="kanban-column-body" id="kanban-column-${status}"
|
||||||
|
${status === 'overdue' || status === 'assigned' ? 'style="opacity: 0.6; cursor: not-allowed;"' : ''}>
|
||||||
|
${renderKanbanCards(column.tasks, filter)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Делаем колонки перетаскиваемыми (кроме 'overdue' и 'assigned')
|
||||||
|
makeKanbanDraggable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderKanbanCards(tasks, filter) {
|
||||||
|
if (tasks.length === 0) {
|
||||||
|
return '<div class="kanban-empty">Нет задач</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return tasks.map(task => {
|
||||||
|
// Определяем иконку роли
|
||||||
|
let roleIcon = '';
|
||||||
|
let roleTitle = '';
|
||||||
|
|
||||||
|
if (task.userRole === 'creator') {
|
||||||
|
roleIcon = '👤';
|
||||||
|
roleTitle = 'Вы поставили эту задачу';
|
||||||
|
} else if (task.userRole === 'assignee') {
|
||||||
|
roleIcon = '🎯';
|
||||||
|
roleTitle = 'Вам поставили эту задачу';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Исправление: безопасное получение имени пользователя
|
||||||
|
const userName = task.assignments && task.assignments.length > 0 && task.assignments[0]?.user_name
|
||||||
|
? task.assignments[0].user_name
|
||||||
|
: 'Неизвестно';
|
||||||
|
|
||||||
|
// Исправление: безопасное получение первого символа имени
|
||||||
|
const userInitial = userName && userName.length > 0 ? userName.charAt(0) : '?';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="kanban-card" draggable="true" data-task-id="${task.id}">
|
||||||
|
<div class="kanban-card-header">
|
||||||
|
<div class="kanban-task-id">#${task.id}</div>
|
||||||
|
<div class="kanban-task-role" title="${roleTitle}">${roleIcon}</div>
|
||||||
|
<div class="kanban-task-actions">
|
||||||
|
<button onclick="openKanbanTask(${task.id})" title="Открыть">👁️</button>
|
||||||
|
<button onclick="copyKanbanTask(${task.id})" title="Копировать">📋</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="kanban-task-title" onclick="openKanbanTask(${task.id})">
|
||||||
|
${task.title || 'Без названия'}
|
||||||
|
</div>
|
||||||
|
<div class="kanban-task-info">
|
||||||
|
<div class="kanban-deadline">
|
||||||
|
${task.due_date ? `<span class="kanban-date">📅 ${formatDate(task.due_date)}</span>` : '<span class="kanban-no-date">Без срока</span>'}
|
||||||
|
</div>
|
||||||
|
<div class="kanban-assignees">
|
||||||
|
${task.assignments && task.assignments.length > 0 ?
|
||||||
|
task.assignments.slice(0, 3).map(a => {
|
||||||
|
// Исправление: безопасное получение имени исполнителя
|
||||||
|
const assigneeName = a.user_name || 'Неизвестно';
|
||||||
|
const assigneeInitial = assigneeName && assigneeName.length > 0 ? assigneeName.charAt(0) : '?';
|
||||||
|
return `<span class="kanban-assignee" title="${assigneeName}">${assigneeInitial}</span>`;
|
||||||
|
}).join('') :
|
||||||
|
'<span class="kanban-no-assignee">👤</span>'
|
||||||
|
}
|
||||||
|
${task.assignments && task.assignments.length > 3 ?
|
||||||
|
`<span class="kanban-more-assignees">+${task.assignments.length - 3}</span>` : ''
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="kanban-task-footer">
|
||||||
|
<span class="kanban-creator">👤 ${task.creator_name || 'Неизвестно'}</span>
|
||||||
|
${task.files && task.files.length > 0 ?
|
||||||
|
`<span class="kanban-files">📎 ${task.files.length}</span>` : ''
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDayWord(days) {
|
||||||
|
if (days === 1) return 'день';
|
||||||
|
if (days >= 2 && days <= 4) return 'дня';
|
||||||
|
return 'дней';
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeKanbanDraggable() {
|
||||||
|
const cards = document.querySelectorAll('.kanban-card');
|
||||||
|
const columns = document.querySelectorAll('.kanban-column-body:not([style*="opacity: 0.6"])');
|
||||||
|
|
||||||
|
cards.forEach(card => {
|
||||||
|
card.addEventListener('dragstart', (e) => {
|
||||||
|
e.dataTransfer.setData('text/plain', card.dataset.taskId);
|
||||||
|
card.classList.add('dragging');
|
||||||
|
});
|
||||||
|
|
||||||
|
card.addEventListener('dragend', () => {
|
||||||
|
card.classList.remove('dragging');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
columns.forEach(column => {
|
||||||
|
const status = column.parentElement.dataset.status;
|
||||||
|
|
||||||
|
// Запрещаем перетаскивание в 'overdue' и 'assigned'
|
||||||
|
if (status === 'overdue' || status === 'assigned') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
column.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const draggingCard = document.querySelector('.dragging');
|
||||||
|
if (draggingCard) {
|
||||||
|
column.appendChild(draggingCard);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
column.addEventListener('drop', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const taskId = e.dataTransfer.getData('text/plain');
|
||||||
|
const newStatus = column.parentElement.dataset.status;
|
||||||
|
|
||||||
|
if (taskId) {
|
||||||
|
try {
|
||||||
|
// Запрещаем установку статуса 'overdue' и 'assigned'
|
||||||
|
if (newStatus === 'overdue' || newStatus === 'assigned') {
|
||||||
|
alert('Невозможно изменить статус задачи на "Просрочены" или "Назначены" через Канбан');
|
||||||
|
// Возвращаем задачу в исходное положение
|
||||||
|
loadKanbanTasks();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем статус на сервере
|
||||||
|
const response = await fetch(`/api/kanban-tasks/${taskId}/status`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ status: newStatus })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Перезагружаем Канбан
|
||||||
|
loadKanbanTasks();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(`Ошибка обновления статуса: ${error.error || 'Неизвестная ошибка'}`);
|
||||||
|
// Возвращаем задачу в исходное положение
|
||||||
|
loadKanbanTasks();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка обновления статуса:', error);
|
||||||
|
alert('Ошибка обновления статуса');
|
||||||
|
loadKanbanTasks();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openKanbanTask(taskId) {
|
||||||
|
// Находим задачу и открываем её в основном интерфейсе
|
||||||
|
const task = kanbanTasks.find(t => t.id == taskId);
|
||||||
|
if (task) {
|
||||||
|
showSection('tasks');
|
||||||
|
// Прокручиваем к задаче
|
||||||
|
setTimeout(() => {
|
||||||
|
const taskElement = document.querySelector(`.task-card[data-task-id="${taskId}"]`);
|
||||||
|
if (taskElement) {
|
||||||
|
taskElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
// Раскрываем задачу если она свернута
|
||||||
|
if (!expandedTasks.has(taskId)) {
|
||||||
|
toggleTask(taskId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyKanbanTask(taskId) {
|
||||||
|
openCopyModal(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString) {
|
||||||
|
if (!dateString) return '';
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('ru-RU');
|
||||||
|
}
|
||||||
281
public/loadMyCreatedTasks.js
Normal file
281
public/loadMyCreatedTasks.js
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
// loadMyCreatedTasks.js - Задачи, созданные пользователем и назначенные ему
|
||||||
|
|
||||||
|
// Глобальные переменные
|
||||||
|
let expandedMyTasks = new Set();
|
||||||
|
let updateInterval = null;
|
||||||
|
let isUpdating = false;
|
||||||
|
|
||||||
|
// Показать секцию "Мои задачи (как автор)"
|
||||||
|
function showMyTasksSection() {
|
||||||
|
showSection('mytasks');
|
||||||
|
window.currentTaskView = 'my_assigned';
|
||||||
|
loadTasks();
|
||||||
|
startAutoUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показать секцию "Задачи для исполнения"
|
||||||
|
function showRunTasksSection() {
|
||||||
|
showSection('runtasks');
|
||||||
|
window.currentTaskView = 'assigned_to_me';
|
||||||
|
loadTasks();
|
||||||
|
startAutoUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Остановка автообновления при уходе с секции
|
||||||
|
function hideTasksSection() {
|
||||||
|
stopAutoUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запуск автоматического обновления
|
||||||
|
function startAutoUpdate() {
|
||||||
|
// Останавливаем предыдущий интервал, если был
|
||||||
|
stopAutoUpdate();
|
||||||
|
|
||||||
|
// Запускаем новый интервал (каждые 15 секунд)
|
||||||
|
updateInterval = setInterval(() => {
|
||||||
|
autoUpdateTasks();
|
||||||
|
}, 120000); // 120000 мс = 2 минуты
|
||||||
|
|
||||||
|
console.log('🔄 Автообновление задач запущено (каждые 2 минуты)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Остановка автоматического обновления
|
||||||
|
function stopAutoUpdate() {
|
||||||
|
if (updateInterval) {
|
||||||
|
clearInterval(updateInterval);
|
||||||
|
updateInterval = null;
|
||||||
|
console.log('⏹️ Автообновление задач остановлено');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция автоматического обновления
|
||||||
|
async function autoUpdateTasks() {
|
||||||
|
// Предотвращаем множественные обновления
|
||||||
|
if (isUpdating) {
|
||||||
|
console.log('⏳ Обновление уже выполняется, пропускаем...');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, активна ли секция
|
||||||
|
const mytasksSection = document.getElementById('mytasks-section');
|
||||||
|
const runtasksSection = document.getElementById('runtasks-section');
|
||||||
|
if ((!mytasksSection || !mytasksSection.classList.contains('active')) &&
|
||||||
|
(!runtasksSection || !runtasksSection.classList.contains('active'))) {
|
||||||
|
console.log('⏸️ Секция неактивна, автообновление приостановлено');
|
||||||
|
stopAutoUpdate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isUpdating = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('🔄 Автообновление задач...', new Date().toLocaleTimeString());
|
||||||
|
|
||||||
|
await loadTasks(); // просто перезагружаем с текущими фильтрами
|
||||||
|
|
||||||
|
// Показываем уведомление об обновлении
|
||||||
|
showUpdateNotification();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Ошибка автообновления:', error);
|
||||||
|
} finally {
|
||||||
|
isUpdating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показ уведомления об обновлении
|
||||||
|
function showUpdateNotification() {
|
||||||
|
const notification = document.createElement('div');
|
||||||
|
notification.className = 'update-notification';
|
||||||
|
notification.innerHTML = `
|
||||||
|
<span>🔄 Данные обновлены</span>
|
||||||
|
<span class="update-time">${new Date().toLocaleTimeString()}</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(notification);
|
||||||
|
|
||||||
|
// Анимация появления и исчезновения
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.classList.add('show');
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.classList.remove('show');
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.remove();
|
||||||
|
}, 300);
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция фильтрации для "Мои задачи" - просто перезагружает с текущими фильтрами
|
||||||
|
function filterMyTasks() {
|
||||||
|
loadTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция фильтрации для "Задачи для исполнения"
|
||||||
|
function filterRunTasks() {
|
||||||
|
loadTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Рендеринг для "Мои задачи"
|
||||||
|
function renderMyTasks() {
|
||||||
|
const container = document.getElementById('mytasks-list');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (!window.tasks || window.tasks.length === 0) {
|
||||||
|
container.innerHTML = '<div class="loading">У вас пока нет созданных задач</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Используем общую функцию рендеринга, если она есть
|
||||||
|
if (typeof renderTasksInContainer === 'function') {
|
||||||
|
renderTasksInContainer('mytasks-list', window.tasks);
|
||||||
|
} else {
|
||||||
|
// Запасной вариант: рендерим прямо здесь (упрощённо)
|
||||||
|
renderMyTasksSimple(window.tasks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Упрощённый рендеринг для "Мои задачи" (если нет общей функции)
|
||||||
|
function renderMyTasksSimple(tasks) {
|
||||||
|
const container = document.getElementById('mytasks-list');
|
||||||
|
|
||||||
|
// Сортируем задачи по дате создания (новые сверху)
|
||||||
|
const sortedTasks = [...tasks].sort((a, b) =>
|
||||||
|
new Date(b.created_at || 0) - new Date(a.created_at || 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
container.innerHTML = sortedTasks.map(task => {
|
||||||
|
const isExpanded = expandedMyTasks.has(task.id);
|
||||||
|
const overallStatus = getTaskOverallStatus(task);
|
||||||
|
const statusClass = getStatusClass(overallStatus);
|
||||||
|
const isClosed = task.closed_at !== null;
|
||||||
|
const isCopy = task.original_task_id !== null;
|
||||||
|
|
||||||
|
const timeLeftInfo = getTimeLeftInfo(task);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="task-card" data-task-id="${task.id}">
|
||||||
|
<div class="task-header">
|
||||||
|
<div class="task-title" onclick="toggleMyTask(${task.id})" style="cursor: pointer; display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<span class="task-number">Задача №${task.id}</span>
|
||||||
|
<strong>${task.title || 'Без названия'}</strong>
|
||||||
|
${task.task_type ? `<span class="task-type-badge ${task.task_type}">${getTaskTypeDisplayName(task.task_type)}</span>` : ''}
|
||||||
|
${isClosed ? '<span class="closed-badge">Закрыта</span>' : ''}
|
||||||
|
${isCopy ? '<span class="copy-badge">Копия</span>' : ''}
|
||||||
|
${timeLeftInfo ? `<span class="deadline-badge ${timeLeftInfo.class}">${timeLeftInfo.text}</span>` : ''}
|
||||||
|
${task.assignments && task.assignments.length > 0 ?
|
||||||
|
`<span class="task-number">${task.assignments.map(a => a.user_name).join(', ')}</span>` : ''
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<span class="task-status ${statusClass}">
|
||||||
|
Выполнить до: ${formatDateTime(task.due_date || task.created_at)}
|
||||||
|
</span>
|
||||||
|
<div class="expand-icon" style="margin-left: 10px; transition: transform 0.3s; transform: rotate(${isExpanded ? '180deg' : '0deg'});">
|
||||||
|
▼
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="task-content ${isExpanded ? 'expanded' : ''}">
|
||||||
|
${isExpanded ? `
|
||||||
|
<div class="task-actions">
|
||||||
|
<button class="copy-btn" onclick="openTaskChat(${task.id})" title="Открыть чат">💬</button>
|
||||||
|
<button class="add-file-btn" onclick="openAddFileModal(${task.id})" title="Добавить файл">📎</button>
|
||||||
|
${currentUser && currentUser.login === 'minicrm' ?
|
||||||
|
`<button class="edit-btn" onclick="openEditModal(${task.id})" title="Редактировать">✏️</button>` : ''
|
||||||
|
}
|
||||||
|
${currentUser && currentUser.login === 'kalugin.o' ?
|
||||||
|
`<button class="manage-assignees-btn" onclick="openManageAssigneesModal(${task.id})" title="Управление исполнителями">👥</button>` : ''
|
||||||
|
}
|
||||||
|
${currentUser && (currentUser.role === 'tasks' || currentUser.role === 'admin') ?
|
||||||
|
`<button class="manage-assignees-btn" onclick="assignAdd_openModal(${task.id})" title="Управление исполнителями">🧑💼➕Добавить</button>` : ''
|
||||||
|
}
|
||||||
|
${currentUser && (currentUser.role === 'tasks' || currentUser.role === 'admin') ?
|
||||||
|
`<button class="manage-assignees-btn" onclick="assignRemove_openModal(${task.id})" title="Управление исполнителями">🧑💼❌Удалить</button>` : ''
|
||||||
|
}
|
||||||
|
<button class="copy-btn" onclick="openCopyModal(${task.id})" title="Создать копию">📋</button>
|
||||||
|
${currentUser && currentUser.login === 'minicrm' ?
|
||||||
|
`<button class="rework-btn" onclick="openReworkModal(${task.id})" title="Вернуть на доработку">🔄</button>` : ''
|
||||||
|
}
|
||||||
|
${currentUser && currentUser.login === 'minicrm' ?
|
||||||
|
`<button class="close-btn" onclick="closeTask(${task.id})" title="Закрыть задачу">🔒</button>` : ''
|
||||||
|
}
|
||||||
|
<button class="delete-btn" onclick="deleteTask(${task.id})" title="Удалить">🗑️</button>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${isCopy && task.original_task_title ? `
|
||||||
|
<div class="task-original">
|
||||||
|
<small>Оригинал: "${task.original_task_title}" (создал: ${task.original_creator_name})</small>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<div class="task-description">${task.description || 'Нет описания'}</div>
|
||||||
|
|
||||||
|
${task.rework_comment ? `
|
||||||
|
<div class="rework-comment">
|
||||||
|
<strong>Комментарий к доработке:</strong> ${task.rework_comment}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<div class="file-list" id="files-${task.id}">
|
||||||
|
<strong>Файлы:</strong>
|
||||||
|
${task.files && task.files.length > 0 ?
|
||||||
|
(typeof renderGroupedFilesWithDelete === 'function' ? renderGroupedFilesWithDelete(task) : renderGroupedFiles(task))
|
||||||
|
: '<span class="no-files">нет файлов</span>'
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="task-assignments">
|
||||||
|
<strong>Исполнители:</strong>
|
||||||
|
${task.assignments && task.assignments.length > 0 ?
|
||||||
|
renderAssignmentList(task.assignments, task.id, true) :
|
||||||
|
'<div>Не назначены</div>'
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="task-meta">
|
||||||
|
<small>
|
||||||
|
Создана: ${formatDateTime(task.start_date || task.created_at)}
|
||||||
|
| Выполнить до: ${formatDateTime(task.due_date || task.created_at)}
|
||||||
|
| Автор: ${task.creator_name}
|
||||||
|
| Тип: ${task.task_type ? `<span class="task-type-badge ${task.task_type}">${getTaskTypeDisplayName(task.task_type)}</span>` : ''}
|
||||||
|
</small>
|
||||||
|
${task.closed_at ? `<br><small>Закрыта: ${formatDateTime(task.closed_at)}</small>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Загружаем файлы для развернутых задач
|
||||||
|
expandedMyTasks.forEach(taskId => {
|
||||||
|
if (window.tasks.some(t => t.id == taskId)) {
|
||||||
|
loadTaskFiles(taskId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для переключения развернутого состояния задачи
|
||||||
|
function toggleMyTask(taskId) {
|
||||||
|
if (expandedMyTasks.has(taskId)) {
|
||||||
|
expandedMyTasks.delete(taskId);
|
||||||
|
} else {
|
||||||
|
expandedMyTasks.add(taskId);
|
||||||
|
loadTaskFiles(taskId);
|
||||||
|
}
|
||||||
|
renderMyTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Остальные вспомогательные функции (getStatusClass, getTaskTypeDisplayName, formatDateTime, renderAssignment, filterAssignments, openAddFileModal, closeAddFileModal, openTaskChat, openEditModal, openCopyModal, openReworkModal, closeTask, deleteTask, updateStatus) остаются без изменений
|
||||||
|
// ...
|
||||||
|
|
||||||
|
// Экспортируем функции в глобальную область
|
||||||
|
window.showMyTasksSection = showMyTasksSection;
|
||||||
|
window.showRunTasksSection = showRunTasksSection;
|
||||||
|
window.filterMyTasks = filterMyTasks;
|
||||||
|
window.filterRunTasks = filterRunTasks;
|
||||||
|
window.toggleMyTask = toggleMyTask;
|
||||||
|
window.renderMyTasks = renderMyTasks;
|
||||||
29
public/loading-end.js
Normal file
29
public/loading-end.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// loading-end.js - Загружается последним
|
||||||
|
(function() {
|
||||||
|
// Отмечаем, что скрипт загружен
|
||||||
|
if (window.loadingScripts) {
|
||||||
|
window.loadingScripts.loaded++;
|
||||||
|
window.loadingScripts.updateProgress();
|
||||||
|
|
||||||
|
// Дополнительная проверка на ошибки загрузки
|
||||||
|
if (window.loadingScripts.errors.length > 0) {
|
||||||
|
console.warn('Некоторые скрипты не загрузились:', window.loadingScripts.errors);
|
||||||
|
|
||||||
|
// Показываем предупреждение, но не блокируем интерфейс
|
||||||
|
const statusDisplay = document.getElementById('loading-status');
|
||||||
|
if (statusDisplay) {
|
||||||
|
statusDisplay.innerHTML = '<span class="status-icon">⚠️</span> Загружено с предупреждениями';
|
||||||
|
statusDisplay.style.color = '#ffd700';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что все скрипты действительно загружены
|
||||||
|
setTimeout(() => {
|
||||||
|
if (window.loadingScripts && window.loadingScripts.loaded < window.loadingScripts.total) {
|
||||||
|
// Принудительно завершаем загрузку через 10 секунд
|
||||||
|
window.loadingScripts.loaded = window.loadingScripts.total;
|
||||||
|
window.loadingScripts.updateProgress();
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
|
})();
|
||||||
848
public/loading-start.js
Normal file
848
public/loading-start.js
Normal file
@@ -0,0 +1,848 @@
|
|||||||
|
// loading-start.js - Загружается первым в <head>
|
||||||
|
(function() {
|
||||||
|
// Конфигурация
|
||||||
|
const CONFIG = {
|
||||||
|
maxRetries: 3,
|
||||||
|
retryDelay: 5000,
|
||||||
|
timeout: 10000,
|
||||||
|
showWarnings: true,
|
||||||
|
maxLoadingTime: 15000, // Максимум 15 секунд
|
||||||
|
minLoadingTime: 1000 // Минимум 5 секунд
|
||||||
|
};
|
||||||
|
|
||||||
|
// Генерация случайных цветов для градиента
|
||||||
|
function getRandomGradient() {
|
||||||
|
// Базовая палитра цветов для школы/образования
|
||||||
|
const colorPalettes = [
|
||||||
|
// Сине-фиолетовая гамма
|
||||||
|
{ start: '#667eea', end: '#764ba2' },
|
||||||
|
{ start: '#6B73FF', end: '#000DFF' },
|
||||||
|
{ start: '#5f72bd', end: '#9b23ea' },
|
||||||
|
|
||||||
|
// Зелено-голубая гамма
|
||||||
|
{ start: '#11998e', end: '#38ef7d' },
|
||||||
|
{ start: '#0BA360', end: '#3CBBB2' },
|
||||||
|
{ start: '#1D976C', end: '#93F9B9' },
|
||||||
|
|
||||||
|
// Оранжево-розовая гамма
|
||||||
|
{ start: '#f46b45', end: '#eea849' },
|
||||||
|
{ start: '#FF512F', end: '#DD2476' },
|
||||||
|
{ start: '#F09819', end: '#EDDE5D' },
|
||||||
|
|
||||||
|
// Красная гамма
|
||||||
|
{ start: '#cb2d3e', end: '#ef473a' },
|
||||||
|
{ start: '#FF416C', end: '#FF4B2B' },
|
||||||
|
{ start: '#DC2424', end: '#4A569D' },
|
||||||
|
|
||||||
|
// Фиолетовая гамма
|
||||||
|
{ start: '#8E2DE2', end: '#4A00E0' },
|
||||||
|
{ start: '#A770EF', end: '#CF8BF3' },
|
||||||
|
{ start: '#7F00FF', end: '#E100FF' },
|
||||||
|
|
||||||
|
// Морская гамма
|
||||||
|
{ start: '#00B4DB', end: '#0083B0' },
|
||||||
|
{ start: '#00C9FF', end: '#92FE9D' },
|
||||||
|
{ start: '#1FA2FF', end: '#12D8FA' },
|
||||||
|
|
||||||
|
// Осенняя гамма
|
||||||
|
{ start: '#FC4A1A', end: '#F7B733' },
|
||||||
|
{ start: '#ED213A', end: '#93291E' },
|
||||||
|
{ start: '#F12711', end: '#F5AF19' },
|
||||||
|
|
||||||
|
// Ночная гамма
|
||||||
|
{ start: '#141E30', end: '#243B55' },
|
||||||
|
{ start: '#0F2027', end: '#203A43' },
|
||||||
|
{ start: '#1c1c1c', end: '#3a3a3a' },
|
||||||
|
|
||||||
|
// Пастельная гамма
|
||||||
|
{ start: '#a8c0ff', end: '#3f2b96' },
|
||||||
|
{ start: '#c84e89', end: '#F15F79' },
|
||||||
|
{ start: '#b993d0', end: '#8ca6db' },
|
||||||
|
|
||||||
|
// Школьная гамма
|
||||||
|
{ start: '#56ab2f', end: '#a8e063' },
|
||||||
|
{ start: '#1D976C', end: '#93F9B9' },
|
||||||
|
{ start: '#4CA1AF', end: '#2C3E50' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Случайная палитра
|
||||||
|
const palette = colorPalettes[Math.floor(Math.random() * colorPalettes.length)];
|
||||||
|
|
||||||
|
// Случайное смещение градиента
|
||||||
|
const directions = [
|
||||||
|
'135deg', '90deg', '45deg', '180deg', '225deg', '270deg', '315deg'
|
||||||
|
];
|
||||||
|
const direction = directions[Math.floor(Math.random() * directions.length)];
|
||||||
|
|
||||||
|
return {
|
||||||
|
background: `linear-gradient(${direction}, ${palette.start}, ${palette.end})`,
|
||||||
|
start: palette.start,
|
||||||
|
end: palette.end,
|
||||||
|
direction: direction
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Генерация случайного цвета для прогресс-бара
|
||||||
|
function getRandomProgressColor() {
|
||||||
|
const colors = [
|
||||||
|
'#fff', '#ffd700', '#00ff87', '#00ffff', '#ff69b4', '#ffa500',
|
||||||
|
'#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4', '#ffeaa7', '#dfe6e9'
|
||||||
|
];
|
||||||
|
return colors[Math.floor(Math.random() * colors.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Генерация случайного цвета для текста
|
||||||
|
function getRandomTextColor() {
|
||||||
|
const colors = [
|
||||||
|
'white', '#f0f0f0', '#ffeaa7', '#dfe6e9', '#b2bec3', '#ffffff'
|
||||||
|
];
|
||||||
|
return colors[Math.floor(Math.random() * colors.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем случайные цвета для этой загрузки
|
||||||
|
const gradientColors = getRandomGradient();
|
||||||
|
const progressColor = getRandomProgressColor();
|
||||||
|
const textColor = getRandomTextColor();
|
||||||
|
const accentColor = getRandomProgressColor();
|
||||||
|
|
||||||
|
// Статусы для динамического отображения
|
||||||
|
const LOADING_STATUSES = [
|
||||||
|
{ emoji: '📦', text: 'Загрузка компонентов...', weight: 10 },
|
||||||
|
{ emoji: '🔧', text: 'Настройка модулей...', weight: 15 },
|
||||||
|
{ emoji: '⚙️', text: 'Инициализация системы...', weight: 20 },
|
||||||
|
{ emoji: '🔌', text: 'Подключение к серверу...', weight: 25 },
|
||||||
|
{ emoji: '📊', text: 'Загрузка данных...', weight: 30 },
|
||||||
|
{ emoji: '🧩', text: 'Сборка интерфейса...', weight: 35 },
|
||||||
|
{ emoji: '🎨', text: 'Отрисовка элементов...', weight: 40 },
|
||||||
|
{ emoji: '🔐', text: 'Проверка безопасности...', weight: 45 },
|
||||||
|
{ emoji: '📁', text: 'Загрузка файлов...', weight: 50 },
|
||||||
|
{ emoji: '🔄', text: 'Синхронизация...', weight: 55 },
|
||||||
|
{ emoji: '🧪', text: 'Тестирование модулей...', weight: 60 },
|
||||||
|
{ emoji: '📝', text: 'Подготовка задач...', weight: 65 },
|
||||||
|
{ emoji: '👥', text: 'Загрузка пользователей...', weight: 70 },
|
||||||
|
{ emoji: '📅', text: 'Обновление календаря...', weight: 75 },
|
||||||
|
{ emoji: '🔔', text: 'Настройка уведомлений...', weight: 80 },
|
||||||
|
{ emoji: '💾', text: 'Сохранение кэша...', weight: 85 },
|
||||||
|
{ emoji: '✨', text: 'Финальная обработка...', weight: 90 },
|
||||||
|
{ emoji: '✅', text: 'Наливаем кофе...', weight: 98 },
|
||||||
|
{ emoji: '✅', text: 'Завершено!', weight: 100 }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Дополнительные случайные статусы
|
||||||
|
const RANDOM_STATUSES = [
|
||||||
|
{ emoji: '☕', text: 'Наливаем кофе...' },
|
||||||
|
{ emoji: '🧹', text: 'Подметаем баги...' },
|
||||||
|
{ emoji: '🎮', text: 'Играем в косынку...' },
|
||||||
|
{ emoji: '📚', text: 'Читаем документацию...' },
|
||||||
|
{ emoji: '🎵', text: 'Включаем музыку...' },
|
||||||
|
{ emoji: '🧘', text: 'Медитируем...' },
|
||||||
|
{ emoji: '🏋️', text: 'Качаем бицепс...' },
|
||||||
|
{ emoji: '🍕', text: 'Заказываем пиццу...' },
|
||||||
|
{ emoji: '🌴', text: 'Улетаем в отпуск...' },
|
||||||
|
{ emoji: '🎪', text: 'Устраиваем цирк...' },
|
||||||
|
{ emoji: '🎭', text: 'Играем спектакль...' },
|
||||||
|
{ emoji: '🎯', text: 'Целимся в баги...' },
|
||||||
|
{ emoji: '🎲', text: 'Бросаем кубик...' },
|
||||||
|
{ emoji: '🎰', text: 'Крутим барабан...' },
|
||||||
|
{ emoji: '🦄', text: 'Ловим единорога...' },
|
||||||
|
{ emoji: '🌈', text: 'Красим радугу...' },
|
||||||
|
{ emoji: '🪄', text: 'Колдуем...' },
|
||||||
|
{ emoji: '🎩', text: 'Достаем кролика...' },
|
||||||
|
{ emoji: '🕯️', text: 'Зажигаем свечи...' },
|
||||||
|
{ emoji: '🔮', text: 'Гадаем на баги...' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Функция для инициализации после загрузки DOM
|
||||||
|
function initializeLoadingOverlay() {
|
||||||
|
// Проверяем, существует ли уже оверлей
|
||||||
|
if (document.getElementById('loading-overlay')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем контейнер для анимации загрузки
|
||||||
|
const loadingOverlay = document.createElement('div');
|
||||||
|
loadingOverlay.id = 'loading-overlay';
|
||||||
|
loadingOverlay.innerHTML = `
|
||||||
|
<div class="loading-container">
|
||||||
|
<div class="loading-spinner">
|
||||||
|
<div class="spinner" id="loading-spinner"></div>
|
||||||
|
</div>
|
||||||
|
<div class="loading-text">
|
||||||
|
<h2 id="loading-title">School CRM</h2>
|
||||||
|
<p>Загрузка сервиса управления задачами...</p>
|
||||||
|
<div class="loading-progress">
|
||||||
|
<div class="progress-bar" id="loading-progress-bar"></div>
|
||||||
|
</div>
|
||||||
|
<div class="loading-status" id="loading-status">
|
||||||
|
<span class="status-icon">📦</span> Инициализация...
|
||||||
|
</div>
|
||||||
|
<div class="loading-percent" id="loading-percent">0%</div>
|
||||||
|
<div class="loading-details" id="loading-details"></div>
|
||||||
|
</div>
|
||||||
|
<div class="loading-footer">
|
||||||
|
<p>МАОУ - СОШ № 25 | Версия 0.9</p>
|
||||||
|
<p class="loading-tip" id="loading-tip"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Добавляем стили для анимации со случайными цветами
|
||||||
|
const styles = document.createElement('style');
|
||||||
|
styles.textContent = `
|
||||||
|
#loading-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: ${gradientColors.background};
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 9999;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
transition: opacity 0.5s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
#loading-overlay.fade-out {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-container {
|
||||||
|
text-align: center;
|
||||||
|
color: ${textColor};
|
||||||
|
max-width: 500px;
|
||||||
|
width: 90%;
|
||||||
|
padding: 40px;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 20px;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
animation: slideUp 0.8s ease-out;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
transform: translateY(50px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
margin: 0 auto;
|
||||||
|
border: 5px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top-color: ${progressColor};
|
||||||
|
border-left-color: ${accentColor};
|
||||||
|
border-right-color: ${accentColor};
|
||||||
|
animation: spin 1s ease-in-out infinite;
|
||||||
|
box-shadow: 0 0 20px ${progressColor}40;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text h2 {
|
||||||
|
font-size: 32px;
|
||||||
|
margin: 0 0 15px;
|
||||||
|
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
letter-spacing: 2px;
|
||||||
|
background: linear-gradient(135deg, ${textColor}, ${progressColor});
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text p {
|
||||||
|
font-size: 18px;
|
||||||
|
margin: 0 0 30px;
|
||||||
|
opacity: 0.9;
|
||||||
|
color: ${textColor};
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-progress {
|
||||||
|
width: 100%;
|
||||||
|
height: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 20px 0;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
width: 0%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, ${progressColor}, ${accentColor}, ${progressColor});
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
animation: progressPulse 2s ease-in-out infinite;
|
||||||
|
background-size: 200% 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes progressPulse {
|
||||||
|
0%, 100% { opacity: 1; background-position: 0% 50%; }
|
||||||
|
50% { opacity: 0.8; background-position: 100% 50%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-status {
|
||||||
|
font-size: 18px;
|
||||||
|
margin: 15px 0;
|
||||||
|
min-height: 28px;
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: ${textColor};
|
||||||
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-percent {
|
||||||
|
font-size: 42px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 10px 0;
|
||||||
|
text-shadow: 2px 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
background: linear-gradient(135deg, ${textColor}, ${progressColor});
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
animation: percentPulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes percentPulse {
|
||||||
|
0%, 100% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.05); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-details {
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 15px;
|
||||||
|
opacity: 0.8;
|
||||||
|
min-height: 20px;
|
||||||
|
color: ${textColor};
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 8px 15px;
|
||||||
|
border-radius: 20px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-footer {
|
||||||
|
margin-top: 30px;
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.8;
|
||||||
|
color: ${textColor};
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-footer p {
|
||||||
|
margin: 5px 0;
|
||||||
|
color: ${textColor};
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-tip {
|
||||||
|
font-size: 13px;
|
||||||
|
font-style: italic;
|
||||||
|
color: ${textColor};
|
||||||
|
animation: fadeInOut 3s ease-in-out infinite;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 5px 15px;
|
||||||
|
border-radius: 20px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInOut {
|
||||||
|
0%, 100% { opacity: 0.7; transform: translateY(0); }
|
||||||
|
50% { opacity: 1; transform: translateY(-2px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon {
|
||||||
|
display: inline-block;
|
||||||
|
animation: bounce 1s ease infinite;
|
||||||
|
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bounce {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-5px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-button {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border: 2px solid ${textColor};
|
||||||
|
color: ${textColor};
|
||||||
|
padding: 12px 30px;
|
||||||
|
border-radius: 30px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-top: 20px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-button:hover {
|
||||||
|
background: ${progressColor};
|
||||||
|
color: ${gradientColors.start};
|
||||||
|
transform: scale(1.05);
|
||||||
|
border-color: ${progressColor};
|
||||||
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning {
|
||||||
|
color: #ffd700;
|
||||||
|
text-shadow: 0 0 10px #ffd700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #ff6b6b;
|
||||||
|
text-shadow: 0 0 10px #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-dots {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-dots:after {
|
||||||
|
content: '...';
|
||||||
|
animation: dots 1.5s steps(4, end) infinite;
|
||||||
|
font-weight: bold;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dots {
|
||||||
|
0%, 20% { content: ''; }
|
||||||
|
40% { content: '.'; }
|
||||||
|
60% { content: '..'; }
|
||||||
|
80%, 100% { content: '...'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Добавляем случайные частицы для эффекта */
|
||||||
|
.loading-container::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -50%;
|
||||||
|
left: -50%;
|
||||||
|
width: 200%;
|
||||||
|
height: 200%;
|
||||||
|
background: radial-gradient(circle, ${progressColor}20 1px, transparent 1px);
|
||||||
|
background-size: 50px 50px;
|
||||||
|
animation: particles 20s linear infinite;
|
||||||
|
opacity: 0.1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes particles {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Добавляем элементы в DOM с проверкой
|
||||||
|
if (document.head) {
|
||||||
|
document.head.appendChild(styles);
|
||||||
|
} else {
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.head.appendChild(styles);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.body) {
|
||||||
|
document.body.appendChild(loadingOverlay);
|
||||||
|
} else {
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.body.appendChild(loadingOverlay);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Логируем использованные цвета
|
||||||
|
console.log('🎨 Случайные цвета загрузки:', {
|
||||||
|
gradient: gradientColors,
|
||||||
|
progress: progressColor,
|
||||||
|
accent: accentColor,
|
||||||
|
text: textColor
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализируем оверлей после загрузки DOM
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initializeLoadingOverlay);
|
||||||
|
} else {
|
||||||
|
initializeLoadingOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Массив полезных советов
|
||||||
|
const TIPS = [
|
||||||
|
'💡 Совет: Используйте фильтры для быстрого поиска задач',
|
||||||
|
'💡 Совет: Можно прикреплять до 15 файлов к задаче',
|
||||||
|
'💡 Совет: Настраивайте уведомления в личном кабинете',
|
||||||
|
'💡 Совет: Используйте Канбан-доску для визуализации задач',
|
||||||
|
'💡 Совет: Отмечайте важные задачи цветными метками',
|
||||||
|
'💡 Совет: Следите за просроченными задачами в отдельной вкладке',
|
||||||
|
'💡 Совет: Создавайте шаблоны для частых типов задач',
|
||||||
|
'💡 Совет: Используйте поиск для быстрого доступа к задачам',
|
||||||
|
'💡 Совет: Комментируйте задачи для уточнения деталей',
|
||||||
|
'💡 Совет: Прикрепляйте скриншоты к задачам в ИТ-отдел',
|
||||||
|
'💡 Совет: Проверяйте уведомления на email ежедневно',
|
||||||
|
'💡 Совет: Архивируйте выполненные задачи для чистоты списка',
|
||||||
|
'🌟 Факт: Сегодняшний градиент - один из 30 возможных',
|
||||||
|
'🎨 Факт: Цвета загрузки уникальны для каждого обновления',
|
||||||
|
'🌈 Факт: У вас сегодня особенная цветовая схема'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Счетчик загруженных скриптов
|
||||||
|
window.loadingScripts = {
|
||||||
|
total: 0,
|
||||||
|
loaded: 0,
|
||||||
|
errors: [],
|
||||||
|
retryCounts: {},
|
||||||
|
startTime: Date.now(),
|
||||||
|
scripts: [],
|
||||||
|
animationInterval: null,
|
||||||
|
tipInterval: null,
|
||||||
|
loadingComplete: false,
|
||||||
|
minTimeElapsed: false,
|
||||||
|
|
||||||
|
updateProgress: function() {
|
||||||
|
const percent = Math.min(Math.round((this.loaded / this.total) * 100), 100);
|
||||||
|
const progressBar = document.getElementById('loading-progress-bar');
|
||||||
|
const percentDisplay = document.getElementById('loading-percent');
|
||||||
|
const statusDisplay = document.getElementById('loading-status');
|
||||||
|
const detailsDisplay = document.getElementById('loading-details');
|
||||||
|
|
||||||
|
if (progressBar) progressBar.style.width = percent + '%';
|
||||||
|
if (percentDisplay) percentDisplay.textContent = percent + '%';
|
||||||
|
|
||||||
|
// Обновляем статус в зависимости от процента загрузки
|
||||||
|
if (statusDisplay) {
|
||||||
|
if (this.errors.length > 0) {
|
||||||
|
statusDisplay.innerHTML = '<span class="status-icon warning">⚠️</span> Проблемы с загрузкой. Пытаемся восстановить...';
|
||||||
|
} else {
|
||||||
|
// Находим подходящий статус по проценту загрузки
|
||||||
|
const currentStatus = LOADING_STATUSES.find(s => s.weight >= percent) || LOADING_STATUSES[LOADING_STATUSES.length - 1];
|
||||||
|
statusDisplay.innerHTML = `<span class="status-icon">${currentStatus.emoji}</span> ${currentStatus.text} <span class="progress-dots"></span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем детальную информацию
|
||||||
|
if (detailsDisplay) {
|
||||||
|
const loadedScripts = this.loaded;
|
||||||
|
const totalScripts = this.total;
|
||||||
|
const errors = this.errors.length;
|
||||||
|
const timeElapsed = ((Date.now() - this.startTime) / 3000).toFixed(1);
|
||||||
|
|
||||||
|
let detailsHtml = `📊 ${loadedScripts}/${totalScripts} модулей • ⏱️ ${timeElapsed}с`;
|
||||||
|
|
||||||
|
if (errors > 0) {
|
||||||
|
detailsHtml += `<br><span class="warning">⚠️ Ошибок: ${errors}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
detailsDisplay.innerHTML = detailsHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, все ли скрипты загружены
|
||||||
|
if (this.loaded >= this.total && this.total > 0 && !this.loadingComplete) {
|
||||||
|
this.loadingComplete = true;
|
||||||
|
this.checkAndFinishLoading();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
checkAndFinishLoading: function() {
|
||||||
|
const elapsed = Date.now() - this.startTime;
|
||||||
|
|
||||||
|
// Если прошло минимум 5 секунд, завершаем загрузку
|
||||||
|
if (elapsed >= CONFIG.minLoadingTime) {
|
||||||
|
this.finishLoading();
|
||||||
|
} else {
|
||||||
|
// Иначе ждем оставшееся время
|
||||||
|
const remainingTime = CONFIG.minLoadingTime - elapsed;
|
||||||
|
|
||||||
|
// Показываем сообщение о финальной подготовке
|
||||||
|
const statusDisplay = document.getElementById('loading-status');
|
||||||
|
if (statusDisplay) {
|
||||||
|
statusDisplay.innerHTML = '<span class="status-icon">✨</span> Финальная подготовка интерфейса... <span class="progress-dots"></span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.finishLoading();
|
||||||
|
}, remainingTime);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
startDynamicLoading: function() {
|
||||||
|
// Запускаем анимацию прогресса
|
||||||
|
let fakePercent = 0;
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
this.animationInterval = setInterval(() => {
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
const realPercent = Math.min(Math.round((this.loaded / this.total) * 100), 100);
|
||||||
|
|
||||||
|
// Рассчитываем фейковый процент на основе реального и времени
|
||||||
|
if (elapsed < CONFIG.maxLoadingTime && !this.loadingComplete) {
|
||||||
|
// Фейковый процент растет медленнее к концу
|
||||||
|
const timePercent = (elapsed / CONFIG.maxLoadingTime) * 100;
|
||||||
|
fakePercent = Math.min(
|
||||||
|
Math.max(realPercent, Math.floor(timePercent * 0.7 + Math.random() * 10)),
|
||||||
|
99 // Никогда не достигаем 100% фейком
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Если загрузка завершена или прошло 15 секунд, показываем реальный процент
|
||||||
|
fakePercent = realPercent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем прогресс бар с фейковым процентом
|
||||||
|
const progressBar = document.getElementById('loading-progress-bar');
|
||||||
|
const percentDisplay = document.getElementById('loading-percent');
|
||||||
|
|
||||||
|
if (progressBar) progressBar.style.width = fakePercent + '%';
|
||||||
|
if (percentDisplay) percentDisplay.textContent = fakePercent + '%';
|
||||||
|
|
||||||
|
// Случайно меняем статус для динамики
|
||||||
|
if (Math.random() > 0.7 && !this.loadingComplete) {
|
||||||
|
const statusDisplay = document.getElementById('loading-status');
|
||||||
|
if (statusDisplay && this.errors.length === 0) {
|
||||||
|
const randomStatus = RANDOM_STATUSES[Math.floor(Math.random() * RANDOM_STATUSES.length)];
|
||||||
|
statusDisplay.innerHTML = `<span class="status-icon">${randomStatus.emoji}</span> ${randomStatus.text} <span class="progress-dots"></span>`;
|
||||||
|
|
||||||
|
// Возвращаем нормальный статус через 2 секунды
|
||||||
|
setTimeout(() => {
|
||||||
|
const currentStatus = LOADING_STATUSES.find(s => s.weight >= fakePercent) || LOADING_STATUSES[LOADING_STATUSES.length - 1];
|
||||||
|
if (statusDisplay && !this.loadingComplete) {
|
||||||
|
statusDisplay.innerHTML = `<span class="status-icon">${currentStatus.emoji}</span> ${currentStatus.text} <span class="progress-dots"></span>`;
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, не пора ли закончить по времени
|
||||||
|
if (elapsed >= CONFIG.maxLoadingTime && !this.loadingComplete) {
|
||||||
|
this.loadingComplete = true;
|
||||||
|
this.finishDynamicLoading();
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
// Запускаем смену советов
|
||||||
|
this.tipInterval = setInterval(() => {
|
||||||
|
const tipElement = document.getElementById('loading-tip');
|
||||||
|
if (tipElement) {
|
||||||
|
const randomTip = TIPS[Math.floor(Math.random() * TIPS.length)];
|
||||||
|
tipElement.textContent = randomTip;
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
},
|
||||||
|
|
||||||
|
finishDynamicLoading: function() {
|
||||||
|
if (this.animationInterval) {
|
||||||
|
clearInterval(this.animationInterval);
|
||||||
|
this.animationInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показываем реальный прогресс
|
||||||
|
const realPercent = Math.min(Math.round((this.loaded / this.total) * 100), 100);
|
||||||
|
const progressBar = document.getElementById('loading-progress-bar');
|
||||||
|
const percentDisplay = document.getElementById('loading-percent');
|
||||||
|
|
||||||
|
if (progressBar) progressBar.style.width = realPercent + '%';
|
||||||
|
if (percentDisplay) percentDisplay.textContent = realPercent + '%';
|
||||||
|
|
||||||
|
// Завершаем загрузку
|
||||||
|
this.finishLoading();
|
||||||
|
},
|
||||||
|
|
||||||
|
finishLoading: function() {
|
||||||
|
// Останавливаем интервалы
|
||||||
|
if (this.animationInterval) {
|
||||||
|
clearInterval(this.animationInterval);
|
||||||
|
this.animationInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.tipInterval) {
|
||||||
|
clearInterval(this.tipInterval);
|
||||||
|
this.tipInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadingTime = ((Date.now() - this.startTime) / 1000).toFixed(1);
|
||||||
|
const statusDisplay = document.getElementById('loading-status');
|
||||||
|
const percentDisplay = document.getElementById('loading-percent');
|
||||||
|
|
||||||
|
if (this.errors.length > 0) {
|
||||||
|
if (statusDisplay) {
|
||||||
|
statusDisplay.innerHTML = `<span class="status-icon warning">⚠️</span> Загружено с ошибками (${this.errors.length}) за ${loadingTime}с`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (percentDisplay) {
|
||||||
|
percentDisplay.innerHTML = '⚠️';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показываем кнопку перезагрузки
|
||||||
|
const detailsDisplay = document.getElementById('loading-details');
|
||||||
|
if (detailsDisplay) {
|
||||||
|
const retryButton = document.createElement('button');
|
||||||
|
retryButton.className = 'retry-button';
|
||||||
|
retryButton.innerHTML = '🔄 Перезагрузить страницу';
|
||||||
|
retryButton.onclick = () => window.location.reload();
|
||||||
|
detailsDisplay.appendChild(retryButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Автоматическая перезагрузка через 5 секунд
|
||||||
|
setTimeout(() => {
|
||||||
|
if (confirm('Некоторые компоненты не загрузились. Перезагрузить страницу?')) {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
} else {
|
||||||
|
if (statusDisplay) {
|
||||||
|
statusDisplay.innerHTML = `<span class="status-icon">✅</span> Загрузка завершена за ${loadingTime}с`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (percentDisplay) {
|
||||||
|
percentDisplay.innerHTML = '100%';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Плавно скрываем оверлей
|
||||||
|
setTimeout(() => {
|
||||||
|
const overlay = document.getElementById('loading-overlay');
|
||||||
|
if (overlay) {
|
||||||
|
overlay.classList.add('fade-out');
|
||||||
|
setTimeout(() => {
|
||||||
|
if (overlay && overlay.parentNode) {
|
||||||
|
overlay.parentNode.removeChild(overlay);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}, 800);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
retryScript: function(scriptSrc) {
|
||||||
|
if (!this.retryCounts[scriptSrc]) {
|
||||||
|
this.retryCounts[scriptSrc] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.retryCounts[scriptSrc] >= CONFIG.maxRetries) {
|
||||||
|
console.error(`Не удалось загрузить скрипт после ${CONFIG.maxRetries} попыток: ${scriptSrc}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.retryCounts[scriptSrc]++;
|
||||||
|
this.loaded--; // Уменьшаем счетчик загруженных
|
||||||
|
|
||||||
|
const statusDisplay = document.getElementById('loading-status');
|
||||||
|
if (statusDisplay) {
|
||||||
|
statusDisplay.innerHTML = `<span class="status-icon">🔄</span> Перезагрузка: ${scriptSrc.split('/').pop()} (попытка ${this.retryCounts[scriptSrc]})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем новый script элемент
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = scriptSrc;
|
||||||
|
script.async = false;
|
||||||
|
|
||||||
|
script.onload = () => {
|
||||||
|
this.loaded++;
|
||||||
|
this.errors = this.errors.filter(e => e !== scriptSrc);
|
||||||
|
this.updateProgress();
|
||||||
|
};
|
||||||
|
|
||||||
|
script.onerror = () => {
|
||||||
|
setTimeout(() => this.retryScript(scriptSrc), CONFIG.retryDelay);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.head.appendChild(script);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Перехватываем загрузку скриптов
|
||||||
|
const originalAppendChild = document.head ? document.head.appendChild : null;
|
||||||
|
|
||||||
|
if (originalAppendChild) {
|
||||||
|
document.head.appendChild = function(node) {
|
||||||
|
if (node.tagName === 'SCRIPT' && node.src) {
|
||||||
|
window.loadingScripts.total++;
|
||||||
|
window.loadingScripts.scripts.push(node.src);
|
||||||
|
|
||||||
|
// Устанавливаем таймаут для скрипта
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
console.warn(`Таймаут загрузки скрипта: ${node.src}`);
|
||||||
|
if (window.loadingScripts.retryScript(node.src)) {
|
||||||
|
// Удаляем старый скрипт
|
||||||
|
if (node.parentNode) {
|
||||||
|
node.parentNode.removeChild(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, CONFIG.timeout);
|
||||||
|
|
||||||
|
// Обновляем обработчики событий
|
||||||
|
const originalOnLoad = node.onload;
|
||||||
|
node.onload = function() {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
window.loadingScripts.loaded++;
|
||||||
|
window.loadingScripts.updateProgress();
|
||||||
|
if (originalOnLoad) originalOnLoad.apply(this, arguments);
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalOnError = node.onerror;
|
||||||
|
node.onerror = function() {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
window.loadingScripts.errors.push(node.src);
|
||||||
|
window.loadingScripts.updateProgress();
|
||||||
|
|
||||||
|
// Пытаемся перезагрузить скрипт
|
||||||
|
if (window.loadingScripts.retryScript(node.src)) {
|
||||||
|
// Удаляем старый скрипт
|
||||||
|
if (node.parentNode) {
|
||||||
|
node.parentNode.removeChild(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (originalOnError) originalOnError.apply(this, arguments);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return originalAppendChild.call(this, node);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем обработчик для DOMContentLoaded
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Запускаем динамическую загрузку
|
||||||
|
window.loadingScripts.startDynamicLoading();
|
||||||
|
|
||||||
|
// Устанавливаем максимальное время загрузки 15 секунд
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!window.loadingScripts.loadingComplete && window.loadingScripts.loaded < window.loadingScripts.total) {
|
||||||
|
window.loadingScripts.loadingComplete = true;
|
||||||
|
window.loadingScripts.finishDynamicLoading();
|
||||||
|
}
|
||||||
|
}, CONFIG.maxLoadingTime);
|
||||||
|
|
||||||
|
// Проверяем, не зависла ли загрузка
|
||||||
|
setTimeout(() => {
|
||||||
|
if (window.loadingScripts.loaded < window.loadingScripts.total) {
|
||||||
|
const unloadedScripts = window.loadingScripts.scripts.filter(src =>
|
||||||
|
!window.loadingScripts.retryCounts[src] ||
|
||||||
|
window.loadingScripts.retryCounts[src] < CONFIG.maxRetries
|
||||||
|
);
|
||||||
|
|
||||||
|
unloadedScripts.forEach(src => {
|
||||||
|
window.loadingScripts.retryScript(src);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
});
|
||||||
|
})();
|
||||||
BIN
public/login.jpg
Normal file
BIN
public/login.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
BIN
public/login.png
Normal file
BIN
public/login.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 564 KiB |
BIN
public/login2.png
Normal file
BIN
public/login2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 417 KiB |
587
public/main.js
Normal file
587
public/main.js
Normal file
@@ -0,0 +1,587 @@
|
|||||||
|
// main.js - Главный файл инициализации
|
||||||
|
window.currentTaskView = 'all'; // 'all', 'my_assigned', 'assigned_to_me'
|
||||||
|
// прикрепляет переменную к объекту window и использует существующее значение, если оно уже есть
|
||||||
|
// Кэш всех задач
|
||||||
|
// let allTasksCache = []; // Повторное выполнение
|
||||||
|
window.allTasksCache = window.allTasksCache || [];
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
checkAuth();
|
||||||
|
setupEventListeners();
|
||||||
|
|
||||||
|
// Инициализация выбора времени
|
||||||
|
initializeTimeSelectors();
|
||||||
|
|
||||||
|
// Инициализация фильтра
|
||||||
|
const taskViewFilter = document.getElementById('task-view-filter');
|
||||||
|
if (taskViewFilter) {
|
||||||
|
taskViewFilter.value = currentTaskView;
|
||||||
|
}
|
||||||
|
|
||||||
|
// По умолчанию показываем секцию задач
|
||||||
|
showSection('tasks');
|
||||||
|
});
|
||||||
|
|
||||||
|
function setupEventListeners() {
|
||||||
|
// Форма входа
|
||||||
|
const loginForm = document.getElementById('login-form');
|
||||||
|
if (loginForm && !loginForm._hasSubmitListener) {
|
||||||
|
loginForm.addEventListener('submit', login);
|
||||||
|
loginForm._hasSubmitListener = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Форма создания задачи
|
||||||
|
const createForm = document.getElementById('create-task-form');
|
||||||
|
if (createForm && !createForm._hasSubmitListener) {
|
||||||
|
createForm.addEventListener('submit', createTask);
|
||||||
|
createForm._hasSubmitListener = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Форма редактирования задачи
|
||||||
|
const editForm = document.getElementById('edit-task-form');
|
||||||
|
if (editForm && !editForm._hasSubmitListener) {
|
||||||
|
editForm.addEventListener('submit', updateTask);
|
||||||
|
editForm._hasSubmitListener = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Форма копирования задачи
|
||||||
|
const copyForm = document.getElementById('copy-task-form');
|
||||||
|
if (copyForm && !copyForm._hasSubmitListener) {
|
||||||
|
copyForm.addEventListener('submit', copyTask);
|
||||||
|
copyForm._hasSubmitListener = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Форма редактирования назначения
|
||||||
|
const editAssignmentForm = document.getElementById('edit-assignment-form');
|
||||||
|
if (editAssignmentForm && !editAssignmentForm._hasSubmitListener) {
|
||||||
|
editAssignmentForm.addEventListener('submit', updateAssignment);
|
||||||
|
editAssignmentForm._hasSubmitListener = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Форма доработки
|
||||||
|
const reworkForm = document.getElementById('rework-task-form');
|
||||||
|
if (reworkForm && !reworkForm._hasSubmitListener) {
|
||||||
|
reworkForm.addEventListener('submit', sendForRework);
|
||||||
|
reworkForm._hasSubmitListener = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Файлы
|
||||||
|
const filesInput = document.getElementById('files');
|
||||||
|
if (filesInput && !filesInput._hasChangeListener) {
|
||||||
|
filesInput.addEventListener('change', updateFileList);
|
||||||
|
filesInput._hasChangeListener = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const editFilesInput = document.getElementById('edit-files');
|
||||||
|
if (editFilesInput && !editFilesInput._hasChangeListener) {
|
||||||
|
editFilesInput.addEventListener('change', updateEditFileList);
|
||||||
|
editFilesInput._hasChangeListener = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Настройки уведомлений
|
||||||
|
const notificationForm = document.getElementById('notification-settings-form');
|
||||||
|
if (notificationForm && !notificationForm._hasSubmitListener) {
|
||||||
|
notificationForm.addEventListener('submit', saveNotificationSettings);
|
||||||
|
notificationForm._hasSubmitListener = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ознакомление
|
||||||
|
const acquaintanceForm = document.getElementById('acquaintance-task-form');
|
||||||
|
if (acquaintanceForm && !acquaintanceForm._hasSubmitListener) {
|
||||||
|
acquaintanceForm.addEventListener('submit', createAcquaintanceTask);
|
||||||
|
acquaintanceForm._hasSubmitListener = true;
|
||||||
|
}
|
||||||
|
// Инициализация загрузки файлов
|
||||||
|
initializeFileUploads();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для изменения вида задач
|
||||||
|
function changeTaskView() {
|
||||||
|
const select = document.getElementById('task-view-filter');
|
||||||
|
currentTaskView = select.value;
|
||||||
|
loadTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Переопределяем функцию loadTasks для фильтрации
|
||||||
|
(function() {
|
||||||
|
// Сохраняем оригинальную функцию loadTasks
|
||||||
|
const originalLoadTasks = window.loadTasks;
|
||||||
|
|
||||||
|
// Создаем новую функцию
|
||||||
|
window.loadTasks = async function() {
|
||||||
|
// Вызываем оригинальную функцию
|
||||||
|
if (typeof originalLoadTasks === 'function') {
|
||||||
|
await originalLoadTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кэшируем все задачи
|
||||||
|
if (window.tasks && Array.isArray(window.tasks)) {
|
||||||
|
allTasksCache = [...window.tasks];
|
||||||
|
|
||||||
|
// Фильтруем задачи в зависимости от выбранного вида
|
||||||
|
if (currentTaskView !== 'all' && currentUser) {
|
||||||
|
let filteredTasks = [];
|
||||||
|
|
||||||
|
if (currentTaskView === 'my_assigned') {
|
||||||
|
// Показываем задачи, которые я назначил (я - создатель)
|
||||||
|
filteredTasks = window.tasks.filter(task => {
|
||||||
|
return parseInt(task.created_by) === currentUser.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
} else if (currentTaskView === 'assigned_to_me') {
|
||||||
|
// Показываем задачи, назначенные мне (я - исполнитель)
|
||||||
|
filteredTasks = window.tasks.filter(task => {
|
||||||
|
// Проверяем, назначена ли задача текущему пользователю
|
||||||
|
if (task.assignments && Array.isArray(task.assignments)) {
|
||||||
|
return task.assignments.some(assignment =>
|
||||||
|
parseInt(assignment.user_id) === currentUser.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем глобальный массив задач
|
||||||
|
window.tasks = filteredTasks;
|
||||||
|
|
||||||
|
// Перерисовываем задачи
|
||||||
|
if (window.renderTasks && typeof window.renderTasks === 'function') {
|
||||||
|
window.renderTasks();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Обновленная функция для создания задачи
|
||||||
|
async function createTask(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
alert('Требуется аутентификация');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = document.getElementById('title').value;
|
||||||
|
let description = document.getElementById('description').value;
|
||||||
|
const taskType = document.getElementById('task-type').value;
|
||||||
|
|
||||||
|
// Для типа it добавляем кабинет и тип проблемы в описание
|
||||||
|
if (taskType === 'it') {
|
||||||
|
const cabinet = document.getElementById('it-cabinet').value.trim();
|
||||||
|
const corpusType = document.getElementById('it-corpus-type').value;
|
||||||
|
const problemType = document.getElementById('it-problem-type').value;
|
||||||
|
if (!cabinet || !problemType) {
|
||||||
|
alert('Для заявки в ИТ необходимо указать номер кабинета и тип проблемы');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Добавляем в конец описания с переносом строки
|
||||||
|
description = description + '<br>Кабинет: ' + cabinet + '<br>Корпус: ' + corpusType + '<br>Тип проблемы: ' + problemType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем полную дату и время из отдельных полей
|
||||||
|
const fullDateTime = getFullDateTime('due-date', 'due-time');
|
||||||
|
|
||||||
|
if (!title || !fullDateTime) {
|
||||||
|
alert('Название задачи и дата выполнения обязательны');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedUsers.length === 0) {
|
||||||
|
alert('Выберите хотя бы одного исполнителя');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Дополнительная проверка для задач типа "document"
|
||||||
|
if (taskType === 'document' && selectedUsers.length > 0) {
|
||||||
|
// Проверяем, что все выбранные пользователи - секретари
|
||||||
|
for (const userId of selectedUsers) {
|
||||||
|
const groups = await getUserGroups(userId);
|
||||||
|
const hasSecretaryGroup = groups.some(group =>
|
||||||
|
group.name === 'Секретарь' ||
|
||||||
|
(typeof group === 'string' && group.includes('Секретарь'))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasSecretaryGroup) {
|
||||||
|
alert('Для задач типа "Согласование документа" можно выбирать только пользователей с правом "согласовать документы"');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Дополнительная проверка для задач типа "it"
|
||||||
|
if (taskType === 'it' && selectedUsers.length > 0) {
|
||||||
|
// Проверяем, что все выбранные пользователи - it
|
||||||
|
for (const userId of selectedUsers) {
|
||||||
|
const groups = await getUserGroups(userId);
|
||||||
|
const hasSecretaryGroup = groups.some(group =>
|
||||||
|
group.name === 'ИТ специалист' ||
|
||||||
|
(typeof group === 'string' && group.includes('ИТ специалист'))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasSecretaryGroup) {
|
||||||
|
alert('Для задачи можно выбирать только пользователей из группы "ИТ специалист"');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Дополнительная проверка для задач типа "ahch"
|
||||||
|
if (taskType === 'ahch' && selectedUsers.length > 0) {
|
||||||
|
// Проверяем, что все выбранные пользователи - it
|
||||||
|
for (const userId of selectedUsers) {
|
||||||
|
const groups = await getUserGroups(userId);
|
||||||
|
const hasSecretaryGroup = groups.some(group =>
|
||||||
|
group.name === 'АХЧ' ||
|
||||||
|
(typeof group === 'string' && group.includes('АХЧ'))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasSecretaryGroup) {
|
||||||
|
alert('Для задачи можно выбирать только пользователей из группы "сотрудники АХЧ"');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Дополнительная проверка для задач типа "psychologist"
|
||||||
|
if (taskType === 'psychologist' && selectedUsers.length > 0) {
|
||||||
|
// Проверяем, что все выбранные пользователи - it
|
||||||
|
for (const userId of selectedUsers) {
|
||||||
|
const groups = await getUserGroups(userId);
|
||||||
|
const hasSecretaryGroup = groups.some(group =>
|
||||||
|
group.name === 'психолог' ||
|
||||||
|
(typeof group === 'string' && group.includes('психолог'))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasSecretaryGroup) {
|
||||||
|
alert('Для задачи можно выбирать только пользователей из группы "сотрудники психологи"');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Дополнительная проверка для задач типа "speech_therapist"
|
||||||
|
if (taskType === 'speech_therapist' && selectedUsers.length > 0) {
|
||||||
|
// Проверяем, что все выбранные пользователи - speech_therapist
|
||||||
|
for (const userId of selectedUsers) {
|
||||||
|
const groups = await getUserGroups(userId);
|
||||||
|
const hasSecretaryGroup = groups.some(group =>
|
||||||
|
group.name === 'логопед' ||
|
||||||
|
(typeof group === 'string' && group.includes('логопед'))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasSecretaryGroup) {
|
||||||
|
alert('Для задачи можно выбирать только пользователей из группы "сотрудники логопеды"');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Дополнительная проверка для задач типа "hr"
|
||||||
|
if (taskType === 'hr' && selectedUsers.length > 0) {
|
||||||
|
// Проверяем, что все выбранные пользователи - hr
|
||||||
|
for (const userId of selectedUsers) {
|
||||||
|
const groups = await getUserGroups(userId);
|
||||||
|
const hasSecretaryGroup = groups.some(group =>
|
||||||
|
group.name === 'Диспетчер' ||
|
||||||
|
(typeof group === 'string' && group.includes('Диспетчер'))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasSecretaryGroup) {
|
||||||
|
alert('Для задачи можно выбирать только пользователей из группы "Диспетчер расписания"');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Дополнительная проверка для задач типа "certificate"
|
||||||
|
if (taskType === 'certificate' && selectedUsers.length > 0) {
|
||||||
|
// Проверяем, что все выбранные пользователи - certificate
|
||||||
|
for (const userId of selectedUsers) {
|
||||||
|
const groups = await getUserGroups(userId);
|
||||||
|
const hasSecretaryGroup = groups.some(group =>
|
||||||
|
group.name === 'Администрация' ||
|
||||||
|
(typeof group === 'string' && group.includes('Администрация'))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasSecretaryGroup) {
|
||||||
|
alert('Для задачи можно выбирать только пользователей из группы "секретари и завучи"');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Дополнительная проверка для задач типа "e_journal"
|
||||||
|
if (taskType === 'e_journal' && selectedUsers.length > 0) {
|
||||||
|
// Проверяем, что все выбранные пользователи - e_journal
|
||||||
|
for (const userId of selectedUsers) {
|
||||||
|
const groups = await getUserGroups(userId);
|
||||||
|
const hasSecretaryGroup = groups.some(group =>
|
||||||
|
group.name === 'Админ ЭЖ' ||
|
||||||
|
(typeof group === 'string' && group.includes('Админ ЭЖ'))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasSecretaryGroup) {
|
||||||
|
alert('Для задачи можно выбирать только пользователей из группы "администраторы электронного журнала"');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Дополнительная проверка для задач типа "Social_educator"
|
||||||
|
if (taskType === 'Social_educator' && selectedUsers.length > 0) {
|
||||||
|
// Проверяем, что все выбранные пользователи - Social_educator
|
||||||
|
for (const userId of selectedUsers) {
|
||||||
|
const groups = await getUserGroups(userId);
|
||||||
|
const hasSecretaryGroup = groups.some(group =>
|
||||||
|
group.name === 'Социальный педагог' ||
|
||||||
|
(typeof group === 'string' && group.includes('Социальный педагог'))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasSecretaryGroup) {
|
||||||
|
alert('Для задачи можно выбирать только пользователей из группы "Социальный педагог"');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('title', title);
|
||||||
|
formData.append('description', description);
|
||||||
|
formData.append('taskType', taskType);
|
||||||
|
formData.append('dueDate', fullDateTime);
|
||||||
|
|
||||||
|
selectedUsers.forEach(userId => {
|
||||||
|
formData.append('assignedUsers', userId);
|
||||||
|
});
|
||||||
|
|
||||||
|
const files = document.getElementById('files').files;
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
formData.append('files', files[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка прав для типа "regular"
|
||||||
|
const userGroups = currentUser?.groups || [];
|
||||||
|
const isAdmin = currentUser?.role === 'admin';
|
||||||
|
const hasTasksGroup = currentUser?.role === 'tasks';
|
||||||
|
const hasSecretaryGroup = currentUser?.role === 'secretary';
|
||||||
|
|
||||||
|
if (taskType === 'regular' && !(isAdmin || hasTasksGroup || hasSecretaryGroup)) {
|
||||||
|
alert('У вас нет прав для создания обычных задач. Выберите другой тип задачи.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/tasks', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
alert('Задача успешно создана!');
|
||||||
|
|
||||||
|
// Сброс формы
|
||||||
|
document.getElementById('create-task-form').reset();
|
||||||
|
document.getElementById('file-list').innerHTML = '';
|
||||||
|
document.getElementById('user-search').value = '';
|
||||||
|
selectedUsers = [];
|
||||||
|
|
||||||
|
// Сбрасываем дату и время
|
||||||
|
const tomorrow = new Date();
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
document.getElementById('due-date').value = tomorrow.toISOString().split('T')[0];
|
||||||
|
document.getElementById('due-time').value = '12:00';
|
||||||
|
|
||||||
|
// Сбрасываем активные кнопки
|
||||||
|
const timeButtons = document.querySelectorAll('.time-btn');
|
||||||
|
timeButtons.forEach(btn => btn.classList.remove('active'));
|
||||||
|
if (timeButtons.length > 0) {
|
||||||
|
timeButtons[0].classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем отображение кнопок
|
||||||
|
updateDateTimeDisplay();
|
||||||
|
|
||||||
|
// Обновляем список пользователей
|
||||||
|
renderUsersChecklist();
|
||||||
|
|
||||||
|
// Загружаем задачи и логи
|
||||||
|
loadTasks();
|
||||||
|
loadActivityLogs();
|
||||||
|
|
||||||
|
// Показываем секцию задач
|
||||||
|
showSection('tasks');
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(error.error || 'Ошибка создания задачи');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка:', error);
|
||||||
|
alert('Ошибка создания задачи');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновленная функция открытия модального окна редактирования
|
||||||
|
async function openEditModal(taskId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/tasks/${taskId}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
alert('Задача не найдена или у вас нет прав доступа');
|
||||||
|
}
|
||||||
|
throw new Error('Ошибка загрузки задачи');
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = await response.json();
|
||||||
|
|
||||||
|
if (!canUserEditTask(task)) {
|
||||||
|
alert('У вас нет прав для редактирования этой задачи');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('edit-task-id').value = task.id;
|
||||||
|
document.getElementById('edit-title').value = task.title;
|
||||||
|
document.getElementById('edit-description').value = task.description || '';
|
||||||
|
|
||||||
|
// Устанавливаем дату и время с помощью новой функции
|
||||||
|
setDateTimeForEdit(task.due_date);
|
||||||
|
|
||||||
|
// Устанавливаем выбранных пользователей
|
||||||
|
editSelectedUsers = task.assignments ? task.assignments.map(a => a.user_id) : [];
|
||||||
|
renderEditUsersChecklist(users);
|
||||||
|
|
||||||
|
// Показываем существующие файлы
|
||||||
|
currentEditTaskFiles = task.files || [];
|
||||||
|
updateEditFileList();
|
||||||
|
|
||||||
|
document.getElementById('edit-task-modal').style.display = 'block';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка:', error);
|
||||||
|
alert('Ошибка загрузки задачи');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновленная функция обновления задачи
|
||||||
|
async function updateTask(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const taskId = document.getElementById('edit-task-id').value;
|
||||||
|
const title = document.getElementById('edit-title').value;
|
||||||
|
const description = document.getElementById('edit-description').value;
|
||||||
|
|
||||||
|
// Получаем полную дату и время из отдельных полей
|
||||||
|
const fullDateTime = getFullDateTime('edit-due-date', 'edit-due-time');
|
||||||
|
|
||||||
|
if (!fullDateTime) {
|
||||||
|
alert('Дата и время выполнения обязательны');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Используем editSelectedUsers
|
||||||
|
const assignedUserIds = editSelectedUsers;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('title', title);
|
||||||
|
formData.append('description', description);
|
||||||
|
formData.append('assignedUsers', JSON.stringify(assignedUserIds));
|
||||||
|
formData.append('dueDate', fullDateTime);
|
||||||
|
|
||||||
|
const files = document.getElementById('edit-files').files;
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
formData.append('files', files[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/tasks/${taskId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
alert('Задача успешно обновлена!');
|
||||||
|
closeEditModal();
|
||||||
|
loadTasks();
|
||||||
|
loadActivityLogs();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(error.error || 'Ошибка обновления задачи');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка:', error);
|
||||||
|
alert('Ошибка обновления задачи');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновленная функция создания копии задачи
|
||||||
|
async function copyTask(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const taskId = document.getElementById('copy-task-id').value;
|
||||||
|
|
||||||
|
// Получаем полную дату и время из отдельных полей
|
||||||
|
const fullDateTime = getFullDateTime('copy-due-date', 'copy-due-time');
|
||||||
|
|
||||||
|
if (!fullDateTime) {
|
||||||
|
alert('Дата и время выполнения обязательны для копии задачи');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Используем copySelectedUsers
|
||||||
|
const assignedUserIds = copySelectedUsers;
|
||||||
|
|
||||||
|
if (assignedUserIds.length === 0) {
|
||||||
|
alert('Выберите хотя бы одного исполнителя для копии задачи');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/tasks/${taskId}/copy`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
assignedUsers: assignedUserIds,
|
||||||
|
dueDate: fullDateTime
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
alert('Копия задачи успешно создана!');
|
||||||
|
closeCopyModal();
|
||||||
|
loadTasks();
|
||||||
|
loadActivityLogs();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(error.error || 'Ошибка создания копии задачи');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка:', error);
|
||||||
|
alert('Ошибка создания копии задачи');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для отображения секции
|
||||||
|
function showSection(sectionName) {
|
||||||
|
document.querySelectorAll('.section').forEach(section => {
|
||||||
|
section.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById(sectionName + '-section').classList.add('active');
|
||||||
|
|
||||||
|
if (sectionName === 'tasks') {
|
||||||
|
loadTasks();
|
||||||
|
} else if (sectionName === 'logs') {
|
||||||
|
loadActivityLogs();
|
||||||
|
} else if (sectionName === 'kanban') {
|
||||||
|
loadKanbanTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка профиля при переходе в личный кабинет
|
||||||
|
if (sectionName === 'profile') {
|
||||||
|
loadUserProfile();
|
||||||
|
loadNotificationSettings();
|
||||||
|
}
|
||||||
|
if (sectionName === 'reports') {
|
||||||
|
if (typeof loadReportData === 'function') {
|
||||||
|
loadReportData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для отображения Канбан доски
|
||||||
|
function showKanbanSection() {
|
||||||
|
showSection('kanban');
|
||||||
|
}
|
||||||
|
window.setupEventListeners = setupEventListeners;
|
||||||
|
|
||||||
587
public/nav-task-actions.js
Normal file
587
public/nav-task-actions.js
Normal file
@@ -0,0 +1,587 @@
|
|||||||
|
// nav-task-actions.js – модальное окно с действиями задачи (сетка 3 колонки, центрированное)
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const LOG_ENABLED = 0;
|
||||||
|
function log(...args) {
|
||||||
|
if (LOG_ENABLED) console.log(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ДЛЯ РЕКВИЗИТОВ ==========
|
||||||
|
function escapeHtml(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return String(str).replace(/[&<>]/g, function(m) {
|
||||||
|
if (m === '&') return '&';
|
||||||
|
if (m === '<') return '<';
|
||||||
|
if (m === '>') return '>';
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTaskCardDisplay(taskId, data) {
|
||||||
|
const taskCards = document.querySelectorAll(`[data-task-id="${taskId}"]`);
|
||||||
|
taskCards.forEach(card => {
|
||||||
|
let docContainer = card.querySelector('.document-fields-display');
|
||||||
|
if (!docContainer) {
|
||||||
|
docContainer = document.createElement('div');
|
||||||
|
docContainer.className = 'document-fields-display';
|
||||||
|
const header = card.querySelector('.task-header');
|
||||||
|
if (header) header.after(docContainer);
|
||||||
|
else card.prepend(docContainer);
|
||||||
|
}
|
||||||
|
let html = '<div style="margin: 8px 0; padding: 5px; background-color: #f5f5f5; border-radius: 4px; font-size: 0.9em;">';
|
||||||
|
if (data.document_n) html += `<span style="margin-right: 15px;"><strong>№:</strong> ${escapeHtml(data.document_n)}</span>`;
|
||||||
|
if (data.document_d) html += `<span style="margin-right: 15px;"><strong>Дата:</strong> ${escapeHtml(data.document_d)}</span>`;
|
||||||
|
if (data.document_a) html += `<span><strong>Автор:</strong> ${escapeHtml(data.document_a)}</span>`;
|
||||||
|
if (!data.document_n && !data.document_d) html += '<span style="color: #999;">Нет данных документа</span>';
|
||||||
|
html += '</div>';
|
||||||
|
docContainer.innerHTML = html;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDocumentFieldsModal(taskId) {
|
||||||
|
const existing = document.getElementById('doc-fields-modal');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
// Загружаем текущие поля
|
||||||
|
let fields = { document_n: '', document_d: '', document_a: '' };
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/tasks/${taskId}/document-fields`);
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
fields = result.data || {};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки реквизитов:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Преобразуем дату из ДД.ММ.ГГГГ в YYYY-MM-DD для input type="date"
|
||||||
|
let dateValue = '';
|
||||||
|
if (fields.document_d) {
|
||||||
|
const parts = fields.document_d.split('.');
|
||||||
|
if (parts.length === 3) {
|
||||||
|
dateValue = `${parts[2]}-${parts[1]}-${parts[0]}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем права на редактирование
|
||||||
|
let canEdit = false;
|
||||||
|
const hasFields = !!(fields.document_n || fields.document_d);
|
||||||
|
if (hasFields) {
|
||||||
|
canEdit = fields.document_a === currentUser?.login;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const groupsRes = await fetch(`/api2/idusers/user/${currentUser.id}/groups`);
|
||||||
|
if (groupsRes.ok) {
|
||||||
|
const groups = await groupsRes.json();
|
||||||
|
canEdit = groups.some(g => g === 'Подписант' || g === 'Секретарь' || g.includes('Подписант') || g.includes('Секретарь'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка проверки групп:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаём модальное окно
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.id = 'doc-fields-modal';
|
||||||
|
modal.className = 'modal';
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
modal.style.alignItems = 'center';
|
||||||
|
modal.style.justifyContent = 'center';
|
||||||
|
modal.style.zIndex = '10001';
|
||||||
|
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="modal-content" style="max-width: 500px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>📄 Реквизиты документа</h3>
|
||||||
|
<span class="close" onclick="window.closeDocFieldsModal()">×</span>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="doc-number">Номер документа</label>
|
||||||
|
<input type="text" id="doc-number" class="form-control" value="${escapeHtml(fields.document_n || '')}" ${!canEdit ? 'readonly' : ''}>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="doc-date">Дата документа</label>
|
||||||
|
<input type="date" id="doc-date" class="form-control" value="${dateValue}" ${!canEdit ? 'readonly' : ''}>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="doc-author">Автор (логин)</label>
|
||||||
|
<input type="text" id="doc-author" class="form-control" value="${escapeHtml(fields.document_a || currentUser?.login || '')}" readonly>
|
||||||
|
</div>
|
||||||
|
${!canEdit && hasFields ? '<div class="alert alert-warning">⚠️ Вы не можете редактировать реквизиты, так как они уже заполнены другим пользователем.</div>' : ''}
|
||||||
|
${!canEdit && !hasFields ? '<div class="alert alert-warning">⚠️ У вас нет прав для указания реквизитов.</div>' : ''}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn-secondary" onclick="window.closeDocFieldsModal()">Отмена</button>
|
||||||
|
${canEdit ? `<button class="btn-primary" id="save-doc-fields">Сохранить</button>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
window.closeDocFieldsModal = function() {
|
||||||
|
const m = document.getElementById('doc-fields-modal');
|
||||||
|
if (m) m.remove();
|
||||||
|
delete window.closeDocFieldsModal;
|
||||||
|
};
|
||||||
|
modal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modal) window.closeDocFieldsModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (canEdit) {
|
||||||
|
const saveBtn = document.getElementById('save-doc-fields');
|
||||||
|
saveBtn.addEventListener('click', async () => {
|
||||||
|
const number = document.getElementById('doc-number').value.trim();
|
||||||
|
const dateInput = document.getElementById('doc-date');
|
||||||
|
let formattedDate = '';
|
||||||
|
if (dateInput.value) {
|
||||||
|
// Преобразуем YYYY-MM-DD в ДД.ММ.ГГГГ
|
||||||
|
const parts = dateInput.value.split('-');
|
||||||
|
if (parts.length === 3) {
|
||||||
|
formattedDate = `${parts[2]}.${parts[1]}.${parts[0]}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const author = document.getElementById('doc-author').value.trim() || currentUser?.login;
|
||||||
|
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
saveBtn.textContent = 'Сохранение...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/tasks/${taskId}/document-fields`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
document_n: number || null,
|
||||||
|
document_d: formattedDate || null,
|
||||||
|
document_a: author
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (response.ok && result.success) {
|
||||||
|
alert('✅ Реквизиты сохранены');
|
||||||
|
window.closeDocFieldsModal();
|
||||||
|
const task = window.tasks?.find(t => t.id == taskId);
|
||||||
|
if (task) task.document_fields = result.data;
|
||||||
|
updateTaskCardDisplay(taskId, result.data);
|
||||||
|
if (typeof loadTasks === 'function') loadTasks();
|
||||||
|
} else {
|
||||||
|
alert('❌ Ошибка: ' + (result.error || 'Неизвестная ошибка'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка сохранения:', error);
|
||||||
|
alert('Сетевая ошибка');
|
||||||
|
} finally {
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.textContent = 'Сохранить';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== ОСНОВНАЯ ЛОГИКА МЕНЮ ДЕЙСТВИЙ ==========
|
||||||
|
// Сбор доступных действий для конкретной задачи
|
||||||
|
function buildActionsForTask(task) {
|
||||||
|
const taskId = task.id;
|
||||||
|
const isDeleted = task.status === 'deleted';
|
||||||
|
const isClosed = task.closed_at !== null;
|
||||||
|
const userRole = window.getUserRoleInTask ? window.getUserRoleInTask(task) : 'Наблюдатель';
|
||||||
|
const canEdit = window.canUserEditTask ? window.canUserEditTask(task) : false;
|
||||||
|
|
||||||
|
const actions = [];
|
||||||
|
|
||||||
|
// ==== ДОБАВЛЕННЫЙ БЛОК: кнопки для текущего исполнителя
|
||||||
|
if (currentUser && !isDeleted && !isClosed) {
|
||||||
|
const myAssignment = task.assignments?.find(a => parseInt(a.user_id) === currentUser.id);
|
||||||
|
if (myAssignment) {
|
||||||
|
if (myAssignment.status === 'assigned') {
|
||||||
|
actions.push({
|
||||||
|
label: '▶️ Приступить',
|
||||||
|
handler: () => window.updateStatus(taskId, currentUser.id, 'in_progress'),
|
||||||
|
primary: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (['in_progress', 'overdue', 'rework'].includes(myAssignment.status)) {
|
||||||
|
const isDocumentTask = task.task_type === 'document';
|
||||||
|
const handler = isDocumentTask
|
||||||
|
? () => window.openDocumentCompleteModal(taskId, currentUser.id)
|
||||||
|
: () => window.updateStatus(taskId, currentUser.id, 'completed');
|
||||||
|
actions.push({
|
||||||
|
label: '✅ Выполнено',
|
||||||
|
handler: handler,
|
||||||
|
primary: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Копия – доступна всем, у кого есть кнопка
|
||||||
|
if (typeof openCopyModal === 'function') {
|
||||||
|
actions.push({
|
||||||
|
label: '📋 Создать копию',
|
||||||
|
handler: () => openCopyModal(taskId),
|
||||||
|
primary: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Действия для активных (не удалённых, не закрытых) задач
|
||||||
|
if (!isDeleted && !isClosed && currentUser.login === 'minicrm') {
|
||||||
|
if (typeof openTaskChat === 'function') {
|
||||||
|
actions.push({ label: '💬 Чат', handler: () => openTaskChat(taskId) });
|
||||||
|
}
|
||||||
|
if (typeof openAddFileModal === 'function') {
|
||||||
|
actions.push({ label: '📎 Добавить файл', handler: () => openAddFileModal(taskId) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Специальные пользователи
|
||||||
|
if (currentUser && currentUser.login === 'minicrm') {
|
||||||
|
if (typeof openEditModal === 'function') {
|
||||||
|
actions.push({ label: '✏️ Редактировать', handler: () => openEditModal(taskId) });
|
||||||
|
}
|
||||||
|
if (typeof openManageAssigneesModal === 'function') {
|
||||||
|
actions.push({ label: '👥 Управление исполнителями', handler: () => openManageAssigneesModal(taskId) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подписание (только для документов, если текущий пользователь - исполнитель)
|
||||||
|
if (!isDeleted && !isClosed && task.task_type === 'document' && currentUser && task.assignments?.some(a => parseInt(a.user_id) === currentUser.id)) {
|
||||||
|
if (typeof window.signTask === 'function') {
|
||||||
|
actions.push({
|
||||||
|
label: '✍️ Подписать',
|
||||||
|
handler: () => window.signTask(taskId, currentUser.id),
|
||||||
|
admin: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Реквизиты документа (для задач типа document)
|
||||||
|
if (!isDeleted && !isClosed && task.task_type === 'document' && currentUser && task.assignments?.some(a => parseInt(a.user_id) === currentUser.id)) {
|
||||||
|
actions.push({
|
||||||
|
label: '📄 Реквизиты',
|
||||||
|
handler: () => openDocumentFieldsModal(taskId),
|
||||||
|
admin: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Администраторы и роль tasks
|
||||||
|
if (currentUser && (currentUser.role === 'admin' || (currentUser.role === 'tasks' && canEdit))) {
|
||||||
|
if (typeof assignAdd_openModal === 'function') {
|
||||||
|
actions.push({
|
||||||
|
label: '🧑💼➕ Добавить исполнителя',
|
||||||
|
handler: () => assignAdd_openModal(taskId),
|
||||||
|
admin: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (typeof assignRemove_openModal === 'function') {
|
||||||
|
actions.push({
|
||||||
|
label: '🧑💼❌ Удалить исполнителя',
|
||||||
|
handler: () => assignRemove_openModal(taskId),
|
||||||
|
admin: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (currentUser && (currentUser.role === 'admin' && currentUser.login === 'minicrm')) {
|
||||||
|
if (typeof openReworkModal === 'function') {
|
||||||
|
actions.push({ label: '🔄 Переделать документ', handler: () => openReworkModal(taskId) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопка "Создать ознакомление" для админов и tasks
|
||||||
|
if (currentUser && (currentUser.role === 'admin' || currentUser.role === 'tasks')) {
|
||||||
|
actions.push({
|
||||||
|
label: '📖 Создать ознакомление',
|
||||||
|
handler: () => openAcquaintanceModal(task.id),
|
||||||
|
primary: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Доработка и изменение срока для необычных задач (исполнитель)
|
||||||
|
if (!isDeleted && !isClosed && task.task_type !== 'regular' && task.assignments && task.assignments.some(a => parseInt(a.user_id) === currentUser?.id)) {
|
||||||
|
if (typeof openReworkModal === 'function') {
|
||||||
|
actions.push({ label: '🔄 Доработка', handler: () => openReworkModal(taskId), primary_task: true });
|
||||||
|
}
|
||||||
|
if (typeof openChangeDeadlineModal === 'function') {
|
||||||
|
actions.push({ label: '📅 Изменить срок', handler: () => openChangeDeadlineModal(taskId), primary_task: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDeleted && !isClosed && task.task_type == 'regular') {
|
||||||
|
if (typeof closeTask === 'function') {
|
||||||
|
actions.push({ label: '🔒 Закрыть задачу', handler: () => closeTask(taskId), admin: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canEdit && !isDeleted && !isClosed && currentUser.role === 'admin') {
|
||||||
|
if (typeof deleteTask === 'function') actions.push({ label: '🗑️ Удалить', handler: () => deleteTask(taskId) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentUser && currentUser.login === 'minicrm') {
|
||||||
|
if (typeof closeTask === 'function') actions.push({ label: '🔒 Закрыть задачу', handler: () => closeTask(taskId) });
|
||||||
|
if (canEdit && !isDeleted && !isClosed && currentUser.role === 'admin') {
|
||||||
|
if (typeof deleteTask === 'function') actions.push({ label: '🗑️ Удалить', handler: () => deleteTask(taskId) });
|
||||||
|
}
|
||||||
|
if (isClosed && canEdit) {
|
||||||
|
if (typeof reopenTask === 'function') actions.push({ label: '🔓 Открыть задачу', handler: () => reopenTask(taskId) });
|
||||||
|
}
|
||||||
|
if (isDeleted && window.currentUser?.role === 'admin') {
|
||||||
|
if (typeof restoreTask === 'function') actions.push({ label: '↶ Восстановить', handler: () => restoreTask(taskId) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Рендеринг кнопки меню
|
||||||
|
window.renderTaskActions = function(task) {
|
||||||
|
if (!task) return '';
|
||||||
|
return `
|
||||||
|
<div class="task-actions-menu-container">
|
||||||
|
<button class="task-actions-menu-btn" data-task-id="${task.id}">⋮ Действия</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Удалить модальное окно, если оно есть
|
||||||
|
function removeModal() {
|
||||||
|
const existing = document.getElementById('task-action-modal');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработчик клика на кнопку меню
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
const menuBtn = e.target.closest('.task-actions-menu-btn');
|
||||||
|
if (!menuBtn) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
removeModal();
|
||||||
|
|
||||||
|
const taskId = menuBtn.dataset.taskId;
|
||||||
|
if (!taskId) {
|
||||||
|
console.error('No task id found on button');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = window.tasks?.find(t => t.id == taskId);
|
||||||
|
if (!task) {
|
||||||
|
console.error('Task not found for id', taskId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions = buildActionsForTask(task);
|
||||||
|
if (actions.length === 0) return;
|
||||||
|
|
||||||
|
const primaryActions = actions.filter(a => a.primary);
|
||||||
|
const adminActions = actions.filter(a => a.admin && !a.primary);
|
||||||
|
const taskActions = actions.filter(a => a.primary_task && !a.primary && !a.primary);
|
||||||
|
const userActions = actions.filter(a => !a.primary && !a.admin && !a.primary_task);
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.id = 'task-action-modal';
|
||||||
|
overlay.style.position = 'fixed';
|
||||||
|
overlay.style.top = '0';
|
||||||
|
overlay.style.left = '0';
|
||||||
|
overlay.style.width = '100%';
|
||||||
|
overlay.style.height = '100%';
|
||||||
|
overlay.style.backgroundColor = 'rgba(0,0,0,0.5)';
|
||||||
|
overlay.style.zIndex = '10000';
|
||||||
|
overlay.style.display = 'flex';
|
||||||
|
overlay.style.alignItems = 'center';
|
||||||
|
overlay.style.justifyContent = 'center';
|
||||||
|
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.style.backgroundColor = 'white';
|
||||||
|
modal.style.borderRadius = '12px';
|
||||||
|
modal.style.padding = '25px';
|
||||||
|
modal.style.maxWidth = '500px';
|
||||||
|
modal.style.width = '90%';
|
||||||
|
modal.style.boxShadow = '0 10px 40px rgba(0,0,0,0.2)';
|
||||||
|
modal.style.position = 'relative';
|
||||||
|
modal.style.maxHeight = '85vh';
|
||||||
|
modal.style.overflowY = 'auto';
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.style.display = 'flex';
|
||||||
|
header.style.justifyContent = 'space-between';
|
||||||
|
header.style.alignItems = 'center';
|
||||||
|
header.style.marginBottom = '20px';
|
||||||
|
header.style.paddingBottom = '10px';
|
||||||
|
header.style.borderBottom = '1px solid #eee';
|
||||||
|
|
||||||
|
const title = document.createElement('h3');
|
||||||
|
title.style.margin = '0';
|
||||||
|
title.style.fontSize = '18px';
|
||||||
|
title.style.color = '#333';
|
||||||
|
title.style.flex = '1';
|
||||||
|
title.style.textAlign = 'center';
|
||||||
|
title.textContent = `Действия для задачи #${taskId}`;
|
||||||
|
|
||||||
|
const closeBtn = document.createElement('span');
|
||||||
|
closeBtn.innerHTML = '×';
|
||||||
|
closeBtn.style.fontSize = '28px';
|
||||||
|
closeBtn.style.fontWeight = 'bold';
|
||||||
|
closeBtn.style.cursor = 'pointer';
|
||||||
|
closeBtn.style.color = '#999';
|
||||||
|
closeBtn.style.transition = 'color 0.2s';
|
||||||
|
closeBtn.onmouseover = () => closeBtn.style.color = '#e74c3c';
|
||||||
|
closeBtn.onmouseout = () => closeBtn.style.color = '#999';
|
||||||
|
closeBtn.onclick = () => removeModal();
|
||||||
|
|
||||||
|
header.appendChild(title);
|
||||||
|
header.appendChild(closeBtn);
|
||||||
|
modal.appendChild(header);
|
||||||
|
|
||||||
|
// БЛОК: primary actions
|
||||||
|
if (primaryActions.length > 0) {
|
||||||
|
const primaryContainer = document.createElement('div');
|
||||||
|
primaryContainer.style.marginBottom = '20px';
|
||||||
|
primaryActions.forEach(a => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.textContent = a.label;
|
||||||
|
btn.style.cssText = `
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
background-color: #27ae60;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
`;
|
||||||
|
btn.onmouseover = () => { btn.style.backgroundColor = '#229954'; };
|
||||||
|
btn.onmouseout = () => { btn.style.backgroundColor = '#27ae60'; };
|
||||||
|
btn.onclick = (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
a.handler();
|
||||||
|
removeModal();
|
||||||
|
};
|
||||||
|
primaryContainer.appendChild(btn);
|
||||||
|
});
|
||||||
|
modal.appendChild(primaryContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// БЛОК: taskActions
|
||||||
|
if (taskActions.length > 0) {
|
||||||
|
const taskContainer = document.createElement('div');
|
||||||
|
taskContainer.style.marginBottom = '20px';
|
||||||
|
taskActions.forEach(a => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.textContent = a.label;
|
||||||
|
btn.style.cssText = `
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
background-color: #dfc63cff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
`;
|
||||||
|
btn.onmouseover = () => btn.style.backgroundColor = '#dfc63cff';
|
||||||
|
btn.onmouseout = () => btn.style.backgroundColor = '#dfc63cff';
|
||||||
|
btn.onclick = (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
a.handler();
|
||||||
|
removeModal();
|
||||||
|
};
|
||||||
|
taskContainer.appendChild(btn);
|
||||||
|
});
|
||||||
|
modal.appendChild(taskContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// БЛОК: admin
|
||||||
|
if (adminActions.length > 0) {
|
||||||
|
const adminContainer = document.createElement('div');
|
||||||
|
adminContainer.style.marginBottom = '20px';
|
||||||
|
adminActions.forEach(a => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.textContent = a.label;
|
||||||
|
btn.style.cssText = `
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
background-color: #e74c3c;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
`;
|
||||||
|
btn.onmouseover = () => btn.style.backgroundColor = '#c0392b';
|
||||||
|
btn.onmouseout = () => btn.style.backgroundColor = '#e74c3c';
|
||||||
|
btn.onclick = (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
a.handler();
|
||||||
|
removeModal();
|
||||||
|
};
|
||||||
|
adminContainer.appendChild(btn);
|
||||||
|
});
|
||||||
|
modal.appendChild(adminContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// БЛОК: остальные (user)
|
||||||
|
if (userActions.length > 0) {
|
||||||
|
const grid = document.createElement('div');
|
||||||
|
grid.style.cssText = `
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: center;
|
||||||
|
`;
|
||||||
|
userActions.forEach(a => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.textContent = a.label;
|
||||||
|
btn.style.cssText = `
|
||||||
|
flex: 0 0 calc(33.333% - 10px);
|
||||||
|
min-width: 120px;
|
||||||
|
padding: 12px 8px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: #3498db;
|
||||||
|
color: white;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
`;
|
||||||
|
btn.onmouseover = () => btn.style.backgroundColor = '#2980b9';
|
||||||
|
btn.onmouseout = () => btn.style.backgroundColor = '#3498db';
|
||||||
|
btn.onclick = (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
a.handler();
|
||||||
|
removeModal();
|
||||||
|
};
|
||||||
|
grid.appendChild(btn);
|
||||||
|
});
|
||||||
|
modal.appendChild(grid);
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay.appendChild(modal);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
overlay.addEventListener('click', function(event) {
|
||||||
|
if (event.target === overlay) {
|
||||||
|
removeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Экспортируем функции для использования в HTML (например, closeDocFieldsModal)
|
||||||
|
window.closeDocFieldsModal = function() {
|
||||||
|
const m = document.getElementById('doc-fields-modal');
|
||||||
|
if (m) m.remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
//console.log('nav-task-actions.js loaded (centered modal)');
|
||||||
|
})();
|
||||||
178
public/navbar.js
Normal file
178
public/navbar.js
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
// Функция для проверки наличия указанной группы у текущего пользователя
|
||||||
|
function navbar_checkUserGroup(navbar_groupName) {
|
||||||
|
try {
|
||||||
|
// Проверяем, есть ли данные пользователя
|
||||||
|
if (!currentUser || !currentUser.id) {
|
||||||
|
console.error('Пользователь не аутентифицирован или данные отсутствуют');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Текущий пользователь:', currentUser.login || currentUser.name);
|
||||||
|
const navbar_currentUserId = currentUser.id;
|
||||||
|
|
||||||
|
// Делаем синхронный запрос с помощью XMLHttpRequest
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('GET', `/api2/idusers/user/${navbar_currentUserId}/groups`, false); // false = синхронный запрос
|
||||||
|
xhr.send();
|
||||||
|
|
||||||
|
if (xhr.status !== 200) {
|
||||||
|
console.error('Ошибка получения групп пользователя');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const navbar_groups = JSON.parse(xhr.responseText);
|
||||||
|
|
||||||
|
// Проверяем наличие указанной группы
|
||||||
|
const navbar_hasGroup = navbar_groups.some(userGroup => {
|
||||||
|
return userGroup === navbar_groupName ||
|
||||||
|
userGroup.includes(navbar_groupName) ||
|
||||||
|
userGroup.toLowerCase().includes(navbar_groupName.toLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
|
if (navbar_hasGroup) {
|
||||||
|
console.log(`✓ Пользователь состоит в группе "${navbar_groupName}"`);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
console.log(`✗ Пользователь НЕ состоит в группе "${navbar_groupName}"`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Ошибка при проверке группы "${navbar_groupName}":`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Функция для создания навигационной панели
|
||||||
|
function createNavigation() {
|
||||||
|
const navbar = document.getElementById('navbar-container');
|
||||||
|
if (!navbar) return;
|
||||||
|
// 👇 ДОБАВЛЯЕМ ПОДРОБНЫЕ ЛОГИ 👇
|
||||||
|
if (currentUser) {
|
||||||
|
//console.log('ID:', currentUser.id);
|
||||||
|
//console.log('ФИО:', currentUser.name);
|
||||||
|
//console.log('Логин:', currentUser.login);
|
||||||
|
//console.log('Роль:', currentUser.role);
|
||||||
|
} else {
|
||||||
|
console.log('currentUser отсутствует (не авторизован)');
|
||||||
|
}
|
||||||
|
// Базовые кнопки для всех авторизованных пользователей
|
||||||
|
const navButtons = [
|
||||||
|
{
|
||||||
|
onclick: "window.location.href = '/'",
|
||||||
|
className: "nav-btn tasks",
|
||||||
|
icon: "fas fa-cog",
|
||||||
|
text: "Главная",
|
||||||
|
id: "home-btn"
|
||||||
|
},
|
||||||
|
|
||||||
|
];
|
||||||
|
navButtons.push(
|
||||||
|
{
|
||||||
|
onclick: "showSection('tasks')",
|
||||||
|
className: "nav-btn tasks",
|
||||||
|
icon: "fas fa-list",
|
||||||
|
text: "Все задачи",
|
||||||
|
id: "tasks-btn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onclick: "showSection('create-task')",
|
||||||
|
className: "nav-btn create",
|
||||||
|
icon: "fas fa-plus-circle",
|
||||||
|
text: "Создать задачу",
|
||||||
|
id: "create-task-btn"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
navButtons.push(
|
||||||
|
{
|
||||||
|
onclick: "showKanbanSection()",
|
||||||
|
className: "nav-btn kanban",
|
||||||
|
icon: "fas fa-columns",
|
||||||
|
text: "Канбан",
|
||||||
|
id: "kanban-btn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onclick: "showSection('profile')",
|
||||||
|
className: "nav-btn profile",
|
||||||
|
icon: "fas fa-user-circle",
|
||||||
|
text: "Личный кабинет",
|
||||||
|
id: "profile-btn"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (currentUser && currentUser.role === 'admin') {
|
||||||
|
navButtons.push({
|
||||||
|
onclick: "showSection('runtasks')",
|
||||||
|
className: "nav-btn assigned-to-me",
|
||||||
|
icon: "fas fa-user-check",
|
||||||
|
text: "Мои задачи (Исполнитель)",
|
||||||
|
id: "kanban-btn"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 👇 Кнопка админ-панели ТОЛЬКО для admin 👇
|
||||||
|
if (currentUser && currentUser.role === 'admin') {
|
||||||
|
navButtons.push({
|
||||||
|
onclick: "window.location.href = '/admin'",
|
||||||
|
className: "nav-btn admin",
|
||||||
|
icon: "fas fa-cog",
|
||||||
|
text: "Админ-панель",
|
||||||
|
id: "admin-btn"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 👇 Кнопка админ-панели ТОЛЬКО для admin 👇
|
||||||
|
if (currentUser && currentUser.role === 'admin') {
|
||||||
|
navButtons.push({
|
||||||
|
onclick: "window.location.href = '/admin-api-management.html'",
|
||||||
|
className: "nav-btn profile",
|
||||||
|
icon: "fas fa-cog",
|
||||||
|
text: "api-management",
|
||||||
|
id: "admin-btn"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 👇 Кнопка админ-панели ТОЛЬКО для admin 👇
|
||||||
|
if (currentUser && currentUser.role === 'admin') {
|
||||||
|
navButtons.push({
|
||||||
|
onclick: "window.location.href = '/client.html'",
|
||||||
|
className: "nav-btn profile",
|
||||||
|
icon: "fas fa-cog",
|
||||||
|
text: "client",
|
||||||
|
id: "admin-btn"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Отчеты
|
||||||
|
if (currentUser && (currentUser.role === 'admin' || currentUser.role === 'tasks')) {
|
||||||
|
navButtons.push({
|
||||||
|
onclick: "showReportsSection()",
|
||||||
|
className: "nav-btn reports",
|
||||||
|
icon: "fas fa-chart-pie",
|
||||||
|
text: "Отчёты",
|
||||||
|
id: "reports-btn"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Кнопка выхода
|
||||||
|
navButtons.push({
|
||||||
|
onclick: "logout()",
|
||||||
|
className: "btn-logout",
|
||||||
|
icon: "fas fa-sign-out-alt",
|
||||||
|
text: "Выйти",
|
||||||
|
id: "logout-btn"
|
||||||
|
});
|
||||||
|
|
||||||
|
// Очищаем и создаем кнопки
|
||||||
|
navbar.innerHTML = '';
|
||||||
|
navButtons.forEach(button => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.setAttribute('onclick', button.onclick);
|
||||||
|
btn.className = button.className;
|
||||||
|
btn.id = button.id;
|
||||||
|
btn.innerHTML = `<i class="${button.icon}"></i> ${button.text}`;
|
||||||
|
navbar.appendChild(btn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация при загрузке страницы
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// createNavigation();
|
||||||
|
|
||||||
|
// Если нужно обновлять навигацию при изменениях
|
||||||
|
// window.addEventListener('userRoleChanged', createNavigation);
|
||||||
|
});
|
||||||
105
public/openTaskChat.js
Normal file
105
public/openTaskChat.js
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
function openTaskChat(taskId) {
|
||||||
|
// Находим задачу
|
||||||
|
const task = tasks.find(t => t.id === taskId);
|
||||||
|
if (!task) {
|
||||||
|
alert('Задача не найдена');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем модальное окно чата
|
||||||
|
const modalHtml = `
|
||||||
|
<div class="modal" id="task-chat-modal">
|
||||||
|
<div class="modal-content" style="max-width: 800px; max-height: 80vh;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>💬 Чат для задачи №${taskId}: "${task.title}"</h3>
|
||||||
|
<span class="close" onclick="closeTaskChat()">×</span>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" style="padding: 0;">
|
||||||
|
<div style="padding: 20px; text-align: center; height: 300px; display: flex; flex-direction: column; justify-content: center; align-items: center; color: #666;">
|
||||||
|
<div style="font-size: 48px; margin-bottom: 20px;">🚧</div>
|
||||||
|
<h3 style="margin-bottom: 10px;">Функция чата в разработке</h3>
|
||||||
|
<p>Чат для обсуждения задачи №${taskId} находится в стадии разработки.</p>
|
||||||
|
<p style="margin-top: 10px;">Скоро здесь можно будет обмениваться сообщениями с другими участниками задачи.</p>
|
||||||
|
|
||||||
|
<div style="margin-top: 30px; padding: 15px; background-color: #f5f5f5; border-radius: 8px; max-width: 500px;">
|
||||||
|
<h4 style="margin-top: 0;">Что будет в чате:</h4>
|
||||||
|
<ul style="text-align: left; margin-bottom: 0;">
|
||||||
|
<li>Обмен сообщениями с исполнителями</li>
|
||||||
|
<li>Обсуждение деталей выполнения задачи</li>
|
||||||
|
<li>Уведомления о новых сообщениях</li>
|
||||||
|
<li>История обсуждений по задаче</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<div style="font-size: 12px; color: #888;">
|
||||||
|
Последнее обновление: ${new Date().toLocaleDateString('ru-RU')}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button type="button" class="btn-cancel" onclick="closeTaskChat()">Закрыть</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Добавляем модальное окно в DOM
|
||||||
|
const modalContainer = document.createElement('div');
|
||||||
|
modalContainer.innerHTML = modalHtml;
|
||||||
|
document.body.appendChild(modalContainer);
|
||||||
|
|
||||||
|
// Показываем модальное окно
|
||||||
|
setTimeout(() => {
|
||||||
|
const modal = document.getElementById('task-chat-modal');
|
||||||
|
modal.style.display = 'block';
|
||||||
|
|
||||||
|
// Добавляем CSS для анимации (опционально)
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
#task-chat-modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0,0,0,0.5);
|
||||||
|
animation: fadeIn 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#task-chat-modal .modal-content {
|
||||||
|
animation: slideIn 0.3s ease-out;
|
||||||
|
background-color: #fefefe;
|
||||||
|
margin: 5% auto;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.2);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from { transform: translateY(-50px); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для закрытия чата
|
||||||
|
function closeTaskChat() {
|
||||||
|
const modal = document.getElementById('task-chat-modal');
|
||||||
|
if (modal) {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
// Удаляем модальное окно из DOM через некоторое время
|
||||||
|
setTimeout(() => {
|
||||||
|
modal.parentElement.remove();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
}
|
||||||
130
public/openTaskChat2.js
Normal file
130
public/openTaskChat2.js
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
// Показывает модальное окно со списком задач
|
||||||
|
function showUnreadNotification(tasks) {
|
||||||
|
const modalHtml = `
|
||||||
|
<div class="modal" id="unread-notification-modal">
|
||||||
|
<div class="modal-content" style="max-width: 500px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>📬 У вас новые сообщения</h3>
|
||||||
|
<span class="close" onclick="closeUnreadNotification()">×</span>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>В следующих задачах есть непрочитанные сообщения:</p>
|
||||||
|
<ul style="list-style: none; padding: 0;">
|
||||||
|
${tasks.map(task => `
|
||||||
|
<li style="margin: 10px 0; padding: 10px; background: #f5f5f5; border-radius: 5px; display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<span><strong>${escapeHtml(task.title)}</strong> (${task.unreadCount} ${pluralize(task.unreadCount, ['новое сообщение', 'новых сообщения', 'новых сообщений'])})</span>
|
||||||
|
<button class="btn-primary" onclick="openTaskAndMarkRead(${task.taskId})">Открыть чат</button>
|
||||||
|
</li>
|
||||||
|
`).join('')}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn-cancel" onclick="closeUnreadNotification()">Закрыть</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
let modal = document.getElementById('unread-notification-modal');
|
||||||
|
if (modal) modal.remove();
|
||||||
|
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.innerHTML = modalHtml;
|
||||||
|
document.body.appendChild(container);
|
||||||
|
|
||||||
|
modal = document.getElementById('unread-notification-modal');
|
||||||
|
modal.style.display = 'block';
|
||||||
|
|
||||||
|
// Добавляем стили (если ещё не добавлены)
|
||||||
|
if (!document.getElementById('unread-notification-styles')) {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = 'unread-notification-styles';
|
||||||
|
style.textContent = `
|
||||||
|
#unread-notification-modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1001;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0,0,0,0.5);
|
||||||
|
animation: fadeIn 0.3s;
|
||||||
|
}
|
||||||
|
#unread-notification-modal .modal-content {
|
||||||
|
animation: slideIn 0.3s ease-out;
|
||||||
|
background-color: #fefefe;
|
||||||
|
margin: 5% auto;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.2);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes slideIn {
|
||||||
|
from { transform: translateY(-50px); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeUnreadNotification() {
|
||||||
|
const modal = document.getElementById('unread-notification-modal');
|
||||||
|
if (modal) {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
setTimeout(() => modal.remove(), 300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вспомогательная функция для склонения
|
||||||
|
function pluralize(count, words) {
|
||||||
|
const cases = [2, 0, 1, 1, 1, 2];
|
||||||
|
return words[(count % 100 > 4 && count % 100 < 20) ? 2 : cases[Math.min(count % 10, 5)]];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Экранирование HTML в заголовке задачи (защита от XSS)
|
||||||
|
function escapeHtml(unsafe) {
|
||||||
|
return unsafe
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Открывает чат задачи и помечает все сообщения как прочитанные
|
||||||
|
function openTaskAndMarkRead(taskId) {
|
||||||
|
closeUnreadNotification();
|
||||||
|
// Опционально: сразу отмечаем все сообщения прочитанными
|
||||||
|
fetch(`/api/chat/tasks/${taskId}/mark-read`, { method: 'POST' })
|
||||||
|
.catch(err => console.warn('Не удалось отметить сообщения как прочитанные', err))
|
||||||
|
.finally(() => openTaskChat(taskId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка наличия непрочитанных сообщений
|
||||||
|
function checkUnreadMessages() {
|
||||||
|
// Не беспокоим пользователя, если страница не активна
|
||||||
|
if (document.hidden) return;
|
||||||
|
|
||||||
|
fetch('/api/chat/unread-summary')
|
||||||
|
.then(response => {
|
||||||
|
if (response.status === 401) return null; // пользователь не авторизован
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
if (data && data.totalUnread > 0) {
|
||||||
|
showUnreadNotification(data.tasks);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => console.error('Ошибка проверки новых сообщений:', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запуск периодической проверки (раз в 5 минут)
|
||||||
|
setInterval(checkUnreadMessages, 5 * 60 * 1000);
|
||||||
|
|
||||||
|
// Проверка при загрузке страницы
|
||||||
|
document.addEventListener('DOMContentLoaded', checkUnreadMessages);
|
||||||
137
public/profile.js
Normal file
137
public/profile.js
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
// profile.js - Личный кабинет и настройки
|
||||||
|
|
||||||
|
// Личный кабинет
|
||||||
|
function showProfileSection() {
|
||||||
|
showSection('profile');
|
||||||
|
loadUserProfile();
|
||||||
|
loadNotificationSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUserProfile() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/user');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.user) {
|
||||||
|
const userInfo = document.getElementById('user-profile-info');
|
||||||
|
userInfo.innerHTML = `
|
||||||
|
<div class="profile-modern">
|
||||||
|
<!-- Шапка профиля с аватаром -->
|
||||||
|
<div class="profile-header">
|
||||||
|
<div class="profile-avatar">
|
||||||
|
<i class="fas fa-user-circle"></i>
|
||||||
|
</div>
|
||||||
|
<div class="profile-title">
|
||||||
|
<h3>${data.user.name}</h3>
|
||||||
|
<span class="profile-badge ${data.user.role}">
|
||||||
|
${data.user.role === 'admin' ? 'Администратор' : 'Сотрудник'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Сетка с информацией -->
|
||||||
|
<div class="profile-grid">
|
||||||
|
<div class="profile-info-card">
|
||||||
|
<div class="info-icon">
|
||||||
|
<i class="fas fa-at"></i>
|
||||||
|
</div>
|
||||||
|
<div class="info-content">
|
||||||
|
<span class="info-label">Логин</span>
|
||||||
|
<span class="info-value">${data.user.login}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="profile-info-card">
|
||||||
|
<div class="info-icon">
|
||||||
|
<i class="fas fa-shield-alt"></i>
|
||||||
|
</div>
|
||||||
|
<div class="info-content">
|
||||||
|
<span class="info-label">Тип авторизации</span>
|
||||||
|
<span class="info-value">
|
||||||
|
${data.user.auth_type === 'ldap' ?
|
||||||
|
'<span class="badge-ldap"><i class="fas fa-building"></i> LDAP</span>' :
|
||||||
|
'<span class="badge-local"><i class="fas fa-database"></i> Локальная</span>'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${data.user.groups && data.user.groups.length > 0 ? `
|
||||||
|
<div class="profile-info-card groups-card">
|
||||||
|
<div class="info-icon">
|
||||||
|
<i class="fas fa-users"></i>
|
||||||
|
</div>
|
||||||
|
<div class="info-content">
|
||||||
|
<span class="info-label">Группы доступа</span>
|
||||||
|
<div class="groups-list">
|
||||||
|
${Array.isArray(data.user.groups) ?
|
||||||
|
data.user.groups.map(group =>
|
||||||
|
`<span class="group-tag">${group}</span>`
|
||||||
|
).join('') :
|
||||||
|
`<span class="group-tag">${data.user.groups}</span>`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки профиля:', error);
|
||||||
|
const userInfo = document.getElementById('user-profile-info');
|
||||||
|
if (userInfo) {
|
||||||
|
userInfo.innerHTML = `
|
||||||
|
<div class="profile-error">
|
||||||
|
<i class="fas fa-exclamation-circle"></i>
|
||||||
|
<p>Ошибка загрузки профиля</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Настройки уведомлений
|
||||||
|
async function loadNotificationSettings() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/user/settings');
|
||||||
|
const settings = await response.json();
|
||||||
|
|
||||||
|
document.getElementById('email-notifications').checked = settings.email_notifications;
|
||||||
|
document.getElementById('notification-email').value = settings.notification_email || '';
|
||||||
|
document.getElementById('telegram-notifications').checked = settings.telegram_notifications;
|
||||||
|
document.getElementById('vk-notifications').checked = settings.vk_notifications;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки настроек:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveNotificationSettings(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const settings = {
|
||||||
|
email_notifications: document.getElementById('email-notifications').checked,
|
||||||
|
notification_email: document.getElementById('notification-email').value.trim(),
|
||||||
|
telegram_notifications: document.getElementById('telegram-notifications').checked,
|
||||||
|
vk_notifications: document.getElementById('vk-notifications').checked
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/user/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(settings)
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
alert('Настройки уведомлений сохранены!');
|
||||||
|
} else {
|
||||||
|
alert('Ошибка сохранения настроек: ' + (result.error || 'Неизвестная ошибка'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка сохранения настроек:', error);
|
||||||
|
alert('Ошибка сохранения настроек');
|
||||||
|
}
|
||||||
|
}
|
||||||
297
public/reports.js
Normal file
297
public/reports.js
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
// reports.js – Отчёт по задачам с фильтрацией и группировкой
|
||||||
|
|
||||||
|
let reportData = []; // все назначения для отчёта
|
||||||
|
let currentReportFiltered = []; // отфильтрованные данные
|
||||||
|
|
||||||
|
// Конфигурация статусов и типов для фильтров
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ value: '', label: 'Все статусы' },
|
||||||
|
{ value: 'assigned', label: 'Назначена' },
|
||||||
|
{ value: 'assigned_overdue', label: 'Назначена (просрочена)' },
|
||||||
|
{ value: 'in_progress', label: 'В работе' },
|
||||||
|
{ value: 'in_progress_overdue', label: 'В работе (просрочена)' },
|
||||||
|
{ value: 'completed', label: 'Выполнена' },
|
||||||
|
{ value: 'completed_after_due', label: 'Выполнена после срока' },
|
||||||
|
{ value: 'overdue', label: 'Просрочена (системная)' },
|
||||||
|
{ value: 'rework', label: 'На доработке' },
|
||||||
|
{ value: 'deleted', label: 'Удалена' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const TASK_TYPE_OPTIONS = [
|
||||||
|
{ value: '', label: 'Все типы' },
|
||||||
|
{ value: 'regular', label: 'Обычная задача' },
|
||||||
|
{ value: 'document', label: 'Согласование документа' },
|
||||||
|
{ value: 'it', label: 'ИТ отдел' },
|
||||||
|
{ value: 'ahch', label: 'АХЧ' },
|
||||||
|
{ value: 'psychologist', label: 'Психолог' },
|
||||||
|
{ value: 'speech_therapist', label: 'Логопед' },
|
||||||
|
{ value: 'hr', label: 'Диспетчер расписания' },
|
||||||
|
{ value: 'certificate', label: 'Справка' },
|
||||||
|
{ value: 'e_journal', label: 'Эл. журнал' },
|
||||||
|
{ value: 'acquaintance', label: 'Ознакомление' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Функция показа секции отчёта
|
||||||
|
function showReportsSection() {
|
||||||
|
// Используем глобальный currentUser из auth.js
|
||||||
|
if (typeof currentUser === 'undefined' || !currentUser) {
|
||||||
|
console.error('currentUser не определён');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showSection('reports');
|
||||||
|
if (reportData.length === 0) {
|
||||||
|
loadReportData();
|
||||||
|
} else {
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка, имеет ли пользователь право видеть все задачи
|
||||||
|
function canViewAllTasks() {
|
||||||
|
if (!currentUser) return false;
|
||||||
|
|
||||||
|
// Администратор
|
||||||
|
if (currentUser.role === 'admin') return true;
|
||||||
|
|
||||||
|
// Секретарь (по роли)
|
||||||
|
if (currentUser.role === 'secretary') return true;
|
||||||
|
|
||||||
|
// Проверка групп "Руководители" и "Секретарь"
|
||||||
|
if (currentUser.groups && Array.isArray(currentUser.groups)) {
|
||||||
|
if (currentUser.groups.some(g =>
|
||||||
|
g === 'Руководители' || g.includes('Руководители') ||
|
||||||
|
g === 'Секретарь' || g.includes('Секретарь')
|
||||||
|
)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вычисление уточнённого статуса с учётом просрочки
|
||||||
|
function computeDisplayStatus(item) {
|
||||||
|
const now = new Date();
|
||||||
|
const due = item.due_date ? new Date(item.due_date) : null;
|
||||||
|
const completedAt = item.status === 'completed' && item.status_updated_at ? new Date(item.status_updated_at) : null;
|
||||||
|
|
||||||
|
if (item.status === 'assigned') {
|
||||||
|
if (due && due < now) return 'assigned_overdue';
|
||||||
|
return 'assigned';
|
||||||
|
}
|
||||||
|
if (item.status === 'in_progress') {
|
||||||
|
if (due && due < now) return 'in_progress_overdue';
|
||||||
|
return 'in_progress';
|
||||||
|
}
|
||||||
|
if (item.status === 'completed') {
|
||||||
|
if (due && completedAt && completedAt > due) return 'completed_after_due';
|
||||||
|
return 'completed';
|
||||||
|
}
|
||||||
|
return item.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка данных для отчёта
|
||||||
|
async function loadReportData() {
|
||||||
|
try {
|
||||||
|
const tbody = document.getElementById('report-table-body');
|
||||||
|
tbody.innerHTML = '<tr><td colspan="7" class="loading">Загрузка данных...</td></tr>';
|
||||||
|
|
||||||
|
const response = await fetch('/api/tasks?status=all&limit=1000');
|
||||||
|
if (!response.ok) throw new Error('Ошибка загрузки задач');
|
||||||
|
|
||||||
|
const tasks = await response.json();
|
||||||
|
|
||||||
|
reportData = [];
|
||||||
|
const canViewAll = canViewAllTasks();
|
||||||
|
tasks.forEach(task => {
|
||||||
|
// Если не админ и не руководитель, показываем только задачи, где пользователь - автор
|
||||||
|
if (!canViewAll && task.created_by !== currentUser.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (task.assignments && task.assignments.length) {
|
||||||
|
task.assignments.forEach(ass => {
|
||||||
|
const item = {
|
||||||
|
task_id: task.id,
|
||||||
|
task_title: task.title,
|
||||||
|
task_description: task.description || '',
|
||||||
|
task_type: task.task_type || 'regular',
|
||||||
|
due_date: task.due_date,
|
||||||
|
user_id: ass.user_id,
|
||||||
|
user_name: ass.user_name,
|
||||||
|
status: ass.status,
|
||||||
|
status_updated_at: ass.updated_at || task.updated_at,
|
||||||
|
creator_name: task.creator_name,
|
||||||
|
created_by: task.created_by
|
||||||
|
};
|
||||||
|
item.displayStatus = computeDisplayStatus(item);
|
||||||
|
reportData.push(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
populateUserFilter(reportData);
|
||||||
|
populateTaskIdFilter(reportData);
|
||||||
|
applyFilters();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки отчёта:', error);
|
||||||
|
document.getElementById('report-table-body').innerHTML =
|
||||||
|
'<tr><td colspan="7" class="error">Ошибка загрузки данных</td></tr>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заполнение выпадающего списка пользователей
|
||||||
|
function populateUserFilter(data) {
|
||||||
|
const select = document.getElementById('report-user-filter');
|
||||||
|
const usersMap = new Map();
|
||||||
|
data.forEach(item => {
|
||||||
|
const name = item.user_name || 'Без имени';
|
||||||
|
usersMap.set(item.user_id, name);
|
||||||
|
});
|
||||||
|
|
||||||
|
let options = '<option value="">Все пользователи</option>';
|
||||||
|
const sorted = Array.from(usersMap.entries()).sort((a, b) => {
|
||||||
|
const nameA = a[1] || '';
|
||||||
|
const nameB = b[1] || '';
|
||||||
|
return nameA.localeCompare(nameB);
|
||||||
|
});
|
||||||
|
for (let [id, name] of sorted) {
|
||||||
|
options += `<option value="${id}">${escapeHtml(name)}</option>`;
|
||||||
|
}
|
||||||
|
select.innerHTML = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заполнение выпадающего списка номеров задач (по убыванию)
|
||||||
|
function populateTaskIdFilter(data) {
|
||||||
|
const select = document.getElementById('report-task-id-filter');
|
||||||
|
const taskIds = [...new Set(data.map(item => item.task_id))];
|
||||||
|
taskIds.sort((a, b) => b - a);
|
||||||
|
|
||||||
|
let options = '<option value="">Все номера</option>';
|
||||||
|
taskIds.forEach(id => {
|
||||||
|
options += `<option value="${id}">${id}</option>`;
|
||||||
|
});
|
||||||
|
select.innerHTML = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Применение всех фильтров и рендеринг
|
||||||
|
function applyFilters() {
|
||||||
|
const userId = document.getElementById('report-user-filter').value;
|
||||||
|
const statusFilter = document.getElementById('report-status-filter').value;
|
||||||
|
const typeFilter = document.getElementById('report-type-filter').value;
|
||||||
|
const taskIdFilter = document.getElementById('report-task-id-filter').value;
|
||||||
|
|
||||||
|
currentReportFiltered = reportData.filter(item => {
|
||||||
|
if (userId && item.user_id != userId) return false;
|
||||||
|
if (typeFilter && item.task_type !== typeFilter) return false;
|
||||||
|
if (taskIdFilter && item.task_id != taskIdFilter) return false;
|
||||||
|
if (statusFilter) {
|
||||||
|
if (item.displayStatus !== statusFilter) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
renderReport(currentReportFiltered);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сброс всех фильтров
|
||||||
|
function resetReportFilters() {
|
||||||
|
document.getElementById('report-user-filter').value = '';
|
||||||
|
document.getElementById('report-status-filter').value = '';
|
||||||
|
document.getElementById('report-type-filter').value = '';
|
||||||
|
document.getElementById('report-task-id-filter').value = '';
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Рендеринг таблицы и сводки
|
||||||
|
function renderReport(data) {
|
||||||
|
const tbody = document.getElementById('report-table-body');
|
||||||
|
const summaryDiv = document.getElementById('report-summary');
|
||||||
|
|
||||||
|
if (!data.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="7" class="no-data">Нет данных для отображения</td></tr>';
|
||||||
|
summaryDiv.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusCounts = {};
|
||||||
|
data.forEach(item => {
|
||||||
|
statusCounts[item.displayStatus] = (statusCounts[item.displayStatus] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusLabels = {
|
||||||
|
'assigned': 'Назначена',
|
||||||
|
'assigned_overdue': 'Назначена (просрочена)',
|
||||||
|
'in_progress': 'В работе',
|
||||||
|
'in_progress_overdue': 'В работе (просрочена)',
|
||||||
|
'completed': 'Выполнена',
|
||||||
|
'completed_after_due': 'Выполнена после срока',
|
||||||
|
'overdue': 'Просрочена (системная)',
|
||||||
|
'rework': 'На доработке',
|
||||||
|
'deleted': 'Удалена'
|
||||||
|
};
|
||||||
|
|
||||||
|
let summaryHtml = '';
|
||||||
|
for (let [status, count] of Object.entries(statusCounts)) {
|
||||||
|
summaryHtml += `
|
||||||
|
<div class="summary-item">
|
||||||
|
<span class="status-badge">${count}</span>
|
||||||
|
<span class="status-label">${statusLabels[status] || status}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
summaryDiv.innerHTML = summaryHtml;
|
||||||
|
|
||||||
|
tbody.innerHTML = data.map(item => `
|
||||||
|
<tr>
|
||||||
|
<td>${item.task_id}</td>
|
||||||
|
<td>${escapeHtml(item.task_title)}</td>
|
||||||
|
<td>${formatDateTimereports(item.due_date) || '—'}</td>
|
||||||
|
<td>${escapeHtml(item.user_name || 'Неизвестно')}</td>
|
||||||
|
<td>${escapeHtml(item.creator_name || 'Неизвестно')}</td>
|
||||||
|
<td><span class="status-badge status-${item.displayStatus}">${statusLabels[item.displayStatus] || item.displayStatus}</span></td>
|
||||||
|
<td>${formatDateTimereports(item.status_updated_at) || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
function formatDateTimereports(dateTimeString) {
|
||||||
|
if (!dateTimeString) return '';
|
||||||
|
|
||||||
|
let date;
|
||||||
|
// Если строка в формате SQLite (без часового пояса)
|
||||||
|
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(dateTimeString)) {
|
||||||
|
// Добавляем 'Z', чтобы интерпретировать как UTC
|
||||||
|
date = new Date(dateTimeString.replace(' ', 'T') + 'Z');
|
||||||
|
} else {
|
||||||
|
// Стандартная дата с часовым поясом (например, с Z или смещением)
|
||||||
|
date = new Date(dateTimeString);
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toLocaleString('ru-RU');
|
||||||
|
}
|
||||||
|
// Вспомогательные функции
|
||||||
|
function truncateText(text, maxLen) {
|
||||||
|
if (!text) return '';
|
||||||
|
return text.length > maxLen ? text.substr(0, maxLen) + '…' : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(unsafe) {
|
||||||
|
if (!unsafe) return '';
|
||||||
|
return String(unsafe)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function printReport() {
|
||||||
|
window.print();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Экспорт
|
||||||
|
window.showReportsSection = showReportsSection;
|
||||||
|
window.loadReportData = loadReportData;
|
||||||
|
window.applyFilters = applyFilters;
|
||||||
|
window.resetReportFilters = resetReportFilters;
|
||||||
|
window.printReport = printReport;
|
||||||
1932
public/script.js
1932
public/script.js
File diff suppressed because it is too large
Load Diff
193
public/signature.js
Normal file
193
public/signature.js
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
// signature.js - Универсальный скрипт для подписания задач (только логика, без встройки в интерфейс)
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Конфигурация
|
||||||
|
const CONFIG = {
|
||||||
|
signerGroup: 'Подписант',
|
||||||
|
apiEndpoint: '/api2/idusers',
|
||||||
|
usersEndpoint: '/api/users',
|
||||||
|
replaceEndpoint: '/api/tasks/${taskId}/replace-assignee',
|
||||||
|
replaceAllEndpoint: '/api/tasks/${taskId}/replace-all-assignees',
|
||||||
|
taskTypeDocument: 'document'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Текущий пользователь
|
||||||
|
let currentUser = null;
|
||||||
|
// Является ли текущий пользователь подписантом
|
||||||
|
let isCurrentUserSigner = false;
|
||||||
|
|
||||||
|
// Получение текущего пользователя
|
||||||
|
async function getCurrentUser() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/user');
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.user) {
|
||||||
|
currentUser = data.user;
|
||||||
|
await checkIfUserIsSigner();
|
||||||
|
}
|
||||||
|
return currentUser;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Signature: ошибка получения пользователя', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка, является ли текущий пользователь подписантом
|
||||||
|
async function checkIfUserIsSigner() {
|
||||||
|
if (!currentUser) return false;
|
||||||
|
try {
|
||||||
|
const signers = await getSigners();
|
||||||
|
isCurrentUserSigner = signers.some(signer =>
|
||||||
|
signer.id === currentUser.id ||
|
||||||
|
signer.login === currentUser.login ||
|
||||||
|
signer.name === currentUser.name
|
||||||
|
);
|
||||||
|
return isCurrentUserSigner;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Signature: ошибка проверки подписанта', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получение подписантов
|
||||||
|
async function getSigners() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(CONFIG.apiEndpoint);
|
||||||
|
if (!response.ok) return await getSignersFallback();
|
||||||
|
const data = await response.json();
|
||||||
|
const signers = data.filter(user =>
|
||||||
|
user.is_active && (
|
||||||
|
user.group_name?.toLowerCase().includes(CONFIG.signerGroup.toLowerCase()) ||
|
||||||
|
user.metadata?.groups?.some(g => g.toLowerCase().includes(CONFIG.signerGroup.toLowerCase())) ||
|
||||||
|
user.ldap_group?.toLowerCase().includes(CONFIG.signerGroup.toLowerCase())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return signers.map(s => ({
|
||||||
|
id: s.user_id,
|
||||||
|
name: s.user_name || s.login || 'Неизвестно',
|
||||||
|
login: s.user_login || s.login || ''
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
return await getSignersFallback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запасной метод получения подписантов
|
||||||
|
async function getSignersFallback() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(CONFIG.usersEndpoint);
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const users = await res.json();
|
||||||
|
return users.filter(u => u.role === 'admin' || u.role === 'signer')
|
||||||
|
.map(u => ({ id: u.id, name: u.name || u.login, login: u.login }));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Основная функция подписания
|
||||||
|
window.signTask = async function(taskId, userId) {
|
||||||
|
const targetUserId = userId || currentUser?.id;
|
||||||
|
if (!targetUserId) {
|
||||||
|
alert('❌ Не удалось определить текущего пользователя');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const signers = await getSigners();
|
||||||
|
if (!signers.length) {
|
||||||
|
alert('❌ Секретари не найдены в системе');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const names = signers.map(s => `• ${s.name} (${s.login})`).join('\n');
|
||||||
|
const msg = signers.length === 1
|
||||||
|
? `✍️ Назначить подписанта?\n\n${names}`
|
||||||
|
: `✍️ Назначить подписантов?\n\n${names}`;
|
||||||
|
if (!confirm(msg)) return false;
|
||||||
|
|
||||||
|
let response;
|
||||||
|
if (signers.length > 1) {
|
||||||
|
const url = CONFIG.replaceAllEndpoint.replace('${taskId}', taskId);
|
||||||
|
response = await fetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ newAssigneeIds: signers.map(s => s.id) })
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const url = CONFIG.replaceEndpoint.replace('${taskId}', taskId);
|
||||||
|
response = await fetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
oldAssigneeId: targetUserId,
|
||||||
|
newAssigneeId: signers[0].id
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
alert(`✅ Задача ${signers.length > 1 ? 'назначена подписантам' : 'переназначена подписанту'}`);
|
||||||
|
// Пробуем перезагрузить задачи
|
||||||
|
if (window.TasksType && typeof window.TasksType.loadTasks === 'function') {
|
||||||
|
window.TasksType.loadTasks();
|
||||||
|
} else if (window.loadTasks && typeof window.loadTasks === 'function') {
|
||||||
|
window.loadTasks();
|
||||||
|
} else {
|
||||||
|
setTimeout(() => window.location.reload(), 1500);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
const err = await response.json();
|
||||||
|
alert('❌ Ошибка: ' + (err.error || 'Неизвестная ошибка'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Signature error:', error);
|
||||||
|
alert('❌ Сетевая ошибка: ' + error.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Обновление статуса задачи (может пригодиться)
|
||||||
|
window.updateTaskStatus = async function(taskId, userId, status) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/tasks/${taskId}/status`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ userId, status })
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
if (window.TasksType && typeof window.TasksType.loadTasks === 'function') {
|
||||||
|
window.TasksType.loadTasks();
|
||||||
|
} else if (window.loadTasks && typeof window.loadTasks === 'function') {
|
||||||
|
window.loadTasks();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
const err = await response.json();
|
||||||
|
alert('❌ Ошибка: ' + (err.error || 'Неизвестная ошибка'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Status update error:', error);
|
||||||
|
alert('❌ Сетевая ошибка');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Инициализация: получаем данные текущего пользователя (без добавления кнопок)
|
||||||
|
async function init() {
|
||||||
|
await getCurrentUser();
|
||||||
|
console.log('✅ Signature module loaded (UI integration removed)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запускаем инициализацию после загрузки DOM
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
})();
|
||||||
3002
public/style.css
3002
public/style.css
File diff suppressed because it is too large
Load Diff
1213
public/tasks-type.js
Normal file
1213
public/tasks-type.js
Normal file
File diff suppressed because it is too large
Load Diff
753
public/tasks.js
Normal file
753
public/tasks.js
Normal file
@@ -0,0 +1,753 @@
|
|||||||
|
// tasks.js - Основные операции с задачами
|
||||||
|
let tasks = []; // локальная переменная
|
||||||
|
window.tasks = tasks; // делаем доступной глобально
|
||||||
|
let expandedTasks = new Set();
|
||||||
|
let showingTasksWithoutDate = false;
|
||||||
|
|
||||||
|
// Функция загрузки задач с фильтрацией на сервере
|
||||||
|
async function loadTasks() {
|
||||||
|
try {
|
||||||
|
// Получаем значения фильтров
|
||||||
|
const statusFilter = document.getElementById('status-filter')?.value || 'active,in_progress,assigned,overdue,rework';
|
||||||
|
const creatorFilter = document.getElementById('creator-filter')?.value || '';
|
||||||
|
const assigneeFilter = document.getElementById('assignee-filter')?.value || '';
|
||||||
|
const deadlineFilter = document.getElementById('deadline-filter')?.value || '';
|
||||||
|
const searchQuery = document.getElementById('search-tasks')?.value || '';
|
||||||
|
const typeFilter = document.getElementById('type-filter')?.value || '';
|
||||||
|
|
||||||
|
// ===== ДОБАВЛЕНО: получаем текущий вид из глобальной переменной =====
|
||||||
|
const view = window.currentTaskView || 'all'; // 'all', 'my_assigned', 'assigned_to_me'
|
||||||
|
|
||||||
|
// Формируем URL с параметрами
|
||||||
|
let url = '/api/tasks?';
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (statusFilter !== 'all') {
|
||||||
|
params.push(`status=${encodeURIComponent(statusFilter)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (creatorFilter) {
|
||||||
|
params.push(`creator=${encodeURIComponent(creatorFilter)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assigneeFilter) {
|
||||||
|
params.push(`assignee=${encodeURIComponent(assigneeFilter)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deadlineFilter) {
|
||||||
|
params.push(`deadline=${encodeURIComponent(deadlineFilter)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchQuery) {
|
||||||
|
params.push(`search=${encodeURIComponent(searchQuery)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeFilter) {
|
||||||
|
params.push(`task_type=${encodeURIComponent(typeFilter)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view !== 'all') {
|
||||||
|
params.push(`view=${encodeURIComponent(view)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
url += params.join('&');
|
||||||
|
|
||||||
|
////console.log('Загрузка задач с фильтрами:', url);
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks = await response.json();
|
||||||
|
window.tasks = tasks; // синхронизируем глобальную переменную
|
||||||
|
//console.log(`Загружено ${tasks.length} задач`);
|
||||||
|
|
||||||
|
// Загружаем поля документа для задач типа "document"
|
||||||
|
await loadDocumentFieldsForTasks();
|
||||||
|
|
||||||
|
// Загружаем файлы для развернутых задач
|
||||||
|
await loadFilesForExpandedTasks();
|
||||||
|
|
||||||
|
// Обновляем отображение в зависимости от активной секции
|
||||||
|
renderTasksForActiveSection();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки задач:', error);
|
||||||
|
const container = document.getElementById('tasks-list');
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = '<div class="error">Ошибка загрузки задач</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Новая функция для определения активной секции и рендеринга
|
||||||
|
function renderTasksForActiveSection() {
|
||||||
|
const activeSection = document.querySelector('.section.active');
|
||||||
|
if (!activeSection) {
|
||||||
|
renderTasks(); // по умолчанию
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionId = activeSection.id;
|
||||||
|
if (sectionId === 'mytasks-section') {
|
||||||
|
if (typeof renderMyTasks === 'function') {
|
||||||
|
renderMyTasks();
|
||||||
|
} else {
|
||||||
|
renderTasksInContainer('mytasks-list', tasks);
|
||||||
|
}
|
||||||
|
} else if (sectionId === 'runtasks-section') {
|
||||||
|
if (typeof renderRunTasks === 'function') {
|
||||||
|
renderRunTasks();
|
||||||
|
} else {
|
||||||
|
renderTasksInContainer('runtasks-list', tasks);
|
||||||
|
}
|
||||||
|
} else if (sectionId === 'tasks-section') {
|
||||||
|
renderTasks();
|
||||||
|
} else {
|
||||||
|
renderTasks();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Новая функция для загрузки полей документов
|
||||||
|
async function loadDocumentFieldsForTasks() {
|
||||||
|
const documentTasks = tasks.filter(task => task.task_type === 'document');
|
||||||
|
|
||||||
|
if (documentTasks.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
//console.log(`Загрузка полей документов для ${documentTasks.length} задач`);
|
||||||
|
|
||||||
|
// Загружаем поля для каждой задачи
|
||||||
|
for (const task of documentTasks) {
|
||||||
|
try {
|
||||||
|
const docResponse = await fetch(`/api/tasks/${task.id}/document-fields`);
|
||||||
|
if (docResponse.ok) {
|
||||||
|
const docData = await docResponse.json();
|
||||||
|
task.document_fields = docData.data || {};
|
||||||
|
//console.log(`✅ Загружены поля для задачи ${task.id}:`, task.document_fields);
|
||||||
|
} else {
|
||||||
|
task.document_fields = {};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Ошибка загрузки полей документа для задачи ${task.id}:`, error);
|
||||||
|
task.document_fields = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Новая функция для загрузки файлов развернутых задач
|
||||||
|
async function loadFilesForExpandedTasks() {
|
||||||
|
const expandedTasksArray = tasks.filter(task => expandedTasks.has(task.id));
|
||||||
|
|
||||||
|
if (expandedTasksArray.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(expandedTasksArray.map(async (task) => {
|
||||||
|
try {
|
||||||
|
const filesResponse = await fetch(`/api/tasks/${task.id}/files`);
|
||||||
|
if (filesResponse.ok) {
|
||||||
|
task.files = await filesResponse.json();
|
||||||
|
} else {
|
||||||
|
task.files = [];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Ошибка загрузки файлов для задачи ${task.id}:`, error);
|
||||||
|
task.files = [];
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function showTasksWithoutDate() {
|
||||||
|
showingTasksWithoutDate = true;
|
||||||
|
const btn = document.getElementById('tasks-no-date-btn');
|
||||||
|
if (btn) btn.classList.add('active');
|
||||||
|
loadTasksWithoutDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAllFilters() {
|
||||||
|
document.getElementById('status-filter').value = 'active,in_progress,assigned,overdue,rework';
|
||||||
|
document.getElementById('creator-filter').value = '';
|
||||||
|
document.getElementById('assignee-filter').value = '';
|
||||||
|
document.getElementById('deadline-filter').value = '';
|
||||||
|
document.getElementById('type-filter').value = '';
|
||||||
|
document.getElementById('search-tasks').value = '';
|
||||||
|
loadTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTasksWithoutDate() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/tasks');
|
||||||
|
if (!response.ok) throw new Error('Ошибка загрузки задач');
|
||||||
|
|
||||||
|
const allTasks = await response.json();
|
||||||
|
tasks = allTasks.filter(task => {
|
||||||
|
const hasTaskDueDate = !task.due_date;
|
||||||
|
const hasAssignmentDueDates = task.assignments &&
|
||||||
|
task.assignments.every(assignment => !assignment.due_date);
|
||||||
|
return hasTaskDueDate && hasAssignmentDueDates;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Загружаем файлы для всех задач
|
||||||
|
await Promise.all(tasks.map(async (task) => {
|
||||||
|
try {
|
||||||
|
const filesResponse = await fetch(`/api/tasks/${task.id}/files`);
|
||||||
|
if (filesResponse.ok) {
|
||||||
|
task.files = await filesResponse.json();
|
||||||
|
} else {
|
||||||
|
task.files = [];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Ошибка загрузки файлов для задачи ${task.id}:`, error);
|
||||||
|
task.files = [];
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Загружаем поля документов
|
||||||
|
await loadDocumentFieldsForTasks();
|
||||||
|
|
||||||
|
renderTasks();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки задач без срока:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEditModal(taskId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/tasks/${taskId}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
alert('Задача не найдена или у вас нет прав доступа');
|
||||||
|
}
|
||||||
|
throw new Error('Ошибка загрузки задачи');
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = await response.json();
|
||||||
|
|
||||||
|
if (!canUserEditTask(task)) {
|
||||||
|
alert('У вас нет прав для редактирования этой задачи');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('edit-task-id').value = task.id;
|
||||||
|
document.getElementById('edit-title').value = task.title;
|
||||||
|
document.getElementById('edit-description').value = task.description || '';
|
||||||
|
|
||||||
|
document.getElementById('edit-due-date').value = task.due_date ? formatDateTimeForInput(task.due_date) : '';
|
||||||
|
|
||||||
|
// Устанавливаем выбранных пользователей
|
||||||
|
editSelectedUsers = task.assignments ? task.assignments.map(a => a.user_id) : [];
|
||||||
|
renderEditUsersChecklist(users);
|
||||||
|
|
||||||
|
// Показываем существующие файлы
|
||||||
|
currentEditTaskFiles = task.files || [];
|
||||||
|
updateEditFileList();
|
||||||
|
|
||||||
|
document.getElementById('edit-task-modal').style.display = 'block';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка:', error);
|
||||||
|
alert('Ошибка загрузки задачи');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditModal() {
|
||||||
|
document.getElementById('edit-task-modal').style.display = 'none';
|
||||||
|
document.getElementById('edit-file-list').innerHTML = '';
|
||||||
|
document.getElementById('edit-user-search').value = '';
|
||||||
|
editSelectedUsers = [];
|
||||||
|
currentEditTaskFiles = [];
|
||||||
|
filterEditUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateTask(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const taskId = document.getElementById('edit-task-id').value;
|
||||||
|
const title = document.getElementById('edit-title').value;
|
||||||
|
const description = document.getElementById('edit-description').value;
|
||||||
|
const dueDate = document.getElementById('edit-due-date').value;
|
||||||
|
|
||||||
|
if (!dueDate) {
|
||||||
|
alert('Дата и время выполнения обязательны');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignedUserIds = editSelectedUsers;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('title', title);
|
||||||
|
formData.append('description', description);
|
||||||
|
formData.append('assignedUsers', JSON.stringify(assignedUserIds));
|
||||||
|
formData.append('dueDate', dueDate);
|
||||||
|
|
||||||
|
const files = document.getElementById('edit-files').files;
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
formData.append('files', files[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/tasks/${taskId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
alert('Задача успешно обновлена!');
|
||||||
|
closeEditModal();
|
||||||
|
loadTasks();
|
||||||
|
loadActivityLogs();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(error.error || 'Ошибка обновления задачи');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка:', error);
|
||||||
|
alert('Ошибка обновления задачи');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCopyModal(taskId) {
|
||||||
|
document.getElementById('copy-task-id').value = taskId;
|
||||||
|
|
||||||
|
const defaultDate = new Date();
|
||||||
|
defaultDate.setDate(defaultDate.getDate() + 7);
|
||||||
|
document.getElementById('copy-due-date').value = defaultDate.toISOString().substring(0, 16);
|
||||||
|
|
||||||
|
copySelectedUsers = [];
|
||||||
|
renderCopyUsersChecklist(users);
|
||||||
|
|
||||||
|
document.getElementById('copy-task-modal').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCopyModal() {
|
||||||
|
document.getElementById('copy-task-modal').style.display = 'none';
|
||||||
|
document.getElementById('copy-user-search').value = '';
|
||||||
|
copySelectedUsers = [];
|
||||||
|
filterCopyUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyTask(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const taskId = document.getElementById('copy-task-id').value;
|
||||||
|
const dueDate = document.getElementById('copy-due-date').value;
|
||||||
|
|
||||||
|
if (!dueDate) {
|
||||||
|
alert('Дата и время выполнения обязательны для копии задачи');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignedUserIds = copySelectedUsers;
|
||||||
|
|
||||||
|
if (assignedUserIds.length === 0) {
|
||||||
|
alert('Выберите хотя бы одного исполнителя для копии задачи');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/tasks/${taskId}/copy`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
assignedUsers: assignedUserIds,
|
||||||
|
dueDate: dueDate
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
alert('Копия задачи успешно создана!');
|
||||||
|
closeCopyModal();
|
||||||
|
loadTasks();
|
||||||
|
loadActivityLogs();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(error.error || 'Ошибка создания копии задачи');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка:', error);
|
||||||
|
alert('Ошибка создания копии задачи');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeTask(taskId) {
|
||||||
|
if (!confirm('Вы уверены, что хотите закрыть эту задачу? Исполнители больше не будут видеть её.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/tasks/${taskId}/close`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
alert('Задача закрыта!');
|
||||||
|
loadTasks();
|
||||||
|
loadActivityLogs();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(error.error || 'Ошибка закрытия задачи');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка:', error);
|
||||||
|
alert('Ошибка закрытия задачи');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reopenTask(taskId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/tasks/${taskId}/reopen`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
alert('Задача открыта!');
|
||||||
|
loadTasks();
|
||||||
|
loadActivityLogs();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(error.error || 'Ошибка открытия задачи');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка:', error);
|
||||||
|
alert('Ошибка открытия задачи');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTask(taskId) {
|
||||||
|
if (!confirm('Вы уверены, что хотите удалить эту задачу?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/tasks/${taskId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
alert('Задача удалена!');
|
||||||
|
loadTasks();
|
||||||
|
loadActivityLogs();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(error.error || 'Ошибка удаления задачи');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка:', error);
|
||||||
|
alert('Ошибка удаления задачи');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreTask(taskId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/tasks/${taskId}/restore`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
alert('Задача восстановлена!');
|
||||||
|
loadTasks();
|
||||||
|
loadActivityLogs();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(error.error || 'Ошибка восстановления задачи');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка:', error);
|
||||||
|
alert('Ошибка восстановления задачи');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditAssignmentModal(taskId, userId) {
|
||||||
|
const task = tasks.find(t => t.id === taskId);
|
||||||
|
if (!task) return;
|
||||||
|
|
||||||
|
const assignment = task.assignments.find(a => a.user_id === userId);
|
||||||
|
if (!assignment) return;
|
||||||
|
|
||||||
|
document.getElementById('edit-assignment-task-id').value = taskId;
|
||||||
|
document.getElementById('edit-assignment-user-id').value = userId;
|
||||||
|
document.getElementById('edit-assignment-due-date').value = assignment.due_date ? formatDateTimeForInput(assignment.due_date) : '';
|
||||||
|
|
||||||
|
document.getElementById('edit-assignment-modal').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditAssignmentModal() {
|
||||||
|
document.getElementById('edit-assignment-modal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateAssignment(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const taskId = document.getElementById('edit-assignment-task-id').value;
|
||||||
|
const userId = document.getElementById('edit-assignment-user-id').value;
|
||||||
|
const dueDate = document.getElementById('edit-assignment-due-date').value;
|
||||||
|
|
||||||
|
if (!dueDate) {
|
||||||
|
alert('Дата и время выполнения обязательны');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/tasks/${taskId}/assignment/${userId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
dueDate: dueDate
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
alert('Сроки исполнителя обновлены!');
|
||||||
|
closeEditAssignmentModal();
|
||||||
|
loadTasks();
|
||||||
|
loadActivityLogs();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(error.error || 'Ошибка обновления сроков');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка:', error);
|
||||||
|
alert('Ошибка обновления сроков');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openReworkModal(taskId) {
|
||||||
|
document.getElementById('rework-task-id').value = taskId;
|
||||||
|
document.getElementById('rework-task-modal').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeReworkModal() {
|
||||||
|
document.getElementById('rework-task-modal').style.display = 'none';
|
||||||
|
document.getElementById('rework-comment').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendForRework(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const taskId = document.getElementById('rework-task-id').value;
|
||||||
|
const comment = document.getElementById('rework-comment').value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/tasks/${taskId}/rework`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ comment })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
alert('Задача возвращена на доработку!');
|
||||||
|
closeReworkModal();
|
||||||
|
loadTasks();
|
||||||
|
loadActivityLogs();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(error.error || 'Ошибка возврата задачи на доработку');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка:', error);
|
||||||
|
alert('Ошибка возврата задачи на доработку');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateStatus(taskId, userId, status) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/tasks/${taskId}/status`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ userId, status })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
loadTasks();
|
||||||
|
loadActivityLogs();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(error.error || 'Ошибка обновления статуса');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка:', error);
|
||||||
|
alert('Ошибка обновления статуса');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function canUserEditTask(task) {
|
||||||
|
if (!currentUser) return false;
|
||||||
|
|
||||||
|
if (currentUser.role === 'admin') return true;
|
||||||
|
if (currentUser.role === 'tasks') return true;
|
||||||
|
|
||||||
|
if (parseInt(task.created_by) === currentUser.id) {
|
||||||
|
if (task.assignments && task.assignments.length > 0) {
|
||||||
|
const assignedToOthers = task.assignments.some(assignment =>
|
||||||
|
parseInt(assignment.user_id) !== currentUser.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (assignedToOthers) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task.assignments) {
|
||||||
|
const isExecutor = task.assignments.some(assignment =>
|
||||||
|
parseInt(assignment.user_id) === currentUser.id
|
||||||
|
);
|
||||||
|
if (isExecutor) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canUserAddFilesToTask(task) {
|
||||||
|
if (!currentUser) return false;
|
||||||
|
|
||||||
|
if (currentUser.role === 'admin') return true;
|
||||||
|
|
||||||
|
if (parseInt(task.created_by) === currentUser.id) return true;
|
||||||
|
|
||||||
|
if (task.assignments) {
|
||||||
|
const isExecutor = task.assignments.some(assignment =>
|
||||||
|
parseInt(assignment.user_id) === currentUser.id
|
||||||
|
);
|
||||||
|
return isExecutor;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== ФУНКЦИИ ДЛЯ ОЗНАКОМЛЕНИЯ ====================
|
||||||
|
|
||||||
|
async function openAcquaintanceModal(taskId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/tasks/${taskId}`);
|
||||||
|
if (!response.ok) throw new Error('Ошибка загрузки задачи');
|
||||||
|
const task = await response.json();
|
||||||
|
|
||||||
|
// Заполняем модальное окно
|
||||||
|
document.getElementById('acquaintance-original-task-id').value = task.id;
|
||||||
|
document.getElementById('acquaintance-original-title').innerHTML = `
|
||||||
|
<strong>№${task.id}</strong> ${task.title}<br>
|
||||||
|
<small>Автор: ${task.creator_name}</small>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Устанавливаем дату выполнения по умолчанию (завтра)
|
||||||
|
const tomorrow = new Date();
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
document.getElementById('acquaintance-due-date').value = tomorrow.toISOString().split('T')[0];
|
||||||
|
document.getElementById('acquaintance-due-time').value = '19:00';
|
||||||
|
|
||||||
|
// Загружаем пользователей для выбора автора
|
||||||
|
await loadUsers(); // гарантируем, что users загружены
|
||||||
|
renderAcquaintanceAuthorsChecklist(users);
|
||||||
|
|
||||||
|
// Информация об исполнителе (текущий пользователь)
|
||||||
|
const executorInfo = document.getElementById('acquaintance-executor-info');
|
||||||
|
if (executorInfo) {
|
||||||
|
executorInfo.innerHTML = `Исполнитель: ${currentUser.name} (${currentUser.login})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отображаем модальное окно
|
||||||
|
document.getElementById('acquaintance-task-modal').style.display = 'block';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка открытия модального окна ознакомления:', error);
|
||||||
|
alert('Не удалось загрузить задачу');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAcquaintanceModal() {
|
||||||
|
const modal = document.getElementById('acquaintance-task-modal');
|
||||||
|
if (modal) modal.style.display = 'none';
|
||||||
|
|
||||||
|
const authorSearch = document.getElementById('acquaintance-author-search');
|
||||||
|
if (authorSearch) authorSearch.value = '';
|
||||||
|
|
||||||
|
const userSearch = document.getElementById('acquaintance-user-search');
|
||||||
|
if (userSearch) userSearch.value = '';
|
||||||
|
|
||||||
|
acquaintanceSelectedUsers = [];
|
||||||
|
acquaintanceSelectedAuthor = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAcquaintanceTask(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const originalTaskId = document.getElementById('acquaintance-original-task-id').value;
|
||||||
|
const dueDate = document.getElementById('acquaintance-due-date').value;
|
||||||
|
const dueTime = document.getElementById('acquaintance-due-time').value;
|
||||||
|
const fullDueDateTime = `${dueDate}T${dueTime}:00`;
|
||||||
|
const comment = document.getElementById('acquaintance-comment').value.trim();
|
||||||
|
const assignedUserIds = [currentUser.id]; // исполнитель – текущий пользователь
|
||||||
|
const creatorId = document.querySelector('input[name="acquaintance-author"]:checked')?.value;
|
||||||
|
|
||||||
|
if (!creatorId) {
|
||||||
|
alert('Выберите автора задачи');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/tasks/acquaintance', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
originalTaskId,
|
||||||
|
dueDate: fullDueDateTime,
|
||||||
|
assignedUserIds,
|
||||||
|
creatorId,
|
||||||
|
comment
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
alert('Задача ознакомления успешно создана!');
|
||||||
|
closeAcquaintanceModal();
|
||||||
|
loadTasks();
|
||||||
|
loadActivityLogs();
|
||||||
|
} else {
|
||||||
|
alert(`Ошибка: ${result.error || 'Неизвестная ошибка'}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка создания задачи ознакомления:', error);
|
||||||
|
alert('Сетевая ошибка');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем отладочную функцию
|
||||||
|
function debugDocumentFields() {
|
||||||
|
console.log('=== ОТЛАДКА ПОЛЕЙ ДОКУМЕНТОВ ===');
|
||||||
|
const documentTasks = tasks.filter(task => task.task_type === 'document');
|
||||||
|
console.log(`Найдено ${documentTasks.length} задач типа "document"`);
|
||||||
|
|
||||||
|
documentTasks.forEach(task => {
|
||||||
|
console.log(`Задача ${task.id}:`, {
|
||||||
|
title: task.title,
|
||||||
|
document_fields: task.document_fields || 'НЕТ ПОЛЕЙ'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
console.log('================================');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вызываем отладку после загрузки (можно вызвать вручную из консоли)
|
||||||
|
window.debugDocumentFields = debugDocumentFields;
|
||||||
|
window.loadTasks = loadTasks;
|
||||||
|
window.updateAssignment = updateAssignment;
|
||||||
|
window.renderTasksForActiveSection = renderTasksForActiveSection;
|
||||||
|
window.openAcquaintanceModal = openAcquaintanceModal;
|
||||||
|
window.closeAcquaintanceModal = closeAcquaintanceModal;
|
||||||
|
window.createAcquaintanceTask = createAcquaintanceTask;
|
||||||
619
public/tasks_files.js
Normal file
619
public/tasks_files.js
Normal file
@@ -0,0 +1,619 @@
|
|||||||
|
// tasks_files.js - Расширенное управление файлами задач
|
||||||
|
// Функции для удаления файлов, загрузки и управления доступом
|
||||||
|
|
||||||
|
// Сохраняем ссылку на оригинальную функцию при загрузке скрипта
|
||||||
|
let originalRenderFileIcon = null;
|
||||||
|
let originalRenderGroupedFiles = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, может ли пользователь удалить файл
|
||||||
|
* @param {Object} file - Объект файла
|
||||||
|
* @param {Object} task - Объект задачи
|
||||||
|
* @returns {boolean} - true если может удалить
|
||||||
|
*/
|
||||||
|
function canUserDeleteFile(file, task) {
|
||||||
|
if (!currentUser) return false;
|
||||||
|
|
||||||
|
// Администратор может удалять любые файлы
|
||||||
|
if (currentUser.role === 'admin') return true;
|
||||||
|
|
||||||
|
// Пользователи с ролью 'tasks' могут удалять любые файлы
|
||||||
|
if (currentUser.role === 'tasks') return true;
|
||||||
|
|
||||||
|
// Автор задачи может удалять любые файлы в своей задаче
|
||||||
|
if (task && parseInt(task.created_by) === currentUser.id) return true;
|
||||||
|
|
||||||
|
// Пользователь может удалять только свои файлы
|
||||||
|
if (file && parseInt(file.user_id) === currentUser.id) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаляет файл из задачи
|
||||||
|
* @param {number} fileId - ID файла
|
||||||
|
* @param {number} taskId - ID задачи
|
||||||
|
*/
|
||||||
|
async function deleteTaskFile(fileId, taskId) {
|
||||||
|
if (!confirm('Вы уверены, что хотите удалить этот файл?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Находим задачу и файл для проверки прав
|
||||||
|
const task = tasks.find(t => t.id === taskId);
|
||||||
|
if (!task) {
|
||||||
|
alert('Задача не найдена');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Находим файл в текущих данных
|
||||||
|
let file = null;
|
||||||
|
if (task.files) {
|
||||||
|
file = task.files.find(f => f.id === fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
// Пробуем загрузить файл отдельно
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/files/${fileId}`);
|
||||||
|
if (response.ok) {
|
||||||
|
file = await response.json();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки данных файла:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем права на удаление
|
||||||
|
if (!canUserDeleteFile(file || { user_id: 0 }, task)) {
|
||||||
|
alert('У вас нет прав для удаления этого файла');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/tasks/${taskId}/files/${fileId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
alert('✅ Файл успешно удален');
|
||||||
|
|
||||||
|
// Обновляем список файлов в задаче
|
||||||
|
if (task.files) {
|
||||||
|
task.files = task.files.filter(f => f.id !== fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем отображение
|
||||||
|
const fileContainer = document.getElementById(`files-${taskId}`);
|
||||||
|
if (fileContainer) {
|
||||||
|
fileContainer.innerHTML = `
|
||||||
|
<strong>Файлы:</strong>
|
||||||
|
${task.files && task.files.length > 0 ?
|
||||||
|
renderGroupedFilesWithDelete(task) :
|
||||||
|
'<span class="no-files">нет файлов</span>'}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Перезагружаем задачи для синхронизации
|
||||||
|
if (typeof loadTasks === 'function') {
|
||||||
|
loadTasks();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(`❌ Ошибка: ${error.error || 'Неизвестная ошибка'}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Ошибка удаления файла:', error);
|
||||||
|
alert('Сетевая ошибка при удалении файла');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Рендерит иконку файла с кнопкой удаления
|
||||||
|
* @param {Object} file - Объект файла
|
||||||
|
* @param {number} taskId - ID задачи
|
||||||
|
* @param {Object} task - Объект задачи (опционально)
|
||||||
|
* @returns {string} HTML строка
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Рендерит иконку файла с кнопкой удаления
|
||||||
|
* @param {Object} file - Объект файла
|
||||||
|
* @param {number} taskId - ID задачи
|
||||||
|
* @param {Object} task - Объект задачи (опционально)
|
||||||
|
* @returns {string} HTML строка
|
||||||
|
*/
|
||||||
|
function renderFileIconWithDelete(file, taskId, task) {
|
||||||
|
// Получаем задачу, если не передана
|
||||||
|
if (!task && taskId) {
|
||||||
|
task = tasks.find(t => t.id === taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Исправляем кодировку имени файла
|
||||||
|
const fixEncoding = (str) => {
|
||||||
|
if (!str) return '';
|
||||||
|
try {
|
||||||
|
if (str.includes('Ð') || str.includes('Ñ')) {
|
||||||
|
return decodeURIComponent(escape(str));
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
} catch (e) {
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileName = fixEncoding(file.original_name);
|
||||||
|
const fileSize = (file.file_size / 1024 / 1024).toFixed(2);
|
||||||
|
const uploadedBy = file.user_name;
|
||||||
|
|
||||||
|
// --- ПОЛНАЯ ЛОГИКА ОПРЕДЕЛЕНИЯ ЦВЕТА И ИКОНКИ ИЗ ОРИГИНАЛЬНОЙ renderFileIcon ---
|
||||||
|
let iconColor = '';
|
||||||
|
let iconText = '';
|
||||||
|
let textClass = '';
|
||||||
|
|
||||||
|
// Определяем расширение файла
|
||||||
|
const extension = fileName.includes('.') ?
|
||||||
|
fileName.split('.').pop().toLowerCase() :
|
||||||
|
'';
|
||||||
|
|
||||||
|
// Определяем тип файла на основе расширения
|
||||||
|
if (extension) {
|
||||||
|
switch (extension) {
|
||||||
|
case 'pdf':
|
||||||
|
iconColor = '#e74c3c';
|
||||||
|
iconText = 'PDF';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'doc':
|
||||||
|
iconColor = '#3498db';
|
||||||
|
iconText = 'DOC';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'docx':
|
||||||
|
iconColor = '#3498db';
|
||||||
|
iconText = 'DOCX';
|
||||||
|
textClass = 'medium';
|
||||||
|
break;
|
||||||
|
case 'xls':
|
||||||
|
iconColor = '#2ecc71';
|
||||||
|
iconText = 'XLS';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'xlsx':
|
||||||
|
iconColor = '#2ecc71';
|
||||||
|
iconText = 'XLSX';
|
||||||
|
textClass = 'medium';
|
||||||
|
break;
|
||||||
|
case 'csv':
|
||||||
|
iconColor = '#2ecc71';
|
||||||
|
iconText = 'CSV';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'ppt':
|
||||||
|
iconColor = '#e67e22';
|
||||||
|
iconText = 'PPT';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'pptx':
|
||||||
|
iconColor = '#e67e22';
|
||||||
|
iconText = 'PPTX';
|
||||||
|
textClass = 'medium';
|
||||||
|
break;
|
||||||
|
case 'zip':
|
||||||
|
iconColor = '#f39c12';
|
||||||
|
iconText = 'ZIP';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'rar':
|
||||||
|
iconColor = '#f39c12';
|
||||||
|
iconText = 'RAR';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case '7z':
|
||||||
|
iconColor = '#f39c12';
|
||||||
|
iconText = '7Z';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'tar':
|
||||||
|
iconColor = '#f39c12';
|
||||||
|
iconText = 'TAR';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'gz':
|
||||||
|
iconColor = '#f39c12';
|
||||||
|
iconText = 'GZ';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'txt':
|
||||||
|
iconColor = '#95a5a6';
|
||||||
|
iconText = 'TXT';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'log':
|
||||||
|
iconColor = '#95a5a6';
|
||||||
|
iconText = 'LOG';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'md':
|
||||||
|
iconColor = '#95a5a6';
|
||||||
|
iconText = 'MD';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'jpg':
|
||||||
|
iconColor = '#9b59b6';
|
||||||
|
iconText = 'JPG';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'jpeg':
|
||||||
|
iconColor = '#9b59b6';
|
||||||
|
iconText = 'JPEG';
|
||||||
|
textClass = 'medium';
|
||||||
|
break;
|
||||||
|
case 'png':
|
||||||
|
iconColor = '#9b59b6';
|
||||||
|
iconText = 'PNG';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'gif':
|
||||||
|
iconColor = '#9b59b6';
|
||||||
|
iconText = 'GIF';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'bmp':
|
||||||
|
iconColor = '#9b59b6';
|
||||||
|
iconText = 'BMP';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'svg':
|
||||||
|
iconColor = '#9b59b6';
|
||||||
|
iconText = 'SVG';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'webp':
|
||||||
|
iconColor = '#9b59b6';
|
||||||
|
iconText = 'WEBP';
|
||||||
|
textClass = 'medium';
|
||||||
|
break;
|
||||||
|
case 'mp3':
|
||||||
|
iconColor = '#1abc9c';
|
||||||
|
iconText = 'MP3';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'wav':
|
||||||
|
iconColor = '#1abc9c';
|
||||||
|
iconText = 'WAV';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'ogg':
|
||||||
|
iconColor = '#1abc9c';
|
||||||
|
iconText = 'OGG';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'flac':
|
||||||
|
iconColor = '#1abc9c';
|
||||||
|
iconText = 'FLAC';
|
||||||
|
textClass = 'medium';
|
||||||
|
break;
|
||||||
|
case 'mp4':
|
||||||
|
iconColor = '#d35400';
|
||||||
|
iconText = 'MP4';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'avi':
|
||||||
|
iconColor = '#d35400';
|
||||||
|
iconText = 'AVI';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'mkv':
|
||||||
|
iconColor = '#d35400';
|
||||||
|
iconText = 'MKV';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'mov':
|
||||||
|
iconColor = '#d35400';
|
||||||
|
iconText = 'MOV';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'wmv':
|
||||||
|
iconColor = '#d35400';
|
||||||
|
iconText = 'WMV';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'exe':
|
||||||
|
iconColor = '#c0392b';
|
||||||
|
iconText = 'EXE';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'msi':
|
||||||
|
iconColor = '#c0392b';
|
||||||
|
iconText = 'MSI';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'js':
|
||||||
|
iconColor = '#2980b9';
|
||||||
|
iconText = 'JS';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'html':
|
||||||
|
iconColor = '#2980b9';
|
||||||
|
iconText = 'HTML';
|
||||||
|
textClass = 'medium';
|
||||||
|
break;
|
||||||
|
case 'css':
|
||||||
|
iconColor = '#2980b9';
|
||||||
|
iconText = 'CSS';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'php':
|
||||||
|
iconColor = '#2980b9';
|
||||||
|
iconText = 'PHP';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'py':
|
||||||
|
iconColor = '#2980b9';
|
||||||
|
iconText = 'PY';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'java':
|
||||||
|
iconColor = '#2980b9';
|
||||||
|
iconText = 'JAVA';
|
||||||
|
textClass = 'medium';
|
||||||
|
break;
|
||||||
|
case 'json':
|
||||||
|
iconColor = '#8e44ad';
|
||||||
|
iconText = 'JSON';
|
||||||
|
textClass = 'medium';
|
||||||
|
break;
|
||||||
|
case 'xml':
|
||||||
|
iconColor = '#8e44ad';
|
||||||
|
iconText = 'XML';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'yml':
|
||||||
|
iconColor = '#8e44ad';
|
||||||
|
iconText = 'YML';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'yaml':
|
||||||
|
iconColor = '#8e44ad';
|
||||||
|
iconText = 'YAML';
|
||||||
|
textClass = 'medium';
|
||||||
|
break;
|
||||||
|
case 'sql':
|
||||||
|
iconColor = '#27ae60';
|
||||||
|
iconText = 'SQL';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'db':
|
||||||
|
iconColor = '#27ae60';
|
||||||
|
iconText = 'DB';
|
||||||
|
textClass = 'short';
|
||||||
|
break;
|
||||||
|
case 'sqlite':
|
||||||
|
iconColor = '#27ae60';
|
||||||
|
iconText = 'SQLITE';
|
||||||
|
textClass = 'long';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Для других расширений используем расширение или первые 4 символа
|
||||||
|
iconColor = '#7f8c8d';
|
||||||
|
iconText = extension.length > 4 ?
|
||||||
|
extension.substring(0, 4).toUpperCase() :
|
||||||
|
extension.toUpperCase();
|
||||||
|
|
||||||
|
// Определяем класс по длине текста
|
||||||
|
if (iconText.length <= 2) {
|
||||||
|
textClass = 'short';
|
||||||
|
} else if (iconText.length <= 4) {
|
||||||
|
textClass = 'medium';
|
||||||
|
} else {
|
||||||
|
textClass = 'long';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Если нет расширения
|
||||||
|
iconColor = '#7f8c8d';
|
||||||
|
iconText = 'ФАЙЛ';
|
||||||
|
textClass = 'short';
|
||||||
|
}
|
||||||
|
// --- КОНЕЦ ЛОГИКИ ОПРЕДЕЛЕНИЯ ЦВЕТА ---
|
||||||
|
|
||||||
|
const displayFileName = truncateFileName(fileName);
|
||||||
|
const canDelete = task ? canUserDeleteFile(file, task) : false;
|
||||||
|
|
||||||
|
// Создаем контейнер с flex-расположением
|
||||||
|
let html = `
|
||||||
|
<div class="file-icon-wrapper" data-file-id="${file.id}" data-task-id="${task.id}" style="display: flex; align-items: center;">
|
||||||
|
<a href="/api/files/${file.id}/download"
|
||||||
|
download="${encodeURIComponent(fileName)}"
|
||||||
|
class="file-icon-container"
|
||||||
|
title="${fileName} (${fileSize} MB) - Загрузил: ${uploadedBy}">
|
||||||
|
<div class="file-icon" style="background: ${iconColor}">
|
||||||
|
<span class="file-extension ${textClass}">${iconText}</span>
|
||||||
|
</div>
|
||||||
|
<div class="file-name">${displayFileName}</div>
|
||||||
|
</a>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Добавляем кнопку удаления справа от файла, вертикальную
|
||||||
|
if (canDelete) {
|
||||||
|
html += `
|
||||||
|
<div style="display: flex; flex-direction: column; margin-left: 8px;">
|
||||||
|
<button class="deadline-badge deadline-24h"
|
||||||
|
onclick="event.preventDefault(); event.stopPropagation(); deleteTaskFile(${file.id}, ${task.id}); return false;"
|
||||||
|
title="Удалить файл"
|
||||||
|
style="writing-mode: vertical-rl;
|
||||||
|
transform: rotate(180deg);
|
||||||
|
height: auto;
|
||||||
|
min-height: 60px;
|
||||||
|
padding: 8px 4px;
|
||||||
|
background: red;
|
||||||
|
font-size: 0.85em;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;">
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
html += `</div>`;
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Рендерит группированные файлы с поддержкой удаления
|
||||||
|
* @param {Object} task - Объект задачи
|
||||||
|
* @returns {string} HTML строка
|
||||||
|
*/
|
||||||
|
function renderGroupedFilesWithDelete(task) {
|
||||||
|
if (!task.files || task.files.length === 0) {
|
||||||
|
return '<span class="no-files">нет файлов</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Группируем файлы по пользователю
|
||||||
|
const filesByUploader = {};
|
||||||
|
|
||||||
|
task.files.forEach(file => {
|
||||||
|
const uploaderId = file.user_id;
|
||||||
|
const uploaderName = file.user_name || 'Неизвестный пользователь';
|
||||||
|
|
||||||
|
if (!filesByUploader[uploaderId]) {
|
||||||
|
filesByUploader[uploaderId] = {
|
||||||
|
name: uploaderName,
|
||||||
|
id: uploaderId,
|
||||||
|
files: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
filesByUploader[uploaderId].files.push(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Определяем видимые группы
|
||||||
|
const visibleGroups = [];
|
||||||
|
|
||||||
|
for (const uploaderId in filesByUploader) {
|
||||||
|
const uploaderGroup = filesByUploader[uploaderId];
|
||||||
|
const uploaderIdNum = parseInt(uploaderId);
|
||||||
|
|
||||||
|
let canSeeThisUploader = false;
|
||||||
|
|
||||||
|
if (currentUser.role === 'admin' ||
|
||||||
|
currentUser.role === 'tasks' ||
|
||||||
|
parseInt(task.created_by) === currentUser.id) {
|
||||||
|
canSeeThisUploader = true;
|
||||||
|
} else {
|
||||||
|
const creatorId = parseInt(task.created_by);
|
||||||
|
if (uploaderIdNum === creatorId || uploaderIdNum === currentUser.id) {
|
||||||
|
canSeeThisUploader = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canSeeThisUploader) {
|
||||||
|
visibleGroups.push({
|
||||||
|
name: uploaderGroup.name,
|
||||||
|
id: uploaderGroup.id,
|
||||||
|
files: uploaderGroup.files
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visibleGroups.length === 0) {
|
||||||
|
return '<span class="no-files">нет файлов</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Рендерим группы
|
||||||
|
if (visibleGroups.length === 1) {
|
||||||
|
const uploader = visibleGroups[0];
|
||||||
|
return `
|
||||||
|
<div class="file-group single-user">
|
||||||
|
<div class="file-group-header">
|
||||||
|
<strong>${escapeHtml(uploader.name)}:</strong>
|
||||||
|
</div>
|
||||||
|
<div class="file-icons-container">
|
||||||
|
${uploader.files.map(file =>
|
||||||
|
renderFileIconWithDelete(file, task.id, task)
|
||||||
|
).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return visibleGroups.map(uploader => `
|
||||||
|
<div class="file-group">
|
||||||
|
<div class="file-group-header">
|
||||||
|
<strong>${escapeHtml(uploader.name)}:</strong>
|
||||||
|
</div>
|
||||||
|
<div class="file-icons-container">
|
||||||
|
${uploader.files.map(file =>
|
||||||
|
renderFileIconWithDelete(file, task.id, task)
|
||||||
|
).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Экранирует HTML специальные символы
|
||||||
|
* @param {string} text - Текст для экранирования
|
||||||
|
* @returns {string} Экранированный текст
|
||||||
|
*/
|
||||||
|
function escapeHtml(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
return String(text)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Инициализирует расширенные функции работы с файлами
|
||||||
|
*/
|
||||||
|
function initializeFileManagement() {
|
||||||
|
console.log('📁 Инициализация расширенного управления файлами...');
|
||||||
|
|
||||||
|
// Сохраняем ссылки на оригинальные функции
|
||||||
|
if (typeof renderFileIcon === 'function' && renderFileIcon !== renderFileIconWithDelete) {
|
||||||
|
originalRenderFileIcon = renderFileIcon;
|
||||||
|
window.originalRenderFileIcon = renderFileIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof renderGroupedFiles === 'function' && renderGroupedFiles !== renderGroupedFilesWithDelete) {
|
||||||
|
originalRenderGroupedFiles = renderGroupedFiles;
|
||||||
|
window.originalRenderGroupedFiles = renderGroupedFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Переопределяем глобальные функции
|
||||||
|
window.renderFileIcon = renderFileIconWithDelete;
|
||||||
|
window.renderGroupedFiles = renderGroupedFilesWithDelete;
|
||||||
|
|
||||||
|
console.log('✅ Расширенное управление файлами инициализировано');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Восстанавливает оригинальные функции рендеринга файлов
|
||||||
|
*/
|
||||||
|
function restoreOriginalFileRenderers() {
|
||||||
|
if (window.originalRenderFileIcon) {
|
||||||
|
window.renderFileIcon = window.originalRenderFileIcon;
|
||||||
|
}
|
||||||
|
if (window.originalRenderGroupedFiles) {
|
||||||
|
window.renderGroupedFiles = window.originalRenderGroupedFiles;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Экспортируем функции для глобального доступа
|
||||||
|
window.canUserDeleteFile = canUserDeleteFile;
|
||||||
|
window.deleteTaskFile = deleteTaskFile;
|
||||||
|
window.renderFileIconWithDelete = renderFileIconWithDelete;
|
||||||
|
window.renderGroupedFilesWithDelete = renderGroupedFilesWithDelete;
|
||||||
|
window.initializeFileManagement = initializeFileManagement;
|
||||||
|
window.restoreOriginalFileRenderers = restoreOriginalFileRenderers;
|
||||||
191
public/time-selector.js
Normal file
191
public/time-selector.js
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
// time-selector.js - Управление выбором даты и времени для задач
|
||||||
|
|
||||||
|
// Функция для установки времени задачи (создание)
|
||||||
|
function setTaskTime(time) {
|
||||||
|
const timeButtons = document.querySelectorAll('.time-btn');
|
||||||
|
const hiddenTimeInput = document.getElementById('due-time');
|
||||||
|
|
||||||
|
// Убираем активный класс со всех кнопок
|
||||||
|
timeButtons.forEach(btn => {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Добавляем активный класс к нажатой кнопке
|
||||||
|
const activeButton = Array.from(timeButtons).find(btn => {
|
||||||
|
if (time === '12:00') {
|
||||||
|
return btn.textContent.includes('До обеда');
|
||||||
|
} else {
|
||||||
|
return btn.textContent.includes('После обеда');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (activeButton) {
|
||||||
|
activeButton.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Устанавливаем значение в скрытое поле
|
||||||
|
hiddenTimeInput.value = time;
|
||||||
|
|
||||||
|
// Обновляем отображение
|
||||||
|
updateDateTimeDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для установки времени задачи (редактирование)
|
||||||
|
function setEditTaskTime(time) {
|
||||||
|
const timeButtons = document.querySelectorAll('.edit-time-btn');
|
||||||
|
const hiddenTimeInput = document.getElementById('edit-due-time');
|
||||||
|
|
||||||
|
// Убираем активный класс со всех кнопок
|
||||||
|
timeButtons.forEach(btn => {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Добавляем активный класс к нажатой кнопке
|
||||||
|
const activeButton = Array.from(timeButtons).find(btn => {
|
||||||
|
if (time === '12:00') {
|
||||||
|
return btn.textContent.includes('До обеда');
|
||||||
|
} else {
|
||||||
|
return btn.textContent.includes('После обеда');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (activeButton) {
|
||||||
|
activeButton.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Устанавливаем значение в скрытое поле
|
||||||
|
hiddenTimeInput.value = time;
|
||||||
|
|
||||||
|
// Обновляем отображение
|
||||||
|
updateEditDateTimeDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для обновления отображения даты и времени (создание)
|
||||||
|
function updateDateTimeDisplay() {
|
||||||
|
const dateInput = document.getElementById('due-date');
|
||||||
|
const timeInput = document.getElementById('due-time');
|
||||||
|
const timeButtons = document.querySelectorAll('.time-btn');
|
||||||
|
|
||||||
|
if (dateInput.value && timeInput.value) {
|
||||||
|
// Обновляем текст кнопок с полной датой
|
||||||
|
const selectedDate = new Date(dateInput.value);
|
||||||
|
const formattedDate = selectedDate.toLocaleDateString('ru-RU', {
|
||||||
|
weekday: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short'
|
||||||
|
});
|
||||||
|
|
||||||
|
timeButtons.forEach(btn => {
|
||||||
|
const timeText = btn.textContent.includes('До обеда') ? 'До обеда' : 'После обеда';
|
||||||
|
btn.innerHTML = `<i class="fas fa-${btn.textContent.includes('До обеда') ? 'sun' : 'moon'}"></i> ${timeText} (${timeInput.value})`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для обновления отображения даты и времени (редактирование)
|
||||||
|
function updateEditDateTimeDisplay() {
|
||||||
|
const dateInput = document.getElementById('edit-due-date');
|
||||||
|
const timeInput = document.getElementById('edit-due-time');
|
||||||
|
const timeButtons = document.querySelectorAll('.edit-time-btn');
|
||||||
|
|
||||||
|
if (dateInput.value && timeInput.value) {
|
||||||
|
timeButtons.forEach(btn => {
|
||||||
|
const timeText = btn.textContent.includes('До обеда') ? 'До обеда' : 'После обеда';
|
||||||
|
btn.innerHTML = `<i class="fas fa-${btn.textContent.includes('До обеда') ? 'sun' : 'moon'}"></i> ${timeText} (${timeInput.value})`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для инициализации выбора времени
|
||||||
|
function initializeTimeSelectors() {
|
||||||
|
// Устанавливаем сегодняшнюю дату по умолчанию для создания задачи
|
||||||
|
const today = new Date();
|
||||||
|
const tomorrow = new Date();
|
||||||
|
tomorrow.setDate(today.getDate() + 1);
|
||||||
|
|
||||||
|
// Форматируем дату для input[type="date"]
|
||||||
|
const formattedDate = tomorrow.toISOString().split('T')[0];
|
||||||
|
const dueDateInput = document.getElementById('due-date');
|
||||||
|
|
||||||
|
if (dueDateInput) {
|
||||||
|
dueDateInput.value = formattedDate;
|
||||||
|
|
||||||
|
// Устанавливаем время по умолчанию (12:00)
|
||||||
|
document.getElementById('due-time').value = '19:00';
|
||||||
|
|
||||||
|
// Добавляем активный класс к первой кнопке
|
||||||
|
const timeButtons = document.querySelectorAll('.time-btn');
|
||||||
|
if (timeButtons.length > 0) {
|
||||||
|
timeButtons[0].classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем обработчик изменения даты
|
||||||
|
dueDateInput.addEventListener('change', function() {
|
||||||
|
updateDateTimeDisplay();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Инициализируем отображение
|
||||||
|
updateDateTimeDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация для редактирования
|
||||||
|
const editDueDateInput = document.getElementById('edit-due-date');
|
||||||
|
if (editDueDateInput) {
|
||||||
|
editDueDateInput.addEventListener('change', function() {
|
||||||
|
updateEditDateTimeDisplay();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для форматирования полной даты из отдельных полей
|
||||||
|
function getFullDateTime(dateInputId, timeInputId) {
|
||||||
|
const dateValue = document.getElementById(dateInputId).value;
|
||||||
|
const timeValue = document.getElementById(timeInputId).value;
|
||||||
|
|
||||||
|
if (!dateValue || !timeValue) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${dateValue}T${timeValue}:00`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для установки даты и времени в форму редактирования
|
||||||
|
function setDateTimeForEdit(taskDueDate) {
|
||||||
|
if (!taskDueDate) return;
|
||||||
|
|
||||||
|
const dateTime = new Date(taskDueDate);
|
||||||
|
const dateStr = dateTime.toISOString().split('T')[0];
|
||||||
|
const hours = dateTime.getHours().toString().padStart(2, '0');
|
||||||
|
const minutes = dateTime.getMinutes().toString().padStart(2, '0');
|
||||||
|
const timeStr = `${hours}:${minutes}`;
|
||||||
|
|
||||||
|
// Устанавливаем значения полей
|
||||||
|
document.getElementById('edit-due-date').value = dateStr;
|
||||||
|
document.getElementById('edit-due-time').value = timeStr;
|
||||||
|
|
||||||
|
// Устанавливаем активную кнопку времени
|
||||||
|
const timeButtons = document.querySelectorAll('.edit-time-btn');
|
||||||
|
timeButtons.forEach(btn => btn.classList.remove('active'));
|
||||||
|
|
||||||
|
// Определяем, какая кнопка должна быть активна
|
||||||
|
const isBeforeLunch = parseInt(hours) < 12 || (parseInt(hours) === 12 && parseInt(minutes) === 0);
|
||||||
|
const activeButton = isBeforeLunch ?
|
||||||
|
document.querySelector('.edit-time-btn:first-child') :
|
||||||
|
document.querySelector('.edit-time-btn:last-child');
|
||||||
|
|
||||||
|
if (activeButton) {
|
||||||
|
activeButton.classList.add('active');
|
||||||
|
// Обновляем текст кнопки
|
||||||
|
const timeText = isBeforeLunch ? 'До обеда' : 'После обеда';
|
||||||
|
activeButton.innerHTML = `<i class="fas fa-${isBeforeLunch ? 'sun' : 'moon'}"></i> ${timeText} (${timeStr})`;
|
||||||
|
|
||||||
|
// Обновляем другую кнопку
|
||||||
|
const otherButton = isBeforeLunch ?
|
||||||
|
document.querySelector('.edit-time-btn:last-child') :
|
||||||
|
document.querySelector('.edit-time-btn:first-child');
|
||||||
|
|
||||||
|
const otherTimeText = isBeforeLunch ? 'После обеда' : 'До обеда';
|
||||||
|
const otherTimeValue = isBeforeLunch ? '19:00' : '12:00';
|
||||||
|
otherButton.innerHTML = `<i class="fas fa-${isBeforeLunch ? 'moon' : 'sun'}"></i> ${otherTimeText} (${otherTimeValue})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
2263
public/ui.js
Normal file
2263
public/ui.js
Normal file
File diff suppressed because it is too large
Load Diff
795
public/users.js
Normal file
795
public/users.js
Normal file
@@ -0,0 +1,795 @@
|
|||||||
|
// users.js - Управление пользователями и пользовательскими списками
|
||||||
|
|
||||||
|
let users = [];
|
||||||
|
let allUsers = [];
|
||||||
|
let usersLoadingPromise = null;
|
||||||
|
let filteredUsers = [];
|
||||||
|
let selectedUsers = [];
|
||||||
|
let editSelectedUsers = [];
|
||||||
|
let copySelectedUsers = [];
|
||||||
|
let acquaintanceSelectedUsers = []; // исполнители для ознакомления
|
||||||
|
let acquaintanceSelectedAuthor = null; // выбранный автор для ознакомления
|
||||||
|
|
||||||
|
// Переменные для пользовательских списков
|
||||||
|
let userLists = [];
|
||||||
|
let isUserListsLoading = false;
|
||||||
|
let currentEditingListId = null;
|
||||||
|
|
||||||
|
// Кэш групп пользователей
|
||||||
|
let userGroupsCache = {};
|
||||||
|
|
||||||
|
let isUsersLoading = false;
|
||||||
|
|
||||||
|
// ==================== ЗАГРУЗКА ПОЛЬЗОВАТЕЛЕЙ ====================
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
// Если загрузка уже идёт, возвращаем существующий промис
|
||||||
|
if (usersLoadingPromise) return usersLoadingPromise;
|
||||||
|
|
||||||
|
usersLoadingPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/users');
|
||||||
|
const allUsersData = await response.json();
|
||||||
|
allUsers = allUsersData;
|
||||||
|
users = filterAssignableUsers(allUsersData);
|
||||||
|
filteredUsers = [...users];
|
||||||
|
renderUsersChecklist();
|
||||||
|
renderEditUsersChecklist();
|
||||||
|
renderCopyUsersChecklist();
|
||||||
|
populateFilterDropdowns();
|
||||||
|
// Загружаем пользовательские списки (не ждём)
|
||||||
|
loadUserLists();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки пользователей:', error);
|
||||||
|
} finally {
|
||||||
|
usersLoadingPromise = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return usersLoadingPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== ПОЛУЧЕНИЕ ГРУПП ПОЛЬЗОВАТЕЛЯ ====================
|
||||||
|
|
||||||
|
async function getUserGroups(userId) {
|
||||||
|
if (userGroupsCache[userId]) {
|
||||||
|
return userGroupsCache[userId];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api2/idusers/user/${userId}/groups`);
|
||||||
|
if (!response.ok) return [];
|
||||||
|
|
||||||
|
const groups = await response.json();
|
||||||
|
userGroupsCache[userId] = groups || [];
|
||||||
|
return userGroupsCache[userId];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка получения групп пользователя:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== ФИЛЬТРАЦИЯ ПОЛЬЗОВАТЕЛЕЙ ПО ПРАВАМ ====================
|
||||||
|
|
||||||
|
function filterAssignableUsers(allUsers, taskType = 'regular') {
|
||||||
|
if (!currentUser) return [];
|
||||||
|
|
||||||
|
// Для задач типа "document" – только секретари (асинхронно не получится здесь, но мы фильтруем позже)
|
||||||
|
// В текущей реализации эта функция вызывается синхронно, поэтому для специальных типов фильтрация будет в filterUsers
|
||||||
|
// Здесь оставляем базовую фильтрацию по ролям
|
||||||
|
|
||||||
|
// Администратор видит всех, кроме себя
|
||||||
|
if (currentUser.role === 'admin') {
|
||||||
|
return allUsers.filter(user => user.id !== currentUser.id);
|
||||||
|
}
|
||||||
|
if (currentUser.role === 'secretary') {
|
||||||
|
return allUsers.filter(user => user.id !== currentUser.id);
|
||||||
|
}
|
||||||
|
if (currentUser.role === 'ithelp') {
|
||||||
|
return allUsers.filter(user =>
|
||||||
|
(user.role === 'teacher' || user.role === 'tasks' || user.role === 'help' || user.role === 'request' || user.role === 'ithelp') &&
|
||||||
|
user.id !== currentUser.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (currentUser.role === 'request') {
|
||||||
|
return allUsers.filter(user =>
|
||||||
|
(user.role === 'teacher' || user.role === 'tasks' || user.role === 'help' || user.role === 'request' || user.role === 'ithelp') &&
|
||||||
|
user.id !== currentUser.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (currentUser.role === 'help') {
|
||||||
|
return allUsers.filter(user =>
|
||||||
|
(user.role === 'teacher' || user.role === 'tasks' || user.role === 'help' || user.role === 'request' || user.role === 'ithelp') &&
|
||||||
|
user.id !== currentUser.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (currentUser.role === 'tasks') {
|
||||||
|
return allUsers.filter(user =>
|
||||||
|
(user.role === 'teacher' || user.role === 'tasks' || user.role === 'help' || user.role === 'request' || user.role === 'ithelp') &&
|
||||||
|
user.id !== currentUser.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (currentUser.role === 'teacher') {
|
||||||
|
return allUsers.filter(user =>
|
||||||
|
(user.role === 'help' || user.role === 'request' || user.role === 'ithelp') &&
|
||||||
|
user.id !== currentUser.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== ЗАПОЛНЕНИЕ ВЫПАДАЮЩИХ СПИСКОВ ФИЛЬТРОВ ====================
|
||||||
|
|
||||||
|
function populateFilterDropdowns() {
|
||||||
|
const creatorFilter = document.getElementById('creator-filter');
|
||||||
|
const assigneeFilter = document.getElementById('assignee-filter');
|
||||||
|
|
||||||
|
if (creatorFilter) {
|
||||||
|
creatorFilter.innerHTML = '<option value="">Все заказчики</option>';
|
||||||
|
}
|
||||||
|
if (assigneeFilter) {
|
||||||
|
assigneeFilter.innerHTML = '<option value="">Все исполнители</option>';
|
||||||
|
}
|
||||||
|
|
||||||
|
users.forEach(user => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = user.id;
|
||||||
|
option.textContent = `${user.name} (${user.login})`;
|
||||||
|
|
||||||
|
if (creatorFilter) creatorFilter.appendChild(option.cloneNode(true));
|
||||||
|
if (assigneeFilter) assigneeFilter.appendChild(option.cloneNode(true));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== ФИЛЬТРАЦИЯ ПРИ ПОИСКЕ (С УЧЁТОМ ТИПА ЗАДАЧИ) ====================
|
||||||
|
|
||||||
|
async function filterUsers() {
|
||||||
|
const search = document.getElementById('user-search')?.value.toLowerCase() || '';
|
||||||
|
const taskType = document.getElementById('task-type')?.value || 'regular';
|
||||||
|
|
||||||
|
isUsersLoading = true;
|
||||||
|
renderUsersChecklist(); // Показываем загрузку
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Сначала фильтруем по поиску
|
||||||
|
let tempFiltered = users.filter(user =>
|
||||||
|
user.name.toLowerCase().includes(search) ||
|
||||||
|
user.login.toLowerCase().includes(search) ||
|
||||||
|
(user.email && user.email.toLowerCase().includes(search))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Если тип задачи требует специальной группы, фильтруем по группам
|
||||||
|
const specialTypes = ['document', 'it', 'ahch', 'psychologist', 'speech_therapist', 'Social_educator', 'hr', 'certificate', 'e_journal'];
|
||||||
|
if (specialTypes.includes(taskType)) {
|
||||||
|
const groupNames = {
|
||||||
|
'document': 'Секретарь',
|
||||||
|
'it': 'ИТ специалист',
|
||||||
|
'ahch': 'АХЧ',
|
||||||
|
'psychologist': 'психолог',
|
||||||
|
'speech_therapist': 'логопед',
|
||||||
|
'Social_educator': 'Социальный педагог',
|
||||||
|
'hr': 'Диспетчер',
|
||||||
|
'certificate': 'Администрация',
|
||||||
|
'e_journal': 'Админ ЭЖ'
|
||||||
|
};
|
||||||
|
const targetGroup = groupNames[taskType];
|
||||||
|
|
||||||
|
filteredUsers = [];
|
||||||
|
for (const user of tempFiltered) {
|
||||||
|
const groups = await getUserGroups(user.id);
|
||||||
|
const hasTargetGroup = groups.some(group =>
|
||||||
|
group.name === targetGroup ||
|
||||||
|
(typeof group === 'string' && group.includes(targetGroup))
|
||||||
|
);
|
||||||
|
if (hasTargetGroup) {
|
||||||
|
filteredUsers.push(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
filteredUsers = tempFiltered;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка фильтрации пользователей:', error);
|
||||||
|
filteredUsers = [];
|
||||||
|
} finally {
|
||||||
|
isUsersLoading = false;
|
||||||
|
renderUsersChecklist();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function filterEditUsers() {
|
||||||
|
const search = document.getElementById('edit-user-search')?.value.toLowerCase() || '';
|
||||||
|
const taskId = document.getElementById('edit-task-id')?.value;
|
||||||
|
if (!taskId) return;
|
||||||
|
|
||||||
|
const task = window.tasks?.find(t => t.id == taskId);
|
||||||
|
const taskType = task ? task.task_type : 'regular';
|
||||||
|
|
||||||
|
let filtered = users.filter(user =>
|
||||||
|
user.name.toLowerCase().includes(search) ||
|
||||||
|
user.login.toLowerCase().includes(search) ||
|
||||||
|
(user.email && user.email.toLowerCase().includes(search))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (taskType === 'document') {
|
||||||
|
const filteredByGroup = [];
|
||||||
|
for (const user of filtered) {
|
||||||
|
const groups = await getUserGroups(user.id);
|
||||||
|
const hasSecretaryGroup = groups.some(group =>
|
||||||
|
group.name === 'Секретарь' ||
|
||||||
|
(typeof group === 'string' && group.includes('Секретарь'))
|
||||||
|
);
|
||||||
|
if (hasSecretaryGroup) filteredByGroup.push(user);
|
||||||
|
}
|
||||||
|
filtered = filteredByGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderEditUsersChecklist(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function filterCopyUsers() {
|
||||||
|
const search = document.getElementById('copy-user-search')?.value.toLowerCase() || '';
|
||||||
|
const taskId = document.getElementById('copy-task-id')?.value;
|
||||||
|
if (!taskId) return;
|
||||||
|
|
||||||
|
const task = window.tasks?.find(t => t.id == taskId);
|
||||||
|
const taskType = task ? task.task_type : 'regular';
|
||||||
|
|
||||||
|
let filtered = users.filter(user =>
|
||||||
|
user.name.toLowerCase().includes(search) ||
|
||||||
|
user.login.toLowerCase().includes(search) ||
|
||||||
|
(user.email && user.email.toLowerCase().includes(search))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (taskType === 'document') {
|
||||||
|
const filteredByGroup = [];
|
||||||
|
for (const user of filtered) {
|
||||||
|
const groups = await getUserGroups(user.id);
|
||||||
|
const hasSecretaryGroup = groups.some(group =>
|
||||||
|
group.name === 'Секретарь' ||
|
||||||
|
(typeof group === 'string' && group.includes('Секретарь'))
|
||||||
|
);
|
||||||
|
if (hasSecretaryGroup) filteredByGroup.push(user);
|
||||||
|
}
|
||||||
|
filtered = filteredByGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCopyUsersChecklist(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== РЕНДЕРИНГ ЧЕКБОКСОВ ПОЛЬЗОВАТЕЛЕЙ ====================
|
||||||
|
|
||||||
|
function renderUsersChecklist() {
|
||||||
|
const container = document.getElementById('users-checklist');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
// Создаём структуру с двумя колонками, если её ещё нет
|
||||||
|
if (!container.querySelector('.users-two-columns')) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="users-two-columns" style="display: flex; gap: 20px;">
|
||||||
|
<div class="left-column" style="flex: 1; min-width: 0;"></div>
|
||||||
|
<div class="right-column" style="flex: 1; min-width: 0;"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftCol = container.querySelector('.left-column');
|
||||||
|
const rightCol = container.querySelector('.right-column');
|
||||||
|
|
||||||
|
// Левая колонка – чекбоксы пользователей
|
||||||
|
if (isUsersLoading) {
|
||||||
|
leftCol.innerHTML = '<div class="loading-spinner">⏳ Загрузка пользователей...</div>';
|
||||||
|
} else if (!filteredUsers || filteredUsers.length === 0) {
|
||||||
|
leftCol.innerHTML = '<div class="no-users">Нет доступных пользователей</div>';
|
||||||
|
} else {
|
||||||
|
leftCol.innerHTML = filteredUsers
|
||||||
|
.filter(user => user.id !== currentUser?.id)
|
||||||
|
.map(user => `
|
||||||
|
<div class="checkbox-item">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="assignedUsers" value="${user.id}"
|
||||||
|
onchange="toggleUserSelection(this, ${user.id})"
|
||||||
|
${selectedUsers.includes(user.id) ? 'checked' : ''}>
|
||||||
|
${escapeHtml(user.name)}
|
||||||
|
${getUserTypeLabel(user, document.getElementById('task-type')?.value)}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Правая колонка – панель списков пользователя
|
||||||
|
if (!document.getElementById('user-lists-panel')) {
|
||||||
|
const panel = document.createElement('div');
|
||||||
|
panel.id = 'user-lists-panel';
|
||||||
|
rightCol.appendChild(panel);
|
||||||
|
}
|
||||||
|
renderUserListsPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEditUsersChecklist(filtered = users) {
|
||||||
|
const container = document.getElementById('edit-users-checklist');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = filtered
|
||||||
|
.filter(user => user.id !== currentUser?.id)
|
||||||
|
.map(user => `
|
||||||
|
<div class="checkbox-item">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="assignedUsers" value="${user.id}"
|
||||||
|
onchange="toggleEditUserSelection(this, ${user.id})"
|
||||||
|
${editSelectedUsers.includes(user.id) ? 'checked' : ''}>
|
||||||
|
${escapeHtml(user.name)} (${user.email})
|
||||||
|
${user.auth_type === 'ldap' ? '<small style="color: #666;"> - LDAP</small>' : ''}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCopyUsersChecklist(filtered = users) {
|
||||||
|
const container = document.getElementById('copy-users-checklist');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = filtered
|
||||||
|
.filter(user => user.id !== currentUser?.id)
|
||||||
|
.map(user => `
|
||||||
|
<div class="checkbox-item">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="assignedUsers" value="${user.id}"
|
||||||
|
onchange="toggleCopyUserSelection(this, ${user.id})"
|
||||||
|
${copySelectedUsers.includes(user.id) ? 'checked' : ''}>
|
||||||
|
${escapeHtml(user.name)} (${user.email})
|
||||||
|
${user.auth_type === 'ldap' ? '<small style="color: #666;"> - LDAP</small>' : ''}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== УПРАВЛЕНИЕ ВЫБРАННЫМИ ПОЛЬЗОВАТЕЛЯМИ ====================
|
||||||
|
|
||||||
|
function toggleUserSelection(checkbox, userId) {
|
||||||
|
if (checkbox.checked) {
|
||||||
|
if (!selectedUsers.includes(userId)) selectedUsers.push(userId);
|
||||||
|
} else {
|
||||||
|
selectedUsers = selectedUsers.filter(id => id !== userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleEditUserSelection(checkbox, userId) {
|
||||||
|
if (checkbox.checked) {
|
||||||
|
if (!editSelectedUsers.includes(userId)) editSelectedUsers.push(userId);
|
||||||
|
} else {
|
||||||
|
editSelectedUsers = editSelectedUsers.filter(id => id !== userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCopyUserSelection(checkbox, userId) {
|
||||||
|
if (checkbox.checked) {
|
||||||
|
if (!copySelectedUsers.includes(userId)) copySelectedUsers.push(userId);
|
||||||
|
} else {
|
||||||
|
copySelectedUsers = copySelectedUsers.filter(id => id !== userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== ФУНКЦИИ ДЛЯ РАБОТЫ СО СПИСКАМИ ПОЛЬЗОВАТЕЛЕЙ ====================
|
||||||
|
|
||||||
|
async function loadUserLists() {
|
||||||
|
if (!currentUser) return;
|
||||||
|
isUserListsLoading = true;
|
||||||
|
renderUserListsPanel();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/user/lists');
|
||||||
|
if (response.ok) {
|
||||||
|
const lists = await response.json();
|
||||||
|
// Сервер уже отдаёт user_ids как массив, просто копируем в userIds
|
||||||
|
userLists = lists.map(list => ({
|
||||||
|
...list,
|
||||||
|
userIds: list.user_ids || [] // предполагаем, что это массив
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
console.error('Ошибка загрузки списков:', response.status);
|
||||||
|
userLists = [];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Сетевая ошибка при загрузке списков:', error);
|
||||||
|
userLists = [];
|
||||||
|
} finally {
|
||||||
|
isUserListsLoading = false;
|
||||||
|
renderUserListsPanel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveUserList(listData) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/user/lists', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(listData)
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const newList = await response.json();
|
||||||
|
const transformed = {
|
||||||
|
...newList,
|
||||||
|
userIds: newList.user_ids || []
|
||||||
|
};
|
||||||
|
userLists.push(transformed);
|
||||||
|
renderUserListsPanel();
|
||||||
|
return transformed;
|
||||||
|
} else {
|
||||||
|
const err = await response.json();
|
||||||
|
alert('Ошибка создания списка: ' + (err.error || 'Неизвестная ошибка'));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Сетевая ошибка:', error);
|
||||||
|
alert('Не удалось сохранить список. Проверьте соединение.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateUserList(listId, listData) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/user/lists/${listId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(listData)
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const updatedList = await response.json();
|
||||||
|
const transformed = {
|
||||||
|
...updatedList,
|
||||||
|
userIds: updatedList.user_ids || []
|
||||||
|
};
|
||||||
|
const index = userLists.findIndex(l => l.id === listId);
|
||||||
|
if (index !== -1) userLists[index] = transformed;
|
||||||
|
renderUserListsPanel();
|
||||||
|
return transformed;
|
||||||
|
} else {
|
||||||
|
const err = await response.json();
|
||||||
|
alert('Ошибка обновления списка: ' + (err.error || 'Неизвестная ошибка'));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Сетевая ошибка:', error);
|
||||||
|
alert('Не удалось обновить список.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUserList(listId) {
|
||||||
|
if (!confirm('Вы уверены, что хотите удалить этот список?')) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/user/lists/${listId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
userLists = userLists.filter(l => l.id !== listId);
|
||||||
|
renderUserListsPanel();
|
||||||
|
} else {
|
||||||
|
const err = await response.json();
|
||||||
|
alert('Ошибка удаления списка: ' + (err.error || 'Неизвестная ошибка'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Сетевая ошибка:', error);
|
||||||
|
alert('Не удалось удалить список.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyUserList(list) {
|
||||||
|
if (!list || !list.userIds || list.userIds.length === 0) return;
|
||||||
|
|
||||||
|
// Очищаем текущий выбор
|
||||||
|
selectedUsers = [];
|
||||||
|
|
||||||
|
// Добавляем всех пользователей из списка
|
||||||
|
list.userIds.forEach(userId => {
|
||||||
|
if (!selectedUsers.includes(userId)) {
|
||||||
|
selectedUsers.push(userId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обновляем состояние чекбоксов в левой колонке
|
||||||
|
const checkboxes = document.querySelectorAll('#users-checklist .left-column input[type="checkbox"]');
|
||||||
|
checkboxes.forEach(cb => {
|
||||||
|
const userId = parseInt(cb.value);
|
||||||
|
cb.checked = list.userIds.includes(userId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUserListsPanel() {
|
||||||
|
const panel = document.getElementById('user-lists-panel');
|
||||||
|
if (!panel) return;
|
||||||
|
|
||||||
|
if (isUserListsLoading) {
|
||||||
|
panel.innerHTML = '<div class="loading-spinner">⏳ Загрузка списков...</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<div class="user-lists-header">
|
||||||
|
<h4>Мои списки</h4>
|
||||||
|
<button class="btn-create-list" onclick="openCreateListModal()">➕ Создать</button>
|
||||||
|
</div>
|
||||||
|
<div class="user-lists-container">
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (userLists.length === 0) {
|
||||||
|
html += '<p class="no-lists">У вас пока нет списков</p>';
|
||||||
|
} else {
|
||||||
|
userLists.forEach(list => {
|
||||||
|
const memberCount = list.userIds ? list.userIds.length : 0;
|
||||||
|
// Экранируем название для безопасного использования в onclick
|
||||||
|
const listJson = JSON.stringify(list).replace(/"/g, '"');
|
||||||
|
html += `
|
||||||
|
<div class="user-list-item" data-list-id="${list.id}">
|
||||||
|
<div class="list-info" onclick="applyUserList(${listJson})">
|
||||||
|
<span class="list-name">${escapeHtml(list.name)}</span>
|
||||||
|
<span class="list-count">(${memberCount})</span>
|
||||||
|
</div>
|
||||||
|
<div class="list-actions">
|
||||||
|
<button class="list-edit-btn" onclick="event.stopPropagation(); openEditListModal(${list.id})" title="Редактировать">✏️</button>
|
||||||
|
<button class="list-delete-btn" onclick="event.stopPropagation(); deleteUserList(${list.id})" title="Удалить">🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
panel.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateListModal() {
|
||||||
|
currentEditingListId = null;
|
||||||
|
showListModal(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditListModal(listId) {
|
||||||
|
const list = userLists.find(l => l.id === listId);
|
||||||
|
if (list) {
|
||||||
|
currentEditingListId = listId;
|
||||||
|
showListModal(list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showListModal(list) {
|
||||||
|
// Удаляем предыдущее модальное окно, если есть
|
||||||
|
const existingModal = document.getElementById('list-modal');
|
||||||
|
if (existingModal) existingModal.remove();
|
||||||
|
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.id = 'list-modal';
|
||||||
|
modal.className = 'modal';
|
||||||
|
modal.style.display = 'block';
|
||||||
|
|
||||||
|
const title = list ? 'Редактировать список' : 'Создать список';
|
||||||
|
const listName = list ? list.name : '';
|
||||||
|
const selectedUserIds = list ? list.userIds || [] : [];
|
||||||
|
|
||||||
|
// Генерируем чекбоксы всех пользователей (allUsers)
|
||||||
|
const usersCheckboxes = allUsers
|
||||||
|
.filter(user => user.id !== currentUser?.id)
|
||||||
|
.map(user => {
|
||||||
|
const checked = selectedUserIds.includes(user.id) ? 'checked' : '';
|
||||||
|
return `
|
||||||
|
<div class="checkbox-item">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" class="list-user-checkbox" value="${user.id}" ${checked}>
|
||||||
|
${escapeHtml(user.name)} (${escapeHtml(user.login)})
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="modal-content" style="max-width: 600px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>${title}</h3>
|
||||||
|
<span class="close" onclick="closeListModal()">×</span>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="list-name">Название списка (до 35 символов):</label>
|
||||||
|
<input type="text" id="list-name" maxlength="35" value="${escapeHtml(listName)}" placeholder="Введите название">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Выберите пользователей:</label>
|
||||||
|
<div class="user-search-box" style="margin-bottom: 10px;">
|
||||||
|
<input type="text" id="list-user-search" placeholder="Поиск пользователей..." oninput="filterListUsers()" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;">
|
||||||
|
</div>
|
||||||
|
<div class="users-checklist-scroll" id="list-users-container" style="max-height: 300px; overflow-y: auto; border: 1px solid #ddd; padding: 10px;">
|
||||||
|
${usersCheckboxes}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn-cancel" onclick="closeListModal()">Отмена</button>
|
||||||
|
<button type="button" class="btn-primary" onclick="saveListFromModal()">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
}
|
||||||
|
function filterListUsers() {
|
||||||
|
const searchInput = document.getElementById('list-user-search');
|
||||||
|
if (!searchInput) return;
|
||||||
|
const searchTerm = searchInput.value.toLowerCase();
|
||||||
|
const container = document.getElementById('list-users-container');
|
||||||
|
if (!container) return;
|
||||||
|
const items = container.querySelectorAll('.checkbox-item');
|
||||||
|
items.forEach(item => {
|
||||||
|
const label = item.querySelector('label')?.innerText.toLowerCase() || '';
|
||||||
|
if (label.includes(searchTerm) || searchTerm === '') {
|
||||||
|
item.style.display = '';
|
||||||
|
} else {
|
||||||
|
item.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function closeListModal() {
|
||||||
|
const modal = document.getElementById('list-modal');
|
||||||
|
if (modal) {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
setTimeout(() => modal.remove(), 300);
|
||||||
|
}
|
||||||
|
currentEditingListId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveListFromModal() {
|
||||||
|
const nameInput = document.getElementById('list-name');
|
||||||
|
const name = nameInput.value.trim();
|
||||||
|
if (!name) {
|
||||||
|
alert('Введите название списка');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (name.length > 35) {
|
||||||
|
alert('Название не должно превышать 35 символов');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Собираем выбранные ID пользователей
|
||||||
|
const checkboxes = document.querySelectorAll('#list-modal .list-user-checkbox:checked');
|
||||||
|
const userIds = Array.from(checkboxes).map(cb => parseInt(cb.value));
|
||||||
|
|
||||||
|
const listData = { name, userIds };
|
||||||
|
|
||||||
|
if (currentEditingListId) {
|
||||||
|
await updateUserList(currentEditingListId, listData);
|
||||||
|
} else {
|
||||||
|
await saveUserList(listData);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeListModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== ФУНКЦИИ ДЛЯ ОЗНАКОМЛЕНИЯ (выбор исполнителей) ====================
|
||||||
|
|
||||||
|
function renderAcquaintanceUsersChecklist(filtered = users) {
|
||||||
|
const container = document.getElementById('acquaintance-users-checklist');
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = filtered
|
||||||
|
.filter(user => user.id !== currentUser?.id)
|
||||||
|
.map(user => `
|
||||||
|
<div class="checkbox-item">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="assignedUsers" value="${user.id}"
|
||||||
|
onchange="toggleAcquaintanceUserSelection(this, ${user.id})"
|
||||||
|
${acquaintanceSelectedUsers.includes(user.id) ? 'checked' : ''}>
|
||||||
|
${escapeHtml(user.name)} (${user.login})
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAcquaintanceUserSelection(checkbox, userId) {
|
||||||
|
if (checkbox.checked) {
|
||||||
|
if (!acquaintanceSelectedUsers.includes(userId)) acquaintanceSelectedUsers.push(userId);
|
||||||
|
} else {
|
||||||
|
acquaintanceSelectedUsers = acquaintanceSelectedUsers.filter(id => id !== userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function filterAcquaintanceUsers() {
|
||||||
|
const search = document.getElementById('acquaintance-user-search')?.value.toLowerCase() || '';
|
||||||
|
let filtered = users.filter(user =>
|
||||||
|
user.name.toLowerCase().includes(search) ||
|
||||||
|
user.login.toLowerCase().includes(search) ||
|
||||||
|
(user.email && user.email.toLowerCase().includes(search))
|
||||||
|
);
|
||||||
|
renderAcquaintanceUsersChecklist(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== ФУНКЦИИ ДЛЯ ВЫБОРА АВТОРА В ЗАДАЧЕ ОЗНАКОМЛЕНИЯ ====================
|
||||||
|
|
||||||
|
function renderAcquaintanceAuthorsChecklist(filtered = users) {
|
||||||
|
const container = document.getElementById('acquaintance-authors-checklist');
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = filtered
|
||||||
|
.filter(user => user.id !== currentUser?.id) // можно разрешить выбирать себя, если нужно
|
||||||
|
.map(user => `
|
||||||
|
<div class="checkbox-item">
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="acquaintance-author" value="${user.id}"
|
||||||
|
onchange="toggleAcquaintanceAuthorSelection(this, ${user.id})"
|
||||||
|
${acquaintanceSelectedAuthor === user.id ? 'checked' : ''}>
|
||||||
|
${escapeHtml(user.name)} (${user.login})
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAcquaintanceAuthorSelection(radio, userId) {
|
||||||
|
acquaintanceSelectedAuthor = radio.checked ? userId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function filterAcquaintanceAuthors() {
|
||||||
|
const search = document.getElementById('acquaintance-author-search')?.value.toLowerCase() || '';
|
||||||
|
let filtered = users.filter(user =>
|
||||||
|
user.name.toLowerCase().includes(search) ||
|
||||||
|
user.login.toLowerCase().includes(search) ||
|
||||||
|
(user.email && user.email.toLowerCase().includes(search))
|
||||||
|
);
|
||||||
|
renderAcquaintanceAuthorsChecklist(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ====================
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
return String(text)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserTypeLabel(user, taskType) {
|
||||||
|
const labels = {
|
||||||
|
'document': '(Секретарь)',
|
||||||
|
'it': '(ИТ специалист)',
|
||||||
|
'ahch': '(АХЧ)',
|
||||||
|
'psychologist': '(Психолог)',
|
||||||
|
'speech_therapist': '(Логопед)',
|
||||||
|
'Social_educator': '(Социальный педагог)',
|
||||||
|
'hr': '(Диспетчер)',
|
||||||
|
'certificate': '(Администрация)',
|
||||||
|
'e_journal': '(Админ ЭЖ)'
|
||||||
|
};
|
||||||
|
return labels[taskType] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Экспорт функций в глобальную область (для вызова из HTML)
|
||||||
|
window.loadUsers = loadUsers;
|
||||||
|
window.filterUsers = filterUsers;
|
||||||
|
window.filterEditUsers = filterEditUsers;
|
||||||
|
window.filterCopyUsers = filterCopyUsers;
|
||||||
|
window.toggleUserSelection = toggleUserSelection;
|
||||||
|
window.toggleEditUserSelection = toggleEditUserSelection;
|
||||||
|
window.toggleCopyUserSelection = toggleCopyUserSelection;
|
||||||
|
window.openCreateListModal = openCreateListModal;
|
||||||
|
window.openEditListModal = openEditListModal;
|
||||||
|
window.deleteUserList = deleteUserList;
|
||||||
|
window.applyUserList = applyUserList;
|
||||||
|
window.closeListModal = closeListModal;
|
||||||
|
window.saveListFromModal = saveListFromModal;
|
||||||
|
window.filterListUsers = filterListUsers;
|
||||||
|
|
||||||
|
// Экспорт функций для ознакомления (исполнители)
|
||||||
|
window.renderAcquaintanceUsersChecklist = renderAcquaintanceUsersChecklist;
|
||||||
|
window.toggleAcquaintanceUserSelection = toggleAcquaintanceUserSelection;
|
||||||
|
window.filterAcquaintanceUsers = filterAcquaintanceUsers;
|
||||||
|
window.acquaintanceSelectedUsers = acquaintanceSelectedUsers;
|
||||||
|
|
||||||
|
// Экспорт функций для выбора автора
|
||||||
|
window.renderAcquaintanceAuthorsChecklist = renderAcquaintanceAuthorsChecklist;
|
||||||
|
window.toggleAcquaintanceAuthorSelection = toggleAcquaintanceAuthorSelection;
|
||||||
|
window.filterAcquaintanceAuthors = filterAcquaintanceAuthors;
|
||||||
|
window.acquaintanceSelectedAuthor = acquaintanceSelectedAuthor;
|
||||||
|
|
||||||
|
// Также экспортируем переменные, которые могут понадобиться в других скриптах
|
||||||
|
window.selectedUsers = selectedUsers;
|
||||||
|
window.editSelectedUsers = editSelectedUsers;
|
||||||
|
window.copySelectedUsers = copySelectedUsers;
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
// server-init.js
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
const { setupUploadMiddleware } = require('./upload-middleware');
|
|
||||||
const { setupTaskEndpoints } = require('./task-endpoints');
|
|
||||||
|
|
||||||
async function initializeServer(app) {
|
|
||||||
console.log('🚀 Инициализация сервера...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { initializeDatabase, getDb, isInitialized } = require('./database');
|
|
||||||
const authService = require('./auth');
|
|
||||||
const postgresLogger = require('./postgres');
|
|
||||||
|
|
||||||
// 1. Инициализируем базу данных
|
|
||||||
console.log('🔧 Инициализация базы данных...');
|
|
||||||
await initializeDatabase();
|
|
||||||
|
|
||||||
// 2. Получаем объект БД
|
|
||||||
const db = getDb();
|
|
||||||
console.log('✅ База данных готова');
|
|
||||||
|
|
||||||
// 3. Настраиваем authService с БД
|
|
||||||
authService.setDatabase(db);
|
|
||||||
console.log('✅ Сервис аутентификации готов');
|
|
||||||
|
|
||||||
// 4. Настраиваем загрузку файлов
|
|
||||||
const upload = setupUploadMiddleware();
|
|
||||||
console.log('✅ Middleware загрузки файлов настроен');
|
|
||||||
|
|
||||||
// 5. Настраиваем endpoint'ы для задач
|
|
||||||
setupTaskEndpoints(app, db, upload);
|
|
||||||
console.log('✅ Endpoint\'ы задач настроены');
|
|
||||||
|
|
||||||
// 6. Загружаем админ роутер
|
|
||||||
try {
|
|
||||||
const adminRouter = require('./admin-server');
|
|
||||||
console.log('Admin router loaded:', adminRouter);
|
|
||||||
console.log('Type:', typeof adminRouter);
|
|
||||||
|
|
||||||
if (adminRouter && typeof adminRouter === 'function') {
|
|
||||||
app.use(adminRouter);
|
|
||||||
console.log('✅ Админ роутер подключен');
|
|
||||||
} else {
|
|
||||||
console.error('❌ Admin router is not a valid middleware function');
|
|
||||||
// Создаем заглушку, чтобы сервер работал
|
|
||||||
const express = require('express');
|
|
||||||
const stubRouter = express.Router();
|
|
||||||
stubRouter.get('*', (req, res) => {
|
|
||||||
res.status(501).json({ error: 'Admin router not available' });
|
|
||||||
});
|
|
||||||
app.use(stubRouter);
|
|
||||||
console.log('⚠️ Используется заглушка для админ роутера');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Ошибка загрузки админ роутера:', error.message);
|
|
||||||
console.error('Stack:', error.stack);
|
|
||||||
|
|
||||||
// Создаем заглушку, чтобы сервер не падал
|
|
||||||
const express = require('express');
|
|
||||||
const stubRouter = express.Router();
|
|
||||||
stubRouter.get('*', (req, res) => {
|
|
||||||
res.status(503).json({
|
|
||||||
error: 'Admin panel temporarily unavailable',
|
|
||||||
message: error.message
|
|
||||||
});
|
|
||||||
});
|
|
||||||
app.use(stubRouter);
|
|
||||||
console.log('⚠️ Создана заглушка для админ роутера из-за ошибки');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('✅ Сервер полностью инициализирован');
|
|
||||||
|
|
||||||
return { db, upload };
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Ошибка инициализации сервера:', error.message);
|
|
||||||
console.error(error.stack);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { initializeServer };
|
|
||||||
1129
task-endpoints.js
1129
task-endpoints.js
File diff suppressed because it is too large
Load Diff
61
task-timeout.js
Normal file
61
task-timeout.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// task-timeout.js
|
||||||
|
// Хранилище времени последнего создания задачи для каждого пользователя
|
||||||
|
const lastTaskCreationTime = new Map();
|
||||||
|
|
||||||
|
// Middleware для проверки таймаута между созданием задач
|
||||||
|
const checkTaskCreationTimeout = (req, res, next) => {
|
||||||
|
const userId = req.session?.user?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const timeoutMs = 15000; // 15 секунд в миллисекундах
|
||||||
|
|
||||||
|
if (lastTaskCreationTime.has(userId)) {
|
||||||
|
const lastCreation = lastTaskCreationTime.get(userId);
|
||||||
|
const timeSinceLastCreation = now - lastCreation;
|
||||||
|
|
||||||
|
if (timeSinceLastCreation < timeoutMs) {
|
||||||
|
const remainingSeconds = Math.ceil((timeoutMs - timeSinceLastCreation) / 1000);
|
||||||
|
return res.status(429).json({
|
||||||
|
error: `Слишком частое создание задач. Подождите ${remainingSeconds} секунд.`,
|
||||||
|
remainingSeconds: remainingSeconds,
|
||||||
|
timeout: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Помечаем, что проверка пройдена
|
||||||
|
req.taskCreationCheckPassed = true;
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Функция для обновления времени создания
|
||||||
|
const updateLastTaskCreationTime = (userId) => {
|
||||||
|
if (userId) {
|
||||||
|
lastTaskCreationTime.set(userId, Date.now());
|
||||||
|
console.log(`✅ Время создания задачи обновлено для пользователя ${userId}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Очистка старых записей (раз в час)
|
||||||
|
setInterval(() => {
|
||||||
|
const oneHourAgo = Date.now() - 3600000;
|
||||||
|
let deletedCount = 0;
|
||||||
|
for (const [userId, creationTime] of lastTaskCreationTime.entries()) {
|
||||||
|
if (creationTime < oneHourAgo) {
|
||||||
|
lastTaskCreationTime.delete(userId);
|
||||||
|
deletedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (deletedCount > 0) {
|
||||||
|
console.log(`🧹 Очищено ${deletedCount} устаревших записей времени создания задач`);
|
||||||
|
}
|
||||||
|
}, 3600000); // Каждый час
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
checkTaskCreationTimeout,
|
||||||
|
updateLastTaskCreationTime
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user