commit c553e92f9fd443e0178d475851d25cb682089212
Author: Bernd Bleßmann <bernd@kivitendo-premium.de>
Date:   Tue Nov 17 17:25:03 2020 +0100

    Zeiterfassung: Controller

diff --git a/SL/Controller/TimeRecording.pm b/SL/Controller/TimeRecording.pm
new file mode 100644
index 000000000..95de51388
--- /dev/null
+++ b/SL/Controller/TimeRecording.pm
@@ -0,0 +1,259 @@
+package SL::Controller::TimeRecording;
+
+use strict;
+use parent qw(SL::Controller::Base);
+
+use DateTime;
+use English qw(-no_match_vars);
+use POSIX qw(strftime);
+
+use SL::Controller::Helper::GetModels;
+use SL::Controller::Helper::ReportGenerator;
+use SL::DB::Customer;
+use SL::DB::Employee;
+use SL::DB::TimeRecording;
+use SL::Locale::String qw(t8);
+use SL::ReportGenerator;
+
+use Rose::Object::MakeMethods::Generic
+(
+# scalar                  => [ qw() ],
+ 'scalar --get_set_init' => [ qw(time_recording models all_time_recording_types all_employees) ],
+);
+
+
+# safety
+#__PACKAGE__->run_before('check_auth');
+
+#
+# actions
+#
+
+my %sort_columns = (
+  start_time   => t8('Start'),
+  end_time     => t8('End'),
+  customer     => t8('Customer'),
+  type         => t8('Type'),
+  project      => t8('Project'),
+  description  => t8('Description'),
+  staff_member => t8('Mitarbeiter'),
+  duration     => t8('Duration'),
+);
+
+sub action_list {
+  my ($self, %params) = @_;
+
+  $self->setup_list_action_bar;
+  $self->make_filter_summary;
+  $self->prepare_report;
+
+  $self->report_generator_list_objects(report => $self->{report}, objects => $self->models->get);
+}
+
+sub action_edit {
+  my ($self) = @_;
+
+  $::request->{layout}->use_javascript("${_}.js") for qw(kivi.TimeRecording ckeditor/ckeditor ckeditor/adapters/jquery kivi.Validator);
+
+  if ($self->time_recording->start_time) {
+    $self->{start_date} = $self->time_recording->start_time->to_kivitendo;
+    $self->{start_time} = $self->time_recording->start_time->to_kivitendo_time;
+  }
+  if ($self->time_recording->end_time) {
+    $self->{end_date}   = $self->time_recording->end_time->to_kivitendo;
+    $self->{end_time}   = $self->time_recording->end_time->to_kivitendo_time;
+  }
+
+  $self->setup_edit_action_bar;
+
+  $self->render('time_recording/form',
+                title  => t8('Time Recording'),
+  );
+}
+
+sub action_save {
+  my ($self) = @_;
+
+  my @errors = $self->time_recording->validate;
+  if (@errors) {
+    $::form->error(t8('Saving the time recording entry failed: #1', join '<br>', @errors));
+    return;
+  }
+
+  if ( !eval { $self->time_recording->save; 1; } ) {
+    $::form->error(t8('Saving the time recording entry failed: #1', $EVAL_ERROR));
+    return;
+  }
+
+  $self->redirect_to(safe_callback());
+}
+
+sub action_delete {
+  my ($self) = @_;
+
+  $self->time_recording->delete;
+
+  $self->redirect_to(safe_callback());
+}
+
+sub init_time_recording {
+  my $time_recording = ($::form->{id}) ? SL::DB::TimeRecording->new(id => $::form->{id})->load
+                                       : SL::DB::TimeRecording->new(start_time => DateTime->now_local);
+
+  my %attributes = %{ $::form->{time_recording} || {} };
+
+  foreach my $type (qw(start end)) {
+    if ($::form->{$type . '_date'}) {
+      my $date = DateTime->from_kivitendo($::form->{$type . '_date'});
+      $attributes{$type . '_time'} = $date->clone;
+      if ($::form->{$type . '_time'}) {
+        my ($hour, $min) = split ':', $::form->{$type . '_time'};
+        $attributes{$type . '_time'}->set_hour($hour)  if $hour;
+        $attributes{$type . '_time'}->set_minute($min) if $min;
+      }
+    }
+  }
+
+  $attributes{staff_member_id} = $attributes{employee_id} = SL::DB::Manager::Employee->current->id;
+
+  $time_recording->assign_attributes(%attributes);
+
+  return $time_recording;
+}
+
+sub init_models {
+  SL::Controller::Helper::GetModels->new(
+    controller     => $_[0],
+    sorted         => \%sort_columns,
+    disable_plugin => 'paginated',
+    with_objects   => [ 'customer', 'type', 'project', 'staff_member', 'employee' ],
+  );
+}
+
+sub init_all_time_recording_types {
+  SL::DB::Manager::TimeRecordingType->get_all_sorted(query => [obsolete => 0]);
+}
+
+sub init_all_employees {
+  SL::DB::Manager::Employee->get_all_sorted;
+}
+
+sub prepare_report {
+  my ($self) = @_;
+
+  my $report      = SL::ReportGenerator->new(\%::myconfig, $::form);
+  $self->{report} = $report;
+
+  my @columns  = qw(start_time end_time customer type project description staff_member duration);
+
+  my %column_defs = (
+    start_time   => { text => t8('Start'),        sub => sub { $_[0]->start_time_as_timestamp },
+                      obj_link => sub { $self->url_for(action => 'edit', 'id' => $_[0]->id, callback => $self->models->get_callback) }  },
+    end_time     => { text => t8('End'),          sub => sub { $_[0]->end_time_as_timestamp },
+                      obj_link => sub { $self->url_for(action => 'edit', 'id' => $_[0]->id, callback => $self->models->get_callback) }  },
+    customer     => { text => t8('Customer'),     sub => sub { $_[0]->customer->displayable_name } },
+    type         => { text => t8('Type'),         sub => sub { $_[0]->type && $_[0]->type->abbreviation } },
+    project      => { text => t8('Project'),      sub => sub { $_[0]->project && $_[0]->project->displayable_name } },
+    description  => { text => t8('Description'),  sub => sub { $_[0]->description_as_stripped_html },
+                      raw_data => sub { $_[0]->description_as_restricted_html }, # raw_data only used for html(?)
+                      obj_link => sub { $self->url_for(action => 'edit', 'id' => $_[0]->id, callback => $self->models->get_callback) }  },
+    staff_member => { text => t8('Mitarbeiter'),  sub => sub { $_[0]->staff_member->safe_name } },
+    duration     => { text => t8('Duration'),     sub => sub { $_[0]->duration_as_duration_string },
+                      align => 'right'},
+  );
+
+  $report->set_options(
+    controller_class      => 'TimeRecording',
+    std_column_visibility => 1,
+    output_format         => 'HTML',
+    title                 => t8('Time Recordings'),
+    allow_pdf_export      => 1,
+    allow_csv_export      => 1,
+  );
+
+  $report->set_columns(%column_defs);
+  $report->set_column_order(@columns);
+  $report->set_export_options(qw(list filter));
+  $report->set_options_from_form;
+
+  $self->models->disable_plugin('paginated') if $report->{options}{output_format} =~ /^(pdf|csv)$/i;
+  #$self->models->add_additional_url_params();
+  $self->models->finalize;
+  $self->models->set_report_generator_sort_options(report => $report, sortable_columns => [keys %sort_columns]);
+
+  $report->set_options(
+    raw_top_info_text    => $self->render('time_recording/report_top',    { output => 0 }),
+    raw_bottom_info_text => $self->render('time_recording/report_bottom', { output => 0 }, models => $self->models),
+    attachment_basename  => t8('time_recordings') . strftime('_%Y%m%d', localtime time),
+  );
+}
+
+sub make_filter_summary {
+  my ($self) = @_;
+
+  my $filter = $::form->{filter} || {};
+  my @filter_strings;
+
+  my $staff_member = $filter->{staff_member_id} ? SL::DB::Employee->new(id => $filter->{staff_member_id})->load->safe_name : '';
+
+  my @filters = (
+    [ $filter->{"start_time:date::ge"},                        t8('From Start')      ],
+    [ $filter->{"start_time:date::le"},                        t8('To Start')        ],
+    [ $filter->{"customer"}->{"name:substr::ilike"},           t8('Customer')        ],
+    [ $filter->{"customer"}->{"customernumber:substr::ilike"}, t8('Customer Number') ],
+    [ $staff_member,                                           t8('Mitarbeiter')     ],
+  );
+
+  for (@filters) {
+    push @filter_strings, "$_->[1]: $_->[0]" if $_->[0];
+  }
+
+  $self->{filter_summary} = join ', ', @filter_strings;
+}
+
+sub setup_list_action_bar {
+  my ($self) = @_;
+
+  for my $bar ($::request->layout->get('actionbar')) {
+    $bar->add(
+      action => [
+        t8('Update'),
+        submit    => [ '#filter_form', { action => 'TimeRecording/list' } ],
+        accesskey => 'enter',
+      ],
+      action => [
+        t8('Add'),
+        link => $self->url_for(action => 'edit', callback => $self->models->get_callback),
+      ],
+    );
+  }
+}
+
+sub setup_edit_action_bar {
+  my ($self) = @_;
+
+  for my $bar ($::request->layout->get('actionbar')) {
+    $bar->add(
+      action => [
+        t8('Save'),
+        submit => [ '#form', { action => 'TimeRecording/save' } ],
+        checks => [ 'kivi.validate_form' ],
+      ],
+      action => [
+        t8('Delete'),
+        submit  => [ '#form', { action => 'TimeRecording/delete' } ],
+        only_if => $self->time_recording->id,
+      ],
+      action => [
+        t8('Cancel'),
+        link  => $self->url_for(safe_callback()),
+      ],
+    );
+  }
+}
+
+sub safe_callback {
+  $::form->{callback} || (action => 'list')
+}
+
+1;
diff --git a/SL/DB/Manager/TimeRecording.pm b/SL/DB/Manager/TimeRecording.pm
index f0c7f429e..e1e9b84e1 100644
--- a/SL/DB/Manager/TimeRecording.pm
+++ b/SL/DB/Manager/TimeRecording.pm
@@ -7,8 +7,20 @@ use strict;
 
 use parent qw(SL::DB::Helper::Manager);
 
