first commit

This commit is contained in:
exercict
2026-07-14 08:10:11 +04:00
commit fdfb0dd62e
60 changed files with 9930 additions and 0 deletions
+113
View File
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Auth;
use App\Core\DB;
use App\Core\View;
class AdminAdController
{
private function requireAdmin(): void
{
if (!Auth::check()) {
header('Location: /login');
exit;
}
$user = Auth::user();
if ((int)($user['is_admin'] ?? 0) !== 1) {
http_response_code(403);
echo 'Доступ запрещен';
exit;
}
}
public function index(): void
{
$this->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;
}
}
+591
View File
@@ -0,0 +1,591 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Auth;
use App\Core\DB;
use App\Core\View;
class AdminBoardController
{
private function requireAdmin(): void
{
if (!Auth::check()) {
header('Location: /login');
exit;
}
$user = Auth::user();
if ((int)($user['is_admin'] ?? 0) !== 1) {
http_response_code(403);
echo 'Доступ запрещен';
exit;
}
}
public function index(): void
{
$this->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']);
}
}
@@ -0,0 +1,258 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Auth;
use App\Core\DB;
use App\Core\View;
class AdminBoardFieldController
{
private function requireAdmin(): void
{
if (!Auth::check()) {
header('Location: /login');
exit;
}
$user = Auth::user();
if ((int)($user['is_admin'] ?? 0) !== 1) {
http_response_code(403);
echo 'Доступ запрещен';
exit;
}
}
public function index(): void
{
$this->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;
}
}
@@ -0,0 +1,259 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Auth;
use App\Core\DB;
use App\Core\View;
class AdminBoardSourceController
{
private function requireAdmin(): void
{
if (!Auth::check()) {
header('Location: /login');
exit;
}
$user = Auth::user();
if ((int)($user['is_admin'] ?? 0) !== 1) {
http_response_code(403);
echo 'Доступ запрещен';
exit;
}
}
private function extractSpreadsheetId(string $url): string
{
if (preg_match('~/spreadsheets/d/([a-zA-Z0-9-_]+)~', $url, $matches)) {
return $matches[1];
}
return trim($url);
}
public function edit(): void
{
$this->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;
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Auth;
use App\Core\View;
class AdminController
{
protected function requireAdmin(): void
{
if (!Auth::check()) {
header('Location: /login');
exit;
}
$user = Auth::user();
if ((int)($user['is_admin'] ?? 0) !== 1) {
http_response_code(403);
echo 'Доступ запрещен';
exit;
}
}
public function index(): void
{
$this->requireAdmin();
View::render('admin/index', [
'user' => Auth::user(),
]);
}
}
+88
View File
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Auth;
use App\Core\DB;
use App\Core\View;
use App\Services\Auth\LdapService;
class AuthController
{
public function showLogin(): void
{
View::render('auth/login', [], 'guest');
}
public function login(): void
{
$login = trim($_POST['login'] ?? '');
$password = trim($_POST['password'] ?? '');
if ($login === '' || $password === '') {
$_SESSION['error'] = 'Введите логин и пароль';
header('Location: /login');
exit;
}
$ldapService = new LdapService();
$ldapUser = $ldapService->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;
}
}
+153
View File
@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Auth;
use App\Core\DB;
use App\Services\WebSocket\EventPublisher;
class CommentController
{
public function store(): void
{
if (!Auth::check()) {
if ($this->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;
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Auth;
use App\Core\View;
class DashboardController
{
public function index(): void
{
if (!Auth::check()) {
header('Location: /login');
exit;
}
View::render('dashboard/index', [
'user' => Auth::user(),
]);
}
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Auth;
use App\Services\NotificationService;
class NotificationController
{
public function list(): void
{
if (!Auth::check()) {
http_response_code(403);
echo 'Unauthorized';
return;
}
$service = new NotificationService();
$items = $service->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);
}
}
File diff suppressed because it is too large Load Diff
+281
View File
@@ -0,0 +1,281 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Auth;
use App\Core\DB;
class TaskFileController
{
public function upload(): void
{
if (!Auth::check()) {
if ($this->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;
}
}