Features

  • Timeline with business hours only (nights and weekends are hidden)
  • Subsequent job phases linked visually
  • Spring Boot web application (Java 8)
  • Built using JavaScript Scheduler UI component from DayPilot Pro for JavaScript

This tutorial covers advanced scheduling features of DayPilot Pro. For a basic tutorial on using the Scheduler with Spring Boot please see Using JavaScript/HTML5 Scheduler in Spring Boot (Java).

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.

Scheduling Workflow Overview

Scheduling a New Job

machine-production-job-scheduling-spring-new-task.png

You can create a new task using drag and drop. Select the machine and the start/end time.

The schedule displays available machines on the Y axis (grouped by type) and the working hours on the X axis. The time axis is limited to business hours (defined as 9AM to 5PM from Monday to Friday in this example).

New Job Name

machine-production-job-scheduling-spring-new-task-name.png

As soon as you finish the time selection a modal dialog for entering the job name appears.

Job Color

machine-production-job-scheduling-spring-task-color.png

The job color can be changed using a context menu.

Scheduling Follow-Up Jobs

machine-production-job-scheduling-spring-next-job.png

The application supports easy scheduling of a follow-job. Start dragging the "ยป" icon in the lower-right corner of the scheduled job.

machine-production-job-scheduling-spring-next-job-icon.png

Dragging activates a placeholder that highlight the new job position.

machine-production-job-scheduling-spring-next-job-drag.png

The follow-up job is automatically linked to the previous job. It is created with a default duration (1 hour).

machine-production-job-scheduling-spring-next-job-created.png

Changing Job Duration

machine-production-job-scheduling-spring-next-job-resize.png

You can change the job duration by dragging one of the edges.

Deleting a Scheduled Job

machine-production-job-scheduling-spring-job-delete.png

You can also delete a job from the schedule using a context menu. It also deletes subsequent jobs.

Implementation

The application uses JavaScript Scheduler component from DayPilot Pro for JavaScript package. The Scheduler component includes built-in features that make the implementation easier.

Most of the functionality is implemented on the client side in a HTML5/JavaScript page. The backend is implemented in Spring Boot - it defines REST/JSON endpoints that let you load the data and save changes made by users.

This tutorial doesn't cover the Scheduler basics. For an introduction to using the Scheduler in a Spring Boot application please see the following tutorial:

Defining and Loading Machines

machine-production-job-scheduling-spring-boot-resources.png

The resource tree (machines and their groups) is loaded from the server using an AJAX call:

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

This URL ("/api/resources") returns a JSON object with the tree:

[
  {"name":"Cutting","children":[
    {"name":"Cutting Machine 1","id":1},
    {"name":"Cutting Machine 2","id":2}
    ],"ordinal":1,"id":"G1","expanded":true},
  {"name":"Welding","children":[
    {"name":"Welding Cell 1","id":3},
    {"name":"Welding Cell 2","id":4},
    {"name":"Welding Cell 3","id":5}
    ],"ordinal":2,"id":"G2","expanded":true},
  {"name":"Sandblasting","children":[
    {"name":"Blast Booth 1","id":6}
    ],"ordinal":3,"id":"G3","expanded":true}
]

Disabling Parent Rows

machine-production-job-scheduling-spring-boot-groups.png

We don't want the users to schedule jobs for the rows that represent groups. It's possible to disable the parent rows:

dp.preventParentUsage = true;

The grid cells that belong to machine groups are automatically marked with .scheduler_default_cellparent CSS class. We will use CSS to apply a light gray color to the parent cells:

.scheduler_default_main .scheduler_default_cell.scheduler_default_cellparent {
    background-color: #f0f0f0;
}

Business Hours

machine-production-job-scheduling-spring-boot-business-hours.png

We have configured the Scheduler to use a cell size of 1 hour:

dp.scale = "Hour";

The time headers will display day and hour names:

dp.timeHeaders = [
  {groupBy: "Day", format: "dddd M/d/yyyy"},
  {groupBy: "Hour"}
];

Our machines are in operation only during working hours, defined as 9am - 5pm:

dp.businessBeginsHour = 9;
dp.businessEndsHour = 18;

Displaying the full timeline would result in big gaps (9 hours of operating hours vs 15 hours of non-operating hours). That's why will hide the time outside of the operating hours:

dp.showNonBusiness = false;

We will also set the Scheduler to skip the hidden time during drag and drop operations (creating and moving jobs). This option will automatically adjust the start and end time as needed (it will be extended by the hidden time).

dp.eventMoveSkipNonBusiness = true;

machine-production-job-scheduling-spring-boot-skip.png

Context Menu

machine-production-scheduling-spring-boot-context-menu.png

The context menu lets users delete scheduled jobs (it uses an AJAX call to a backend URL to save the changes to a database).

