1
0

Compare commits

..

15 Commits

Author SHA1 Message Date
1e9f8e0f6e Кирилица в файлах
Исправлена проблема при скачивании прикреплённых файлов или просмотра картинок, когда название было на Русском.
2026-01-29 23:56:50 +07:00
88189a3f04 Правки 2026-01-21 12:46:43 +07:00
8e3cd770df Фиксы... 2026-01-19 15:10:37 +07:00
7d7b817d7e Доработка сессий 2026-01-18 21:29:28 +07:00
6928687982 Фиксы
Доп правки
2026-01-18 21:02:23 +07:00
e8a4480747 Важный фикс
Забыл добавить управление отделами :)
2026-01-18 20:45:17 +07:00
190b4d0a5e Большое обновление
1. Создание личных проектов
2. Управление командой
3. Приглашение участников
4. Уведомления

и многое другое...
2026-01-18 20:17:02 +07:00
250eac70a7 Управление проектами
Добавил возможность удаления, создание и редактирование проектов.
2026-01-18 10:19:34 +07:00
15725ae90a Ошибки фронта PWA
Правки для фронта в PWA режиме.
2026-01-17 14:21:09 +07:00
9b2a2f0c2e Фиксы
Очередные фиксы фронта  + favicon
2026-01-17 09:48:31 +07:00
aca5eb84fd Фронт правки
1. Улучшено отображение на iphone и android в PWA
2026-01-17 09:25:10 +07:00
c46fd3952e PWA модуль
Теперь проект может быть установлен как приложение на телефон
2026-01-16 16:12:34 +07:00
25663a7aa4 Фиксы фронта 2026-01-16 10:46:40 +07:00
f856e68ea8 Мелкие правки фронта
Улучшены окна подтверждений
2026-01-16 10:22:32 +07:00
3258fa9137 Исправления фронта
Множество оптимизаций по фронту
2026-01-16 10:15:33 +07:00
75 changed files with 16889 additions and 3124 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,96 @@ if ($method === 'POST') {
// Удаление комментария
if ($action === 'delete') {
$id = $data['id'] ?? null;
$result = Comment::delete($id, $current_user_id);
// Проверяем доступ к проекту через комментарий -> задачу
$commentData = Comment::checkComment($id);
$taskData = Task::check_task($commentData['id_task']);
$project_id = $taskData['id_project'];
ProjectAccess::requireAccess($project_id, $current_user_id);
// Проверяем права на удаление
$isAuthor = (int)$commentData['id_accounts'] === (int)$current_user_id;
$isAdmin = ProjectAccess::isAdmin($project_id, $current_user_id);
$canDeleteAll = ProjectAccess::can($project_id, $current_user_id, 'delete_all_comments');
$canDeleteOwn = ProjectAccess::can($project_id, $current_user_id, 'delete_own_comments');
if (!$isAdmin && !$canDeleteAll && !($isAuthor && $canDeleteOwn)) {
RestApi::response([
'success' => false,
'errors' => ['access' => 'Нет прав на удаление комментария']
], 403);
}
$result = Comment::deleteWithAccess($id);
RestApi::response($result);
}
// Загрузка файла к комментарию (только автор)
// Загрузка файла к комментарию
if ($action === 'upload_image') {
$comment_id = $data['comment_id'] ?? null;
$file_base64 = $data['file_data'] ?? null;
$file_name = $data['file_name'] ?? null;
// Проверяем доступ к проекту
$commentData = Comment::checkComment($comment_id);
$taskData = Task::check_task($commentData['id_task']);
$project_id = $taskData['id_project'];
ProjectAccess::requireAccess($project_id, $current_user_id);
// Нельзя загружать файлы к комментариям архивных задач
if ((int)$taskData['archive'] === 1) {
RestApi::response([
'success' => false,
'errors' => ['task' => 'Нельзя загружать файлы к комментариям архивных задач']
], 400);
}
// Проверяем право на загрузку картинок
ProjectAccess::requirePermission($project_id, $current_user_id, 'upload_images');
// Только автор может загружать к своему комментарию
if ((int)$commentData['id_accounts'] !== (int)$current_user_id) {
RestApi::response([
'success' => false,
'errors' => ['access' => 'Вы можете загружать файлы только к своим комментариям']
], 403);
}
$result = Comment::uploadFile($comment_id, $file_base64, $file_name, $current_user_id);
$result = Comment::uploadFileSimple($comment_id, $file_base64, $file_name);
RestApi::response($result);
}
// Удаление файлов комментария (автор или админ проекта)
// Удаление файлов комментария
if ($action === 'delete_image') {
$comment_id = $data['comment_id'] ?? null;
$file_names = $data['file_names'] ?? $data['file_name'] ?? null;
// Проверяем доступ к проекту
$commentData = Comment::checkComment($comment_id);
$taskData = Task::check_task($commentData['id_task']);
$project_id = $taskData['id_project'];
ProjectAccess::requireAccess($project_id, $current_user_id);
// Нельзя удалять файлы из комментариев архивных задач
if ((int)$taskData['archive'] === 1) {
RestApi::response([
'success' => false,
'errors' => ['task' => 'Нельзя удалять файлы из комментариев архивных задач']
], 400);
}
// Проверка прав: автор комментария ИЛИ админ проекта
$isAuthor = (int)$commentData['id_accounts'] === (int)$current_user_id;
$isAdmin = ProjectAccess::isAdmin($project_id, $current_user_id);
if (!$isAuthor && !$isAdmin) {
RestApi::response([
'success' => false,
'errors' => ['access' => 'Нет прав на удаление файлов']
], 403);
}
$result = Comment::deleteFile($comment_id, $file_names, $current_user_id);
$result = Comment::deleteFileSimple($comment_id, $file_names);
RestApi::response($result);
}
@@ -66,12 +159,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

@@ -5,10 +5,15 @@ $method = $_SERVER['REQUEST_METHOD'];
if ($method === 'POST') {
$data = RestApi::getInput();
$action = $data['action'] ?? null;
$user_id = RestApi::getCurrentUserId();
// Получение данных проекта (проект + колонки + отделы)
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]);
@@ -17,6 +22,174 @@ if ($method === 'POST') {
}
}
// ==================== CRUD ПРОЕКТОВ ====================
// Создание проекта
if ($action === 'create') {
$name = trim($data['name'] ?? '');
if (!$name) {
RestApi::response(['success' => false, 'errors' => ['name' => 'Укажите название проекта']], 400);
}
$result = Project::create($name, $user_id);
RestApi::response($result, $result['success'] ? 200 : 400);
}
// Обновление проекта
if ($action === 'update') {
$id = $data['id'] ?? null;
$name = trim($data['name'] ?? '');
if (!$id || !$name) {
RestApi::response(['success' => false, 'errors' => ['data' => 'Укажите ID и название']], 400);
}
$result = Project::update($id, $name, $user_id);
RestApi::response($result, $result['success'] ? 200 : 403);
}
// Удаление проекта
if ($action === 'delete') {
$id = $data['id'] ?? null;
if (!$id) {
RestApi::response(['success' => false, 'errors' => ['id' => 'Укажите ID проекта']], 400);
}
$result = Project::delete($id, $user_id);
RestApi::response($result, $result['success'] ? 200 : 403);
}
// Обновление порядка проектов
if ($action === 'update_order') {
$ids = $data['ids'] ?? [];
if (empty($ids)) {
RestApi::response(['success' => false, 'errors' => ['ids' => 'Укажите массив ID']], 400);
}
$result = Project::updateOrder($ids, $user_id);
RestApi::response($result);
}
// ==================== CRUD КОЛОНОК ====================
// Добавление колонки
if ($action === 'add_column') {
$project_id = $data['project_id'] ?? null;
$name = trim($data['name'] ?? '');
$color = $data['color'] ?? '#6366f1';
if (!$project_id || !$name) {
RestApi::response(['success' => false, 'errors' => ['data' => 'Укажите project_id и name']], 400);
}
$result = Project::addColumn($project_id, $name, $color, $user_id);
RestApi::response($result, $result['success'] ? 200 : 403);
}
// Обновление колонки
if ($action === 'update_column') {
$id = $data['id'] ?? null;
$name = isset($data['name']) ? trim($data['name']) : null;
$color = $data['color'] ?? null;
if (!$id) {
RestApi::response(['success' => false, 'errors' => ['id' => 'Укажите ID колонки']], 400);
}
$result = Project::updateColumn($id, $name, $color, $user_id);
RestApi::response($result, $result['success'] ? 200 : 403);
}
// Получение количества задач в колонке (для подтверждения удаления)
if ($action === 'get_column_tasks_count') {
$id = $data['id'] ?? null;
if (!$id) {
RestApi::response(['success' => false, 'errors' => ['id' => 'Укажите ID колонки']], 400);
}
$count = Project::getColumnTasksCount($id);
RestApi::response(['success' => true, 'count' => $count]);
}
// Удаление колонки
if ($action === 'delete_column') {
$id = $data['id'] ?? null;
if (!$id) {
RestApi::response(['success' => false, 'errors' => ['id' => 'Укажите ID колонки']], 400);
}
$result = Project::deleteColumn($id, $user_id);
RestApi::response($result, $result['success'] ? 200 : 403);
}
// Обновление порядка колонок
if ($action === 'update_columns_order') {
$project_id = $data['project_id'] ?? null;
$ids = $data['ids'] ?? [];
if (!$project_id || empty($ids)) {
RestApi::response(['success' => false, 'errors' => ['data' => 'Укажите project_id и ids']], 400);
}
$result = Project::updateColumnsOrder($project_id, $ids, $user_id);
RestApi::response($result, $result['success'] ? 200 : 403);
}
// Установка финальной колонки
if ($action === 'set_ready_column') {
$project_id = $data['project_id'] ?? null;
$column_id = $data['column_id'] ?? null;
if (!$project_id || !$column_id) {
RestApi::response(['success' => false, 'errors' => ['data' => 'Укажите project_id и column_id']], 400);
}
$result = Project::setReadyColumn($project_id, $column_id, $user_id);
RestApi::response($result, $result['success'] ? 200 : 403);
}
// ==================== CRUD ОТДЕЛОВ ====================
// Добавление отдела
if ($action === 'add_department') {
$project_id = $data['project_id'] ?? null;
$name = trim($data['name'] ?? '');
$color = $data['color'] ?? '#6366f1';
if (!$project_id || !$name) {
RestApi::response(['success' => false, 'errors' => ['data' => 'Укажите project_id и name']], 400);
}
$result = Project::addDepartment($project_id, $name, $color, $user_id);
RestApi::response($result, $result['success'] ? 200 : 403);
}
// Обновление отдела
if ($action === 'update_department') {
$id = $data['id'] ?? null;
$name = isset($data['name']) ? trim($data['name']) : null;
$color = $data['color'] ?? null;
if (!$id) {
RestApi::response(['success' => false, 'errors' => ['id' => 'Укажите ID отдела']], 400);
}
$result = Project::updateDepartment($id, $name, $color, $user_id);
RestApi::response($result, $result['success'] ? 200 : 403);
}
// Получение количества задач в отделе (для подтверждения удаления)
if ($action === 'get_department_tasks_count') {
$id = $data['id'] ?? null;
if (!$id) {
RestApi::response(['success' => false, 'errors' => ['id' => 'Укажите ID отдела']], 400);
}
$count = Project::getDepartmentTasksCount($id);
RestApi::response(['success' => true, 'count' => $count]);
}
// Удаление отдела
if ($action === 'delete_department') {
$id = $data['id'] ?? null;
if (!$id) {
RestApi::response(['success' => false, 'errors' => ['id' => 'Укажите ID отдела']], 400);
}
$result = Project::deleteDepartment($id, $user_id);
RestApi::response($result, $result['success'] ? 200 : 403);
}
// Обновление порядка отделов
if ($action === 'update_departments_order') {
$project_id = $data['project_id'] ?? null;
$ids = $data['ids'] ?? [];
if (!$project_id || empty($ids)) {
RestApi::response(['success' => false, 'errors' => ['data' => 'Укажите project_id и ids']], 400);
}
$result = Project::updateDepartmentsOrder($project_id, $ids, $user_id);
RestApi::response($result, $result['success'] ? 200 : 403);
}
// Метод не указан
if (!$action) {
RestApi::response(['success' => false, 'error' => 'Укажите метод'], 400);
@@ -24,16 +197,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' => [
@@ -42,7 +222,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,22 @@ 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');
// Проверяем что у проекта есть колонки
$columns = Project::getColumns($project_id);
if (empty($columns)) {
RestApi::response(['success' => false, 'errors' => ['project' => 'У проекта нет колонок. Создайте хотя бы одну колонку.']], 400);
}
$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 +104,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 +117,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 +136,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();
@@ -31,7 +24,7 @@ if ($method === 'POST') {
RestApi::response($result);
}
// Выход (удаление всех сессий)
// Выход (только текущая сессия)
if ($action === 'logout') {
$account = new Account();
$keycookies = $data['keycookies'] ?? $_COOKIE['session'] ?? null;
@@ -39,6 +32,14 @@ if ($method === 'POST') {
RestApi::response($result);
}
// Выход со всех устройств
if ($action === 'logout_all') {
$account = new Account();
$keycookies = $data['keycookies'] ?? $_COOKIE['session'] ?? null;
$result = $account->logout_all($keycookies);
RestApi::response($result);
}
// Создание пользователя
if ($action === 'create_user') {
$account = new Account();
@@ -53,6 +54,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 +82,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

@@ -30,8 +30,12 @@ class Comment extends BaseEntity {
return $errors;
}
// Проверяем что задача существует
Task::check_task($this->id_task);
// Проверяем что задача существует и не архивная
$task = Task::check_task($this->id_task);
if ((int)$task['archive'] === 1) {
$this->addError('task', 'Нельзя комментировать архивные задачи');
return $this->getErrors();
}
// Если это ответ — проверяем что родительский комментарий существует
if ($this->id_answer) {
@@ -75,6 +79,13 @@ class Comment extends BaseEntity {
// Проверяем что комментарий существует
$comment = self::checkComment($this->id);
// Проверяем что задача не архивная
$task = Database::get('cards_task', ['archive'], ['id' => $comment['id_task']]);
if ($task && (int)$task['archive'] === 1) {
$this->addError('task', 'Нельзя редактировать комментарии архивных задач');
return $this->getErrors();
}
// Проверяем что пользователь — автор комментария
if ((int)$comment['id_accounts'] !== (int)$this->id_accounts) {
$this->addError('access', 'Вы можете редактировать только свои комментарии');
@@ -99,12 +110,20 @@ class Comment extends BaseEntity {
// Проверяем что комментарий существует
$comment = self::checkComment($id);
// Получаем задачу для проверки админа проекта
$task = Database::get('cards_task', ['id_project'], ['id' => $comment['id_task']]);
// Получаем задачу для проверки админа проекта и архивации
$task = Database::get('cards_task', ['id_project', 'archive'], ['id' => $comment['id_task']]);
// Нельзя удалять комментарии архивных задач
if ($task && (int)$task['archive'] === 1) {
RestApi::response([
'success' => false,
'errors' => ['task' => 'Нельзя удалять комментарии архивных задач']
], 400);
}
// Проверяем права: автор комментария ИЛИ админ проекта
$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 +157,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 +192,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 +202,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;
@@ -82,9 +76,11 @@ class Project extends BaseEntity {
return Database::select('departments', [
'id',
'name_departments',
'color'
'color',
'order_id'
], [
'id_project' => $project_id
'id_project' => $project_id,
'ORDER' => ['order_id' => 'ASC']
]);
}
@@ -114,15 +110,342 @@ 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;
// ==================== CRUD ПРОЕКТОВ ====================
// Создание проекта БЕЗ колонок (колонки создаются на фронте)
public static function create($name, $user_id) {
// Получаем максимальный id_order
$maxOrder = (int)(Database::max('project', 'id_order') ?? 0);
// Создаём проект с создателем как владельцем (id_admin)
Database::insert('project', [
'name' => $name,
'id_order' => $maxOrder + 1,
'id_admin' => json_encode([$user_id])
]);
$projectId = Database::id();
if (!$projectId) {
return ['success' => false, 'errors' => ['project' => 'Ошибка создания проекта']];
}
$admins = json_decode($project['id_admin'], true) ?: [];
return in_array((int)$user_id, $admins, true);
return [
'success' => true,
'id' => $projectId,
'is_admin' => true
];
}
// Обновление проекта
public static function update($id, $name, $user_id) {
if (!ProjectAccess::isAdmin($id, $user_id)) {
return ['success' => false, 'errors' => ['access' => 'Нет прав на редактирование']];
}
Database::update('project', ['name' => $name], ['id' => $id]);
return ['success' => true];
}
// Удаление проекта (каскадно: колонки, задачи, комментарии, файлы)
public static function delete($id, $user_id) {
if (!ProjectAccess::isAdmin($id, $user_id)) {
return ['success' => false, 'errors' => ['access' => 'Нет прав на удаление']];
}
// Получаем все задачи проекта
$tasks = Database::select('cards_task', ['id'], ['id_project' => $id]);
$taskIds = array_column($tasks, 'id');
if (!empty($taskIds)) {
// Получаем все комментарии к задачам
$comments = Database::select('comments', ['id'], ['id_task' => $taskIds]);
// Удаляем файлы комментариев с диска
foreach ($comments as $comment) {
FileUpload::deleteFolder('comment', $comment['id']);
}
// Удаляем комментарии из БД
Database::delete('comments', ['id_task' => $taskIds]);
}
// Удаляем файлы задач с диска
foreach ($tasks as $task) {
FileUpload::deleteFolder('task', $task['id']);
}
// Удаляем задачи из БД
Database::delete('cards_task', ['id_project' => $id]);
// Удаляем колонки
Database::delete('columns', ['id_project' => $id]);
// Удаляем отделы проекта
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]);
return ['success' => true];
}
// Обновление порядка проектов
public static function updateOrder($ids, $user_id) {
foreach ($ids as $order => $id) {
Database::update('project', ['id_order' => $order + 1], ['id' => $id]);
}
return ['success' => true];
}
// ==================== CRUD КОЛОНОК ====================
// Добавление колонки
public static function addColumn($project_id, $name, $color, $user_id) {
if (!ProjectAccess::can($project_id, $user_id, 'create_column')) {
return ['success' => false, 'errors' => ['access' => 'Нет прав на создание колонок']];
}
// Получаем максимальный id_order для проекта
$maxOrder = (int)(Database::max('columns', 'id_order', ['id_project' => $project_id]) ?? 0);
Database::insert('columns', [
'name_columns' => $name,
'color' => $color ?: '#6366f1',
'id_project' => $project_id,
'id_order' => $maxOrder + 1
]);
$columnId = Database::id();
return [
'success' => true,
'id' => $columnId,
'column' => [
'id' => $columnId,
'name_columns' => $name,
'color' => $color ?: '#6366f1',
'id_order' => $maxOrder + 1
]
];
}
// Обновление колонки
public static function updateColumn($id, $name, $color, $user_id) {
// Получаем колонку для проверки проекта
$column = Database::get('columns', ['id_project'], ['id' => $id]);
if (!$column) {
return ['success' => false, 'errors' => ['column' => 'Колонка не найдена']];
}
if (!ProjectAccess::can($column['id_project'], $user_id, 'edit_column')) {
return ['success' => false, 'errors' => ['access' => 'Нет прав на редактирование колонок']];
}
$updateData = [];
if ($name !== null) $updateData['name_columns'] = $name;
if ($color !== null) $updateData['color'] = $color;
if (!empty($updateData)) {
Database::update('columns', $updateData, ['id' => $id]);
}
return ['success' => true];
}
// Удаление колонки (возвращает количество задач для подтверждения)
public static function getColumnTasksCount($column_id) {
return Database::count('cards_task', ['column_id' => $column_id]);
}
// Удаление колонки с задачами
public static function deleteColumn($id, $user_id) {
// Получаем колонку для проверки проекта
$column = Database::get('columns', ['id_project', 'id_order'], ['id' => $id]);
if (!$column) {
return ['success' => false, 'errors' => ['column' => 'Колонка не найдена']];
}
if (!ProjectAccess::can($column['id_project'], $user_id, 'delete_column')) {
return ['success' => false, 'errors' => ['access' => 'Нет прав на удаление колонок']];
}
// Проверяем, не является ли это последней колонкой перед "Готово"
// Минимум должно быть 3 колонки (2 обычные + готово), чтобы можно было удалить
$columnsCount = Database::count('columns', ['id_project' => $column['id_project']]);
if ($columnsCount <= 2) {
return ['success' => false, 'errors' => ['column' => 'Нельзя удалить последнюю колонку перед финальной']];
}
// Проверяем, не является ли это последней (финальной) колонкой
$lastColumn = Database::get('columns', ['id'], [
'id_project' => $column['id_project'],
'ORDER' => ['id_order' => 'DESC'],
'LIMIT' => 1
]);
if ($lastColumn && (int)$lastColumn['id'] === (int)$id) {
return ['success' => false, 'errors' => ['column' => 'Нельзя удалить финальную колонку (последнюю)']];
}
// Получаем задачи колонки
$tasks = Database::select('cards_task', ['id'], ['column_id' => $id]);
$taskIds = array_column($tasks, 'id');
// Удаляем комментарии к задачам
if (!empty($taskIds)) {
Database::delete('comments', ['id_task' => $taskIds]);
}
// Удаляем задачи
Database::delete('cards_task', ['column_id' => $id]);
// Удаляем колонку
Database::delete('columns', ['id' => $id]);
// Обновляем id_ready на новую последнюю колонку
$newLastColumn = Database::get('columns', ['id'], [
'id_project' => $column['id_project'],
'ORDER' => ['id_order' => 'DESC'],
'LIMIT' => 1
]);
if ($newLastColumn) {
Database::update('project', ['id_ready' => $newLastColumn['id']], ['id' => $column['id_project']]);
}
return ['success' => true, 'deleted_tasks' => count($taskIds)];
}
// Обновление порядка колонок
public static function updateColumnsOrder($project_id, $ids, $user_id) {
if (!ProjectAccess::can($project_id, $user_id, 'edit_column')) {
return ['success' => false, 'errors' => ['access' => 'Нет прав на редактирование колонок']];
}
foreach ($ids as $order => $id) {
Database::update('columns', ['id_order' => $order + 1], ['id' => $id, 'id_project' => $project_id]);
}
// Автоматически устанавливаем id_ready на последнюю колонку
$lastColumnId = end($ids);
if ($lastColumnId) {
Database::update('project', ['id_ready' => $lastColumnId], ['id' => $project_id]);
}
return ['success' => true];
}
// Установка финальной колонки (id_ready) - ЗАБЛОКИРОВАНО
// Финальная колонка всегда последняя, изменение запрещено
public static function setReadyColumn($project_id, $column_id, $user_id) {
return ['success' => false, 'errors' => ['column' => 'Изменение финальной колонки запрещено. Финальная колонка всегда последняя.']];
}
// ==================== CRUD ОТДЕЛОВ ====================
// Добавление отдела
public static function addDepartment($project_id, $name, $color, $user_id) {
if (!ProjectAccess::can($project_id, $user_id, 'manage_departments')) {
return ['success' => false, 'errors' => ['access' => 'Нет прав на управление отделами']];
}
// Валидация имени
if (!$name || trim($name) === '') {
return ['success' => false, 'errors' => ['name' => 'Укажите название отдела']];
}
// Получаем максимальный order_id для проекта
$maxOrder = (int)(Database::max('departments', 'order_id', ['id_project' => $project_id]) ?? 0);
$newOrderId = $maxOrder + 1;
Database::insert('departments', [
'name_departments' => $name,
'color' => $color ?: '#6366f1',
'id_project' => $project_id,
'order_id' => $newOrderId
]);
$departmentId = Database::id();
return [
'success' => true,
'id' => $departmentId,
'department' => [
'id' => $departmentId,
'name_departments' => $name,
'color' => $color ?: '#6366f1',
'order_id' => $newOrderId
]
];
}
// Обновление отдела
public static function updateDepartment($id, $name, $color, $user_id) {
// Получаем отдел для проверки проекта
$department = Database::get('departments', ['id_project'], ['id' => $id]);
if (!$department) {
return ['success' => false, 'errors' => ['department' => 'Отдел не найден']];
}
if (!ProjectAccess::can($department['id_project'], $user_id, 'manage_departments')) {
return ['success' => false, 'errors' => ['access' => 'Нет прав на управление отделами']];
}
// Валидация имени (если передано)
if ($name !== null && trim($name) === '') {
return ['success' => false, 'errors' => ['name' => 'Укажите название отдела']];
}
$updateData = [];
if ($name !== null) $updateData['name_departments'] = $name;
if ($color !== null) $updateData['color'] = $color;
if (!empty($updateData)) {
Database::update('departments', $updateData, ['id' => $id]);
}
return ['success' => true];
}
// Получение количества задач в отделе
public static function getDepartmentTasksCount($department_id) {
return Database::count('cards_task', ['id_department' => $department_id]);
}
// Удаление отдела (обнуляет id_department у задач)
public static function deleteDepartment($id, $user_id) {
// Получаем отдел для проверки проекта
$department = Database::get('departments', ['id_project'], ['id' => $id]);
if (!$department) {
return ['success' => false, 'errors' => ['department' => 'Отдел не найден']];
}
if (!ProjectAccess::can($department['id_project'], $user_id, 'manage_departments')) {
return ['success' => false, 'errors' => ['access' => 'Нет прав на управление отделами']];
}
// Обнуляем id_department у задач (не удаляем сами задачи)
Database::update('cards_task', ['id_department' => null], ['id_department' => $id]);
// Удаляем отдел
Database::delete('departments', ['id' => $id]);
return ['success' => true];
}
// Обновление порядка отделов
public static function updateDepartmentsOrder($project_id, $ids, $user_id) {
if (!ProjectAccess::can($project_id, $user_id, 'manage_departments')) {
return ['success' => false, 'errors' => ['access' => 'Нет прав на управление отделами']];
}
foreach ($ids as $order => $id) {
Database::update('departments', ['order_id' => $order + 1], ['id' => $id, 'id_project' => $project_id]);
}
return ['success' => true];
}
}

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;
@@ -60,6 +61,9 @@ class Task extends BaseEntity {
return $errors;
}
// Формируем дату создания (одна переменная для БД и ответа)
$date_create = date('Y-m-d H:i:s');
// Вставляем в базу
Database::insert($this->db_name, [
'id_project' => $this->id_project,
@@ -69,11 +73,12 @@ 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,
'archive' => 0,
'date_create' => date('Y-m-d H:i:s'),
'date_create' => $date_create,
'file_img' => '[]'
]);
@@ -94,6 +99,9 @@ class Task extends BaseEntity {
return [
'success' => true,
'id' => $this->id,
'date' => $this->date ?: null,
'date_create' => $date_create,
'date_closed' => null,
'files' => $uploaded_files
];
}
@@ -106,12 +114,18 @@ class Task extends BaseEntity {
}
// Проверка что задача существует и получаем текущие данные
$task = Database::get($this->db_name, ['id', 'column_id', 'order', 'id_project'], ['id' => $this->id]);
$task = Database::get($this->db_name, ['id', 'column_id', 'order', 'id_project', 'archive'], ['id' => $this->id]);
if (!$task) {
$this->addError('task', 'Задача не найдена');
return $this->getErrors();
}
// Архивные задачи нельзя редактировать
if ((int)$task['archive'] === 1) {
$this->addError('task', 'Архивные задачи нельзя редактировать');
return $this->getErrors();
}
// Получаем текущую колонку
$old_column_id = (int)$task['column_id'];
@@ -177,14 +191,32 @@ class Task extends BaseEntity {
// Загрузка файла к задаче
public static function uploadFile($task_id, $file_base64, $file_name) {
// Проверка что задача существует
self::check_task($task_id);
$task = self::check_task($task_id);
// Архивные задачи нельзя редактировать
if ((int)$task['archive'] === 1) {
RestApi::response([
'success' => false,
'errors' => ['task' => 'Нельзя загружать файлы в архивную задачу']
], 400);
}
return FileUpload::upload('task', $task_id, $file_base64, $file_name);
}
// Удаление файлов задачи
public static function deleteFile($task_id, $file_names) {
// Проверка что задача существует
self::check_task($task_id);
$task = self::check_task($task_id);
// Архивные задачи нельзя редактировать
if ((int)$task['archive'] === 1) {
RestApi::response([
'success' => false,
'errors' => ['task' => 'Нельзя удалять файлы из архивной задачи']
], 400);
}
return FileUpload::delete('task', $task_id, $file_names);
}
@@ -197,6 +229,14 @@ class Task extends BaseEntity {
$new_column_id = (int)$column_id;
$archive = (int)$task['archive'];
// Архивные задачи нельзя перемещать
if ($archive === 1) {
RestApi::response([
'success' => false,
'errors' => ['task' => 'Архивные задачи нельзя перемещать']
], 400);
}
// Получаем id_ready (колонка "Готово") из проекта
$done_column_id = Project::getReadyColumnId($task['id_project']);
@@ -255,6 +295,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',
@@ -153,7 +196,7 @@ class Account extends BaseEntity {
];
}
// Удаление всех сессий пользователя (logout)
// Удаление только текущей сессии (logout)
public function logout($keycookies) {
// Проверяем, что сессия не пустая
if (!$keycookies) {
@@ -161,6 +204,28 @@ class Account extends BaseEntity {
return $this->getErrors();
}
// Удаляем только текущую сессию
Database::delete($this->db_name_session, [
'keycookies' => $keycookies
]);
// Удаляем cookie
setcookie('session', '', [
'expires' => time() - 3600,
'path' => '/'
]);
return ['success' => true];
}
// Удаление всех сессий пользователя (logout со всех устройств)
public function logout_all($keycookies) {
// Проверяем, что сессия не пустая
if (!$keycookies) {
$this->addError('session', 'Сессия не указана');
return $this->getErrors();
}
// Получаем сессию чтобы узнать id пользователя
$session = Database::get($this->db_name_session, ['id_accounts'], [
'keycookies' => $keycookies

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

@@ -4,14 +4,19 @@
function routing_static_files() {
$requestUri = $_SERVER['REQUEST_URI'] ?? '';
$path = parse_url($requestUri, PHP_URL_PATH);
$path = urldecode($path); // декодируем кириллицу из URL
// Отдача файлов из /public/ (принудительное скачивание)
if (strpos($path, '/public/') === 0) {
$file = dirname(dirname(__DIR__)) . $path;
if (is_file($file)) {
$filename = basename($file);
$filename_encoded = rawurlencode($filename);
header('Content-Type: application/octet-stream');
header('Content-Length: ' . filesize($file));
header('Content-Disposition: attachment; filename="' . basename($file) . '"');
// RFC 5987: filename* для кириллицы, filename для fallback
header("Content-Disposition: attachment; filename=\"$filename\"; filename*=UTF-8''$filename_encoded");
readfile($file);
exit;
}

View File

@@ -3,6 +3,17 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<!-- PWA -->
<meta name="theme-color" content="#111113">
<meta name="description" content="Task management application">
<!-- iOS PWA -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="TaskBoard">
<link rel="apple-touch-icon" href="/icon_phone/apple-touch-icon.png">
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<title>TaskBoard</title>
<link rel="preconnect" href="https://fonts.googleapis.com">

File diff suppressed because it is too large Load Diff

View File

@@ -15,5 +15,8 @@
"vite": "^7.3.1",
"vue": "^3.5.26",
"vue-router": "^4.6.4"
},
"devDependencies": {
"vite-plugin-pwa": "^1.2.0"
}
}

View File

@@ -2,10 +2,17 @@
window.APP_CONFIG = {
API_BASE: 'http://192.168.1.6',
// Интервал автообновления данных (в секундах)
IDLE_REFRESH_SECONDS: 1,
// Интервалы автообновления данных (в секундах, 0 = отключено)
REFRESH_INTERVALS: {
cards: 2, // Карточки на доске
comments: 5, // Комментарии к задаче
invites: 5 // Приглашения (страница: задачи + страница без проектов)
},
// Брейкпоинт для мобильной версии (px)
MOBILE_BREAKPOINT: 1400
MOBILE_BREAKPOINT: 1400,
// Автообновление страницы (F5) — интервал в секундах, 0 = отключено
AUTO_REFRESH_SECONDS: 500
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 684 KiB

View File

@@ -1,10 +1,15 @@
<template>
<router-view />
<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
@@ -25,9 +30,23 @@ onUnmounted(() => {
box-sizing: border-box;
}
/* Отключаем 300ms задержку на тапах для всех кликабельных элементов */
button,
a,
[role="button"],
input,
select,
textarea,
label {
touch-action: manipulation;
}
/* CSS переменные (цветовая палитра) */
:root {
--bg-body: #111113;
--bg-gradient:
radial-gradient(ellipse 80% 50% at 50% -20%, rgba(86, 86, 99, 0.212), transparent)
var(--bg-body);
--bg-sidebar: #161618;
--bg-main: #111113;
--bg-card: rgba(255, 255, 255, 0.04);
@@ -54,33 +73,120 @@ body {
min-height: 100vh;
}
/* Мобильный режим — фиксируем body */
/* Градиент на #app — виден везде включая fixed элементы */
#app {
min-height: 100vh;
background: var(--bg-gradient);
}
/* Мобильный режим */
body.is-mobile {
position: fixed;
width: 100%;
height: 100%;
height: 100dvh; /* Динамическая высота для iOS */
overflow: hidden;
touch-action: none;
/* Блокируем overscroll (pull-to-refresh), но разрешаем touch внутри приложения */
overscroll-behavior: none;
}
body.is-mobile #app {
height: 100%;
height: 100dvh;
overflow: hidden;
/* Убираем градиент на мобильных - сплошной цвет */
background: var(--bg-body);
}
/* Safe area для iPhone (notch и home indicator) */
:root {
--safe-area-top: env(safe-area-inset-top, 0px);
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
}
/* ========== PWA Safe Area Wrapper ========== */
/* Обёртка для iOS PWA - решает проблему safe area глобально */
.pwa-safe-wrapper {
min-height: 100vh;
background: var(--bg-gradient);
}
/* На мобильных: wrapper становится containing block для всех fixed элементов */
body.is-mobile .pwa-safe-wrapper {
position: fixed;
top: var(--safe-area-top);
left: 0;
right: 0;
bottom: 0;
min-height: auto;
height: calc(100dvh - var(--safe-area-top));
overflow: hidden;
/* transform создаёт containing block - fixed элементы внутри
теперь позиционируются относительно wrapper, а не viewport */
transform: translateZ(0);
/* Убираем градиент на мобильных - сплошной цвет */
background: var(--bg-body);
}
/* Полоска под статус-баром iPhone */
@supports (padding-top: env(safe-area-inset-top)) {
body.is-mobile::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
height: var(--safe-area-top);
/* Сплошной цвет вместо градиента */
background: var(--bg-body);
z-index: 99999;
pointer-events: none;
}
}
/* Блокировка скролла когда панель открыта */
/* Убрали position: fixed - он конфликтует с pwa-safe-wrapper */
body.panel-open {
overflow: hidden !important;
position: fixed !important;
width: 100% !important;
height: 100% !important;
touch-action: none !important;
/* Используем overscroll-behavior вместо touch-action: none для PWA совместимости */
overscroll-behavior: none !important;
}
/* ========== PWA режим: исправление "залипания" свайпа ========== */
/* iOS PWA не понимает когда заканчивается вертикальный жест */
/* Эти правила помогают браузеру корректно определять направление скролла */
@media (display-mode: standalone), (display-mode: fullscreen) {
html, body {
/* Отключаем overscroll bounce на уровне документа */
overscroll-behavior: none;
/* Отключаем выделение текста на всём документе при касании */
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}
/* Горизонтальные скролл-контейнеры */
.columns,
.mobile-cards {
/* Изолируем overscroll внутри контейнера */
overscroll-behavior: contain;
}
/* Вертикальные скролл-контейнеры */
.cards,
.archive-list {
overscroll-behavior: contain;
}
/* Скрываем скроллбар на мобильных */
.cards,
.archive-list,
.columns,
.mobile-cards {
scrollbar-width: none;
-ms-overflow-style: none;
}
.cards::-webkit-scrollbar,
.archive-list::-webkit-scrollbar,
.columns::-webkit-scrollbar,
.mobile-cards::-webkit-scrollbar {
display: none;
}
}
</style>

View File

@@ -10,10 +10,23 @@ export const getFullUrl = (url) => {
return API_BASE + url
}
// Базовая функция запроса
const request = async (endpoint, options = {}) => {
// Базовая функция запроса с глобальной проверкой сессии
const request = async (endpoint, options = {}, skipSessionCheck = false) => {
const res = await fetch(`${API_BASE}${endpoint}`, options)
return res.json()
const data = await res.json()
// Глобальная проверка: если сессия истекла — редирект на логин
// Пропускаем для эндпоинтов авторизации/регистрации
if (!skipSessionCheck && data.success === false && data.errors?.session) {
// Очищаем кэш авторизации (через window чтобы избежать циклической зависимости)
if (window.__clearAuthCache) window.__clearAuthCache()
// Редирект на логин (если ещё не там)
if (window.location.pathname !== '/login') {
window.location.href = '/login'
}
}
return data
}
// ==================== AUTH ====================
@@ -23,19 +36,31 @@ 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 — это выход
logoutAll: () => request('/api/user', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'logout_all' })
}, true) // skipSessionCheck — выход со всех устройств
}
// ==================== PROJECTS ====================
@@ -52,6 +77,113 @@ export const projectsApi = {
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'get_project_data', id_project })
}),
// Создание проекта
create: (name) => request('/api/project', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'create', name })
}),
// Обновление проекта
update: (id, name) => request('/api/project', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'update', id, name })
}),
// Удаление проекта
delete: (id) => request('/api/project', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'delete', id })
}),
// Обновление порядка проектов
updateOrder: (ids) => request('/api/project', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'update_order', ids })
}),
// ==================== КОЛОНКИ ====================
// Добавление колонки
addColumn: (project_id, name, color) => request('/api/project', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'add_column', project_id, name, color })
}),
// Обновление колонки
updateColumn: (id, name, color) => request('/api/project', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'update_column', id, name, color })
}),
// Получение количества задач в колонке
getColumnTasksCount: (id) => request('/api/project', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'get_column_tasks_count', id })
}),
// Удаление колонки
deleteColumn: (id) => request('/api/project', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'delete_column', id })
}),
// Обновление порядка колонок
updateColumnsOrder: (project_id, ids) => request('/api/project', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'update_columns_order', project_id, ids })
}),
// Установка финальной колонки
setReadyColumn: (project_id, column_id) => request('/api/project', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'set_ready_column', project_id, column_id })
}),
// ==================== ОТДЕЛЫ ====================
// Добавление отдела
addDepartment: (project_id, name, color) => request('/api/project', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'add_department', project_id, name, color })
}),
// Обновление отдела
updateDepartment: (id, name, color) => request('/api/project', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'update_department', id, name, color })
}),
// Получение количества задач в отделе
getDepartmentTasksCount: (id) => request('/api/project', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'get_department_tasks_count', id })
}),
// Удаление отдела
deleteDepartment: (id) => request('/api/project', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'delete_department', id })
}),
// Обновление порядка отделов
updateDepartmentsOrder: (project_id, ids) => request('/api/project', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'update_departments_order', project_id, ids })
})
}
@@ -132,7 +264,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 ====================
@@ -166,11 +396,15 @@ export const serverSettings = {
parseDate(dateStr) {
if (!dateStr) return null
// Добавляем таймзону сервера для корректного парсинга
const normalized = dateStr.replace(' ', 'T')
let normalized = dateStr.replace(' ', 'T')
// Если уже есть таймзона — не добавляем
if (normalized.includes('+') || normalized.includes('Z')) {
return new Date(normalized)
}
// Если нет времени (только дата YYYY-MM-DD) — добавляем 00:00:00
if (normalized.length === 10) {
normalized += 'T00:00:00'
}
return new Date(normalized + this.timezoneOffset)
}
}

