Большое обновление
1. Создание личных проектов 2. Управление командой 3. Приглашение участников 4. Уведомления и многое другое...
This commit is contained in:
@@ -12,9 +12,25 @@ if ($method === 'POST') {
|
||||
|
||||
// Создание комментария
|
||||
if ($action === 'create') {
|
||||
$comment->id_task = $data['id_task'] ?? null;
|
||||
$id_task = $data['id_task'] ?? null;
|
||||
|
||||
// Проверяем доступ к проекту через задачу
|
||||
$taskData = Task::check_task($id_task);
|
||||
ProjectAccess::requireAccess($taskData['id_project'], $current_user_id);
|
||||
|
||||
// Проверяем право на создание комментариев (с учётом create_comment_own_task_only)
|
||||
if (!ProjectAccess::canCreateComment(
|
||||
$taskData['id_project'],
|
||||
$current_user_id,
|
||||
(int)($taskData['id_account'] ?? 0),
|
||||
(int)($taskData['create_id_account'] ?? 0)
|
||||
)) {
|
||||
RestApi::response(['success' => false, 'errors' => ['access' => 'Нет прав на создание комментария']], 403);
|
||||
}
|
||||
|
||||
$comment->id_task = $id_task;
|
||||
$comment->id_accounts = $current_user_id;
|
||||
$comment->id_answer = $data['id_answer'] ?? null; // Ответ на комментарий
|
||||
$comment->id_answer = $data['id_answer'] ?? null;
|
||||
$comment->text = $data['text'] ?? '';
|
||||
|
||||
$result = $comment->create();
|
||||
@@ -23,7 +39,14 @@ if ($method === 'POST') {
|
||||
|
||||
// Обновление комментария
|
||||
if ($action === 'update') {
|
||||
$comment->id = $data['id'] ?? null;
|
||||
$comment_id = $data['id'] ?? null;
|
||||
|
||||
// Проверяем доступ к проекту через комментарий -> задачу
|
||||
$commentData = Comment::checkComment($comment_id);
|
||||
$taskData = Task::check_task($commentData['id_task']);
|
||||
ProjectAccess::requireAccess($taskData['id_project'], $current_user_id);
|
||||
|
||||
$comment->id = $comment_id;
|
||||
$comment->id_accounts = $current_user_id;
|
||||
$comment->text = $data['text'] ?? '';
|
||||
|
||||
@@ -34,26 +57,80 @@ if ($method === 'POST') {
|
||||
// Удаление комментария
|
||||
if ($action === 'delete') {
|
||||
$id = $data['id'] ?? null;
|
||||
$result = Comment::delete($id, $current_user_id);
|
||||
|
||||
// Проверяем доступ к проекту через комментарий -> задачу
|
||||
$commentData = Comment::checkComment($id);
|
||||
$taskData = Task::check_task($commentData['id_task']);
|
||||
$project_id = $taskData['id_project'];
|
||||
ProjectAccess::requireAccess($project_id, $current_user_id);
|
||||
|
||||
// Проверяем права на удаление
|
||||
$isAuthor = (int)$commentData['id_accounts'] === (int)$current_user_id;
|
||||
$isAdmin = ProjectAccess::isAdmin($project_id, $current_user_id);
|
||||
$canDeleteAll = ProjectAccess::can($project_id, $current_user_id, 'delete_all_comments');
|
||||
$canDeleteOwn = ProjectAccess::can($project_id, $current_user_id, 'delete_own_comments');
|
||||
|
||||
if (!$isAdmin && !$canDeleteAll && !($isAuthor && $canDeleteOwn)) {
|
||||
RestApi::response([
|
||||
'success' => false,
|
||||
'errors' => ['access' => 'Нет прав на удаление комментария']
|
||||
], 403);
|
||||
}
|
||||
|
||||
$result = Comment::deleteWithAccess($id);
|
||||
RestApi::response($result);
|
||||
}
|
||||
|
||||
// Загрузка файла к комментарию (только автор)
|
||||
// Загрузка файла к комментарию
|
||||
if ($action === 'upload_image') {
|
||||
$comment_id = $data['comment_id'] ?? null;
|
||||
$file_base64 = $data['file_data'] ?? null;
|
||||
$file_name = $data['file_name'] ?? null;
|
||||
|
||||
// Проверяем доступ к проекту
|
||||
$commentData = Comment::checkComment($comment_id);
|
||||
$taskData = Task::check_task($commentData['id_task']);
|
||||
$project_id = $taskData['id_project'];
|
||||
ProjectAccess::requireAccess($project_id, $current_user_id);
|
||||
|
||||
// Проверяем право на загрузку картинок
|
||||
ProjectAccess::requirePermission($project_id, $current_user_id, 'upload_images');
|
||||
|
||||
// Только автор может загружать к своему комментарию
|
||||
if ((int)$commentData['id_accounts'] !== (int)$current_user_id) {
|
||||
RestApi::response([
|
||||
'success' => false,
|
||||
'errors' => ['access' => 'Вы можете загружать файлы только к своим комментариям']
|
||||
], 403);
|
||||
}
|
||||
|
||||
$result = Comment::uploadFile($comment_id, $file_base64, $file_name, $current_user_id);
|
||||
$result = Comment::uploadFileSimple($comment_id, $file_base64, $file_name);
|
||||
RestApi::response($result);
|
||||
}
|
||||
|
||||
// Удаление файлов комментария (автор или админ проекта)
|
||||
// Удаление файлов комментария
|
||||
if ($action === 'delete_image') {
|
||||
$comment_id = $data['comment_id'] ?? null;
|
||||
$file_names = $data['file_names'] ?? $data['file_name'] ?? null;
|
||||
|
||||
// Проверяем доступ к проекту
|
||||
$commentData = Comment::checkComment($comment_id);
|
||||
$taskData = Task::check_task($commentData['id_task']);
|
||||
$project_id = $taskData['id_project'];
|
||||
ProjectAccess::requireAccess($project_id, $current_user_id);
|
||||
|
||||
// Проверка прав: автор комментария ИЛИ админ проекта
|
||||
$isAuthor = (int)$commentData['id_accounts'] === (int)$current_user_id;
|
||||
$isAdmin = ProjectAccess::isAdmin($project_id, $current_user_id);
|
||||
|
||||
if (!$isAuthor && !$isAdmin) {
|
||||
RestApi::response([
|
||||
'success' => false,
|
||||
'errors' => ['access' => 'Нет прав на удаление файлов']
|
||||
], 403);
|
||||
}
|
||||
|
||||
$result = Comment::deleteFile($comment_id, $file_names, $current_user_id);
|
||||
$result = Comment::deleteFileSimple($comment_id, $file_names);
|
||||
RestApi::response($result);
|
||||
}
|
||||
|
||||
@@ -66,12 +143,17 @@ if ($method === 'POST') {
|
||||
if ($method === 'GET') {
|
||||
// Получение комментариев задачи
|
||||
// ?id_task=X (обязательный)
|
||||
$current_user_id = RestApi::getCurrentUserId();
|
||||
$id_task = $_GET['id_task'] ?? null;
|
||||
|
||||
if (!$id_task) {
|
||||
RestApi::response(['success' => false, 'errors' => ['id_task' => 'Задача не указана']], 400);
|
||||
}
|
||||
|
||||
// Проверяем доступ к проекту через задачу
|
||||
$taskData = Task::check_task($id_task);
|
||||
ProjectAccess::requireAccess($taskData['id_project'], $current_user_id);
|
||||
|
||||
$comment = new Comment();
|
||||
$comments = $comment->getByTask($id_task);
|
||||
|
||||
|
||||
@@ -10,6 +10,10 @@ if ($method === 'POST') {
|
||||
// Получение данных проекта (проект + колонки + отделы)
|
||||
if ($action === 'get_project_data') {
|
||||
$project_id = $data['id_project'] ?? null;
|
||||
|
||||
// Проверяем доступ к проекту
|
||||
ProjectAccess::requireAccess($project_id, $user_id);
|
||||
|
||||
$result = Project::getProjectData($project_id);
|
||||
if ($result) {
|
||||
RestApi::response(['success' => true, 'data' => $result]);
|
||||
@@ -136,16 +140,23 @@ if ($method === 'POST') {
|
||||
}
|
||||
|
||||
if ($method === 'GET') {
|
||||
// Получение всех проектов
|
||||
// Получение всех проектов (только те, где пользователь участник)
|
||||
// ?active=ID — дополнительно вернуть данные активного проекта
|
||||
$user_id = RestApi::getCurrentUserId();
|
||||
|
||||
$project = new Project();
|
||||
$projects = $project->getAll();
|
||||
|
||||
$active_id = $_GET['active'] ?? null;
|
||||
|
||||
if ($active_id) {
|
||||
// Возвращаем список проектов + данные активного
|
||||
$activeData = null;
|
||||
|
||||
if ($active_id && ProjectAccess::isMember((int)$active_id, $user_id)) {
|
||||
// Есть доступ — возвращаем данные активного проекта
|
||||
$activeData = Project::getProjectData((int)$active_id);
|
||||
}
|
||||
|
||||
if ($activeData) {
|
||||
RestApi::response([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
@@ -154,7 +165,7 @@ if ($method === 'GET') {
|
||||
]
|
||||
]);
|
||||
} else {
|
||||
// Только список проектов
|
||||
// Нет active или нет доступа — возвращаем только список
|
||||
RestApi::response(['success' => true, 'data' => $projects]);
|
||||
}
|
||||
}
|
||||
|
||||
85
backend/api/projectAccess.php
Normal file
85
backend/api/projectAccess.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
if ($method === 'POST') {
|
||||
$data = RestApi::getInput();
|
||||
$action = $data['action'] ?? null;
|
||||
$current_user_id = RestApi::getCurrentUserId();
|
||||
|
||||
// Приглашение участника в проект (только админ)
|
||||
if ($action === 'add_member') {
|
||||
$project_id = (int)($data['project_id'] ?? 0);
|
||||
$user_id = (int)($data['user_id'] ?? 0);
|
||||
$is_admin = (bool)($data['is_admin'] ?? false);
|
||||
$permissions = $data['permissions'] ?? null;
|
||||
|
||||
if (!$project_id || !$user_id) {
|
||||
RestApi::response(['success' => false, 'errors' => ['data' => 'Укажите project_id и user_id']], 400);
|
||||
}
|
||||
|
||||
// Приглашать участников могут только админы
|
||||
ProjectAccess::requireAdmin($project_id, $current_user_id);
|
||||
|
||||
$result = ProjectAccess::addMember($project_id, $user_id, $current_user_id, $is_admin, $permissions);
|
||||
RestApi::response($result, $result['success'] ? 200 : 400);
|
||||
}
|
||||
|
||||
// Обновление прав участника (только админ)
|
||||
if ($action === 'update_member') {
|
||||
$project_id = (int)($data['project_id'] ?? 0);
|
||||
$user_id = (int)($data['user_id'] ?? 0);
|
||||
$is_admin = isset($data['is_admin']) ? (bool)$data['is_admin'] : null;
|
||||
$permissions = $data['permissions'] ?? null;
|
||||
|
||||
if (!$project_id || !$user_id) {
|
||||
RestApi::response(['success' => false, 'errors' => ['data' => 'Укажите project_id и user_id']], 400);
|
||||
}
|
||||
|
||||
// Редактировать права могут только админы
|
||||
ProjectAccess::requireAdmin($project_id, $current_user_id);
|
||||
|
||||
// Обновляем is_admin если передан
|
||||
if ($is_admin !== null) {
|
||||
$result = ProjectAccess::setAdmin($project_id, $user_id, $is_admin, $current_user_id);
|
||||
if (!$result['success']) {
|
||||
RestApi::response($result, 400);
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем права если переданы
|
||||
if ($permissions !== null) {
|
||||
$result = ProjectAccess::setPermissions($project_id, $user_id, $permissions, $current_user_id);
|
||||
if (!$result['success']) {
|
||||
RestApi::response($result, 400);
|
||||
}
|
||||
}
|
||||
|
||||
RestApi::response(['success' => true]);
|
||||
}
|
||||
|
||||
// Удаление участника из проекта (право remove_members или админ, или сам себя)
|
||||
if ($action === 'remove_member') {
|
||||
$project_id = (int)($data['project_id'] ?? 0);
|
||||
$user_id = (int)($data['user_id'] ?? 0);
|
||||
|
||||
if (!$project_id || !$user_id) {
|
||||
RestApi::response(['success' => false, 'errors' => ['data' => 'Укажите project_id и user_id']], 400);
|
||||
}
|
||||
|
||||
// Пользователь может удалить сам себя, иначе нужно право remove_members
|
||||
if ((int)$user_id !== (int)$current_user_id) {
|
||||
ProjectAccess::requirePermission($project_id, $current_user_id, 'remove_members');
|
||||
}
|
||||
|
||||
$result = ProjectAccess::removeMember($project_id, $user_id, $current_user_id);
|
||||
RestApi::response($result, $result['success'] ? 200 : 400);
|
||||
}
|
||||
|
||||
// Метод не указан
|
||||
if (!$action) {
|
||||
RestApi::response(['success' => false, 'error' => 'Укажите метод'], 400);
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
134
backend/api/projectInvite.php
Normal file
134
backend/api/projectInvite.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
// GET — получить свои pending-приглашения
|
||||
if ($method === 'GET') {
|
||||
$current_user_id = RestApi::getCurrentUserId();
|
||||
|
||||
if (!$current_user_id) {
|
||||
RestApi::response(['success' => false, 'errors' => ['auth' => 'Требуется авторизация']], 401);
|
||||
}
|
||||
|
||||
$invites = ProjectInvite::getMyPending($current_user_id);
|
||||
$count = count($invites);
|
||||
|
||||
RestApi::response([
|
||||
'success' => true,
|
||||
'data' => $invites,
|
||||
'count' => $count
|
||||
]);
|
||||
}
|
||||
|
||||
// POST — действия с приглашениями
|
||||
if ($method === 'POST') {
|
||||
$data = RestApi::getInput();
|
||||
$action = $data['action'] ?? null;
|
||||
$current_user_id = RestApi::getCurrentUserId();
|
||||
|
||||
if (!$current_user_id) {
|
||||
RestApi::response(['success' => false, 'errors' => ['auth' => 'Требуется авторизация']], 401);
|
||||
}
|
||||
|
||||
// Отправить приглашение (только админ проекта)
|
||||
if ($action === 'send') {
|
||||
$project_id = (int)($data['project_id'] ?? 0);
|
||||
$user_id = (int)($data['user_id'] ?? 0);
|
||||
$is_admin = (bool)($data['is_admin'] ?? false);
|
||||
$permissions = $data['permissions'] ?? null;
|
||||
|
||||
if (!$project_id || !$user_id) {
|
||||
RestApi::response(['success' => false, 'errors' => ['data' => 'Укажите project_id и user_id']], 400);
|
||||
}
|
||||
|
||||
// Только админ может приглашать
|
||||
ProjectAccess::requireAdmin($project_id, $current_user_id);
|
||||
|
||||
$result = ProjectInvite::create($project_id, $user_id, $current_user_id, $is_admin, $permissions);
|
||||
RestApi::response($result, $result['success'] ? 200 : 400);
|
||||
}
|
||||
|
||||
// Проверить, есть ли pending-приглашение для пользователя в проект
|
||||
if ($action === 'check_pending') {
|
||||
$project_id = (int)($data['project_id'] ?? 0);
|
||||
$user_id = (int)($data['user_id'] ?? 0);
|
||||
|
||||
if (!$project_id || !$user_id) {
|
||||
RestApi::response(['success' => false, 'errors' => ['data' => 'Укажите project_id и user_id']], 400);
|
||||
}
|
||||
|
||||
// Проверяем доступ к проекту
|
||||
ProjectAccess::requireAccess($project_id, $current_user_id);
|
||||
|
||||
$hasPending = ProjectInvite::hasPending($project_id, $user_id);
|
||||
RestApi::response(['success' => true, 'has_pending' => $hasPending]);
|
||||
}
|
||||
|
||||
// Получить pending-приглашения проекта (для админа)
|
||||
if ($action === 'get_project_pending') {
|
||||
$project_id = (int)($data['project_id'] ?? 0);
|
||||
|
||||
if (!$project_id) {
|
||||
RestApi::response(['success' => false, 'errors' => ['data' => 'Укажите project_id']], 400);
|
||||
}
|
||||
|
||||
// Только админ может видеть
|
||||
ProjectAccess::requireAdmin($project_id, $current_user_id);
|
||||
|
||||
$invites = ProjectInvite::getProjectPending($project_id);
|
||||
RestApi::response(['success' => true, 'data' => $invites]);
|
||||
}
|
||||
|
||||
// Получить количество pending-приглашений для текущего пользователя
|
||||
if ($action === 'get_count') {
|
||||
$count = ProjectInvite::getPendingCount($current_user_id);
|
||||
RestApi::response(['success' => true, 'count' => $count]);
|
||||
}
|
||||
|
||||
// Принять приглашение
|
||||
if ($action === 'accept') {
|
||||
$invite_id = (int)($data['invite_id'] ?? 0);
|
||||
|
||||
if (!$invite_id) {
|
||||
RestApi::response(['success' => false, 'errors' => ['data' => 'Укажите invite_id']], 400);
|
||||
}
|
||||
|
||||
$result = ProjectInvite::accept($invite_id, $current_user_id);
|
||||
RestApi::response($result, $result['success'] ? 200 : 400);
|
||||
}
|
||||
|
||||
// Отклонить приглашение
|
||||
if ($action === 'decline') {
|
||||
$invite_id = (int)($data['invite_id'] ?? 0);
|
||||
|
||||
if (!$invite_id) {
|
||||
RestApi::response(['success' => false, 'errors' => ['data' => 'Укажите invite_id']], 400);
|
||||
}
|
||||
|
||||
$result = ProjectInvite::decline($invite_id, $current_user_id);
|
||||
RestApi::response($result, $result['success'] ? 200 : 400);
|
||||
}
|
||||
|
||||
// Отменить приглашение (только админ)
|
||||
if ($action === 'cancel') {
|
||||
$invite_id = (int)($data['invite_id'] ?? 0);
|
||||
$project_id = (int)($data['project_id'] ?? 0);
|
||||
|
||||
if (!$invite_id || !$project_id) {
|
||||
RestApi::response(['success' => false, 'errors' => ['data' => 'Укажите invite_id и project_id']], 400);
|
||||
}
|
||||
|
||||
// Только админ может отменять
|
||||
ProjectAccess::requireAdmin($project_id, $current_user_id);
|
||||
|
||||
$result = ProjectInvite::cancel($invite_id, $project_id);
|
||||
RestApi::response($result, $result['success'] ? 200 : 400);
|
||||
}
|
||||
|
||||
// Метод не указан
|
||||
if (!$action) {
|
||||
RestApi::response(['success' => false, 'error' => 'Укажите action'], 400);
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
@@ -5,6 +5,7 @@ $method = $_SERVER['REQUEST_METHOD'];
|
||||
if ($method === 'POST') {
|
||||
$data = RestApi::getInput();
|
||||
$action = $data['action'] ?? null;
|
||||
$user_id = RestApi::getCurrentUserId();
|
||||
$task = new Task();
|
||||
|
||||
// Загрузка изображения
|
||||
@@ -13,15 +14,23 @@ if ($method === 'POST') {
|
||||
$file_base64 = $data['file_data'] ?? null;
|
||||
$file_name = $data['file_name'] ?? null;
|
||||
|
||||
// Проверяем право на редактирование задачи
|
||||
$taskData = Task::check_task($task_id);
|
||||
ProjectAccess::requireEditTask($taskData['id_project'], $user_id, (int)($taskData['id_account'] ?? 0), (int)($taskData['create_id_account'] ?? 0));
|
||||
|
||||
$result = Task::uploadFile($task_id, $file_base64, $file_name);
|
||||
RestApi::response($result);
|
||||
}
|
||||
|
||||
// Удаление изображений (принимает file_names массив или file_name строку)
|
||||
// Удаление изображений
|
||||
if ($action === 'delete_image') {
|
||||
$task_id = $data['task_id'] ?? null;
|
||||
$file_names = $data['file_names'] ?? $data['file_name'] ?? null;
|
||||
|
||||
// Проверяем право на редактирование задачи
|
||||
$taskData = Task::check_task($task_id);
|
||||
ProjectAccess::requireEditTask($taskData['id_project'], $user_id, (int)($taskData['id_account'] ?? 0), (int)($taskData['create_id_account'] ?? 0));
|
||||
|
||||
$result = Task::deleteFile($task_id, $file_names);
|
||||
RestApi::response($result);
|
||||
}
|
||||
@@ -32,6 +41,10 @@ if ($method === 'POST') {
|
||||
$column_id = $data['column_id'] ?? null;
|
||||
$to_index = $data['to_index'] ?? 0;
|
||||
|
||||
// Проверяем право на перемещение (с учётом move_own_task_only)
|
||||
$taskData = Task::check_task($id);
|
||||
ProjectAccess::requireMoveTask($taskData['id_project'], $user_id, (int)($taskData['id_account'] ?? 0), (int)($taskData['create_id_account'] ?? 0));
|
||||
|
||||
$result = Task::updateOrder($id, $column_id, $to_index);
|
||||
RestApi::response($result);
|
||||
}
|
||||
@@ -39,6 +52,11 @@ if ($method === 'POST') {
|
||||
// Обновление задачи
|
||||
if ($action === 'update') {
|
||||
$task->id = $data['id'] ?? null;
|
||||
|
||||
// Проверяем право на редактирование
|
||||
$taskData = Task::check_task($task->id);
|
||||
ProjectAccess::requireEditTask($taskData['id_project'], $user_id, (int)($taskData['id_account'] ?? 0), (int)($taskData['create_id_account'] ?? 0));
|
||||
|
||||
$task->id_department = $data['id_department'] ?? null;
|
||||
$task->id_label = $data['id_label'] ?? null;
|
||||
$task->id_account = $data['id_account'] ?? null;
|
||||
@@ -55,10 +73,16 @@ if ($method === 'POST') {
|
||||
|
||||
// Создание задачи
|
||||
if ($action === 'create') {
|
||||
$task->id_project = $data['id_project'] ?? null;
|
||||
$project_id = $data['id_project'] ?? null;
|
||||
|
||||
// Проверяем право на создание задач
|
||||
ProjectAccess::requirePermission($project_id, $user_id, 'create_task');
|
||||
|
||||
$task->id_project = $project_id;
|
||||
$task->id_department = $data['id_department'] ?? null;
|
||||
$task->id_label = $data['id_label'] ?? null;
|
||||
$task->id_account = $data['id_account'] ?? null;
|
||||
$task->create_id_account = $user_id; // Создатель задачи
|
||||
$task->column_id = $data['column_id'] ?? null;
|
||||
$task->order = $data['order'] ?? 0;
|
||||
$task->date = $data['date'] ?? null;
|
||||
@@ -74,6 +98,11 @@ if ($method === 'POST') {
|
||||
// Удаление задачи
|
||||
if ($action === 'delete') {
|
||||
$id = $data['id'] ?? null;
|
||||
|
||||
// Проверяем право на удаление
|
||||
$taskData = Task::check_task($id);
|
||||
ProjectAccess::requirePermission($taskData['id_project'], $user_id, 'delete_task');
|
||||
|
||||
$result = Task::delete($id);
|
||||
RestApi::response($result);
|
||||
}
|
||||
@@ -82,6 +111,11 @@ if ($method === 'POST') {
|
||||
if ($action === 'set_archive') {
|
||||
$id = $data['id'] ?? null;
|
||||
$archive = $data['archive'] ?? 1;
|
||||
|
||||
// Проверяем право на архивирование
|
||||
$taskData = Task::check_task($id);
|
||||
ProjectAccess::requirePermission($taskData['id_project'], $user_id, 'archive_task');
|
||||
|
||||
$result = Task::setArchive($id, $archive);
|
||||
RestApi::response($result);
|
||||
}
|
||||
@@ -96,12 +130,16 @@ if ($method === 'GET') {
|
||||
// Получение задач проекта
|
||||
// ?id_project=1 (обязательный)
|
||||
// ?archive=0 (неархивные, по умолчанию), ?archive=1 (архивные), ?archive=all (все)
|
||||
$user_id = RestApi::getCurrentUserId();
|
||||
$id_project = $_GET['id_project'] ?? null;
|
||||
|
||||
if (!$id_project) {
|
||||
RestApi::response(['success' => false, 'errors' => ['id_project' => 'Проект не указан']], 400);
|
||||
}
|
||||
|
||||
// Проверяем доступ к проекту
|
||||
ProjectAccess::requireAccess((int)$id_project, $user_id);
|
||||
|
||||
$archive = $_GET['archive'] ?? 0;
|
||||
if ($archive === 'all') {
|
||||
$archive = null;
|
||||
|
||||
@@ -6,13 +6,6 @@ if ($method === 'POST') {
|
||||
$data = RestApi::getInput();
|
||||
$action = $data['action'] ?? null;
|
||||
|
||||
// Получение конфигурации приложения
|
||||
if ($action === 'get_config') {
|
||||
RestApi::response(['success' => true, 'data' => [
|
||||
'COLUMN_DONE_ID' => COLUMN_DONE_ID
|
||||
]]);
|
||||
}
|
||||
|
||||
// Авторизация
|
||||
if ($action === 'auth_login') {
|
||||
$account = new Account();
|
||||
@@ -53,6 +46,27 @@ if ($method === 'POST') {
|
||||
RestApi::response($result);
|
||||
}
|
||||
|
||||
// Поиск пользователя по логину
|
||||
if ($action === 'search') {
|
||||
$current_user_id = RestApi::getCurrentUserId();
|
||||
$username = trim($data['username'] ?? '');
|
||||
|
||||
if (!$username) {
|
||||
RestApi::response(['success' => false, 'errors' => ['username' => 'Введите логин']], 400);
|
||||
}
|
||||
|
||||
// Ищем пользователя по username
|
||||
$user = Database::get('accounts', ['id', 'name', 'username', 'avatar_url'], [
|
||||
'username' => $username
|
||||
]);
|
||||
|
||||
if (!$user) {
|
||||
RestApi::response(['success' => false, 'errors' => ['username' => 'Пользователь не найден']]);
|
||||
}
|
||||
|
||||
RestApi::response(['success' => true, 'data' => $user]);
|
||||
}
|
||||
|
||||
// Проверяем, что метод не пустой
|
||||
if (!$action) {
|
||||
RestApi::response(['success' => false, 'error' => 'Укажите метод'], 400);
|
||||
@@ -60,11 +74,22 @@ if ($method === 'POST') {
|
||||
}
|
||||
|
||||
if ($method === 'GET') {
|
||||
// Получение всех пользователей
|
||||
$account = new Account();
|
||||
$users = $account->getAll();
|
||||
// Получение участников проекта
|
||||
// ?id_project=X (обязательный)
|
||||
$current_user_id = RestApi::getCurrentUserId();
|
||||
$id_project = $_GET['id_project'] ?? null;
|
||||
|
||||
RestApi::response(['success' => true, 'data' => $users]);
|
||||
if (!$id_project) {
|
||||
RestApi::response(['success' => false, 'errors' => ['id_project' => 'Проект не указан']], 400);
|
||||
}
|
||||
|
||||
// Проверяем доступ к проекту
|
||||
ProjectAccess::requireAccess((int)$id_project, $current_user_id);
|
||||
|
||||
// Получаем участников проекта
|
||||
$members = ProjectAccess::getMembers((int)$id_project);
|
||||
|
||||
RestApi::response(['success' => true, 'data' => $members]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ class Comment extends BaseEntity {
|
||||
|
||||
// Проверяем права: автор комментария ИЛИ админ проекта
|
||||
$isAuthor = (int)$comment['id_accounts'] === (int)$id_accounts;
|
||||
$isProjectAdmin = $task && Project::isAdmin($task['id_project'], $id_accounts);
|
||||
$isProjectAdmin = $task && ProjectAccess::isAdmin($task['id_project'], $id_accounts);
|
||||
|
||||
if (!$isAuthor && !$isProjectAdmin) {
|
||||
RestApi::response([
|
||||
@@ -138,9 +138,26 @@ class Comment extends BaseEntity {
|
||||
Database::delete('comments', ['id' => $id]);
|
||||
}
|
||||
|
||||
// Удаление комментария (проверка прав уже выполнена на уровне API)
|
||||
public static function deleteWithAccess($id) {
|
||||
// Рекурсивно удаляем все дочерние комментарии
|
||||
self::deleteWithChildren($id);
|
||||
return ['success' => true];
|
||||
}
|
||||
|
||||
// === МЕТОДЫ ДЛЯ РАБОТЫ С ФАЙЛАМИ ===
|
||||
|
||||
// Загрузка файла к комментарию (только автор может загружать)
|
||||
// Загрузка файла к комментарию (проверка прав уже выполнена на уровне API)
|
||||
public static function uploadFileSimple($comment_id, $file_base64, $file_name) {
|
||||
return FileUpload::upload('comment', $comment_id, $file_base64, $file_name);
|
||||
}
|
||||
|
||||
// Удаление файлов комментария (проверка прав уже выполнена на уровне API)
|
||||
public static function deleteFileSimple($comment_id, $file_names) {
|
||||
return FileUpload::delete('comment', $comment_id, $file_names);
|
||||
}
|
||||
|
||||
// Загрузка файла к комментарию (только автор может загружать) - DEPRECATED
|
||||
public static function uploadFile($comment_id, $file_base64, $file_name, $user_id) {
|
||||
// Проверка что комментарий существует
|
||||
$comment = self::checkComment($comment_id);
|
||||
@@ -156,7 +173,7 @@ class Comment extends BaseEntity {
|
||||
return FileUpload::upload('comment', $comment_id, $file_base64, $file_name);
|
||||
}
|
||||
|
||||
// Удаление файлов комментария (автор или админ проекта)
|
||||
// Удаление файлов комментария (автор или админ проекта) - DEPRECATED
|
||||
public static function deleteFile($comment_id, $file_names, $user_id) {
|
||||
// Проверка что комментарий существует
|
||||
$comment = self::checkComment($comment_id);
|
||||
@@ -166,7 +183,7 @@ class Comment extends BaseEntity {
|
||||
|
||||
// Проверка прав: автор комментария ИЛИ админ проекта
|
||||
$isAuthor = (int)$comment['id_accounts'] === (int)$user_id;
|
||||
$isProjectAdmin = $task && Project::isAdmin($task['id_project'], $user_id);
|
||||
$isProjectAdmin = $task && ProjectAccess::isAdmin($task['id_project'], $user_id);
|
||||
|
||||
if (!$isAuthor && !$isProjectAdmin) {
|
||||
RestApi::response([
|
||||
|
||||
@@ -4,55 +4,49 @@ class Project extends BaseEntity {
|
||||
|
||||
protected $db_name = 'project';
|
||||
|
||||
// Получение всех проектов
|
||||
// Получение всех проектов (только те, где пользователь участник)
|
||||
public function getAll() {
|
||||
$current_user_id = RestApi::getCurrentUserId();
|
||||
|
||||
if (!$current_user_id) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Получаем ID проектов где пользователь участник
|
||||
$projectIds = ProjectAccess::getUserProjectIds($current_user_id);
|
||||
|
||||
if (empty($projectIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$projects = Database::select($this->db_name, [
|
||||
'id',
|
||||
'id_order',
|
||||
'name',
|
||||
'id_ready',
|
||||
'id_admin'
|
||||
'id_ready'
|
||||
], [
|
||||
'id' => $projectIds,
|
||||
'ORDER' => ['id_order' => 'ASC']
|
||||
]);
|
||||
|
||||
// Обрабатываем id_admin для каждого проекта
|
||||
// Добавляем флаг is_admin для каждого проекта
|
||||
return array_map(function($project) use ($current_user_id) {
|
||||
$admins = $project['id_admin'] ? json_decode($project['id_admin'], true) : [];
|
||||
|
||||
if ($current_user_id && in_array((int)$current_user_id, $admins, true)) {
|
||||
$project['id_admin'] = true;
|
||||
} else {
|
||||
unset($project['id_admin']);
|
||||
}
|
||||
|
||||
$project['is_admin'] = ProjectAccess::isAdmin($project['id'], $current_user_id);
|
||||
return $project;
|
||||
}, $projects);
|
||||
}
|
||||
|
||||
// Получение одного проекта
|
||||
// $current_user_id — ID текущего пользователя для проверки админства
|
||||
public static function get($id, $current_user_id = null) {
|
||||
$project = Database::get('project', [
|
||||
'id',
|
||||
'id_order',
|
||||
'name',
|
||||
'id_ready',
|
||||
'id_admin'
|
||||
'id_ready'
|
||||
], ['id' => $id]);
|
||||
|
||||
if ($project) {
|
||||
$admins = $project['id_admin'] ? json_decode($project['id_admin'], true) : [];
|
||||
|
||||
// Если передан user_id — проверяем админство
|
||||
if ($current_user_id && in_array((int)$current_user_id, $admins, true)) {
|
||||
$project['id_admin'] = true;
|
||||
} else {
|
||||
// Не админ — убираем поле
|
||||
unset($project['id_admin']);
|
||||
}
|
||||
if ($project && $current_user_id) {
|
||||
$project['is_admin'] = ProjectAccess::isAdmin($id, $current_user_id);
|
||||
}
|
||||
|
||||
return $project;
|
||||
@@ -114,17 +108,6 @@ class Project extends BaseEntity {
|
||||
];
|
||||
}
|
||||
|
||||
// Проверка является ли пользователь админом проекта
|
||||
public static function isAdmin($project_id, $user_id): bool {
|
||||
$project = Database::get('project', ['id_admin'], ['id' => $project_id]);
|
||||
if (!$project || !$project['id_admin']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$admins = json_decode($project['id_admin'], true) ?: [];
|
||||
return in_array((int)$user_id, $admins, true);
|
||||
}
|
||||
|
||||
// ==================== CRUD ПРОЕКТОВ ====================
|
||||
|
||||
// Создание проекта с дефолтными колонками
|
||||
@@ -132,12 +115,11 @@ class Project extends BaseEntity {
|
||||
// Получаем максимальный id_order
|
||||
$maxOrder = Database::max('project', 'id_order') ?? 0;
|
||||
|
||||
// Создаём проект
|
||||
// Создаём проект с создателем как владельцем (id_admin)
|
||||
Database::insert('project', [
|
||||
'name' => $name,
|
||||
'id_order' => $maxOrder + 1,
|
||||
'id_admin' => json_encode([(int)$user_id]),
|
||||
'id_member' => json_encode([])
|
||||
'id_admin' => json_encode([$user_id])
|
||||
]);
|
||||
|
||||
$projectId = Database::id();
|
||||
@@ -172,14 +154,14 @@ class Project extends BaseEntity {
|
||||
['id' => $firstColumnId, 'name_columns' => 'К выполнению', 'color' => '#6366f1', 'id_order' => 1],
|
||||
['id' => $readyColumnId, 'name_columns' => 'Готово', 'color' => '#22c55e', 'id_order' => 2]
|
||||
],
|
||||
'id_ready' => $readyColumnId
|
||||
'id_ready' => $readyColumnId,
|
||||
'is_admin' => true
|
||||
];
|
||||
}
|
||||
|
||||
// Обновление проекта
|
||||
public static function update($id, $name, $user_id) {
|
||||
// Проверяем права
|
||||
if (!self::isAdmin($id, $user_id)) {
|
||||
if (!ProjectAccess::isAdmin($id, $user_id)) {
|
||||
return ['success' => false, 'errors' => ['access' => 'Нет прав на редактирование']];
|
||||
}
|
||||
|
||||
@@ -189,8 +171,7 @@ class Project extends BaseEntity {
|
||||
|
||||
// Удаление проекта (каскадно: колонки, задачи, комментарии, файлы)
|
||||
public static function delete($id, $user_id) {
|
||||
// Проверяем права
|
||||
if (!self::isAdmin($id, $user_id)) {
|
||||
if (!ProjectAccess::isAdmin($id, $user_id)) {
|
||||
return ['success' => false, 'errors' => ['access' => 'Нет прав на удаление']];
|
||||
}
|
||||
|
||||
@@ -225,6 +206,12 @@ class Project extends BaseEntity {
|
||||
// Удаляем отделы проекта
|
||||
Database::delete('departments', ['id_project' => $id]);
|
||||
|
||||
// Удаляем участников проекта
|
||||
Database::delete('project_members', ['id_project' => $id]);
|
||||
|
||||
// Удаляем приглашения в проект
|
||||
Database::delete('project_invites', ['id_project' => $id]);
|
||||
|
||||
// Удаляем проект
|
||||
Database::delete('project', ['id' => $id]);
|
||||
|
||||
@@ -243,9 +230,8 @@ class Project extends BaseEntity {
|
||||
|
||||
// Добавление колонки
|
||||
public static function addColumn($project_id, $name, $color, $user_id) {
|
||||
// Проверяем права
|
||||
if (!self::isAdmin($project_id, $user_id)) {
|
||||
return ['success' => false, 'errors' => ['access' => 'Нет прав на редактирование']];
|
||||
if (!ProjectAccess::can($project_id, $user_id, 'create_column')) {
|
||||
return ['success' => false, 'errors' => ['access' => 'Нет прав на создание колонок']];
|
||||
}
|
||||
|
||||
// Получаем максимальный id_order для проекта
|
||||
@@ -279,9 +265,8 @@ class Project extends BaseEntity {
|
||||
return ['success' => false, 'errors' => ['column' => 'Колонка не найдена']];
|
||||
}
|
||||
|
||||
// Проверяем права
|
||||
if (!self::isAdmin($column['id_project'], $user_id)) {
|
||||
return ['success' => false, 'errors' => ['access' => 'Нет прав на редактирование']];
|
||||
if (!ProjectAccess::can($column['id_project'], $user_id, 'edit_column')) {
|
||||
return ['success' => false, 'errors' => ['access' => 'Нет прав на редактирование колонок']];
|
||||
}
|
||||
|
||||
$updateData = [];
|
||||
@@ -308,9 +293,8 @@ class Project extends BaseEntity {
|
||||
return ['success' => false, 'errors' => ['column' => 'Колонка не найдена']];
|
||||
}
|
||||
|
||||
// Проверяем права
|
||||
if (!self::isAdmin($column['id_project'], $user_id)) {
|
||||
return ['success' => false, 'errors' => ['access' => 'Нет прав на удаление']];
|
||||
if (!ProjectAccess::can($column['id_project'], $user_id, 'delete_column')) {
|
||||
return ['success' => false, 'errors' => ['access' => 'Нет прав на удаление колонок']];
|
||||
}
|
||||
|
||||
// Проверяем, не является ли это последней колонкой перед "Готово"
|
||||
@@ -360,9 +344,8 @@ class Project extends BaseEntity {
|
||||
|
||||
// Обновление порядка колонок
|
||||
public static function updateColumnsOrder($project_id, $ids, $user_id) {
|
||||
// Проверяем права
|
||||
if (!self::isAdmin($project_id, $user_id)) {
|
||||
return ['success' => false, 'errors' => ['access' => 'Нет прав на редактирование']];
|
||||
if (!ProjectAccess::can($project_id, $user_id, 'edit_column')) {
|
||||
return ['success' => false, 'errors' => ['access' => 'Нет прав на редактирование колонок']];
|
||||
}
|
||||
|
||||
foreach ($ids as $order => $id) {
|
||||
|
||||
540
backend/app/class/enity/class_projectAccess.php
Normal file
540
backend/app/class/enity/class_projectAccess.php
Normal file
@@ -0,0 +1,540 @@
|
||||
<?php
|
||||
|
||||
class ProjectAccess {
|
||||
|
||||
// Список всех доступных прав
|
||||
const PERMISSIONS = [
|
||||
'create_task', // создание задач
|
||||
'edit_task', // редактирование любых задач
|
||||
'edit_own_task_only', // редактирование только назначенных на себя
|
||||
'delete_task', // удаление задач
|
||||
'move_task', // перемещение любых задач между колонками
|
||||
'move_own_task_only', // перемещение только назначенных на себя
|
||||
'archive_task', // архивирование задач
|
||||
'create_column', // создание колонок
|
||||
'edit_column', // редактирование колонок
|
||||
'delete_column', // удаление колонок
|
||||
'manage_departments', // управление отделами
|
||||
'remove_members', // удаление участников (приглашать/редактировать могут только админы)
|
||||
'create_comment', // создание комментариев в любых задачах
|
||||
'create_comment_own_task_only', // создание комментариев только в назначенных на себя
|
||||
'delete_own_comments', // удаление своих комментариев
|
||||
'delete_all_comments', // удаление любых комментариев в проекте
|
||||
'upload_files', // загрузка файлов
|
||||
'upload_images' // загрузка картинок
|
||||
];
|
||||
|
||||
// Права по умолчанию для нового участника
|
||||
const DEFAULT_PERMISSIONS = [
|
||||
'create_task' => true, // создание задач
|
||||
'edit_task' => false, // редактирование любых задач
|
||||
'edit_own_task_only' => true, // редактирование только назначенных на себя
|
||||
'delete_task' => false, // удаление задач
|
||||
'move_task' => false, // перемещение любых задач между колонками
|
||||
'move_own_task_only' => true, // перемещение только назначенных на себя
|
||||
'archive_task' => true, // архивирование задач
|
||||
'create_column' => false, // создание колонок
|
||||
'edit_column' => false, // редактирование колонок
|
||||
'delete_column' => false, // удаление колонок
|
||||
'manage_departments' => false, // управление отделами
|
||||
'remove_members' => false, // удаление участников
|
||||
'create_comment' => false, // создание комментариев в любых задачах
|
||||
'create_comment_own_task_only' => true, // создание комментариев только в назначенных на себя
|
||||
'delete_own_comments' => true, // удаление своих комментариев
|
||||
'delete_all_comments' => false, // удаление любых комментариев
|
||||
'upload_files' => true, // загрузка файлов
|
||||
'upload_images' => true // загрузка картинок
|
||||
];
|
||||
|
||||
// ==================== ПРОВЕРКИ ДОСТУПА ====================
|
||||
|
||||
// Проверить, является ли пользователь владельцем проекта (в id_admin таблицы projects)
|
||||
public static function isOwner(int $project_id, int $user_id): bool {
|
||||
$project = Database::get('project', ['id_admin'], ['id' => $project_id]);
|
||||
if (!$project || !$project['id_admin']) {
|
||||
return false;
|
||||
}
|
||||
$owners = json_decode($project['id_admin'], true);
|
||||
return is_array($owners) && in_array($user_id, $owners);
|
||||
}
|
||||
|
||||
// Проверить, является ли пользователь участником проекта
|
||||
public static function isMember(int $project_id, int $user_id): bool {
|
||||
// Владелец всегда имеет доступ
|
||||
if (self::isOwner($project_id, $user_id)) {
|
||||
return true;
|
||||
}
|
||||
return Database::has('project_members', [
|
||||
'id_project' => $project_id,
|
||||
'id_user' => $user_id
|
||||
]);
|
||||
}
|
||||
|
||||
// Проверить, является ли пользователь админом проекта
|
||||
public static function isAdmin(int $project_id, int $user_id): bool {
|
||||
// Владелец = безоговорочный админ
|
||||
if (self::isOwner($project_id, $user_id)) {
|
||||
return true;
|
||||
}
|
||||
return Database::has('project_members', [
|
||||
'id_project' => $project_id,
|
||||
'id_user' => $user_id,
|
||||
'is_admin' => 1
|
||||
]);
|
||||
}
|
||||
|
||||
// Проверить конкретное право (владелец/админ имеет все права)
|
||||
public static function can(int $project_id, int $user_id, string $permission): bool {
|
||||
// Владелец может всё
|
||||
if (self::isOwner($project_id, $user_id)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$member = Database::get('project_members', ['is_admin', 'permissions'], [
|
||||
'id_project' => $project_id,
|
||||
'id_user' => $user_id
|
||||
]);
|
||||
|
||||
if (!$member) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Админ может всё
|
||||
if ((int)$member['is_admin'] === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$permissions = $member['permissions'] ? json_decode($member['permissions'], true) : [];
|
||||
return isset($permissions[$permission]) && $permissions[$permission] === true;
|
||||
}
|
||||
|
||||
// Проверить право на редактирование задачи (с учётом edit_own_task_only)
|
||||
// "Своя задача" = назначен на пользователя ИЛИ создана пользователем
|
||||
public static function canEditTask(int $project_id, int $user_id, ?int $task_assignee_id, ?int $task_creator_id = null): bool {
|
||||
if (self::isAdmin($project_id, $user_id)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (self::can($project_id, $user_id, 'edit_task')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Создатель + право создания → может редактировать свои созданные
|
||||
if ($task_creator_id === $user_id && self::can($project_id, $user_id, 'create_task')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Назначена на себя + право edit_own_task_only
|
||||
if ($task_assignee_id === $user_id && self::can($project_id, $user_id, 'edit_own_task_only')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Проверить право на перемещение задачи (с учётом move_own_task_only)
|
||||
// "Своя задача" = назначен на пользователя ИЛИ создана пользователем
|
||||
public static function canMoveTask(int $project_id, int $user_id, ?int $task_assignee_id, ?int $task_creator_id = null): bool {
|
||||
if (self::isAdmin($project_id, $user_id)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (self::can($project_id, $user_id, 'move_task')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Создатель + право создания → может перемещать свои созданные
|
||||
if ($task_creator_id === $user_id && self::can($project_id, $user_id, 'create_task')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Назначена на себя + право move_own_task_only
|
||||
if ($task_assignee_id === $user_id && self::can($project_id, $user_id, 'move_own_task_only')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Проверить право на создание комментария в задаче
|
||||
public static function canCreateComment(int $project_id, int $user_id, ?int $task_assignee_id, ?int $task_creator_id = null): bool {
|
||||
if (self::isAdmin($project_id, $user_id)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Полное право — комментировать любые задачи
|
||||
if (self::can($project_id, $user_id, 'create_comment')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Создатель + право создания → может комментировать свои созданные
|
||||
if ($task_creator_id === $user_id && self::can($project_id, $user_id, 'create_task')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Назначена на себя + право create_comment_own_task_only
|
||||
if ($task_assignee_id === $user_id && self::can($project_id, $user_id, 'create_comment_own_task_only')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ==================== ПРОВЕРКИ С EXIT ====================
|
||||
|
||||
// Требовать доступ к проекту (при отказе — 403 и exit)
|
||||
public static function requireAccess(int $project_id, ?int $user_id): void {
|
||||
if (!$user_id) {
|
||||
RestApi::response(['success' => false, 'errors' => ['auth' => 'Требуется авторизация']], 401);
|
||||
}
|
||||
|
||||
if (!self::isMember($project_id, $user_id)) {
|
||||
RestApi::response(['success' => false, 'errors' => ['access' => 'Нет доступа к проекту']], 403);
|
||||
}
|
||||
}
|
||||
|
||||
// Требовать права админа
|
||||
public static function requireAdmin(int $project_id, ?int $user_id): void {
|
||||
if (!$user_id) {
|
||||
RestApi::response(['success' => false, 'errors' => ['auth' => 'Требуется авторизация']], 401);
|
||||
}
|
||||
|
||||
if (!self::isAdmin($project_id, $user_id)) {
|
||||
RestApi::response(['success' => false, 'errors' => ['access' => 'Требуются права администратора']], 403);
|
||||
}
|
||||
}
|
||||
|
||||
// Требовать конкретное право
|
||||
public static function requirePermission(int $project_id, ?int $user_id, string $permission): void {
|
||||
if (!$user_id) {
|
||||
RestApi::response(['success' => false, 'errors' => ['auth' => 'Требуется авторизация']], 401);
|
||||
}
|
||||
|
||||
if (!self::can($project_id, $user_id, $permission)) {
|
||||
RestApi::response(['success' => false, 'errors' => ['access' => 'Недостаточно прав']], 403);
|
||||
}
|
||||
}
|
||||
|
||||
// Требовать право на редактирование задачи
|
||||
public static function requireEditTask(int $project_id, ?int $user_id, ?int $task_assignee_id, ?int $task_creator_id = null): void {
|
||||
if (!$user_id) {
|
||||
RestApi::response(['success' => false, 'errors' => ['auth' => 'Требуется авторизация']], 401);
|
||||
}
|
||||
|
||||
if (!self::canEditTask($project_id, $user_id, $task_assignee_id, $task_creator_id)) {
|
||||
RestApi::response(['success' => false, 'errors' => ['access' => 'Нет прав на редактирование задачи']], 403);
|
||||
}
|
||||
}
|
||||
|
||||
// Требовать право на перемещение задачи
|
||||
public static function requireMoveTask(int $project_id, ?int $user_id, ?int $task_assignee_id, ?int $task_creator_id = null): void {
|
||||
if (!$user_id) {
|
||||
RestApi::response(['success' => false, 'errors' => ['auth' => 'Требуется авторизация']], 401);
|
||||
}
|
||||
|
||||
if (!self::canMoveTask($project_id, $user_id, $task_assignee_id, $task_creator_id)) {
|
||||
RestApi::response(['success' => false, 'errors' => ['access' => 'Нет прав на перемещение задачи']], 403);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== УПРАВЛЕНИЕ УЧАСТНИКАМИ ====================
|
||||
|
||||
// Добавить участника в проект
|
||||
public static function addMember(int $project_id, int $user_id, int $current_user_id, bool $is_admin = false, ?array $permissions = null): array {
|
||||
// Нельзя пригласить себя
|
||||
if ($user_id === $current_user_id) {
|
||||
return ['success' => false, 'errors' => ['member' => 'Нельзя пригласить себя в проект']];
|
||||
}
|
||||
|
||||
if (self::isMember($project_id, $user_id)) {
|
||||
return ['success' => false, 'errors' => ['member' => 'Пользователь уже участник проекта']];
|
||||
}
|
||||
|
||||
$perms = $permissions ?? self::DEFAULT_PERMISSIONS;
|
||||
|
||||
Database::insert('project_members', [
|
||||
'id_project' => $project_id,
|
||||
'id_user' => $user_id,
|
||||
'is_admin' => $is_admin ? 1 : 0,
|
||||
'permissions' => json_encode($perms),
|
||||
'created_at' => date('Y-m-d H:i:s')
|
||||
]);
|
||||
|
||||
return ['success' => true, 'id' => Database::id()];
|
||||
}
|
||||
|
||||
// Удалить участника из проекта (владельца удалить нельзя, админ не может удалить себя)
|
||||
public static function removeMember(int $project_id, int $user_id, int $current_user_id): array {
|
||||
// Владельца удалить нельзя
|
||||
if (self::isOwner($project_id, $user_id)) {
|
||||
return ['success' => false, 'errors' => ['member' => 'Нельзя удалить владельца проекта']];
|
||||
}
|
||||
|
||||
$member = Database::get('project_members', ['is_admin'], [
|
||||
'id_project' => $project_id,
|
||||
'id_user' => $user_id
|
||||
]);
|
||||
|
||||
if (!$member) {
|
||||
return ['success' => false, 'errors' => ['member' => 'Участник не найден']];
|
||||
}
|
||||
|
||||
// Админ может выйти сам, если есть другие админы или владелец
|
||||
// (владелец всегда есть в id_admin проекта, так что это безопасно)
|
||||
|
||||
Database::delete('project_members', [
|
||||
'id_project' => $project_id,
|
||||
'id_user' => $user_id
|
||||
]);
|
||||
|
||||
return ['success' => true];
|
||||
}
|
||||
|
||||
// Получить всех участников проекта (включая владельцев из id_admin)
|
||||
public static function getMembers(int $project_id): array {
|
||||
$result = [];
|
||||
$addedUserIds = [];
|
||||
|
||||
// Получаем владельцев из id_admin проекта
|
||||
$project = Database::get('project', ['id_admin'], ['id' => $project_id]);
|
||||
if ($project && $project['id_admin']) {
|
||||
$ownerIds = json_decode($project['id_admin'], true);
|
||||
if (is_array($ownerIds) && count($ownerIds) > 0) {
|
||||
$owners = Database::select('accounts', ['id', 'name', 'username', 'avatar_url', 'telegram'], ['id' => $ownerIds]);
|
||||
foreach ($owners as $owner) {
|
||||
$result[] = [
|
||||
'id' => null,
|
||||
'id_user' => (int)$owner['id'],
|
||||
'is_admin' => true,
|
||||
'is_owner' => true,
|
||||
'permissions' => [],
|
||||
'created_at' => null,
|
||||
'name' => $owner['name'],
|
||||
'username' => $owner['username'],
|
||||
'avatar_url' => $owner['avatar_url'],
|
||||
'telegram' => $owner['telegram']
|
||||
];
|
||||
$addedUserIds[] = (int)$owner['id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Получаем участников из project_members
|
||||
$members = Database::select('project_members', [
|
||||
'[>]accounts' => ['id_user' => 'id']
|
||||
], [
|
||||
'project_members.id',
|
||||
'project_members.id_user',
|
||||
'project_members.is_admin',
|
||||
'project_members.permissions',
|
||||
'project_members.created_at',
|
||||
'accounts.name',
|
||||
'accounts.username',
|
||||
'accounts.avatar_url',
|
||||
'accounts.telegram'
|
||||
], [
|
||||
'project_members.id_project' => $project_id
|
||||
]);
|
||||
|
||||
foreach ($members as $member) {
|
||||
$userId = (int)$member['id_user'];
|
||||
// Пропускаем если уже добавлен как владелец
|
||||
if (in_array($userId, $addedUserIds)) {
|
||||
continue;
|
||||
}
|
||||
$result[] = [
|
||||
'id' => $member['id'],
|
||||
'id_user' => $userId,
|
||||
'is_admin' => (int)$member['is_admin'] === 1,
|
||||
'is_owner' => false,
|
||||
'permissions' => $member['permissions'] ? json_decode($member['permissions'], true) : [],
|
||||
'created_at' => $member['created_at'],
|
||||
'name' => $member['name'],
|
||||
'username' => $member['username'],
|
||||
'avatar_url' => $member['avatar_url'],
|
||||
'telegram' => $member['telegram']
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Получить ID всех проектов пользователя (включая где он владелец)
|
||||
public static function getUserProjectIds(int $user_id): array {
|
||||
$projectIds = [];
|
||||
|
||||
// Проекты из project_members
|
||||
$rows = Database::select('project_members', ['id_project'], [
|
||||
'id_user' => $user_id
|
||||
]);
|
||||
foreach ($rows as $row) {
|
||||
$projectIds[] = (int)$row['id_project'];
|
||||
}
|
||||
|
||||
// Проекты где пользователь в id_admin
|
||||
$allProjects = Database::select('project', ['id', 'id_admin']);
|
||||
foreach ($allProjects as $project) {
|
||||
if ($project['id_admin']) {
|
||||
$owners = json_decode($project['id_admin'], true);
|
||||
if (is_array($owners) && in_array($user_id, $owners)) {
|
||||
$pid = (int)$project['id'];
|
||||
if (!in_array($pid, $projectIds)) {
|
||||
$projectIds[] = $pid;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $projectIds;
|
||||
}
|
||||
|
||||
// ==================== УПРАВЛЕНИЕ ПРАВАМИ ====================
|
||||
|
||||
// Установить конкретное право
|
||||
public static function setPermission(int $project_id, int $user_id, string $permission, bool $value, int $current_user_id): array {
|
||||
// Нельзя редактировать свои права
|
||||
if ($user_id === $current_user_id) {
|
||||
return ['success' => false, 'errors' => ['member' => 'Нельзя изменять свои права']];
|
||||
}
|
||||
|
||||
if (!in_array($permission, self::PERMISSIONS)) {
|
||||
return ['success' => false, 'errors' => ['permission' => 'Неизвестное право: ' . $permission]];
|
||||
}
|
||||
|
||||
$member = Database::get('project_members', ['permissions'], [
|
||||
'id_project' => $project_id,
|
||||
'id_user' => $user_id
|
||||
]);
|
||||
|
||||
if (!$member) {
|
||||
return ['success' => false, 'errors' => ['member' => 'Участник не найден']];
|
||||
}
|
||||
|
||||
$permissions = $member['permissions'] ? json_decode($member['permissions'], true) : [];
|
||||
$permissions[$permission] = $value;
|
||||
|
||||
Database::update('project_members', [
|
||||
'permissions' => json_encode($permissions)
|
||||
], [
|
||||
'id_project' => $project_id,
|
||||
'id_user' => $user_id
|
||||
]);
|
||||
|
||||
return ['success' => true, 'permissions' => $permissions];
|
||||
}
|
||||
|
||||
// Установить несколько прав сразу
|
||||
public static function setPermissions(int $project_id, int $user_id, array $permissions, int $current_user_id): array {
|
||||
// Нельзя редактировать свои права
|
||||
if ($user_id === $current_user_id) {
|
||||
return ['success' => false, 'errors' => ['member' => 'Нельзя изменять свои права']];
|
||||
}
|
||||
|
||||
// Нельзя редактировать права владельца
|
||||
if (self::isOwner($project_id, $user_id)) {
|
||||
return ['success' => false, 'errors' => ['member' => 'Нельзя изменять права владельца проекта']];
|
||||
}
|
||||
|
||||
foreach (array_keys($permissions) as $perm) {
|
||||
if (!in_array($perm, self::PERMISSIONS)) {
|
||||
return ['success' => false, 'errors' => ['permission' => 'Неизвестное право: ' . $perm]];
|
||||
}
|
||||
}
|
||||
|
||||
$member = Database::get('project_members', ['permissions'], [
|
||||
'id_project' => $project_id,
|
||||
'id_user' => $user_id
|
||||
]);
|
||||
|
||||
if (!$member) {
|
||||
return ['success' => false, 'errors' => ['member' => 'Участник не найден']];
|
||||
}
|
||||
|
||||
$currentPerms = $member['permissions'] ? json_decode($member['permissions'], true) : [];
|
||||
$mergedPerms = array_merge($currentPerms, $permissions);
|
||||
|
||||
Database::update('project_members', [
|
||||
'permissions' => json_encode($mergedPerms)
|
||||
], [
|
||||
'id_project' => $project_id,
|
||||
'id_user' => $user_id
|
||||
]);
|
||||
|
||||
return ['success' => true, 'permissions' => $mergedPerms];
|
||||
}
|
||||
|
||||
// Получить права участника
|
||||
public static function getPermissions(int $project_id, int $user_id): ?array {
|
||||
// Владелец имеет все права
|
||||
if (self::isOwner($project_id, $user_id)) {
|
||||
$allPerms = [];
|
||||
foreach (self::PERMISSIONS as $perm) {
|
||||
$allPerms[$perm] = true;
|
||||
}
|
||||
return $allPerms;
|
||||
}
|
||||
|
||||
$member = Database::get('project_members', ['is_admin', 'permissions'], [
|
||||
'id_project' => $project_id,
|
||||
'id_user' => $user_id
|
||||
]);
|
||||
|
||||
if (!$member) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Админ имеет все права
|
||||
if ((int)$member['is_admin'] === 1) {
|
||||
$allPerms = [];
|
||||
foreach (self::PERMISSIONS as $perm) {
|
||||
$allPerms[$perm] = true;
|
||||
}
|
||||
return $allPerms;
|
||||
}
|
||||
|
||||
return $member['permissions'] ? json_decode($member['permissions'], true) : [];
|
||||
}
|
||||
|
||||
// Назначить/снять админа
|
||||
public static function setAdmin(int $project_id, int $user_id, bool $is_admin, int $current_user_id): array {
|
||||
// Нельзя редактировать свои права
|
||||
if ($user_id === $current_user_id) {
|
||||
return ['success' => false, 'errors' => ['member' => 'Нельзя изменять свои права']];
|
||||
}
|
||||
|
||||
// Нельзя изменять статус владельца
|
||||
if (self::isOwner($project_id, $user_id)) {
|
||||
return ['success' => false, 'errors' => ['member' => 'Нельзя изменять статус владельца проекта']];
|
||||
}
|
||||
|
||||
$member = Database::get('project_members', ['id'], [
|
||||
'id_project' => $project_id,
|
||||
'id_user' => $user_id
|
||||
]);
|
||||
|
||||
if (!$member) {
|
||||
return ['success' => false, 'errors' => ['member' => 'Участник не найден']];
|
||||
}
|
||||
|
||||
Database::update('project_members', [
|
||||
'is_admin' => $is_admin ? 1 : 0
|
||||
], [
|
||||
'id_project' => $project_id,
|
||||
'id_user' => $user_id
|
||||
]);
|
||||
|
||||
return ['success' => true];
|
||||
}
|
||||
|
||||
// Получить список всех доступных прав (для UI)
|
||||
public static function getAvailablePermissions(): array {
|
||||
return self::PERMISSIONS;
|
||||
}
|
||||
|
||||
// Получить дефолтные права (для UI)
|
||||
public static function getDefaultPermissions(): array {
|
||||
return self::DEFAULT_PERMISSIONS;
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
237
backend/app/class/enity/class_projectInvite.php
Normal file
237
backend/app/class/enity/class_projectInvite.php
Normal file
@@ -0,0 +1,237 @@
|
||||
<?php
|
||||
|
||||
class ProjectInvite {
|
||||
|
||||
const STATUS_PENDING = 'pending';
|
||||
const STATUS_ACCEPTED = 'accepted';
|
||||
const STATUS_DECLINED = 'declined';
|
||||
|
||||
// ==================== СОЗДАНИЕ ПРИГЛАШЕНИЯ ====================
|
||||
|
||||
/**
|
||||
* Создать приглашение в проект
|
||||
*/
|
||||
public static function create(int $project_id, int $to_user_id, int $from_user_id, bool $is_admin = false, ?array $permissions = null): array {
|
||||
// Нельзя пригласить себя
|
||||
if ($to_user_id === $from_user_id) {
|
||||
return ['success' => false, 'errors' => ['invite' => 'Нельзя пригласить себя в проект']];
|
||||
}
|
||||
|
||||
// Проверяем, что пользователь существует
|
||||
$user = Database::get('accounts', ['id'], ['id' => $to_user_id]);
|
||||
if (!$user) {
|
||||
return ['success' => false, 'errors' => ['invite' => 'Пользователь не найден']];
|
||||
}
|
||||
|
||||
// Проверяем, что пользователь ещё не участник проекта
|
||||
if (ProjectAccess::isMember($project_id, $to_user_id)) {
|
||||
return ['success' => false, 'errors' => ['invite' => 'Пользователь уже участник проекта']];
|
||||
}
|
||||
|
||||
// Проверяем, нет ли уже pending-приглашения
|
||||
if (self::hasPending($project_id, $to_user_id)) {
|
||||
return ['success' => false, 'errors' => ['invite' => 'Пользователь уже приглашён, ожидается ответ']];
|
||||
}
|
||||
|
||||
// Права по умолчанию
|
||||
$perms = $permissions ?? ProjectAccess::DEFAULT_PERMISSIONS;
|
||||
|
||||
// Сохраняем is_admin в permissions для передачи при accept
|
||||
$inviteData = [
|
||||
'is_admin' => $is_admin,
|
||||
'permissions' => $perms
|
||||
];
|
||||
|
||||
Database::insert('project_invites', [
|
||||
'id_project' => $project_id,
|
||||
'id_from_user' => $from_user_id,
|
||||
'id_to_user' => $to_user_id,
|
||||
'status' => self::STATUS_PENDING,
|
||||
'permissions' => json_encode($inviteData),
|
||||
'created_at' => date('Y-m-d H:i:s')
|
||||
]);
|
||||
|
||||
return ['success' => true, 'id' => Database::id()];
|
||||
}
|
||||
|
||||
// ==================== ПРОВЕРКИ ====================
|
||||
|
||||
/**
|
||||
* Есть ли pending-приглашение для пользователя в проект
|
||||
*/
|
||||
public static function hasPending(int $project_id, int $to_user_id): bool {
|
||||
return Database::has('project_invites', [
|
||||
'id_project' => $project_id,
|
||||
'id_to_user' => $to_user_id,
|
||||
'status' => self::STATUS_PENDING
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить количество pending-приглашений для пользователя
|
||||
*/
|
||||
public static function getPendingCount(int $user_id): int {
|
||||
return Database::count('project_invites', [
|
||||
'id_to_user' => $user_id,
|
||||
'status' => self::STATUS_PENDING
|
||||
]);
|
||||
}
|
||||
|
||||
// ==================== ПОЛУЧЕНИЕ ПРИГЛАШЕНИЙ ====================
|
||||
|
||||
/**
|
||||
* Получить все pending-приглашения для пользователя (входящие)
|
||||
*/
|
||||
public static function getMyPending(int $user_id): array {
|
||||
$invites = Database::select('project_invites', [
|
||||
'[>]project' => ['id_project' => 'id'],
|
||||
'[>]accounts' => ['id_from_user' => 'id']
|
||||
], [
|
||||
'project_invites.id',
|
||||
'project_invites.id_project',
|
||||
'project_invites.created_at',
|
||||
'project_invites.permissions',
|
||||
'project.name(project_name)',
|
||||
'accounts.name(from_user_name)',
|
||||
'accounts.username(from_user_username)',
|
||||
'accounts.avatar_url(from_user_avatar)'
|
||||
], [
|
||||
'project_invites.id_to_user' => $user_id,
|
||||
'project_invites.status' => self::STATUS_PENDING,
|
||||
'ORDER' => ['project_invites.created_at' => 'DESC']
|
||||
]);
|
||||
|
||||
return array_map(function($invite) {
|
||||
$permData = $invite['permissions'] ? json_decode($invite['permissions'], true) : [];
|
||||
return [
|
||||
'id' => (int)$invite['id'],
|
||||
'project_id' => (int)$invite['id_project'],
|
||||
'project_name' => $invite['project_name'],
|
||||
'from_user' => [
|
||||
'name' => $invite['from_user_name'],
|
||||
'username' => $invite['from_user_username'],
|
||||
'avatar_url' => $invite['from_user_avatar']
|
||||
],
|
||||
'is_admin' => $permData['is_admin'] ?? false,
|
||||
'created_at' => $invite['created_at']
|
||||
];
|
||||
}, $invites);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить pending-приглашения для проекта (для админа)
|
||||
*/
|
||||
public static function getProjectPending(int $project_id): array {
|
||||
$invites = Database::select('project_invites', [
|
||||
'[>]accounts' => ['id_to_user' => 'id']
|
||||
], [
|
||||
'project_invites.id',
|
||||
'project_invites.id_to_user',
|
||||
'project_invites.created_at',
|
||||
'project_invites.permissions',
|
||||
'accounts.name',
|
||||
'accounts.username',
|
||||
'accounts.avatar_url'
|
||||
], [
|
||||
'project_invites.id_project' => $project_id,
|
||||
'project_invites.status' => self::STATUS_PENDING,
|
||||
'ORDER' => ['project_invites.created_at' => 'DESC']
|
||||
]);
|
||||
|
||||
return array_map(function($invite) {
|
||||
$permData = $invite['permissions'] ? json_decode($invite['permissions'], true) : [];
|
||||
return [
|
||||
'id' => (int)$invite['id'],
|
||||
'id_user' => (int)$invite['id_to_user'],
|
||||
'name' => $invite['name'],
|
||||
'username' => $invite['username'],
|
||||
'avatar_url' => $invite['avatar_url'],
|
||||
'is_admin' => $permData['is_admin'] ?? false,
|
||||
'created_at' => $invite['created_at'],
|
||||
'status' => 'pending'
|
||||
];
|
||||
}, $invites);
|
||||
}
|
||||
|
||||
// ==================== ОТВЕТ НА ПРИГЛАШЕНИЕ ====================
|
||||
|
||||
/**
|
||||
* Принять приглашение
|
||||
*/
|
||||
public static function accept(int $invite_id, int $user_id): array {
|
||||
$invite = Database::get('project_invites', '*', [
|
||||
'id' => $invite_id,
|
||||
'id_to_user' => $user_id,
|
||||
'status' => self::STATUS_PENDING
|
||||
]);
|
||||
|
||||
if (!$invite) {
|
||||
return ['success' => false, 'errors' => ['invite' => 'Приглашение не найдено или уже обработано']];
|
||||
}
|
||||
|
||||
$project_id = (int)$invite['id_project'];
|
||||
$permData = $invite['permissions'] ? json_decode($invite['permissions'], true) : [];
|
||||
$is_admin = $permData['is_admin'] ?? false;
|
||||
$permissions = $permData['permissions'] ?? ProjectAccess::DEFAULT_PERMISSIONS;
|
||||
|
||||
// Добавляем в project_members
|
||||
Database::insert('project_members', [
|
||||
'id_project' => $project_id,
|
||||
'id_user' => $user_id,
|
||||
'is_admin' => $is_admin ? 1 : 0,
|
||||
'permissions' => json_encode($permissions),
|
||||
'created_at' => date('Y-m-d H:i:s')
|
||||
]);
|
||||
|
||||
// Обновляем статус приглашения
|
||||
Database::update('project_invites', [
|
||||
'status' => self::STATUS_ACCEPTED,
|
||||
'responded_at' => date('Y-m-d H:i:s')
|
||||
], ['id' => $invite_id]);
|
||||
|
||||
return ['success' => true, 'project_id' => $project_id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Отклонить приглашение
|
||||
*/
|
||||
public static function decline(int $invite_id, int $user_id): array {
|
||||
$invite = Database::get('project_invites', ['id'], [
|
||||
'id' => $invite_id,
|
||||
'id_to_user' => $user_id,
|
||||
'status' => self::STATUS_PENDING
|
||||
]);
|
||||
|
||||
if (!$invite) {
|
||||
return ['success' => false, 'errors' => ['invite' => 'Приглашение не найдено или уже обработано']];
|
||||
}
|
||||
|
||||
Database::update('project_invites', [
|
||||
'status' => self::STATUS_DECLINED,
|
||||
'responded_at' => date('Y-m-d H:i:s')
|
||||
], ['id' => $invite_id]);
|
||||
|
||||
return ['success' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* Отменить приглашение (для админа)
|
||||
*/
|
||||
public static function cancel(int $invite_id, int $project_id): array {
|
||||
$invite = Database::get('project_invites', ['id'], [
|
||||
'id' => $invite_id,
|
||||
'id_project' => $project_id,
|
||||
'status' => self::STATUS_PENDING
|
||||
]);
|
||||
|
||||
if (!$invite) {
|
||||
return ['success' => false, 'errors' => ['invite' => 'Приглашение не найдено']];
|
||||
}
|
||||
|
||||
Database::delete('project_invites', ['id' => $invite_id]);
|
||||
|
||||
return ['success' => true];
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
@@ -14,6 +14,7 @@ class Task extends BaseEntity {
|
||||
public $date;
|
||||
public $date_closed;
|
||||
public $id_account;
|
||||
public $create_id_account; // ID создателя задачи
|
||||
public $title;
|
||||
public $descript;
|
||||
public $descript_full;
|
||||
@@ -69,6 +70,7 @@ class Task extends BaseEntity {
|
||||
'column_id' => $this->column_id,
|
||||
'date' => $this->date ?: null,
|
||||
'id_account' => $this->id_account,
|
||||
'create_id_account' => $this->create_id_account,
|
||||
'title' => $this->title,
|
||||
'descript' => $this->descript ?: null,
|
||||
'descript_full' => $this->descript_full ?: null,
|
||||
@@ -255,6 +257,7 @@ class Task extends BaseEntity {
|
||||
'id_department',
|
||||
'id_label',
|
||||
'id_account',
|
||||
'create_id_account',
|
||||
'order',
|
||||
'column_id',
|
||||
'date',
|
||||
|
||||
@@ -16,27 +16,69 @@ class Account extends BaseEntity {
|
||||
|
||||
// Валидация данных при создании аккаунта
|
||||
protected function validate() {
|
||||
$this->error_message = [];
|
||||
static::$error_message = [];
|
||||
|
||||
// === ИМЯ ===
|
||||
if (!$this->name) {
|
||||
$this->addError('name', 'Имя не может быть пустым');
|
||||
} elseif (mb_strlen($this->name) < 2) {
|
||||
$this->addError('name', 'Имя слишком короткое');
|
||||
} elseif (mb_strlen($this->name) > 50) {
|
||||
$this->addError('name', 'Имя слишком длинное');
|
||||
} elseif (!preg_match('/^[\p{L}\s\-]+$/u', $this->name)) {
|
||||
// Только буквы (любого языка), пробелы и дефис
|
||||
$this->addError('name', 'Имя может содержать только буквы');
|
||||
}
|
||||
|
||||
// === ЛОГИН ===
|
||||
if (!$this->username) {
|
||||
$this->addError('username', 'Логин не может быть пустым');
|
||||
} elseif (strlen($this->username) < 3) {
|
||||
$this->addError('username', 'Логин минимум 3 символа');
|
||||
} elseif (strlen($this->username) > 32) {
|
||||
$this->addError('username', 'Логин максимум 32 символа');
|
||||
} elseif (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $this->username)) {
|
||||
// Начинается с буквы, далее a-z, 0-9, _
|
||||
$this->addError('username', 'Логин: латиница, цифры и _ (начинается с буквы)');
|
||||
} elseif (Database::get($this->db_name, ['id'], ['username' => $this->username])) {
|
||||
$this->addError('username', 'Этот логин уже занят');
|
||||
}
|
||||
if ($this->username && Database::get($this->db_name, ['id'], ['username' => $this->username])) {
|
||||
$this->addError('username', 'Пользователь с таким логином уже существует');
|
||||
}
|
||||
|
||||
// === ПАРОЛЬ ===
|
||||
if (!$this->password) {
|
||||
$this->addError('password', 'Пароль не может быть пустым');
|
||||
} elseif (strlen($this->password) < 6) {
|
||||
$this->addError('password', 'Пароль минимум 6 символов');
|
||||
} elseif (strlen($this->password) > 100) {
|
||||
$this->addError('password', 'Пароль слишком длинный');
|
||||
}
|
||||
|
||||
return $this->getErrors();
|
||||
}
|
||||
|
||||
// Очистка данных перед сохранением
|
||||
protected function sanitize() {
|
||||
// Имя: убираем лишние пробелы
|
||||
$this->name = trim(preg_replace('/\s+/', ' ', $this->name));
|
||||
|
||||
// Логин: только допустимые символы (регистр сохраняется)
|
||||
$this->username = preg_replace('/[^a-zA-Z0-9_]/', '', $this->username);
|
||||
|
||||
// Telegram: убираем @, только допустимые символы
|
||||
if ($this->telegram) {
|
||||
$this->telegram = str_replace('@', '', $this->telegram);
|
||||
$this->telegram = preg_replace('/[^a-zA-Z0-9_]/', '', $this->telegram);
|
||||
$this->telegram = strtolower($this->telegram);
|
||||
$this->telegram = $this->telegram ?: null;
|
||||
}
|
||||
}
|
||||
|
||||
// Создание нового аккаунта
|
||||
public function create() {
|
||||
|
||||
// Очищаем данные
|
||||
$this->sanitize();
|
||||
|
||||
// Валидация данных
|
||||
if ($errors = $this->validate()) {
|
||||
return $errors;
|
||||
@@ -133,6 +175,7 @@ class Account extends BaseEntity {
|
||||
|
||||
// Получаем данные пользователя
|
||||
$user = Database::get($this->db_name, [
|
||||
'id',
|
||||
'id_department',
|
||||
'name',
|
||||
'username',
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
require_once __DIR__ . '/class/enity/class_base.php';
|
||||
require_once __DIR__ . '/class/enity/class_fileUpload.php';
|
||||
require_once __DIR__ . '/class/enity/class_user.php';
|
||||
require_once __DIR__ . '/class/enity/class_projectAccess.php';
|
||||
require_once __DIR__ . '/class/enity/class_projectInvite.php';
|
||||
require_once __DIR__ . '/class/enity/class_project.php';
|
||||
require_once __DIR__ . '/class/enity/class_task.php';
|
||||
require_once __DIR__ . '/class/enity/class_comment.php';
|
||||
@@ -35,10 +37,12 @@
|
||||
'/api/user' => __DIR__ . '/../api/user.php',
|
||||
'/api/task' => __DIR__ . '/../api/task.php',
|
||||
'/api/project' => __DIR__ . '/../api/project.php',
|
||||
'/api/projectAccess' => __DIR__ . '/../api/projectAccess.php',
|
||||
'/api/projectInvite' => __DIR__ . '/../api/projectInvite.php',
|
||||
'/api/comment' => __DIR__ . '/../api/comment.php',
|
||||
'/api/server' => __DIR__ . '/../api/server.php',
|
||||
];
|
||||
|
||||
$publicActions = ['auth_login', 'check_session', 'get_settings'];
|
||||
$publicActions = ['auth_login', 'check_session', 'create_user', 'get_settings'];
|
||||
|
||||
?>
|
||||
@@ -2,10 +2,17 @@
|
||||
window.APP_CONFIG = {
|
||||
API_BASE: 'http://192.168.1.6',
|
||||
|
||||
// Интервал автообновления данных (в секундах)
|
||||
IDLE_REFRESH_SECONDS: 1,
|
||||
// Интервалы автообновления данных (в секундах)
|
||||
REFRESH_INTERVALS: {
|
||||
cards: 2, // Карточки на доске
|
||||
comments: 5, // Комментарии к задаче
|
||||
invites: 10 // Приглашения на странице без проектов
|
||||
},
|
||||
|
||||
// Брейкпоинт для мобильной версии (px)
|
||||
MOBILE_BREAKPOINT: 1400
|
||||
MOBILE_BREAKPOINT: 1400,
|
||||
|
||||
// Автообновление страницы (F5) — интервал в секундах, 0 = отключено
|
||||
AUTO_REFRESH_SECONDS: 500
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 8.0 KiB |
@@ -2,11 +2,14 @@
|
||||
<div class="pwa-safe-wrapper">
|
||||
<router-view />
|
||||
</div>
|
||||
<!-- Глобальные toast-уведомления -->
|
||||
<ToastContainer />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { initMobileClass } from './composables/useMobile'
|
||||
import ToastContainer from './components/ui/ToastContainer.vue'
|
||||
|
||||
let cleanup = null
|
||||
|
||||
@@ -27,6 +30,17 @@ onUnmounted(() => {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Отключаем 300ms задержку на тапах для всех кликабельных элементов */
|
||||
button,
|
||||
a,
|
||||
[role="button"],
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
label {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/* CSS переменные (цветовая палитра) */
|
||||
:root {
|
||||
--bg-body: #111113;
|
||||
|
||||
@@ -11,12 +11,13 @@ export const getFullUrl = (url) => {
|
||||
}
|
||||
|
||||
// Базовая функция запроса с глобальной проверкой сессии
|
||||
const request = async (endpoint, options = {}) => {
|
||||
const request = async (endpoint, options = {}, skipSessionCheck = false) => {
|
||||
const res = await fetch(`${API_BASE}${endpoint}`, options)
|
||||
const data = await res.json()
|
||||
|
||||
// Глобальная проверка: если сессия истекла — редирект на логин
|
||||
if (data.success === false && data.errors?.session) {
|
||||
// Пропускаем для эндпоинтов авторизации/регистрации
|
||||
if (!skipSessionCheck && data.success === false && data.errors?.session) {
|
||||
// Очищаем кэш авторизации (через window чтобы избежать циклической зависимости)
|
||||
if (window.__clearAuthCache) window.__clearAuthCache()
|
||||
// Редирект на логин (если ещё не там)
|
||||
@@ -35,19 +36,25 @@ export const authApi = {
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'auth_login', username, password })
|
||||
}),
|
||||
}, true), // skipSessionCheck — это авторизация
|
||||
register: ({ name, username, password, telegram }) => request('/api/user', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'create_user', name, username, password, telegram })
|
||||
}, true), // skipSessionCheck — это регистрация
|
||||
check: () => request('/api/user', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'check_session' })
|
||||
}),
|
||||
}, true), // skipSessionCheck — это проверка сессии
|
||||
logout: () => request('/api/user', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'logout' })
|
||||
})
|
||||
}, true) // skipSessionCheck — это выход
|
||||
}
|
||||
|
||||
// ==================== PROJECTS ====================
|
||||
@@ -215,7 +222,105 @@ export const commentImageApi = {
|
||||
|
||||
// ==================== USERS ====================
|
||||
export const usersApi = {
|
||||
getAll: () => request('/api/user', { credentials: 'include' })
|
||||
// Получить участников проекта (id_project обязателен)
|
||||
getAll: (id_project) => request(`/api/user?id_project=${id_project}`, { credentials: 'include' }),
|
||||
|
||||
// Поиск пользователя по логину
|
||||
search: (username) => request('/api/user', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'search', username })
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== PROJECT INVITES ====================
|
||||
export const projectInviteApi = {
|
||||
// Получить свои pending-приглашения
|
||||
getMyPending: () => request('/api/projectInvite', { credentials: 'include' }),
|
||||
|
||||
// Получить количество pending-приглашений
|
||||
getCount: () => request('/api/projectInvite', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'get_count' })
|
||||
}),
|
||||
|
||||
// Отправить приглашение
|
||||
send: (project_id, user_id, is_admin = false, permissions = null) => request('/api/projectInvite', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'send', project_id, user_id, is_admin, permissions })
|
||||
}),
|
||||
|
||||
// Проверить, есть ли pending-приглашение
|
||||
checkPending: (project_id, user_id) => request('/api/projectInvite', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'check_pending', project_id, user_id })
|
||||
}),
|
||||
|
||||
// Получить pending-приглашения проекта (для админа)
|
||||
getProjectPending: (project_id) => request('/api/projectInvite', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'get_project_pending', project_id })
|
||||
}),
|
||||
|
||||
// Принять приглашение
|
||||
accept: (invite_id) => request('/api/projectInvite', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'accept', invite_id })
|
||||
}),
|
||||
|
||||
// Отклонить приглашение
|
||||
decline: (invite_id) => request('/api/projectInvite', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'decline', invite_id })
|
||||
}),
|
||||
|
||||
// Отменить приглашение (для админа)
|
||||
cancel: (invite_id, project_id) => request('/api/projectInvite', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'cancel', invite_id, project_id })
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== PROJECT ACCESS ====================
|
||||
export const projectAccessApi = {
|
||||
// Добавление участника напрямую (используется после принятия инвайта — внутренний метод)
|
||||
addMember: (project_id, user_id, is_admin = false, permissions = null) => request('/api/projectAccess', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'add_member', project_id, user_id, is_admin, permissions })
|
||||
}),
|
||||
|
||||
// Обновление прав участника
|
||||
updateMember: (project_id, user_id, { is_admin, permissions } = {}) => request('/api/projectAccess', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'update_member', project_id, user_id, is_admin, permissions })
|
||||
}),
|
||||
|
||||
// Удаление участника из проекта
|
||||
removeMember: (project_id, user_id) => request('/api/projectAccess', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'remove_member', project_id, user_id })
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== SERVER ====================
|
||||
|
||||
@@ -126,7 +126,7 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['stats-updated', 'open-task', 'create-task'])
|
||||
const emit = defineEmits(['stats-updated', 'open-task', 'create-task', 'cards-moved'])
|
||||
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
@@ -140,8 +140,18 @@ onUpdated(refreshIcons)
|
||||
// Локальная копия карточек для optimistic UI
|
||||
const localCards = ref([])
|
||||
|
||||
// Флаг для предотвращения race condition при drag-and-drop
|
||||
// Пока карточка перемещается, не перезаписываем localCards данными из props
|
||||
const isMovingCard = ref(false)
|
||||
|
||||
// Синхронизируем с props при загрузке/смене проекта
|
||||
watch(() => props.cards, (newCards) => {
|
||||
// Если идёт перемещение карточки, не перезаписываем локальное состояние
|
||||
// чтобы избежать "прыжка" из-за stale данных от polling
|
||||
if (isMovingCard.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// Копируем данные и добавляем order если нет
|
||||
localCards.value = JSON.parse(JSON.stringify(newCards)).map((card, idx) => ({
|
||||
...card,
|
||||
@@ -219,6 +229,9 @@ const handleDropCard = async ({ cardId, fromColumnId, toColumnId, toIndex }) =>
|
||||
const card = localCards.value.find(c => c.id === cardId)
|
||||
if (!card) return
|
||||
|
||||
// Устанавливаем флаг чтобы watcher не перезаписывал localCards
|
||||
isMovingCard.value = true
|
||||
|
||||
// Локально обновляем для мгновенного отклика
|
||||
card.column_id = toColumnId
|
||||
|
||||
@@ -240,8 +253,16 @@ const handleDropCard = async ({ cardId, fromColumnId, toColumnId, toIndex }) =>
|
||||
c.order = idx
|
||||
})
|
||||
|
||||
// Отправляем на сервер (сервер сам пересчитает order для всех)
|
||||
await cardsApi.updateOrder(cardId, toColumnId, toIndex)
|
||||
try {
|
||||
// Отправляем на сервер (сервер сам пересчитает order для всех)
|
||||
await cardsApi.updateOrder(cardId, toColumnId, toIndex)
|
||||
|
||||
// После успешного обновления, сообщаем родителю чтобы он обновил данные
|
||||
emit('cards-moved')
|
||||
} finally {
|
||||
// Сбрасываем флаг после завершения операции
|
||||
isMovingCard.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Генератор id для новых карточек
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
class="card"
|
||||
:draggable="!isMobile"
|
||||
:draggable="!isMobile && canMove"
|
||||
@dragstart="handleDragStart"
|
||||
@dragend="handleDragEnd"
|
||||
@touchstart="handleTouchStart"
|
||||
@@ -11,7 +11,7 @@
|
||||
@mouseup="handleMouseUp"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@contextmenu="handleContextMenu"
|
||||
:class="{ dragging: isDragging, 'has-label-color': cardLabelColor, 'long-pressing': isLongPressing }"
|
||||
:class="{ dragging: isDragging, 'has-label-color': cardLabelColor, 'long-pressing': isLongPressing, 'no-move': !canMove }"
|
||||
:style="cardLabelColor ? { '--label-bg': cardLabelColor } : {}"
|
||||
>
|
||||
<div class="card-header">
|
||||
@@ -24,6 +24,23 @@
|
||||
>
|
||||
{{ cardDepartment.name_departments }}
|
||||
</span>
|
||||
<!-- Индикаторы ограничений прав -->
|
||||
<div v-if="!canEdit || !canMove" class="permissions-indicators">
|
||||
<span
|
||||
v-if="!canEdit"
|
||||
class="perm-icon no-edit"
|
||||
title="Нет прав на редактирование"
|
||||
>
|
||||
<i data-lucide="pencil-off"></i>
|
||||
</span>
|
||||
<span
|
||||
v-if="!canMove"
|
||||
class="perm-icon no-move-icon"
|
||||
title="Нет прав на перемещение"
|
||||
>
|
||||
<i data-lucide="lock"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button
|
||||
@@ -91,7 +108,15 @@ useLucideIcons()
|
||||
const props = defineProps({
|
||||
card: Object,
|
||||
columnId: [String, Number],
|
||||
index: Number
|
||||
index: Number,
|
||||
canMove: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
canEdit: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['archive', 'move-request'])
|
||||
@@ -102,7 +127,7 @@ let longPressTimer = null
|
||||
let touchStartPos = { x: 0, y: 0 }
|
||||
|
||||
const handleTouchStart = (e) => {
|
||||
if (!isMobile.value) return
|
||||
if (!isMobile.value || !props.canMove) return
|
||||
|
||||
const touch = e.touches[0]
|
||||
touchStartPos = { x: touch.clientX, y: touch.clientY }
|
||||
@@ -154,7 +179,7 @@ const handleContextMenu = (e) => {
|
||||
|
||||
// Mouse long-press для ПК в мобильном режиме (когда touch события недоступны)
|
||||
const handleMouseDown = (e) => {
|
||||
if (!isMobile.value) return
|
||||
if (!isMobile.value || !props.canMove) return
|
||||
// Только левая кнопка мыши
|
||||
if (e.button !== 0) return
|
||||
|
||||
@@ -189,6 +214,10 @@ const handleMouseLeave = () => {
|
||||
const isDragging = ref(false)
|
||||
|
||||
const handleDragStart = (e) => {
|
||||
if (!props.canMove) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
isDragging.value = true
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('cardId', props.card.id.toString())
|
||||
@@ -248,6 +277,10 @@ const handleArchive = () => {
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
.card.no-move {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
background: var(--bg-card-hover);
|
||||
}
|
||||
@@ -424,4 +457,34 @@ const handleArchive = () => {
|
||||
.btn-archive-card.always-visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* ========== ИНДИКАТОРЫ ПРАВ ========== */
|
||||
.permissions-indicators {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.perm-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.perm-icon i,
|
||||
.perm-icon svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.perm-icon.no-edit {
|
||||
color: var(--orange);
|
||||
}
|
||||
|
||||
.perm-icon.no-move-icon {
|
||||
color: var(--red);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
:card="card"
|
||||
:column-id="column.id"
|
||||
:index="index"
|
||||
:can-move="store.canMoveTask(card)"
|
||||
:can-edit="store.canEditTask(card)"
|
||||
@click="emit('open-task', card)"
|
||||
@archive="emit('archive-task', $event)"
|
||||
@move-request="emit('move-request', $event)"
|
||||
@@ -44,8 +46,10 @@
|
||||
import { ref, onMounted, onUpdated } from 'vue'
|
||||
import Card from './Card.vue'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
import { useProjectsStore } from '../stores/projects'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
const store = useProjectsStore()
|
||||
|
||||
const props = defineProps({
|
||||
column: Object
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="datepicker" ref="datepickerRef">
|
||||
<div class="datepicker-trigger" :class="{ mobile: isMobile }" @click="toggleCalendar">
|
||||
<div class="datepicker" :class="{ disabled: disabled }" ref="datepickerRef">
|
||||
<div class="datepicker-trigger" :class="{ mobile: isMobile, disabled: disabled }" @click="!disabled && toggleCalendar()">
|
||||
<i data-lucide="calendar"></i>
|
||||
<span v-if="modelValue" class="date-text">{{ formatCompactDate(modelValue) }}</span>
|
||||
<span v-else class="placeholder">Выберите дату</span>
|
||||
<button v-if="modelValue" class="clear-btn" @click.stop="clearDate">
|
||||
<button v-if="modelValue && !disabled" class="clear-btn" @click.stop="clearDate">
|
||||
<i data-lucide="x"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -102,7 +102,11 @@ import { useMobile } from '../composables/useMobile'
|
||||
const { isMobile } = useMobile()
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: String
|
||||
modelValue: String,
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
@@ -336,6 +340,15 @@ watch(isOpen, () => {
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.datepicker-trigger.disabled {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.datepicker-trigger.disabled:hover {
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.datepicker-trigger i {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
|
||||
@@ -2,18 +2,17 @@
|
||||
<header class="header" :class="{ mobile: isMobile }">
|
||||
<!-- Десктоп версия -->
|
||||
<template v-if="!isMobile">
|
||||
<div class="header-left">
|
||||
<div v-if="title || $slots.filters" class="header-left">
|
||||
<div class="title-row">
|
||||
<h1 class="page-title">{{ title }}</h1>
|
||||
<h1 v-if="title" class="page-title">{{ title }}</h1>
|
||||
<slot name="filters"></slot>
|
||||
</div>
|
||||
<p v-if="subtitle" class="page-subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<slot name="actions"></slot>
|
||||
<slot name="stats"></slot>
|
||||
<button class="logout-btn" @click="logout" title="Выйти">
|
||||
<i data-lucide="log-out"></i>
|
||||
</button>
|
||||
<LogoutButton />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -21,11 +20,10 @@
|
||||
<template v-else>
|
||||
<!-- Компактная строка: заголовок слева + иконки-кнопки -->
|
||||
<div class="mobile-header-row">
|
||||
<h1 v-if="!$slots['mobile-filters']" class="mobile-title">{{ title }}</h1>
|
||||
<h1 v-if="title && !$slots['mobile-filters']" class="mobile-title">{{ title }}</h1>
|
||||
<slot name="mobile-filters"></slot>
|
||||
<button class="mobile-logout-btn" @click="logout" title="Выйти">
|
||||
<i data-lucide="log-out"></i>
|
||||
</button>
|
||||
<slot name="actions"></slot>
|
||||
<LogoutButton :mobile="true" />
|
||||
</div>
|
||||
</template>
|
||||
</header>
|
||||
@@ -33,10 +31,8 @@
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUpdated } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { authApi } from '../api'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
import { clearAuthCache } from '../router'
|
||||
import LogoutButton from './ui/LogoutButton.vue'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
|
||||
@@ -51,14 +47,6 @@ defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const logout = async () => {
|
||||
clearAuthCache()
|
||||
await authApi.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
@@ -116,31 +104,6 @@ onUpdated(refreshIcons)
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.logout-btn i {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* ========== MOBILE ========== */
|
||||
.header.mobile {
|
||||
padding: 10px 16px;
|
||||
@@ -162,31 +125,4 @@ onUpdated(refreshIcons)
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mobile-logout-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 10px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.mobile-logout-btn:hover,
|
||||
.mobile-logout-btn:active {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.mobile-logout-btn i {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
</style>
|
||||
|
||||
955
front_vue/src/components/MemberPanel.vue
Normal file
955
front_vue/src/components/MemberPanel.vue
Normal file
@@ -0,0 +1,955 @@
|
||||
<template>
|
||||
<SlidePanel
|
||||
:show="show"
|
||||
@close="handleClose"
|
||||
>
|
||||
<template #header>
|
||||
<h2>{{ isEdit ? 'Редактирование участника' : 'Пригласить участника' }}</h2>
|
||||
</template>
|
||||
|
||||
<template #default>
|
||||
<!-- Поиск пользователя (только для нового) -->
|
||||
<div v-if="!isEdit" class="form-section">
|
||||
<label class="form-label">Логин пользователя</label>
|
||||
<TextInput
|
||||
v-model="form.username"
|
||||
placeholder="Введите логин для поиска"
|
||||
/>
|
||||
<p v-if="searching" class="form-hint">Поиск...</p>
|
||||
<p v-else-if="searchError" class="form-error">{{ searchError }}</p>
|
||||
<div v-else-if="foundUser" class="found-user" :class="{ 'is-me': isFoundUserMe, 'is-member': isFoundUserAlreadyMember, 'is-pending': isFoundUserPendingInvite }">
|
||||
<div class="found-user-avatar">
|
||||
<img v-if="foundUser.avatar_url" :src="getFullUrl(foundUser.avatar_url)" :alt="foundUser.name || foundUser.username">
|
||||
<span v-else class="avatar-placeholder">{{ (foundUser.name || foundUser.username || '?')[0] }}</span>
|
||||
</div>
|
||||
<div class="found-user-info">
|
||||
<span v-if="isFoundUserMe" class="found-user-me">Это же вы...</span>
|
||||
<span v-else-if="isFoundUserAlreadyMember" class="found-user-member">Участник уже в этом проекте</span>
|
||||
<span v-else-if="isFoundUserPendingInvite" class="found-user-pending">Пользователь уже приглашён, ожидается ответ</span>
|
||||
<template v-else>
|
||||
<span class="found-user-name">{{ foundUser.name || foundUser.username }}</span>
|
||||
<span v-if="foundUser.name" class="found-user-username">@{{ foundUser.username }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<i v-if="!isFoundUserMe && !isFoundUserAlreadyMember && !isFoundUserPendingInvite" data-lucide="check-circle" class="found-user-check"></i>
|
||||
<i v-else-if="isFoundUserPendingInvite" data-lucide="clock" class="found-user-pending-icon"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Инфо о пользователе (для редактирования) -->
|
||||
<div v-else class="member-info">
|
||||
<div class="member-avatar">
|
||||
<img v-if="member?.avatar_url" :src="getFullUrl(member.avatar_url)" :alt="member.name || member.username">
|
||||
<span v-else class="avatar-placeholder">{{ (member?.name || member?.username || '?')[0] }}</span>
|
||||
</div>
|
||||
<div class="member-details">
|
||||
<h3>{{ member?.name || member?.username || 'Без имени' }}</h3>
|
||||
<span v-if="member?.is_owner" class="member-badge owner">Создатель</span>
|
||||
<span v-else-if="member?.is_admin" class="member-badge admin">Администратор</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Админ чекбокс (не для владельцев) -->
|
||||
<div v-if="!member?.is_owner" class="form-section">
|
||||
<label class="admin-toggle" :class="{ active: form.is_admin }">
|
||||
<div class="admin-toggle-info">
|
||||
<i data-lucide="shield"></i>
|
||||
<div class="admin-toggle-text">
|
||||
<span class="admin-toggle-label">Администратор</span>
|
||||
<span class="admin-toggle-hint">Полный доступ ко всем функциям проекта</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toggle-switch large" :class="{ on: form.is_admin }">
|
||||
<input type="checkbox" v-model="form.is_admin" @click.stop />
|
||||
<span class="toggle-slider"></span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Права (скрыты для админов) -->
|
||||
<div v-if="!form.is_admin && !member?.is_owner" class="form-section permissions-section">
|
||||
<label class="form-label">Права доступа</label>
|
||||
|
||||
<div v-for="group in permissionsGroups" :key="group.title" class="permission-group">
|
||||
<div class="permission-group-title">{{ group.title }}</div>
|
||||
<div class="permission-group-list">
|
||||
<label
|
||||
v-for="perm in group.permissions"
|
||||
:key="perm.key"
|
||||
v-show="!isPermissionHidden(perm)"
|
||||
class="permission-row"
|
||||
:class="{
|
||||
active: form.permissions[perm.key],
|
||||
sub: perm.hiddenBy
|
||||
}"
|
||||
>
|
||||
<i v-if="perm.hiddenBy" data-lucide="corner-down-right" class="permission-branch"></i>
|
||||
<span class="permission-name">{{ perm.label }}</span>
|
||||
<div class="toggle-switch" :class="{ on: form.permissions[perm.key] }">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="form.permissions[perm.key]"
|
||||
@click.stop
|
||||
/>
|
||||
<span class="toggle-slider"></span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<!-- Кнопка удаления (только для редактирования, не для владельцев) -->
|
||||
<button
|
||||
v-if="isEdit && !member?.is_owner"
|
||||
class="btn-delete"
|
||||
@click="confirmRemove"
|
||||
:disabled="saving"
|
||||
>
|
||||
<i data-lucide="trash-2"></i>
|
||||
<span class="btn-delete-text">Удалить из проекта</span>
|
||||
</button>
|
||||
<span v-else></span>
|
||||
|
||||
<ActionButtons
|
||||
:cancelText="'Отмена'"
|
||||
:saveText="isEdit ? 'Сохранить' : 'Пригласить'"
|
||||
:saveIcon="isEdit ? '' : 'send'"
|
||||
:loading="saving"
|
||||
:disabled="!canSave"
|
||||
@cancel="handleClose"
|
||||
@save="handleSave"
|
||||
/>
|
||||
</template>
|
||||
</SlidePanel>
|
||||
|
||||
<!-- Диалог подтверждения удаления -->
|
||||
<ConfirmDialog
|
||||
:show="showRemoveDialog"
|
||||
type="removeMember"
|
||||
:message="removeDialogMessage"
|
||||
:action="doRemoveMember"
|
||||
@confirm="showRemoveDialog = false"
|
||||
@cancel="showRemoveDialog = false"
|
||||
/>
|
||||
|
||||
<!-- Диалог несохранённых изменений -->
|
||||
<ConfirmDialog
|
||||
:show="showUnsavedDialog"
|
||||
type="unsavedChanges"
|
||||
@confirm="saveAndClose"
|
||||
@cancel="showUnsavedDialog = false"
|
||||
@discard="discardAndClose"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUpdated } from 'vue'
|
||||
import SlidePanel from './ui/SlidePanel.vue'
|
||||
import TextInput from './ui/TextInput.vue'
|
||||
import ActionButtons from './ui/ActionButtons.vue'
|
||||
import ConfirmDialog from './ConfirmDialog.vue'
|
||||
import { getFullUrl, usersApi, projectAccessApi, projectInviteApi } from '../api'
|
||||
import { useProjectsStore } from '../stores/projects'
|
||||
import { useToast } from '../composables/useToast'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
member: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'saved'])
|
||||
|
||||
const store = useProjectsStore()
|
||||
|
||||
// Режим редактирования
|
||||
const isEdit = computed(() => !!props.member)
|
||||
|
||||
// Форма
|
||||
const form = ref({
|
||||
username: '',
|
||||
is_admin: false,
|
||||
permissions: {}
|
||||
})
|
||||
|
||||
// Состояния
|
||||
const saving = ref(false)
|
||||
const searching = ref(false)
|
||||
const searchError = ref('')
|
||||
const foundUser = ref(null)
|
||||
let searchTimeout = null
|
||||
|
||||
// Найденный пользователь — это я?
|
||||
const isFoundUserMe = computed(() =>
|
||||
foundUser.value && Number(foundUser.value.id) === store.currentUserId
|
||||
)
|
||||
|
||||
// Найденный пользователь уже участник проекта?
|
||||
const isFoundUserAlreadyMember = computed(() => {
|
||||
if (!foundUser.value) return false
|
||||
const userId = Number(foundUser.value.id)
|
||||
return store.users.some(u => Number(u.id_user) === userId)
|
||||
})
|
||||
|
||||
// Найденный пользователь уже приглашён (pending)?
|
||||
const isFoundUserPendingInvite = ref(false)
|
||||
|
||||
// Поиск пользователя с debounce
|
||||
const searchUser = async (username) => {
|
||||
if (!username || username.length < 2) {
|
||||
foundUser.value = null
|
||||
searchError.value = ''
|
||||
isFoundUserPendingInvite.value = false
|
||||
return
|
||||
}
|
||||
|
||||
searching.value = true
|
||||
searchError.value = ''
|
||||
isFoundUserPendingInvite.value = false
|
||||
|
||||
try {
|
||||
const result = await usersApi.search(username)
|
||||
if (result.success) {
|
||||
foundUser.value = result.data
|
||||
searchError.value = ''
|
||||
|
||||
// Проверяем, нет ли уже pending-приглашения
|
||||
const userId = Number(result.data.id)
|
||||
if (userId !== store.currentUserId && !store.users.some(u => Number(u.id_user) === userId)) {
|
||||
const pendingCheck = await projectInviteApi.checkPending(store.currentProjectId, userId)
|
||||
isFoundUserPendingInvite.value = pendingCheck.success && pendingCheck.has_pending
|
||||
}
|
||||
} else {
|
||||
foundUser.value = null
|
||||
searchError.value = result.errors?.username || 'Пользователь не найден'
|
||||
}
|
||||
} catch (error) {
|
||||
foundUser.value = null
|
||||
searchError.value = 'Ошибка поиска'
|
||||
} finally {
|
||||
searching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Debounced поиск при вводе
|
||||
watch(() => form.value.username, (newVal) => {
|
||||
if (searchTimeout) clearTimeout(searchTimeout)
|
||||
foundUser.value = null
|
||||
searchError.value = ''
|
||||
isFoundUserPendingInvite.value = false
|
||||
|
||||
if (newVal && newVal.length >= 2) {
|
||||
searchTimeout = setTimeout(() => searchUser(newVal), 400)
|
||||
}
|
||||
})
|
||||
|
||||
// Группы прав с зависимостями
|
||||
// hiddenBy: если указанное право активно — это право скрывается и автоматически включается
|
||||
// Порядок: включённые по умолчанию первыми
|
||||
const permissionsGroups = [
|
||||
{
|
||||
title: 'Задачи',
|
||||
permissions: [
|
||||
{ key: 'create_task', label: 'Создание задач' },
|
||||
{ key: 'archive_task', label: 'Архивирование задач' },
|
||||
{ key: 'edit_task', label: 'Редактирование любых задач' },
|
||||
{ key: 'edit_own_task_only', label: 'Только назначенные на себя', hiddenBy: 'edit_task' },
|
||||
{ key: 'move_task', label: 'Перемещение любых задач' },
|
||||
{ key: 'move_own_task_only', label: 'Только назначенные на себя', hiddenBy: 'move_task' },
|
||||
{ key: 'delete_task', label: 'Удаление задач' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Комментарии',
|
||||
permissions: [
|
||||
{ key: 'create_comment', label: 'Создание в любых задачах' },
|
||||
{ key: 'create_comment_own_task_only', label: 'Только в назначенных на себя', hiddenBy: 'create_comment' },
|
||||
{ key: 'delete_all_comments', label: 'Удаление любых комментариев' },
|
||||
{ key: 'delete_own_comments', label: 'Только своих комментариев', hiddenBy: 'delete_all_comments' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Файлы',
|
||||
permissions: [
|
||||
{ key: 'upload_files', label: 'Загрузка файлов' },
|
||||
{ key: 'upload_images', label: 'Загрузка картинок' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Колонки',
|
||||
permissions: [
|
||||
{ key: 'create_column', label: 'Создание колонок' },
|
||||
{ key: 'edit_column', label: 'Редактирование колонок' },
|
||||
{ key: 'delete_column', label: 'Удаление колонок' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Управление',
|
||||
permissions: [
|
||||
{ key: 'manage_departments', label: 'Управление отделами' },
|
||||
{ key: 'remove_members', label: 'Удаление участников' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
// Проверка, должно ли право быть скрыто
|
||||
const isPermissionHidden = (perm) => {
|
||||
if (!perm.hiddenBy) return false
|
||||
return form.value.permissions[perm.hiddenBy] === true
|
||||
}
|
||||
|
||||
// Права по умолчанию
|
||||
const defaultPermissions = {
|
||||
create_task: true,
|
||||
edit_task: false,
|
||||
edit_own_task_only: true,
|
||||
delete_task: false,
|
||||
move_task: false,
|
||||
move_own_task_only: true,
|
||||
archive_task: true,
|
||||
create_column: false,
|
||||
edit_column: false,
|
||||
delete_column: false,
|
||||
manage_departments: false,
|
||||
remove_members: false,
|
||||
create_comment: false,
|
||||
create_comment_own_task_only: true,
|
||||
delete_own_comments: true,
|
||||
delete_all_comments: false,
|
||||
upload_files: true,
|
||||
upload_images: true
|
||||
}
|
||||
|
||||
// Можно сохранять
|
||||
const canSave = computed(() => {
|
||||
if (isEdit.value) {
|
||||
return !props.member?.is_owner
|
||||
}
|
||||
// Для приглашения нужен найденный пользователь (не я, не уже участник, не pending)
|
||||
return !!foundUser.value && !searching.value && !isFoundUserMe.value && !isFoundUserAlreadyMember.value && !isFoundUserPendingInvite.value
|
||||
})
|
||||
|
||||
// Инициализация формы
|
||||
const initForm = () => {
|
||||
if (props.member) {
|
||||
form.value = {
|
||||
username: '',
|
||||
is_admin: props.member.is_admin || false,
|
||||
permissions: { ...defaultPermissions, ...(props.member.permissions || {}) }
|
||||
}
|
||||
} else {
|
||||
form.value = {
|
||||
username: '',
|
||||
is_admin: false,
|
||||
permissions: { ...defaultPermissions }
|
||||
}
|
||||
}
|
||||
// Сохраняем начальное состояние для проверки изменений
|
||||
initialForm.value = JSON.parse(JSON.stringify(form.value))
|
||||
searchError.value = ''
|
||||
foundUser.value = null
|
||||
isFoundUserPendingInvite.value = false
|
||||
}
|
||||
|
||||
// Следим за открытием панели
|
||||
watch(() => props.show, (newVal) => {
|
||||
if (newVal) {
|
||||
initForm()
|
||||
}
|
||||
})
|
||||
|
||||
// Начальное состояние для проверки изменений
|
||||
const initialForm = ref(null)
|
||||
|
||||
// Проверка есть ли изменения
|
||||
const hasChanges = computed(() => {
|
||||
if (!initialForm.value) return false
|
||||
if (!isEdit.value) {
|
||||
// Для нового — есть изменения если найден пользователь
|
||||
return !!foundUser.value
|
||||
}
|
||||
// Для редактирования — сравниваем с начальным состоянием
|
||||
return JSON.stringify(form.value) !== JSON.stringify(initialForm.value)
|
||||
})
|
||||
|
||||
// Диалоги
|
||||
const showRemoveDialog = ref(false)
|
||||
const removeDialogMessage = ref('')
|
||||
const showUnsavedDialog = ref(false)
|
||||
|
||||
// Закрытие
|
||||
const handleClose = () => {
|
||||
if (hasChanges.value) {
|
||||
showUnsavedDialog.value = true
|
||||
return
|
||||
}
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// Сохранить и закрыть (из диалога unsavedChanges)
|
||||
const saveAndClose = async () => {
|
||||
showUnsavedDialog.value = false
|
||||
await handleSave()
|
||||
}
|
||||
|
||||
// Отменить изменения и закрыть
|
||||
const discardAndClose = () => {
|
||||
showUnsavedDialog.value = false
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// Сохранение
|
||||
const handleSave = async () => {
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
const projectId = store.currentProjectId
|
||||
|
||||
if (isEdit.value) {
|
||||
// Обновление прав участника
|
||||
const result = await projectAccessApi.updateMember(
|
||||
projectId,
|
||||
props.member.id_user,
|
||||
{
|
||||
is_admin: form.value.is_admin,
|
||||
permissions: form.value.permissions
|
||||
}
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
console.error('Error updating member:', result.errors)
|
||||
toast.error('Ошибка сохранения прав')
|
||||
return
|
||||
}
|
||||
|
||||
toast.success('Права участника сохранены')
|
||||
} else {
|
||||
// Отправка приглашения новому участнику
|
||||
if (!foundUser.value) return
|
||||
|
||||
const result = await projectInviteApi.send(
|
||||
projectId,
|
||||
foundUser.value.id,
|
||||
form.value.is_admin,
|
||||
form.value.permissions
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
searchError.value = result.errors?.invite || 'Ошибка при отправке приглашения'
|
||||
toast.error(searchError.value)
|
||||
return
|
||||
}
|
||||
|
||||
toast.success(`Приглашение отправлено ${foundUser.value.name || foundUser.value.username}`)
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
emit('close') // Закрываем напрямую, без проверки hasChanges
|
||||
} catch (error) {
|
||||
console.error('Error saving member:', error)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Открыть диалог удаления
|
||||
const confirmRemove = () => {
|
||||
removeDialogMessage.value = `<b>${props.member?.name || props.member?.username}</b> будет удалён из проекта.`
|
||||
showRemoveDialog.value = true
|
||||
}
|
||||
|
||||
// Выполнить удаление
|
||||
const doRemoveMember = async () => {
|
||||
const result = await projectAccessApi.removeMember(
|
||||
store.currentProjectId,
|
||||
props.member.id_user
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(result.errors?.member || 'Ошибка удаления')
|
||||
throw new Error(result.errors?.member || 'Ошибка удаления')
|
||||
}
|
||||
|
||||
toast.success('Участник удалён из проекта')
|
||||
emit('saved')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// Обновление иконок
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refreshIcons)
|
||||
onUpdated(refreshIcons)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Уменьшаем отступ между первой секцией и админ-чекбоксом */
|
||||
.form-section:first-child {
|
||||
margin-bottom: -8px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
color: #ef4444;
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Найденный пользователь */
|
||||
.found-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: rgba(0, 212, 170, 0.1);
|
||||
border: 1px solid rgba(0, 212, 170, 0.3);
|
||||
border-radius: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.found-user-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.found-user-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.found-user-avatar .avatar-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.found-user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.found-user-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.found-user-username {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.found-user-check {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Найденный пользователь — это я */
|
||||
.found-user.is-me,
|
||||
.found-user.is-member {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.found-user-me,
|
||||
.found-user-member {
|
||||
font-size: 14px;
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
/* Найденный пользователь уже приглашён */
|
||||
.found-user.is-pending {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
border-color: rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
.found-user-pending {
|
||||
font-size: 14px;
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.found-user-pending-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #fbbf24;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Инфо участника */
|
||||
.member-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 12px;
|
||||
margin-bottom: -8px;
|
||||
}
|
||||
|
||||
.member-avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.member-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.member-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.member-details h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.member-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
border-radius: 4px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.member-badge.owner {
|
||||
color: var(--accent);
|
||||
background: rgba(0, 212, 170, 0.15);
|
||||
}
|
||||
|
||||
.member-badge.admin {
|
||||
color: #f59e0b;
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
}
|
||||
|
||||
/* Тогл админа */
|
||||
.admin-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.admin-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.admin-toggle.active {
|
||||
background: rgba(0, 212, 170, 0.1);
|
||||
border-color: rgba(0, 212, 170, 0.3);
|
||||
}
|
||||
|
||||
.admin-toggle-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-toggle-info > i {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.admin-toggle.active .admin-toggle-info > i {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.admin-toggle-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.admin-toggle-label {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.admin-toggle-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Toggle Switch */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.toggle-switch.large {
|
||||
width: 52px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 24px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.toggle-slider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.toggle-switch.large .toggle-slider::before {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.toggle-switch.on .toggle-slider {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.toggle-switch.on .toggle-slider::before {
|
||||
left: calc(100% - 21px);
|
||||
}
|
||||
|
||||
.toggle-switch.large.on .toggle-slider::before {
|
||||
left: calc(100% - 25px);
|
||||
}
|
||||
|
||||
/* Группы прав */
|
||||
.permissions-section {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.permission-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.permission-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.permission-group-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.permission-group-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.permission-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.permission-row:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.permission-row.active {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.permission-row.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -1px;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: rgba(0, 212, 170, 0.5);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.permission-row:not(.sub) + .permission-row:not(.sub) {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
/* Первый элемент без отрицательного top */
|
||||
.permission-row.active:first-child::before {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.permission-row.sub {
|
||||
padding-left: 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.permission-branch {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.4;
|
||||
margin-right: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.permission-row.sub .permission-name {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.permission-name {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.permission-row.active .permission-name {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Кнопка удаления */
|
||||
.btn-delete {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
border-radius: 8px;
|
||||
color: #ef4444;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.btn-delete:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-delete i {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* Мобильная версия: только иконка */
|
||||
@media (max-width: 480px) {
|
||||
.btn-delete {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.btn-delete-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -162,6 +162,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import SlidePanel from './ui/SlidePanel.vue'
|
||||
import TextInput from './ui/TextInput.vue'
|
||||
import ColorPicker from './ui/ColorPicker.vue'
|
||||
@@ -171,10 +172,13 @@ import ConfirmDialog from './ConfirmDialog.vue'
|
||||
import { useLucideIcons } from '../composables/useLucideIcons'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
import { useProjectsStore } from '../stores/projects'
|
||||
import { useToast } from '../composables/useToast'
|
||||
|
||||
const { refreshIcons } = useLucideIcons()
|
||||
const toast = useToast()
|
||||
const { isMobile } = useMobile()
|
||||
const store = useProjectsStore()
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
show: Boolean,
|
||||
@@ -491,8 +495,11 @@ const handleSave = async () => {
|
||||
// Обновляем колонки если есть изменения
|
||||
// Дефолтные колонки уже созданы сервером
|
||||
// Здесь можно добавить логику для кастомизации колонок при создании
|
||||
toast.success('Проект создан')
|
||||
emit('saved', result.id)
|
||||
emit('close')
|
||||
} else {
|
||||
toast.error('Ошибка создания проекта')
|
||||
}
|
||||
} else {
|
||||
// Редактирование проекта
|
||||
@@ -529,6 +536,7 @@ const handleSave = async () => {
|
||||
await store.reorderColumns(ids)
|
||||
}
|
||||
|
||||
toast.success('Изменения сохранены')
|
||||
emit('saved', props.project.id)
|
||||
emit('close')
|
||||
}
|
||||
@@ -568,10 +576,17 @@ const confirmDeleteProject = async () => {
|
||||
|
||||
const result = await store.deleteProject(props.project.id)
|
||||
if (!result.success) {
|
||||
toast.error(result.errors?.access || 'Ошибка удаления')
|
||||
throw new Error(result.errors?.access || 'Ошибка удаления')
|
||||
}
|
||||
|
||||
toast.success('Проект удалён')
|
||||
emit('close')
|
||||
|
||||
// Если после удаления нет проектов — переходим на страницу без проектов
|
||||
if (store.projects.length === 0) {
|
||||
router.push('/no-projects')
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== WATCH ====================
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
{{ project.name }}
|
||||
</button>
|
||||
<button
|
||||
v-if="project.id_admin"
|
||||
v-if="project.is_admin"
|
||||
class="project-edit-btn"
|
||||
title="Настройки проекта"
|
||||
@click.stop="handleEdit(project)"
|
||||
|
||||
@@ -16,15 +16,29 @@
|
||||
<router-link to="/team" class="nav-item" :class="{ active: $route.path === '/team' }" title="Команда">
|
||||
<i data-lucide="users"></i>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="pendingInvitesCount > 0"
|
||||
to="/invites"
|
||||
class="nav-item invites"
|
||||
:class="{ active: $route.path === '/invites' }"
|
||||
title="Приглашения"
|
||||
>
|
||||
<i data-lucide="mail"></i>
|
||||
<span class="invites-badge">{{ pendingInvitesCount }}</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUpdated } from 'vue'
|
||||
import { computed, onMounted, onUpdated } from 'vue'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
import { useProjectsStore } from '../stores/projects'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
const store = useProjectsStore()
|
||||
|
||||
const pendingInvitesCount = computed(() => store.pendingInvitesCount)
|
||||
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
@@ -102,6 +116,27 @@ onUpdated(refreshIcons)
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
/* Приглашения */
|
||||
.nav-item.invites {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.invites-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
/* ========== MOBILE: Нижняя навигация ========== */
|
||||
.sidebar.mobile {
|
||||
top: auto;
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
<template>
|
||||
<SlidePanel
|
||||
:show="show"
|
||||
:width="500"
|
||||
:min-width="400"
|
||||
:max-width="700"
|
||||
@close="handleClose"
|
||||
>
|
||||
<template #header>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Кнопка добавления комментария -->
|
||||
<div class="comment-action-bar">
|
||||
<div v-if="canComment" class="comment-action-bar">
|
||||
<button class="btn-new-comment" @click="openEditorForCreate">
|
||||
<i data-lucide="message-square-plus"></i>
|
||||
Написать комментарий
|
||||
@@ -93,6 +93,10 @@ const props = defineProps({
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
canComment: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
@@ -242,7 +246,7 @@ const loadComments = async (silent = false) => {
|
||||
// Автообновление
|
||||
const startRefresh = () => {
|
||||
stopRefresh()
|
||||
const interval = (window.APP_CONFIG?.IDLE_REFRESH_SECONDS || 30) * 1000
|
||||
const interval = (window.APP_CONFIG?.REFRESH_INTERVALS?.comments || 30) * 1000
|
||||
refreshInterval = setInterval(async () => {
|
||||
if (props.active && props.taskId) {
|
||||
await loadComments(true)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
v-model="form.title"
|
||||
placeholder="Введите название задачи"
|
||||
ref="titleInputRef"
|
||||
:readonly="!canEdit"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@@ -12,11 +13,12 @@
|
||||
<TextInput
|
||||
v-model="form.description"
|
||||
placeholder="Краткое описание в одну строку..."
|
||||
:readonly="!canEdit"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Подробное описание">
|
||||
<template #actions>
|
||||
<template v-if="canEdit" #actions>
|
||||
<div class="format-buttons">
|
||||
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('bold')" title="Жирный (Ctrl+B)">
|
||||
<i data-lucide="bold"></i>
|
||||
@@ -33,6 +35,7 @@
|
||||
v-model="form.details"
|
||||
placeholder="Подробное описание задачи, заметки, ссылки..."
|
||||
:show-toolbar="false"
|
||||
:disabled="!canEdit"
|
||||
ref="detailsEditorRef"
|
||||
/>
|
||||
</FormField>
|
||||
@@ -41,6 +44,7 @@
|
||||
<TagsSelect
|
||||
v-model="form.departmentId"
|
||||
:options="departmentOptions"
|
||||
:disabled="!canEdit"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@@ -48,12 +52,13 @@
|
||||
<TagsSelect
|
||||
v-model="form.labelId"
|
||||
:options="labelOptions"
|
||||
:disabled="!canEdit"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div class="field-row" :class="{ mobile: isMobile }">
|
||||
<FormField label="Срок выполнения">
|
||||
<DatePicker v-model="form.dueDate" />
|
||||
<DatePicker v-model="form.dueDate" :disabled="!canEdit" />
|
||||
</FormField>
|
||||
|
||||
<FormField label="Исполнитель">
|
||||
@@ -63,18 +68,21 @@
|
||||
searchable
|
||||
placeholder="Без исполнителя"
|
||||
empty-label="Без исполнителя"
|
||||
:disabled="!canEdit"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
v-if="canEdit || attachedFiles.length > 0"
|
||||
label="Прикреплённые файлы"
|
||||
hint="Разрешены: PNG, JPEG, JPG, ZIP, RAR (до 10 МБ)"
|
||||
:hint="canEdit ? 'Разрешены: PNG, JPEG, JPG, ZIP, RAR (до 10 МБ)' : ''"
|
||||
:error="fileError"
|
||||
>
|
||||
<FileUploader
|
||||
:files="attachedFiles"
|
||||
:get-full-url="getFullUrl"
|
||||
:read-only="!canEdit"
|
||||
@add="handleFileAdd"
|
||||
@remove="handleFileRemove"
|
||||
@preview="$emit('preview-image', $event)"
|
||||
@@ -126,6 +134,10 @@ const props = defineProps({
|
||||
users: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
canEdit: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
@@ -173,13 +185,32 @@ const labelOptions = computed(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
// Данные исполнителя из карточки (может быть удалённый участник)
|
||||
const cardAssignee = ref(null)
|
||||
|
||||
const userOptions = computed(() => {
|
||||
return props.users.map(user => ({
|
||||
value: user.id,
|
||||
const options = props.users.map(user => ({
|
||||
value: user.id_user, // id_user - это id пользователя из accounts
|
||||
label: user.name,
|
||||
subtitle: user.telegram,
|
||||
avatar: getFullUrl(user.avatar_url)
|
||||
}))
|
||||
|
||||
// Если текущий исполнитель не в списке участников — добавляем как виртуальную опцию
|
||||
if (cardAssignee.value && form.userId) {
|
||||
const exists = options.some(opt => Number(opt.value) === Number(form.userId))
|
||||
if (!exists) {
|
||||
options.unshift({
|
||||
value: form.userId,
|
||||
label: cardAssignee.value.name || 'Удалённый участник',
|
||||
subtitle: '',
|
||||
avatar: cardAssignee.value.avatar ? getFullUrl(cardAssignee.value.avatar) : null,
|
||||
disabled: true // Нельзя выбрать повторно
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return options
|
||||
})
|
||||
|
||||
// Change tracking
|
||||
@@ -216,6 +247,7 @@ const resetForm = () => {
|
||||
form.labelId = 2 // Нормально по умолчанию
|
||||
form.dueDate = new Date().toISOString().split('T')[0]
|
||||
form.userId = null
|
||||
cardAssignee.value = null
|
||||
clearFiles()
|
||||
}
|
||||
|
||||
@@ -234,6 +266,16 @@ const loadFromCard = (card) => {
|
||||
form.dueDate = card.dueDate || ''
|
||||
form.userId = card.accountId || null
|
||||
|
||||
// Сохраняем данные исполнителя для случая если он удалён из проекта
|
||||
if (card.accountId && card.assignee) {
|
||||
cardAssignee.value = {
|
||||
avatar: card.assignee,
|
||||
name: null // Имя неизвестно, будет показываться аватарка
|
||||
}
|
||||
} else {
|
||||
cardAssignee.value = null
|
||||
}
|
||||
|
||||
if (card.files && card.files.length > 0) {
|
||||
attachedFiles.value = card.files.map(f => ({
|
||||
name: f.name,
|
||||
@@ -256,8 +298,15 @@ const applyFormat = (command) => {
|
||||
|
||||
const getAvatarByUserId = (userId) => {
|
||||
if (!userId) return null
|
||||
const user = props.users.find(u => u.id === userId)
|
||||
return user ? user.avatar_url : null
|
||||
const user = props.users.find(u => Number(u.id_user) === Number(userId))
|
||||
if (user) return user.avatar_url
|
||||
|
||||
// Fallback: если это тот же удалённый участник (userId не изменился), используем сохранённый аватар
|
||||
if (cardAssignee.value && Number(userId) === Number(initialForm.value.userId)) {
|
||||
return cardAssignee.value.avatar
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// File handlers
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
:departments="store.departments"
|
||||
:labels="store.labels"
|
||||
:users="store.users"
|
||||
:can-edit="canEdit"
|
||||
@preview-image="openImagePreview"
|
||||
/>
|
||||
|
||||
@@ -42,13 +43,14 @@
|
||||
:current-user-avatar="store.currentUserAvatar"
|
||||
:is-project-admin="store.isProjectAdmin"
|
||||
:active="activeTab === 'comments'"
|
||||
:can-comment="canComment"
|
||||
@comments-loaded="commentsCount = $event"
|
||||
@preview-file="openImagePreview"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Footer: скрываем на вкладке комментариев -->
|
||||
<template #footer v-if="activeTab !== 'comments'">
|
||||
<!-- Footer: скрываем на вкладке комментариев или если нет прав редактировать -->
|
||||
<template #footer v-if="activeTab !== 'comments' && canEdit">
|
||||
<div class="footer-left">
|
||||
<IconButton
|
||||
v-if="!isNew"
|
||||
@@ -143,9 +145,11 @@ import { useMobile } from '../../composables/useMobile'
|
||||
import { useLucideIcons } from '../../composables/useLucideIcons'
|
||||
import { useDateFormat } from '../../composables/useDateFormat'
|
||||
import { useProjectsStore } from '../../stores/projects'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
const { refreshIcons } = useLucideIcons()
|
||||
const toast = useToast()
|
||||
const { formatFull } = useDateFormat()
|
||||
const store = useProjectsStore()
|
||||
|
||||
@@ -203,7 +207,7 @@ const previewImage = ref(null)
|
||||
const panelTitle = computed(() => {
|
||||
if (isNew.value) return 'Новая задача'
|
||||
if (activeTab.value === 'comments') return 'Комментарии'
|
||||
return 'Редактирование'
|
||||
return canEdit.value ? 'Редактирование' : 'Просмотр'
|
||||
})
|
||||
|
||||
// Tabs config
|
||||
@@ -223,6 +227,18 @@ const canArchive = computed(() => {
|
||||
return store.doneColumnId && Number(props.columnId) === store.doneColumnId
|
||||
})
|
||||
|
||||
// Право на редактирование (для новой — create_task, для существующей — canEditTask)
|
||||
const canEdit = computed(() => {
|
||||
if (isNew.value) return store.can('create_task')
|
||||
return store.canEditTask(props.card)
|
||||
})
|
||||
|
||||
// Право на создание комментариев
|
||||
const canComment = computed(() => {
|
||||
if (isNew.value) return false // В новой задаче нельзя комментировать
|
||||
return store.canCreateComment(props.card)
|
||||
})
|
||||
|
||||
// Close handling
|
||||
const tryClose = () => {
|
||||
if (editTabRef.value?.hasChanges) {
|
||||
@@ -295,6 +311,11 @@ const handleSave = async () => {
|
||||
} else {
|
||||
emit('save', taskData)
|
||||
}
|
||||
|
||||
toast.success(props.card?.id ? 'Задача сохранена' : 'Задача создана')
|
||||
} catch (error) {
|
||||
toast.error('Ошибка сохранения задачи')
|
||||
console.error('Error saving task:', error)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
@@ -315,6 +336,8 @@ const confirmDelete = async () => {
|
||||
} else {
|
||||
emit('delete', props.card.id)
|
||||
}
|
||||
|
||||
toast.success('Задача удалена')
|
||||
}
|
||||
|
||||
// Archive
|
||||
@@ -332,6 +355,8 @@ const confirmArchive = async () => {
|
||||
} else {
|
||||
emit('archive', props.card.id)
|
||||
}
|
||||
|
||||
toast.success('Задача в архиве')
|
||||
}
|
||||
|
||||
// Restore
|
||||
@@ -349,6 +374,8 @@ const confirmRestore = async () => {
|
||||
} else {
|
||||
emit('restore', props.card.id)
|
||||
}
|
||||
|
||||
toast.success('Задача восстановлена')
|
||||
}
|
||||
|
||||
// Image preview
|
||||
@@ -401,6 +428,9 @@ watch(() => props.show, async (newVal) => {
|
||||
previewImage.value = null
|
||||
isSaving.value = false // Сброс состояния кнопки сохранения
|
||||
|
||||
// Обновляем права пользователя (могли измениться администратором)
|
||||
store.fetchUsers()
|
||||
|
||||
// Reset comments tab
|
||||
commentsTabRef.value?.reset()
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
style="display: none"
|
||||
>
|
||||
|
||||
<!-- Пустая зона drag & drop -->
|
||||
<!-- Пустая зона drag & drop (скрываем в readOnly если нет файлов) -->
|
||||
<div
|
||||
v-if="files.length === 0"
|
||||
v-if="files.length === 0 && !readOnly"
|
||||
class="file-dropzone"
|
||||
:class="{ 'dragover': isDragging }"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@@ -28,10 +28,10 @@
|
||||
<div
|
||||
v-else
|
||||
class="files-container"
|
||||
:class="{ 'dragover': isDragging }"
|
||||
@dragover.prevent="isDragging = true"
|
||||
:class="{ 'dragover': isDragging && !readOnly }"
|
||||
@dragover.prevent="!readOnly && (isDragging = true)"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@drop.prevent="handleFileDrop"
|
||||
@drop.prevent="!readOnly && handleFileDrop($event)"
|
||||
>
|
||||
<!-- Превью файлов -->
|
||||
<div
|
||||
@@ -39,7 +39,7 @@
|
||||
:key="file.name + '-' + file.size"
|
||||
class="file-preview-item"
|
||||
>
|
||||
<div class="file-actions">
|
||||
<div v-if="!readOnly" class="file-actions">
|
||||
<button class="btn-download-file" @click.stop="downloadFile(file)" title="Скачать">
|
||||
<i data-lucide="download"></i>
|
||||
</button>
|
||||
@@ -58,8 +58,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Кнопка добавить ещё -->
|
||||
<div class="file-add-btn" @click="triggerFileInput">
|
||||
<!-- Кнопка добавить ещё (скрываем в readOnly) -->
|
||||
<div v-if="!readOnly" class="file-add-btn" @click="triggerFileInput">
|
||||
<i data-lucide="plus"></i>
|
||||
<span>Добавить</span>
|
||||
</div>
|
||||
@@ -101,6 +101,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: 'или нажмите для выбора'
|
||||
},
|
||||
readOnly: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
getFullUrl: {
|
||||
type: Function,
|
||||
default: (url) => url
|
||||
|
||||
121
front_vue/src/components/ui/LogoutButton.vue
Normal file
121
front_vue/src/components/ui/LogoutButton.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<button
|
||||
:class="['logout-btn', { mobile }]"
|
||||
@click="showDialog = true"
|
||||
title="Выйти"
|
||||
>
|
||||
<i data-lucide="log-out"></i>
|
||||
<span v-if="showText" class="logout-text">Выйти из аккаунта</span>
|
||||
</button>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model:show="showDialog"
|
||||
type="logout"
|
||||
:action="logout"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUpdated } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { authApi } from '../../api'
|
||||
import { clearAuthCache } from '../../router'
|
||||
import { useProjectsStore } from '../../stores/projects'
|
||||
import ConfirmDialog from '../ConfirmDialog.vue'
|
||||
|
||||
defineProps({
|
||||
showText: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
mobile: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const store = useProjectsStore()
|
||||
const showDialog = ref(false)
|
||||
|
||||
const logout = async () => {
|
||||
clearAuthCache()
|
||||
await authApi.logout()
|
||||
store.reset()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) window.lucide.createIcons()
|
||||
}
|
||||
|
||||
onMounted(refreshIcons)
|
||||
onUpdated(refreshIcons)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.logout-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
gap: 8px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.logout-btn i {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* С текстом */
|
||||
.logout-btn:has(.logout-text) {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.logout-btn:has(.logout-text):hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.logout-btn:has(.logout-text) i {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* Мобильная версия (иконка) */
|
||||
.logout-btn.mobile {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-muted);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.logout-btn.mobile:hover,
|
||||
.logout-btn.mobile:active {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
color: #ef4444;
|
||||
}
|
||||
</style>
|
||||
271
front_vue/src/components/ui/NotificationCard.vue
Normal file
271
front_vue/src/components/ui/NotificationCard.vue
Normal file
@@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<div
|
||||
class="notification-card"
|
||||
:class="{ processing, [type]: true }"
|
||||
>
|
||||
<div class="notification-info">
|
||||
<!-- Аватар -->
|
||||
<div class="notification-avatar" :style="avatarStyle">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" :alt="title">
|
||||
<span v-else class="avatar-initials">{{ avatarInitial }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Детали -->
|
||||
<div class="notification-details">
|
||||
<span class="notification-title">{{ title }}</span>
|
||||
<span v-if="subtitle" class="notification-subtitle">{{ subtitle }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Бейдж -->
|
||||
<span v-if="badge" class="notification-badge">{{ badge }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Действия -->
|
||||
<div v-if="showActions" class="notification-actions">
|
||||
<button
|
||||
class="btn-action btn-decline"
|
||||
@click="$emit('decline')"
|
||||
:disabled="processing"
|
||||
title="Отклонить"
|
||||
>
|
||||
<i data-lucide="x"></i>
|
||||
</button>
|
||||
<button
|
||||
class="btn-action btn-accept"
|
||||
@click="$emit('accept')"
|
||||
:disabled="processing"
|
||||
title="Принять"
|
||||
>
|
||||
<i data-lucide="check"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useLucideIcons } from '../../composables/useLucideIcons'
|
||||
|
||||
useLucideIcons()
|
||||
|
||||
const props = defineProps({
|
||||
// Основные данные
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
badge: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// Аватар
|
||||
avatarUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
avatarName: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
avatarColor: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// Тип уведомления (для стилизации)
|
||||
type: {
|
||||
type: String,
|
||||
default: 'default', // default, invite, warning, info
|
||||
validator: (v) => ['default', 'invite', 'warning', 'info'].includes(v)
|
||||
},
|
||||
|
||||
// Состояние
|
||||
processing: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showActions: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['accept', 'decline'])
|
||||
|
||||
// Первая буква для аватара
|
||||
const avatarInitial = computed(() => {
|
||||
return (props.avatarName || props.title || '?')[0].toUpperCase()
|
||||
})
|
||||
|
||||
// Стиль аватара
|
||||
const avatarStyle = computed(() => {
|
||||
if (props.avatarColor) {
|
||||
return { background: props.avatarColor }
|
||||
}
|
||||
return {}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notification-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 10px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.notification-card:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.notification-card.processing {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Инфо */
|
||||
.notification-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Аватар */
|
||||
.notification-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: var(--blue, #3b82f6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notification-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-initials {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Детали */
|
||||
.notification-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notification-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.notification-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Бейдж */
|
||||
.notification-badge {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
padding: 3px 6px;
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
color: #f59e0b;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Действия */
|
||||
.notification-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-action i {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.btn-action:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-decline {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn-decline:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.btn-accept {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.btn-accept:hover:not(:disabled) {
|
||||
background: #00e6b8;
|
||||
}
|
||||
|
||||
/* ========== ТИПЫ ========== */
|
||||
/* Invite (зелёный акцент) */
|
||||
.notification-card.invite .btn-accept {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* Warning (оранжевый) */
|
||||
.notification-card.warning .notification-badge {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
/* Info (синий) */
|
||||
.notification-card.info .notification-badge {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #3b82f6;
|
||||
}
|
||||
</style>
|
||||
@@ -31,8 +31,8 @@
|
||||
<!-- Редактируемое поле -->
|
||||
<div
|
||||
class="rich-editor"
|
||||
:class="{ 'is-empty': !modelValue }"
|
||||
contenteditable="true"
|
||||
:class="{ 'is-empty': !modelValue, 'disabled': disabled }"
|
||||
:contenteditable="!disabled"
|
||||
ref="editorRef"
|
||||
@input="onInput"
|
||||
@paste="onPaste"
|
||||
@@ -65,6 +65,10 @@ const props = defineProps({
|
||||
autoLinkify: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
@@ -309,6 +313,12 @@ defineExpose({
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.rich-editor.disabled {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.rich-editor:focus {
|
||||
border-color: var(--accent);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
v-for="option in filteredOptions"
|
||||
:key="option.value"
|
||||
class="dropdown-item"
|
||||
:class="{ active: modelValue === option.value }"
|
||||
:class="{ active: isActive(option.value) }"
|
||||
@click="selectOption(option.value)"
|
||||
>
|
||||
<slot name="option" :option="option">
|
||||
@@ -56,7 +56,16 @@
|
||||
>
|
||||
<div class="option-content">
|
||||
<span class="option-label">{{ option.label }}</span>
|
||||
<span v-if="option.subtitle" class="option-subtitle">{{ option.subtitle }}</span>
|
||||
<a
|
||||
v-if="option.subtitle"
|
||||
class="option-subtitle"
|
||||
:href="'https://t.me/' + option.subtitle.replace('@', '')"
|
||||
target="_blank"
|
||||
@click.stop
|
||||
>
|
||||
<i data-lucide="send"></i>
|
||||
{{ option.subtitle.startsWith('@') ? option.subtitle : '@' + option.subtitle }}
|
||||
</a>
|
||||
</div>
|
||||
</slot>
|
||||
</button>
|
||||
@@ -115,7 +124,7 @@
|
||||
v-for="option in filteredOptions"
|
||||
:key="option.value"
|
||||
class="mobile-select-item"
|
||||
:class="{ active: modelValue === option.value }"
|
||||
:class="{ active: isActive(option.value) }"
|
||||
@click="selectOption(option.value)"
|
||||
>
|
||||
<img
|
||||
@@ -126,9 +135,18 @@
|
||||
>
|
||||
<div class="option-content">
|
||||
<span class="option-label">{{ option.label }}</span>
|
||||
<span v-if="option.subtitle" class="option-subtitle">{{ option.subtitle }}</span>
|
||||
<a
|
||||
v-if="option.subtitle"
|
||||
class="option-subtitle"
|
||||
:href="'https://t.me/' + option.subtitle.replace('@', '')"
|
||||
target="_blank"
|
||||
@click.stop
|
||||
>
|
||||
<i data-lucide="send"></i>
|
||||
{{ option.subtitle.startsWith('@') ? option.subtitle : '@' + option.subtitle }}
|
||||
</a>
|
||||
</div>
|
||||
<i v-if="modelValue === option.value" data-lucide="check" class="check-icon"></i>
|
||||
<i v-if="isActive(option.value)" data-lucide="check" class="check-icon"></i>
|
||||
</button>
|
||||
|
||||
<!-- Пустой результат поиска -->
|
||||
@@ -247,17 +265,24 @@ const closeDropdown = () => {
|
||||
searchQuery.value = ''
|
||||
}
|
||||
|
||||
// Сравнение значений (приводим к числу для корректного сравнения)
|
||||
const isActive = (optionValue) => {
|
||||
if (props.modelValue === null || props.modelValue === undefined) return false
|
||||
return Number(optionValue) === Number(props.modelValue)
|
||||
}
|
||||
|
||||
// Выбранная опция
|
||||
const selectedOption = computed(() => {
|
||||
if (!props.modelValue) return null
|
||||
return props.options.find(opt => opt.value === props.modelValue)
|
||||
if (props.modelValue === null || props.modelValue === undefined) return null
|
||||
return props.options.find(opt => isActive(opt.value))
|
||||
})
|
||||
|
||||
// Отфильтрованные опции
|
||||
// Отфильтрованные опции (исключаем disabled, они только для отображения выбранного)
|
||||
const filteredOptions = computed(() => {
|
||||
if (!searchQuery.value.trim()) return props.options
|
||||
let opts = props.options.filter(opt => !opt.disabled)
|
||||
if (!searchQuery.value.trim()) return opts
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return props.options.filter(opt =>
|
||||
return opts.filter(opt =>
|
||||
opt.label?.toLowerCase().includes(query) ||
|
||||
opt.subtitle?.toLowerCase().includes(query)
|
||||
)
|
||||
@@ -500,12 +525,30 @@ onUpdated(refreshIcons)
|
||||
.option-subtitle {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.option-subtitle:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.option-subtitle :deep(svg) {
|
||||
width: 10px !important;
|
||||
height: 10px !important;
|
||||
}
|
||||
|
||||
.dropdown-item.active .option-subtitle {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.dropdown-item.active .option-subtitle:hover {
|
||||
color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.no-selection-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
@@ -678,6 +721,15 @@ onUpdated(refreshIcons)
|
||||
.mobile-select-item .option-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.mobile-select-item .option-subtitle :deep(svg) {
|
||||
width: 12px !important;
|
||||
height: 12px !important;
|
||||
}
|
||||
|
||||
.mobile-select-item.active .option-subtitle {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<template>
|
||||
<div class="tags-select">
|
||||
<div class="tags-select" :class="{ disabled: disabled }">
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
class="tag-option"
|
||||
:class="{ active: isSelected(option.value) }"
|
||||
:style="{ '--tag-color': option.color || defaultColor }"
|
||||
@click="toggleOption(option.value)"
|
||||
:disabled="disabled"
|
||||
@click="!disabled && toggleOption(option.value)"
|
||||
>
|
||||
<span v-if="option.icon" class="tag-icon">{{ option.icon }}</span>
|
||||
{{ option.label }}
|
||||
@@ -38,6 +39,10 @@ const props = defineProps({
|
||||
defaultColor: {
|
||||
type: String,
|
||||
default: 'var(--accent)'
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
@@ -114,6 +119,22 @@ const toggleOption = (value) => {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.tags-select.disabled .tag-option {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.tags-select.disabled .tag-option:hover {
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tags-select.disabled .tag-option.active:hover {
|
||||
background: var(--tag-color);
|
||||
border-color: var(--tag-color);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.tag-icon {
|
||||
font-size: 14px;
|
||||
margin-right: 4px;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<input
|
||||
type="text"
|
||||
class="text-input"
|
||||
:class="{ readonly: readonly }"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
@@ -65,4 +66,14 @@ defineEmits(['update:modelValue', 'focus', 'blur', 'keydown'])
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.text-input.readonly {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.text-input.readonly:focus {
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
</style>
|
||||
|
||||
431
front_vue/src/components/ui/ToastContainer.vue
Normal file
431
front_vue/src/components/ui/ToastContainer.vue
Normal file
@@ -0,0 +1,431 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="toast-container" :class="{ mobile: isMobile }">
|
||||
<TransitionGroup :name="isMobile ? 'toast-mobile' : 'toast'">
|
||||
<div
|
||||
v-for="(toast, index) in toasts"
|
||||
:key="toast.id"
|
||||
class="toast"
|
||||
:class="toast.type"
|
||||
:style="getStackStyle(index)"
|
||||
@click="!isMobile && remove(toast.id)"
|
||||
@touchstart="handleTouchStart($event, toast.id)"
|
||||
@touchmove="handleTouchMove($event, toast.id)"
|
||||
@touchend="handleTouchEnd($event, toast.id)"
|
||||
>
|
||||
<!-- Progress bar (время до автозакрытия) -->
|
||||
<div class="toast-progress" :style="{ '--duration': toast.duration + 'ms' }"></div>
|
||||
|
||||
<!-- Цветной акцент слева -->
|
||||
<div class="toast-accent"></div>
|
||||
|
||||
<div class="toast-content">
|
||||
<div class="toast-icon">
|
||||
<i :data-lucide="iconName(toast.type)"></i>
|
||||
</div>
|
||||
<span class="toast-message">{{ toast.message }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Кнопка закрытия (только десктоп) -->
|
||||
<button v-if="!isMobile" class="toast-close" @click.stop="remove(toast.id)">
|
||||
<i data-lucide="x"></i>
|
||||
</button>
|
||||
|
||||
<!-- Swipe hint для мобильных (на верхнем тосте) -->
|
||||
<div v-if="isMobile && index === toasts.length - 1" class="swipe-hint">
|
||||
<i data-lucide="chevrons-left"></i>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import { useLucideIcons } from '../../composables/useLucideIcons'
|
||||
import { useMobile } from '../../composables/useMobile'
|
||||
|
||||
const { toasts, remove } = useToast()
|
||||
const { refreshIcons } = useLucideIcons()
|
||||
const { isMobile } = useMobile()
|
||||
|
||||
// Swipe tracking
|
||||
const swipeState = ref({})
|
||||
|
||||
const handleTouchStart = (e, id) => {
|
||||
const touch = e.touches[0]
|
||||
swipeState.value[id] = {
|
||||
startX: touch.clientX,
|
||||
startY: touch.clientY,
|
||||
currentX: 0,
|
||||
swiping: false
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchMove = (e, id) => {
|
||||
const state = swipeState.value[id]
|
||||
if (!state) return
|
||||
|
||||
const touch = e.touches[0]
|
||||
const deltaX = touch.clientX - state.startX
|
||||
const deltaY = Math.abs(touch.clientY - state.startY)
|
||||
|
||||
// Определяем направление: горизонтальный свайп влево
|
||||
if (Math.abs(deltaX) > 10 && deltaX < 0 && deltaY < 30) {
|
||||
state.swiping = true
|
||||
state.currentX = Math.max(deltaX, -150) // Ограничиваем свайп
|
||||
|
||||
// Применяем трансформацию (сохраняем центрирование -50%)
|
||||
const el = e.currentTarget
|
||||
el.style.transform = `translateX(calc(-50% + ${state.currentX}px))`
|
||||
el.style.opacity = 1 - Math.abs(state.currentX) / 150
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchEnd = (e, id) => {
|
||||
const state = swipeState.value[id]
|
||||
if (!state) return
|
||||
|
||||
const el = e.currentTarget
|
||||
|
||||
// Если свайпнули достаточно далеко — удаляем
|
||||
if (state.currentX < -80) {
|
||||
// Помечаем как swiped чтобы отключить CSS-анимацию TransitionGroup
|
||||
el.classList.add('swiped')
|
||||
el.style.transform = 'translateX(-150%)'
|
||||
el.style.opacity = '0'
|
||||
// Удаляем сразу, анимация уже произошла через JS
|
||||
setTimeout(() => remove(id), 100)
|
||||
} else {
|
||||
// Возвращаем на место
|
||||
el.style.transition = 'transform 0.2s, opacity 0.2s'
|
||||
el.style.transform = 'translateX(-50%)'
|
||||
el.style.opacity = '1'
|
||||
setTimeout(() => {
|
||||
el.style.transition = ''
|
||||
}, 200)
|
||||
}
|
||||
|
||||
delete swipeState.value[id]
|
||||
}
|
||||
|
||||
// Stack effect для нескольких тостов (мобильная версия)
|
||||
// Новые тосты полностью перекрывают старые, без уменьшения
|
||||
const getStackStyle = (index) => {
|
||||
if (!isMobile.value) return {}
|
||||
|
||||
// Новые тосты (с большим индексом) должны быть сверху
|
||||
return {
|
||||
zIndex: 100 + index
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем иконки при добавлении новых toast
|
||||
watch(() => toasts.value.length, () => {
|
||||
setTimeout(refreshIcons, 10)
|
||||
})
|
||||
|
||||
const iconName = (type) => {
|
||||
switch (type) {
|
||||
case 'success': return 'circle-check'
|
||||
case 'error': return 'circle-x'
|
||||
case 'warning': return 'triangle-alert'
|
||||
case 'info':
|
||||
default: return 'info'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ==================== DESKTOP ==================== */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 100000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
pointer-events: none;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
/* Safe area для iPhone */
|
||||
@supports (padding-top: env(safe-area-inset-top)) {
|
||||
.toast-container:not(.mobile) {
|
||||
top: calc(20px + env(safe-area-inset-top));
|
||||
right: calc(20px + env(safe-area-inset-right));
|
||||
}
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
padding-left: 20px;
|
||||
background: var(--bg-secondary, #1a1a1f);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(12px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Цветной акцент слева */
|
||||
.toast-accent {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
border-radius: 4px 0 0 4px;
|
||||
}
|
||||
|
||||
.toast-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toast-icon i,
|
||||
.toast-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #f4f4f5);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.toast-close {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted, #6b6b70);
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.toast-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toast-close i,
|
||||
.toast-close svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
.toast-progress {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
background: currentColor;
|
||||
opacity: 0.3;
|
||||
animation: progress var(--duration) linear forwards;
|
||||
border-radius: 0 0 0 12px;
|
||||
}
|
||||
|
||||
@keyframes progress {
|
||||
from { width: 100%; }
|
||||
to { width: 0%; }
|
||||
}
|
||||
|
||||
/* Swipe hint (скрыт по умолчанию) */
|
||||
.swipe-hint {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ==================== ТИПЫ УВЕДОМЛЕНИЙ ==================== */
|
||||
.toast.success .toast-accent { background: var(--accent, #00d4aa); }
|
||||
.toast.success .toast-icon { color: var(--accent, #00d4aa); }
|
||||
.toast.success .toast-progress { color: var(--accent, #00d4aa); }
|
||||
|
||||
.toast.error .toast-accent { background: var(--red, #f87171); }
|
||||
.toast.error .toast-icon { color: var(--red, #f87171); }
|
||||
.toast.error .toast-progress { color: var(--red, #f87171); }
|
||||
|
||||
.toast.warning .toast-accent { background: var(--orange, #fbbf24); }
|
||||
.toast.warning .toast-icon { color: var(--orange, #fbbf24); }
|
||||
.toast.warning .toast-progress { color: var(--orange, #fbbf24); }
|
||||
|
||||
.toast.info .toast-accent { background: var(--blue, #60a5fa); }
|
||||
.toast.info .toast-icon { color: var(--blue, #60a5fa); }
|
||||
.toast.info .toast-progress { color: var(--blue, #60a5fa); }
|
||||
|
||||
/* ==================== DESKTOP ANIMATIONS ==================== */
|
||||
.toast-enter-active {
|
||||
animation: toast-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
.toast-leave-active {
|
||||
animation: toast-out 0.2s ease-in forwards;
|
||||
}
|
||||
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toast-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== MOBILE ==================== */
|
||||
.toast-container.mobile {
|
||||
top: auto;
|
||||
bottom: 90px; /* Над навигацией */
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-width: none;
|
||||
gap: 0;
|
||||
min-height: 60px; /* Место для абсолютно спозиционированных тостов */
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@supports (padding-bottom: env(safe-area-inset-bottom)) {
|
||||
.toast-container.mobile {
|
||||
bottom: calc(90px + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
|
||||
.toast-container.mobile .toast {
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
padding: 12px 16px;
|
||||
padding-left: 18px;
|
||||
border-radius: 16px;
|
||||
cursor: default;
|
||||
/* Pill-style с цветным градиентом на фоне */
|
||||
background: linear-gradient(135deg,
|
||||
rgba(30, 30, 35, 0.95) 0%,
|
||||
rgba(25, 25, 30, 0.98) 100%
|
||||
);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow:
|
||||
0 4px 20px rgba(0, 0, 0, 0.3),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
|
||||
}
|
||||
|
||||
/* Акцент на мобильных — градиентная полоска */
|
||||
.toast-container.mobile .toast-accent {
|
||||
width: 3px;
|
||||
background: linear-gradient(180deg, currentColor 0%, transparent 100%);
|
||||
}
|
||||
|
||||
.toast-container.mobile .toast.success .toast-accent { color: var(--accent, #00d4aa); }
|
||||
.toast-container.mobile .toast.error .toast-accent { color: var(--red, #f87171); }
|
||||
.toast-container.mobile .toast.warning .toast-accent { color: var(--orange, #fbbf24); }
|
||||
.toast-container.mobile .toast.info .toast-accent { color: var(--blue, #60a5fa); }
|
||||
|
||||
/* Swipe hint на мобильных */
|
||||
.toast-container.mobile .swipe-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.4;
|
||||
animation: swipe-hint 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.toast-container.mobile .swipe-hint i,
|
||||
.toast-container.mobile .swipe-hint svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
@keyframes swipe-hint {
|
||||
0%, 100% { transform: translateX(0); opacity: 0.4; }
|
||||
50% { transform: translateX(-4px); opacity: 0.7; }
|
||||
}
|
||||
|
||||
/* ==================== MOBILE ANIMATIONS ==================== */
|
||||
.toast-mobile-enter-active {
|
||||
animation: toast-mobile-in 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.toast-mobile-leave-active {
|
||||
animation: toast-mobile-out 0.25s ease-in forwards;
|
||||
}
|
||||
|
||||
/* Отключаем анимацию для элементов удалённых свайпом (уже анимированы через JS) */
|
||||
.toast-mobile-leave-active.swiped {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes toast-mobile-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(100%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toast-mobile-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(-150%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Все тосты на мобильных в одном месте, новые поверх старых */
|
||||
.toast-container.mobile .toast {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
/* Только верхний (последний) тост интерактивен */
|
||||
.toast-container.mobile .toast:not(:last-child) {
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,33 @@
|
||||
import { onMounted, onUpdated } from 'vue'
|
||||
|
||||
// Глобальный debounce таймер для всех компонентов
|
||||
let debounceTimer = null
|
||||
const DEBOUNCE_MS = 50
|
||||
|
||||
/**
|
||||
* Проверяет, есть ли необработанные иконки (<i data-lucide> без svg)
|
||||
*/
|
||||
const hasUnprocessedIcons = () => {
|
||||
const icons = document.querySelectorAll('i[data-lucide]')
|
||||
return icons.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Безопасное обновление иконок с debounce
|
||||
*/
|
||||
const debouncedRefresh = () => {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
}
|
||||
debounceTimer = setTimeout(() => {
|
||||
// Обновляем только если есть необработанные иконки
|
||||
if (hasUnprocessedIcons() && window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
debounceTimer = null
|
||||
}, DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable для автоматического обновления Lucide иконок
|
||||
* при монтировании и обновлении компонента.
|
||||
@@ -10,12 +38,17 @@ import { onMounted, onUpdated } from 'vue'
|
||||
*/
|
||||
export function useLucideIcons() {
|
||||
const refresh = () => {
|
||||
debouncedRefresh()
|
||||
}
|
||||
|
||||
// Немедленное обновление при монтировании
|
||||
onMounted(() => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refresh)
|
||||
})
|
||||
|
||||
// Debounced обновление при изменениях
|
||||
onUpdated(refresh)
|
||||
|
||||
return { refreshIcons: refresh }
|
||||
|
||||
79
front_vue/src/composables/useToast.js
Normal file
79
front_vue/src/composables/useToast.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
// Глобальное состояние уведомлений (singleton)
|
||||
const toasts = ref([])
|
||||
let toastId = 0
|
||||
|
||||
/**
|
||||
* Composable для управления toast-уведомлениями
|
||||
*
|
||||
* Использование:
|
||||
* import { useToast } from '@/composables/useToast'
|
||||
* const toast = useToast()
|
||||
* toast.success('Сохранено!')
|
||||
* toast.error('Ошибка!')
|
||||
* toast.info('Информация')
|
||||
* toast.warning('Внимание')
|
||||
*/
|
||||
export function useToast() {
|
||||
/**
|
||||
* Добавить уведомление
|
||||
* @param {string} message - Текст уведомления
|
||||
* @param {'success'|'error'|'info'|'warning'} type - Тип уведомления
|
||||
* @param {number} duration - Время показа в мс (0 = не скрывать автоматически)
|
||||
*/
|
||||
const add = (message, type = 'info', duration = 3000) => {
|
||||
const id = ++toastId
|
||||
|
||||
toasts.value.push({
|
||||
id,
|
||||
message,
|
||||
type,
|
||||
duration, // Для progress bar в UI
|
||||
visible: true
|
||||
})
|
||||
|
||||
// Автоматическое скрытие
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
remove(id)
|
||||
}, duration)
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* Удалить уведомление
|
||||
*/
|
||||
const remove = (id) => {
|
||||
const index = toasts.value.findIndex(t => t.id === id)
|
||||
if (index !== -1) {
|
||||
toasts.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Очистить все уведомления
|
||||
*/
|
||||
const clear = () => {
|
||||
toasts.value = []
|
||||
}
|
||||
|
||||
// Хелперы для разных типов
|
||||
const success = (message, duration = 3000) => add(message, 'success', duration)
|
||||
const error = (message, duration = 4000) => add(message, 'error', duration)
|
||||
const info = (message, duration = 3000) => add(message, 'info', duration)
|
||||
const warning = (message, duration = 3500) => add(message, 'warning', duration)
|
||||
|
||||
return {
|
||||
toasts,
|
||||
add,
|
||||
remove,
|
||||
clear,
|
||||
success,
|
||||
error,
|
||||
info,
|
||||
warning
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,17 @@ serverSettings.init()
|
||||
// Регистрация Service Worker для PWA
|
||||
registerSW({ immediate: true })
|
||||
|
||||
// Автообновление страницы (F5) по таймеру
|
||||
const initAutoRefresh = () => {
|
||||
const seconds = window.APP_CONFIG?.AUTO_REFRESH_SECONDS || 0
|
||||
if (seconds > 0) {
|
||||
setInterval(() => {
|
||||
window.location.reload()
|
||||
}, seconds * 1000)
|
||||
}
|
||||
}
|
||||
initAutoRefresh()
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
@@ -3,7 +3,10 @@ import MainApp from './views/MainApp.vue'
|
||||
import LoginPage from './views/LoginPage.vue'
|
||||
import TeamPage from './views/TeamPage.vue'
|
||||
import ArchivePage from './views/ArchivePage.vue'
|
||||
import NoProjectsPage from './views/NoProjectsPage.vue'
|
||||
import InvitesPage from './views/InvitesPage.vue'
|
||||
import { authApi } from './api'
|
||||
import { useProjectsStore } from './stores/projects'
|
||||
|
||||
// Кэш авторизации (чтобы не делать запрос при каждой навигации)
|
||||
let authCache = {
|
||||
@@ -59,18 +62,30 @@ const routes = [
|
||||
path: '/',
|
||||
name: 'main',
|
||||
component: MainApp,
|
||||
meta: { requiresAuth: true }
|
||||
meta: { requiresAuth: true, requiresProject: true }
|
||||
},
|
||||
{
|
||||
path: '/team',
|
||||
name: 'team',
|
||||
component: TeamPage,
|
||||
meta: { requiresAuth: true }
|
||||
meta: { requiresAuth: true, requiresProject: true }
|
||||
},
|
||||
{
|
||||
path: '/archive',
|
||||
name: 'archive',
|
||||
component: ArchivePage,
|
||||
meta: { requiresAuth: true, requiresProject: true }
|
||||
},
|
||||
{
|
||||
path: '/no-projects',
|
||||
name: 'no-projects',
|
||||
component: NoProjectsPage,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/invites',
|
||||
name: 'invites',
|
||||
component: InvitesPage,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
@@ -85,10 +100,11 @@ const router = createRouter({
|
||||
routes
|
||||
})
|
||||
|
||||
// Navigation guard — проверка авторизации
|
||||
// Navigation guard — проверка авторизации и наличия проектов
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
// Если переходим между защищёнными страницами и кэш валиден — не проверяем сеть
|
||||
const needsAuth = to.meta.requiresAuth
|
||||
const needsProject = to.meta.requiresProject
|
||||
const fromProtected = from.meta?.requiresAuth
|
||||
|
||||
// Форсируем проверку только при переходе на защищённую страницу извне
|
||||
@@ -100,12 +116,44 @@ router.beforeEach(async (to, from, next) => {
|
||||
if (needsAuth && !isAuth) {
|
||||
// Не авторизован — на логин
|
||||
next('/login')
|
||||
} else if (to.path === '/login' && isAuth) {
|
||||
return
|
||||
}
|
||||
|
||||
if (to.path === '/login' && isAuth) {
|
||||
// Уже авторизован — на главную
|
||||
next('/')
|
||||
} else {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
// Проверка наличия проектов для страниц, которые их требуют
|
||||
if (needsProject && isAuth) {
|
||||
const store = useProjectsStore()
|
||||
|
||||
// Инициализируем store если ещё не инициализирован
|
||||
if (!store.initialized) {
|
||||
await store.init()
|
||||
}
|
||||
|
||||
// Нет проектов — на страницу /no-projects
|
||||
if (store.projects.length === 0) {
|
||||
next('/no-projects')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Если на /no-projects но проекты есть — на главную
|
||||
if (to.path === '/no-projects' && isAuth) {
|
||||
const store = useProjectsStore()
|
||||
if (!store.initialized) {
|
||||
await store.init()
|
||||
}
|
||||
if (store.projects.length > 0) {
|
||||
next('/')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -74,5 +74,29 @@ export const DIALOGS = {
|
||||
message: '', // Будет задан динамически
|
||||
confirmText: 'Удалить',
|
||||
variant: 'danger'
|
||||
},
|
||||
|
||||
// Удаление участника из проекта
|
||||
removeMember: {
|
||||
title: 'Удалить участника?',
|
||||
message: '', // Будет задан динамически
|
||||
confirmText: 'Удалить',
|
||||
variant: 'danger'
|
||||
},
|
||||
|
||||
// Выход из проекта
|
||||
leaveProject: {
|
||||
title: 'Выйти из проекта?',
|
||||
message: 'Вы потеряете доступ к задачам<br>и данным этого проекта.',
|
||||
confirmText: 'Выйти',
|
||||
variant: 'danger'
|
||||
},
|
||||
|
||||
// Выход из системы
|
||||
logout: {
|
||||
title: 'Выйти из аккаунта?',
|
||||
message: 'Вы будете перенаправлены<br>на страницу входа.',
|
||||
confirmText: 'Выйти',
|
||||
variant: 'warning'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,51 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { projectsApi, usersApi, cardsApi } from '../api'
|
||||
import { projectsApi, usersApi, cardsApi, projectInviteApi } from '../api'
|
||||
import { getCachedUser } from '../router'
|
||||
|
||||
// ==================== ЛОКАЛЬНЫЙ ПОРЯДОК ПРОЕКТОВ ====================
|
||||
const PROJECTS_ORDER_KEY = 'projectsOrder'
|
||||
|
||||
// Получить сохранённый порядок из localStorage
|
||||
const getLocalProjectsOrder = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem(PROJECTS_ORDER_KEY)
|
||||
return saved ? JSON.parse(saved) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Сохранить порядок в localStorage
|
||||
const saveLocalProjectsOrder = (ids) => {
|
||||
localStorage.setItem(PROJECTS_ORDER_KEY, JSON.stringify(ids))
|
||||
}
|
||||
|
||||
// Применить локальный порядок к массиву проектов
|
||||
const applyLocalOrder = (projectsArray) => {
|
||||
const savedOrder = getLocalProjectsOrder()
|
||||
if (!savedOrder.length) return projectsArray
|
||||
|
||||
// Создаём Map для быстрого доступа
|
||||
const projectsMap = new Map(projectsArray.map(p => [p.id, p]))
|
||||
const result = []
|
||||
|
||||
// Сначала добавляем проекты в сохранённом порядке
|
||||
for (const id of savedOrder) {
|
||||
if (projectsMap.has(id)) {
|
||||
result.push(projectsMap.get(id))
|
||||
projectsMap.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
// Затем добавляем новые проекты (которых нет в сохранённом порядке)
|
||||
for (const project of projectsMap.values()) {
|
||||
result.push(project)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export const useProjectsStore = defineStore('projects', () => {
|
||||
// ==================== СОСТОЯНИЕ ====================
|
||||
const projects = ref([])
|
||||
@@ -16,6 +59,7 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
const loading = ref(false)
|
||||
const initialized = ref(false)
|
||||
const currentUser = ref(null) // Текущий авторизованный пользователь
|
||||
const pendingInvitesCount = ref(0) // Количество pending-приглашений
|
||||
|
||||
// Текущий проект (из localStorage)
|
||||
const savedProjectId = localStorage.getItem('currentProjectId')
|
||||
@@ -35,8 +79,8 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
return project ? Number(project.id_ready) : null
|
||||
})
|
||||
|
||||
// ID текущего пользователя
|
||||
const currentUserId = computed(() => currentUser.value?.id || null)
|
||||
// ID текущего пользователя (приводим к числу для корректного сравнения)
|
||||
const currentUserId = computed(() => currentUser.value?.id ? Number(currentUser.value.id) : null)
|
||||
|
||||
// Имя текущего пользователя
|
||||
const currentUserName = computed(() => currentUser.value?.name || '')
|
||||
@@ -45,12 +89,81 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
const currentUserAvatar = computed(() => currentUser.value?.avatar_url || '')
|
||||
|
||||
// Является ли текущий пользователь админом проекта
|
||||
// Сервер возвращает id_admin: true только если текущий пользователь — админ
|
||||
const isProjectAdmin = computed(() => {
|
||||
const project = projects.value.find(p => p.id === currentProjectId.value)
|
||||
return project?.id_admin === true
|
||||
return project?.is_admin === true
|
||||
})
|
||||
|
||||
// Права текущего пользователя в проекте (загружаются с users)
|
||||
const currentUserPermissions = computed(() => {
|
||||
if (!currentUserId.value) return {}
|
||||
const member = users.value.find(u => u.id_user === currentUserId.value)
|
||||
return member?.permissions || {}
|
||||
})
|
||||
|
||||
// Проверка конкретного права (админ имеет все права)
|
||||
const can = (permission) => {
|
||||
if (isProjectAdmin.value) return true
|
||||
return currentUserPermissions.value[permission] === true
|
||||
}
|
||||
|
||||
// Проверка права на редактирование задачи
|
||||
const canEditTask = (task) => {
|
||||
if (!currentUserId.value) return false
|
||||
if (isProjectAdmin.value) return true
|
||||
if (can('edit_task')) return true
|
||||
|
||||
const creatorId = task?.create_id_account ? Number(task.create_id_account) : null
|
||||
// accountId (mapped) или id_account (raw)
|
||||
const assigneeId = task?.accountId ? Number(task.accountId) : (task?.id_account ? Number(task.id_account) : null)
|
||||
|
||||
// Создатель + право создания → может редактировать
|
||||
if (creatorId === currentUserId.value && can('create_task')) return true
|
||||
|
||||
// Назначена на себя + право edit_own_task_only
|
||||
if (assigneeId === currentUserId.value && can('edit_own_task_only')) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Проверка права на перемещение задачи
|
||||
const canMoveTask = (task) => {
|
||||
if (!currentUserId.value) return false
|
||||
if (isProjectAdmin.value) return true
|
||||
if (can('move_task')) return true
|
||||
|
||||
const creatorId = task?.create_id_account ? Number(task.create_id_account) : null
|
||||
// accountId (mapped) или id_account (raw)
|
||||
const assigneeId = task?.accountId ? Number(task.accountId) : (task?.id_account ? Number(task.id_account) : null)
|
||||
|
||||
// Создатель + право создания → может перемещать
|
||||
if (creatorId === currentUserId.value && can('create_task')) return true
|
||||
|
||||
// Назначена на себя + право move_own_task_only
|
||||
if (assigneeId === currentUserId.value && can('move_own_task_only')) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Проверка права на создание комментария в задаче
|
||||
const canCreateComment = (task) => {
|
||||
if (!currentUserId.value) return false
|
||||
if (isProjectAdmin.value) return true
|
||||
if (can('create_comment')) return true
|
||||
|
||||
const creatorId = task?.create_id_account ? Number(task.create_id_account) : null
|
||||
// accountId (mapped) или id_account (raw)
|
||||
const assigneeId = task?.accountId ? Number(task.accountId) : (task?.id_account ? Number(task.id_account) : null)
|
||||
|
||||
// Создатель + право создания → может комментировать
|
||||
if (creatorId === currentUserId.value && can('create_task')) return true
|
||||
|
||||
// Назначена на себя + право create_comment_own_task_only
|
||||
if (assigneeId === currentUserId.value && can('create_comment_own_task_only')) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ==================== ДЕЙСТВИЯ ====================
|
||||
// Инициализация (загрузка проектов + данных активного)
|
||||
const init = async () => {
|
||||
@@ -64,7 +177,8 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
|
||||
if (result.success) {
|
||||
if (result.data.projects) {
|
||||
projects.value = result.data.projects
|
||||
// Применяем локальный порядок сортировки
|
||||
projects.value = applyLocalOrder(result.data.projects)
|
||||
|
||||
// Применяем данные активного проекта
|
||||
if (result.data.active) {
|
||||
@@ -73,11 +187,18 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
labels.value = result.data.active.labels
|
||||
}
|
||||
} else {
|
||||
projects.value = result.data
|
||||
// Применяем локальный порядок сортировки
|
||||
projects.value = applyLocalOrder(result.data)
|
||||
}
|
||||
|
||||
// Если нет проектов — очищаем currentProjectId
|
||||
if (projects.value.length === 0) {
|
||||
currentProjectId.value = null
|
||||
localStorage.removeItem('currentProjectId')
|
||||
localStorage.removeItem('currentProjectName')
|
||||
}
|
||||
// Если нет выбранного проекта — выбираем первый
|
||||
if (!currentProjectId.value || !projects.value.find(p => p.id === currentProjectId.value)) {
|
||||
else if (!currentProjectId.value || !projects.value.find(p => p.id === currentProjectId.value)) {
|
||||
if (projects.value.length > 0) {
|
||||
await selectProject(projects.value[0].id, true) // Загружаем данные проекта
|
||||
}
|
||||
@@ -91,20 +212,22 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Загружаем пользователей
|
||||
const usersData = await usersApi.getAll()
|
||||
if (usersData.success) users.value = usersData.data
|
||||
|
||||
// Получаем текущего пользователя из кэша роутера (без повторного запроса)
|
||||
// Получаем текущего пользователя из кэша роутера
|
||||
if (!currentUser.value) {
|
||||
const cachedUser = getCachedUser()
|
||||
if (cachedUser) {
|
||||
// Находим полные данные пользователя (с id) из списка users
|
||||
const fullUser = users.value.find(u => u.username === cachedUser.username)
|
||||
currentUser.value = fullUser || cachedUser
|
||||
currentUser.value = cachedUser
|
||||
}
|
||||
}
|
||||
|
||||
// Загружаем участников проекта (users теперь привязаны к проекту)
|
||||
if (currentProjectId.value) {
|
||||
await fetchUsers()
|
||||
// Загружаем количество pending-приглашений (только если есть проекты)
|
||||
// Если проектов нет — NoProjectsPage сама загрузит полные данные с count
|
||||
await fetchPendingInvitesCount()
|
||||
}
|
||||
|
||||
initialized.value = true
|
||||
} catch (error) {
|
||||
console.error('Ошибка инициализации:', error)
|
||||
@@ -113,6 +236,60 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Принудительная перезагрузка проектов (для обновления после принятия приглашения/выхода из проекта)
|
||||
const refreshProjects = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const result = await projectsApi.getAll()
|
||||
|
||||
if (result.success) {
|
||||
if (result.data.projects) {
|
||||
// Применяем локальный порядок сортировки
|
||||
projects.value = applyLocalOrder(result.data.projects)
|
||||
} else {
|
||||
// Применяем локальный порядок сортировки
|
||||
projects.value = applyLocalOrder(result.data)
|
||||
}
|
||||
|
||||
// Если нет проектов — очищаем currentProjectId
|
||||
if (projects.value.length === 0) {
|
||||
currentProjectId.value = null
|
||||
columns.value = []
|
||||
departments.value = []
|
||||
labels.value = []
|
||||
users.value = []
|
||||
cards.value = []
|
||||
archivedCards.value = []
|
||||
localStorage.removeItem('currentProjectId')
|
||||
localStorage.removeItem('currentProjectName')
|
||||
}
|
||||
// Проверяем, есть ли текущий проект в списке
|
||||
else if (currentProjectId.value && !projects.value.find(p => p.id === currentProjectId.value)) {
|
||||
// Текущий проект больше недоступен — переключаемся на первый
|
||||
await selectProject(projects.value[0].id, true)
|
||||
}
|
||||
}
|
||||
|
||||
// Устанавливаем текущего пользователя из кэша (если ещё не установлен)
|
||||
if (!currentUser.value) {
|
||||
const cachedUser = getCachedUser()
|
||||
if (cachedUser) {
|
||||
currentUser.value = cachedUser
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем количество pending-приглашений
|
||||
await fetchPendingInvitesCount()
|
||||
|
||||
initialized.value = true
|
||||
} catch (error) {
|
||||
console.error('Ошибка перезагрузки проектов:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Выбор проекта
|
||||
const selectProject = async (projectId, fetchData = true) => {
|
||||
currentProjectId.value = projectId
|
||||
@@ -140,17 +317,46 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
departments.value = projectData.data.departments
|
||||
labels.value = projectData.data.labels
|
||||
|
||||
// Обновляем id_admin в списке проектов (сервер возвращает true если текущий пользователь админ)
|
||||
// Обновляем is_admin в списке проектов
|
||||
const project = projects.value.find(p => p.id === currentProjectId.value)
|
||||
if (project && projectData.data.project?.id_admin === true) {
|
||||
project.id_admin = true
|
||||
if (project && projectData.data.project?.is_admin === true) {
|
||||
project.is_admin = true
|
||||
}
|
||||
}
|
||||
|
||||
// Загружаем участников проекта
|
||||
await fetchUsers()
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки данных проекта:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Загрузка участников проекта
|
||||
const fetchUsers = async () => {
|
||||
if (!currentProjectId.value) return
|
||||
|
||||
try {
|
||||
const usersData = await usersApi.getAll(currentProjectId.value)
|
||||
if (usersData.success) {
|
||||
users.value = usersData.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки участников:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Загрузка количества pending-приглашений
|
||||
const fetchPendingInvitesCount = async () => {
|
||||
try {
|
||||
const result = await projectInviteApi.getCount()
|
||||
if (result.success) {
|
||||
pendingInvitesCount.value = result.count
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки приглашений:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== КАРТОЧКИ ====================
|
||||
// Загрузка активных карточек (silent = тихое обновление без loading)
|
||||
const fetchCards = async (silent = false) => {
|
||||
@@ -221,9 +427,11 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
archivedCards.value = []
|
||||
currentProjectId.value = null
|
||||
currentUser.value = null
|
||||
pendingInvitesCount.value = 0
|
||||
initialized.value = false
|
||||
localStorage.removeItem('currentProjectId')
|
||||
localStorage.removeItem('currentProjectName')
|
||||
localStorage.removeItem(PROJECTS_ORDER_KEY)
|
||||
}
|
||||
|
||||
// ==================== CRUD ПРОЕКТОВ ====================
|
||||
@@ -233,13 +441,19 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
const result = await projectsApi.create(name)
|
||||
if (result.success) {
|
||||
// Добавляем проект в список
|
||||
projects.value.push({
|
||||
const newProject = {
|
||||
id: result.id,
|
||||
name,
|
||||
id_order: projects.value.length + 1,
|
||||
id_ready: result.id_ready,
|
||||
id_admin: true // Создатель = админ
|
||||
})
|
||||
is_admin: true // Создатель = админ
|
||||
}
|
||||
projects.value.push(newProject)
|
||||
|
||||
// Добавляем в локальный порядок
|
||||
const currentOrder = getLocalProjectsOrder()
|
||||
currentOrder.push(result.id)
|
||||
saveLocalProjectsOrder(currentOrder)
|
||||
|
||||
// Переключаемся на новый проект
|
||||
await selectProject(result.id)
|
||||
}
|
||||
@@ -270,6 +484,12 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
if (index !== -1) {
|
||||
projects.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// Удаляем из локального порядка
|
||||
const currentOrder = getLocalProjectsOrder()
|
||||
const filteredOrder = currentOrder.filter(pid => pid !== id)
|
||||
saveLocalProjectsOrder(filteredOrder)
|
||||
|
||||
// Если удалили текущий проект — переключаемся на первый
|
||||
if (id === currentProjectId.value) {
|
||||
if (projects.value.length > 0) {
|
||||
@@ -287,17 +507,17 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
return result
|
||||
}
|
||||
|
||||
// Обновление порядка проектов
|
||||
const reorderProjects = async (ids) => {
|
||||
// Оптимистичное обновление
|
||||
// Обновление порядка проектов (локально, без отправки на сервер)
|
||||
const reorderProjects = (ids) => {
|
||||
// Применяем новый порядок
|
||||
const reordered = ids.map((id, index) => {
|
||||
const project = projects.value.find(p => p.id === id)
|
||||
return { ...project, id_order: index + 1 }
|
||||
})
|
||||
return { ...project }
|
||||
}).filter(Boolean)
|
||||
projects.value = reordered
|
||||
|
||||
// Отправляем на сервер
|
||||
await projectsApi.updateOrder(ids)
|
||||
// Сохраняем порядок локально
|
||||
saveLocalProjectsOrder(ids)
|
||||
}
|
||||
|
||||
// ==================== CRUD КОЛОНОК ====================
|
||||
@@ -390,6 +610,7 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
initialized,
|
||||
currentProjectId,
|
||||
currentUser,
|
||||
pendingInvitesCount,
|
||||
// Геттеры
|
||||
currentProject,
|
||||
doneColumnId,
|
||||
@@ -397,10 +618,19 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
currentUserName,
|
||||
currentUserAvatar,
|
||||
isProjectAdmin,
|
||||
currentUserPermissions,
|
||||
// Проверки прав
|
||||
can,
|
||||
canEditTask,
|
||||
canMoveTask,
|
||||
canCreateComment,
|
||||
// Действия
|
||||
init,
|
||||
refreshProjects,
|
||||
selectProject,
|
||||
fetchProjectData,
|
||||
fetchUsers,
|
||||
fetchPendingInvitesCount,
|
||||
fetchCards,
|
||||
fetchArchivedCards,
|
||||
clearCards,
|
||||
|
||||
@@ -292,6 +292,7 @@ const handleRestoreFromPanel = async (cardId) => {
|
||||
|
||||
// ==================== ИНИЦИАЛИЗАЦИЯ ====================
|
||||
onMounted(async () => {
|
||||
// Store уже мог быть инициализирован в роутере
|
||||
await store.init()
|
||||
await fetchCards()
|
||||
|
||||
|
||||
548
front_vue/src/views/InvitesPage.vue
Normal file
548
front_vue/src/views/InvitesPage.vue
Normal file
@@ -0,0 +1,548 @@
|
||||
<template>
|
||||
<PageLayout>
|
||||
<Header title="Приглашения">
|
||||
<template #stats>
|
||||
<div v-if="invites.length > 0" class="header-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-value">{{ invites.length }}</span>
|
||||
<span class="stat-label">{{ invitesLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Header>
|
||||
|
||||
<main class="main" :class="{ mobile: isMobile }">
|
||||
<Loader v-if="loading" />
|
||||
|
||||
<div v-else-if="invites.length === 0" class="empty-state" :class="{ mobile: isMobile }">
|
||||
<i data-lucide="inbox"></i>
|
||||
<p>Нет приглашений</p>
|
||||
<span>Когда вас пригласят в проект, приглашение появится здесь</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="invites-list" :class="{ mobile: isMobile }">
|
||||
<div
|
||||
v-for="invite in invites"
|
||||
:key="invite.id"
|
||||
class="invite-card"
|
||||
:class="{ processing: processingId === invite.id, mobile: isMobile }"
|
||||
>
|
||||
<!-- Desktop версия -->
|
||||
<template v-if="!isMobile">
|
||||
<!-- Аватар пользователя -->
|
||||
<div class="user-avatar">
|
||||
<img v-if="invite.from_user.avatar_url" :src="getFullUrl(invite.from_user.avatar_url)" :alt="invite.from_user.name || invite.from_user.username">
|
||||
<span v-else class="avatar-initials">{{ (invite.from_user.name || invite.from_user.username || '?')[0] }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Основной контент -->
|
||||
<div class="card-main">
|
||||
<span class="user-name">{{ invite.from_user.name || invite.from_user.username }}</span>
|
||||
<span class="invite-separator">→</span>
|
||||
<div class="project-info">
|
||||
<i data-lucide="folder"></i>
|
||||
<span class="project-label">Проект:</span>
|
||||
<span class="project-name">{{ invite.project_name }}</span>
|
||||
</div>
|
||||
<span v-if="invite.is_admin" class="admin-badge">Админ</span>
|
||||
</div>
|
||||
|
||||
<!-- Дата -->
|
||||
<div class="card-date">{{ formatDate(invite.created_at) }}</div>
|
||||
|
||||
<!-- Кнопки -->
|
||||
<div class="card-actions">
|
||||
<button
|
||||
class="btn-action btn-decline"
|
||||
:disabled="processingId === invite.id"
|
||||
@click.stop="declineInvite(invite)"
|
||||
title="Отклонить"
|
||||
>
|
||||
<i data-lucide="x"></i>
|
||||
Отклонить
|
||||
</button>
|
||||
<button
|
||||
class="btn-action btn-accept"
|
||||
:disabled="processingId === invite.id"
|
||||
@click.stop="acceptInvite(invite)"
|
||||
title="Принять"
|
||||
>
|
||||
<i data-lucide="check"></i>
|
||||
Принять
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Mobile версия -->
|
||||
<template v-else>
|
||||
<div class="card-row">
|
||||
<div class="user-avatar">
|
||||
<img v-if="invite.from_user.avatar_url" :src="getFullUrl(invite.from_user.avatar_url)" :alt="invite.from_user.name || invite.from_user.username">
|
||||
<span v-else class="avatar-initials">{{ (invite.from_user.name || invite.from_user.username || '?')[0] }}</span>
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<div class="card-top">
|
||||
<span class="user-name">{{ invite.from_user.name || invite.from_user.username }}</span>
|
||||
<span v-if="invite.is_admin" class="admin-badge">Админ</span>
|
||||
</div>
|
||||
<div class="card-bottom">
|
||||
<div class="project-info">
|
||||
<i data-lucide="folder"></i>
|
||||
<span class="project-label">Проект:</span>
|
||||
<span class="project-name">{{ invite.project_name }}</span>
|
||||
</div>
|
||||
<span class="card-date">{{ formatDate(invite.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<button
|
||||
class="btn-action btn-decline"
|
||||
:disabled="processingId === invite.id"
|
||||
@click.stop="declineInvite(invite)"
|
||||
>
|
||||
<i data-lucide="x"></i>
|
||||
Отклонить
|
||||
</button>
|
||||
<button
|
||||
class="btn-action btn-accept"
|
||||
:disabled="processingId === invite.id"
|
||||
@click.stop="acceptInvite(invite)"
|
||||
>
|
||||
<i data-lucide="check"></i>
|
||||
Принять
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</PageLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUpdated, watch, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import PageLayout from '../components/PageLayout.vue'
|
||||
import Header from '../components/Header.vue'
|
||||
import Loader from '../components/ui/Loader.vue'
|
||||
import { getFullUrl, projectInviteApi } from '../api'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
import { useDateFormat } from '../composables/useDateFormat'
|
||||
import { useProjectsStore } from '../stores/projects'
|
||||
import { useToast } from '../composables/useToast'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useProjectsStore()
|
||||
const toast = useToast()
|
||||
const { isMobile } = useMobile()
|
||||
const { formatRelative } = useDateFormat()
|
||||
|
||||
const loading = ref(true)
|
||||
const invites = ref([])
|
||||
const processingId = ref(null)
|
||||
|
||||
// Склонение слова "приглашение"
|
||||
const invitesLabel = computed(() => {
|
||||
const count = invites.value.length
|
||||
if (count === 1) return 'приглашение'
|
||||
if (count >= 2 && count <= 4) return 'приглашения'
|
||||
return 'приглашений'
|
||||
})
|
||||
|
||||
// Форматирование даты
|
||||
const formatDate = (dateStr) => {
|
||||
return formatRelative(dateStr)
|
||||
}
|
||||
|
||||
// Загрузка приглашений
|
||||
const loadInvites = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await projectInviteApi.getMyPending()
|
||||
if (result.success) {
|
||||
invites.value = result.data
|
||||
// Обновляем счётчик в store
|
||||
store.pendingInvitesCount = result.count
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки приглашений:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Принять приглашение
|
||||
const acceptInvite = async (invite) => {
|
||||
processingId.value = invite.id
|
||||
try {
|
||||
const result = await projectInviteApi.accept(invite.id)
|
||||
if (result.success) {
|
||||
toast.success(`Вы присоединились к проекту «${invite.project_name}»`)
|
||||
// Удаляем из списка
|
||||
invites.value = invites.value.filter(i => i.id !== invite.id)
|
||||
// Обновляем счётчик
|
||||
store.pendingInvitesCount = invites.value.length
|
||||
// Принудительно обновляем список проектов
|
||||
await store.refreshProjects()
|
||||
// Переходим к проекту
|
||||
await store.selectProject(result.project_id)
|
||||
router.push('/')
|
||||
} else {
|
||||
toast.error('Ошибка принятия приглашения')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка принятия приглашения:', error)
|
||||
toast.error('Ошибка принятия приглашения')
|
||||
} finally {
|
||||
processingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Отклонить приглашение
|
||||
const declineInvite = async (invite) => {
|
||||
processingId.value = invite.id
|
||||
try {
|
||||
const result = await projectInviteApi.decline(invite.id)
|
||||
if (result.success) {
|
||||
toast.info('Приглашение отклонено')
|
||||
invites.value = invites.value.filter(i => i.id !== invite.id)
|
||||
// Обновляем счётчик
|
||||
store.pendingInvitesCount = invites.value.length
|
||||
} else {
|
||||
toast.error('Ошибка отклонения приглашения')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка отклонения приглашения:', error)
|
||||
toast.error('Ошибка отклонения приглашения')
|
||||
} finally {
|
||||
processingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Обновление иконок
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}
|
||||
|
||||
// Следим за сменой режима mobile/desktop
|
||||
watch(isMobile, async () => {
|
||||
await nextTick()
|
||||
refreshIcons()
|
||||
})
|
||||
|
||||
// Обновляем иконки когда данные загрузились
|
||||
watch(loading, async (newVal, oldVal) => {
|
||||
if (oldVal === true && newVal === false) {
|
||||
await nextTick()
|
||||
refreshIcons()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
loadInvites()
|
||||
await nextTick()
|
||||
refreshIcons()
|
||||
})
|
||||
|
||||
onUpdated(refreshIcons)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ========== MAIN ========== */
|
||||
.main {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.main.mobile {
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
/* ========== ПУСТОЕ СОСТОЯНИЕ ========== */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 20px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
opacity: 0.4;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.empty-state span {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.empty-state.mobile {
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.empty-state.mobile i {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
/* ========== СПИСОК ========== */
|
||||
.invites-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-width: 1200px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.invites-list.mobile {
|
||||
max-width: none;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ========== DESKTOP: КАРТОЧКА ========== */
|
||||
.invite-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.invite-card:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.invite-card.processing {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Аватар пользователя */
|
||||
.user-avatar {
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: var(--blue, #3b82f6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.user-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-initials {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Основной контент */
|
||||
.card-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.invite-separator {
|
||||
color: var(--text-muted);
|
||||
opacity: 0.4;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.project-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.project-info i {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.project-label,
|
||||
.project-name {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.project-name {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-badge {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
padding: 3px 6px;
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
color: #f59e0b;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Дата */
|
||||
.card-date {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.7;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Кнопки */
|
||||
.card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-action i {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.btn-decline:hover {
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.btn-accept {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.btn-accept:hover {
|
||||
background: #00e6b8;
|
||||
border-color: #00e6b8;
|
||||
}
|
||||
|
||||
.btn-action:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ========== MOBILE: КАРТОЧКА ========== */
|
||||
.invite-card.mobile {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.invite-card.mobile .card-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.invite-card.mobile .user-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.invite-card.mobile .avatar-initials {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.invite-card.mobile .card-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.invite-card.mobile .card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.invite-card.mobile .user-name {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.invite-card.mobile .card-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.invite-card.mobile .project-label,
|
||||
.invite-card.mobile .project-name {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.invite-card.mobile .project-info i {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
}
|
||||
|
||||
.invite-card.mobile .card-date {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.invite-card.mobile .card-actions {
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.invite-card.mobile .btn-action {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -21,7 +21,7 @@
|
||||
<div class="bg-glow glow-3"></div>
|
||||
|
||||
<!-- Контент авторизации -->
|
||||
<div class="login-content" :class="{ 'is-loading': isAuthenticating, 'is-success': showSuccess }">
|
||||
<div class="login-content" :class="{ 'is-loading': isProcessing, 'is-success': showSuccess }">
|
||||
|
||||
<!-- Состояние успеха -->
|
||||
<Transition name="success-fade">
|
||||
@@ -29,15 +29,15 @@
|
||||
<div class="success-icon">
|
||||
<i data-lucide="check"></i>
|
||||
</div>
|
||||
<h1 class="success-title">Добро пожаловать!</h1>
|
||||
<p class="success-text">Авторизация прошла успешно</p>
|
||||
<h1 class="success-title">{{ isRegisterMode ? 'Регистрация завершена!' : 'Добро пожаловать!' }}</h1>
|
||||
<p class="success-text">{{ isRegisterMode ? 'Сейчас вы будете перенаправлены' : 'Авторизация прошла успешно' }}</p>
|
||||
<div class="success-loader">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Форма входа -->
|
||||
<!-- Форма входа / регистрации -->
|
||||
<Transition name="form-fade">
|
||||
<div v-if="!showSuccess" class="login-form-wrapper">
|
||||
<!-- Логотип -->
|
||||
@@ -46,10 +46,13 @@
|
||||
<i data-lucide="layout-grid"></i>
|
||||
</div>
|
||||
<h1 class="login-title">TaskBoard</h1>
|
||||
<p class="login-subtitle">Войдите в систему управления задачами</p>
|
||||
<p class="login-subtitle">
|
||||
{{ isRegisterMode ? 'Создайте аккаунт для работы с задачами' : 'Войдите в систему управления задачами' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleLogin" class="login-form">
|
||||
<!-- Форма входа -->
|
||||
<form v-if="!isRegisterMode" @submit.prevent="handleLogin" class="login-form">
|
||||
<!-- Поле логина -->
|
||||
<div class="input-group" :class="{ 'has-value': login, 'has-focus': loginFocused }">
|
||||
<div class="input-icon">
|
||||
@@ -62,7 +65,7 @@
|
||||
@blur="loginFocused = false"
|
||||
placeholder="Введите логин"
|
||||
autocomplete="username"
|
||||
:disabled="isAuthenticating"
|
||||
:disabled="isProcessing"
|
||||
>
|
||||
<div class="input-line"></div>
|
||||
</div>
|
||||
@@ -79,7 +82,7 @@
|
||||
@blur="passwordFocused = false"
|
||||
placeholder="Введите пароль"
|
||||
autocomplete="current-password"
|
||||
:disabled="isAuthenticating"
|
||||
:disabled="isProcessing"
|
||||
>
|
||||
<div class="input-line"></div>
|
||||
</div>
|
||||
@@ -96,8 +99,8 @@
|
||||
<button
|
||||
type="submit"
|
||||
class="login-btn"
|
||||
:class="{ 'is-loading': isAuthenticating }"
|
||||
:disabled="isAuthenticating || !login || !password"
|
||||
:class="{ 'is-loading': isProcessing }"
|
||||
:disabled="isProcessing || !login || !password"
|
||||
>
|
||||
<span class="btn-text">Войти</span>
|
||||
<span class="btn-loader">
|
||||
@@ -105,6 +108,118 @@
|
||||
</span>
|
||||
<i data-lucide="arrow-right" class="btn-arrow"></i>
|
||||
</button>
|
||||
|
||||
<!-- Ссылка на регистрацию -->
|
||||
<div class="form-switch">
|
||||
<span>Нет аккаунта?</span>
|
||||
<button type="button" class="switch-btn" @click="switchToRegister">Зарегистрироваться</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Форма регистрации -->
|
||||
<form v-else @submit.prevent="handleRegister" class="login-form">
|
||||
<!-- Логин -->
|
||||
<div class="input-group" :class="{ 'has-value': regUsername, 'has-focus': regUsernameFocused, 'has-error': errors.username, 'shake': shakeUsername }">
|
||||
<div class="input-icon">
|
||||
<i data-lucide="at-sign"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
v-model="regUsername"
|
||||
@focus="regUsernameFocused = true"
|
||||
@blur="regUsernameFocused = false"
|
||||
@input="filterUsername"
|
||||
placeholder="Придумайте логин"
|
||||
autocomplete="username"
|
||||
maxlength="32"
|
||||
:disabled="isProcessing"
|
||||
>
|
||||
<div class="input-line"></div>
|
||||
</div>
|
||||
|
||||
<!-- Имя -->
|
||||
<div class="input-group" :class="{ 'has-value': regName, 'has-focus': regNameFocused, 'has-error': errors.name, 'shake': shakeName }">
|
||||
<div class="input-icon">
|
||||
<i data-lucide="user"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
v-model="regName"
|
||||
@focus="regNameFocused = true"
|
||||
@blur="regNameFocused = false"
|
||||
@input="filterName"
|
||||
placeholder="Ваше имя"
|
||||
autocomplete="name"
|
||||
maxlength="50"
|
||||
:disabled="isProcessing"
|
||||
>
|
||||
<div class="input-line"></div>
|
||||
</div>
|
||||
|
||||
<!-- Пароль -->
|
||||
<div class="input-group" :class="{ 'has-value': regPassword, 'has-focus': regPasswordFocused, 'has-error': errors.password }">
|
||||
<div class="input-icon">
|
||||
<i data-lucide="lock"></i>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
v-model="regPassword"
|
||||
@focus="regPasswordFocused = true"
|
||||
@blur="regPasswordFocused = false"
|
||||
@input="clearPasswordError"
|
||||
placeholder="Придумайте пароль"
|
||||
autocomplete="new-password"
|
||||
:disabled="isProcessing"
|
||||
>
|
||||
<div class="input-line"></div>
|
||||
</div>
|
||||
|
||||
<!-- Telegram (опционально) -->
|
||||
<div class="input-group" :class="{ 'has-value': regTelegram.length > 0, 'has-focus': regTelegramFocused, 'shake': shakeTelegram }">
|
||||
<div class="input-icon">
|
||||
<i data-lucide="send"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
v-model="regTelegram"
|
||||
@focus="regTelegramFocused = true"
|
||||
@blur="regTelegramFocused = false"
|
||||
@input="filterTelegram"
|
||||
@keydown="preventDeleteAt"
|
||||
placeholder="@telegram (необязательно)"
|
||||
autocomplete="off"
|
||||
:disabled="isProcessing"
|
||||
>
|
||||
<div class="input-line"></div>
|
||||
</div>
|
||||
|
||||
<!-- Ошибка -->
|
||||
<Transition name="error-shake">
|
||||
<div v-if="error" class="error-message">
|
||||
<i data-lucide="alert-circle"></i>
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Кнопка регистрации -->
|
||||
<button
|
||||
type="submit"
|
||||
class="login-btn"
|
||||
:class="{ 'is-loading': isProcessing }"
|
||||
:disabled="isProcessing || !regName || !regUsername || !regPassword"
|
||||
>
|
||||
<span class="btn-text">Зарегистрироваться</span>
|
||||
<span class="btn-loader">
|
||||
<span></span><span></span><span></span>
|
||||
</span>
|
||||
<i data-lucide="arrow-right" class="btn-arrow"></i>
|
||||
</button>
|
||||
|
||||
<!-- Ссылка на вход -->
|
||||
<div class="form-switch">
|
||||
<span>Уже есть аккаунт?</span>
|
||||
<button type="button" class="switch-btn" @click="switchToLogin">Войти</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -113,7 +228,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, nextTick, watch } from 'vue'
|
||||
import { ref, reactive, onMounted, nextTick, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { authApi, cardsApi } from '../api'
|
||||
import { useProjectsStore } from '../stores/projects'
|
||||
@@ -125,25 +240,124 @@ const { isMobile } = useMobile()
|
||||
const router = useRouter()
|
||||
const store = useProjectsStore()
|
||||
|
||||
// Режим: вход или регистрация
|
||||
const isRegisterMode = ref(false)
|
||||
|
||||
// Вход
|
||||
const login = ref('')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const isAuthenticating = ref(false)
|
||||
const showSuccess = ref(false)
|
||||
const loginFocused = ref(false)
|
||||
const passwordFocused = ref(false)
|
||||
|
||||
// Регистрация
|
||||
const regName = ref('')
|
||||
const regUsername = ref('')
|
||||
const regPassword = ref('')
|
||||
const regTelegram = ref('')
|
||||
const regNameFocused = ref(false)
|
||||
const regUsernameFocused = ref(false)
|
||||
const regPasswordFocused = ref(false)
|
||||
const regTelegramFocused = ref(false)
|
||||
|
||||
// Общие
|
||||
const error = ref('')
|
||||
const errors = reactive({})
|
||||
const isProcessing = ref(false)
|
||||
const showSuccess = ref(false)
|
||||
|
||||
// Состояния тряски полей
|
||||
const shakeUsername = ref(false)
|
||||
const shakeName = ref(false)
|
||||
const shakeTelegram = ref(false)
|
||||
|
||||
// Функция тряски поля
|
||||
const triggerShake = (shakeRef) => {
|
||||
shakeRef.value = true
|
||||
setTimeout(() => { shakeRef.value = false }, 400)
|
||||
}
|
||||
|
||||
// Сброс ошибки пароля при вводе
|
||||
const clearPasswordError = () => {
|
||||
if (errors.password) delete errors.password
|
||||
}
|
||||
|
||||
// Фильтрация логина — начинается с буквы, далее a-zA-Z, 0-9, _
|
||||
const filterUsername = () => {
|
||||
// Сбрасываем ошибку при вводе
|
||||
if (errors.username) delete errors.username
|
||||
|
||||
const before = regUsername.value
|
||||
let value = before.replace(/[^a-zA-Z0-9_]/g, '')
|
||||
// Первый символ должен быть буквой — убираем цифры/_ из начала
|
||||
value = value.replace(/^[0-9_]+/, '')
|
||||
regUsername.value = value
|
||||
// Если что-то отфильтровалось — тряска
|
||||
if (before !== value && before.length >= value.length) {
|
||||
triggerShake(shakeUsername)
|
||||
}
|
||||
}
|
||||
|
||||
// Фильтрация имени — только буквы, пробелы, дефис
|
||||
const filterName = () => {
|
||||
// Сбрасываем ошибку при вводе
|
||||
if (errors.name) delete errors.name
|
||||
|
||||
const before = regName.value
|
||||
let value = before.replace(/[^a-zA-Zа-яА-ЯёЁ\s\-]/g, '')
|
||||
value = value.replace(/\s+/g, ' ')
|
||||
regName.value = value
|
||||
// Если что-то отфильтровалось — тряска
|
||||
if (before !== value && before.length > value.length) {
|
||||
triggerShake(shakeName)
|
||||
}
|
||||
}
|
||||
|
||||
// Фильтрация telegram — только a-z, 0-9, _ и начинается с @
|
||||
const filterTelegram = () => {
|
||||
const before = regTelegram.value
|
||||
let value = before
|
||||
value = value.replace(/@/g, '')
|
||||
value = value.replace(/[^a-zA-Z0-9_]/g, '').toLowerCase()
|
||||
const after = value ? '@' + value : ''
|
||||
regTelegram.value = after
|
||||
// Если что-то отфильтровалось (кроме добавления @) — тряска
|
||||
const beforeClean = before.replace(/@/g, '').toLowerCase()
|
||||
if (beforeClean !== value && beforeClean.length > value.length) {
|
||||
triggerShake(shakeTelegram)
|
||||
}
|
||||
}
|
||||
|
||||
// Не даём удалить @ — очищаем поле полностью
|
||||
const preventDeleteAt = (e) => {
|
||||
if ((e.key === 'Backspace' || e.key === 'Delete') && regTelegram.value === '@') {
|
||||
e.preventDefault()
|
||||
regTelegram.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// Переключение режимов
|
||||
const switchToRegister = () => {
|
||||
error.value = ''
|
||||
Object.keys(errors).forEach(k => delete errors[k])
|
||||
isRegisterMode.value = true
|
||||
nextTick(refreshIcons)
|
||||
}
|
||||
|
||||
const switchToLogin = () => {
|
||||
error.value = ''
|
||||
Object.keys(errors).forEach(k => delete errors[k])
|
||||
isRegisterMode.value = false
|
||||
regTelegram.value = ''
|
||||
nextTick(refreshIcons)
|
||||
}
|
||||
|
||||
// Предзагрузка данных пока показывается анимация успеха
|
||||
const prefetchData = async () => {
|
||||
try {
|
||||
// Загружаем проекты, колонки, отделы, лейблы, юзеров
|
||||
await store.init()
|
||||
|
||||
// Если есть текущий проект — загружаем карточки
|
||||
if (store.currentProjectId) {
|
||||
const result = await cardsApi.getAll(store.currentProjectId)
|
||||
if (result.success) {
|
||||
// Сохраняем в sessionStorage для MainApp
|
||||
sessionStorage.setItem('prefetchedCards', JSON.stringify(result.data))
|
||||
}
|
||||
}
|
||||
@@ -152,38 +366,93 @@ const prefetchData = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Вход
|
||||
const handleLogin = async () => {
|
||||
error.value = ''
|
||||
isAuthenticating.value = true
|
||||
isProcessing.value = true
|
||||
|
||||
try {
|
||||
const data = await authApi.login(login.value, password.value)
|
||||
|
||||
if (data.success) {
|
||||
// Устанавливаем кэш авторизации (чтобы навигация между страницами была мгновенной)
|
||||
setAuthCache(true)
|
||||
|
||||
// Показываем анимацию успеха
|
||||
showSuccess.value = true
|
||||
await nextTick()
|
||||
refreshIcons()
|
||||
|
||||
// Параллельно: предзагрузка данных + ожидание анимации
|
||||
const prefetchPromise = prefetchData()
|
||||
const animationPromise = new Promise(resolve => setTimeout(resolve, 1800))
|
||||
|
||||
// Ждём обоих (минимум 1.8 сек анимации)
|
||||
await Promise.all([prefetchPromise, animationPromise])
|
||||
|
||||
router.push('/')
|
||||
} else {
|
||||
isAuthenticating.value = false
|
||||
isProcessing.value = false
|
||||
error.value = data.errors?.username || data.errors?.password || 'Неверный логин или пароль'
|
||||
await nextTick()
|
||||
refreshIcons()
|
||||
}
|
||||
} catch (e) {
|
||||
isAuthenticating.value = false
|
||||
isProcessing.value = false
|
||||
error.value = 'Ошибка подключения к серверу'
|
||||
await nextTick()
|
||||
refreshIcons()
|
||||
}
|
||||
}
|
||||
|
||||
// Регистрация
|
||||
const handleRegister = async () => {
|
||||
error.value = ''
|
||||
Object.keys(errors).forEach(k => delete errors[k])
|
||||
isProcessing.value = true
|
||||
|
||||
try {
|
||||
// Убираем @ из telegram при отправке, если только @ — отправляем null
|
||||
const telegramValue = regTelegram.value.replace('@', '').trim()
|
||||
|
||||
const data = await authApi.register({
|
||||
name: regName.value.trim(),
|
||||
username: regUsername.value.trim(),
|
||||
password: regPassword.value,
|
||||
telegram: telegramValue || null
|
||||
})
|
||||
|
||||
if (data.success) {
|
||||
// Автоматически входим после регистрации
|
||||
const loginResult = await authApi.login(regUsername.value.trim(), regPassword.value)
|
||||
|
||||
if (loginResult.success) {
|
||||
setAuthCache(true)
|
||||
showSuccess.value = true
|
||||
await nextTick()
|
||||
refreshIcons()
|
||||
|
||||
const prefetchPromise = prefetchData()
|
||||
const animationPromise = new Promise(resolve => setTimeout(resolve, 1800))
|
||||
await Promise.all([prefetchPromise, animationPromise])
|
||||
|
||||
router.push('/')
|
||||
} else {
|
||||
// Если автовход не удался — переключаем на форму входа
|
||||
isProcessing.value = false
|
||||
switchToLogin()
|
||||
error.value = 'Аккаунт создан. Войдите с вашими данными.'
|
||||
}
|
||||
} else {
|
||||
isProcessing.value = false
|
||||
// Показываем ошибки с бэка
|
||||
if (data.errors) {
|
||||
Object.assign(errors, data.errors)
|
||||
// Берём первую ошибку для отображения
|
||||
const firstError = Object.values(data.errors)[0]
|
||||
error.value = firstError || 'Ошибка регистрации'
|
||||
} else {
|
||||
error.value = 'Ошибка регистрации'
|
||||
}
|
||||
await nextTick()
|
||||
refreshIcons()
|
||||
}
|
||||
} catch (e) {
|
||||
isProcessing.value = false
|
||||
error.value = 'Ошибка подключения к серверу'
|
||||
await nextTick()
|
||||
refreshIcons()
|
||||
@@ -198,8 +467,7 @@ const refreshIcons = () => {
|
||||
|
||||
onMounted(refreshIcons)
|
||||
|
||||
// Обновляем иконки только когда меняется состояние успеха
|
||||
watch(showSuccess, () => {
|
||||
watch([showSuccess, isRegisterMode], () => {
|
||||
nextTick(refreshIcons)
|
||||
})
|
||||
</script>
|
||||
@@ -403,6 +671,10 @@ watch(showSuccess, () => {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.input-group.has-error .input-icon {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
@@ -455,6 +727,29 @@ watch(showSuccess, () => {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
.input-group.has-error .input-line::after {
|
||||
background: #f87171;
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
/* Анимация тряски при неверном вводе */
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
20% { transform: translateX(-4px); }
|
||||
40% { transform: translateX(4px); }
|
||||
60% { transform: translateX(-3px); }
|
||||
80% { transform: translateX(3px); }
|
||||
}
|
||||
|
||||
.input-group.shake {
|
||||
animation: shake 0.4s ease-out;
|
||||
}
|
||||
|
||||
.input-group.shake .input-line::after {
|
||||
background: #f87171 !important;
|
||||
transform: scaleX(1) !important;
|
||||
}
|
||||
|
||||
/* Ошибка */
|
||||
.error-message {
|
||||
display: flex;
|
||||
@@ -559,6 +854,31 @@ watch(showSuccess, () => {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
/* Переключение между входом и регистрацией */
|
||||
.form-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.switch-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.switch-btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Состояние успеха */
|
||||
.success-state {
|
||||
display: flex;
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
@stats-updated="stats = $event"
|
||||
@open-task="openTaskPanel"
|
||||
@create-task="openNewTaskPanel"
|
||||
@cards-moved="onCardsMoved"
|
||||
/>
|
||||
</main>
|
||||
|
||||
@@ -158,6 +159,11 @@ const onProjectChange = async () => {
|
||||
await fetchCards()
|
||||
}
|
||||
|
||||
// После перемещения карточки — тихо обновляем данные с сервера
|
||||
const onCardsMoved = async () => {
|
||||
await fetchCards(true)
|
||||
}
|
||||
|
||||
// ==================== СТАТИСТИКА ====================
|
||||
const stats = ref({ total: 0, inProgress: 0, done: 0 })
|
||||
|
||||
@@ -231,15 +237,26 @@ const onProjectSaved = async (projectId) => {
|
||||
}
|
||||
|
||||
// ==================== АВТООБНОВЛЕНИЕ (POLLING) ====================
|
||||
const REFRESH_INTERVAL = (window.APP_CONFIG?.IDLE_REFRESH_SECONDS ?? 30) * 1000
|
||||
const CARDS_REFRESH_INTERVAL = (window.APP_CONFIG?.REFRESH_INTERVALS?.cards ?? 30) * 1000
|
||||
const INVITES_REFRESH_INTERVAL = (window.APP_CONFIG?.REFRESH_INTERVALS?.invites ?? 30) * 1000
|
||||
let pollTimer = null
|
||||
let invitesPollTimer = null
|
||||
|
||||
const startPolling = () => {
|
||||
// Polling карточек
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
pollTimer = setInterval(async () => {
|
||||
// Не обновляем когда открыта модалка — это может прерывать клики
|
||||
if (panelOpen.value || projectPanelOpen.value) return
|
||||
console.log('[AutoRefresh] Обновление данных...')
|
||||
await fetchCards(true) // silent = true, без Loader
|
||||
}, REFRESH_INTERVAL)
|
||||
}, CARDS_REFRESH_INTERVAL)
|
||||
|
||||
// Polling приглашений (для бейджа)
|
||||
if (invitesPollTimer) clearInterval(invitesPollTimer)
|
||||
invitesPollTimer = setInterval(async () => {
|
||||
await store.fetchPendingInvitesCount()
|
||||
}, INVITES_REFRESH_INTERVAL)
|
||||
}
|
||||
|
||||
const stopPolling = () => {
|
||||
@@ -247,11 +264,15 @@ const stopPolling = () => {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
if (invitesPollTimer) {
|
||||
clearInterval(invitesPollTimer)
|
||||
invitesPollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== ИНИЦИАЛИЗАЦИЯ ====================
|
||||
onMounted(async () => {
|
||||
// Store уже мог быть инициализирован при логине (prefetch)
|
||||
// Store уже мог быть инициализирован при логине (prefetch) или в роутере
|
||||
await store.init()
|
||||
|
||||
// Проверяем предзагруженные карточки
|
||||
|
||||
621
front_vue/src/views/NoProjectsPage.vue
Normal file
621
front_vue/src/views/NoProjectsPage.vue
Normal file
@@ -0,0 +1,621 @@
|
||||
<template>
|
||||
<div class="no-projects-page" :class="{ 'is-mobile': isMobile }">
|
||||
<!-- Левитирующие иконки на фоне -->
|
||||
<div class="floating-icons">
|
||||
<div class="float-icon icon-1"><i data-lucide="folder"></i></div>
|
||||
<div class="float-icon icon-2"><i data-lucide="clipboard-list"></i></div>
|
||||
<div class="float-icon icon-3"><i data-lucide="calendar"></i></div>
|
||||
<div class="float-icon icon-4"><i data-lucide="users"></i></div>
|
||||
<div class="float-icon icon-5"><i data-lucide="check-square"></i></div>
|
||||
<div class="float-icon icon-6"><i data-lucide="target"></i></div>
|
||||
<div class="float-icon icon-7"><i data-lucide="star"></i></div>
|
||||
<div class="float-icon icon-8"><i data-lucide="zap"></i></div>
|
||||
<div class="float-icon icon-9"><i data-lucide="flag"></i></div>
|
||||
<div class="float-icon icon-10"><i data-lucide="layers"></i></div>
|
||||
<div class="float-icon icon-11"><i data-lucide="clock"></i></div>
|
||||
<div class="float-icon icon-12"><i data-lucide="inbox"></i></div>
|
||||
</div>
|
||||
|
||||
<!-- Контент -->
|
||||
<div class="content" :class="{ 'is-success': showSuccess }">
|
||||
|
||||
<!-- Состояние успеха -->
|
||||
<Transition name="success-fade">
|
||||
<div v-if="showSuccess" class="success-state">
|
||||
<div class="success-icon">
|
||||
<i data-lucide="check"></i>
|
||||
</div>
|
||||
<h1 class="success-title">{{ successType === 'create' ? 'Ура!' : 'Поздравляем!' }}</h1>
|
||||
<p class="success-text">{{ successType === 'create' ? 'Вы создали первый проект' : 'Ваш первый проект ждёт вас' }}</p>
|
||||
<div class="success-loader">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Основной контент -->
|
||||
<Transition name="form-fade">
|
||||
<div v-if="!showSuccess" class="main-content">
|
||||
<div class="icon-wrapper">
|
||||
<i data-lucide="folder-plus"></i>
|
||||
</div>
|
||||
|
||||
<h1>Нет доступных проектов</h1>
|
||||
|
||||
<p class="description">
|
||||
<template v-if="invites.length > 0">
|
||||
Примите приглашение или<br>создайте свой проект
|
||||
</template>
|
||||
<template v-else>
|
||||
Создайте новый проект или попросите<br>пригласить вас в существующий
|
||||
</template>
|
||||
</p>
|
||||
|
||||
<!-- Приглашения -->
|
||||
<div v-if="invites.length > 0" class="invites-section">
|
||||
<div class="invites-header">
|
||||
<i data-lucide="mail"></i>
|
||||
<span>{{ invites.length }} {{ invitesLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div class="invites-list">
|
||||
<NotificationCard
|
||||
v-for="invite in invites"
|
||||
:key="invite.id"
|
||||
type="invite"
|
||||
:title="invite.project_name"
|
||||
:subtitle="'от ' + (invite.from_user.name || invite.from_user.username)"
|
||||
:avatar-url="invite.from_user.avatar_url ? getFullUrl(invite.from_user.avatar_url) : ''"
|
||||
:avatar-name="invite.from_user.name || invite.from_user.username"
|
||||
:badge="invite.is_admin ? 'Админ' : ''"
|
||||
:processing="processingId === invite.id"
|
||||
@accept="acceptInvite(invite)"
|
||||
@decline="declineInvite(invite)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn-create" @click="openCreateProject">
|
||||
<i data-lucide="plus"></i>
|
||||
<span>Создать проект</span>
|
||||
</button>
|
||||
|
||||
<LogoutButton :show-text="true" />
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- Панель создания проекта -->
|
||||
<ProjectPanel
|
||||
:show="showProjectPanel"
|
||||
:project="null"
|
||||
@close="showProjectPanel = false"
|
||||
@saved="onProjectCreated"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, onUpdated, nextTick, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ProjectPanel from '../components/ProjectPanel.vue'
|
||||
import NotificationCard from '../components/ui/NotificationCard.vue'
|
||||
import LogoutButton from '../components/ui/LogoutButton.vue'
|
||||
import { useProjectsStore } from '../stores/projects'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
import { projectInviteApi, getFullUrl, cardsApi } from '../api'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
const router = useRouter()
|
||||
const store = useProjectsStore()
|
||||
|
||||
const showProjectPanel = ref(false)
|
||||
const invites = ref([])
|
||||
const processingId = ref(null)
|
||||
const showSuccess = ref(false)
|
||||
const successType = ref('invite') // 'invite' | 'create'
|
||||
|
||||
// Склонение слова "приглашение"
|
||||
const invitesLabel = computed(() => {
|
||||
const count = invites.value.length
|
||||
if (count === 1) return 'приглашение'
|
||||
if (count >= 2 && count <= 4) return 'приглашения'
|
||||
return 'приглашений'
|
||||
})
|
||||
|
||||
// Загрузка приглашений
|
||||
const loadInvites = async () => {
|
||||
try {
|
||||
const result = await projectInviteApi.getMyPending()
|
||||
if (result.success) {
|
||||
invites.value = result.data
|
||||
store.pendingInvitesCount = result.count
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки приглашений:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Предзагрузка данных пока показывается анимация успеха
|
||||
const prefetchData = async (projectId) => {
|
||||
try {
|
||||
// Перезагружаем проекты принудительно
|
||||
await store.refreshProjects()
|
||||
await store.selectProject(projectId)
|
||||
|
||||
// Загружаем карточки
|
||||
if (store.currentProjectId) {
|
||||
const result = await cardsApi.getAll(store.currentProjectId)
|
||||
if (result.success) {
|
||||
sessionStorage.setItem('prefetchedCards', JSON.stringify(result.data))
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Prefetch error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Принять приглашение
|
||||
const acceptInvite = async (invite) => {
|
||||
processingId.value = invite.id
|
||||
try {
|
||||
const result = await projectInviteApi.accept(invite.id)
|
||||
if (result.success) {
|
||||
// Показываем анимацию успеха
|
||||
successType.value = 'invite'
|
||||
showSuccess.value = true
|
||||
await nextTick()
|
||||
refreshIcons()
|
||||
|
||||
// Параллельно: предзагрузка данных + ожидание анимации
|
||||
const prefetchPromise = prefetchData(result.project_id)
|
||||
const animationPromise = new Promise(resolve => setTimeout(resolve, 1800))
|
||||
|
||||
// Ждём обоих (минимум 1.8 сек анимации)
|
||||
await Promise.all([prefetchPromise, animationPromise])
|
||||
|
||||
router.push('/')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка принятия приглашения:', error)
|
||||
} finally {
|
||||
processingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Отклонить приглашение
|
||||
const declineInvite = async (invite) => {
|
||||
processingId.value = invite.id
|
||||
try {
|
||||
const result = await projectInviteApi.decline(invite.id)
|
||||
if (result.success) {
|
||||
invites.value = invites.value.filter(i => i.id !== invite.id)
|
||||
store.pendingInvitesCount = invites.value.length
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка отклонения приглашения:', error)
|
||||
} finally {
|
||||
processingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const openCreateProject = () => {
|
||||
showProjectPanel.value = true
|
||||
}
|
||||
|
||||
const onProjectCreated = async (projectId) => {
|
||||
showProjectPanel.value = false
|
||||
|
||||
// Показываем анимацию успеха
|
||||
successType.value = 'create'
|
||||
showSuccess.value = true
|
||||
await nextTick()
|
||||
refreshIcons()
|
||||
|
||||
// createProject() в store уже добавил проект и выбрал его
|
||||
// Нужно только загрузить карточки для предзагрузки
|
||||
const prefetchPromise = (async () => {
|
||||
try {
|
||||
if (store.currentProjectId) {
|
||||
const result = await cardsApi.getAll(store.currentProjectId)
|
||||
if (result.success) {
|
||||
sessionStorage.setItem('prefetchedCards', JSON.stringify(result.data))
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Prefetch error:', e)
|
||||
}
|
||||
})()
|
||||
|
||||
const animationPromise = new Promise(resolve => setTimeout(resolve, 1800))
|
||||
|
||||
// Ждём обоих (минимум 1.8 сек анимации)
|
||||
await Promise.all([prefetchPromise, animationPromise])
|
||||
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
// Обновление иконок
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}
|
||||
|
||||
// Периодическое обновление приглашений
|
||||
let refreshInterval = null
|
||||
const REFRESH_MS = (window.APP_CONFIG?.REFRESH_INTERVALS?.invites || 30) * 1000
|
||||
|
||||
onMounted(() => {
|
||||
loadInvites()
|
||||
refreshIcons()
|
||||
|
||||
// Запускаем периодическое обновление
|
||||
refreshInterval = setInterval(() => {
|
||||
// Не обновляем если показывается анимация успеха
|
||||
if (!showSuccess.value) {
|
||||
loadInvites()
|
||||
}
|
||||
}, REFRESH_MS)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval)
|
||||
refreshInterval = null
|
||||
}
|
||||
})
|
||||
|
||||
onUpdated(refreshIcons)
|
||||
|
||||
// Обновляем иконки при смене состояния успеха
|
||||
watch(showSuccess, () => {
|
||||
nextTick(refreshIcons)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.no-projects-page {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #0a0a0c 0%, #111113 50%, #0d1117 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Левитирующие иконки */
|
||||
.floating-icons {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.float-icon {
|
||||
position: absolute;
|
||||
color: rgba(255, 255, 255, 0.04);
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.float-icon i,
|
||||
.float-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.icon-1 { width: 48px; height: 48px; top: 8%; left: 12%; animation: float1 6s ease-in-out infinite; }
|
||||
.icon-2 { width: 36px; height: 36px; top: 15%; right: 18%; animation: float2 7s ease-in-out infinite; }
|
||||
.icon-3 { width: 42px; height: 42px; top: 25%; left: 8%; animation: float3 8s ease-in-out infinite; }
|
||||
.icon-4 { width: 32px; height: 32px; top: 35%; right: 10%; animation: float1 5s ease-in-out infinite; }
|
||||
.icon-5 { width: 40px; height: 40px; top: 55%; left: 15%; animation: float2 9s ease-in-out infinite; }
|
||||
.icon-6 { width: 44px; height: 44px; top: 65%; right: 20%; animation: float3 6s ease-in-out infinite; }
|
||||
.icon-7 { width: 28px; height: 28px; top: 75%; left: 10%; animation: float1 7s ease-in-out infinite; }
|
||||
.icon-8 { width: 38px; height: 38px; top: 85%; right: 15%; animation: float2 8s ease-in-out infinite; }
|
||||
.icon-9 { width: 34px; height: 34px; bottom: 20%; left: 25%; animation: float3 5s ease-in-out infinite; }
|
||||
.icon-10 { width: 46px; height: 46px; top: 45%; left: 5%; animation: float1 10s ease-in-out infinite; }
|
||||
.icon-11 { width: 30px; height: 30px; top: 20%; left: 25%; animation: float2 6s ease-in-out infinite; }
|
||||
.icon-12 { width: 36px; height: 36px; bottom: 30%; right: 8%; animation: float3 7s ease-in-out infinite; }
|
||||
|
||||
@keyframes float1 {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||
25% { transform: translateY(-15px) rotate(5deg); }
|
||||
75% { transform: translateY(10px) rotate(-3deg); }
|
||||
}
|
||||
|
||||
@keyframes float2 {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||
33% { transform: translateY(-20px) rotate(-5deg); }
|
||||
66% { transform: translateY(15px) rotate(3deg); }
|
||||
}
|
||||
|
||||
@keyframes float3 {
|
||||
0%, 100% { transform: translateY(0) translateX(0); }
|
||||
50% { transform: translateY(-12px) translateX(8px); }
|
||||
}
|
||||
|
||||
/* Контент */
|
||||
.content {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
animation: contentAppear 0.6s ease;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ========== ПРИГЛАШЕНИЯ ========== */
|
||||
.invites-section {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
margin-bottom: 24px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.invites-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
color: var(--accent);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.invites-header i {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.invites-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@keyframes contentAppear {
|
||||
0% { opacity: 0; transform: translateY(20px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
margin: 0 auto 28px;
|
||||
background: linear-gradient(135deg, var(--accent) 0%, #00e6b8 100%);
|
||||
border-radius: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 20px 60px rgba(0, 212, 170, 0.3);
|
||||
animation: iconFloat 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.icon-wrapper :deep(svg) {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
@keyframes iconFloat {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-8px); }
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 12px;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 15px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
margin: 0 0 36px;
|
||||
}
|
||||
|
||||
.btn-create {
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
background: linear-gradient(135deg, var(--accent) 0%, #00e6b8 100%);
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
color: #000;
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
transition: all 0.3s ease;
|
||||
margin-bottom: 14px;
|
||||
box-shadow: 0 10px 40px rgba(0, 212, 170, 0.3);
|
||||
}
|
||||
|
||||
.btn-create:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 15px 50px rgba(0, 212, 170, 0.4);
|
||||
}
|
||||
|
||||
.btn-create:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-create :deep(svg) {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
/* Мобильная адаптация */
|
||||
.no-projects-page.is-mobile h1 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.no-projects-page.is-mobile .description {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.no-projects-page.is-mobile .icon-wrapper {
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
}
|
||||
|
||||
.no-projects-page.is-mobile .icon-wrapper :deep(svg) {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.no-projects-page.is-mobile .float-icon {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Мобильные стили для приглашений */
|
||||
.no-projects-page.is-mobile .invites-section {
|
||||
padding: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* ========== СОСТОЯНИЕ УСПЕХА ========== */
|
||||
.success-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
animation: successAppear 0.6s ease;
|
||||
}
|
||||
|
||||
@keyframes successAppear {
|
||||
0% { opacity: 0; transform: scale(0.9); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
background: linear-gradient(135deg, var(--accent) 0%, #00e6b8 100%);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 28px;
|
||||
box-shadow: 0 25px 80px rgba(0, 212, 170, 0.4);
|
||||
animation: successPulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.success-icon :deep(svg) {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: #000;
|
||||
animation: successCheck 0.5s ease 0.2s both;
|
||||
}
|
||||
|
||||
@keyframes successPulse {
|
||||
0%, 100% { transform: scale(1); box-shadow: 0 25px 80px rgba(0, 212, 170, 0.4); }
|
||||
50% { transform: scale(1.05); box-shadow: 0 30px 100px rgba(0, 212, 170, 0.5); }
|
||||
}
|
||||
|
||||
@keyframes successCheck {
|
||||
0% { transform: scale(0) rotate(-45deg); opacity: 0; }
|
||||
50% { transform: scale(1.2) rotate(10deg); }
|
||||
100% { transform: scale(1) rotate(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.success-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.success-text {
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 32px 0;
|
||||
}
|
||||
|
||||
.success-loader {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.success-loader span {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: successLoaderBounce 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.success-loader span:nth-child(2) { animation-delay: 0.16s; }
|
||||
.success-loader span:nth-child(3) { animation-delay: 0.32s; }
|
||||
|
||||
@keyframes successLoaderBounce {
|
||||
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
.form-fade-enter-active,
|
||||
.form-fade-leave-active {
|
||||
transition: opacity 0.4s ease, transform 0.4s ease;
|
||||
}
|
||||
|
||||
.form-fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.form-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.success-fade-enter-active {
|
||||
transition: opacity 0.5s ease 0.2s, transform 0.5s ease 0.2s;
|
||||
}
|
||||
|
||||
.success-fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
/* Мобильная адаптация успеха */
|
||||
.no-projects-page.is-mobile .success-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.no-projects-page.is-mobile .success-icon :deep(svg) {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.no-projects-page.is-mobile .success-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.no-projects-page.is-mobile .success-text {
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,38 +1,100 @@
|
||||
<template>
|
||||
<div class="app" :class="{ mobile: isMobile }">
|
||||
<Sidebar />
|
||||
|
||||
<div class="main-wrapper">
|
||||
<Header title="Команда" subtitle="Наша команда специалистов" />
|
||||
<PageLayout>
|
||||
<Header title="Команда">
|
||||
<template #filters>
|
||||
<ProjectSelector />
|
||||
<div v-if="canManageMembers" class="team-actions desktop">
|
||||
<button
|
||||
class="team-action-btn"
|
||||
title="Пригласить участника"
|
||||
@click="openMemberPanel(null)"
|
||||
>
|
||||
<i data-lucide="plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<main class="main">
|
||||
<Loader v-if="loading" />
|
||||
<template #mobile-filters>
|
||||
<ProjectSelector />
|
||||
<div v-if="canManageMembers" class="team-actions">
|
||||
<button
|
||||
class="team-action-btn"
|
||||
title="Пригласить участника"
|
||||
@click="openMemberPanel(null)"
|
||||
>
|
||||
<i data-lucide="plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #stats>
|
||||
<div v-if="users.length > 0" class="header-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-value">{{ users.length }}</span>
|
||||
<span class="stat-label">в команде</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Header>
|
||||
|
||||
<main class="main" :class="{ mobile: isMobile }">
|
||||
<Loader v-if="loading" />
|
||||
|
||||
<!-- Desktop: grid -->
|
||||
<div v-else-if="!isMobile" class="team-grid">
|
||||
<div
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
:key="user.id_user"
|
||||
class="team-card"
|
||||
>
|
||||
<div class="card-avatar">
|
||||
<img v-if="user.avatar_url" :src="getFullUrl(user.avatar_url)" :alt="user.name || ''">
|
||||
<span v-else class="avatar-placeholder">{{ (user.name || '?')[0] }}</span>
|
||||
<div
|
||||
class="card-avatar"
|
||||
:class="{ clickable: canEditUser(user) }"
|
||||
@click="canEditUser(user) && openMemberPanel(user)"
|
||||
>
|
||||
<img v-if="user.avatar_url" :src="getFullUrl(user.avatar_url)" :alt="user.name || user.username || ''">
|
||||
<span v-else class="avatar-placeholder">{{ (user.name || user.username || '?')[0] }}</span>
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<h3 class="card-name">{{ user.name || 'Без имени' }}</h3>
|
||||
<h3 class="card-name">{{ user.name || user.username || 'Без имени' }}</h3>
|
||||
<div class="card-meta">
|
||||
<span v-if="user.department" class="card-department">{{ user.department }}</span>
|
||||
<span v-if="user.username" class="card-username">@{{ user.username }}</span>
|
||||
<button
|
||||
v-if="canEditUser(user)"
|
||||
class="card-edit-btn"
|
||||
title="Редактировать права"
|
||||
@click="openMemberPanel(user)"
|
||||
>
|
||||
<i data-lucide="pencil"></i>
|
||||
</button>
|
||||
<span v-if="user.is_owner" class="card-role owner">Создатель</span>
|
||||
<span v-else-if="user.is_admin" class="card-role admin">Админ</span>
|
||||
<span v-else class="card-role member">Участник</span>
|
||||
<button
|
||||
v-if="canRemoveUser(user)"
|
||||
class="card-remove-btn"
|
||||
title="Удалить из проекта"
|
||||
@click="confirmRemoveUser(user)"
|
||||
>
|
||||
<i data-lucide="trash-2"></i>
|
||||
</button>
|
||||
<!-- Кнопка выхода - только для себя -->
|
||||
<button
|
||||
v-if="isCurrentUser(user)"
|
||||
class="card-leave-btn"
|
||||
title="Выйти из проекта"
|
||||
@click="confirmLeaveProject"
|
||||
>
|
||||
<i data-lucide="log-out"></i>
|
||||
</button>
|
||||
</div>
|
||||
<a
|
||||
v-if="user.telegram"
|
||||
:href="'https://t.me/' + user.telegram.replace('@', '')"
|
||||
v-if="user.telegram"
|
||||
:href="`https://t.me/${user.telegram}`"
|
||||
target="_blank"
|
||||
class="card-telegram"
|
||||
>
|
||||
<i data-lucide="send"></i>
|
||||
{{ user.telegram }}
|
||||
@{{ user.telegram }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -43,84 +105,246 @@
|
||||
<div class="mobile-cards" ref="mobileCardsRef" @scroll="onCardsScroll">
|
||||
<div
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
:key="user.id_user"
|
||||
class="mobile-card"
|
||||
>
|
||||
<div class="mobile-avatar">
|
||||
<img v-if="user.avatar_url" :src="getFullUrl(user.avatar_url)" :alt="user.name || ''">
|
||||
<span v-else class="avatar-placeholder">{{ (user.name || '?')[0] }}</span>
|
||||
<div
|
||||
class="mobile-avatar"
|
||||
:class="{ clickable: canEditUser(user) }"
|
||||
@click="canEditUser(user) && openMemberPanel(user)"
|
||||
>
|
||||
<img v-if="user.avatar_url" :src="getFullUrl(user.avatar_url)" :alt="user.name || user.username || ''">
|
||||
<span v-else class="avatar-placeholder">{{ (user.name || user.username || '?')[0] }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mobile-info">
|
||||
<h2 class="mobile-name">{{ user.name || 'Без имени' }}</h2>
|
||||
<span v-if="user.username" class="mobile-username">@{{ user.username }}</span>
|
||||
<span v-if="user.department" class="mobile-department">{{ user.department }}</span>
|
||||
<h2 class="mobile-name">{{ user.name || user.username || 'Без имени' }}</h2>
|
||||
<div class="mobile-role-row">
|
||||
<button
|
||||
v-if="canEditUser(user)"
|
||||
class="mobile-edit-btn"
|
||||
title="Редактировать права"
|
||||
@click="openMemberPanel(user)"
|
||||
>
|
||||
<i data-lucide="pencil"></i>
|
||||
</button>
|
||||
<span v-if="user.is_owner" class="mobile-role owner">Создатель</span>
|
||||
<span v-else-if="user.is_admin" class="mobile-role admin">Администратор</span>
|
||||
<span v-else class="mobile-role member">Участник</span>
|
||||
<button
|
||||
v-if="canRemoveUser(user)"
|
||||
class="mobile-remove-btn"
|
||||
title="Удалить из проекта"
|
||||
@click="confirmRemoveUser(user)"
|
||||
>
|
||||
<i data-lucide="trash-2"></i>
|
||||
</button>
|
||||
<!-- Кнопка выхода - только для себя -->
|
||||
<button
|
||||
v-if="isCurrentUser(user)"
|
||||
class="mobile-leave-btn"
|
||||
title="Выйти из проекта"
|
||||
@click="confirmLeaveProject"
|
||||
>
|
||||
<i data-lucide="log-out"></i>
|
||||
</button>
|
||||
</div>
|
||||
<a
|
||||
v-if="user.telegram"
|
||||
:href="`https://t.me/${user.telegram}`"
|
||||
target="_blank"
|
||||
class="mobile-telegram"
|
||||
>
|
||||
<i data-lucide="send"></i>
|
||||
Написать в Telegram
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<a
|
||||
v-if="user.telegram"
|
||||
:href="'https://t.me/' + user.telegram.replace('@', '')"
|
||||
target="_blank"
|
||||
class="mobile-telegram"
|
||||
>
|
||||
<i data-lucide="send"></i>
|
||||
Написать в Telegram
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Фиксированные индикаторы над навигацией -->
|
||||
<div v-if="isMobile && !loading && users.length > 0" class="mobile-team-footer">
|
||||
<div class="team-indicators">
|
||||
<button
|
||||
v-for="(user, index) in users"
|
||||
:key="user.id"
|
||||
class="indicator-dot"
|
||||
:class="{ active: currentUserIndex === index }"
|
||||
@click="scrollToUser(index)"
|
||||
>
|
||||
<img v-if="user.avatar_url" :src="getFullUrl(user.avatar_url)" :alt="user.name || ''">
|
||||
<span v-else class="avatar-placeholder-small">{{ (user.name || '?')[0] }}</span>
|
||||
</button>
|
||||
<!-- Фиксированные индикаторы над навигацией -->
|
||||
<div v-if="isMobile && !loading && users.length > 0" class="mobile-team-footer">
|
||||
<div class="team-indicators">
|
||||
<button
|
||||
v-for="(user, index) in users"
|
||||
:key="user.id_user"
|
||||
class="indicator-dot"
|
||||
:class="{ active: currentUserIndex === index }"
|
||||
@click="scrollToUser(index)"
|
||||
>
|
||||
<img v-if="user.avatar_url" :src="getFullUrl(user.avatar_url)" :alt="user.name || user.username || ''">
|
||||
<span v-else class="avatar-placeholder-small">{{ (user.name || user.username || '?')[0] }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Панель участника -->
|
||||
<MemberPanel
|
||||
:show="showMemberPanel"
|
||||
:member="selectedMember"
|
||||
@close="closeMemberPanel"
|
||||
@saved="store.fetchUsers()"
|
||||
/>
|
||||
|
||||
<!-- Диалог подтверждения удаления -->
|
||||
<ConfirmDialog
|
||||
:show="showRemoveDialog"
|
||||
type="removeMember"
|
||||
:message="removeDialogMessage"
|
||||
:action="doRemoveMember"
|
||||
@confirm="showRemoveDialog = false"
|
||||
@cancel="showRemoveDialog = false"
|
||||
/>
|
||||
|
||||
<!-- Диалог подтверждения выхода из проекта -->
|
||||
<ConfirmDialog
|
||||
:show="showLeaveDialog"
|
||||
type="leaveProject"
|
||||
:action="doLeaveProject"
|
||||
@confirm="showLeaveDialog = false"
|
||||
@cancel="showLeaveDialog = false"
|
||||
/>
|
||||
</PageLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onUpdated } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import Sidebar from '../components/Sidebar.vue'
|
||||
import { ref, computed, watch, onMounted, onUpdated, onUnmounted, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import PageLayout from '../components/PageLayout.vue'
|
||||
import Header from '../components/Header.vue'
|
||||
import Loader from '../components/ui/Loader.vue'
|
||||
import { usersApi, getFullUrl } from '../api'
|
||||
import ProjectSelector from '../components/ProjectSelector.vue'
|
||||
import MemberPanel from '../components/MemberPanel.vue'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
import { getFullUrl, projectAccessApi } from '../api'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
import { useProjectsStore } from '../stores/projects'
|
||||
import { useToast } from '../composables/useToast'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { isMobile } = useMobile()
|
||||
const store = useProjectsStore()
|
||||
|
||||
const users = ref([])
|
||||
const loading = ref(true)
|
||||
const mobileCardsRef = ref(null)
|
||||
const currentUserIndex = ref(0)
|
||||
const { users, loading, currentProjectId } = storeToRefs(store)
|
||||
|
||||
const fetchUsers = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await usersApi.getAll()
|
||||
if (data.success) {
|
||||
users.value = data.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки команды:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
// Права на управление участниками (приглашать/редактировать - только админ)
|
||||
const canManageMembers = computed(() => store.isProjectAdmin)
|
||||
|
||||
// Права на удаление участников (remove_members или админ)
|
||||
const canRemoveMembers = computed(() => store.isProjectAdmin || store.can('remove_members'))
|
||||
|
||||
// Можно ли редактировать пользователя (не себя и не владельца) - только админ
|
||||
const canEditUser = (user) => {
|
||||
return canManageMembers.value && Number(user.id_user) !== store.currentUserId && !user.is_owner
|
||||
}
|
||||
|
||||
// Можно ли удалить пользователя (только для не-админов с правом remove_members)
|
||||
// Админы удаляют через панель редактирования
|
||||
const canRemoveUser = (user) => {
|
||||
return !store.isProjectAdmin && store.can('remove_members') && Number(user.id_user) !== store.currentUserId && !user.is_owner
|
||||
}
|
||||
|
||||
// Это текущий пользователь? (для кнопки выхода)
|
||||
const isCurrentUser = (user) => {
|
||||
return Number(user.id_user) === store.currentUserId && !user.is_owner
|
||||
}
|
||||
|
||||
// Диалог удаления участника
|
||||
const showRemoveDialog = ref(false)
|
||||
const removeDialogMessage = ref('')
|
||||
const userToRemove = ref(null)
|
||||
|
||||
// Открыть диалог удаления
|
||||
const confirmRemoveUser = (user) => {
|
||||
userToRemove.value = user
|
||||
removeDialogMessage.value = `<b>${user.name || user.username}</b> будет удалён из проекта.`
|
||||
showRemoveDialog.value = true
|
||||
}
|
||||
|
||||
// Выполнить удаление
|
||||
const doRemoveMember = async () => {
|
||||
if (!userToRemove.value) return
|
||||
|
||||
const result = await projectAccessApi.removeMember(store.currentProjectId, userToRemove.value.id_user)
|
||||
if (result.success) {
|
||||
await store.fetchUsers()
|
||||
} else {
|
||||
throw new Error(result.errors?.member || 'Ошибка удаления')
|
||||
}
|
||||
}
|
||||
|
||||
// Диалог выхода из проекта
|
||||
const showLeaveDialog = ref(false)
|
||||
|
||||
const confirmLeaveProject = () => {
|
||||
showLeaveDialog.value = true
|
||||
}
|
||||
|
||||
// Выполнить выход из проекта
|
||||
const doLeaveProject = async () => {
|
||||
const projectIdToLeave = store.currentProjectId
|
||||
const result = await projectAccessApi.removeMember(projectIdToLeave, store.currentUserId)
|
||||
if (result.success) {
|
||||
toast.success('Вы вышли из проекта')
|
||||
|
||||
// Очищаем текущий проект из localStorage чтобы избежать ошибок доступа
|
||||
localStorage.removeItem('currentProjectId')
|
||||
localStorage.removeItem('currentProjectName')
|
||||
store.currentProjectId = null
|
||||
|
||||
// Принудительно перезагружаем список проектов
|
||||
await store.refreshProjects()
|
||||
|
||||
// Проверяем есть ли другие проекты
|
||||
if (store.projects.length > 0) {
|
||||
// Переключаемся на первый доступный проект
|
||||
await store.selectProject(store.projects[0].id)
|
||||
router.push('/')
|
||||
} else {
|
||||
router.push('/no-projects')
|
||||
}
|
||||
} else {
|
||||
toast.error(result.errors?.member || 'Ошибка выхода из проекта')
|
||||
throw new Error(result.errors?.member || 'Ошибка выхода из проекта')
|
||||
}
|
||||
}
|
||||
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}
|
||||
|
||||
// Инициализация store при прямом открытии страницы
|
||||
onMounted(async () => {
|
||||
// Store уже мог быть инициализирован в роутере
|
||||
if (!store.initialized) {
|
||||
await store.init()
|
||||
}
|
||||
refreshIcons()
|
||||
})
|
||||
|
||||
// Панель участника
|
||||
const showMemberPanel = ref(false)
|
||||
const selectedMember = ref(null)
|
||||
|
||||
const openMemberPanel = (member = null) => {
|
||||
selectedMember.value = member
|
||||
showMemberPanel.value = true
|
||||
}
|
||||
|
||||
const closeMemberPanel = () => {
|
||||
showMemberPanel.value = false
|
||||
selectedMember.value = null
|
||||
}
|
||||
|
||||
const mobileCardsRef = ref(null)
|
||||
const currentUserIndex = ref(0)
|
||||
|
||||
const onCardsScroll = () => {
|
||||
if (!mobileCardsRef.value) return
|
||||
const container = mobileCardsRef.value
|
||||
@@ -129,46 +353,89 @@ const onCardsScroll = () => {
|
||||
currentUserIndex.value = Math.round(scrollLeft / cardWidth)
|
||||
}
|
||||
|
||||
const scrollToUser = (index) => {
|
||||
const scrollToUser = (index, smooth = true) => {
|
||||
if (!mobileCardsRef.value) return
|
||||
const container = mobileCardsRef.value
|
||||
const cardWidth = container.offsetWidth
|
||||
container.scrollTo({
|
||||
left: index * cardWidth,
|
||||
behavior: 'smooth'
|
||||
behavior: smooth ? 'smooth' : 'instant'
|
||||
})
|
||||
}
|
||||
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
// При ресайзе — пересчитываем scroll позицию чтобы карточка не съезжала
|
||||
const onResize = () => {
|
||||
if (mobileCardsRef.value && currentUserIndex.value > 0) {
|
||||
scrollToUser(currentUserIndex.value, false)
|
||||
}
|
||||
}
|
||||
|
||||
// Загружаем при входе на страницу
|
||||
watch(() => route.path, async (path) => {
|
||||
if (path === '/team') {
|
||||
await fetchUsers()
|
||||
// При смене режима mobile/desktop - сбрасываем и перерисовываем иконки
|
||||
watch(isMobile, async (newVal) => {
|
||||
if (!newVal) {
|
||||
// Переключились на desktop — сбрасываем индекс
|
||||
currentUserIndex.value = 0
|
||||
}
|
||||
await nextTick()
|
||||
refreshIcons()
|
||||
})
|
||||
|
||||
// Слушаем ресайз окна
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', onResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
})
|
||||
|
||||
// Загружаем участников при смене проекта
|
||||
watch(currentProjectId, async (newId, oldId) => {
|
||||
if (newId && oldId && newId !== oldId) {
|
||||
await store.fetchUsers()
|
||||
refreshIcons()
|
||||
}
|
||||
}, { immediate: true })
|
||||
})
|
||||
|
||||
onUpdated(refreshIcons)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app {
|
||||
/* Кнопки действий (как на главной) */
|
||||
.team-actions {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.main-wrapper {
|
||||
flex: 1;
|
||||
margin-left: 64px;
|
||||
.team-actions.desktop {
|
||||
margin-left: -16px;
|
||||
}
|
||||
|
||||
.team-action-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
max-height: 100vh;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.team-action-btn:hover,
|
||||
.team-action-btn:active {
|
||||
color: var(--accent);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.team-action-btn i {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.main {
|
||||
@@ -214,7 +481,7 @@ onUpdated(refreshIcons)
|
||||
}
|
||||
|
||||
.team-card:hover {
|
||||
border-color: var(--accent);
|
||||
border-color: var(--border-hover, rgba(255, 255, 255, 0.15));
|
||||
}
|
||||
|
||||
.card-avatar {
|
||||
@@ -224,6 +491,15 @@ onUpdated(refreshIcons)
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.card-avatar.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-avatar.clickable:hover {
|
||||
box-shadow: 0 0 0 3px var(--accent);
|
||||
}
|
||||
|
||||
.card-avatar img {
|
||||
@@ -274,17 +550,89 @@ onUpdated(refreshIcons)
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.card-department {
|
||||
padding: 2px 8px;
|
||||
font-size: 10px;
|
||||
.card-role {
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.card-role.owner {
|
||||
color: var(--accent);
|
||||
background: rgba(0, 212, 170, 0.15);
|
||||
}
|
||||
|
||||
.card-role.admin {
|
||||
color: #f59e0b;
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
}
|
||||
|
||||
.card-role.member {
|
||||
color: var(--text-muted);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.card-edit-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.card-edit-btn:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.card-edit-btn i {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.card-remove-btn,
|
||||
.card-leave-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.card-remove-btn {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.card-remove-btn:hover {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.card-leave-btn {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.card-leave-btn:hover {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.card-remove-btn i,
|
||||
.card-leave-btn i {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.card-telegram {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -311,31 +659,7 @@ onUpdated(refreshIcons)
|
||||
}
|
||||
|
||||
/* Mobile styles */
|
||||
.app.mobile {
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.app.mobile .main-wrapper {
|
||||
margin-left: 0;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app.mobile .main {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
.main.mobile {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -382,6 +706,15 @@ onUpdated(refreshIcons)
|
||||
box-shadow:
|
||||
0 0 0 4px rgba(0, 212, 170, 0.3),
|
||||
0 20px 60px rgba(0, 0, 0, 0.4);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.mobile-avatar.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mobile-avatar.clickable:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.mobile-avatar img {
|
||||
@@ -414,16 +747,94 @@ onUpdated(refreshIcons)
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.mobile-department {
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
.mobile-role-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.mobile-role {
|
||||
padding: 8px 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
border-radius: 20px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.mobile-role.owner {
|
||||
color: var(--accent);
|
||||
background: rgba(0, 212, 170, 0.15);
|
||||
}
|
||||
|
||||
.mobile-role.admin {
|
||||
color: #f59e0b;
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
}
|
||||
|
||||
.mobile-role.member {
|
||||
color: var(--text-muted);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.mobile-edit-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.mobile-edit-btn:active {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.mobile-edit-btn i {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.mobile-remove-btn,
|
||||
.mobile-leave-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.mobile-remove-btn {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.mobile-remove-btn:active {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.mobile-leave-btn {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.mobile-leave-btn:active {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.mobile-remove-btn i,
|
||||
.mobile-leave-btn i {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.mobile-telegram {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
Target Server Version : 90200 (9.2.0)
|
||||
File Encoding : 65001
|
||||
|
||||
Date: 15/01/2026 07:15:39
|
||||
Date: 18/01/2026 17:17:50
|
||||
*/
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
@@ -30,7 +30,7 @@ CREATE TABLE `accounts` (
|
||||
`avatar_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
|
||||
`telegram` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
|
||||
PRIMARY KEY (`id`) USING BTREE
|
||||
) ENGINE = InnoDB AUTO_INCREMENT = 6 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
|
||||
) ENGINE = InnoDB AUTO_INCREMENT = 8 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for accounts_session
|
||||
@@ -45,7 +45,7 @@ CREATE TABLE `accounts_session` (
|
||||
`ip_address` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
|
||||
`user_agent` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
|
||||
PRIMARY KEY (`id`) USING BTREE
|
||||
) ENGINE = InnoDB AUTO_INCREMENT = 52 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
|
||||
) ENGINE = InnoDB AUTO_INCREMENT = 101 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for cards_task
|
||||
@@ -67,8 +67,9 @@ CREATE TABLE `cards_task` (
|
||||
`descript` varchar(2048) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
|
||||
`descript_full` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL,
|
||||
`id_project` int NULL DEFAULT NULL,
|
||||
`create_id_account` int NULL DEFAULT NULL,
|
||||
PRIMARY KEY (`id`) USING BTREE
|
||||
) ENGINE = InnoDB AUTO_INCREMENT = 46 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
|
||||
) ENGINE = InnoDB AUTO_INCREMENT = 64 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for columns
|
||||
@@ -81,7 +82,7 @@ CREATE TABLE `columns` (
|
||||
`id_project` int NULL DEFAULT NULL,
|
||||
`id_order` int NULL DEFAULT NULL,
|
||||
PRIMARY KEY (`id`) USING BTREE
|
||||
) ENGINE = InnoDB AUTO_INCREMENT = 56 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
|
||||
) ENGINE = InnoDB AUTO_INCREMENT = 62 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for comments
|
||||
@@ -96,7 +97,7 @@ CREATE TABLE `comments` (
|
||||
`date_create` datetime NULL DEFAULT NULL,
|
||||
`file_img` json NULL,
|
||||
PRIMARY KEY (`id`) USING BTREE
|
||||
) ENGINE = InnoDB AUTO_INCREMENT = 13 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
|
||||
) ENGINE = InnoDB AUTO_INCREMENT = 23 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for departments
|
||||
@@ -132,8 +133,42 @@ CREATE TABLE `project` (
|
||||
`name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
|
||||
`id_ready` int NULL DEFAULT NULL,
|
||||
`id_admin` json NULL,
|
||||
`id_member` json NULL,
|
||||
PRIMARY KEY (`id`) USING BTREE
|
||||
) ENGINE = InnoDB AUTO_INCREMENT = 7 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
|
||||
) ENGINE = InnoDB AUTO_INCREMENT = 10 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for project_invites
|
||||
-- ----------------------------
|
||||
DROP TABLE IF EXISTS `project_invites`;
|
||||
CREATE TABLE `project_invites` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`id_project` int NOT NULL,
|
||||
`id_from_user` int NOT NULL,
|
||||
`id_to_user` int NOT NULL,
|
||||
`status` enum('pending','accepted','declined') CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT 'pending',
|
||||
`permissions` json NULL,
|
||||
`created_at` datetime NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`responded_at` datetime NULL DEFAULT NULL,
|
||||
PRIMARY KEY (`id`) USING BTREE,
|
||||
INDEX `idx_to_user`(`id_to_user` ASC, `status` ASC) USING BTREE,
|
||||
INDEX `idx_project`(`id_project` ASC) USING BTREE
|
||||
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for project_members
|
||||
-- ----------------------------
|
||||
DROP TABLE IF EXISTS `project_members`;
|
||||
CREATE TABLE `project_members` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`id_project` int NOT NULL,
|
||||
`id_user` int NOT NULL,
|
||||
`is_admin` tinyint(1) NULL DEFAULT 0,
|
||||
`permissions` json NULL,
|
||||
`created_at` datetime NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`) USING BTREE,
|
||||
UNIQUE INDEX `unique_member`(`id_project` ASC, `id_user` ASC) USING BTREE,
|
||||
INDEX `idx_project`(`id_project` ASC) USING BTREE,
|
||||
INDEX `idx_user`(`id_user` ASC) USING BTREE
|
||||
) ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
|
||||
Reference in New Issue
Block a user