Features

  • Timesheet view (time spent by one person)
  • Project view (time spent on project, by people)
  • Drag and drop support
  • Daily summaries
  • AngularJS scheduler
  • Using Angular routes for navigation
  • Modal dialog for event editing
  • PHP backend
  • Sample SQLite database

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.

Installation

The installation and setup of the DayPilot AngularJS Scheduler is described in the introductory tutorial:

A quick summary:

1. Include AngularJS and DayPilot JavaScript libraries (AngularJS first):

<script src="//ajax.googleapis.com/ajax/libs/angularjs/1.5.0/angular.min.js"></script>
<script src="js/daypilot/daypilot-all.min.js"></script>

2. Use <daypilot-scheduler> element to include the Scheduler in the page. DayPilot includes AngularJS bindings that expose the API using an AngularJS directive.

Configuring the Timesheet View

angularjs-timesheet-javascript-php-timesheet-initialization.png

We will display the timesheet using AngularJS Scheduler control, configured to load days as rows. You can switch the Scheduler to the timesheet mode using viewType property of the config.

View (people.html)

<!-- AngularJS -->
<script src="//ajax.googleapis.com/ajax/libs/angularjs/1.5.0/angular.min.js"></script>

<!-- DayPilot -->
<script src="js/daypilot/daypilot-all.min.js"></script>

<!-- controller -->
<script src="people.js"></script>

<!-- HTML -->
<div ng-app="timesheet.people" ng-controller="PeopleCtrl">
  <daypilot-scheduler id="dp" config="scheduler"></daypilot-scheduler>
</div>


Controller (people.js)

var app = angular.module('timesheet.people', ['daypilot']);

app.controller('PeopleCtrl', function($scope) {

    $scope.scheduler = {
        viewType: "Days",
        startDate: DayPilot.Date.today().firstDayOfMonth(),
        days: DayPilot.Date.today().daysInMonth(),
        cellWidthSpec: "Auto"
    };
});

This configuration displays an empty timesheet for the current month:

Scale and Time Headers

angularjs-timesheet-javascript-php-time-headers.png

The default configuration uses one cell per hour which is not usually the best scale for the timesheet. We will use 15-minute cells for the initial view and make the value configurable using a drop-down list later.

We will add a second time header row which will correspond to the grid cells. We will keep it empty using format = "" (empty string) but you can use a custom date/time format string.

Controller (people.js)

var app = angular.module('timesheet.people', ['daypilot']);

app.controller('PeopleCtrl', function($scope) {

    $scope.events = null;

    $scope.scheduler = {
        // ...
        scale: "CellDuration",
        cellDuration: "15",
        timeHeaders: [
            { groupBy: "Hour" },
            { groupBy: "Cell", format: "" }
        ]
        
    };

});

Hiding Non-Business Hours

angularjs-timesheet-javascript-php-business-hours.png

The next step will be to hide cells that are outside of the normal business hours. Typically, it is 16 hours per day and it would take 2/3 of the available space.

Controller (people.js)

var app = angular.module('timesheet.people', ['daypilot']);

app.controller('PeopleCtrl', function($scope) {

    $scope.scheduler = {
        // ...
        showNonBusiness: false,
        businessBeginsHour: 9,
        businessEndsHour: 17
    };
    
});

Loading Timesheet Records

angularjs-timesheet-javascript-php-loading-records.png

The timesheet grid is now ready and we can display the records. We will add an "events" attribute to the Scheduler directive in the HTML view to point it to the scope variable that holds the timesheet records.

View (people.html)

<daypilot-scheduler id="dp" config="scheduler" events="events"></daypilot-scheduler>

We will load the records/events using a special AJAX call (using AngularJS $http helper).

As soon as we assign the data to the "events" property the Scheduler will be updated automatically.

Controller (people.js)

var app = angular.module('timesheet.people', ['daypilot']);

