Управление проектами
Добавил возможность удаления, создание и редактирование проектов.
This commit is contained in:
@@ -5,6 +5,7 @@ $method = $_SERVER['REQUEST_METHOD'];
|
|||||||
if ($method === 'POST') {
|
if ($method === 'POST') {
|
||||||
$data = RestApi::getInput();
|
$data = RestApi::getInput();
|
||||||
$action = $data['action'] ?? null;
|
$action = $data['action'] ?? null;
|
||||||
|
$user_id = RestApi::getCurrentUserId();
|
||||||
|
|
||||||
// Получение данных проекта (проект + колонки + отделы)
|
// Получение данных проекта (проект + колонки + отделы)
|
||||||
if ($action === 'get_project_data') {
|
if ($action === 'get_project_data') {
|
||||||
@@ -17,6 +18,117 @@ if ($method === 'POST') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== CRUD ПРОЕКТОВ ====================
|
||||||
|
|
||||||
|
// Создание проекта
|
||||||
|
if ($action === 'create') {
|
||||||
|
$name = trim($data['name'] ?? '');
|
||||||
|
if (!$name) {
|
||||||
|
RestApi::response(['success' => false, 'errors' => ['name' => 'Укажите название проекта']], 400);
|
||||||
|
}
|
||||||
|
$result = Project::create($name, $user_id);
|
||||||
|
RestApi::response($result, $result['success'] ? 200 : 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление проекта
|
||||||
|
if ($action === 'update') {
|
||||||
|
$id = $data['id'] ?? null;
|
||||||
|
$name = trim($data['name'] ?? '');
|
||||||
|
if (!$id || !$name) {
|
||||||
|
RestApi::response(['success' => false, 'errors' => ['data' => 'Укажите ID и название']], 400);
|
||||||
|
}
|
||||||
|
$result = Project::update($id, $name, $user_id);
|
||||||
|
RestApi::response($result, $result['success'] ? 200 : 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаление проекта
|
||||||
|
if ($action === 'delete') {
|
||||||
|
$id = $data['id'] ?? null;
|
||||||
|
if (!$id) {
|
||||||
|
RestApi::response(['success' => false, 'errors' => ['id' => 'Укажите ID проекта']], 400);
|
||||||
|
}
|
||||||
|
$result = Project::delete($id, $user_id);
|
||||||
|
RestApi::response($result, $result['success'] ? 200 : 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление порядка проектов
|
||||||
|
if ($action === 'update_order') {
|
||||||
|
$ids = $data['ids'] ?? [];
|
||||||
|
if (empty($ids)) {
|
||||||
|
RestApi::response(['success' => false, 'errors' => ['ids' => 'Укажите массив ID']], 400);
|
||||||
|
}
|
||||||
|
$result = Project::updateOrder($ids, $user_id);
|
||||||
|
RestApi::response($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== CRUD КОЛОНОК ====================
|
||||||
|
|
||||||
|
// Добавление колонки
|
||||||
|
if ($action === 'add_column') {
|
||||||
|
$project_id = $data['project_id'] ?? null;
|
||||||
|
$name = trim($data['name'] ?? '');
|
||||||
|
$color = $data['color'] ?? '#6366f1';
|
||||||
|
if (!$project_id || !$name) {
|
||||||
|
RestApi::response(['success' => false, 'errors' => ['data' => 'Укажите project_id и name']], 400);
|
||||||
|
}
|
||||||
|
$result = Project::addColumn($project_id, $name, $color, $user_id);
|
||||||
|
RestApi::response($result, $result['success'] ? 200 : 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление колонки
|
||||||
|
if ($action === 'update_column') {
|
||||||
|
$id = $data['id'] ?? null;
|
||||||
|
$name = isset($data['name']) ? trim($data['name']) : null;
|
||||||
|
$color = $data['color'] ?? null;
|
||||||
|
if (!$id) {
|
||||||
|
RestApi::response(['success' => false, 'errors' => ['id' => 'Укажите ID колонки']], 400);
|
||||||
|
}
|
||||||
|
$result = Project::updateColumn($id, $name, $color, $user_id);
|
||||||
|
RestApi::response($result, $result['success'] ? 200 : 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получение количества задач в колонке (для подтверждения удаления)
|
||||||
|
if ($action === 'get_column_tasks_count') {
|
||||||
|
$id = $data['id'] ?? null;
|
||||||
|
if (!$id) {
|
||||||
|
RestApi::response(['success' => false, 'errors' => ['id' => 'Укажите ID колонки']], 400);
|
||||||
|
}
|
||||||
|
$count = Project::getColumnTasksCount($id);
|
||||||
|
RestApi::response(['success' => true, 'count' => $count]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаление колонки
|
||||||
|
if ($action === 'delete_column') {
|
||||||
|
$id = $data['id'] ?? null;
|
||||||
|
if (!$id) {
|
||||||
|
RestApi::response(['success' => false, 'errors' => ['id' => 'Укажите ID колонки']], 400);
|
||||||
|
}
|
||||||
|
$result = Project::deleteColumn($id, $user_id);
|
||||||
|
RestApi::response($result, $result['success'] ? 200 : 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление порядка колонок
|
||||||
|
if ($action === 'update_columns_order') {
|
||||||
|
$project_id = $data['project_id'] ?? null;
|
||||||
|
$ids = $data['ids'] ?? [];
|
||||||
|
if (!$project_id || empty($ids)) {
|
||||||
|
RestApi::response(['success' => false, 'errors' => ['data' => 'Укажите project_id и ids']], 400);
|
||||||
|
}
|
||||||
|
$result = Project::updateColumnsOrder($project_id, $ids, $user_id);
|
||||||
|
RestApi::response($result, $result['success'] ? 200 : 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Установка финальной колонки
|
||||||
|
if ($action === 'set_ready_column') {
|
||||||
|
$project_id = $data['project_id'] ?? null;
|
||||||
|
$column_id = $data['column_id'] ?? null;
|
||||||
|
if (!$project_id || !$column_id) {
|
||||||
|
RestApi::response(['success' => false, 'errors' => ['data' => 'Укажите project_id и column_id']], 400);
|
||||||
|
}
|
||||||
|
$result = Project::setReadyColumn($project_id, $column_id, $user_id);
|
||||||
|
RestApi::response($result, $result['success'] ? 200 : 403);
|
||||||
|
}
|
||||||
|
|
||||||
// Метод не указан
|
// Метод не указан
|
||||||
if (!$action) {
|
if (!$action) {
|
||||||
RestApi::response(['success' => false, 'error' => 'Укажите метод'], 400);
|
RestApi::response(['success' => false, 'error' => 'Укажите метод'], 400);
|
||||||
|
|||||||
@@ -124,6 +124,265 @@ class Project extends BaseEntity {
|
|||||||
$admins = json_decode($project['id_admin'], true) ?: [];
|
$admins = json_decode($project['id_admin'], true) ?: [];
|
||||||
return in_array((int)$user_id, $admins, true);
|
return in_array((int)$user_id, $admins, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== CRUD ПРОЕКТОВ ====================
|
||||||
|
|
||||||
|
// Создание проекта с дефолтными колонками
|
||||||
|
public static function create($name, $user_id) {
|
||||||
|
// Получаем максимальный id_order
|
||||||
|
$maxOrder = Database::max('project', 'id_order') ?? 0;
|
||||||
|
|
||||||
|
// Создаём проект
|
||||||
|
Database::insert('project', [
|
||||||
|
'name' => $name,
|
||||||
|
'id_order' => $maxOrder + 1,
|
||||||
|
'id_admin' => json_encode([(int)$user_id]),
|
||||||
|
'id_member' => json_encode([])
|
||||||
|
]);
|
||||||
|
|
||||||
|
$projectId = Database::id();
|
||||||
|
if (!$projectId) {
|
||||||
|
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
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление проекта
|
||||||
|
public static function update($id, $name, $user_id) {
|
||||||
|
// Проверяем права
|
||||||
|
if (!self::isAdmin($id, $user_id)) {
|
||||||
|
return ['success' => false, 'errors' => ['access' => 'Нет прав на редактирование']];
|
||||||
|
}
|
||||||
|
|
||||||
|
Database::update('project', ['name' => $name], ['id' => $id]);
|
||||||
|
return ['success' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаление проекта (каскадно: колонки, задачи, комментарии, файлы)
|
||||||
|
public static function delete($id, $user_id) {
|
||||||
|
// Проверяем права
|
||||||
|
if (!self::isAdmin($id, $user_id)) {
|
||||||
|
return ['success' => false, 'errors' => ['access' => 'Нет прав на удаление']];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем все задачи проекта
|
||||||
|
$tasks = Database::select('cards_task', ['id'], ['id_project' => $id]);
|
||||||
|
$taskIds = array_column($tasks, 'id');
|
||||||
|
|
||||||
|
if (!empty($taskIds)) {
|
||||||
|
// Получаем все комментарии к задачам
|
||||||
|
$comments = Database::select('comments', ['id'], ['id_task' => $taskIds]);
|
||||||
|
|
||||||
|
// Удаляем файлы комментариев с диска
|
||||||
|
foreach ($comments as $comment) {
|
||||||
|
FileUpload::deleteFolder('comment', $comment['id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаляем комментарии из БД
|
||||||
|
Database::delete('comments', ['id_task' => $taskIds]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаляем файлы задач с диска
|
||||||
|
foreach ($tasks as $task) {
|
||||||
|
FileUpload::deleteFolder('task', $task['id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаляем задачи из БД
|
||||||
|
Database::delete('cards_task', ['id_project' => $id]);
|
||||||
|
|
||||||
|
// Удаляем колонки
|
||||||
|
Database::delete('columns', ['id_project' => $id]);
|
||||||
|
|
||||||
|
// Удаляем отделы проекта
|
||||||
|
Database::delete('departments', ['id_project' => $id]);
|
||||||
|
|
||||||
|
// Удаляем проект
|
||||||
|
Database::delete('project', ['id' => $id]);
|
||||||
|
|
||||||
|
return ['success' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление порядка проектов
|
||||||
|
public static function updateOrder($ids, $user_id) {
|
||||||
|
foreach ($ids as $order => $id) {
|
||||||
|
Database::update('project', ['id_order' => $order + 1], ['id' => $id]);
|
||||||
|
}
|
||||||
|
return ['success' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== CRUD КОЛОНОК ====================
|
||||||
|
|
||||||
|
// Добавление колонки
|
||||||
|
public static function addColumn($project_id, $name, $color, $user_id) {
|
||||||
|
// Проверяем права
|
||||||
|
if (!self::isAdmin($project_id, $user_id)) {
|
||||||
|
return ['success' => false, 'errors' => ['access' => 'Нет прав на редактирование']];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем максимальный id_order для проекта
|
||||||
|
$maxOrder = Database::max('columns', 'id_order', ['id_project' => $project_id]) ?? 0;
|
||||||
|
|
||||||
|
Database::insert('columns', [
|
||||||
|
'name_columns' => $name,
|
||||||
|
'color' => $color ?: '#6366f1',
|
||||||
|
'id_project' => $project_id,
|
||||||
|
'id_order' => $maxOrder + 1
|
||||||
|
]);
|
||||||
|
|
||||||
|
$columnId = Database::id();
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'id' => $columnId,
|
||||||
|
'column' => [
|
||||||
|
'id' => $columnId,
|
||||||
|
'name_columns' => $name,
|
||||||
|
'color' => $color ?: '#6366f1',
|
||||||
|
'id_order' => $maxOrder + 1
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление колонки
|
||||||
|
public static function updateColumn($id, $name, $color, $user_id) {
|
||||||
|
// Получаем колонку для проверки проекта
|
||||||
|
$column = Database::get('columns', ['id_project'], ['id' => $id]);
|
||||||
|
if (!$column) {
|
||||||
|
return ['success' => false, 'errors' => ['column' => 'Колонка не найдена']];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем права
|
||||||
|
if (!self::isAdmin($column['id_project'], $user_id)) {
|
||||||
|
return ['success' => false, 'errors' => ['access' => 'Нет прав на редактирование']];
|
||||||
|
}
|
||||||
|
|
||||||
|
$updateData = [];
|
||||||
|
if ($name !== null) $updateData['name_columns'] = $name;
|
||||||
|
if ($color !== null) $updateData['color'] = $color;
|
||||||
|
|
||||||
|
if (!empty($updateData)) {
|
||||||
|
Database::update('columns', $updateData, ['id' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['success' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаление колонки (возвращает количество задач для подтверждения)
|
||||||
|
public static function getColumnTasksCount($column_id) {
|
||||||
|
return Database::count('cards_task', ['column_id' => $column_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаление колонки с задачами
|
||||||
|
public static function deleteColumn($id, $user_id) {
|
||||||
|
// Получаем колонку для проверки проекта
|
||||||
|
$column = Database::get('columns', ['id_project', 'id_order'], ['id' => $id]);
|
||||||
|
if (!$column) {
|
||||||
|
return ['success' => false, 'errors' => ['column' => 'Колонка не найдена']];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем права
|
||||||
|
if (!self::isAdmin($column['id_project'], $user_id)) {
|
||||||
|
return ['success' => false, 'errors' => ['access' => 'Нет прав на удаление']];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, не является ли это последней колонкой перед "Готово"
|
||||||
|
// Минимум должно быть 3 колонки (2 обычные + готово), чтобы можно было удалить
|
||||||
|
$columnsCount = Database::count('columns', ['id_project' => $column['id_project']]);
|
||||||
|
if ($columnsCount <= 2) {
|
||||||
|
return ['success' => false, 'errors' => ['column' => 'Нельзя удалить последнюю колонку перед финальной']];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, не является ли это последней (финальной) колонкой
|
||||||
|
$lastColumn = Database::get('columns', ['id'], [
|
||||||
|
'id_project' => $column['id_project'],
|
||||||
|
'ORDER' => ['id_order' => 'DESC'],
|
||||||
|
'LIMIT' => 1
|
||||||
|
]);
|
||||||
|
if ($lastColumn && (int)$lastColumn['id'] === (int)$id) {
|
||||||
|
return ['success' => false, 'errors' => ['column' => 'Нельзя удалить финальную колонку (последнюю)']];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем задачи колонки
|
||||||
|
$tasks = Database::select('cards_task', ['id'], ['column_id' => $id]);
|
||||||
|
$taskIds = array_column($tasks, 'id');
|
||||||
|
|
||||||
|
// Удаляем комментарии к задачам
|
||||||
|
if (!empty($taskIds)) {
|
||||||
|
Database::delete('comments', ['id_task' => $taskIds]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаляем задачи
|
||||||
|
Database::delete('cards_task', ['column_id' => $id]);
|
||||||
|
|
||||||
|
// Удаляем колонку
|
||||||
|
Database::delete('columns', ['id' => $id]);
|
||||||
|
|
||||||
|
// Обновляем id_ready на новую последнюю колонку
|
||||||
|
$newLastColumn = Database::get('columns', ['id'], [
|
||||||
|
'id_project' => $column['id_project'],
|
||||||
|
'ORDER' => ['id_order' => 'DESC'],
|
||||||
|
'LIMIT' => 1
|
||||||
|
]);
|
||||||
|
if ($newLastColumn) {
|
||||||
|
Database::update('project', ['id_ready' => $newLastColumn['id']], ['id' => $column['id_project']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['success' => true, 'deleted_tasks' => count($taskIds)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление порядка колонок
|
||||||
|
public static function updateColumnsOrder($project_id, $ids, $user_id) {
|
||||||
|
// Проверяем права
|
||||||
|
if (!self::isAdmin($project_id, $user_id)) {
|
||||||
|
return ['success' => false, 'errors' => ['access' => 'Нет прав на редактирование']];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($ids as $order => $id) {
|
||||||
|
Database::update('columns', ['id_order' => $order + 1], ['id' => $id, 'id_project' => $project_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Автоматически устанавливаем id_ready на последнюю колонку
|
||||||
|
$lastColumnId = end($ids);
|
||||||
|
if ($lastColumnId) {
|
||||||
|
Database::update('project', ['id_ready' => $lastColumnId], ['id' => $project_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['success' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Установка финальной колонки (id_ready) - ЗАБЛОКИРОВАНО
|
||||||
|
// Финальная колонка всегда последняя, изменение запрещено
|
||||||
|
public static function setReadyColumn($project_id, $column_id, $user_id) {
|
||||||
|
return ['success' => false, 'errors' => ['column' => 'Изменение финальной колонки запрещено. Финальная колонка всегда последняя.']];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
?>
|
?>
|
||||||
|
|||||||
@@ -64,6 +64,77 @@ export const projectsApi = {
|
|||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ action: 'get_project_data', id_project })
|
body: JSON.stringify({ action: 'get_project_data', id_project })
|
||||||
|
}),
|
||||||
|
// Создание проекта
|
||||||
|
create: (name) => request('/api/project', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'create', name })
|
||||||
|
}),
|
||||||
|
// Обновление проекта
|
||||||
|
update: (id, name) => request('/api/project', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'update', id, name })
|
||||||
|
}),
|
||||||
|
// Удаление проекта
|
||||||
|
delete: (id) => request('/api/project', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'delete', id })
|
||||||
|
}),
|
||||||
|
// Обновление порядка проектов
|
||||||
|
updateOrder: (ids) => request('/api/project', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'update_order', ids })
|
||||||
|
}),
|
||||||
|
// ==================== КОЛОНКИ ====================
|
||||||
|
// Добавление колонки
|
||||||
|
addColumn: (project_id, name, color) => request('/api/project', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'add_column', project_id, name, color })
|
||||||
|
}),
|
||||||
|
// Обновление колонки
|
||||||
|
updateColumn: (id, name, color) => request('/api/project', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'update_column', id, name, color })
|
||||||
|
}),
|
||||||
|
// Получение количества задач в колонке
|
||||||
|
getColumnTasksCount: (id) => request('/api/project', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'get_column_tasks_count', id })
|
||||||
|
}),
|
||||||
|
// Удаление колонки
|
||||||
|
deleteColumn: (id) => request('/api/project', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'delete_column', id })
|
||||||
|
}),
|
||||||
|
// Обновление порядка колонок
|
||||||
|
updateColumnsOrder: (project_id, ids) => request('/api/project', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'update_columns_order', project_id, ids })
|
||||||
|
}),
|
||||||
|
// Установка финальной колонки
|
||||||
|
setReadyColumn: (project_id, column_id) => request('/api/project', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'set_ready_column', project_id, column_id })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<ProjectSelector @change="$emit('project-change')" />
|
<ProjectSelector
|
||||||
|
@change="$emit('project-change')"
|
||||||
|
@edit="$emit('edit-project', $event)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="add-project-btn"
|
||||||
|
title="Создать проект"
|
||||||
|
@click="$emit('create-project')"
|
||||||
|
>
|
||||||
|
<i data-lucide="plus"></i>
|
||||||
|
</button>
|
||||||
<div class="filter-divider"></div>
|
<div class="filter-divider"></div>
|
||||||
<button
|
<button
|
||||||
class="filter-tag"
|
class="filter-tag"
|
||||||
@@ -22,6 +32,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { onMounted, onUpdated } from 'vue'
|
||||||
import ProjectSelector from './ProjectSelector.vue'
|
import ProjectSelector from './ProjectSelector.vue'
|
||||||
import { useProjectsStore } from '../stores/projects'
|
import { useProjectsStore } from '../stores/projects'
|
||||||
|
|
||||||
@@ -34,7 +45,39 @@ defineProps({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
defineEmits(['update:modelValue', 'project-change'])
|
defineEmits(['update:modelValue', 'project-change', 'create-project', 'edit-project'])
|
||||||
|
|
||||||
|
const refreshIcons = () => {
|
||||||
|
if (window.lucide) window.lucide.createIcons()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(refreshIcons)
|
||||||
|
onUpdated(refreshIcons)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Стили определены в PageLayout.vue через :deep(.filters) -->
|
<style scoped>
|
||||||
|
.add-project-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-project-btn:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-project-btn i {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
856
front_vue/src/components/ProjectPanel.vue
Normal file
856
front_vue/src/components/ProjectPanel.vue
Normal file
@@ -0,0 +1,856 @@
|
|||||||
|
<template>
|
||||||
|
<SlidePanel
|
||||||
|
:show="show"
|
||||||
|
@close="tryClose"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<h2>{{ isNew ? 'Новый проект' : 'Настройки проекта' }}</h2>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #default>
|
||||||
|
<!-- Название проекта -->
|
||||||
|
<div class="form-section">
|
||||||
|
<label class="form-label">Название проекта</label>
|
||||||
|
<TextInput
|
||||||
|
v-model="form.name"
|
||||||
|
placeholder="Введите название"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Колонки -->
|
||||||
|
<div class="form-section">
|
||||||
|
<label class="form-label">Колонки</label>
|
||||||
|
|
||||||
|
<div class="columns-list" ref="columnsListRef">
|
||||||
|
<div
|
||||||
|
v-for="(column, index) in form.columns"
|
||||||
|
:key="column.id || column.tempId"
|
||||||
|
class="column-item"
|
||||||
|
:class="{
|
||||||
|
'is-ready': isReadyColumn(column),
|
||||||
|
'is-dragging': !isMobile && !isLastColumn(index) && dragIndex === index,
|
||||||
|
'drag-over-top': !isMobile && dragOverIndex === index && dragPosition === 'top',
|
||||||
|
'drag-over-bottom': !isMobile && !isLastColumn(index) && dragOverIndex === index && dragPosition === 'bottom'
|
||||||
|
}"
|
||||||
|
:draggable="!isMobile && !isLastColumn(index)"
|
||||||
|
@dragstart="!isMobile && !isLastColumn(index) && handleColumnDragStart($event, index)"
|
||||||
|
@dragend="!isMobile && handleColumnDragEnd()"
|
||||||
|
@dragover.prevent="!isMobile && handleColumnDragOver($event, index)"
|
||||||
|
@dragleave="!isMobile && handleColumnDragLeave()"
|
||||||
|
@drop.prevent="!isMobile && handleColumnDrop(index)"
|
||||||
|
>
|
||||||
|
<!-- Desktop: drag handle (не показываем для последней колонки и если только 2 колонки) -->
|
||||||
|
<div v-if="!isMobile && !isLastColumn(index) && canReorderColumns" class="column-drag-handle">
|
||||||
|
<i data-lucide="grip-vertical"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile: up/down arrows (скрываем если обе кнопки были бы неактивны) -->
|
||||||
|
<div v-else-if="isMobile && !isLastColumn(index) && (index > 0 || index < form.columns.length - 2)" class="column-move-buttons">
|
||||||
|
<button
|
||||||
|
class="move-btn"
|
||||||
|
:disabled="index === 0"
|
||||||
|
@click="moveColumnUp(index)"
|
||||||
|
title="Вверх"
|
||||||
|
>
|
||||||
|
<i data-lucide="chevron-up"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="move-btn"
|
||||||
|
:disabled="index === form.columns.length - 2"
|
||||||
|
@click="moveColumnDown(index)"
|
||||||
|
title="Вниз"
|
||||||
|
>
|
||||||
|
<i data-lucide="chevron-down"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ColorPicker v-model="column.color" />
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="column-name"
|
||||||
|
:ref="el => columnInputRefs[index] = el"
|
||||||
|
v-model="column.name_columns"
|
||||||
|
placeholder="Название колонки"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Иконка редактирования -->
|
||||||
|
<button
|
||||||
|
class="column-edit-btn"
|
||||||
|
title="Редактировать название"
|
||||||
|
@click="focusColumnName(index)"
|
||||||
|
>
|
||||||
|
<i data-lucide="pencil"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Маркер финальной колонки (только отображение) -->
|
||||||
|
<span
|
||||||
|
v-if="isLastColumn(index)"
|
||||||
|
class="column-ready-marker"
|
||||||
|
title="Финальная колонка (Готово)"
|
||||||
|
>
|
||||||
|
<i data-lucide="check-circle"></i>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Кнопка удаления: скрыта для последней колонки и если только 2 колонки -->
|
||||||
|
<button
|
||||||
|
v-if="!isLastColumn(index) && form.columns.length > 2"
|
||||||
|
class="column-delete-btn"
|
||||||
|
title="Удалить колонку"
|
||||||
|
@click="confirmDeleteColumn(index)"
|
||||||
|
>
|
||||||
|
<i data-lucide="trash-2"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="add-column-btn" @click="addColumn">
|
||||||
|
<i data-lucide="plus"></i>
|
||||||
|
Добавить колонку
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="footer-left">
|
||||||
|
<IconButton
|
||||||
|
v-if="!isNew"
|
||||||
|
icon="trash-2"
|
||||||
|
variant="danger"
|
||||||
|
title="Удалить проект"
|
||||||
|
@click="handleDeleteProject"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ActionButtons
|
||||||
|
:save-text="isNew ? 'Создать' : 'Сохранить'"
|
||||||
|
:loading="isSaving"
|
||||||
|
:disabled="!canSave"
|
||||||
|
@save="handleSave"
|
||||||
|
@cancel="tryClose"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</SlidePanel>
|
||||||
|
|
||||||
|
<!-- Диалог удаления проекта -->
|
||||||
|
<ConfirmDialog
|
||||||
|
:show="showDeleteProjectDialog"
|
||||||
|
type="deleteProject"
|
||||||
|
:action="confirmDeleteProject"
|
||||||
|
@confirm="showDeleteProjectDialog = false"
|
||||||
|
@cancel="showDeleteProjectDialog = false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Диалог удаления колонки -->
|
||||||
|
<ConfirmDialog
|
||||||
|
:show="showDeleteColumnDialog"
|
||||||
|
type="deleteColumn"
|
||||||
|
:message="deleteColumnMessage"
|
||||||
|
:action="confirmDeleteColumnAction"
|
||||||
|
@confirm="showDeleteColumnDialog = false"
|
||||||
|
@cancel="showDeleteColumnDialog = false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Диалог несохранённых изменений -->
|
||||||
|
<ConfirmDialog
|
||||||
|
:show="showUnsavedDialog"
|
||||||
|
type="unsavedChanges"
|
||||||
|
@confirm="confirmSave"
|
||||||
|
@cancel="showUnsavedDialog = false"
|
||||||
|
@discard="confirmDiscard"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, nextTick } from 'vue'
|
||||||
|
import SlidePanel from './ui/SlidePanel.vue'
|
||||||
|
import TextInput from './ui/TextInput.vue'
|
||||||
|
import ColorPicker from './ui/ColorPicker.vue'
|
||||||
|
import IconButton from './ui/IconButton.vue'
|
||||||
|
import ActionButtons from './ui/ActionButtons.vue'
|
||||||
|
import ConfirmDialog from './ConfirmDialog.vue'
|
||||||
|
import { useLucideIcons } from '../composables/useLucideIcons'
|
||||||
|
import { useMobile } from '../composables/useMobile'
|
||||||
|
import { useProjectsStore } from '../stores/projects'
|
||||||
|
|
||||||
|
const { refreshIcons } = useLucideIcons()
|
||||||
|
const { isMobile } = useMobile()
|
||||||
|
const store = useProjectsStore()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: Boolean,
|
||||||
|
project: Object // null = создание, object = редактирование
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'saved'])
|
||||||
|
|
||||||
|
// State
|
||||||
|
const isNew = computed(() => !props.project)
|
||||||
|
const isSaving = ref(false)
|
||||||
|
|
||||||
|
// Form data
|
||||||
|
const form = ref({
|
||||||
|
name: '',
|
||||||
|
columns: []
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initial form for change detection
|
||||||
|
const initialForm = ref(null)
|
||||||
|
|
||||||
|
// Dialogs
|
||||||
|
const showDeleteProjectDialog = ref(false)
|
||||||
|
const showDeleteColumnDialog = ref(false)
|
||||||
|
const showUnsavedDialog = ref(false)
|
||||||
|
const columnToDelete = ref(null)
|
||||||
|
const deleteColumnMessage = ref('')
|
||||||
|
|
||||||
|
// Drag state
|
||||||
|
const dragIndex = ref(null)
|
||||||
|
const dragOverIndex = ref(null)
|
||||||
|
const dragPosition = ref(null) // 'top' | 'bottom'
|
||||||
|
const columnsListRef = ref(null)
|
||||||
|
const columnInputRefs = ref([])
|
||||||
|
|
||||||
|
// Temp ID counter for new columns
|
||||||
|
let tempIdCounter = 0
|
||||||
|
|
||||||
|
// Can save
|
||||||
|
const canSave = computed(() => {
|
||||||
|
return form.value.name?.trim() && form.value.columns.length > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// Можно ли сортировать колонки (больше 2 колонок = минимум 2 перемещаемые + ready)
|
||||||
|
const canReorderColumns = computed(() => {
|
||||||
|
return form.value.columns.length > 2
|
||||||
|
})
|
||||||
|
|
||||||
|
// Has changes
|
||||||
|
const hasChanges = computed(() => {
|
||||||
|
if (!initialForm.value) return false
|
||||||
|
return JSON.stringify(form.value) !== JSON.stringify(initialForm.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==================== COLUMNS MANAGEMENT ====================
|
||||||
|
|
||||||
|
// Фокус на поле названия колонки
|
||||||
|
const focusColumnName = (index) => {
|
||||||
|
const input = columnInputRefs.value[index]
|
||||||
|
if (input) {
|
||||||
|
input.focus()
|
||||||
|
// Ставим курсор в конец текста
|
||||||
|
const len = input.value.length
|
||||||
|
input.setSelectionRange(len, len)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка является ли колонка последней (финальной)
|
||||||
|
const isLastColumn = (index) => {
|
||||||
|
return index === form.value.columns.length - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для совместимости со стилями (is-ready класс)
|
||||||
|
const isReadyColumn = (column) => {
|
||||||
|
const index = form.value.columns.findIndex(c =>
|
||||||
|
(c.id && c.id === column.id) || (c.tempId && c.tempId === column.tempId)
|
||||||
|
)
|
||||||
|
return isLastColumn(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Цвета для новых колонок
|
||||||
|
const columnColors = [
|
||||||
|
'#ef4444', '#f97316', '#f59e0b', '#eab308', '#84cc16',
|
||||||
|
'#22c55e', '#10b981', '#14b8a6', '#06b6d4', '#0ea5e9',
|
||||||
|
'#3b82f6', '#6366f1', '#8b5cf6', '#a855f7', '#d946ef',
|
||||||
|
'#ec4899', '#f43f5e'
|
||||||
|
]
|
||||||
|
|
||||||
|
const getRandomColor = () => {
|
||||||
|
// Получаем цвета уже используемых колонок
|
||||||
|
const usedColors = form.value.columns.map(c => c.color)
|
||||||
|
// Фильтруем доступные цвета
|
||||||
|
const availableColors = columnColors.filter(c => !usedColors.includes(c))
|
||||||
|
// Если все цвета использованы — берём из полного списка
|
||||||
|
const colorPool = availableColors.length > 0 ? availableColors : columnColors
|
||||||
|
return colorPool[Math.floor(Math.random() * colorPool.length)]
|
||||||
|
}
|
||||||
|
|
||||||
|
const addColumn = () => {
|
||||||
|
// Находим индекс финальной колонки
|
||||||
|
const readyIndex = form.value.columns.findIndex(col => isReadyColumn(col))
|
||||||
|
|
||||||
|
const newColumn = {
|
||||||
|
tempId: `new-${++tempIdCounter}`,
|
||||||
|
name_columns: '',
|
||||||
|
color: getRandomColor(),
|
||||||
|
id_order: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вставляем перед финальной колонкой, или в конец если финальная не найдена
|
||||||
|
if (readyIndex !== -1) {
|
||||||
|
form.value.columns.splice(readyIndex, 0, newColumn)
|
||||||
|
} else {
|
||||||
|
form.value.columns.push(newColumn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем id_order у всех колонок
|
||||||
|
form.value.columns.forEach((col, idx) => {
|
||||||
|
col.id_order = idx + 1
|
||||||
|
})
|
||||||
|
|
||||||
|
nextTick(refreshIcons)
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDeleteColumn = async (index) => {
|
||||||
|
const column = form.value.columns[index]
|
||||||
|
columnToDelete.value = index
|
||||||
|
|
||||||
|
// Для существующих колонок проверяем количество задач
|
||||||
|
if (column.id) {
|
||||||
|
const count = await store.getColumnTasksCount(column.id)
|
||||||
|
if (count > 0) {
|
||||||
|
deleteColumnMessage.value = `В колонке ${count} задач.<br>Все они будут удалены.`
|
||||||
|
} else {
|
||||||
|
deleteColumnMessage.value = 'Колонка будет удалена.'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
deleteColumnMessage.value = 'Колонка будет удалена.'
|
||||||
|
}
|
||||||
|
|
||||||
|
showDeleteColumnDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDeleteColumnAction = async () => {
|
||||||
|
const index = columnToDelete.value
|
||||||
|
const column = form.value.columns[index]
|
||||||
|
|
||||||
|
// Если колонка уже существует на сервере — удаляем через API
|
||||||
|
if (column.id) {
|
||||||
|
const result = await store.deleteColumn(column.id)
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.errors?.column || 'Ошибка удаления')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаляем из формы
|
||||||
|
form.value.columns.splice(index, 1)
|
||||||
|
|
||||||
|
columnToDelete.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== MOBILE: MOVE COLUMNS ====================
|
||||||
|
|
||||||
|
const moveColumnUp = async (index) => {
|
||||||
|
if (index <= 0) return
|
||||||
|
|
||||||
|
const columns = form.value.columns
|
||||||
|
;[columns[index - 1], columns[index]] = [columns[index], columns[index - 1]]
|
||||||
|
|
||||||
|
// Update id_order
|
||||||
|
columns.forEach((col, idx) => {
|
||||||
|
col.id_order = idx + 1
|
||||||
|
})
|
||||||
|
|
||||||
|
// Save to server if editing
|
||||||
|
if (!isNew.value) {
|
||||||
|
const ids = columns.filter(c => c.id).map(c => c.id)
|
||||||
|
if (ids.length > 0) {
|
||||||
|
await store.reorderColumns(ids)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTick(refreshIcons)
|
||||||
|
}
|
||||||
|
|
||||||
|
const moveColumnDown = async (index) => {
|
||||||
|
// Нельзя перемещать последнюю колонку и нельзя менять местами с последней
|
||||||
|
const lastIndex = form.value.columns.length - 1
|
||||||
|
if (index >= lastIndex - 1) return
|
||||||
|
|
||||||
|
const columns = form.value.columns
|
||||||
|
;[columns[index], columns[index + 1]] = [columns[index + 1], columns[index]]
|
||||||
|
|
||||||
|
// Update id_order
|
||||||
|
columns.forEach((col, idx) => {
|
||||||
|
col.id_order = idx + 1
|
||||||
|
})
|
||||||
|
|
||||||
|
// Save to server if editing
|
||||||
|
if (!isNew.value) {
|
||||||
|
const ids = columns.filter(c => c.id).map(c => c.id)
|
||||||
|
if (ids.length > 0) {
|
||||||
|
await store.reorderColumns(ids)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTick(refreshIcons)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== DRAG AND DROP COLUMNS (Desktop) ====================
|
||||||
|
|
||||||
|
const handleColumnDragStart = (e, index) => {
|
||||||
|
dragIndex.value = index
|
||||||
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
|
e.dataTransfer.setData('text/plain', index.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleColumnDragEnd = () => {
|
||||||
|
dragIndex.value = null
|
||||||
|
dragOverIndex.value = null
|
||||||
|
dragPosition.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleColumnDragOver = (e, index) => {
|
||||||
|
if (dragIndex.value === null || dragIndex.value === index) return
|
||||||
|
|
||||||
|
// Нельзя перетаскивать НА последнюю колонку (она зафиксирована)
|
||||||
|
const lastIndex = form.value.columns.length - 1
|
||||||
|
if (index === lastIndex) return
|
||||||
|
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
|
const midY = rect.top + rect.height / 2
|
||||||
|
|
||||||
|
dragOverIndex.value = index
|
||||||
|
|
||||||
|
// Для предпоследней колонки: нельзя drop "bottom" (это поставит после последней)
|
||||||
|
if (index === lastIndex - 1) {
|
||||||
|
dragPosition.value = 'top'
|
||||||
|
} else {
|
||||||
|
dragPosition.value = e.clientY < midY ? 'top' : 'bottom'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleColumnDragLeave = () => {
|
||||||
|
// Don't clear immediately to avoid flickering
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleColumnDrop = async (targetIndex) => {
|
||||||
|
if (dragIndex.value === null || dragIndex.value === targetIndex) {
|
||||||
|
handleColumnDragEnd()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastIndex = form.value.columns.length - 1
|
||||||
|
|
||||||
|
// Нельзя drop на последнюю колонку
|
||||||
|
if (targetIndex === lastIndex) {
|
||||||
|
handleColumnDragEnd()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceIndex = dragIndex.value
|
||||||
|
let insertIndex = targetIndex
|
||||||
|
|
||||||
|
// Adjust insert index based on drag position
|
||||||
|
if (dragPosition.value === 'bottom') {
|
||||||
|
insertIndex = targetIndex + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// If moving down, adjust for removal
|
||||||
|
if (sourceIndex < insertIndex) {
|
||||||
|
insertIndex--
|
||||||
|
}
|
||||||
|
|
||||||
|
// Не даём вставить на позицию последней колонки (перед ready)
|
||||||
|
const maxInsertIndex = lastIndex - 1
|
||||||
|
if (insertIndex > maxInsertIndex) {
|
||||||
|
insertIndex = maxInsertIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reorder array
|
||||||
|
const [movedColumn] = form.value.columns.splice(sourceIndex, 1)
|
||||||
|
form.value.columns.splice(insertIndex, 0, movedColumn)
|
||||||
|
|
||||||
|
// Update id_order
|
||||||
|
form.value.columns.forEach((col, idx) => {
|
||||||
|
col.id_order = idx + 1
|
||||||
|
})
|
||||||
|
|
||||||
|
// If editing existing project — save order to server
|
||||||
|
if (!isNew.value) {
|
||||||
|
const ids = form.value.columns.filter(c => c.id).map(c => c.id)
|
||||||
|
if (ids.length > 0) {
|
||||||
|
await store.reorderColumns(ids)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleColumnDragEnd()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== SAVE / CLOSE ====================
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!canSave.value || isSaving.value) return
|
||||||
|
|
||||||
|
isSaving.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isNew.value) {
|
||||||
|
// Создание проекта
|
||||||
|
const result = await store.createProject(form.value.name)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// Обновляем колонки если есть изменения
|
||||||
|
// Дефолтные колонки уже созданы сервером
|
||||||
|
// Здесь можно добавить логику для кастомизации колонок при создании
|
||||||
|
emit('saved', result.id)
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Редактирование проекта
|
||||||
|
// Обновляем название если изменилось
|
||||||
|
if (form.value.name !== initialForm.value.name) {
|
||||||
|
await store.updateProject(props.project.id, form.value.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обрабатываем новые колонки
|
||||||
|
for (const column of form.value.columns) {
|
||||||
|
if (column.tempId && !column.id) {
|
||||||
|
// Новая колонка
|
||||||
|
const result = await store.addColumn(column.name_columns, column.color)
|
||||||
|
if (result.success) {
|
||||||
|
column.id = result.id
|
||||||
|
delete column.tempId
|
||||||
|
}
|
||||||
|
} else if (column.id) {
|
||||||
|
// Проверяем изменения в существующей колонке
|
||||||
|
const original = initialForm.value.columns.find(c => c.id === column.id)
|
||||||
|
if (original) {
|
||||||
|
const nameChanged = column.name_columns !== original.name_columns
|
||||||
|
const colorChanged = column.color !== original.color
|
||||||
|
if (nameChanged || colorChanged) {
|
||||||
|
await store.updateColumn(column.id, column.name_columns, column.color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем порядок колонок
|
||||||
|
const ids = form.value.columns.filter(c => c.id).map(c => c.id)
|
||||||
|
if (ids.length > 0) {
|
||||||
|
await store.reorderColumns(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('saved', props.project.id)
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tryClose = () => {
|
||||||
|
if (hasChanges.value) {
|
||||||
|
showUnsavedDialog.value = true
|
||||||
|
} else {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmSave = () => {
|
||||||
|
showUnsavedDialog.value = false
|
||||||
|
handleSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDiscard = () => {
|
||||||
|
showUnsavedDialog.value = false
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== DELETE PROJECT ====================
|
||||||
|
|
||||||
|
const handleDeleteProject = () => {
|
||||||
|
showDeleteProjectDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDeleteProject = async () => {
|
||||||
|
if (!props.project?.id) {
|
||||||
|
throw new Error('Проект не выбран')
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await store.deleteProject(props.project.id)
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.errors?.access || 'Ошибка удаления')
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== WATCH ====================
|
||||||
|
|
||||||
|
watch(() => props.show, async (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
isSaving.value = false
|
||||||
|
tempIdCounter = 0
|
||||||
|
|
||||||
|
if (props.project) {
|
||||||
|
// Редактирование — загружаем данные
|
||||||
|
form.value = {
|
||||||
|
name: props.project.name,
|
||||||
|
columns: JSON.parse(JSON.stringify(store.columns))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Создание — дефолтные значения (последняя колонка = финальная)
|
||||||
|
form.value = {
|
||||||
|
name: '',
|
||||||
|
columns: [
|
||||||
|
{ tempId: 'new-1', name_columns: 'К выполнению', color: '#6366f1', id_order: 1 },
|
||||||
|
{ tempId: 'new-2', name_columns: 'Готово', color: '#22c55e', id_order: 2 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
tempIdCounter = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем начальное состояние
|
||||||
|
await nextTick()
|
||||||
|
initialForm.value = JSON.parse(JSON.stringify(form.value))
|
||||||
|
refreshIcons()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Refresh icons when columns change
|
||||||
|
watch(() => form.value.columns.length, () => {
|
||||||
|
nextTick(refreshIcons)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.form-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== COLUMNS LIST ==================== */
|
||||||
|
|
||||||
|
.columns-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-item.is-ready {
|
||||||
|
border-color: var(--green);
|
||||||
|
background: rgba(34, 197, 94, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-item.is-dragging {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-item.drag-over-top {
|
||||||
|
border-top: 2px solid var(--accent);
|
||||||
|
padding-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-item.drag-over-bottom {
|
||||||
|
border-bottom: 2px solid var(--accent);
|
||||||
|
padding-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-drag-handle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: grab;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-drag-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-drag-handle i {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile: up/down buttons */
|
||||||
|
.column-move-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.move-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.move-btn i {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.move-btn:not(:disabled):hover,
|
||||||
|
.move-btn:not(:disabled):active {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.move-btn:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-name::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-name:focus {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-edit-btn i {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-edit-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Маркер финальной колонки (не кликабельный) */
|
||||||
|
.column-ready-marker {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
color: var(--green);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-ready-marker i {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-delete-btn i {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-delete-btn:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== ADD COLUMN BUTTON ==================== */
|
||||||
|
|
||||||
|
.add-column-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-column-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-column-btn i {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== FOOTER ==================== */
|
||||||
|
|
||||||
|
.footer-left {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -7,15 +7,41 @@
|
|||||||
<i data-lucide="chevron-down" class="chevron" :class="{ open: dropdownOpen }"></i>
|
<i data-lucide="chevron-down" class="chevron" :class="{ open: dropdownOpen }"></i>
|
||||||
</button>
|
</button>
|
||||||
<div class="project-dropdown" v-if="dropdownOpen">
|
<div class="project-dropdown" v-if="dropdownOpen">
|
||||||
<button
|
<div
|
||||||
v-for="project in store.projects"
|
v-for="(project, index) in store.projects"
|
||||||
:key="project.id"
|
:key="project.id"
|
||||||
|
class="project-option-wrapper"
|
||||||
|
:class="{
|
||||||
|
'is-dragging': dragIndex === index,
|
||||||
|
'drag-over-top': dragOverIndex === index && dragPosition === 'top',
|
||||||
|
'drag-over-bottom': dragOverIndex === index && dragPosition === 'bottom'
|
||||||
|
}"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="handleDragStart($event, index)"
|
||||||
|
@dragend="handleDragEnd"
|
||||||
|
@dragover.prevent="handleDragOver($event, index)"
|
||||||
|
@dragleave="handleDragLeave"
|
||||||
|
@drop.prevent="handleDrop(index)"
|
||||||
|
>
|
||||||
|
<div class="project-drag-handle">
|
||||||
|
<i data-lucide="grip-vertical"></i>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
class="project-option"
|
class="project-option"
|
||||||
:class="{ active: store.currentProjectId === project.id }"
|
:class="{ active: store.currentProjectId === project.id }"
|
||||||
@click="handleSelect(project.id)"
|
@click="handleSelect(project.id)"
|
||||||
>
|
>
|
||||||
{{ project.name }}
|
{{ project.name }}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="project.id_admin"
|
||||||
|
class="project-edit-btn"
|
||||||
|
title="Настройки проекта"
|
||||||
|
@click.stop="handleEdit(project)"
|
||||||
|
>
|
||||||
|
<i data-lucide="settings"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -32,7 +58,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import { useProjectsStore } from '../stores/projects'
|
import { useProjectsStore } from '../stores/projects'
|
||||||
import { useMobile } from '../composables/useMobile'
|
import { useMobile } from '../composables/useMobile'
|
||||||
import MobileSelect from './ui/MobileSelect.vue'
|
import MobileSelect from './ui/MobileSelect.vue'
|
||||||
@@ -41,7 +67,72 @@ const store = useProjectsStore()
|
|||||||
const { isMobile } = useMobile()
|
const { isMobile } = useMobile()
|
||||||
const dropdownOpen = ref(false)
|
const dropdownOpen = ref(false)
|
||||||
|
|
||||||
const emit = defineEmits(['change'])
|
const emit = defineEmits(['change', 'edit'])
|
||||||
|
|
||||||
|
// ==================== DRAG AND DROP ====================
|
||||||
|
const dragIndex = ref(null)
|
||||||
|
const dragOverIndex = ref(null)
|
||||||
|
const dragPosition = ref(null) // 'top' | 'bottom'
|
||||||
|
|
||||||
|
const handleDragStart = (e, index) => {
|
||||||
|
dragIndex.value = index
|
||||||
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
|
e.dataTransfer.setData('text/plain', index.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
dragIndex.value = null
|
||||||
|
dragOverIndex.value = null
|
||||||
|
dragPosition.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragOver = (e, index) => {
|
||||||
|
if (dragIndex.value === null || dragIndex.value === index) return
|
||||||
|
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
|
const midY = rect.top + rect.height / 2
|
||||||
|
|
||||||
|
dragOverIndex.value = index
|
||||||
|
dragPosition.value = e.clientY < midY ? 'top' : 'bottom'
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragLeave = () => {
|
||||||
|
// Don't clear immediately to avoid flickering
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrop = async (targetIndex) => {
|
||||||
|
if (dragIndex.value === null || dragIndex.value === targetIndex) {
|
||||||
|
handleDragEnd()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceIndex = dragIndex.value
|
||||||
|
let insertIndex = targetIndex
|
||||||
|
|
||||||
|
// Adjust insert index based on drag position
|
||||||
|
if (dragPosition.value === 'bottom') {
|
||||||
|
insertIndex = targetIndex + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// If moving down, adjust for removal
|
||||||
|
if (sourceIndex < insertIndex) {
|
||||||
|
insertIndex--
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build new order array
|
||||||
|
const projects = [...store.projects]
|
||||||
|
const [movedProject] = projects.splice(sourceIndex, 1)
|
||||||
|
projects.splice(insertIndex, 0, movedProject)
|
||||||
|
|
||||||
|
// Get IDs in new order
|
||||||
|
const ids = projects.map(p => p.id)
|
||||||
|
|
||||||
|
// Update store
|
||||||
|
await store.reorderProjects(ids)
|
||||||
|
|
||||||
|
handleDragEnd()
|
||||||
|
nextTick(refreshIcons)
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== DESKTOP ====================
|
// ==================== DESKTOP ====================
|
||||||
const handleSelect = async (projectId) => {
|
const handleSelect = async (projectId) => {
|
||||||
@@ -50,6 +141,11 @@ const handleSelect = async (projectId) => {
|
|||||||
emit('change', projectId)
|
emit('change', projectId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleEdit = (project) => {
|
||||||
|
dropdownOpen.value = false
|
||||||
|
emit('edit', project)
|
||||||
|
}
|
||||||
|
|
||||||
// Закрытие дропдауна при клике вне
|
// Закрытие дропдауна при клике вне
|
||||||
const closeDropdown = (e) => {
|
const closeDropdown = (e) => {
|
||||||
if (!e.target.closest('.project-select')) {
|
if (!e.target.closest('.project-select')) {
|
||||||
@@ -75,14 +171,23 @@ const handleMobileSelect = async (projectId) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ==================== LIFECYCLE ====================
|
// ==================== LIFECYCLE ====================
|
||||||
|
const refreshIcons = () => {
|
||||||
|
if (window.lucide) window.lucide.createIcons()
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('click', closeDropdown)
|
document.addEventListener('click', closeDropdown)
|
||||||
if (window.lucide) window.lucide.createIcons()
|
refreshIcons()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('click', closeDropdown)
|
document.removeEventListener('click', closeDropdown)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Refresh icons when dropdown opens
|
||||||
|
watch(dropdownOpen, (open) => {
|
||||||
|
if (open) nextTick(refreshIcons)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -169,4 +274,91 @@ onUnmounted(() => {
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==================== DRAG AND DROP ==================== */
|
||||||
|
|
||||||
|
.project-option-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-option-wrapper:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-option-wrapper.is-dragging {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-option-wrapper.drag-over-top {
|
||||||
|
border-top: 2px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-option-wrapper.drag-over-bottom {
|
||||||
|
border-bottom: 2px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-drag-handle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: grab;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 2px;
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-drag-handle:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-drag-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-drag-handle i {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-option-wrapper .project-option {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-edit-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 2px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-edit-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: var(--text-primary);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-edit-btn i {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
448
front_vue/src/components/ui/ColorPicker.vue
Normal file
448
front_vue/src/components/ui/ColorPicker.vue
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
<template>
|
||||||
|
<div class="color-picker" ref="pickerRef">
|
||||||
|
<!-- Триггер - текущий цвет -->
|
||||||
|
<button
|
||||||
|
class="color-trigger"
|
||||||
|
ref="triggerRef"
|
||||||
|
:style="{ backgroundColor: modelValue }"
|
||||||
|
@click="togglePicker"
|
||||||
|
:title="'Выбрать цвет'"
|
||||||
|
>
|
||||||
|
<span class="color-check" v-if="modelValue">
|
||||||
|
<i data-lucide="check"></i>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Desktop: dropdown палитра (teleport чтобы не обрезалось) -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition v-if="!isMobile" name="dropdown">
|
||||||
|
<div
|
||||||
|
v-if="isOpen"
|
||||||
|
class="color-dropdown"
|
||||||
|
ref="dropdownRef"
|
||||||
|
:style="dropdownStyle"
|
||||||
|
>
|
||||||
|
<div class="color-grid">
|
||||||
|
<button
|
||||||
|
v-for="color in colors"
|
||||||
|
:key="color"
|
||||||
|
class="color-option"
|
||||||
|
:class="{ selected: modelValue === color }"
|
||||||
|
:style="{ backgroundColor: color }"
|
||||||
|
@click="selectColor(color)"
|
||||||
|
>
|
||||||
|
<i v-if="modelValue === color" data-lucide="check"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<!-- Mobile: bottom sheet -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="mobile-picker">
|
||||||
|
<div v-if="isMobile && isOpen" class="mobile-overlay" @click.self="closePicker">
|
||||||
|
<div class="mobile-sheet">
|
||||||
|
<div class="sheet-header">
|
||||||
|
<span class="sheet-title">Выберите цвет</span>
|
||||||
|
<button class="sheet-close" @click="closePicker">
|
||||||
|
<i data-lucide="x"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sheet-body">
|
||||||
|
<div class="color-grid mobile">
|
||||||
|
<button
|
||||||
|
v-for="color in colors"
|
||||||
|
:key="color"
|
||||||
|
class="color-option"
|
||||||
|
:class="{ selected: modelValue === color }"
|
||||||
|
:style="{ backgroundColor: color }"
|
||||||
|
@click="selectColor(color)"
|
||||||
|
>
|
||||||
|
<i v-if="modelValue === color" data-lucide="check"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||||
|
import { useMobile } from '../../composables/useMobile'
|
||||||
|
import { useLucideIcons } from '../../composables/useLucideIcons'
|
||||||
|
|
||||||
|
const { isMobile } = useMobile()
|
||||||
|
const { refreshIcons } = useLucideIcons()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: '#6366f1'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
// Предустановленные цвета
|
||||||
|
const colors = [
|
||||||
|
// Основные
|
||||||
|
'#ef4444', // red
|
||||||
|
'#f97316', // orange
|
||||||
|
'#f59e0b', // amber
|
||||||
|
'#eab308', // yellow
|
||||||
|
'#84cc16', // lime
|
||||||
|
'#22c55e', // green
|
||||||
|
'#10b981', // emerald
|
||||||
|
'#14b8a6', // teal
|
||||||
|
'#06b6d4', // cyan
|
||||||
|
'#0ea5e9', // sky
|
||||||
|
'#3b82f6', // blue
|
||||||
|
'#6366f1', // indigo
|
||||||
|
'#8b5cf6', // violet
|
||||||
|
'#a855f7', // purple
|
||||||
|
'#d946ef', // fuchsia
|
||||||
|
'#ec4899', // pink
|
||||||
|
'#f43f5e', // rose
|
||||||
|
// Нейтральные
|
||||||
|
'#64748b', // slate
|
||||||
|
'#6b7280', // gray
|
||||||
|
'#78716c', // stone
|
||||||
|
]
|
||||||
|
|
||||||
|
const pickerRef = ref(null)
|
||||||
|
const triggerRef = ref(null)
|
||||||
|
const dropdownRef = ref(null)
|
||||||
|
const isOpen = ref(false)
|
||||||
|
const dropdownPosition = ref({ top: 0, left: 0 })
|
||||||
|
|
||||||
|
// Вычисляем позицию dropdown относительно триггера
|
||||||
|
const updateDropdownPosition = () => {
|
||||||
|
if (triggerRef.value) {
|
||||||
|
const rect = triggerRef.value.getBoundingClientRect()
|
||||||
|
const dropdownWidth = 220
|
||||||
|
const dropdownHeight = 320
|
||||||
|
|
||||||
|
// Левый край dropdown начинается от левого края триггера
|
||||||
|
let top = rect.bottom + 8
|
||||||
|
let left = rect.left
|
||||||
|
|
||||||
|
// Проверяем не выходит ли за правый край
|
||||||
|
if (left + dropdownWidth > window.innerWidth - 16) {
|
||||||
|
left = window.innerWidth - dropdownWidth - 16
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем не выходит ли за нижний край — открываем вверх
|
||||||
|
if (top + dropdownHeight > window.innerHeight - 16) {
|
||||||
|
top = rect.top - dropdownHeight - 8
|
||||||
|
}
|
||||||
|
|
||||||
|
dropdownPosition.value = { top, left }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dropdownStyle = computed(() => ({
|
||||||
|
position: 'fixed',
|
||||||
|
top: `${dropdownPosition.value.top}px`,
|
||||||
|
left: `${dropdownPosition.value.left}px`,
|
||||||
|
zIndex: 2000
|
||||||
|
}))
|
||||||
|
|
||||||
|
const togglePicker = () => {
|
||||||
|
isOpen.value = !isOpen.value
|
||||||
|
if (isOpen.value) {
|
||||||
|
updateDropdownPosition()
|
||||||
|
nextTick(refreshIcons)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closePicker = () => {
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectColor = (color) => {
|
||||||
|
emit('update:modelValue', color)
|
||||||
|
// Закрываем dropdown после выбора цвета из палитры
|
||||||
|
closePicker()
|
||||||
|
nextTick(refreshIcons)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClickOutside = (e) => {
|
||||||
|
// Проверяем что клик не на триггере и не на dropdown
|
||||||
|
const isClickOnTrigger = pickerRef.value && pickerRef.value.contains(e.target)
|
||||||
|
const isClickOnDropdown = dropdownRef.value && dropdownRef.value.contains(e.target)
|
||||||
|
|
||||||
|
if (!isClickOnTrigger && !isClickOnDropdown) {
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем позицию при скролле/ресайзе
|
||||||
|
watch(isOpen, (val) => {
|
||||||
|
if (val && !isMobile.value) {
|
||||||
|
window.addEventListener('scroll', updateDropdownPosition, true)
|
||||||
|
window.addEventListener('resize', updateDropdownPosition)
|
||||||
|
} else {
|
||||||
|
window.removeEventListener('scroll', updateDropdownPosition, true)
|
||||||
|
window.removeEventListener('resize', updateDropdownPosition)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('click', handleClickOutside)
|
||||||
|
refreshIcons()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('click', handleClickOutside)
|
||||||
|
window.removeEventListener('scroll', updateDropdownPosition, true)
|
||||||
|
window.removeEventListener('resize', updateDropdownPosition)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.color-picker {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-trigger {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-trigger:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-check {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== DROPDOWN (Desktop) ========== */
|
||||||
|
.color-dropdown {
|
||||||
|
background: #1e1e24;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.5);
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-option {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-option:hover {
|
||||||
|
transform: scale(1.15);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-option.selected {
|
||||||
|
box-shadow: 0 0 0 2px var(--bg-body), 0 0 0 4px currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-option i {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: #fff;
|
||||||
|
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Кастомный цвет */
|
||||||
|
.custom-color {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.native-picker {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.native-picker::-webkit-color-swatch-wrapper {
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.native-picker::-webkit-color-swatch {
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropdown transition */
|
||||||
|
.dropdown-enter-active,
|
||||||
|
.dropdown-leave-active {
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-enter-from,
|
||||||
|
.dropdown-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== MOBILE: Bottom Sheet ========== */
|
||||||
|
.mobile-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
z-index: 2000;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sheet {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 70vh;
|
||||||
|
background: var(--bg-body);
|
||||||
|
border-radius: 20px 20px 0 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-close {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-close i {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-body {
|
||||||
|
padding: 20px;
|
||||||
|
padding-bottom: calc(20px + env(safe-area-inset-bottom, 0px));
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-grid.mobile {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 48px);
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-grid.mobile .color-option {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-grid.mobile .color-option i {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-color.mobile {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-color.mobile .custom-label {
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-preview {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-color.mobile .native-picker {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile transition */
|
||||||
|
.mobile-picker-enter-active,
|
||||||
|
.mobile-picker-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-picker-enter-active .mobile-sheet,
|
||||||
|
.mobile-picker-leave-active .mobile-sheet {
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-picker-enter-from,
|
||||||
|
.mobile-picker-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-picker-enter-from .mobile-sheet,
|
||||||
|
.mobile-picker-leave-to .mobile-sheet {
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -368,7 +368,7 @@ onUnmounted(() => {
|
|||||||
.panel.mobile .panel-body {
|
.panel.mobile .panel-body {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
gap: 0;
|
gap: 20px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,5 +50,29 @@ export const DIALOGS = {
|
|||||||
confirmText: 'Сохранить',
|
confirmText: 'Сохранить',
|
||||||
showDiscard: true,
|
showDiscard: true,
|
||||||
variant: 'default'
|
variant: 'default'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Удаление проекта
|
||||||
|
deleteProject: {
|
||||||
|
title: 'Удалить проект?',
|
||||||
|
message: 'Все задачи, колонки и комментарии<br>будут удалены навсегда.',
|
||||||
|
confirmText: 'Удалить',
|
||||||
|
variant: 'danger'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Удаление колонки
|
||||||
|
deleteColumn: {
|
||||||
|
title: 'Удалить колонку?',
|
||||||
|
message: 'Колонка и все задачи в ней<br>будут удалены навсегда.',
|
||||||
|
confirmText: 'Удалить',
|
||||||
|
variant: 'danger'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Удаление колонки с задачами (динамический message)
|
||||||
|
deleteColumnWithTasks: {
|
||||||
|
title: 'Удалить колонку?',
|
||||||
|
message: '', // Будет задан динамически
|
||||||
|
confirmText: 'Удалить',
|
||||||
|
variant: 'danger'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -226,6 +226,156 @@ export const useProjectsStore = defineStore('projects', () => {
|
|||||||
localStorage.removeItem('currentProjectName')
|
localStorage.removeItem('currentProjectName')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== CRUD ПРОЕКТОВ ====================
|
||||||
|
|
||||||
|
// Создание проекта
|
||||||
|
const createProject = async (name) => {
|
||||||
|
const result = await projectsApi.create(name)
|
||||||
|
if (result.success) {
|
||||||
|
// Добавляем проект в список
|
||||||
|
projects.value.push({
|
||||||
|
id: result.id,
|
||||||
|
name,
|
||||||
|
id_order: projects.value.length + 1,
|
||||||
|
id_ready: result.id_ready,
|
||||||
|
id_admin: true // Создатель = админ
|
||||||
|
})
|
||||||
|
// Переключаемся на новый проект
|
||||||
|
await selectProject(result.id)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление проекта
|
||||||
|
const updateProject = async (id, name) => {
|
||||||
|
const result = await projectsApi.update(id, name)
|
||||||
|
if (result.success) {
|
||||||
|
const project = projects.value.find(p => p.id === id)
|
||||||
|
if (project) {
|
||||||
|
project.name = name
|
||||||
|
// Обновляем localStorage если это текущий проект
|
||||||
|
if (id === currentProjectId.value) {
|
||||||
|
localStorage.setItem('currentProjectName', name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаление проекта
|
||||||
|
const deleteProject = async (id) => {
|
||||||
|
const result = await projectsApi.delete(id)
|
||||||
|
if (result.success) {
|
||||||
|
const index = projects.value.findIndex(p => p.id === id)
|
||||||
|
if (index !== -1) {
|
||||||
|
projects.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
// Если удалили текущий проект — переключаемся на первый
|
||||||
|
if (id === currentProjectId.value) {
|
||||||
|
if (projects.value.length > 0) {
|
||||||
|
await selectProject(projects.value[0].id)
|
||||||
|
} else {
|
||||||
|
currentProjectId.value = null
|
||||||
|
columns.value = []
|
||||||
|
departments.value = []
|
||||||
|
cards.value = []
|
||||||
|
localStorage.removeItem('currentProjectId')
|
||||||
|
localStorage.removeItem('currentProjectName')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление порядка проектов
|
||||||
|
const reorderProjects = async (ids) => {
|
||||||
|
// Оптимистичное обновление
|
||||||
|
const reordered = ids.map((id, index) => {
|
||||||
|
const project = projects.value.find(p => p.id === id)
|
||||||
|
return { ...project, id_order: index + 1 }
|
||||||
|
})
|
||||||
|
projects.value = reordered
|
||||||
|
|
||||||
|
// Отправляем на сервер
|
||||||
|
await projectsApi.updateOrder(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== CRUD КОЛОНОК ====================
|
||||||
|
|
||||||
|
// Добавление колонки
|
||||||
|
const addColumn = async (name, color = '#6366f1') => {
|
||||||
|
if (!currentProjectId.value) return { success: false }
|
||||||
|
|
||||||
|
const result = await projectsApi.addColumn(currentProjectId.value, name, color)
|
||||||
|
if (result.success) {
|
||||||
|
columns.value.push(result.column)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление колонки
|
||||||
|
const updateColumn = async (id, name, color) => {
|
||||||
|
const result = await projectsApi.updateColumn(id, name, color)
|
||||||
|
if (result.success) {
|
||||||
|
const column = columns.value.find(c => c.id === id)
|
||||||
|
if (column) {
|
||||||
|
if (name !== null && name !== undefined) column.name_columns = name
|
||||||
|
if (color !== null && color !== undefined) column.color = color
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получение количества задач в колонке
|
||||||
|
const getColumnTasksCount = async (id) => {
|
||||||
|
const result = await projectsApi.getColumnTasksCount(id)
|
||||||
|
return result.success ? result.count : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаление колонки
|
||||||
|
const deleteColumn = async (id) => {
|
||||||
|
const result = await projectsApi.deleteColumn(id)
|
||||||
|
if (result.success) {
|
||||||
|
const index = columns.value.findIndex(c => c.id === id)
|
||||||
|
if (index !== -1) {
|
||||||
|
columns.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
// Удаляем карточки этой колонки из локального состояния
|
||||||
|
cards.value = cards.value.filter(c => c.column_id !== id)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление порядка колонок
|
||||||
|
const reorderColumns = async (ids) => {
|
||||||
|
if (!currentProjectId.value) return
|
||||||
|
|
||||||
|
// Оптимистичное обновление
|
||||||
|
const reordered = ids.map((id, index) => {
|
||||||
|
const column = columns.value.find(c => c.id === id)
|
||||||
|
return { ...column, id_order: index + 1 }
|
||||||
|
})
|
||||||
|
columns.value = reordered
|
||||||
|
|
||||||
|
// Отправляем на сервер
|
||||||
|
await projectsApi.updateColumnsOrder(currentProjectId.value, ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Установка финальной колонки
|
||||||
|
const setReadyColumn = async (columnId) => {
|
||||||
|
if (!currentProjectId.value) return { success: false }
|
||||||
|
|
||||||
|
const result = await projectsApi.setReadyColumn(currentProjectId.value, columnId)
|
||||||
|
if (result.success) {
|
||||||
|
// Обновляем id_ready в проекте
|
||||||
|
const project = projects.value.find(p => p.id === currentProjectId.value)
|
||||||
|
if (project) {
|
||||||
|
project.id_ready = columnId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Состояние
|
// Состояние
|
||||||
projects,
|
projects,
|
||||||
@@ -254,6 +404,18 @@ export const useProjectsStore = defineStore('projects', () => {
|
|||||||
fetchCards,
|
fetchCards,
|
||||||
fetchArchivedCards,
|
fetchArchivedCards,
|
||||||
clearCards,
|
clearCards,
|
||||||
reset
|
reset,
|
||||||
|
// CRUD проектов
|
||||||
|
createProject,
|
||||||
|
updateProject,
|
||||||
|
deleteProject,
|
||||||
|
reorderProjects,
|
||||||
|
// CRUD колонок
|
||||||
|
addColumn,
|
||||||
|
updateColumn,
|
||||||
|
getColumnTasksCount,
|
||||||
|
deleteColumn,
|
||||||
|
reorderColumns,
|
||||||
|
setReadyColumn
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,12 +7,35 @@
|
|||||||
<DepartmentTags
|
<DepartmentTags
|
||||||
v-model="activeDepartment"
|
v-model="activeDepartment"
|
||||||
@project-change="onProjectChange"
|
@project-change="onProjectChange"
|
||||||
|
@create-project="openCreateProjectPanel"
|
||||||
|
@edit-project="openEditProjectPanel"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Мобильный: Проект + Отделы -->
|
<!-- Мобильный: Проект + Отделы -->
|
||||||
<template #mobile-filters>
|
<template #mobile-filters>
|
||||||
<ProjectSelector @change="onProjectChange" />
|
<ProjectSelector
|
||||||
|
@change="onProjectChange"
|
||||||
|
@edit="openEditProjectPanel"
|
||||||
|
/>
|
||||||
|
<!-- Кнопки управления проектами (мобильные) -->
|
||||||
|
<div class="mobile-project-actions">
|
||||||
|
<button
|
||||||
|
v-if="store.isProjectAdmin"
|
||||||
|
class="mobile-project-btn"
|
||||||
|
title="Настройки проекта"
|
||||||
|
@click="openEditProjectPanel(store.currentProject)"
|
||||||
|
>
|
||||||
|
<i data-lucide="settings"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="mobile-project-btn"
|
||||||
|
title="Создать проект"
|
||||||
|
@click="openCreateProjectPanel"
|
||||||
|
>
|
||||||
|
<i data-lucide="plus"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<MobileSelect
|
<MobileSelect
|
||||||
v-model="activeDepartment"
|
v-model="activeDepartment"
|
||||||
:options="departmentOptions"
|
:options="departmentOptions"
|
||||||
@@ -69,6 +92,13 @@
|
|||||||
@close="closePanel"
|
@close="closePanel"
|
||||||
@delete="handleDeleteTask"
|
@delete="handleDeleteTask"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ProjectPanel
|
||||||
|
:show="projectPanelOpen"
|
||||||
|
:project="editingProject"
|
||||||
|
@close="closeProjectPanel"
|
||||||
|
@saved="onProjectSaved"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
</template>
|
</template>
|
||||||
@@ -79,6 +109,7 @@ import PageLayout from '../components/PageLayout.vue'
|
|||||||
import Header from '../components/Header.vue'
|
import Header from '../components/Header.vue'
|
||||||
import Board from '../components/Board.vue'
|
import Board from '../components/Board.vue'
|
||||||
import TaskPanel from '../components/TaskPanel'
|
import TaskPanel from '../components/TaskPanel'
|
||||||
|
import ProjectPanel from '../components/ProjectPanel.vue'
|
||||||
import DepartmentTags from '../components/DepartmentTags.vue'
|
import DepartmentTags from '../components/DepartmentTags.vue'
|
||||||
import ProjectSelector from '../components/ProjectSelector.vue'
|
import ProjectSelector from '../components/ProjectSelector.vue'
|
||||||
import MobileSelect from '../components/ui/MobileSelect.vue'
|
import MobileSelect from '../components/ui/MobileSelect.vue'
|
||||||
@@ -172,6 +203,33 @@ const handleArchiveTask = async (cardId) => {
|
|||||||
closePanel()
|
closePanel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== ПАНЕЛЬ ПРОЕКТА ====================
|
||||||
|
const projectPanelOpen = ref(false)
|
||||||
|
const editingProject = ref(null)
|
||||||
|
|
||||||
|
const openCreateProjectPanel = () => {
|
||||||
|
editingProject.value = null
|
||||||
|
projectPanelOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEditProjectPanel = (project) => {
|
||||||
|
editingProject.value = project
|
||||||
|
projectPanelOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeProjectPanel = () => {
|
||||||
|
projectPanelOpen.value = false
|
||||||
|
editingProject.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const onProjectSaved = async (projectId) => {
|
||||||
|
// Перезагружаем данные если изменился текущий проект
|
||||||
|
if (projectId === store.currentProjectId) {
|
||||||
|
await store.fetchProjectData()
|
||||||
|
await fetchCards()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== АВТООБНОВЛЕНИЕ (POLLING) ====================
|
// ==================== АВТООБНОВЛЕНИЕ (POLLING) ====================
|
||||||
const REFRESH_INTERVAL = (window.APP_CONFIG?.IDLE_REFRESH_SECONDS ?? 30) * 1000
|
const REFRESH_INTERVAL = (window.APP_CONFIG?.IDLE_REFRESH_SECONDS ?? 30) * 1000
|
||||||
let pollTimer = null
|
let pollTimer = null
|
||||||
@@ -231,4 +289,36 @@ onUnmounted(() => {
|
|||||||
.main.mobile {
|
.main.mobile {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Мобильные кнопки управления проектами */
|
||||||
|
.mobile-project-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-project-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-project-btn:active {
|
||||||
|
color: var(--accent);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-project-btn i {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user