Overview

  • This ASP.NET Core application lets you schedule employee shifts for multiple locations.

  • You can switch between locations easily and view the shifts from a different perspective.

  • You can see the employee availability immediately.

  • The Scheduler displays total hours planned for each resource (location, employee).

  • The application uses a HTML5 frontend with implemented in JavaScript that uses the JavaScript Scheduler component from DayPilot Pro for JavaScript library to show the shift data.

  • The backend is implemented using ASP.NET Core API Controllers (REST API).

  • C# and .NET 7

  • Entity Framework 7

  • Visual Studio 2022 project

  • 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.

Scheduling Employee Shifts in ASP.NET Core

You can use this ASP.NET Core application to assign shifts to locations (or positions) and workers. This tutorial focuses on the visual representation of the shifts and drag, and management of the shift assignments using drag and drop.

  • The horizontal axis of the ASP.NET Core Scheduler component displays the timeline (shift slots).

  • The vertical axis displays the selected location (in the first row) and all active employees.

  • The attached ASP.NET Core application defines 3 different locations/positions and 6 employees. These resources are loaded from the database and you can add/modify them as needed.

The Scheduler UI lets you create shift assignments using the following steps:

1. Select a location using the dropdown list.

  • The Scheduler will switch the view to display details of shifts scheduler for the selected location.

  • Each assignment is represented by two event boxes (in the location row and in the employee row), connected using an orange link.

asp.net core shift planning select location

2. To create a new shift assignment, click the selected shift slot in the grid.

  • This will create a new shift assignment record for the selected date and employee.

  • It is not possible to create a new assignment in a slot that is already used.

  • The shift assignment can’t be created in the top row (which displays data for the selected location).

asp.net core shift planning select employee slot

3. Confirm creation of a new shift assignment:

asp.net core shift planning confirm assignment

4. The Scheduler will create a two linked boxes in the grid - in the top row (which displays an overview for the location) and in the employee row (it displays all shifts assigned to an employee).

  • The new assignment is saved in the SQL Server database (using an API call).

asp.net core shift planning assignment

Storing and Displaying Shift Assignments

asp.net core shift planning storing and displaying assignments

Each shift assignment is stored as a single record in the database (SQL Server/LocalDB).

The assignments use the following model class:

public class Assignment
{
    public int Id { get; set; }

    public int EmployeeId { get; set; }

    [JsonIgnore]
    public Employee Employee { get; set; }

    public int LocationId { get; set; }

    [JsonIgnore]
    public Location Location { get; set; }

    public DateTime Start { get; set; }

    public DateTime End { get; set; }
}

You can see that it stores the basic information (Id, Start, End) and a link to the assigned employee (EmployeeId) and location (LocationId).

The AssignmentsController loads assignments from the SQL Server database.

  • The selection is limited to the date range specified using start and end query string parameters.

  • The GetAssignments() method returns the data in JSON format, using a special ShiftAssignment class.

namespace Project.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class AssignmentsController : ControllerBase
    {
        private readonly ShiftDbContext _context;

        public AssignmentsController(ShiftDbContext context)
        {
            _context = context;
        }

        // GET: api/Assignments
        [HttpGet]
        public async Task<ActionResult<IEnumerable<Assignment>>> GetAssignments([FromQuery] DateTime start, [FromQuery] DateTime end)
        {
          if (_context.Assignments == null)
          {
              return NotFound();
          }

          List<Assignment> list = await _context.Assignments.Where(e => !((e.End <= start) || (e.Start >= end))).ToListAsync();

          List<ShiftAssignment> result = new List<ShiftAssignment>();
          foreach (var assignment in list)
          {
              var a1 = new ShiftAssignment
              {
                  Id = assignment.Id,
                  Start = assignment.Start,
                  End = assignment.End,
                  EmployeeId = assignment.EmployeeId,
                  LocationId = assignment.LocationId
              };
              result.Add(a1);
          }

          return Ok(result);

        }
        
        // ...

    }

    public class ShiftAssignment
    {
        public int Id { get; set; }
        public DateTime Start { get; set; }
        public DateTime End { get; set; }
        public int EmployeeId { get; set; }
        public int LocationId { get; set; }
    }

}

