commit fdfb0dd62e39f50e3dbc4cd6906a4a48d359bcb0 Author: exercict Date: Tue Jul 14 08:10:11 2026 +0400 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..246c0af --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +/vendor/ +/node_modules/ + +.env +.env.* +!.env.example + +/storage/google-service-account.json +/storage/ws/ +/storage/logs/ +/storage/cache/ +/storage/uploads/ + +*.pid +*.log +*.zip +*.tar +*.tar.gz + +/.well-known/ +/public/.well-known/ + +.idea/ +.vscode/ +Thumbs.db +.DS_Store \ No newline at end of file diff --git a/.user.ini b/.user.ini new file mode 100644 index 0000000..346ee65 --- /dev/null +++ b/.user.ini @@ -0,0 +1,3 @@ +open_basedir=/www/wwwroot/it.rifdev.ru/:/tmp/ +upload_max_filesize = 50M +post_max_size = 50M \ No newline at end of file diff --git a/app/Controllers/AdminAdController.php b/app/Controllers/AdminAdController.php new file mode 100644 index 0000000..eb3bb3c --- /dev/null +++ b/app/Controllers/AdminAdController.php @@ -0,0 +1,113 @@ +requireAdmin(); + + $pdo = DB::connection(); + + $groupCount = (int)$pdo->query("SELECT COUNT(*) FROM ad_groups")->fetchColumn(); + $userCount = (int)$pdo->query("SELECT COUNT(*) FROM user_ad_groups")->fetchColumn(); + + View::render('admin/ad/index', [ + 'groupCount' => $groupCount, + 'userGroupCount' => $userCount, + 'user' => Auth::user(), + ]); + } + + public function sync(): void + { + $this->requireAdmin(); + + $pdo = DB::connection(); + + // MVP: берем уже залогиненных пользователей и их ad_groups из users + $stmt = $pdo->query(" + SELECT id, ad_groups + FROM users + WHERE ad_groups IS NOT NULL + AND ad_groups <> '' + "); + $users = $stmt->fetchAll(); + + $pdo->beginTransaction(); + + try { + $pdo->exec("DELETE FROM user_ad_groups"); + + foreach ($users as $user) { + $groups = json_decode((string)$user['ad_groups'], true); + + if (!is_array($groups)) { + continue; + } + + foreach ($groups as $groupName) { + $groupName = trim((string)$groupName); + if ($groupName === '') { + continue; + } + + $stmt = $pdo->prepare(" + INSERT INTO ad_groups (name, created_at, updated_at) + VALUES (?, NOW(), NOW()) + ON DUPLICATE KEY UPDATE + updated_at = NOW() + "); + $stmt->execute([$groupName]); + + $stmt = $pdo->prepare("SELECT id FROM ad_groups WHERE name = ? LIMIT 1"); + $stmt->execute([$groupName]); + $groupId = (int)$stmt->fetchColumn(); + + if ($groupId > 0) { + $stmt = $pdo->prepare(" + INSERT IGNORE INTO user_ad_groups (user_id, group_id, created_at) + VALUES (?, ?, NOW()) + "); + $stmt->execute([ + (int)$user['id'], + $groupId, + ]); + } + } + } + + $pdo->commit(); + $_SESSION['success'] = 'Синхронизация AD-групп завершена'; + } catch (\Throwable $e) { + $pdo->rollBack(); + $_SESSION['error'] = 'Ошибка синхронизации AD: ' . $e->getMessage(); + } + + header('Location: /admin/ad'); + exit; + } +} \ No newline at end of file diff --git a/app/Controllers/AdminBoardController.php b/app/Controllers/AdminBoardController.php new file mode 100644 index 0000000..d366820 --- /dev/null +++ b/app/Controllers/AdminBoardController.php @@ -0,0 +1,591 @@ +requireAdmin(); + + $pdo = DB::connection(); + + $stmt = $pdo->query(" + SELECT id, name, code, description, is_active, show_on_home, created_at + FROM boards + ORDER BY id DESC + "); + $boards = $stmt->fetchAll(); + + View::render('admin/boards/index', [ + 'boards' => $boards, + 'user' => Auth::user(), + ]); + } + + public function create(): void + { + $this->requireAdmin(); + + View::render('admin/boards/create', [ + 'user' => Auth::user(), + ]); + } + + public function store(): void + { + $this->requireAdmin(); + + $name = trim($_POST['name'] ?? ''); + $code = trim($_POST['code'] ?? ''); + $description = trim($_POST['description'] ?? ''); + $isActive = isset($_POST['is_active']) ? 1 : 0; + + if ($name === '' || $code === '') { + $_SESSION['error'] = 'Заполните название и код доски'; + header('Location: /admin/boards/create'); + exit; + } + + $pdo = DB::connection(); + + $stmt = $pdo->prepare(" + INSERT INTO boards (name, code, description, is_active, created_at, updated_at) + VALUES (?, ?, ?, ?, NOW(), NOW()) + "); + $stmt->execute([ + $name, + $code, + $description !== '' ? $description : null, + $isActive, + ]); + $showOnHome = isset($_POST['show_on_home']) ? 1 : 0; + header('Location: /admin/boards'); + exit; + } + public function showModal(): void + { + $this->requireAdmin(); + + $boardId = (int)($_GET['id'] ?? 0); + + if ($boardId <= 0) { + http_response_code(400); + echo 'Board ID is required'; + return; + } + + $pdo = DB::connection(); + + $stmt = $pdo->prepare(" + SELECT + b.*, + COUNT(t.id) AS task_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 boards b + LEFT JOIN tasks t ON t.board_id = b.id + WHERE b.id = ? + GROUP BY b.id + LIMIT 1 + "); + $stmt->execute([$boardId]); + $board = $stmt->fetch(); + + if (!$board) { + http_response_code(404); + echo 'Board not found'; + return; + } + + View::render('admin/boards/modal', [ + 'board' => $board, + 'user' => Auth::user(), + ], null); + } + public function access(): void + { + $this->requireAdmin(); + + $boardId = (int)($_GET['id'] ?? 0); + + if ($boardId <= 0) { + http_response_code(400); + echo 'Board ID is required'; + return; + } + + $pdo = DB::connection(); + + $stmt = $pdo->prepare(" + SELECT id, name, code, description + FROM boards + WHERE id = ? + LIMIT 1 + "); + $stmt->execute([$boardId]); + $board = $stmt->fetch(); + + if (!$board) { + http_response_code(404); + echo 'Доска не найдена'; + return; + } + + $groups = $pdo->query(" + SELECT id, name + FROM ad_groups + ORDER BY name ASC + ")->fetchAll(); + + $stmt = $pdo->prepare(" + SELECT group_id + FROM board_access_groups + WHERE board_id = ? + "); + $stmt->execute([$boardId]); + $selectedGroupIds = array_map('intval', array_column($stmt->fetchAll(), 'group_id')); + + View::render('admin/boards/access', [ + 'board' => $board, + 'groups' => $groups, + 'selectedGroupIds' => $selectedGroupIds, + 'user' => Auth::user(), + ]); + } + + public function saveAccess(): void + { + $this->requireAdmin(); + + $boardId = (int)($_POST['board_id'] ?? 0); + $groupIds = $_POST['group_ids'] ?? []; + + if ($boardId <= 0) { + $_SESSION['error'] = 'Некорректная доска'; + header('Location: /admin/boards'); + exit; + } + + $pdo = DB::connection(); + $pdo->beginTransaction(); + + try { + $stmt = $pdo->prepare("DELETE FROM board_access_groups WHERE board_id = ?"); + $stmt->execute([$boardId]); + + if (is_array($groupIds)) { + $stmt = $pdo->prepare(" + INSERT INTO board_access_groups (board_id, group_id, created_at) + VALUES (?, ?, NOW()) + "); + + foreach ($groupIds as $groupId) { + $groupId = (int)$groupId; + if ($groupId > 0) { + $stmt->execute([$boardId, $groupId]); + } + } + } + + $pdo->commit(); + $_SESSION['success'] = 'Права доступа к доске сохранены'; + } catch (\Throwable $e) { + $pdo->rollBack(); + $_SESSION['error'] = 'Ошибка сохранения прав: ' . $e->getMessage(); + } + + header('Location: /admin/boards/access?id=' . $boardId); + exit; + } + public function statuses(): void + { + $this->requireAdmin(); + + $boardId = (int)($_GET['id'] ?? 0); + + if ($boardId <= 0) { + http_response_code(400); + echo 'Board ID is required'; + return; + } + + $pdo = DB::connection(); + + $stmt = $pdo->prepare("SELECT id, name, code FROM boards WHERE id = ? LIMIT 1"); + $stmt->execute([$boardId]); + $board = $stmt->fetch(); + + if (!$board) { + http_response_code(404); + echo 'Доска не найдена'; + return; + } + + $stmt = $pdo->prepare(" + SELECT * + FROM board_statuses + WHERE board_id = ? + ORDER BY sort_order ASC, id ASC + "); + $stmt->execute([$boardId]); + $statuses = $stmt->fetchAll(); + + View::render('admin/boards/statuses/index', [ + 'board' => $board, + 'statuses' => $statuses, + 'user' => Auth::user(), + ]); + } + + public function createStatus(): void + { + $this->requireAdmin(); + + $boardId = (int)($_GET['board_id'] ?? 0); + + $pdo = DB::connection(); + $stmt = $pdo->prepare("SELECT id, name, code FROM boards WHERE id = ? LIMIT 1"); + $stmt->execute([$boardId]); + $board = $stmt->fetch(); + + if (!$board) { + http_response_code(404); + echo 'Доска не найдена'; + return; + } + + View::render('admin/boards/statuses/create', [ + 'board' => $board, + 'user' => Auth::user(), + ]); + } + + public function storeStatus(): void + { + $this->requireAdmin(); + + $boardId = (int)($_POST['board_id'] ?? 0); + $code = trim($_POST['code'] ?? ''); + $name = trim($_POST['name'] ?? ''); + $color = trim($_POST['color'] ?? 'secondary'); + $sortOrder = (int)($_POST['sort_order'] ?? 100); + $isDone = isset($_POST['is_done']) ? 1 : 0; + $isActive = isset($_POST['is_active']) ? 1 : 0; + + if ($boardId <= 0 || $code === '' || $name === '') { + $_SESSION['error'] = 'Заполните обязательные поля статуса'; + header('Location: /admin/boards/statuses/create?board_id=' . $boardId); + exit; + } + + $pdo = DB::connection(); + + $stmt = $pdo->prepare(" + INSERT INTO board_statuses ( + board_id, code, name, color, sort_order, is_done, is_active, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) + "); + $stmt->execute([ + $boardId, + $code, + $name, + $color, + $sortOrder, + $isDone, + $isActive, + ]); + + header('Location: /admin/boards/statuses?id=' . $boardId); + exit; + } + + public function editStatus(): void + { + $this->requireAdmin(); + + $statusId = (int)($_GET['id'] ?? 0); + + $pdo = DB::connection(); + $stmt = $pdo->prepare(" + SELECT bs.*, b.name AS board_name, b.code AS board_code + FROM board_statuses bs + INNER JOIN boards b ON b.id = bs.board_id + WHERE bs.id = ? + LIMIT 1 + "); + $stmt->execute([$statusId]); + $status = $stmt->fetch(); + + if (!$status) { + http_response_code(404); + echo 'Статус не найден'; + return; + } + + View::render('admin/boards/statuses/edit', [ + 'status' => $status, + 'user' => Auth::user(), + ]); + } + + public function updateStatus(): void + { + $this->requireAdmin(); + + $statusId = (int)($_POST['id'] ?? 0); + $code = trim($_POST['code'] ?? ''); + $name = trim($_POST['name'] ?? ''); + $color = trim($_POST['color'] ?? 'secondary'); + $sortOrder = (int)($_POST['sort_order'] ?? 100); + $isDone = isset($_POST['is_done']) ? 1 : 0; + $isActive = isset($_POST['is_active']) ? 1 : 0; + + if ($statusId <= 0 || $code === '' || $name === '') { + $_SESSION['error'] = 'Заполните обязательные поля'; + header('Location: /admin'); + exit; + } + + $pdo = DB::connection(); + + $stmt = $pdo->prepare("SELECT board_id FROM board_statuses WHERE id = ? LIMIT 1"); + $stmt->execute([$statusId]); + $boardId = (int)$stmt->fetchColumn(); + + $stmt = $pdo->prepare(" + UPDATE board_statuses + SET + code = ?, + name = ?, + color = ?, + sort_order = ?, + is_done = ?, + is_active = ?, + updated_at = NOW() + WHERE id = ? + "); + $stmt->execute([ + $code, + $name, + $color, + $sortOrder, + $isDone, + $isActive, + $statusId, + ]); + + header('Location: /admin/boards/statuses?id=' . $boardId); + exit; + } + public function delete(): void + { + $this->requireAdmin(); + + $boardId = (int)($_POST['id'] ?? 0); + + if ($boardId <= 0) { + $_SESSION['error'] = 'Доска не найдена'; + header('Location: /admin/boards'); + exit; + } + + $pdo = DB::connection(); + + $stmt = $pdo->prepare(" + SELECT id, name, code + FROM boards + WHERE id = ? + LIMIT 1 + "); + $stmt->execute([$boardId]); + $board = $stmt->fetch(); + + if (!$board) { + $_SESSION['error'] = 'Доска не найдена'; + header('Location: /admin/boards'); + exit; + } + + $stmt = $pdo->prepare("DELETE FROM boards WHERE id = ?"); + $stmt->execute([$boardId]); + + $_SESSION['success'] = 'Доска "' . ($board['name'] ?? '') . '" удалена вместе со связанными данными'; + header('Location: /admin/boards'); + exit; + } + public function edit(): void + { + $this->requireAdmin(); + + $boardId = (int)($_GET['id'] ?? 0); + + if ($boardId <= 0) { + http_response_code(400); + echo 'Board ID is required'; + return; + } + + $pdo = DB::connection(); + + $stmt = $pdo->prepare(" + SELECT id, name, code, description, is_active, show_on_home + FROM boards + WHERE id = ? + LIMIT 1 + "); + $stmt->execute([$boardId]); + $board = $stmt->fetch(); + + if (!$board) { + http_response_code(404); + echo 'Доска не найдена'; + return; + } + + View::render('admin/boards/edit', [ + 'board' => $board, + 'user' => Auth::user(), + ]); + } + + public function update(): void + { + $this->requireAdmin(); + + $boardId = (int)($_POST['id'] ?? 0); + $name = trim((string)($_POST['name'] ?? '')); + $code = trim((string)($_POST['code'] ?? '')); + $description = trim((string)($_POST['description'] ?? '')); + $isActive = isset($_POST['is_active']) ? 1 : 0; + + if ($boardId <= 0 || $name === '' || $code === '') { + $_SESSION['error'] = 'Заполните название и код доски'; + header('Location: /admin/boards/edit?id=' . $boardId); + exit; + } + + $pdo = DB::connection(); + + try { + $stmt = $pdo->prepare(" + UPDATE boards + SET + name = ?, + code = ?, + description = ?, + is_active = ?, + updated_at = NOW() + WHERE id = ? + "); + $stmt->execute([ + $name, + $code, + $description !== '' ? $description : null, + $isActive, + $boardId, + ]); + + $_SESSION['success'] = 'Доска обновлена'; + } catch (\PDOException $e) { + $_SESSION['error'] = 'Не удалось обновить доску: ' . $e->getMessage(); + header('Location: /admin/boards/edit?id=' . $boardId); + exit; + } + $showOnHome = isset($_POST['show_on_home']) ? 1 : 0; + header('Location: /admin/boards'); + exit; + } + public function saveAll(): void + { + $pdo = DB::connection(); + + $boardId = (int)$_POST['board_id']; + + // === ОБНОВЛЕНИЕ ДОСКИ === + $stmt = $pdo->prepare(" + UPDATE boards SET + name = ?, + code = ?, + description = ?, + is_active = ?, + show_on_home = ? + WHERE id = ? + "); + + $stmt->execute([ + $_POST['name'], + $_POST['code'], + $_POST['description'] ?? null, + isset($_POST['is_active']) ? 1 : 0, + isset($_POST['show_on_home']) ? 1 : 0, + $boardId + ]); + + // === КАСТОМНЫЕ ПОЛЯ === + if (!empty($_POST['fields'])) { + foreach ($_POST['fields'] as $field) { + + if (!empty($field['delete'])) { + $pdo->prepare("DELETE FROM board_fields WHERE id = ?") + ->execute([$field['id']]); + continue; + } + + $pdo->prepare(" + UPDATE board_fields + SET name = ?, field_type = ? + WHERE id = ? + ")->execute([ + $field['name'], + $field['type'], + $field['id'] + ]); + } + } + + // === НОВЫЕ ПОЛЯ === + if (!empty($_POST['new_fields'])) { + foreach ($_POST['new_fields'] as $f) { + if (empty($f['name'])) continue; + + $pdo->prepare(" + INSERT INTO board_fields (board_id, code, name) + VALUES (?, ?, ?) + ")->execute([ + $boardId, + strtolower(preg_replace('/[^a-z0-9]/', '_', $f['name'])), + $f['name'] + ]); + } + } + + header('Location: ' . $_SERVER['HTTP_REFERER']); + } +} \ No newline at end of file diff --git a/app/Controllers/AdminBoardFieldController.php b/app/Controllers/AdminBoardFieldController.php new file mode 100644 index 0000000..05bbfd7 --- /dev/null +++ b/app/Controllers/AdminBoardFieldController.php @@ -0,0 +1,258 @@ +requireAdmin(); + + $boardId = (int)($_GET['id'] ?? 0); + + $pdo = DB::connection(); + + $stmt = $pdo->prepare("SELECT id, name, code FROM boards WHERE id = ? LIMIT 1"); + $stmt->execute([$boardId]); + $board = $stmt->fetch(); + + if (!$board) { + http_response_code(404); + echo 'Доска не найдена'; + return; + } + + $stmt = $pdo->prepare(" + SELECT * + FROM board_fields + WHERE board_id = ? + ORDER BY sort_order ASC, id ASC + "); + $stmt->execute([$boardId]); + $fields = $stmt->fetchAll(); + + View::render('admin/boards/fields/index', [ + 'board' => $board, + 'fields' => $fields, + 'user' => Auth::user(), + ]); + } + + public function create(): void + { + $this->requireAdmin(); + + $boardId = (int)($_GET['board_id'] ?? 0); + + $pdo = DB::connection(); + $stmt = $pdo->prepare("SELECT id, name, code FROM boards WHERE id = ? LIMIT 1"); + $stmt->execute([$boardId]); + $board = $stmt->fetch(); + + if (!$board) { + http_response_code(404); + echo 'Доска не найдена'; + return; + } + + View::render('admin/boards/fields/create', [ + 'board' => $board, + 'user' => Auth::user(), + ]); + } + + public function store(): void + { + $this->requireAdmin(); + + $boardId = (int)($_POST['board_id'] ?? 0); + $code = trim((string)($_POST['code'] ?? '')); + $name = trim((string)($_POST['name'] ?? '')); + $fieldType = trim((string)($_POST['field_type'] ?? 'text')); + $isRequired = isset($_POST['is_required']) ? 1 : 0; + $isActive = isset($_POST['is_active']) ? 1 : 0; + $sortOrder = (int)($_POST['sort_order'] ?? 100); + + $settings = null; + if ($fieldType === 'select') { + $rawOptions = trim((string)($_POST['select_options'] ?? '')); + $options = array_values(array_filter(array_map('trim', preg_split('/\r\n|\r|\n/', $rawOptions)))); + $settings = json_encode(['options' => $options], JSON_UNESCAPED_UNICODE); + } + + if ($boardId <= 0 || $code === '' || $name === '') { + $_SESSION['error'] = 'Заполни обязательные поля'; + header('Location: /admin/boards/fields/create?board_id=' . $boardId); + exit; + } + + $pdo = DB::connection(); + + $stmt = $pdo->prepare(" + INSERT INTO board_fields ( + board_id, code, name, field_type, is_required, sort_order, is_active, settings_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) + "); + $stmt->execute([ + $boardId, + $code, + $name, + $fieldType, + $isRequired, + $sortOrder, + $isActive, + $settings, + ]); + + header('Location: /admin/boards/fields?id=' . $boardId); + exit; + } + + public function edit(): void + { + $this->requireAdmin(); + + $fieldId = (int)($_GET['id'] ?? 0); + + $pdo = DB::connection(); + $stmt = $pdo->prepare(" + SELECT bf.*, b.name AS board_name, b.code AS board_code + FROM board_fields bf + INNER JOIN boards b ON b.id = bf.board_id + WHERE bf.id = ? + LIMIT 1 + "); + $stmt->execute([$fieldId]); + $field = $stmt->fetch(); + + if (!$field) { + http_response_code(404); + echo 'Поле не найдено'; + return; + } + + View::render('admin/boards/fields/edit', [ + 'field' => $field, + 'user' => Auth::user(), + ]); + } + + public function update(): void + { + $this->requireAdmin(); + + $fieldId = (int)($_POST['id'] ?? 0); + $code = trim((string)($_POST['code'] ?? '')); + $name = trim((string)($_POST['name'] ?? '')); + $fieldType = trim((string)($_POST['field_type'] ?? 'text')); + $isRequired = isset($_POST['is_required']) ? 1 : 0; + $isActive = isset($_POST['is_active']) ? 1 : 0; + $sortOrder = (int)($_POST['sort_order'] ?? 100); + + $settings = null; + if ($fieldType === 'select') { + $rawOptions = trim((string)($_POST['select_options'] ?? '')); + $options = array_values(array_filter(array_map('trim', preg_split('/\r\n|\r|\n/', $rawOptions)))); + $settings = json_encode(['options' => $options], JSON_UNESCAPED_UNICODE); + } + + if ($fieldId <= 0 || $code === '' || $name === '') { + $_SESSION['error'] = 'Заполни обязательные поля'; + header('Location: /admin'); + exit; + } + + $pdo = DB::connection(); + + $stmt = $pdo->prepare("SELECT board_id FROM board_fields WHERE id = ? LIMIT 1"); + $stmt->execute([$fieldId]); + $boardId = (int)$stmt->fetchColumn(); + + $stmt = $pdo->prepare(" + UPDATE board_fields + SET + code = ?, + name = ?, + field_type = ?, + is_required = ?, + sort_order = ?, + is_active = ?, + settings_json = ?, + updated_at = NOW() + WHERE id = ? + "); + $stmt->execute([ + $code, + $name, + $fieldType, + $isRequired, + $sortOrder, + $isActive, + $settings, + $fieldId, + ]); + + header('Location: /admin/boards/fields?id=' . $boardId); + exit; + } + public function delete(): void + { + $this->requireAdmin(); + + $fieldId = (int)($_POST['id'] ?? 0); + + if ($fieldId <= 0) { + $_SESSION['error'] = 'Поле не найдено'; + header('Location: /admin/boards'); + exit; + } + + $pdo = DB::connection(); + + $stmt = $pdo->prepare(" + SELECT id, board_id + FROM board_fields + WHERE id = ? + LIMIT 1 + "); + $stmt->execute([$fieldId]); + $field = $stmt->fetch(); + + if (!$field) { + $_SESSION['error'] = 'Поле не найдено'; + header('Location: /admin/boards'); + exit; + } + + $boardId = (int)$field['board_id']; + + $stmt = $pdo->prepare("DELETE FROM board_fields WHERE id = ?"); + $stmt->execute([$fieldId]); + + $_SESSION['success'] = 'Поле удалено'; + header('Location: /admin/boards/fields?id=' . $boardId); + exit; + } +} \ No newline at end of file diff --git a/app/Controllers/AdminBoardSourceController.php b/app/Controllers/AdminBoardSourceController.php new file mode 100644 index 0000000..94a4ee6 --- /dev/null +++ b/app/Controllers/AdminBoardSourceController.php @@ -0,0 +1,259 @@ +requireAdmin(); + + $boardId = (int)($_GET['id'] ?? 0); + + $pdo = DB::connection(); + + $stmt = $pdo->prepare("SELECT id, name, code FROM boards WHERE id = ? LIMIT 1"); + $stmt->execute([$boardId]); + $board = $stmt->fetch(); + + if (!$board) { + http_response_code(404); + echo 'Доска не найдена'; + return; + } + + $stmt = $pdo->prepare(" + SELECT * + FROM board_sources + WHERE board_id = ? + AND source_type = 'google_sheet' + LIMIT 1 + "); + $stmt->execute([$boardId]); + $source = $stmt->fetch(); + + $stmt = $pdo->prepare(" + SELECT id, code, name + FROM board_fields + WHERE board_id = ? + AND is_active = 1 + ORDER BY sort_order ASC, id ASC + "); + $stmt->execute([$boardId]); + $customFields = $stmt->fetchAll(); + + $mappings = []; + if ($source) { + $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([$source['id']]); + foreach ($stmt->fetchAll() as $row) { + $mappings[$row['target_type'] . ':' . $row['target_key']] = $row['source_column_name']; + } + } + + $baseFields = [ + ['key' => 'creator_name', 'name' => 'Постановщик'], + ['key' => 'assignee_name', 'name' => 'Ответственный'], + ['key' => 'name', 'name' => 'Наименование'], + ['key' => 'description', 'name' => 'Описание задачи'], + ['key' => 'status', 'name' => 'Статус'], + ['key' => 'priority', 'name' => 'Приоритет'], + ['key' => 'crm_id', 'name' => 'CRM ID'], + ['key' => 'completed_flag', 'name' => 'Выполнено'], + ['key' => 'task_created_at', 'name' => 'Дата постановки'], + ['key' => 'planned_at', 'name' => 'Дата план'], + ['key' => 'completed_at', 'name' => 'Дата факт'], + ]; + + View::render('admin/boards/source/edit', [ + 'board' => $board, + 'source' => $source, + 'baseFields' => $baseFields, + 'customFields' => $customFields, + 'mappings' => $mappings, + 'user' => Auth::user(), + ]); + } + + public function save(): void + { + $this->requireAdmin(); + + $boardId = (int)($_POST['board_id'] ?? 0); + $spreadsheetUrl = trim((string)($_POST['spreadsheet_url'] ?? '')); + $sheetName = trim((string)($_POST['sheet_name'] ?? '')); + $syncMode = trim((string)($_POST['sync_mode'] ?? 'import_export')); + $isActive = isset($_POST['is_active']) ? 1 : 0; + + if ($boardId <= 0 || $spreadsheetUrl === '' || $sheetName === '') { + $_SESSION['error'] = 'Заполни ссылку и имя вкладки'; + header('Location: /admin/boards/source?id=' . $boardId); + exit; + } + + $spreadsheetId = $this->extractSpreadsheetId($spreadsheetUrl); + + if ($spreadsheetId === '') { + $_SESSION['error'] = 'Не удалось определить Spreadsheet ID'; + header('Location: /admin/boards/source?id=' . $boardId); + exit; + } + + $pdo = DB::connection(); + $pdo->beginTransaction(); + + try { + $stmt = $pdo->prepare(" + SELECT id + FROM board_sources + WHERE board_id = ? + AND source_type = 'google_sheet' + LIMIT 1 + "); + $stmt->execute([$boardId]); + $sourceId = (int)$stmt->fetchColumn(); + + if ($sourceId > 0) { + $stmt = $pdo->prepare(" + UPDATE board_sources + SET + spreadsheet_url = ?, + source_key = ?, + sheet_name = ?, + sync_mode = ?, + is_active = ?, + updated_at = NOW() + WHERE id = ? + "); + $stmt->execute([ + $spreadsheetUrl, + $spreadsheetId, + $sheetName, + $syncMode, + $isActive, + $sourceId, + ]); + } else { + $stmt = $pdo->prepare(" + INSERT INTO board_sources ( + board_id, + source_type, + spreadsheet_url, + source_key, + sheet_name, + sync_mode, + is_active, + created_at, + updated_at + ) VALUES (?, 'google_sheet', ?, ?, ?, ?, ?, NOW(), NOW()) + "); + $stmt->execute([ + $boardId, + $spreadsheetUrl, + $spreadsheetId, + $sheetName, + $syncMode, + $isActive, + ]); + + $sourceId = (int)$pdo->lastInsertId(); + } + + $stmt = $pdo->prepare("DELETE FROM board_source_mappings WHERE board_source_id = ?"); + $stmt->execute([$sourceId]); + + $baseMappings = $_POST['mapping_base'] ?? []; + if (is_array($baseMappings)) { + $stmt = $pdo->prepare(" + INSERT INTO board_source_mappings ( + board_source_id, + target_type, + target_key, + source_column_name, + is_active, + created_at, + updated_at + ) VALUES (?, 'base', ?, ?, 1, NOW(), NOW()) + "); + + foreach ($baseMappings as $key => $columnName) { + $columnName = trim((string)$columnName); + if ($columnName === '') { + continue; + } + + $stmt->execute([$sourceId, $key, $columnName]); + } + } + + $customMappings = $_POST['mapping_custom'] ?? []; + if (is_array($customMappings)) { + $stmt = $pdo->prepare(" + INSERT INTO board_source_mappings ( + board_source_id, + target_type, + target_key, + source_column_name, + is_active, + created_at, + updated_at + ) VALUES (?, 'custom', ?, ?, 1, NOW(), NOW()) + "); + + foreach ($customMappings as $key => $columnName) { + $columnName = trim((string)$columnName); + if ($columnName === '') { + continue; + } + + $stmt->execute([$sourceId, $key, $columnName]); + } + } + + $pdo->commit(); + $_SESSION['success'] = 'Интеграция Google Sheets сохранена'; + } catch (\Throwable $e) { + $pdo->rollBack(); + $_SESSION['error'] = 'Ошибка сохранения интеграции: ' . $e->getMessage(); + } + + header('Location: /admin/boards/source?id=' . $boardId); + exit; + } +} \ No newline at end of file diff --git a/app/Controllers/AdminController.php b/app/Controllers/AdminController.php new file mode 100644 index 0000000..83e794a --- /dev/null +++ b/app/Controllers/AdminController.php @@ -0,0 +1,37 @@ +requireAdmin(); + + View::render('admin/index', [ + 'user' => Auth::user(), + ]); + } +} \ No newline at end of file diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php new file mode 100644 index 0000000..66c4830 --- /dev/null +++ b/app/Controllers/AuthController.php @@ -0,0 +1,88 @@ +authenticate($login, $password); + + if ($ldapUser === false) { + $_SESSION['error'] = 'Неверный логин или пароль'; + header('Location: /login'); + exit; + } + + $pdo = DB::connection(); + + $stmt = $pdo->prepare("SELECT * FROM users WHERE login = ? LIMIT 1"); + $stmt->execute([$ldapUser['login']]); + $user = $stmt->fetch(); + + if (!$user) { + $stmt = $pdo->prepare(" + INSERT INTO users (login, display_name, email, is_admin, ad_groups, last_login_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, NOW(), NOW(), NOW()) + "); + $stmt->execute([ + $ldapUser['login'], + $ldapUser['display_name'], + $ldapUser['email'], + $ldapUser['is_admin'] ? 1 : 0, + json_encode($ldapUser['groups'], JSON_UNESCAPED_UNICODE), + ]); + } else { + $stmt = $pdo->prepare(" + UPDATE users + SET display_name = ?, email = ?, is_admin = ?, ad_groups = ?, last_login_at = NOW(), updated_at = NOW() + WHERE id = ? + "); + $stmt->execute([ + $ldapUser['display_name'], + $ldapUser['email'], + $ldapUser['is_admin'] ? 1 : 0, + json_encode($ldapUser['groups'], JSON_UNESCAPED_UNICODE), + $user['id'], + ]); + } + + $stmt = $pdo->prepare("SELECT * FROM users WHERE login = ? LIMIT 1"); + $stmt->execute([$ldapUser['login']]); + $user = $stmt->fetch(); + + Auth::login($user); + + header('Location: /'); + exit; + } + + public function logout(): void + { + Auth::logout(); + header('Location: /login'); + exit; + } +} \ No newline at end of file diff --git a/app/Controllers/CommentController.php b/app/Controllers/CommentController.php new file mode 100644 index 0000000..cc98ddd --- /dev/null +++ b/app/Controllers/CommentController.php @@ -0,0 +1,153 @@ +isAjax()) { + $this->json(['success' => false, 'message' => 'Unauthorized'], 403); + } + + header('Location: /login'); + exit; + } + + $taskId = (int)($_POST['task_id'] ?? 0); + $text = trim((string)($_POST['text'] ?? '')); + + if ($taskId <= 0 || $text === '') { + if ($this->isAjax()) { + $this->json(['success' => false, 'message' => 'Комментарий не заполнен'], 400); + } + + $_SESSION['error'] = 'Комментарий не заполнен'; + header('Location: /tasks'); + exit; + } + + $pdo = DB::connection(); + + // 1. Добавляем комментарий + $stmt = $pdo->prepare(" + INSERT INTO task_comments ( + task_id, + user_id, + text, + created_at, + updated_at + ) VALUES (?, ?, ?, NOW(), NOW()) + "); + $stmt->execute([ + $taskId, + Auth::user()['id'], + $text, + ]); + + // 2. Получаем мета + $stmt = $pdo->prepare(" + SELECT + COUNT(*) AS comment_count, + MAX(id) AS last_comment_id + FROM task_comments + WHERE task_id = ? + "); + $stmt->execute([$taskId]); + $commentMeta = $stmt->fetch(); + + // 3. Получаем задачу + $stmt = $pdo->prepare(" + SELECT id, name, assignee_id + FROM tasks + WHERE id = ? + LIMIT 1 + "); + $stmt->execute([$taskId]); + $task = $stmt->fetch(); + + // 4. WS — обновление счетчиков + $publisher = new EventPublisher(); + $publisher->publish([ + 'type' => 'comment_added', + 'task_id' => $taskId, + 'comment_count' => (int)($commentMeta['comment_count'] ?? 0), + 'last_comment_id' => (int)($commentMeta['last_comment_id'] ?? 0), + 'author_user_id' => (int)Auth::user()['id'], + 'created_at' => date('Y-m-d H:i:s'), + ]); + + // 5. Уведомление ответственному + if ($task && !empty($task['assignee_id'])) { + + $assigneeId = (int)$task['assignee_id']; + $currentUserId = (int)Auth::user()['id']; + + if ($assigneeId !== $currentUserId) { + + // запись в БД (если есть таблица notifications) + $stmt = $pdo->prepare(" + INSERT INTO notifications ( + user_id, + type, + title, + message, + data_json, + is_read, + created_at + ) VALUES (?, ?, ?, ?, ?, 0, NOW()) + "); + + $stmt->execute([ + $assigneeId, + 'task_comment_added', + 'Новый комментарий', + 'Задача: ' . (string)$task['name'], + json_encode(['task_id' => $taskId], JSON_UNESCAPED_UNICODE), + ]); + + // WS уведомление + $publisher->publish([ + 'type' => 'notification_created', + 'user_id' => $assigneeId, + 'task_id' => $taskId, + 'title' => 'Новый комментарий', + 'message' => 'Задача: ' . (string)$task['name'], + 'created_at' => date('Y-m-d H:i:s'), + ]); + } + } + + // 6. Ответ + if ($this->isAjax()) { + $this->json([ + 'success' => true, + 'task_id' => $taskId, + 'comment_count' => (int)($commentMeta['comment_count'] ?? 0), + 'last_comment_id' => (int)($commentMeta['last_comment_id'] ?? 0), + ]); + } + + header('Location: /tasks'); + exit; + } + 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; + } +} \ No newline at end of file diff --git a/app/Controllers/DashboardController.php b/app/Controllers/DashboardController.php new file mode 100644 index 0000000..4df0574 --- /dev/null +++ b/app/Controllers/DashboardController.php @@ -0,0 +1,23 @@ + Auth::user(), + ]); + } +} \ No newline at end of file diff --git a/app/Controllers/NotificationController.php b/app/Controllers/NotificationController.php new file mode 100644 index 0000000..9cfff3b --- /dev/null +++ b/app/Controllers/NotificationController.php @@ -0,0 +1,45 @@ +getLatest((int)Auth::user()['id'], 20); + $unreadCount = $service->getUnreadCount((int)Auth::user()['id']); + + header('Content-Type: application/json; charset=utf-8'); + echo json_encode([ + 'items' => $items, + 'unread_count' => $unreadCount, + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + public function readAll(): void + { + if (!Auth::check()) { + http_response_code(403); + echo 'Unauthorized'; + return; + } + + $service = new NotificationService(); + $service->markAllRead((int)Auth::user()['id']); + + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['success' => true], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } +} \ No newline at end of file diff --git a/app/Controllers/TaskController.php b/app/Controllers/TaskController.php new file mode 100644 index 0000000..742bc66 --- /dev/null +++ b/app/Controllers/TaskController.php @@ -0,0 +1,1207 @@ +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); + } +} \ No newline at end of file diff --git a/app/Controllers/TaskFileController.php b/app/Controllers/TaskFileController.php new file mode 100644 index 0000000..baf9127 --- /dev/null +++ b/app/Controllers/TaskFileController.php @@ -0,0 +1,281 @@ +isAjax()) { + $this->json(['success' => false, 'message' => 'Unauthorized'], 403); + } + + header('Location: /login'); + exit; + } + + $taskId = (int)($_POST['task_id'] ?? 0); + + if ($taskId <= 0 || empty($_FILES['file'])) { + if ($this->isAjax()) { + $this->json(['success' => false, 'message' => 'Файл не выбран'], 400); + } + + $_SESSION['error'] = 'Файл не выбран'; + header('Location: /tasks'); + exit; + } + + $file = $_FILES['file']; + + if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { + if ($this->isAjax()) { + $this->json(['success' => false, 'message' => 'Ошибка загрузки файла'], 400); + } + + $_SESSION['error'] = 'Ошибка загрузки файла'; + header('Location: /tasks'); + exit; + } + + $uploadDir = __DIR__ . '/../../storage/uploads/tasks/' . $taskId; + + if (!is_dir($uploadDir)) { + mkdir($uploadDir, 0775, true); + } + + $originalName = (string)$file['name']; + $tmpName = (string)$file['tmp_name']; + $extension = pathinfo($originalName, PATHINFO_EXTENSION); + $storedName = uniqid('task_', true) . ($extension ? '.' . $extension : ''); + $targetPath = $uploadDir . '/' . $storedName; + + if (!move_uploaded_file($tmpName, $targetPath)) { + if ($this->isAjax()) { + $this->json(['success' => false, 'message' => 'Не удалось сохранить файл'], 500); + } + + $_SESSION['error'] = 'Не удалось сохранить файл'; + header('Location: /tasks'); + exit; + } + + $relativePath = 'storage/uploads/tasks/' . $taskId . '/' . $storedName; + $fileSize = (int)filesize($targetPath); + $mimeType = mime_content_type($targetPath) ?: null; + + $pdo = DB::connection(); + + $stmt = $pdo->prepare(" + INSERT INTO task_files ( + task_id, + uploaded_by, + original_name, + stored_name, + file_path, + file_size, + mime_type, + created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, NOW()) + "); + $stmt->execute([ + $taskId, + Auth::user()['id'], + $originalName, + $storedName, + $relativePath, + $fileSize, + $mimeType, + ]); + + if ($this->isAjax()) { + $this->json([ + 'success' => true, + 'task_id' => $taskId, + ]); + } + + header('Location: /tasks'); + exit; + } + + public function download(): void + { + if (!Auth::check()) { + header('Location: /login'); + exit; + } + + $fileId = (int)($_GET['id'] ?? 0); + + if ($fileId <= 0) { + http_response_code(404); + echo 'Файл не найден'; + return; + } + + $pdo = DB::connection(); + + $stmt = $pdo->prepare(" + SELECT * + FROM task_files + WHERE id = ? + LIMIT 1 + "); + $stmt->execute([$fileId]); + $file = $stmt->fetch(); + + if (!$file) { + http_response_code(404); + echo 'Файл не найден'; + return; + } + + $absolutePath = __DIR__ . '/../../' . $file['file_path']; + + if (!is_file($absolutePath)) { + http_response_code(404); + echo 'Файл не найден на диске'; + return; + } + + header('Content-Description: File Transfer'); + header('Content-Type: ' . ($file['mime_type'] ?: 'application/octet-stream')); + header('Content-Disposition: attachment; filename="' . basename((string)$file['original_name']) . '"'); + header('Content-Length: ' . filesize($absolutePath)); + header('Pragma: public'); + + readfile($absolutePath); + exit; + } + public function view(): void + { + if (!Auth::check()) { + header('Location: /login'); + exit; + } + + $fileId = (int)($_GET['id'] ?? 0); + + if ($fileId <= 0) { + http_response_code(404); + echo 'Файл не найден'; + return; + } + + $pdo = DB::connection(); + + $stmt = $pdo->prepare(" + SELECT * + FROM task_files + WHERE id = ? + LIMIT 1 + "); + $stmt->execute([$fileId]); + $file = $stmt->fetch(); + + if (!$file) { + http_response_code(404); + echo 'Файл не найден'; + return; + } + + $absolutePath = __DIR__ . '/../../' . $file['file_path']; + + if (!is_file($absolutePath)) { + http_response_code(404); + echo 'Файл не найден на диске'; + return; + } + + $mimeType = $file['mime_type'] ?: mime_content_type($absolutePath) ?: 'application/octet-stream'; + + header('Content-Type: ' . $mimeType); + header('Content-Length: ' . filesize($absolutePath)); + header('Content-Disposition: inline; filename="' . basename((string)$file['original_name']) . '"'); + + readfile($absolutePath); + exit; + } + public function delete(): void + { + if (!Auth::check()) { + if ($this->isAjax()) { + $this->json(['success' => false, 'message' => 'Unauthorized'], 403); + } + + header('Location: /login'); + exit; + } + + $fileId = (int)($_POST['id'] ?? 0); + + if ($fileId <= 0) { + if ($this->isAjax()) { + $this->json(['success' => false, 'message' => 'Файл не найден'], 404); + } + + $_SESSION['error'] = 'Файл не найден'; + header('Location: /tasks'); + exit; + } + + $pdo = DB::connection(); + + $stmt = $pdo->prepare(" + SELECT * + FROM task_files + WHERE id = ? + LIMIT 1 + "); + $stmt->execute([$fileId]); + $file = $stmt->fetch(); + + if (!$file) { + if ($this->isAjax()) { + $this->json(['success' => false, 'message' => 'Файл не найден'], 404); + } + + $_SESSION['error'] = 'Файл не найден'; + header('Location: /tasks'); + exit; + } + + $absolutePath = __DIR__ . '/../../' . $file['file_path']; + + $stmt = $pdo->prepare("DELETE FROM task_files WHERE id = ?"); + $stmt->execute([$fileId]); + + if (is_file($absolutePath)) { + @unlink($absolutePath); + } + + if ($this->isAjax()) { + $this->json([ + 'success' => true, + 'file_id' => $fileId, + ]); + } + + header('Location: /tasks'); + exit; + } + 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; + } +} \ No newline at end of file diff --git a/app/Core/Auth.php b/app/Core/Auth.php new file mode 100644 index 0000000..e3d67f0 --- /dev/null +++ b/app/Core/Auth.php @@ -0,0 +1,29 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + + return self::$pdo; + } +} \ No newline at end of file diff --git a/app/Core/Router.php b/app/Core/Router.php new file mode 100644 index 0000000..e89d7b8 --- /dev/null +++ b/app/Core/Router.php @@ -0,0 +1,45 @@ + [], + 'POST' => [], + ]; + + public function get(string $path, array $handler): void + { + $this->routes['GET'][] = [$path, $handler]; + } + + public function post(string $path, array $handler): void + { + $this->routes['POST'][] = [$path, $handler]; + } + + public function dispatch(string $method, string $uri): void + { + $path = parse_url($uri, PHP_URL_PATH); + + foreach ($this->routes[$method] ?? [] as [$route, $handler]) { + $pattern = preg_replace('/\{[a-zA-Z_][a-zA-Z0-9_]*\}/', '([^/]+)', $route); + $pattern = '#^' . $pattern . '$#'; + + if (preg_match($pattern, $path, $matches)) { + array_shift($matches); + + [$class, $action] = $handler; + $controller = new $class(); + $controller->$action(...$matches); + return; + } + } + + http_response_code(404); + echo '404 Not Found'; + } +} \ No newline at end of file diff --git a/app/Core/View.php b/app/Core/View.php new file mode 100644 index 0000000..47db068 --- /dev/null +++ b/app/Core/View.php @@ -0,0 +1,27 @@ + 'Новая', + 'IN_PROGRESS' => 'В работе', + 'REVIEW' => 'На проверке', + 'DONE' => 'Закрыта', + 'CANCELED' => 'Отменена', + 'OVERDUE' => 'Просрочена', + default => (string)$status, + }; + } +} + +if (!function_exists('task_priority_label')) { + function task_priority_label(?string $priority): string + { + return match ($priority) { + 'LOW' => 'Низкий', + 'MEDIUM' => 'Средний', + 'HIGH' => 'Высокий', + 'CRITICAL' => 'Критический', + default => (string)$priority, + }; + } +} +if (!function_exists('taskDeadlineState')) { + function taskDeadlineState(?string $plannedAt, ?string $status = null): ?array + { + if (empty($plannedAt)) { + return null; + } + + // Закрытые/отменённые не подсвечиваем + if (in_array($status, ['DONE', 'CANCELED'], true)) { + return null; + } + + $plannedTs = strtotime($plannedAt); + if (!$plannedTs) { + return null; + } + + $now = time(); + $diff = $plannedTs - $now; + + // больше 2 дней + if ($diff > 2 * 86400) { + return [ + 'code' => 'green', + 'class' => 'deadline-green', + 'text' => 'В запасе', + ]; + } + + // от 1 до 2 дней + if ($diff > 86400) { + return [ + 'code' => 'yellow', + 'class' => 'deadline-yellow', + 'text' => 'Остался 1 день', + ]; + } + + // меньше 1 дня или уже просрочено + return [ + 'code' => 'red', + 'class' => 'deadline-red', + 'text' => $diff < 0 ? 'Просрочено' : 'Меньше 1 дня', + ]; + } +} + diff --git a/app/Services/Auth/LdapService.php b/app/Services/Auth/LdapService.php new file mode 100644 index 0000000..1a6440a --- /dev/null +++ b/app/Services/Auth/LdapService.php @@ -0,0 +1,95 @@ + $login, + 'display_name' => $login, + 'email' => null, + 'groups' => [], + 'is_admin' => false, + ]; + } + + $entries = ldap_get_entries($connection, $search); + ldap_unbind($connection); + + if (($entries['count'] ?? 0) < 1) { + return [ + 'login' => $login, + 'display_name' => $login, + 'email' => null, + 'groups' => [], + 'is_admin' => false, + ]; + } + + $entry = $entries[0]; + + $groups = []; + if (!empty($entry['memberof']) && is_array($entry['memberof'])) { + for ($i = 0; $i < ($entry['memberof']['count'] ?? 0); $i++) { + $dn = $entry['memberof'][$i]; + if (preg_match('/CN=([^,]+)/u', $dn, $matches)) { + $groups[] = $matches[1]; + } + } + } + + $isAdmin = false; + + foreach ($groups as $group) { + if (mb_strtolower(trim($group)) === mb_strtolower(trim($adminGroupName))) { + $isAdmin = true; + break; + } + } + + return [ + 'login' => $entry['samaccountname'][0] ?? $login, + 'display_name' => $entry['displayname'][0] ?? $entry['cn'][0] ?? $login, + 'email' => $entry['mail'][0] ?? null, + 'groups' => $groups, + 'is_admin' => $isAdmin, + ]; + } +} \ No newline at end of file diff --git a/app/Services/Google/GoogleExportService.php b/app/Services/Google/GoogleExportService.php new file mode 100644 index 0000000..2c82179 --- /dev/null +++ b/app/Services/Google/GoogleExportService.php @@ -0,0 +1,72 @@ +prepare(" + SELECT t.*, b.code AS board_code + FROM tasks t + LEFT JOIN boards b ON b.id = t.board_id + WHERE t.id = ? + LIMIT 1 + "); + $stmt->execute([$taskId]); + $task = $stmt->fetch(); + + if (!$task) { + return false; + } + + // Пока экспортируем только задачи доски снабжения + if (($task['board_code'] ?? '') !== 'supply') { + return false; + } + + $sheets = new GoogleSheetsService(); + + $row = [ + $task['name'] ?? '', // A Наименование + $task['quantity'] ?? '', // B кол-во, шт + $task['balance_tn'] ?? '', // C Остаток, тн + $task['order_number'] ?? '', // D Заказ, № + $task['order_amount'] ?? '', // E Сумма заказа + $task['applicant'] ?? '', // F Фамилия + $task['request_date'] ?? '', // G дата заявки + $task['transport_company'] ?? '', // H ТК + $task['supplier'] ?? '', // I Поставщик + $task['invoice_number'] ?? '', // J № счета + $task['payment_date'] ?? '', // K Дата оплаты + $task['delivery_date'] ?? '', // L дата поставки + $task['received_by'] ?? '', // M Груз получил + ((int)($task['completed_flag'] ?? 0) === 1) ? 'TRUE' : 'FALSE', // N Выполнено + ]; + + $rowNumber = $sheets->findFirstEmptyRow(); + $sheets->updateRow($rowNumber, $row); + + if ($rowNumber > 0) { + $stmt = $pdo->prepare(" + UPDATE tasks + SET + google_row_id = ?, + source_type = 'google', + updated_at = NOW() + WHERE id = ? + "); + $stmt->execute([(string)$rowNumber, $taskId]); + } + + return true; + } +} \ No newline at end of file diff --git a/app/Services/Google/GoogleImportService.php b/app/Services/Google/GoogleImportService.php new file mode 100644 index 0000000..7e80f3a --- /dev/null +++ b/app/Services/Google/GoogleImportService.php @@ -0,0 +1,389 @@ +query(" + SELECT * + FROM board_sources + WHERE source_type = 'google_sheet' + AND is_active = 1 + AND sync_mode IN ('import', 'import_export') + "); + $sources = $stmt->fetchAll(); + + $totalCreated = 0; + $totalUpdated = 0; + $totalSkipped = 0; + $totalRows = 0; + + foreach ($sources as $source) { + $result = $this->importBoard($pdo, $source); + + $totalCreated += $result['created']; + $totalUpdated += $result['updated']; + $totalSkipped += $result['skipped']; + $totalRows += $result['total']; + } + + return [ + 'created' => $totalCreated, + 'updated' => $totalUpdated, + 'skipped' => $totalSkipped, + 'total' => $totalRows, + ]; + } + + private function importBoard(PDO $pdo, array $source): array + { + $boardId = (int)$source['board_id']; + $spreadsheetId = (string)$source['source_key']; + $sheetName = (string)$source['sheet_name']; + $notificationService = new \App\Services\NotificationService(); + $userIds = $this->getAllUserIds($pdo); + + $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([$source['id']]); + $mappings = $stmt->fetchAll(); + + if (empty($mappings)) { + return ['created' => 0, 'updated' => 0, 'skipped' => 0, 'total' => 0]; + } + + $sheets = new GoogleSheetsService($spreadsheetId, $sheetName); + $rows = $sheets->getRowsRaw(); + + $created = 0; + $updated = 0; + $skipped = 0; + + $publisher = new \App\Services\WebSocket\EventPublisher(); + $stmt = $pdo->prepare("SELECT code FROM boards WHERE id = ? LIMIT 1"); + $stmt->execute([$boardId]); + $boardCode = (string)($stmt->fetchColumn() ?: ''); + + foreach ($rows as $row) { + $rowNumber = (int)$row['_row_number']; + + $base = [ + 'name' => '', + 'creator_name' => null, + 'assignee_name' => null, + 'description' => null, + 'status' => 'NEW', + 'priority' => 'MEDIUM', + 'crm_id' => null, + 'completed_flag' => 0, + 'task_created_at' => date('Y-m-d H:i:s'), + 'planned_at' => null, + 'completed_at' => null, + ]; + + $custom = []; + + foreach ($mappings as $map) { + $col = strtoupper((string)$map['source_column_name']); + $value = $row[$col] ?? null; + + if ($map['target_type'] === 'base') { + $this->applyBase($base, (string)$map['target_key'], $value); + } + + if ($map['target_type'] === 'custom') { + $custom[(string)$map['target_key']] = trim((string)$value); + } + } + + $normalizedName = mb_strtolower(trim((string)$base['name'])); + if ( + $normalizedName === '' || + $normalizedName === 'наименование детали' || + $normalizedName === 'наименование' + ) { + $skipped++; + continue; + } + + $base['status'] = ((int)$base['completed_flag'] === 1) ? 'DONE' : 'IN_PROGRESS'; + + $existing = $this->findByRow($pdo, $boardId, $rowNumber); + +// if (!$existing) { +// $existing = $this->findByName($pdo, $boardId, $base['name']); +// } + + if ($existing) { + $taskId = (int)$existing['id']; + + $stmt = $pdo->prepare(" + UPDATE tasks SET + google_row_id = ?, + name = ?, + description = ?, + creator_name = ?, + assignee_name = ?, + priority = ?, + completed_flag = ?, + task_created_at = ?, + planned_at = ?, + completed_at = ?, + updated_at = NOW() + WHERE id = ? + "); + + $stmt->execute([ + $rowNumber, + $base['name'], + $base['description'], + $base['creator_name'], + $base['assignee_name'], + $base['status'], + $base['priority'], + $base['completed_flag'], + $base['task_created_at'], + $base['planned_at'], + $base['completed_at'], + $taskId + ]); + + $this->saveCustom($pdo, $taskId, $boardId, $custom); + + $updated++; + + $publisher->publish([ + 'type' => 'task_updated', + 'task_id' => $taskId, + 'board_id' => $boardId, + 'board_code' => $boardCode, + 'name' => $base['name'], + 'status' => $base['status'], + 'updated_at' => date('Y-m-d H:i:s'), + ]); + } else { + $crmId = !empty($base['crm_id']) + ? (string)$base['crm_id'] + : $this->generateCrmId(); + + $stmt = $pdo->prepare(" + INSERT INTO tasks ( + crm_id, board_id, google_row_id, source_type, + name, description, creator_name, assignee_name, + status, priority, completed_flag, + task_created_at, planned_at, completed_at, + created_at, updated_at + ) VALUES (?, ?, ?, 'google', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) + "); + + $stmt->execute([ + $crmId, + $boardId, + $rowNumber, + $base['name'], + $base['description'], + $base['creator_name'], + $base['assignee_name'], + $base['status'], + $base['priority'], + $base['completed_flag'], + $base['task_created_at'], + $base['planned_at'], + $base['completed_at'], + ]); + + $taskId = (int)$pdo->lastInsertId(); + + $this->saveCustom($pdo, $taskId, $boardId, $custom); + + $created++; + + $notificationService->createForUsers( + $userIds, + 'task_created', + 'Новая задача', + $base['name'], + [ + 'task_id' => $taskId, + 'board_id' => $boardId, + 'board_code' => $boardCode, + 'crm_id' => $crmId, + ] + ); + + $publisher->publish([ + 'type' => 'task_created', + 'task_id' => $taskId, + 'board_id' => $boardId, + 'board_code' => $boardCode, + 'name' => $base['name'], + 'crm_id' => $crmId, + 'created_at' => date('Y-m-d H:i:s'), + ]); + } + } + + $publisher->publish([ + 'type' => 'import_finished', + 'board_id' => $boardId, + 'board_code' => $boardCode, + 'created' => $created, + 'updated' => $updated, + 'skipped' => $skipped, + 'total' => count($rows), + 'finished_at' => date('Y-m-d H:i:s'), + ]); + +// if ($created > 0 || $updated > 0) { +// $notificationService->createForUsers( +// $userIds, +// 'import_finished', +// 'Импорт завершен', +// 'Новых: ' . $created . ', обновлено: ' . $updated, +// [ +// 'board_id' => $boardId, +// 'board_code' => $boardCode, +// 'created' => $created, +// 'updated' => $updated, +// ] +// ); +// } + + return [ + 'created' => $created, + 'updated' => $updated, + 'skipped' => $skipped, + 'total' => count($rows) + ]; + } + + private function applyBase(array &$base, string $key, mixed $value): void + { + $value = trim((string)$value); + + switch ($key) { + case 'name': $base['name'] = $value; break; + case 'description': $base['description'] = $value ?: null; break; + case 'creator_name': $base['creator_name'] = $value ?: null; break; + case 'assignee_name': $base['assignee_name'] = $value ?: null; break; + case 'crm_id': $base['crm_id'] = $value ?: null; break; + case 'completed_flag': $base['completed_flag'] = $this->toBool($value); break; + case 'task_created_at': + $base['task_created_at'] = $this->normalizeImportDateTime($value, true); + break; + + case 'planned_at': + $base['planned_at'] = $this->normalizeImportDateTime($value, false); + break; + + case 'completed_at': + $base['completed_at'] = $this->normalizeImportDateTime($value, false); + break; + } + } + + private function saveCustom(PDO $pdo, int $taskId, int $boardId, array $data): void + { + if (!$data) return; + + $stmt = $pdo->prepare(" + SELECT id, code FROM board_fields WHERE board_id = ? + "); + $stmt->execute([$boardId]); + $fields = $stmt->fetchAll(); + + $map = []; + foreach ($fields as $f) $map[$f['code']] = $f['id']; + + foreach ($data as $code=>$val) { + + if (!isset($map[$code])) continue; + + $stmt = $pdo->prepare(" + INSERT INTO task_field_values (task_id, field_id, value_text) + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE value_text = VALUES(value_text) + "); + + $stmt->execute([$taskId, $map[$code], $val]); + } + } + + private function findByRow(PDO $pdo, int $boardId, int $row): array|false + { + $stmt = $pdo->prepare(" + SELECT id FROM tasks WHERE board_id=? AND google_row_id=? + "); + $stmt->execute([$boardId, $row]); + return $stmt->fetch(); + } + + private function findByName(PDO $pdo, int $boardId, string $name): array|false + { + $stmt = $pdo->prepare(" + SELECT id FROM tasks WHERE board_id=? AND name=? LIMIT 1 + "); + $stmt->execute([$boardId, $name]); + return $stmt->fetch(); + } + + private function generateCrmId(): string + { + return 'CRM-' . date('Ymd-His') . '-' . bin2hex(random_bytes(2)); + } + + private function toBool($v): int + { + $v = mb_strtolower(trim((string)$v)); + return in_array($v,['1','true','да']) ? 1 : 0; + } + private function getAllUserIds(PDO $pdo): array + { + $stmt = $pdo->query("SELECT id FROM users"); + return array_map('intval', array_column($stmt->fetchAll(), 'id')); + } + private function normalizeImportDateTime(mixed $value, bool $useNowIfEmpty = false): ?string + { + $value = trim((string)$value); + + if ($value === '') { + return $useNowIfEmpty ? date('Y-m-d H:i:s') : null; + } + + $formats = [ + 'd.m.Y H:i:s', + 'd.m.Y H:i', + 'Y-m-d H:i:s', + 'Y-m-d H:i', + 'd.m.Y', + 'Y-m-d', + ]; + + foreach ($formats as $format) { + $date = \DateTime::createFromFormat($format, $value); + if ($date instanceof \DateTime) { + if ($format === 'd.m.Y' || $format === 'Y-m-d') { + return $date->format('Y-m-d') . ' 00:00:00'; + } + + return $date->format('Y-m-d H:i:s'); + } + } + + return $useNowIfEmpty ? date('Y-m-d H:i:s') : null; + } +} \ No newline at end of file diff --git a/app/Services/Google/GoogleSheetsService.php b/app/Services/Google/GoogleSheetsService.php new file mode 100644 index 0000000..1d4b87d --- /dev/null +++ b/app/Services/Google/GoogleSheetsService.php @@ -0,0 +1,66 @@ + [ + CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4, + ] + ]); + + $client = new Client(); + $client->setHttpClient($guzzle); + $client->setAuthConfig(__DIR__ . '/../../../storage/google-service-account.json'); + $client->setScopes([Sheets::SPREADSHEETS]); + + $this->service = new Sheets($client); + + $this->spreadsheetId = $spreadsheetId; + $this->sheetName = $sheetName; + } + + public function getRowsRaw(): array + { + $range = "'{$this->sheetName}'!A:Z"; + + $response = $this->service->spreadsheets_values->get( + $this->spreadsheetId, + $range + ); + + $values = $response->getValues() ?? []; + + $rows = []; + + foreach ($values as $i => $row) { + + $rowNum = $i + 1; + + if ($rowNum === 1) continue; + + $item = ['_row_number' => $rowNum]; + + foreach (range('A','Z') as $idx=>$col) { + $item[$col] = $row[$idx] ?? null; + } + + $rows[] = $item; + } + + return $rows; + } +} \ No newline at end of file diff --git a/app/Services/NotificationService.php b/app/Services/NotificationService.php new file mode 100644 index 0000000..180f3df --- /dev/null +++ b/app/Services/NotificationService.php @@ -0,0 +1,93 @@ + $id > 0); + + if (empty($userIds)) { + return; + } + + $pdo = DB::connection(); + + $stmt = $pdo->prepare(" + INSERT INTO notifications ( + user_id, + type, + title, + message, + payload_json, + is_read, + created_at + ) VALUES (?, ?, ?, ?, ?, 0, NOW()) + "); + + $payloadJson = $payload ? json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null; + + foreach ($userIds as $userId) { + $stmt->execute([ + $userId, + $type, + $title, + $message, + $payloadJson, + ]); + } + } + + public function getUnreadCount(int $userId): int + { + $pdo = DB::connection(); + + $stmt = $pdo->prepare(" + SELECT COUNT(*) + FROM notifications + WHERE user_id = ? + AND is_read = 0 + "); + $stmt->execute([$userId]); + + return (int)$stmt->fetchColumn(); + } + + public function getLatest(int $userId, int $limit = 20): array + { + $pdo = DB::connection(); + + $stmt = $pdo->prepare(" + SELECT * + FROM notifications + WHERE user_id = ? + ORDER BY id DESC + LIMIT ? + "); + $stmt->bindValue(1, $userId, \PDO::PARAM_INT); + $stmt->bindValue(2, $limit, \PDO::PARAM_INT); + $stmt->execute(); + + return $stmt->fetchAll(); + } + + public function markAllRead(int $userId): void + { + $pdo = DB::connection(); + + $stmt = $pdo->prepare(" + UPDATE notifications + SET is_read = 1, + read_at = NOW() + WHERE user_id = ? + AND is_read = 0 + "); + $stmt->execute([$userId]); + } +} \ No newline at end of file diff --git a/app/Services/WebSocket/EventPublisher.php b/app/Services/WebSocket/EventPublisher.php new file mode 100644 index 0000000..6187c44 --- /dev/null +++ b/app/Services/WebSocket/EventPublisher.php @@ -0,0 +1,33 @@ +queueFile = __DIR__ . '/../../../storage/ws/events.log'; + } + + public function publish(array $payload): void + { + $dir = dirname($this->queueFile); + + if (!is_dir($dir)) { + mkdir($dir, 0775, true); + } + + $line = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + if ($line === false) { + return; + } + + file_put_contents($this->queueFile, $line . PHP_EOL, FILE_APPEND | LOCK_EX); + } +} \ No newline at end of file diff --git a/app/Views/admin/ad/index.php b/app/Views/admin/ad/index.php new file mode 100644 index 0000000..7fc90dd --- /dev/null +++ b/app/Views/admin/ad/index.php @@ -0,0 +1,47 @@ +

Синхронизация AD

+ + +
+ +
+ + + + +
+ +
+ + + +
+
+
+
+
Групп AD
+
+
+
+
+ +
+
+
+
Связей пользователь-группа
+
+
+
+
+
+ +
+
+

+ Сейчас синхронизация берет группы из локальной таблицы пользователей, которые уже вошли в CRM через AD. +

+ +
+ +
+
+
\ No newline at end of file diff --git a/app/Views/admin/boards/access.php b/app/Views/admin/boards/access.php new file mode 100644 index 0000000..0cf3881 --- /dev/null +++ b/app/Views/admin/boards/access.php @@ -0,0 +1,62 @@ +

Доступ к доске

+ + +
+ +
+ + + + +
+ +
+ + + +
+
+
Название:
+
Код:
+ +
Описание:
+ +
+
+ +
+
+
+ + + + +
+ +
Группы пока не загружены. Сначала синхронизируй AD.
+ + +
+ + > + +
+ + +
+ +
+ + Назад +
+
+
+
\ No newline at end of file diff --git a/app/Views/admin/boards/create.php b/app/Views/admin/boards/create.php new file mode 100644 index 0000000..71add78 --- /dev/null +++ b/app/Views/admin/boards/create.php @@ -0,0 +1,46 @@ +

Создание доски

+ + +
+ +
+ + + +
+
+
+
+ + +
+ +
+ + +
Например: supply, it, hr
+
+ +
+ + +
+ +
+ + +
+
+ + +
+ + + Назад +
+
+
\ No newline at end of file diff --git a/app/Views/admin/boards/edit.php b/app/Views/admin/boards/edit.php new file mode 100644 index 0000000..e7bcaf9 --- /dev/null +++ b/app/Views/admin/boards/edit.php @@ -0,0 +1,85 @@ +

Редактирование доски

+ + +
+ +
+ + + + +
+ +
+ + + +
+
+
+ + +
+ + +
+ +
+ + +
Используется в URL, например: /boards/supply
+
+ +
+ + +
+ +
+ + > + +
+
+ + > + +
+ +
+ + Назад +
+
+
+
\ No newline at end of file diff --git a/app/Views/admin/boards/fields/create.php b/app/Views/admin/boards/fields/create.php new file mode 100644 index 0000000..d7331a4 --- /dev/null +++ b/app/Views/admin/boards/fields/create.php @@ -0,0 +1,134 @@ +

Создание поля

+ + +
+ +
+ + + + +
+ +
+ + + +
+
+
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + +
+ + +
+ +
+ + +
+ +
+ + +
+ + + Назад +
+
+
+ + +
+
+
Уже созданные поля
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
IDКодНазваниеТипПорядок
+
+ + Изменить + + +
+ + +
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/app/Views/admin/boards/fields/edit.php b/app/Views/admin/boards/fields/edit.php new file mode 100644 index 0000000..e671dd9 --- /dev/null +++ b/app/Views/admin/boards/fields/edit.php @@ -0,0 +1,73 @@ + + +

Изменение поля

+ +
+
+
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ > + +
+ +
+ > + +
+ + + Назад +
+
+
+ + \ No newline at end of file diff --git a/app/Views/admin/boards/fields/index.php b/app/Views/admin/boards/fields/index.php new file mode 100644 index 0000000..6c88f7a --- /dev/null +++ b/app/Views/admin/boards/fields/index.php @@ -0,0 +1,93 @@ +

Создание поля

+ + +
+ +
+ + + +
+
+

Поля доски

+
+ () +
+
+ + + + Создать поле + +
+ + +
+ +
+ + + + +
+ +
+ + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDКодНазваниеТипОбязательноеАктивноПорядокДействия
+
+ + Изменить + + +
+ + +
+
+
Поля пока не созданы
+
+
+ + \ No newline at end of file diff --git a/app/Views/admin/boards/index.php b/app/Views/admin/boards/index.php new file mode 100644 index 0000000..279ab6c --- /dev/null +++ b/app/Views/admin/boards/index.php @@ -0,0 +1,83 @@ + +
+ +
+ + + + +
+ +
+ + + +
+

Доски

+ + Создать доску +
+ + +
+ +
+ + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDНазваниеКодОписаниеАктивнаСозданаДоступДействия
+ + + + + + +
+ Изменить +
+ + +
+
+
+
+
\ No newline at end of file diff --git a/app/Views/admin/boards/modal.php b/app/Views/admin/boards/modal.php new file mode 100644 index 0000000..9e8f88e --- /dev/null +++ b/app/Views/admin/boards/modal.php @@ -0,0 +1,94 @@ + + + + + \ No newline at end of file diff --git a/app/Views/admin/boards/source/edit.php b/app/Views/admin/boards/source/edit.php new file mode 100644 index 0000000..853bd05 --- /dev/null +++ b/app/Views/admin/boards/source/edit.php @@ -0,0 +1,121 @@ +

Интеграция Google Sheets

+ + +
+ +
+ + + + +
+ +
+ + + +
+
+
Доска:
+
Код:
+
+
+ +
+
+
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ > + +
+ +
+ +
Сопоставление базовых полей
+ +
+ + +
+ + +
+ +
+ +
Сопоставление кастомных полей
+ + +
+ У доски пока нет кастомных полей. +
+ +
+ + +
+ + +
+ +
+ + +
+ + Назад +
+
+
+
\ No newline at end of file diff --git a/app/Views/admin/boards/statuses/create.php b/app/Views/admin/boards/statuses/create.php new file mode 100644 index 0000000..4d59958 --- /dev/null +++ b/app/Views/admin/boards/statuses/create.php @@ -0,0 +1,55 @@ +

Создание статуса

+ +
+
+
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + Назад +
+
+
\ No newline at end of file diff --git a/app/Views/admin/boards/statuses/edit.php b/app/Views/admin/boards/statuses/edit.php new file mode 100644 index 0000000..71b8f06 --- /dev/null +++ b/app/Views/admin/boards/statuses/edit.php @@ -0,0 +1,53 @@ +

Изменение статуса

+ +
+
+
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ > + +
+ +
+ > + +
+ + + Назад +
+
+
\ No newline at end of file diff --git a/app/Views/admin/boards/statuses/index.php b/app/Views/admin/boards/statuses/index.php new file mode 100644 index 0000000..d1cb41a --- /dev/null +++ b/app/Views/admin/boards/statuses/index.php @@ -0,0 +1,54 @@ +
+
+

Статусы доски

+
+ () +
+
+ + + + Создать статус + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDКодНазваниеЦветПорядокФинальныйАктивен
+ + Изменить + +
Статусов пока нет
+
+
\ No newline at end of file diff --git a/app/Views/admin/index.php b/app/Views/admin/index.php new file mode 100644 index 0000000..6a1913e --- /dev/null +++ b/app/Views/admin/index.php @@ -0,0 +1,12 @@ +

Админка

+ +
+ +
\ No newline at end of file diff --git a/app/Views/auth/login.php b/app/Views/auth/login.php new file mode 100644 index 0000000..7fad069 --- /dev/null +++ b/app/Views/auth/login.php @@ -0,0 +1,30 @@ +
+
+
+
+

Вход в CRM

+ + +
+ +
+ + + +
+
+ + +
+ +
+ + +
+ + +
+
+
+
+
\ No newline at end of file diff --git a/app/Views/dashboard/index.php b/app/Views/dashboard/index.php new file mode 100644 index 0000000..4187a35 --- /dev/null +++ b/app/Views/dashboard/index.php @@ -0,0 +1,8 @@ +

Главная

+ +
+
+

Вы вошли как:

+ Перейти к задачам +
+
\ No newline at end of file diff --git a/app/Views/layouts/app.php b/app/Views/layouts/app.php new file mode 100644 index 0000000..29cbb3f --- /dev/null +++ b/app/Views/layouts/app.php @@ -0,0 +1,691 @@ + + + + + + CRM + + + + + +query(" + SELECT name, code + FROM boards + WHERE is_active = 1 + ORDER BY id ASC +"); +$boards = $stmt->fetchAll(); + +$currentPath = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); +?> + + +
+ +
+ + + + + + +
+
+
Доски
+ +
+ +
+ + +
+ + + + + + + +
+
+
+ + + + + + + + +
+ + + + + + + + \ No newline at end of file diff --git a/app/Views/layouts/guest.php b/app/Views/layouts/guest.php new file mode 100644 index 0000000..3e00a71 --- /dev/null +++ b/app/Views/layouts/guest.php @@ -0,0 +1,14 @@ + + + + + + CRM + + + +
+ +
+ + \ No newline at end of file diff --git a/app/Views/tasks/board.php b/app/Views/tasks/board.php new file mode 100644 index 0000000..823affc --- /dev/null +++ b/app/Views/tasks/board.php @@ -0,0 +1,717 @@ + $filters['status'] ?? '', + 'priority' => $filters['priority'] ?? '', + 'assignee_id' => $filters['assignee_id'] ?? '', + 'search' => $filters['search'] ?? '', +]; + +$tableUrl = $baseUrl . '?view=table'; +$kanbanUrl = $baseUrl . '?view=kanban'; +$cardsUrl = $baseUrl . '?view=cards'; +$deadlineRank = function (array $task): int { + $deadline = taskDeadlineState($task['planned_at'] ?? null, $task['status'] ?? null); + + return match ($deadline['code'] ?? '') { + 'red' => 1, + 'yellow' => 2, + 'green' => 3, + default => 4, + }; +}; + +usort($tasks, function (array $a, array $b) use ($deadlineRank): int { + $rankA = $deadlineRank($a); + $rankB = $deadlineRank($b); + + if ($rankA !== $rankB) { + return $rankA <=> $rankB; + } + + return strtotime((string)($a['planned_at'] ?? '9999-12-31')) <=> strtotime((string)($b['planned_at'] ?? '9999-12-31')); +}); + +if (!empty($columns)) { + foreach ($columns as $statusCode => $column) { + usort($columns[$statusCode]['tasks'], function (array $a, array $b) use ($deadlineRank): int { + $rankA = $deadlineRank($a); + $rankB = $deadlineRank($b); + + if ($rankA !== $rankB) { + return $rankA <=> $rankB; + } + + return strtotime((string)($a['planned_at'] ?? '9999-12-31')) <=> strtotime((string)($b['planned_at'] ?? '9999-12-31')); + }); + } +} +?> + + + +
+
+

