1207 lines
34 KiB
PHP
1207 lines
34 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Controllers;
|
||
|
||
use App\Core\Auth;
|
||
use App\Core\DB;
|
||
use App\Core\View;
|
||
|
||
class TaskController
|
||
{
|
||
public function index(): void
|
||
{
|
||
if (!Auth::check()) {
|
||
header('Location: /login');
|
||
exit;
|
||
}
|
||
|
||
$pdo = DB::connection();
|
||
|
||
// 1. Берем только активные доски, которые надо показывать на главной
|
||
$stmt = $pdo->query("
|
||
SELECT
|
||
b.id,
|
||
b.name,
|
||
b.code,
|
||
b.description
|
||
FROM boards b
|
||
WHERE b.is_active = 1
|
||
AND b.show_on_home = 1
|
||
ORDER BY b.id ASC
|
||
");
|
||
$boards = $stmt->fetchAll();
|
||
|
||
if (empty($boards)) {
|
||
View::render('tasks/index', [
|
||
'boards' => [],
|
||
'user' => Auth::user(),
|
||
]);
|
||
return;
|
||
}
|
||
|
||
$boardIds = array_map(static fn($b) => (int)$b['id'], $boards);
|
||
$placeholders = implode(',', array_fill(0, count($boardIds), '?'));
|
||
|
||
// 2. Статистика по задачам для каждой доски
|
||
$stmt = $pdo->prepare("
|
||
SELECT
|
||
t.board_id,
|
||
COUNT(*) AS total_count,
|
||
SUM(CASE WHEN t.status = 'NEW' THEN 1 ELSE 0 END) AS new_count,
|
||
SUM(CASE WHEN t.status = 'IN_PROGRESS' THEN 1 ELSE 0 END) AS in_progress_count,
|
||
SUM(CASE WHEN t.status = 'REVIEW' THEN 1 ELSE 0 END) AS review_count,
|
||
SUM(CASE WHEN t.status = 'DONE' THEN 1 ELSE 0 END) AS done_count,
|
||
SUM(CASE WHEN t.status = 'CANCELED' THEN 1 ELSE 0 END) AS canceled_count,
|
||
SUM(CASE WHEN t.status = 'OVERDUE' THEN 1 ELSE 0 END) AS overdue_count
|
||
FROM tasks t
|
||
WHERE t.board_id IN ($placeholders)
|
||
GROUP BY t.board_id
|
||
");
|
||
$stmt->execute($boardIds);
|
||
$statsRows = $stmt->fetchAll();
|
||
|
||
$statsMap = [];
|
||
foreach ($statsRows as $row) {
|
||
$statsMap[(int)$row['board_id']] = $row;
|
||
}
|
||
|
||
// 3. Последние задачи по доскам
|
||
$stmt = $pdo->prepare("
|
||
SELECT
|
||
t.id,
|
||
t.board_id,
|
||
t.crm_id,
|
||
t.name,
|
||
t.status,
|
||
t.priority,
|
||
t.creator_name,
|
||
t.planned_at,
|
||
t.assignee_name,
|
||
COALESCE(tc.comment_count, 0) AS comment_count,
|
||
tc.last_comment_id,
|
||
tcr.last_read_comment_id
|
||
FROM tasks t
|
||
LEFT JOIN (
|
||
SELECT
|
||
task_id,
|
||
COUNT(*) AS comment_count,
|
||
MAX(id) AS last_comment_id
|
||
FROM task_comments
|
||
GROUP BY task_id
|
||
) tc ON tc.task_id = t.id
|
||
LEFT JOIN task_comment_reads tcr
|
||
ON tcr.task_id = t.id
|
||
AND tcr.user_id = ?
|
||
WHERE t.board_id IN ($placeholders)
|
||
ORDER BY t.board_id ASC, t.id DESC
|
||
");
|
||
$params = array_merge([Auth::user()['id']], $boardIds);
|
||
$stmt->execute($params);
|
||
$taskRows = $stmt->fetchAll();
|
||
|
||
$tasksMap = [];
|
||
foreach ($taskRows as $task) {
|
||
$boardId = (int)$task['board_id'];
|
||
|
||
if (!isset($tasksMap[$boardId])) {
|
||
$tasksMap[$boardId] = [];
|
||
}
|
||
|
||
if (count($tasksMap[$boardId]) < 5) {
|
||
$tasksMap[$boardId][] = $task;
|
||
}
|
||
}
|
||
|
||
// 4. Собираем финальный массив досок
|
||
foreach ($boards as &$board) {
|
||
$boardId = (int)$board['id'];
|
||
|
||
$board['stats'] = $statsMap[$boardId] ?? [
|
||
'total_count' => 0,
|
||
'new_count' => 0,
|
||
'in_progress_count' => 0,
|
||
'review_count' => 0,
|
||
'done_count' => 0,
|
||
'canceled_count' => 0,
|
||
'overdue_count' => 0,
|
||
];
|
||
|
||
$board['tasks'] = $tasksMap[$boardId] ?? [];
|
||
}
|
||
unset($board);
|
||
|
||
View::render('tasks/index', [
|
||
'boards' => $boards,
|
||
'user' => Auth::user(),
|
||
]);
|
||
}
|
||
|
||
public function create(): void
|
||
{
|
||
if (!Auth::check()) {
|
||
header('Location: /login');
|
||
exit;
|
||
}
|
||
|
||
$pdo = DB::connection();
|
||
|
||
$stmt = $pdo->query("
|
||
SELECT id, login, display_name
|
||
FROM users
|
||
ORDER BY
|
||
CASE WHEN display_name IS NULL OR display_name = '' THEN 1 ELSE 0 END,
|
||
display_name ASC,
|
||
login ASC
|
||
");
|
||
$users = $stmt->fetchAll();
|
||
|
||
$stmt = $pdo->query("
|
||
SELECT id, name, code
|
||
FROM boards
|
||
WHERE is_active = 1
|
||
ORDER BY name ASC
|
||
");
|
||
$boards = $stmt->fetchAll();
|
||
|
||
$boardCode = trim((string)($_GET['board_code'] ?? ''));
|
||
$selectedBoard = null;
|
||
$boardFields = [];
|
||
|
||
if ($boardCode !== '') {
|
||
$stmt = $pdo->prepare("
|
||
SELECT id, name, code
|
||
FROM boards
|
||
WHERE code = ?
|
||
AND is_active = 1
|
||
LIMIT 1
|
||
");
|
||
$stmt->execute([$boardCode]);
|
||
$selectedBoard = $stmt->fetch();
|
||
}
|
||
|
||
if ($selectedBoard) {
|
||
$stmt = $pdo->prepare("
|
||
SELECT *
|
||
FROM board_fields
|
||
WHERE board_id = ?
|
||
AND is_active = 1
|
||
ORDER BY sort_order ASC, id ASC
|
||
");
|
||
$stmt->execute([$selectedBoard['id']]);
|
||
$boardFields = $stmt->fetchAll();
|
||
}
|
||
|
||
View::render('tasks/create', [
|
||
'user' => Auth::user(),
|
||
'users' => $users,
|
||
'boards' => $boards,
|
||
'board_code' => $boardCode,
|
||
'selectedBoard' => $selectedBoard,
|
||
'boardFields' => $boardFields,
|
||
]);
|
||
}
|
||
|
||
public function store(): void
|
||
{
|
||
if (!Auth::check()) {
|
||
header('Location: /login');
|
||
exit;
|
||
}
|
||
|
||
$name = trim((string)($_POST['name'] ?? ''));
|
||
$taskCreatedAt = $this->normalizeDateTime($_POST['task_created_at'] ?? '', true);
|
||
$plannedAt = $this->normalizeDateTime($_POST['planned_at'] ?? '', false);
|
||
|
||
if ($name === '') {
|
||
$_SESSION['error'] = 'Заполните наименование задачи';
|
||
header('Location: /tasks/create');
|
||
exit;
|
||
}
|
||
|
||
$pdo = DB::connection();
|
||
|
||
$crmId = 'CRM-' . date('Ymd-His');
|
||
|
||
$creatorId = (int)($_POST['creator_id'] ?? Auth::user()['id']);
|
||
$boardId = (int)($_POST['board_id'] ?? 0);
|
||
$assigneeId = (int)($_POST['assignee_id'] ?? 0);
|
||
$assigneeName = null;
|
||
|
||
$stmt = $pdo->prepare("
|
||
SELECT id, login, display_name
|
||
FROM users
|
||
WHERE id = ?
|
||
LIMIT 1
|
||
");
|
||
$stmt->execute([$creatorId]);
|
||
$creator = $stmt->fetch();
|
||
|
||
if (!$creator) {
|
||
$creatorId = (int)Auth::user()['id'];
|
||
$creator = Auth::user();
|
||
}
|
||
|
||
$creatorName = $creator['display_name'] ?: $creator['login'];
|
||
|
||
if ($assigneeId > 0) {
|
||
$stmt = $pdo->prepare("
|
||
SELECT id, login, display_name
|
||
FROM users
|
||
WHERE id = ?
|
||
LIMIT 1
|
||
");
|
||
$stmt->execute([$assigneeId]);
|
||
$assignee = $stmt->fetch();
|
||
|
||
if ($assignee) {
|
||
$assigneeName = $assignee['display_name'] ?: $assignee['login'];
|
||
} else {
|
||
$assigneeId = 0;
|
||
}
|
||
}
|
||
|
||
$stmt = $pdo->prepare("
|
||
INSERT INTO tasks (
|
||
crm_id,
|
||
board_id,
|
||
source_type,
|
||
name,
|
||
description,
|
||
task_created_at,
|
||
planned_at,
|
||
creator_id,
|
||
creator_name,
|
||
assignee_id,
|
||
assignee_name,
|
||
status,
|
||
priority,
|
||
created_by,
|
||
updated_by,
|
||
created_at,
|
||
updated_at
|
||
) VALUES (
|
||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()
|
||
)
|
||
");
|
||
|
||
$stmt->execute([
|
||
$crmId,
|
||
$boardId ?: null,
|
||
'manual',
|
||
$name,
|
||
trim((string)($_POST['description'] ?? '')) ?: null,
|
||
$taskCreatedAt,
|
||
$plannedAt,
|
||
$creatorId,
|
||
$creatorName,
|
||
$assigneeId ?: null,
|
||
$assigneeName,
|
||
trim((string)($_POST['status'] ?? 'NEW')),
|
||
trim((string)($_POST['priority'] ?? 'MEDIUM')),
|
||
Auth::user()['id'],
|
||
Auth::user()['id'],
|
||
]);
|
||
|
||
$taskId = (int)$pdo->lastInsertId();
|
||
|
||
// Сохраняем кастомные поля доски
|
||
if ($boardId > 0) {
|
||
$stmt = $pdo->prepare("
|
||
SELECT id, code, field_type
|
||
FROM board_fields
|
||
WHERE board_id = ?
|
||
AND is_active = 1
|
||
ORDER BY sort_order ASC, id ASC
|
||
");
|
||
$stmt->execute([$boardId]);
|
||
$fields = $stmt->fetchAll();
|
||
|
||
if (!empty($fields)) {
|
||
$insertStmt = $pdo->prepare("
|
||
INSERT INTO task_field_values (
|
||
task_id,
|
||
field_id,
|
||
value_text,
|
||
created_at,
|
||
updated_at
|
||
) VALUES (?, ?, ?, NOW(), NOW())
|
||
");
|
||
|
||
foreach ($fields as $field) {
|
||
$inputName = 'custom_field_' . (int)$field['id'];
|
||
|
||
if ((string)$field['field_type'] === 'checkbox') {
|
||
$value = isset($_POST[$inputName]) ? '1' : '0';
|
||
} else {
|
||
$value = trim((string)($_POST[$inputName] ?? ''));
|
||
}
|
||
|
||
if ((string)$field['field_type'] !== 'checkbox' && $value === '') {
|
||
continue;
|
||
}
|
||
|
||
$insertStmt->execute([
|
||
$taskId,
|
||
(int)$field['id'],
|
||
$value,
|
||
]);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Экспорт в Google Sheets только для supply
|
||
if ($boardId > 0) {
|
||
$stmt = $pdo->prepare("
|
||
SELECT code
|
||
FROM boards
|
||
WHERE id = ?
|
||
LIMIT 1
|
||
");
|
||
$stmt->execute([$boardId]);
|
||
$board = $stmt->fetch();
|
||
|
||
if (($board['code'] ?? '') === 'supply') {
|
||
try {
|
||
$exportService = new \App\Services\Google\GoogleExportService();
|
||
$exportService->exportTaskById($taskId);
|
||
} catch (\Throwable $e) {
|
||
$_SESSION['error'] = 'Задача создана, но не удалось выгрузить в Google Sheets: ' . $e->getMessage();
|
||
}
|
||
}
|
||
var_dump($board);
|
||
}
|
||
|
||
$boardCode = trim((string)($_POST['board_code'] ?? ''));
|
||
|
||
if ($boardCode !== '') {
|
||
header('Location: /boards/' . urlencode($boardCode));
|
||
exit;
|
||
}
|
||
|
||
header('Location: /tasks');
|
||
exit;
|
||
}
|
||
public function showModal(): void
|
||
{
|
||
if (!Auth::check()) {
|
||
http_response_code(403);
|
||
echo 'Unauthorized';
|
||
return;
|
||
}
|
||
|
||
$taskId = (int)($_GET['id'] ?? 0);
|
||
|
||
if ($taskId <= 0) {
|
||
http_response_code(400);
|
||
echo 'Task ID is required';
|
||
return;
|
||
}
|
||
|
||
$pdo = DB::connection();
|
||
|
||
$stmt = $pdo->prepare("
|
||
SELECT
|
||
t.*,
|
||
b.name AS board_name,
|
||
b.code AS board_code,
|
||
b.description AS board_description
|
||
FROM tasks t
|
||
LEFT JOIN boards b ON b.id = t.board_id
|
||
WHERE t.id = ?
|
||
LIMIT 1
|
||
");
|
||
$stmt->execute([$taskId]);
|
||
$task = $stmt->fetch();
|
||
$canEditTask = $this->canEditTask($task);
|
||
if (!$task) {
|
||
http_response_code(404);
|
||
echo 'Task not found';
|
||
return;
|
||
}
|
||
|
||
$stmt = $pdo->prepare("
|
||
SELECT
|
||
bf.id,
|
||
bf.code,
|
||
bf.name,
|
||
bf.field_type,
|
||
bf.sort_order,
|
||
tfv.value_text AS value
|
||
FROM board_fields bf
|
||
LEFT JOIN task_field_values tfv
|
||
ON tfv.field_id = bf.id
|
||
AND tfv.task_id = ?
|
||
WHERE bf.board_id = ?
|
||
AND bf.is_active = 1
|
||
ORDER BY bf.sort_order ASC, bf.id ASC
|
||
");
|
||
$stmt->execute([
|
||
$taskId,
|
||
(int)$task['board_id'],
|
||
]);
|
||
$customFields = $stmt->fetchAll();
|
||
|
||
$stmt = $pdo->prepare("
|
||
SELECT
|
||
tc.id,
|
||
tc.text,
|
||
tc.created_at,
|
||
tc.updated_at,
|
||
u.display_name,
|
||
u.login
|
||
FROM task_comments tc
|
||
LEFT JOIN users u ON u.id = tc.user_id
|
||
WHERE tc.task_id = ?
|
||
ORDER BY tc.id DESC
|
||
");
|
||
$stmt->execute([$taskId]);
|
||
$comments = $stmt->fetchAll();
|
||
|
||
$lastCommentId = 0;
|
||
if (!empty($comments)) {
|
||
$lastCommentId = (int)$comments[0]['id'];
|
||
}
|
||
|
||
$stmt = $pdo->prepare("
|
||
INSERT INTO task_comment_reads (
|
||
task_id,
|
||
user_id,
|
||
last_read_comment_id,
|
||
last_read_at,
|
||
created_at,
|
||
updated_at
|
||
) VALUES (?, ?, ?, NOW(), NOW(), NOW())
|
||
ON DUPLICATE KEY UPDATE
|
||
last_read_comment_id = VALUES(last_read_comment_id),
|
||
last_read_at = NOW(),
|
||
updated_at = NOW()
|
||
");
|
||
$stmt->execute([
|
||
$taskId,
|
||
Auth::user()['id'],
|
||
$lastCommentId > 0 ? $lastCommentId : null,
|
||
]);
|
||
|
||
$stmt = $pdo->prepare("
|
||
SELECT
|
||
tf.id,
|
||
tf.original_name,
|
||
tf.file_size,
|
||
tf.mime_type,
|
||
tf.created_at,
|
||
u.display_name,
|
||
u.login
|
||
FROM task_files tf
|
||
LEFT JOIN users u ON u.id = tf.uploaded_by
|
||
WHERE tf.task_id = ?
|
||
ORDER BY tf.id DESC
|
||
");
|
||
$stmt->execute([$taskId]);
|
||
$files = $stmt->fetchAll();
|
||
|
||
foreach ($files as &$file) {
|
||
$mime = (string)($file['mime_type'] ?? '');
|
||
$file['is_image'] = str_starts_with($mime, 'image/');
|
||
}
|
||
unset($file);
|
||
|
||
View::render('tasks/modal', [
|
||
'task' => $task,
|
||
'customFields' => $customFields,
|
||
'comments' => $comments,
|
||
'files' => $files,
|
||
'canEditTask' => $canEditTask,
|
||
'user' => Auth::user(),
|
||
], null);
|
||
}
|
||
public function kanban(): void
|
||
{
|
||
if (!Auth::check()) {
|
||
header('Location: /login');
|
||
exit;
|
||
}
|
||
|
||
$pdo = DB::connection();
|
||
|
||
$filterStatus = trim($_GET['status'] ?? '');
|
||
|
||
$allowedStatuses = ['NEW', 'IN_PROGRESS', 'REVIEW', 'DONE', 'CANCELED', 'OVERDUE'];
|
||
|
||
$sql = "
|
||
SELECT
|
||
t.id,
|
||
t.crm_id,
|
||
t.name,
|
||
t.description,
|
||
t.creator_id,
|
||
t.creator_name,
|
||
t.assignee_name,
|
||
t.order_number,
|
||
t.applicant,
|
||
t.supplier,
|
||
t.delivery_date,
|
||
t.completed_flag,
|
||
t.status,
|
||
t.priority,
|
||
|
||
COALESCE(tc.comment_count, 0) AS comment_count,
|
||
tc.last_comment_id,
|
||
tcr.last_read_comment_id
|
||
|
||
FROM tasks t
|
||
|
||
LEFT JOIN (
|
||
SELECT
|
||
task_id,
|
||
COUNT(*) AS comment_count,
|
||
MAX(id) AS last_comment_id
|
||
FROM task_comments
|
||
GROUP BY task_id
|
||
) tc ON tc.task_id = t.id
|
||
|
||
LEFT JOIN task_comment_reads tcr
|
||
ON tcr.task_id = t.id
|
||
AND tcr.user_id = ?
|
||
";
|
||
|
||
$params = [Auth::user()['id']];
|
||
|
||
if ($filterStatus !== '' && in_array($filterStatus, $allowedStatuses, true)) {
|
||
$sql .= " WHERE t.status = ? ";
|
||
$params[] = $filterStatus;
|
||
}
|
||
|
||
$sql .= " ORDER BY t.id DESC ";
|
||
|
||
$stmt = $pdo->prepare($sql);
|
||
$stmt->execute($params);
|
||
$tasks = $stmt->fetchAll();
|
||
|
||
$columns = [
|
||
'NEW' => [
|
||
'title' => 'Новая',
|
||
'tasks' => [],
|
||
'header_class' => 'kanban-header-new',
|
||
],
|
||
'IN_PROGRESS' => [
|
||
'title' => 'В работе',
|
||
'tasks' => [],
|
||
'header_class' => 'kanban-header-progress',
|
||
],
|
||
'REVIEW' => [
|
||
'title' => 'На проверке',
|
||
'tasks' => [],
|
||
'header_class' => 'kanban-header-review',
|
||
],
|
||
'DONE' => [
|
||
'title' => 'Закрыта',
|
||
'tasks' => [],
|
||
'header_class' => 'kanban-header-done',
|
||
],
|
||
'CANCELED' => [
|
||
'title' => 'Отменена',
|
||
'tasks' => [],
|
||
'header_class' => 'kanban-header-canceled',
|
||
],
|
||
'OVERDUE' => [
|
||
'title' => 'Просрочена',
|
||
'tasks' => [],
|
||
'header_class' => 'kanban-header-overdue',
|
||
],
|
||
];
|
||
|
||
foreach ($tasks as $task) {
|
||
$status = $task['status'] ?? 'NEW';
|
||
|
||
if (!isset($columns[$status])) {
|
||
$status = 'NEW';
|
||
}
|
||
|
||
$columns[$status]['tasks'][] = $task;
|
||
}
|
||
|
||
View::render('tasks/kanban', [
|
||
'columns' => $columns,
|
||
'user' => Auth::user(),
|
||
'filterStatus' => $filterStatus,
|
||
]);
|
||
}
|
||
public function changeStatus(): void
|
||
{
|
||
if (!Auth::check()) {
|
||
http_response_code(403);
|
||
echo json_encode(['success' => false, 'message' => 'Unauthorized']);
|
||
return;
|
||
}
|
||
|
||
header('Content-Type: application/json; charset=utf-8');
|
||
|
||
$taskId = (int)($_POST['task_id'] ?? 0);
|
||
$status = trim($_POST['status'] ?? '');
|
||
|
||
$allowedStatuses = ['NEW', 'IN_PROGRESS', 'REVIEW', 'DONE', 'CANCELED', 'OVERDUE'];
|
||
|
||
if ($taskId <= 0 || !in_array($status, $allowedStatuses, true)) {
|
||
http_response_code(400);
|
||
echo json_encode(['success' => false, 'message' => 'Некорректные данные']);
|
||
return;
|
||
}
|
||
|
||
$pdo = DB::connection();
|
||
|
||
$stmt = $pdo->prepare("SELECT id, status FROM tasks WHERE id = ? LIMIT 1");
|
||
$stmt->execute([$taskId]);
|
||
$task = $stmt->fetch();
|
||
|
||
if (!$task) {
|
||
http_response_code(404);
|
||
echo json_encode(['success' => false, 'message' => 'Задача не найдена']);
|
||
return;
|
||
}
|
||
|
||
$oldStatus = $task['status'];
|
||
|
||
if ($oldStatus === $status) {
|
||
echo json_encode(['success' => true, 'message' => 'Статус не изменился']);
|
||
return;
|
||
}
|
||
|
||
$stmt = $pdo->prepare("
|
||
UPDATE tasks
|
||
SET status = ?, updated_by = ?, updated_at = NOW()
|
||
WHERE id = ?
|
||
");
|
||
$stmt->execute([$status, Auth::user()['id'], $taskId]);
|
||
|
||
$stmt = $pdo->prepare("
|
||
INSERT INTO task_history (
|
||
task_id,
|
||
user_id,
|
||
action,
|
||
field_name,
|
||
old_value,
|
||
new_value,
|
||
created_at
|
||
) VALUES (?, ?, 'status_changed', 'status', ?, ?, NOW())
|
||
");
|
||
$stmt->execute([
|
||
$taskId,
|
||
Auth::user()['id'],
|
||
$oldStatus,
|
||
$status,
|
||
]);
|
||
|
||
echo json_encode(['success' => true]);
|
||
}
|
||
public function update(): void
|
||
{
|
||
if (!Auth::check()) {
|
||
header('Location: /login');
|
||
exit;
|
||
}
|
||
|
||
$pdo = DB::connection();
|
||
|
||
$taskId = (int)($_POST['task_id'] ?? 0);
|
||
|
||
if ($taskId <= 0) {
|
||
$_SESSION['error'] = 'Задача не найдена';
|
||
header('Location: /tasks');
|
||
exit;
|
||
}
|
||
|
||
$stmt = $pdo->prepare("
|
||
SELECT *
|
||
FROM tasks
|
||
WHERE id = ?
|
||
LIMIT 1
|
||
");
|
||
$stmt->execute([$taskId]);
|
||
$task = $stmt->fetch();
|
||
|
||
if (!$task) {
|
||
$_SESSION['error'] = 'Задача не найдена';
|
||
header('Location: /tasks');
|
||
exit;
|
||
}
|
||
|
||
if (!$this->canEditTask($task)) {
|
||
http_response_code(403);
|
||
echo 'Нет прав на изменение задачи';
|
||
return;
|
||
}
|
||
|
||
$name = trim((string)($_POST['name'] ?? ''));
|
||
|
||
if ($name === '') {
|
||
$_SESSION['error'] = 'Наименование не может быть пустым';
|
||
header('Location: ' . ($_SERVER['HTTP_REFERER'] ?? '/tasks'));
|
||
exit;
|
||
}
|
||
|
||
$description = trim((string)($_POST['description'] ?? ''));
|
||
$taskCreatedAt = $this->normalizeDateTime($_POST['task_created_at'] ?? '', true);
|
||
$plannedAt = $this->normalizeDateTime($_POST['planned_at'] ?? '', false);
|
||
|
||
$stmt = $pdo->prepare("
|
||
UPDATE tasks
|
||
SET
|
||
name = ?,
|
||
description = ?,
|
||
task_created_at = ?,
|
||
planned_at = ?,
|
||
updated_by = ?,
|
||
updated_at = NOW()
|
||
WHERE id = ?
|
||
");
|
||
$stmt->execute([
|
||
$name,
|
||
$description !== '' ? $description : null,
|
||
$taskCreatedAt,
|
||
$plannedAt,
|
||
Auth::user()['id'],
|
||
$taskId,
|
||
]);
|
||
|
||
$stmt = $pdo->prepare("
|
||
SELECT id, field_type
|
||
FROM board_fields
|
||
WHERE board_id = ?
|
||
AND is_active = 1
|
||
");
|
||
$stmt->execute([(int)$task['board_id']]);
|
||
$fields = $stmt->fetchAll();
|
||
|
||
foreach ($fields as $field) {
|
||
$inputName = 'custom_field_' . (int)$field['id'];
|
||
|
||
if ((string)$field['field_type'] === 'checkbox') {
|
||
$value = isset($_POST[$inputName]) ? '1' : '0';
|
||
} else {
|
||
$value = trim((string)($_POST[$inputName] ?? ''));
|
||
}
|
||
|
||
$stmt = $pdo->prepare("
|
||
INSERT INTO task_field_values (
|
||
task_id,
|
||
field_id,
|
||
value_text,
|
||
created_at,
|
||
updated_at
|
||
) VALUES (?, ?, ?, NOW(), NOW())
|
||
ON DUPLICATE KEY UPDATE
|
||
value_text = VALUES(value_text),
|
||
updated_at = NOW()
|
||
");
|
||
$stmt->execute([
|
||
$taskId,
|
||
(int)$field['id'],
|
||
$value,
|
||
]);
|
||
}
|
||
|
||
header('Location: ' . ($_SERVER['HTTP_REFERER'] ?? '/tasks'));
|
||
exit;
|
||
}
|
||
public function updateStatus(): void
|
||
{
|
||
if (!Auth::check()) {
|
||
if ($this->isAjax()) {
|
||
$this->json(['success' => false, 'message' => 'Unauthorized'], 403);
|
||
}
|
||
|
||
header('Location: /login');
|
||
exit;
|
||
}
|
||
|
||
$taskId = (int)($_POST['task_id'] ?? 0);
|
||
$status = trim((string)($_POST['status'] ?? ''));
|
||
|
||
$allowedStatuses = ['NEW', 'IN_PROGRESS', 'REVIEW', 'DONE', 'CANCELED', 'OVERDUE'];
|
||
|
||
if ($taskId <= 0 || !in_array($status, $allowedStatuses, true)) {
|
||
if ($this->isAjax()) {
|
||
$this->json(['success' => false, 'message' => 'Некорректный статус'], 400);
|
||
}
|
||
|
||
$_SESSION['error'] = 'Некорректный статус';
|
||
header('Location: /tasks');
|
||
exit;
|
||
}
|
||
|
||
$pdo = DB::connection();
|
||
|
||
$stmt = $pdo->prepare("
|
||
SELECT id, status, completed_flag
|
||
FROM tasks
|
||
WHERE id = ?
|
||
LIMIT 1
|
||
");
|
||
$stmt->execute([$taskId]);
|
||
$task = $stmt->fetch();
|
||
if (!$this->canEditTask($task)) {
|
||
if ($this->isAjax()) {
|
||
$this->json(['success' => false, 'message' => 'Нет прав на изменение задачи'], 403);
|
||
}
|
||
|
||
$_SESSION['error'] = 'Нет прав на изменение задачи';
|
||
header('Location: /tasks');
|
||
exit;
|
||
}
|
||
if (!$task) {
|
||
if ($this->isAjax()) {
|
||
$this->json(['success' => false, 'message' => 'Задача не найдена'], 404);
|
||
}
|
||
|
||
$_SESSION['error'] = 'Задача не найдена';
|
||
header('Location: /tasks');
|
||
exit;
|
||
}
|
||
|
||
$oldStatus = (string)$task['status'];
|
||
$oldCompletedFlag = (int)$task['completed_flag'];
|
||
$completedFlag = ($status === 'DONE') ? 1 : 0;
|
||
|
||
$stmt = $pdo->prepare("
|
||
UPDATE tasks
|
||
SET
|
||
status = ?,
|
||
completed_flag = ?,
|
||
completed_at = ?,
|
||
updated_by = ?,
|
||
updated_at = NOW()
|
||
WHERE id = ?
|
||
");
|
||
$currentCompletedAt = $task['completed_at'] ?? null;
|
||
$newCompletedAt = $status === 'DONE'
|
||
? ($currentCompletedAt ?: date('Y-m-d H:i:s'))
|
||
: null;
|
||
$stmt->execute([
|
||
$status,
|
||
$completedFlag,
|
||
$newCompletedAt,
|
||
Auth::user()['id'],
|
||
$taskId,
|
||
]);
|
||
|
||
if ($oldStatus !== $status) {
|
||
$stmt = $pdo->prepare("
|
||
INSERT INTO task_history (
|
||
task_id,
|
||
user_id,
|
||
action,
|
||
field_name,
|
||
old_value,
|
||
new_value,
|
||
created_at
|
||
) VALUES (?, ?, 'status_changed', 'status', ?, ?, NOW())
|
||
");
|
||
$stmt->execute([
|
||
$taskId,
|
||
Auth::user()['id'],
|
||
$oldStatus,
|
||
$status,
|
||
]);
|
||
}
|
||
|
||
if ($oldCompletedFlag !== $completedFlag) {
|
||
$stmt = $pdo->prepare("
|
||
INSERT INTO task_history (
|
||
task_id,
|
||
user_id,
|
||
action,
|
||
field_name,
|
||
old_value,
|
||
new_value,
|
||
created_at
|
||
) VALUES (?, ?, 'completed_flag_changed', 'completed_flag', ?, ?, NOW())
|
||
");
|
||
$stmt->execute([
|
||
$taskId,
|
||
Auth::user()['id'],
|
||
(string)$oldCompletedFlag,
|
||
(string)$completedFlag,
|
||
]);
|
||
}
|
||
|
||
if ($this->isAjax()) {
|
||
$this->json([
|
||
'success' => true,
|
||
'task_id' => $taskId,
|
||
'status' => $status,
|
||
'completed_flag' => $completedFlag,
|
||
]);
|
||
}
|
||
|
||
header('Location: /tasks');
|
||
exit;
|
||
}
|
||
public function board(string $code): void
|
||
{
|
||
if (!Auth::check()) {
|
||
header('Location: /login');
|
||
exit;
|
||
}
|
||
|
||
$user = Auth::user();
|
||
$pdo = DB::connection();
|
||
|
||
$stmt = $pdo->prepare("
|
||
SELECT id, name, code, description, is_active, show_on_home
|
||
FROM boards
|
||
WHERE code = ?
|
||
AND is_active = 1
|
||
LIMIT 1
|
||
");
|
||
$stmt->execute([$code]);
|
||
$board = $stmt->fetch();
|
||
|
||
if (!$board) {
|
||
http_response_code(404);
|
||
echo 'Доска не найдена';
|
||
return;
|
||
}
|
||
|
||
$canManageBoard = ((int)($user['is_admin'] ?? 0) === 1)
|
||
|| ((int)($user['can_manage_boards'] ?? 0) === 1);
|
||
|
||
$viewParam = trim((string)($_GET['view'] ?? 'kanban'));
|
||
|
||
$allowedViews = ['table', 'kanban', 'cards'];
|
||
$view = in_array($viewParam, $allowedViews, true) ? $viewParam : 'kanban';
|
||
|
||
$status = trim((string)($_GET['status'] ?? ''));
|
||
$priority = trim((string)($_GET['priority'] ?? ''));
|
||
$assigneeId = (int)($_GET['assignee_id'] ?? 0);
|
||
$search = trim((string)($_GET['search'] ?? ''));
|
||
|
||
$sql = "
|
||
SELECT
|
||
t.*,
|
||
COALESCE(tc.comment_count, 0) AS comment_count,
|
||
tc.last_comment_id,
|
||
tcr.last_read_comment_id
|
||
FROM tasks t
|
||
LEFT JOIN (
|
||
SELECT
|
||
task_id,
|
||
COUNT(*) AS comment_count,
|
||
MAX(id) AS last_comment_id
|
||
FROM task_comments
|
||
GROUP BY task_id
|
||
) tc ON tc.task_id = t.id
|
||
LEFT JOIN task_comment_reads tcr
|
||
ON tcr.task_id = t.id
|
||
AND tcr.user_id = ?
|
||
WHERE t.board_id = ?
|
||
";
|
||
|
||
$params = [
|
||
$user['id'],
|
||
$board['id'],
|
||
];
|
||
|
||
if ($status !== '') {
|
||
$sql .= " AND t.status = ? ";
|
||
$params[] = $status;
|
||
}
|
||
|
||
if ($priority !== '') {
|
||
$sql .= " AND t.priority = ? ";
|
||
$params[] = $priority;
|
||
}
|
||
|
||
if ($assigneeId > 0) {
|
||
$sql .= " AND t.assignee_id = ? ";
|
||
$params[] = $assigneeId;
|
||
}
|
||
|
||
if ($search !== '') {
|
||
$sql .= " AND (
|
||
t.name LIKE ?
|
||
OR t.description LIKE ?
|
||
OR t.order_number LIKE ?
|
||
OR t.applicant LIKE ?
|
||
OR t.supplier LIKE ?
|
||
) ";
|
||
$searchLike = '%' . $search . '%';
|
||
$params[] = $searchLike;
|
||
$params[] = $searchLike;
|
||
$params[] = $searchLike;
|
||
$params[] = $searchLike;
|
||
$params[] = $searchLike;
|
||
}
|
||
|
||
$sql .= " ORDER BY t.id DESC ";
|
||
|
||
$stmt = $pdo->prepare($sql);
|
||
$stmt->execute($params);
|
||
$tasks = $stmt->fetchAll();
|
||
|
||
$stats = [
|
||
'NEW' => 0,
|
||
'IN_PROGRESS' => 0,
|
||
'REVIEW' => 0,
|
||
'DONE' => 0,
|
||
'CANCELED' => 0,
|
||
'OVERDUE' => 0,
|
||
'TOTAL' => count($tasks),
|
||
];
|
||
|
||
foreach ($tasks as $task) {
|
||
$taskStatus = $task['status'] ?? 'NEW';
|
||
|
||
if (!isset($stats[$taskStatus])) {
|
||
$taskStatus = 'NEW';
|
||
}
|
||
|
||
$stats[$taskStatus]++;
|
||
}
|
||
|
||
$stmt = $pdo->query("
|
||
SELECT id, login, display_name
|
||
FROM users
|
||
ORDER BY
|
||
CASE WHEN display_name IS NULL OR display_name = '' THEN 1 ELSE 0 END,
|
||
display_name ASC,
|
||
login ASC
|
||
");
|
||
$users = $stmt->fetchAll();
|
||
|
||
$columns = [
|
||
'NEW' => ['title' => 'Новая', 'tasks' => [], 'header_class' => 'kanban-header-new'],
|
||
'IN_PROGRESS' => ['title' => 'В работе', 'tasks' => [], 'header_class' => 'kanban-header-progress'],
|
||
'REVIEW' => ['title' => 'На проверке', 'tasks' => [], 'header_class' => 'kanban-header-review'],
|
||
'DONE' => ['title' => 'Закрыта', 'tasks' => [], 'header_class' => 'kanban-header-done'],
|
||
'CANCELED' => ['title' => 'Отменена', 'tasks' => [], 'header_class' => 'kanban-header-canceled'],
|
||
'OVERDUE' => ['title' => 'Просрочена', 'tasks' => [], 'header_class' => 'kanban-header-overdue'],
|
||
];
|
||
|
||
foreach ($tasks as $task) {
|
||
$taskStatus = $task['status'] ?? 'NEW';
|
||
|
||
if (!isset($columns[$taskStatus])) {
|
||
$taskStatus = 'NEW';
|
||
}
|
||
|
||
$columns[$taskStatus]['tasks'][] = $task;
|
||
}
|
||
$stmt = $pdo->prepare("
|
||
SELECT *
|
||
FROM board_fields
|
||
WHERE board_id = ?
|
||
ORDER BY sort_order ASC, id ASC
|
||
");
|
||
$stmt->execute([$board['id']]);
|
||
$boardFields = $stmt->fetchAll();
|
||
|
||
$stmt = $pdo->prepare("
|
||
SELECT *
|
||
FROM board_sources
|
||
WHERE board_id = ?
|
||
AND source_type = 'google_sheet'
|
||
LIMIT 1
|
||
");
|
||
$stmt->execute([$board['id']]);
|
||
$boardSource = $stmt->fetch();
|
||
|
||
$boardMappings = [];
|
||
|
||
if ($boardSource) {
|
||
$stmt = $pdo->prepare("
|
||
SELECT target_type, target_key, source_column_name
|
||
FROM board_source_mappings
|
||
WHERE board_source_id = ?
|
||
AND is_active = 1
|
||
");
|
||
$stmt->execute([$boardSource['id']]);
|
||
|
||
foreach ($stmt->fetchAll() as $row) {
|
||
$boardMappings[$row['target_type'] . ':' . $row['target_key']] = $row['source_column_name'];
|
||
}
|
||
}
|
||
|
||
View::render('tasks/board', [
|
||
'board' => $board,
|
||
'tasks' => $tasks,
|
||
'columns' => $columns,
|
||
'users' => $users,
|
||
'view' => $view,
|
||
'filters' => [
|
||
'status' => $status,
|
||
'priority' => $priority,
|
||
'assignee_id' => $assigneeId,
|
||
'search' => $search,
|
||
],
|
||
'stats' => $stats,
|
||
'user' => $user,
|
||
'canManageBoard' => $canManageBoard,
|
||
'boardFields' => $boardFields,
|
||
'boardSource' => $boardSource,
|
||
'boardMappings' => $boardMappings,
|
||
]);
|
||
}
|
||
private function isAjax(): bool
|
||
{
|
||
return strtolower((string)($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')) === 'xmlhttprequest';
|
||
}
|
||
|
||
private function json(array $data, int $statusCode = 200): void
|
||
{
|
||
http_response_code($statusCode);
|
||
header('Content-Type: application/json; charset=utf-8');
|
||
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||
exit;
|
||
}
|
||
private function normalizeDateTime(?string $value, bool $useNowIfEmpty = false): ?string
|
||
{
|
||
$value = trim((string)$value);
|
||
|
||
if ($value === '') {
|
||
return $useNowIfEmpty ? date('Y-m-d H:i:s') : null;
|
||
}
|
||
|
||
// Формат из input type="datetime-local": 2026-04-23T14:30
|
||
if (preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/', $value)) {
|
||
return str_replace('T', ' ', $value) . ':00';
|
||
}
|
||
|
||
// Формат: 2026-04-23 14:30
|
||
if (preg_match('/^\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}$/', $value)) {
|
||
return $value . ':00';
|
||
}
|
||
|
||
// Формат: 2026-04-23 14:30:00
|
||
if (preg_match('/^\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}$/', $value)) {
|
||
return $value;
|
||
}
|
||
|
||
// Только дата: 2026-04-23
|
||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
|
||
return $value . ' 00:00:00';
|
||
}
|
||
|
||
return $useNowIfEmpty ? date('Y-m-d H:i:s') : null;
|
||
}
|
||
private function canEditTask(array $task): bool
|
||
{
|
||
$user = Auth::user();
|
||
|
||
if ((int)($user['is_admin'] ?? 0) === 1) {
|
||
return true;
|
||
}
|
||
|
||
$groups = json_decode((string)($user['groups_json'] ?? '[]'), true);
|
||
if (is_array($groups)) {
|
||
foreach ($groups as $group) {
|
||
if (mb_strtolower(trim((string)$group)) === 'ит-отдел') {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
|
||
return (int)($task['creator_id'] ?? 0) === (int)($user['id'] ?? 0);
|
||
}
|
||
} |