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); } }