Overview

  • Schedule the production workflow using a visual tool.

  • Jobs consist of multiple tasks, each of which is performed on a different machine.

  • The task dependence is highlighted using links.

  • Use custom job colors to differentiate the tasks.

  • Use drag and drop to change the schedule.

  • Quickly review availability of individual machines.

  • Includes a trial version of DayPilot Pro for JavaScript (see License below)

License

Licensed for testing and evaluation purposes. Please see the license agreement included in the sample project. You can use the source code of the tutorial if you are a licensed user of DayPilot Pro for JavaScript. Buy a license.

Visualize Your Production Workflow

This ASP.NET Core application lets you manage a production schedule of jobs that consist of multiple tasks performed on several types of machines. It’s suitable for planning jobs that are not of the same type. Jobs can require different production steps and each of the tasks can take different time.

  • We will use ASP.NET Core with Entity Framework and SQL Server to build the REST backend of our application.

  • The frontend will be created in HTML5 and JavaScript, using JavaScript Scheduler component to display the main scheduling grid.

For an introduction to using the JavaScript Scheduler in ASP.NET Core applications, please see the following tutorial:

Machine Job Scheduling

asp.net-core-production-workflow-scheduling-machines.png

We will start with a basic HTML5/JavaScript page and a minimal JavaScript Scheduler configuration generated using DayPilot Scheduler UI Builder:

Index.cshtml

<script src="~/lib/daypilot/daypilot-all.min.js" asp-append-version="true"></script>

<div id="dp"></div>

<script>
    var dp = new DayPilot.Scheduler("dp", {
        startDate: DayPilot.Date.today().firstDayOfMonth(),
        days: DayPilot.Date.today().daysInMonth(),
        scale: "Hour",
        businessBeginsHour: 9,
        businessEndsHour: 18,
        showNonBusiness: false,
        eventMoveSkipNonBusiness: true,
        timeHeaders: [
            { groupBy: "Day", format: "dddd M/d/yyyy" },
            { groupBy: "Hour" }
        ],
    });
    dp.init();
</script>

This configuration displays an empty Scheduler with no rows. In order to display the main grid, we need to display our production machines in rows. The Scheduler can load the row data from a remote URL that returns a JSON array using rows.load() method. The content of the response will be assigned to the resources property so we need follow the structure in our REST endpoint.

JavaScript

var dp = new DayPilot.Scheduler("dp", {

    // ...

    treeEnabled: true,
    treePreventParentUsage: true,

    // ...

});
dp.init();

dp.rows.load("/api/Machines");

And this is the MachinesController class that defines the /api/Machines REST endpoint:

ASP.NET Core

[Route("api/[controller]")]
[ApiController]
public class MachinesController : ControllerBase
{
    private readonly ProductionContext _context;

    public MachinesController(ProductionContext context)
    {
        _context = context;
    }

    // GET: api/Machines
    [HttpGet]
    public async Task<ActionResult<IEnumerable>> GetMachines()
    {
        return await _context.Groups
            .Include(g => g.Machines)
            .Select(g => new {Id = "G" + g.Id, Expanded = true, Children = g.Machines, Name = g.Name})
            .ToListAsync();
    }

}

Create a New Production Job

asp.net-core-production-workflow-scheduling-create-job-drag.png

Now we need to add the ability to create new production jobs using drag and drop. The Scheduler enables time range selection by default, we just need to add an event handler that will open a modal dialog with details of the new job:

asp.net-core-production-workflow-scheduling-create-job-dialog.png

We use DayPilot.Modal.prompt() method to display the modal dialog (it’s a simple JavaScript prompt() method replacement - you can use DayPilot.Modal.form() to create modal dialogs with more complex forms).

When the user confirms the input by clicking “OK”, we will create a new task (and start a new job) using the /api/Tasks endpoint.

The task is added to the Scheduler using events.add() method in the success event handler.

JavaScript