app.controller('PeopleCtrl', function($scope, $timeout, $http) {

    $scope.events = null;

    $scope.scheduler = {
      // ...
    };
    
    $timeout(function() {
        loadEvents();
    });

    function loadEvents() {

        var start = $scope.dp.visibleStart();
        var end = $scope.dp.visibleEnd();

        var params = {
            start: start.toString(),
            end: end.toString()
        };

        $http.post("backend_events.php", params).then(function(response) {
            var data = response.data;
            $scope.events = data;
        });
    }

});

Backend (backend_events.php)

<?php
require_once '_db.php';

$json = file_get_contents('php://input');
$params = json_decode($json);

$stmt = $db->prepare('SELECT * FROM event WHERE NOT ((event_end <= :start) OR (event_start >= :end))');
$stmt->bindParam(':start', $params->start);
$stmt->bindParam(':end', $params->end);
$stmt->execute();
$result = $stmt->fetchAll();

class Event {}
$events = array();

foreach($result as $row) {
  $e = new Event();
  $e->id = $row['event_id'];
  $e->text = $row['event_name'];
  $e->start = $row['event_start'];
  $e->end = $row['event_end'];
  $e->resource = $row['person_id'];
  $events[] = $e;
}

header('Content-Type: application/json');
echo json_encode($events);

?>

Sample JSON response returned by backend_events.php

[
  {"id":"4","text":"Evaluation","start":"2016-03-07T10:30:00","end":"2016-03-07T13:00:00","resource":"1"},
  {"id":"12","text":"Collecting Requirements","start":"2016-03-03T10:00:00","end":"2016-03-03T12:00:00","resource":"1"},
  {"id":"15","text":"Implementation","start":"2016-03-05T10:00:00","end":"2016-03-05T13:30:00","resource":"1"},
  {"id":"16","text":"Activity","start":"2016-03-01T09:45:00","end":"2016-03-01T12:15:00","resource":"1"}
]

Timesheet Daily Totals

angularjs-timesheet-javascript-php-daily-totals.png

Now we will add another row header column and use it to display daily summaries. We will calculate the daily totals on the client side, using onBeforeRowHeaderRender event handler.

Note that the additional columns are accessible using args.row.columns array. This array doesn't include the default column (which can be reached using args.row.html) so the second column will have index 0.

Controller (people.js)

var app = angular.module('timesheet.people', ['daypilot']);

app.controller('PeopleCtrl', function($scope, $timeout, $http) {

    $scope.events = null;

    $scope.scheduler = {

        // ...
        
        rowHeaderColumns: [
            {title: "Date"},
            {title: "Total"}
        ],
        onBeforeRowHeaderRender: function(args) {
            var duration = args.row.events.totalDuration();
            if (duration.ticks > 0) {
                args.row.columns[0].html = duration.toString("h") + "h " + duration.toString("m") + "m";
            }
        }

    };

});

Filtering By Person

angularjs-timesheet-javascript-php-person-filter.png

We want to display the timesheet data for all people in the team so we will add a drop-down list that will let a user switch the current person:

View (people.html)

<div class="space">
  Person: <select ng-model="selectedPerson" ng-options="option.name for option in people track by option.id" ng-change="onSelectedPersonChanged()"></select>
</div>

We need to load the drop-down list items first - this is done using a special $http call to the server (backend_resources.php):

Controller (people.js)

var app = angular.module('timesheet.people', ['daypilot']);

app.controller('PeopleCtrl', function($scope, $timeout, $http) {

  // ...

  function loadPeople() {
    $http.post("backend_resources.php").then(function(response) {
        var data = response.data;
        $scope.people = data;
        $scope.selectedPerson = data[0];

        if (!$scope.events) {
            loadEvents();
        }
    });
  }

});

As soon as the users changes the selected value, onSelectedPersonChanged() method will be executed. It calls an update loadEvents() method which loads the filtered records from the server.

Controller (people.js)

var app = angular.module('timesheet.people', ['daypilot']);