View File

@@ -49,8 +49,8 @@
<!-- Даты: создано и выполнено -->
<div class="card-dates">
<span class="date-created">{{ formatDateFull(card.dateCreate) }}</span>
<span class="date-closed">{{ formatDateFull(card.dateClosed) }}</span>
<span class="date-created">{{ formatDateTime(card.dateCreate) }}</span>
<span class="date-closed">{{ formatDateTime(card.dateClosed) }}</span>
</div>
<!-- Кнопки действий (всегда видны) -->
@@ -127,7 +127,7 @@
<div class="card-footer">
<span class="date-create">
Создано: {{ formatDateWithYear(card.dateCreate) }}
Создано: {{ formatShort(card.dateCreate) }}
</span>
<span v-if="card.dateClosed" class="date-closed">
Закрыто: {{ closedDateText }}
@@ -138,45 +138,35 @@
</template>
<script setup>
import { computed, onMounted, onUpdated } from 'vue'
import { computed } from 'vue'
import { getFullUrl } from '../api'
import { useMobile } from '../composables/useMobile'
import { useLucideIcons } from '../composables/useLucideIcons'
import { useDateFormat } from '../composables/useDateFormat'
import { useProjectsStore } from '../stores/projects'
const { isMobile } = useMobile()
const { formatShort, formatDateTime, formatRelative } = useDateFormat()
const store = useProjectsStore()
useLucideIcons()
const props = defineProps({
card: Object,
departments: {
type: Array,
default: () => []
},
labels: {
type: Array,
default: () => []
}
card: Object
})
const emit = defineEmits(['click', 'restore', 'delete'])
const refreshIcons = () => {
if (window.lucide) {
window.lucide.createIcons()
}
}
onMounted(refreshIcons)
onUpdated(refreshIcons)
// Получаем отдел по id
const cardDepartment = computed(() => {
if (!props.card.departmentId) return null
return props.departments.find(d => d.id === props.card.departmentId) || null
return store.departments.find(d => d.id === props.card.departmentId) || null
})
// Получаем лейбл по id
const cardLabel = computed(() => {
if (!props.card.labelId) return null
return props.labels.find(l => l.id === props.card.labelId) || null
return store.labels.find(l => l.id === props.card.labelId) || null
})
// Цвет лейбла для акцента
@@ -189,41 +179,8 @@ const isAvatarUrl = (value) => {
return value && (value.startsWith('http://') || value.startsWith('https://') || value.startsWith('/'))
}
// Полная дата с временем
const formatDateFull = (dateStr) => {
if (!dateStr) return '—'
const date = new Date(dateStr)
const day = date.getDate().toString().padStart(2, '0')
const months = ['янв', 'фев', 'мар', 'апр', 'мая', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек']
const year = date.getFullYear()
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')
return `${day} ${months[date.getMonth()]} ${year}, ${hours}:${minutes}`
}
const formatDateWithYear = (dateStr) => {
if (!dateStr) return ''
const date = new Date(dateStr)
const day = date.getDate()
const months = ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек']
return `${day} ${months[date.getMonth()]} ${date.getFullYear()}`
}
// Форматирование даты закрытия (относительный формат)
const closedDateText = computed(() => {
if (!props.card.dateClosed) return ''
const closed = new Date(props.card.dateClosed)
const today = new Date()
today.setHours(0, 0, 0, 0)
closed.setHours(0, 0, 0, 0)
const daysAgo = Math.round((today - closed) / (1000 * 60 * 60 * 24))
if (daysAgo === 0) return 'Сегодня'
if (daysAgo === 1) return 'Вчера'
if (daysAgo >= 2 && daysAgo <= 4) return `${daysAgo} дня назад`
if (daysAgo >= 5 && daysAgo <= 14) return `${daysAgo} дней назад`
return formatDateWithYear(props.card.dateClosed)
})
// Даты через composable
const closedDateText = computed(() => formatRelative(props.card.dateClosed))
</script>
<style scoped>

View File

@@ -5,13 +5,10 @@
v-for="column in filteredColumns"
:key="column.id"
:column="column"
:departments="departments"
:labels="labels"
:done-column-id="doneColumnId"
@drop-card="handleDropCard"
@open-task="(card) => emit('open-task', { card, columnId: column.id })"
@create-task="emit('create-task', column.id)"
@archive-task="archiveTask"
@archive-task="confirmArchive"
@move-request="handleMoveRequest"
/>
</div>
@@ -41,6 +38,15 @@
@close="closeMovePanel"
@move="handleDropCard"
/>
<!-- Диалог подтверждения архивации -->
<ConfirmDialog
:show="archiveDialogOpen"
type="archive"
:action="handleConfirmArchive"
@confirm="archiveDialogOpen = false"
@cancel="archiveDialogOpen = false"
/>
</div>
</template>
@@ -48,10 +54,13 @@
import { ref, computed, onMounted, onUpdated, watch } from 'vue'
import Column from './Column.vue'
import MoveCardPanel from './ui/MoveCardPanel.vue'
import ConfirmDialog from './ConfirmDialog.vue'
import { cardsApi } from '../api'
import { useMobile } from '../composables/useMobile'
import { useProjectsStore } from '../stores/projects'
const { isMobile } = useMobile()
const store = useProjectsStore()
// Состояние для мобильной панели перемещения
const movePanel = ref({
@@ -76,7 +85,7 @@ const closeMovePanel = () => {
// Колонки для панели перемещения (только id, title, color)
const movePanelColumns = computed(() => {
return props.columns.map(col => ({
return store.columns.map(col => ({
id: col.id,
title: col.name_columns,
color: col.color
@@ -111,26 +120,13 @@ const currentColumnTitle = computed(() => {
const props = defineProps({
activeDepartment: Number,
doneColumnId: Number,
departments: {
type: Array,
default: () => []
},
labels: {
type: Array,
default: () => []
},
columns: {
type: Array,
default: () => []
},
cards: {
type: Array,
default: () => []
}
})
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) {
@@ -144,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,
@@ -155,7 +161,7 @@ watch(() => props.cards, (newCards) => {
// Собираем колонки с карточками (используем localCards, сортируем по order)
const columnsWithCards = computed(() => {
return props.columns.map(col => ({
return store.columns.map(col => ({
id: col.id,
title: col.name_columns,
color: col.color,
@@ -205,8 +211,8 @@ const inProgressTasks = computed(() => {
})
const completedTasks = computed(() => {
if (!props.doneColumnId) return 0
const col = filteredColumns.value.find(c => c.id === props.doneColumnId)
if (!store.doneColumnId) return 0
const col = filteredColumns.value.find(c => c.id === store.doneColumnId)
return col ? col.cards.length : 0
})
@@ -223,13 +229,16 @@ 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
// Обновляем date_closed при перемещении в/из колонки "Готово"
if (props.doneColumnId && toColumnId === props.doneColumnId && fromColumnId !== props.doneColumnId) {
if (store.doneColumnId && toColumnId === store.doneColumnId && fromColumnId !== store.doneColumnId) {
card.date_closed = new Date().toISOString()
} else if (props.doneColumnId && fromColumnId === props.doneColumnId && toColumnId !== props.doneColumnId) {
} else if (store.doneColumnId && fromColumnId === store.doneColumnId && toColumnId !== store.doneColumnId) {
card.date_closed = null
}
@@ -244,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 для новых карточек
@@ -304,7 +321,7 @@ const saveTask = async (taskData, columnId) => {
})
if (result.success) {
// Добавляем локально с ID от сервера
// Добавляем локально с данными от сервера
localCards.value.push({
id: parseInt(result.id),
id_department: taskData.departmentId,
@@ -315,8 +332,9 @@ const saveTask = async (taskData, columnId) => {
descript_full: taskData.details,
avatar_img: taskData.assignee,
column_id: columnId,
date: taskData.dueDate,
date_create: new Date().toISOString().split('T')[0],
date: result.date,
date_create: result.date_create,
date_closed: result.date_closed,
order: maxOrder,
files: result.files || []
})
@@ -337,12 +355,38 @@ const deleteTask = async (cardId, columnId) => {
}
}
// ==================== АРХИВАЦИЯ С ПОДТВЕРЖДЕНИЕМ ====================
const archiveDialogOpen = ref(false)
const cardToArchive = ref(null)
const confirmArchive = (cardId) => {
cardToArchive.value = cardId
archiveDialogOpen.value = true
}
const handleConfirmArchive = async () => {
if (!cardToArchive.value) {
throw new Error('Задача не выбрана')
}
const result = await cardsApi.setArchive(cardToArchive.value, 1)
if (!result.success) {
throw new Error('Ошибка архивации задачи')
}
// Удаляем из локального списка
const index = localCards.value.findIndex(c => c.id === cardToArchive.value)
if (index !== -1) {
localCards.value.splice(index, 1)
}
cardToArchive.value = null
}
// Публичный метод для архивации из TaskPanel (без диалога — там свой)
const archiveTask = async (cardId) => {
// Архивируем на сервере
const result = await cardsApi.setArchive(cardId, 1)
if (result.success) {
// Удаляем из локального списка (задача уходит в архив)
const index = localCards.value.findIndex(c => c.id === cardId)
if (index !== -1) {
localCards.value.splice(index, 1)
@@ -372,6 +416,8 @@ defineExpose({ saveTask, deleteTask, archiveTask })
display: flex;
flex-direction: column;
overflow: hidden;
/* Отступ снизу для footer (70px) + навигации (64px) + safe-area */
padding-bottom: calc(70px + 64px + var(--safe-area-bottom, 0px));
}
.board.mobile .columns {
@@ -386,25 +432,27 @@ defineExpose({ saveTask, deleteTask, archiveTask })
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
-ms-overflow-style: none;
/* Отключаем вертикальный скролл на уровне этого элемента */
/* Предотвращаем системные жесты (pull-to-refresh) */
overscroll-behavior: contain;
touch-action: pan-x;
}
.board.mobile .columns::-webkit-scrollbar {
display: none;
}
/* Мобильный футер с индикатором колонок */
/* Мобильный футер с индикатором колонок - фиксированный над навигацией */
.mobile-column-footer {
position: fixed;
left: 0;
right: 0;
bottom: calc(64px + var(--safe-area-bottom, 0px));
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: var(--bg-body);
flex-shrink: 0;
min-height: 60px;
z-index: 100;
}
.current-column-title {

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
@@ -62,12 +79,12 @@
<div class="card-footer">
<span v-if="card.dateCreate" class="date-create">
Создано: {{ formatDateWithYear(card.dateCreate) }}
Создано: {{ formatShort(card.dateCreate) }}
</span>
<span v-if="card.dueDate && Number(columnId) !== doneColumnId" class="due-date" :class="dueDateStatus">
<span v-if="card.dueDate && Number(columnId) !== store.doneColumnId" class="due-date" :class="dueDateStatus">
{{ daysLeftText }}
</span>
<span v-if="doneColumnId && Number(columnId) === doneColumnId && card.dateClosed" class="date-closed">
<span v-if="store.doneColumnId && Number(columnId) === store.doneColumnId && card.dateClosed" class="date-closed">
Закрыто: {{ closedDateText }}
</span>
</div>
@@ -75,24 +92,30 @@
</template>
<script setup>
import { ref, computed, onMounted, onUpdated } from 'vue'
import { ref, computed } from 'vue'
import { getFullUrl } from '../api'
import { useMobile } from '../composables/useMobile'
import { useLucideIcons } from '../composables/useLucideIcons'
import { useDateFormat } from '../composables/useDateFormat'
import { useProjectsStore } from '../stores/projects'
const { isMobile } = useMobile()
const { formatShort, formatRelative, getDaysLeftText, getDueDateStatus } = useDateFormat()
const store = useProjectsStore()
useLucideIcons()
const props = defineProps({
card: Object,
columnId: [String, Number],
doneColumnId: Number,
index: Number,
departments: {
type: Array,
default: () => []
canMove: {
type: Boolean,
default: true
},
labels: {
type: Array,
default: () => []
canEdit: {
type: Boolean,
default: true
}
})
@@ -104,15 +127,13 @@ 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 }
longPressTimer = setTimeout(() => {
isLongPressing.value = true
// Предотвращаем стандартное поведение (выделение текста, контекстное меню)
e.preventDefault()
// Вибрация если поддерживается
if (navigator.vibrate) {
navigator.vibrate(30)
@@ -158,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
@@ -190,18 +211,13 @@ const handleMouseLeave = () => {
isLongPressing.value = false
}
const refreshIcons = () => {
if (window.lucide) {
window.lucide.createIcons()
}
}
onMounted(refreshIcons)
onUpdated(refreshIcons)
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())
@@ -215,13 +231,13 @@ const handleDragEnd = () => {
// Получаем отдел по id
const cardDepartment = computed(() => {
if (!props.card.departmentId) return null
return props.departments.find(d => d.id === props.card.departmentId) || null
return store.departments.find(d => d.id === props.card.departmentId) || null
})
// Получаем лейбл по id
const cardLabel = computed(() => {
if (!props.card.labelId) return null
return props.labels.find(l => l.id === props.card.labelId) || null
return store.labels.find(l => l.id === props.card.labelId) || null
})
// Цвет лейбла для фона карточки
@@ -229,62 +245,18 @@ const cardLabelColor = computed(() => {
return cardLabel.value?.color || null
})
const formatDateWithYear = (dateStr) => {
const date = new Date(dateStr)
const day = date.getDate()
const months = ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек']
return `${day} ${months[date.getMonth()]} ${date.getFullYear()}`
}
const getDaysLeft = () => {
if (!props.card.dueDate) return null
const due = new Date(props.card.dueDate)
const today = new Date()
today.setHours(0, 0, 0, 0)
due.setHours(0, 0, 0, 0)
return Math.round((due - today) / (1000 * 60 * 60 * 24))
}
const dueDateStatus = computed(() => {
const days = getDaysLeft()
if (days === null) return ''
if (days < 0) return 'overdue'
if (days <= 2) return 'soon'
return ''
})
const daysLeftText = computed(() => {
const days = getDaysLeft()
if (days === null) return ''
if (days < 0) return `Просрочено: ${Math.abs(days)} дн.`
if (days === 0) return 'Сегодня'
if (days === 1) return 'Завтра'
return `Осталось: ${days} дн.`
})
// Даты через composable
const dueDateStatus = computed(() => getDueDateStatus(props.card.dueDate))
const daysLeftText = computed(() => getDaysLeftText(props.card.dueDate))
const closedDateText = computed(() => formatRelative(props.card.dateClosed))
const isAvatarUrl = (value) => {
return value && (value.startsWith('http://') || value.startsWith('https://') || value.startsWith('/'))
}
// Форматирование даты закрытия (относительный формат)
const closedDateText = computed(() => {
if (!props.card.dateClosed) return ''
const closed = new Date(props.card.dateClosed)
const today = new Date()
today.setHours(0, 0, 0, 0)
closed.setHours(0, 0, 0, 0)
const daysAgo = Math.round((today - closed) / (1000 * 60 * 60 * 24))
if (daysAgo === 0) return 'Сегодня'
if (daysAgo === 1) return 'Вчера'
if (daysAgo >= 2 && daysAgo <= 4) return `${daysAgo} дня назад`
if (daysAgo >= 5 && daysAgo <= 14) return `${daysAgo} дней назад`
return formatDateWithYear(props.card.dateClosed)
})
// Можно ли архивировать (только если колонка "Готово")
const canArchive = computed(() => {
return props.doneColumnId && Number(props.columnId) === props.doneColumnId
return store.doneColumnId && Number(props.columnId) === store.doneColumnId
})
const handleArchive = () => {
@@ -305,6 +277,10 @@ const handleArchive = () => {
-webkit-touch-callout: none;
}
.card.no-move {
cursor: pointer;
}
.card:hover {
background: var(--bg-card-hover);
}
@@ -481,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

@@ -26,10 +26,9 @@
<Card
:card="card"
:column-id="column.id"
:done-column-id="doneColumnId"
:index="index"
:departments="departments"
:labels="labels"
: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)"
@@ -47,20 +46,13 @@
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,
doneColumnId: Number,
departments: {
type: Array,
default: () => []
},
labels: {
type: Array,
default: () => []
}
column: Object
})
const emit = defineEmits(['drop-card', 'open-task', 'create-task', 'archive-task', 'move-request'])
@@ -119,7 +111,7 @@ const handleDragLeave = (e) => {
const handleDrop = (e) => {
const cardId = parseInt(e.dataTransfer.getData('cardId'))
const fromColumnId = e.dataTransfer.getData('columnId')
const fromColumnId = parseInt(e.dataTransfer.getData('columnId'))
const toIndex = calculateDropIndex(e.clientY)
dragEnterCounter = 0
@@ -150,17 +142,33 @@ const handleDrop = (e) => {
.column.mobile {
width: 100vw;
min-width: 100vw;
height: 100%;
max-height: none;
/* Высота по контенту, но ограничена доступным пространством */
height: fit-content;
max-height: calc(100% - 20px);
padding: 0 16px;
scroll-snap-align: start;
scroll-snap-stop: always;
flex-shrink: 0;
/* Вертикальный скролл на уровне колонки - убирает вложенные скроллы */
overflow-y: auto;
overflow-x: hidden;
}
/* Скрываем скроллбар в мобильной версии - пользователи свайпают пальцем */
.column.mobile {
scrollbar-width: none;
-ms-overflow-style: none;
}
.column.mobile::-webkit-scrollbar {
display: none;
}
.column.mobile .cards {
max-height: calc(100vh - 320px);
overflow-y: auto;
/* Без overflow - скролл на уровне .column */
max-height: none;
overflow: visible;
min-height: auto;
}
.column.drag-over .cards {

View File

@@ -2,27 +2,30 @@
<Transition name="dialog">
<div v-if="show" class="dialog-overlay" @click.self="handleCancel">
<div class="dialog">
<h3>{{ title }}</h3>
<p v-html="message"></p>
<h3>{{ dialogTitle }}</h3>
<p v-html="dialogMessage"></p>
<div class="dialog-buttons">
<button class="btn-cancel" @click="handleCancel">
{{ cancelText }}
<button class="btn-cancel" @click="handleCancel" :disabled="loading">
{{ dialogCancelText }}
</button>
<button
v-if="showDiscard"
v-if="dialogShowDiscard"
class="btn-discard"
:class="dialogDiscardVariant"
@click="handleDiscard"
:disabled="loading"
>
{{ discardText }}
<span v-if="loading === 'discard'" class="btn-loader"></span>
<span v-else>{{ dialogDiscardText }}</span>
</button>
<button
class="btn-confirm"
:class="variant"
:class="dialogVariant"
@click="handleConfirm"
:disabled="isLoading"
:disabled="loading"
>
<span v-if="isLoading" class="btn-loader"></span>
<span v-else>{{ confirmText }}</span>
<span v-if="loading === 'confirm'" class="btn-loader"></span>
<span v-else>{{ dialogConfirmText }}</span>
</button>
</div>
</div>
@@ -31,59 +34,120 @@
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { DIALOGS } from '../stores/dialogs'
const props = defineProps({
show: {
type: Boolean,
default: false
},
title: {
// Тип диалога из конфига (archive, restore, deleteTask, etc.)
type: {
type: String,
default: 'Подтверждение'
},
message: {
type: String,
default: 'Вы уверены?'
},
confirmText: {
type: String,
default: 'Подтвердить'
},
cancelText: {
type: String,
default: 'Отмена'
},
discardText: {
type: String,
default: 'Не сохранять'
default: null
},
// Прямые props (переопределяют type если заданы)
title: String,
message: String,
confirmText: String,
cancelText: String,
discardText: String,
showDiscard: {
type: Boolean,
default: false
default: undefined
},
// Варианты: 'default', 'danger', 'warning'
variant: {
type: String,
default: 'default'
variant: String,
discardVariant: String,
// Async callback для подтверждения — сам управляет loading
action: {
type: Function,
default: null
},
// Состояние загрузки (блокирует кнопку подтверждения)
isLoading: {
type: Boolean,
default: false
// Async callback для discard (опционально)
discardAction: {
type: Function,
default: null
}
})
const emit = defineEmits(['confirm', 'cancel', 'discard'])
// Получаем конфиг по типу
const config = computed(() => props.type ? DIALOGS[props.type] : {})
const handleConfirm = () => {
emit('confirm')
// Computed свойства с fallback: props → config → default
const dialogTitle = computed(() => props.title ?? config.value.title ?? 'Подтверждение')
const dialogMessage = computed(() => props.message ?? config.value.message ?? 'Вы уверены?')
const dialogConfirmText = computed(() => props.confirmText ?? config.value.confirmText ?? 'Подтвердить')
const dialogCancelText = computed(() => props.cancelText ?? 'Отмена')
const dialogDiscardText = computed(() => props.discardText ?? config.value.discardText ?? 'Не сохранять')
const dialogShowDiscard = computed(() => props.showDiscard ?? config.value.showDiscard ?? false)
const dialogVariant = computed(() => props.variant ?? config.value.variant ?? 'default')
const dialogDiscardVariant = computed(() => props.discardVariant ?? config.value.discardVariant ?? 'default')
const emit = defineEmits(['confirm', 'cancel', 'discard', 'update:show'])
// Внутреннее состояние загрузки: null | 'confirm' | 'discard'
const loading = ref(null)
// Сброс состояния при закрытии диалога
watch(() => props.show, (newVal) => {
if (!newVal) {
loading.value = null
}
})
const handleConfirm = async () => {
if (loading.value) return
// Если есть async action — вызываем его и управляем loading
if (props.action) {
loading.value = 'confirm'
try {
await props.action()
// Успех — эмитим confirm и закрываем диалог
emit('confirm')
emit('update:show', false)
} catch (e) {
console.error('ConfirmDialog action failed:', e)
// При ошибке — не закрываем диалог
} finally {
loading.value = null
}
} else {
// Простой режим — просто эмитим и закрываем
emit('confirm')
emit('update:show', false)
}
}
const handleCancel = () => {
if (loading.value) return
emit('cancel')
emit('update:show', false)
}
const handleDiscard = () => {
emit('discard')
const handleDiscard = async () => {
if (loading.value) return
// Если есть async discardAction — вызываем его и управляем loading
if (props.discardAction) {
loading.value = 'discard'
try {
await props.discardAction()
// Успех — эмитим discard и закрываем диалог
emit('discard')
emit('update:show', false)
} catch (e) {
console.error('ConfirmDialog discardAction failed:', e)
// При ошибке — не закрываем диалог
} finally {
loading.value = null
}
} else {
// Простой режим — просто эмитим и закрываем
emit('discard')
emit('update:show', false)
}
}
</script>
@@ -91,8 +155,7 @@ const handleDiscard = () => {
.dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
background: var(--bg-body);
display: flex;
align-items: center;
justify-content: center;
@@ -100,39 +163,44 @@ const handleDiscard = () => {
}
.dialog {
background: var(--bg-secondary);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 32px;
max-width: 420px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.4);
width: 100%;
height: 100%;
padding: 24px;
}
.dialog h3 {
margin: 0 0 12px;
font-size: 20px;
margin: 0 0 16px;
font-size: 24px;
font-weight: 600;
color: var(--text-primary);
}
.dialog p {
margin: 0 0 24px;
font-size: 14px;
margin: 0 0 48px;
font-size: 16px;
color: var(--text-muted);
line-height: 1.5;
max-width: 320px;
}
.dialog-buttons {
display: flex;
flex-direction: column;
gap: 12px;
justify-content: center;
width: 100%;
max-width: 320px;
}
.dialog-buttons button {
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
width: 100%;
padding: 16px 24px;
border-radius: 12px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
@@ -159,6 +227,30 @@ const handleDiscard = () => {
background: rgba(239, 68, 68, 0.25);
}
.btn-discard.warning {
background: rgba(245, 158, 11, 0.15);
color: #fbbf24;
}
.btn-discard.warning:hover {
background: rgba(245, 158, 11, 0.25);
color: #fcd34d;
}
.btn-discard.danger {
background: #ef4444;
color: #fff;
}
.btn-discard.danger:hover {
background: #dc2626;
}
.btn-discard:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.btn-confirm {
background: var(--accent);
color: #000;
@@ -169,21 +261,23 @@ const handleDiscard = () => {
}
.btn-confirm.danger {
background: #ef4444;
color: #fff;
background: rgba(239, 68, 68, 0.15);
color: #f87171;
}
.btn-confirm.danger:hover {
background: #dc2626;
background: rgba(239, 68, 68, 0.25);
color: #fca5a5;
}
.btn-confirm.warning {
background: #f59e0b;
color: #000;
background: rgba(245, 158, 11, 0.15);
color: #fbbf24;
}
.btn-confirm.warning:hover {
background: #d97706;
background: rgba(245, 158, 11, 0.25);
color: #fcd34d;
}
.btn-confirm:disabled {
@@ -193,8 +287,8 @@ const handleDiscard = () => {
.btn-loader {
display: inline-block;
width: 16px;
height: 16px;
width: 18px;
height: 18px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: currentColor;
border-radius: 50%;
@@ -210,12 +304,12 @@ const handleDiscard = () => {
/* Transition */
.dialog-enter-active,
.dialog-leave-active {
transition: all 0.2s ease;
transition: opacity 0.25s ease;
}
.dialog-enter-active .dialog,
.dialog-leave-active .dialog {
transition: transform 0.2s ease;
transition: transform 0.25s ease;
}
.dialog-enter-from,
@@ -223,8 +317,11 @@ const handleDiscard = () => {
opacity: 0;
}
.dialog-enter-from .dialog,
.dialog-enter-from .dialog {
transform: translateY(20px);
}
.dialog-leave-to .dialog {
transform: scale(0.95);
transform: translateY(-20px);
}
</style>

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;
@@ -524,7 +537,7 @@ watch(isOpen, () => {
.mobile-calendar-overlay {
position: fixed;
inset: 0;
background: #18181b;
background: var(--bg-body);
z-index: 2000;
display: flex;
flex-direction: column;
@@ -534,7 +547,7 @@ watch(isOpen, () => {
.mobile-calendar-panel {
width: 100%;
height: 100%;
background: #18181b;
background: var(--bg-body);
display: flex;
flex-direction: column;
overflow: hidden;
@@ -543,6 +556,8 @@ watch(isOpen, () => {
.mobile-calendar-body {
flex: 1;
padding: 20px 16px;
/* Safe area для iPhone notch */
padding-top: calc(20px + env(safe-area-inset-top, 0px));
display: flex;
flex-direction: column;
}

View File

@@ -1,169 +0,0 @@
<template>
<div class="department-select" :class="{ mobile: isMobile }" @click.stop>
<button class="department-btn" @click="dropdownOpen = !dropdownOpen">
<i data-lucide="filter" class="filter-icon"></i>
{{ currentLabel }}
<i data-lucide="chevron-down" class="chevron" :class="{ open: dropdownOpen }"></i>
</button>
<div class="department-dropdown" v-if="dropdownOpen">
<button
class="department-option"
:class="{ active: modelValue === null }"
@click="handleSelect(null)"
>
Все отделы
</button>
<button
v-for="dept in departments"
:key="dept.id"
class="department-option"
:class="{ active: modelValue === dept.id }"
@click="handleSelect(dept.id)"
>
{{ dept.name_departments }}
</button>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useMobile } from '../composables/useMobile'
const props = defineProps({
modelValue: {
type: Number,
default: null
},
departments: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['update:modelValue'])
const { isMobile } = useMobile()
const dropdownOpen = ref(false)
const currentLabel = computed(() => {
if (props.modelValue === null) return 'Все отделы'
const dept = props.departments.find(d => d.id === props.modelValue)
return dept?.name_departments || 'Все отделы'
})
const handleSelect = (id) => {
dropdownOpen.value = false
emit('update:modelValue', id)
}
// Закрытие при клике вне
const closeDropdown = (e) => {
if (!e.target.closest('.department-select')) {
dropdownOpen.value = false
}
}
onMounted(() => {
document.addEventListener('click', closeDropdown)
if (window.lucide) window.lucide.createIcons()
})
onUnmounted(() => {
document.removeEventListener('click', closeDropdown)
})
</script>
<style scoped>
.department-select {
position: relative;
}
.department-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: var(--bg-card);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: var(--text-secondary);
font-family: inherit;
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.department-btn:hover {
background: var(--bg-card-hover);
color: var(--text-primary);
}
.department-btn .filter-icon {
width: 14px;
height: 14px;
opacity: 0.7;
}
.department-btn .chevron {
width: 12px;
height: 12px;
opacity: 0.5;
transition: transform 0.2s;
margin-left: 4px;
}
.department-btn .chevron.open {
transform: rotate(180deg);
}
.department-dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
min-width: 180px;
background: var(--bg-secondary);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
padding: 6px;
z-index: 200;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.department-option {
display: block;
width: 100%;
padding: 10px 12px;
background: transparent;
border: none;
border-radius: 6px;
color: var(--text-secondary);
font-family: inherit;
font-size: 13px;
text-align: left;
cursor: pointer;
transition: all 0.15s;
}
.department-option:hover {
background: rgba(255, 255, 255, 0.06);
color: var(--text-primary);
}
.department-option.active {
background: var(--accent-soft);
color: var(--accent);
}
/* ========== MOBILE ========== */
.department-select.mobile .department-dropdown {
position: fixed;
top: auto;
left: 16px;
right: 16px;
bottom: 80px;
min-width: auto;
max-height: 50vh;
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,83 @@
<template>
<div class="filters">
<ProjectSelector
@change="$emit('project-change')"
@edit="$emit('edit-project', $event)"
/>
<button
class="add-project-btn"
title="Создать проект"
@click="$emit('create-project')"
>
<i data-lucide="plus"></i>
</button>
<div class="filter-divider"></div>
<button
class="filter-tag"
:class="{ active: modelValue === null }"
@click="$emit('update:modelValue', null)"
>
Все
</button>
<button
v-for="dept in store.departments"
:key="dept.id"
class="filter-tag"
:class="{ active: modelValue === dept.id }"
@click="$emit('update:modelValue', modelValue === dept.id ? null : dept.id)"
>
{{ dept.name_departments }}
</button>
</div>
</template>
<script setup>
import { onMounted, onUpdated } from 'vue'
import ProjectSelector from './ProjectSelector.vue'
import { useProjectsStore } from '../stores/projects'
const store = useProjectsStore()
defineProps({
modelValue: {
type: Number,
default: null
}
})
defineEmits(['update:modelValue', 'project-change', 'create-project', 'edit-project'])
const refreshIcons = () => {
if (window.lucide) window.lucide.createIcons()
}
onMounted(refreshIcons)
onUpdated(refreshIcons)
</script>
<style scoped>
.add-project-btn {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
background: none;
border: none;
border-radius: 4px;
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s;
padding: 0;
margin-left: 6px;
}
.add-project-btn:hover {
color: var(--accent);
}
.add-project-btn i {
width: 14px;
height: 14px;
}
</style>

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,9 +31,8 @@
<script setup>
import { onMounted, onUpdated } from 'vue'
import { useRouter } from 'vue-router'
import { authApi } from '../api'
import { useMobile } from '../composables/useMobile'
import LogoutButton from './ui/LogoutButton.vue'
const { isMobile } = useMobile()
@@ -50,13 +47,6 @@ defineProps({
}
})
const router = useRouter()
const logout = async () => {
await authApi.logout()
router.push('/login')
}
const refreshIcons = () => {
if (window.lucide) {
window.lucide.createIcons()
@@ -114,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;
@@ -160,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

@@ -0,0 +1,192 @@
<template>
<div class="app" :class="{ mobile: isMobile }">
<!-- Боковая панель навигации -->
<Sidebar />
<!-- Основной контент -->
<div class="main-wrapper">
<slot></slot>
</div>
<!-- Модальные окна (вне main-wrapper) -->
<slot name="modals"></slot>
</div>
</template>
<script setup>
import Sidebar from './Sidebar.vue'
import { useMobile } from '../composables/useMobile'
const { isMobile } = useMobile()
</script>
<style scoped>
/* ========== APP CONTAINER ========== */
.app {
display: flex;
min-height: 100vh;
}
/* ========== MAIN WRAPPER ========== */
.main-wrapper {
flex: 1;
min-width: 0; /* Позволяет flex-item сжиматься меньше контента */
margin-left: 64px;
display: flex;
flex-direction: column;
}
/* ========== FILTERS (deep для слотов) ========== */
.app :deep(.filters) {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.app :deep(.filter-divider) {
width: 1px;
height: 24px;
background: rgba(255, 255, 255, 0.1);
margin: 0 8px;
flex-shrink: 0;
}
.app :deep(.filter-tag) {
padding: 6px 12px;
background: var(--bg-card);
border: none;
border-radius: 6px;
color: var(--text-secondary);
font-family: inherit;
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.3px;
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
white-space: nowrap;
}
.app :deep(.filter-tag:hover) {
background: var(--bg-card-hover);
color: var(--text-primary);
}
.app :deep(.filter-tag.active) {
background: var(--accent);
color: #000;
}
/* ========== HEADER STATS ========== */
.app :deep(.header-stats) {
display: flex;
align-items: center;
gap: 16px;
padding: 10px 20px;
background: var(--bg-card);
border-radius: 10px;
}
.app :deep(.stat) {
display: flex;
align-items: baseline;
gap: 5px;
}
.app :deep(.stat-value) {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.app :deep(.stat-label) {
font-size: 12px;
color: var(--text-muted);
}
.app :deep(.stat-divider) {
width: 1px;
height: 16px;
background: rgba(255, 255, 255, 0.1);
}
/* ========== MAIN CONTENT AREA ========== */
.app :deep(.main) {
flex: 1;
padding: 0 36px 36px;
}
/* Стилизация горизонтального скроллбара (для доски) */
.app :deep(.main)::-webkit-scrollbar {
height: 10px;
}
.app :deep(.main)::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.08);
border-radius: 5px;
margin: 0 36px;
}
.app :deep(.main)::-webkit-scrollbar-thumb {
background: rgba(0, 212, 170, 0.4);
border-radius: 5px;
border: 2px solid transparent;
background-clip: padding-box;
}
.app :deep(.main)::-webkit-scrollbar-thumb:hover {
background: rgba(0, 212, 170, 0.6);
}
/* ========== MOBILE ========== */
.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;
/* Убрали padding-bottom - каждая страница сама управляет отступом для навигации */
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Мобильные фильтры — горизонтальный скролл */
.app.mobile :deep(.filters) {
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
-ms-overflow-style: none;
}
.app.mobile :deep(.filters)::-webkit-scrollbar {
display: none;
}
/* Скрываем статистику на мобильных */
.app.mobile :deep(.header-stats) {
display: none;
}
/* Мобильный main — базовые flex-стили, padding управляется через класс .main.mobile в каждой странице */
.app.mobile :deep(.main) {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -7,15 +7,41 @@
<i data-lucide="chevron-down" class="chevron" :class="{ open: dropdownOpen }"></i>
</button>
<div class="project-dropdown" v-if="dropdownOpen">
<button
v-for="project in store.projects"
<div
v-for="(project, index) in store.projects"
:key="project.id"
class="project-option"
:class="{ active: store.currentProjectId === project.id }"
@click="handleSelect(project.id)"
class="project-option-wrapper"
:class="{
'is-dragging': dragIndex === index,
'drag-over-top': dragOverIndex === index && dragPosition === 'top',
'drag-over-bottom': dragOverIndex === index && dragPosition === 'bottom'
}"
draggable="true"
@dragstart="handleDragStart($event, index)"
@dragend="handleDragEnd"
@dragover.prevent="handleDragOver($event, index)"
@dragleave="handleDragLeave"
@drop.prevent="handleDrop(index)"
>
{{ project.name }}
</button>
<div class="project-drag-handle">
<i data-lucide="grip-vertical"></i>
</div>
<button
class="project-option"
:class="{ active: store.currentProjectId === project.id }"
@click="handleSelect(project.id)"
>
{{ project.name }}
</button>
<button
v-if="project.is_admin"
class="project-edit-btn"
title="Настройки проекта"
@click.stop="handleEdit(project)"
>
<i data-lucide="settings"></i>
</button>
</div>
</div>
</div>
@@ -32,7 +58,7 @@
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useProjectsStore } from '../stores/projects'
import { useMobile } from '../composables/useMobile'
import MobileSelect from './ui/MobileSelect.vue'
@@ -41,7 +67,72 @@ const store = useProjectsStore()
const { isMobile } = useMobile()
const dropdownOpen = ref(false)
const emit = defineEmits(['change'])
const emit = defineEmits(['change', 'edit'])
// ==================== DRAG AND DROP ====================
const dragIndex = ref(null)
const dragOverIndex = ref(null)
const dragPosition = ref(null) // 'top' | 'bottom'
const handleDragStart = (e, index) => {
dragIndex.value = index
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', index.toString())
}
const handleDragEnd = () => {
dragIndex.value = null
dragOverIndex.value = null
dragPosition.value = null
}
const handleDragOver = (e, index) => {
if (dragIndex.value === null || dragIndex.value === index) return
const rect = e.currentTarget.getBoundingClientRect()
const midY = rect.top + rect.height / 2
dragOverIndex.value = index
dragPosition.value = e.clientY < midY ? 'top' : 'bottom'
}
const handleDragLeave = () => {
// Don't clear immediately to avoid flickering
}
const handleDrop = async (targetIndex) => {
if (dragIndex.value === null || dragIndex.value === targetIndex) {
handleDragEnd()
return
}
const sourceIndex = dragIndex.value
let insertIndex = targetIndex
// Adjust insert index based on drag position
if (dragPosition.value === 'bottom') {
insertIndex = targetIndex + 1
}
// If moving down, adjust for removal
if (sourceIndex < insertIndex) {
insertIndex--
}
// Build new order array
const projects = [...store.projects]
const [movedProject] = projects.splice(sourceIndex, 1)
projects.splice(insertIndex, 0, movedProject)
// Get IDs in new order
const ids = projects.map(p => p.id)
// Update store
await store.reorderProjects(ids)
handleDragEnd()
nextTick(refreshIcons)
}
// ==================== DESKTOP ====================
const handleSelect = async (projectId) => {
@@ -50,6 +141,11 @@ const handleSelect = async (projectId) => {
emit('change', projectId)
}
const handleEdit = (project) => {
dropdownOpen.value = false
emit('edit', project)
}
// Закрытие дропдауна при клике вне
const closeDropdown = (e) => {
if (!e.target.closest('.project-select')) {
@@ -75,14 +171,23 @@ const handleMobileSelect = async (projectId) => {
}
// ==================== LIFECYCLE ====================
const refreshIcons = () => {
if (window.lucide) window.lucide.createIcons()
}
onMounted(() => {
document.addEventListener('click', closeDropdown)
if (window.lucide) window.lucide.createIcons()
refreshIcons()
})
onUnmounted(() => {
document.removeEventListener('click', closeDropdown)
})
// Refresh icons when dropdown opens
watch(dropdownOpen, (open) => {
if (open) nextTick(refreshIcons)
})
</script>
<style scoped>
@@ -169,4 +274,91 @@ onUnmounted(() => {
color: var(--accent);
}
/* ==================== DRAG AND DROP ==================== */
.project-option-wrapper {
display: flex;
align-items: center;
gap: 4px;
border-radius: 6px;
transition: all 0.15s;
}
.project-option-wrapper:hover {
background: rgba(255, 255, 255, 0.03);
}
.project-option-wrapper.is-dragging {
opacity: 0.5;
}
.project-option-wrapper.drag-over-top {
border-top: 2px solid var(--accent);
}
.project-option-wrapper.drag-over-bottom {
border-bottom: 2px solid var(--accent);
}
.project-drag-handle {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
color: var(--text-muted);
cursor: grab;
flex-shrink: 0;
margin-left: 2px;
opacity: 0.5;
transition: opacity 0.15s;
}
.project-drag-handle:hover {
opacity: 1;
}
.project-drag-handle:active {
cursor: grabbing;
}
.project-drag-handle i {
width: 12px;
height: 12px;
}
.project-option-wrapper .project-option {
flex: 1;
min-width: 0;
text-align: left;
}
.project-edit-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
background: none;
border: none;
border-radius: 6px;
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
margin-right: 2px;
opacity: 0.5;
}
.project-edit-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--text-primary);
opacity: 1;
}
.project-edit-btn i {
width: 13px;
height: 13px;
}
</style>

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,769 +0,0 @@
<template>
<div class="comment-form">
<!-- Desktop: Inline форма -->
<template v-if="!isMobile">
<!-- Индикатор "ответ на" -->
<div v-if="replyingTo" class="reply-indicator">
<i data-lucide="corner-down-right"></i>
<span>Ответ для <strong>{{ replyingTo.author_name }}</strong></span>
<button class="reply-cancel" @click="$emit('cancel-reply')" title="Отменить">
<i data-lucide="x"></i>
</button>
</div>
<FormField :label="replyingTo ? 'Ваш ответ' : 'Новый комментарий'">
<template #actions>
<div class="format-buttons">
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('bold')" title="Жирный">
<i data-lucide="bold"></i>
</button>
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('italic')" title="Курсив">
<i data-lucide="italic"></i>
</button>
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('underline')" title="Подчёркивание">
<i data-lucide="underline"></i>
</button>
<button type="button" class="format-btn" @mousedown.prevent="triggerFileInput" title="Прикрепить файл">
<i data-lucide="paperclip"></i>
</button>
</div>
</template>
<RichTextEditor
:modelValue="modelValue"
@update:modelValue="$emit('update:modelValue', $event)"
:placeholder="replyingTo ? 'Напишите ответ...' : 'Напишите комментарий...'"
:show-toolbar="false"
ref="editorRef"
/>
</FormField>
<!-- Превью прикреплённых файлов -->
<div v-if="files.length > 0" class="attached-files">
<div
v-for="(file, index) in files"
:key="file.name + '-' + index"
class="attached-file"
>
<div class="attached-file-icon">
<i v-if="isArchive(file)" data-lucide="archive"></i>
<i v-else data-lucide="image"></i>
</div>
<span class="attached-file-name" :title="file.name">{{ file.name }}</span>
<button class="attached-file-remove" @click="removeFile(index)" title="Удалить">
<i data-lucide="x"></i>
</button>
</div>
</div>
<button
class="btn-send-comment"
@click="$emit('send')"
:disabled="!canSend || isSending"
>
<span v-if="isSending" class="btn-loader"></span>
<template v-else>
<i data-lucide="send"></i>
Отправить
</template>
</button>
</template>
<!-- Mobile: Кнопка открытия формы -->
<template v-else>
<button class="btn-open-form" @click="openMobileForm">
<i data-lucide="message-square-plus"></i>
{{ replyingTo ? 'Написать ответ' : 'Написать комментарий' }}
</button>
</template>
<!-- Скрытый input для файлов (общий) -->
<input
type="file"
ref="fileInputRef"
accept=".png,.jpg,.jpeg,.zip,.rar,image/png,image/jpeg,application/zip,application/x-rar-compressed"
multiple
@change="handleFileSelect"
style="display: none"
>
<!-- Mobile: Fullscreen форма -->
<Teleport to="body">
<Transition name="mobile-form">
<div v-if="isMobile && mobileFormOpen" class="mobile-form-overlay">
<div class="mobile-form-panel">
<div class="mobile-form-header">
<button class="btn-close" @click="closeMobileForm">
<i data-lucide="x"></i>
</button>
<h3 class="panel-title">{{ replyingTo ? 'Ответ' : 'Новый комментарий' }}</h3>
<div class="header-spacer"></div>
</div>
<!-- Индикатор "ответ на" -->
<div v-if="replyingTo" class="mobile-reply-indicator">
<i data-lucide="corner-down-right"></i>
<span>Ответ для <strong>{{ replyingTo.author_name }}</strong></span>
</div>
<div class="mobile-form-body">
<RichTextEditor
:modelValue="modelValue"
@update:modelValue="$emit('update:modelValue', $event)"
:placeholder="replyingTo ? 'Напишите ответ...' : 'Напишите комментарий...'"
:show-toolbar="false"
ref="mobileEditorRef"
class="mobile-editor"
/>
<!-- Превью прикреплённых файлов -->
<div v-if="files.length > 0" class="attached-files mobile">
<div
v-for="(file, index) in files"
:key="file.name + '-' + index"
class="attached-file"
>
<div class="attached-file-icon">
<i v-if="isArchive(file)" data-lucide="archive"></i>
<i v-else data-lucide="image"></i>
</div>
<span class="attached-file-name" :title="file.name">{{ file.name }}</span>
<button class="attached-file-remove" @click="removeFile(index)" title="Удалить">
<i data-lucide="x"></i>
</button>
</div>
</div>
</div>
<div class="mobile-form-footer">
<div class="mobile-format-buttons">
<button class="btn-format" @click="applyMobileFormat('bold')" title="Жирный">
<i data-lucide="bold"></i>
</button>
<button class="btn-format" @click="applyMobileFormat('italic')" title="Курсив">
<i data-lucide="italic"></i>
</button>
<button class="btn-format" @click="applyMobileFormat('underline')" title="Подчёркивание">
<i data-lucide="underline"></i>
</button>
<button class="btn-format" @click="triggerFileInput" title="Прикрепить файл">
<i data-lucide="paperclip"></i>
</button>
</div>
<button
class="btn-send"
@click="handleMobileSend"
:disabled="!canSend || isSending"
>
<span v-if="isSending" class="btn-loader"></span>
<template v-else>
<i data-lucide="send"></i>
</template>
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUpdated, watch, nextTick } from 'vue'
import FormField from '../ui/FormField.vue'
import RichTextEditor from '../ui/RichTextEditor.vue'
import { useMobile } from '../../composables/useMobile'
const { isMobile } = useMobile()
const props = defineProps({
modelValue: {
type: String,
default: ''
},
replyingTo: {
type: Object,
default: null
},
isSending: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue', 'send', 'cancel-reply'])
const editorRef = ref(null)
const mobileEditorRef = ref(null)
const fileInputRef = ref(null)
const files = ref([])
const mobileFormOpen = ref(false)
// Открытие мобильной формы
const openMobileForm = async () => {
mobileFormOpen.value = true
await nextTick()
refreshIcons()
}
// Закрытие мобильной формы
const closeMobileForm = () => {
mobileFormOpen.value = false
emit('cancel-reply')
}
// Отправка из мобильной формы
const handleMobileSend = () => {
emit('send')
// Форма закроется после успешной отправки через watch
}
// Закрытие формы когда isSending становится false после отправки
watch(() => props.isSending, (newVal, oldVal) => {
if (oldVal === true && newVal === false && mobileFormOpen.value) {
// Отправка завершена, закрываем форму
closeMobileForm()
}
})
// Открытие формы при выборе ответа
watch(() => props.replyingTo, (newVal) => {
if (newVal && isMobile.value) {
openMobileForm()
}
})
const allowedExtensions = ['png', 'jpg', 'jpeg', 'zip', 'rar']
const archiveExtensions = ['zip', 'rar']
const maxFileSize = 10 * 1024 * 1024 // 10 MB
// Можно отправить если есть текст или файлы
const canSend = computed(() => {
return props.modelValue.trim() || files.value.length > 0
})
// Проверка расширения файла
const getFileExt = (file) => {
return file.name?.split('.').pop()?.toLowerCase() || ''
}
const isArchive = (file) => {
return archiveExtensions.includes(getFileExt(file))
}
const isAllowedFile = (file) => {
return allowedExtensions.includes(getFileExt(file))
}
// Открыть диалог выбора файлов
const triggerFileInput = () => {
fileInputRef.value?.click()
}
// Обработка выбора файлов
const handleFileSelect = (event) => {
const selectedFiles = event.target.files
if (selectedFiles) {
processFiles(Array.from(selectedFiles))
}
event.target.value = ''
}
// Обработка файлов
const processFiles = (fileList) => {
for (const file of fileList) {
// Проверка типа
if (!isAllowedFile(file)) {
console.warn(`Файл "${file.name}" не поддерживается.`)
continue
}
// Проверка размера
if (file.size > maxFileSize) {
console.warn(`Файл "${file.name}" слишком большой.`)
continue
}
// Проверяем дубликат
const isDuplicate = files.value.some(
f => f.name === file.name && f.size === file.size
)
if (isDuplicate) continue
// Читаем файл как base64
const reader = new FileReader()
reader.onload = (e) => {
files.value.push({
file: file,
name: file.name,
size: file.size,
data: e.target.result
})
refreshIcons()
}
reader.readAsDataURL(file)
}
}
// Удаление файла из списка
const removeFile = (index) => {
files.value.splice(index, 1)
}
// Получить файлы для отправки
const getFiles = () => {
return files.value.map(f => ({
name: f.name,
data: f.data
}))
}
// Очистить файлы
const clearFiles = () => {
files.value = []
}
const applyFormat = (command) => {
editorRef.value?.applyFormat(command)
}
const applyMobileFormat = (command) => {
mobileEditorRef.value?.applyFormat(command)
}
const refreshIcons = () => {
if (window.lucide) {
window.lucide.createIcons()
}
}
onMounted(refreshIcons)
onUpdated(refreshIcons)
// Expose для внешнего доступа
defineExpose({
setContent: (text) => editorRef.value?.setContent(text),
focus: () => editorRef.value?.$el?.focus(),
applyFormat,
getFiles,
clearFiles,
hasFiles: computed(() => files.value.length > 0)
})
</script>
<style scoped>
.comment-form {
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding-top: 16px;
padding-bottom: calc(var(--safe-area-bottom, 0px));
margin-top: auto;
}
.format-buttons {
display: flex;
gap: 3px;
}
.format-btn {
width: 24px;
height: 24px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 5px;
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.format-btn:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.15);
color: var(--text-primary);
}
.format-btn:active {
background: var(--accent);
border-color: var(--accent);
color: #000;
}
.format-btn i {
width: 10px;
height: 10px;
}
.btn-send-comment {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 12px;
background: var(--accent);
border: none;
border-radius: 8px;
color: #000;
font-family: inherit;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
margin-top: 12px;
}
.btn-send-comment:hover {
filter: brightness(1.1);
}
.btn-send-comment:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-send-comment i {
width: 16px;
height: 16px;
}
.btn-loader {
display: inline-block;
width: 18px;
height: 18px;
border: 2px solid rgba(0, 0, 0, 0.3);
border-top-color: #000;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Индикатор ответа */
.reply-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: rgba(0, 212, 170, 0.08);
border-radius: 8px;
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 12px;
}
.reply-indicator i {
width: 14px;
height: 14px;
color: var(--accent);
}
.reply-indicator strong {
color: var(--text-primary);
}
.reply-cancel {
margin-left: auto;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
border-radius: 4px;
transition: all 0.15s;
}
.reply-cancel:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--text-primary);
}
.reply-cancel i {
width: 14px;
height: 14px;
color: inherit;
}
/* Прикреплённые файлы */
.attached-files {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.attached-file {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
font-size: 12px;
color: var(--text-secondary);
}
.attached-file-icon {
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
}
.attached-file-icon i {
width: 14px;
height: 14px;
}
.attached-file-name {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attached-file-remove {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
border-radius: 4px;
transition: all 0.15s;
margin-left: 2px;
}
.attached-file-remove:hover {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.attached-file-remove i {
width: 12px;
height: 12px;
}
/* ========== MOBILE: Кнопка открытия формы ========== */
.btn-open-form {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
padding: 14px;
background: var(--accent);
border: none;
border-radius: 10px;
color: #000;
font-family: inherit;
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.btn-open-form i {
width: 18px;
height: 18px;
}
.btn-open-form:active {
filter: brightness(0.9);
}
/* ========== MOBILE: Fullscreen Form ========== */
.mobile-form-overlay {
position: fixed;
inset: 0;
background: #18181b;
z-index: 2000;
display: flex;
flex-direction: column;
touch-action: none;
}
.mobile-form-panel {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.mobile-form-header {
display: flex;
align-items: center;
padding: 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
flex-shrink: 0;
}
.mobile-form-header .panel-title {
flex: 1;
text-align: center;
font-size: 18px;
font-weight: 500;
margin: 0;
}
.mobile-form-header .btn-close {
width: 36px;
height: 36px;
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
flex-shrink: 0;
}
.mobile-form-header .btn-close i {
width: 20px;
height: 20px;
}
.mobile-form-header .header-spacer {
width: 36px;
flex-shrink: 0;
}
.mobile-reply-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: rgba(0, 212, 170, 0.08);
font-size: 14px;
color: var(--text-secondary);
}
.mobile-reply-indicator i {
width: 16px;
height: 16px;
color: var(--accent);
}
.mobile-reply-indicator strong {
color: var(--text-primary);
}
.mobile-form-body {
flex: 1;
padding: 16px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.mobile-editor {
flex: 1;
min-height: 150px;
}
.mobile-editor :deep(.editor-content) {
min-height: 150px;
}
.attached-files.mobile {
margin-top: auto;
}
.mobile-form-footer {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
padding-bottom: calc(12px + var(--safe-area-bottom, 0px));
border-top: 1px solid rgba(255, 255, 255, 0.06);
flex-shrink: 0;
}
.mobile-format-buttons {
display: flex;
gap: 6px;
}
.btn-format {
width: 40px;
height: 40px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
}
.btn-format i {
width: 16px;
height: 16px;
}
.btn-format:active {
background: var(--accent);
border-color: var(--accent);
color: #000;
}
.btn-send {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 14px;
background: var(--accent);
border: none;
border-radius: 10px;
color: #000;
font-family: inherit;
font-size: 15px;
font-weight: 500;
cursor: pointer;
}
.btn-send i {
width: 18px;
height: 18px;
}
.btn-send:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-send:not(:disabled):active {
filter: brightness(0.9);
}
/* Mobile form transition */
.mobile-form-enter-active,
.mobile-form-leave-active {
transition: opacity 0.2s ease;
}
.mobile-form-enter-from,
.mobile-form-leave-to {
opacity: 0;
}
</style>

View File

@@ -22,6 +22,7 @@
<div class="comment-actions">
<IconButton
v-if="canReply"
icon="reply"
variant="ghost"
small
@@ -82,12 +83,16 @@
</template>
<script setup>
import { computed, onMounted, onUpdated } from 'vue'
import { computed } from 'vue'
import IconButton from '../ui/IconButton.vue'
import { serverSettings } from '../../api'
import { useMobile } from '../../composables/useMobile'
import { useLucideIcons } from '../../composables/useLucideIcons'
import { useDateFormat } from '../../composables/useDateFormat'
const { isMobile } = useMobile()
const { formatTimeAgo } = useDateFormat()
useLucideIcons()
const props = defineProps({
comment: {
@@ -106,6 +111,10 @@ const props = defineProps({
type: Boolean,
default: false
},
canReply: {
type: Boolean,
default: true
},
getFullUrl: {
type: Function,
required: true
@@ -131,36 +140,8 @@ const openPreview = (file) => {
emit('preview-file', file)
}
// Форматирование даты комментария
const formattedDate = computed(() => {
if (!props.comment.date_create) return ''
// Используем таймзону сервера из настроек
const date = serverSettings.parseDate(props.comment.date_create)
const now = new Date()
const diff = now - date
const minutes = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (minutes < 1) return 'только что'
if (minutes < 60) return `${minutes} мин. назад`
if (hours < 24) return `${hours} ч. назад`
if (days < 7) return `${days} дн. назад`
const day = date.getDate()
const months = ['янв', 'фев', 'мар', 'апр', 'мая', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек']
const time = date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
return `${day} ${months[date.getMonth()]} ${time}`
})
const refreshIcons = () => {
if (window.lucide) {
window.lucide.createIcons()
}
}
onMounted(refreshIcons)
onUpdated(refreshIcons)
// Форматирование даты комментария (таймзона сервера учитывается автоматически)
const formattedDate = computed(() => formatTimeAgo(props.comment.date_create))
</script>
<style scoped>

View File

@@ -0,0 +1,389 @@
<template>
<SlidePanel
:show="show"
@close="handleClose"
>
<template #header>
<div class="editor-panel-header">
<div v-if="avatarUrl" class="editor-author-avatar">
<img :src="avatarUrl" alt="">
</div>
<div class="editor-header-text">
<h2>{{ title }}</h2>
<span v-if="subtitle" class="editor-author-name">{{ subtitle }}</span>
</div>
</div>
</template>
<div class="editor-panel-content">
<!-- Редактор текста -->
<div class="editor-form-section">
<div class="editor-form-label">
<span>{{ textLabel }}</span>
<div class="format-buttons">
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('bold')" title="Жирный">
<i data-lucide="bold"></i>
</button>
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('italic')" title="Курсив">
<i data-lucide="italic"></i>
</button>
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('underline')" title="Подчёркивание">
<i data-lucide="underline"></i>
</button>
</div>
</div>
<RichTextEditor
:modelValue="localText"
@update:modelValue="localText = $event"
:placeholder="placeholder"
:show-toolbar="false"
ref="editorRef"
class="editor-textarea"
/>
</div>
<!-- Файлы -->
<div class="editor-form-section">
<div class="editor-form-label">
<span>Файлы</span>
</div>
<FileUploader
:files="allFiles"
:get-full-url="getFileFullUrl"
dropzone-text="Перетащите файлы сюда"
dropzone-subtext="или нажмите для выбора"
@add="handleFileAdd"
@remove="handleFileRemove"
@preview="handleFilePreview"
@error="handleFileError"
/>
</div>
</div>
<template #footer>
<ActionButtons
:save-text="saveButtonText"
save-icon="check"
:loading="isSaving"
:disabled="!canSave"
@save="handleSave"
@cancel="handleClose"
/>
</template>
</SlidePanel>
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import SlidePanel from '../ui/SlidePanel.vue'
import RichTextEditor from '../ui/RichTextEditor.vue'
import FileUploader from '../ui/FileUploader.vue'
import ActionButtons from '../ui/ActionButtons.vue'
import { getFullUrl } from '../../api'
import { useLucideIcons } from '../../composables/useLucideIcons'
const props = defineProps({
// Управление видимостью
show: {
type: Boolean,
default: false
},
// Заголовок панели
title: {
type: String,
default: 'Редактирование'
},
// Подзаголовок (например, имя автора)
subtitle: {
type: String,
default: ''
},
// URL аватара (опционально)
avatarUrl: {
type: String,
default: ''
},
// Начальный текст
text: {
type: String,
default: ''
},
// Placeholder для редактора
placeholder: {
type: String,
default: 'Введите текст...'
},
// Лейбл для поля текста
textLabel: {
type: String,
default: 'Текст'
},
// Существующие файлы (для редактирования)
existingFiles: {
type: Array,
default: () => []
},
// Функция получения URL файла
fileUrlGetter: {
type: Function,
default: null
},
// Текст кнопки сохранения
saveButtonText: {
type: String,
default: 'Сохранить'
},
// Состояние сохранения
isSaving: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['save', 'close', 'update:show'])
const { refreshIcons } = useLucideIcons()
// Refs
const editorRef = ref(null)
// Local state
const localText = ref('')
const allFiles = ref([])
// Computed
const canSave = computed(() => {
const hasText = localText.value.trim()
const hasNewFiles = allFiles.value.some(f => f.isNew)
return hasText || hasNewFiles
})
// Получить полный URL файла
const getFileFullUrl = (url) => {
if (props.fileUrlGetter) {
return props.fileUrlGetter(url)
}
return getFullUrl(url)
}
// File handlers
const handleFileAdd = (file) => {
allFiles.value.push(file)
refreshIcons()
}
const handleFileRemove = (index) => {
const file = allFiles.value[index]
if (file.isNew) {
// Новый файл - удаляем сразу
allFiles.value.splice(index, 1)
} else {
// Существующий файл - помечаем на удаление
allFiles.value[index] = { ...file, toDelete: true }
}
}
const handleFilePreview = (file) => {
// Можно добавить emit для превью если нужно
console.log('Preview file:', file.name)
}
const handleFileError = (message) => {
console.warn(message)
}
// Format
const applyFormat = (command) => {
editorRef.value?.applyFormat(command)
}
// Actions
const handleSave = () => {
// Собираем новые файлы
const newFiles = allFiles.value
.filter(f => f.isNew)
.map(f => ({
name: f.name,
data: f.preview
}))
// Собираем файлы на удаление
const filesToDelete = allFiles.value
.filter(f => f.toDelete)
.map(f => f.name)
emit('save', {
text: localText.value,
newFiles,
filesToDelete
})
}
const handleClose = () => {
emit('close')
emit('update:show', false)
}
// Reset state when panel opens
const resetState = () => {
localText.value = props.text || ''
// Преобразуем существующие файлы в формат FileUploader
allFiles.value = (props.existingFiles || []).map(file => ({
name: file.name,
size: file.size || 0,
preview: file.url,
isNew: false,
toDelete: false
}))
nextTick(() => {
editorRef.value?.setContent(localText.value)
refreshIcons()
})
}
// Watch show prop
watch(() => props.show, (newVal) => {
if (newVal) {
resetState()
}
})
// Watch text/existingFiles for external updates
watch(() => props.text, (newVal) => {
if (props.show) {
localText.value = newVal || ''
}
})
watch(() => props.existingFiles, (newVal) => {
if (props.show) {
allFiles.value = (newVal || []).map(file => ({
name: file.name,
size: file.size || 0,
preview: file.url,
isNew: false,
toDelete: false
}))
}
}, { deep: true })
// Expose
defineExpose({
reset: resetState
})
</script>
<style scoped>
/* ========== Panel Header ========== */
.editor-panel-header {
display: flex;
align-items: center;
gap: 12px;
}
.editor-panel-header h2 {
margin: 0;
font-size: 18px;
font-weight: 500;
}
.editor-author-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
}
.editor-author-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.editor-header-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.editor-author-name {
font-size: 13px;
color: var(--text-muted);
}
/* ========== Content ========== */
.editor-panel-content {
display: flex;
flex-direction: column;
gap: 24px;
}
.editor-form-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.editor-form-label {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.format-buttons {
display: flex;
gap: 4px;
}
.format-btn {
width: 26px;
height: 26px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 5px;
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.format-btn:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.15);
color: var(--text-primary);
}
.format-btn:active {
background: var(--accent);
border-color: var(--accent);
color: #000;
}
.format-btn i {
width: 12px;
height: 12px;
}
.editor-textarea {
min-height: 120px;
}
.editor-textarea :deep(.editor-content) {
min-height: 120px;
}
</style>

File diff suppressed because it is too large Load Diff

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)"
@@ -85,10 +93,7 @@
<!-- Диалог удаления файла -->
<ConfirmDialog
:show="showDeleteFileDialog"
title="Удалить изображение?"
message="Изображение будет удалено из задачи."
confirm-text="Удалить"
variant="danger"
type="deleteFile"
@confirm="confirmDeleteFile"
@cancel="showDeleteFileDialog = false"
/>
@@ -96,7 +101,7 @@
</template>
<script setup>
import { ref, reactive, computed, watch, onMounted, onUpdated, nextTick } from 'vue'
import { ref, reactive, computed, watch, nextTick } from 'vue'
import FormField from '../ui/FormField.vue'
import TextInput from '../ui/TextInput.vue'
import RichTextEditor from '../ui/RichTextEditor.vue'
@@ -107,9 +112,12 @@ import DatePicker from '../DatePicker.vue'
import ConfirmDialog from '../ConfirmDialog.vue'
import { getFullUrl } from '../../api'
import { useMobile } from '../../composables/useMobile'
import { useLucideIcons } from '../../composables/useLucideIcons'
const { isMobile } = useMobile()
const { refreshIcons } = useLucideIcons()
const props = defineProps({
card: {
type: Object,
@@ -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
@@ -286,12 +335,6 @@ const confirmDeleteFile = () => {
fileToDeleteIndex.value = null
}
const refreshIcons = () => {
if (window.lucide) {
window.lucide.createIcons()
}
}
// Get form data for saving
const getFormData = () => {
return {
@@ -335,9 +378,6 @@ const setDetailsContent = (content) => {
detailsEditorRef.value?.setContent(content)
}
onMounted(refreshIcons)
onUpdated(refreshIcons)
// Expose for parent
defineExpose({
form,

View File

@@ -7,7 +7,7 @@
<div class="header-title-block">
<h2>{{ panelTitle }}</h2>
<span v-if="!isNew && card?.dateCreate" class="header-date">
Создано: {{ formatDate(card.dateCreate) }}
Создано: {{ formatFull(card.dateCreate) }}
</span>
</div>
@@ -26,9 +26,10 @@
v-show="activeTab === 'edit' || isNew"
ref="editTabRef"
:card="card"
:departments="departments"
:labels="labels"
:users="users"
:departments="store.departments"
:labels="store.labels"
:users="store.users"
:can-edit="canEdit"
@preview-image="openImagePreview"
/>
@@ -37,16 +38,19 @@
v-show="activeTab === 'comments'"
ref="commentsTabRef"
:task-id="card?.id"
:current-user-id="currentUserId"
:is-project-admin="isProjectAdmin"
:current-user-id="store.currentUserId"
:current-user-name="store.currentUserName"
: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 || isArchived)">
<div class="footer-left">
<IconButton
v-if="!isNew"
@@ -70,27 +74,26 @@
@click="handleRestore"
/>
</div>
<div class="footer-right">
<button class="btn-cancel" @click="tryClose">Отмена</button>
<button
class="btn-save"
@click="handleSave"
:disabled="!canSave || isSaving"
>
<span v-if="isSaving" class="btn-loader"></span>
<span v-else>{{ isNew ? 'Создать' : 'Сохранить' }}</span>
</button>
</div>
<!-- Кнопки сохранения только для редактируемых задач -->
<ActionButtons
v-if="canEdit"
:save-text="isNew ? 'Создать' : 'Сохранить'"
:loading="isSaving"
:disabled="!canSave"
@save="handleSave"
@cancel="tryClose"
/>
<!-- Для архивных только кнопка закрытия -->
<button v-else class="btn-close-panel" @click="tryClose">
Закрыть
</button>
</template>
</SlidePanel>
<!-- Диалог несохранённых изменений -->
<ConfirmDialog
:show="showUnsavedDialog"
title="Обнаружены изменения"
message="У вас есть несохранённые изменения.<br>Что вы хотите сделать?"
confirm-text="Сохранить"
:show-discard="true"
type="unsavedChanges"
@confirm="confirmSave"
@cancel="cancelClose"
@discard="confirmDiscard"
@@ -99,33 +102,27 @@
<!-- Диалог удаления задачи -->
<ConfirmDialog
:show="showDeleteDialog"
title="Удалить задачу?"
message="Это действие нельзя отменить.<br>Задача будет удалена навсегда."
confirm-text="Удалить"
variant="danger"
@confirm="confirmDelete"
type="deleteTask"
:action="confirmDelete"
@confirm="showDeleteDialog = false"
@cancel="showDeleteDialog = false"
/>
<!-- Диалог архивации задачи -->
<ConfirmDialog
:show="showArchiveDialog"
title="Архивировать задачу?"
message="Задача будет перемещена в архив.<br>Вы сможете восстановить её позже."
confirm-text="В архив"
variant="warning"
@confirm="confirmArchive"
type="archive"
:action="confirmArchive"
@confirm="showArchiveDialog = false"
@cancel="showArchiveDialog = false"
/>
<!-- Диалог разархивации задачи -->
<ConfirmDialog
:show="showRestoreDialog"
title="Вернуть из архива?"
message="Задача будет возвращена на доску<br>в колонку «Готово»."
confirm-text="Вернуть"
variant="warning"
@confirm="confirmRestore"
type="restore"
:action="confirmRestore"
@confirm="showRestoreDialog = false"
@cancel="showRestoreDialog = false"
/>
@@ -140,18 +137,27 @@
</template>
<script setup>
import { ref, computed, watch, onMounted, onUpdated, nextTick } from 'vue'
import { ref, computed, watch, onMounted, nextTick } from 'vue'
import SlidePanel from '../ui/SlidePanel.vue'
import TabsPanel from '../ui/TabsPanel.vue'
import IconButton from '../ui/IconButton.vue'
import ActionButtons from '../ui/ActionButtons.vue'
import ImagePreview from '../ui/ImagePreview.vue'
import ConfirmDialog from '../ConfirmDialog.vue'
import TaskEditTab from './TaskEditTab.vue'
import TaskCommentsTab from './TaskCommentsTab.vue'
import { taskImageApi, commentImageApi, getFullUrl } from '../../api'
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()
// Состояние загрузки для кнопки сохранения
const isSaving = ref(false)
@@ -160,33 +166,24 @@ const props = defineProps({
show: Boolean,
card: Object,
columnId: [String, Number],
doneColumnId: Number,
isArchived: {
type: Boolean,
default: false
},
departments: {
type: Array,
default: () => []
},
labels: {
type: Array,
default: () => []
},
users: {
type: Array,
default: () => []
},
currentUserId: {
type: Number,
// Callbacks (возвращают Promise)
onSave: {
type: Function,
default: null
},
isProjectAdmin: {
type: Boolean,
default: false
onDelete: {
type: Function,
default: null
},
// Callback для сохранения (возвращает Promise)
onSave: {
onArchive: {
type: Function,
default: null
},
onRestore: {
type: Function,
default: null
}
@@ -216,7 +213,7 @@ const previewImage = ref(null)
const panelTitle = computed(() => {
if (isNew.value) return 'Новая задача'
if (activeTab.value === 'comments') return 'Комментарии'
return 'Редактирование'
return canEdit.value ? 'Редактирование' : 'Просмотр'
})
// Tabs config
@@ -233,17 +230,24 @@ const canSave = computed(() => {
// Can archive (только если колонка "Готово")
const canArchive = computed(() => {
return props.doneColumnId && Number(props.columnId) === props.doneColumnId
return store.doneColumnId && Number(props.columnId) === store.doneColumnId
})
// Format date
const formatDate = (dateStr) => {
if (!dateStr) return ''
const date = new Date(dateStr)
const day = date.getDate()
const months = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря']
return `${day} ${months[date.getMonth()]} ${date.getFullYear()}`
}
// Право на редактирование (для новой — create_task, для существующей — canEditTask)
// Архивные задачи нельзя редактировать
const canEdit = computed(() => {
if (props.isArchived) return false
if (isNew.value) return store.can('create_task')
return store.canEditTask(props.card)
})
// Право на создание комментариев
// Архивные задачи нельзя комментировать
const canComment = computed(() => {
if (isNew.value) return false // В новой задаче нельзя комментировать
if (props.isArchived) return false // Архивные нельзя комментировать
return store.canCreateComment(props.card)
})
// Close handling
const tryClose = () => {
@@ -317,6 +321,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
}
@@ -327,9 +336,18 @@ const handleDelete = () => {
showDeleteDialog.value = true
}
const confirmDelete = () => {
showDeleteDialog.value = false
emit('delete', props.card.id)
const confirmDelete = async () => {
if (!props.card?.id) {
throw new Error('Задача не выбрана')
}
if (props.onDelete) {
await props.onDelete(props.card.id)
} else {
emit('delete', props.card.id)
}
toast.success('Задача удалена')
}
// Archive
@@ -337,11 +355,18 @@ const handleArchive = () => {
showArchiveDialog.value = true
}
const confirmArchive = () => {
showArchiveDialog.value = false
if (props.card?.id) {
const confirmArchive = async () => {
if (!props.card?.id) {
throw new Error('Задача не выбрана')
}
if (props.onArchive) {
await props.onArchive(props.card.id)
} else {
emit('archive', props.card.id)
}
toast.success('Задача в архиве')
}
// Restore
@@ -349,11 +374,18 @@ const handleRestore = () => {
showRestoreDialog.value = true
}
const confirmRestore = () => {
showRestoreDialog.value = false
if (props.card?.id) {
const confirmRestore = async () => {
if (!props.card?.id) {
throw new Error('Задача не выбрана')
}
if (props.onRestore) {
await props.onRestore(props.card.id)
} else {
emit('restore', props.card.id)
}
toast.success('Задача восстановлена')
}
// Image preview
@@ -397,13 +429,6 @@ const deleteFromPreview = async () => {
}
}
// Icons
const refreshIcons = () => {
if (window.lucide) {
window.lucide.createIcons()
}
}
// Watch show
watch(() => props.show, async (newVal) => {
if (newVal) {
@@ -413,6 +438,9 @@ watch(() => props.show, async (newVal) => {
previewImage.value = null
isSaving.value = false // Сброс состояния кнопки сохранения
// Обновляем права пользователя (могли измениться администратором)
await store.fetchUsers()
// Reset comments tab
commentsTabRef.value?.reset()
@@ -427,9 +455,6 @@ watch(() => props.show, async (newVal) => {
refreshIcons()
}
})
onMounted(refreshIcons)
onUpdated(refreshIcons)
</script>
<style scoped>
@@ -457,39 +482,12 @@ onUpdated(refreshIcons)
gap: 8px;
}
.footer-right {
display: flex;
gap: 12px;
margin-left: auto;
}
.btn-cancel {
.btn-close-panel {
padding: 12px 24px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
color: var(--text-secondary);
font-family: inherit;
font-size: 14px;
cursor: pointer;
transition: all 0.15s;
}
.btn-cancel:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--text-primary);
}
.btn-save {
display: flex;
align-items: center;
justify-content: center;
min-width: 120px;
padding: 12px 28px;
background: var(--accent);
border: none;
border-radius: 8px;
color: #000;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 10px;
color: var(--text-secondary);
font-family: inherit;
font-size: 14px;
font-weight: 500;
@@ -497,28 +495,8 @@ onUpdated(refreshIcons)
transition: all 0.15s;
}
.btn-save:hover {
filter: brightness(1.1);
}
.btn-save:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-loader {
display: inline-block;
width: 18px;
height: 18px;
border: 2px solid rgba(0, 0, 0, 0.3);
border-top-color: #000;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
.btn-close-panel:hover {
background: rgba(255, 255, 255, 0.12);
color: var(--text-primary);
}
</style>

View File

@@ -2,7 +2,7 @@ export { default as TaskPanel } from './TaskPanel.vue'
export { default as TaskEditTab } from './TaskEditTab.vue'
export { default as TaskCommentsTab } from './TaskCommentsTab.vue'
export { default as CommentItem } from './CommentItem.vue'
export { default as CommentForm } from './CommentForm.vue'
export { default as ContentEditorPanel } from './ContentEditorPanel.vue'
// Default export
export { default } from './TaskPanel.vue'

View File

@@ -0,0 +1,128 @@
<template>
<div class="action-buttons">
<button
type="button"
class="btn-cancel"
@click="$emit('cancel')"
>
{{ cancelText }}
</button>
<button
type="button"
class="btn-save"
@click="$emit('save')"
:disabled="disabled || loading"
>
<span v-if="loading" class="btn-loader"></span>
<template v-else>
<i v-if="saveIcon" :data-lucide="saveIcon"></i>
<span>{{ saveText }}</span>
</template>
</button>
</div>
</template>
<script setup>
defineProps({
// Текст кнопки отмены
cancelText: {
type: String,
default: 'Отмена'
},
// Текст кнопки сохранения
saveText: {
type: String,
default: 'Сохранить'
},
// Иконка кнопки сохранения (lucide icon name)
saveIcon: {
type: String,
default: ''
},
// Состояние загрузки
loading: {
type: Boolean,
default: false
},
// Отключить кнопку сохранения
disabled: {
type: Boolean,
default: false
}
})
defineEmits(['save', 'cancel'])
</script>
<style scoped>
.action-buttons {
display: flex;
gap: 12px;
margin-left: auto;
}
.btn-cancel {
padding: 12px 24px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
color: var(--text-secondary);
font-family: inherit;
font-size: 14px;
cursor: pointer;
transition: all 0.15s;
}
.btn-cancel:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--text-primary);
}
.btn-save {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-width: 120px;
padding: 12px 28px;
background: var(--accent);
border: none;
border-radius: 8px;
color: #000;
font-family: inherit;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.btn-save:hover {
filter: brightness(1.1);
}
.btn-save:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-save i {
width: 16px;
height: 16px;
}
.btn-loader {
display: inline-block;
width: 18px;
height: 18px;
border: 2px solid rgba(0, 0, 0, 0.3);
border-top-color: #000;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,448 @@
<template>
<div class="color-picker" ref="pickerRef">
<!-- Триггер - текущий цвет -->
<button
class="color-trigger"
ref="triggerRef"
:style="{ backgroundColor: modelValue }"
@click="togglePicker"
:title="'Выбрать цвет'"
>
<span class="color-check" v-if="modelValue">
<i data-lucide="check"></i>
</span>
</button>
<!-- Desktop: dropdown палитра (teleport чтобы не обрезалось) -->
<Teleport to="body">
<Transition v-if="!isMobile" name="dropdown">
<div
v-if="isOpen"
class="color-dropdown"
ref="dropdownRef"
:style="dropdownStyle"
>
<div class="color-grid">
<button
v-for="color in colors"
:key="color"
class="color-option"
:class="{ selected: modelValue === color }"
:style="{ backgroundColor: color }"
@click="selectColor(color)"
>
<i v-if="modelValue === color" data-lucide="check"></i>
</button>
</div>
</div>
</Transition>
</Teleport>
<!-- Mobile: bottom sheet -->
<Teleport to="body">
<Transition name="mobile-picker">
<div v-if="isMobile && isOpen" class="mobile-overlay" @click.self="closePicker">
<div class="mobile-sheet">
<div class="sheet-header">
<span class="sheet-title">Выберите цвет</span>
<button class="sheet-close" @click="closePicker">
<i data-lucide="x"></i>
</button>
</div>
<div class="sheet-body">
<div class="color-grid mobile">
<button
v-for="color in colors"
:key="color"
class="color-option"
:class="{ selected: modelValue === color }"
:style="{ backgroundColor: color }"
@click="selectColor(color)"
>
<i v-if="modelValue === color" data-lucide="check"></i>
</button>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { useMobile } from '../../composables/useMobile'
import { useLucideIcons } from '../../composables/useLucideIcons'
const { isMobile } = useMobile()
const { refreshIcons } = useLucideIcons()
const props = defineProps({
modelValue: {
type: String,
default: '#6366f1'
}
})
const emit = defineEmits(['update:modelValue'])
// Предустановленные цвета
const colors = [
// Основные
'#ef4444', // red
'#f97316', // orange
'#f59e0b', // amber
'#eab308', // yellow
'#84cc16', // lime
'#22c55e', // green
'#10b981', // emerald
'#14b8a6', // teal
'#06b6d4', // cyan
'#0ea5e9', // sky
'#3b82f6', // blue
'#6366f1', // indigo
'#8b5cf6', // violet
'#a855f7', // purple
'#d946ef', // fuchsia
'#ec4899', // pink
'#f43f5e', // rose
// Нейтральные
'#64748b', // slate
'#6b7280', // gray
'#78716c', // stone
]
const pickerRef = ref(null)
const triggerRef = ref(null)
const dropdownRef = ref(null)
const isOpen = ref(false)
const dropdownPosition = ref({ top: 0, left: 0 })
// Вычисляем позицию dropdown относительно триггера
const updateDropdownPosition = () => {
if (triggerRef.value) {
const rect = triggerRef.value.getBoundingClientRect()
const dropdownWidth = 220
const dropdownHeight = 320
// Левый край dropdown начинается от левого края триггера
let top = rect.bottom + 8
let left = rect.left
// Проверяем не выходит ли за правый край
if (left + dropdownWidth > window.innerWidth - 16) {
left = window.innerWidth - dropdownWidth - 16
}
// Проверяем не выходит ли за нижний край — открываем вверх
if (top + dropdownHeight > window.innerHeight - 16) {
top = rect.top - dropdownHeight - 8
}
dropdownPosition.value = { top, left }
}
}
const dropdownStyle = computed(() => ({
position: 'fixed',
top: `${dropdownPosition.value.top}px`,
left: `${dropdownPosition.value.left}px`,
zIndex: 2000
}))
const togglePicker = () => {
isOpen.value = !isOpen.value
if (isOpen.value) {
updateDropdownPosition()
nextTick(refreshIcons)
}
}
const closePicker = () => {
isOpen.value = false
}
const selectColor = (color) => {
emit('update:modelValue', color)
// Закрываем dropdown после выбора цвета из палитры
closePicker()
nextTick(refreshIcons)
}
const handleClickOutside = (e) => {
// Проверяем что клик не на триггере и не на dropdown
const isClickOnTrigger = pickerRef.value && pickerRef.value.contains(e.target)
const isClickOnDropdown = dropdownRef.value && dropdownRef.value.contains(e.target)
if (!isClickOnTrigger && !isClickOnDropdown) {
isOpen.value = false
}
}
// Обновляем позицию при скролле/ресайзе
watch(isOpen, (val) => {
if (val && !isMobile.value) {
window.addEventListener('scroll', updateDropdownPosition, true)
window.addEventListener('resize', updateDropdownPosition)
} else {
window.removeEventListener('scroll', updateDropdownPosition, true)
window.removeEventListener('resize', updateDropdownPosition)
}
})
onMounted(() => {
document.addEventListener('click', handleClickOutside)
refreshIcons()
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
window.removeEventListener('scroll', updateDropdownPosition, true)
window.removeEventListener('resize', updateDropdownPosition)
})
</script>
<style scoped>
.color-picker {
position: relative;
}
.color-trigger {
width: 32px;
height: 32px;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s;
display: flex;
align-items: center;
justify-content: center;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1);
}
.color-trigger:hover {
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.color-check {
display: none;
}
/* ========== DROPDOWN (Desktop) ========== */
.color-dropdown {
background: #1e1e24;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
padding: 12px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.5);
min-width: 220px;
}
.color-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
}
.color-option {
width: 32px;
height: 32px;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s;
display: flex;
align-items: center;
justify-content: center;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1);
}
.color-option:hover {
transform: scale(1.15);
z-index: 1;
}
.color-option.selected {
box-shadow: 0 0 0 2px var(--bg-body), 0 0 0 4px currentColor;
}
.color-option i {
width: 16px;
height: 16px;
color: #fff;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));
}
/* Кастомный цвет */
.custom-color {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.custom-label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
color: var(--text-secondary);
font-size: 13px;
}
.native-picker {
width: 32px;
height: 32px;
padding: 0;
border: none;
border-radius: 8px;
cursor: pointer;
background: transparent;
}
.native-picker::-webkit-color-swatch-wrapper {
padding: 2px;
}
.native-picker::-webkit-color-swatch {
border-radius: 6px;
border: none;
}
/* Dropdown transition */
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.15s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(-8px);
}
/* ========== MOBILE: Bottom Sheet ========== */
.mobile-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 2000;
display: flex;
align-items: flex-end;
justify-content: center;
}
.mobile-sheet {
width: 100%;
max-height: 70vh;
background: var(--bg-body);
border-radius: 20px 20px 0 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.sheet-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.sheet-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.sheet-close {
width: 32px;
height: 32px;
background: rgba(255, 255, 255, 0.06);
border: none;
border-radius: 8px;
color: var(--text-muted);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.sheet-close i {
width: 18px;
height: 18px;
}
.sheet-body {
padding: 20px;
padding-bottom: calc(20px + env(safe-area-inset-bottom, 0px));
overflow-y: auto;
}
.color-grid.mobile {
display: grid;
grid-template-columns: repeat(5, 48px);
gap: 10px;
justify-content: center;
}
.color-grid.mobile .color-option {
width: 48px;
height: 48px;
border-radius: 10px;
}
.color-grid.mobile .color-option i {
width: 20px;
height: 20px;
}
.custom-color.mobile {
margin-top: 20px;
padding-top: 20px;
}
.custom-color.mobile .custom-label {
padding: 12px 16px;
background: rgba(255, 255, 255, 0.04);
border-radius: 12px;
font-size: 15px;
}
.custom-preview {
width: 28px;
height: 28px;
border-radius: 8px;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1);
}
.custom-color.mobile .native-picker {
width: 40px;
height: 40px;
margin-left: auto;
}
/* Mobile transition */
.mobile-picker-enter-active,
.mobile-picker-leave-active {
transition: opacity 0.2s ease;
}
.mobile-picker-enter-active .mobile-sheet,
.mobile-picker-leave-active .mobile-sheet {
transition: transform 0.25s ease;
}
.mobile-picker-enter-from,
.mobile-picker-leave-to {
opacity: 0;
}
.mobile-picker-enter-from .mobile-sheet,
.mobile-picker-leave-to .mobile-sheet {
transform: translateY(100%);
}
</style>

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>
@@ -68,7 +68,8 @@
</template>
<script setup>
import { ref, computed, onMounted, onUpdated } from 'vue'
import { ref, computed } from 'vue'
import { useLucideIcons } from '../../composables/useLucideIcons'
const props = defineProps({
files: {
@@ -100,6 +101,10 @@ const props = defineProps({
type: String,
default: 'или нажмите для выбора'
},
readOnly: {
type: Boolean,
default: false
},
getFullUrl: {
type: Function,
default: (url) => url
@@ -108,6 +113,8 @@ const props = defineProps({
const emit = defineEmits(['add', 'remove', 'preview', 'error'])
useLucideIcons()
const fileInputRef = ref(null)
const isDragging = ref(false)
@@ -237,16 +244,6 @@ const downloadFile = async (file) => {
}
}
// Обновление иконок
const refreshIcons = () => {
if (window.lucide) {
window.lucide.createIcons()
}
}
onMounted(refreshIcons)
onUpdated(refreshIcons)
// Экспортируем методы
defineExpose({
triggerFileInput

View File

@@ -13,7 +13,9 @@
</template>
<script setup>
import { onMounted, onUpdated } from 'vue'
import { useLucideIcons } from '../../composables/useLucideIcons'
useLucideIcons()
defineProps({
icon: {
@@ -48,15 +50,6 @@ defineProps({
})
defineEmits(['click'])
const refreshIcons = () => {
if (window.lucide) {
window.lucide.createIcons()
}
}
onMounted(refreshIcons)
onUpdated(refreshIcons)
</script>
<style scoped>

View File

@@ -0,0 +1,131 @@
<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="logoutAll"
:discard-action="logoutCurrent"
/>
</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)
// Выход с текущей сессии (discard action)
const logoutCurrent = async () => {
clearAuthCache()
await authApi.logout()
store.reset()
router.push('/login')
}
// Выход со всех сессий (confirm action)
const logoutAll = async () => {
clearAuthCache()
await authApi.logoutAll()
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

@@ -1,6 +1,6 @@
<template>
<!-- Кнопка-триггер -->
<button class="mobile-select-btn" :class="[variant, { compact }]" @click="open = true">
<button class="mobile-select-btn" :class="[variant, { compact, 'has-selection': hasSelection }]" @click="open = true">
<i v-if="icon" :data-lucide="icon" class="btn-icon"></i>
<span v-if="!compact" class="btn-label">{{ displayValue }}</span>
<i data-lucide="chevron-down" class="btn-arrow"></i>
@@ -39,7 +39,7 @@
</template>
<script setup>
import { ref, computed, watch, onMounted, onUpdated } from 'vue'
import { ref, computed, watch, onMounted, onUpdated, onUnmounted } from 'vue'
const props = defineProps({
modelValue: {
@@ -85,6 +85,14 @@ const displayValue = computed(() => {
return option?.label || props.placeholder
})
// Проверка: выбран ли не-дефолтный вариант (первый option обычно "Все")
const hasSelection = computed(() => {
if (props.modelValue === null || props.modelValue === undefined) return false
// Если первый option = null — значит "Все", проверяем что выбрано что-то другое
const firstOption = props.options[0]
return firstOption && props.modelValue !== firstOption.id
})
const selectOption = (id) => {
emit('update:modelValue', id)
open.value = false
@@ -106,6 +114,13 @@ watch(open, (val) => {
}
})
// Cleanup при unmount — гарантируем сброс overflow
onUnmounted(() => {
if (open.value) {
document.body.style.overflow = ''
}
})
onMounted(refreshIcons)
onUpdated(refreshIcons)
</script>
@@ -184,6 +199,17 @@ onUpdated(refreshIcons)
display: none;
}
/* Подсветка когда выбран активный фильтр */
.mobile-select-btn.has-selection {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent);
}
.mobile-select-btn.has-selection .btn-icon {
opacity: 1;
}
.btn-icon {
width: 14px;
height: 14px;
@@ -223,6 +249,8 @@ onUpdated(refreshIcons)
display: flex;
flex-direction: column;
overflow: hidden;
/* Safe area для iPhone notch */
padding-top: env(safe-area-inset-top, 0px);
}
.panel-header {

View File

@@ -39,7 +39,7 @@
</template>
<script setup>
import { watch, onMounted, onUpdated } from 'vue'
import { watch, onMounted, onUpdated, onUnmounted } from 'vue'
const props = defineProps({
open: {
@@ -99,6 +99,13 @@ watch(() => props.open, (val) => {
}
})
// Cleanup при unmount — гарантируем сброс overflow
onUnmounted(() => {
if (props.open) {
document.body.style.overflow = ''
}
})
onMounted(refreshIcons)
onUpdated(refreshIcons)
</script>
@@ -123,6 +130,10 @@ onUpdated(refreshIcons)
display: flex;
flex-direction: column;
overflow: hidden;
/* Предотвращаем выделение/копирование текста при long-press */
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}
.panel-header {

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"
@@ -46,7 +46,8 @@
</template>
<script setup>
import { ref, watch, onMounted, onUpdated } from 'vue'
import { ref, watch, onMounted } from 'vue'
import { useLucideIcons } from '../../composables/useLucideIcons'
const props = defineProps({
modelValue: {
@@ -64,11 +65,17 @@ const props = defineProps({
autoLinkify: {
type: Boolean,
default: true
},
disabled: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue', 'focus', 'blur'])
useLucideIcons()
const editorRef = ref(null)
let isInternalChange = false
@@ -234,20 +241,10 @@ watch(() => props.modelValue, (newVal, oldVal) => {
}
}, { immediate: true })
// Обновление иконок Lucide
const refreshIcons = () => {
if (window.lucide) {
window.lucide.createIcons()
}
}
onMounted(() => {
refreshIcons()
setContent(props.modelValue)
})
onUpdated(refreshIcons)
// Экспортируем методы для использования извне
defineExpose({
setContent,
@@ -316,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

@@ -9,6 +9,7 @@
:alt="selectedOption.label"
class="option-avatar"
>
<span v-else-if="selectedOption?.label" class="avatar-placeholder">{{ selectedOption.label[0] }}</span>
<span v-else-if="!selectedOption" class="no-selection-icon"></span>
<span class="selected-label">{{ selectedOption?.label || placeholder }}</span>
</slot>
@@ -44,7 +45,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">
@@ -54,9 +55,19 @@
:alt="option.label"
class="option-avatar"
>
<span v-else class="avatar-placeholder">{{ (option.label || '?')[0] }}</span>
<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,20 +126,30 @@
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
v-if="option.avatar"
:src="option.avatar"
:alt="option.label"
class="option-avatar"
>
<div class="option-content">
<img
v-if="option.avatar"
:src="option.avatar"
:alt="option.label"
class="option-avatar"
>
<span v-else class="avatar-placeholder">{{ (option.label || '?')[0] }}</span>
<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 +268,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)
)
@@ -473,11 +501,31 @@ onUpdated(refreshIcons)
.option-avatar {
width: 28px;
height: 28px;
border-radius: 6px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.avatar-placeholder {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.1);
text-transform: uppercase;
flex-shrink: 0;
}
.dropdown-item.active .avatar-placeholder {
background: rgba(0, 0, 0, 0.15);
color: #000;
}
.option-content {
display: flex;
flex-direction: column;
@@ -500,12 +548,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;
@@ -660,7 +726,14 @@ onUpdated(refreshIcons)
.mobile-select-item .option-avatar {
width: 40px;
height: 40px;
border-radius: 10px;
border-radius: 50%;
}
.mobile-select-item .avatar-placeholder {
width: 40px;
height: 40px;
border-radius: 50%;
font-size: 16px;
}
.mobile-select-item .option-content {
@@ -678,6 +751,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

@@ -41,11 +41,14 @@
</template>
<script setup>
import { ref, onMounted, onUnmounted, onUpdated, watch } from 'vue'
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { useMobile } from '../../composables/useMobile'
import { useLucideIcons } from '../../composables/useLucideIcons'
const { isMobile } = useMobile()
useLucideIcons()
const props = defineProps({
show: {
type: Boolean,
@@ -139,13 +142,6 @@ const handleClose = () => {
emit('update:show', false)
}
// Обновление иконок Lucide
const refreshIcons = () => {
if (window.lucide) {
window.lucide.createIcons()
}
}
// Блокировка скролла body при открытии панели
const lockBodyScroll = () => {
document.body.classList.add('panel-open')
@@ -155,19 +151,28 @@ const unlockBodyScroll = () => {
document.body.classList.remove('panel-open')
}
// Принудительный reflow для iOS PWA - исправляет баг с safe-area при первом рендере
const forceReflow = () => {
if (panelRef.value) {
// Читаем offsetHeight чтобы вызвать reflow
void panelRef.value.offsetHeight
}
}
// Восстановление ширины при открытии (из localStorage или дефолтная)
watch(() => props.show, (newVal) => {
if (newVal) {
panelWidth.value = getSavedWidth()
lockBodyScroll()
// Два requestAnimationFrame для гарантированного reflow после рендера
requestAnimationFrame(() => {
requestAnimationFrame(forceReflow)
})
} else {
unlockBodyScroll()
}
})
onMounted(refreshIcons)
onUpdated(refreshIcons)
onUnmounted(() => {
document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', stopResize)
@@ -311,15 +316,15 @@ onUnmounted(() => {
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
/* Transition — плавное появление */
/* Transition — выезд справа для десктопа */
.panel-enter-active,
.panel-leave-active {
transition: opacity 0.2s ease;
transition: opacity 0.25s ease;
}
.panel-enter-active .panel,
.panel-leave-active .panel {
transition: opacity 0.2s ease, transform 0.2s ease;
transition: transform 0.25s ease;
}
.panel-enter-from,
@@ -329,18 +334,26 @@ onUnmounted(() => {
.panel-enter-from .panel,
.panel-leave-to .panel {
opacity: 0;
transform: scale(0.98);
transform: translateX(100%);
}
/* На мобильных убираем transform из анимации - он ломает layout в iOS PWA */
.panel-enter-from .panel.mobile,
.panel-leave-to .panel.mobile {
transform: none;
}
/* ========== MOBILE: Fullscreen ========== */
/* height: 100% вместо 100dvh - потому что wrapper уже учитывает safe-area */
.panel.mobile {
width: 100% !important;
max-width: 100%;
height: 100%; /* Занимаем всю высоту wrapper, не viewport */
border-radius: 0;
/* Блокируем горизонтальные свайпы, чтобы не срабатывала навигация браузера */
touch-action: pan-y pinch-zoom;
overscroll-behavior: contain;
/* Сплошной цвет вместо градиента */
background: var(--bg-body);
}
.panel.mobile .resize-handle {
@@ -353,15 +366,15 @@ onUnmounted(() => {
.panel.mobile .panel-body {
padding: 16px;
padding-bottom: calc(16px + var(--safe-area-bottom, 0px));
min-height: 0;
gap: 0; /* Убираем gap — он создаёт пустое место */
gap: 20px;
overflow-y: auto;
}
.panel.mobile .panel-footer {
padding: 16px;
padding-bottom: calc(16px + var(--safe-area-bottom, 0px));
/* Safe area для iPhone home indicator */
padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px));
}
.panel.mobile .header-content :deep(h2) {

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

@@ -0,0 +1,60 @@
import { ref } from 'vue'
/**
* Composable для автообновления данных
*
* @param {string} key - ключ из REFRESH_INTERVALS (cards, comments, invites)
* @param {Function} callback - функция для вызова при каждом обновлении
*
* @example
* const { start, stop } = useAutoRefresh('cards', async () => {
* await fetchCards()
* })
*
* onMounted(() => start())
* onUnmounted(() => stop())
*/
export function useAutoRefresh(key, callback) {
let timer = null
const isActive = ref(false)
// Получаем интервал из конфига (в секундах), конвертируем в мс
const getInterval = () => {
const seconds = window.APP_CONFIG?.REFRESH_INTERVALS?.[key] ?? 30
return seconds * 1000
}
const start = () => {
stop() // Очищаем предыдущий если был
const interval = getInterval()
// 0 = отключено
if (interval <= 0) {
return
}
isActive.value = true
timer = setInterval(async () => {
try {
await callback()
} catch (e) {
console.error(`[AutoRefresh:${key}] Error:`, e)
}
}, interval)
}
const stop = () => {
if (timer) {
clearInterval(timer)
timer = null
}
isActive.value = false
}
return {
start,
stop,
isActive
}
}

View File

@@ -0,0 +1,166 @@
/**
* Composable для форматирования дат
* Автоматически использует таймзону сервера из serverSettings
*
* Использование:
* import { useDateFormat } from '@/composables/useDateFormat'
* const { formatShort, formatRelative, getDaysLeftText } = useDateFormat()
*/
import { serverSettings } from '../api'
// Константы месяцев
const MONTHS_SHORT = ['янв', 'фев', 'мар', 'апр', 'мая', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек']
const MONTHS_FULL = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря']
// Для дат типа "1 май" (именительный падеж)
const MONTHS_SHORT_NOM = ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек']
export function useDateFormat() {
/**
* Парсинг даты с учётом таймзоны сервера
*/
const parseDate = (dateStr) => {
if (!dateStr) return null
return serverSettings.parseDate(dateStr)
}
/**
* Короткий формат: "1 янв 2025"
*/
const formatShort = (dateStr) => {
const date = parseDate(dateStr)
if (!date) return ''
const day = date.getDate()
return `${day} ${MONTHS_SHORT_NOM[date.getMonth()]} ${date.getFullYear()}`
}
/**
* Полный формат: "1 января 2025"
*/
const formatFull = (dateStr) => {
const date = parseDate(dateStr)
if (!date) return ''
const day = date.getDate()
return `${day} ${MONTHS_FULL[date.getMonth()]} ${date.getFullYear()}`
}
/**
* С временем: "01 янв 2025, 14:30"
*/
const formatDateTime = (dateStr) => {
const date = parseDate(dateStr)
if (!date) return '—'
const day = date.getDate().toString().padStart(2, '0')
const year = date.getFullYear()
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')
return `${day} ${MONTHS_SHORT[date.getMonth()]} ${year}, ${hours}:${minutes}`
}
/**
* Расчёт дней между датами
* Положительное число = дней осталось, отрицательное = просрочено
*/
const getDaysUntil = (dateStr) => {
const target = parseDate(dateStr)
if (!target) return null
const today = new Date()
today.setHours(0, 0, 0, 0)
target.setHours(0, 0, 0, 0)
return Math.round((target - today) / (1000 * 60 * 60 * 24))
}
/**
* Расчёт дней назад
*/
const getDaysAgo = (dateStr) => {
const days = getDaysUntil(dateStr)
return days !== null ? -days : null
}
/**
* Текст для дедлайна: "Сегодня", "Завтра", "Осталось: 5 дн.", "Просрочено: 3 дн."
*/
const getDaysLeftText = (dateStr) => {
const days = getDaysUntil(dateStr)
if (days === null) return ''
if (days < 0) return `Просрочено: ${Math.abs(days)} дн.`
if (days === 0) return 'Сегодня'
if (days === 1) return 'Завтра'
return `Осталось: ${days} дн.`
}
/**
* Статус дедлайна для CSS классов: 'overdue', 'soon', ''
*/
const getDueDateStatus = (dateStr) => {
const days = getDaysUntil(dateStr)
if (days === null) return ''
if (days < 0) return 'overdue'
if (days <= 2) return 'soon'
return ''
}
/**
* Относительная дата в прошлом: "Сегодня", "Вчера", "3 дня назад", или полная дата
*/
const formatRelative = (dateStr) => {
if (!dateStr) return ''
const daysAgo = getDaysAgo(dateStr)
if (daysAgo === null) return ''
if (daysAgo === 0) return 'Сегодня'
if (daysAgo === 1) return 'Вчера'
if (daysAgo >= 2 && daysAgo <= 4) return `${daysAgo} дня назад`
if (daysAgo >= 5 && daysAgo <= 14) return `${daysAgo} дней назад`
return formatShort(dateStr)
}
/**
* Время назад для комментариев: "только что", "5 мин. назад", "2 ч. назад", "3 дн. назад"
* Для старых дат — "1 янв 14:30"
*/
const formatTimeAgo = (dateStr) => {
const date = parseDate(dateStr)
if (!date) return ''
const now = new Date()
const diff = now - date
const minutes = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (minutes < 1) return 'только что'
if (minutes < 60) return `${minutes} мин. назад`
if (hours < 24) return `${hours} ч. назад`
if (days < 7) return `${days} дн. назад`
const day = date.getDate()
const time = date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
return `${day} ${MONTHS_SHORT[date.getMonth()]} ${time}`
}
return {
// Константы
MONTHS_SHORT,
MONTHS_FULL,
// Парсинг
parseDate,
// Форматирование
formatShort,
formatFull,
formatDateTime,
formatRelative,
formatTimeAgo,
// Расчёты
getDaysUntil,
getDaysAgo,
getDaysLeftText,
getDueDateStatus
}
}

View File

@@ -0,0 +1,48 @@
import { ref, computed, watch } from 'vue'
import { useProjectsStore } from '../stores/projects'
/**
* Composable для фильтрации по отделам
* Автоматически сохраняет выбор в localStorage
* Берёт departments из Pinia store
*
* Использование:
* import { useDepartmentFilter } from '@/composables/useDepartmentFilter'
* const { activeDepartment, departmentOptions, resetFilter } = useDepartmentFilter()
*/
export function useDepartmentFilter() {
const store = useProjectsStore()
// Восстанавливаем из localStorage
const savedDepartment = localStorage.getItem('activeDepartment')
const activeDepartment = ref(savedDepartment ? parseInt(savedDepartment) : null)
// Опции для MobileSelect
const departmentOptions = computed(() => [
{ id: null, label: 'Все отделы' },
...store.departments.map(d => ({
id: d.id,
label: d.name_departments
}))
])
// Сохраняем в localStorage при изменении
watch(activeDepartment, (newVal) => {
if (newVal === null) {
localStorage.removeItem('activeDepartment')
} else {
localStorage.setItem('activeDepartment', newVal.toString())
}
})
// Сброс фильтра
const resetFilter = () => {
activeDepartment.value = null
}
return {
activeDepartment,
departmentOptions,
resetFilter
}
}

View File

@@ -0,0 +1,55 @@
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 иконок
* при монтировании и обновлении компонента.
*
* Использование:
* import { useLucideIcons } from '@/composables/useLucideIcons'
* useLucideIcons()
*/
export function useLucideIcons() {
const refresh = () => {
debouncedRefresh()
}
// Немедленное обновление при монтировании
onMounted(() => {
if (window.lucide) {
window.lucide.createIcons()
}
})
// 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

@@ -3,10 +3,25 @@ import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import { serverSettings } from './api'
import { registerSW } from 'virtual:pwa-register'
// Инициализация серверных настроек (timezone и т.д.)
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,35 +3,89 @@ 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'
// Проверка авторизации
const checkAuth = async () => {
// Кэш авторизации (чтобы не делать запрос при каждой навигации)
let authCache = {
isAuthenticated: null, // null = не проверяли, true/false = результат
user: null, // Данные пользователя
lastCheck: 0
}
// Время жизни кэша авторизации (5 минут)
const AUTH_CACHE_TTL = 5 * 60 * 1000
// Проверка авторизации (с кэшированием)
const checkAuth = async (forceCheck = false) => {
const now = Date.now()
// Используем кэш если он валиден и не форсируем проверку
if (!forceCheck && authCache.isAuthenticated !== null && (now - authCache.lastCheck) < AUTH_CACHE_TTL) {
return authCache.isAuthenticated
}
try {
const data = await authApi.check()
return data.success === true
authCache.isAuthenticated = data.success === true
authCache.user = data.user || null
authCache.lastCheck = now
return authCache.isAuthenticated
} catch {
// При ошибке сети — используем кэш если есть, иначе false
if (authCache.isAuthenticated !== null) {
return authCache.isAuthenticated
}
return false
}
}
// Сброс кэша (вызывать при logout)
export const clearAuthCache = () => {
authCache = { isAuthenticated: null, user: null, lastCheck: 0 }
}
// Глобальный доступ для api.js (избегаем циклической зависимости)
window.__clearAuthCache = clearAuthCache
// Установка кэша (вызывать при успешном login)
export const setAuthCache = (isAuth, user = null) => {
authCache = { isAuthenticated: isAuth, user, lastCheck: Date.now() }
}
// Получить закэшированного пользователя
export const getCachedUser = () => authCache.user
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 }
},
{
@@ -46,19 +100,60 @@ const router = createRouter({
routes
})
// Navigation guard — проверка авторизации
// Navigation guard — проверка авторизации и наличия проектов
router.beforeEach(async (to, from, next) => {
const isAuth = await checkAuth()
// Если переходим между защищёнными страницами и кэш валиден — не проверяем сеть
const needsAuth = to.meta.requiresAuth
const needsProject = to.meta.requiresProject
const fromProtected = from.meta?.requiresAuth
if (to.meta.requiresAuth && !isAuth) {
// Форсируем проверку только при переходе на защищённую страницу извне
// или при переходе на /login (чтобы редиректнуть если уже авторизован)
const forceCheck = (needsAuth && !fromProtected) || to.path === '/login'
const isAuth = await checkAuth(forceCheck)
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

@@ -0,0 +1,113 @@
/**
* Конфигурация диалогов подтверждения
* Используется в ConfirmDialog через prop "type"
*/
export const DIALOGS = {
// Архивация задачи
archive: {
title: 'Архивировать задачу?',
message: 'Задача будет перемещена в архив.<br>Вы сможете восстановить её позже.',
confirmText: 'В архив',
variant: 'warning'
},
// Восстановление из архива
restore: {
title: 'Вернуть из архива?',
message: 'Задача будет возвращена на доску<br>в колонку «Готово».',
confirmText: 'Вернуть',
variant: 'warning'
},
// Удаление задачи
deleteTask: {
title: 'Удалить задачу?',
message: 'Это действие нельзя отменить.<br>Задача будет удалена навсегда.',
confirmText: 'Удалить',
variant: 'danger'
},
// Удаление комментария
deleteComment: {
title: 'Удалить комментарий?',
message: 'Комментарий будет удалён навсегда.',
confirmText: 'Удалить',
variant: 'danger'
},
// Удаление файла
deleteFile: {
title: 'Удалить изображение?',
message: 'Изображение будет удалено из задачи.',
confirmText: 'Удалить',
variant: 'danger'
},
// Несохранённые изменения
unsavedChanges: {
title: 'Обнаружены изменения',
message: 'У вас есть несохранённые изменения.<br>Что вы хотите сделать?',
confirmText: 'Сохранить',
showDiscard: true,
variant: 'default'
},
// Удаление проекта
deleteProject: {
title: 'Удалить проект?',
message: 'Все задачи, колонки и комментарии<br>будут удалены навсегда.',
confirmText: 'Удалить',
variant: 'danger'
},
// Удаление колонки
deleteColumn: {
title: 'Удалить колонку?',
message: 'Колонка и все задачи в ней<br>будут удалены навсегда.',
confirmText: 'Удалить',
variant: 'danger'
},
// Удаление колонки с задачами (динамический message)
deleteColumnWithTasks: {
title: 'Удалить колонку?',
message: '', // Будет задан динамически
confirmText: 'Удалить',
variant: 'danger'
},
// Удаление участника из проекта
removeMember: {
title: 'Удалить участника?',
message: '', // Будет задан динамически
confirmText: 'Удалить',
variant: 'danger'
},
// Выход из проекта
leaveProject: {
title: 'Выйти из проекта?',
message: 'Вы потеряете доступ к задачам<br>и данным этого проекта.',
confirmText: 'Выйти',
variant: 'danger'
},
// Выход из системы (3 кнопки)
logout: {
title: 'Выйти из аккаунта?',
message: 'Выберите, откуда хотите выйти',
confirmText: 'Все сессии',
discardText: 'Текущая сессия',
showDiscard: true,
variant: 'danger',
discardVariant: 'warning'
},
// Удаление отдела
deleteDepartment: {
title: 'Удалить отдел?',
message: '', // Будет задан динамически
confirmText: 'Удалить',
variant: 'danger'
}
}

View File

@@ -1,6 +1,50 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { projectsApi, usersApi, authApi } 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', () => {
// ==================== СОСТОЯНИЕ ====================
@@ -9,9 +53,13 @@ export const useProjectsStore = defineStore('projects', () => {
const labels = ref([])
const columns = ref([])
const users = ref([])
const cards = ref([]) // Активные карточки текущего проекта
const archivedCards = ref([]) // Архивные карточки текущего проекта
const cardsLoading = ref(false) // Загрузка карточек
const loading = ref(false)
const initialized = ref(false)
const currentUser = ref(null) // Текущий авторизованный пользователь
const pendingInvitesCount = ref(0) // Количество pending-приглашений
// Текущий проект (из localStorage)
const savedProjectId = localStorage.getItem('currentProjectId')
@@ -31,16 +79,91 @@ 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 || '')
// Аватар текущего пользователя
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 () => {
@@ -54,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) {
@@ -63,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) // Загружаем данные проекта
}
@@ -81,16 +212,20 @@ 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) {
currentUser.value = cachedUser
}
}
// Загружаем текущего пользователя
const authData = await authApi.check()
if (authData.success && authData.user) {
// Находим полные данные пользователя (с id) из списка users
const fullUser = users.value.find(u => u.username === authData.user.username)
currentUser.value = fullUser || authData.user
// Загружаем участников проекта (users теперь привязаны к проекту)
if (currentProjectId.value) {
await fetchUsers()
// Загружаем количество pending-приглашений (только если есть проекты)
// Если проектов нет — NoProjectsPage сама загрузит полные данные с count
await fetchPendingInvitesCount()
}
initialized.value = true
@@ -101,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
@@ -128,17 +317,105 @@ 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) => {
if (!currentProjectId.value) {
cardsLoading.value = false
return
}
if (!silent) cardsLoading.value = true
try {
const result = await cardsApi.getAll(currentProjectId.value)
if (result.success) cards.value = result.data
} finally {
if (!silent) cardsLoading.value = false
}
}
// Загрузка архивных карточек
const fetchArchivedCards = async () => {
if (!currentProjectId.value) return
cardsLoading.value = true
try {
const result = await cardsApi.getAll(currentProjectId.value, 1) // archive = 1
if (result.success) {
archivedCards.value = result.data.map(card => ({
id: card.id,
title: card.title,
description: card.descript,
details: card.descript_full,
departmentId: card.id_department,
labelId: card.id_label,
accountId: card.id_account,
assignee: card.avatar_img,
dueDate: card.date,
dateCreate: card.date_create,
dateClosed: card.date_closed,
columnId: card.column_id,
order: card.order ?? 0,
comments_count: card.comments_count || 0,
files: card.files || (card.file_img || []).map(f => ({
name: f.name,
url: f.url,
size: f.size,
preview: f.url
}))
}))
}
} finally {
cardsLoading.value = false
}
}
// Очистка карточек при смене проекта
const clearCards = () => {
cards.value = []
archivedCards.value = []
}
// Сброс при выходе
const reset = () => {
projects.value = []
@@ -146,11 +423,249 @@ export const useProjectsStore = defineStore('projects', () => {
labels.value = []
columns.value = []
users.value = []
cards.value = []
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 ПРОЕКТОВ ====================
// Создание проекта
const createProject = async (name) => {
const result = await projectsApi.create(name)
if (result.success) {
// Добавляем проект в список
const newProject = {
id: result.id,
name,
id_ready: result.id_ready,
is_admin: true // Создатель = админ
}
projects.value.push(newProject)
// Добавляем в локальный порядок
const currentOrder = getLocalProjectsOrder()
currentOrder.push(result.id)
saveLocalProjectsOrder(currentOrder)
// Переключаемся на новый проект
await selectProject(result.id)
}
return result
}
// Обновление проекта
const updateProject = async (id, name) => {
const result = await projectsApi.update(id, name)
if (result.success) {
const project = projects.value.find(p => p.id === id)
if (project) {
project.name = name
// Обновляем localStorage если это текущий проект
if (id === currentProjectId.value) {
localStorage.setItem('currentProjectName', name)
}
}
}
return result
}
// Удаление проекта
const deleteProject = async (id) => {
const result = await projectsApi.delete(id)
if (result.success) {
const index = projects.value.findIndex(p => p.id === id)
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) {
await selectProject(projects.value[0].id)
} else {
currentProjectId.value = null
columns.value = []
departments.value = []
cards.value = []
localStorage.removeItem('currentProjectId')
localStorage.removeItem('currentProjectName')
}
}
}
return result
}
// Обновление порядка проектов (локально, без отправки на сервер)
const reorderProjects = (ids) => {
// Применяем новый порядок
const reordered = ids.map((id, index) => {
const project = projects.value.find(p => p.id === id)
return { ...project }
}).filter(Boolean)
projects.value = reordered
// Сохраняем порядок локально
saveLocalProjectsOrder(ids)
}
// ==================== CRUD КОЛОНОК ====================
// Добавление колонки
const addColumn = async (name, color = '#6366f1') => {
if (!currentProjectId.value) return { success: false }
const result = await projectsApi.addColumn(currentProjectId.value, name, color)
if (result.success) {
columns.value.push(result.column)
}
return result
}
// Обновление колонки
const updateColumn = async (id, name, color) => {
const result = await projectsApi.updateColumn(id, name, color)
if (result.success) {
const column = columns.value.find(c => c.id === id)
if (column) {
if (name !== null && name !== undefined) column.name_columns = name
if (color !== null && color !== undefined) column.color = color
}
}
return result
}
// Получение количества задач в колонке
const getColumnTasksCount = async (id) => {
const result = await projectsApi.getColumnTasksCount(id)
return result.success ? result.count : 0
}
// Удаление колонки
const deleteColumn = async (id) => {
const result = await projectsApi.deleteColumn(id)
if (result.success) {
const index = columns.value.findIndex(c => c.id === id)
if (index !== -1) {
columns.value.splice(index, 1)
}
// Удаляем карточки этой колонки из локального состояния
cards.value = cards.value.filter(c => c.column_id !== id)
}
return result
}
// Обновление порядка колонок
const reorderColumns = async (ids) => {
if (!currentProjectId.value) return
// Оптимистичное обновление
const reordered = ids.map((id, index) => {
const column = columns.value.find(c => c.id === id)
return { ...column, id_order: index + 1 }
})
columns.value = reordered
// Отправляем на сервер
await projectsApi.updateColumnsOrder(currentProjectId.value, ids)
}
// Установка финальной колонки
const setReadyColumn = async (columnId) => {
if (!currentProjectId.value) return { success: false }
const result = await projectsApi.setReadyColumn(currentProjectId.value, columnId)
if (result.success) {
// Обновляем id_ready в проекте
const project = projects.value.find(p => p.id === currentProjectId.value)
if (project) {
project.id_ready = columnId
}
}
return result
}
// ==================== CRUD ОТДЕЛОВ ====================
// Добавление отдела
const addDepartment = async (name, color = '#6366f1') => {
if (!currentProjectId.value) return { success: false }
const result = await projectsApi.addDepartment(currentProjectId.value, name, color)
if (result.success) {
departments.value.push(result.department)
}
return result
}
// Обновление отдела
const updateDepartment = async (id, name, color) => {
const result = await projectsApi.updateDepartment(id, name, color)
if (result.success) {
const department = departments.value.find(d => d.id === id)
if (department) {
if (name !== null && name !== undefined) department.name_departments = name
if (color !== null && color !== undefined) department.color = color
}
}
return result
}
// Получение количества задач в отделе
const getDepartmentTasksCount = async (id) => {
const result = await projectsApi.getDepartmentTasksCount(id)
return result.success ? result.count : 0
}
// Удаление отдела
const deleteDepartment = async (id) => {
const result = await projectsApi.deleteDepartment(id)
if (result.success) {
const index = departments.value.findIndex(d => d.id === id)
if (index !== -1) {
departments.value.splice(index, 1)
}
// Обнуляем id_department у карточек этого отдела
cards.value.forEach(card => {
if (card.id_department === id) {
card.id_department = null
}
})
}
return result
}
// Обновление порядка отделов
const reorderDepartments = async (ids) => {
if (!currentProjectId.value) return
// Оптимистичное обновление
const reordered = ids.map((id, index) => {
const department = departments.value.find(d => d.id === id)
return { ...department, order_id: index + 1 }
})
departments.value = reordered
// Отправляем на сервер
await projectsApi.updateDepartmentsOrder(currentProjectId.value, ids)
}
// Добавление отдела в конкретный проект (для создания нового проекта)
const addDepartmentToProject = async (projectId, name, color = '#6366f1') => {
const result = await projectsApi.addDepartment(projectId, name, color)
// Не добавляем в локальный state — при переключении на проект данные загрузятся
return result
}
return {
@@ -160,19 +675,56 @@ export const useProjectsStore = defineStore('projects', () => {
labels,
columns,
users,
cards,
archivedCards,
cardsLoading,
loading,
initialized,
currentProjectId,
currentUser,
pendingInvitesCount,
// Геттеры
currentProject,
doneColumnId,
currentUserId,
currentUserName,
currentUserAvatar,
isProjectAdmin,
currentUserPermissions,
// Проверки прав
can,
canEditTask,
canMoveTask,
canCreateComment,
// Действия
init,
refreshProjects,
selectProject,
fetchProjectData,
reset
fetchUsers,
fetchPendingInvitesCount,
fetchCards,
fetchArchivedCards,
clearCards,
reset,
// CRUD проектов
createProject,
updateProject,
deleteProject,
reorderProjects,
// CRUD колонок
addColumn,
updateColumn,
getColumnTasksCount,
deleteColumn,
reorderColumns,
setReadyColumn,
// CRUD отделов
addDepartment,
addDepartmentToProject,
updateDepartment,
getDepartmentTasksCount,
deleteDepartment,
reorderDepartments
}
})

View File

@@ -1,37 +1,12 @@
<template>
<div class="app" :class="{ mobile: isMobile }">
<!-- Боковая панель навигации -->
<Sidebar />
<!-- Основной контент -->
<div class="main-wrapper">
<PageLayout>
<!-- Шапка с заголовком и фильтрами -->
<Header title="Архив задач">
<template #filters>
<div class="filters">
<!-- Выбор проекта -->
<ProjectSelector @change="onProjectChange" />
<div class="filter-divider"></div>
<!-- Фильтр по отделам -->
<button
class="filter-tag"
:class="{ active: activeDepartment === null }"
@click="activeDepartment = null"
>
Все
</button>
<button
v-for="dept in store.departments"
:key="dept.id"
class="filter-tag"
:class="{ active: activeDepartment === dept.id }"
@click="activeDepartment = activeDepartment === dept.id ? null : dept.id"
>
{{ dept.name_departments }}
</button>
</div>
<DepartmentTags
v-model="activeDepartment"
@project-change="onProjectChange"
/>
</template>
<!-- Мобильные фильтры -->
@@ -58,7 +33,7 @@
</Header>
<!-- Список архивных задач -->
<main class="main">
<main class="main" :class="{ mobile: isMobile }">
<!-- Мобильный заголовок над карточками -->
<div v-if="isMobile" class="mobile-archive-header">
<div class="archive-title-row">
@@ -68,20 +43,18 @@
</div>
</div>
<div class="archive-list">
<div class="archive-list" :class="{ mobile: isMobile }">
<ArchiveCard
v-for="card in filteredCards"
:key="card.id"
:card="card"
:departments="store.departments"
:labels="store.labels"
@click="openTaskPanel(card)"
@restore="handleRestore"
@restore="confirmRestore"
@delete="confirmDelete"
/>
<!-- Пустое состояние -->
<div v-if="filteredCards.length === 0 && !loading" class="empty-state">
<div v-if="filteredCards.length === 0 && !loading" class="empty-state" :class="{ mobile: isMobile }">
<i data-lucide="archive-x"></i>
<p>Архив пуст</p>
<span>Архивированные задачи появятся здесь</span>
@@ -91,71 +64,63 @@
<Loader v-if="loading" />
</div>
</main>
</div>
<!-- Модальные окна -->
<template #modals>
<TaskPanel
:show="panelOpen"
:card="editingCard"
:column-id="null"
:is-archived="true"
:on-save="handleSaveTask"
@close="closePanel"
@delete="handleDeleteTask"
@restore="handleRestoreFromPanel"
/>
<!-- Панель редактирования задачи -->
<TaskPanel
:show="panelOpen"
:card="editingCard"
:column-id="null"
:is-archived="true"
:departments="store.departments"
:labels="store.labels"
:users="store.users"
:current-user-id="store.currentUserId"
:is-project-admin="store.isProjectAdmin"
:on-save="handleSaveTask"
@close="closePanel"
@delete="handleDeleteTask"
@restore="handleRestoreFromPanel"
/>
<ConfirmDialog
:show="confirmDialogOpen"
type="deleteTask"
:action="handleConfirmDelete"
@confirm="confirmDialogOpen = false"
@cancel="confirmDialogOpen = false"
/>
<!-- Диалог подтверждения удаления -->
<ConfirmDialog
:show="confirmDialogOpen"
title="Удалить задачу?"
message="Задача будет удалена безвозвратно. Это действие нельзя отменить."
confirm-text="Удалить"
variant="danger"
:is-loading="isDeleting"
@confirm="handleConfirmDelete"
@cancel="confirmDialogOpen = false"
/>
</div>
<ConfirmDialog
:show="restoreDialogOpen"
type="restore"
:action="handleConfirmRestore"
@confirm="restoreDialogOpen = false"
@cancel="restoreDialogOpen = false"
/>
</template>
</PageLayout>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import Sidebar from '../components/Sidebar.vue'
import { ref, computed, onMounted } from 'vue'
import PageLayout from '../components/PageLayout.vue'
import Header from '../components/Header.vue'
import ArchiveCard from '../components/ArchiveCard.vue'
import TaskPanel from '../components/TaskPanel'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import DepartmentTags from '../components/DepartmentTags.vue'
import ProjectSelector from '../components/ProjectSelector.vue'
import MobileSelect from '../components/ui/MobileSelect.vue'
import Loader from '../components/ui/Loader.vue'
import { useProjectsStore } from '../stores/projects'
import { cardsApi } from '../api'
import { useMobile } from '../composables/useMobile'
import { useDepartmentFilter } from '../composables/useDepartmentFilter'
const { isMobile } = useMobile()
// ==================== СОСТОЯНИЯ ЗАГРУЗКИ ====================
const isRestoring = ref(false)
const isDeleting = ref(false)
// ==================== STORE ====================
const store = useProjectsStore()
// ==================== MOBILE ====================
const departmentOptions = computed(() => [
{ id: null, label: 'Все отделы' },
...store.departments.map(d => ({
id: d.id,
label: d.name_departments
}))
])
// ==================== ФИЛЬТР ПО ОТДЕЛАМ ====================
const { activeDepartment, departmentOptions, resetFilter } = useDepartmentFilter()
// ==================== КАРТОЧКИ ====================
const cards = ref([])
@@ -215,22 +180,10 @@ const fetchCards = async () => {
// При смене проекта
const onProjectChange = async () => {
activeDepartment.value = null
resetFilter()
await fetchCards()
}
// ==================== ФИЛЬТР ПО ОТДЕЛАМ ====================
const savedDepartment = localStorage.getItem('activeDepartment')
const activeDepartment = ref(savedDepartment ? parseInt(savedDepartment) : null)
watch(activeDepartment, (newVal) => {
if (newVal === null) {
localStorage.removeItem('activeDepartment')
} else {
localStorage.setItem('activeDepartment', newVal.toString())
}
})
// ==================== ПАНЕЛЬ РЕДАКТИРОВАНИЯ ====================
const panelOpen = ref(false)
const editingCard = ref(null)
@@ -291,44 +244,55 @@ const confirmDelete = (cardId) => {
confirmDialogOpen.value = true
}
const handleConfirmDelete = async () => {
if (isDeleting.value || !cardToDelete.value) return
isDeleting.value = true
try {
const result = await cardsApi.delete(cardToDelete.value)
if (result.success) {
cards.value = cards.value.filter(c => c.id !== cardToDelete.value)
}
confirmDialogOpen.value = false
cardToDelete.value = null
} finally {
isDeleting.value = false
}
// ==================== ВОССТАНОВЛЕНИЕ С ПОДТВЕРЖДЕНИЕМ ====================
const restoreDialogOpen = ref(false)
const cardToRestore = ref(null)
const confirmRestore = (cardId) => {
cardToRestore.value = cardId
restoreDialogOpen.value = true
}
// ==================== ВОССТАНОВЛЕНИЕ ====================
const handleRestore = async (cardId) => {
if (isRestoring.value) return
isRestoring.value = true
try {
const result = await cardsApi.setArchive(cardId, 0)
if (result.success) {
cards.value = cards.value.filter(c => c.id !== cardId)
}
} finally {
isRestoring.value = false
const handleConfirmDelete = async () => {
if (!cardToDelete.value) {
throw new Error('Задача не выбрана')
}
const result = await cardsApi.delete(cardToDelete.value)
if (!result.success) {
throw new Error('Ошибка удаления задачи')
}
cards.value = cards.value.filter(c => c.id !== cardToDelete.value)
cardToDelete.value = null
}
// ==================== ВОССТАНОВЛЕНИЕ (действие) ====================
const handleConfirmRestore = async () => {
if (!cardToRestore.value) {
throw new Error('Задача не выбрана')
}
const result = await cardsApi.setArchive(cardToRestore.value, 0)
if (!result.success) {
throw new Error('Ошибка восстановления задачи')
}
cards.value = cards.value.filter(c => c.id !== cardToRestore.value)
cardToRestore.value = null
}
const handleRestoreFromPanel = async (cardId) => {
await handleRestore(cardId)
const result = await cardsApi.setArchive(cardId, 0)
if (result.success) {
cards.value = cards.value.filter(c => c.id !== cardId)
}
closePanel()
}
// ==================== ИНИЦИАЛИЗАЦИЯ ====================
onMounted(async () => {
// Store уже мог быть инициализирован в роутере
await store.init()
await fetchCards()
@@ -337,98 +301,16 @@ onMounted(async () => {
</script>
<style scoped>
/* Контейнер приложения */
.app {
display: flex;
min-height: 100vh;
}
/* Основная область контента */
.main-wrapper {
flex: 1;
margin-left: 64px;
display: flex;
flex-direction: column;
background: var(--bg-main);
}
/* Контейнер фильтров */
.filters {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
/* Разделитель между проектом и отделами */
.filter-divider {
width: 1px;
height: 24px;
background: rgba(255, 255, 255, 0.1);
margin: 0 8px;
}
/* Кнопка фильтра */
.filter-tag {
padding: 6px 12px;
background: var(--bg-card);
border: none;
border-radius: 6px;
color: var(--text-secondary);
font-family: inherit;
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.3px;
cursor: pointer;
transition: all 0.15s;
}
.filter-tag:hover {
background: var(--bg-card-hover);
color: var(--text-primary);
}
/* Активный фильтр */
.filter-tag.active {
background: var(--accent);
color: #000;
}
/* Блок статистики */
.header-stats {
display: flex;
align-items: center;
gap: 16px;
padding: 10px 20px;
background: var(--bg-card);
border-radius: 10px;
}
.stat {
display: flex;
align-items: baseline;
gap: 5px;
}
.stat-value {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.stat-label {
font-size: 12px;
color: var(--text-muted);
}
/* Основная область */
/* Специфичные стили для архива (вертикальный скролл) */
.main {
flex: 1;
padding: 0 36px 36px;
overflow-y: auto;
}
/* Мобильный архив — с padding */
.main.mobile {
padding: 0 16px 16px;
}
/* Список архивных карточек */
.archive-list {
display: flex;
@@ -467,39 +349,13 @@ onMounted(async () => {
font-size: 13px;
}
/* ========== MOBILE ========== */
.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;
padding-bottom: calc(64px + var(--safe-area-bottom, 0px));
overflow: hidden;
display: flex;
flex-direction: column;
}
.app.mobile .main {
flex: 1;
min-height: 0;
padding: 0 16px 16px;
overflow: hidden;
}
.app.mobile .archive-list {
/* 60px header + 40px title + 64px nav + safe-area */
max-height: calc(100dvh - 60px - 40px - 64px - var(--safe-area-bottom, 0px));
/* ========== MOBILE: Архив ========== */
.archive-list.mobile {
/* Как в Column.vue - большой запас для всех элементов */
/* safe-area-top + header + title + nav + safe-area-bottom + запас */
max-height: calc(100dvh - var(--safe-area-top, 0px) - 60px - 50px - 64px - var(--safe-area-bottom, 0px) - 20px);
max-width: none;
gap: 12px;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
@@ -507,20 +363,15 @@ onMounted(async () => {
padding-bottom: 16px;
}
.app.mobile .archive-list::-webkit-scrollbar {
.archive-list.mobile::-webkit-scrollbar {
display: none;
}
.app.mobile .archive-list {
max-width: none;
gap: 12px;
}
.app.mobile .empty-state {
.empty-state.mobile {
padding: 40px 20px;
}
.app.mobile .empty-state i {
.empty-state.mobile i {
width: 40px;
height: 40px;
}

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

@@ -16,12 +16,8 @@
<div class="float-icon icon-12"><i data-lucide="message-circle"></i></div>
</div>
<!-- Градиентные сферы на фоне -->
<div class="bg-glow glow-2"></div>
<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 +25,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 +42,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 +61,7 @@
@blur="loginFocused = false"
placeholder="Введите логин"
autocomplete="username"
:disabled="isAuthenticating"
:disabled="isProcessing"
>
<div class="input-line"></div>
</div>
@@ -79,7 +78,7 @@
@blur="passwordFocused = false"
placeholder="Введите пароль"
autocomplete="current-password"
:disabled="isAuthenticating"
:disabled="isProcessing"
>
<div class="input-line"></div>
</div>
@@ -96,8 +95,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 +104,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,36 +224,136 @@
</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'
import { useMobile } from '../composables/useMobile'
import { setAuthCache } from '../router'
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))
}
}
@@ -151,35 +362,97 @@ 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) {
// Показываем анимацию успеха
// Получаем данные пользователя для кэша
const checkResult = await authApi.check()
setAuthCache(true, checkResult.user || null)
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) {
// Получаем данные пользователя для кэша
const checkResult = await authApi.check()
setAuthCache(true, checkResult.user || null)
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()
@@ -194,8 +467,7 @@ const refreshIcons = () => {
onMounted(refreshIcons)
// Обновляем иконки только когда меняется состояние успеха
watch(showSuccess, () => {
watch([showSuccess, isRegisterMode], () => {
nextTick(refreshIcons)
})
</script>
@@ -213,44 +485,6 @@ watch(showSuccess, () => {
padding: 20px;
}
/* Градиентные сферы на фоне */
.bg-glow {
position: absolute;
border-radius: 50%;
filter: blur(120px);
opacity: 0.4;
pointer-events: none;
}
.glow-2 {
width: 500px;
height: 500px;
background: radial-gradient(circle, rgba(96, 165, 250, 0.25) 0%, transparent 70%);
bottom: -150px;
left: -100px;
animation: glowFloat2 10s ease-in-out infinite;
}
.glow-3 {
width: 400px;
height: 400px;
background: radial-gradient(circle, rgba(244, 114, 182, 0.2) 0%, transparent 70%);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation: glowFloat3 12s ease-in-out infinite;
}
@keyframes glowFloat2 {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(40px, -20px) scale(1.05); }
}
@keyframes glowFloat3 {
0%, 100% { transform: translate(-50%, -50%) scale(1); opacity: 0.3; }
50% { transform: translate(-50%, -50%) scale(1.2); opacity: 0.5; }
}
/* Левитирующие иконки */
.floating-icons {
position: absolute;
@@ -399,6 +633,10 @@ watch(showSuccess, () => {
transform: scale(1.1);
}
.input-group.has-error .input-icon {
color: #f87171;
}
.input-group input {
flex: 1;
background: transparent;
@@ -451,6 +689,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;
@@ -555,6 +816,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

@@ -1,39 +1,41 @@
<template>
<div class="app" :class="{ mobile: isMobile }">
<!-- Боковая панель навигации -->
<Sidebar />
<!-- Основной контент -->
<div class="main-wrapper">
<PageLayout>
<!-- Шапка с заголовком, фильтрами и статистикой -->
<Header title="Доска задач">
<!-- Десктоп: фильтры в одну строку -->
<template #filters>
<div class="filters">
<ProjectSelector @change="onProjectChange" />
<div class="filter-divider"></div>
<button
class="filter-tag"
:class="{ active: activeDepartment === null }"
@click="activeDepartment = null"
>
Все
</button>
<button
v-for="dept in store.departments"
:key="dept.id"
class="filter-tag"
:class="{ active: activeDepartment === dept.id }"
@click="activeDepartment = activeDepartment === dept.id ? null : dept.id"
>
{{ dept.name_departments }}
</button>
</div>
<DepartmentTags
v-model="activeDepartment"
@project-change="onProjectChange"
@create-project="openCreateProjectPanel"
@edit-project="openEditProjectPanel"
/>
</template>
<!-- Мобильный: Проект + Отделы -->
<template #mobile-filters>
<ProjectSelector @change="onProjectChange" />
<ProjectSelector
@change="onProjectChange"
@edit="openEditProjectPanel"
/>
<!-- Кнопки управления проектами (мобильные) -->
<div class="mobile-project-actions">
<button
v-if="store.isProjectAdmin"
class="mobile-project-btn"
title="Настройки проекта"
@click="openEditProjectPanel(store.currentProject)"
>
<i data-lucide="settings"></i>
</button>
<button
class="mobile-project-btn"
title="Создать проект"
@click="openCreateProjectPanel"
>
<i data-lucide="plus"></i>
</button>
</div>
<MobileSelect
v-model="activeDepartment"
:options="departmentOptions"
@@ -66,66 +68,66 @@
</Header>
<!-- Доска с колонками и карточками -->
<main class="main">
<main class="main" :class="{ mobile: isMobile }">
<Loader v-if="loading" />
<Board
v-else
ref="boardRef"
:active-department="activeDepartment"
:departments="store.departments"
:labels="store.labels"
:columns="store.columns"
:cards="cards"
:done-column-id="store.doneColumnId"
@stats-updated="stats = $event"
@open-task="openTaskPanel"
@create-task="openNewTaskPanel"
@cards-moved="onCardsMoved"
/>
</main>
</div>
<!-- Панель редактирования/создания задачи -->
<TaskPanel
:show="panelOpen"
:card="editingCard"
:column-id="editingColumnId"
:done-column-id="store.doneColumnId"
:departments="store.departments"
:labels="store.labels"
:users="store.users"
:current-user-id="store.currentUserId"
:is-project-admin="store.isProjectAdmin"
:on-save="handleSaveTask"
@close="closePanel"
@delete="handleDeleteTask"
@archive="handleArchiveTask"
/>
</div>
<!-- Модальные окна -->
<template #modals>
<TaskPanel
:show="panelOpen"
:card="editingCard"
:column-id="editingColumnId"
:on-save="handleSaveTask"
:on-archive="handleArchiveTask"
@close="closePanel"
@delete="handleDeleteTask"
/>
<ProjectPanel
:show="projectPanelOpen"
:project="editingProject"
@close="closeProjectPanel"
@saved="onProjectSaved"
/>
</template>
</PageLayout>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import Sidebar from '../components/Sidebar.vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import PageLayout from '../components/PageLayout.vue'
import Header from '../components/Header.vue'
import Board from '../components/Board.vue'
import TaskPanel from '../components/TaskPanel'
import ProjectPanel from '../components/ProjectPanel.vue'
import DepartmentTags from '../components/DepartmentTags.vue'
import ProjectSelector from '../components/ProjectSelector.vue'
import MobileSelect from '../components/ui/MobileSelect.vue'
import Loader from '../components/ui/Loader.vue'
import { useProjectsStore } from '../stores/projects'
import { cardsApi } from '../api'
import { useMobile } from '../composables/useMobile'
import { useDepartmentFilter } from '../composables/useDepartmentFilter'
import { useAutoRefresh } from '../composables/useAutoRefresh'
const { isMobile } = useMobile()
// ==================== STORE ====================
const store = useProjectsStore()
// ==================== МОБИЛЬНЫЕ СЕЛЕКТОРЫ ====================
const departmentOptions = computed(() => [
{ id: null, label: 'Все отделы' },
...store.departments.map(d => ({ id: d.id, label: d.name_departments }))
])
// ==================== ФИЛЬТР ПО ОТДЕЛАМ ====================
const { activeDepartment, departmentOptions, resetFilter } = useDepartmentFilter()
// ==================== КАРТОЧКИ ====================
const cards = ref([])
@@ -153,22 +155,15 @@ const fetchCards = async (silent = false) => {
// При смене проекта — перезагружаем карточки
const onProjectChange = async () => {
activeDepartment.value = null
resetFilter()
loading.value = true
await fetchCards()
}
// ==================== ФИЛЬТР ПО ОТДЕЛАМ ====================
const savedDepartment = localStorage.getItem('activeDepartment')
const activeDepartment = ref(savedDepartment ? parseInt(savedDepartment) : null)
watch(activeDepartment, (newVal) => {
if (newVal === null) {
localStorage.removeItem('activeDepartment')
} else {
localStorage.setItem('activeDepartment', newVal.toString())
}
})
// После перемещения карточки — тихо обновляем данные с сервера
const onCardsMoved = async () => {
await fetchCards(true)
}
// ==================== СТАТИСТИКА ====================
const stats = ref({ total: 0, inProgress: 0, done: 0 })
@@ -215,28 +210,58 @@ const handleArchiveTask = async (cardId) => {
closePanel()
}
// ==================== ПАНЕЛЬ ПРОЕКТА ====================
const projectPanelOpen = ref(false)
const editingProject = ref(null)
const openCreateProjectPanel = () => {
editingProject.value = null
projectPanelOpen.value = true
}
const openEditProjectPanel = (project) => {
editingProject.value = project
projectPanelOpen.value = true
}
const closeProjectPanel = () => {
projectPanelOpen.value = false
editingProject.value = null
}
const onProjectSaved = async (projectId) => {
// Перезагружаем данные если изменился текущий проект
if (projectId === store.currentProjectId) {
await store.fetchProjectData()
await fetchCards()
}
}
// ==================== АВТООБНОВЛЕНИЕ (POLLING) ====================
const REFRESH_INTERVAL = (window.APP_CONFIG?.IDLE_REFRESH_SECONDS ?? 30) * 1000
let pollTimer = null
const { start: startCardsPolling, stop: stopCardsPolling } = useAutoRefresh('cards', async () => {
// Не обновляем когда открыта модалка — это может прерывать клики
if (panelOpen.value || projectPanelOpen.value) return
console.log('[AutoRefresh] Обновление данных...')
await fetchCards(true) // silent = true, без Loader
})
const { start: startInvitesPolling, stop: stopInvitesPolling } = useAutoRefresh('invites', async () => {
await store.fetchPendingInvitesCount()
})
const startPolling = () => {
if (pollTimer) clearInterval(pollTimer)
pollTimer = setInterval(async () => {
console.log('[AutoRefresh] Обновление данных...')
await fetchCards(true) // silent = true, без Loader
}, REFRESH_INTERVAL)
startCardsPolling()
startInvitesPolling()
}
const stopPolling = () => {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
stopCardsPolling()
stopInvitesPolling()
}
// ==================== ИНИЦИАЛИЗАЦИЯ ====================
onMounted(async () => {
// Store уже мог быть инициализирован при логине (prefetch)
// Store уже мог быть инициализирован при логине (prefetch) или в роутере
await store.init()
// Проверяем предзагруженные карточки
@@ -264,171 +289,46 @@ onUnmounted(() => {
</script>
<style scoped>
/* Контейнер приложения */
.app {
display: flex;
min-height: 100vh;
}
/* Основная область контента */
.main-wrapper {
flex: 1;
margin-left: 64px;
display: flex;
flex-direction: column;
background: var(--bg-main);
}
/* ========== MOBILE ========== */
.app.mobile {
height: 100vh;
height: 100dvh; /* Динамическая высота для iOS */
overflow: hidden;
}
.app.mobile .main-wrapper {
margin-left: 0;
padding-bottom: calc(64px + var(--safe-area-bottom, 0px)); /* место для нижней навигации + safe area */
height: 100vh;
height: 100dvh; /* Динамическая высота для iOS */
overflow: hidden;
}
/* Контейнер фильтров */
.filters {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
/* На мобильных — горизонтальный скролл */
.app.mobile .filters {
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
-ms-overflow-style: none;
}
.app.mobile .filters::-webkit-scrollbar {
display: none;
}
/* Разделитель между проектом и отделами */
.filter-divider {
width: 1px;
height: 24px;
background: rgba(255, 255, 255, 0.1);
margin: 0 8px;
flex-shrink: 0;
}
/* Кнопка фильтра */
.filter-tag {
padding: 6px 12px;
background: var(--bg-card);
border: none;
border-radius: 6px;
color: var(--text-secondary);
font-family: inherit;
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.3px;
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
white-space: nowrap;
}
.filter-tag:hover {
background: var(--bg-card-hover);
color: var(--text-primary);
}
/* Активный фильтр */
.filter-tag.active {
background: var(--accent);
color: #000;
}
/* Блок статистики */
.header-stats {
display: flex;
align-items: center;
gap: 16px;
padding: 10px 20px;
background: var(--bg-card);
border-radius: 10px;
}
.stat {
display: flex;
align-items: baseline;
gap: 5px;
}
.stat-value {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.stat-label {
font-size: 12px;
color: var(--text-muted);
}
/* Разделитель между статами */
.stat-divider {
width: 1px;
height: 16px;
background: rgba(255, 255, 255, 0.1);
}
/* ========== MOBILE: статистика ========== */
.app.mobile .header-stats {
display: none;
}
/* Основная область с доской */
/* Специфичные стили для доски (горизонтальный скролл) */
.main {
flex: 1;
padding: 0 36px 36px;
overflow-x: auto;
scroll-behavior: smooth;
}
/* Стилизация горизонтального скроллбара */
.main::-webkit-scrollbar {
height: 10px;
}
.main::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.08);
border-radius: 5px;
margin: 0 36px;
}
.main::-webkit-scrollbar-thumb {
background: rgba(0, 212, 170, 0.4);
border-radius: 5px;
border: 2px solid transparent;
background-clip: padding-box;
}
.main::-webkit-scrollbar-thumb:hover {
background: rgba(0, 212, 170, 0.6);
}
/* ========== MOBILE: доска ========== */
.app.mobile .main {
flex: 1;
/* Мобильная доска — без padding, Board сам управляет layout */
.main.mobile {
padding: 0;
overflow: hidden;
}
/* Мобильные кнопки управления проектами */
.mobile-project-actions {
display: flex;
flex-direction: column;
min-height: 0; /* Важно для flex children с overflow */
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.mobile-project-btn {
display: flex;
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;
}
.mobile-project-btn:active {
color: var(--accent);
background: rgba(255, 255, 255, 0.06);
}
.mobile-project-btn i {
width: 18px;
height: 18px;
}
</style>

View File

@@ -0,0 +1,616 @@
<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, onUpdated, onUnmounted, 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 { useAutoRefresh } from '../composables/useAutoRefresh'
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()
}
}
// Периодическое обновление приглашений
const { start: startInvitesRefresh, stop: stopInvitesRefresh } = useAutoRefresh('invites', async () => {
// Не обновляем если показывается анимация успеха
if (!showSuccess.value) {
await loadInvites()
}
})
onMounted(() => {
loadInvites()
refreshIcons()
startInvitesRefresh()
})
onUnmounted(() => {
stopInvitesRefresh()
})
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,36 +1,100 @@
<template>
<div class="app" :class="{ mobile: isMobile }">
<Sidebar />
<div class="main-wrapper">
<Header title="Команда" subtitle="Наша команда специалистов" />
<PageLayout>
<Header title="Команда">
<template #filters>
<ProjectSelector />
<div v-if="canManageMembers" class="team-actions desktop">
<button
class="team-action-btn"
title="Пригласить участника"
@click="openMemberPanel(null)"
>
<i data-lucide="plus"></i>
</button>
</div>
</template>
<main class="main">
<Loader v-if="loading" />
<template #mobile-filters>
<ProjectSelector />
<div v-if="canManageMembers" class="team-actions">
<button
class="team-action-btn"
title="Пригласить участника"
@click="openMemberPanel(null)"
>
<i data-lucide="plus"></i>
</button>
</div>
</template>
<template #stats>
<div v-if="users.length > 0" class="header-stats">
<div class="stat">
<span class="stat-value">{{ users.length }}</span>
<span class="stat-label">в команде</span>
</div>
</div>
</template>
</Header>
<main class="main" :class="{ mobile: isMobile }">
<Loader v-if="loading" />
<!-- Desktop: grid -->
<div v-else-if="!isMobile" class="team-grid">
<div
v-for="user in users"
:key="user.id"
:key="user.id_user"
class="team-card"
>
<div class="card-avatar">
<img :src="getFullUrl(user.avatar_url)" :alt="user.name">
<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 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
:href="'https://t.me/' + user.telegram.replace('@', '')"
v-if="user.telegram"
:href="`https://t.me/${user.telegram}`"
target="_blank"
class="card-telegram"
>
<i data-lucide="send"></i>
{{ user.telegram }}
@{{ user.telegram }}
</a>
</div>
</div>
@@ -41,78 +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 :src="getFullUrl(user.avatar_url)" :alt="user.name">
<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 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
: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 :src="getFullUrl(user.avatar_url)" :alt="user.name">
</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, onMounted, onUpdated } from 'vue'
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 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 () => {
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
@@ -121,44 +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)
}
}
onMounted(() => {
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()
}
})
onUpdated(refreshIcons)
</script>
<style scoped>
.app {
/* Кнопки действий (как на главной) */
.team-actions {
display: flex;
min-height: 100vh;
background: var(--bg);
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 {
@@ -204,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 {
@@ -214,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 {
@@ -222,6 +508,19 @@ onUpdated(refreshIcons)
object-fit: cover;
}
.avatar-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
font-weight: 600;
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.1);
text-transform: uppercase;
}
.card-info {
display: flex;
flex-direction: column;
@@ -251,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;
@@ -288,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;
}
@@ -359,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 {
@@ -367,6 +723,10 @@ onUpdated(refreshIcons)
object-fit: cover;
}
.mobile-avatar .avatar-placeholder {
font-size: 64px;
}
.mobile-info {
display: flex;
flex-direction: column;
@@ -387,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 {
@@ -436,7 +874,7 @@ onUpdated(refreshIcons)
align-items: center;
gap: 8px;
padding: 12px 16px;
background: var(--bg);
background: var(--bg-body);
z-index: 100;
}
@@ -472,4 +910,16 @@ onUpdated(refreshIcons)
transform: scale(1.15);
box-shadow: 0 4px 12px rgba(0, 212, 170, 0.3);
}
.avatar-placeholder-small {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
}
</style>

View File

@@ -1,8 +1,51 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [vue()],
plugins: [
vue(),
VitePWA({
registerType: 'autoUpdate',
// filename в корне - Android требует SW в scope приложения
includeAssets: ['favicon.ico', 'icon_phone/apple-touch-icon.png'],
manifest: {
id: '/',
name: 'TaskBoard',
short_name: 'TaskBoard',
description: 'Task management application',
theme_color: '#111113',
background_color: '#111113',
display: 'standalone',
orientation: 'portrait',
scope: '/',
start_url: '/',
icons: [
{
src: 'icon_phone/pwa-192x192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: 'icon_phone/pwa-512x512.png',
sizes: '512x512',
type: 'image/png'
},
{
src: 'icon_phone/pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable'
}
]
},
workbox: {
// Полностью отключаем кеширование - как в браузере
globPatterns: [],
runtimeCaching: []
}
})
],
server: {
host: '0.0.0.0',
port: 5173

View File

@@ -11,7 +11,7 @@
Target Server Version : 90200 (9.2.0)
File Encoding : 65001
Date: 15/01/2026 07:15:39
Date: 20/01/2026 10:20:56
*/
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 = 14 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 = 137 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 = 103 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 = 158 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 = 32 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for departments
@@ -107,8 +108,9 @@ CREATE TABLE `departments` (
`name_departments` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
`color` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
`id_project` int NULL DEFAULT NULL,
`order_id` int NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 8 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
) ENGINE = InnoDB AUTO_INCREMENT = 44 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for labels
@@ -132,8 +134,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 = 54 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 AUTO_INCREMENT = 21 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 = 22 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
SET FOREIGN_KEY_CHECKS = 1;