This is a sample JSON response with shift data:

[
  {
    "id": 11,
    "start": "2022-09-05T08:00:00",
    "end": "2022-09-05T16:00:00",
    "employeeId": 2,
    "locationId": 1
  }
]

On the client side, we load the assignments whenever the user changes the location selection using in the drop-down list.

When the selection changes, the application calls activateLocation() function that loads the data, transforms it and updates the JavaScript Scheduler component.

  • It loads fresh data (including employees and shift assignments).

  • It transforms the shift assignment data (each assignment active in the current view will be displayed as two boxes)

  • It connects the related shift assignments using a link.

The assignments and employees are loaded using two separate HTTP calls. We execute them in parallel and wait for both of them to complete (to avoid double update of the Scheduler component):

const promiseEmployees = DayPilot.Http.get("/api/Employees");
const promiseAssignments = DayPilot.Http.get(`/api/Assignments?start=${start}&end=${end}`);
const [{data:resources}, {data:assignments}] = await Promise.all([promiseEmployees, promiseAssignments]);

The resources array now holds the employee data loaded from /api/Employees endpoint. The items already use the correct format that is needed to load them as rows in the Scheduler. Because we want to display the current location as the first row, we insert a new item at the top:

resources.splice(0, 0, { id: "L" + item.id, name: item.name, type: "location" });

You can see that the location row will use “L” as the id prefix.

  • Our database uses a standard identity column (id) for [Employees] and [Locations] tables.

  • As we mix resources of two types in the same view, it could result in duplicate ID values.

  • The “L” prefix makes the location row id unique and helps us identify the row type.

Now we can transform the assignments.

const events = [];
const links = [];
assignments.forEach(e => {
    if (e.locationId === item.id) {
        // location row
        events.push({
            ...e,
            id: "L" + e.id,
            text: "",
            resource: "L" + e.locationId,
            join: e.id,
            type: "location"
        });

        // employee row
        events.push({
            ...e,
            text: "",
            resource: e.employeeId,
            join: e.id
        });

        // link connecting the events
        links.push({
            from: "L" + e.id,
            to: e.id,
            type: "FinishToFinish",
            color: "#e69138"
        });
    }
});

Each record is be represented by two boxes, connected with a link.

  • The first event box will be displayed in the “Location” row. It uses the locationId (correctly prefixed with "L") as the resource field. The resource field will be used to determine the target row.

  • The second event box will be displayed in the corresponding “Employee” row. The resource field uses the personId value.

Now we can update the Scheduler component:

scheduler.update({
  resources,
  events,
  links
});

This is the complete activateLocation() function:

const app = {
  async activateLocation(location) {
    let item = location;
    if (typeof location !== "object") {
        item = app.findLocation(location);
    }

    const start = scheduler.visibleStart();
    const end = scheduler.visibleEnd();

    const promiseEmployees = DayPilot.Http.get("/api/Employees");
    const promiseAssignments = DayPilot.Http.get(`/api/Assignments?start=${start}&end=${end}`);

    const [{data:resources}, {data:assignments}] = await Promise.all([promiseEmployees, promiseAssignments]);

    resources.splice(0, 0, { id: "L" + item.id, name: item.name, type: "location" });

    const events = [];
    const links = [];
    assignments.forEach(e => {
        if (e.locationId === item.id) {
            // location row
            events.push({
                ...e,
                id: "L" + e.id,
                text: "",
                resource: "L" + e.locationId,
                join: e.id,
                type: "location"
            });

            // person row
            events.push({
                ...e,
                text: "",
                resource: e.employeeId,
                join: e.id
            });

            // link connecting the events
            links.push({
                from: "L" + e.id,
                to: e.id,
                type: "FinishToFinish",
                color: "#e69138"
            });
        } else {

            // inactive assignment
            events.push({
                ...e,
                text: "",
                resource: e.employeeId,
                join: e.id,
                type: "inactive"
            });
        }
    });

    scheduler.update({
        resources,
        events,
        links
    });
  }
}