var dp = new DayPilot.Scheduler("dp", {

    // ...
    
    onTimeRangeSelected: args => {
        DayPilot.Modal.prompt("Create a new job:", "Job").then(modal => {
            dp.clearSelection();
            if (modal.canceled) {
                return;
            }
            const data = {
                start: args.start,
                end: args.end,
                text: modal.result,
                resource: args.resource
            };
            DayPilot.Http.ajax({
                url: "/api/Tasks",
                method: "POST",
                data,
                success: ajax => {
                    const created = ajax.data;
                    dp.events.add(created);
                    dp.message("Job created");
                }
            });

        });
    },
    
    // ...
});

And here is the implementation of the POST handler for /api/Tasks REST endpoint.

It’s a standard method generated using the API Controller scaffolding extensions in Visual Studio. It’s extended to handle the creation of follow-up tasks which we are going discuss later.

ASP.NET Core

// POST: api/Tasks
// To protect from overposting attacks, see https://go.microsoft.com/fwlink/?linkid=2123754
[HttpPost]
public async Task<ActionResult<Task>> PostTask(TaskWithLink task)
{
    Task previous = null;
    if (task.LinkFrom != null)
    {
        previous = await _context.Tasks.FindAsync(task.LinkFrom);
        if (previous == null)
        {
            return BadRequest();
        }
    }

    _context.Tasks.Add(task);
    await _context.SaveChangesAsync();

    if (previous != null)
    {
        previous.NextId = task.Id;
        task.Text = previous.Text;
        task.JobId = previous.JobId;
        task.Color = previous.Color;
        _context.Tasks.Update(previous);
    }
    else
    {
        task.JobId = task.Id;
        _context.Tasks.Update(task);
    }

    await _context.SaveChangesAsync();

    return CreatedAtAction("GetTask", new {id = task.Id}, task);
}

When the REST API call is completed the Scheduler displays the new task:

asp.net-core-production-workflow-scheduling-job-created.png

Production Workflow: Create a Next Task

asp.net-core-production-workflow-scheduling-linked-task.png

Our application will support creating linked tasks using drag and drop. In the first step, we will customize the task appearance using onBeforeEventRender event handler and add an icon that will let users create a follow-up task directly.

The follow-up icon is created using event active areas - custom elements added to the Scheduler events with a defined appearance and behavior.

  • Our active are will display an icon defined using html, style and backColor properties.

  • The icon will be placed in the lower-right corner of the event (right and bottom properties).

  • The action is set to "Move" - that will activate event moving on mousedown.

  • The data property holds "event-copy" string - that will let us recognize the drag action type later.

JavaScript

var dp = new DayPilot.Scheduler("dp", {

    // ...

    onBeforeEventRender: args => {
        args.data.barColor = args.data.color;

        const duration = new DayPilot.Duration(args.data.start, args.data.end);
        args.data.html = `<div><b>${args.data.text}</b><br>${duration.toString("h")} hours</div>`;

        if (args.data.nextId) {
            return;
        }
        args.data.areas = [
            {
                right: 2,
                bottom: 2,
                width: 16,
                height: 16,
                backColor: "#fff",
                style: "box-sizing: border-box; border-radius: 7px; padding-left: 3px; border: 1px solid #ccc;font-size: 14px;line-height: 14px;color: #999;",
                html: "&raquo;",
                toolTip: "Drag to schedule the next step",
                action: "Move",
                data: "event-copy"
            }
        ];
    },
    
    // ...
});

This is how the drag handler icon looks now:

asp.net-core-production-workflow-scheduling-next-task-icon.png

Now we will use the onEventMoving event handler to customize the appearance during dragging.

  • By default, the Scheduler displays a placeholder at the target location. That will work for use, but we will use the onEventMoving event handler to add a dynamic link from the source task.

  • We will also perform a basic check of the constraints - it will not be possible to place the follow-up task before the end of the previous task. If the target position doesn’t meet the rule, we will disable it using args.allowed property.

var dp = new DayPilot.Scheduler("dp", {

    // ...

    onEventMoving: args => {
        if (args.areaData && args.areaData === "event-copy") {
            args.link = {
                from: args.e,
                color: "#666"
            };
            args.start = args.end.addHours(-1);
            if (args.e.end() > args.start) {
                args.allowed = false;
                args.link.color = "red";
            }
        }
    },
    
    // ...
});

