1
0

Большое обновление

1. Создание личных проектов
2. Управление командой
3. Приглашение участников
4. Уведомления

и многое другое...
This commit is contained in:
2026-01-18 20:17:02 +07:00
parent 250eac70a7
commit 190b4d0a5e
51 changed files with 6179 additions and 426 deletions

View File

@@ -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;
$result = Comment::uploadFile($comment_id, $file_base64, $file_name, $current_user_id);
// Проверяем доступ к проекту
$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::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;
$result = Comment::deleteFile($comment_id, $file_names, $current_user_id);
// Проверяем доступ к проекту
$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::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);

View File

@@ -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]);
}
}

View 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);
}
}
?>

View 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);
}
}
?>

View File

@@ -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;

View File

@@ -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]);
}

View File

@@ -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([

View File

@@ -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) {

View 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;
}
}
?>

View 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];
}
}
?>

View File

@@ -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',

View File

@@ -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',

View File

@@ -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'];
?>

View File

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

View File

@@ -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;

View File

@@ -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 ====================

View File

@@ -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 для новых карточек

View File

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

View File

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

View File

@@ -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;

View File

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

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

View File

@@ -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 ====================

View File

@@ -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)"

View File

@@ -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;

View File

@@ -1,9 +1,6 @@
<template>
<SlidePanel
:show="show"
:width="500"
:min-width="400"
:max-width="700"
@close="handleClose"
>
<template #header>

View File

@@ -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)

View File

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

View File

@@ -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()

View File

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

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

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

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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;

View File

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

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

View File

@@ -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 }

View 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
}
}

View File

@@ -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)

View File

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

View File

@@ -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'
}
}

View File

@@ -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,

View File

@@ -292,6 +292,7 @@ const handleRestoreFromPanel = async (cardId) => {
// ==================== ИНИЦИАЛИЗАЦИЯ ====================
onMounted(async () => {
// Store уже мог быть инициализирован в роутере
await store.init()
await fetchCards()

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

View File

@@ -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;

View File

@@ -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()
// Проверяем предзагруженные карточки

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

View File

@@ -1,38 +1,100 @@
<template>
<div class="app" :class="{ mobile: isMobile }">
<Sidebar />
<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>
<div class="main-wrapper">
<Header title="Команда" subtitle="Наша команда специалистов" />
<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>
<main class="main">
<Loader v-if="loading" />
<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('@', '')"
: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 {

View File

@@ -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;