Files
CRM-IKZ/app/Controllers/TaskController.php
T
2026-07-14 08:19:56 +04:00

1207 lines
34 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
}
}