This is how the target placeholder with the link looks:

asp.net-core-production-workflow-scheduling-drag-hint.png

After dropping the placeholder at the desired location, the Scheduler fires onEventMove event handler.

We need to detect if the drag action was started by the active area (args.areaData === "event-copy"). In that case, we create the next task and a link from the source task.

We use a POST request to /api/Tasks like we did when creating a new job/task. This time, we specify the source task id using linkFrom property so it can be recognized on the server side.

When the POST request is complete, we display the new task using events.add(), create a link between the source and new tasks using links.add() and update the source event using events.update() (to hide the follow-up task drag handle).

var dp = new DayPilot.Scheduler("dp", {

    // ...

    onEventMove: args => {
        if (args.areaData === "event-copy") {
            args.preventDefault();

            const data = {
                start: args.newStart,
                end: args.newEnd,
                resource: args.newResource,
                linkFrom: args.e.data.id
            };
            DayPilot.Http.ajax({
                url: "/api/Tasks",
                data,
                success: ajax => {
                    const created = ajax.data;

                    // new task
                    dp.events.add(created);

                    // link
                    dp.links.add({
                        id: args.e.data.id,
                        from: args.e.data.id,
                        to: created.id
                    });

                    // update the source task
                    args.e.data.nextId = created.id;
                    dp.events.update(args.e);

                    dp.message("Task created");
                }
            });

        }
        else {
            const data = Object.assign({}, args.e.data);
            data.start = args.newStart;
            data.end = args.newEnd;
            data.resource = args.resource;

            DayPilot.Http.ajax({
                url: `/api/Tasks/${args.e.data.id}`,
                method: "PUT",
                data,
                success: () => console.log("Task moved.") 
            });
        }

    },
    
    // ...
});

The is the workflow visualization after the additional task is created:

asp.net-core-production-workflow-scheduling-new-task.png

Production Workflow: Delete Tasks

asp.net-core-production-workflow-scheduling-delete-task.png

In this step, we will add a context menu that will let us delete individual tasks.

The context menu can be defined using contextMenu property. When clicking the “Delete” item, the Scheduler will call the onClick event handler which we will use to send a request to the ASP.NET Core backend (DELETE /api/Tasks/ID).

Context menu:

var dp = new DayPilot.Scheduler("dp", {

    // ...

    contextMenu: new DayPilot.Menu({
        items: [
            {
                text: "Delete",
                onClick: args => {
                    DayPilot.Http.ajax({
                        url: `/api/Tasks/${args.source.data.id}`,
                        method: "DELETE",
                        success: ajax => {
                            ajax.data.updated.forEach(d => {
                                dp.events.update(d);
                                dp.links.remove(d.id);
                            });
                            ajax.data.deleted.forEach(id => {
                                dp.events.remove(id);
                                dp.links.remove(id);
                            });
                            dp.message("Job deleted");
                        }
                    });

                }
            },
            {
                text: "-"
            },
            {
                text: "Blue",
                icon: "icon icon-blue",
                color: "#1155cc",
                onClick: args => updateColor(args.source, args.item.color)
            },
            {
                text: "Green",
                icon: "icon icon-green",
                color: "#6aa84f",
                onClick: args => updateColor(args.source, args.item.color)
            },
            {
                text: "Yellow",
                icon: "icon icon-yellow",
                color: "#f1c232",
                onClick: args => updateColor(args.source, args.item.color)
            },
            {
                text: "Red",
                icon: "icon icon-red",
                color: "#cc0000",
                onClick: args => updateColor(args.source, args.item.color)
            }
        ]
    }),

    
    // ...
});

The DeleteTask() method will also check if the tasks to be deleted has any follow-up tasks and delete them as well.

It returns a list of tasks that were deleted (deleted array) and updated (updated array).

ASP.NET Core (DELETE /api/Task)