app.controller('PeopleCtrl', function($scope, $timeout, $http) {

  // ...


  $scope.onSelectedPersonChanged = function() {
    loadEvents();
  };

  function loadEvents() {

    var start = $scope.dp.visibleStart();
    var end = $scope.dp.visibleEnd();

    var params = {
      start: start.toString(),
      end: end.toString(),
      resource: $scope.selectedPerson.id
    };

    $http.post("backend_events_resource.php", params).then(function(response) {
      var data = response.data;
      $scope.events = data;
    });
  }

});

Changing the Visible Month

angularjs-timesheet-javascript-php-navigator.png

The users will need to change the visible month. We will add a Navigator control which displays a small monthly calendar and bind it to the Scheduler.

The Navigator can be added using <daypilot-navigator> element:

View (people.html)

<div style="float:left; width:160px">
    <daypilot-navigator id="navigator" config="navigatorConfig"></daypilot-navigator>
</div>
<div style="margin-left: 160px">
    <daypilot-scheduler id="dp" config="scheduler" events="events"></daypilot-scheduler>
</div>

We need to change our loadEvents() method to read the current date selection from the Navigator (so it can be passed to the backend_events_resources.php script) and also change the visible range when receiving the response.

We could also change the visible range ($scope.scheduler.startDate, $scope.scheduler.days) right away but it would cause an unnecessary Scheduler update.

Controller (people.js)

var app = angular.module('timesheet.people', ['daypilot']);

app.controller('PeopleCtrl', function($scope, $timeout, $http) {

  // ...

  $scope.navigatorConfig = {
    selectMode: "month",
    showMonths: 3,
    skipMonths: 3,
    onTimeRangeSelected: function(args) {
      loadEvents();
    }
  };

  function loadEvents() {

    var start = $scope.navigator.selectionStart;
    var days = start.daysInMonth();
    var end = start.addDays(days);

    var params = {
      start: start.toString(),
      end: end.toString(),
      resource: $scope.selectedPerson.id
    };

    $http.post("backend_events_resource.php", params).then(function(response) {
      var data = response.data;
      $scope.scheduler.startDate = start;
      $scope.scheduler.days = days;
      $scope.events = data;
    });
  }

});

Modal Dialog for Timesheet Record Editing

angularjs-timesheet-javascript-php-edit.png

It will be possible to edit the records by clicking on them. We will add this functionality using onEventClick event handler.

The event handler opens a special page (edit.html) using a modal dialog.

Controller (people.js)

var app = angular.module('timesheet.people', ['daypilot', 'ngRoute']);

app.controller('PeopleCtrl', function($scope, $timeout, $http, $location, $routeParams) {

    $scope.scheduler = {
    
      // ...

      onEventClick: function(args) {
            var modal = new DayPilot.Modal({
                onClosed: function() {
                    loadEvents();
                }
            });

            modal.showUrl("edit.html#/?id=" + args.e.id());
        }
    };

});

Modal HTML (edit.html)