+use SL::DB::Helper::Sorted;
+
 sub object_class { 'SL::DB::TimeRecording' }
 
 __PACKAGE__->make_manager_methods;
 
+
+sub _sort_spec {
+  return ( default => [ 'start_time', 1 ],
+           columns => { SIMPLE    => 'ALL' ,
+                        customer  => [ 'lower(customer.name)', ],
+           }
+  );
+}
+
+
 1;
diff --git a/SL/DB/TimeRecording.pm b/SL/DB/TimeRecording.pm
index 341e572b2..970fa8738 100644
--- a/SL/DB/TimeRecording.pm
+++ b/SL/DB/TimeRecording.pm
@@ -5,9 +5,128 @@ package SL::DB::TimeRecording;
 
 use strict;
 
+use SL::Locale::String qw(t8);
+
+use SL::DB::Helper::AttrDuration;
+use SL::DB::Helper::AttrHTML;
+
 use SL::DB::MetaSetup::TimeRecording;
 use SL::DB::Manager::TimeRecording;
 
 __PACKAGE__->meta->initialize;
 
+__PACKAGE__->attr_duration_minutes(qw(duration));
+
+__PACKAGE__->attr_html('description');
+
+__PACKAGE__->before_save('_before_save_check_valid');
+
+sub _before_save_check_valid {
+  my ($self) = @_;
+
+  my @errors = $self->validate;
+  return (scalar @errors == 0);
+}
+
+sub validate {
+  my ($self) = @_;
+
+  my @errors;
+
+  push @errors, t8('Start time must not be empty.')                            if !$self->start_time;
+  push @errors, t8('Customer must not be empty.')                              if !$self->customer_id;
+  push @errors, t8('Staff member must not be empty.')                          if !$self->staff_member_id;
+  push @errors, t8('Employee must not be empty.')                              if !$self->employee_id;
+  push @errors, t8('Description must not be empty.')                           if !$self->description;
+  push @errors, t8('Start time must be earlier than end time.')                if $self->is_time_in_wrong_order;
+
+  my $conflict = $self->is_time_overlapping;
+  push @errors, t8('Entry overlaps with "#1".', $conflict->displayable_times)  if $conflict;
+
+  return @errors;
+}
+
+sub is_time_overlapping {
+  my ($self) = @_;
+
+  # Do not allow overlapping time periods.
+  # Start time can be equal to another end time
+  # (an end time can be equal to another start time)
+
+  # We cannot check if no staff member is given.
+  return if !$self->staff_member_id;
+
+  # If no start time and no end time are given, there is no overlapping.
+  return if !($self->start_time || $self->end_time);
+
+  my $conflicting;
+
+  # Start time or end time can be undefined.
+  if (!$self->start_time) {
+    $conflicting = SL::DB::Manager::TimeRecording->get_all(where  => [ and => [ '!id'           => $self->id,
+                                                                                staff_member_id => $self->staff_member_id,
+                                                                                start_time      => {lt => $self->end_time},
+                                                                                end_time        => {ge => $self->end_time} ] ],
+                                                           sort_by => 'start_time DESC',
+                                                           limit   => 1);
+  } elsif (!$self->end_time) {
+    $conflicting = SL::DB::Manager::TimeRecording->get_all(where  => [ and => [ '!id'           => $self->id,
+                                                                                staff_member_id => $self->staff_member_id,
+                                                                                or              => [ and => [start_time => {le => $self->start_time},
+                                                                                                             end_time   => {gt => $self->start_time} ],
+                                                                                                     start_time => $self->start_time,
+                                                                                ],
+                                                                       ],
+                                                           ],
+                                                           sort_by => 'start_time DESC',
+                                                           limit   => 1);
+  } else {
+    $conflicting = SL::DB::Manager::TimeRecording->get_all(where  => [ and => [ '!id'           => $self->id,
+                                                                                staff_member_id => $self->staff_member_id,
+                                                                                or              => [ and => [ start_time => {lt => $self->end_time},
+                                                                                                              end_time   => {gt => $self->start_time} ] ,
+                                                                                                     or  => [ start_time => $self->start_time,
+                                                                                                              end_time   => $self->end_time, ],
+                                                                                ]
+                                                                       ]
+                                                           ],
+                                                           sort_by => 'start_time DESC',
+                                                           limit   => 1);
+  }
+
+  return $conflicting->[0] if @$conflicting;
+  return;
+}
+
+sub is_time_in_wrong_order {
+  my ($self) = @_;
+
+  if ($self->start_time && $self->end_time
+      && $self->start_time >= $self->end_time) {
+    return 1;
+  }
+
+  return;
+}
+
+sub displayable_times {
+  my ($self) = @_;
+
+  # placeholder
+  my $ph = $::locale->format_date_object(DateTime->new(year => 1111, month => 11, day => 11, hour => 11, minute => 11), precision => 'minute');
+  $ph =~ s{1}{-}g;
+
+  return ($self->start_time_as_timestamp||$ph) . ' - ' . ($self->end_time_as_timestamp||$ph);
+}
+
+sub duration {
+  my ($self) = @_;
+
+  if ($self->start_time && $self->end_time) {
+    return ($self->end_time->subtract_datetime_absolute($self->start_time))->seconds/60.0;
+  } else {
+    return;
+  }
+}
+
 1;
