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 0000000..bb766f8
Binary files /dev/null and b/front_vue/public/favicon.ico differ
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 @@
+
+
+
+
+
+