dhtmlxGantt с ASP.NET MVC

Это руководство объясняет, как создать приложение с диаграммой Ганта, используя ASP.NET и REST API на серверной стороне. Шаги включают настройку веб-платформы ASP.NET MVC 5 и использование контроллера Web API 2 для REST API. Entity Framework будет обрабатывать связь с базой данных, а для создания приложения будет использоваться Visual Studio.

Если вас интересуют другие интеграции на стороне сервера, ознакомьтесь с этими учебниками:

Полный исходный код для этого руководства доступен на GitHub.

Шаг 1: Создание проекта

Настройка нового проекта в Visual Studio

Запустите Visual Studio 2022 и выберите опцию для создания нового проекта.

Выберите "ASP.NET Web Application" в качестве шаблона и назовите проект DHX.Gantt.Web. Если этот шаблон не виден, обратитесь к разделу Устранение неполадок.

Выберите шаблон Empty и включите опции MVC и Web API.


Шаг 2: Добавление Gantt на страницу

Создание контроллера

С пустым проектом следующим шагом является реализация диаграммы Ганта. Начните с добавления MVC контроллера для отображения страницы с диаграммой Ганта.

Чтобы добавить контроллер, щелкните правой кнопкой мыши на папке Controllers и выберите Add -> Controller. Выберите "MVC 5 Controller - Empty" и назовите его "HomeController".

HomeController уже включает метод Index() класса ActionResult, так что дополнительная логика не требуется. Следующим шагом будет добавление представления для этого метода.

Controllers/HomeController.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
 
namespace DHX.Gantt.Web.Controllers
{
    public class HomeController : Controller
    {
        // GET: Home
        public ActionResult Index()
        {
            return View();
        }
    }
}

Создание представления

Теперь создайте страницу index. Перейдите в Views/Home и добавьте пустое представление с именем Index.

Откройте вновь созданное представление и добавьте следующий код:

Views/Home/Index.cshtml

@{
    Layout = null;
}
 
<!DOCTYPE html>
 
<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>Index</title>
    <link href="https://cdn.dhtmlx.com/gantt/edge/dhtmlxgantt.css" 
        rel="stylesheet" type="text/css" />
    <script src="https://cdn.dhtmlx.com/gantt/edge/dhtmlxgantt.js"></script>
    <script>
        document.addEventListener("DOMContentLoaded", function(event) {
            // указание формата даты
            gantt.config.date_format = "%Y-%m-%d %H:%i";
            // инициализация gantt
            gantt.init("gantt_here");
 
            // инициация загрузки данных
            gantt.load("/api/data");
            // инициализация dataProcessor
            var dp = new gantt.dataProcessor("/api/");
            // и привязка его к gantt
            dp.init(gantt);
            // установка режима REST для dataProcessor
            dp.setTransactionMode("REST");
        });
</script> </head> <body> <div id="gantt_here" style="width: 100%; height: 100vh;"></div> </body> </html>

Вот что делает этот код:

  • Устанавливает базовую структуру страницы для приложения с диаграммой Ганта.
  • Включает dhtmlxGantt JavaScript и CSS через CDN ссылки.
  • Инициализирует диаграмму Ганта на странице.

Конфигурация формата даты гарантирует, что клиент сможет правильно анализировать даты с сервера:

Views/Home/Index.cshtml

gantt.config.date_format = "%Y-%m-%d %H:%i";

Диаграмма Ганта также настроена для работы с RESTful API, используя "/api/" в качестве маршрута по умолчанию:

Views/Home/Index.cshtml

gantt.load("/api/data");
// инициализация dataProcessor
var dp = new gantt.dataProcessor("/api/");
// и привязка его к gantt
dp.init(gantt);
// установка режима REST для dataProcessor
dp.setTransactionMode("REST");

На этом этапе вы можете запустить приложение, чтобы увидеть диаграмму Ганта, отображаемую на странице.


Шаг 3: Создание моделей и базы данных

Создание моделей

Диаграмма Ганта требует модели данных, состоящей из Tasks и Links. Клиентская модель следует определенной конвенции именования, которая отличается от стандартных C# конвенций. Некоторые свойства в клиентской модели могут не требовать хранения в базе данных, но полезны для клиента или логики на сервере.

