first commit
This commit is contained in:
+26
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
open_basedir=/www/wwwroot/it.rifdev.ru/:/tmp/
|
||||||
|
upload_max_filesize = 50M
|
||||||
|
post_max_size = 50M
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
class Auth
|
||||||
|
{
|
||||||
|
public static function check(): bool
|
||||||
|
{
|
||||||
|
return !empty($_SESSION['user']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function user(): ?array
|
||||||
|
{
|
||||||
|
return $_SESSION['user'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function login(array $user): void
|
||||||
|
{
|
||||||
|
$_SESSION['user'] = $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function logout(): void
|
||||||
|
{
|
||||||
|
unset($_SESSION['user']);
|
||||||
|
session_destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
use PDOException;
|
||||||
|
|
||||||
|
class DB
|
||||||
|
{
|
||||||
|
private static ?PDO $pdo = null;
|
||||||
|
|
||||||
|
public static function connection(): PDO
|
||||||
|
{
|
||||||
|
if (self::$pdo !== null) {
|
||||||
|
return self::$pdo;
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = require __DIR__ . '/../../config/db.php';
|
||||||
|
|
||||||
|
$dsn = sprintf(
|
||||||
|
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
|
||||||
|
$config['host'],
|
||||||
|
$config['port'],
|
||||||
|
$config['database'],
|
||||||
|
$config['charset']
|
||||||
|
);
|
||||||
|
|
||||||
|
self::$pdo = new PDO($dsn, $config['username'], $config['password'], [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return self::$pdo;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
class Router
|
||||||
|
{
|
||||||
|
private array $routes = [
|
||||||
|
'GET' => [],
|
||||||
|
'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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
class View
|
||||||
|
{
|
||||||
|
public static function render(string $viewName, array $data = [], ?string $layout = 'app'): void
|
||||||
|
{
|
||||||
|
extract($data, EXTR_SKIP);
|
||||||
|
|
||||||
|
$viewFile = __DIR__ . '/../Views/' . $viewName . '.php';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
require $viewFile;
|
||||||
|
$content = ob_get_clean();
|
||||||
|
|
||||||
|
if ($layout === null) {
|
||||||
|
echo $content;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$layoutFile = __DIR__ . '/../Views/layouts/' . $layout . '.php';
|
||||||
|
require $layoutFile;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
if (!function_exists('task_status_label')) {
|
||||||
|
function task_status_label(?string $status): string
|
||||||
|
{
|
||||||
|
return match ($status) {
|
||||||
|
'NEW' => 'Новая',
|
||||||
|
'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 дня',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Auth;
|
||||||
|
|
||||||
|
class LdapService
|
||||||
|
{
|
||||||
|
public function authenticate(string $login, string $password): array|false
|
||||||
|
{
|
||||||
|
$config = require __DIR__ . '/../../../config/ldap.php';
|
||||||
|
|
||||||
|
$host = $config['host'];
|
||||||
|
$port = $config['port'];
|
||||||
|
$baseDn = $config['base_dn'];
|
||||||
|
$domain = $config['domain'];
|
||||||
|
$adminGroupName = $config['admin_group_name'] ?? 'ИТ-Отдел';
|
||||||
|
|
||||||
|
$connection = ldap_connect("ldap://{$host}:{$port}");
|
||||||
|
|
||||||
|
if (!$connection) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ldap_set_option($connection, LDAP_OPT_PROTOCOL_VERSION, 3);
|
||||||
|
ldap_set_option($connection, LDAP_OPT_REFERRALS, 0);
|
||||||
|
|
||||||
|
$bindRdn = $login . '@' . $domain;
|
||||||
|
|
||||||
|
$bind = @ldap_bind($connection, $bindRdn, $password);
|
||||||
|
|
||||||
|
if (!$bind) {
|
||||||
|
ldap_unbind($connection);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$filter = sprintf('(sAMAccountName=%s)', ldap_escape($login, '', LDAP_ESCAPE_FILTER));
|
||||||
|
$attributes = ['cn', 'displayName', 'mail', 'sAMAccountName', 'memberOf'];
|
||||||
|
|
||||||
|
$search = @ldap_search($connection, $baseDn, $filter, $attributes);
|
||||||
|
|
||||||
|
if (!$search) {
|
||||||
|
ldap_unbind($connection);
|
||||||
|
return [
|
||||||
|
'login' => $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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Google;
|
||||||
|
|
||||||
|
use App\Core\DB;
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
class GoogleExportService
|
||||||
|
{
|
||||||
|
public function exportTaskById(int $taskId): bool
|
||||||
|
{
|
||||||
|
$pdo = DB::connection();
|
||||||
|
|
||||||
|
$stmt = $pdo->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Google;
|
||||||
|
|
||||||
|
use App\Core\DB;
|
||||||
|
use DateTime;
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
class GoogleImportService
|
||||||
|
{
|
||||||
|
public function import(): array
|
||||||
|
{
|
||||||
|
$pdo = DB::connection();
|
||||||
|
|
||||||
|
$stmt = $pdo->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Google;
|
||||||
|
|
||||||
|
use Google\Client;
|
||||||
|
use Google\Service\Sheets;
|
||||||
|
use GuzzleHttp\Client as GuzzleClient;
|
||||||
|
|
||||||
|
class GoogleSheetsService
|
||||||
|
{
|
||||||
|
private Sheets $service;
|
||||||
|
private string $spreadsheetId;
|
||||||
|
private string $sheetName;
|
||||||
|
|
||||||
|
public function __construct(string $spreadsheetId, string $sheetName)
|
||||||
|
{
|
||||||
|
$guzzle = new GuzzleClient([
|
||||||
|
'curl' => [
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Core\DB;
|
||||||
|
|
||||||
|
class NotificationService
|
||||||
|
{
|
||||||
|
public function createForUsers(array $userIds, string $type, string $title, ?string $message = null, ?array $payload = null): void
|
||||||
|
{
|
||||||
|
$userIds = array_values(array_unique(array_map('intval', $userIds)));
|
||||||
|
$userIds = array_filter($userIds, fn($id) => $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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\WebSocket;
|
||||||
|
|
||||||
|
class EventPublisher
|
||||||
|
{
|
||||||
|
private string $queueFile;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<h1 class="h3 mb-3">Синхронизация AD</h1>
|
||||||
|
|
||||||
|
<?php if (!empty($_SESSION['success'])): ?>
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<?= htmlspecialchars($_SESSION['success']) ?>
|
||||||
|
</div>
|
||||||
|
<?php unset($_SESSION['success']); ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (!empty($_SESSION['error'])): ?>
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
<?= htmlspecialchars($_SESSION['error']) ?>
|
||||||
|
</div>
|
||||||
|
<?php unset($_SESSION['error']); ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="text-muted small">Групп AD</div>
|
||||||
|
<div class="fs-4 fw-semibold"><?= (int)$groupCount ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="text-muted small">Связей пользователь-группа</div>
|
||||||
|
<div class="fs-4 fw-semibold"><?= (int)$userGroupCount ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="mb-3">
|
||||||
|
Сейчас синхронизация берет группы из локальной таблицы пользователей, которые уже вошли в CRM через AD.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="post" action="/admin/ad/sync">
|
||||||
|
<button type="submit" class="btn btn-primary">Синхронизировать группы из AD</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<h1 class="h3 mb-3">Доступ к доске</h1>
|
||||||
|
|
||||||
|
<?php if (!empty($_SESSION['success'])): ?>
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<?= htmlspecialchars($_SESSION['success']) ?>
|
||||||
|
</div>
|
||||||
|
<?php unset($_SESSION['success']); ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (!empty($_SESSION['error'])): ?>
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
<?= htmlspecialchars($_SESSION['error']) ?>
|
||||||
|
</div>
|
||||||
|
<?php unset($_SESSION['error']); ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<div><strong>Название:</strong> <?= htmlspecialchars((string)$board['name']) ?></div>
|
||||||
|
<div><strong>Код:</strong> <?= htmlspecialchars((string)$board['code']) ?></div>
|
||||||
|
<?php if (!empty($board['description'])): ?>
|
||||||
|
<div><strong>Описание:</strong> <?= htmlspecialchars((string)$board['description']) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="/admin/boards/access/save">
|
||||||
|
<input type="hidden" name="board_id" value="<?= (int)$board['id'] ?>">
|
||||||
|
|
||||||
|
<label class="form-label fw-semibold">Группы, которым доступна доска</label>
|
||||||
|
|
||||||
|
<div class="border rounded p-3" style="max-height: 420px; overflow:auto;">
|
||||||
|
<?php if (empty($groups)): ?>
|
||||||
|
<div class="text-muted">Группы пока не загружены. Сначала синхронизируй AD.</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<?php foreach ($groups as $group): ?>
|
||||||
|
<div class="form-check mb-2">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
name="group_ids[]"
|
||||||
|
value="<?= (int)$group['id'] ?>"
|
||||||
|
id="group_<?= (int)$group['id'] ?>"
|
||||||
|
<?= in_array((int)$group['id'], $selectedGroupIds, true) ? 'checked' : '' ?>
|
||||||
|
>
|
||||||
|
<label class="form-check-label" for="group_<?= (int)$group['id'] ?>">
|
||||||
|
<?= htmlspecialchars((string)$group['name']) ?>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<button type="submit" class="btn btn-success">Сохранить доступ</button>
|
||||||
|
<a href="/admin/boards" class="btn btn-secondary">Назад</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<h1 class="h3 mb-3">Создание доски</h1>
|
||||||
|
|
||||||
|
<?php if (!empty($_SESSION['error'])): ?>
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
<?= htmlspecialchars($_SESSION['error']) ?>
|
||||||
|
</div>
|
||||||
|
<?php unset($_SESSION['error']); ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="/admin/boards/store">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Название</label>
|
||||||
|
<input type="text" name="name" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Код</label>
|
||||||
|
<input type="text" name="code" class="form-control" required>
|
||||||
|
<div class="form-text">Например: supply, it, hr</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Описание</label>
|
||||||
|
<textarea name="description" class="form-control" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" name="is_active" id="is_active" checked>
|
||||||
|
<label class="form-check-label" for="is_active">
|
||||||
|
Активная доска
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" name="show_on_home" id="show_on_home" checked>
|
||||||
|
<label class="form-check-label" for="show_on_home">
|
||||||
|
Показывать на главной
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-success">Создать</button>
|
||||||
|
<a href="/admin/boards" class="btn btn-secondary">Назад</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<h1 class="h3 mb-3">Редактирование доски</h1>
|
||||||
|
|
||||||
|
<?php if (!empty($_SESSION['success'])): ?>
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<?= htmlspecialchars($_SESSION['success']) ?>
|
||||||
|
</div>
|
||||||
|
<?php unset($_SESSION['success']); ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (!empty($_SESSION['error'])): ?>
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
<?= htmlspecialchars($_SESSION['error']) ?>
|
||||||
|
</div>
|
||||||
|
<?php unset($_SESSION['error']); ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="/admin/boards/update">
|
||||||
|
<input type="hidden" name="id" value="<?= (int)$board['id'] ?>">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Название</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
class="form-control"
|
||||||
|
value="<?= htmlspecialchars((string)$board['name']) ?>"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Код</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="code"
|
||||||
|
class="form-control"
|
||||||
|
value="<?= htmlspecialchars((string)$board['code']) ?>"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<div class="form-text">Используется в URL, например: /boards/supply</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Описание</label>
|
||||||
|
<textarea
|
||||||
|
name="description"
|
||||||
|
class="form-control"
|
||||||
|
rows="4"
|
||||||
|
><?= htmlspecialchars((string)($board['description'] ?? '')) ?></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
name="is_active"
|
||||||
|
id="is_active"
|
||||||
|
<?= (int)$board['is_active'] ? 'checked' : '' ?>
|
||||||
|
>
|
||||||
|
<label class="form-check-label" for="is_active">
|
||||||
|
Активная доска
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
name="show_on_home"
|
||||||
|
id="show_on_home"
|
||||||
|
<?= (int)$board['show_on_home'] ? 'checked' : '' ?>
|
||||||
|
>
|
||||||
|
<label class="form-check-label" for="show_on_home">
|
||||||
|
Показывать на главной
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-success">Сохранить</button>
|
||||||
|
<a href="/admin/boards" class="btn btn-secondary">Назад</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<h1 class="h3 mb-3">Создание поля</h1>
|
||||||
|
|
||||||
|
<?php if (!empty($_SESSION['error'])): ?>
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
<?= htmlspecialchars((string)$_SESSION['error']) ?>
|
||||||
|
</div>
|
||||||
|
<?php unset($_SESSION['error']); ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (!empty($_SESSION['success'])): ?>
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<?= htmlspecialchars((string)$_SESSION['success']) ?>
|
||||||
|
</div>
|
||||||
|
<?php unset($_SESSION['success']); ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="/admin/boards/fields/store">
|
||||||
|
<input type="hidden" name="board_id" value="<?= (int)$board['id'] ?>">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Доска</label>
|
||||||
|
<input type="text" class="form-control" value="<?= htmlspecialchars((string)$board['name']) ?>" disabled>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Код</label>
|
||||||
|
<input type="text" name="code" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Название</label>
|
||||||
|
<input type="text" name="name" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Тип поля</label>
|
||||||
|
<select name="field_type" class="form-select" id="field_type">
|
||||||
|
<option value="text">text</option>
|
||||||
|
<option value="textarea">textarea</option>
|
||||||
|
<option value="number">number</option>
|
||||||
|
<option value="date">date</option>
|
||||||
|
<option value="select">select</option>
|
||||||
|
<option value="checkbox">checkbox</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3" id="select_options_wrap" style="display:none;">
|
||||||
|
<label class="form-label">Варианты select</label>
|
||||||
|
<textarea name="select_options" class="form-control" rows="5" placeholder="Каждый вариант с новой строки"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Порядок</label>
|
||||||
|
<input type="number" name="sort_order" class="form-control" value="100">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check mb-2">
|
||||||
|
<input class="form-check-input" type="checkbox" name="is_required" id="is_required">
|
||||||
|
<label class="form-check-label" for="is_required">Обязательное</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" name="is_active" id="is_active" checked>
|
||||||
|
<label class="form-check-label" for="is_active">Активное</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-success">Создать</button>
|
||||||
|
<a href="/admin/boards/fields?id=<?= (int)$board['id'] ?>" class="btn btn-secondary">Назад</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!empty($fields)): ?>
|
||||||
|
<div class="card shadow-sm mt-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="mb-3">Уже созданные поля</h5>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm align-middle mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Код</th>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Тип</th>
|
||||||
|
<th>Порядок</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($fields as $field): ?>
|
||||||
|
<tr>
|
||||||
|
<td><?= (int)$field['id'] ?></td>
|
||||||
|
<td><?= htmlspecialchars((string)$field['code']) ?></td>
|
||||||
|
<td><?= htmlspecialchars((string)$field['name']) ?></td>
|
||||||
|
<td><?= htmlspecialchars((string)$field['field_type']) ?></td>
|
||||||
|
<td><?= (int)$field['sort_order'] ?></td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="/admin/boards/fields/edit?id=<?= (int)$field['id'] ?>" class="btn btn-sm btn-outline-primary">
|
||||||
|
Изменить
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<form method="post" action="/admin/boards/fields/delete" onsubmit="return confirm('Удалить поле и все его значения у задач?');" class="d-inline">
|
||||||
|
<input type="hidden" name="id" value="<?= (int)$field['id'] ?>">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-danger">
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const fieldType = document.getElementById('field_type');
|
||||||
|
const wrap = document.getElementById('select_options_wrap');
|
||||||
|
|
||||||
|
if (fieldType && wrap) {
|
||||||
|
fieldType.addEventListener('change', function () {
|
||||||
|
wrap.style.display = this.value === 'select' ? 'block' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
$settings = json_decode((string)($field['settings_json'] ?? ''), true);
|
||||||
|
$optionsText = '';
|
||||||
|
|
||||||
|
if (!empty($settings['options']) && is_array($settings['options'])) {
|
||||||
|
$optionsText = implode("\n", $settings['options']);
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
||||||
|
<h1 class="h3 mb-3">Изменение поля</h1>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="/admin/boards/fields/update">
|
||||||
|
<input type="hidden" name="id" value="<?= (int)$field['id'] ?>">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Доска</label>
|
||||||
|
<input type="text" class="form-control" value="<?= htmlspecialchars((string)$field['board_name']) ?>" disabled>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Код</label>
|
||||||
|
<input type="text" name="code" class="form-control" value="<?= htmlspecialchars((string)$field['code']) ?>" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Название</label>
|
||||||
|
<input type="text" name="name" class="form-control" value="<?= htmlspecialchars((string)$field['name']) ?>" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Тип поля</label>
|
||||||
|
<select name="field_type" class="form-select" id="field_type">
|
||||||
|
<?php foreach (['text','textarea','number','date','select','checkbox'] as $type): ?>
|
||||||
|
<option value="<?= $type ?>" <?= $field['field_type'] === $type ? 'selected' : '' ?>>
|
||||||
|
<?= $type ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3" id="select_options_wrap" style="<?= $field['field_type'] === 'select' ? '' : 'display:none;' ?>">
|
||||||
|
<label class="form-label">Варианты select</label>
|
||||||
|
<textarea name="select_options" class="form-control" rows="5"><?= htmlspecialchars($optionsText) ?></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Порядок</label>
|
||||||
|
<input type="number" name="sort_order" class="form-control" value="<?= (int)$field['sort_order'] ?>">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check mb-2">
|
||||||
|
<input class="form-check-input" type="checkbox" name="is_required" id="is_required" <?= (int)$field['is_required'] ? 'checked' : '' ?>>
|
||||||
|
<label class="form-check-label" for="is_required">Обязательное</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" name="is_active" id="is_active" <?= (int)$field['is_active'] ? 'checked' : '' ?>>
|
||||||
|
<label class="form-check-label" for="is_active">Активное</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-success">Сохранить</button>
|
||||||
|
<a href="/admin/boards/fields?id=<?= (int)$field['board_id'] ?>" class="btn btn-secondary">Назад</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('field_type').addEventListener('change', function () {
|
||||||
|
document.getElementById('select_options_wrap').style.display = this.value === 'select' ? 'block' : 'none';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<h1 class="h3 mb-3">Создание поля</h1>
|
||||||
|
|
||||||
|
<?php if (!empty($_SESSION['error'])): ?>
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
<?= htmlspecialchars($_SESSION['error']) ?>
|
||||||
|
</div>
|
||||||
|
<?php unset($_SESSION['error']); ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0">Поля доски</h1>
|
||||||
|
<div class="text-muted small">
|
||||||
|
<?= htmlspecialchars((string)$board['name']) ?> (<?= htmlspecialchars((string)$board['code']) ?>)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="/admin/boards/fields/create?board_id=<?= (int)$board['id'] ?>" class="btn btn-primary">
|
||||||
|
+ Создать поле
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!empty($_SESSION['success'])): ?>
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<?= htmlspecialchars($_SESSION['success']) ?>
|
||||||
|
</div>
|
||||||
|
<?php unset($_SESSION['success']); ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (!empty($_SESSION['error'])): ?>
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
<?= htmlspecialchars($_SESSION['error']) ?>
|
||||||
|
</div>
|
||||||
|
<?php unset($_SESSION['error']); ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Код</th>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Тип</th>
|
||||||
|
<th>Обязательное</th>
|
||||||
|
<th>Активно</th>
|
||||||
|
<th>Порядок</th>
|
||||||
|
<th style="width: 220px;">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($fields as $field): ?>
|
||||||
|
<tr>
|
||||||
|
<td><?= (int)$field['id'] ?></td>
|
||||||
|
<td><?= htmlspecialchars((string)$field['code']) ?></td>
|
||||||
|
<td><?= htmlspecialchars((string)$field['name']) ?></td>
|
||||||
|
<td><?= htmlspecialchars((string)$field['field_type']) ?></td>
|
||||||
|
<td><?= (int)$field['is_required'] ? 'Да' : 'Нет' ?></td>
|
||||||
|
<td><?= (int)$field['is_active'] ? 'Да' : 'Нет' ?></td>
|
||||||
|
<td><?= (int)$field['sort_order'] ?></td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="/admin/boards/fields/edit?id=<?= (int)$field['id'] ?>" class="btn btn-sm btn-outline-primary">
|
||||||
|
Изменить
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<form method="post" action="/admin/boards/fields/delete" onsubmit="return confirm('Удалить поле и все его значения у задач?');" class="d-inline">
|
||||||
|
<input type="hidden" name="id" value="<?= (int)$field['id'] ?>">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-danger">
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
<?php if (empty($fields)): ?>
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" class="text-center text-muted py-4">Поля пока не созданы</td>
|
||||||
|
</tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('field_type').addEventListener('change', function () {
|
||||||
|
document.getElementById('select_options_wrap').style.display = this.value === 'select' ? 'block' : 'none';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php if (!empty($_SESSION['success'])): ?>
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<?= htmlspecialchars($_SESSION['success']) ?>
|
||||||
|
</div>
|
||||||
|
<?php unset($_SESSION['success']); ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (!empty($_SESSION['error'])): ?>
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
<?= htmlspecialchars($_SESSION['error']) ?>
|
||||||
|
</div>
|
||||||
|
<?php unset($_SESSION['error']); ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h1 class="h3 mb-0">Доски</h1>
|
||||||
|
<a href="/admin/boards/create" class="btn btn-primary">+ Создать доску</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!empty($_SESSION['error'])): ?>
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
<?= htmlspecialchars($_SESSION['error']) ?>
|
||||||
|
</div>
|
||||||
|
<?php unset($_SESSION['error']); ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Код</th>
|
||||||
|
<th>Описание</th>
|
||||||
|
<th>Активна</th>
|
||||||
|
<th>Создана</th>
|
||||||
|
<th>Доступ</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($boards as $board): ?>
|
||||||
|
<tr>
|
||||||
|
<td><?= (int)$board['id'] ?></td>
|
||||||
|
<td>
|
||||||
|
<a href="/admin/boards/show?id=<?= (int)$board['id'] ?>"
|
||||||
|
data-board-modal
|
||||||
|
data-board-id="<?= (int)$board['id'] ?>"
|
||||||
|
class="text-decoration-none">
|
||||||
|
<?= htmlspecialchars((string)$board['name']) ?>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td><?= htmlspecialchars((string)$board['code']) ?></td>
|
||||||
|
<td><?= htmlspecialchars((string)($board['description'] ?? '')) ?></td>
|
||||||
|
<td><?= (int)$board['is_active'] ? 'Да' : 'Нет' ?></td>
|
||||||
|
<td><?= htmlspecialchars((string)$board['created_at']) ?></td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
|
<a href="/admin/boards/access?id=<?= (int)$board['id'] ?>" class="btn btn-sm btn-outline-secondary">Права</a>
|
||||||
|
<a href="/admin/boards/statuses?id=<?= (int)$board['id'] ?>" class="btn btn-sm btn-outline-dark">Статусы</a>
|
||||||
|
<a href="/admin/boards/fields?id=<?= (int)$board['id'] ?>" class="btn btn-sm btn-outline-info">Поля</a>
|
||||||
|
<a href="/admin/boards/source?id=<?= (int)$board['id'] ?>" class="btn btn-sm btn-outline-success">Google Sheets</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="/admin/boards/edit?id=<?= (int)$board['id'] ?>" class="btn btn-sm btn-outline-primary">Изменить</a>
|
||||||
|
<form method="post" action="/admin/boards/delete" onsubmit="return confirm('Удалить доску и все связанные задачи, поля, комментарии, файлы и настройки?');" class="d-inline">
|
||||||
|
<input type="hidden" name="id" value="<?= (int)$board['id'] ?>">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-danger">
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">
|
||||||
|
<?= htmlspecialchars((string)$board['name']) ?>
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div><strong>ID:</strong> <?= (int)$board['id'] ?></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div><strong>Код:</strong> <?= htmlspecialchars((string)$board['code']) ?></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div>
|
||||||
|
<strong>Активна:</strong>
|
||||||
|
<?= (int)$board['is_active'] ? 'Да' : 'Нет' ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div><strong>Создана:</strong> <?= htmlspecialchars((string)$board['created_at']) ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label fw-bold">Описание доски</label>
|
||||||
|
<div class="border rounded p-3 bg-light">
|
||||||
|
<?= !empty($board['description'])
|
||||||
|
? nl2br(htmlspecialchars((string)$board['description']))
|
||||||
|
: '<span class="text-muted">Описание не заполнено</span>' ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<h6 class="mb-3">Статистика</h6>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-6 col-md-4">
|
||||||
|
<div class="border rounded p-3 bg-light">
|
||||||
|
<div class="small text-muted">Всего задач</div>
|
||||||
|
<div class="fs-5 fw-semibold"><?= (int)$board['task_count'] ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-6 col-md-4">
|
||||||
|
<div class="border rounded p-3 bg-light">
|
||||||
|
<div class="small text-muted">Новые</div>
|
||||||
|
<div class="fs-5 fw-semibold"><?= (int)$board['new_count'] ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-6 col-md-4">
|
||||||
|
<div class="border rounded p-3 bg-light">
|
||||||
|
<div class="small text-muted">В работе</div>
|
||||||
|
<div class="fs-5 fw-semibold"><?= (int)$board['in_progress_count'] ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-6 col-md-4">
|
||||||
|
<div class="border rounded p-3 bg-light">
|
||||||
|
<div class="small text-muted">На проверке</div>
|
||||||
|
<div class="fs-5 fw-semibold"><?= (int)$board['review_count'] ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-6 col-md-4">
|
||||||
|
<div class="border rounded p-3 bg-light">
|
||||||
|
<div class="small text-muted">Закрыты</div>
|
||||||
|
<div class="fs-5 fw-semibold"><?= (int)$board['done_count'] ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-6 col-md-4">
|
||||||
|
<div class="border rounded p-3 bg-light">
|
||||||
|
<div class="small text-muted">Отменены</div>
|
||||||
|
<div class="fs-5 fw-semibold"><?= (int)$board['canceled_count'] ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-6 col-md-4">
|
||||||
|
<div class="border rounded p-3 bg-light">
|
||||||
|
<div class="small text-muted">Просрочены</div>
|
||||||
|
<div class="fs-5 fw-semibold"><?= (int)$board['overdue_count'] ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
<h1 class="h3 mb-3">Интеграция Google Sheets</h1>
|
||||||
|
|
||||||
|
<?php if (!empty($_SESSION['success'])): ?>
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<?= htmlspecialchars($_SESSION['success']) ?>
|
||||||
|
</div>
|
||||||
|
<?php unset($_SESSION['success']); ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (!empty($_SESSION['error'])): ?>
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
<?= htmlspecialchars($_SESSION['error']) ?>
|
||||||
|
</div>
|
||||||
|
<?php unset($_SESSION['error']); ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<div><strong>Доска:</strong> <?= htmlspecialchars((string)$board['name']) ?></div>
|
||||||
|
<div><strong>Код:</strong> <?= htmlspecialchars((string)$board['code']) ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="/admin/boards/source/save">
|
||||||
|
<input type="hidden" name="board_id" value="<?= (int)$board['id'] ?>">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Ссылка на Google Sheets</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="spreadsheet_url"
|
||||||
|
class="form-control"
|
||||||
|
value="<?= htmlspecialchars((string)($source['spreadsheet_url'] ?? '')) ?>"
|
||||||
|
placeholder="https://docs.google.com/spreadsheets/d/..."
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Имя вкладки</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="sheet_name"
|
||||||
|
class="form-control"
|
||||||
|
value="<?= htmlspecialchars((string)($source['sheet_name'] ?? '')) ?>"
|
||||||
|
placeholder="Лист1"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Режим синхронизации</label>
|
||||||
|
<select name="sync_mode" class="form-select">
|
||||||
|
<?php $mode = (string)($source['sync_mode'] ?? 'import_export'); ?>
|
||||||
|
<option value="import" <?= $mode === 'import' ? 'selected' : '' ?>>Только импорт</option>
|
||||||
|
<option value="export" <?= $mode === 'export' ? 'selected' : '' ?>>Только экспорт</option>
|
||||||
|
<option value="import_export" <?= $mode === 'import_export' ? 'selected' : '' ?>>Импорт + экспорт</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check mb-4">
|
||||||
|
<input class="form-check-input" type="checkbox" name="is_active" id="is_active" <?= (int)($source['is_active'] ?? 1) ? 'checked' : '' ?>>
|
||||||
|
<label class="form-check-label" for="is_active">Интеграция активна</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-4">
|
||||||
|
|
||||||
|
<h5 class="mb-3">Сопоставление базовых полей</h5>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<?php foreach ($baseFields as $field): ?>
|
||||||
|
<?php $mapKey = 'base:' . $field['key']; ?>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label"><?= htmlspecialchars((string)$field['name']) ?></label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="mapping_base[<?= htmlspecialchars((string)$field['key']) ?>]"
|
||||||
|
class="form-control"
|
||||||
|
value="<?= htmlspecialchars((string)($mappings[$mapKey] ?? '')) ?>"
|
||||||
|
placeholder="Название колонки в Google Sheets"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h5 class="mb-3">Сопоставление кастомных полей</h5>
|
||||||
|
|
||||||
|
<?php if (empty($customFields)): ?>
|
||||||
|
<div class="alert alert-light border">
|
||||||
|
У доски пока нет кастомных полей.
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<?php foreach ($customFields as $field): ?>
|
||||||
|
<?php $mapKey = 'custom:' . $field['code']; ?>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">
|
||||||
|
<?= htmlspecialchars((string)$field['name']) ?>
|
||||||
|
<span class="text-muted small">(<?= htmlspecialchars((string)$field['code']) ?>)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="mapping_custom[<?= htmlspecialchars((string)$field['code']) ?>]"
|
||||||
|
class="form-control"
|
||||||
|
value="<?= htmlspecialchars((string)($mappings[$mapKey] ?? '')) ?>"
|
||||||
|
placeholder="Название колонки в Google Sheets"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-success">Сохранить</button>
|
||||||
|
<a href="/admin/boards" class="btn btn-secondary">Назад</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<h1 class="h3 mb-3">Создание статуса</h1>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="/admin/boards/statuses/store">
|
||||||
|
<input type="hidden" name="board_id" value="<?= (int)$board['id'] ?>">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Доска</label>
|
||||||
|
<input type="text" class="form-control" value="<?= htmlspecialchars((string)$board['name']) ?>" disabled>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Код</label>
|
||||||
|
<input type="text" name="code" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Название</label>
|
||||||
|
<input type="text" name="name" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Цвет</label>
|
||||||
|
<select name="color" class="form-select">
|
||||||
|
<option value="secondary">secondary</option>
|
||||||
|
<option value="primary">primary</option>
|
||||||
|
<option value="warning">warning</option>
|
||||||
|
<option value="success">success</option>
|
||||||
|
<option value="danger">danger</option>
|
||||||
|
<option value="dark">dark</option>
|
||||||
|
<option value="info">info</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Порядок</label>
|
||||||
|
<input type="number" name="sort_order" class="form-control" value="100">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check mb-2">
|
||||||
|
<input class="form-check-input" type="checkbox" name="is_done" id="is_done">
|
||||||
|
<label class="form-check-label" for="is_done">Финальный статус</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" name="is_active" id="is_active" checked>
|
||||||
|
<label class="form-check-label" for="is_active">Активный</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-success">Создать</button>
|
||||||
|
<a href="/admin/boards/statuses?id=<?= (int)$board['id'] ?>" class="btn btn-secondary">Назад</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<h1 class="h3 mb-3">Изменение статуса</h1>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="/admin/boards/statuses/update">
|
||||||
|
<input type="hidden" name="id" value="<?= (int)$status['id'] ?>">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Доска</label>
|
||||||
|
<input type="text" class="form-control" value="<?= htmlspecialchars((string)$status['board_name']) ?>" disabled>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Код</label>
|
||||||
|
<input type="text" name="code" class="form-control" value="<?= htmlspecialchars((string)$status['code']) ?>" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Название</label>
|
||||||
|
<input type="text" name="name" class="form-control" value="<?= htmlspecialchars((string)$status['name']) ?>" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Цвет</label>
|
||||||
|
<select name="color" class="form-select">
|
||||||
|
<?php foreach (['secondary','primary','warning','success','danger','dark','info'] as $color): ?>
|
||||||
|
<option value="<?= $color ?>" <?= $status['color'] === $color ? 'selected' : '' ?>>
|
||||||
|
<?= $color ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Порядок</label>
|
||||||
|
<input type="number" name="sort_order" class="form-control" value="<?= (int)$status['sort_order'] ?>">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check mb-2">
|
||||||
|
<input class="form-check-input" type="checkbox" name="is_done" id="is_done" <?= (int)$status['is_done'] ? 'checked' : '' ?>>
|
||||||
|
<label class="form-check-label" for="is_done">Финальный статус</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" name="is_active" id="is_active" <?= (int)$status['is_active'] ? 'checked' : '' ?>>
|
||||||
|
<label class="form-check-label" for="is_active">Активный</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-success">Сохранить</button>
|
||||||
|
<a href="/admin/boards/statuses?id=<?= (int)$status['board_id'] ?>" class="btn btn-secondary">Назад</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0">Статусы доски</h1>
|
||||||
|
<div class="text-muted small">
|
||||||
|
<?= htmlspecialchars((string)$board['name']) ?> (<?= htmlspecialchars((string)$board['code']) ?>)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="/admin/boards/statuses/create?board_id=<?= (int)$board['id'] ?>" class="btn btn-primary">
|
||||||
|
+ Создать статус
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Код</th>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Цвет</th>
|
||||||
|
<th>Порядок</th>
|
||||||
|
<th>Финальный</th>
|
||||||
|
<th>Активен</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($statuses as $status): ?>
|
||||||
|
<tr>
|
||||||
|
<td><?= (int)$status['id'] ?></td>
|
||||||
|
<td><?= htmlspecialchars((string)$status['code']) ?></td>
|
||||||
|
<td><?= htmlspecialchars((string)$status['name']) ?></td>
|
||||||
|
<td><?= htmlspecialchars((string)$status['color']) ?></td>
|
||||||
|
<td><?= (int)$status['sort_order'] ?></td>
|
||||||
|
<td><?= (int)$status['is_done'] ? 'Да' : 'Нет' ?></td>
|
||||||
|
<td><?= (int)$status['is_active'] ? 'Да' : 'Нет' ?></td>
|
||||||
|
<td>
|
||||||
|
<a href="/admin/boards/statuses/edit?id=<?= (int)$status['id'] ?>" class="btn btn-sm btn-outline-primary">
|
||||||
|
Изменить
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php if (empty($statuses)): ?>
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" class="text-center text-muted py-4">Статусов пока нет</td>
|
||||||
|
</tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<h1 class="h3 mb-3">Админка</h1>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
<a href="/admin/boards" class="btn btn-primary">Доски</a>
|
||||||
|
<a href="/admin/ad" class="btn btn-outline-primary">Синхронизация AD</a>
|
||||||
|
|
||||||
|
<!-- <a href="/admin/permission" class="btn btn-primary">Права доступа AD к доскам</a>-->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-12 col-md-5">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<h1 class="h4 mb-4">Вход в CRM</h1>
|
||||||
|
|
||||||
|
<?php if (!empty($_SESSION['error'])): ?>
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
<?= htmlspecialchars($_SESSION['error']) ?>
|
||||||
|
</div>
|
||||||
|
<?php unset($_SESSION['error']); ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form method="post" action="/login">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Логин</label>
|
||||||
|
<input type="text" name="login" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Пароль</label>
|
||||||
|
<input type="password" name="password" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary w-100">Войти</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<h1 class="h3 mb-3">Главная</h1>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="mb-2">Вы вошли как: <strong><?= htmlspecialchars($user['display_name'] ?? $user['login']) ?></strong></p>
|
||||||
|
<a href="/tasks" class="btn btn-primary">Перейти к задачам</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,691 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>CRM</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.6/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css">
|
||||||
|
<link rel="stylesheet" href="/assets/css/custom.css?v=<?= time() ?>">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<?php
|
||||||
|
use App\Core\Auth;
|
||||||
|
use App\Core\DB;
|
||||||
|
|
||||||
|
$authUser = Auth::user();
|
||||||
|
$pdo = DB::connection();
|
||||||
|
$stmt = $pdo->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);
|
||||||
|
?>
|
||||||
|
<nav class="navbar navbar-expand-lg bg-dark navbar-dark">
|
||||||
|
<div class="container">
|
||||||
|
<a class="navbar-brand" href="/">CRM</a>
|
||||||
|
<div class="dropdown ms-3">
|
||||||
|
<button
|
||||||
|
class="btn btn-light rounded-circle position-relative"
|
||||||
|
id="notificationBell"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
style="width: 42px; height: 42px;"
|
||||||
|
>
|
||||||
|
<i class="bi bi-bell-fill" id="notificationBellIcon"></i>
|
||||||
|
<span
|
||||||
|
id="notificationBadge"
|
||||||
|
class="start-100 translate-middle badge rounded-pill bg-primary d-none"
|
||||||
|
style="font-size: 0.65rem;"
|
||||||
|
>
|
||||||
|
0
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="dropdown-menu dropdown-menu-end p-0" style="width: 360px;">
|
||||||
|
<div class="p-3 border-bottom d-flex justify-content-between align-items-center">
|
||||||
|
<strong>Уведомления</strong>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" id="markAllNotificationsRead">
|
||||||
|
Прочитать все
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="notificationList" style="max-height: 420px; overflow-y: auto;">
|
||||||
|
<div class="p-3 text-muted">Загрузка...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
<button class="btn btn-outline-light btn-sm"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="offcanvas"
|
||||||
|
data-bs-target="#boardsMenuCanvas"
|
||||||
|
aria-controls="boardsMenuCanvas">
|
||||||
|
Доски
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<?php if ((int)($authUser['is_admin'] ?? 0) === 1): ?>
|
||||||
|
<a href="/admin" class="btn btn-sm btn-warning">Админка</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form method="post" action="/logout" class="d-inline">
|
||||||
|
<button class="btn btn-sm btn-danger" type="submit">Выход</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<span id="headerClock" style="color: white" data-server-time="<?= date('Y-m-d H:i:s') ?>">
|
||||||
|
<?= date('d.m.Y H:i:s') ?>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="container-fluid py-4">
|
||||||
|
<?= $content ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="taskModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
||||||
|
<div class="modal-content" id="taskModalContent">
|
||||||
|
<div class="modal-body p-4 text-center">
|
||||||
|
Загрузка...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="boardModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||||
|
<div class="modal-content" id="boardModalContent">
|
||||||
|
<div class="modal-body p-4 text-center">
|
||||||
|
Загрузка...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="imagePreviewModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-xl modal-dialog-centered">
|
||||||
|
<div class="modal-content bg-dark">
|
||||||
|
<div class="modal-header border-0">
|
||||||
|
<h5 class="modal-title text-white" id="imagePreviewTitle">Просмотр изображения</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body text-center">
|
||||||
|
<img id="imagePreviewModalImg" src="" alt="" class="img-fluid rounded">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="offcanvas offcanvas-start" tabindex="-1" id="boardsMenuCanvas" aria-labelledby="boardsMenuCanvasLabel">
|
||||||
|
<div class="offcanvas-header">
|
||||||
|
<h5 class="offcanvas-title" id="boardsMenuCanvasLabel">Доски</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Закрыть"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="offcanvas-body">
|
||||||
|
<input type="text" class="form-control mb-3" placeholder="Поиск..." id="boardsSearch">
|
||||||
|
|
||||||
|
<div class="list-group" id="boardsList">
|
||||||
|
<?php foreach ($boards as $b): ?>
|
||||||
|
<?php $boardUrl = '/boards/' . $b['code']; ?>
|
||||||
|
|
||||||
|
<a href="<?= htmlspecialchars($boardUrl) ?>"
|
||||||
|
class="list-group-item list-group-item-action <?= $currentPath === $boardUrl ? 'active' : '' ?>">
|
||||||
|
<?= htmlspecialchars((string)$b['name']) ?>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.6/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('click', async function (e) {
|
||||||
|
const btn = e.target.closest('[data-task-modal]');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const taskId = btn.getAttribute('data-task-id');
|
||||||
|
const modalElement = document.getElementById('taskModal');
|
||||||
|
const modalContent = document.getElementById('taskModalContent');
|
||||||
|
|
||||||
|
modalContent.innerHTML = '<div class="modal-body p-4 text-center">Загрузка...</div>';
|
||||||
|
|
||||||
|
const modal = new bootstrap.Modal(modalElement);
|
||||||
|
modal.show();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/tasks/show?id=' + encodeURIComponent(taskId), {
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const html = await response.text();
|
||||||
|
modalContent.innerHTML = html;
|
||||||
|
const badge = document.getElementById('comment-badge-' + taskId);
|
||||||
|
if (badge) {
|
||||||
|
const lastCommentId = parseInt(badge.dataset.lastCommentId || '0', 10);
|
||||||
|
badge.dataset.lastReadCommentId = String(lastCommentId);
|
||||||
|
badge.classList.remove('bg-primary');
|
||||||
|
badge.classList.add('bg-secondary');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
modalContent.innerHTML = '<div class="modal-body p-4 text-danger">Ошибка загрузки карточки задачи</div>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
let wsProtocol = location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||||
|
let wsUrl = wsProtocol + location.host + '/ws/';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const socket = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
socket.onmessage = function (event) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.type === 'notification_created') {
|
||||||
|
|
||||||
|
if (typeof window.CRM_USER_ID !== 'undefined'
|
||||||
|
&& parseInt(data.user_id, 10) !== parseInt(window.CRM_USER_ID, 10)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window.refreshNotifications === 'function') {
|
||||||
|
window.refreshNotifications();
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(data.title + ': ' + data.message, 'primary');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === 'comment_added') {
|
||||||
|
updateCommentBadge(data);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
data.type === 'task_created' ||
|
||||||
|
data.type === 'task_updated' ||
|
||||||
|
data.type === 'import_finished'
|
||||||
|
) {
|
||||||
|
const path = window.location.pathname;
|
||||||
|
|
||||||
|
if (
|
||||||
|
path === '/tasks' ||
|
||||||
|
path.startsWith('/boards/')
|
||||||
|
) {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('WS parse error', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function updateCommentBadge(data) {
|
||||||
|
const badge = document.getElementById('comment-badge-' + data.task_id);
|
||||||
|
if (!badge) return;
|
||||||
|
|
||||||
|
const innerCount = badge.querySelector('.comment-count');
|
||||||
|
|
||||||
|
if (innerCount) {
|
||||||
|
innerCount.textContent = data.comment_count;
|
||||||
|
} else {
|
||||||
|
badge.textContent = data.comment_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastRead = parseInt(badge.dataset.lastReadCommentId || '0', 10);
|
||||||
|
const lastComment = parseInt(data.last_comment_id || '0', 10);
|
||||||
|
|
||||||
|
badge.dataset.lastCommentId = String(lastComment);
|
||||||
|
|
||||||
|
badge.classList.remove('bg-secondary', 'bg-primary');
|
||||||
|
|
||||||
|
if (lastComment > lastRead) {
|
||||||
|
badge.classList.add('bg-primary');
|
||||||
|
} else {
|
||||||
|
badge.classList.add('bg-secondary');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.crmSocket = socket;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('WS connection error', e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('click', async function (e) {
|
||||||
|
const btn = e.target.closest('[data-board-modal]');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const boardId = btn.getAttribute('data-board-id');
|
||||||
|
const modalElement = document.getElementById('boardModal');
|
||||||
|
const modalContent = document.getElementById('boardModalContent');
|
||||||
|
|
||||||
|
modalContent.innerHTML = '<div class="modal-body p-4 text-center">Загрузка...</div>';
|
||||||
|
|
||||||
|
const modal = new bootstrap.Modal(modalElement);
|
||||||
|
modal.show();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/admin/boards/show?id=' + encodeURIComponent(boardId), {
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const html = await response.text();
|
||||||
|
modalContent.innerHTML = html;
|
||||||
|
} catch (error) {
|
||||||
|
modalContent.innerHTML = '<div class="modal-body p-4 text-danger">Ошибка загрузки доски</div>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
const btn = e.target.closest('[data-image-modal]');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const src = btn.getAttribute('data-image-src');
|
||||||
|
const title = btn.getAttribute('data-image-title') || 'Просмотр изображения';
|
||||||
|
|
||||||
|
const modalElement = document.getElementById('imagePreviewModal');
|
||||||
|
const modalTitle = document.getElementById('imagePreviewTitle');
|
||||||
|
const modalImg = document.getElementById('imagePreviewModalImg');
|
||||||
|
|
||||||
|
modalTitle.textContent = title;
|
||||||
|
modalImg.src = src;
|
||||||
|
modalImg.alt = title;
|
||||||
|
|
||||||
|
const modal = new bootstrap.Modal(modalElement);
|
||||||
|
modal.show();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
window.reloadTaskModal = async function (taskId) {
|
||||||
|
const modalContent = document.getElementById('taskModalContent');
|
||||||
|
|
||||||
|
if (!taskId || !modalContent) return;
|
||||||
|
|
||||||
|
modalContent.innerHTML = '<div class="modal-body p-4 text-center">Загрузка...</div>';
|
||||||
|
|
||||||
|
const response = await fetch('/tasks/show?id=' + encodeURIComponent(taskId), {
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const html = await response.text();
|
||||||
|
modalContent.innerHTML = html;
|
||||||
|
|
||||||
|
const badge = document.getElementById('comment-badge-' + taskId);
|
||||||
|
if (badge) {
|
||||||
|
const lastCommentId = parseInt(badge.dataset.lastCommentId || '0', 10);
|
||||||
|
badge.dataset.lastReadCommentId = String(lastCommentId);
|
||||||
|
badge.classList.remove('bg-primary');
|
||||||
|
badge.classList.add('bg-secondary');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('submit', async function (e) {
|
||||||
|
const statusForm = e.target.closest('.js-task-status-form');
|
||||||
|
const commentForm = e.target.closest('.js-task-comment-form');
|
||||||
|
const uploadForm = e.target.closest('.js-task-file-upload-form');
|
||||||
|
const deleteForm = e.target.closest('.js-task-file-delete-form');
|
||||||
|
|
||||||
|
const form = statusForm || commentForm || uploadForm || deleteForm;
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const taskId = form.getAttribute('data-task-id');
|
||||||
|
const formData = new FormData(form);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(form.action, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
alert(result.message || 'Ошибка выполнения действия');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await window.reloadTaskModal(taskId);
|
||||||
|
} catch (error) {
|
||||||
|
alert('Ошибка выполнения запроса');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<div class="toast-container position-fixed top-0 end-0 p-3" id="crmToastContainer" style="z-index: 2000;"></div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
let reloadTimer = null;
|
||||||
|
|
||||||
|
function showToast(message, type = 'primary') {
|
||||||
|
const container = document.getElementById('crmToastContainer');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const toastEl = document.createElement('div');
|
||||||
|
toastEl.className = 'toast align-items-center text-bg-' + type + ' border-0';
|
||||||
|
toastEl.setAttribute('role', 'alert');
|
||||||
|
toastEl.setAttribute('aria-live', 'assertive');
|
||||||
|
toastEl.setAttribute('aria-atomic', 'true');
|
||||||
|
|
||||||
|
toastEl.innerHTML = `
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="toast-body">${message}</div>
|
||||||
|
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(toastEl);
|
||||||
|
|
||||||
|
const toast = new bootstrap.Toast(toastEl, { delay: 5000 });
|
||||||
|
toast.show();
|
||||||
|
|
||||||
|
toastEl.addEventListener('hidden.bs.toast', function () {
|
||||||
|
toastEl.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentBoardCode() {
|
||||||
|
const match = window.location.pathname.match(/^\/boards\/([^\/]+)$/);
|
||||||
|
return match ? decodeURIComponent(match[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleBoardRefresh() {
|
||||||
|
if (reloadTimer) {
|
||||||
|
clearTimeout(reloadTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
reloadTimer = setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTaskCreated(data) {
|
||||||
|
showToast('Новая задача: ' + (data.name || 'без названия'), 'success');
|
||||||
|
|
||||||
|
if (typeof window.refreshNotifications === 'function') {
|
||||||
|
window.refreshNotifications();
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleBoardRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTaskUpdated(data) {
|
||||||
|
showToast('Обновлена задача: ' + (data.name || 'без названия'), 'primary');
|
||||||
|
|
||||||
|
if (typeof window.refreshNotifications === 'function') {
|
||||||
|
window.refreshNotifications();
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleBoardRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleImportFinished(data) {
|
||||||
|
if ((parseInt(data.created || 0, 10) > 0) || (parseInt(data.updated || 0, 10) > 0)) {
|
||||||
|
showToast(
|
||||||
|
'Импорт завершен. Новых: ' + (data.created || 0) + ', обновлено: ' + (data.updated || 0),
|
||||||
|
'dark'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window.refreshNotifications === 'function') {
|
||||||
|
window.refreshNotifications();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let wsProtocol = location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||||
|
let wsUrl = wsProtocol + location.host + '/ws/';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const socket = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
socket.onmessage = function (event) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (data.type === 'comment_added') {
|
||||||
|
if (typeof updateCommentBadge === 'function') {
|
||||||
|
updateCommentBadge(data);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === 'task_created') {
|
||||||
|
handleTaskCreated(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === 'task_updated') {
|
||||||
|
handleTaskUpdated(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === 'import_finished') {
|
||||||
|
handleImportFinished(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error('WS parse error', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.crmSocket = socket;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('WS connection error', e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
(async function () {
|
||||||
|
const bellIcon = document.getElementById('notificationBellIcon');
|
||||||
|
const badge = document.getElementById('notificationBadge');
|
||||||
|
const list = document.getElementById('notificationList');
|
||||||
|
const markAllBtn = document.getElementById('markAllNotificationsRead');
|
||||||
|
|
||||||
|
if (!bellIcon || !badge || !list || !markAllBtn) return;
|
||||||
|
|
||||||
|
function updateBell(unreadCount) {
|
||||||
|
bellIcon.classList.remove('text-secondary', 'text-primary');
|
||||||
|
bellIcon.classList.add(unreadCount > 0 ? 'text-primary' : 'text-secondary');
|
||||||
|
|
||||||
|
if (unreadCount > 0) {
|
||||||
|
badge.textContent = String(unreadCount);
|
||||||
|
badge.classList.remove('d-none');
|
||||||
|
} else {
|
||||||
|
badge.classList.add('d-none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNotifications(items) {
|
||||||
|
if (!items.length) {
|
||||||
|
list.innerHTML = '<div class="p-3 text-muted">Уведомлений пока нет</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = items.map(item => {
|
||||||
|
const payload = item.payload_json ? JSON.parse(item.payload_json) : {};
|
||||||
|
const boardCode = payload.board_code || '';
|
||||||
|
const taskId = payload.task_id || 0;
|
||||||
|
|
||||||
|
let link = '#';
|
||||||
|
if (taskId) {
|
||||||
|
link = '/tasks/show?id=' + taskId;
|
||||||
|
} else if (boardCode) {
|
||||||
|
link = '/boards/' + encodeURIComponent(boardCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<a href="${link}" class="dropdown-item border-bottom py-3 ${item.is_read == 0 ? 'bg-light' : ''}" ${taskId ? `data-task-modal data-task-id="${taskId}"` : ''}>
|
||||||
|
<div class="fw-semibold">${escapeHtml(item.title || '')}</div>
|
||||||
|
<div class="small text-muted">${escapeHtml(item.message || '')}</div>
|
||||||
|
<div class="small text-muted mt-1">${escapeHtml(item.created_at || '')}</div>
|
||||||
|
</a>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
return String(str)
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNotifications() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/notifications/list', {
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
renderNotifications(data.items || []);
|
||||||
|
updateBell(parseInt(data.unread_count || 0, 10));
|
||||||
|
} catch (e) {
|
||||||
|
list.innerHTML = '<div class="p-3 text-danger">Ошибка загрузки уведомлений</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
markAllBtn.addEventListener('click', async function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/notifications/read-all', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
await loadNotifications();
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
await loadNotifications();
|
||||||
|
|
||||||
|
window.refreshNotifications = loadNotifications;
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const clock = document.getElementById('headerClock');
|
||||||
|
if (!clock) return;
|
||||||
|
|
||||||
|
let current = new Date(clock.dataset.serverTime.replace(' ', 'T'));
|
||||||
|
|
||||||
|
function pad(n) {
|
||||||
|
return String(n).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
current.setSeconds(current.getSeconds() + 1);
|
||||||
|
|
||||||
|
const y = current.getFullYear();
|
||||||
|
const m = pad(current.getMonth() + 1);
|
||||||
|
const d = pad(current.getDate());
|
||||||
|
const h = pad(current.getHours());
|
||||||
|
const i = pad(current.getMinutes());
|
||||||
|
const s = pad(current.getSeconds());
|
||||||
|
|
||||||
|
clock.textContent = `${d}.${m}.${y} ${h}:${i}:${s}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(render, 1000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
const editBtn = e.target.closest('#taskEditBtn');
|
||||||
|
const cancelBtn = e.target.closest('#taskCancelEditBtn');
|
||||||
|
|
||||||
|
if (editBtn) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const modal = editBtn.closest('.modal-content') || document;
|
||||||
|
|
||||||
|
modal.querySelectorAll('.task-view-value').forEach(el => {
|
||||||
|
el.classList.add('d-none');
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.querySelectorAll('.task-edit-field').forEach(el => {
|
||||||
|
el.classList.remove('d-none');
|
||||||
|
});
|
||||||
|
|
||||||
|
const actions = modal.querySelector('#taskEditActions');
|
||||||
|
if (actions) {
|
||||||
|
actions.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
|
editBtn.classList.add('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelBtn) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const modal = cancelBtn.closest('.modal-content') || document;
|
||||||
|
|
||||||
|
modal.querySelectorAll('.task-view-value').forEach(el => {
|
||||||
|
el.classList.remove('d-none');
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.querySelectorAll('.task-edit-field').forEach(el => {
|
||||||
|
el.classList.add('d-none');
|
||||||
|
});
|
||||||
|
|
||||||
|
const actions = modal.querySelector('#taskEditActions');
|
||||||
|
if (actions) {
|
||||||
|
actions.classList.add('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
|
const editBtn = modal.querySelector('#taskEditBtn');
|
||||||
|
if (editBtn) {
|
||||||
|
editBtn.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
window.CRM_USER_ID = <?= \App\Core\Auth::check() ? (int)\App\Core\Auth::user()['id'] : 0 ?>;
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('input', function (e) {
|
||||||
|
if (e.target.id !== 'boardsSearch') return;
|
||||||
|
|
||||||
|
const search = e.target.value.toLowerCase();
|
||||||
|
|
||||||
|
document.querySelectorAll('#boardsList a').forEach(item => {
|
||||||
|
item.style.display = item.textContent.toLowerCase().includes(search) ? '' : 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>CRM</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.6/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body class="bg-light">
|
||||||
|
<div class="container py-5">
|
||||||
|
<?= $content ?>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,717 @@
|
|||||||
|
<?php
|
||||||
|
$baseUrl = '/boards/' . urlencode((string)$board['code']);
|
||||||
|
|
||||||
|
$queryBase = [
|
||||||
|
'status' => $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'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<!-- <div class="alert alert-info mb-3">-->
|
||||||
|
<!-- view = --><?php //= htmlspecialchars((string)$view) ?>
|
||||||
|
<!-- </div>-->
|
||||||
|
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2 mb-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0"><?= htmlspecialchars((string)$board['name']) ?></h1>
|
||||||
|
<div class="d-flex flex-wrap gap-2 mt-2">
|
||||||
|
<span class="badge text-bg-secondary">
|
||||||
|
Всего: <?= (int)$stats['TOTAL'] ?>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="badge text-bg-secondary">
|
||||||
|
Новые: <?= (int)$stats['NEW'] ?>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="badge text-bg-primary">
|
||||||
|
В работе: <?= (int)$stats['IN_PROGRESS'] ?>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="badge text-bg-warning text-dark">
|
||||||
|
На проверке: <?= (int)$stats['REVIEW'] ?>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="badge text-bg-success">
|
||||||
|
Завершено: <?= (int)$stats['DONE'] ?>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="badge text-bg-danger">
|
||||||
|
Просрочено: <?= (int)$stats['OVERDUE'] ?>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted small">Код: <?= htmlspecialchars((string)$board['code']) ?></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
|
<a href="<?= htmlspecialchars($tableUrl) ?>" class="btn <?= $view === 'table' ? 'btn-primary' : 'btn-outline-primary' ?>">Таблица</a>
|
||||||
|
<a href="<?= htmlspecialchars($kanbanUrl) ?>" class="btn <?= $view === 'kanban' ? 'btn-primary' : 'btn-outline-primary' ?>">Канбан</a>
|
||||||
|
<a href="<?= htmlspecialchars($cardsUrl) ?>" class="btn <?= $view === 'cards' ? 'btn-primary' : 'btn-outline-primary' ?>">Плитки</a>
|
||||||
|
<a href="/tasks/create?board_code=<?= urlencode((string)$board['code']) ?>" class="btn btn-success">+ Новая задача</a>
|
||||||
|
</div>
|
||||||
|
<?php if (!empty($canManageBoard)): ?>
|
||||||
|
<button class="btn btn-primary"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="offcanvas"
|
||||||
|
data-bs-target="#boardSettingsCanvas"
|
||||||
|
aria-controls="boardSettingsCanvas">
|
||||||
|
Редактировать доску
|
||||||
|
</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="get" action="<?= htmlspecialchars($baseUrl) ?>" class="row g-2 align-items-end">
|
||||||
|
<input type="hidden" name="view" value="<?= htmlspecialchars((string)$view) ?>">
|
||||||
|
|
||||||
|
<div class="col-12 col-md-3">
|
||||||
|
<label class="form-label">Статус</label>
|
||||||
|
<select name="status" class="form-select">
|
||||||
|
<option value="">Все</option>
|
||||||
|
<option value="NEW" <?= ($filters['status'] ?? '') === 'NEW' ? 'selected' : '' ?>>Новая</option>
|
||||||
|
<option value="IN_PROGRESS" <?= ($filters['status'] ?? '') === 'IN_PROGRESS' ? 'selected' : '' ?>>В работе</option>
|
||||||
|
<option value="REVIEW" <?= ($filters['status'] ?? '') === 'REVIEW' ? 'selected' : '' ?>>На проверке</option>
|
||||||
|
<option value="DONE" <?= ($filters['status'] ?? '') === 'DONE' ? 'selected' : '' ?>>Закрыта</option>
|
||||||
|
<option value="CANCELED" <?= ($filters['status'] ?? '') === 'CANCELED' ? 'selected' : '' ?>>Отменена</option>
|
||||||
|
<option value="OVERDUE" <?= ($filters['status'] ?? '') === 'OVERDUE' ? 'selected' : '' ?>>Просрочена</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-2">
|
||||||
|
<label class="form-label">Приоритет</label>
|
||||||
|
<select name="priority" class="form-select">
|
||||||
|
<option value="">Все</option>
|
||||||
|
<option value="LOW" <?= ($filters['priority'] ?? '') === 'LOW' ? 'selected' : '' ?>>Низкий</option>
|
||||||
|
<option value="MEDIUM" <?= ($filters['priority'] ?? '') === 'MEDIUM' ? 'selected' : '' ?>>Средний</option>
|
||||||
|
<option value="HIGH" <?= ($filters['priority'] ?? '') === 'HIGH' ? 'selected' : '' ?>>Высокий</option>
|
||||||
|
<option value="CRITICAL" <?= ($filters['priority'] ?? '') === 'CRITICAL' ? 'selected' : '' ?>>Критический</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-3">
|
||||||
|
<label class="form-label">Ответственный</label>
|
||||||
|
<select name="assignee_id" class="form-select">
|
||||||
|
<option value="">Все</option>
|
||||||
|
<?php foreach ($users as $u): ?>
|
||||||
|
<option value="<?= (int)$u['id'] ?>" <?= (int)($filters['assignee_id'] ?? 0) === (int)$u['id'] ? 'selected' : '' ?>>
|
||||||
|
<?= htmlspecialchars((string)($u['display_name'] ?: $u['login'])) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-3">
|
||||||
|
<label class="form-label">Поиск</label>
|
||||||
|
<input type="text" name="search" value="<?= htmlspecialchars((string)($filters['search'] ?? '')) ?>" class="form-control" placeholder="Наименование, заказ, заявитель...">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-auto">
|
||||||
|
<button type="submit" class="btn btn-primary">Применить</button>
|
||||||
|
<a href="<?= htmlspecialchars($baseUrl . '?view=' . urlencode((string)$view)) ?>" class="btn btn-outline-secondary">Сбросить</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($view === 'table'): ?>
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>CRM ID</th>
|
||||||
|
<th>Постановщик</th>
|
||||||
|
<th>Ответственный</th>
|
||||||
|
<th>Наименование</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Приоритет</th>
|
||||||
|
<th>Комментарии</th>
|
||||||
|
<th>Дата план</th>
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($tasks as $task): ?>
|
||||||
|
<?php
|
||||||
|
$deadline = taskDeadlineState($task['planned_at'] ?? null, $task['status'] ?? null);
|
||||||
|
|
||||||
|
$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';
|
||||||
|
|
||||||
|
$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',
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
<tr class="<?= htmlspecialchars($deadline['class'] ?? '') ?>">
|
||||||
|
<td><?= (int)$task['id'] ?></td>
|
||||||
|
<td><?= htmlspecialchars((string)$task['crm_id']) ?></td>
|
||||||
|
<td><?= htmlspecialchars((string)($task['creator_name'] ?? '')) ?></td>
|
||||||
|
<td><?= htmlspecialchars((string)($task['assignee_name'] ?? '')) ?></td>
|
||||||
|
<td>
|
||||||
|
<a href="/tasks/show?id=<?= (int)$task['id'] ?>"
|
||||||
|
data-task-modal
|
||||||
|
data-task-id="<?= (int)$task['id'] ?>"
|
||||||
|
class="text-decoration-none">
|
||||||
|
<?= htmlspecialchars((string)$task['name']) ?>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge <?= $statusClass ?>">
|
||||||
|
<?= htmlspecialchars(task_status_label((string)$task['status'])) ?>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge <?= $priorityClass ?>">
|
||||||
|
<?= htmlspecialchars(task_priority_label((string)$task['priority'])) ?>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
id="comment-badge-<?= (int)$task['id'] ?>"
|
||||||
|
class="badge <?= $badgeClass ?>"
|
||||||
|
data-task-id="<?= (int)$task['id'] ?>"
|
||||||
|
data-last-read-comment-id="<?= $lastReadCommentId ?>"
|
||||||
|
data-last-comment-id="<?= $lastCommentId ?>"
|
||||||
|
>
|
||||||
|
<?= $commentCount ?>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<?php if (!empty($task['planned_at'])): ?>
|
||||||
|
<div class="small text-muted mt-1">
|
||||||
|
<?= htmlspecialchars(date('d.m.Y H:i', strtotime((string)$task['planned_at']))) ?>
|
||||||
|
|
||||||
|
<?php if ($deadline): ?>
|
||||||
|
<span class="badge deadline-badge-<?= htmlspecialchars($deadline['code']) ?> ms-1">
|
||||||
|
<?= htmlspecialchars($deadline['text']) ?>
|
||||||
|
</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php if (empty($tasks)): ?>
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" class="text-center text-muted py-4">Нет задач</td>
|
||||||
|
</tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php elseif ($view === 'cards'): ?>
|
||||||
|
<div class="row" id="cardsContainer">
|
||||||
|
<?php foreach ($tasks as $task): ?>
|
||||||
|
<?php
|
||||||
|
$deadline = taskDeadlineState($task['planned_at'] ?? null, $task['status'] ?? null);
|
||||||
|
|
||||||
|
$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;
|
||||||
|
|
||||||
|
$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',
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
<div class="col-12 col-md-6 col-xl-3 my-3 task-card"
|
||||||
|
data-task-id="<?= (int)$task['id'] ?>"
|
||||||
|
data-status="<?= htmlspecialchars((string)$task['status']) ?>">
|
||||||
|
|
||||||
|
<div class="card h-100 shadow-sm <?= $cardBorderClass ?> <?= htmlspecialchars($deadline['class'] ?? '') ?>"
|
||||||
|
data-task-modal
|
||||||
|
data-task-id="<?= (int)$task['id'] ?>"
|
||||||
|
style="cursor: pointer; border-width: 2px;">
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="card-title fw-bold mb-2">
|
||||||
|
<?= htmlspecialchars((string)($task['crm_id'] ?: ('Заявка #' . $task['id']))) ?>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="card-text mb-3">
|
||||||
|
<?= htmlspecialchars((string)$task['name']) ?>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="fw-bold mb-1 small">
|
||||||
|
Постановщик: <?= htmlspecialchars((string)($task['creator_name'] ?? '')) ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fw-bold mb-1 small">
|
||||||
|
Ответственный:
|
||||||
|
<?= htmlspecialchars((string)($task['assignee_name'] ?? 'Не назначен')) ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!empty($task['supplier'])): ?>
|
||||||
|
<div class="small text-muted mb-1">
|
||||||
|
Поставщик: <?= htmlspecialchars((string)$task['supplier']) ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="plane mb-1 small text-muted">
|
||||||
|
<strong>Дата план:</strong>
|
||||||
|
<?= !empty($task['planned_at']) ? htmlspecialchars(date('d.m.Y H:i', strtotime((string)$task['planned_at']))) : '—' ?>
|
||||||
|
|
||||||
|
<?php if ($deadline): ?>
|
||||||
|
<span class="badge deadline-badge-<?= htmlspecialchars($deadline['code']) ?> ms-1">
|
||||||
|
<?= htmlspecialchars($deadline['text']) ?>
|
||||||
|
</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fact mb-2 small text-muted">
|
||||||
|
<strong>Дата факт:</strong> <?= htmlspecialchars((string)($task['completed_at'] ?? '')) ?>
|
||||||
|
</div>
|
||||||
|
<div class="fact mb-2 small text-muted">
|
||||||
|
<div><strong>Дата постановки:</strong> <?= htmlspecialchars((string)($task['task_created_at'] ?? '')) ?></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-3 pt-2 border-top flex-wrap gap-2">
|
||||||
|
<span class="badge <?= $priorityClass ?> rounded-pill">
|
||||||
|
Приоритет: <?= htmlspecialchars(task_priority_label((string)$task['priority'])) ?>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
id="comment-badge-<?= (int)$task['id'] ?>"
|
||||||
|
class="badge <?= $isUnread ? 'bg-primary' : 'bg-secondary' ?>"
|
||||||
|
data-task-id="<?= (int)$task['id'] ?>"
|
||||||
|
data-last-read-comment-id="<?= $lastReadCommentId ?>"
|
||||||
|
data-last-comment-id="<?= $lastCommentId ?>"
|
||||||
|
>
|
||||||
|
<span class="comment-count"><?= $commentCount ?></span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="badge <?= $statusBadgeClass ?>">
|
||||||
|
<?= htmlspecialchars(task_status_label((string)$task['status'])) ?>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
<?php if (empty($tasks)): ?>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="alert alert-light border text-muted">
|
||||||
|
Нет задач
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#cardsContainer .task-card .card {
|
||||||
|
transition: transform .15s ease, box-shadow .15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#cardsContainer .task-card .card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 .5rem 1rem rgba(0,0,0,.12) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#cardsContainer .card-title {
|
||||||
|
font-size: .95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#cardsContainer .card-text {
|
||||||
|
min-height: 48px;
|
||||||
|
font-size: .95rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="kanban-wrapper">
|
||||||
|
<div class="kanban-board">
|
||||||
|
<?php foreach ($columns as $statusCode => $column): ?>
|
||||||
|
<div class="kanban-column" data-status="<?= htmlspecialchars($statusCode) ?>">
|
||||||
|
<div class="kanban-column-header <?= htmlspecialchars($column['header_class']) ?>">
|
||||||
|
<div class="fw-semibold"><?= htmlspecialchars($column['title']) ?></div>
|
||||||
|
<span class="badge text-bg-light kanban-count"><?= count($column['tasks']) ?></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kanban-column-body dropzone" data-status="<?= htmlspecialchars($statusCode) ?>">
|
||||||
|
<?php if (empty($column['tasks'])): ?>
|
||||||
|
<div class="kanban-empty">Нет задач</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php foreach ($column['tasks'] as $task): ?>
|
||||||
|
<?php
|
||||||
|
$deadline = taskDeadlineState($task['planned_at'] ?? null, $task['status'] ?? null);
|
||||||
|
?>
|
||||||
|
<?php
|
||||||
|
$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',
|
||||||
|
};
|
||||||
|
|
||||||
|
$isDone = (($task['status'] ?? '') === 'DONE');
|
||||||
|
?>
|
||||||
|
<div class="kanban-card <?= htmlspecialchars($deadline['class'] ?? '') ?>"
|
||||||
|
draggable="true"
|
||||||
|
data-task-id="<?= (int)$task['id'] ?>"
|
||||||
|
data-current-status="<?= htmlspecialchars((string)$task['status']) ?>">
|
||||||
|
<div class="d-flex justify-content-between align-items-start gap-2 mb-2">
|
||||||
|
<a href="/tasks/show?id=<?= (int)$task['id'] ?>"
|
||||||
|
data-task-modal
|
||||||
|
data-task-id="<?= (int)$task['id'] ?>"
|
||||||
|
class="kanban-card-title text-decoration-none">
|
||||||
|
<?= htmlspecialchars((string)$task['name']) ?>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<span class="badge <?= $priorityClass ?>">
|
||||||
|
<?= htmlspecialchars(task_priority_label((string)$task['priority'])) ?>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!empty($task['description'])): ?>
|
||||||
|
<div class="kanban-card-desc mb-2">
|
||||||
|
<?= htmlspecialchars(mb_strimwidth((string)$task['description'], 0, 140, '...')) ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="small text-muted mb-2">
|
||||||
|
<div><strong>CRM:</strong> <?= htmlspecialchars((string)$task['crm_id']) ?></div>
|
||||||
|
<?php if (!empty($task['creator_name'])): ?>
|
||||||
|
<div><strong>Постановщик:</strong> <?= htmlspecialchars((string)$task['creator_name']) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (!empty($task['assignee_name'])): ?>
|
||||||
|
<div><strong>Ответственный:</strong> <?= htmlspecialchars((string)$task['assignee_name']) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (!empty($task['planned_at'])): ?>
|
||||||
|
<div>
|
||||||
|
<strong>Дата план:</strong>
|
||||||
|
<?= htmlspecialchars(date('d.m.Y H:i', strtotime((string)$task['planned_at']))) ?>
|
||||||
|
|
||||||
|
<?php if ($deadline): ?>
|
||||||
|
<span class="badge deadline-badge-<?= htmlspecialchars($deadline['code']) ?> ms-1">
|
||||||
|
<?= htmlspecialchars($deadline['text']) ?>
|
||||||
|
</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<span
|
||||||
|
id="comment-badge-<?= (int)$task['id'] ?>"
|
||||||
|
class="badge <?= $badgeClass ?>"
|
||||||
|
data-task-id="<?= (int)$task['id'] ?>"
|
||||||
|
data-last-read-comment-id="<?= $lastReadCommentId ?>"
|
||||||
|
data-last-comment-id="<?= $lastCommentId ?>"
|
||||||
|
>
|
||||||
|
Комментарии: <span class="comment-count"><?= $commentCount ?></span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="badge <?= $isDone ? 'text-bg-success' : 'text-bg-light' ?>">
|
||||||
|
<?= $isDone ? 'Выполнено' : 'Не выполнено' ?>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.kanban-wrapper { overflow-x: auto; padding-bottom: 8px; }
|
||||||
|
.kanban-board { display: flex; gap: 16px; align-items: flex-start; min-width: max-content; }
|
||||||
|
.kanban-column { width: 320px; background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 14px; display: flex; flex-direction: column; max-height: calc(100vh - 260px); }
|
||||||
|
.kanban-column-header { padding: 12px 14px; border-bottom: 1px solid rgba(255,255,255,.15); display: flex; justify-content: space-between; align-items: center; border-top-left-radius: 14px; border-top-right-radius: 14px; position: sticky; top: 0; z-index: 2; color: #fff; }
|
||||||
|
.kanban-header-new { background: #6c757d; }
|
||||||
|
.kanban-header-progress { background: #0d6efd; }
|
||||||
|
.kanban-header-review { background: #fd7e14; }
|
||||||
|
.kanban-header-done { background: #198754; }
|
||||||
|
.kanban-header-canceled { background: #495057; }
|
||||||
|
.kanban-header-overdue { background: #dc3545; }
|
||||||
|
.kanban-column-body { padding: 12px; overflow-y: auto; min-height: 120px; transition: background-color .2s ease; }
|
||||||
|
.kanban-column-body.drag-over { background: #e9f2ff; }
|
||||||
|
.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: #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;
|
||||||
|
}
|
||||||
|
.kanban-card:last-child { margin-bottom: 0; }
|
||||||
|
.kanban-card.dragging { opacity: .55; transform: rotate(1deg); }
|
||||||
|
.kanban-card-title { font-weight: 600; color: #212529; line-height: 1.3; }
|
||||||
|
.kanban-card-title:hover { color: #0d6efd; }
|
||||||
|
.kanban-card-desc { color: #6c757d; font-size: 0.92rem; line-height: 1.35; }
|
||||||
|
.kanban-empty { color: #6c757d; text-align: center; padding: 20px 10px; border: 1px dashed #ced4da; border-radius: 10px; background: #fff; }
|
||||||
|
.offcanvas{--bs-offcanvas-width: 1000px;};
|
||||||
|
@media (max-width: 768px) { .kanban-column { width: 280px; } }
|
||||||
|
</style>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (!empty($canManageBoard)): ?>
|
||||||
|
<div class="offcanvas offcanvas-end" tabindex="-1" id="boardSettingsCanvas" style="width: 720px;">
|
||||||
|
<div class="offcanvas-header">
|
||||||
|
<h5 class="offcanvas-title">Редактирование доски: <?= htmlspecialchars($board['name']) ?></h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="offcanvas"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="offcanvas-body">
|
||||||
|
|
||||||
|
<form method="post" action="/admin/boards/save-all">
|
||||||
|
|
||||||
|
<input type="hidden" name="board_id" value="<?= (int)$board['id'] ?>">
|
||||||
|
|
||||||
|
<div class="accordion" id="boardSettingsAccordion">
|
||||||
|
|
||||||
|
<!-- ОСНОВНОЕ -->
|
||||||
|
<div class="accordion-item">
|
||||||
|
<h2 class="accordion-header">
|
||||||
|
<button class="accordion-button" data-bs-toggle="collapse" data-bs-target="#mainBlock">
|
||||||
|
Основное
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div id="mainBlock" class="accordion-collapse collapse show">
|
||||||
|
<div class="accordion-body">
|
||||||
|
|
||||||
|
<input type="text" name="name" class="form-control mb-2"
|
||||||
|
value="<?= htmlspecialchars($board['name']) ?>" placeholder="Название">
|
||||||
|
|
||||||
|
<input type="text" name="code" class="form-control mb-2"
|
||||||
|
value="<?= htmlspecialchars($board['code']) ?>" placeholder="Код">
|
||||||
|
|
||||||
|
<textarea name="description" class="form-control mb-2"><?= htmlspecialchars($board['description'] ?? '') ?></textarea>
|
||||||
|
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="is_active"
|
||||||
|
<?= !empty($board['is_active']) ? 'checked' : '' ?>>
|
||||||
|
<label class="form-check-label">Активна</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="show_on_home"
|
||||||
|
<?= !empty($board['show_on_home']) ? 'checked' : '' ?>>
|
||||||
|
<label class="form-check-label">Показывать на главной</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- GOOGLE -->
|
||||||
|
<div class="accordion-item">
|
||||||
|
<h2 class="accordion-header">
|
||||||
|
<button class="accordion-button collapsed" data-bs-toggle="collapse" data-bs-target="#googleBlock" type="button">
|
||||||
|
Google Sheets
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div id="googleBlock" class="accordion-collapse collapse">
|
||||||
|
<div class="accordion-body">
|
||||||
|
|
||||||
|
<input type="text"
|
||||||
|
name="sheet_url"
|
||||||
|
class="form-control mb-2"
|
||||||
|
value="<?= htmlspecialchars((string)($boardSource['spreadsheet_url'] ?? '')) ?>"
|
||||||
|
placeholder="Ссылка на таблицу">
|
||||||
|
|
||||||
|
<input type="text"
|
||||||
|
name="sheet_name"
|
||||||
|
class="form-control mb-2"
|
||||||
|
value="<?= htmlspecialchars((string)($boardSource['sheet_name'] ?? '')) ?>"
|
||||||
|
placeholder="Название листа">
|
||||||
|
|
||||||
|
<select name="sync_mode" class="form-control">
|
||||||
|
<option value="import" <?= (($boardSource['sync_mode'] ?? '') === 'import') ? 'selected' : '' ?>>Импорт</option>
|
||||||
|
<option value="export" <?= (($boardSource['sync_mode'] ?? '') === 'export') ? 'selected' : '' ?>>Экспорт</option>
|
||||||
|
<option value="import_export" <?= (($boardSource['sync_mode'] ?? 'import_export') === 'import_export') ? 'selected' : '' ?>>Импорт + экспорт</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div class="form-check mt-2">
|
||||||
|
<input class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
name="sheet_is_active"
|
||||||
|
id="sheet_is_active"
|
||||||
|
<?= !empty($boardSource['is_active']) ? 'checked' : '' ?>>
|
||||||
|
<label class="form-check-label" for="sheet_is_active">Интеграция активна</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- КАСТОМНЫЕ ПОЛЯ -->
|
||||||
|
<div class="accordion-item">
|
||||||
|
<h2 class="accordion-header">
|
||||||
|
<button class="accordion-button collapsed" data-bs-toggle="collapse" data-bs-target="#fieldsBlock" type="button">
|
||||||
|
Кастомные поля
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div id="fieldsBlock" class="accordion-collapse collapse">
|
||||||
|
<div class="accordion-body">
|
||||||
|
<?php if (!empty($boardFields)): ?>
|
||||||
|
<?php foreach ($boardFields as $field): ?>
|
||||||
|
<div class="border p-2 mb-2">
|
||||||
|
|
||||||
|
<input type="hidden" name="fields[<?= $field['id'] ?>][id]" value="<?= $field['id'] ?>">
|
||||||
|
|
||||||
|
<input type="text"
|
||||||
|
name="fields[<?= $field['id'] ?>][name]"
|
||||||
|
value="<?= htmlspecialchars($field['name']) ?>"
|
||||||
|
class="form-control mb-1">
|
||||||
|
|
||||||
|
<select name="fields[<?= $field['id'] ?>][type]" class="form-control mb-1">
|
||||||
|
<option value="text" <?= $field['field_type']=='text'?'selected':'' ?>>Текст</option>
|
||||||
|
<option value="date" <?= $field['field_type']=='date'?'selected':'' ?>>Дата</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox"
|
||||||
|
name="fields[<?= $field['id'] ?>][delete]"
|
||||||
|
class="form-check-input">
|
||||||
|
<label class="form-check-label text-danger">Удалить</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="text-muted">Кастомных полей пока нет</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- ДОБАВИТЬ НОВОЕ -->
|
||||||
|
<input type="text" name="new_fields[][name]" class="form-control mb-2" placeholder="Новое поле">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<button type="submit" class="btn btn-success w-100">
|
||||||
|
💾 Сохранить всё
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
console.log('bootstrap =', window.bootstrap);
|
||||||
|
console.log('offcanvas el =', document.getElementById('boardSettingsCanvas'));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<h1 class="h3 mb-3">Создание задачи</h1>
|
||||||
|
|
||||||
|
<?php if (!empty($_SESSION['error'])): ?>
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
<?= htmlspecialchars($_SESSION['error']) ?>
|
||||||
|
</div>
|
||||||
|
<?php unset($_SESSION['error']); ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="/tasks/store">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Доска</label>
|
||||||
|
<select name="board_id" class="form-select" required>
|
||||||
|
<option value="">Выберите доску</option>
|
||||||
|
<?php foreach ($boards as $board): ?>
|
||||||
|
<option value="<?= (int)$board['id'] ?>" <?= (($board_code ?? '') === ($board['code'] ?? '')) ? 'selected' : '' ?>>
|
||||||
|
<?= htmlspecialchars((string)$board['name']) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Постановщик</label>
|
||||||
|
<select name="creator_id" class="form-select" required>
|
||||||
|
<?php foreach ($users as $item): ?>
|
||||||
|
<option value="<?= (int)$item['id'] ?>" <?= (int)$item['id'] === (int)$user['id'] ? 'selected' : '' ?>>
|
||||||
|
<?= htmlspecialchars((string)($item['display_name'] ?: $item['login'])) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Ответственный</label>
|
||||||
|
<select name="assignee_id" class="form-select">
|
||||||
|
<option value="">Не выбран</option>
|
||||||
|
<?php foreach ($users as $item): ?>
|
||||||
|
<option value="<?= (int)$item['id'] ?>">
|
||||||
|
<?= htmlspecialchars((string)($item['display_name'] ?: $item['login'])) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Наименование</label>
|
||||||
|
<input type="text" name="name" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Описание задачи</label>
|
||||||
|
<textarea name="description" class="form-control" rows="4"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3 mt-1">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Дата постановки</label>
|
||||||
|
<input type="datetime-local" name="task_created_at" class="form-control">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Дата план</label>
|
||||||
|
<input type="datetime-local" name="planned_at" class="form-control">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php foreach ($boardFields as $field): ?>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">
|
||||||
|
<?= htmlspecialchars((string)$field['name']) ?>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
$inputName = 'custom_field_' . (int)$field['id'];
|
||||||
|
$required = (int)$field['is_required'] === 1 ? 'required' : '';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<?php if ($field['field_type'] === 'textarea'): ?>
|
||||||
|
<textarea
|
||||||
|
name="<?= $inputName ?>"
|
||||||
|
class="form-control"
|
||||||
|
<?= $required ?>
|
||||||
|
></textarea>
|
||||||
|
|
||||||
|
<?php elseif ($field['field_type'] === 'date'): ?>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
name="<?= $inputName ?>"
|
||||||
|
class="form-control"
|
||||||
|
<?= $required ?>
|
||||||
|
>
|
||||||
|
|
||||||
|
<?php elseif ($field['field_type'] === 'number'): ?>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="<?= $inputName ?>"
|
||||||
|
class="form-control"
|
||||||
|
<?= $required ?>
|
||||||
|
>
|
||||||
|
|
||||||
|
<?php elseif ($field['field_type'] === 'checkbox'): ?>
|
||||||
|
<div class="form-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="<?= $inputName ?>"
|
||||||
|
value="1"
|
||||||
|
class="form-check-input"
|
||||||
|
id="field_<?= (int)$field['id'] ?>"
|
||||||
|
>
|
||||||
|
<label class="form-check-label" for="field_<?= (int)$field['id'] ?>">
|
||||||
|
Да
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php else: ?>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="<?= $inputName ?>"
|
||||||
|
class="form-control"
|
||||||
|
<?= $required ?>
|
||||||
|
>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Статус</label>
|
||||||
|
<select name="status" class="form-select">
|
||||||
|
<option value="NEW">Новая</option>
|
||||||
|
<option value="IN_PROGRESS">В работе</option>
|
||||||
|
<option value="REVIEW">На проверке</option>
|
||||||
|
<option value="DONE">Закрыта</option>
|
||||||
|
<option value="CANCELED">Отменена</option>
|
||||||
|
<option value="OVERDUE">Просрочена</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Приоритет</label>
|
||||||
|
<select name="priority" class="form-select">
|
||||||
|
<option value="LOW">Низкий</option>
|
||||||
|
<option value="MEDIUM" selected>Средний</option>
|
||||||
|
<option value="HIGH">Высокий</option>
|
||||||
|
<option value="CRITICAL">Критический</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="board_code" value="<?= htmlspecialchars((string)($board_code ?? '')) ?>">
|
||||||
|
|
||||||
|
<?php
|
||||||
|
$backUrl = !empty($board_code) ? '/boards/' . urlencode((string)$board_code) : '/tasks';
|
||||||
|
?>
|
||||||
|
<input type="hidden" name="board_code" value="<?= htmlspecialchars((string)($board_code ?? '')) ?>">
|
||||||
|
<button type="submit" class="btn btn-success">Создать</button>
|
||||||
|
<a href="<?= htmlspecialchars($backUrl) ?>" class="btn btn-secondary">Назад</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<?php
|
||||||
|
$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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
?>
|
||||||
|
<div class="container-fluid py-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h1 class="h3 mb-0">Главная</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (empty($boards)): ?>
|
||||||
|
<div class="alert alert-light border text-muted">
|
||||||
|
Нет досок для отображения на главной.
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="row g-3">
|
||||||
|
<?php foreach ($boards as $board): ?>
|
||||||
|
<div class="col-12 col-md-6 col-xl-4">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-header bg-white">
|
||||||
|
<div class="d-flex justify-content-between align-items-start gap-2">
|
||||||
|
<div>
|
||||||
|
<a href="/boards/<?= htmlspecialchars((string)$board['code']) ?>"
|
||||||
|
class="text-decoration-none fw-semibold fs-5">
|
||||||
|
<?= htmlspecialchars((string)$board['name']) ?>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<?php if (!empty($board['description'])): ?>
|
||||||
|
<div class="text-muted small mt-1">
|
||||||
|
<?= htmlspecialchars((string)$board['description']) ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||||
|
<span class="badge text-bg-secondary">
|
||||||
|
Всего: <?= (int)$board['stats']['total_count'] ?>
|
||||||
|
</span>
|
||||||
|
<span class="badge text-bg-secondary">
|
||||||
|
Новые: <?= (int)$board['stats']['new_count'] ?>
|
||||||
|
</span>
|
||||||
|
<span class="badge text-bg-primary">
|
||||||
|
В работе: <?= (int)$board['stats']['in_progress_count'] ?>
|
||||||
|
</span>
|
||||||
|
<span class="badge text-bg-success">
|
||||||
|
Завершено: <?= (int)$board['stats']['done_count'] ?>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
<?php if (empty($board['tasks'])): ?>
|
||||||
|
<div class="text-muted small">
|
||||||
|
В этой доске пока нет задач.
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<?php foreach ($board['tasks'] as $task): ?>
|
||||||
|
<?php
|
||||||
|
$deadline = taskDeadlineState($task['planned_at'] ?? null, $task['status'] ?? null);
|
||||||
|
|
||||||
|
$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';
|
||||||
|
|
||||||
|
$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',
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
<div class="list-group-item px-2 <?= $deadline['class'] ?? '' ?>">
|
||||||
|
<div class="d-flex justify-content-between align-items-start gap-2">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<a href="/tasks/show?id=<?= (int)$task['id'] ?>"
|
||||||
|
data-task-modal
|
||||||
|
data-task-id="<?= (int)$task['id'] ?>"
|
||||||
|
class="text-decoration-none fw-semibold">
|
||||||
|
<?= htmlspecialchars((string)$task['name']) ?>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="small text-muted">
|
||||||
|
<?= htmlspecialchars((string)$task['crm_id']) ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!empty($task['assignee_name'])): ?>
|
||||||
|
<div class="small text-muted">
|
||||||
|
Ответственный: <?= htmlspecialchars((string)$task['assignee_name']) ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (!empty($task['planned_at'])): ?>
|
||||||
|
<div class="small mt-1">
|
||||||
|
<span class="text-muted">План:</span>
|
||||||
|
<?= htmlspecialchars(date('d.m.Y H:i', strtotime((string)$task['planned_at']))) ?>
|
||||||
|
|
||||||
|
<?php if ($deadline): ?>
|
||||||
|
<span class="badge deadline-badge-<?= htmlspecialchars($deadline['code']) ?> ms-1">
|
||||||
|
<?= htmlspecialchars($deadline['text']) ?>
|
||||||
|
</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-column align-items-end gap-1">
|
||||||
|
<span class="badge <?= $statusClass ?>">
|
||||||
|
<?= htmlspecialchars(task_status_label((string)$task['status'])) ?>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
id="comment-badge-<?= (int)$task['id'] ?>"
|
||||||
|
class="badge <?= $badgeClass ?>"
|
||||||
|
data-task-id="<?= (int)$task['id'] ?>"
|
||||||
|
data-last-read-comment-id="<?= $lastReadCommentId ?>"
|
||||||
|
data-last-comment-id="<?= $lastCommentId ?>"
|
||||||
|
>
|
||||||
|
<?= $commentCount ?>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-footer bg-white">
|
||||||
|
<a href="/boards/<?= htmlspecialchars((string)$board['code']) ?>" class="btn btn-sm btn-outline-primary">
|
||||||
|
Открыть доску
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2 mb-3">
|
||||||
|
<h1 class="h3 mb-0">Канбан-доска</h1>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
|
<a href="/tasks" class="btn btn-outline-primary">Таблица</a>
|
||||||
|
<a href="/tasks/kanban" class="btn btn-outline-secondary">Канбан</a>
|
||||||
|
<a href="/tasks/create" class="btn btn-primary">+ Новая задача</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="get" action="/tasks/kanban" class="row g-2 align-items-end">
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<label class="form-label">Фильтр по статусу</label>
|
||||||
|
<select name="status" class="form-select">
|
||||||
|
<option value="">Все статусы</option>
|
||||||
|
<option value="NEW" <?= ($filterStatus ?? '') === 'NEW' ? 'selected' : '' ?>>Новая</option>
|
||||||
|
<option value="IN_PROGRESS" <?= ($filterStatus ?? '') === 'IN_PROGRESS' ? 'selected' : '' ?>>В работе</option>
|
||||||
|
<option value="REVIEW" <?= ($filterStatus ?? '') === 'REVIEW' ? 'selected' : '' ?>>На проверке</option>
|
||||||
|
<option value="DONE" <?= ($filterStatus ?? '') === 'DONE' ? 'selected' : '' ?>>Закрыта</option>
|
||||||
|
<option value="CANCELED" <?= ($filterStatus ?? '') === 'CANCELED' ? 'selected' : '' ?>>Отменена</option>
|
||||||
|
<option value="OVERDUE" <?= ($filterStatus ?? '') === 'OVERDUE' ? 'selected' : '' ?>>Просрочена</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-auto">
|
||||||
|
<button type="submit" class="btn btn-primary">Применить</button>
|
||||||
|
<a href="/tasks/kanban" class="btn btn-outline-secondary">Сбросить</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kanban-wrapper">
|
||||||
|
<div class="kanban-board">
|
||||||
|
<?php foreach ($columns as $statusCode => $column): ?>
|
||||||
|
<div class="kanban-column" data-status="<?= htmlspecialchars($statusCode) ?>">
|
||||||
|
<div class="kanban-column-header <?= htmlspecialchars($column['header_class']) ?>">
|
||||||
|
<div class="fw-semibold">
|
||||||
|
<?= htmlspecialchars($column['title']) ?>
|
||||||
|
</div>
|
||||||
|
<span class="badge text-bg-light kanban-count">
|
||||||
|
<?= count($column['tasks']) ?>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kanban-column-body dropzone" data-status="<?= htmlspecialchars($statusCode) ?>">
|
||||||
|
<?php if (empty($column['tasks'])): ?>
|
||||||
|
<div class="kanban-empty">
|
||||||
|
Нет задач
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php foreach ($column['tasks'] as $task): ?>
|
||||||
|
<?php
|
||||||
|
$deadline = taskDeadlineState($task['planned_at'] ?? null, $task['status'] ?? null);
|
||||||
|
echo '<pre style="background:#000;color:#0f0;font-size:11px">';
|
||||||
|
echo 'ID: ' . ($task['id'] ?? '') . PHP_EOL;
|
||||||
|
echo 'planned_at: ' . ($task['planned_at'] ?? 'EMPTY') . PHP_EOL;
|
||||||
|
echo 'deadline: ' . print_r($deadline, true);
|
||||||
|
echo '</pre>';
|
||||||
|
|
||||||
|
|
||||||
|
$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',
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
<div class="kanban-card <?= htmlspecialchars($deadline['class'] ?? '') ?>"
|
||||||
|
draggable="true"
|
||||||
|
data-task-id="<?= (int)$task['id'] ?>"
|
||||||
|
data-current-status="<?= htmlspecialchars((string)$task['status']) ?>">
|
||||||
|
<div class="d-flex justify-content-between align-items-start gap-2 mb-2">
|
||||||
|
<a href="/tasks/show?id=<?= (int)$task['id'] ?>"
|
||||||
|
data-task-modal
|
||||||
|
data-task-id="<?= (int)$task['id'] ?>"
|
||||||
|
class="kanban-card-title text-decoration-none">
|
||||||
|
<?= htmlspecialchars((string)$task['name']) ?>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<span class="badge <?= $priorityClass ?>">
|
||||||
|
<?= htmlspecialchars(task_priority_label((string)$task['priority'])) ?>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!empty($task['description'])): ?>
|
||||||
|
<div class="kanban-card-desc mb-2">
|
||||||
|
<?= htmlspecialchars(mb_strimwidth((string)$task['description'], 0, 140, '...')) ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="small text-muted mb-2">
|
||||||
|
<div><strong>CRM:</strong> <?= htmlspecialchars((string)$task['crm_id']) ?></div>
|
||||||
|
<?php if (!empty($task['creator_name'])): ?>
|
||||||
|
<div><strong>Постановщик:</strong> <?= htmlspecialchars((string)$task['creator_name']) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (!empty($task['assignee_name'])): ?>
|
||||||
|
<div><strong>Ответственный:</strong> <?= htmlspecialchars((string)$task['assignee_name']) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (!empty($task['supplier'])): ?>
|
||||||
|
<div><strong>Поставщик:</strong> <?= htmlspecialchars((string)$task['supplier']) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (!empty($task['delivery_date'])): ?>
|
||||||
|
<div><strong>Срок:</strong> <?= htmlspecialchars((string)$task['delivery_date']) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (!empty($task['planned_at'])): ?>
|
||||||
|
<div>
|
||||||
|
<strong>Дата план:</strong>
|
||||||
|
<?= htmlspecialchars(date('d.m.Y H:i', strtotime((string)$task['planned_at']))) ?>
|
||||||
|
|
||||||
|
<?php if ($deadline): ?>
|
||||||
|
<span class="badge deadline-badge-<?= htmlspecialchars($deadline['code']) ?> ms-1">
|
||||||
|
<?= htmlspecialchars($deadline['text']) ?>
|
||||||
|
</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<span
|
||||||
|
id="comment-badge-<?= (int)$task['id'] ?>"
|
||||||
|
class="badge <?= $badgeClass ?>"
|
||||||
|
data-task-id="<?= (int)$task['id'] ?>"
|
||||||
|
data-last-read-comment-id="<?= $lastReadCommentId ?>"
|
||||||
|
data-last-comment-id="<?= $lastCommentId ?>"
|
||||||
|
>
|
||||||
|
Комментарии: <span class="comment-count"><?= $commentCount ?></span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
$isDone = (($task['status'] ?? '') === 'DONE');
|
||||||
|
?>
|
||||||
|
<span class="badge <?= $isDone ? 'text-bg-success' : 'text-bg-light' ?>">
|
||||||
|
<?= $isDone ? 'Выполнено' : 'Не выполнено' ?>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.kanban-wrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-board {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
min-width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-column {
|
||||||
|
width: 320px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: calc(100vh - 220px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-column-header {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,.15);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-top-left-radius: 14px;
|
||||||
|
border-top-right-radius: 14px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-header-new {
|
||||||
|
background: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-header-progress {
|
||||||
|
background: #0d6efd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-header-review {
|
||||||
|
background: #fd7e14;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-header-done {
|
||||||
|
background: #198754;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-header-canceled {
|
||||||
|
background: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-header-overdue {
|
||||||
|
background: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-column-body {
|
||||||
|
padding: 12px;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 120px;
|
||||||
|
transition: background-color .2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-column-body.drag-over {
|
||||||
|
background: #e9f2ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card.dragging {
|
||||||
|
opacity: .55;
|
||||||
|
transform: rotate(1deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #212529;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card-title:hover {
|
||||||
|
color: #0d6efd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card-desc {
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-empty {
|
||||||
|
color: #6c757d;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px 10px;
|
||||||
|
border: 1px dashed #ced4da;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.kanban-column {
|
||||||
|
width: 280px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
let draggedCard = null;
|
||||||
|
let originalStatus = null;
|
||||||
|
|
||||||
|
document.querySelectorAll('.kanban-card').forEach(card => {
|
||||||
|
card.addEventListener('dragstart', function () {
|
||||||
|
draggedCard = this;
|
||||||
|
originalStatus = this.getAttribute('data-current-status');
|
||||||
|
this.classList.add('dragging');
|
||||||
|
});
|
||||||
|
|
||||||
|
card.addEventListener('dragend', function () {
|
||||||
|
this.classList.remove('dragging');
|
||||||
|
document.querySelectorAll('.dropzone').forEach(zone => zone.classList.remove('drag-over'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.dropzone').forEach(zone => {
|
||||||
|
zone.addEventListener('dragover', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.classList.add('drag-over');
|
||||||
|
});
|
||||||
|
|
||||||
|
zone.addEventListener('dragleave', function () {
|
||||||
|
this.classList.remove('drag-over');
|
||||||
|
});
|
||||||
|
|
||||||
|
zone.addEventListener('drop', async function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.classList.remove('drag-over');
|
||||||
|
|
||||||
|
if (!draggedCard) return;
|
||||||
|
|
||||||
|
const newStatus = this.getAttribute('data-status');
|
||||||
|
const taskId = draggedCard.getAttribute('data-task-id');
|
||||||
|
|
||||||
|
if (!taskId || !newStatus || originalStatus === newStatus) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldZone = document.querySelector('.dropzone[data-status="' + originalStatus + '"]');
|
||||||
|
|
||||||
|
this.appendChild(draggedCard);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('task_id', taskId);
|
||||||
|
formData.append('status', newStatus);
|
||||||
|
|
||||||
|
const response = await fetch('/tasks/change-status', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.message || 'Ошибка смены статуса');
|
||||||
|
}
|
||||||
|
|
||||||
|
draggedCard.setAttribute('data-current-status', newStatus);
|
||||||
|
updateColumnCounts();
|
||||||
|
removeEmptyBlocks();
|
||||||
|
} catch (error) {
|
||||||
|
if (oldZone) {
|
||||||
|
oldZone.appendChild(draggedCard);
|
||||||
|
}
|
||||||
|
updateColumnCounts();
|
||||||
|
removeEmptyBlocks();
|
||||||
|
alert('Не удалось изменить статус');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateColumnCounts() {
|
||||||
|
document.querySelectorAll('.kanban-column').forEach(column => {
|
||||||
|
const cards = column.querySelectorAll('.kanban-card');
|
||||||
|
const countBadge = column.querySelector('.kanban-count');
|
||||||
|
if (countBadge) {
|
||||||
|
countBadge.textContent = cards.length;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeEmptyBlocks() {
|
||||||
|
document.querySelectorAll('.dropzone').forEach(zone => {
|
||||||
|
const cards = zone.querySelectorAll('.kanban-card');
|
||||||
|
const empty = zone.querySelector('.kanban-empty');
|
||||||
|
|
||||||
|
if (cards.length === 0) {
|
||||||
|
if (!empty) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'kanban-empty';
|
||||||
|
div.textContent = 'Нет задач';
|
||||||
|
zone.appendChild(div);
|
||||||
|
}
|
||||||
|
} else if (empty) {
|
||||||
|
empty.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
<?php
|
||||||
|
function crm_format_datetime(?string $value): string
|
||||||
|
{
|
||||||
|
$value = trim((string)$value);
|
||||||
|
if ($value === '') return '—';
|
||||||
|
|
||||||
|
$timestamp = strtotime($value);
|
||||||
|
return $timestamp === false ? htmlspecialchars($value) : date('d.m.Y H:i', $timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function crm_datetime_local(?string $value): string
|
||||||
|
{
|
||||||
|
$value = trim((string)$value);
|
||||||
|
if ($value === '') return '';
|
||||||
|
|
||||||
|
$timestamp = strtotime($value);
|
||||||
|
return $timestamp === false ? '' : date('Y-m-d\TH:i', $timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function crm_hide_custom_field(array $field): bool
|
||||||
|
{
|
||||||
|
$fieldName = mb_strtolower(trim((string)($field['name'] ?? '')));
|
||||||
|
$fieldCode = mb_strtolower(trim((string)($field['code'] ?? '')));
|
||||||
|
|
||||||
|
return in_array($fieldName, [
|
||||||
|
'дата создания',
|
||||||
|
'дата постановки',
|
||||||
|
'дата план',
|
||||||
|
'дата факта',
|
||||||
|
'дата факт',
|
||||||
|
], true) || in_array($fieldCode, [
|
||||||
|
'task_created_at',
|
||||||
|
'planned_at',
|
||||||
|
'completed_at',
|
||||||
|
'date_created',
|
||||||
|
'created_date',
|
||||||
|
], true);
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="modal-header row">
|
||||||
|
<h5 class="modal-title fs-5 col-9">
|
||||||
|
<?= htmlspecialchars((string)$task['name']) ?>
|
||||||
|
</h5>
|
||||||
|
|
||||||
|
<div class="col-3 d-flex gap-2 align-items-center">
|
||||||
|
<?php if (!empty($canEditTask)): ?>
|
||||||
|
<button type="button" class="btn btn-outline-primary btn-sm" id="taskEditBtn">
|
||||||
|
Редактировать задачу
|
||||||
|
</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
|
||||||
|
<form id="taskEditForm" method="post" action="/tasks/update">
|
||||||
|
<input type="hidden" name="task_id" value="<?= (int)$task['id'] ?>">
|
||||||
|
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<strong>CRM ID:</strong> <?= htmlspecialchars((string)$task['crm_id']) ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<strong>Постановщик:</strong> <?= htmlspecialchars((string)($task['creator_name'] ?? '')) ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<strong>Ответственный:</strong> <?= htmlspecialchars((string)($task['assignee_name'] ?? '')) ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<strong>Статус:</strong> <?= htmlspecialchars(task_status_label((string)$task['status'])) ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<strong>Приоритет:</strong> <?= htmlspecialchars(task_priority_label((string)$task['priority'])) ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<strong>Дата постановки:</strong>
|
||||||
|
<span class="task-view-value"><?= crm_format_datetime($task['task_created_at'] ?? null) ?></span>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
name="task_created_at"
|
||||||
|
class="form-control task-edit-field d-none mt-1"
|
||||||
|
value="<?= htmlspecialchars(crm_datetime_local($task['task_created_at'] ?? null)) ?>"
|
||||||
|
<?= empty($canEditTask) ? 'disabled' : '' ?>
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<strong>Дата план:</strong>
|
||||||
|
<span class="task-view-value"><?= crm_format_datetime($task['planned_at'] ?? null) ?></span>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
name="planned_at"
|
||||||
|
class="form-control task-edit-field d-none mt-1"
|
||||||
|
value="<?= htmlspecialchars(crm_datetime_local($task['planned_at'] ?? null)) ?>"
|
||||||
|
<?= empty($canEditTask) ? 'disabled' : '' ?>
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<strong>Дата факт:</strong>
|
||||||
|
<?= crm_format_datetime($task['completed_at'] ?? null) ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label fw-bold">Наименование</label>
|
||||||
|
|
||||||
|
<div class="task-view-value border rounded p-3 bg-light">
|
||||||
|
<?= htmlspecialchars((string)$task['name']) ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
class="form-control task-edit-field d-none"
|
||||||
|
value="<?= htmlspecialchars((string)$task['name']) ?>"
|
||||||
|
<?= empty($canEditTask) ? 'disabled' : '' ?>
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label fw-bold">Описание задачи</label>
|
||||||
|
|
||||||
|
<div class="task-view-value border rounded p-3 bg-light">
|
||||||
|
<?= nl2br(htmlspecialchars((string)($task['description'] ?? ''))) ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
name="description"
|
||||||
|
class="form-control task-edit-field d-none"
|
||||||
|
rows="4"
|
||||||
|
<?= empty($canEditTask) ? 'disabled' : '' ?>
|
||||||
|
><?= htmlspecialchars((string)($task['description'] ?? '')) ?></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!empty($customFields)): ?>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label fw-bold">Поля доски</label>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<?php foreach ($customFields as $field): ?>
|
||||||
|
<?php if (crm_hide_custom_field($field)) continue; ?>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="border rounded p-3 bg-light h-100">
|
||||||
|
<label class="fw-semibold mb-1">
|
||||||
|
<?= htmlspecialchars((string)$field['name']) ?>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="task-view-value">
|
||||||
|
<?= trim((string)($field['value'] ?? '')) !== ''
|
||||||
|
? nl2br(htmlspecialchars((string)$field['value']))
|
||||||
|
: '<span class="text-muted">—</span>' ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="custom_field_<?= (int)$field['id'] ?>"
|
||||||
|
class="form-control task-edit-field d-none"
|
||||||
|
value="<?= htmlspecialchars((string)($field['value'] ?? '')) ?>"
|
||||||
|
<?= empty($canEditTask) ? 'disabled' : '' ?>
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (!empty($canEditTask)): ?>
|
||||||
|
<div class="mt-4 mb-4 d-none" id="taskEditActions">
|
||||||
|
<button type="submit" class="btn btn-success">
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" class="btn btn-secondary" id="taskCancelEditBtn">
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label fw-bold">Доска</label>
|
||||||
|
<div class="border rounded p-3 bg-light">
|
||||||
|
<div><strong>Название:</strong> <?= htmlspecialchars((string)($task['board_name'] ?? '')) ?></div>
|
||||||
|
<div><strong>Код:</strong> <?= htmlspecialchars((string)($task['board_code'] ?? '')) ?></div>
|
||||||
|
|
||||||
|
<?php if (!empty($task['board_description'])): ?>
|
||||||
|
<div class="mt-2">
|
||||||
|
<strong>Описание:</strong><br>
|
||||||
|
<?= nl2br(htmlspecialchars((string)$task['board_description'])) ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!empty($canEditTask)): ?>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h6 class="mb-3">Управление статусом</h6>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap gap-2 mb-4">
|
||||||
|
<?php
|
||||||
|
$statuses = [
|
||||||
|
'NEW' => ['Новая', 'btn-outline-secondary'],
|
||||||
|
'IN_PROGRESS' => ['В работу', 'btn-outline-primary'],
|
||||||
|
'REVIEW' => ['На проверку', 'btn-outline-warning'],
|
||||||
|
'CANCELED' => ['Отменить', 'btn-outline-dark'],
|
||||||
|
'DONE' => ['Завершить', 'btn-success fw-semibold'],
|
||||||
|
];
|
||||||
|
?>
|
||||||
|
|
||||||
|
<?php foreach ($statuses as $statusCode => [$statusLabel, $btnClass]): ?>
|
||||||
|
<form method="post" action="/tasks/update-status" class="d-inline js-task-status-form" data-task-id="<?= (int)$task['id'] ?>">
|
||||||
|
<input type="hidden" name="task_id" value="<?= (int)$task['id'] ?>">
|
||||||
|
<input type="hidden" name="status" value="<?= htmlspecialchars($statusCode) ?>">
|
||||||
|
<button type="submit" class="btn <?= htmlspecialchars($btnClass) ?> btn-sm">
|
||||||
|
<?= htmlspecialchars($statusLabel) ?>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h6 class="mb-3">Файлы</h6>
|
||||||
|
|
||||||
|
<form method="post" action="/tasks/files/upload" enctype="multipart/form-data" class="mb-4 js-task-file-upload-form" data-task-id="<?= (int)$task['id'] ?>">
|
||||||
|
<input type="hidden" name="task_id" value="<?= (int)$task['id'] ?>">
|
||||||
|
|
||||||
|
<div class="row g-2 align-items-end">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<label class="form-label">Загрузить файл</label>
|
||||||
|
<input type="file" name="file" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
<button type="submit" class="btn btn-primary w-100">Загрузить файл</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="files-list mb-4">
|
||||||
|
<?php if (empty($files)): ?>
|
||||||
|
<div class="text-muted">Файлов пока нет.</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="row g-3">
|
||||||
|
<?php foreach ($files as $file): ?>
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<div class="border rounded p-3 h-100">
|
||||||
|
<?php if (!empty($file['is_image'])): ?>
|
||||||
|
<div class="mb-3 text-center">
|
||||||
|
<img
|
||||||
|
src="/tasks/files/view?id=<?= (int)$file['id'] ?>"
|
||||||
|
alt="<?= htmlspecialchars((string)$file['original_name']) ?>"
|
||||||
|
class="img-fluid rounded border file-preview-image"
|
||||||
|
style="max-height: 180px; object-fit: cover; cursor: pointer;"
|
||||||
|
data-image-modal
|
||||||
|
data-image-src="/tasks/files/view?id=<?= (int)$file['id'] ?>"
|
||||||
|
data-image-title="<?= htmlspecialchars((string)$file['original_name']) ?>"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="fw-semibold mb-1">
|
||||||
|
<?= htmlspecialchars((string)$file['original_name']) ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="small text-muted">
|
||||||
|
Размер: <?= number_format(((int)$file['file_size']) / 1024, 1, '.', ' ') ?> KB
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="small text-muted mb-3">
|
||||||
|
Загрузил:
|
||||||
|
<?= htmlspecialchars((string)($file['display_name'] ?: $file['login'] ?: 'Пользователь')) ?>
|
||||||
|
— <?= htmlspecialchars((string)$file['created_at']) ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
|
<?php if (!empty($file['is_image'])): ?>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-outline-primary"
|
||||||
|
data-image-modal
|
||||||
|
data-image-src="/tasks/files/view?id=<?= (int)$file['id'] ?>"
|
||||||
|
data-image-title="<?= htmlspecialchars((string)$file['original_name']) ?>"
|
||||||
|
>
|
||||||
|
Открыть
|
||||||
|
</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<a href="/tasks/files/download?id=<?= (int)$file['id'] ?>" class="btn btn-sm btn-outline-secondary">
|
||||||
|
Скачать
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<?php if (!empty($canEditTask)): ?>
|
||||||
|
<form method="post"
|
||||||
|
action="/tasks/files/delete"
|
||||||
|
class="d-inline js-task-file-delete-form"
|
||||||
|
data-task-id="<?= (int)$task['id'] ?>"
|
||||||
|
onsubmit="return confirm('Удалить файл?');">
|
||||||
|
<input type="hidden" name="id" value="<?= (int)$file['id'] ?>">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-danger">Удалить</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h6 class="mb-3">Комментарии</h6>
|
||||||
|
|
||||||
|
<form method="post" action="/tasks/comment/store" class="mb-4 js-task-comment-form" data-task-id="<?= (int)$task['id'] ?>">
|
||||||
|
<input type="hidden" name="task_id" value="<?= (int)$task['id'] ?>">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<textarea name="text" class="form-control" rows="3" placeholder="Введите комментарий..." required></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">Добавить комментарий</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="comments-list">
|
||||||
|
<?php if (empty($comments)): ?>
|
||||||
|
<div class="text-muted">Комментариев пока нет.</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<?php foreach ($comments as $comment): ?>
|
||||||
|
<div class="border rounded p-3 mb-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||||
|
<strong>
|
||||||
|
<?= htmlspecialchars((string)($comment['display_name'] ?: $comment['login'] ?: 'Пользователь')) ?>
|
||||||
|
</strong>
|
||||||
|
|
||||||
|
<small class="text-muted">
|
||||||
|
<?= htmlspecialchars((string)$comment['created_at']) ?>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<?= nl2br(htmlspecialchars((string)$comment['text'])) ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
|
||||||
|
</div>
|
||||||
@@ -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/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1880
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => $_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',
|
||||||
|
];
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'host' => $_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',
|
||||||
|
];
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'sheet_id' => $_ENV['GOOGLE_SHEET_ID'] ?? '',
|
||||||
|
'sheet_name' => $_ENV['GOOGLE_SHEET_NAME'] ?? '',
|
||||||
|
'credentials_path' => $_ENV['GOOGLE_CREDENTIALS_PATH'] ?? '',
|
||||||
|
];
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'host' => $_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'] ?? 'ИТ-Отдел',
|
||||||
|
];
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Dotenv\Dotenv;
|
||||||
|
use App\Services\Google\GoogleImportService;
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../vendor/autoload.php';
|
||||||
|
require_once __DIR__ . '/../app/Helpers/format.php';
|
||||||
|
|
||||||
|
$dotenv = Dotenv::createImmutable(dirname(__DIR__));
|
||||||
|
$dotenv->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;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
RewriteEngine On
|
||||||
|
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteRule ^ index.php [QSA,L]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
open_basedir=/www/wwwroot/it.rifdev.ru/:/tmp/
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
date_default_timezone_set('Europe/Samara');
|
||||||
|
|
||||||
|
use App\Core\Router;
|
||||||
|
use Dotenv\Dotenv;
|
||||||
|
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../vendor/autoload.php';
|
||||||
|
require_once __DIR__ . '/../app/Helpers/format.php';
|
||||||
|
|
||||||
|
$dotenv = Dotenv::createImmutable(dirname(__DIR__));
|
||||||
|
$dotenv->safeLoad();
|
||||||
|
|
||||||
|
$router = new Router();
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../routes/web.php';
|
||||||
|
|
||||||
|
$router->dispatch($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']);
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Controllers\AuthController;
|
||||||
|
use App\Controllers\DashboardController;
|
||||||
|
use App\Controllers\TaskController;
|
||||||
|
use App\Controllers\CommentController;
|
||||||
|
use App\Controllers\AdminController;
|
||||||
|
use App\Controllers\AdminBoardController;
|
||||||
|
use App\Controllers\AdminAdController;
|
||||||
|
use App\Controllers\TaskFileController;
|
||||||
|
use App\Controllers\AdminBoardFieldController;
|
||||||
|
use App\Controllers\AdminBoardSourceController;
|
||||||
|
use App\Controllers\NotificationController;
|
||||||
|
|
||||||
|
$router->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']);
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
use Workerman\Timer;
|
||||||
|
use Workerman\Worker;
|
||||||
|
|
||||||
|
$wsWorker = new Worker('websocket://0.0.0.0:2346');
|
||||||
|
$wsWorker->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();
|
||||||
Reference in New Issue
Block a user