1
0

Compare commits

..

6 Commits

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

View File

@@ -93,6 +93,14 @@ if ($method === 'POST') {
$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');
@@ -119,6 +127,14 @@ if ($method === 'POST') {
$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);

View File

@@ -133,6 +133,63 @@ if ($method === 'POST') {
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);

View File

@@ -78,6 +78,12 @@ if ($method === 'POST') {
// Проверяем право на создание задач
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;

View File

@@ -24,7 +24,7 @@ if ($method === 'POST') {
RestApi::response($result);
}
// Выход (удаление всех сессий)
// Выход (только текущая сессия)
if ($action === 'logout') {
$account = new Account();
$keycookies = $data['keycookies'] ?? $_COOKIE['session'] ?? null;
@@ -32,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();

View File

@@ -30,8 +30,12 @@ class Comment extends BaseEntity {
return $errors;
}
// Проверяем что задача существует
Task::check_task($this->id_task);
// Проверяем что задача существует и не архивная
$task = Task::check_task($this->id_task);
if ((int)$task['archive'] === 1) {
$this->addError('task', 'Нельзя комментировать архивные задачи');
return $this->getErrors();
}
// Если это ответ — проверяем что родительский комментарий существует
if ($this->id_answer) {
@@ -75,6 +79,13 @@ class Comment extends BaseEntity {
// Проверяем что комментарий существует
$comment = self::checkComment($this->id);
// Проверяем что задача не архивная
$task = Database::get('cards_task', ['archive'], ['id' => $comment['id_task']]);
if ($task && (int)$task['archive'] === 1) {
$this->addError('task', 'Нельзя редактировать комментарии архивных задач');
return $this->getErrors();
}
// Проверяем что пользователь — автор комментария
if ((int)$comment['id_accounts'] !== (int)$this->id_accounts) {
$this->addError('access', 'Вы можете редактировать только свои комментарии');
@@ -99,8 +110,16 @@ 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;

View File

@@ -76,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']
]);
}
@@ -110,10 +112,10 @@ class Project extends BaseEntity {
// ==================== CRUD ПРОЕКТОВ ====================
// Создание проекта с дефолтными колонками
// Создание проекта БЕЗ колонок (колонки создаются на фронте)
public static function create($name, $user_id) {
// Получаем максимальный id_order
$maxOrder = Database::max('project', 'id_order') ?? 0;
$maxOrder = (int)(Database::max('project', 'id_order') ?? 0);
// Создаём проект с создателем как владельцем (id_admin)
Database::insert('project', [
@@ -127,34 +129,9 @@ class Project extends BaseEntity {
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 [
'success' => true,
'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
];
}
@@ -235,7 +212,7 @@ class Project extends BaseEntity {
}
// Получаем максимальный 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', [
'name_columns' => $name,
@@ -366,6 +343,110 @@ class Project extends BaseEntity {
public static function setReadyColumn($project_id, $column_id, $user_id) {
return ['success' => false, 'errors' => ['column' => 'Изменение финальной колонки запрещено. Финальная колонка всегда последняя.']];
}
// ==================== CRUD ОТДЕЛОВ ====================
// Добавление отдела
public static function addDepartment($project_id, $name, $color, $user_id) {
if (!ProjectAccess::can($project_id, $user_id, 'manage_departments')) {
return ['success' => false, 'errors' => ['access' => 'Нет прав на управление отделами']];
}
// Валидация имени
if (!$name || trim($name) === '') {
return ['success' => false, 'errors' => ['name' => 'Укажите название отдела']];
}
// Получаем максимальный order_id для проекта
$maxOrder = (int)(Database::max('departments', 'order_id', ['id_project' => $project_id]) ?? 0);
$newOrderId = $maxOrder + 1;
Database::insert('departments', [
'name_departments' => $name,
'color' => $color ?: '#6366f1',
'id_project' => $project_id,
'order_id' => $newOrderId
]);
$departmentId = Database::id();
return [
'success' => true,
'id' => $departmentId,
'department' => [
'id' => $departmentId,
'name_departments' => $name,
'color' => $color ?: '#6366f1',
'order_id' => $newOrderId
]
];
}
// Обновление отдела
public static function updateDepartment($id, $name, $color, $user_id) {
// Получаем отдел для проверки проекта
$department = Database::get('departments', ['id_project'], ['id' => $id]);
if (!$department) {
return ['success' => false, 'errors' => ['department' => 'Отдел не найден']];
}
if (!ProjectAccess::can($department['id_project'], $user_id, 'manage_departments')) {
return ['success' => false, 'errors' => ['access' => 'Нет прав на управление отделами']];
}
// Валидация имени (если передано)
if ($name !== null && trim($name) === '') {
return ['success' => false, 'errors' => ['name' => 'Укажите название отдела']];
}
$updateData = [];
if ($name !== null) $updateData['name_departments'] = $name;
if ($color !== null) $updateData['color'] = $color;
if (!empty($updateData)) {
Database::update('departments', $updateData, ['id' => $id]);
}
return ['success' => true];
}
// Получение количества задач в отделе
public static function getDepartmentTasksCount($department_id) {
return Database::count('cards_task', ['id_department' => $department_id]);
}
// Удаление отдела (обнуляет id_department у задач)
public static function deleteDepartment($id, $user_id) {
// Получаем отдел для проверки проекта
$department = Database::get('departments', ['id_project'], ['id' => $id]);
if (!$department) {
return ['success' => false, 'errors' => ['department' => 'Отдел не найден']];
}
if (!ProjectAccess::can($department['id_project'], $user_id, 'manage_departments')) {
return ['success' => false, 'errors' => ['access' => 'Нет прав на управление отделами']];
}
// Обнуляем id_department у задач (не удаляем сами задачи)
Database::update('cards_task', ['id_department' => null], ['id_department' => $id]);
// Удаляем отдел
Database::delete('departments', ['id' => $id]);
return ['success' => true];
}
// Обновление порядка отделов
public static function updateDepartmentsOrder($project_id, $ids, $user_id) {
if (!ProjectAccess::can($project_id, $user_id, 'manage_departments')) {
return ['success' => false, 'errors' => ['access' => 'Нет прав на управление отделами']];
}
foreach ($ids as $order => $id) {
Database::update('departments', ['order_id' => $order + 1], ['id' => $id, 'id_project' => $project_id]);
}
return ['success' => true];
}
}
?>

View File

@@ -61,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,
@@ -75,7 +78,7 @@ class Task extends BaseEntity {
'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' => '[]'
]);
@@ -96,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
];
}
@@ -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) {
$this->addError('task', 'Задача не найдена');
return $this->getErrors();
}
// Архивные задачи нельзя редактировать
if ((int)$task['archive'] === 1) {
$this->addError('task', 'Архивные задачи нельзя редактировать');
return $this->getErrors();
}
// Получаем текущую колонку
$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) {
// Проверка что задача существует
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);
}
@@ -199,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']);

View File

@@ -196,7 +196,7 @@ class Account extends BaseEntity {
];
}
// Удаление всех сессий пользователя (logout)
// Удаление только текущей сессии (logout)
public function logout($keycookies) {
// Проверяем, что сессия не пустая
if (!$keycookies) {
@@ -204,6 +204,28 @@ class Account extends BaseEntity {
return $this->getErrors();
}
// Удаляем только текущую сессию
Database::delete($this->db_name_session, [
'keycookies' => $keycookies
]);
// Удаляем cookie
setcookie('session', '', [
'expires' => time() - 3600,
'path' => '/'
]);
return ['success' => true];
}
// Удаление всех сессий пользователя (logout со всех устройств)
public function logout_all($keycookies) {
// Проверяем, что сессия не пустая
if (!$keycookies) {
$this->addError('session', 'Сессия не указана');
return $this->getErrors();
}
// Получаем сессию чтобы узнать id пользователя
$session = Database::get($this->db_name_session, ['id_accounts'], [
'keycookies' => $keycookies

View File

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

View File

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

View File

@@ -54,7 +54,13 @@ export const authApi = {
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
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 ====================
@@ -142,6 +148,42 @@ export const projectsApi = {
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 })
})
}
@@ -354,11 +396,15 @@ export const serverSettings = {
parseDate(dateStr) {
if (!dateStr) return null
// Добавляем таймзону сервера для корректного парсинга
const normalized = dateStr.replace(' ', 'T')
let normalized = dateStr.replace(' ', 'T')
// Если уже есть таймзона — не добавляем
if (normalized.includes('+') || normalized.includes('Z')) {
return new Date(normalized)
}
// Если нет времени (только дата YYYY-MM-DD) — добавляем 00:00:00
if (normalized.length === 10) {
normalized += 'T00:00:00'
}
return new Date(normalized + this.timezoneOffset)
}
}

View File

@@ -321,7 +321,7 @@ const saveTask = async (taskData, columnId) => {
})
if (result.success) {
// Добавляем локально с ID от сервера
// Добавляем локально с данными от сервера
localCards.value.push({
id: parseInt(result.id),
id_department: taskData.departmentId,
@@ -332,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 || []
})
@@ -433,8 +434,6 @@ defineExpose({ saveTask, deleteTask, archiveTask })
-ms-overflow-style: none;
/* Предотвращаем системные жесты (pull-to-refresh) */
overscroll-behavior: contain;
/* Разрешаем и горизонтальный и вертикальный pan - колонки внутри скроллятся вертикально */
touch-action: pan-x pan-y;
}
.board.mobile .columns::-webkit-scrollbar {

View File

@@ -142,17 +142,33 @@ const handleDrop = (e) => {
.column.mobile {
width: 100vw;
min-width: 100vw;
height: 100%;
max-height: none;
/* Высота по контенту, но ограничена доступным пространством */
height: fit-content;
max-height: calc(100% - 20px);
padding: 0 16px;
scroll-snap-align: start;
scroll-snap-stop: always;
flex-shrink: 0;
/* Вертикальный скролл на уровне колонки - убирает вложенные скроллы */
overflow-y: auto;
overflow-x: hidden;
}
/* Скрываем скроллбар в мобильной версии - пользователи свайпают пальцем */
.column.mobile {
scrollbar-width: none;
-ms-overflow-style: none;
}
.column.mobile::-webkit-scrollbar {
display: none;
}
.column.mobile .cards {
max-height: calc(100vh - 320px);
overflow-y: auto;
/* Без overflow - скролл на уровне .column */
max-height: none;
overflow: visible;
min-height: auto;
}
.column.drag-over .cards {

View File

@@ -11,10 +11,12 @@
<button
v-if="dialogShowDiscard"
class="btn-discard"
:class="dialogDiscardVariant"
@click="handleDiscard"
:disabled="loading"
>
{{ dialogDiscardText }}
<span v-if="loading === 'discard'" class="btn-loader"></span>
<span v-else>{{ dialogDiscardText }}</span>
</button>
<button
class="btn-confirm"
@@ -22,7 +24,7 @@
@click="handleConfirm"
:disabled="loading"
>
<span v-if="loading" class="btn-loader"></span>
<span v-if="loading === 'confirm'" class="btn-loader"></span>
<span v-else>{{ dialogConfirmText }}</span>
</button>
</div>
@@ -56,10 +58,16 @@ const props = defineProps({
default: undefined
},
variant: String,
discardVariant: String,
// Async callback для подтверждения — сам управляет loading
action: {
type: Function,
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 dialogConfirmText = computed(() => props.confirmText ?? config.value.confirmText ?? 'Подтвердить')
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 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'])
// Внутреннее состояние загрузки
const loading = ref(false)
// Внутреннее состояние загрузки: null | 'confirm' | 'discard'
const loading = ref(null)
// Сброс состояния при закрытии диалога
watch(() => props.show, (newVal) => {
if (!newVal) {
loading.value = false
loading.value = null
}
})
@@ -92,31 +101,53 @@ const handleConfirm = async () => {
// Если есть async action — вызываем его и управляем loading
if (props.action) {
loading.value = true
loading.value = 'confirm'
try {
await props.action()
// Успех — эмитим confirm для закрытия
// Успех — эмитим confirm и закрываем диалог
emit('confirm')
emit('update:show', false)
} catch (e) {
console.error('ConfirmDialog action failed:', e)
// При ошибке — не закрываем диалог
} finally {
loading.value = false
loading.value = null
}
} else {
// Простой режим — просто эмитим
// Простой режим — просто эмитим и закрываем
emit('confirm')
emit('update:show', false)
}
}
const handleCancel = () => {
if (loading.value) return
emit('cancel')
emit('update:show', false)
}
const handleDiscard = () => {
const handleDiscard = async () => {
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>
@@ -196,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;
@@ -206,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 {

View File

@@ -109,6 +109,92 @@
Добавить колонку
</button>
</div>
<!-- Отделы -->
<div class="form-section">
<label class="form-label">Отделы</label>
<div class="departments-list" v-if="form.departments.length > 0">
<div
v-for="(department, index) in form.departments"
:key="department.id || department.tempId"
class="department-item"
:class="{
'is-dragging': !isMobile && deptDragIndex === index,
'drag-over-top': !isMobile && deptDragOverIndex === index && deptDragPosition === 'top',
'drag-over-bottom': !isMobile && deptDragOverIndex === index && deptDragPosition === 'bottom'
}"
:draggable="!isMobile && canReorderDepartments"
@dragstart="!isMobile && canReorderDepartments && handleDeptDragStart($event, index)"
@dragend="!isMobile && handleDeptDragEnd()"
@dragover.prevent="!isMobile && handleDeptDragOver($event, index)"
@dragleave="!isMobile && handleDeptDragLeave()"
@drop.prevent="!isMobile && handleDeptDrop(index)"
>
<!-- Desktop: drag handle -->
<div v-if="!isMobile && canReorderDepartments" class="department-drag-handle">
<i data-lucide="grip-vertical"></i>
</div>
<!-- Mobile: up/down arrows -->
<div v-else-if="isMobile && canReorderDepartments" class="department-move-buttons">
<button
class="move-btn"
:disabled="index === 0"
@click="moveDepartmentUp(index)"
title="Вверх"
>
<i data-lucide="chevron-up"></i>
</button>
<button
class="move-btn"
:disabled="index === form.departments.length - 1"
@click="moveDepartmentDown(index)"
title="Вниз"
>
<i data-lucide="chevron-down"></i>
</button>
</div>
<ColorPicker v-model="department.color" />
<input
type="text"
class="department-name"
:ref="el => departmentInputRefs[index] = el"
v-model="department.name_departments"
placeholder="Название отдела"
/>
<!-- Иконка редактирования -->
<button
class="department-edit-btn"
title="Редактировать название"
@click="focusDepartmentName(index)"
>
<i data-lucide="pencil"></i>
</button>
<!-- Кнопка удаления -->
<button
class="department-delete-btn"
title="Удалить отдел"
@click="confirmDeleteDepartment(index)"
>
<i data-lucide="trash-2"></i>
</button>
</div>
</div>
<p v-else class="no-departments-hint">
Отделов пока нет. Добавьте первый отдел.
</p>
<button class="add-department-btn" @click="addDepartment">
<i data-lucide="plus"></i>
Добавить отдел
</button>
</div>
</template>
<template #footer>
@@ -150,6 +236,16 @@
@cancel="showDeleteColumnDialog = false"
/>
<!-- Диалог удаления отдела -->
<ConfirmDialog
:show="showDeleteDepartmentDialog"
type="deleteDepartment"
:message="deleteDepartmentMessage"
:action="confirmDeleteDepartmentAction"
@confirm="showDeleteDepartmentDialog = false"
@cancel="showDeleteDepartmentDialog = false"
/>
<!-- Диалог несохранённых изменений -->
<ConfirmDialog
:show="showUnsavedDialog"
@@ -194,7 +290,8 @@ const isSaving = ref(false)
// Form data
const form = ref({
name: '',
columns: []
columns: [],
departments: []
})
// Initial form for change detection
@@ -203,17 +300,29 @@ const initialForm = ref(null)
// Dialogs
const showDeleteProjectDialog = ref(false)
const showDeleteColumnDialog = ref(false)
const showDeleteDepartmentDialog = ref(false)
const showUnsavedDialog = ref(false)
const columnToDelete = ref(null)
const deleteColumnMessage = ref('')
const departmentToDelete = ref(null)
const deleteDepartmentMessage = ref('')
// Drag state
// Отделы к удалению (ID существующих отделов для удаления при сохранении)
const departmentsToDelete = ref([])
// Drag state for columns
const dragIndex = ref(null)
const dragOverIndex = ref(null)
const dragPosition = ref(null) // 'top' | 'bottom'
const columnsListRef = ref(null)
const columnInputRefs = ref([])
// Drag state for departments
const deptDragIndex = ref(null)
const deptDragOverIndex = ref(null)
const deptDragPosition = ref(null) // 'top' | 'bottom'
const departmentInputRefs = ref([])
// Temp ID counter for new columns
let tempIdCounter = 0
@@ -227,6 +336,11 @@ const canReorderColumns = computed(() => {
return form.value.columns.length > 2
})
// Можно ли сортировать отделы (больше 1 отдела)
const canReorderDepartments = computed(() => {
return form.value.departments.length > 1
})
// Has changes
const hasChanges = computed(() => {
if (!initialForm.value) return false
@@ -340,6 +454,176 @@ const confirmDeleteColumnAction = async () => {
columnToDelete.value = null
}
// ==================== DEPARTMENTS MANAGEMENT ====================
// Цвета для новых отделов
const departmentColors = [
'#ef4444', '#f97316', '#f59e0b', '#eab308', '#84cc16',
'#22c55e', '#10b981', '#14b8a6', '#06b6d4', '#0ea5e9',
'#3b82f6', '#6366f1', '#8b5cf6', '#a855f7', '#d946ef',
'#ec4899', '#f43f5e'
]
const getRandomDepartmentColor = () => {
// Получаем цвета уже используемых отделов
const usedColors = form.value.departments.map(d => d.color)
// Фильтруем доступные цвета
const availableColors = departmentColors.filter(c => !usedColors.includes(c))
// Если все цвета использованы — берём из полного списка
const colorPool = availableColors.length > 0 ? availableColors : departmentColors
return colorPool[Math.floor(Math.random() * colorPool.length)]
}
// Фокус на поле названия отдела
const focusDepartmentName = (index) => {
const input = departmentInputRefs.value[index]
if (input) {
input.focus()
const len = input.value.length
input.setSelectionRange(len, len)
}
}
const addDepartment = () => {
const newDepartment = {
tempId: `new-dept-${++tempIdCounter}`,
name_departments: '',
color: getRandomDepartmentColor(),
order_id: form.value.departments.length + 1
}
form.value.departments.push(newDepartment)
nextTick(refreshIcons)
}
const confirmDeleteDepartment = async (index) => {
const department = form.value.departments[index]
// Для новых отделов (ещё не на сервере) — удаляем сразу без диалога
if (department.tempId && !department.id) {
form.value.departments.splice(index, 1)
nextTick(refreshIcons)
return
}
// Для существующих отделов — показываем диалог с предупреждением
departmentToDelete.value = index
const count = await store.getDepartmentTasksCount(department.id)
if (count > 0) {
deleteDepartmentMessage.value = `В отделе ${count} задач.<br>Задачи не удалятся, но потеряют привязку к отделу.`
} else {
deleteDepartmentMessage.value = 'Отдел будет удалён.'
}
showDeleteDepartmentDialog.value = true
}
const confirmDeleteDepartmentAction = async () => {
const index = departmentToDelete.value
const department = form.value.departments[index]
// Если отдел существует на сервере — добавляем в список для удаления при сохранении
if (department.id) {
departmentsToDelete.value.push(department.id)
}
// Удаляем из формы (локально)
form.value.departments.splice(index, 1)
departmentToDelete.value = null
}
// ==================== MOBILE: MOVE DEPARTMENTS ====================
const moveDepartmentUp = (index) => {
if (index <= 0) return
const departments = form.value.departments
;[departments[index - 1], departments[index]] = [departments[index], departments[index - 1]]
// Update order_id
departments.forEach((dept, idx) => {
dept.order_id = idx + 1
})
nextTick(refreshIcons)
}
const moveDepartmentDown = (index) => {
const lastIndex = form.value.departments.length - 1
if (index >= lastIndex) return
const departments = form.value.departments
;[departments[index], departments[index + 1]] = [departments[index + 1], departments[index]]
// Update order_id
departments.forEach((dept, idx) => {
dept.order_id = idx + 1
})
nextTick(refreshIcons)
}
// ==================== DRAG AND DROP DEPARTMENTS (Desktop) ====================
const handleDeptDragStart = (e, index) => {
deptDragIndex.value = index
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', `dept-${index}`)
}
const handleDeptDragEnd = () => {
deptDragIndex.value = null
deptDragOverIndex.value = null
deptDragPosition.value = null
}
const handleDeptDragOver = (e, index) => {
if (deptDragIndex.value === null || deptDragIndex.value === index) return
const rect = e.currentTarget.getBoundingClientRect()
const midY = rect.top + rect.height / 2
deptDragOverIndex.value = index
deptDragPosition.value = e.clientY < midY ? 'top' : 'bottom'
}
const handleDeptDragLeave = () => {
// Don't clear immediately to avoid flickering
}
const handleDeptDrop = (targetIndex) => {
if (deptDragIndex.value === null || deptDragIndex.value === targetIndex) {
handleDeptDragEnd()
return
}
const sourceIndex = deptDragIndex.value
let insertIndex = targetIndex
// Adjust insert index based on drag position
if (deptDragPosition.value === 'bottom') {
insertIndex = targetIndex + 1
}
// If moving down, adjust for removal
if (sourceIndex < insertIndex) {
insertIndex--
}
// Reorder array
const [movedDept] = form.value.departments.splice(sourceIndex, 1)
form.value.departments.splice(insertIndex, 0, movedDept)
// Update order_id
form.value.departments.forEach((dept, idx) => {
dept.order_id = idx + 1
})
handleDeptDragEnd()
}
// ==================== MOBILE: MOVE COLUMNS ====================
const moveColumnUp = async (index) => {
@@ -488,21 +772,74 @@ const handleSave = async () => {
try {
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())
if (emptyDeptNew) {
toast.error('Укажите название для всех отделов')
return
}
// Создание проекта
const result = await store.createProject(form.value.name)
if (result.success) {
// Обновляем колонки если есть изменения
// Дефолтные колонки уже созданы сервером
// Здесь можно добавить логику для кастомизации колонок при создании
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) {
if (department.tempId) {
const deptResult = await store.addDepartmentToProject(
newProjectId,
department.name_departments,
department.color
)
if (!deptResult.success) {
toast.error(deptResult.errors?.name || 'Ошибка создания отдела')
}
}
}
toast.success('Проект создан')
emit('saved', result.id)
emit('saved', newProjectId)
emit('close')
} else {
toast.error('Ошибка создания проекта')
}
} else {
// Редактирование проекта
// Проверяем что у всех колонок есть имена
const emptyColumn = form.value.columns.find(c => !c.name_columns?.trim())
if (emptyColumn) {
toast.error('Укажите название для всех колонок')
return
}
// Обновляем название если изменилось
if (form.value.name !== initialForm.value.name) {
await store.updateProject(props.project.id, form.value.name)
@@ -536,6 +873,58 @@ const handleSave = async () => {
await store.reorderColumns(ids)
}
// Удаляем отделы, помеченные на удаление
for (const deptId of departmentsToDelete.value) {
const result = await store.deleteDepartment(deptId)
if (!result.success) {
toast.error(result.errors?.department || result.errors?.access || 'Ошибка удаления отдела')
return
}
}
departmentsToDelete.value = []
// Проверяем что у всех отделов есть имена
const emptyDepartment = form.value.departments.find(d => !d.name_departments?.trim())
if (emptyDepartment) {
toast.error('Укажите название для всех отделов')
return
}
// Обрабатываем отделы
for (const department of form.value.departments) {
if (department.tempId && !department.id) {
// Новый отдел
const result = await store.addDepartment(department.name_departments, department.color)
if (result.success) {
department.id = result.id
delete department.tempId
} else {
toast.error(result.errors?.name || result.errors?.access || 'Ошибка создания отдела')
return
}
} else if (department.id) {
// Проверяем изменения в существующем отделе
const original = initialForm.value.departments.find(d => d.id === department.id)
if (original) {
const nameChanged = department.name_departments !== original.name_departments
const colorChanged = department.color !== original.color
if (nameChanged || colorChanged) {
const result = await store.updateDepartment(department.id, department.name_departments, department.color)
if (!result.success) {
toast.error(result.errors?.name || result.errors?.access || 'Ошибка обновления отдела')
return
}
}
}
}
}
// Сохраняем порядок отделов
const deptIds = form.value.departments.filter(d => d.id).map(d => d.id)
if (deptIds.length > 0) {
await store.reorderDepartments(deptIds)
}
toast.success('Изменения сохранены')
emit('saved', props.project.id)
emit('close')
@@ -595,12 +984,14 @@ watch(() => props.show, async (newVal) => {
if (newVal) {
isSaving.value = false
tempIdCounter = 0
departmentsToDelete.value = []
if (props.project) {
// Редактирование — загружаем данные
form.value = {
name: props.project.name,
columns: JSON.parse(JSON.stringify(store.columns))
columns: JSON.parse(JSON.stringify(store.columns)),
departments: JSON.parse(JSON.stringify(store.departments))
}
} else {
// Создание — дефолтные значения (последняя колонка = финальная)
@@ -609,6 +1000,9 @@ watch(() => props.show, async (newVal) => {
columns: [
{ tempId: 'new-1', name_columns: 'К выполнению', color: '#6366f1', id_order: 1 },
{ tempId: 'new-2', name_columns: 'Готово', color: '#22c55e', id_order: 2 }
],
departments: [
{ tempId: 'new-dept-1', name_departments: 'Отдел разработки', color: '#3b82f6', order_id: 1 }
]
}
tempIdCounter = 2
@@ -621,10 +1015,14 @@ watch(() => props.show, async (newVal) => {
}
})
// Refresh icons when columns change
// Refresh icons when columns or departments change
watch(() => form.value.columns.length, () => {
nextTick(refreshIcons)
})
watch(() => form.value.departments.length, () => {
nextTick(refreshIcons)
})
</script>
<style scoped>
@@ -862,6 +1260,182 @@ watch(() => form.value.columns.length, () => {
height: 16px;
}
/* ==================== DEPARTMENTS LIST ==================== */
.departments-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.department-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 8px;
transition: all 0.15s;
}
.department-item:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
}
.department-item.is-dragging {
opacity: 0.5;
}
.department-item.drag-over-top {
border-top: 2px solid var(--accent);
padding-top: 6px;
}
.department-item.drag-over-bottom {
border-bottom: 2px solid var(--accent);
padding-bottom: 6px;
}
.department-drag-handle {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
color: var(--text-muted);
cursor: grab;
flex-shrink: 0;
}
.department-drag-handle:active {
cursor: grabbing;
}
.department-drag-handle i {
width: 16px;
height: 16px;
}
/* Mobile: up/down buttons for departments */
.department-move-buttons {
display: flex;
flex-direction: column;
gap: 2px;
flex-shrink: 0;
}
.department-name {
flex: 1;
min-width: 0;
background: transparent;
border: none;
color: var(--text-primary);
font-family: inherit;
font-size: 14px;
outline: none;
padding: 8px;
}
.department-name::placeholder {
color: var(--text-muted);
}
.department-name:focus {
background: rgba(255, 255, 255, 0.04);
border-radius: 4px;
}
.department-edit-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: none;
border: none;
border-radius: 6px;
color: var(--text-muted);
opacity: 0.5;
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
}
.department-edit-btn i {
width: 14px;
height: 14px;
}
.department-edit-btn:hover {
opacity: 1;
background: rgba(255, 255, 255, 0.06);
color: var(--accent);
}
.department-delete-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;
flex-shrink: 0;
}
.department-delete-btn i {
width: 16px;
height: 16px;
}
.department-delete-btn:hover {
background: rgba(239, 68, 68, 0.1);
color: var(--red);
}
.no-departments-hint {
color: var(--text-muted);
font-size: 13px;
text-align: center;
padding: 12px;
}
/* ==================== ADD DEPARTMENT BUTTON ==================== */
.add-department-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 12px;
background: rgba(255, 255, 255, 0.03);
border: 1px dashed rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: var(--text-muted);
font-family: inherit;
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.add-department-btn:hover {
background: rgba(255, 255, 255, 0.06);
border-color: var(--accent);
color: var(--accent);
}
.add-department-btn i {
width: 16px;
height: 16px;
}
/* ==================== FOOTER ==================== */
.footer-left {

View File

@@ -22,6 +22,7 @@
<div class="comment-actions">
<IconButton
v-if="canReply"
icon="reply"
variant="ghost"
small
@@ -110,6 +111,10 @@ const props = defineProps({
type: Boolean,
default: false
},
canReply: {
type: Boolean,
default: true
},
getFullUrl: {
type: Function,
required: true

View File

@@ -17,6 +17,7 @@
:level="comment.level"
:is-own="comment.id_accounts === currentUserId"
:can-edit="canEditComment(comment)"
:can-reply="canComment"
:get-full-url="getFullUrl"
@reply="startReply"
@edit="startEditComment"
@@ -68,6 +69,7 @@ import ContentEditorPanel from './ContentEditorPanel.vue'
import Loader from '../ui/Loader.vue'
import { commentsApi, commentImageApi, getFullUrl } from '../../api'
import { useLucideIcons } from '../../composables/useLucideIcons'
import { useAutoRefresh } from '../../composables/useAutoRefresh'
const props = defineProps({
taskId: {
@@ -150,8 +152,12 @@ const editorAvatarUrl = computed(() => {
// Refs
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(() => {
@@ -243,23 +249,7 @@ const loadComments = async (silent = false) => {
}
}
// Автообновление
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
}
}
// startRefresh и stopRefresh определены через useAutoRefresh выше
// ========== Редактор: открытие/закрытие ==========
@@ -402,6 +392,8 @@ const updateComment = async (text, newFiles, filesToDelete) => {
// ========== Редактирование (права) ==========
const canEditComment = (comment) => {
// Для архивных задач — нельзя редактировать комментарии
if (!props.canComment) return false
return comment.id_accounts === props.currentUserId || props.isProjectAdmin
}

View File

@@ -49,8 +49,8 @@
/>
</template>
<!-- Footer: скрываем на вкладке комментариев или если нет прав редактировать -->
<template #footer v-if="activeTab !== 'comments' && canEdit">
<!-- Footer: скрываем на вкладке комментариев, показываем если есть права редактировать или это архивная задача -->
<template #footer v-if="activeTab !== 'comments' && (canEdit || isArchived)">
<div class="footer-left">
<IconButton
v-if="!isNew"
@@ -74,13 +74,19 @@
@click="handleRestore"
/>
</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>
@@ -228,14 +234,18 @@ const canArchive = computed(() => {
})
// Право на редактирование (для новой — 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)
})
@@ -429,7 +439,7 @@ watch(() => props.show, async (newVal) => {
isSaving.value = false // Сброс состояния кнопки сохранения
// Обновляем права пользователя (могли измениться администратором)
store.fetchUsers()
await store.fetchUsers()
// Reset comments tab
commentsTabRef.value?.reset()
@@ -472,4 +482,21 @@ watch(() => props.show, async (newVal) => {
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>

View File

@@ -11,7 +11,8 @@
<ConfirmDialog
v-model:show="showDialog"
type="logout"
:action="logout"
:action="logoutAll"
:discard-action="logoutCurrent"
/>
</template>
@@ -38,13 +39,22 @@ const router = useRouter()
const store = useProjectsStore()
const showDialog = ref(false)
const logout = async () => {
// Выход с текущей сессии (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()
}

View File

@@ -1,6 +1,6 @@
<template>
<!-- Кнопка-триггер -->
<button class="mobile-select-btn" :class="[variant, { compact }]" @click="open = true">
<button class="mobile-select-btn" :class="[variant, { compact, 'has-selection': hasSelection }]" @click="open = true">
<i v-if="icon" :data-lucide="icon" class="btn-icon"></i>
<span v-if="!compact" class="btn-label">{{ displayValue }}</span>
<i data-lucide="chevron-down" class="btn-arrow"></i>
@@ -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
@@ -191,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;

View File

@@ -9,6 +9,7 @@
:alt="selectedOption.label"
class="option-avatar"
>
<span v-else-if="selectedOption?.label" class="avatar-placeholder">{{ selectedOption.label[0] }}</span>
<span v-else-if="!selectedOption" class="no-selection-icon"></span>
<span class="selected-label">{{ selectedOption?.label || placeholder }}</span>
</slot>
@@ -54,6 +55,7 @@
: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>
<a
@@ -127,13 +129,14 @@
: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>
<a
v-if="option.subtitle"
@@ -498,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;
@@ -703,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 {

View File

@@ -316,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,
@@ -334,8 +334,7 @@ onUnmounted(() => {
.panel-enter-from .panel,
.panel-leave-to .panel {
opacity: 0;
transform: scale(0.98);
transform: translateX(100%);
}
/* На мобильных убираем transform из анимации - он ломает layout в iOS PWA */

View File

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

View File

@@ -92,11 +92,22 @@ export const DIALOGS = {
variant: 'danger'
},
// Выход из системы
// Выход из системы (3 кнопки)
logout: {
title: 'Выйти из аккаунта?',
message: 'Вы будете перенаправлены<br>на страницу входа.',
confirmText: 'Выйти',
variant: 'warning'
message: 'Выберите, откуда хотите выйти',
confirmText: 'Все сессии',
discardText: 'Текущая сессия',
showDiscard: true,
variant: 'danger',
discardVariant: 'warning'
},
// Удаление отдела
deleteDepartment: {
title: 'Удалить отдел?',
message: '', // Будет задан динамически
confirmText: 'Удалить',
variant: 'danger'
}
}

View File

@@ -596,6 +596,78 @@ export const useProjectsStore = defineStore('projects', () => {
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 {
// Состояние
projects,
@@ -646,6 +718,13 @@ export const useProjectsStore = defineStore('projects', () => {
getColumnTasksCount,
deleteColumn,
reorderColumns,
setReadyColumn
setReadyColumn,
// CRUD отделов
addDepartment,
addDepartmentToProject,
updateDepartment,
getDepartmentTasksCount,
deleteDepartment,
reorderDepartments
}
})

View File

@@ -16,10 +16,6 @@
<div class="float-icon icon-12"><i data-lucide="message-circle"></i></div>
</div>
<!-- Градиентные сферы на фоне -->
<div class="bg-glow glow-2"></div>
<div class="bg-glow glow-3"></div>
<!-- Контент авторизации -->
<div class="login-content" :class="{ 'is-loading': isProcessing, 'is-success': showSuccess }">
@@ -375,7 +371,9 @@ const handleLogin = async () => {
const data = await authApi.login(login.value, password.value)
if (data.success) {
setAuthCache(true)
// Получаем данные пользователя для кэша
const checkResult = await authApi.check()
setAuthCache(true, checkResult.user || null)
showSuccess.value = true
await nextTick()
refreshIcons()
@@ -421,7 +419,9 @@ const handleRegister = async () => {
const loginResult = await authApi.login(regUsername.value.trim(), regPassword.value)
if (loginResult.success) {
setAuthCache(true)
// Получаем данные пользователя для кэша
const checkResult = await authApi.check()
setAuthCache(true, checkResult.user || null)
showSuccess.value = true
await nextTick()
refreshIcons()
@@ -485,44 +485,6 @@ watch([showSuccess, isRegisterMode], () => {
padding: 20px;
}
/* Градиентные сферы на фоне */
.bg-glow {
position: absolute;
border-radius: 50%;
filter: blur(120px);
opacity: 0.4;
pointer-events: none;
}
.glow-2 {
width: 500px;
height: 500px;
background: radial-gradient(circle, rgba(96, 165, 250, 0.25) 0%, transparent 70%);
bottom: -150px;
left: -100px;
animation: glowFloat2 10s ease-in-out infinite;
}
.glow-3 {
width: 400px;
height: 400px;
background: radial-gradient(circle, rgba(244, 114, 182, 0.2) 0%, transparent 70%);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation: glowFloat3 12s ease-in-out infinite;
}
@keyframes glowFloat2 {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(40px, -20px) scale(1.05); }
}
@keyframes glowFloat3 {
0%, 100% { transform: translate(-50%, -50%) scale(1); opacity: 0.3; }
50% { transform: translate(-50%, -50%) scale(1.2); opacity: 0.5; }
}
/* Левитирующие иконки */
.floating-icons {
position: absolute;

View File

@@ -119,6 +119,7 @@ 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()
@@ -237,37 +238,25 @@ const onProjectSaved = async (projectId) => {
}
// ==================== АВТООБНОВЛЕНИЕ (POLLING) ====================
const CARDS_REFRESH_INTERVAL = (window.APP_CONFIG?.REFRESH_INTERVALS?.cards ?? 30) * 1000
const INVITES_REFRESH_INTERVAL = (window.APP_CONFIG?.REFRESH_INTERVALS?.invites ?? 30) * 1000
let pollTimer = null
let invitesPollTimer = null
const { 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 = () => {
// Polling карточек
if (pollTimer) clearInterval(pollTimer)
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)
startCardsPolling()
startInvitesPolling()
}
const stopPolling = () => {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
if (invitesPollTimer) {
clearInterval(invitesPollTimer)
invitesPollTimer = null
}
stopCardsPolling()
stopInvitesPolling()
}
// ==================== ИНИЦИАЛИЗАЦИЯ ====================

View File

@@ -96,13 +96,14 @@
</template>
<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 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()
@@ -243,27 +244,21 @@ const refreshIcons = () => {
}
// Периодическое обновление приглашений
let refreshInterval = null
const REFRESH_MS = (window.APP_CONFIG?.REFRESH_INTERVALS?.invites || 30) * 1000
const { start: startInvitesRefresh, stop: stopInvitesRefresh } = useAutoRefresh('invites', async () => {
// Не обновляем если показывается анимация успеха
if (!showSuccess.value) {
await loadInvites()
}
})
onMounted(() => {
loadInvites()
refreshIcons()
// Запускаем периодическое обновление
refreshInterval = setInterval(() => {
// Не обновляем если показывается анимация успеха
if (!showSuccess.value) {
loadInvites()
}
}, REFRESH_MS)
startInvitesRefresh()
})
onUnmounted(() => {
if (refreshInterval) {
clearInterval(refreshInterval)
refreshInterval = null
}
stopInvitesRefresh()
})
onUpdated(refreshIcons)

View File

@@ -11,7 +11,7 @@
Target Server Version : 90200 (9.2.0)
File Encoding : 65001
Date: 18/01/2026 17:17:50
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 = 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
@@ -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 = 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
@@ -69,7 +69,7 @@ CREATE TABLE `cards_task` (
`id_project` int NULL DEFAULT NULL,
`create_id_account` int NULL DEFAULT NULL,
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
@@ -82,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 = 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
@@ -97,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 = 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
@@ -108,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
@@ -134,7 +135,7 @@ CREATE TABLE `project` (
`id_ready` int NULL DEFAULT NULL,
`id_admin` json NULL,
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
@@ -152,7 +153,7 @@ CREATE TABLE `project_invites` (
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_to_user`(`id_to_user` ASC, `status` ASC) USING BTREE,
INDEX `idx_project`(`id_project` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
) ENGINE = InnoDB AUTO_INCREMENT = 21 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- 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,
INDEX `idx_project`(`id_project` ASC) USING BTREE,
INDEX `idx_user`(`id_user` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
) ENGINE = InnoDB AUTO_INCREMENT = 22 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
SET FOREIGN_KEY_CHECKS = 1;