Compare commits
4 Commits
6928687982
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e9f8e0f6e | |||
| 88189a3f04 | |||
| 8e3cd770df | |||
| 7d7b817d7e |
@@ -93,6 +93,14 @@ if ($method === 'POST') {
|
|||||||
$project_id = $taskData['id_project'];
|
$project_id = $taskData['id_project'];
|
||||||
ProjectAccess::requireAccess($project_id, $current_user_id);
|
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');
|
ProjectAccess::requirePermission($project_id, $current_user_id, 'upload_images');
|
||||||
|
|
||||||
@@ -119,6 +127,14 @@ if ($method === 'POST') {
|
|||||||
$project_id = $taskData['id_project'];
|
$project_id = $taskData['id_project'];
|
||||||
ProjectAccess::requireAccess($project_id, $current_user_id);
|
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;
|
$isAuthor = (int)$commentData['id_accounts'] === (int)$current_user_id;
|
||||||
$isAdmin = ProjectAccess::isAdmin($project_id, $current_user_id);
|
$isAdmin = ProjectAccess::isAdmin($project_id, $current_user_id);
|
||||||
|
|||||||
@@ -77,6 +77,12 @@ if ($method === 'POST') {
|
|||||||
|
|
||||||
// Проверяем право на создание задач
|
// Проверяем право на создание задач
|
||||||
ProjectAccess::requirePermission($project_id, $user_id, 'create_task');
|
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_project = $project_id;
|
||||||
$task->id_department = $data['id_department'] ?? null;
|
$task->id_department = $data['id_department'] ?? null;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ if ($method === 'POST') {
|
|||||||
RestApi::response($result);
|
RestApi::response($result);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Выход (удаление всех сессий)
|
// Выход (только текущая сессия)
|
||||||
if ($action === 'logout') {
|
if ($action === 'logout') {
|
||||||
$account = new Account();
|
$account = new Account();
|
||||||
$keycookies = $data['keycookies'] ?? $_COOKIE['session'] ?? null;
|
$keycookies = $data['keycookies'] ?? $_COOKIE['session'] ?? null;
|
||||||
@@ -32,6 +32,14 @@ if ($method === 'POST') {
|
|||||||
RestApi::response($result);
|
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') {
|
if ($action === 'create_user') {
|
||||||
$account = new Account();
|
$account = new Account();
|
||||||
|
|||||||
@@ -30,8 +30,12 @@ class Comment extends BaseEntity {
|
|||||||
return $errors;
|
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) {
|
if ($this->id_answer) {
|
||||||
@@ -75,6 +79,13 @@ class Comment extends BaseEntity {
|
|||||||
// Проверяем что комментарий существует
|
// Проверяем что комментарий существует
|
||||||
$comment = self::checkComment($this->id);
|
$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) {
|
if ((int)$comment['id_accounts'] !== (int)$this->id_accounts) {
|
||||||
$this->addError('access', 'Вы можете редактировать только свои комментарии');
|
$this->addError('access', 'Вы можете редактировать только свои комментарии');
|
||||||
@@ -99,8 +110,16 @@ class Comment extends BaseEntity {
|
|||||||
// Проверяем что комментарий существует
|
// Проверяем что комментарий существует
|
||||||
$comment = self::checkComment($id);
|
$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;
|
$isAuthor = (int)$comment['id_accounts'] === (int)$id_accounts;
|
||||||
|
|||||||
@@ -112,10 +112,10 @@ class Project extends BaseEntity {
|
|||||||
|
|
||||||
// ==================== CRUD ПРОЕКТОВ ====================
|
// ==================== CRUD ПРОЕКТОВ ====================
|
||||||
|
|
||||||
// Создание проекта с дефолтными колонками
|
// Создание проекта БЕЗ колонок (колонки создаются на фронте)
|
||||||
public static function create($name, $user_id) {
|
public static function create($name, $user_id) {
|
||||||
// Получаем максимальный id_order
|
// Получаем максимальный id_order
|
||||||
$maxOrder = Database::max('project', 'id_order') ?? 0;
|
$maxOrder = (int)(Database::max('project', 'id_order') ?? 0);
|
||||||
|
|
||||||
// Создаём проект с создателем как владельцем (id_admin)
|
// Создаём проект с создателем как владельцем (id_admin)
|
||||||
Database::insert('project', [
|
Database::insert('project', [
|
||||||
@@ -129,34 +129,9 @@ class Project extends BaseEntity {
|
|||||||
return ['success' => false, 'errors' => ['project' => 'Ошибка создания проекта']];
|
return ['success' => false, 'errors' => ['project' => 'Ошибка создания проекта']];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Создаём дефолтные колонки
|
|
||||||
Database::insert('columns', [
|
|
||||||
'name_columns' => 'К выполнению',
|
|
||||||
'color' => '#6366f1',
|
|
||||||
'id_project' => $projectId,
|
|
||||||
'id_order' => 1
|
|
||||||
]);
|
|
||||||
$firstColumnId = Database::id();
|
|
||||||
|
|
||||||
Database::insert('columns', [
|
|
||||||
'name_columns' => 'Готово',
|
|
||||||
'color' => '#22c55e',
|
|
||||||
'id_project' => $projectId,
|
|
||||||
'id_order' => 2
|
|
||||||
]);
|
|
||||||
$readyColumnId = Database::id();
|
|
||||||
|
|
||||||
// Устанавливаем id_ready
|
|
||||||
Database::update('project', ['id_ready' => $readyColumnId], ['id' => $projectId]);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'id' => $projectId,
|
'id' => $projectId,
|
||||||
'columns' => [
|
|
||||||
['id' => $firstColumnId, 'name_columns' => 'К выполнению', 'color' => '#6366f1', 'id_order' => 1],
|
|
||||||
['id' => $readyColumnId, 'name_columns' => 'Готово', 'color' => '#22c55e', 'id_order' => 2]
|
|
||||||
],
|
|
||||||
'id_ready' => $readyColumnId,
|
|
||||||
'is_admin' => true
|
'is_admin' => true
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -237,7 +212,7 @@ class Project extends BaseEntity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Получаем максимальный id_order для проекта
|
// Получаем максимальный id_order для проекта
|
||||||
$maxOrder = Database::max('columns', 'id_order', ['id_project' => $project_id]) ?? 0;
|
$maxOrder = (int)(Database::max('columns', 'id_order', ['id_project' => $project_id]) ?? 0);
|
||||||
|
|
||||||
Database::insert('columns', [
|
Database::insert('columns', [
|
||||||
'name_columns' => $name,
|
'name_columns' => $name,
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ class Task extends BaseEntity {
|
|||||||
return $errors;
|
return $errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Формируем дату создания (одна переменная для БД и ответа)
|
||||||
|
$date_create = date('Y-m-d H:i:s');
|
||||||
|
|
||||||
// Вставляем в базу
|
// Вставляем в базу
|
||||||
Database::insert($this->db_name, [
|
Database::insert($this->db_name, [
|
||||||
'id_project' => $this->id_project,
|
'id_project' => $this->id_project,
|
||||||
@@ -75,7 +78,7 @@ class Task extends BaseEntity {
|
|||||||
'descript' => $this->descript ?: null,
|
'descript' => $this->descript ?: null,
|
||||||
'descript_full' => $this->descript_full ?: null,
|
'descript_full' => $this->descript_full ?: null,
|
||||||
'archive' => 0,
|
'archive' => 0,
|
||||||
'date_create' => date('Y-m-d H:i:s'),
|
'date_create' => $date_create,
|
||||||
'file_img' => '[]'
|
'file_img' => '[]'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -96,6 +99,9 @@ class Task extends BaseEntity {
|
|||||||
return [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
|
'date' => $this->date ?: null,
|
||||||
|
'date_create' => $date_create,
|
||||||
|
'date_closed' => null,
|
||||||
'files' => $uploaded_files
|
'files' => $uploaded_files
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -108,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) {
|
if (!$task) {
|
||||||
$this->addError('task', 'Задача не найдена');
|
$this->addError('task', 'Задача не найдена');
|
||||||
return $this->getErrors();
|
return $this->getErrors();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Архивные задачи нельзя редактировать
|
||||||
|
if ((int)$task['archive'] === 1) {
|
||||||
|
$this->addError('task', 'Архивные задачи нельзя редактировать');
|
||||||
|
return $this->getErrors();
|
||||||
|
}
|
||||||
|
|
||||||
// Получаем текущую колонку
|
// Получаем текущую колонку
|
||||||
$old_column_id = (int)$task['column_id'];
|
$old_column_id = (int)$task['column_id'];
|
||||||
|
|
||||||
@@ -179,14 +191,32 @@ class Task extends BaseEntity {
|
|||||||
// Загрузка файла к задаче
|
// Загрузка файла к задаче
|
||||||
public static function uploadFile($task_id, $file_base64, $file_name) {
|
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);
|
return FileUpload::upload('task', $task_id, $file_base64, $file_name);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Удаление файлов задачи
|
// Удаление файлов задачи
|
||||||
public static function deleteFile($task_id, $file_names) {
|
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);
|
return FileUpload::delete('task', $task_id, $file_names);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,6 +229,14 @@ class Task extends BaseEntity {
|
|||||||
$new_column_id = (int)$column_id;
|
$new_column_id = (int)$column_id;
|
||||||
$archive = (int)$task['archive'];
|
$archive = (int)$task['archive'];
|
||||||
|
|
||||||
|
// Архивные задачи нельзя перемещать
|
||||||
|
if ($archive === 1) {
|
||||||
|
RestApi::response([
|
||||||
|
'success' => false,
|
||||||
|
'errors' => ['task' => 'Архивные задачи нельзя перемещать']
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
// Получаем id_ready (колонка "Готово") из проекта
|
// Получаем id_ready (колонка "Готово") из проекта
|
||||||
$done_column_id = Project::getReadyColumnId($task['id_project']);
|
$done_column_id = Project::getReadyColumnId($task['id_project']);
|
||||||
|
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ class Account extends BaseEntity {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Удаление всех сессий пользователя (logout)
|
// Удаление только текущей сессии (logout)
|
||||||
public function logout($keycookies) {
|
public function logout($keycookies) {
|
||||||
// Проверяем, что сессия не пустая
|
// Проверяем, что сессия не пустая
|
||||||
if (!$keycookies) {
|
if (!$keycookies) {
|
||||||
@@ -204,6 +204,28 @@ class Account extends BaseEntity {
|
|||||||
return $this->getErrors();
|
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 пользователя
|
// Получаем сессию чтобы узнать id пользователя
|
||||||
$session = Database::get($this->db_name_session, ['id_accounts'], [
|
$session = Database::get($this->db_name_session, ['id_accounts'], [
|
||||||
'keycookies' => $keycookies
|
'keycookies' => $keycookies
|
||||||
|
|||||||
@@ -4,14 +4,19 @@
|
|||||||
function routing_static_files() {
|
function routing_static_files() {
|
||||||
$requestUri = $_SERVER['REQUEST_URI'] ?? '';
|
$requestUri = $_SERVER['REQUEST_URI'] ?? '';
|
||||||
$path = parse_url($requestUri, PHP_URL_PATH);
|
$path = parse_url($requestUri, PHP_URL_PATH);
|
||||||
|
$path = urldecode($path); // декодируем кириллицу из URL
|
||||||
|
|
||||||
// Отдача файлов из /public/ (принудительное скачивание)
|
// Отдача файлов из /public/ (принудительное скачивание)
|
||||||
if (strpos($path, '/public/') === 0) {
|
if (strpos($path, '/public/') === 0) {
|
||||||
$file = dirname(dirname(__DIR__)) . $path;
|
$file = dirname(dirname(__DIR__)) . $path;
|
||||||
if (is_file($file)) {
|
if (is_file($file)) {
|
||||||
|
$filename = basename($file);
|
||||||
|
$filename_encoded = rawurlencode($filename);
|
||||||
|
|
||||||
header('Content-Type: application/octet-stream');
|
header('Content-Type: application/octet-stream');
|
||||||
header('Content-Length: ' . filesize($file));
|
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);
|
readfile($file);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
window.APP_CONFIG = {
|
window.APP_CONFIG = {
|
||||||
API_BASE: 'http://192.168.1.6',
|
API_BASE: 'http://192.168.1.6',
|
||||||
|
|
||||||
// Интервалы автообновления данных (в секундах)
|
// Интервалы автообновления данных (в секундах, 0 = отключено)
|
||||||
REFRESH_INTERVALS: {
|
REFRESH_INTERVALS: {
|
||||||
cards: 2, // Карточки на доске
|
cards: 2, // Карточки на доске
|
||||||
comments: 5, // Комментарии к задаче
|
comments: 5, // Комментарии к задаче
|
||||||
invites: 10 // Приглашения на странице без проектов
|
invites: 5 // Приглашения (страница: задачи + страница без проектов)
|
||||||
},
|
},
|
||||||
|
|
||||||
// Брейкпоинт для мобильной версии (px)
|
// Брейкпоинт для мобильной версии (px)
|
||||||
|
|||||||
@@ -54,7 +54,13 @@ export const authApi = {
|
|||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ action: 'logout' })
|
body: JSON.stringify({ action: 'logout' })
|
||||||
}, true) // skipSessionCheck — это выход
|
}, true), // skipSessionCheck — это выход
|
||||||
|
logoutAll: () => request('/api/user', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'logout_all' })
|
||||||
|
}, true) // skipSessionCheck — выход со всех устройств
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== PROJECTS ====================
|
// ==================== PROJECTS ====================
|
||||||
@@ -390,11 +396,15 @@ export const serverSettings = {
|
|||||||
parseDate(dateStr) {
|
parseDate(dateStr) {
|
||||||
if (!dateStr) return null
|
if (!dateStr) return null
|
||||||
// Добавляем таймзону сервера для корректного парсинга
|
// Добавляем таймзону сервера для корректного парсинга
|
||||||
const normalized = dateStr.replace(' ', 'T')
|
let normalized = dateStr.replace(' ', 'T')
|
||||||
// Если уже есть таймзона — не добавляем
|
// Если уже есть таймзона — не добавляем
|
||||||
if (normalized.includes('+') || normalized.includes('Z')) {
|
if (normalized.includes('+') || normalized.includes('Z')) {
|
||||||
return new Date(normalized)
|
return new Date(normalized)
|
||||||
}
|
}
|
||||||
|
// Если нет времени (только дата YYYY-MM-DD) — добавляем 00:00:00
|
||||||
|
if (normalized.length === 10) {
|
||||||
|
normalized += 'T00:00:00'
|
||||||
|
}
|
||||||
return new Date(normalized + this.timezoneOffset)
|
return new Date(normalized + this.timezoneOffset)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -321,7 +321,7 @@ const saveTask = async (taskData, columnId) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Добавляем локально с ID от сервера
|
// Добавляем локально с данными от сервера
|
||||||
localCards.value.push({
|
localCards.value.push({
|
||||||
id: parseInt(result.id),
|
id: parseInt(result.id),
|
||||||
id_department: taskData.departmentId,
|
id_department: taskData.departmentId,
|
||||||
@@ -332,8 +332,9 @@ const saveTask = async (taskData, columnId) => {
|
|||||||
descript_full: taskData.details,
|
descript_full: taskData.details,
|
||||||
avatar_img: taskData.assignee,
|
avatar_img: taskData.assignee,
|
||||||
column_id: columnId,
|
column_id: columnId,
|
||||||
date: taskData.dueDate,
|
date: result.date,
|
||||||
date_create: new Date().toISOString().split('T')[0],
|
date_create: result.date_create,
|
||||||
|
date_closed: result.date_closed,
|
||||||
order: maxOrder,
|
order: maxOrder,
|
||||||
files: result.files || []
|
files: result.files || []
|
||||||
})
|
})
|
||||||
@@ -433,8 +434,6 @@ defineExpose({ saveTask, deleteTask, archiveTask })
|
|||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
/* Предотвращаем системные жесты (pull-to-refresh) */
|
/* Предотвращаем системные жесты (pull-to-refresh) */
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
/* Разрешаем и горизонтальный и вертикальный pan - колонки внутри скроллятся вертикально */
|
|
||||||
touch-action: pan-x pan-y;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.board.mobile .columns::-webkit-scrollbar {
|
.board.mobile .columns::-webkit-scrollbar {
|
||||||
|
|||||||
@@ -142,17 +142,33 @@ const handleDrop = (e) => {
|
|||||||
.column.mobile {
|
.column.mobile {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
min-width: 100vw;
|
min-width: 100vw;
|
||||||
height: 100%;
|
/* Высота по контенту, но ограничена доступным пространством */
|
||||||
max-height: none;
|
height: fit-content;
|
||||||
|
max-height: calc(100% - 20px);
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
scroll-snap-align: start;
|
scroll-snap-align: start;
|
||||||
scroll-snap-stop: always;
|
scroll-snap-stop: always;
|
||||||
flex-shrink: 0;
|
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 {
|
.column.mobile .cards {
|
||||||
max-height: calc(100vh - 320px);
|
/* Без overflow - скролл на уровне .column */
|
||||||
overflow-y: auto;
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
|
min-height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.column.drag-over .cards {
|
.column.drag-over .cards {
|
||||||
|
|||||||
@@ -11,10 +11,12 @@
|
|||||||
<button
|
<button
|
||||||
v-if="dialogShowDiscard"
|
v-if="dialogShowDiscard"
|
||||||
class="btn-discard"
|
class="btn-discard"
|
||||||
|
:class="dialogDiscardVariant"
|
||||||
@click="handleDiscard"
|
@click="handleDiscard"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
>
|
>
|
||||||
{{ dialogDiscardText }}
|
<span v-if="loading === 'discard'" class="btn-loader"></span>
|
||||||
|
<span v-else>{{ dialogDiscardText }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="btn-confirm"
|
class="btn-confirm"
|
||||||
@@ -22,7 +24,7 @@
|
|||||||
@click="handleConfirm"
|
@click="handleConfirm"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
>
|
>
|
||||||
<span v-if="loading" class="btn-loader"></span>
|
<span v-if="loading === 'confirm'" class="btn-loader"></span>
|
||||||
<span v-else>{{ dialogConfirmText }}</span>
|
<span v-else>{{ dialogConfirmText }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -56,10 +58,16 @@ const props = defineProps({
|
|||||||
default: undefined
|
default: undefined
|
||||||
},
|
},
|
||||||
variant: String,
|
variant: String,
|
||||||
|
discardVariant: String,
|
||||||
// Async callback для подтверждения — сам управляет loading
|
// Async callback для подтверждения — сам управляет loading
|
||||||
action: {
|
action: {
|
||||||
type: Function,
|
type: Function,
|
||||||
default: null
|
default: null
|
||||||
|
},
|
||||||
|
// Async callback для discard (опционально)
|
||||||
|
discardAction: {
|
||||||
|
type: Function,
|
||||||
|
default: null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -71,19 +79,20 @@ const dialogTitle = computed(() => props.title ?? config.value.title ?? 'Под
|
|||||||
const dialogMessage = computed(() => props.message ?? config.value.message ?? 'Вы уверены?')
|
const dialogMessage = computed(() => props.message ?? config.value.message ?? 'Вы уверены?')
|
||||||
const dialogConfirmText = computed(() => props.confirmText ?? config.value.confirmText ?? 'Подтвердить')
|
const dialogConfirmText = computed(() => props.confirmText ?? config.value.confirmText ?? 'Подтвердить')
|
||||||
const dialogCancelText = computed(() => props.cancelText ?? 'Отмена')
|
const dialogCancelText = computed(() => props.cancelText ?? 'Отмена')
|
||||||
const dialogDiscardText = computed(() => props.discardText ?? 'Не сохранять')
|
const dialogDiscardText = computed(() => props.discardText ?? config.value.discardText ?? 'Не сохранять')
|
||||||
const dialogShowDiscard = computed(() => props.showDiscard ?? config.value.showDiscard ?? false)
|
const dialogShowDiscard = computed(() => props.showDiscard ?? config.value.showDiscard ?? false)
|
||||||
const dialogVariant = computed(() => props.variant ?? config.value.variant ?? 'default')
|
const dialogVariant = computed(() => props.variant ?? config.value.variant ?? 'default')
|
||||||
|
const dialogDiscardVariant = computed(() => props.discardVariant ?? config.value.discardVariant ?? 'default')
|
||||||
|
|
||||||
const emit = defineEmits(['confirm', 'cancel', 'discard'])
|
const emit = defineEmits(['confirm', 'cancel', 'discard', 'update:show'])
|
||||||
|
|
||||||
// Внутреннее состояние загрузки
|
// Внутреннее состояние загрузки: null | 'confirm' | 'discard'
|
||||||
const loading = ref(false)
|
const loading = ref(null)
|
||||||
|
|
||||||
// Сброс состояния при закрытии диалога
|
// Сброс состояния при закрытии диалога
|
||||||
watch(() => props.show, (newVal) => {
|
watch(() => props.show, (newVal) => {
|
||||||
if (!newVal) {
|
if (!newVal) {
|
||||||
loading.value = false
|
loading.value = null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -92,31 +101,53 @@ const handleConfirm = async () => {
|
|||||||
|
|
||||||
// Если есть async action — вызываем его и управляем loading
|
// Если есть async action — вызываем его и управляем loading
|
||||||
if (props.action) {
|
if (props.action) {
|
||||||
loading.value = true
|
loading.value = 'confirm'
|
||||||
try {
|
try {
|
||||||
await props.action()
|
await props.action()
|
||||||
// Успех — эмитим confirm для закрытия
|
// Успех — эмитим confirm и закрываем диалог
|
||||||
emit('confirm')
|
emit('confirm')
|
||||||
|
emit('update:show', false)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('ConfirmDialog action failed:', e)
|
console.error('ConfirmDialog action failed:', e)
|
||||||
// При ошибке — не закрываем диалог
|
// При ошибке — не закрываем диалог
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = null
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Простой режим — просто эмитим
|
// Простой режим — просто эмитим и закрываем
|
||||||
emit('confirm')
|
emit('confirm')
|
||||||
|
emit('update:show', false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
if (loading.value) return
|
if (loading.value) return
|
||||||
emit('cancel')
|
emit('cancel')
|
||||||
|
emit('update:show', false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDiscard = () => {
|
const handleDiscard = async () => {
|
||||||
if (loading.value) return
|
if (loading.value) return
|
||||||
emit('discard')
|
|
||||||
|
// Если есть 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>
|
</script>
|
||||||
|
|
||||||
@@ -196,6 +227,30 @@ const handleDiscard = () => {
|
|||||||
background: rgba(239, 68, 68, 0.25);
|
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 {
|
.btn-confirm {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #000;
|
color: #000;
|
||||||
@@ -206,21 +261,23 @@ const handleDiscard = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-confirm.danger {
|
.btn-confirm.danger {
|
||||||
background: #ef4444;
|
background: rgba(239, 68, 68, 0.15);
|
||||||
color: #fff;
|
color: #f87171;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-confirm.danger:hover {
|
.btn-confirm.danger:hover {
|
||||||
background: #dc2626;
|
background: rgba(239, 68, 68, 0.25);
|
||||||
|
color: #fca5a5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-confirm.warning {
|
.btn-confirm.warning {
|
||||||
background: #f59e0b;
|
background: rgba(245, 158, 11, 0.15);
|
||||||
color: #000;
|
color: #fbbf24;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-confirm.warning:hover {
|
.btn-confirm.warning:hover {
|
||||||
background: #d97706;
|
background: rgba(245, 158, 11, 0.25);
|
||||||
|
color: #fcd34d;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-confirm:disabled {
|
.btn-confirm:disabled {
|
||||||
|
|||||||
@@ -772,6 +772,13 @@ const handleSave = async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (isNew.value) {
|
if (isNew.value) {
|
||||||
|
// Проверяем что у всех колонок есть имена
|
||||||
|
const emptyColumnNew = form.value.columns.find(c => !c.name_columns?.trim())
|
||||||
|
if (emptyColumnNew) {
|
||||||
|
toast.error('Укажите название для всех колонок')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Проверяем что у всех отделов есть имена (для нового проекта тоже)
|
// Проверяем что у всех отделов есть имена (для нового проекта тоже)
|
||||||
const emptyDeptNew = form.value.departments.find(d => !d.name_departments?.trim())
|
const emptyDeptNew = form.value.departments.find(d => !d.name_departments?.trim())
|
||||||
if (emptyDeptNew) {
|
if (emptyDeptNew) {
|
||||||
@@ -785,6 +792,25 @@ const handleSave = async () => {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
const newProjectId = result.id
|
const newProjectId = result.id
|
||||||
|
|
||||||
|
// Создаём колонки для нового проекта
|
||||||
|
const createdColumnIds = []
|
||||||
|
for (const column of form.value.columns) {
|
||||||
|
if (column.tempId) {
|
||||||
|
const colResult = await store.addColumn(column.name_columns, column.color)
|
||||||
|
if (colResult.success) {
|
||||||
|
createdColumnIds.push(colResult.id)
|
||||||
|
} else {
|
||||||
|
toast.error('Ошибка создания колонки')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Устанавливаем последнюю колонку как финальную (id_ready)
|
||||||
|
if (createdColumnIds.length > 0) {
|
||||||
|
const lastColumnId = createdColumnIds[createdColumnIds.length - 1]
|
||||||
|
await store.reorderColumns(createdColumnIds) // Это автоматически установит id_ready
|
||||||
|
}
|
||||||
|
|
||||||
// Создаём отделы для нового проекта
|
// Создаём отделы для нового проекта
|
||||||
for (const department of form.value.departments) {
|
for (const department of form.value.departments) {
|
||||||
if (department.tempId) {
|
if (department.tempId) {
|
||||||
@@ -807,6 +833,13 @@ const handleSave = async () => {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Редактирование проекта
|
// Редактирование проекта
|
||||||
|
// Проверяем что у всех колонок есть имена
|
||||||
|
const emptyColumn = form.value.columns.find(c => !c.name_columns?.trim())
|
||||||
|
if (emptyColumn) {
|
||||||
|
toast.error('Укажите название для всех колонок')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Обновляем название если изменилось
|
// Обновляем название если изменилось
|
||||||
if (form.value.name !== initialForm.value.name) {
|
if (form.value.name !== initialForm.value.name) {
|
||||||
await store.updateProject(props.project.id, form.value.name)
|
await store.updateProject(props.project.id, form.value.name)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
|
|
||||||
<div class="comment-actions">
|
<div class="comment-actions">
|
||||||
<IconButton
|
<IconButton
|
||||||
|
v-if="canReply"
|
||||||
icon="reply"
|
icon="reply"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
small
|
small
|
||||||
@@ -110,6 +111,10 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
},
|
},
|
||||||
|
canReply: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
getFullUrl: {
|
getFullUrl: {
|
||||||
type: Function,
|
type: Function,
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
:level="comment.level"
|
:level="comment.level"
|
||||||
:is-own="comment.id_accounts === currentUserId"
|
:is-own="comment.id_accounts === currentUserId"
|
||||||
:can-edit="canEditComment(comment)"
|
:can-edit="canEditComment(comment)"
|
||||||
|
:can-reply="canComment"
|
||||||
:get-full-url="getFullUrl"
|
:get-full-url="getFullUrl"
|
||||||
@reply="startReply"
|
@reply="startReply"
|
||||||
@edit="startEditComment"
|
@edit="startEditComment"
|
||||||
@@ -68,6 +69,7 @@ import ContentEditorPanel from './ContentEditorPanel.vue'
|
|||||||
import Loader from '../ui/Loader.vue'
|
import Loader from '../ui/Loader.vue'
|
||||||
import { commentsApi, commentImageApi, getFullUrl } from '../../api'
|
import { commentsApi, commentImageApi, getFullUrl } from '../../api'
|
||||||
import { useLucideIcons } from '../../composables/useLucideIcons'
|
import { useLucideIcons } from '../../composables/useLucideIcons'
|
||||||
|
import { useAutoRefresh } from '../../composables/useAutoRefresh'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
taskId: {
|
taskId: {
|
||||||
@@ -150,8 +152,12 @@ const editorAvatarUrl = computed(() => {
|
|||||||
// Refs
|
// Refs
|
||||||
const commentsListRef = ref(null)
|
const commentsListRef = ref(null)
|
||||||
|
|
||||||
// Интервал обновления
|
// Автообновление комментариев
|
||||||
let refreshInterval = null
|
const { start: startRefresh, stop: stopRefresh } = useAutoRefresh('comments', async () => {
|
||||||
|
if (props.active && props.taskId) {
|
||||||
|
await loadComments(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Построение дерева комментариев
|
// Построение дерева комментариев
|
||||||
const commentsTree = computed(() => {
|
const commentsTree = computed(() => {
|
||||||
@@ -243,23 +249,7 @@ const loadComments = async (silent = false) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Автообновление
|
// startRefresh и stopRefresh определены через useAutoRefresh выше
|
||||||
const startRefresh = () => {
|
|
||||||
stopRefresh()
|
|
||||||
const interval = (window.APP_CONFIG?.REFRESH_INTERVALS?.comments || 30) * 1000
|
|
||||||
refreshInterval = setInterval(async () => {
|
|
||||||
if (props.active && props.taskId) {
|
|
||||||
await loadComments(true)
|
|
||||||
}
|
|
||||||
}, interval)
|
|
||||||
}
|
|
||||||
|
|
||||||
const stopRefresh = () => {
|
|
||||||
if (refreshInterval) {
|
|
||||||
clearInterval(refreshInterval)
|
|
||||||
refreshInterval = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== Редактор: открытие/закрытие ==========
|
// ========== Редактор: открытие/закрытие ==========
|
||||||
|
|
||||||
@@ -402,6 +392,8 @@ const updateComment = async (text, newFiles, filesToDelete) => {
|
|||||||
// ========== Редактирование (права) ==========
|
// ========== Редактирование (права) ==========
|
||||||
|
|
||||||
const canEditComment = (comment) => {
|
const canEditComment = (comment) => {
|
||||||
|
// Для архивных задач — нельзя редактировать комментарии
|
||||||
|
if (!props.canComment) return false
|
||||||
return comment.id_accounts === props.currentUserId || props.isProjectAdmin
|
return comment.id_accounts === props.currentUserId || props.isProjectAdmin
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,8 +49,8 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Footer: скрываем на вкладке комментариев или если нет прав редактировать -->
|
<!-- Footer: скрываем на вкладке комментариев, показываем если есть права редактировать или это архивная задача -->
|
||||||
<template #footer v-if="activeTab !== 'comments' && canEdit">
|
<template #footer v-if="activeTab !== 'comments' && (canEdit || isArchived)">
|
||||||
<div class="footer-left">
|
<div class="footer-left">
|
||||||
<IconButton
|
<IconButton
|
||||||
v-if="!isNew"
|
v-if="!isNew"
|
||||||
@@ -74,13 +74,19 @@
|
|||||||
@click="handleRestore"
|
@click="handleRestore"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Кнопки сохранения — только для редактируемых задач -->
|
||||||
<ActionButtons
|
<ActionButtons
|
||||||
|
v-if="canEdit"
|
||||||
:save-text="isNew ? 'Создать' : 'Сохранить'"
|
:save-text="isNew ? 'Создать' : 'Сохранить'"
|
||||||
:loading="isSaving"
|
:loading="isSaving"
|
||||||
:disabled="!canSave"
|
:disabled="!canSave"
|
||||||
@save="handleSave"
|
@save="handleSave"
|
||||||
@cancel="tryClose"
|
@cancel="tryClose"
|
||||||
/>
|
/>
|
||||||
|
<!-- Для архивных — только кнопка закрытия -->
|
||||||
|
<button v-else class="btn-close-panel" @click="tryClose">
|
||||||
|
Закрыть
|
||||||
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</SlidePanel>
|
</SlidePanel>
|
||||||
|
|
||||||
@@ -228,14 +234,18 @@ const canArchive = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Право на редактирование (для новой — create_task, для существующей — canEditTask)
|
// Право на редактирование (для новой — create_task, для существующей — canEditTask)
|
||||||
|
// Архивные задачи нельзя редактировать
|
||||||
const canEdit = computed(() => {
|
const canEdit = computed(() => {
|
||||||
|
if (props.isArchived) return false
|
||||||
if (isNew.value) return store.can('create_task')
|
if (isNew.value) return store.can('create_task')
|
||||||
return store.canEditTask(props.card)
|
return store.canEditTask(props.card)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Право на создание комментариев
|
// Право на создание комментариев
|
||||||
|
// Архивные задачи нельзя комментировать
|
||||||
const canComment = computed(() => {
|
const canComment = computed(() => {
|
||||||
if (isNew.value) return false // В новой задаче нельзя комментировать
|
if (isNew.value) return false // В новой задаче нельзя комментировать
|
||||||
|
if (props.isArchived) return false // Архивные нельзя комментировать
|
||||||
return store.canCreateComment(props.card)
|
return store.canCreateComment(props.card)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -429,7 +439,7 @@ watch(() => props.show, async (newVal) => {
|
|||||||
isSaving.value = false // Сброс состояния кнопки сохранения
|
isSaving.value = false // Сброс состояния кнопки сохранения
|
||||||
|
|
||||||
// Обновляем права пользователя (могли измениться администратором)
|
// Обновляем права пользователя (могли измениться администратором)
|
||||||
store.fetchUsers()
|
await store.fetchUsers()
|
||||||
|
|
||||||
// Reset comments tab
|
// Reset comments tab
|
||||||
commentsTabRef.value?.reset()
|
commentsTabRef.value?.reset()
|
||||||
@@ -472,4 +482,21 @@ watch(() => props.show, async (newVal) => {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-close-panel {
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
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;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close-panel:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
v-model:show="showDialog"
|
v-model:show="showDialog"
|
||||||
type="logout"
|
type="logout"
|
||||||
:action="logout"
|
:action="logoutAll"
|
||||||
|
:discard-action="logoutCurrent"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -38,13 +39,22 @@ const router = useRouter()
|
|||||||
const store = useProjectsStore()
|
const store = useProjectsStore()
|
||||||
const showDialog = ref(false)
|
const showDialog = ref(false)
|
||||||
|
|
||||||
const logout = async () => {
|
// Выход с текущей сессии (discard action)
|
||||||
|
const logoutCurrent = async () => {
|
||||||
clearAuthCache()
|
clearAuthCache()
|
||||||
await authApi.logout()
|
await authApi.logout()
|
||||||
store.reset()
|
store.reset()
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Выход со всех сессий (confirm action)
|
||||||
|
const logoutAll = async () => {
|
||||||
|
clearAuthCache()
|
||||||
|
await authApi.logoutAll()
|
||||||
|
store.reset()
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
|
||||||
const refreshIcons = () => {
|
const refreshIcons = () => {
|
||||||
if (window.lucide) window.lucide.createIcons()
|
if (window.lucide) window.lucide.createIcons()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<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>
|
<i v-if="icon" :data-lucide="icon" class="btn-icon"></i>
|
||||||
<span v-if="!compact" class="btn-label">{{ displayValue }}</span>
|
<span v-if="!compact" class="btn-label">{{ displayValue }}</span>
|
||||||
<i data-lucide="chevron-down" class="btn-arrow"></i>
|
<i data-lucide="chevron-down" class="btn-arrow"></i>
|
||||||
@@ -85,6 +85,14 @@ const displayValue = computed(() => {
|
|||||||
return option?.label || props.placeholder
|
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) => {
|
const selectOption = (id) => {
|
||||||
emit('update:modelValue', id)
|
emit('update:modelValue', id)
|
||||||
open.value = false
|
open.value = false
|
||||||
@@ -191,6 +199,17 @@ onUpdated(refreshIcons)
|
|||||||
display: none;
|
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 {
|
.btn-icon {
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
:alt="selectedOption.label"
|
:alt="selectedOption.label"
|
||||||
class="option-avatar"
|
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 v-else-if="!selectedOption" class="no-selection-icon">—</span>
|
||||||
<span class="selected-label">{{ selectedOption?.label || placeholder }}</span>
|
<span class="selected-label">{{ selectedOption?.label || placeholder }}</span>
|
||||||
</slot>
|
</slot>
|
||||||
@@ -54,6 +55,7 @@
|
|||||||
:alt="option.label"
|
:alt="option.label"
|
||||||
class="option-avatar"
|
class="option-avatar"
|
||||||
>
|
>
|
||||||
|
<span v-else class="avatar-placeholder">{{ (option.label || '?')[0] }}</span>
|
||||||
<div class="option-content">
|
<div class="option-content">
|
||||||
<span class="option-label">{{ option.label }}</span>
|
<span class="option-label">{{ option.label }}</span>
|
||||||
<a
|
<a
|
||||||
@@ -127,13 +129,14 @@
|
|||||||
:class="{ active: isActive(option.value) }"
|
:class="{ active: isActive(option.value) }"
|
||||||
@click="selectOption(option.value)"
|
@click="selectOption(option.value)"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="option.avatar"
|
v-if="option.avatar"
|
||||||
:src="option.avatar"
|
:src="option.avatar"
|
||||||
:alt="option.label"
|
:alt="option.label"
|
||||||
class="option-avatar"
|
class="option-avatar"
|
||||||
>
|
>
|
||||||
<div class="option-content">
|
<span v-else class="avatar-placeholder">{{ (option.label || '?')[0] }}</span>
|
||||||
|
<div class="option-content">
|
||||||
<span class="option-label">{{ option.label }}</span>
|
<span class="option-label">{{ option.label }}</span>
|
||||||
<a
|
<a
|
||||||
v-if="option.subtitle"
|
v-if="option.subtitle"
|
||||||
@@ -498,11 +501,31 @@ onUpdated(refreshIcons)
|
|||||||
.option-avatar {
|
.option-avatar {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
border-radius: 6px;
|
border-radius: 50%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
flex-shrink: 0;
|
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 {
|
.option-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -703,7 +726,14 @@ onUpdated(refreshIcons)
|
|||||||
.mobile-select-item .option-avatar {
|
.mobile-select-item .option-avatar {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 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 {
|
.mobile-select-item .option-content {
|
||||||
|
|||||||
@@ -316,15 +316,15 @@ onUnmounted(() => {
|
|||||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Transition — плавное появление */
|
/* Transition — выезд справа для десктопа */
|
||||||
.panel-enter-active,
|
.panel-enter-active,
|
||||||
.panel-leave-active {
|
.panel-leave-active {
|
||||||
transition: opacity 0.2s ease;
|
transition: opacity 0.25s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-enter-active .panel,
|
.panel-enter-active .panel,
|
||||||
.panel-leave-active .panel {
|
.panel-leave-active .panel {
|
||||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
transition: transform 0.25s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-enter-from,
|
.panel-enter-from,
|
||||||
@@ -334,8 +334,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.panel-enter-from .panel,
|
.panel-enter-from .panel,
|
||||||
.panel-leave-to .panel {
|
.panel-leave-to .panel {
|
||||||
opacity: 0;
|
transform: translateX(100%);
|
||||||
transform: scale(0.98);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* На мобильных убираем transform из анимации - он ломает layout в iOS PWA */
|
/* На мобильных убираем transform из анимации - он ломает layout в iOS PWA */
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,12 +92,15 @@ export const DIALOGS = {
|
|||||||
variant: 'danger'
|
variant: 'danger'
|
||||||
},
|
},
|
||||||
|
|
||||||
// Выход из системы
|
// Выход из системы (3 кнопки)
|
||||||
logout: {
|
logout: {
|
||||||
title: 'Выйти из аккаунта?',
|
title: 'Выйти из аккаунта?',
|
||||||
message: 'Вы будете перенаправлены<br>на страницу входа.',
|
message: 'Выберите, откуда хотите выйти',
|
||||||
confirmText: 'Выйти',
|
confirmText: 'Все сессии',
|
||||||
variant: 'warning'
|
discardText: 'Текущая сессия',
|
||||||
|
showDiscard: true,
|
||||||
|
variant: 'danger',
|
||||||
|
discardVariant: 'warning'
|
||||||
},
|
},
|
||||||
|
|
||||||
// Удаление отдела
|
// Удаление отдела
|
||||||
|
|||||||
@@ -16,10 +16,6 @@
|
|||||||
<div class="float-icon icon-12"><i data-lucide="message-circle"></i></div>
|
<div class="float-icon icon-12"><i data-lucide="message-circle"></i></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Градиентные сферы на фоне -->
|
|
||||||
<div class="bg-glow glow-2"></div>
|
|
||||||
<div class="bg-glow glow-3"></div>
|
|
||||||
|
|
||||||
<!-- Контент авторизации -->
|
<!-- Контент авторизации -->
|
||||||
<div class="login-content" :class="{ 'is-loading': isProcessing, 'is-success': showSuccess }">
|
<div class="login-content" :class="{ 'is-loading': isProcessing, 'is-success': showSuccess }">
|
||||||
|
|
||||||
@@ -489,44 +485,6 @@ watch([showSuccess, isRegisterMode], () => {
|
|||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Градиентные сферы на фоне */
|
|
||||||
.bg-glow {
|
|
||||||
position: absolute;
|
|
||||||
border-radius: 50%;
|
|
||||||
filter: blur(120px);
|
|
||||||
opacity: 0.4;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.glow-2 {
|
|
||||||
width: 500px;
|
|
||||||
height: 500px;
|
|
||||||
background: radial-gradient(circle, rgba(96, 165, 250, 0.25) 0%, transparent 70%);
|
|
||||||
bottom: -150px;
|
|
||||||
left: -100px;
|
|
||||||
animation: glowFloat2 10s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.glow-3 {
|
|
||||||
width: 400px;
|
|
||||||
height: 400px;
|
|
||||||
background: radial-gradient(circle, rgba(244, 114, 182, 0.2) 0%, transparent 70%);
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
animation: glowFloat3 12s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes glowFloat2 {
|
|
||||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
|
||||||
50% { transform: translate(40px, -20px) scale(1.05); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes glowFloat3 {
|
|
||||||
0%, 100% { transform: translate(-50%, -50%) scale(1); opacity: 0.3; }
|
|
||||||
50% { transform: translate(-50%, -50%) scale(1.2); opacity: 0.5; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Левитирующие иконки */
|
/* Левитирующие иконки */
|
||||||
.floating-icons {
|
.floating-icons {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ import { useProjectsStore } from '../stores/projects'
|
|||||||
import { cardsApi } from '../api'
|
import { cardsApi } from '../api'
|
||||||
import { useMobile } from '../composables/useMobile'
|
import { useMobile } from '../composables/useMobile'
|
||||||
import { useDepartmentFilter } from '../composables/useDepartmentFilter'
|
import { useDepartmentFilter } from '../composables/useDepartmentFilter'
|
||||||
|
import { useAutoRefresh } from '../composables/useAutoRefresh'
|
||||||
|
|
||||||
const { isMobile } = useMobile()
|
const { isMobile } = useMobile()
|
||||||
|
|
||||||
@@ -237,37 +238,25 @@ const onProjectSaved = async (projectId) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ==================== АВТООБНОВЛЕНИЕ (POLLING) ====================
|
// ==================== АВТООБНОВЛЕНИЕ (POLLING) ====================
|
||||||
const CARDS_REFRESH_INTERVAL = (window.APP_CONFIG?.REFRESH_INTERVALS?.cards ?? 30) * 1000
|
const { start: startCardsPolling, stop: stopCardsPolling } = useAutoRefresh('cards', async () => {
|
||||||
const INVITES_REFRESH_INTERVAL = (window.APP_CONFIG?.REFRESH_INTERVALS?.invites ?? 30) * 1000
|
// Не обновляем когда открыта модалка — это может прерывать клики
|
||||||
let pollTimer = null
|
if (panelOpen.value || projectPanelOpen.value) return
|
||||||
let invitesPollTimer = null
|
console.log('[AutoRefresh] Обновление данных...')
|
||||||
|
await fetchCards(true) // silent = true, без Loader
|
||||||
|
})
|
||||||
|
|
||||||
|
const { start: startInvitesPolling, stop: stopInvitesPolling } = useAutoRefresh('invites', async () => {
|
||||||
|
await store.fetchPendingInvitesCount()
|
||||||
|
})
|
||||||
|
|
||||||
const startPolling = () => {
|
const startPolling = () => {
|
||||||
// Polling карточек
|
startCardsPolling()
|
||||||
if (pollTimer) clearInterval(pollTimer)
|
startInvitesPolling()
|
||||||
pollTimer = setInterval(async () => {
|
|
||||||
// Не обновляем когда открыта модалка — это может прерывать клики
|
|
||||||
if (panelOpen.value || projectPanelOpen.value) return
|
|
||||||
console.log('[AutoRefresh] Обновление данных...')
|
|
||||||
await fetchCards(true) // silent = true, без Loader
|
|
||||||
}, CARDS_REFRESH_INTERVAL)
|
|
||||||
|
|
||||||
// Polling приглашений (для бейджа)
|
|
||||||
if (invitesPollTimer) clearInterval(invitesPollTimer)
|
|
||||||
invitesPollTimer = setInterval(async () => {
|
|
||||||
await store.fetchPendingInvitesCount()
|
|
||||||
}, INVITES_REFRESH_INTERVAL)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const stopPolling = () => {
|
const stopPolling = () => {
|
||||||
if (pollTimer) {
|
stopCardsPolling()
|
||||||
clearInterval(pollTimer)
|
stopInvitesPolling()
|
||||||
pollTimer = null
|
|
||||||
}
|
|
||||||
if (invitesPollTimer) {
|
|
||||||
clearInterval(invitesPollTimer)
|
|
||||||
invitesPollTimer = null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== ИНИЦИАЛИЗАЦИЯ ====================
|
// ==================== ИНИЦИАЛИЗАЦИЯ ====================
|
||||||
|
|||||||
@@ -96,13 +96,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onUnmounted, onUpdated, nextTick, watch } from 'vue'
|
import { ref, computed, onMounted, onUpdated, onUnmounted, nextTick, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import ProjectPanel from '../components/ProjectPanel.vue'
|
import ProjectPanel from '../components/ProjectPanel.vue'
|
||||||
import NotificationCard from '../components/ui/NotificationCard.vue'
|
import NotificationCard from '../components/ui/NotificationCard.vue'
|
||||||
import LogoutButton from '../components/ui/LogoutButton.vue'
|
import LogoutButton from '../components/ui/LogoutButton.vue'
|
||||||
import { useProjectsStore } from '../stores/projects'
|
import { useProjectsStore } from '../stores/projects'
|
||||||
import { useMobile } from '../composables/useMobile'
|
import { useMobile } from '../composables/useMobile'
|
||||||
|
import { useAutoRefresh } from '../composables/useAutoRefresh'
|
||||||
import { projectInviteApi, getFullUrl, cardsApi } from '../api'
|
import { projectInviteApi, getFullUrl, cardsApi } from '../api'
|
||||||
|
|
||||||
const { isMobile } = useMobile()
|
const { isMobile } = useMobile()
|
||||||
@@ -243,27 +244,21 @@ const refreshIcons = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Периодическое обновление приглашений
|
// Периодическое обновление приглашений
|
||||||
let refreshInterval = null
|
const { start: startInvitesRefresh, stop: stopInvitesRefresh } = useAutoRefresh('invites', async () => {
|
||||||
const REFRESH_MS = (window.APP_CONFIG?.REFRESH_INTERVALS?.invites || 30) * 1000
|
// Не обновляем если показывается анимация успеха
|
||||||
|
if (!showSuccess.value) {
|
||||||
|
await loadInvites()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadInvites()
|
loadInvites()
|
||||||
refreshIcons()
|
refreshIcons()
|
||||||
|
startInvitesRefresh()
|
||||||
// Запускаем периодическое обновление
|
|
||||||
refreshInterval = setInterval(() => {
|
|
||||||
// Не обновляем если показывается анимация успеха
|
|
||||||
if (!showSuccess.value) {
|
|
||||||
loadInvites()
|
|
||||||
}
|
|
||||||
}, REFRESH_MS)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (refreshInterval) {
|
stopInvitesRefresh()
|
||||||
clearInterval(refreshInterval)
|
|
||||||
refreshInterval = null
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onUpdated(refreshIcons)
|
onUpdated(refreshIcons)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
Target Server Version : 90200 (9.2.0)
|
Target Server Version : 90200 (9.2.0)
|
||||||
File Encoding : 65001
|
File Encoding : 65001
|
||||||
|
|
||||||
Date: 18/01/2026 17:17:50
|
Date: 20/01/2026 10:20:56
|
||||||
*/
|
*/
|
||||||
|
|
||||||
SET NAMES utf8mb4;
|
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,
|
`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,
|
`telegram` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
|
||||||
PRIMARY KEY (`id`) USING BTREE
|
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 = 14 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
|
||||||
|
|
||||||
-- ----------------------------
|
-- ----------------------------
|
||||||
-- Table structure for accounts_session
|
-- 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,
|
`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,
|
`user_agent` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
|
||||||
PRIMARY KEY (`id`) USING BTREE
|
PRIMARY KEY (`id`) USING BTREE
|
||||||
) ENGINE = InnoDB AUTO_INCREMENT = 101 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
|
-- Table structure for cards_task
|
||||||
@@ -69,7 +69,7 @@ CREATE TABLE `cards_task` (
|
|||||||
`id_project` int NULL DEFAULT NULL,
|
`id_project` int NULL DEFAULT NULL,
|
||||||
`create_id_account` int NULL DEFAULT NULL,
|
`create_id_account` int NULL DEFAULT NULL,
|
||||||
PRIMARY KEY (`id`) USING BTREE
|
PRIMARY KEY (`id`) USING BTREE
|
||||||
) ENGINE = InnoDB AUTO_INCREMENT = 64 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
|
-- Table structure for columns
|
||||||
@@ -82,7 +82,7 @@ CREATE TABLE `columns` (
|
|||||||
`id_project` int NULL DEFAULT NULL,
|
`id_project` int NULL DEFAULT NULL,
|
||||||
`id_order` int NULL DEFAULT NULL,
|
`id_order` int NULL DEFAULT NULL,
|
||||||
PRIMARY KEY (`id`) USING BTREE
|
PRIMARY KEY (`id`) USING BTREE
|
||||||
) ENGINE = InnoDB AUTO_INCREMENT = 62 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
|
-- Table structure for comments
|
||||||
@@ -97,7 +97,7 @@ CREATE TABLE `comments` (
|
|||||||
`date_create` datetime NULL DEFAULT NULL,
|
`date_create` datetime NULL DEFAULT NULL,
|
||||||
`file_img` json NULL,
|
`file_img` json NULL,
|
||||||
PRIMARY KEY (`id`) USING BTREE
|
PRIMARY KEY (`id`) USING BTREE
|
||||||
) ENGINE = InnoDB AUTO_INCREMENT = 23 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
|
-- Table structure for departments
|
||||||
@@ -108,8 +108,9 @@ CREATE TABLE `departments` (
|
|||||||
`name_departments` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
|
`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,
|
`color` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
|
||||||
`id_project` int NULL DEFAULT NULL,
|
`id_project` int NULL DEFAULT NULL,
|
||||||
|
`order_id` int NULL DEFAULT NULL,
|
||||||
PRIMARY KEY (`id`) USING BTREE
|
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
|
-- Table structure for labels
|
||||||
@@ -134,7 +135,7 @@ CREATE TABLE `project` (
|
|||||||
`id_ready` int NULL DEFAULT NULL,
|
`id_ready` int NULL DEFAULT NULL,
|
||||||
`id_admin` json NULL,
|
`id_admin` json NULL,
|
||||||
PRIMARY KEY (`id`) USING BTREE
|
PRIMARY KEY (`id`) USING BTREE
|
||||||
) ENGINE = InnoDB AUTO_INCREMENT = 10 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
|
-- Table structure for project_invites
|
||||||
@@ -152,7 +153,7 @@ CREATE TABLE `project_invites` (
|
|||||||
PRIMARY KEY (`id`) USING BTREE,
|
PRIMARY KEY (`id`) USING BTREE,
|
||||||
INDEX `idx_to_user`(`id_to_user` ASC, `status` ASC) USING BTREE,
|
INDEX `idx_to_user`(`id_to_user` ASC, `status` ASC) USING BTREE,
|
||||||
INDEX `idx_project`(`id_project` ASC) USING BTREE
|
INDEX `idx_project`(`id_project` ASC) USING BTREE
|
||||||
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
|
) ENGINE = InnoDB AUTO_INCREMENT = 21 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
|
||||||
|
|
||||||
-- ----------------------------
|
-- ----------------------------
|
||||||
-- Table structure for project_members
|
-- Table structure for project_members
|
||||||
@@ -169,6 +170,6 @@ CREATE TABLE `project_members` (
|
|||||||
UNIQUE INDEX `unique_member`(`id_project` ASC, `id_user` ASC) USING BTREE,
|
UNIQUE INDEX `unique_member`(`id_project` ASC, `id_user` ASC) USING BTREE,
|
||||||
INDEX `idx_project`(`id_project` ASC) USING BTREE,
|
INDEX `idx_project`(`id_project` ASC) USING BTREE,
|
||||||
INDEX `idx_user`(`id_user` ASC) USING BTREE
|
INDEX `idx_user`(`id_user` ASC) USING BTREE
|
||||||
) ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
|
) ENGINE = InnoDB AUTO_INCREMENT = 22 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
|
||||||
|
|
||||||
SET FOREIGN_KEY_CHECKS = 1;
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
|
|||||||
Reference in New Issue
Block a user