Для обработки этого используется паттерн Data Transfer Object. Будут созданы отдельные классы доменной модели для использования с EF и внутри приложения, а также DTO классы для коммуникации с Web API. Также будет реализовано отображение между этими моделями.

Модель Task

Начните с создания класса для Task со следующей структурой:

Models/Task.cs

using System;
 
namespace DHX.Gantt.Web.Models
{
    public class Task
    {
        public int Id { get; set; }
        public string Text { get; set; }
        public DateTime StartDate { get; set; }
        public int Duration { get; set; }
        public decimal Progress { get; set; }
        public int? ParentId { get; set; }
        public string Type { get; set; }
    }
}

Для полного списка доступных свойств объекта Task обратитесь к документации.

Модель Link

Далее создайте класс Link со следующей структурой:

Models/Link.cs

namespace DHX.Gantt.Web.Models
{
    public class Link
    {
        public int Id { get; set; }
        public string Type { get; set; }
        public int SourceTaskId { get; set; }
        public int TargetTaskId { get; set; }
    }
}

Настройка подключения к базе данных

Установка Entity Framework

Для управления базой данных установите Entity Framework, выполнив эту команду в консоли диспетчера пакетов:

Install-Package EntityFramework

Создание контекста базы данных

Создайте класс GanttContext, чтобы представить сессию с базой данных. Этот контекст позволяет извлекать и сохранять данные.

Добавьте новый класс в папку Models с именем "GanttContext" со следующим содержимым:

Models/GanttContext.cs

using System.Data.Entity;
 
namespace DHX.Gantt.Web.Models
{
    public class GanttContext : DbContext
    {
        public DbSet<Task> Tasks { get; set; }
        public DbSet<Link> Links { get; set; }
    }
}

Добавление начальных записей в базу данных

Чтобы заполнить базу данных начальными данными, настройте Entity Framework для автоматического создания и заполнения базы данных при запуске приложения.

Создайте инициализатор базы данных, добавив новый класс в папку App_Start. Назовите его "GanttInitializer" и наследуйте от класса DropCreateDatabaseIfModelChanges. Переопределите метод Seed(), чтобы заполнить базу данных тестовыми данными.

Вот полный код для класса GanttInitializer:

App_Start/GanttInitializer.cs

using System;
using System.Collections.Generic;
using System.Data.Entity;
 
namespace DHX.Gantt.Web.Models
{
    public class GanttInitializer : DropCreateDatabaseIfModelChanges<GanttContext>
    {
        protected override void Seed(GanttContext context)
        {
            List<Task> tasks = new List<Task>()
            {
                new Task()
                {
                    Id = 1,
                    Text = "Project #2",
                    StartDate = DateTime.Today.AddDays(-3),
                    Duration = 18,
                    Progress = 0.4m,
                    ParentId = null
                },
                new Task()
                {
                    Id = 2,
                    Text = "Task #1",
                    StartDate = DateTime.Today.AddDays(-2),
                    Duration = 8,
                    Progress = 0.6m,
                    ParentId = 1
                },
                new Task()
                {
                    Id = 3,
                    Text = "Task #2",
                    StartDate = DateTime.Today.AddDays(-1),
                    Duration = 8,
                    Progress = 0.6m,
                    ParentId = 1
                }
            };
 
            tasks.ForEach(s => context.Tasks.Add(s));
            context.SaveChanges();
 
            List<Link> links = new List<Link>()
            {
                new Link() {Id = 1, SourceTaskId = 1, TargetTaskId = 2, Type = "1"},
                new Link() {Id = 2, SourceTaskId = 2, TargetTaskId = 3, Type = "0"}
            };
 
            links.ForEach(s => context.Links.Add(s));
            context.SaveChanges();
        }
    }
}

Наконец, откройте файл Global.asax и добавьте необходимое пространство имен и настройку инициализатора в методе Application_Start():

Global.asax.cs

using System;
using System.Web;
using System.Web.Mvc;
using System.Web.Routing;
using System.Web.Http;
 
using System.Data.Entity;
using DHX.Gantt.Web.Models;
 
namespace DHX.Gantt.Web
{
    public class Global : HttpApplication
    {
        void Application_Start(object sender, EventArgs e)
        {
            // Код, который выполняется при запуске приложения
            AreaRegistration.RegisterAllAreas();
            GlobalConfiguration.Configure(WebApiConfig.Register);
            RouteConfig.RegisterRoutes(RouteTable.Routes);
 
            Database.SetInitializer(new GanttInitializer());
        }
    }
}