diff --git a/SL/Dev/ALL.pm b/SL/Dev/ALL.pm
index b702b0f5b..8bf30a551 100644
--- a/SL/Dev/ALL.pm
+++ b/SL/Dev/ALL.pm
@@ -9,10 +9,11 @@ use SL::Dev::Inventory;
 use SL::Dev::Record;
 use SL::Dev::Payment;
 use SL::Dev::Shop;
+use SL::Dev::TimeRecording;
 
 sub import {
   no strict "refs";
-  for (qw(Part CustomerVendor Inventory Record Payment Shop)) {
+  for (qw(Part CustomerVendor Inventory Record Payment Shop TimeRecording)) {
     Exporter::export_to_level("SL::Dev::$_", 1, @_);
   }
 }
diff --git a/SL/Dev/TimeRecording.pm b/SL/Dev/TimeRecording.pm
new file mode 100644
index 000000000..825472f81
--- /dev/null
+++ b/SL/Dev/TimeRecording.pm
@@ -0,0 +1,41 @@
+package SL::Dev::TimeRecording;
+
+use strict;
+use base qw(Exporter);
+our @EXPORT_OK = qw(new_time_recording);
+our %EXPORT_TAGS = (ALL => \@EXPORT_OK);
+
+use DateTime;
+
+use SL::DB::TimeRecording;
+
+use SL::DB::Employee;
+use SL::Dev::CustomerVendor qw(new_customer);
+
+
+sub new_time_recording {
+  my (%params) = @_;
+
+  my $customer = delete $params{customer} // new_customer(name => 'Testcustomer')->save;
+  die "illegal customer" unless defined $customer && ref($customer) eq 'SL::DB::Customer';
+
+  my $employee     = $params{employee}     // SL::DB::Manager::Employee->current;
+  my $staff_member = $params{staff_member} // $employee;
+
+  my $now = DateTime->now_local;
+
+  my $time_recording = SL::DB::TimeRecording->new(
+    start_time   => $now,
+    end_time     => $now->add(hours => 1),
+    customer     => $customer,
+    description  => '<p>this and that</p>',
+    staff_member => $staff_member,
+    employee     => $employee,
+    %params,
+  );
+
+  return $time_recording;
+}
+
+
+1;
diff --git a/js/kivi.TimeRecording.js b/js/kivi.TimeRecording.js
new file mode 100644
index 000000000..cf5014905
--- /dev/null
+++ b/js/kivi.TimeRecording.js
@@ -0,0 +1,22 @@
+namespace('kivi.TimeRecording', function(ns) {
+  'use strict';
+
+  ns.set_end_date = function() {
+    if ($('#start_date').val() !== '' && $('#end_date').val() === '') {
+      var kivi_start_date  = kivi.format_date(kivi.parse_date($('#start_date').val()));
+      $('#end_date').val(kivi_start_date);
+    }
+  };
+
+  ns.set_current_date_time = function(what) {
+    if (what !== 'start' && what !== 'end') return;
+
+    var $date = $('#' + what + '_date');
+    var $time = $('#' + what + '_time');
+    var date = new Date();
+
+    $date.val(kivi.format_date(date));
+    $time.val(kivi.format_time(date));
+  };
+
+});
diff --git a/menus/user/10-time-recording.yaml b/menus/user/10-time-recording.yaml
index 204d8a9a0..0e42a4f36 100644
--- a/menus/user/10-time-recording.yaml
+++ b/menus/user/10-time-recording.yaml
@@ -6,3 +6,15 @@
   params:
     action: SimpleSystemSetting/list
     type: time_recording_type
+- parent: productivity
+  id: productivity_time_recording
+  name: Time Recording
+  order: 350
+  params:
+    action: TimeRecording/edit
+- parent: productivity_reports
+  id: productivity_reports_time_recording
+  name: Time Recording
+  order: 300
+  params:
+    action: TimeRecording/list
diff --git a/t/db/time_recordig.t b/t/db/time_recordig.t
new file mode 100644
index 000000000..33b036484
--- /dev/null
+++ b/t/db/time_recordig.t
@@ -0,0 +1,582 @@
+use Test::More tests => 40;
+
+use strict;
+
+use lib 't';
+use utf8;
+
+use Support::TestSetup;
+use Test::Exception;
+use DateTime;
+
+use_ok 'SL::DB::TimeRecording';
+
+use SL::Dev::ALL qw(:ALL);
+
+Support::TestSetup::login();
+
+my @time_recordings;
+my ($s1, $e1, $s2, $e2);
+
+sub clear_up {
+  foreach (qw(TimeRecording Customer)) {
+    "SL::DB::Manager::${_}"->delete_all(all => 1);
+  }
+  SL::DB::Manager::Employee->delete_all(where => [ '!login' => 'unittests' ]);
+};
+
+########################################
+
+$s1 = DateTime->now_local;
+$e1 = $s1->clone;
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1);
+
+ok( $time_recordings[0]->is_time_in_wrong_order, 'same start and end detected' );
+ok( !$time_recordings[0]->is_time_overlapping, 'not overlapping if only one time recording entry in db' );
+
+###
+$time_recordings[0]->end_time(undef);
+ok( !$time_recordings[0]->is_time_in_wrong_order, 'order ok if no end' );
+
+########################################
+# ------------s1-----e1-----
+# --s2---e2-----------------
+# -> does not overlap
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 15, minute => 0);
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 11, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2)->save;
+
+ok( !$time_recordings[0]->is_time_overlapping, 'not overlapping: completely before 1' );
+ok( !$time_recordings[1]->is_time_overlapping, 'not overlapping: completely before 2' );
+
+
+# -------s1-----e1----------
+# --s2---e2-----------------
+# -> does not overlap
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 15, minute => 0);
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2)->save;
+
+ok( !$time_recordings[0]->is_time_overlapping, 'not overlapping: before 1' );
+ok( !$time_recordings[1]->is_time_overlapping, 'not overlapping: before 2' );
+
+# ---s1-----e1--------------
+# ---------------s2---e2----
+# -> does not overlap
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 13, minute => 0);
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 14, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2)->save;
+
+ok( !$time_recordings[0]->is_time_overlapping, 'not overlapping: completely after 1' );
+ok( !$time_recordings[1]->is_time_overlapping, 'not overlapping: completely after 2' );
+
+# ---s1-----e1--------------
+# ----------s2---e2---------
+# -> does not overlap
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 14, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2)->save;
+
+ok( !$time_recordings[0]->is_time_overlapping, 'not overlapping: after 1' );
+ok( !$time_recordings[1]->is_time_overlapping, 'not overlapping: after 2' );
+
+# -------s1-----e1----------
+# ---s2-----e2--------------
+# -> overlaps
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 15, minute => 0);
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour =>  9, minute => 0);
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 14, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( $time_recordings[1]->is_time_overlapping, 'overlapping: start before, end inbetween' );
+
+# -------s1-----e1----------
+# -----------s2-----e2------
+# -> overlaps
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 15, minute => 0);
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 17, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( $time_recordings[1]->is_time_overlapping, 'overlapping: start inbetween, end after' );
+
+# ---s1---------e1----------
+# ------s2---e2-------------
+# -> overlaps
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 17, minute => 0);
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 15, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( $time_recordings[1]->is_time_overlapping, 'overlapping: completely inbetween' );
+
+
+# ------s1---e1-------------
+# ---s2---------e2----------
+# -> overlaps
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 14, minute => 0);
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 17, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( $time_recordings[1]->is_time_overlapping, 'overlapping: completely oudside' );
+
+
+# ---s1---e1----------------
+# ---s2---------e2----------
+# -> overlaps
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 14, minute => 0);
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 17, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( $time_recordings[1]->is_time_overlapping, 'overlapping: same start, end outside' );
+
+# ---s1------e1-------------
+# ------s2---e2-------------
+# -> overlaps
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 14, minute => 0);
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 14, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( $time_recordings[1]->is_time_overlapping, 'overlapping: start after, same end' );
+
+# ---s1------e1-------------
+# ------s2------------------
+# e2 undef
+# -> overlaps
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 14, minute => 0);
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e2 = undef;
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( $time_recordings[1]->is_time_overlapping, 'overlapping: start inbetween, no end' );
+
+# ---s1------e1-------------
+# ---s2---------------------
+# e2 undef
+# -> overlaps
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 14, minute => 0);
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e2 = undef;
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( $time_recordings[1]->is_time_overlapping, 'overlapping: same start, no end' );
+
+# -------s1------e1---------
+# ---s2---------------------
+# e2 undef
+# -> does not overlap
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 14, minute => 0);
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e2 = undef;
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( !$time_recordings[1]->is_time_overlapping, 'not overlapping: start before, no end' );
+
+# -------s1------e1---------
+# -------------------s2-----
+# e2 undef
+# -> does not overlap
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 14, minute => 0);
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 16, minute => 0);
+$e2 = undef;
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( !$time_recordings[1]->is_time_overlapping, 'not overlapping: start after, no end' );
+
+# -------s1------e1---------
+# ---------------s2---------
+# e2 undef
+# -> does not overlap
+
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 14, minute => 0);
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 14, minute => 0);
+$e2 = undef;
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( !$time_recordings[1]->is_time_overlapping, 'not overlapping: same start as other end, no end' );
+
+# -------s1------e1---------
+# -----------e2-------------
+# s2 undef
+# -> overlaps
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 17, minute => 0);
+$s2 = undef;
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( $time_recordings[1]->is_time_overlapping, 'overlapping: no start, end inbetween' );
+
+# -------s1------e1---------
+# ---------------e2---------
+# s2 undef
+# -> overlaps
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 17, minute => 0);
+$s2 = undef;
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 17, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( $time_recordings[1]->is_time_overlapping, 'overlapping: no start, same end' );
+
+# -------s1------e1---------
+# --e2----------------------
+# s2 undef
+# -> does not overlap
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 17, minute => 0);
+$s2 = undef;
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( !$time_recordings[1]->is_time_overlapping, 'not overlapping: no start, end before' );
+
+# -------s1------e1---------
+# -------------------e2-----
+# s2 undef
+# -> does not overlap
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 15, minute => 0);
+$s2 = undef;
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 17, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( !$time_recordings[1]->is_time_overlapping, 'not overlapping: no start, end after' );
+
+# -------s1------e1---------
+# -------e2-----------------
+# s2 undef
+# -> does not overlap
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 15, minute => 0);
+$s2 = undef;
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( !$time_recordings[1]->is_time_overlapping, 'not overlapping: no start, same end as other start' );
+
+# ----s1--------------------
+# ----s2-----e2-------------
+# e1 undef
+# -> overlaps
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e1 = undef;
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 14, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( $time_recordings[1]->is_time_overlapping, 'overlapping: no end in db, same start' );
+
+# --------s1----------------
+# ----s2-----e2-------------
+# e1 undef
+# -> does not overlap
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e1 = undef;
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 14, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( !$time_recordings[1]->is_time_overlapping, 'not overlapping: no end in db, enclosing' );
+
+# ---s1---------------------
+# ---------s2-----e2--------
+# e1 undef
+# -> does not overlap
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e1 = undef;
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 14, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( !$time_recordings[1]->is_time_overlapping, 'not overlapping: no end in db, completely after' );
+
+# ---------s1---------------
+# --------------------------
+# e1, s2, e2 undef
+# -> does not overlap
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e1 = undef;
+$s2 = undef;
+$e2 = undef;
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( !$time_recordings[1]->is_time_overlapping, 'not overlapping: no end in db, no times in object' );
+
+# ---------s1---------------
+# -----s2-------------------
+# e1, e2 undef
+# -> does not overlap
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e1 = undef;
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e2 = undef;
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( !$time_recordings[1]->is_time_overlapping, 'not overlapping: no end in db, start before, no end in object' );
+
+# ---------s1---------------
+# -------------s2-----------
+# e1, e2 undef
+# -> does not overlap
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e1 = undef;
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 14, minute => 0);
+$e2 = undef;
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( !$time_recordings[1]->is_time_overlapping, 'not overlapping: no end in db, start after, no end in object' );
+
+# ---------s1---------------
+# ---------s2---------------
+# e1, e2 undef
+# -> overlaps
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e1 = undef;
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e2 = undef;
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( $time_recordings[1]->is_time_overlapping, 'overlapping: no end in db, same start' );
+
+# ---------s1---------------
+# ---e2---------------------
+# e1, s2 undef
+# -> does not overlap
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e1 = undef;
+$s2 = undef;
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( !$time_recordings[1]->is_time_overlapping, 'not overlapping: no end in db, no start in object, end before' );
+
+# ---------s1---------------
+# ---------------e2---------
+# e1, s2 undef
+# -> does not overlap
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e1 = undef;
+$s2 = undef;
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 14, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( !$time_recordings[1]->is_time_overlapping, 'not overlapping: no end in db, no start in object, end after' );
+
+# ---------s1---------------
+# ---------e2---------------
+# e1, s2 undef
+# -> does not overlap
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+$e1 = undef;
+$s2 = undef;
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 12, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( !$time_recordings[1]->is_time_overlapping, 'not overlapping: no end in db, no start in object, same end' );
+
+########################################
+# not overlapping if different staff_member
+$s1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 10, minute => 0);
+$e1 = DateTime->new(year => 2020, month => 11, day => 15, hour => 15, minute => 0);
+$s2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 11, minute => 0);
+$e2 = DateTime->new(year => 2020, month => 11, day => 15, hour => 14, minute => 0);
+
+clear_up;
+
+@time_recordings = ();
+push @time_recordings, new_time_recording(start_time => $s1, end_time => $e1)->save;
+push @time_recordings, new_time_recording(start_time => $s2, end_time => $e2);
+
+ok( $time_recordings[1]->is_time_overlapping, 'overlapping if same staff member' );
+$time_recordings[1]->update_attributes(staff_member => SL::DB::Employee->new(
+                                         'login' => 'testuser',
+                                         'name'  => 'Test User',
+                                       )->save);
+ok( !$time_recordings[1]->is_time_overlapping, 'not overlapping if different staff member' );
+
+clear_up;
+
+1;
+
+
+# set emacs to perl mode
+# Local Variables:
+# mode: perl
+# End:
diff --git a/templates/webpages/time_recording/_filter.html b/templates/webpages/time_recording/_filter.html
new file mode 100644
index 000000000..c94f00d88
--- /dev/null
+++ b/templates/webpages/time_recording/_filter.html
@@ -0,0 +1,48 @@
+[%- USE T8 %]
+[%- USE L %]
+[%- USE LxERP %]
+[%- USE HTML %]
+<form action='controller.pl' method='post' id='filter_form'>
+<div class='filter_toggle'>
+<a href='#' onClick='javascript:$(".filter_toggle").toggle()'>[% 'Show Filter' | $T8 %]</a>
+  [% SELF.filter_summary | html %]
+</div>
+<div class='filter_toggle' style='display:none'>
+<a href='#' onClick='javascript:$(".filter_toggle").toggle()'>[% 'Hide Filter' | $T8 %]</a>
+ <table id='filter_table'>
+  <tr>
+   <th align="right">[% 'Start' | $T8 %] [% 'From Date' | $T8 %]</th>
+   <td>[% L.date_tag('filter.start_time:date::ge', filter.start_time_date__ge) %]</td>
+  </tr>
+  <tr>
+   <th align="right">[% 'Start' | $T8 %] [% 'To Date' | $T8 %]</th>
+   <td>[% L.date_tag('filter.start_time:date::le', filter.start_time_date__le) %]</td>
+  </tr>
+  <tr>
+    <th align="right">[% 'Customer' | $T8 %]</th>
+    <td>[% L.input_tag('filter.customer.name:substr::ilike', filter.customer.name_substr__ilike, size = 20) %]</td>
+  </tr>
+  <tr>
+    <th align="right">[% 'Customer Number' | $T8 %]</th>
+    <td>[% L.input_tag('filter.customer.customernumber:substr::ilike', filter.customer.customernumber_substr__ilike, size = 20) %]</td>
+  </tr>
+  <tr>
+   <th align="right">[% 'Mitarbeiter' | $T8 %]</th>
+   <td>
+     [% L.select_tag('filter.staff_member_id', SELF.all_employees,
+                     default    => filter.staff_member_id,
+                     title_key  => 'name',
+                     value_key  => 'id',
+                     with_empty => 1,
+                     style      => 'width: 200px') %]
+   </td>
+  </tr>
+ </table>
+
+[% L.hidden_tag('sort_by', FORM.sort_by) %]
+[% L.hidden_tag('sort_dir', FORM.sort_dir) %]
+[% L.hidden_tag('page', FORM.page) %]
+[% L.button_tag('$("#filter_form").clearForm()', LxERP.t8('Reset')) %]
+</div>
+
+</form>
diff --git a/templates/webpages/time_recording/form.html b/templates/webpages/time_recording/form.html
new file mode 100644
index 000000000..6d46067b6
--- /dev/null
+++ b/templates/webpages/time_recording/form.html
@@ -0,0 +1,41 @@
+[% USE L %]
+[% USE P %]
+[% USE T8 %]
+[% USE LxERP %]
+
+<h1>[% title %]</h1>
+
+[%- INCLUDE 'common/flash.html' %]
+
+<form method="post" action="controller.pl" id="form">
+  [% P.hidden_tag('id',       SELF.time_recording.id) %]
+  [% L.hidden_tag('callback', FORM.callback) %]
+
+  <table>
+    <thead class="listheading">
+      <th>[% 'Start' | T8 %]</th>
+      <th>[% 'End' | T8 %]</th>
+      <th>[% 'Customer' | T8 %]</th>
+      <th>[% 'Type' | T8 %]</th>
+      <th>[% 'Project' | T8 %]</th>
+      <th>[% 'Description' | T8 %]</th>
+    </thead>
+    <tbody valign="top">
+      <td>
+        [% P.date_tag('start_date',  SELF.start_date, "data-validate"="required", "data-title"=LxERP.t8('Start date'), onchange='kivi.TimeRecording.set_end_date()') %]<br>
+        [% P.input_tag('start_time', SELF.start_time, type="time", "data-validate"="required", "data-title"=LxERP.t8('Start time')) %]
+        [% P.button_tag('kivi.TimeRecording.set_current_date_time("start")', LxERP.t8('now')) %]
+      </td>
+      <td>
+        [% P.date_tag('end_date',  SELF.end_date) %]<br>
+        [% P.input_tag('end_time', SELF.end_time, type="time") %]
+        [% P.button_tag('kivi.TimeRecording.set_current_date_time("end")', LxERP.t8('now')) %]
+      </td>
+      <td>[% P.customer_vendor.picker('time_recording.customer_id', SELF.time_recording.customer_id, type='customer', style='width: 300px', "data-validate"="required", "data-title"=LxERP.t8('Customer')) %]</td>
+      <td>[% P.select_tag('time_recording.type_id', SELF.all_time_recording_types, default=SELF.time_recording.type.id, with_empty=1, title_key='abbreviation') %]</td>
+      <td>[% P.project.picker('time_recording.project_id', SELF.time_recording.project_id, style='width: 300px') %]</td>
+      <td>[% L.textarea_tag('time_recording.description', SELF.time_recording.description, wrap="soft", style="width: 350px; height: 150px", class="texteditor", "data-validate"="required", "data-title"=LxERP.t8('Description')) %]</td>
+    </tbody>
+  </table>
+
+</form>
diff --git a/templates/webpages/time_recording/report_bottom.html b/templates/webpages/time_recording/report_bottom.html
new file mode 100644
index 000000000..a27818a5e
--- /dev/null
+++ b/templates/webpages/time_recording/report_bottom.html
@@ -0,0 +1,9 @@
+[% USE HTML%]
+[%- USE T8 %]
+[%- USE L %][%- USE LxERP -%]
+ [% L.paginate_controls(models=SELF.models) %]
+ <input type="hidden" name="rowcount" value="[% HTML.escape(rowcount) %]">
+ [%- FOREACH item = HIDDEN %]
+ <input type="hidden" name="[% HTML.escape(item.key) %]" value="[% HTML.escape(item.value) %]">
+ [%- END %]
+</form>
diff --git a/templates/webpages/time_recording/report_top.html b/templates/webpages/time_recording/report_top.html
new file mode 100644
index 000000000..9541d808f
--- /dev/null
+++ b/templates/webpages/time_recording/report_top.html
@@ -0,0 +1,2 @@
+[%- PROCESS 'time_recording/_filter.html' filter=SELF.models.filtered.laundered %]
+<hr>