[Route("api/[controller]")]
[ApiController]
public class TasksController : ControllerBase
{
    private readonly ProductionContext _context;

    public TasksController(ProductionContext context)
    {
        _context = context;
    }


    // DELETE: api/Tasks/5
    [HttpDelete("{id}")]
    public async Task<ActionResult<TaskChanges>> DeleteTask(int id)
    {
        var task = await _context.Tasks.FindAsync(id);
        if (task == null)
        {
            return NotFound();
        }

        var changes = new TaskChanges()
        {
            Deleted = new List<int>(),
            Updated = new List<Task>()
        };

        _context.Tasks.Remove(task);
        changes.Deleted.Add(task.Id);

        int? next = task.NextId;
        while (next != null)
        {
            var e = await _context.Tasks.FindAsync(next);
            if (e == null)
            {
                return BadRequest();
            }
            _context.Tasks.Remove(e);
            changes.Deleted.Add(e.Id);
            next = e.NextId;
        }

        var previous = await _context.Tasks.Where(e => e.NextId == id).FirstAsync();
        if (previous != null)
        {
            previous.NextId = null;
            _context.Tasks.Update(previous);
            changes.Updated.Add(previous);
        }


        await _context.SaveChangesAsync();

        return changes;
    }

}

Data Model: Entity Framework and SQL Server

The project includes an Entity Framework migration that will create and initialize the database DayPilot.TutorialAspNetCoreProduction.mdf connected using LocalDB.

In order to initialize the database, use the Update-Database command.

The migration creates three database tables:

  • Groups

  • Machines

  • Tasks

The Groups and Machines table will be initialized with resources.

Data.cs

using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore;

namespace Project.Models
{
    public class ProductionContext : DbContext
    {
        public DbSet<Task> Tasks { get; set; }
        public DbSet<Machine> Machines { get; set; }
        public DbSet<Group> Groups { get; set; }

        public ProductionContext(DbContextOptions<ProductionContext> options) : base(options) { }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Group>().HasData(new Group { Id = 1, Name = "Cutting" });
            modelBuilder.Entity<Group>().HasData(new Group { Id = 2, Name = "Welding" });
            modelBuilder.Entity<Group>().HasData(new Group { Id = 3, Name = "Sandblasting" });
            
            modelBuilder.Entity<Machine>().HasData(new Machine { Id = 1, Name = "Cutting Machine 1", GroupId = 1 });
            modelBuilder.Entity<Machine>().HasData(new Machine { Id = 2, Name = "Cutting Machine 2", GroupId = 1 });
            modelBuilder.Entity<Machine>().HasData(new Machine { Id = 3, Name = "Cutting Machine 3", GroupId = 1 });

            modelBuilder.Entity<Machine>().HasData(new Machine { Id = 4, Name = "Welding Cell 1", GroupId = 2 });
            modelBuilder.Entity<Machine>().HasData(new Machine { Id = 5, Name = "Welding Cell 2", GroupId = 2 });
            modelBuilder.Entity<Machine>().HasData(new Machine { Id = 6, Name = "Welding Cell 3", GroupId = 2 });

            modelBuilder.Entity<Machine>().HasData(new Machine { Id = 7, Name = "Blast Booth 1", GroupId = 3 });
            modelBuilder.Entity<Machine>().HasData(new Machine { Id = 8, Name = "Blast Booth 2", GroupId = 3 });

        }
    }

    public class Task
    {
        public int Id { get; set; }
        public DateTime Start { get; set; }
        public DateTime End { get; set; }
        
        [JsonPropertyName("resource")]
        public int ResourceId { get; set; }
        
        public string Text { get; set; }

        public string Color { get; set; }

        public int JobId { get; set; }

        public int? NextId { get; set; }
    }

    public class Group
    {
        public int Id { get; set; }
        public string Name { get; set; }

        [JsonPropertyName("children")]
        public ICollection<Machine> Machines { get; set; }

    }

    public class Machine
    {
        public int Id { get; set; }
        public string Name { get; set; }

        [JsonIgnore]
        public int GroupId { get; set; }
    }

}