Определение DTO и отображение

Для начала вам нужно настроить классы DTO для Web API. Для отображения между моделью и DTO мы упростим задачу, определив явный оператор преобразования для этих классов.

Вот как определяется класс TaskDto:

Models/TaskDto.cs

using System;
 
namespace DHX.Gantt.Web.Models
{
    public class TaskDto
    {
        public int id { get; set; }
        public string text { get; set; }
        public string start_date { get; set; }
        public int duration { get; set; }
        public decimal progress { get; set; }
        public int? parent { get; set; }
        public string type { get; set; }
        public bool open
        {
            get { return true; }
            set { }
        }
 
        public static explicit operator TaskDto(Task task)
        {
            return new TaskDto
            {
                id = task.Id,
                text = task.Text,
                start_date = task.StartDate.ToString("yyyy-MM-dd HH:mm"),
                duration = task.Duration,
                parent = task.ParentId,
                type = task.Type,
                progress = task.Progress
            };
        }
 
        public static explicit operator Task(TaskDto task)
        {
            return new Task
            {
                Id = task.id,
                Text = task.text,
                StartDate = DateTime.Parse(
                    task.start_date, 
                    System.Globalization.CultureInfo.InvariantCulture),
                Duration = task.duration,
                ParentId = task.parent,
                Type = task.type,
                Progress = task.progress
            };
        }
    }
}

Далее, вот как структурирован класс LinkDto:

Models/LinkDto.cs

namespace DHX.Gantt.Web.Models
{
    public class LinkDto
    {
        public int id { get; set; }
        public string type { get; set; }
        public int source { get; set; }
        public int target { get; set; }
 
        public static explicit operator LinkDto(Link link)
        {
            return new LinkDto
            {
                id = link.Id,
                type = link.Type,
                source = link.SourceTaskId,
                target = link.TargetTaskId
            };
        }
 
        public static explicit operator Link(LinkDto link)
        {
            return new Link
            {
                Id = link.id,
                Type = link.type,
                SourceTaskId = link.source,
                TargetTaskId = link.target
            };
        }
    }
}

Наконец, вот модель для источника данных:

Models/GanttDto.cs

using System.Collections.Generic;
 
namespace DHX.Gantt.Web.Models
{
    public class GanttDto
    {
        public IEnumerable<TaskDto> data { get; set; }
        public IEnumerable<LinkDto> links { get; set; }
    }
}

Шаг 4: Реализация Web API

Общий подход к загрузке данных через REST API

Следующий шаг включает реализацию API. Основываясь на деталях API, вам понадобятся три контроллера: один для задач, один для связей и другой для действия 'load data', чтобы обработать смешанный результат.

Контроллер задач

Чтобы создать новый контроллер:

  • Щелкните правой кнопкой мыши на папке Controllers и выберите Add -> Controller.
  • Выберите Web API 2 Controller -> Empty template. Назовите новый контроллер "TaskController".

Вот реализация основных действий CRUD для записей задач:

Controllers/TaskController.cs

using System.Collections.Generic;
using System.Data.Entity;
using System.Linq;
using System.Web.Http;
using System.Web.UI.WebControls;
 
using DHX.Gantt.Web.Models;
 
namespace DHX.Gantt.Web.Controllers
{
    public class TaskController : ApiController
    {
        private GanttContext db = new GanttContext();
 
        // GET api/Task
        public IEnumerable<TaskDto> Get()
        {
            return db.Tasks
                .ToList()
                .Select(t => (TaskDto)t);
        }
 
        // GET api/Task/5
        [System.Web.Http.HttpGet]
        public TaskDto Get(int id)
        {
            return (TaskDto)db
                .Tasks
                .Find(id);
        }
 
        // PUT api/Task/5
        [System.Web.Http.HttpPut]
        public IHttpActionResult EditTask(int id, TaskDto taskDto)
        {
            var updatedTask = (Task)taskDto;
            updatedTask.Id = id;
            db.Entry(updatedTask).State = EntityState.Modified;
            db.SaveChanges();
 
            return Ok(new
            {
                action = "updated"
            });
        }
 