It also allows changing the job color (4 colors are predefined). The selected color will be applied to all linked jobs.

dp.contextMenu = new DayPilot.Menu({
    items: [
        {
            text: "Delete",
            onClick: function(args) {
                var params = {
                    id: args.source.id(),
                };
                $.ajax({
                    type: 'POST',
                    url: '/api/events/delete',
                    data: JSON.stringify(params),
                    success: function (data) {
                        data.deleted.forEach(function(id) {
                            dp.events.removeById(id);
                        });
                        data.updated.forEach(function(data) {
                            var e = dp.events.find(data.id);
                            if (e) {
                                e.data.hasNext = data.hasNext;
                                dp.events.update(e);
                            }
                        });

                        dp.message("Job deleted");
                    },
                    contentType: "application/json",
                    dataType: 'json'
                });

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

    ]
});

Creating a Follow-Up Job using Drag and Drop

machine-production-scheduling-spring-boot-follow-up-job.png

The follow-up jobs can be scheduled using the follow-up icon in the lower-right corner of existing jobs. Only one follow-up job is allowed (the icon is automatically hidden if the follow-up job has been already defined).

The follow-up icon is created using an active area:

dp.onBeforeEventRender = function(args) {
    if (args.data.hasNext) {
        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: "»",
            toolTip: "Drag to schedule next step",
            action: "Move",
            data: "event-copy"
        }
    ];
};

We will provide real-time feedback to users during drag and drop using onEventMoving event handler. The event handler checks if the moving was initiated using the follow-up icon (args.areaData === "event-copy"). It displays a link to the original job (args.link):

    dp.onEventMoving = function(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";
            }
        }
    };

It also checks if the target position is valid (it must start after the original job has finished):

machine-production-scheduling-spring-boot-invalid-position.png

Job Editing

machine-production-job-scheduling-spring-boot-job-details.png

Clicking the job box opens a simple modal dialog that lets users change the job name.

The modal dialog can be replaced with a more complex form that would allow editing additional job properties.

dp.onEventClick = function(args) {
    DayPilot.Modal.prompt("Task name:", args.e.text()).then(function(modal) {
        if (!modal.result) {
            return;
        }
        var text = modal.result;
        var params = {
            join: args.e.data.join,
            text: text
        };
        $.ajax({
            type: 'POST',
            url: '/api/events/setText',
            data: JSON.stringify(params),
            success: function (data) {
                var list = data.events;
                list.forEach(function(data) {
                    var e = dp.events.find(data.id);
                    if (e) {
                        e.data.text = text;
                        dp.events.update(e);
                    }
                });
                dp.message("Text updated");
            },
            contentType: "application/json",
            dataType: 'json'
        });
    });
};

Job Dependencies

machine-production-job-scheduling-spring-boot-dependencies.png

The dependencies are displayed using event links. When the scheduler is initialized it requests the links from the server side:

dp.links.load("/api/links");

The /api/links URL returns a JSON array with the events links:

[
  {"id":1,"to":2,"from":1}
]

Server-Side Controller (Java)

Here is the source code of the MainController class which implements the REST API behind this application.

It's a Spring MVC controller that specifies endpoints for loading machines (resources), jobs (events), and dependencies (links). It also saves changes done by the user through the Scheduler UI.

package org.daypilot.demo.machinescheduling.controller;

import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import org.daypilot.demo.machinescheduling.domain.Event;
import org.daypilot.demo.machinescheduling.domain.Link;
import org.daypilot.demo.machinescheduling.domain.Resource;
import org.daypilot.demo.machinescheduling.domain.ResourceGroup;
import org.daypilot.demo.machinescheduling.repository.EventRepository;
import org.daypilot.demo.machinescheduling.repository.LinkRepository;
import org.daypilot.demo.machinescheduling.repository.ResourceGroupRepository;
import org.daypilot.demo.machinescheduling.repository.ResourceRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.format.annotation.DateTimeFormat.ISO;
import org.springframework.web.bind.annotation.*;

import javax.transaction.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

@RestController
public class MainController {

    @Autowired
    EventRepository er;

    @Autowired
    ResourceRepository rr;

    @Autowired
    ResourceGroupRepository gr;

    @Autowired
    LinkRepository lr;

    @RequestMapping("/api/resources")
    List<ResourceGroup> resources() {
        List<ResourceGroup> groups = gr.findAllByOrderByOrdinal();

        groups.forEach(resourceGroup -> {
            List<Resource> children = rr.findByParent(resourceGroup);
            resourceGroup.setChildren(children);
        });

        return groups;
    }

