Compare commits
17 Commits
5c130fc01c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e9f8e0f6e | |||
| 88189a3f04 | |||
| 8e3cd770df | |||
| 7d7b817d7e | |||
| 6928687982 | |||
| e8a4480747 | |||
| 190b4d0a5e | |||
| 250eac70a7 | |||
| 15725ae90a | |||
| 9b2a2f0c2e | |||
| aca5eb84fd | |||
| c46fd3952e | |||
| 25663a7aa4 | |||
| f856e68ea8 | |||
| 3258fa9137 | |||
| cb075e56be | |||
| 36e844d4ea |
@@ -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);
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
85
backend/api/projectAccess.php
Normal file
85
backend/api/projectAccess.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
if ($method === 'POST') {
|
||||
$data = RestApi::getInput();
|
||||
$action = $data['action'] ?? null;
|
||||
$current_user_id = RestApi::getCurrentUserId();
|
||||
|
||||
// Приглашение участника в проект (только админ)
|
||||
if ($action === 'add_member') {
|
||||
$project_id = (int)($data['project_id'] ?? 0);
|
||||
$user_id = (int)($data['user_id'] ?? 0);
|
||||
$is_admin = (bool)($data['is_admin'] ?? false);
|
||||
$permissions = $data['permissions'] ?? null;
|
||||
|
||||
if (!$project_id || !$user_id) {
|
||||
RestApi::response(['success' => false, 'errors' => ['data' => 'Укажите project_id и user_id']], 400);
|
||||
}
|
||||
|
||||
// Приглашать участников могут только админы
|
||||
ProjectAccess::requireAdmin($project_id, $current_user_id);
|
||||
|
||||
$result = ProjectAccess::addMember($project_id, $user_id, $current_user_id, $is_admin, $permissions);
|
||||
RestApi::response($result, $result['success'] ? 200 : 400);
|
||||
}
|
||||
|
||||
// Обновление прав участника (только админ)
|
||||
if ($action === 'update_member') {
|
||||
$project_id = (int)($data['project_id'] ?? 0);
|
||||
$user_id = (int)($data['user_id'] ?? 0);
|
||||
$is_admin = isset($data['is_admin']) ? (bool)$data['is_admin'] : null;
|
||||
$permissions = $data['permissions'] ?? null;
|
||||
|
||||
if (!$project_id || !$user_id) {
|
||||
RestApi::response(['success' => false, 'errors' => ['data' => 'Укажите project_id и user_id']], 400);
|
||||
}
|
||||
|
||||
// Редактировать права могут только админы
|
||||
ProjectAccess::requireAdmin($project_id, $current_user_id);
|
||||
|
||||
// Обновляем is_admin если передан
|
||||
if ($is_admin !== null) {
|
||||
$result = ProjectAccess::setAdmin($project_id, $user_id, $is_admin, $current_user_id);
|
||||
if (!$result['success']) {
|
||||
RestApi::response($result, 400);
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем права если переданы
|
||||
if ($permissions !== null) {
|
||||
$result = ProjectAccess::setPermissions($project_id, $user_id, $permissions, $current_user_id);
|
||||
if (!$result['success']) {
|
||||
RestApi::response($result, 400);
|
||||
}
|
||||
}
|
||||
|
||||
RestApi::response(['success' => true]);
|
||||
}
|
||||
|
||||
// Удаление участника из проекта (право remove_members или админ, или сам себя)
|
||||
if ($action === 'remove_member') {
|
||||
$project_id = (int)($data['project_id'] ?? 0);
|
||||
$user_id = (int)($data['user_id'] ?? 0);
|
||||
|
||||
if (!$project_id || !$user_id) {
|
||||
RestApi::response(['success' => false, 'errors' => ['data' => 'Укажите project_id и user_id']], 400);
|
||||
}
|
||||
|
||||
// Пользователь может удалить сам себя, иначе нужно право remove_members
|
||||
if ((int)$user_id !== (int)$current_user_id) {
|
||||
ProjectAccess::requirePermission($project_id, $current_user_id, 'remove_members');
|
||||
}
|
||||
|
||||
$result = ProjectAccess::removeMember($project_id, $user_id, $current_user_id);
|
||||
RestApi::response($result, $result['success'] ? 200 : 400);
|
||||
}
|
||||
|
||||
// Метод не указан
|
||||
if (!$action) {
|
||||
RestApi::response(['success' => false, 'error' => 'Укажите метод'], 400);
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
134
backend/api/projectInvite.php
Normal file
134
backend/api/projectInvite.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
// GET — получить свои pending-приглашения
|
||||
if ($method === 'GET') {
|
||||
$current_user_id = RestApi::getCurrentUserId();
|
||||
|
||||
if (!$current_user_id) {
|
||||
RestApi::response(['success' => false, 'errors' => ['auth' => 'Требуется авторизация']], 401);
|
||||
}
|
||||
|
||||
$invites = ProjectInvite::getMyPending($current_user_id);
|
||||
$count = count($invites);
|
||||
|
||||
RestApi::response([
|
||||
'success' => true,
|
||||
'data' => $invites,
|
||||
'count' => $count
|
||||
]);
|
||||
}
|
||||
|
||||
// POST — действия с приглашениями
|
||||
if ($method === 'POST') {
|
||||
$data = RestApi::getInput();
|
||||
$action = $data['action'] ?? null;
|
||||
$current_user_id = RestApi::getCurrentUserId();
|
||||
|
||||
if (!$current_user_id) {
|
||||
RestApi::response(['success' => false, 'errors' => ['auth' => 'Требуется авторизация']], 401);
|
||||
}
|
||||
|
||||
// Отправить приглашение (только админ проекта)
|
||||
if ($action === 'send') {
|
||||
$project_id = (int)($data['project_id'] ?? 0);
|
||||
$user_id = (int)($data['user_id'] ?? 0);
|
||||
$is_admin = (bool)($data['is_admin'] ?? false);
|
||||
$permissions = $data['permissions'] ?? null;
|
||||
|
||||
if (!$project_id || !$user_id) {
|
||||
RestApi::response(['success' => false, 'errors' => ['data' => 'Укажите project_id и user_id']], 400);
|
||||
}
|
||||
|
||||
// Только админ может приглашать
|
||||
ProjectAccess::requireAdmin($project_id, $current_user_id);
|
||||
|
||||
$result = ProjectInvite::create($project_id, $user_id, $current_user_id, $is_admin, $permissions);
|
||||
RestApi::response($result, $result['success'] ? 200 : 400);
|
||||
}
|
||||
|
||||
// Проверить, есть ли pending-приглашение для пользователя в проект
|
||||
if ($action === 'check_pending') {
|
||||
$project_id = (int)($data['project_id'] ?? 0);
|
||||
$user_id = (int)($data['user_id'] ?? 0);
|
||||
|
||||
if (!$project_id || !$user_id) {
|
||||
RestApi::response(['success' => false, 'errors' => ['data' => 'Укажите project_id и user_id']], 400);
|
||||
}
|
||||
|
||||
// Проверяем доступ к проекту
|
||||
ProjectAccess::requireAccess($project_id, $current_user_id);
|
||||
|
||||
$hasPending = ProjectInvite::hasPending($project_id, $user_id);
|
||||
RestApi::response(['success' => true, 'has_pending' => $hasPending]);
|
||||
}
|
||||
|
||||
// Получить pending-приглашения проекта (для админа)
|
||||
if ($action === 'get_project_pending') {
|
||||
$project_id = (int)($data['project_id'] ?? 0);
|
||||
|
||||
if (!$project_id) {
|
||||
RestApi::response(['success' => false, 'errors' => ['data' => 'Укажите project_id']], 400);
|
||||
}
|
||||
|
||||
// Только админ может видеть
|
||||
ProjectAccess::requireAdmin($project_id, $current_user_id);
|
||||
|
||||
$invites = ProjectInvite::getProjectPending($project_id);
|
||||
RestApi::response(['success' => true, 'data' => $invites]);
|
||||
}
|
||||
|
||||
// Получить количество pending-приглашений для текущего пользователя
|
||||
if ($action === 'get_count') {
|
||||
$count = ProjectInvite::getPendingCount($current_user_id);
|
||||
RestApi::response(['success' => true, 'count' => $count]);
|
||||
}
|
||||
|
||||
// Принять приглашение
|
||||
if ($action === 'accept') {
|
||||
$invite_id = (int)($data['invite_id'] ?? 0);
|
||||
|
||||
if (!$invite_id) {
|
||||
RestApi::response(['success' => false, 'errors' => ['data' => 'Укажите invite_id']], 400);
|
||||
}
|
||||
|
||||
$result = ProjectInvite::accept($invite_id, $current_user_id);
|
||||
RestApi::response($result, $result['success'] ? 200 : 400);
|
||||
}
|
||||
|
||||
// Отклонить приглашение
|
||||
if ($action === 'decline') {
|
||||
$invite_id = (int)($data['invite_id'] ?? 0);
|
||||
|
||||
if (!$invite_id) {
|
||||
RestApi::response(['success' => false, 'errors' => ['data' => 'Укажите invite_id']], 400);
|
||||
}
|
||||
|
||||
$result = ProjectInvite::decline($invite_id, $current_user_id);
|
||||
RestApi::response($result, $result['success'] ? 200 : 400);
|
||||
}
|
||||
|
||||
// Отменить приглашение (только админ)
|
||||
if ($action === 'cancel') {
|
||||
$invite_id = (int)($data['invite_id'] ?? 0);
|
||||
$project_id = (int)($data['project_id'] ?? 0);
|
||||
|
||||
if (!$invite_id || !$project_id) {
|
||||
RestApi::response(['success' => false, 'errors' => ['data' => 'Укажите invite_id и project_id']], 400);
|
||||
}
|
||||
|
||||
// Только админ может отменять
|
||||
ProjectAccess::requireAdmin($project_id, $current_user_id);
|
||||
|
||||
$result = ProjectInvite::cancel($invite_id, $project_id);
|
||||
RestApi::response($result, $result['success'] ? 200 : 400);
|
||||
}
|
||||
|
||||
// Метод не указан
|
||||
if (!$action) {
|
||||
RestApi::response(['success' => false, 'error' => 'Укажите action'], 400);
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
@@ -5,6 +5,7 @@ $method = $_SERVER['REQUEST_METHOD'];
|
||||
if ($method === 'POST') {
|
||||
$data = RestApi::getInput();
|
||||
$action = $data['action'] ?? null;
|
||||
$user_id = RestApi::getCurrentUserId();
|
||||
$task = new Task();
|
||||
|
||||
// Загрузка изображения
|
||||
@@ -13,15 +14,23 @@ if ($method === 'POST') {
|
||||
$file_base64 = $data['file_data'] ?? null;
|
||||
$file_name = $data['file_name'] ?? null;
|
||||
|
||||
// Проверяем право на редактирование задачи
|
||||
$taskData = Task::check_task($task_id);
|
||||
ProjectAccess::requireEditTask($taskData['id_project'], $user_id, (int)($taskData['id_account'] ?? 0), (int)($taskData['create_id_account'] ?? 0));
|
||||
|
||||
$result = Task::uploadFile($task_id, $file_base64, $file_name);
|
||||
RestApi::response($result);
|
||||
}
|
||||
|
||||
// Удаление изображений (принимает file_names массив или file_name строку)
|
||||
// Удаление изображений
|
||||
if ($action === 'delete_image') {
|
||||
$task_id = $data['task_id'] ?? null;
|
||||
$file_names = $data['file_names'] ?? $data['file_name'] ?? null;
|
||||
|
||||
// Проверяем право на редактирование задачи
|
||||
$taskData = Task::check_task($task_id);
|
||||
ProjectAccess::requireEditTask($taskData['id_project'], $user_id, (int)($taskData['id_account'] ?? 0), (int)($taskData['create_id_account'] ?? 0));
|
||||
|
||||
$result = Task::deleteFile($task_id, $file_names);
|
||||
RestApi::response($result);
|
||||
}
|
||||
@@ -32,6 +41,10 @@ if ($method === 'POST') {
|
||||
$column_id = $data['column_id'] ?? null;
|
||||
$to_index = $data['to_index'] ?? 0;
|
||||
|
||||
// Проверяем право на перемещение (с учётом move_own_task_only)
|
||||
$taskData = Task::check_task($id);
|
||||
ProjectAccess::requireMoveTask($taskData['id_project'], $user_id, (int)($taskData['id_account'] ?? 0), (int)($taskData['create_id_account'] ?? 0));
|
||||
|
||||
$result = Task::updateOrder($id, $column_id, $to_index);
|
||||
RestApi::response($result);
|
||||
}
|
||||
@@ -39,6 +52,11 @@ if ($method === 'POST') {
|
||||
// Обновление задачи
|
||||
if ($action === 'update') {
|
||||
$task->id = $data['id'] ?? null;
|
||||
|
||||
// Проверяем право на редактирование
|
||||
$taskData = Task::check_task($task->id);
|
||||
ProjectAccess::requireEditTask($taskData['id_project'], $user_id, (int)($taskData['id_account'] ?? 0), (int)($taskData['create_id_account'] ?? 0));
|
||||
|
||||
$task->id_department = $data['id_department'] ?? null;
|
||||
$task->id_label = $data['id_label'] ?? null;
|
||||
$task->id_account = $data['id_account'] ?? null;
|
||||
@@ -55,10 +73,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;
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
540
backend/app/class/enity/class_projectAccess.php
Normal file
540
backend/app/class/enity/class_projectAccess.php
Normal file
@@ -0,0 +1,540 @@
|
||||
<?php
|
||||
|
||||
class ProjectAccess {
|
||||
|
||||
// Список всех доступных прав
|
||||
const PERMISSIONS = [
|
||||
'create_task', // создание задач
|
||||
'edit_task', // редактирование любых задач
|
||||
'edit_own_task_only', // редактирование только назначенных на себя
|
||||
'delete_task', // удаление задач
|
||||
'move_task', // перемещение любых задач между колонками
|
||||
'move_own_task_only', // перемещение только назначенных на себя
|
||||
'archive_task', // архивирование задач
|
||||
'create_column', // создание колонок
|
||||
'edit_column', // редактирование колонок
|
||||
'delete_column', // удаление колонок
|
||||
'manage_departments', // управление отделами
|
||||
'remove_members', // удаление участников (приглашать/редактировать могут только админы)
|
||||
'create_comment', // создание комментариев в любых задачах
|
||||
'create_comment_own_task_only', // создание комментариев только в назначенных на себя
|
||||
'delete_own_comments', // удаление своих комментариев
|
||||
'delete_all_comments', // удаление любых комментариев в проекте
|
||||
'upload_files', // загрузка файлов
|
||||
'upload_images' // загрузка картинок
|
||||
];
|
||||
|
||||
// Права по умолчанию для нового участника
|
||||
const DEFAULT_PERMISSIONS = [
|
||||
'create_task' => true, // создание задач
|
||||
'edit_task' => false, // редактирование любых задач
|
||||
'edit_own_task_only' => true, // редактирование только назначенных на себя
|
||||
'delete_task' => false, // удаление задач
|
||||
'move_task' => false, // перемещение любых задач между колонками
|
||||
'move_own_task_only' => true, // перемещение только назначенных на себя
|
||||
'archive_task' => true, // архивирование задач
|
||||
'create_column' => false, // создание колонок
|
||||
'edit_column' => false, // редактирование колонок
|
||||
'delete_column' => false, // удаление колонок
|
||||
'manage_departments' => false, // управление отделами
|
||||
'remove_members' => false, // удаление участников
|
||||
'create_comment' => false, // создание комментариев в любых задачах
|
||||
'create_comment_own_task_only' => true, // создание комментариев только в назначенных на себя
|
||||
'delete_own_comments' => true, // удаление своих комментариев
|
||||
'delete_all_comments' => false, // удаление любых комментариев
|
||||
'upload_files' => true, // загрузка файлов
|
||||
'upload_images' => true // загрузка картинок
|
||||
];
|
||||
|
||||
// ==================== ПРОВЕРКИ ДОСТУПА ====================
|
||||
|
||||
// Проверить, является ли пользователь владельцем проекта (в id_admin таблицы projects)
|
||||
public static function isOwner(int $project_id, int $user_id): bool {
|
||||
$project = Database::get('project', ['id_admin'], ['id' => $project_id]);
|
||||
if (!$project || !$project['id_admin']) {
|
||||
return false;
|
||||
}
|
||||
$owners = json_decode($project['id_admin'], true);
|
||||
return is_array($owners) && in_array($user_id, $owners);
|
||||
}
|
||||
|
||||
// Проверить, является ли пользователь участником проекта
|
||||
public static function isMember(int $project_id, int $user_id): bool {
|
||||
// Владелец всегда имеет доступ
|
||||
if (self::isOwner($project_id, $user_id)) {
|
||||
return true;
|
||||
}
|
||||
return Database::has('project_members', [
|
||||
'id_project' => $project_id,
|
||||
'id_user' => $user_id
|
||||
]);
|
||||
}
|
||||
|
||||
// Проверить, является ли пользователь админом проекта
|
||||
public static function isAdmin(int $project_id, int $user_id): bool {
|
||||
// Владелец = безоговорочный админ
|
||||
if (self::isOwner($project_id, $user_id)) {
|
||||
return true;
|
||||
}
|
||||
return Database::has('project_members', [
|
||||
'id_project' => $project_id,
|
||||
'id_user' => $user_id,
|
||||
'is_admin' => 1
|
||||
]);
|
||||
}
|
||||
|
||||
// Проверить конкретное право (владелец/админ имеет все права)
|
||||
public static function can(int $project_id, int $user_id, string $permission): bool {
|
||||
// Владелец может всё
|
||||
if (self::isOwner($project_id, $user_id)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$member = Database::get('project_members', ['is_admin', 'permissions'], [
|
||||
'id_project' => $project_id,
|
||||
'id_user' => $user_id
|
||||
]);
|
||||
|
||||
if (!$member) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Админ может всё
|
||||
if ((int)$member['is_admin'] === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$permissions = $member['permissions'] ? json_decode($member['permissions'], true) : [];
|
||||
return isset($permissions[$permission]) && $permissions[$permission] === true;
|
||||
}
|
||||
|
||||
// Проверить право на редактирование задачи (с учётом edit_own_task_only)
|
||||
// "Своя задача" = назначен на пользователя ИЛИ создана пользователем
|
||||
public static function canEditTask(int $project_id, int $user_id, ?int $task_assignee_id, ?int $task_creator_id = null): bool {
|
||||
if (self::isAdmin($project_id, $user_id)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (self::can($project_id, $user_id, 'edit_task')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Создатель + право создания → может редактировать свои созданные
|
||||
if ($task_creator_id === $user_id && self::can($project_id, $user_id, 'create_task')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Назначена на себя + право edit_own_task_only
|
||||
if ($task_assignee_id === $user_id && self::can($project_id, $user_id, 'edit_own_task_only')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Проверить право на перемещение задачи (с учётом move_own_task_only)
|
||||
// "Своя задача" = назначен на пользователя ИЛИ создана пользователем
|
||||
public static function canMoveTask(int $project_id, int $user_id, ?int $task_assignee_id, ?int $task_creator_id = null): bool {
|
||||
if (self::isAdmin($project_id, $user_id)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (self::can($project_id, $user_id, 'move_task')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Создатель + право создания → может перемещать свои созданные
|
||||
if ($task_creator_id === $user_id && self::can($project_id, $user_id, 'create_task')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Назначена на себя + право move_own_task_only
|
||||
if ($task_assignee_id === $user_id && self::can($project_id, $user_id, 'move_own_task_only')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Проверить право на создание комментария в задаче
|
||||
public static function canCreateComment(int $project_id, int $user_id, ?int $task_assignee_id, ?int $task_creator_id = null): bool {
|
||||
if (self::isAdmin($project_id, $user_id)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Полное право — комментировать любые задачи
|
||||
if (self::can($project_id, $user_id, 'create_comment')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Создатель + право создания → может комментировать свои созданные
|
||||
if ($task_creator_id === $user_id && self::can($project_id, $user_id, 'create_task')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Назначена на себя + право create_comment_own_task_only
|
||||
if ($task_assignee_id === $user_id && self::can($project_id, $user_id, 'create_comment_own_task_only')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ==================== ПРОВЕРКИ С EXIT ====================
|
||||
|
||||
// Требовать доступ к проекту (при отказе — 403 и exit)
|
||||
public static function requireAccess(int $project_id, ?int $user_id): void {
|
||||
if (!$user_id) {
|
||||
RestApi::response(['success' => false, 'errors' => ['auth' => 'Требуется авторизация']], 401);
|
||||
}
|
||||
|
||||
if (!self::isMember($project_id, $user_id)) {
|
||||
RestApi::response(['success' => false, 'errors' => ['access' => 'Нет доступа к проекту']], 403);
|
||||
}
|
||||
}
|
||||
|
||||
// Требовать права админа
|
||||
public static function requireAdmin(int $project_id, ?int $user_id): void {
|
||||
if (!$user_id) {
|
||||
RestApi::response(['success' => false, 'errors' => ['auth' => 'Требуется авторизация']], 401);
|
||||
}
|
||||
|
||||
if (!self::isAdmin($project_id, $user_id)) {
|
||||
RestApi::response(['success' => false, 'errors' => ['access' => 'Требуются права администратора']], 403);
|
||||
}
|
||||
}
|
||||
|
||||
// Требовать конкретное право
|
||||
public static function requirePermission(int $project_id, ?int $user_id, string $permission): void {
|
||||
if (!$user_id) {
|
||||
RestApi::response(['success' => false, 'errors' => ['auth' => 'Требуется авторизация']], 401);
|
||||
}
|
||||
|
||||
if (!self::can($project_id, $user_id, $permission)) {
|
||||
RestApi::response(['success' => false, 'errors' => ['access' => 'Недостаточно прав']], 403);
|
||||
}
|
||||
}
|
||||
|
||||
// Требовать право на редактирование задачи
|
||||
public static function requireEditTask(int $project_id, ?int $user_id, ?int $task_assignee_id, ?int $task_creator_id = null): void {
|
||||
if (!$user_id) {
|
||||
RestApi::response(['success' => false, 'errors' => ['auth' => 'Требуется авторизация']], 401);
|
||||
}
|
||||
|
||||
if (!self::canEditTask($project_id, $user_id, $task_assignee_id, $task_creator_id)) {
|
||||
RestApi::response(['success' => false, 'errors' => ['access' => 'Нет прав на редактирование задачи']], 403);
|
||||
}
|
||||
}
|
||||
|
||||
// Требовать право на перемещение задачи
|
||||
public static function requireMoveTask(int $project_id, ?int $user_id, ?int $task_assignee_id, ?int $task_creator_id = null): void {
|
||||
if (!$user_id) {
|
||||
RestApi::response(['success' => false, 'errors' => ['auth' => 'Требуется авторизация']], 401);
|
||||
}
|
||||
|
||||
if (!self::canMoveTask($project_id, $user_id, $task_assignee_id, $task_creator_id)) {
|
||||
RestApi::response(['success' => false, 'errors' => ['access' => 'Нет прав на перемещение задачи']], 403);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== УПРАВЛЕНИЕ УЧАСТНИКАМИ ====================
|
||||
|
||||
// Добавить участника в проект
|
||||
public static function addMember(int $project_id, int $user_id, int $current_user_id, bool $is_admin = false, ?array $permissions = null): array {
|
||||
// Нельзя пригласить себя
|
||||
if ($user_id === $current_user_id) {
|
||||
return ['success' => false, 'errors' => ['member' => 'Нельзя пригласить себя в проект']];
|
||||
}
|
||||
|
||||
if (self::isMember($project_id, $user_id)) {
|
||||
return ['success' => false, 'errors' => ['member' => 'Пользователь уже участник проекта']];
|
||||
}
|
||||
|
||||
$perms = $permissions ?? self::DEFAULT_PERMISSIONS;
|
||||
|
||||
Database::insert('project_members', [
|
||||
'id_project' => $project_id,
|
||||
'id_user' => $user_id,
|
||||
'is_admin' => $is_admin ? 1 : 0,
|
||||
'permissions' => json_encode($perms),
|
||||
'created_at' => date('Y-m-d H:i:s')
|
||||
]);
|
||||
|
||||
return ['success' => true, 'id' => Database::id()];
|
||||
}
|
||||
|
||||
// Удалить участника из проекта (владельца удалить нельзя, админ не может удалить себя)
|
||||
public static function removeMember(int $project_id, int $user_id, int $current_user_id): array {
|
||||
// Владельца удалить нельзя
|
||||
if (self::isOwner($project_id, $user_id)) {
|
||||
return ['success' => false, 'errors' => ['member' => 'Нельзя удалить владельца проекта']];
|
||||
}
|
||||
|
||||
$member = Database::get('project_members', ['is_admin'], [
|
||||
'id_project' => $project_id,
|
||||
'id_user' => $user_id
|
||||
]);
|
||||
|
||||
if (!$member) {
|
||||
return ['success' => false, 'errors' => ['member' => 'Участник не найден']];
|
||||
}
|
||||
|
||||
// Админ может выйти сам, если есть другие админы или владелец
|
||||
// (владелец всегда есть в id_admin проекта, так что это безопасно)
|
||||
|
||||
Database::delete('project_members', [
|
||||
'id_project' => $project_id,
|
||||
'id_user' => $user_id
|
||||
]);
|
||||
|
||||
return ['success' => true];
|
||||
}
|
||||
|
||||
// Получить всех участников проекта (включая владельцев из id_admin)
|
||||
public static function getMembers(int $project_id): array {
|
||||
$result = [];
|
||||
$addedUserIds = [];
|
||||
|
||||
// Получаем владельцев из id_admin проекта
|
||||
$project = Database::get('project', ['id_admin'], ['id' => $project_id]);
|
||||
if ($project && $project['id_admin']) {
|
||||
$ownerIds = json_decode($project['id_admin'], true);
|
||||
if (is_array($ownerIds) && count($ownerIds) > 0) {
|
||||
$owners = Database::select('accounts', ['id', 'name', 'username', 'avatar_url', 'telegram'], ['id' => $ownerIds]);
|
||||
foreach ($owners as $owner) {
|
||||
$result[] = [
|
||||
'id' => null,
|
||||
'id_user' => (int)$owner['id'],
|
||||
'is_admin' => true,
|
||||
'is_owner' => true,
|
||||
'permissions' => [],
|
||||
'created_at' => null,
|
||||
'name' => $owner['name'],
|
||||
'username' => $owner['username'],
|
||||
'avatar_url' => $owner['avatar_url'],
|
||||
'telegram' => $owner['telegram']
|
||||
];
|
||||
$addedUserIds[] = (int)$owner['id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Получаем участников из project_members
|
||||
$members = Database::select('project_members', [
|
||||
'[>]accounts' => ['id_user' => 'id']
|
||||
], [
|
||||
'project_members.id',
|
||||
'project_members.id_user',
|
||||
'project_members.is_admin',
|
||||
'project_members.permissions',
|
||||
'project_members.created_at',
|
||||
'accounts.name',
|
||||
'accounts.username',
|
||||
'accounts.avatar_url',
|
||||
'accounts.telegram'
|
||||
], [
|
||||
'project_members.id_project' => $project_id
|
||||
]);
|
||||
|
||||
foreach ($members as $member) {
|
||||
$userId = (int)$member['id_user'];
|
||||
// Пропускаем если уже добавлен как владелец
|
||||
if (in_array($userId, $addedUserIds)) {
|
||||
continue;
|
||||
}
|
||||
$result[] = [
|
||||
'id' => $member['id'],
|
||||
'id_user' => $userId,
|
||||
'is_admin' => (int)$member['is_admin'] === 1,
|
||||
'is_owner' => false,
|
||||
'permissions' => $member['permissions'] ? json_decode($member['permissions'], true) : [],
|
||||
'created_at' => $member['created_at'],
|
||||
'name' => $member['name'],
|
||||
'username' => $member['username'],
|
||||
'avatar_url' => $member['avatar_url'],
|
||||
'telegram' => $member['telegram']
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Получить ID всех проектов пользователя (включая где он владелец)
|
||||
public static function getUserProjectIds(int $user_id): array {
|
||||
$projectIds = [];
|
||||
|
||||
// Проекты из project_members
|
||||
$rows = Database::select('project_members', ['id_project'], [
|
||||
'id_user' => $user_id
|
||||
]);
|
||||
foreach ($rows as $row) {
|
||||
$projectIds[] = (int)$row['id_project'];
|
||||
}
|
||||
|
||||
// Проекты где пользователь в id_admin
|
||||
$allProjects = Database::select('project', ['id', 'id_admin']);
|
||||
foreach ($allProjects as $project) {
|
||||
if ($project['id_admin']) {
|
||||
$owners = json_decode($project['id_admin'], true);
|
||||
if (is_array($owners) && in_array($user_id, $owners)) {
|
||||
$pid = (int)$project['id'];
|
||||
if (!in_array($pid, $projectIds)) {
|
||||
$projectIds[] = $pid;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $projectIds;
|
||||
}
|
||||
|
||||
// ==================== УПРАВЛЕНИЕ ПРАВАМИ ====================
|
||||
|
||||
// Установить конкретное право
|
||||
public static function setPermission(int $project_id, int $user_id, string $permission, bool $value, int $current_user_id): array {
|
||||
// Нельзя редактировать свои права
|
||||
if ($user_id === $current_user_id) {
|
||||
return ['success' => false, 'errors' => ['member' => 'Нельзя изменять свои права']];
|
||||
}
|
||||
|
||||
if (!in_array($permission, self::PERMISSIONS)) {
|
||||
return ['success' => false, 'errors' => ['permission' => 'Неизвестное право: ' . $permission]];
|
||||
}
|
||||
|
||||
$member = Database::get('project_members', ['permissions'], [
|
||||
'id_project' => $project_id,
|
||||
'id_user' => $user_id
|
||||
]);
|
||||
|
||||
if (!$member) {
|
||||
return ['success' => false, 'errors' => ['member' => 'Участник не найден']];
|
||||
}
|
||||
|
||||
$permissions = $member['permissions'] ? json_decode($member['permissions'], true) : [];
|
||||
$permissions[$permission] = $value;
|
||||
|
||||
Database::update('project_members', [
|
||||
'permissions' => json_encode($permissions)
|
||||
], [
|
||||
'id_project' => $project_id,
|
||||
'id_user' => $user_id
|
||||
]);
|
||||
|
||||
return ['success' => true, 'permissions' => $permissions];
|
||||
}
|
||||
|
||||
// Установить несколько прав сразу
|
||||
public static function setPermissions(int $project_id, int $user_id, array $permissions, int $current_user_id): array {
|
||||
// Нельзя редактировать свои права
|
||||
if ($user_id === $current_user_id) {
|
||||
return ['success' => false, 'errors' => ['member' => 'Нельзя изменять свои права']];
|
||||
}
|
||||
|
||||
// Нельзя редактировать права владельца
|
||||
if (self::isOwner($project_id, $user_id)) {
|
||||
return ['success' => false, 'errors' => ['member' => 'Нельзя изменять права владельца проекта']];
|
||||
}
|
||||
|
||||
foreach (array_keys($permissions) as $perm) {
|
||||
if (!in_array($perm, self::PERMISSIONS)) {
|
||||
return ['success' => false, 'errors' => ['permission' => 'Неизвестное право: ' . $perm]];
|
||||
}
|
||||
}
|
||||
|
||||
$member = Database::get('project_members', ['permissions'], [
|
||||
'id_project' => $project_id,
|
||||
'id_user' => $user_id
|
||||
]);
|
||||
|
||||
if (!$member) {
|
||||
return ['success' => false, 'errors' => ['member' => 'Участник не найден']];
|
||||
}
|
||||
|
||||
$currentPerms = $member['permissions'] ? json_decode($member['permissions'], true) : [];
|
||||
$mergedPerms = array_merge($currentPerms, $permissions);
|
||||
|
||||
Database::update('project_members', [
|
||||
'permissions' => json_encode($mergedPerms)
|
||||
], [
|
||||
'id_project' => $project_id,
|
||||
'id_user' => $user_id
|
||||
]);
|
||||
|
||||
return ['success' => true, 'permissions' => $mergedPerms];
|
||||
}
|
||||
|
||||
// Получить права участника
|
||||
public static function getPermissions(int $project_id, int $user_id): ?array {
|
||||
// Владелец имеет все права
|
||||
if (self::isOwner($project_id, $user_id)) {
|
||||
$allPerms = [];
|
||||
foreach (self::PERMISSIONS as $perm) {
|
||||
$allPerms[$perm] = true;
|
||||
}
|
||||
return $allPerms;
|
||||
}
|
||||
|
||||
$member = Database::get('project_members', ['is_admin', 'permissions'], [
|
||||
'id_project' => $project_id,
|
||||
'id_user' => $user_id
|
||||
]);
|
||||
|
||||
if (!$member) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Админ имеет все права
|
||||
if ((int)$member['is_admin'] === 1) {
|
||||
$allPerms = [];
|
||||
foreach (self::PERMISSIONS as $perm) {
|
||||
$allPerms[$perm] = true;
|
||||
}
|
||||
return $allPerms;
|
||||
}
|
||||
|
||||
return $member['permissions'] ? json_decode($member['permissions'], true) : [];
|
||||
}
|
||||
|
||||
// Назначить/снять админа
|
||||
public static function setAdmin(int $project_id, int $user_id, bool $is_admin, int $current_user_id): array {
|
||||
// Нельзя редактировать свои права
|
||||
if ($user_id === $current_user_id) {
|
||||
return ['success' => false, 'errors' => ['member' => 'Нельзя изменять свои права']];
|
||||
}
|
||||
|
||||
// Нельзя изменять статус владельца
|
||||
if (self::isOwner($project_id, $user_id)) {
|
||||
return ['success' => false, 'errors' => ['member' => 'Нельзя изменять статус владельца проекта']];
|
||||
}
|
||||
|
||||
$member = Database::get('project_members', ['id'], [
|
||||
'id_project' => $project_id,
|
||||
'id_user' => $user_id
|
||||
]);
|
||||
|
||||
if (!$member) {
|
||||
return ['success' => false, 'errors' => ['member' => 'Участник не найден']];
|
||||
}
|
||||
|
||||
Database::update('project_members', [
|
||||
'is_admin' => $is_admin ? 1 : 0
|
||||
], [
|
||||
'id_project' => $project_id,
|
||||
'id_user' => $user_id
|
||||
]);
|
||||
|
||||
return ['success' => true];
|
||||
}
|
||||
|
||||
// Получить список всех доступных прав (для UI)
|
||||
public static function getAvailablePermissions(): array {
|
||||
return self::PERMISSIONS;
|
||||
}
|
||||
|
||||
// Получить дефолтные права (для UI)
|
||||
public static function getDefaultPermissions(): array {
|
||||
return self::DEFAULT_PERMISSIONS;
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
237
backend/app/class/enity/class_projectInvite.php
Normal file
237
backend/app/class/enity/class_projectInvite.php
Normal file
@@ -0,0 +1,237 @@
|
||||
<?php
|
||||
|
||||
class ProjectInvite {
|
||||
|
||||
const STATUS_PENDING = 'pending';
|
||||
const STATUS_ACCEPTED = 'accepted';
|
||||
const STATUS_DECLINED = 'declined';
|
||||
|
||||
// ==================== СОЗДАНИЕ ПРИГЛАШЕНИЯ ====================
|
||||
|
||||
/**
|
||||
* Создать приглашение в проект
|
||||
*/
|
||||
public static function create(int $project_id, int $to_user_id, int $from_user_id, bool $is_admin = false, ?array $permissions = null): array {
|
||||
// Нельзя пригласить себя
|
||||
if ($to_user_id === $from_user_id) {
|
||||
return ['success' => false, 'errors' => ['invite' => 'Нельзя пригласить себя в проект']];
|
||||
}
|
||||
|
||||
// Проверяем, что пользователь существует
|
||||
$user = Database::get('accounts', ['id'], ['id' => $to_user_id]);
|
||||
if (!$user) {
|
||||
return ['success' => false, 'errors' => ['invite' => 'Пользователь не найден']];
|
||||
}
|
||||
|
||||
// Проверяем, что пользователь ещё не участник проекта
|
||||
if (ProjectAccess::isMember($project_id, $to_user_id)) {
|
||||
return ['success' => false, 'errors' => ['invite' => 'Пользователь уже участник проекта']];
|
||||
}
|
||||
|
||||
// Проверяем, нет ли уже pending-приглашения
|
||||
if (self::hasPending($project_id, $to_user_id)) {
|
||||
return ['success' => false, 'errors' => ['invite' => 'Пользователь уже приглашён, ожидается ответ']];
|
||||
}
|
||||
|
||||
// Права по умолчанию
|
||||
$perms = $permissions ?? ProjectAccess::DEFAULT_PERMISSIONS;
|
||||
|
||||
// Сохраняем is_admin в permissions для передачи при accept
|
||||
$inviteData = [
|
||||
'is_admin' => $is_admin,
|
||||
'permissions' => $perms
|
||||
];
|
||||
|
||||
Database::insert('project_invites', [
|
||||
'id_project' => $project_id,
|
||||
'id_from_user' => $from_user_id,
|
||||
'id_to_user' => $to_user_id,
|
||||
'status' => self::STATUS_PENDING,
|
||||
'permissions' => json_encode($inviteData),
|
||||
'created_at' => date('Y-m-d H:i:s')
|
||||
]);
|
||||
|
||||
return ['success' => true, 'id' => Database::id()];
|
||||
}
|
||||
|
||||
// ==================== ПРОВЕРКИ ====================
|
||||
|
||||
/**
|
||||
* Есть ли pending-приглашение для пользователя в проект
|
||||
*/
|
||||
public static function hasPending(int $project_id, int $to_user_id): bool {
|
||||
return Database::has('project_invites', [
|
||||
'id_project' => $project_id,
|
||||
'id_to_user' => $to_user_id,
|
||||
'status' => self::STATUS_PENDING
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить количество pending-приглашений для пользователя
|
||||
*/
|
||||
public static function getPendingCount(int $user_id): int {
|
||||
return Database::count('project_invites', [
|
||||
'id_to_user' => $user_id,
|
||||
'status' => self::STATUS_PENDING
|
||||
]);
|
||||
}
|
||||
|
||||
// ==================== ПОЛУЧЕНИЕ ПРИГЛАШЕНИЙ ====================
|
||||
|
||||
/**
|
||||
* Получить все pending-приглашения для пользователя (входящие)
|
||||
*/
|
||||
public static function getMyPending(int $user_id): array {
|
||||
$invites = Database::select('project_invites', [
|
||||
'[>]project' => ['id_project' => 'id'],
|
||||
'[>]accounts' => ['id_from_user' => 'id']
|
||||
], [
|
||||
'project_invites.id',
|
||||
'project_invites.id_project',
|
||||
'project_invites.created_at',
|
||||
'project_invites.permissions',
|
||||
'project.name(project_name)',
|
||||
'accounts.name(from_user_name)',
|
||||
'accounts.username(from_user_username)',
|
||||
'accounts.avatar_url(from_user_avatar)'
|
||||
], [
|
||||
'project_invites.id_to_user' => $user_id,
|
||||
'project_invites.status' => self::STATUS_PENDING,
|
||||
'ORDER' => ['project_invites.created_at' => 'DESC']
|
||||
]);
|
||||
|
||||
return array_map(function($invite) {
|
||||
$permData = $invite['permissions'] ? json_decode($invite['permissions'], true) : [];
|
||||
return [
|
||||
'id' => (int)$invite['id'],
|
||||
'project_id' => (int)$invite['id_project'],
|
||||
'project_name' => $invite['project_name'],
|
||||
'from_user' => [
|
||||
'name' => $invite['from_user_name'],
|
||||
'username' => $invite['from_user_username'],
|
||||
'avatar_url' => $invite['from_user_avatar']
|
||||
],
|
||||
'is_admin' => $permData['is_admin'] ?? false,
|
||||
'created_at' => $invite['created_at']
|
||||
];
|
||||
}, $invites);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить pending-приглашения для проекта (для админа)
|
||||
*/
|
||||
public static function getProjectPending(int $project_id): array {
|
||||
$invites = Database::select('project_invites', [
|
||||
'[>]accounts' => ['id_to_user' => 'id']
|
||||
], [
|
||||
'project_invites.id',
|
||||
'project_invites.id_to_user',
|
||||
'project_invites.created_at',
|
||||
'project_invites.permissions',
|
||||
'accounts.name',
|
||||
'accounts.username',
|
||||
'accounts.avatar_url'
|
||||
], [
|
||||
'project_invites.id_project' => $project_id,
|
||||
'project_invites.status' => self::STATUS_PENDING,
|
||||
'ORDER' => ['project_invites.created_at' => 'DESC']
|
||||
]);
|
||||
|
||||
return array_map(function($invite) {
|
||||
$permData = $invite['permissions'] ? json_decode($invite['permissions'], true) : [];
|
||||
return [
|
||||
'id' => (int)$invite['id'],
|
||||
'id_user' => (int)$invite['id_to_user'],
|
||||
'name' => $invite['name'],
|
||||
'username' => $invite['username'],
|
||||
'avatar_url' => $invite['avatar_url'],
|
||||
'is_admin' => $permData['is_admin'] ?? false,
|
||||
'created_at' => $invite['created_at'],
|
||||
'status' => 'pending'
|
||||
];
|
||||
}, $invites);
|
||||
}
|
||||
|
||||
// ==================== ОТВЕТ НА ПРИГЛАШЕНИЕ ====================
|
||||
|
||||
/**
|
||||
* Принять приглашение
|
||||
*/
|
||||
public static function accept(int $invite_id, int $user_id): array {
|
||||
$invite = Database::get('project_invites', '*', [
|
||||
'id' => $invite_id,
|
||||
'id_to_user' => $user_id,
|
||||
'status' => self::STATUS_PENDING
|
||||
]);
|
||||
|
||||
if (!$invite) {
|
||||
return ['success' => false, 'errors' => ['invite' => 'Приглашение не найдено или уже обработано']];
|
||||
}
|
||||
|
||||
$project_id = (int)$invite['id_project'];
|
||||
$permData = $invite['permissions'] ? json_decode($invite['permissions'], true) : [];
|
||||
$is_admin = $permData['is_admin'] ?? false;
|
||||
$permissions = $permData['permissions'] ?? ProjectAccess::DEFAULT_PERMISSIONS;
|
||||
|
||||
// Добавляем в project_members
|
||||
Database::insert('project_members', [
|
||||
'id_project' => $project_id,
|
||||
'id_user' => $user_id,
|
||||
'is_admin' => $is_admin ? 1 : 0,
|
||||
'permissions' => json_encode($permissions),
|
||||
'created_at' => date('Y-m-d H:i:s')
|
||||
]);
|
||||
|
||||
// Обновляем статус приглашения
|
||||
Database::update('project_invites', [
|
||||
'status' => self::STATUS_ACCEPTED,
|
||||
'responded_at' => date('Y-m-d H:i:s')
|
||||
], ['id' => $invite_id]);
|
||||
|
||||
return ['success' => true, 'project_id' => $project_id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Отклонить приглашение
|
||||
*/
|
||||
public static function decline(int $invite_id, int $user_id): array {
|
||||
$invite = Database::get('project_invites', ['id'], [
|
||||
'id' => $invite_id,
|
||||
'id_to_user' => $user_id,
|
||||
'status' => self::STATUS_PENDING
|
||||
]);
|
||||
|
||||
if (!$invite) {
|
||||
return ['success' => false, 'errors' => ['invite' => 'Приглашение не найдено или уже обработано']];
|
||||
}
|
||||
|
||||
Database::update('project_invites', [
|
||||
'status' => self::STATUS_DECLINED,
|
||||
'responded_at' => date('Y-m-d H:i:s')
|
||||
], ['id' => $invite_id]);
|
||||
|
||||
return ['success' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* Отменить приглашение (для админа)
|
||||
*/
|
||||
public static function cancel(int $invite_id, int $project_id): array {
|
||||
$invite = Database::get('project_invites', ['id'], [
|
||||
'id' => $invite_id,
|
||||
'id_project' => $project_id,
|
||||
'status' => self::STATUS_PENDING
|
||||
]);
|
||||
|
||||
if (!$invite) {
|
||||
return ['success' => false, 'errors' => ['invite' => 'Приглашение не найдено']];
|
||||
}
|
||||
|
||||
Database::delete('project_invites', ['id' => $invite_id]);
|
||||
|
||||
return ['success' => true];
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
@@ -14,6 +14,7 @@ class Task extends BaseEntity {
|
||||
public $date;
|
||||
public $date_closed;
|
||||
public $id_account;
|
||||
public $create_id_account; // ID создателя задачи
|
||||
public $title;
|
||||
public $descript;
|
||||
public $descript_full;
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'];
|
||||
|
||||
?>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
5166
front_vue/package-lock.json
generated
5166
front_vue/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -15,5 +15,8 @@
|
||||
"vite": "^7.3.1",
|
||||
"vue": "^3.5.26",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite-plugin-pwa": "^1.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: 768
|
||||
MOBILE_BREAKPOINT: 1400,
|
||||
|
||||
// Автообновление страницы (F5) — интервал в секундах, 0 = отключено
|
||||
AUTO_REFRESH_SECONDS: 500
|
||||
}
|
||||
|
||||
|
||||
BIN
front_vue/public/icon_phone/apple-touch-icon.png
Normal file
BIN
front_vue/public/icon_phone/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
BIN
front_vue/public/icon_phone/pwa-192x192.png
Normal file
BIN
front_vue/public/icon_phone/pwa-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
BIN
front_vue/public/icon_phone/pwa-512x512.png
Normal file
BIN
front_vue/public/icon_phone/pwa-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 684 KiB |
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,15 +38,19 @@
|
||||
>
|
||||
{{ cardDepartment.name_departments }}
|
||||
</span>
|
||||
<span v-if="card.files && card.files.length" class="files-badge" :title="card.files.length + ' файлов'">
|
||||
<i data-lucide="image"></i>
|
||||
<span v-if="card.comments_count" class="comments-indicator" :title="card.comments_count + ' комментариев'">
|
||||
<i data-lucide="message-circle"></i>
|
||||
<span class="indicator-count">{{ card.comments_count }}</span>
|
||||
</span>
|
||||
<span v-if="card.files && card.files.length" class="files-indicator" :title="card.files.length + ' файлов'">
|
||||
<i data-lucide="image-plus"></i>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Даты: создано и выполнено -->
|
||||
<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>
|
||||
|
||||
<!-- Кнопки действий (всегда видны) -->
|
||||
@@ -96,6 +100,10 @@
|
||||
>
|
||||
<i data-lucide="trash-2"></i>
|
||||
</button>
|
||||
<span v-if="card.comments_count" class="comments-indicator" :title="card.comments_count + ' комментариев'">
|
||||
<i data-lucide="message-circle"></i>
|
||||
<span class="indicator-count">{{ card.comments_count }}</span>
|
||||
</span>
|
||||
<span v-if="card.files && card.files.length" class="files-indicator">
|
||||
<i data-lucide="image-plus"></i>
|
||||
</span>
|
||||
@@ -119,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 }}
|
||||
@@ -130,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
|
||||
})
|
||||
|
||||
// Цвет лейбла для акцента
|
||||
@@ -181,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>
|
||||
@@ -299,7 +264,8 @@ const closedDateText = computed(() => {
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
.files-badge {
|
||||
.comments-indicator,
|
||||
.files-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
@@ -307,11 +273,18 @@ const closedDateText = computed(() => {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.files-badge i {
|
||||
.comments-indicator i,
|
||||
.comments-indicator svg,
|
||||
.files-indicator i,
|
||||
.files-indicator svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.indicator-count {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Аватарка */
|
||||
.card-assignee {
|
||||
flex-shrink: 0;
|
||||
@@ -373,19 +346,20 @@ const closedDateText = computed(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
border-radius: 6px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-action i {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
.btn-action i,
|
||||
.btn-action svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.btn-restore:hover {
|
||||
@@ -445,22 +419,32 @@ const closedDateText = computed(() => {
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.archive-card.mobile .btn-action i {
|
||||
.archive-card.mobile .btn-action i,
|
||||
.archive-card.mobile .btn-action svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.archive-card.mobile .comments-indicator,
|
||||
.archive-card.mobile .files-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.archive-card.mobile .files-indicator i {
|
||||
.archive-card.mobile .comments-indicator i,
|
||||
.archive-card.mobile .comments-indicator svg,
|
||||
.archive-card.mobile .files-indicator i,
|
||||
.archive-card.mobile .files-indicator svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.archive-card.mobile .indicator-count {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.archive-card.mobile .assignee {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -35,6 +52,10 @@
|
||||
>
|
||||
<i data-lucide="archive"></i>
|
||||
</button>
|
||||
<span v-if="card.comments_count" class="comments-indicator" :title="card.comments_count + ' комментариев'">
|
||||
<i data-lucide="message-circle"></i>
|
||||
<span class="indicator-count">{{ card.comments_count }}</span>
|
||||
</span>
|
||||
<span v-if="card.files && card.files.length" class="files-indicator" :title="card.files.length + ' изображений'">
|
||||
<i data-lucide="image-plus"></i>
|
||||
</span>
|
||||
@@ -58,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>
|
||||
@@ -71,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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -100,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)
|
||||
@@ -154,7 +179,7 @@ const handleContextMenu = (e) => {
|
||||
|
||||
// Mouse long-press для ПК в мобильном режиме (когда touch события недоступны)
|
||||
const handleMouseDown = (e) => {
|
||||
if (!isMobile.value) return
|
||||
if (!isMobile.value || !props.canMove) return
|
||||
// Только левая кнопка мыши
|
||||
if (e.button !== 0) return
|
||||
|
||||
@@ -186,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())
|
||||
@@ -211,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
|
||||
})
|
||||
|
||||
// Цвет лейбла для фона карточки
|
||||
@@ -225,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 = () => {
|
||||
@@ -301,6 +277,10 @@ const handleArchive = () => {
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
.card.no-move {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
background: var(--bg-card-hover);
|
||||
}
|
||||
@@ -405,17 +385,27 @@ const handleArchive = () => {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.comments-indicator,
|
||||
.files-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.files-indicator i {
|
||||
.comments-indicator i,
|
||||
.comments-indicator svg,
|
||||
.files-indicator i,
|
||||
.files-indicator svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.indicator-count {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.due-date {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
@@ -467,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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
83
front_vue/src/components/DepartmentTags.vue
Normal file
83
front_vue/src/components/DepartmentTags.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
955
front_vue/src/components/MemberPanel.vue
Normal file
955
front_vue/src/components/MemberPanel.vue
Normal file
@@ -0,0 +1,955 @@
|
||||
<template>
|
||||
<SlidePanel
|
||||
:show="show"
|
||||
@close="handleClose"
|
||||
>
|
||||
<template #header>
|
||||
<h2>{{ isEdit ? 'Редактирование участника' : 'Пригласить участника' }}</h2>
|
||||
</template>
|
||||
|
||||
<template #default>
|
||||
<!-- Поиск пользователя (только для нового) -->
|
||||
<div v-if="!isEdit" class="form-section">
|
||||
<label class="form-label">Логин пользователя</label>
|
||||
<TextInput
|
||||
v-model="form.username"
|
||||
placeholder="Введите логин для поиска"
|
||||
/>
|
||||
<p v-if="searching" class="form-hint">Поиск...</p>
|
||||
<p v-else-if="searchError" class="form-error">{{ searchError }}</p>
|
||||
<div v-else-if="foundUser" class="found-user" :class="{ 'is-me': isFoundUserMe, 'is-member': isFoundUserAlreadyMember, 'is-pending': isFoundUserPendingInvite }">
|
||||
<div class="found-user-avatar">
|
||||
<img v-if="foundUser.avatar_url" :src="getFullUrl(foundUser.avatar_url)" :alt="foundUser.name || foundUser.username">
|
||||
<span v-else class="avatar-placeholder">{{ (foundUser.name || foundUser.username || '?')[0] }}</span>
|
||||
</div>
|
||||
<div class="found-user-info">
|
||||
<span v-if="isFoundUserMe" class="found-user-me">Это же вы...</span>
|
||||
<span v-else-if="isFoundUserAlreadyMember" class="found-user-member">Участник уже в этом проекте</span>
|
||||
<span v-else-if="isFoundUserPendingInvite" class="found-user-pending">Пользователь уже приглашён, ожидается ответ</span>
|
||||
<template v-else>
|
||||
<span class="found-user-name">{{ foundUser.name || foundUser.username }}</span>
|
||||
<span v-if="foundUser.name" class="found-user-username">@{{ foundUser.username }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<i v-if="!isFoundUserMe && !isFoundUserAlreadyMember && !isFoundUserPendingInvite" data-lucide="check-circle" class="found-user-check"></i>
|
||||
<i v-else-if="isFoundUserPendingInvite" data-lucide="clock" class="found-user-pending-icon"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Инфо о пользователе (для редактирования) -->
|
||||
<div v-else class="member-info">
|
||||
<div class="member-avatar">
|
||||
<img v-if="member?.avatar_url" :src="getFullUrl(member.avatar_url)" :alt="member.name || member.username">
|
||||
<span v-else class="avatar-placeholder">{{ (member?.name || member?.username || '?')[0] }}</span>
|
||||
</div>
|
||||
<div class="member-details">
|
||||
<h3>{{ member?.name || member?.username || 'Без имени' }}</h3>
|
||||
<span v-if="member?.is_owner" class="member-badge owner">Создатель</span>
|
||||
<span v-else-if="member?.is_admin" class="member-badge admin">Администратор</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Админ чекбокс (не для владельцев) -->
|
||||
<div v-if="!member?.is_owner" class="form-section">
|
||||
<label class="admin-toggle" :class="{ active: form.is_admin }">
|
||||
<div class="admin-toggle-info">
|
||||
<i data-lucide="shield"></i>
|
||||
<div class="admin-toggle-text">
|
||||
<span class="admin-toggle-label">Администратор</span>
|
||||
<span class="admin-toggle-hint">Полный доступ ко всем функциям проекта</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toggle-switch large" :class="{ on: form.is_admin }">
|
||||
<input type="checkbox" v-model="form.is_admin" @click.stop />
|
||||
<span class="toggle-slider"></span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Права (скрыты для админов) -->
|
||||
<div v-if="!form.is_admin && !member?.is_owner" class="form-section permissions-section">
|
||||
<label class="form-label">Права доступа</label>
|
||||
|
||||
<div v-for="group in permissionsGroups" :key="group.title" class="permission-group">
|
||||
<div class="permission-group-title">{{ group.title }}</div>
|
||||
<div class="permission-group-list">
|
||||
<label
|
||||
v-for="perm in group.permissions"
|
||||
:key="perm.key"
|
||||
v-show="!isPermissionHidden(perm)"
|
||||
class="permission-row"
|
||||
:class="{
|
||||
active: form.permissions[perm.key],
|
||||
sub: perm.hiddenBy
|
||||
}"
|
||||
>
|
||||
<i v-if="perm.hiddenBy" data-lucide="corner-down-right" class="permission-branch"></i>
|
||||
<span class="permission-name">{{ perm.label }}</span>
|
||||
<div class="toggle-switch" :class="{ on: form.permissions[perm.key] }">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="form.permissions[perm.key]"
|
||||
@click.stop
|
||||
/>
|
||||
<span class="toggle-slider"></span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<!-- Кнопка удаления (только для редактирования, не для владельцев) -->
|
||||
<button
|
||||
v-if="isEdit && !member?.is_owner"
|
||||
class="btn-delete"
|
||||
@click="confirmRemove"
|
||||
:disabled="saving"
|
||||
>
|
||||
<i data-lucide="trash-2"></i>
|
||||
<span class="btn-delete-text">Удалить из проекта</span>
|
||||
</button>
|
||||
<span v-else></span>
|
||||
|
||||
<ActionButtons
|
||||
:cancelText="'Отмена'"
|
||||
:saveText="isEdit ? 'Сохранить' : 'Пригласить'"
|
||||
:saveIcon="isEdit ? '' : 'send'"
|
||||
:loading="saving"
|
||||
:disabled="!canSave"
|
||||
@cancel="handleClose"
|
||||
@save="handleSave"
|
||||
/>
|
||||
</template>
|
||||
</SlidePanel>
|
||||
|
||||
<!-- Диалог подтверждения удаления -->
|
||||
<ConfirmDialog
|
||||
:show="showRemoveDialog"
|
||||
type="removeMember"
|
||||
:message="removeDialogMessage"
|
||||
:action="doRemoveMember"
|
||||
@confirm="showRemoveDialog = false"
|
||||
@cancel="showRemoveDialog = false"
|
||||
/>
|
||||
|
||||
<!-- Диалог несохранённых изменений -->
|
||||
<ConfirmDialog
|
||||
:show="showUnsavedDialog"
|
||||
type="unsavedChanges"
|
||||
@confirm="saveAndClose"
|
||||
@cancel="showUnsavedDialog = false"
|
||||
@discard="discardAndClose"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUpdated } from 'vue'
|
||||
import SlidePanel from './ui/SlidePanel.vue'
|
||||
import TextInput from './ui/TextInput.vue'
|
||||
import ActionButtons from './ui/ActionButtons.vue'
|
||||
import ConfirmDialog from './ConfirmDialog.vue'
|
||||
import { getFullUrl, usersApi, projectAccessApi, projectInviteApi } from '../api'
|
||||
import { useProjectsStore } from '../stores/projects'
|
||||
import { useToast } from '../composables/useToast'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
member: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'saved'])
|
||||
|
||||
const store = useProjectsStore()
|
||||
|
||||
// Режим редактирования
|
||||
const isEdit = computed(() => !!props.member)
|
||||
|
||||
// Форма
|
||||
const form = ref({
|
||||
username: '',
|
||||
is_admin: false,
|
||||
permissions: {}
|
||||
})
|
||||
|
||||
// Состояния
|
||||
const saving = ref(false)
|
||||
const searching = ref(false)
|
||||
const searchError = ref('')
|
||||
const foundUser = ref(null)
|
||||
let searchTimeout = null
|
||||
|
||||
// Найденный пользователь — это я?
|
||||
const isFoundUserMe = computed(() =>
|
||||
foundUser.value && Number(foundUser.value.id) === store.currentUserId
|
||||
)
|
||||
|
||||
// Найденный пользователь уже участник проекта?
|
||||
const isFoundUserAlreadyMember = computed(() => {
|
||||
if (!foundUser.value) return false
|
||||
const userId = Number(foundUser.value.id)
|
||||
return store.users.some(u => Number(u.id_user) === userId)
|
||||
})
|
||||
|
||||
// Найденный пользователь уже приглашён (pending)?
|
||||
const isFoundUserPendingInvite = ref(false)
|
||||
|
||||
// Поиск пользователя с debounce
|
||||
const searchUser = async (username) => {
|
||||
if (!username || username.length < 2) {
|
||||
foundUser.value = null
|
||||
searchError.value = ''
|
||||
isFoundUserPendingInvite.value = false
|
||||
return
|
||||
}
|
||||
|
||||
searching.value = true
|
||||
searchError.value = ''
|
||||
isFoundUserPendingInvite.value = false
|
||||
|
||||
try {
|
||||
const result = await usersApi.search(username)
|
||||
if (result.success) {
|
||||
foundUser.value = result.data
|
||||
searchError.value = ''
|
||||
|
||||
// Проверяем, нет ли уже pending-приглашения
|
||||
const userId = Number(result.data.id)
|
||||
if (userId !== store.currentUserId && !store.users.some(u => Number(u.id_user) === userId)) {
|
||||
const pendingCheck = await projectInviteApi.checkPending(store.currentProjectId, userId)
|
||||
isFoundUserPendingInvite.value = pendingCheck.success && pendingCheck.has_pending
|
||||
}
|
||||
} else {
|
||||
foundUser.value = null
|
||||
searchError.value = result.errors?.username || 'Пользователь не найден'
|
||||
}
|
||||
} catch (error) {
|
||||
foundUser.value = null
|
||||
searchError.value = 'Ошибка поиска'
|
||||
} finally {
|
||||
searching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Debounced поиск при вводе
|
||||
watch(() => form.value.username, (newVal) => {
|
||||
if (searchTimeout) clearTimeout(searchTimeout)
|
||||
foundUser.value = null
|
||||
searchError.value = ''
|
||||
isFoundUserPendingInvite.value = false
|
||||
|
||||
if (newVal && newVal.length >= 2) {
|
||||
searchTimeout = setTimeout(() => searchUser(newVal), 400)
|
||||
}
|
||||
})
|
||||
|
||||
// Группы прав с зависимостями
|
||||
// hiddenBy: если указанное право активно — это право скрывается и автоматически включается
|
||||
// Порядок: включённые по умолчанию первыми
|
||||
const permissionsGroups = [
|
||||
{
|
||||
title: 'Задачи',
|
||||
permissions: [
|
||||
{ key: 'create_task', label: 'Создание задач' },
|
||||
{ key: 'archive_task', label: 'Архивирование задач' },
|
||||
{ key: 'edit_task', label: 'Редактирование любых задач' },
|
||||
{ key: 'edit_own_task_only', label: 'Только назначенные на себя', hiddenBy: 'edit_task' },
|
||||
{ key: 'move_task', label: 'Перемещение любых задач' },
|
||||
{ key: 'move_own_task_only', label: 'Только назначенные на себя', hiddenBy: 'move_task' },
|
||||
{ key: 'delete_task', label: 'Удаление задач' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Комментарии',
|
||||
permissions: [
|
||||
{ key: 'create_comment', label: 'Создание в любых задачах' },
|
||||
{ key: 'create_comment_own_task_only', label: 'Только в назначенных на себя', hiddenBy: 'create_comment' },
|
||||
{ key: 'delete_all_comments', label: 'Удаление любых комментариев' },
|
||||
{ key: 'delete_own_comments', label: 'Только своих комментариев', hiddenBy: 'delete_all_comments' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Файлы',
|
||||
permissions: [
|
||||
{ key: 'upload_files', label: 'Загрузка файлов' },
|
||||
{ key: 'upload_images', label: 'Загрузка картинок' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Колонки',
|
||||
permissions: [
|
||||
{ key: 'create_column', label: 'Создание колонок' },
|
||||
{ key: 'edit_column', label: 'Редактирование колонок' },
|
||||
{ key: 'delete_column', label: 'Удаление колонок' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Управление',
|
||||
permissions: [
|
||||
{ key: 'manage_departments', label: 'Управление отделами' },
|
||||
{ key: 'remove_members', label: 'Удаление участников' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
// Проверка, должно ли право быть скрыто
|
||||
const isPermissionHidden = (perm) => {
|
||||
if (!perm.hiddenBy) return false
|
||||
return form.value.permissions[perm.hiddenBy] === true
|
||||
}
|
||||
|
||||
// Права по умолчанию
|
||||
const defaultPermissions = {
|
||||
create_task: true,
|
||||
edit_task: false,
|
||||
edit_own_task_only: true,
|
||||
delete_task: false,
|
||||
move_task: false,
|
||||
move_own_task_only: true,
|
||||
archive_task: true,
|
||||
create_column: false,
|
||||
edit_column: false,
|
||||
delete_column: false,
|
||||
manage_departments: false,
|
||||
remove_members: false,
|
||||
create_comment: false,
|
||||
create_comment_own_task_only: true,
|
||||
delete_own_comments: true,
|
||||
delete_all_comments: false,
|
||||
upload_files: true,
|
||||
upload_images: true
|
||||
}
|
||||
|
||||
// Можно сохранять
|
||||
const canSave = computed(() => {
|
||||
if (isEdit.value) {
|
||||
return !props.member?.is_owner
|
||||
}
|
||||
// Для приглашения нужен найденный пользователь (не я, не уже участник, не pending)
|
||||
return !!foundUser.value && !searching.value && !isFoundUserMe.value && !isFoundUserAlreadyMember.value && !isFoundUserPendingInvite.value
|
||||
})
|
||||
|
||||
// Инициализация формы
|
||||
const initForm = () => {
|
||||
if (props.member) {
|
||||
form.value = {
|
||||
username: '',
|
||||
is_admin: props.member.is_admin || false,
|
||||
permissions: { ...defaultPermissions, ...(props.member.permissions || {}) }
|
||||
}
|
||||
} else {
|
||||
form.value = {
|
||||
username: '',
|
||||
is_admin: false,
|
||||
permissions: { ...defaultPermissions }
|
||||
}
|
||||
}
|
||||
// Сохраняем начальное состояние для проверки изменений
|
||||
initialForm.value = JSON.parse(JSON.stringify(form.value))
|
||||
searchError.value = ''
|
||||
foundUser.value = null
|
||||
isFoundUserPendingInvite.value = false
|
||||
}
|
||||
|
||||
// Следим за открытием панели
|
||||
watch(() => props.show, (newVal) => {
|
||||
if (newVal) {
|
||||
initForm()
|
||||
}
|
||||
})
|
||||
|
||||
// Начальное состояние для проверки изменений
|
||||
const initialForm = ref(null)
|
||||
|
||||
// Проверка есть ли изменения
|
||||
const hasChanges = computed(() => {
|
||||
if (!initialForm.value) return false
|
||||
if (!isEdit.value) {
|
||||
// Для нового — есть изменения если найден пользователь
|
||||
return !!foundUser.value
|
||||
}
|
||||
// Для редактирования — сравниваем с начальным состоянием
|
||||
return JSON.stringify(form.value) !== JSON.stringify(initialForm.value)
|
||||
})
|
||||
|
||||
// Диалоги
|
||||
const showRemoveDialog = ref(false)
|
||||
const removeDialogMessage = ref('')
|
||||
const showUnsavedDialog = ref(false)
|
||||
|
||||
// Закрытие
|
||||
const handleClose = () => {
|
||||
if (hasChanges.value) {
|
||||
showUnsavedDialog.value = true
|
||||
return
|
||||
}
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// Сохранить и закрыть (из диалога unsavedChanges)
|
||||
const saveAndClose = async () => {
|
||||
showUnsavedDialog.value = false
|
||||
await handleSave()
|
||||
}
|
||||
|
||||
// Отменить изменения и закрыть
|
||||
const discardAndClose = () => {
|
||||
showUnsavedDialog.value = false
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// Сохранение
|
||||
const handleSave = async () => {
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
const projectId = store.currentProjectId
|
||||
|
||||
if (isEdit.value) {
|
||||
// Обновление прав участника
|
||||
const result = await projectAccessApi.updateMember(
|
||||
projectId,
|
||||
props.member.id_user,
|
||||
{
|
||||
is_admin: form.value.is_admin,
|
||||
permissions: form.value.permissions
|
||||
}
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
console.error('Error updating member:', result.errors)
|
||||
toast.error('Ошибка сохранения прав')
|
||||
return
|
||||
}
|
||||
|
||||
toast.success('Права участника сохранены')
|
||||
} else {
|
||||
// Отправка приглашения новому участнику
|
||||
if (!foundUser.value) return
|
||||
|
||||
const result = await projectInviteApi.send(
|
||||
projectId,
|
||||
foundUser.value.id,
|
||||
form.value.is_admin,
|
||||
form.value.permissions
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
searchError.value = result.errors?.invite || 'Ошибка при отправке приглашения'
|
||||
toast.error(searchError.value)
|
||||
return
|
||||
}
|
||||
|
||||
toast.success(`Приглашение отправлено ${foundUser.value.name || foundUser.value.username}`)
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
emit('close') // Закрываем напрямую, без проверки hasChanges
|
||||
} catch (error) {
|
||||
console.error('Error saving member:', error)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Открыть диалог удаления
|
||||
const confirmRemove = () => {
|
||||
removeDialogMessage.value = `<b>${props.member?.name || props.member?.username}</b> будет удалён из проекта.`
|
||||
showRemoveDialog.value = true
|
||||
}
|
||||
|
||||
// Выполнить удаление
|
||||
const doRemoveMember = async () => {
|
||||
const result = await projectAccessApi.removeMember(
|
||||
store.currentProjectId,
|
||||
props.member.id_user
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(result.errors?.member || 'Ошибка удаления')
|
||||
throw new Error(result.errors?.member || 'Ошибка удаления')
|
||||
}
|
||||
|
||||
toast.success('Участник удалён из проекта')
|
||||
emit('saved')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// Обновление иконок
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refreshIcons)
|
||||
onUpdated(refreshIcons)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Уменьшаем отступ между первой секцией и админ-чекбоксом */
|
||||
.form-section:first-child {
|
||||
margin-bottom: -8px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
color: #ef4444;
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Найденный пользователь */
|
||||
.found-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: rgba(0, 212, 170, 0.1);
|
||||
border: 1px solid rgba(0, 212, 170, 0.3);
|
||||
border-radius: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.found-user-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.found-user-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.found-user-avatar .avatar-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.found-user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.found-user-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.found-user-username {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.found-user-check {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Найденный пользователь — это я */
|
||||
.found-user.is-me,
|
||||
.found-user.is-member {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.found-user-me,
|
||||
.found-user-member {
|
||||
font-size: 14px;
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
/* Найденный пользователь уже приглашён */
|
||||
.found-user.is-pending {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
border-color: rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
.found-user-pending {
|
||||
font-size: 14px;
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.found-user-pending-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #fbbf24;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Инфо участника */
|
||||
.member-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 12px;
|
||||
margin-bottom: -8px;
|
||||
}
|
||||
|
||||
.member-avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.member-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.member-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.member-details h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.member-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
border-radius: 4px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.member-badge.owner {
|
||||
color: var(--accent);
|
||||
background: rgba(0, 212, 170, 0.15);
|
||||
}
|
||||
|
||||
.member-badge.admin {
|
||||
color: #f59e0b;
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
}
|
||||
|
||||
/* Тогл админа */
|
||||
.admin-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.admin-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.admin-toggle.active {
|
||||
background: rgba(0, 212, 170, 0.1);
|
||||
border-color: rgba(0, 212, 170, 0.3);
|
||||
}
|
||||
|
||||
.admin-toggle-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-toggle-info > i {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.admin-toggle.active .admin-toggle-info > i {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.admin-toggle-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.admin-toggle-label {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.admin-toggle-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Toggle Switch */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.toggle-switch.large {
|
||||
width: 52px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 24px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.toggle-slider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.toggle-switch.large .toggle-slider::before {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.toggle-switch.on .toggle-slider {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.toggle-switch.on .toggle-slider::before {
|
||||
left: calc(100% - 21px);
|
||||
}
|
||||
|
||||
.toggle-switch.large.on .toggle-slider::before {
|
||||
left: calc(100% - 25px);
|
||||
}
|
||||
|
||||
/* Группы прав */
|
||||
.permissions-section {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.permission-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.permission-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.permission-group-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.permission-group-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.permission-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.permission-row:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.permission-row.active {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.permission-row.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -1px;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: rgba(0, 212, 170, 0.5);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.permission-row:not(.sub) + .permission-row:not(.sub) {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
/* Первый элемент без отрицательного top */
|
||||
.permission-row.active:first-child::before {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.permission-row.sub {
|
||||
padding-left: 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.permission-branch {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.4;
|
||||
margin-right: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.permission-row.sub .permission-name {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.permission-name {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.permission-row.active .permission-name {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Кнопка удаления */
|
||||
.btn-delete {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
border-radius: 8px;
|
||||
color: #ef4444;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.btn-delete:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-delete i {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* Мобильная версия: только иконка */
|
||||
@media (max-width: 480px) {
|
||||
.btn-delete {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.btn-delete-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
192
front_vue/src/components/PageLayout.vue
Normal file
192
front_vue/src/components/PageLayout.vue
Normal 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>
|
||||
1445
front_vue/src/components/ProjectPanel.vue
Normal file
1445
front_vue/src/components/ProjectPanel.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -16,15 +16,29 @@
|
||||
<router-link to="/team" class="nav-item" :class="{ active: $route.path === '/team' }" title="Команда">
|
||||
<i data-lucide="users"></i>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="pendingInvitesCount > 0"
|
||||
to="/invites"
|
||||
class="nav-item invites"
|
||||
:class="{ active: $route.path === '/invites' }"
|
||||
title="Приглашения"
|
||||
>
|
||||
<i data-lucide="mail"></i>
|
||||
<span class="invites-badge">{{ pendingInvitesCount }}</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUpdated } from 'vue'
|
||||
import { computed, onMounted, onUpdated } from 'vue'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
import { useProjectsStore } from '../stores/projects'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
const store = useProjectsStore()
|
||||
|
||||
const pendingInvitesCount = computed(() => store.pendingInvitesCount)
|
||||
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
@@ -102,6 +116,27 @@ onUpdated(refreshIcons)
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
/* Приглашения */
|
||||
.nav-item.invites {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.invites-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
/* ========== MOBILE: Нижняя навигация ========== */
|
||||
.sidebar.mobile {
|
||||
top: auto;
|
||||
|
||||
@@ -1,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>
|
||||
@@ -3,7 +3,8 @@
|
||||
class="comment"
|
||||
:class="{
|
||||
'comment--own': isOwn,
|
||||
'comment--reply': level > 0
|
||||
'comment--reply': level > 0,
|
||||
'is-mobile': isMobile
|
||||
}"
|
||||
:style="{ marginLeft: level * 24 + 'px' }"
|
||||
>
|
||||
@@ -14,11 +15,14 @@
|
||||
|
||||
<div class="comment-body">
|
||||
<div class="comment-header">
|
||||
<span class="comment-author">{{ comment.author_name }}</span>
|
||||
<span class="comment-date">{{ formattedDate }}</span>
|
||||
<div class="comment-meta" :class="{ 'is-mobile': isMobile }">
|
||||
<span class="comment-author">{{ comment.author_name }}</span>
|
||||
<span class="comment-date">{{ formattedDate }}</span>
|
||||
</div>
|
||||
|
||||
<div class="comment-actions">
|
||||
<IconButton
|
||||
v-if="canReply"
|
||||
icon="reply"
|
||||
variant="ghost"
|
||||
small
|
||||
@@ -79,9 +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: {
|
||||
@@ -100,6 +111,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
canReply: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
getFullUrl: {
|
||||
type: Function,
|
||||
required: true
|
||||
@@ -125,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>
|
||||
@@ -225,13 +212,17 @@ onUpdated(refreshIcons)
|
||||
|
||||
.comment-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.comment-header .comment-date {
|
||||
margin-right: auto;
|
||||
.comment-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.comment-author {
|
||||
@@ -245,6 +236,13 @@ onUpdated(refreshIcons)
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Mobile: дата под ником (MOBILE_BREAKPOINT из config.js) */
|
||||
.comment-meta.is-mobile {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.comment-text {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
@@ -261,17 +259,6 @@ onUpdated(refreshIcons)
|
||||
.comment-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.comment:hover .comment-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Mobile: иконки видны всегда */
|
||||
:global(body.is-mobile) .comment-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.comment-btn-delete:hover {
|
||||
@@ -364,4 +351,9 @@ onUpdated(refreshIcons)
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
/* Mobile: скрыть кнопку скачивания на миниатюре */
|
||||
.comment.is-mobile .comment-file-download {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
389
front_vue/src/components/TaskPanel/ContentEditorPanel.vue
Normal file
389
front_vue/src/components/TaskPanel/ContentEditorPanel.vue
Normal 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
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
128
front_vue/src/components/ui/ActionButtons.vue
Normal file
128
front_vue/src/components/ui/ActionButtons.vue
Normal 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>
|
||||
448
front_vue/src/components/ui/ColorPicker.vue
Normal file
448
front_vue/src/components/ui/ColorPicker.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
60
front_vue/src/components/ui/Loader.vue
Normal file
60
front_vue/src/components/ui/Loader.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="loader-container" :class="{ 'loader--inline': inline }">
|
||||
<div class="loader-spinner"></div>
|
||||
<span v-if="text" class="loader-text">{{ text }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
text: {
|
||||
type: String,
|
||||
default: 'Загрузка...'
|
||||
},
|
||||
inline: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loader-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 80px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.loader-container.loader--inline {
|
||||
flex-direction: row;
|
||||
padding: 40px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.loader-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.1);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.loader--inline .loader-spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.loader-text {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
131
front_vue/src/components/ui/LogoutButton.vue
Normal file
131
front_vue/src/components/ui/LogoutButton.vue
Normal 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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
271
front_vue/src/components/ui/NotificationCard.vue
Normal file
271
front_vue/src/components/ui/NotificationCard.vue
Normal file
@@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<div
|
||||
class="notification-card"
|
||||
:class="{ processing, [type]: true }"
|
||||
>
|
||||
<div class="notification-info">
|
||||
<!-- Аватар -->
|
||||
<div class="notification-avatar" :style="avatarStyle">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" :alt="title">
|
||||
<span v-else class="avatar-initials">{{ avatarInitial }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Детали -->
|
||||
<div class="notification-details">
|
||||
<span class="notification-title">{{ title }}</span>
|
||||
<span v-if="subtitle" class="notification-subtitle">{{ subtitle }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Бейдж -->
|
||||
<span v-if="badge" class="notification-badge">{{ badge }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Действия -->
|
||||
<div v-if="showActions" class="notification-actions">
|
||||
<button
|
||||
class="btn-action btn-decline"
|
||||
@click="$emit('decline')"
|
||||
:disabled="processing"
|
||||
title="Отклонить"
|
||||
>
|
||||
<i data-lucide="x"></i>
|
||||
</button>
|
||||
<button
|
||||
class="btn-action btn-accept"
|
||||
@click="$emit('accept')"
|
||||
:disabled="processing"
|
||||
title="Принять"
|
||||
>
|
||||
<i data-lucide="check"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useLucideIcons } from '../../composables/useLucideIcons'
|
||||
|
||||
useLucideIcons()
|
||||
|
||||
const props = defineProps({
|
||||
// Основные данные
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
badge: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// Аватар
|
||||
avatarUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
avatarName: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
avatarColor: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// Тип уведомления (для стилизации)
|
||||
type: {
|
||||
type: String,
|
||||
default: 'default', // default, invite, warning, info
|
||||
validator: (v) => ['default', 'invite', 'warning', 'info'].includes(v)
|
||||
},
|
||||
|
||||
// Состояние
|
||||
processing: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showActions: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['accept', 'decline'])
|
||||
|
||||
// Первая буква для аватара
|
||||
const avatarInitial = computed(() => {
|
||||
return (props.avatarName || props.title || '?')[0].toUpperCase()
|
||||
})
|
||||
|
||||
// Стиль аватара
|
||||
const avatarStyle = computed(() => {
|
||||
if (props.avatarColor) {
|
||||
return { background: props.avatarColor }
|
||||
}
|
||||
return {}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notification-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 10px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.notification-card:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.notification-card.processing {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Инфо */
|
||||
.notification-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Аватар */
|
||||
.notification-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: var(--blue, #3b82f6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notification-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-initials {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Детали */
|
||||
.notification-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notification-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.notification-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Бейдж */
|
||||
.notification-badge {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
padding: 3px 6px;
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
color: #f59e0b;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Действия */
|
||||
.notification-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-action i {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.btn-action:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-decline {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn-decline:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.btn-accept {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.btn-accept:hover:not(:disabled) {
|
||||
background: #00e6b8;
|
||||
}
|
||||
|
||||
/* ========== ТИПЫ ========== */
|
||||
/* Invite (зелёный акцент) */
|
||||
.notification-card.invite .btn-accept {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* Warning (оранжевый) */
|
||||
.notification-card.warning .notification-badge {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
/* Info (синий) */
|
||||
.notification-card.info .notification-badge {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #3b82f6;
|
||||
}
|
||||
</style>
|
||||
@@ -31,8 +31,8 @@
|
||||
<!-- Редактируемое поле -->
|
||||
<div
|
||||
class="rich-editor"
|
||||
:class="{ 'is-empty': !modelValue }"
|
||||
contenteditable="true"
|
||||
:class="{ 'is-empty': !modelValue, 'disabled': disabled }"
|
||||
:contenteditable="!disabled"
|
||||
ref="editorRef"
|
||||
@input="onInput"
|
||||
@paste="onPaste"
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,14 +366,15 @@ onUnmounted(() => {
|
||||
|
||||
.panel.mobile .panel-body {
|
||||
padding: 16px;
|
||||
padding-bottom: calc(16px + var(--safe-area-bottom, 0px));
|
||||
min-height: 0; /* Важно для flex overflow */
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
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) {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<template>
|
||||
<div class="tags-select">
|
||||
<div class="tags-select" :class="{ disabled: disabled }">
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
class="tag-option"
|
||||
:class="{ active: isSelected(option.value) }"
|
||||
:style="{ '--tag-color': option.color || defaultColor }"
|
||||
@click="toggleOption(option.value)"
|
||||
:disabled="disabled"
|
||||
@click="!disabled && toggleOption(option.value)"
|
||||
>
|
||||
<span v-if="option.icon" class="tag-icon">{{ option.icon }}</span>
|
||||
{{ option.label }}
|
||||
@@ -38,6 +39,10 @@ const props = defineProps({
|
||||
defaultColor: {
|
||||
type: String,
|
||||
default: 'var(--accent)'
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
@@ -114,6 +119,22 @@ const toggleOption = (value) => {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.tags-select.disabled .tag-option {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.tags-select.disabled .tag-option:hover {
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tags-select.disabled .tag-option.active:hover {
|
||||
background: var(--tag-color);
|
||||
border-color: var(--tag-color);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.tag-icon {
|
||||
font-size: 14px;
|
||||
margin-right: 4px;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<input
|
||||
type="text"
|
||||
class="text-input"
|
||||
:class="{ readonly: readonly }"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
@@ -65,4 +66,14 @@ defineEmits(['update:modelValue', 'focus', 'blur', 'keydown'])
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.text-input.readonly {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.text-input.readonly:focus {
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
</style>
|
||||
|
||||
431
front_vue/src/components/ui/ToastContainer.vue
Normal file
431
front_vue/src/components/ui/ToastContainer.vue
Normal file
@@ -0,0 +1,431 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="toast-container" :class="{ mobile: isMobile }">
|
||||
<TransitionGroup :name="isMobile ? 'toast-mobile' : 'toast'">
|
||||
<div
|
||||
v-for="(toast, index) in toasts"
|
||||
:key="toast.id"
|
||||
class="toast"
|
||||
:class="toast.type"
|
||||
:style="getStackStyle(index)"
|
||||
@click="!isMobile && remove(toast.id)"
|
||||
@touchstart="handleTouchStart($event, toast.id)"
|
||||
@touchmove="handleTouchMove($event, toast.id)"
|
||||
@touchend="handleTouchEnd($event, toast.id)"
|
||||
>
|
||||
<!-- Progress bar (время до автозакрытия) -->
|
||||
<div class="toast-progress" :style="{ '--duration': toast.duration + 'ms' }"></div>
|
||||
|
||||
<!-- Цветной акцент слева -->
|
||||
<div class="toast-accent"></div>
|
||||
|
||||
<div class="toast-content">
|
||||
<div class="toast-icon">
|
||||
<i :data-lucide="iconName(toast.type)"></i>
|
||||
</div>
|
||||
<span class="toast-message">{{ toast.message }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Кнопка закрытия (только десктоп) -->
|
||||
<button v-if="!isMobile" class="toast-close" @click.stop="remove(toast.id)">
|
||||
<i data-lucide="x"></i>
|
||||
</button>
|
||||
|
||||
<!-- Swipe hint для мобильных (на верхнем тосте) -->
|
||||
<div v-if="isMobile && index === toasts.length - 1" class="swipe-hint">
|
||||
<i data-lucide="chevrons-left"></i>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import { useLucideIcons } from '../../composables/useLucideIcons'
|
||||
import { useMobile } from '../../composables/useMobile'
|
||||
|
||||
const { toasts, remove } = useToast()
|
||||
const { refreshIcons } = useLucideIcons()
|
||||
const { isMobile } = useMobile()
|
||||
|
||||
// Swipe tracking
|
||||
const swipeState = ref({})
|
||||
|
||||
const handleTouchStart = (e, id) => {
|
||||
const touch = e.touches[0]
|
||||
swipeState.value[id] = {
|
||||
startX: touch.clientX,
|
||||
startY: touch.clientY,
|
||||
currentX: 0,
|
||||
swiping: false
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchMove = (e, id) => {
|
||||
const state = swipeState.value[id]
|
||||
if (!state) return
|
||||
|
||||
const touch = e.touches[0]
|
||||
const deltaX = touch.clientX - state.startX
|
||||
const deltaY = Math.abs(touch.clientY - state.startY)
|
||||
|
||||
// Определяем направление: горизонтальный свайп влево
|
||||
if (Math.abs(deltaX) > 10 && deltaX < 0 && deltaY < 30) {
|
||||
state.swiping = true
|
||||
state.currentX = Math.max(deltaX, -150) // Ограничиваем свайп
|
||||
|
||||
// Применяем трансформацию (сохраняем центрирование -50%)
|
||||
const el = e.currentTarget
|
||||
el.style.transform = `translateX(calc(-50% + ${state.currentX}px))`
|
||||
el.style.opacity = 1 - Math.abs(state.currentX) / 150
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchEnd = (e, id) => {
|
||||
const state = swipeState.value[id]
|
||||
if (!state) return
|
||||
|
||||
const el = e.currentTarget
|
||||
|
||||
// Если свайпнули достаточно далеко — удаляем
|
||||
if (state.currentX < -80) {
|
||||
// Помечаем как swiped чтобы отключить CSS-анимацию TransitionGroup
|
||||
el.classList.add('swiped')
|
||||
el.style.transform = 'translateX(-150%)'
|
||||
el.style.opacity = '0'
|
||||
// Удаляем сразу, анимация уже произошла через JS
|
||||
setTimeout(() => remove(id), 100)
|
||||
} else {
|
||||
// Возвращаем на место
|
||||
el.style.transition = 'transform 0.2s, opacity 0.2s'
|
||||
el.style.transform = 'translateX(-50%)'
|
||||
el.style.opacity = '1'
|
||||
setTimeout(() => {
|
||||
el.style.transition = ''
|
||||
}, 200)
|
||||
}
|
||||
|
||||
delete swipeState.value[id]
|
||||
}
|
||||
|
||||
// Stack effect для нескольких тостов (мобильная версия)
|
||||
// Новые тосты полностью перекрывают старые, без уменьшения
|
||||
const getStackStyle = (index) => {
|
||||
if (!isMobile.value) return {}
|
||||
|
||||
// Новые тосты (с большим индексом) должны быть сверху
|
||||
return {
|
||||
zIndex: 100 + index
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем иконки при добавлении новых toast
|
||||
watch(() => toasts.value.length, () => {
|
||||
setTimeout(refreshIcons, 10)
|
||||
})
|
||||
|
||||
const iconName = (type) => {
|
||||
switch (type) {
|
||||
case 'success': return 'circle-check'
|
||||
case 'error': return 'circle-x'
|
||||
case 'warning': return 'triangle-alert'
|
||||
case 'info':
|
||||
default: return 'info'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ==================== DESKTOP ==================== */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 100000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
pointer-events: none;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
/* Safe area для iPhone */
|
||||
@supports (padding-top: env(safe-area-inset-top)) {
|
||||
.toast-container:not(.mobile) {
|
||||
top: calc(20px + env(safe-area-inset-top));
|
||||
right: calc(20px + env(safe-area-inset-right));
|
||||
}
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
padding-left: 20px;
|
||||
background: var(--bg-secondary, #1a1a1f);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(12px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Цветной акцент слева */
|
||||
.toast-accent {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
border-radius: 4px 0 0 4px;
|
||||
}
|
||||
|
||||
.toast-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toast-icon i,
|
||||
.toast-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #f4f4f5);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.toast-close {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted, #6b6b70);
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.toast-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toast-close i,
|
||||
.toast-close svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
.toast-progress {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
background: currentColor;
|
||||
opacity: 0.3;
|
||||
animation: progress var(--duration) linear forwards;
|
||||
border-radius: 0 0 0 12px;
|
||||
}
|
||||
|
||||
@keyframes progress {
|
||||
from { width: 100%; }
|
||||
to { width: 0%; }
|
||||
}
|
||||
|
||||
/* Swipe hint (скрыт по умолчанию) */
|
||||
.swipe-hint {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ==================== ТИПЫ УВЕДОМЛЕНИЙ ==================== */
|
||||
.toast.success .toast-accent { background: var(--accent, #00d4aa); }
|
||||
.toast.success .toast-icon { color: var(--accent, #00d4aa); }
|
||||
.toast.success .toast-progress { color: var(--accent, #00d4aa); }
|
||||
|
||||
.toast.error .toast-accent { background: var(--red, #f87171); }
|
||||
.toast.error .toast-icon { color: var(--red, #f87171); }
|
||||
.toast.error .toast-progress { color: var(--red, #f87171); }
|
||||
|
||||
.toast.warning .toast-accent { background: var(--orange, #fbbf24); }
|
||||
.toast.warning .toast-icon { color: var(--orange, #fbbf24); }
|
||||
.toast.warning .toast-progress { color: var(--orange, #fbbf24); }
|
||||
|
||||
.toast.info .toast-accent { background: var(--blue, #60a5fa); }
|
||||
.toast.info .toast-icon { color: var(--blue, #60a5fa); }
|
||||
.toast.info .toast-progress { color: var(--blue, #60a5fa); }
|
||||
|
||||
/* ==================== DESKTOP ANIMATIONS ==================== */
|
||||
.toast-enter-active {
|
||||
animation: toast-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
.toast-leave-active {
|
||||
animation: toast-out 0.2s ease-in forwards;
|
||||
}
|
||||
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toast-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== MOBILE ==================== */
|
||||
.toast-container.mobile {
|
||||
top: auto;
|
||||
bottom: 90px; /* Над навигацией */
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-width: none;
|
||||
gap: 0;
|
||||
min-height: 60px; /* Место для абсолютно спозиционированных тостов */
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@supports (padding-bottom: env(safe-area-inset-bottom)) {
|
||||
.toast-container.mobile {
|
||||
bottom: calc(90px + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
|
||||
.toast-container.mobile .toast {
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
padding: 12px 16px;
|
||||
padding-left: 18px;
|
||||
border-radius: 16px;
|
||||
cursor: default;
|
||||
/* Pill-style с цветным градиентом на фоне */
|
||||
background: linear-gradient(135deg,
|
||||
rgba(30, 30, 35, 0.95) 0%,
|
||||
rgba(25, 25, 30, 0.98) 100%
|
||||
);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow:
|
||||
0 4px 20px rgba(0, 0, 0, 0.3),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
|
||||
}
|
||||
|
||||
/* Акцент на мобильных — градиентная полоска */
|
||||
.toast-container.mobile .toast-accent {
|
||||
width: 3px;
|
||||
background: linear-gradient(180deg, currentColor 0%, transparent 100%);
|
||||
}
|
||||
|
||||
.toast-container.mobile .toast.success .toast-accent { color: var(--accent, #00d4aa); }
|
||||
.toast-container.mobile .toast.error .toast-accent { color: var(--red, #f87171); }
|
||||
.toast-container.mobile .toast.warning .toast-accent { color: var(--orange, #fbbf24); }
|
||||
.toast-container.mobile .toast.info .toast-accent { color: var(--blue, #60a5fa); }
|
||||
|
||||
/* Swipe hint на мобильных */
|
||||
.toast-container.mobile .swipe-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.4;
|
||||
animation: swipe-hint 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.toast-container.mobile .swipe-hint i,
|
||||
.toast-container.mobile .swipe-hint svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
@keyframes swipe-hint {
|
||||
0%, 100% { transform: translateX(0); opacity: 0.4; }
|
||||
50% { transform: translateX(-4px); opacity: 0.7; }
|
||||
}
|
||||
|
||||
/* ==================== MOBILE ANIMATIONS ==================== */
|
||||
.toast-mobile-enter-active {
|
||||
animation: toast-mobile-in 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.toast-mobile-leave-active {
|
||||
animation: toast-mobile-out 0.25s ease-in forwards;
|
||||
}
|
||||
|
||||
/* Отключаем анимацию для элементов удалённых свайпом (уже анимированы через JS) */
|
||||
.toast-mobile-leave-active.swiped {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes toast-mobile-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(100%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toast-mobile-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(-150%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Все тосты на мобильных в одном месте, новые поверх старых */
|
||||
.toast-container.mobile .toast {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
/* Только верхний (последний) тост интерактивен */
|
||||
.toast-container.mobile .toast:not(:last-child) {
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
60
front_vue/src/composables/useAutoRefresh.js
Normal file
60
front_vue/src/composables/useAutoRefresh.js
Normal 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
|
||||
}
|
||||
}
|
||||
166
front_vue/src/composables/useDateFormat.js
Normal file
166
front_vue/src/composables/useDateFormat.js
Normal 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
|
||||
}
|
||||
}
|
||||
48
front_vue/src/composables/useDepartmentFilter.js
Normal file
48
front_vue/src/composables/useDepartmentFilter.js
Normal 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
|
||||
}
|
||||
}
|
||||
55
front_vue/src/composables/useLucideIcons.js
Normal file
55
front_vue/src/composables/useLucideIcons.js
Normal 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 }
|
||||
}
|
||||
79
front_vue/src/composables/useToast.js
Normal file
79
front_vue/src/composables/useToast.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
// Глобальное состояние уведомлений (singleton)
|
||||
const toasts = ref([])
|
||||
let toastId = 0
|
||||
|
||||
/**
|
||||
* Composable для управления toast-уведомлениями
|
||||
*
|
||||
* Использование:
|
||||
* import { useToast } from '@/composables/useToast'
|
||||
* const toast = useToast()
|
||||
* toast.success('Сохранено!')
|
||||
* toast.error('Ошибка!')
|
||||
* toast.info('Информация')
|
||||
* toast.warning('Внимание')
|
||||
*/
|
||||
export function useToast() {
|
||||
/**
|
||||
* Добавить уведомление
|
||||
* @param {string} message - Текст уведомления
|
||||
* @param {'success'|'error'|'info'|'warning'} type - Тип уведомления
|
||||
* @param {number} duration - Время показа в мс (0 = не скрывать автоматически)
|
||||
*/
|
||||
const add = (message, type = 'info', duration = 3000) => {
|
||||
const id = ++toastId
|
||||
|
||||
toasts.value.push({
|
||||
id,
|
||||
message,
|
||||
type,
|
||||
duration, // Для progress bar в UI
|
||||
visible: true
|
||||
})
|
||||
|
||||
// Автоматическое скрытие
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
remove(id)
|
||||
}, duration)
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* Удалить уведомление
|
||||
*/
|
||||
const remove = (id) => {
|
||||
const index = toasts.value.findIndex(t => t.id === id)
|
||||
if (index !== -1) {
|
||||
toasts.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Очистить все уведомления
|
||||
*/
|
||||
const clear = () => {
|
||||
toasts.value = []
|
||||
}
|
||||
|
||||
// Хелперы для разных типов
|
||||
const success = (message, duration = 3000) => add(message, 'success', duration)
|
||||
const error = (message, duration = 4000) => add(message, 'error', duration)
|
||||
const info = (message, duration = 3000) => add(message, 'info', duration)
|
||||
const warning = (message, duration = 3500) => add(message, 'warning', duration)
|
||||
|
||||
return {
|
||||
toasts,
|
||||
add,
|
||||
remove,
|
||||
clear,
|
||||
success,
|
||||
error,
|
||||
info,
|
||||
warning
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
113
front_vue/src/stores/dialogs.js
Normal file
113
front_vue/src/stores/dialogs.js
Normal 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'
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,96 +43,84 @@
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<!-- Загрузка -->
|
||||
<div v-if="loading" class="loading-state">
|
||||
<i data-lucide="loader-2" class="spin"></i>
|
||||
<span>Загрузка...</span>
|
||||
</div>
|
||||
<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([])
|
||||
@@ -217,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)
|
||||
@@ -293,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()
|
||||
|
||||
@@ -339,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;
|
||||
@@ -469,63 +349,13 @@ onMounted(async () => {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Загрузка */
|
||||
.loading-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* ========== 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;
|
||||
padding: 60px 20px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.loading-state i {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ========== 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));
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
@@ -533,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;
|
||||
}
|
||||
|
||||
548
front_vue/src/views/InvitesPage.vue
Normal file
548
front_vue/src/views/InvitesPage.vue
Normal file
@@ -0,0 +1,548 @@
|
||||
<template>
|
||||
<PageLayout>
|
||||
<Header title="Приглашения">
|
||||
<template #stats>
|
||||
<div v-if="invites.length > 0" class="header-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-value">{{ invites.length }}</span>
|
||||
<span class="stat-label">{{ invitesLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Header>
|
||||
|
||||
<main class="main" :class="{ mobile: isMobile }">
|
||||
<Loader v-if="loading" />
|
||||
|
||||
<div v-else-if="invites.length === 0" class="empty-state" :class="{ mobile: isMobile }">
|
||||
<i data-lucide="inbox"></i>
|
||||
<p>Нет приглашений</p>
|
||||
<span>Когда вас пригласят в проект, приглашение появится здесь</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="invites-list" :class="{ mobile: isMobile }">
|
||||
<div
|
||||
v-for="invite in invites"
|
||||
:key="invite.id"
|
||||
class="invite-card"
|
||||
:class="{ processing: processingId === invite.id, mobile: isMobile }"
|
||||
>
|
||||
<!-- Desktop версия -->
|
||||
<template v-if="!isMobile">
|
||||
<!-- Аватар пользователя -->
|
||||
<div class="user-avatar">
|
||||
<img v-if="invite.from_user.avatar_url" :src="getFullUrl(invite.from_user.avatar_url)" :alt="invite.from_user.name || invite.from_user.username">
|
||||
<span v-else class="avatar-initials">{{ (invite.from_user.name || invite.from_user.username || '?')[0] }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Основной контент -->
|
||||
<div class="card-main">
|
||||
<span class="user-name">{{ invite.from_user.name || invite.from_user.username }}</span>
|
||||
<span class="invite-separator">→</span>
|
||||
<div class="project-info">
|
||||
<i data-lucide="folder"></i>
|
||||
<span class="project-label">Проект:</span>
|
||||
<span class="project-name">{{ invite.project_name }}</span>
|
||||
</div>
|
||||
<span v-if="invite.is_admin" class="admin-badge">Админ</span>
|
||||
</div>
|
||||
|
||||
<!-- Дата -->
|
||||
<div class="card-date">{{ formatDate(invite.created_at) }}</div>
|
||||
|
||||
<!-- Кнопки -->
|
||||
<div class="card-actions">
|
||||
<button
|
||||
class="btn-action btn-decline"
|
||||
:disabled="processingId === invite.id"
|
||||
@click.stop="declineInvite(invite)"
|
||||
title="Отклонить"
|
||||
>
|
||||
<i data-lucide="x"></i>
|
||||
Отклонить
|
||||
</button>
|
||||
<button
|
||||
class="btn-action btn-accept"
|
||||
:disabled="processingId === invite.id"
|
||||
@click.stop="acceptInvite(invite)"
|
||||
title="Принять"
|
||||
>
|
||||
<i data-lucide="check"></i>
|
||||
Принять
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Mobile версия -->
|
||||
<template v-else>
|
||||
<div class="card-row">
|
||||
<div class="user-avatar">
|
||||
<img v-if="invite.from_user.avatar_url" :src="getFullUrl(invite.from_user.avatar_url)" :alt="invite.from_user.name || invite.from_user.username">
|
||||
<span v-else class="avatar-initials">{{ (invite.from_user.name || invite.from_user.username || '?')[0] }}</span>
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<div class="card-top">
|
||||
<span class="user-name">{{ invite.from_user.name || invite.from_user.username }}</span>
|
||||
<span v-if="invite.is_admin" class="admin-badge">Админ</span>
|
||||
</div>
|
||||
<div class="card-bottom">
|
||||
<div class="project-info">
|
||||
<i data-lucide="folder"></i>
|
||||
<span class="project-label">Проект:</span>
|
||||
<span class="project-name">{{ invite.project_name }}</span>
|
||||
</div>
|
||||
<span class="card-date">{{ formatDate(invite.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<button
|
||||
class="btn-action btn-decline"
|
||||
:disabled="processingId === invite.id"
|
||||
@click.stop="declineInvite(invite)"
|
||||
>
|
||||
<i data-lucide="x"></i>
|
||||
Отклонить
|
||||
</button>
|
||||
<button
|
||||
class="btn-action btn-accept"
|
||||
:disabled="processingId === invite.id"
|
||||
@click.stop="acceptInvite(invite)"
|
||||
>
|
||||
<i data-lucide="check"></i>
|
||||
Принять
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</PageLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUpdated, watch, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import PageLayout from '../components/PageLayout.vue'
|
||||
import Header from '../components/Header.vue'
|
||||
import Loader from '../components/ui/Loader.vue'
|
||||
import { getFullUrl, projectInviteApi } from '../api'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
import { useDateFormat } from '../composables/useDateFormat'
|
||||
import { useProjectsStore } from '../stores/projects'
|
||||
import { useToast } from '../composables/useToast'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useProjectsStore()
|
||||
const toast = useToast()
|
||||
const { isMobile } = useMobile()
|
||||
const { formatRelative } = useDateFormat()
|
||||
|
||||
const loading = ref(true)
|
||||
const invites = ref([])
|
||||
const processingId = ref(null)
|
||||
|
||||
// Склонение слова "приглашение"
|
||||
const invitesLabel = computed(() => {
|
||||
const count = invites.value.length
|
||||
if (count === 1) return 'приглашение'
|
||||
if (count >= 2 && count <= 4) return 'приглашения'
|
||||
return 'приглашений'
|
||||
})
|
||||
|
||||
// Форматирование даты
|
||||
const formatDate = (dateStr) => {
|
||||
return formatRelative(dateStr)
|
||||
}
|
||||
|
||||
// Загрузка приглашений
|
||||
const loadInvites = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await projectInviteApi.getMyPending()
|
||||
if (result.success) {
|
||||
invites.value = result.data
|
||||
// Обновляем счётчик в store
|
||||
store.pendingInvitesCount = result.count
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки приглашений:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Принять приглашение
|
||||
const acceptInvite = async (invite) => {
|
||||
processingId.value = invite.id
|
||||
try {
|
||||
const result = await projectInviteApi.accept(invite.id)
|
||||
if (result.success) {
|
||||
toast.success(`Вы присоединились к проекту «${invite.project_name}»`)
|
||||
// Удаляем из списка
|
||||
invites.value = invites.value.filter(i => i.id !== invite.id)
|
||||
// Обновляем счётчик
|
||||
store.pendingInvitesCount = invites.value.length
|
||||
// Принудительно обновляем список проектов
|
||||
await store.refreshProjects()
|
||||
// Переходим к проекту
|
||||
await store.selectProject(result.project_id)
|
||||
router.push('/')
|
||||
} else {
|
||||
toast.error('Ошибка принятия приглашения')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка принятия приглашения:', error)
|
||||
toast.error('Ошибка принятия приглашения')
|
||||
} finally {
|
||||
processingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Отклонить приглашение
|
||||
const declineInvite = async (invite) => {
|
||||
processingId.value = invite.id
|
||||
try {
|
||||
const result = await projectInviteApi.decline(invite.id)
|
||||
if (result.success) {
|
||||
toast.info('Приглашение отклонено')
|
||||
invites.value = invites.value.filter(i => i.id !== invite.id)
|
||||
// Обновляем счётчик
|
||||
store.pendingInvitesCount = invites.value.length
|
||||
} else {
|
||||
toast.error('Ошибка отклонения приглашения')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка отклонения приглашения:', error)
|
||||
toast.error('Ошибка отклонения приглашения')
|
||||
} finally {
|
||||
processingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Обновление иконок
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}
|
||||
|
||||
// Следим за сменой режима mobile/desktop
|
||||
watch(isMobile, async () => {
|
||||
await nextTick()
|
||||
refreshIcons()
|
||||
})
|
||||
|
||||
// Обновляем иконки когда данные загрузились
|
||||
watch(loading, async (newVal, oldVal) => {
|
||||
if (oldVal === true && newVal === false) {
|
||||
await nextTick()
|
||||
refreshIcons()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
loadInvites()
|
||||
await nextTick()
|
||||
refreshIcons()
|
||||
})
|
||||
|
||||
onUpdated(refreshIcons)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ========== MAIN ========== */
|
||||
.main {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.main.mobile {
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
/* ========== ПУСТОЕ СОСТОЯНИЕ ========== */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 20px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
opacity: 0.4;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.empty-state span {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.empty-state.mobile {
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.empty-state.mobile i {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
/* ========== СПИСОК ========== */
|
||||
.invites-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-width: 1200px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.invites-list.mobile {
|
||||
max-width: none;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ========== DESKTOP: КАРТОЧКА ========== */
|
||||
.invite-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.invite-card:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.invite-card.processing {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Аватар пользователя */
|
||||
.user-avatar {
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: var(--blue, #3b82f6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.user-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-initials {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Основной контент */
|
||||
.card-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.invite-separator {
|
||||
color: var(--text-muted);
|
||||
opacity: 0.4;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.project-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.project-info i {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.project-label,
|
||||
.project-name {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.project-name {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-badge {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
padding: 3px 6px;
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
color: #f59e0b;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Дата */
|
||||
.card-date {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.7;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Кнопки */
|
||||
.card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-action i {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.btn-decline:hover {
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.btn-accept {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.btn-accept:hover {
|
||||
background: #00e6b8;
|
||||
border-color: #00e6b8;
|
||||
}
|
||||
|
||||
.btn-action:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ========== MOBILE: КАРТОЧКА ========== */
|
||||
.invite-card.mobile {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.invite-card.mobile .card-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.invite-card.mobile .user-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.invite-card.mobile .avatar-initials {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.invite-card.mobile .card-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.invite-card.mobile .card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.invite-card.mobile .user-name {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.invite-card.mobile .card-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.invite-card.mobile .project-label,
|
||||
.invite-card.mobile .project-name {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.invite-card.mobile .project-info i {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
}
|
||||
|
||||
.invite-card.mobile .card-date {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.invite-card.mobile .card-actions {
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.invite-card.mobile .btn-action {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,92 +68,102 @@
|
||||
</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([])
|
||||
const loading = ref(true)
|
||||
|
||||
// Загрузка карточек текущего проекта
|
||||
const fetchCards = async () => {
|
||||
if (!store.currentProjectId) return
|
||||
// Загрузка карточек текущего проекта (silent = тихое обновление без Loader)
|
||||
const fetchCards = async (silent = false) => {
|
||||
if (!store.currentProjectId) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const result = await cardsApi.getAll(store.currentProjectId)
|
||||
if (result.success) cards.value = result.data
|
||||
if (!silent) {
|
||||
loading.value = true
|
||||
}
|
||||
try {
|
||||
const result = await cardsApi.getAll(store.currentProjectId)
|
||||
if (result.success) cards.value = result.data
|
||||
} finally {
|
||||
if (!silent) {
|
||||
loading.value = 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 })
|
||||
@@ -198,29 +210,74 @@ 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()
|
||||
}, REFRESH_INTERVAL)
|
||||
startCardsPolling()
|
||||
startInvitesPolling()
|
||||
}
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
stopCardsPolling()
|
||||
stopInvitesPolling()
|
||||
}
|
||||
|
||||
// ==================== ИНИЦИАЛИЗАЦИЯ ====================
|
||||
onMounted(async () => {
|
||||
// Store уже мог быть инициализирован при логине (prefetch) или в роутере
|
||||
await store.init()
|
||||
await fetchCards()
|
||||
|
||||
// Проверяем предзагруженные карточки
|
||||
const prefetchedCards = sessionStorage.getItem('prefetchedCards')
|
||||
if (prefetchedCards) {
|
||||
try {
|
||||
cards.value = JSON.parse(prefetchedCards)
|
||||
sessionStorage.removeItem('prefetchedCards')
|
||||
loading.value = false
|
||||
} catch (e) {
|
||||
await fetchCards()
|
||||
}
|
||||
} else {
|
||||
await fetchCards()
|
||||
}
|
||||
|
||||
startPolling()
|
||||
|
||||
if (window.lucide) window.lucide.createIcons()
|
||||
@@ -232,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>
|
||||
|
||||
616
front_vue/src/views/NoProjectsPage.vue
Normal file
616
front_vue/src/views/NoProjectsPage.vue
Normal 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>
|
||||
@@ -1,39 +1,100 @@
|
||||
<template>
|
||||
<div class="app" :class="{ mobile: isMobile }">
|
||||
<Sidebar />
|
||||
|
||||
<div class="main-wrapper">
|
||||
<Header title="Команда" subtitle="Наша команда специалистов" />
|
||||
|
||||
<main class="main">
|
||||
<div v-if="loading" class="loading">
|
||||
<div class="spinner"></div>
|
||||
<span>Загрузка...</span>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
@@ -44,77 +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 { usersApi, getFullUrl } from '../api'
|
||||
import Loader from '../components/ui/Loader.vue'
|
||||
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
|
||||
@@ -123,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 {
|
||||
@@ -187,28 +462,6 @@ onUpdated(refreshIcons)
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 80px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.1);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.team-grid {
|
||||
display: grid;
|
||||
@@ -228,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 {
|
||||
@@ -238,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 {
|
||||
@@ -246,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;
|
||||
@@ -275,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;
|
||||
@@ -312,39 +659,11 @@ 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;
|
||||
}
|
||||
|
||||
.app.mobile .loading {
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
/* Mobile swipe cards */
|
||||
.mobile-cards {
|
||||
width: 100%;
|
||||
@@ -387,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 {
|
||||
@@ -395,6 +723,10 @@ onUpdated(refreshIcons)
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.mobile-avatar .avatar-placeholder {
|
||||
font-size: 64px;
|
||||
}
|
||||
|
||||
.mobile-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -415,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 {
|
||||
@@ -464,7 +874,7 @@ onUpdated(refreshIcons)
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg);
|
||||
background: var(--bg-body);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
@@ -500,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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user