        // POST api/Task
        [System.Web.Http.HttpPost]
        public IHttpActionResult CreateTask(TaskDto taskDto)
        {
            var newTask = (Task)taskDto;
 
            db.Tasks.Add(newTask);
            db.SaveChanges();
 
            return Ok(new
            {
                tid = newTask.Id,
                action = "inserted"
            });
        }
 
        // DELETE api/Task/5
        [System.Web.Http.HttpDelete]
        public IHttpActionResult DeleteTask(int id)
        {
            var task = db.Tasks.Find(id);
            if (task != null)
            {
                db.Tasks.Remove(task);
                db.SaveChanges();
            }
 
            return Ok(new
            {
                action = "deleted"
            });
        }
 
        protected override void Dispose(bool disposing)
        {
            if (disposing)
            {
                db.Dispose();
            }
            base.Dispose(disposing);
        }
    }
}

Логика здесь довольно проста:

  • Для действий GET задачи извлекаются из базы данных и преобразуются в их DTO.
  • Для действий PUT/POST DTO преобразуются обратно в модели задач и сохраняются в базе данных.

Далее вы настроите контроллер для связей.

Контроллер связей

Создайте новый пустой контроллер Web API для связей. Вот как он должен выглядеть:

Controllers/LinkController.cs

using System.Collections.Generic;
using System.Data.Entity;
using System.Linq;
using System.Web.Http;
using DHX.Gantt.Web.Models;
 
namespace DHX.Gantt.Web.Controllers
{
    public class LinkController : ApiController
    {
        private GanttContext db = new GanttContext();
 
        // GET api/Link
        [System.Web.Http.HttpGet]
        public IEnumerable<LinkDto> Get()
        {
            return db
                .Links
                .ToList()
                .Select(l => (LinkDto)l);
        }
 
        // GET api/Link/5
        [System.Web.Http.HttpGet]
        public LinkDto Get(int id)
        {
            return (LinkDto)db
                .Links
                .Find(id);
        }
 
        // POST api/Link
        [System.Web.Http.HttpPost]
        public IHttpActionResult CreateLink(LinkDto linkDto)
        {
            var newLink = (Link)linkDto;
            db.Links.Add(newLink);
            db.SaveChanges();
 
            return Ok(new
            {
                tid = newLink.Id,
                action = "inserted"
            });
        }
 
        // PUT api/Link/5
        [System.Web.Http.HttpPut]
        public IHttpActionResult EditLink(int id, LinkDto linkDto)
        {
            var clientLink = (Link)linkDto;
            clientLink.Id = id;
 
            db.Entry(clientLink).State = EntityState.Modified;
            db.SaveChanges();
 
            return Ok(new
            {
                action = "updated"
            });
        }
 
        // DELETE api/Link/5
        [System.Web.Http.HttpDelete]
        public IHttpActionResult DeleteLink(int id)
        {
            var link = db.Links.Find(id);
            if (link != null)
            {
                db.Links.Remove(link);
                db.SaveChanges();
            }
            return Ok(new
            {
                action = "deleted" 
            });
        }
 
        protected override void Dispose(bool disposing)
        {
            if (disposing)
            {
                db.Dispose();
            }
            base.Dispose(disposing);
        }
 
    }
}

Контроллер данных

Наконец, добавьте контроллер для обработки действия данных:

Controllers/DataController.cs

using System.Web.Http;
 
using DHX.Gantt.Web.Models;
 
namespace DHX.Gantt.Web.Controllers
{
    public class DataController : ApiController
    {
        // GET api/
        [System.Web.Http.HttpGet]
        public GanttDto Get()
        {
            return new GanttDto
            {
                data = new TaskController().Get(),
                links = new LinkController().Get()
            };
        }
    }
}

Как только все будет на месте, запуск приложения должен отобразить полностью функциональную диаграмму Ганта:

Проверьте демонстрацию на GitHub.


Обработка ошибок

Чтобы обрабатывать исключения в действиях CRUD и возвращать клиенту читаемые ответы, можно использовать фильтры исключений.

Вот как это можно настроить:

  1. В папке App_Start создайте новый класс под названием GanttAPIExceptionFilterAttribute:

App_Start/GanttAPIExceptionFilterAttribute.cs