+
+ + Всего: + + + + Новые: + + + + В работе: + + + + На проверке: + + + + Завершено: + + + + Просрочено: + +
+
Код:
+
+ + + + + +
+ +
+
+
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + Сбросить +
+
+
+
+ + +
+
+ + + + + + + + + + + + + + + + + + 0 && $lastCommentId > $lastReadCommentId; + $badgeClass = $isUnread ? 'bg-primary' : 'bg-secondary'; + + $statusClass = match ($task['status']) { + 'NEW' => 'bg-secondary', + 'IN_PROGRESS' => 'bg-primary', + 'REVIEW' => 'bg-warning text-dark', + 'DONE' => 'bg-success', + 'CANCELED' => 'bg-dark', + 'OVERDUE' => 'bg-danger', + default => 'bg-secondary', + }; + + $priorityClass = match ($task['priority']) { + 'LOW' => 'bg-light text-dark', + 'MEDIUM' => 'bg-secondary', + 'HIGH' => 'bg-warning text-dark', + 'CRITICAL' => 'bg-danger', + default => 'bg-secondary', + }; + ?> + + + + + + + + + + + + + + + + + + +
IDCRM IDПостановщикОтветственныйНаименованиеСтатусПриоритетКомментарииДата план
+ + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ +
Нет задач
+
+
+ +
+ + 0 && $lastCommentId > $lastReadCommentId; + + $statusBadgeClass = match ($task['status']) { + 'NEW' => 'bg-secondary text-white', + 'IN_PROGRESS' => 'bg-info text-white', + 'REVIEW' => 'bg-warning text-dark', + 'DONE' => 'bg-success text-white', + 'CANCELED' => 'bg-dark text-white', + 'OVERDUE' => 'bg-danger text-white', + default => 'bg-secondary text-white', + }; + + $cardBorderClass = match ($task['status']) { + 'NEW' => 'border-secondary', + 'IN_PROGRESS' => 'border-info', + 'REVIEW' => 'border-warning', + 'DONE' => 'border-success', + 'CANCELED' => 'border-dark', + 'OVERDUE' => 'border-danger', + default => 'border-secondary', + }; + + $priorityClass = match ($task['priority']) { + 'LOW' => 'bg-light text-dark', + 'MEDIUM' => 'bg-primary text-white', + 'HIGH' => 'bg-warning text-dark', + 'CRITICAL' => 'bg-danger text-white', + default => 'bg-primary text-white', + }; + ?> +
+ +
+ +
+

