From 719aecd09e6638fbf0eb8477b5b53498c9675d6b Mon Sep 17 00:00:00 2001 From: Falknat Date: Wed, 14 Jan 2026 10:46:38 +0700 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавили возможность создавания разных проектов. --- backend/api/project.php | 86 +++++++ backend/api/task.php | 30 +-- backend/app/class/enity/class_project.php | 241 +++++++++++++++++++ backend/app/class/enity/class_task.php | 60 +++-- backend/app/class/enity/class_taskImage.php | 4 +- backend/app/config.php | 5 +- front_vue/index.html | 1 + front_vue/package-lock.json | 137 ++++++++++- front_vue/package.json | 1 + front_vue/public/favicon.ico | Bin 0 -> 8158 bytes front_vue/src/api.js | 76 +++--- front_vue/src/components/Board.vue | 26 +- front_vue/src/components/Card.vue | 8 +- front_vue/src/components/Column.vue | 2 + front_vue/src/components/ProjectSelector.vue | 137 +++++++++++ front_vue/src/components/TaskPanel.vue | 3 +- front_vue/src/main.js | 6 +- front_vue/src/router.js | 10 +- front_vue/src/stores/projects.js | 145 +++++++++++ front_vue/src/views/ArchivePage.vue | 118 ++++----- front_vue/src/views/MainApp.vue | 118 ++++----- taskboard.sql | 15 +- 22 files changed, 996 insertions(+), 233 deletions(-) create mode 100644 backend/api/project.php create mode 100644 backend/app/class/enity/class_project.php create mode 100644 front_vue/public/favicon.ico create mode 100644 front_vue/src/components/ProjectSelector.vue create mode 100644 front_vue/src/stores/projects.js diff --git a/backend/api/project.php b/backend/api/project.php new file mode 100644 index 0000000..c47cd5b --- /dev/null +++ b/backend/api/project.php @@ -0,0 +1,86 @@ + true, 'data' => $result]); + } else { + RestApi::response(['success' => false, 'errors' => ['project' => 'Проект не найден']], 404); + } + } + + // Установка колонки "Готово" для проекта + if ($action === 'set_ready_column') { + $project_id = $data['id_project'] ?? null; + $column_id = $data['column_id'] ?? null; + $result = Project::setReadyColumn($project_id, $column_id); + RestApi::response($result); + } + + // Создание проекта + if ($action === 'create') { + $project->name = $data['name'] ?? ''; + $project->id_ready = $data['id_ready'] ?? null; + + $result = $project->create(); + RestApi::response($result); + } + + // Обновление проекта + if ($action === 'update') { + $project->id = $data['id'] ?? null; + $project->name = $data['name'] ?? ''; + $project->id_ready = $data['id_ready'] ?? null; + $project->id_order = $data['id_order'] ?? null; + + $result = $project->update(); + RestApi::response($result); + } + + // Удаление проекта + if ($action === 'delete') { + $id = $data['id'] ?? null; + $result = Project::delete($id); + RestApi::response($result); + } + + // Метод не указан + if (!$action) { + RestApi::response(['success' => false, 'error' => 'Укажите метод'], 400); + } +} + +if ($method === 'GET') { + // Получение всех проектов + // ?active=ID — дополнительно вернуть данные активного проекта + $project = new Project(); + $projects = $project->getAll(); + + $active_id = $_GET['active'] ?? null; + + if ($active_id) { + // Возвращаем список проектов + данные активного + $activeData = Project::getProjectData((int)$active_id); + RestApi::response([ + 'success' => true, + 'data' => [ + 'projects' => $projects, + 'active' => $activeData + ] + ]); + } else { + // Только список проектов + RestApi::response(['success' => true, 'data' => $projects]); + } +} + +?> diff --git a/backend/api/task.php b/backend/api/task.php index 61d2a8f..eedde7b 100644 --- a/backend/api/task.php +++ b/backend/api/task.php @@ -7,24 +7,6 @@ if ($method === 'POST') { $action = $data['action'] ?? null; $task = new Task(); - // Получение колонок - if ($action === 'get_columns') { - $result = $task->getColumns(); - RestApi::response(['success' => true, 'data' => $result]); - } - - // Получение департаментов - if ($action === 'get_departments') { - $result = $task->getDepartments(); - RestApi::response(['success' => true, 'data' => $result]); - } - - // Получение меток - if ($action === 'get_labels') { - $result = $task->getLabels(); - RestApi::response(['success' => true, 'data' => $result]); - } - // Загрузка изображения if ($action === 'upload_image') { $task_id = $data['task_id'] ?? null; @@ -73,6 +55,7 @@ if ($method === 'POST') { // Создание задачи if ($action === 'create') { + $task->id_project = $data['id_project'] ?? null; $task->id_department = $data['id_department'] ?? null; $task->id_label = $data['id_label'] ?? null; $task->id_account = $data['id_account'] ?? null; @@ -110,8 +93,15 @@ if ($method === 'POST') { } if ($method === 'GET') { - // Получение всех задач + // Получение задач проекта + // ?id_project=1 (обязательный) // ?archive=0 (неархивные, по умолчанию), ?archive=1 (архивные), ?archive=all (все) + $id_project = $_GET['id_project'] ?? null; + + if (!$id_project) { + RestApi::response(['success' => false, 'errors' => ['id_project' => 'Проект не указан']], 400); + } + $archive = $_GET['archive'] ?? 0; if ($archive === 'all') { $archive = null; @@ -120,7 +110,7 @@ if ($method === 'GET') { } $task = new Task(); - $tasks = $task->getAll($archive); + $tasks = $task->getAll($id_project, $archive); RestApi::response(['success' => true, 'data' => $tasks]); } diff --git a/backend/app/class/enity/class_project.php b/backend/app/class/enity/class_project.php new file mode 100644 index 0000000..63fd2c5 --- /dev/null +++ b/backend/app/class/enity/class_project.php @@ -0,0 +1,241 @@ +id) { + $this->addError('id', 'ID проекта не указан'); + } + if (!$this->name) { + $this->addError('name', 'Название проекта не может быть пустым'); + } + + return $this->getErrors(); + } + + // Валидация данных (для create) + protected function validateCreate() { + static::$error_message = []; + + if (!$this->name) { + $this->addError('name', 'Название проекта не может быть пустым'); + } + + return $this->getErrors(); + } + + // Создание проекта + public function create() { + // Валидация + if ($errors = $this->validateCreate()) { + return $errors; + } + + // Получаем максимальный order + $maxOrder = Database::max($this->db_name, 'id_order') ?? 0; + + // Вставляем в базу + Database::insert($this->db_name, [ + 'name' => $this->name, + 'id_order' => $maxOrder + 1, + 'id_ready' => $this->id_ready + ]); + + $this->id = Database::id(); + + return [ + 'success' => true, + 'id' => $this->id + ]; + } + + // Обновление проекта + public function update() { + // Валидация + if ($errors = $this->validate()) { + return $errors; + } + + // Проверка что проект существует + $project = Database::get($this->db_name, ['id'], ['id' => $this->id]); + if (!$project) { + $this->addError('project', 'Проект не найден'); + return $this->getErrors(); + } + + // Формируем данные для обновления + $update_data = [ + 'name' => $this->name + ]; + + // id_ready обновляем только если передан + if ($this->id_ready !== null) { + $update_data['id_ready'] = $this->id_ready; + } + + // id_order обновляем только если передан + if ($this->id_order !== null) { + $update_data['id_order'] = $this->id_order; + } + + // Обновляем в БД + Database::update($this->db_name, $update_data, [ + 'id' => $this->id + ]); + + return ['success' => true]; + } + + // Удаление проекта + public static function delete($id) { + // Проверка что проект существует + $project = self::checkProject($id); + if (!$project) { + return ['success' => false, 'errors' => ['project' => 'Проект не найден']]; + } + + // Удаляем все связанные данные + // Удаляем задачи проекта (и их файлы) + $tasks = Database::select('cards_task', ['id'], ['id_project' => $id]); + foreach ($tasks as $task) { + Task::delete($task['id']); + } + + // Удаляем колонки проекта + Database::delete('columns', ['id_project' => $id]); + + // Удаляем отделы проекта + Database::delete('departments', ['id_project' => $id]); + + // Удаляем сам проект + Database::delete('project', ['id' => $id]); + + return ['success' => true]; + } + + // Получение всех проектов + public function getAll() { + return Database::select($this->db_name, [ + 'id', + 'id_order', + 'name', + 'id_ready' + ], [ + 'ORDER' => ['id_order' => 'ASC'] + ]); + } + + // Получение одного проекта + public static function get($id) { + return Database::get('project', [ + 'id', + 'id_order', + 'name', + 'id_ready' + ], ['id' => $id]); + } + + // Получение id_ready (ID колонки "Готово") по ID проекта + public static function getReadyColumnId($project_id) { + $project = Database::get('project', ['id_ready'], ['id' => $project_id]); + return $project ? (int)$project['id_ready'] : null; + } + + // Установка id_ready для проекта + public static function setReadyColumn($project_id, $column_id) { + // Проверка что проект существует + $project = self::checkProject($project_id); + if (!$project) { + return ['success' => false, 'errors' => ['project' => 'Проект не найден']]; + } + + // Проверка что колонка существует и принадлежит этому проекту + $column = Database::get('columns', ['id', 'id_project'], ['id' => $column_id]); + if (!$column || (int)$column['id_project'] !== (int)$project_id) { + return ['success' => false, 'errors' => ['column' => 'Колонка не найдена или не принадлежит проекту']]; + } + + // Обновляем id_ready + Database::update('project', [ + 'id_ready' => $column_id + ], [ + 'id' => $project_id + ]); + + return ['success' => true, 'id_ready' => $column_id]; + } + + // Получение колонок проекта + public static function getColumns($project_id) { + return Database::select('columns', [ + 'id', + 'name_columns', + 'color', + 'id_order' + ], [ + 'id_project' => $project_id, + 'ORDER' => ['id_order' => 'ASC'] + ]); + } + + // Получение отделов проекта + public static function getDepartments($project_id) { + return Database::select('departments', [ + 'id', + 'name_departments', + 'color' + ], [ + 'id_project' => $project_id + ]); + } + + // Получение всех данных проекта (проект + колонки + отделы + метки) + public static function getProjectData($project_id) { + $project = self::get($project_id); + if (!$project) { + return null; + } + + // Получаем метки (глобальные) + $labels = Database::select('labels', [ + 'id', + 'name_labels', + 'icon', + 'color' + ]); + + return [ + 'project' => $project, + 'columns' => self::getColumns($project_id), + 'departments' => self::getDepartments($project_id), + 'labels' => $labels + ]; + } + + // Проверка и получение проекта + public static function checkProject($project_id) { + return Database::get('project', '*', ['id' => $project_id]); + } + + // Проверка проекта с ответом (при ошибке — сразу ответ и exit) + public static function check($project_id) { + $project = self::checkProject($project_id); + if (!$project_id || !$project) { + RestApi::response(['success' => false, 'errors' => ['project' => 'Проект не найден']], 400); + } + return $project; + } +} + +?> diff --git a/backend/app/class/enity/class_task.php b/backend/app/class/enity/class_task.php index 4b304d1..af95ab6 100644 --- a/backend/app/class/enity/class_task.php +++ b/backend/app/class/enity/class_task.php @@ -6,6 +6,7 @@ class Task extends BaseEntity { // Свойства задачи public $id; + public $id_project; public $id_department; public $id_label; public $order; @@ -45,6 +46,9 @@ class Task extends BaseEntity { if (!$this->id_department) { $this->addError('id_department', 'Департамент не указан'); } + if (!$this->id_project) { + $this->addError('id_project', 'Проект не указан'); + } return $this->getErrors(); } @@ -58,6 +62,7 @@ class Task extends BaseEntity { // Вставляем в базу Database::insert($this->db_name, [ + 'id_project' => $this->id_project, 'id_department' => $this->id_department, 'id_label' => $this->id_label, 'order' => $this->order ?? 0, @@ -101,7 +106,7 @@ class Task extends BaseEntity { } // Проверка что задача существует и получаем текущие данные - $task = Database::get($this->db_name, ['id', 'column_id', 'order'], ['id' => $this->id]); + $task = Database::get($this->db_name, ['id', 'column_id', 'order', 'id_project'], ['id' => $this->id]); if (!$task) { $this->addError('task', 'Задача не найдена'); return $this->getErrors(); @@ -113,6 +118,9 @@ class Task extends BaseEntity { // Если column_id не передан — оставляем текущий $new_column_id = $this->column_id !== null ? (int)$this->column_id : $old_column_id; + // Получаем id_ready (колонка "Готово") из проекта + $done_column_id = Project::getReadyColumnId($task['id_project']); + // Формируем данные для обновления $update_data = [ 'id_department' => $this->id_department, @@ -127,9 +135,9 @@ class Task extends BaseEntity { ]; // Обновляем date_closed при смене колонки - if ($new_column_id === COLUMN_DONE_ID && $old_column_id !== COLUMN_DONE_ID) { + if ($done_column_id && $new_column_id === $done_column_id && $old_column_id !== $done_column_id) { $update_data['date_closed'] = date('Y-m-d H:i:s'); - } elseif ($old_column_id === COLUMN_DONE_ID && $new_column_id !== COLUMN_DONE_ID) { + } elseif ($done_column_id && $old_column_id === $done_column_id && $new_column_id !== $done_column_id) { $update_data['date_closed'] = null; } @@ -173,6 +181,9 @@ class Task extends BaseEntity { $new_column_id = (int)$column_id; $archive = (int)$task['archive']; + // Получаем id_ready (колонка "Готово") из проекта + $done_column_id = Project::getReadyColumnId($task['id_project']); + // Получаем все карточки целевой колонки с тем же статусом архивации (кроме перемещаемой) $cards = Database::select('cards_task', ['id', 'order'], [ 'column_id' => $column_id, @@ -192,13 +203,13 @@ class Task extends BaseEntity { ]; // Только для перемещаемой карточки обновляем date_closed - if ($card['id'] == $id) { + if ($card['id'] == $id && $done_column_id) { // Перемещаем В колонку "Готово" — устанавливаем дату закрытия - if ($new_column_id === COLUMN_DONE_ID && $old_column_id !== COLUMN_DONE_ID) { + if ($new_column_id === $done_column_id && $old_column_id !== $done_column_id) { $update_data['date_closed'] = date('Y-m-d H:i:s'); } // Перемещаем ИЗ колонки "Готово" — обнуляем дату - elseif ($old_column_id === COLUMN_DONE_ID && $new_column_id !== COLUMN_DONE_ID) { + elseif ($old_column_id === $done_column_id && $new_column_id !== $done_column_id) { $update_data['date_closed'] = null; } } @@ -211,16 +222,20 @@ class Task extends BaseEntity { return ['success' => true]; } - // Получение всех задач + // Получение всех задач проекта + // $id_project: ID проекта (обязательный) // $archive: 0 = неархивные, 1 = архивные, null = все - public function getAll($archive = 0) { - $where = []; + public function getAll($id_project, $archive = 0) { + $where = [ + 'id_project' => $id_project + ]; if ($archive !== null) { $where['archive'] = $archive ? 1 : 0; } $tasks = Database::select($this->db_name, [ 'id', + 'id_project', 'id_department', 'id_label', 'id_account', @@ -238,7 +253,7 @@ class Task extends BaseEntity { // Декодируем JSON и получаем avatar_url из accounts return array_map(function($task) { - $task['file_img'] = json_decode($task['file_img'], true) ?? []; + $task['file_img'] = $task['file_img'] ? json_decode($task['file_img'], true) : []; // Получаем avatar_url из accounts по id_account if ($task['id_account']) { @@ -252,21 +267,27 @@ class Task extends BaseEntity { }, $tasks); } - // Получение всех колонок - public function getColumns() { + // Получение колонок проекта + public function getColumns($id_project) { return Database::select('columns', [ 'id', 'name_columns', - 'color' + 'color', + 'id_order' + ], [ + 'id_project' => $id_project, + 'ORDER' => ['id_order' => 'ASC'] ]); } - // Получение всех департаментов - public function getDepartments() { + // Получение отделов проекта + public function getDepartments($id_project) { return Database::select('departments', [ 'id', 'name_departments', 'color' + ], [ + 'id_project' => $id_project ]); } @@ -294,8 +315,11 @@ class Task extends BaseEntity { // Проверка что задача существует $task = self::check_task($id); + // Получаем id_ready (колонка "Готово") из проекта + $done_column_id = Project::getReadyColumnId($task['id_project']); + // Архивировать можно только задачи в колонке "Готово" - if ($archive && (int)$task['column_id'] !== COLUMN_DONE_ID) { + if ($archive && $done_column_id && (int)$task['column_id'] !== $done_column_id) { RestApi::response([ 'success' => false, 'errors' => ['column' => 'Архивировать можно только задачи из колонки "Готово"'] @@ -308,8 +332,8 @@ class Task extends BaseEntity { ]; // При разархивировании — возвращаем в колонку "Готово" - if (!$archive) { - $update_data['column_id'] = COLUMN_DONE_ID; + if (!$archive && $done_column_id) { + $update_data['column_id'] = $done_column_id; } // Обновляем в БД diff --git a/backend/app/class/enity/class_taskImage.php b/backend/app/class/enity/class_taskImage.php index c7bb1cb..d634b8d 100644 --- a/backend/app/class/enity/class_taskImage.php +++ b/backend/app/class/enity/class_taskImage.php @@ -81,7 +81,7 @@ class TaskImage { $file_size = strlen($file_data); // Обновляем file_img в базе - $current_files = json_decode($task['file_img'], true) ?? []; + $current_files = $task['file_img'] ? json_decode($task['file_img'], true) : []; $current_files[] = [ 'url' => $file_url, 'name' => $final_name, @@ -115,7 +115,7 @@ class TaskImage { } // Получаем текущие файлы - $current_files = json_decode($task['file_img'], true) ?? []; + $current_files = $task['file_img'] ? json_decode($task['file_img'], true) : []; $upload_dir = __DIR__ . '/../../../public/task/' . $task_id; $deleted = []; diff --git a/backend/app/config.php b/backend/app/config.php index c0f0d2a..4b601c3 100644 --- a/backend/app/config.php +++ b/backend/app/config.php @@ -12,6 +12,7 @@ require_once __DIR__ . '/restAPI/class_restApi.php'; require_once __DIR__ . '/class/enity/class_base.php'; require_once __DIR__ . '/class/enity/class_user.php'; + require_once __DIR__ . '/class/enity/class_project.php'; require_once __DIR__ . '/class/enity/class_task.php'; require_once __DIR__ . '/class/enity/class_taskImage.php'; @@ -23,15 +24,13 @@ define('DB_PORT', 3306); define('DB_CHARSET', 'utf8mb4'); - // ID колонки "Готово" (для фиксации date_closed и архивации) - define('COLUMN_DONE_ID', 4); - // Инициализация подключения к БД Database::init(); $routes = [ '/api/user' => __DIR__ . '/../api/user.php', '/api/task' => __DIR__ . '/../api/task.php', + '/api/project' => __DIR__ . '/../api/project.php', ]; $publicActions = ['auth_login', 'check_session']; diff --git a/front_vue/index.html b/front_vue/index.html index f7b6ac7..66cf375 100644 --- a/front_vue/index.html +++ b/front_vue/index.html @@ -3,6 +3,7 @@ + TaskBoard diff --git a/front_vue/package-lock.json b/front_vue/package-lock.json index de8b1aa..925f89d 100644 --- a/front_vue/package-lock.json +++ b/front_vue/package-lock.json @@ -7,9 +7,9 @@ "": { "name": "taskboard", "version": "1.0.0", - "license": "ISC", "dependencies": { "@vitejs/plugin-vue": "^6.0.3", + "pinia": "^3.0.4", "vite": "^7.3.1", "vue": "^3.5.26", "vue-router": "^4.6.4" @@ -892,6 +892,30 @@ "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", "license": "MIT" }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, "node_modules/@vue/reactivity": { "version": "3.5.26", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.26.tgz", @@ -942,6 +966,30 @@ "integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==", "license": "MIT" }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1038,6 +1086,24 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1047,6 +1113,12 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -1065,6 +1137,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1084,6 +1162,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pinia/node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -1112,6 +1220,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, "node_modules/rollup": { "version": "4.55.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", @@ -1165,6 +1279,27 @@ "node": ">=0.10.0" } }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", diff --git a/front_vue/package.json b/front_vue/package.json index b51e5ca..6858248 100644 --- a/front_vue/package.json +++ b/front_vue/package.json @@ -11,6 +11,7 @@ "type": "module", "dependencies": { "@vitejs/plugin-vue": "^6.0.3", + "pinia": "^3.0.4", "vite": "^7.3.1", "vue": "^3.5.26", "vue-router": "^4.6.4" diff --git a/front_vue/public/favicon.ico b/front_vue/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..bb766f82624d32375a51fd2d4d0e07ba16499eb0 GIT binary patch literal 8158 zcmeHMy-ve05I#U4!2nE*z{t>nfd^n>WJUt<04#K2V<=C+12Djd*ic@e2njJGcKQZ@ zR5qmGFXYnWaNHb})`8Dv45Xd8W?uYP#S{70uV2Le~>>cc1* z7wEeTm$q;3&us`Ehk?=8M*&I{0*)h`V;U#x+1$fAEqG^Nl#=(Yy5JRiY?ORz9)Gfr-97E4 z&##^It=s)pV27XkvBdH{eI);ODf;LWUy7CS12eZ#_FTSLzNe2gYL;Yx<)@9!zpNv!WG2Y1bx6n9h zpVsBrhI}$k)y?@}ne&!dg{y3zG2^wYsg~HPcw;_?($4^__#au@W7YXwJ63!e7oWj9 zJMy?J$78V)@~IXp{G?vUhD?aJqNM3ok_PPEI-L{<2F4 nvPsm2T<^&i^hF|;Q2+C%g@+T*+lDUq=s<7wcw-(@X9lky)FbQV literal 0 HcmV?d00001 diff --git a/front_vue/src/api.js b/front_vue/src/api.js index 416ea03..e92daa4 100644 --- a/front_vue/src/api.js +++ b/front_vue/src/api.js @@ -38,40 +38,56 @@ export const authApi = { }) } -// ==================== DEPARTMENTS ==================== -export const departmentsApi = { - getAll: () => request('/api/task', { +// ==================== PROJECTS ==================== +export const projectsApi = { + // active: ID проекта для загрузки данных (опционально) + getAll: (active = null) => { + let url = '/api/project' + if (active) url += `?active=${active}` + return request(url, { credentials: 'include' }) + }, + // Получение данных проекта (проект + колонки + отделы) — один запрос вместо трёх + getData: (id_project) => request('/api/project', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action: 'get_departments' }) - }) -} - -// ==================== LABELS ==================== -export const labelsApi = { - getAll: () => request('/api/task', { + body: JSON.stringify({ action: 'get_project_data', id_project }) + }), + create: (data) => request('/api/project', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action: 'get_labels' }) - }) -} - -// ==================== COLUMNS ==================== -export const columnsApi = { - getAll: () => request('/api/task', { + body: JSON.stringify({ action: 'create', ...data }) + }), + update: (data) => request('/api/project', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action: 'get_columns' }) + body: JSON.stringify({ action: 'update', ...data }) + }), + delete: (id) => request('/api/project', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'delete', id }) + }), + setReadyColumn: (id_project, column_id) => request('/api/project', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'set_ready_column', id_project, column_id }) }) } // ==================== CARDS ==================== export const cardsApi = { + // id_project: ID проекта (обязательный) // archive: 0 = неархивные (по умолчанию), 1 = архивные, 'all' = все - getAll: (archive = 0) => request(`/api/task${archive !== 0 ? `?archive=${archive}` : ''}`, { credentials: 'include' }), + getAll: (id_project, archive = 0) => { + let url = `/api/task?id_project=${id_project}` + if (archive !== 0) url += `&archive=${archive}` + return request(url, { credentials: 'include' }) + }, updateOrder: (id, column_id, to_index) => request('/api/task', { method: 'POST', credentials: 'include', @@ -125,25 +141,3 @@ export const taskImageApi = { export const usersApi = { getAll: () => request('/api/user', { credentials: 'include' }) } - -// ==================== CONFIG ==================== -export const configApi = { - get: () => request('/api/user', { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action: 'get_config' }) - }) -} - -// Загрузка конфига с сервера и мерж с window.APP_CONFIG -export const loadServerConfig = async () => { - try { - const result = await configApi.get() - if (result.success && result.data) { - window.APP_CONFIG = { ...window.APP_CONFIG, ...result.data } - } - } catch (error) { - console.error('Ошибка загрузки конфига:', error) - } -} \ No newline at end of file diff --git a/front_vue/src/components/Board.vue b/front_vue/src/components/Board.vue index 55fa0fc..556ad69 100644 --- a/front_vue/src/components/Board.vue +++ b/front_vue/src/components/Board.vue @@ -7,6 +7,7 @@ :column="column" :departments="departments" :labels="labels" + :done-column-id="doneColumnId" @drop-card="handleDropCard" @open-task="(card) => emit('open-task', { card, columnId: column.id })" @create-task="emit('create-task', column.id)" @@ -23,6 +24,7 @@ import { cardsApi } from '../api' const props = defineProps({ activeDepartment: Number, + doneColumnId: Number, departments: { type: Array, default: () => [] @@ -55,15 +57,13 @@ onUpdated(refreshIcons) // Локальная копия карточек для optimistic UI const localCards = ref([]) -// Синхронизируем с props при загрузке данных +// Синхронизируем с props при загрузке/смене проекта watch(() => props.cards, (newCards) => { - if (newCards.length > 0 && localCards.value.length === 0) { - // Первая загрузка - копируем данные и добавляем order если нет - localCards.value = JSON.parse(JSON.stringify(newCards)).map((card, idx) => ({ - ...card, - order: card.order ?? idx - })) - } + // Копируем данные и добавляем order если нет + localCards.value = JSON.parse(JSON.stringify(newCards)).map((card, idx) => ({ + ...card, + order: card.order ?? idx + })) }, { immediate: true }) // Собираем колонки с карточками (используем localCards, сортируем по order) @@ -117,7 +117,8 @@ const inProgressTasks = computed(() => { }) const completedTasks = computed(() => { - const col = filteredColumns.value.find(c => c.id === 4) // Готово + if (!props.doneColumnId) return 0 + const col = filteredColumns.value.find(c => c.id === props.doneColumnId) return col ? col.cards.length : 0 }) @@ -134,15 +135,13 @@ const handleDropCard = async ({ cardId, fromColumnId, toColumnId, toIndex }) => const card = localCards.value.find(c => c.id === cardId) if (!card) return - const doneColumnId = window.APP_CONFIG.COLUMN_DONE_ID - // Локально обновляем для мгновенного отклика card.column_id = toColumnId // Обновляем date_closed при перемещении в/из колонки "Готово" - if (toColumnId === doneColumnId && fromColumnId !== doneColumnId) { + if (props.doneColumnId && toColumnId === props.doneColumnId && fromColumnId !== props.doneColumnId) { card.date_closed = new Date().toISOString() - } else if (fromColumnId === doneColumnId && toColumnId !== doneColumnId) { + } else if (props.doneColumnId && fromColumnId === props.doneColumnId && toColumnId !== props.doneColumnId) { card.date_closed = null } @@ -203,6 +202,7 @@ const saveTask = async (taskData, columnId) => { // Отправляем на сервер const result = await cardsApi.create({ + id_project: taskData.id_project, id_department: taskData.departmentId, id_label: taskData.labelId, id_account: taskData.accountId, diff --git a/front_vue/src/components/Card.vue b/front_vue/src/components/Card.vue index b3cafda..f2453e9 100644 --- a/front_vue/src/components/Card.vue +++ b/front_vue/src/components/Card.vue @@ -55,7 +55,7 @@ {{ daysLeftText }} - + Закрыто: {{ closedDateText }} @@ -69,6 +69,7 @@ import { getFullUrl } from '../api' const props = defineProps({ card: Object, columnId: [String, Number], + doneColumnId: Number, index: Number, departments: { type: Array, @@ -174,12 +175,9 @@ const closedDateText = computed(() => { return formatDateWithYear(props.card.dateClosed) }) -// ID колонки "Готово" из конфига -const doneColumnId = window.APP_CONFIG.COLUMN_DONE_ID - // Можно ли архивировать (только если колонка "Готово") const canArchive = computed(() => { - return Number(props.columnId) === doneColumnId + return props.doneColumnId && Number(props.columnId) === props.doneColumnId }) const handleArchive = () => { diff --git a/front_vue/src/components/Column.vue b/front_vue/src/components/Column.vue index 633ad36..290d8b6 100644 --- a/front_vue/src/components/Column.vue +++ b/front_vue/src/components/Column.vue @@ -26,6 +26,7 @@ [] diff --git a/front_vue/src/components/ProjectSelector.vue b/front_vue/src/components/ProjectSelector.vue new file mode 100644 index 0000000..0df46cf --- /dev/null +++ b/front_vue/src/components/ProjectSelector.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/front_vue/src/components/TaskPanel.vue b/front_vue/src/components/TaskPanel.vue index 6558149..ae44439 100644 --- a/front_vue/src/components/TaskPanel.vue +++ b/front_vue/src/components/TaskPanel.vue @@ -199,6 +199,7 @@ const props = defineProps({ show: Boolean, card: Object, columnId: [String, Number], + doneColumnId: Number, isArchived: { type: Boolean, default: false @@ -461,7 +462,7 @@ const handleDelete = () => { // Можно ли архивировать (только если колонка "Готово") const canArchive = computed(() => { - return Number(props.columnId) === window.APP_CONFIG.COLUMN_DONE_ID + return props.doneColumnId && Number(props.columnId) === props.doneColumnId }) const handleArchive = () => { diff --git a/front_vue/src/main.js b/front_vue/src/main.js index 3e79677..7b5cd15 100644 --- a/front_vue/src/main.js +++ b/front_vue/src/main.js @@ -1,5 +1,9 @@ import { createApp } from 'vue' +import { createPinia } from 'pinia' import App from './App.vue' import router from './router' -createApp(App).use(router).mount('#app') +const app = createApp(App) +app.use(createPinia()) +app.use(router) +app.mount('#app') diff --git a/front_vue/src/router.js b/front_vue/src/router.js index 0bd8c28..f863089 100644 --- a/front_vue/src/router.js +++ b/front_vue/src/router.js @@ -3,10 +3,7 @@ import MainApp from './views/MainApp.vue' import LoginPage from './views/LoginPage.vue' import TeamPage from './views/TeamPage.vue' import ArchivePage from './views/ArchivePage.vue' -import { authApi, loadServerConfig } from './api' - -// Флаг загрузки конфига (один раз за сессию) -let configLoaded = false +import { authApi } from './api' // Проверка авторизации const checkAuth = async () => { @@ -60,11 +57,6 @@ router.beforeEach(async (to, from, next) => { // Уже авторизован — на главную next('/') } else { - // Загружаем конфиг с сервера один раз для защищённых страниц - if (to.meta.requiresAuth && isAuth && !configLoaded) { - await loadServerConfig() - configLoaded = true - } next() } }) diff --git a/front_vue/src/stores/projects.js b/front_vue/src/stores/projects.js new file mode 100644 index 0000000..34f2897 --- /dev/null +++ b/front_vue/src/stores/projects.js @@ -0,0 +1,145 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { projectsApi, usersApi } from '../api' + +export const useProjectsStore = defineStore('projects', () => { + // ==================== СОСТОЯНИЕ ==================== + const projects = ref([]) + const departments = ref([]) + const labels = ref([]) + const columns = ref([]) + const users = ref([]) + const loading = ref(false) + const initialized = ref(false) + + // Текущий проект (из localStorage) + const savedProjectId = localStorage.getItem('currentProjectId') + const savedProjectName = localStorage.getItem('currentProjectName') + const currentProjectId = ref(savedProjectId ? parseInt(savedProjectId) : null) + + // ==================== ГЕТТЕРЫ ==================== + // Текущий проект (объект) + const currentProject = computed(() => + projects.value.find(p => p.id === currentProjectId.value) || + (savedProjectName && currentProjectId.value ? { id: currentProjectId.value, name: savedProjectName } : null) + ) + + // ID колонки "Готово" текущего проекта + const doneColumnId = computed(() => { + const project = projects.value.find(p => p.id === currentProjectId.value) + return project ? Number(project.id_ready) : null + }) + + // ==================== ДЕЙСТВИЯ ==================== + // Инициализация (загрузка проектов + данных активного) + const init = async () => { + if (initialized.value) return + loading.value = true + + try { + // Загружаем проекты и данные активного одним запросом + const result = await projectsApi.getAll(currentProjectId.value || undefined) + + if (result.success) { + if (result.data.projects) { + projects.value = result.data.projects + + // Применяем данные активного проекта + if (result.data.active) { + columns.value = result.data.active.columns + departments.value = result.data.active.departments + labels.value = result.data.active.labels + } + } else { + projects.value = result.data + } + + // Если нет выбранного проекта — выбираем первый + if (!currentProjectId.value || !projects.value.find(p => p.id === currentProjectId.value)) { + if (projects.value.length > 0) { + await selectProject(projects.value[0].id, false) + } + } else { + // Обновляем название в localStorage + const project = projects.value.find(p => p.id === currentProjectId.value) + if (project) localStorage.setItem('currentProjectName', project.name) + } + } + + // Загружаем пользователей + const usersData = await usersApi.getAll() + if (usersData.success) users.value = usersData.data + + initialized.value = true + } catch (error) { + console.error('Ошибка инициализации:', error) + } finally { + loading.value = false + } + } + + // Выбор проекта + const selectProject = async (projectId, fetchData = true) => { + currentProjectId.value = projectId + localStorage.setItem('currentProjectId', projectId.toString()) + + // Сохраняем название + const project = projects.value.find(p => p.id === projectId) + if (project) localStorage.setItem('currentProjectName', project.name) + + // Загружаем данные проекта + if (fetchData) { + await fetchProjectData() + } + } + + // Загрузка данных текущего проекта + const fetchProjectData = async () => { + if (!currentProjectId.value) return + + try { + const projectData = await projectsApi.getData(currentProjectId.value) + + if (projectData.success) { + columns.value = projectData.data.columns + departments.value = projectData.data.departments + labels.value = projectData.data.labels + } + } catch (error) { + console.error('Ошибка загрузки данных проекта:', error) + } + } + + // Сброс при выходе + const reset = () => { + projects.value = [] + departments.value = [] + labels.value = [] + columns.value = [] + users.value = [] + currentProjectId.value = null + initialized.value = false + localStorage.removeItem('currentProjectId') + localStorage.removeItem('currentProjectName') + } + + return { + // Состояние + projects, + departments, + labels, + columns, + users, + loading, + initialized, + currentProjectId, + // Геттеры + currentProject, + doneColumnId, + // Действия + init, + selectProject, + fetchProjectData, + reset + } +}) diff --git a/front_vue/src/views/ArchivePage.vue b/front_vue/src/views/ArchivePage.vue index 15a5df6..d256ddd 100644 --- a/front_vue/src/views/ArchivePage.vue +++ b/front_vue/src/views/ArchivePage.vue @@ -9,6 +9,12 @@