using System.Net;
using System.Net.Http;
using System.Web.Http.Filters;
 
namespace DHX.Gantt.Web
{
    public class GanttAPIExceptionFilterAttribute : ExceptionFilterAttribute
    {
        public override void OnException(HttpActionExecutedContext context)
        {
 
            context.Response = context.Request.CreateResponse(
                HttpStatusCode.InternalServerError, new
                    {
                        action = "error",
                        message = context.Exception.Message
                    }
            );
        }
    }
}
  1. Добавьте этот класс ко всем WebAPI контроллерам:
  • Контроллер данных:

Controllers/DataController.cs

namespace DHX.Gantt.Web.Controllers
{
    [GanttAPIExceptionFilter]    public class DataController : ApiController
  • Контроллер связей:

Controllers/LinkController.cs

namespace DHX.Gantt.Web.Controllers
{
    [GanttAPIExceptionFilter]    public class LinkController : ApiController
  • Контроллер задач:

Controllers/TaskController.cs

namespace DHX.Gantt.Web.Controllers
{
    [GanttAPIExceptionFilter]    public class TaskController : ApiController

Это гарантирует, что любые исключения, вызванные контроллерами Web API, приведут к ответу об ошибке, который клиентская сторона Gantt сможет обработать.


Хранение порядка задач

Диаграмма Ганта поддерживает изменение порядка задач с помощью перетаскивания. Если вы используете эту функцию, вам нужно будет сохранить порядок в базе данных. Вот общее руководство о том, как это сделать.

Включение изменения порядка задач на клиенте

Чтобы позволить пользователям изменять порядок задач в пользовательском интерфейсе, необходимо внести некоторые изменения.

Начните с открытия представления Index и настройки конфигурации gantt:

Views/Home/Index.cshtml

gantt.config.order_branch = true;gantt.config.order_branch_free = true; 
// указание формата даты
gantt.config.date_format = "%Y-%m-%d %H:%i";
// инициализация gantt
gantt.init("gantt_here");

Добавление порядка задач в модель

Эти изменения также должны быть отражены на серверной стороне.

Чтобы хранить порядок, новое свойство с именем SortOrder будет добавлено в класс Task:

Models/Task.cs

using System;
using System.ComponentModel.DataAnnotations;
 
namespace DHX.Gantt.Web.Models
{
    public class Task
    {
        public int Id { get; set; }
        [MaxLength(255)]
        public string Text { get; set; }
        public DateTime StartDate { get; set; }
        public int Duration { get; set; }
        public decimal Progress { get; set; }
        public int? ParentId { get; set; }
        public string Type { get; set; }
        public int SortOrder { get; set; }    }
}

Контроллер TaskController также нуждается в обновлениях:

  • Задачи должны быть отправлены клиенту, отсортированными по значению SortOrder:

Controllers/TaskController.cs

namespace DHX.Gantt.Web.Controllers
{
    [GanttAPIExceptionFilter]
    public class TaskController : ApiController
    {
        private GanttContext db = new GanttContext();
 
        // GET api/Task
        public IEnumerable<TaskDto> Get()
        {
            return db.Tasks
                .OrderBy(t => t.SortOrder)                 .ToList()
                .Select(t => (TaskDto)t);
        }
  • Новые задачи должны автоматически получать значение SortOrder по умолчанию:

Controllers/TaskController.cs

namespace DHX.Gantt.Web.Controllers
{
    [System.Web.Http.HttpPost]
    public IHttpActionResult CreateTask(TaskDto taskDto)
    {
        var newTask = (Task)taskDto;
 
        newTask.SortOrder = db.Tasks.Max(t => t.SortOrder) + 1; 
        db.Tasks.Add(newTask);
        db.SaveChanges();
 
        return Ok(new
        {
            tid = newTask.Id,
            action = "inserted"
        });
    }
  • SortOrder должен обновляться при изменении порядка задач на клиенте.

Когда задачи переставляются, Gantt отправляет запрос PUT с новым положением в свойстве ['target'](Интеграция на стороне сервера), вместе с другими деталями задачи. Чтобы обработать это, в класс TaskDto добавляется дополнительное свойство:

Models/TaskDto.cs

namespace DHX.Gantt.Web.Models
{
  public class TaskDto
  {
    public int id { get; set; }
    public string text { get; set; }
    public string start_date { get; set; }
    public int duration { get; set; }
    public decimal progress { get; set; }
    public int? parent { get; set; }
    public string type { get; set; }
    public bool open{ get { return true; } set { } }
    public string target { get; set; } 
    ...
  }
}

Наконец, реализуйте логику изменения порядка в действии EditTask:

Controllers/TaskController.cs