<!DOCTYPE html>
<html>
    <head>
        <title></title>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <script src="js/jquery-1.11.2.min.js"></script>
        <script src="//ajax.googleapis.com/ajax/libs/angularjs/1.5.0/angular.min.js"></script>
        <script src="js/daypilot/daypilot-all.min.js" type="text/javascript"></script>

        <link type="text/css" rel="stylesheet" href="media/modal.css" />
    </head>
    <body>
        <div ng-app="main" ng-controller="EditCtrl">

            <h2>Edit Event</h2>

            <div class="space">
            <button ng-click="delete()">Delete</button>
            </div>

            <div class="space">
                <label for="name">Name</label>
                <input id="name" type="text" ng-model="event.text" />
            </div>
            <div class="space">
                <label for="start">Start</label>
                <input id="start" type="text" ng-model="event.start" disabled />
            </div>
            <div class="space">
                <label for="end">End</label>
                <input id="end" type="text" ng-model="event.end" disabled />
            </div>
            <div class="space">
                <label for="person">Person</label>
                <select id="person" ng-model="event.resource" ng-options="option.id as option.name for option in resources"></select>
            </div>
            <div class="form-group">
                <label for="project">Project</label>
                <select id="project" ng-model="event.project" ng-options="option.id as option.name for option in projects"></select>
            </div>

            <div class="space">
            <button ng-click="save()">OK</button>
            <button ng-click="cancel()">Cancel</button>
            </div>

        </div>

        <script>
            var app = angular.module('main', []).controller('EditCtrl', function($scope, $timeout, $http, $location) {
            $scope.delete = function() {
                $http.post("backend_delete.php", $scope.event).then(function(response) {
                    DayPilot.Modal.close(response.data);
                });
            };
            $scope.save = function() {
                $http.post("backend_update.php", $scope.event).then(function(response) {
                    DayPilot.Modal.close(response.data);
                });
            };
            $scope.cancel = function() {
                DayPilot.Modal.close();
            };

            $http.post("backend_projects.php").then(function(response) {
                $scope.projects = response.data;
            });

            $http.post("backend_resources.php").then(function(response) {
                $scope.resources = response.data;
            });

            $http.post("backend_event.php", {id: $location.search().id}).then(function(response) {
                $scope.event = response.data;
            });

            $("#name").focus();
        });

        </script>
    </body>
</html>

backend_update.php

<?php
require_once '_db.php';

$json = file_get_contents('php://input');
$params = json_decode($json);

class Result {}

$stmt = $db->prepare("UPDATE event SET event_name = :name, project_id = :project, person_id = :resource WHERE event_id = :id");
$stmt->bindParam(':id', $params->id);
$stmt->bindParam(':name', $params->text);
$stmt->bindParam(':project', $params->project);
$stmt->bindParam(':resource', $params->resource);
$stmt->execute();

$response = new Result();
$response->result = 'OK';
$response->message = 'Update successful';

header('Content-Type: application/json');
echo json_encode($response);

?>

Modifying the Timesheet Records using Drag and Drop

angularjs-timesheet-javascript-php-drag-and-drop.png

Adding the drag and drop moving support is easy. In fact, it is enabled by default. All we have to do is to handle onEventMoved event and notify the server about the new record position.

The same applies to drag and drop resizing.

Moving and resizing

var app = angular.module('timesheet.people', ['daypilot']);

app.controller('PeopleCtrl', function($scope, $timeout, $http) {

    $scope.scheduler = {
    
        // ...
    
        onEventMoved: function(args) {
            var params = {
                id: args.e.id(),
                start: args.newStart.toString(),
                end: args.newEnd.toString()
            };
            $http.post("backend_move.php", params).then(function(response) {
                $scope.dp.message("Moved.");
            });
        },
        onEventResized: function(args) {
            var params = {
                id: args.e.id(),
                start: args.newStart.toString(),
                end: args.newEnd.toString()
            };
            $http.post("backend_move.php", params).then(function(response) {
                $scope.dp.message("Resized.");
            });
        },

    };

});

Single Page Application (ngRoute Navigation)

angularjs-timesheet-javascript-php-ngroute-spa.png

At this moment we have a complete view that displays the timesheet for the selected person. We are also tracking the project reference for each timesheet record and we want to add another view which will display all records for a given project.

So we add a master page (index.html) which will use the existing timsheet (people.html) as one of the views.

The navigation between the views will use ngRoute AngularJS plugin.