Shifts at Different Locations

asp.net core shift planning other worker assignments

The shift scheduler view is focused on displaying shifts for the selected location. However, we want to display the employee assignments for the other locations as well.

  • To make it cleat that they don’t belong to the current location, these assignments use a gray background color.

  • These “inactive” assignments provide context - you can see all assignments for each worker in each row.

  • It also prevents the shift administrator from assigning shifts to employees who are not available.

Drag and Drop Shift Scheduling

The Scheduler component includes built-in support for drag and drop. We will use this feature to add an ability to reschedule the shifts easily.

To change the time slot, you can drag the item horizontally in the location row to a new time slots. The assigned worker will remain unchanged.

asp.net core shift planning drag drop time slot

You can also use drag and drop to assign the shift slot to another employee. Drag the bottom box to another row to change the worker assignment:

asp.net core shift planning drag drop employee

Calculating Totals for Each Employee/Row

asp.net core shift planning totals

The Scheduler component can display data related to rows in additional columns. We will define a special column that will display the total shift time for each row.

  • The first row displays the total time scheduled for the selected location.

  • The employee rows display a shift total for the respective worker.

const scheduler = new DayPilot.Scheduler("dp", {

    // ...

    rowHeaderColumns: [
        { name: "Name", display: "name" },
        { name: "Total" }
    ],
    onBeforeRowHeaderRender: (args) => {
        const duration = args.row.events.totalDuration();
        const columnTotal = args.row.columns[1];
        if (duration.totalHours() > 0 && columnTotal) {
            columnTotal.text = duration.totalHours() + "h";
        }
    },

    // ...

});

The total time is calculated in hours but you can also switch to shifts.

const scheduler = new DayPilot.Scheduler("dp", {

    // ...

    onBeforeRowHeaderRender: (args) => {
        const shifts = args.row.events.all().length;
        const columnTotal = args.row.columns[1];
        if (duration.totalHours() > 0 && columnTotal) {
            columnTotal.text = shifts;
        }
    },

    // ...

});

Defining Shift Slots (Timeline)

asp.net core shift planning timeline

In most standard views, the JavaScript Scheduler component uses pre-defined slot units (such as Hour, Day, Week, etc.). You only define the slot size, start date and the number of days and the timeline (displayed on the horizontal axis) will be generated automatically.

We need to show three 8-hour shifts per day, starting at 12am, 8am, and 4pm. The Scheduler doesn’t have a special scale value for this scenario but can generate the timeline manually and define all slots one by one.

  • When you add scale: "Manual" to the config, the Scheduler will use slots defined using the timeline property.

  • We will use the app.getTimeline() function to generate the time slots.

Scheduler configuration:

const scheduler = new DayPilot.Scheduler("dp", {
    timeHeaders: [{ groupBy: "Month" }, { groupBy: "Day", format: "dddd M/d/yyyy" }, { groupBy: "Cell" }],
    startDate: "2022-07-01",
    days: 31,
    onBeforeTimeHeaderRender: (args) => {
        if (args.header.level === 2) {
            args.header.text = args.header.start.toString("h") + args.header.start.toString("tt").substring(0, 1).toLowerCase();
        }
    },
    scale: "Manual",
    timeline: app.getTimeline(),
    // ...
});

And this is the app.getTimeline() function that defines the shift slots:

const app = {
    // ...
    getTimeline() {
        const days = DayPilot.Date.today().daysInMonth();
        const start = DayPilot.Date.today().firstDayOfMonth();

        const result = [];
        for (let i = 0; i < days; i++) {
            const day = start.addDays(i);
            result.push({
                start: day.addHours(0),
                end: day.addHours(8)
            });
            result.push({
                start: day.addHours(8),
                end: day.addHours(16)
            });
            result.push({
                start: day.addHours(16),
                end: day.addHours(24)
            });
        }
        return result;
    },

};