    // PUT api/Task/5
    [System.Web.Http.HttpPut]
    public IHttpActionResult EditTask(int id, TaskDto taskDto)
    {
      var updatedTask = (Task)taskDto;
      updatedTask.Id = id;
 
      if (!string.IsNullOrEmpty(taskDto.target))
      {
        // произошло изменение порядка
        this._UpdateOrders(updatedTask, taskDto.target);      }
 
      db.Entry(updatedTask).State = EntityState.Modified;
      db.SaveChanges();
 
      return Ok(new
      {
        action = "updated"
      });
    }
 
    private void _UpdateOrders(Task updatedTask, string orderTarget)    {
      int adjacentTaskId;
      var nextSibling = false;
 
      var targetId = orderTarget;
 
      // идентификатор соседней задачи отправляется либо как '{id}', либо как 'next:{id}', в зависимости от того, является ли это следующей или предыдущей задачей
      if (targetId.StartsWith("next:"))
      {
        targetId = targetId.Replace("next:", "");
        nextSibling = true;
      }
 
      if (!int.TryParse(targetId, out adjacentTaskId))
      {
        return;
      }
 
      var adjacentTask = db.Tasks.Find(adjacentTaskId);
      var startOrder = adjacentTask.SortOrder;
 
      if (nextSibling)
        startOrder++;
 
      updatedTask.SortOrder = startOrder;
 
      var updateOrders = db.Tasks
       .Where(t => t.Id != updatedTask.Id)
       .Where(t => t.SortOrder >= startOrder)
       .OrderBy(t => t.SortOrder);
 
       var taskList = updateOrders.ToList();
 
       taskList.ForEach(t => t.SortOrder++);
    }

Известные проблемы

Запросы HTTP PUT и DELETE могут возвращать ошибки 405 или 401 при запуске приложения на IIS. Это может произойти из-за конфликта с модулем WebDAV. Чтобы решить эту проблему, отключите модуль в файле web.config. Подробнее об этом можно узнать здесь.

Безопасность приложения

Gantt не включает встроенную защиту от угроз, таких как SQL-инъекции, XSS или CSRF-атаки. Обеспечение безопасности приложения возлагается на разработчиков, работающих над серверной частью. Более подробную информацию можно найти в этой статье.

Устранение неполадок

Отсутствие шаблона ASP.NET Web Application

Если шаблон проекта "ASP.NET Web Application" недоступен в Visual Studio 2022, выполните следующие шаги:

  1. Закройте Visual Studio 2022.
  2. Откройте установщик Visual Studio из меню "Пуск".
  3. Найдите Visual Studio Community 2022 и нажмите Modify.

  4. В установщике перейдите к Individual components, отметьте опцию ".NET Framework Project and item templates" и нажмите Modify.

После этого снова откройте Visual Studio 2022, и шаблон должен быть доступен.

Исключение инициализации базы данных

Если возникает проблема с инициализатором DropCreateDatabaseIfModelChanges, который не создает новую базу данных, обновите GanttInitializer.cs, чтобы использовать DropCreateDatabaseAlways вместо:

App_Start/GanttInitializer.cs

using System;
using System.Collections.Generic;
using System.Data.Entity;
 
namespace DHX.Gantt.Web.Models
{
    public class GanttInitializer : DropCreateDatabaseAlways<GanttContext>     {
        ...
    }
}

Затем перезапустите приложение.

Проблемы с отображением задач и связей

Если задачи и связи не отображаются на странице после выполнения шагов, ознакомьтесь с руководством по устранению неполадок в статье Устранение проблем с интеграцией бэкенда для получения советов по выявлению проблемы.

Что дальше

Ваш грид теперь полностью функционален. Полный код доступен на GitHub, где вы можете клонировать или скачать его для своих проектов.

Для получения дополнительных функций и руководств ознакомьтесь с документацией по функциям или учебниками по интеграции Gantt с другими серверными фреймворками.

К началу