+ +

+ +

+ +

+ +
+ Постановщик: +
+ +
+ Ответственный: + +
+ + +
+ Поставщик: +
+ + +
+ Дата план: + + + + + + + +
+ +
+ Дата факт: +
+
+
Дата постановки:
+
+ +
+ + Приоритет: + + + + + + + + + +
+
+
+
+ + + +
+
+ Нет задач +
+
+ +
+ + + +
+
+ $column): ?> +
+
+
+ +
+ +
+ +
Нет задач
+ + + + + 0 && $lastCommentId > $lastReadCommentId; + $badgeClass = $isUnread ? 'bg-primary' : 'bg-secondary'; + + $priorityClass = match ($task['priority']) { + 'LOW' => 'text-bg-light', + 'MEDIUM' => 'text-bg-secondary', + 'HIGH' => 'text-bg-warning', + 'CRITICAL' => 'text-bg-danger', + default => 'text-bg-secondary', + }; + + $isDone = (($task['status'] ?? '') === 'DONE'); + ?> +
+
+ + + + + + + +
+ + +
+ +
+ + +
+
CRM:
+ +
Постановщик:
+ + +
Ответственный:
+ + +
+ Дата план: + + + + + + + +
+ +
+ +
+ + Комментарии: + + + + + +
+
+ +
+
+ +
+
+ + + + + +
+
+
Редактирование доски:
+ +
+ +
+ +
+ + + +
+ + +
+