index.html

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8" />
        <title>AngularJS Timesheet Tutorial (JavaScript, PHP)</title>

        <!-- demo stylesheet -->
    	<link type="text/css" rel="stylesheet" href="media/layout.css" />
    </head>
    <body>
        <div class="header">
            <h1><a href='http://code.daypilot.org/54503/angularjs-timesheet-tutorial-javascript-php'>AngularJS Timesheet Tutorial (JavaScript, PHP)</a></h1>
            <div><a href="http://javascript.daypilot.org/">DayPilot for JavaScript</a> - AJAX Calendar/Scheduling Widgets for JavaScript/HTML5/jQuery/AngularJS</div>
        </div>

        <div ng-app="timesheet" ng-controller="MainCtrl" class="main">
            <div class="space"><a href="#/people">People</a> | <a href="#/projects">Projects</a></div>
            <div ng-view></div>
        </div>

        <script src="js/jquery-1.11.2.min.js"></script>
        <script src="//ajax.googleapis.com/ajax/libs/angularjs/1.5.0/angular.min.js"></script>
        <script src="//ajax.googleapis.com/ajax/libs/angularjs/1.5.0/angular-route.min.js"></script>
        <script src="js/daypilot/daypilot-all.min.js"></script>
        <script src="index.js"></script>
        <script src="people.js"></script>
        <script src="projects.js"></script>
    </body>
</html>

index.js

var app = angular.module('timesheet', [
    'ngRoute',
    'timesheet.projects',
    'timesheet.people']);

app.config(['$routeProvider',
    function($routeProvider) {
      $routeProvider.
        when('/people', {
          templateUrl: 'people.html',
          controller: 'PeopleCtrl',
          reloadOnSearch: false
        }).
        when('/projects', {
          templateUrl: 'projects.html',
          controller: 'ProjectsCtrl',
          reloadOnSearch: false
        }).
        otherwise({
          redirectTo: '/people'
        });
    }]);

app.controller('MainCtrl', function($scope, $timeout, $http) {

});

Project Timesheet View

angularjs-timesheet-javascript-php-projects.png

The projects timesheet view displays details for a selected project. The timesheet records are displayed in rows, one per person.

We will use the AngularJS Scheduler control again, this time in the standard view which displays one resource (person) per row. The horizontal (X) axis is extended and it displays the full date range as specified using .days property.

View (projects.html)

<div style="float:left; width:160px">
    <daypilot-navigator id="navigator" config="navigatorConfig"></daypilot-navigator>
</div>
<div style="margin-left: 160px">
    <div class="space">
        Project: <select ng-model="selectedProject" ng-options="option.name for option in projects track by option.id" ng-change="onSelectedProjectChanged()"></select>
    </div>
    <div class="space">
        Cell duration:
        <select ng-model="scheduler.cellDuration">
            <option value="15">15 minutes</option>
            <option value="30">30 minutes</option>
            <option value="60">60 minutes</option>
        </select>

        Show non-business hours:
        <select ng-model="scheduler.showNonBusiness" ng-options="(item?'show':'hide') for item in [true, false]"></select>

    </div>
    <daypilot-scheduler id="dp" config="scheduler" events="events"></daypilot-scheduler>
</div>

Controller (projects.js)

var app = angular.module('timesheet.projects', ['daypilot', 'ngRoute']);