    @GetMapping("/api/events")
    @JsonSerialize(using = LocalDateTimeSerializer.class)
    Iterable<Event> events(@RequestParam("start") @DateTimeFormat(iso = ISO.DATE_TIME) LocalDateTime start, @RequestParam("end") @DateTimeFormat(iso = ISO.DATE_TIME) LocalDateTime end) {
        return er.findBetween(start, end);
    }

    @GetMapping("/api/links")
    @JsonSerialize(using = LocalDateTimeSerializer.class)
    Iterable<Link> links(@RequestParam("start") @DateTimeFormat(iso = ISO.DATE_TIME) LocalDateTime start, @RequestParam("end") @DateTimeFormat(iso = ISO.DATE_TIME) LocalDateTime end) {
        return lr.findAll();
    }

    @PostMapping("/api/events/create")
    @JsonSerialize(using = LocalDateTimeSerializer.class)
    @Transactional
    UpdateResponse createEvent(@RequestBody EventCreateParams params) {

        List<Event> update = new ArrayList<>();

        Resource r = rr.findOne(params.resource);

        Event e = new Event();
        e.setStart(params.start);
        e.setEnd(params.end);
        e.setText(params.text);
        e.setResource(r);
        er.save(e);

        e.setJoin(e.getId());
        er.save(e);

        update.add(e);

        if (params.link != null) {
            Event from = er.findOne(params.link.from);
            from.setHasNext(true);
            er.save(from);

            update.add(from);

            Link link = new Link();
            link.setFrom(from);
            link.setTo(e);
            lr.save(link);

            e.setText(from.getText());
            e.setJoin(from.getJoin());
            e.setColor(from.getColor());
            er.save(e);
        }

        return new UpdateResponse(){{
            events = update;
        }};
    }

    @PostMapping("/api/events/move")
    @JsonSerialize(using = LocalDateTimeSerializer.class)
    @Transactional
    Event moveEvent(@RequestBody EventMoveParams params) {

        Event e = er.findOne(params.id);
        Resource r = rr.findOne(params.resource);

        e.setStart(params.start);
        e.setEnd(params.end);
        e.setResource(r);

        er.save(e);

        return e;
    }

    @PostMapping("/api/events/delete")
    @JsonSerialize(using = LocalDateTimeSerializer.class)
    @Transactional
    EventDeleteResponse deleteEvent(@RequestBody EventDeleteParams params) {

        List<Long> deletedIds = new ArrayList<>();
        List<Event> updatedEvents = new ArrayList<>();

        Event e = er.findOne(params.id);

        List<Link> previous = lr.findByTo(e);
        previous.forEach(link -> {
            link.getFrom().setHasNext(false);
            er.save(link.getFrom());

            updatedEvents.add(link.getFrom());
        });

        deleteEventWithLinks(e, deletedIds);


        return new EventDeleteResponse() {{
            updated = updatedEvents;
            deleted = deletedIds;
        }};
    }

    private void deleteEventWithLinks(Event e, List<Long> deleted) {
        List<Link> toLinks = lr.findByTo(e);
        lr.delete(toLinks);

        List<Link> fromLinks = lr.findByFrom(e);
        fromLinks.forEach(link -> {
            deleteEventWithLinks(link.getTo(), deleted);
        });

        er.delete(e);
        deleted.add(e.getId());

    }


    @PostMapping("/api/events/setColor")
    @JsonSerialize(using = LocalDateTimeSerializer.class)
    @Transactional
    UpdateResponse setColor(@RequestBody SetColorParams params) {

        List<Event> list = er.findByJoin(params.join);
        list.forEach(e -> {
            e.setColor(params.color);
            er.save(e);
        });

        return new UpdateResponse() {{
            events = list;
        }};
    }

    @PostMapping("/api/events/setText")
    @JsonSerialize(using = LocalDateTimeSerializer.class)
    @Transactional
    UpdateResponse setText(@RequestBody SetTextParams params) {

        List<Event> list = er.findByJoin(params.join);
        list.forEach(e -> {
            e.setText(params.text);
            er.save(e);
        });

        return new UpdateResponse() {{
            events = list;
        }};
    }

    public static class EventCreateParams {
        public LocalDateTime start;
        public LocalDateTime end;
        public String text;
        public Long resource;
        public LinkCreateParams link;
    }

    public static class LinkCreateParams {
        public Long from;
    }

    public static class EventMoveParams {
        public Long id;
        public LocalDateTime start;
        public LocalDateTime end;
        public Long resource;
    }

    public static class SetColorParams {
        public Long join;
        public String color;
    }

    public static class SetTextParams {
        public Long join;
        public String text;
    }

    public static class EventDeleteParams {
        public Long id;
    }

    public static class EventDeleteResponse {
        public List<Long> deleted;
        public List<Event> updated;
    }

    public static class UpdateResponse {
        public List<Event> events;
    }


}