+ +

+
+
+ + + + + + + +
+ > + +
+ +
+ > + +
+ +
+
+
+ + +
+

+ +

+
+
+ + + + + + + +
+ > + +
+ +
+
+
+ + +
+

+ +

+
+
+ + +
+ + + + + + + +
+ + +
+ +
+ + +
Кастомных полей пока нет
+ + +
+ + + + +
+
+
+ +
+ +
+ +
+ +
+ +
+
+ + + + + diff --git a/app/Views/tasks/create.php b/app/Views/tasks/create.php new file mode 100644 index 0000000..d4c8f0c --- /dev/null +++ b/app/Views/tasks/create.php @@ -0,0 +1,158 @@ +

Создание задачи

+ + +
+ +
+ + + +
+
+
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + +
+ +
+ + +
+
+ +
+ + + + + + + + + + > + + + + > + + +
+ + +
+ + + + > + + +
+ + +
+ + +
+ +
+ + +
+ + + + + + Назад +
+
+
\ No newline at end of file diff --git a/app/Views/tasks/index.php b/app/Views/tasks/index.php new file mode 100644 index 0000000..8f0253a --- /dev/null +++ b/app/Views/tasks/index.php @@ -0,0 +1,170 @@ + 1, + 'yellow' => 2, + 'green' => 3, + default => 4, + }; +}; + +foreach ($boards as &$board) { + if (empty($board['tasks']) || !is_array($board['tasks'])) { + continue; + } + + usort($board['tasks'], function (array $a, array $b) use ($deadlineRank): int { + $rankA = $deadlineRank($a); + $rankB = $deadlineRank($b); + + if ($rankA !== $rankB) { + return $rankA <=> $rankB; + } + + return strtotime((string)($a['planned_at'] ?? '9999-12-31')) + <=> strtotime((string)($b['planned_at'] ?? '9999-12-31')); + }); +} +unset($board); +?> +
+
+