You can modify the getTimeline() function to use different shift start hours and/or different shift duration (and the number of shifts per day).

Styling the Shift Assignment using CSS and Active Areas

First, we will enable rounded corners by overriding the event CSS class from the default CSS theme.

<style>
    .scheduler_default_event_inner {
        border-radius: 20px;
    }
</style>

The default CSS theme is scheduler_default. If you keep using the default theme, the assignments will be marked with scheduler_default_event CSS class (the top-level <div> element) and scheduler_default_event_inner CSS class (the inner <div> element). All styling (such as background color, border) is applied to the inner div so we will override it.

For further appearance customization of the shift assignment, we will use the onBeforeEventRender event handler:

First, we hide the default duration bar:

args.data.barHidden = true;

Then we set color of the event background, border and font:

args.data.fontColor = "#ffffff";
args.data.backColor = "#6fa7d4";
args.data.borderColor = "darker";

As the final step, we will add a circle with an abbreviation of the assignment text using an active area. You can also replace the abbreviated text with a custom icon.

onBeforeEventRender: (args) => {
    args.data.barHidden = true;
    args.data.fontColor = "#ffffff";
    args.data.backColor = "#6fa7d4";
    args.data.borderColor = "darker";

    // dot
    const short = app.initials(args.data.text);
    args.data.areas = {
        left: 0,
        top: 0,
        width: 40,
        height: 40,
        style: "border-radius: 36px; font-size: 20px; font-weight: bold; display: flex; align-items: center; justify-content: center;",
        backColor: DayPilot.ColorUtil.darker(args.data.backColor),
        fontColor: "#ffffff",
        text: short
    };
}

The app.initials() function extracts the initial letter from the first two words of the shift assignment text description:

const app = {
  initials(str) {
      if (typeof str !== "string") {
          return "";
      }
      return str.split(" ").slice(0, 2).map(w => w[0] && w[0].toUpperCase()).join("");
  },
  
  // ...
  
};

Deleting Shift Assignments

asp.net core shift planning delete assignments

In our shift scheduling application , you can delete the assignments using a “delete” icon that is displayed on the right side of the assignment boxes in the Scheduler.

  • You can add the icon using event active areas.

  • We will only add the icon to the main assignment box that is displayed in the “Location” row.

  • The linked assignment box that is displayed in the employee row will be deleted as well but it doesn’t show the icon.

To add the icon, use onBeforeEventRender event handler:

onBeforeEventRender: (args) => {
    const isLocation = args.data.type === "location";

    // ...

    if (isLocation) {
        const person = scheduler.rows.find(args.data.employeeId);

        args.data.backColor = "#3d85c6";
        args.data.text = person.name;
        args.data.moveVDisabled = true;

        args.data.areas = [
            {
                right: 2,
                top: 10,
                height: 20,
                width: 20,
                cssClass: "scheduler_default_event_delete",
                style: "background-color: #fff; border: 1px solid #ccc; box-sizing: border-box; border-radius: 10px; padding: 0px;",
                visibility: "Visible",
                onClick: async (args) => {
                    const modal = await DayPilot.Modal.confirm("Delete this assignments?");
                    if (modal.canceled) {
                        return;
                    }
                    const locationAssignment = args.source;
                    const assignmentId = parseInt(locationAssignment.data.join);
                    const employeeAssignment = scheduler.events.find(assignmentId);
                    await DayPilot.Http.delete("/api/Assignments/" + assignmentId);
                    scheduler.events.remove(locationAssignment);
                    scheduler.events.remove(employeeAssignment);
                }
            }
        ];
    } 

    // ...

}

The DELETE HTTP request will invoke DeleteAssignment() method of the AssignmentsController class on the server side.