app.controller('ProjectsCtrl', function($scope, $timeout, $http, $location, $routeParams, $q) {
    $scope.navigatorConfig = {
        selectMode: "month",
        showMonths: 3,
        skipMonths: 3,
        onTimeRangeSelected: function(args) {
            loadEvents();
        }
    };

    $scope.events = null;

    $scope.scheduler = {
        days: 30,
        cellDuration: "60",
        showNonBusiness: false,
        useEventBoxes: "Never",
        rowHeaderColumns: [
            {title: "Person"},
            {title: "Total"}
        ],
        onBeforeEventRender: function(args) {
            args.data.moveVDisabled = true;
        },
        onBeforeRowHeaderRender: function(args) {
            var duration = args.row.events.totalDuration();
            if (duration.ticks > 0) {
                args.row.columns[0].html = duration.toString("h") + "h " + duration.toString("m") + "m";
            }
        },
        onEventClick: function(args) {
            var modal = new DayPilot.Modal({
                onClosed: function() {
                    loadEvents();
                }
            });

            //modal.showUrl("edit.php?id=" + args.e.id());
            modal.showUrl("edit.html#/?id=" + args.e.id());
        },
        onEventMoved: function(args) {
            var params = {
                id: args.e.id(),
                start: args.newStart.toString(),
                end: args.newEnd.toString()
            };
            $http.post("backend_move.php", params).then(function(response) {
                $scope.dp.message("Moved.");
            });
        },
        onEventResized: function(args) {
            var params = {
                id: args.e.id(),
                start: args.newStart.toString(),
                end: args.newEnd.toString()
            };
            $http.post("backend_move.php", params).then(function(response) {
                $scope.dp.message("Moved.");
            });
        },
    };

    $scope.onSelectedProjectChanged = function() {
        loadEvents();
    };

    $timeout(function() {
        dp = $scope.dp;
        loadProjects();
    });

    function loadResources() {
        $http.post("backend_resources.php").then(function(response) {
            $scope.scheduler.resources = resources;
        });
    }

    function loadEvents() {
        //console.log("Loading events");

        var start = $scope.navigator.selectionStart;
        var days = start.daysInMonth();
        var end = start.addDays(days);

        var params = {
            start: start.toString(),
            end: end.toString(),
            project: $scope.selectedProject.id
        };

        $q.all([
            $http.post("backend_resources.php"),
            $http.post("backend_events_project.php", params)
        ]).then(function(args) {
            var resources = args[0].data;
            var events = args[1].data;

            $scope.scheduler.resources = resources;
            $scope.scheduler.startDate = start;
            $scope.scheduler.days = days;
            $scope.events = events;
        });
    }

    function loadProjects() {
        //console.log("Loading people");
        $http.post("backend_projects.php").then(function(response) {
            var data = response.data;
            $scope.projects = data;
            $scope.selectedProject = data[0];

            if (!$scope.events) {
                loadEvents();
            }
        });
    }
});

Note that we are loading the resources and events using two separate AJAX requests but we wait for both to complete before updating the Scheduler.

Updating the Scheduler is not expensive, usually it takes less than 500ms. However, we merge the update to make the UI faster:

function loadEvents() {
  //console.log("Loading events");

  var start = $scope.navigator.selectionStart;
  var days = start.daysInMonth();
  var end = start.addDays(days);

  var params = {
      start: start.toString(),
      end: end.toString(),
      project: $scope.selectedProject.id
  };

  $q.all([
      $http.post("backend_resources.php"),
      $http.post("backend_events_project.php", params)
  ]).then(function(args) {
      var resources = args[0].data;
      var events = args[1].data;

      $scope.scheduler.resources = resources;
      $scope.scheduler.startDate = start;
      $scope.scheduler.days = days;
      $scope.events = events;
  });
}

In the projects view, we don't allow moving the timesheet records from one person to another. This can be done using onBeforeEventRender handler (set args.data.moveVDisabled value to false):

var app = angular.module('timesheet.projects', ['daypilot', 'ngRoute']);

app.controller('ProjectsCtrl', function($scope, $timeout, $http, $location, $routeParams, $q) {
    $scope.scheduler = {
    
        // ...
        
        onBeforeEventRender: function(args) {
            args.data.moveVDisabled = true;
        },

    };

});

SQL Schema (DDL)

The sample project uses an SQLite database. The database file will be created automatically if daypilot.sqlite file is missing (see the definition in _db.php file). Note that you need to enable write permissions for the database file.

CREATE TABLE event (
    event_id    INTEGER PRIMARY KEY,
    event_name  TEXT,
    event_start DATETIME,
    event_end   DATETIME,
    person_id   INTEGER (30),
    project_id  INTEGER
);

CREATE TABLE person (
    person_id   INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
    person_name VARCHAR (200) 
);


CREATE TABLE project (
    project_id   INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
    project_name VARCHAR (200) 
);