Главная

+
+ + +
+ Нет досок для отображения на главной. +
+ +
+ +
+
+
+
+
+ + + + + +
+ +
+ +
+
+
+ +
+
+ + Всего: + + + Новые: + + + В работе: + + + Завершено: + +
+ +
+ +
+ В этой доске пока нет задач. +
+ + + 0 && $lastCommentId > $lastReadCommentId; + $badgeClass = $isUnread ? 'bg-primary' : 'bg-secondary'; + + $statusClass = match ($task['status']) { + 'NEW' => 'bg-secondary', + 'IN_PROGRESS' => 'bg-primary', + 'REVIEW' => 'bg-warning text-dark', + 'DONE' => 'bg-success', + 'CANCELED' => 'bg-dark', + 'OVERDUE' => 'bg-danger', + default => 'bg-secondary', + }; + ?> +
+
+
+ + + + +
+ +
+ + +
+ Ответственный: +
+ + +
+ План: + + + + + + + +
+ +
+ +
+ + + + + + + +
+
+
+ + +
+
+ + +
+
+ +
+ +
\ No newline at end of file diff --git a/app/Views/tasks/kanban.php b/app/Views/tasks/kanban.php new file mode 100644 index 0000000..c8b5fc9 --- /dev/null +++ b/app/Views/tasks/kanban.php @@ -0,0 +1,385 @@ +
+