// DELETE: api/Assignments/5
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteAssignment(int id)
{
    if (_context.Assignments == null)
    {
        return NotFound();
    }
    var assignment = await _context.Assignments.FindAsync(id);
    if (assignment == null)
    {
        return NotFound();
    }

    _context.Assignments.Remove(assignment);
    await _context.SaveChangesAsync();

    return NoContent();
}

Entity Framework Model Classes

This ASP.NET Core project uses Entity Framework to handle data persistence. The following NuGet packages are already installed:

PM > Install-Package Microsoft.EntityFrameworkCore
PM > Install-Package Microsoft.EntityFrameworkCore.SqlServer
PM > Install-Package Microsoft.EntityFrameworkCore.Tools

The project uses 3 data model classes:

  • Employee - stores information about employees (employees will be displayed as rows in the Scheduler)

  • Location - information about locations (you can switch the current location using a drop down displayed above the main scheduling grid)

  • Assignment - the Assignment class stores shift data (it links an employee with a location and a specific shift slot)

Employee class

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

Location class

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

Assignment class

public class Assignment
{
    public int Id { get; set; }

    public int EmployeeId { get; set; }

    [JsonIgnore]
    public Employee Employee { get; set; }

    public int LocationId { get; set; }

    [JsonIgnore]
    public Location Location { get; set; }

    public DateTime Start { get; set; }

    public DateTime End { get; set; }
}

How to Run This ASP.NET Core Project

Requirements:

  • SQL Server (LocalDB)

  • .NET 7

The project uses “code first” approach to define the data model. This means you can generate the database schema for SQL Server from the code.

Before running the ASP.NET Core application, you need to create and initialize the database. The initial migrations are already defined. All you need to do is to run Update-Database in the PM console:

Update-Database

This will create the database and initialize the database schema. It will also include sample rows (employees) and locations.

The database name is specified in the config (appsettings.json). If neede, you can review the connection string before running the the database initialization script.

The database name is DayPilot.TutorialAspNetCoreShifts.

{
    "ConnectionStrings": {
        "ShiftContext": "Server=(localdb)\\mssqllocaldb;Database=DayPilot.TutorialAspNetCoreShifts;Trusted_Connection=True"
    },
}

SQL Server Database Schema

Here is the SQL Server database schema generated from the Entity Framework model classes.

[Assignments] database table:

CREATE TABLE [dbo].[Assignments] (
    [Id]         INT           IDENTITY (1, 1) NOT NULL,
    [EmployeeId] INT           NOT NULL,
    [LocationId] INT           NOT NULL,
    [Start]      DATETIME2 (7) NOT NULL,
    [End]        DATETIME2 (7) NOT NULL,
    CONSTRAINT [PK_Assignments] PRIMARY KEY CLUSTERED ([Id] ASC),
    CONSTRAINT [FK_Assignments_Employees_EmployeeId] FOREIGN KEY ([EmployeeId]) REFERENCES [dbo].[Employees] ([Id]) ON DELETE CASCADE,
    CONSTRAINT [FK_Assignments_Locations_LocationId] FOREIGN KEY ([LocationId]) REFERENCES [dbo].[Locations] ([Id]) ON DELETE CASCADE
);


GO
CREATE NONCLUSTERED INDEX [IX_Assignments_EmployeeId]
    ON [dbo].[Assignments]([EmployeeId] ASC);


GO
CREATE NONCLUSTERED INDEX [IX_Assignments_LocationId]
    ON [dbo].[Assignments]([LocationId] ASC);

[Employees] database table:

CREATE TABLE [dbo].[Employees] (
    [Id]   INT            IDENTITY (1, 1) NOT NULL,
    [Name] NVARCHAR (MAX) NOT NULL,
    CONSTRAINT [PK_Employees] PRIMARY KEY CLUSTERED ([Id] ASC)
);

[Locations] database table:

CREATE TABLE [dbo].[Locations] (
    [Id]   INT            IDENTITY (1, 1) NOT NULL,
    [Name] NVARCHAR (MAX) NOT NULL,
    CONSTRAINT [PK_Locations] PRIMARY KEY CLUSTERED ([Id] ASC)
);