Канбан-доска

+ + +
+ +
+
+
+
+ + +
+ +
+ + Сбросить +
+
+
+
+ +
+
+ $column): ?> +
+
+
+ +
+ + + +
+ +
+ +
+ Нет задач +
+ + + + '; + echo 'ID: ' . ($task['id'] ?? '') . PHP_EOL; + echo 'planned_at: ' . ($task['planned_at'] ?? 'EMPTY') . PHP_EOL; + echo 'deadline: ' . print_r($deadline, true); + echo ''; + + + $commentCount = (int)($task['comment_count'] ?? 0); + $lastCommentId = (int)($task['last_comment_id'] ?? 0); + $lastReadCommentId = (int)($task['last_read_comment_id'] ?? 0); + $isUnread = $commentCount > 0 && $lastCommentId > $lastReadCommentId; + $badgeClass = $isUnread ? 'bg-primary' : 'bg-secondary'; + + $priorityClass = match ($task['priority']) { + 'LOW' => 'text-bg-light', + 'MEDIUM' => 'text-bg-secondary', + 'HIGH' => 'text-bg-warning', + 'CRITICAL' => 'text-bg-danger', + default => 'text-bg-secondary', + }; + ?> +
+
+ + + + + + + +
+ + +
+ +
+ + +
+
CRM:
+ +
Постановщик:
+ + +
Ответственный:
+ + +
Поставщик:
+ + +
Срок:
+ + +
+ Дата план: + + + + + + + +
+ +
+ +
+ + Комментарии: + + + + + + +
+
+ +
+
+ +
+
+ + + + \ No newline at end of file diff --git a/app/Views/tasks/modal.php b/app/Views/tasks/modal.php new file mode 100644 index 0000000..e2e8d4a --- /dev/null +++ b/app/Views/tasks/modal.php @@ -0,0 +1,367 @@ + + + + + + + \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..c1f3f9f --- /dev/null +++ b/composer.json @@ -0,0 +1,15 @@ +{ + "name": "ikz/crm", + "type": "project", + "require": { + "php": "^8.3", + "vlucas/phpdotenv": "^5.6", + "google/apiclient": "^2.18", + "workerman/workerman": "^5.1" + }, + "autoload": { + "psr-4": { + "App\\": "app/" + } + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..27a8dae --- /dev/null +++ b/composer.lock @@ -0,0 +1,1880 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "da77d09f79cbc3e7b3985db7ea1c1972", + "packages": [ + { + "name": "firebase/php-jwt", + "version": "v7.0.5", + "source": { + "type": "git", + "url": "https://github.com/firebase/php-jwt.git", + "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/47ad26bab5e7c70ae8a6f08ed25ff83631121380", + "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpfastcache/phpfastcache": "^9.2", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/firebase/php-jwt/issues", + "source": "https://github.com/firebase/php-jwt/tree/v7.0.5" + }, + "time": "2026-04-01T20:38:03+00:00" + }, + { + "name": "google/apiclient", + "version": "v2.19.2", + "source": { + "type": "git", + "url": "https://github.com/googleapis/google-api-php-client.git", + "reference": "703ba9acfaf4ba71306108207feafb6d1d137eb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/googleapis/google-api-php-client/zipball/703ba9acfaf4ba71306108207feafb6d1d137eb0", + "reference": "703ba9acfaf4ba71306108207feafb6d1d137eb0", + "shasum": "" + }, + "require": { + "firebase/php-jwt": "^6.0||^7.0", + "google/apiclient-services": "~0.350", + "google/auth": "^1.37", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.6", + "monolog/monolog": "^2.9||^3.0", + "php": "^8.1", + "phpseclib/phpseclib": "^3.0.50" + }, + "require-dev": { + "cache/filesystem-adapter": "^1.1", + "composer/composer": "^2.9", + "phpcompatibility/php-compatibility": "^9.2", + "phpspec/prophecy-phpunit": "^2.1", + "phpunit/phpunit": "^9.6", + "squizlabs/php_codesniffer": "^3.8", + "symfony/css-selector": "~2.1", + "symfony/dom-crawler": "~2.1" + }, + "suggest": { + "cache/filesystem-adapter": "For caching certs and tokens (using Google\\Client::setCache)" + }, + "type": "library", + "extra": { + "component": { + "entry": "src/Client.php" + }, + "branch-alias": { + "dev-main": "2.x-dev" + } + }, + "autoload": { + "files": [ + "src/aliases.php" + ], + "psr-4": { + "Google\\": "src/" + }, + "classmap": [ + "src/aliases.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "description": "Client library for Google APIs", + "homepage": "http://developers.google.com/api-client-library/php", + "keywords": [ + "google" + ], + "support": { + "issues": "https://github.com/googleapis/google-api-php-client/issues", + "source": "https://github.com/googleapis/google-api-php-client/tree/v2.19.2" + }, + "time": "2026-03-30T18:54:44+00:00" + }, + { + "name": "google/apiclient-services", + "version": "v0.436.0", + "source": { + "type": "git", + "url": "https://github.com/googleapis/google-api-php-client-services.git", + "reference": "42493f9565963a0456b1edace1eb5f1cc53f2f73" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/googleapis/google-api-php-client-services/zipball/42493f9565963a0456b1edace1eb5f1cc53f2f73", + "reference": "42493f9565963a0456b1edace1eb5f1cc53f2f73", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6" + }, + "type": "library", + "autoload": { + "files": [ + "autoload.php" + ], + "psr-4": { + "Google\\Service\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "description": "Client library for Google APIs", + "homepage": "http://developers.google.com/api-client-library/php", + "keywords": [ + "google" + ], + "support": { + "issues": "https://github.com/googleapis/google-api-php-client-services/issues", + "source": "https://github.com/googleapis/google-api-php-client-services/tree/v0.436.0" + }, + "time": "2026-04-06T01:28:26+00:00" + }, + { + "name": "google/auth", + "version": "v1.50.1", + "source": { + "type": "git", + "url": "https://github.com/googleapis/google-auth-library-php.git", + "reference": "870c17ee3a1d73338d39a9ffa77a700ba77f5a83" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/googleapis/google-auth-library-php/zipball/870c17ee3a1d73338d39a9ffa77a700ba77f5a83", + "reference": "870c17ee3a1d73338d39a9ffa77a700ba77f5a83", + "shasum": "" + }, + "require": { + "firebase/php-jwt": "^6.0||^7.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.4.5", + "php": "^8.1", + "psr/cache": "^2.0||^3.0", + "psr/http-message": "^1.1||^2.0", + "psr/log": "^2.0||^3.0" + }, + "require-dev": { + "guzzlehttp/promises": "^2.0", + "kelvinmo/simplejwt": "^1.1.0", + "phpseclib/phpseclib": "^3.0.35", + "phpspec/prophecy-phpunit": "^2.1", + "phpunit/phpunit": "^9.6", + "sebastian/comparator": ">=1.2.3", + "squizlabs/php_codesniffer": "^4.0", + "symfony/filesystem": "^6.3||^7.3", + "symfony/process": "^6.0||^7.0", + "webmozart/assert": "^1.11||^2.0" + }, + "suggest": { + "phpseclib/phpseclib": "May be used in place of OpenSSL for signing strings or for token management. Please require version ^2." + }, + "type": "library", + "autoload": { + "psr-4": { + "Google\\Auth\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "description": "Google Auth Library for PHP", + "homepage": "https://github.com/google/google-auth-library-php", + "keywords": [ + "Authentication", + "google", + "oauth2" + ], + "support": { + "docs": "https://cloud.google.com/php/docs/reference/auth/latest", + "issues": "https://github.com/googleapis/google-auth-library-php/issues", + "source": "https://github.com/googleapis/google-auth-library-php/tree/v1.50.1" + }, + "time": "2026-03-18T20:03:29+00:00" + }, + { + "name": "graham-campbell/result-type", + "version": "v1.1.4", + "source": { + "type": "git", + "url": "https://github.com/GrahamCampbell/Result-Type.git", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "An Implementation Of The Result Type", + "keywords": [ + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" + ], + "support": { + "issues": "https://github.com/GrahamCampbell/Result-Type/issues", + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:43:20+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.10.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.3", + "guzzlehttp/psr7": "^2.8", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.39 || ^9.6.20", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.10.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2025-08-23T22:36:01+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "481557b130ef3790cf82b713667b43030dc9c957" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", + "reference": "481557b130ef3790cf82b713667b43030dc9c957", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.3.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2025-08-22T14:34:08+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.9.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/7d0ed42f28e42d61352a7a79de682e5e67fec884", + "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "0.9.0", + "jshttp/mime-db": "1.54.0.1", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.9.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2026-03-10T16:41:02+00:00" + }, + { + "name": "monolog/monolog", + "version": "3.10.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8 || ^2.0", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.10.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2026-01-02T08:56:05+00:00" + }, + { + "name": "paragonie/constant_time_encoding", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "shasum": "" + }, + "require": { + "php": "^8" + }, + "require-dev": { + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2025-09-24T15:06:41+00:00" + }, + { + "name": "paragonie/random_compat", + "version": "v9.99.100", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", + "shasum": "" + }, + "require": { + "php": ">= 7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "time": "2020-10-15T08:29:30+00:00" + }, + { + "name": "phpoption/phpoption", + "version": "1.9.5", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com", + "homepage": "https://github.com/schmittjoh" + }, + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "support": { + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:41:33+00:00" + }, + { + "name": "phpseclib/phpseclib", + "version": "3.0.50", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "aa6ad8321ed103dc3624fb600a25b66ebf78ec7b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/aa6ad8321ed103dc3624fb600a25b66ebf78ec7b", + "reference": "aa6ad8321ed103dc3624fb600a25b66ebf78ec7b", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1|^2|^3", + "paragonie/random_compat": "^1.4|^2.0|^9.99.99", + "php": ">=5.6.1" + }, + "require-dev": { + "phpunit/phpunit": "*" + }, + "suggest": { + "ext-dom": "Install the DOM extension to load XML formatted public keys.", + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + }, + "type": "library", + "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib3\\": "phpseclib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-Jürgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" + } + ], + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "http://phpseclib.sourceforge.net", + "keywords": [ + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" + ], + "support": { + "issues": "https://github.com/phpseclib/phpseclib/issues", + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.50" + }, + "funding": [ + { + "url": "https://github.com/terrafrost", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpseclib", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib", + "type": "tidelift" + } + ], + "time": "2026-03-19T02:57:58+00:00" + }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-12-23T08:48:59+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-01-02T08:10:11+00:00" + }, + { + "name": "vlucas/phpdotenv", + "version": "v5.6.3", + "source": { + "type": "git", + "url": "https://github.com/vlucas/phpdotenv.git", + "reference": "955e7815d677a3eaa7075231212f2110983adecc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", + "reference": "955e7815d677a3eaa7075231212f2110983adecc", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.1.4", + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-filter": "*", + "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "5.6-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://github.com/vlucas" + } + ], + "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "issues": "https://github.com/vlucas/phpdotenv/issues", + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:49:13+00:00" + }, + { + "name": "workerman/coroutine", + "version": "v1.1.5", + "source": { + "type": "git", + "url": "https://github.com/workerman-php/coroutine.git", + "reference": "b60e44267b90d398dbfa7a320f3e97b46357ac9f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/workerman-php/coroutine/zipball/b60e44267b90d398dbfa7a320f3e97b46357ac9f", + "reference": "b60e44267b90d398dbfa7a320f3e97b46357ac9f", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "workerman/workerman": "^5.1" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "psr/log": "*" + }, + "type": "library", + "autoload": { + "psr-4": { + "Workerman\\": "src", + "Workerman\\Coroutine\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Workerman coroutine", + "support": { + "issues": "https://github.com/workerman-php/coroutine/issues", + "source": "https://github.com/workerman-php/coroutine/tree/v1.1.5" + }, + "time": "2026-03-12T02:07:37+00:00" + }, + { + "name": "workerman/workerman", + "version": "v5.1.10", + "source": { + "type": "git", + "url": "https://github.com/walkor/workerman.git", + "reference": "6ecda94609c40ade0f1e548535d24d8e09e67409" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/walkor/workerman/zipball/6ecda94609c40ade0f1e548535d24d8e09e67409", + "reference": "6ecda94609c40ade0f1e548535d24d8e09e67409", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=8.1", + "workerman/coroutine": "^1.1 || dev-main" + }, + "conflict": { + "ext-swow": " $_ENV['APP_NAME'] ?? 'CRM', + 'env' => $_ENV['APP_ENV'] ?? 'production', + 'debug' => filter_var($_ENV['APP_DEBUG'] ?? false, FILTER_VALIDATE_BOOLEAN), + 'url' => $_ENV['APP_URL'] ?? 'http://localhost', +]; \ No newline at end of file diff --git a/config/db.php b/config/db.php new file mode 100644 index 0000000..8e7d853 --- /dev/null +++ b/config/db.php @@ -0,0 +1,10 @@ + $_ENV['DB_HOST'] ?? '127.0.0.1', + 'port' => (int)($_ENV['DB_PORT'] ?? 3306), + 'database' => $_ENV['DB_DATABASE'] ?? '', + 'username' => $_ENV['DB_USERNAME'] ?? '', + 'password' => $_ENV['DB_PASSWORD'] ?? '', + 'charset' => 'utf8mb4', +]; \ No newline at end of file diff --git a/config/google.php b/config/google.php new file mode 100644 index 0000000..ff1576d --- /dev/null +++ b/config/google.php @@ -0,0 +1,7 @@ + $_ENV['GOOGLE_SHEET_ID'] ?? '', + 'sheet_name' => $_ENV['GOOGLE_SHEET_NAME'] ?? '', + 'credentials_path' => $_ENV['GOOGLE_CREDENTIALS_PATH'] ?? '', +]; \ No newline at end of file diff --git a/config/ldap.php b/config/ldap.php new file mode 100644 index 0000000..fb1352d --- /dev/null +++ b/config/ldap.php @@ -0,0 +1,9 @@ + $_ENV['LDAP_HOST'] ?? '127.0.0.1', + 'port' => (int)($_ENV['LDAP_PORT'] ?? 389), + 'base_dn' => $_ENV['LDAP_BASE_DN'] ?? '', + 'domain' => $_ENV['LDAP_DOMAIN'] ?? '', + 'admin_group_name' => $_ENV['LDAP_ADMIN_GROUP_NAME'] ?? 'ИТ-Отдел', +]; \ No newline at end of file diff --git a/cron/import_google.php b/cron/import_google.php new file mode 100644 index 0000000..bdc040c --- /dev/null +++ b/cron/import_google.php @@ -0,0 +1,21 @@ +safeLoad(); +date_default_timezone_set($_ENV['APP_TIMEZONE'] ?? 'Europe/Samara'); +$service = new GoogleImportService(); +$result = $service->import(); + +echo '[' . date('Y-m-d H:i:s') . '] '; +echo 'created=' . $result['created'] . ', '; +echo 'updated=' . $result['updated'] . ', '; +echo 'skipped=' . $result['skipped'] . ', '; +echo 'total=' . $result['total'] . PHP_EOL; \ No newline at end of file diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..c56487c --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,5 @@ +RewriteEngine On + +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^ index.php [QSA,L] \ No newline at end of file diff --git a/public/.user.ini b/public/.user.ini new file mode 100644 index 0000000..9339b0e --- /dev/null +++ b/public/.user.ini @@ -0,0 +1 @@ +open_basedir=/www/wwwroot/it.rifdev.ru/:/tmp/ \ No newline at end of file diff --git a/public/assets/css/custom.css b/public/assets/css/custom.css new file mode 100644 index 0000000..1247e85 --- /dev/null +++ b/public/assets/css/custom.css @@ -0,0 +1,202 @@ +.btn .badge { + position: relative; + top: 0!important; +} +.offcanvas { + position: fixed !important; +} +.deadline-green { + background: rgba(25, 135, 84, 0.12) !important; + border: 1px solid rgba(25, 135, 84, 0.35) !important; + transition: 0.2s; +} + +.deadline-yellow { + background: rgba(255, 193, 7, 0.15) !important; + border: 1px solid rgba(255, 193, 7, 0.4) !important; + transition: 0.2s; +} + +.deadline-red { + background: rgba(220, 53, 69, 0.14) !important; + border: 1px solid rgba(220, 53, 69, 0.45) !important; + transition: 0.2s; +} + +.deadline-green:hover, +.deadline-yellow:hover, +.deadline-red:hover { + filter: brightness(0.97); +} +.deadline-red { + animation: pulseDeadline 2s infinite; +} + +@keyframes pulseDeadline { + 0% { + box-shadow: 0 0 0 0 rgba(220,53,69,0.35); + } + + 70% { + box-shadow: 0 0 0 8px rgba(220,53,69,0); + } + + 100% { + box-shadow: 0 0 0 0 rgba(220,53,69,0); + } +} +.kanban-card { + background: #fff; + border: 1px solid #e9ecef; + border-radius: 12px; + padding: 12px; + box-shadow: 0 1px 2px rgba(0,0,0,.04); + margin-bottom: 12px; + cursor: grab; +} + +.kanban-card.deadline-green { + background-color: rgba(25, 135, 84, 0.18) !important; + border-color: rgba(25, 135, 84, 0.55) !important; +} + +.kanban-card.deadline-yellow { + background-color: rgba(255, 193, 7, 0.28) !important; + border-color: rgba(255, 193, 7, 0.75) !important; +} + +.kanban-card.deadline-red { + background-color: rgba(220, 53, 69, 0.25) !important; + border-color: rgba(220, 53, 69, 0.75) !important; +} + +.deadline-badge-green { + background: #198754; +} + +.deadline-badge-yellow { + background: #ffc107; + color: #000; +} + +.deadline-badge-red { + background: #dc3545; +} + +/* deadline: table */ +tr.deadline-green > td { + background: #d1e7dd !important; +} + +tr.deadline-yellow > td { + background: #fff3cd !important; +} + +tr.deadline-red > td { + background: #f8d7da !important; +} + +/* deadline: cards */ +.card.deadline-green { + background: #d1e7dd !important; + border: 2px solid #198754 !important; +} + +.card.deadline-yellow { + background: #fff3cd !important; + border: 2px solid #ffc107 !important; +} + +.card.deadline-red { + background: #f8d7da !important; + border: 2px solid #dc3545 !important; +} + +/* deadline: kanban */ +.kanban-card.deadline-green { + background: #d1e7dd !important; + border: 2px solid #198754 !important; +} + +.kanban-card.deadline-yellow { + background: #fff3cd !important; + border: 2px solid #ffc107 !important; +} + +.kanban-card.deadline-red { + background: #f8d7da !important; + border: 2px solid #dc3545 !important; +} + +.deadline-badge-green { + background: #198754; +} + +.deadline-badge-yellow { + background: #ffc107; + color: #000; +} + +.deadline-badge-red { + background: #dc3545; +} +.list-group-item.deadline-green { + background: #d1e7dd !important; + border: 1px solid #198754 !important; +} + +.list-group-item.deadline-yellow { + background: #fff3cd !important; + border: 1px solid #ffc107 !important; +} + +.list-group-item.deadline-red { + background: #f8d7da !important; + border: 1px solid #dc3545 !important; +} + +.deadline-badge-green { + background: #198754; +} +/* TASK DEADLINE STATES */ + +.list-group-item.deadline-green { + background: #d1e7dd !important; + border: 1px solid #198754 !important; +} + +.list-group-item.deadline-yellow { + background: #fff3cd !important; + border: 1px solid #ffc107 !important; +} + +.list-group-item.deadline-red { + background: #f8d7da !important; + border: 1px solid #dc3545 !important; +} + +/* badges */ + +.deadline-badge-green { + background: #198754; + color: #fff; +} + +.deadline-badge-yellow { + background: #ffc107; + color: #000; +} + +.deadline-badge-red { + background: #dc3545; + color: #fff; +} + +.deadline-badge-yellow { + background: #ffc107; + color: #000; +} + +.deadline-badge-red { + background: #dc3545; +} \ No newline at end of file diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..ed4bde1 --- /dev/null +++ b/public/index.php @@ -0,0 +1,21 @@ +safeLoad(); + +$router = new Router(); + +require_once __DIR__ . '/../routes/web.php'; + +$router->dispatch($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']); \ No newline at end of file diff --git a/routes/web.php b/routes/web.php new file mode 100644 index 0000000..e3311a8 --- /dev/null +++ b/routes/web.php @@ -0,0 +1,78 @@ +get('/', [DashboardController::class, 'index']); + +$router->get('/login', [AuthController::class, 'showLogin']); +$router->post('/login', [AuthController::class, 'login']); +$router->post('/logout', [AuthController::class, 'logout']); + +$router->get('/tasks', [TaskController::class, 'index']); +$router->get('/tasks/create', [TaskController::class, 'create']); +$router->post('/tasks/store', [TaskController::class, 'store']); + +$router->get('/tasks/show', [TaskController::class, 'showModal']); +$router->post('/tasks/comment/store', [CommentController::class, 'store']); +$router->get('/tasks/kanban', [TaskController::class, 'kanban']); +$router->post('/tasks/change-status', [TaskController::class, 'changeStatus']); +$router->post('/tasks/update-status', [TaskController::class, 'updateStatus']); + + +$router->get('/boards/{code}', [TaskController::class, 'board']); + +#Админ панель +$router->get('/admin', [AdminController::class, 'index']); +$router->get('/admin/boards', [AdminBoardController::class, 'index']); +$router->get('/admin/boards/create', [AdminBoardController::class, 'create']); +$router->post('/admin/boards/store', [AdminBoardController::class, 'store']); +$router->get('/admin/boards/show', [AdminBoardController::class, 'showModal']); + +$router->get('/admin/ad', [AdminAdController::class, 'index']); +$router->post('/admin/ad/sync', [AdminAdController::class, 'sync']); + +$router->get('/admin/boards/access', [AdminBoardController::class, 'access']); +$router->post('/admin/boards/access/save', [AdminBoardController::class, 'saveAccess']); + +$router->get('/admin/boards/statuses', [AdminBoardController::class, 'statuses']); +$router->get('/admin/boards/statuses/create', [AdminBoardController::class, 'createStatus']); +$router->post('/admin/boards/statuses/store', [AdminBoardController::class, 'storeStatus']); +$router->get('/admin/boards/statuses/edit', [AdminBoardController::class, 'editStatus']); +$router->post('/admin/boards/statuses/update', [AdminBoardController::class, 'updateStatus']); +$router->post('/tasks/files/upload', [TaskFileController::class, 'upload']); +$router->get('/tasks/files/download', [TaskFileController::class, 'download']); + +$router->get('/tasks/files/view', [TaskFileController::class, 'view']); +$router->post('/tasks/files/delete', [TaskFileController::class, 'delete']); + + +$router->get('/admin/boards/fields', [AdminBoardFieldController::class, 'index']); +$router->get('/admin/boards/fields/create', [AdminBoardFieldController::class, 'create']); +$router->post('/admin/boards/fields/store', [AdminBoardFieldController::class, 'store']); +$router->get('/admin/boards/fields/edit', [AdminBoardFieldController::class, 'edit']); +$router->post('/admin/boards/fields/update', [AdminBoardFieldController::class, 'update']); +$router->post('/admin/boards/fields/delete', [AdminBoardFieldController::class, 'delete']); + +$router->post('/admin/boards/delete', [AdminBoardController::class, 'delete']); + +$router->get('/admin/boards/source', [AdminBoardSourceController::class, 'edit']); +$router->post('/admin/boards/source/save', [AdminBoardSourceController::class, 'save']); + +$router->get('/admin/boards/edit', [AdminBoardController::class, 'edit']); +$router->post('/admin/boards/update', [AdminBoardController::class, 'update']); + +$router->get('/notifications/list', [NotificationController::class, 'list']); +$router->post('/notifications/read-all', [NotificationController::class, 'readAll']); + +$router->post('/admin/boards/save-all', [AdminBoardController::class, 'saveAll']); +$router->post('/tasks/update', [TaskController::class, 'update']); \ No newline at end of file diff --git a/ws-server.php b/ws-server.php new file mode 100644 index 0000000..aa197d0 --- /dev/null +++ b/ws-server.php @@ -0,0 +1,73 @@ +count = 1; + +$connections = []; +$queueFile = __DIR__ . '/storage/ws/events.log'; +$offsetFile = __DIR__ . '/storage/ws/events.offset'; + +$wsWorker->onConnect = function ($connection) use (&$connections) { + $connections[$connection->id] = $connection; +}; + +$wsWorker->onClose = function ($connection) use (&$connections) { + unset($connections[$connection->id]); +}; + +$wsWorker->onWorkerStart = function () use (&$connections, $queueFile, $offsetFile) { + if (!is_dir(dirname($queueFile))) { + mkdir(dirname($queueFile), 0775, true); + } + + if (!file_exists($queueFile)) { + touch($queueFile); + } + + if (!file_exists($offsetFile)) { + file_put_contents($offsetFile, '0'); + } + + Timer::add(1, function () use (&$connections, $queueFile, $offsetFile) { + clearstatcache(true, $queueFile); + + $size = filesize($queueFile); + $offset = (int) @file_get_contents($offsetFile); + + if ($size === false || $size <= $offset) { + return; + } + + $fp = fopen($queueFile, 'r'); + if (!$fp) { + return; + } + + fseek($fp, $offset); + + while (($line = fgets($fp)) !== false) { + $line = trim($line); + if ($line === '') { + continue; + } + + foreach ($connections as $connection) { + $connection->send($line); + } + } + + $newOffset = ftell($fp); + fclose($fp); + + file_put_contents($offsetFile, (string) $newOffset, LOCK_EX); + }); +}; + +Worker::runAll(); \ No newline at end of file