Project import generated by Copybara.

GitOrigin-RevId: 63746295f1a5ab5a619056791995793d65529e62
diff --git a/src/inc/api.php b/src/inc/api.php
new file mode 100644
index 0000000..9b606be
--- /dev/null
+++ b/src/inc/api.php
@@ -0,0 +1,28 @@
+<?php
+class api {
+  public static function inputJson() {
+    $string = trim(file_get_contents("php://input"));
+
+    if (empty($string)) return false;
+
+    $json = json_decode($string, true);
+
+    if (json_last_error() !== JSON_ERROR_NONE) return false;
+
+    return $json;
+  }
+
+  public static function error($message = null) {
+    if ($message !== null) self::write([
+      'error' => true,
+      'message' => $message,
+    ]);
+    http_response_code(400);
+    exit();
+  }
+
+  public static function write($array) {
+    header('Content-Type: application/json');
+    echo json_encode($array);
+  }
+}
diff --git a/src/inc/calendars.php b/src/inc/calendars.php
new file mode 100644
index 0000000..a8b4a9f
--- /dev/null
+++ b/src/inc/calendars.php
@@ -0,0 +1,182 @@
+<?php
+class calendars {
+  const TYPE_FESTIU = 0;
+  const TYPE_FEINER = 1;
+  const TYPE_LECTIU = 2;
+
+  const NO_CALENDAR_APPLICABLE = 0;
+
+  public static $types = array(
+    0 => "Festivo",
+    2 => "Lectivo",
+    1 => "No lectivo"
+  );
+  public static $workingTypes = [1, 2];
+  public static $days = ["Lunes", "Martes", "Miércoles", "Jueves", "Viernes", "Sábado", "Domingo"];
+  public static $months = ["Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio", "Julio", "Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre"];
+
+  public static function parseFormCalendar($form, $ibegins, $iends, $timestamp = false) {
+    if ($timestamp) {
+      $current = new DateTime();
+      $current->setTimestamp((int)$ibegins);
+      $ends = new DateTime();
+      $ends->setTimestamp((int)$iends);
+    } else {
+      $current = new DateTime($ibegins);
+      $ends = new DateTime($iends);
+    }
+    $interval = new DateInterval("P1D");
+
+    if ($current->diff($ends)->invert === 1) {
+      return false;
+    }
+
+    $return = array(
+      "begins" => $current->getTimestamp(),
+      "ends" => $ends->getTimestamp(),
+      "calendar" => []
+    );
+
+    $possible_values = array_keys(self::$types);
+
+    $day = 0;
+    while ($current->diff($ends)->invert === 0) {
+      if (!isset($form[$day]) || !in_array($form[$day], $possible_values)) {
+        return false;
+      }
+
+      $return["calendar"][$current->getTimestamp()] = (int)$form[$day];
+
+      $day++;
+      $current->add($interval);
+    }
+
+    return $return;
+  }
+
+  public static function checkOverlap($category, $begins, $ends) {
+    global $con;
+
+    $scategory = (int)$category;
+    $sbegins = (int)$begins;
+    $sends = (int)$ends;
+
+    $query = mysqli_query($con, "SELECT * FROM calendars WHERE begins <= $sends AND ends >= $sbegins AND category = $scategory LIMIT 1");
+
+    return (mysqli_num_rows($query) > 0);
+  }
+
+  public static function add($category, $begins, $ends, $calendar) {
+    global $con;
+
+    if (self::checkOverlap($category, $begins, $ends)) {
+      return -1;
+    }
+
+    $scategory = (int)$category;
+    $sbegins = (int)$begins;
+    $sends = (int)$ends;
+    $scalendar = db::sanitize(json_encode($calendar));
+
+    return (mysqli_query($con, "INSERT INTO calendars (category, begins, ends, details) VALUES ($scategory, $sbegins, $sends, '$scalendar')") ? 0 : -2);
+  }
+
+  public static function edit($id, $calendar) {
+    global $con;
+
+    $sid = (int)$id;
+    $scalendar = db::sanitize(json_encode($calendar));
+
+    return (mysqli_query($con, "UPDATE calendars SET details = '$scalendar' WHERE id = $sid LIMIT 1"));
+  }
+
+  public static function get($id) {
+    global $con;
+
+    $sid = (int)$id;
+
+    $query = mysqli_query($con, "SELECT c.id id, c.begins begins, c.ends ends, c.details details, c.category category, ca.name categoryname FROM calendars c LEFT JOIN categories ca ON c.category = ca.id WHERE c.id = $sid");
+
+    if (!mysqli_num_rows($query)) {
+      return false;
+    }
+
+    $row = mysqli_fetch_assoc($query);
+
+    if ($row["category"] == -1) {
+      $row["categoryname"] = "Calendario por defecto";
+    }
+
+    return $row;
+  }
+
+  public static function getByCategory($id, $details = false) {
+    global $con;
+
+    $sid = (int)$id;
+
+    $query = mysqli_query($con, "SELECT id, begins, ends".($details ? ", details" : "")." FROM calendars WHERE category = $sid");
+
+    $calendars = [];
+
+    while ($row = mysqli_fetch_assoc($query)) {
+      $calendars[] = $row;
+    }
+
+    return $calendars;
+  }
+
+  public static function getAll() {
+    $categories = categories::getAll();
+    $categories[-1] = "Calendario por defecto";
+
+    $return = [];
+
+    foreach ($categories as $id => $category) {
+      $return[] = array(
+        "category" => $category,
+        "categoryid" => $id,
+        "calendars" => self::getByCategory($id)
+      );
+    }
+
+    return $return;
+  }
+
+  public static function getCurrentCalendarByCategory($category) {
+    global $con;
+
+    $scategory = (int)$category;
+    $stime = (int)time();
+
+    $query = mysqli_query($con, "SELECT id, category, begins, ends, details FROM calendars WHERE category IN (-1, $scategory) AND begins <= $stime AND ends >= $stime");
+    if ($query === false) return false;
+
+    $calendars = [];
+    while ($row = mysqli_fetch_assoc($query)) {
+      $row["details"] = json_decode($row["details"], true);
+      if (json_last_error() !== JSON_ERROR_NONE) return false;
+
+      $calendars[$row["category"]] = $row;
+    }
+
+    return $calendars[$category] ??  $calendars[-1] ?? self::NO_CALENDAR_APPLICABLE;
+  }
+
+  public static function remove($id) {
+    global $con;
+
+    $sid = (int)$id;
+
+    return mysqli_query($con, "DELETE FROM calendars WHERE id = $sid LIMIT 1");
+  }
+
+  public static function exists($id) {
+    global $con;
+
+    $sid = (int)$id;
+    $query = mysqli_query($con, "SELECT id FROM calendars WHERE id = $sid");
+
+    return (mysqli_num_rows($query) > 0);
+  }
+}
diff --git a/src/inc/calendarsView.php b/src/inc/calendarsView.php
new file mode 100644
index 0000000..eddc230
--- /dev/null
+++ b/src/inc/calendarsView.php
@@ -0,0 +1,44 @@
+<?php
+class calendarsView {
+  public static function renderCalendar($current, $ends, $selectedFunc, $disabled = false, $extra = false) {
+    $interval = new DateInterval("P1D");
+
+    echo '<div class="overflow-wrapper">';
+
+    $start = true;
+    $day = 0;
+    while ($current->diff($ends)->invert === 0) {
+      $dow = (int)$current->format("w");
+      if ($dow == 0) $dow = 7;
+      $dom = (int)$current->format("d");
+
+      if ($dow == 1) echo "</tr>";
+      if ($dom == 1) echo "</table>";
+      if ($dom == 1 || $start) echo "<div class='month'>".security::htmlsafe(ucfirst(strftime("%B %G", $current->getTimestamp())))."</div><table class='calendar'>";
+      if ($dow == 1 || $start) echo "<tr>";
+      if ($dom == 1 || $start) {
+        for ($i = 1; $i < $dow; $i++) {
+          echo "<td></td>";
+        }
+      }
+
+      echo "<td class='day'><span class='date'>".$dom."</span><br><select name='type[$day]'".($disabled ? " disabled" : "").">";
+
+      foreach (calendars::$types as $id => $type) {
+        echo "<option value='".(int)$id."'".($selectedFunc($current->getTimestamp(), $id, $dow, $dom, $extra) ? " selected" : "").">".security::htmlsafe($type)."</option>";
+      }
+
+      echo "</td>";
+
+      $start = false;
+      $day++;
+      $current->add($interval);
+    }
+
+    for ($i = $dow + 1; $i <= 7; $i++) {
+      echo "<td></td>";
+    }
+
+    echo "</tr></table></div>";
+  }
+}
diff --git a/src/inc/categories.php b/src/inc/categories.php
new file mode 100644
index 0000000..a75d998
--- /dev/null
+++ b/src/inc/categories.php
@@ -0,0 +1,161 @@
+<?php
+class categories {
+  private static function parseEmails($string) {
+    $string = str_replace(" ", "", $string);
+    $emails = explode(",", $string);
+
+    foreach ($emails as $i => &$e) {
+      if (empty($e)) {
+        unset($emails[$i]);
+      } else {
+        if (filter_var($e, FILTER_VALIDATE_EMAIL) === false) return false;
+      }
+    }
+
+    return $emails;
+  }
+
+  public static function readableEmails($emails) {
+    $array = json_decode($emails, true);
+    if (json_last_error() != JSON_ERROR_NONE) return false;
+    return implode(", ", $array);
+  }
+
+  private static function canBeParent($id) {
+    if ($id == 0) return true;
+
+    $category = self::get($id);
+    return ($category !== false && $category["parent"] == 0);
+  }
+
+  public static function add($category, $stringEmails, $parent) {
+    global $con;
+
+    $emails = (empty($stringEmails) ? [] : self::parseEmails($stringEmails));
+    if ($emails === false) return false;
+
+    if (!self::canBeParent($parent)) return false;
+
+    $scategory = db::sanitize($category);
+    $semails = db::sanitize(json_encode($emails));
+    $sparent = (int)$parent;
+
+    return mysqli_query($con, "INSERT INTO categories (name, emails, parent) VALUES ('$scategory', '$semails', $sparent)");
+  }
+
+  public static function edit($id, $name, $stringEmails, $parent) {
+    global $con;
+
+    $emails = (empty($stringEmails) ? [] : self::parseEmails($stringEmails));
+    if ($emails === false) return false;
+
+    if (!self::canBeParent($parent)) return false;
+
+    $sid = (int)$id;
+    $sname = db::sanitize($name);
+    $semails = db::sanitize(json_encode($emails));
+    $sparent = (int)$parent;
+
+    return mysqli_query($con, "UPDATE categories SET name = '$sname', emails = '$semails', parent = $sparent WHERE id = $sid LIMIT 1");
+  }
+
+  public static function get($id) {
+    global $con;
+
+    $sid = (int)$id;
+
+    $query = mysqli_query($con, "SELECT * FROM categories WHERE id = $sid");
+
+    if (!mysqli_num_rows($query)) return false;
+
+    return mysqli_fetch_assoc($query);
+  }
+
+  private static function addChilds(&$row) {
+    global $con;
+
+    $query = mysqli_query($con, "SELECT * FROM categories WHERE parent = ".(int)$row["id"]);
+
+    $row["childs"] = [];
+    while ($child = mysqli_fetch_assoc($query)) {
+      $row["childs"][] = $child;
+    }
+  }
+
+  public static function getAll($simplified = true, $withparents = true, $includechilds = false) {
+    global $con, $conf;
+
+    $query = mysqli_query($con, "SELECT ".($simplified ? "id, name" : "c.id id, c.name name, c.parent parent, c.emails emails, p.name parentname")." FROM categories c".($simplified ? "" : " LEFT JOIN categories p ON c.parent = p.id").($withparents ? "" : " WHERE c.parent = 0")." ORDER BY ".($conf["debug"] ? "id" : "name")." ASC");
+
+    $categories = [];
+
+    while ($row = mysqli_fetch_assoc($query)) {
+      if (!$simplified && $includechilds) self::addChilds($row);
+
+      if ($simplified) $categories[$row["id"]] = $row["name"];
+      else $categories[] = $row;
+    }
+
+    return $categories;
+  }
+
+  public static function getAllWithWorkers() {
+    global $con;
+
+    $categories = self::getAll(false);
+
+    foreach ($categories as &$category) {
+      $category["workers"] = [];
+
+      $query = mysqli_query($con, "SELECT w.id FROM workers w LEFT JOIN people p ON w.person = p.id WHERE p.category = ".(int)$category["id"]);
+
+      while ($row = mysqli_fetch_assoc($query)) {
+        $category["workers"][] = $row["id"];
+      }
+    }
+
+    return $categories;
+  }
+
+  public static function getChildren() {
+    global $con, $conf;
+
+    $query = mysqli_query($con, "SELECT p.id parent, c.id child FROM categories c LEFT JOIN categories p ON c.parent = p.id WHERE c.parent != 0");
+
+    $childs = [];
+
+    while ($row = mysqli_fetch_assoc($query)) {
+      if (!isset($childs[$row["parent"]])) {
+        $childs[$row["parent"]] = [];
+      }
+      $childs[$row["parent"]][] = $row["child"];
+    }
+
+    return $childs;
+  }
+
+  public static function exists($id) {
+    global $con;
+
+    if ($id == -1) return true;
+
+    $query = mysqli_query($con, "SELECT id FROM categories WHERE id = ".(int)$id);
+
+    return (mysqli_num_rows($query) > 0);
+  }
+
+  public static function getIdByName($name) {
+    global $con;
+
+    if (strtolower($name) == "sin categoría") return -1;
+
+    $sname = db::sanitize($name);
+    $query = mysqli_query($con, "SELECT id FROM categories WHERE name = '$sname'");
+
+    if (!mysqli_num_rows($query)) return false;
+
+    $row = mysqli_fetch_assoc($query);
+
+    return $row["id"];
+  }
+}
diff --git a/src/inc/common.php b/src/inc/common.php
new file mode 100644
index 0000000..26d02aa
--- /dev/null
+++ b/src/inc/common.php
@@ -0,0 +1,17 @@
+<?php
+class common {
+  public static function getDayTimestamp($originaltime) {
+    $datetime = new DateTime();
+    $datetime->setTimestamp($originaltime);
+
+    $rawdate = $datetime->format("Y-m-d")."T00:00:00";
+    $date = new DateTime($rawdate);
+
+    return $time = $date->getTimestamp();
+  }
+
+  public static function getTimestampFromRFC3339($rfc) {
+    $date = new DateTime($rfc);
+    return (int)$date->getTimestamp();
+  }
+}
diff --git a/src/inc/companies.php b/src/inc/companies.php
new file mode 100644
index 0000000..702151f
--- /dev/null
+++ b/src/inc/companies.php
@@ -0,0 +1,58 @@
+<?php
+class companies {
+  public static function add($company, $cif) {
+    global $con;
+
+    $scompany = db::sanitize($company);
+    $scif = db::sanitize($cif);
+    return mysqli_query($con, "INSERT INTO companies (name, cif) VALUES ('$scompany', '$scif')");
+  }
+
+  public static function edit($id, $name, $cif) {
+    global $con;
+
+    $sid = (int)$id;
+    $sname = db::sanitize($name);
+    $scif = db::sanitize($cif);
+
+    return mysqli_query($con, "UPDATE companies SET name = '$sname', cif = '$scif' WHERE id = $sid LIMIT 1");
+  }
+
+  public static function get($id) {
+    global $con;
+
+    $sid = (int)$id;
+
+    $query = mysqli_query($con, "SELECT * FROM companies WHERE id = $sid");
+
+    if (!mysqli_num_rows($query)) return false;
+
+    return mysqli_fetch_assoc($query);
+  }
+
+  public static function getAll($simplified = true, $mixed = false) {
+    global $con;
+
+    $query = mysqli_query($con, "SELECT * FROM companies ORDER BY id ASC");
+
+    $categories = [];
+
+    while ($row = mysqli_fetch_assoc($query)) {
+      if ($simplified) $categories[$row["id"]] = $row["name"];
+      elseif ($mixed) $categories[$row["id"]] = $row;
+      else $categories[] = $row;
+    }
+
+    return $categories;
+  }
+
+  public static function exists($id) {
+    global $con;
+
+    if ($id == -1) return true;
+
+    $query = mysqli_query($con, "SELECT id FROM companies WHERE id = ".(int)$id);
+
+    return (mysqli_num_rows($query) > 0);
+  }
+}
diff --git a/src/inc/csv.php b/src/inc/csv.php
new file mode 100644
index 0000000..ce44c81
--- /dev/null
+++ b/src/inc/csv.php
@@ -0,0 +1,31 @@
+<?php
+class csv {
+  public static $fields = ["dni", "name", "category", "email", "companies"];
+
+  public static function csv2array($filename) {
+    $file = fopen($filename, "r");
+
+    $return = [];
+
+    $i = 0;
+    while (($line = fgetcsv($file, null, ";")) !== false) {
+      if ($i == 0) {
+        if (count($line) < count(self::$fields)) return false;
+
+        for ($j = 0; $j < count(self::$fields); $j++) {
+          if ($line[$j] !== self::$fields[$j]) return false;
+        }
+      } else {
+        $return[$i] = [];
+        foreach (self::$fields as $j => $field) {
+          $return[$i][$field] = trim($line[$j]);
+        }
+      }
+      $i++;
+    }
+
+    fclose($file);
+
+    return $return;
+  }
+}
diff --git a/src/inc/db.php b/src/inc/db.php
new file mode 100644
index 0000000..293707a
--- /dev/null
+++ b/src/inc/db.php
@@ -0,0 +1,38 @@
+<?php
+class db {
+  const EXPORT_DB_FORMAT_SQL = 0;
+
+  public static function sanitize($string) {
+    global $con;
+    return mysqli_real_escape_string($con, $string);
+  }
+
+  public static function needsSetUp() {
+    global $con;
+
+    $checkquery = mysqli_query($con, "SELECT 1 FROM people LIMIT 1");
+
+    return ($checkquery === false);
+  }
+
+  public static function numRows($table) {
+    global $con;
+
+    $stable = preg_replace("/[^A-Za-z0-9 ]/", '', $table);
+
+    $query = mysqli_query($con, "SELECT 1 FROM $stable");
+
+    if ($query === false) return -1;
+
+    return mysqli_num_rows($query);
+  }
+
+  public static function limitPagination($start, $limit) {
+    $slimit = (int)$limit;
+    $sstart = $slimit*(int)$start;
+    if ($slimit > 100 || $slimit < 0) return false;
+    if ($sstart < 0) return false;
+
+    return ($slimit == 0 ? "" : " LIMIT $sstart,$slimit");
+  }
+}
diff --git a/src/inc/export.php b/src/inc/export.php
new file mode 100644
index 0000000..6800d50
--- /dev/null
+++ b/src/inc/export.php
@@ -0,0 +1,54 @@
+<?php
+class export {
+  const FORMAT_PDF = 1;
+  const FORMAT_DETAILEDPDF = 2;
+  const FORMAT_CSV_SCHEDULES = 3;
+  const FORMAT_CSV_INCIDENTS = 4;
+
+  public static $formats = array(
+    1 => "PDF con horario laboral",
+    2 => "PDF detallado",
+    3 => "CSV con horarios",
+    4 => "CSV con incidencias"
+  );
+  public static $workerFormats = [1, 3, 4];
+
+  public static $schedulesFields = ["id", "worker", "workerid", "dni", "company", "day", "beginswork", "endswork", "beginsbreakfast", "endsbreakfast", "beginslunch", "endslunch", "state"];
+  public static $incidentsFields = ["id", "worker", "workerid", "dni", "company", "creator", "updated", "updatedby", "confirmedby", "type", "day", "allday", "begins", "ends", "details", "workerdetails", "verified", "typepresent", "typepaid", "state"];
+
+  public static function convert($str) {
+    return iconv('UTF-8', 'windows-1252', $str);
+  }
+
+  public static function getDays($worker, $begins, $ends, $showvalidated, $shownotvalidated) {
+    $return = [];
+
+    $records = registry::getRecords($worker, $begins, $ends, false, false, false, 0, 0);
+    if ($records === false) return false;
+    foreach ($records as $record) {
+      if (!$showvalidated && $record["state"] === registry::STATE_VALIDATED_BY_WORKER) continue;
+      if (!$shownotvalidated && $record["state"] === registry::STATE_REGISTERED) continue;
+      if (!isset($return[$record["day"]])) $return[$record["day"]] = [];
+      $return[$record["day"]]["schedule"] = $record;
+    }
+
+    $incidents = incidents::getAll(false, 0, 0, $worker, $begins, $ends);
+    if ($incidents === false) return false;
+    foreach ($incidents as $incident) {
+      if ($incident["state"] !== incidents::STATE_REGISTERED && $incident["state"] !== incidents::STATE_VALIDATED_BY_WORKER) continue;
+      if (!$showvalidated && $incident["state"] === incidents::STATE_VALIDATED_BY_WORKER) continue;
+      if (!$shownotvalidated && $incident["state"] === incidents::STATE_REGISTERED) continue;
+      if (!isset($return[$incident["day"]])) $return[$incident["day"]] = [];
+      if (!isset($return[$incident["day"]]["incidents"])) $return[$incident["day"]]["incidents"] = [];
+      $return[$incident["day"]]["incidents"][] = $incident;
+    }
+
+    ksort($return);
+
+    return $return;
+  }
+
+  public static function sec2hours($sec) {
+    return round((double)$sec/3600, 2)." h";
+  }
+}
diff --git a/src/inc/files.php b/src/inc/files.php
new file mode 100644
index 0000000..5f9cce2
--- /dev/null
+++ b/src/inc/files.php
@@ -0,0 +1,106 @@
+<?php
+class files {
+  const NAME_LENGTH = 16;
+  const MAX_SIZE = 6*1024*1024;
+  const READABLE_MAX_SIZE = "6 MB";
+
+  public static $acceptedFormats = ["pdf", "png", "jpg", "jpeg", "bmp", "gif"];
+  public static $mimeTypes = array(
+    "pdf" => "application/pdf",
+    "png" => "image/png",
+    "jpg" => "image/jpeg",
+    "jpeg" => "image/jpeg",
+    "bmp" => "image/bmp",
+    "gif" => "image/gif"
+  );
+  public static $readableMimeTypes = array(
+    "pdf" => "Documento PDF",
+    "png" => "Imagen PNG",
+    "jpg" => "Imagen JPG",
+    "jpeg" => "Imagen JPG",
+    "bmp" => "Imagen BMP",
+    "gif" => "Imagen GIF"
+  );
+  public static $mimeTypesIcons = array(
+    "pdf" => "insert_drive_file",
+    "png" => "image",
+    "jpg" => "image",
+    "jpeg" => "image",
+    "bmp" => "image",
+    "gif" => "image"
+  );
+
+  public static function getAcceptAttribute($pretty = false) {
+    $formats = array_map(function($el) {
+      return ".".$el;
+    }, self::$acceptedFormats);
+
+    return implode(($pretty ? ", " : ","), $formats);
+  }
+
+  // From https://stackoverflow.com/a/31107425
+  private static function randomStr(int $length = 64, string $keyspace = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'): string {
+    if ($length < 1) {
+      throw new \RangeException("Length must be a positive integer");
+    }
+    $pieces = [];
+    $max = mb_strlen($keyspace, '8bit') - 1;
+    for ($i = 0; $i < $length; ++$i) {
+      $pieces []= $keyspace[random_int(0, $max)];
+    }
+    return implode('', $pieces);
+  }
+
+
+  private static function getName($ext) {
+    global $conf;
+
+    $filename = "";
+
+    do {
+      $filename = self::randomStr(self::NAME_LENGTH).".".$ext;
+    } while (file_exists($conf["attachmentsFolder"].$filename));
+
+    return $filename;
+  }
+
+  public static function getFileExtension($file) {
+    $filenameExploded = explode(".", $file);
+    return mb_strtolower($filenameExploded[count($filenameExploded) - 1]);
+  }
+
+  public static function uploadFile(&$file, &$name) {
+    global $conf;
+
+    if (!isset($file["error"]) || is_array($file["error"])) return 1;
+
+    switch ($file["error"]) {
+      case UPLOAD_ERR_OK:
+      break;
+
+      case UPLOAD_ERR_INI_SIZE:
+      case UPLOAD_ERR_FORM_SIZE:
+      return 2;
+      break;
+
+      default:
+      return 1;
+      break;
+    }
+
+    $ext = self::getFileExtension($file["name"]);
+
+    if ($file['size'] > self::MAX_SIZE) return 2;
+    if (!in_array($ext, self::$acceptedFormats)) return 3;
+
+    $name = self::getName($ext);
+
+    return (move_uploaded_file($file["tmp_name"], $conf["attachmentsFolder"].$name) ? 0 : 1);
+  }
+
+  public static function removeFile($name) {
+    global $conf;
+
+    return unlink($conf["attachmentsFolder"].$name);
+  }
+}
diff --git a/src/inc/help.php b/src/inc/help.php
new file mode 100644
index 0000000..3fe7179
--- /dev/null
+++ b/src/inc/help.php
@@ -0,0 +1,52 @@
+<?php
+class help {
+  const PLACE_INCIDENT_FORM = 0;
+  const PLACE_VALIDATION_PAGE = 1;
+  const PLACE_REGISTRY_PAGE = 2;
+  const PLACE_EXPORT_REGISTRY_PAGE = 3;
+
+  public static $places = [0, 1, 2, 3];
+  public static $placesName = [
+    0 => "Formulario de incidencias",
+    1 => "Página de validación",
+    2 => "Página de listado de registros",
+    3 => "Página de exportar registro"
+  ];
+
+  public static function exists($place) {
+    global $con;
+
+    $splace = (int)$place;
+
+    $query = mysqli_query($con, "SELECT 1 FROM help WHERE place = $splace");
+
+    return (mysqli_num_rows($query) > 0);
+  }
+
+  public static function set($place, $url) {
+    global $con;
+
+    if (!in_array($place, self::$places)) return -1;
+    if ($url !== "" && !filter_var($url, FILTER_VALIDATE_URL)) return 1;
+
+    $splace = (int)$place;
+    $surl = db::sanitize($url);
+
+    if (self::exists($place)) return (mysqli_query($con, "UPDATE help SET url = '$surl' WHERE place = $splace LIMIT 1") ? 0 : -1);
+    else return (mysqli_query($con, "INSERT INTO help (place, url) VALUES ('$splace', '$surl')") ? 0 : -1);
+  }
+
+  public static function get($place) {
+    global $con;
+
+    if (!in_array($place, self::$places)) return false;
+    $splace = (int)$place;
+
+    $query = mysqli_query($con, "SELECT url FROM help WHERE place = $splace");
+
+    if (mysqli_num_rows($query) > 0) {
+      $url = mysqli_fetch_assoc($query)["url"];
+      return ($url === "" ? false : $url);
+    } else return false;
+  }
+}
diff --git a/src/inc/helpView.php b/src/inc/helpView.php
new file mode 100644
index 0000000..d6b642a
--- /dev/null
+++ b/src/inc/helpView.php
@@ -0,0 +1,10 @@
+<?php
+class helpView {
+  public static function renderHelpButton($place, $topRight = false, $margin = false) {
+    $url = help::get($place);
+    if ($url === false) return;
+
+    echo ($topRight ? '<div class="help-btn--top-right'.($margin ? ' help-btn--top-right-margin': '').'">' : '').'<a href="'.security::htmlsafe($url).'" target="_blank" rel="noopener noreferrer" class="mdl-button mdl-button--colored mdl-button-js mdl-button--icon mdl-js-ripple-effect" id="help'.(int)$place.'"><i class="material-icons">help_outline</i><span class="mdl-ripple"></span></a>'.($topRight ? '</div>' : '');
+    echo '<div class="mdl-tooltip" for="help'.(int)$place.'">Ayuda</div>';
+  }
+}
diff --git a/src/inc/incidents.php b/src/inc/incidents.php
new file mode 100644
index 0000000..7664fe9
--- /dev/null
+++ b/src/inc/incidents.php
@@ -0,0 +1,609 @@
+<?php
+class incidents {
+  const STARTOFDAY = 0;
+  const ENDOFDAY = 60*60*24;
+
+  const PAGINATION_LIMIT = 20;
+
+  const UPDATE_STATE_NOT_UPDATED = 0;
+  const UPDATE_STATE_UPDATED = 1;
+
+  const STATE_UNVERIFIED = 0;
+  const STATE_REJECTED = 1;
+  const STATE_SCHEDULED = 2;
+  const STATE_REGISTERED = 3;
+  const STATE_MANUALLY_INVALIDATED = 4;
+  const STATE_VALIDATED_BY_WORKER = 5;
+
+  public static $stateIcons = array(
+    0 => "new_releases",
+    1 => "block",
+    2 => "schedule",
+    3 => "check",
+    4 => "delete_forever",
+    5 => "verified_user"
+  );
+
+  public static $stateIconColors = array(
+    0 => "mdl-color-text--orange",
+    1 => "mdl-color-text--red",
+    2 => "mdl-color-text--orange",
+    3 => "mdl-color-text--green",
+    4 => "mdl-color-text--red",
+    5 => "mdl-color-text--green"
+  );
+
+  public static $stateTooltips = array(
+    0 => "Pendiente de revisión",
+    1 => "Rechazada al revisar",
+    2 => "Programada",
+    3 => "Registrada",
+    4 => "Invalidada manualmente",
+    5 => "Validada"
+  );
+
+  public static $statesOrderForFilters = [0, 1, 4, 2, 3, 5];
+
+  public static $invalidStates = [incidents::STATE_REJECTED, incidents::STATE_MANUALLY_INVALIDATED];
+
+  public static $cannotEditCommentsStates = [];
+  public static $cannotEditStates = [self::STATE_VALIDATED_BY_WORKER, self::STATE_REGISTERED, self::STATE_MANUALLY_INVALIDATED, self::STATE_REJECTED];
+  public static $canRemoveStates = [self::STATE_SCHEDULED];
+  public static $canInvalidateStates = [self::STATE_VALIDATED_BY_WORKER, self::STATE_REGISTERED];
+  public static $workerCanEditStates = [self::STATE_UNVERIFIED];
+  public static $workerCanRemoveStates = [self::STATE_UNVERIFIED];
+
+  public static $adminPendingWhere = "i.verified = 0 AND i.confirmedby = -1";
+  public static $workerPendingWhere = "i.workervalidated = 0";
+  public static $activeWhere = "i.verified = 1 AND i.invalidated = 0";
+  public static $notInvalidatedOrRejectedWhere = "i.invalidated = 0 AND (i.verified = 1 OR i.confirmedby = -1)";
+
+  const FILTER_TYPE_ARRAY = 0;
+  const FILTER_TYPE_INT = 1;
+  const FILTER_TYPE_STRING = 2;
+
+  public static $filters = ["begins", "ends", "types", "states", "attachments", "details", "workerdetails"];
+  public static $filtersType = [
+    "begins" => self::FILTER_TYPE_STRING,
+    "ends" => self::FILTER_TYPE_STRING,
+    "types" => self::FILTER_TYPE_ARRAY,
+    "states" => self::FILTER_TYPE_ARRAY,
+    "attachments" => self::FILTER_TYPE_ARRAY,
+    "details" => self::FILTER_TYPE_ARRAY,
+    "workerdetails" => self::FILTER_TYPE_ARRAY
+  ];
+
+  public static $filtersSwitch = ["attachments", "details", "workerdetails"];
+  public static $filtersSwitchOptions = [
+    0 => "Sin",
+    1 => "Con"
+  ];
+  public static $filtersSwitchHelper = [
+    "attachments" => "archivo adjunto",
+    "details" => "observaciones de un administrador",
+    "workerdetails" => "observaciones del trabajador"
+  ];
+  public static $filtersSwitchMysqlField = [
+    "attachments" => "attachments",
+    "details" => "details",
+    "workerdetails" => "workerdetails"
+  ];
+
+  public static function getTypes($showHidden = true, $isForWorker = false) {
+    global $con, $conf;
+
+    $whereConditions = [];
+    if (!$showHidden) $whereConditions[] = "hidden = 0";
+    if ($isForWorker) $whereConditions[] = "workerfill = 1";
+
+    $where = (count($whereConditions) ? " WHERE ".implode(" AND ",$whereConditions) : "");
+
+    $query = mysqli_query($con, "SELECT * FROM typesincidents".$where." ORDER BY ".($conf["debug"] ? "id ASC" : "hidden ASC, name ASC"));
+
+    $incidents = [];
+
+    while ($row = mysqli_fetch_assoc($query)) {
+      $incidents[] = $row;
+    }
+
+    return $incidents;
+  }
+
+  public static function getTypesForm($isForWorker = false) {
+    return self::getTypes(false, $isForWorker);
+  }
+
+  public static function getType($id) {
+    global $con;
+
+    $sid = (int)$id;
+
+    $query = mysqli_query($con, "SELECT * FROM typesincidents WHERE id = $sid");
+
+    if (!mysqli_num_rows($query)) return false;
+
+    return mysqli_fetch_assoc($query);
+  }
+
+  public static function addType($name, $present, $paid, $workerfill, $notifies, $autovalidates, $hidden) {
+    global $con;
+
+    $sname = db::sanitize($name);
+    $spresent = (int)$present;
+    $spaid = (int)$paid;
+    $sworkerfill = (int)$workerfill;
+    $snotifies = (int)$notifies;
+    $sautovalidates = (int)$autovalidates;
+    $shidden = (int)$hidden;
+
+    return mysqli_query($con, "INSERT INTO typesincidents (name, present, paid, workerfill, notifies, autovalidates, hidden) VALUES ('$name', $spresent, $spaid, $sworkerfill, $snotifies, $sautovalidates, $shidden)");
+  }
+
+  public static function editType($id, $name, $present, $paid, $workerfill, $notifies, $autovalidates, $hidden) {
+    global $con;
+
+    $sid = (int)$id;
+    $sname = db::sanitize($name);
+    $spresent = (int)$present;
+    $spaid = (int)$paid;
+    $sworkerfill = (int)$workerfill;
+    $snotifies = (int)$notifies;
+    $sautovalidates = (int)$autovalidates;
+    $shidden = (int)$hidden;
+
+    return mysqli_query($con, "UPDATE typesincidents SET name = '$name', present = $spresent, paid = $spaid, workerfill = $sworkerfill, notifies = $snotifies, autovalidates = $sautovalidates, hidden = $shidden WHERE id = $sid LIMIT 1");
+  }
+
+  public static function addWhereConditionsHandledBySelect($select, &$whereConditions, $alreadyFilteredDates = false) {
+    if (!$alreadyFilteredDates) {
+      if ($select["enabled"]["begins"]) $whereConditions[] = "i.day >= ".(int)common::getTimestampFromRFC3339($select["selected"]["begins"]);
+      if ($select["enabled"]["ends"]) $whereConditions[] = "i.day <= ".(int)common::getTimestampFromRFC3339($select["selected"]["ends"]);
+    }
+
+    if ($select["enabled"]["types"]) {
+      $insideconditions = [];
+      foreach ($select["selected"]["types"] as $type) {
+        $insideconditions[] = "i.type = ".(int)$type;
+      }
+      $whereConditions[] = "(".implode(" OR ", $insideconditions).")";
+    }
+
+    foreach (self::$filtersSwitch as $f) {
+      if ($select["enabled"][$f]) {
+        foreach ($select["selected"][$f] as $onoff) {
+          $mysqlField = self::$filtersSwitchMysqlField[$f];
+
+          if ($onoff == "0") $insideconditions[] = "($mysqlField IS NULL OR $mysqlField = ''".($f == "attachments" ? " OR $mysqlField = '[]'" : "").")";
+          else if ($onoff == "1") $insideconditions[] = "($mysqlField IS NOT NULL AND $mysqlField <> ''".($f == "attachments" ? " AND $mysqlField <> '[]'" : "").")";
+        }
+        if (count($insideconditions) == 1) $whereConditions[] = $insideconditions[0];
+      }
+    }
+  }
+
+  public static function incidentIsWantedAccordingToSelect($select, $incident) {
+    if ($select["enabled"]["states"]) {
+      return in_array($incident["state"], $select["selected"]["states"]);
+    }
+
+    return true;
+  }
+
+  public static function todayPage($limit = self::PAGINATION_LIMIT) {
+    global $con;
+
+    $today = (int)common::getDayTimestamp(time());
+
+    $query = mysqli_query($con, "SELECT COUNT(*) count FROM incidents i WHERE i.day > $today");
+    if ($query === false) return 0;
+
+    $row = mysqli_fetch_assoc($query);
+    $first = $row["count"];
+
+    return floor($first/(int)$limit) + 1;
+  }
+
+  public static function numRows($select = null, $onlyAdminPending = false) {
+    global $con;
+
+    $whereConditions = [];
+
+    if ($select !== null) {
+      if (!$select["showResultsPaginated"]) return 0;
+
+      self::addWhereConditionsHandledBySelect($select, $whereConditions);
+    }
+
+    if ($onlyAdminPending) $whereConditions[] = self::$adminPendingWhere;
+
+    $where = (count($whereConditions) ? " WHERE ".implode(" AND ", $whereConditions) : "");
+    $query = mysqli_query($con, "SELECT COUNT(*) count FROM incidents i".$where);
+
+    if (!mysqli_num_rows($query)) return false;
+
+    $row = mysqli_fetch_assoc($query);
+
+    return (isset($row["count"]) ? (int)$row["count"] : false);
+  }
+
+  public static function numPending() {
+    return self::numRows(null, true);
+  }
+
+  public static function getStatus(&$incident, $beginsdatetime = -1) {
+    if ($beginsdatetime == -1) {
+      $date = new DateTime();
+      $date->setTimestamp($incident["day"]);
+      $dateFormat = $date->format("Y-m-d");
+
+      $begins = new DateTime($dateFormat."T".schedules::sec2time((int)$incident["begins"], false).":00");
+      $beginsdatetime = $begins->getTimestamp();
+    }
+
+    $time = time();
+
+    if ($incident["verified"] == 0 && $incident["confirmedby"] == -1) return self::STATE_UNVERIFIED;
+    elseif ($incident["verified"] == 0) return self::STATE_REJECTED;
+    elseif ($incident["invalidated"] == 1) return self::STATE_MANUALLY_INVALIDATED;
+    elseif ($beginsdatetime >= $time) return self::STATE_SCHEDULED;
+    elseif ($incident["workervalidated"] == 1) return self::STATE_VALIDATED_BY_WORKER;
+    else return self::STATE_REGISTERED;
+  }
+
+  public static function getAttachmentsFromIncident(&$incident) {
+    if (empty($incident["attachments"]) || $incident["attachments"] === null) return [];
+
+    $json = json_decode($incident["attachments"], true);
+    if (json_last_error() !== JSON_ERROR_NONE) return false;
+
+    return $json;
+  }
+
+  public static function getAttachments($id) {
+    global $con;
+
+    $sid = (int)$id;
+
+    $query = mysqli_query($con, "SELECT attachments FROM incidents WHERE id = ".$sid);
+    if (!mysqli_num_rows($query)) return false;
+
+    $incident = mysqli_fetch_assoc($query);
+
+    return self::getAttachmentsFromIncident($incident);
+  }
+
+  public static function addAttachment($id, &$file) {
+    global $con;
+
+    $name = "";
+    $status = files::uploadFile($file, $name);
+    if ($status !== 0) return $status;
+
+    $attachments = self::getAttachments($id);
+    $attachments[] = $name;
+
+    $sid = (int)$id;
+    $srawAttachments = db::sanitize(json_encode($attachments));
+
+    return (mysqli_query($con, "UPDATE incidents SET attachments = '".$srawAttachments."' WHERE id = $sid LIMIT 1") ? 0 : 1);
+  }
+
+  public static function deleteAttachment($id, $name = "ALL") {
+    global $con;
+
+    $incident = incidents::get($id, true);
+    if ($incident === false) return false;
+
+    $attachments = incidents::getAttachmentsFromIncident($incident);
+
+    if ($attachments === false) return false;
+    if (!count($attachments)) return ($name == "ALL");
+
+    $flag = false;
+
+    foreach ($attachments as $i => $attachment) {
+      if ($attachment == $name || $name === "ALL") {
+        $flag = true;
+
+        if (!files::removeFile($attachment)) return false;
+
+        unset($attachments[$i]);
+        $attachments = array_values($attachments);
+
+        $sid = (int)$id;
+        $srawAttachments = db::sanitize(json_encode($attachments));
+      }
+    }
+
+    return ($flag ? mysqli_query($con, "UPDATE incidents SET attachments = '".$srawAttachments."' WHERE id = $sid LIMIT 1") : false);
+  }
+
+  private static function magicIncident(&$row) {
+    $row["allday"] = ($row["begins"] == self::STARTOFDAY && $row["ends"] == self::ENDOFDAY);
+    $row["updatestate"] = ($row["updatedby"] == -1 ? self::UPDATE_STATE_NOT_UPDATED : self::UPDATE_STATE_UPDATED);
+
+    $date = new DateTime();
+    $date->setTimestamp($row["day"]);
+    $dateFormat = $date->format("Y-m-d");
+
+    $begins = new DateTime($dateFormat."T".schedules::sec2time((int)$row["begins"], false).":00");
+    $row["beginsdatetime"] = $begins->getTimestamp();
+    $ends = new DateTime($dateFormat."T".schedules::sec2time((int)$row["ends"], false).":00");
+    $row["endsdatetime"] = $ends->getTimestamp();
+
+    $row["state"] = self::getStatus($row, $row["beginsdatetime"]);
+  }
+
+  public static function get($id, $magic = false) {
+    global $con, $conf;
+
+    $sid = $id;
+
+    $query = mysqli_query($con, "SELECT * FROM incidents WHERE id = $sid");
+
+    if (!mysqli_num_rows($query)) return false;
+
+    $row = mysqli_fetch_assoc($query);
+    if ($magic) self::magicIncident($row);
+
+    return $row;
+  }
+
+  public static function getAll($onlyAdminPending = false, $start = 0, $limit = self::PAGINATION_LIMIT, $worker = "ALL", $begins = null, $ends = null, $onlyWorkerPending = false, $select = false) {
+    global $con, $conf;
+
+    $whereConditions = [];
+    if ($onlyAdminPending) $whereConditions[] = self::$adminPendingWhere;
+    if ($onlyWorkerPending) {
+      $whereConditions[] = self::$workerPendingWhere;
+      $whereConditions[] = self::$activeWhere;
+    }
+    if ($worker !== "ALL") $whereConditions[] = "i.worker = ".(int)$worker;
+    if ($begins !== null && $ends !== null) {
+      $whereConditions[] = "i.day <= ".(int)$ends." AND i.day >= ".(int)$begins;
+      $filteredDates = true;
+    } else $filteredDates = false;
+
+    if ($select !== false) self::addWhereConditionsHandledBySelect($select, $whereConditions, $filteredDates);
+
+    $where = (count($whereConditions) ? " WHERE ".implode(" AND ", $whereConditions) : "");
+
+    $query = mysqli_query($con, "SELECT i.*, t.id typeid, t.name typename, t.present typepresent, t.paid typepaid, t.workerfill typeworkerfill, t.notifies typenotifies, t.hidden typehidden, p.id personid, p.name workername, w.company companyid FROM incidents i LEFT JOIN typesincidents t ON i.type = t.id LEFT JOIN workers w ON i.worker = w.id LEFT JOIN people p ON w.person = p.id".$where." ORDER BY i.day DESC".db::limitPagination($start, $limit));
+
+    $return = [];
+    while ($row = mysqli_fetch_assoc($query)) {
+      self::magicIncident($row);
+      if ($onlyWorkerPending && $row["state"] !== self::STATE_REGISTERED) continue;
+      if ($select !== false && !self::incidentIsWantedAccordingToSelect($select, $row)) continue;
+      $return[] = $row;
+    }
+
+    return $return;
+  }
+
+  public static function checkOverlap($worker, $day, $begins, $ends, $sans = 0) {
+    global $con;
+
+    $sworker = (int)$worker;
+    $sday = (int)$day;
+    $sbegins = (int)$begins;
+    $sends = (int)$ends;
+    $ssans = (int)$sans;
+
+    $query = mysqli_query($con, "SELECT * FROM incidents i WHERE begins <= $sends AND ends >= $sbegins AND day = $sday AND worker = $sworker".($sans == 0 ? "" : " AND id <> $ssans")." AND ".self::$notInvalidatedOrRejectedWhere." LIMIT 1");
+
+    return (mysqli_num_rows($query) > 0);
+  }
+
+  public static function add($worker, $type, $details, $iday, $begins, $ends, $creator = "ME", $verified = 1, $alreadyTimestamp = false, $isWorkerView = false, $sendEmail = true, $forceAutoValidate = false) {
+    global $con, $conf;
+
+    // Gets information about the worker
+    $sworker = (int)$worker;
+    $workerInfo = workers::get($sworker);
+    if ($workerInfo === false) return 1;
+
+    // Sets who is the incident creator
+    if ($creator === "ME") $creator = people::userData("id");
+    $screator = (int)$creator;
+
+    // If the user is not an admin and the person to who we are going to add the incident is not the creator of the incident, do not continue
+    if (!security::isAllowed(security::ADMIN) && $workerInfo["person"] != $creator) return 5;
+
+    // Sanitizes other incident fields
+    $stype = (int)$type;
+    $sverified = (int)$verified;
+    $sdetails = db::sanitize($details);
+
+    // Gets information about the incident type
+    $incidenttype = self::getType($stype);
+    if ($incidenttype === false) return -1;
+
+    // Gets the timestamp of the incident day
+    if ($alreadyTimestamp) {
+      $sday = (int)$iday;
+    } else {
+      $day = new DateTime($iday);
+      $sday = (int)$day->getTimestamp();
+    }
+
+    // Gets the start and end times, and checks whether they are well-formed
+    $sbegins = (int)$begins;
+    $sends = (int)$ends;
+    if ($sbegins >= $sends) return 3;
+
+    // Checks whether the incident overlaps another incident
+    if (self::checkOverlap($worker, $sday, $begins, $ends)) return 2;
+
+    // Adds the incident
+    if (!mysqli_query($con, "INSERT INTO incidents (worker, creator, type, day, begins, ends, ".($isWorkerView ? "workerdetails" : "details").", verified) VALUES ($sworker, $screator, $stype, $sday, $sbegins, $sends, '$sdetails', $sverified)")) return -1;
+
+    // If the incident type is set to autovalidate or we pass the parameter to force the autovalidation, autovalidate it
+    if (($incidenttype["autovalidates"] == 1 || $forceAutoValidate) && !validations::validateIncident(mysqli_insert_id($con), validations::METHOD_AUTOVALIDATION, "ME", false, false)) {
+      return 5;
+    }
+
+    // Bonus: check whether we should send email notifications, and if applicable send them
+    if ($sendEmail && $conf["mail"]["enabled"] && ($conf["mail"]["capabilities"]["notifyOnWorkerIncidentCreation"] || $conf["mail"]["capabilities"]["notifyOnAdminIncidentCreation"] || $conf["mail"]["capabilities"]["notifyCategoryResponsiblesOnIncidentCreation"])) {
+      $workerName = people::workerData("name", $sworker);
+
+      $to = [];
+      if (($conf["mail"]["capabilities"]["notifyOnWorkerIncidentCreation"] && $isWorkerView) || ($conf["mail"]["capabilities"]["notifyOnAdminIncidentCreation"] && !$isWorkerView)) {
+        $to[] = array("email" => $conf["mail"]["adminEmail"]);
+      }
+
+      if ($conf["mail"]["capabilities"]["notifyCategoryResponsiblesOnIncidentCreation"] && $incidenttype["notifies"] == 1) {
+        $categoryid = people::workerData("category", $sworker);
+        $category = categories::get($categoryid);
+        if ($category === false) return 0;
+
+        $emails = json_decode($category["emails"], true);
+        if (json_last_error() === JSON_ERROR_NONE) {
+          foreach ($emails as $email) {
+            $to[] = array("email" => $email);
+          }
+        }
+      }
+
+      if (!count($to)) return 0;
+
+      $subject = "Incidencia del tipo \"".security::htmlsafe($incidenttype["name"])."\" creada para ".security::htmlsafe($workerName)." el ".strftime("%d %b %Y", $sday);
+      $body = mail::bodyTemplate("<p>Hola,</p>
+      <p>Este es un mensaje automático para avisarte de que ".security::htmlsafe(people::userData("name"))." ha introducido la siguiente incidencia en el sistema de registro horario:</p>
+      <ul>
+        <li><b>Trabajador:</b> ".security::htmlsafe($workerName)."</li>
+        <li><b>Motivo:</b> ".security::htmlsafe($incidenttype["name"])."</li>
+        <li><b>Fecha:</b> ".strftime("%d %b %Y", $sday)." ".(($sbegins == 0 && $sends == self::ENDOFDAY) ? "(todo el día)": schedules::sec2time($sbegins)."-".schedules::sec2time($sends))."</li>".
+        (!empty($details) ? "<li><b>Observaciones:</b> <span style='white-space: pre-wrap;'>".security::htmlsafe($details)."</span></li>" : "").
+      "</ul>
+      <p style='font-size: 11px;'>Has recibido este mensaje porque estás configurado como persona responsable de la categoría a la que pertenece este trabajador o eres el administrador del sistema.</p>");
+
+      return (mail::send($to, [], $subject, $body) ? 0 : 4);
+    }
+
+    return 0;
+  }
+
+  public static function edit($id, $type, $iday, $begins, $ends, $updatedby = "ME") {
+    global $con;
+
+    $sid = (int)$id;
+
+    $incident = incidents::get($id);
+    if ($incident === false) return 1;
+
+    $stype = (int)$type;
+    if ($updatedby === "ME") $updatedby = people::userData("id");
+    $supdatedby = (int)$updatedby;
+
+    $day = new DateTime($iday);
+    $sday = (int)$day->getTimestamp();
+
+    $sbegins = (int)$begins;
+    $sends = (int)$ends;
+    if ($sbegins >= $sends) return 3;
+    if (self::checkOverlap($incident["worker"], $sday, $begins, $ends, $id)) return 2;
+
+    return (mysqli_query($con, "UPDATE incidents SET type = $stype, day = $sday, begins = $sbegins, ends = $sends, updatedby = $supdatedby, workervalidated = 0, workervalidation = '' WHERE id = $sid LIMIT 1") ? 0 : -1);
+  }
+
+  public static function editDetails($id, $details, $updatedby = "ME") {
+    global $con;
+
+    $sid = (int)$id;
+    $sdetails = db::sanitize($details);
+    if ($updatedby === "ME") $updatedby = people::userData("id");
+    $supdatedby = (int)$updatedby;
+
+    $incident = self::get($sid);
+    if ($incident === false) return -1;
+
+    $status = self::getStatus($incident);
+    if (in_array($status, self::$cannotEditCommentsStates)) return 1;
+
+    return (mysqli_query($con, "UPDATE incidents SET details = '$sdetails', updatedby = '$supdatedby' WHERE id = $sid LIMIT 1") ? 0 : -1);
+  }
+
+  public static function verify($id, $value, $confirmedby = "ME") {
+    global $con, $conf;
+
+    $sid = (int)$id;
+    $svalue = ($value == 1 ? 1 : 0);
+    if ($confirmedby === "ME") $confirmedby = people::userData("id");
+    $sconfirmedby = (int)$confirmedby;
+
+    $incident = incidents::get($id);
+    if ($incident === false) return false;
+
+    $state = incidents::getStatus($incident);
+    if ($state != incidents::STATE_UNVERIFIED) return false;
+
+    if (!mysqli_query($con, "UPDATE incidents SET verified = $svalue, confirmedby = $sconfirmedby WHERE id = $sid LIMIT 1")) return false;
+
+    if ($conf["mail"]["enabled"] && $conf["mail"]["capabilities"]["notifyWorkerOnIncidentDecision"]) {
+      $workerEmail = people::workerData("email", $incident["worker"]);
+      if ($workerEmail !== false && !empty($workerEmail)) {
+        $workerName = people::workerData("name", $incident["worker"]);
+
+        $to = [array(
+          "email" => $workerEmail,
+          "name" => $workerName
+        )];
+        $subject = "Incidencia del ".strftime("%d %b %Y", $incident["day"])." ".($value == 1 ? "verificada" : "rechazada");
+        $body = mail::bodyTemplate("<p>Bienvenido ".security::htmlsafe($workerName).",</p>
+        <p>Este es un mensaje automático para avisarte de que la incidencia que introduciste para el día ".strftime("%d de %B de %Y", $incident["day"])." ha sido ".($value == 1 ? "aceptada" : "rechazada").".</p><p>Puedes ver el estado de todas tus incidencias en el <a href='".security::htmlsafe($conf["fullPath"])."'>aplicativo web</a>.</p>");
+
+        mail::send($to, [], $subject, $body);
+      }
+    }
+
+    return true;
+  }
+
+  public static function remove($id) {
+    global $con;
+
+    $sid = (int)$id;
+
+    $incident = incidents::get($id);
+    if ($incident === false) return false;
+
+    if (!self::deleteAttachment($id, "ALL")) return false;
+
+    return mysqli_query($con, "DELETE FROM incidents WHERE id = $sid LIMIT 1");
+  }
+
+  public static function invalidate($id, $updatedby = "ME") {
+    global $con;
+
+    $sid = (int)$id;
+    if ($updatedby === "ME") $updatedby = people::userData("id");
+    $supdatedby = (int)$updatedby;
+
+    $incident = incidents::get($id);
+    if ($incident === false) return false;
+
+    $state = incidents::getStatus($incident);
+    if (!in_array($state, self::$canInvalidateStates)) return false;
+
+    return mysqli_query($con, "UPDATE incidents SET invalidated = 1, updatedby = $supdatedby WHERE id = $sid LIMIT 1");
+  }
+
+  public static function checkIncidentIsFromPerson($incident, $person = "ME", $boolean = false) {
+    global $con;
+
+    if ($person == "ME") $person = people::userData("id");
+
+    $sincident = (int)$incident;
+    $sperson = (int)$person;
+
+    $query = mysqli_query($con, "SELECT i.id FROM incidents i INNER JOIN workers w ON i.worker = w.id INNER JOIN people p ON w.person = p.id WHERE p.id = $sperson AND i.id = $sincident LIMIT 1");
+
+    if (!mysqli_num_rows($query)) {
+      if ($boolean) return false;
+      security::denyUseMethod(security::METHOD_NOTFOUND);
+    }
+
+    return true;
+  }
+}
diff --git a/src/inc/incidentsView.php b/src/inc/incidentsView.php
new file mode 100644
index 0000000..ca646cb
--- /dev/null
+++ b/src/inc/incidentsView.php
@@ -0,0 +1,477 @@
+<?php
+class incidentsView {
+  public static $limitOptions = [10, 15, 20, 30, 40, 50];
+
+  public static $incidentsMsgs = [
+    ["added", "Se ha añadido la incidencia correctamente."],
+    ["modified", "Se ha modificado la incidencia correctamente."],
+    ["removed", "Se ha eliminado la incidencia correctamente."],
+    ["invalidated", "Se ha invalidado la incidencia correctamente."],
+    ["empty", "Faltan datos por introducir en el formulario."],
+    ["unexpected", "Ha ocurrido un error inesperado. Inténtelo de nuevo en unos segundos."],
+    ["cannotmodify", "No se pueden modificar la incidencia porque ya ha sido registrada o se ha invalidado manualmente."],
+    ["verified1", "Se ha verificado la incidencia correctamente."],
+    ["verified0", "Se ha rechazado la incidencia correctamente."],
+    ["overlap", "La incidencia no se ha añadido porque se solapa con otra incidencia del mismo trabajador."],
+    ["order", "La hora de inicio debe ser anterior a la hora de fin."],
+    ["addedemailnotsent", "La incidencia se ha añadido, pero no se ha podido enviar un correo de notificación a los responsables de la categoría del trabajador. Por favor, notifica a estas personas manualmente."],
+    ["filesize", "El tamaño del archivo adjunto es demasiado grande (el límite es de ".files::READABLE_MAX_SIZE.")"],
+    ["filetype", "El formato del archivo no está soportado."],
+    ["attachmentadded", "Se ha añadido el archivo adjunto correctamente."],
+    ["attachmentdeleted", "Se ha eliminado el archivo adjunto correctamente."],
+    ["addedrecurring", "Se han añadido todas las incidencias pertinentes correctamente."],
+    ["unexpectedrecurring", "Ha habido algún problema añadiendo alguna(s) o todas las incidencias que se tenían que crear."],
+    ["addednotautovalidated", "La incidencia se ha añadido, pero no se ha podido autovalidar."],
+    ["deleteincidentsbulksuccess", "Las incidencias se han eliminado/invalidado correctamente."],
+    ["deleteincidentsbulkpartialsuccess", "Algunas incidencias (o todas) no se han podido eliminar/invalidar. Por favor, comprueba el resultado de la acción."]
+  ];
+
+  public static function renderIncidents(&$incidents, &$companies, $scrollable = false, $showPersonAndCompany = true, $isForWorker = false, $isForValidationView = false, $isForMassEdit = false, $continueUrl = "incidents.php") {
+    global $conf, $renderIncidentsAutoIncremental;
+    if (!isset($renderIncidentsAutoIncremental)) $renderIncidentsAutoIncremental = 0;
+    $menu = "";
+
+    $safeContinueUrl = security::htmlsafe(urlencode($continueUrl));
+
+    if ($isForMassEdit) {
+      ?>
+      <div class="left-actions">
+        <button id="deleteincidentsbulk" class="mdl-button mdl-js-button mdl-button--icon mdl-js-ripple-effect"><i class="material-icons icon">delete</i></button>
+      </div>
+      <?php
+    }
+    ?>
+    <div class="mdl-shadow--2dp overflow-wrapper incidents-wrapper<?=($scrollable ? " incidents-wrapper--scrollable" : "")?>">
+      <table class="incidents">
+        <?php
+        if ($isForValidationView || $isForMassEdit) {
+          ?>
+          <tr class="artificial-height">
+            <?php if ($conf["debug"]) echo "<td></td>"; ?>
+            <td class="icon-cell has-checkbox">
+              <label for="checkboxall_i_<?=$renderIncidentsAutoIncremental?>" class="mdl-checkbox mdl-js-checkbox mdl-js-ripple-effect" data-check-all="true">
+                <input type="checkbox" id="checkboxall_i_<?=$renderIncidentsAutoIncremental?>" class="mdl-checkbox__input" autocomplete="off">
+              </label>
+            </td>
+            <?php if ($conf["debug"]) echo "<td></td>"; ?>
+            <td></td>
+            <?php if (!$isForValidationView) echo "<td></td>"; ?>
+            <td></td>
+            <?php if ($showPersonAndCompany) echo "<td></td><td></td>"; ?>
+            <td></td>
+          </tr>
+          <?php
+        }
+
+        foreach ($incidents as $incident) {
+          $id = (int)$incident["id"]."_".(int)$renderIncidentsAutoIncremental;
+          $canEdit = (!$isForWorker && !in_array($incident["state"], incidents::$cannotEditStates)) || ($isForWorker && in_array($incident["state"], incidents::$workerCanEditStates));
+          $canRemove = (!$isForWorker && in_array($incident["state"], incidents::$canRemoveStates)) || ($isForWorker && in_array($incident["state"], incidents::$workerCanRemoveStates));
+          $canInvalidate = !$isForWorker && in_array($incident["state"], incidents::$canInvalidateStates);
+          $attachments = count(incidents::getAttachmentsFromIncident($incident));
+          ?>
+          <tr<?=(in_array($incident["state"], incidents::$invalidStates) ? ' class="mdl-color-text--grey-700 line-through"' : '')?>>
+            <?php if ($conf["debug"]) { ?><td><?=(int)$incident["id"]?></td><?php } ?>
+            <?php
+            if ($isForValidationView || $isForMassEdit) {
+              ?>
+              <td class="icon-cell has-checkbox">
+                <label for="checkbox_i_<?=$id?>" class="mdl-checkbox mdl-js-checkbox mdl-js-ripple-effect">
+                  <input type="checkbox" id="checkbox_i_<?=$id?>" data-incident="<?=(int)$incident["id"]?>" class="mdl-checkbox__input"<?=($isForMassEdit && !in_array($incident["state"], incidents::$canRemoveStates) && !in_array($incident["state"], incidents::$canInvalidateStates) ? " disabled" : "")?> autocomplete="off">
+                </label>
+              </td>
+              <?php
+            }
+
+            if (!$isForValidationView) {
+              ?>
+              <td class="icon-cell">
+                <i id="state<?=$id?>" class="material-icons <?=security::htmlsafe(incidents::$stateIconColors[$incident["state"]])?>"><?=security::htmlsafe(incidents::$stateIcons[$incident["state"]])?></i>
+              </td>
+              <?php
+              visual::addTooltip("state".$id, security::htmlsafe(incidents::$stateTooltips[$incident["state"]]));
+            }
+            ?>
+            <td class="can-strike"><?=security::htmlsafe($incident["typename"])?></td>
+            <?php if ($showPersonAndCompany) {
+              ?>
+              <td class="can-strike"><span data-dyndialog-href="dynamic/user.php?id=<?=(int)$incident["personid"]?>"><?=security::htmlsafe($incident["workername"])?></span></td>
+              <td class="can-strike"><?=security::htmlsafe($companies[$incident["companyid"]])?></td>
+              <?php
+            }
+            ?>
+            <td class="can-strike"><?=strftime("%d %b %Y", $incident["day"])." ".security::htmlsafe($incident["allday"] ? "(todo el día)" : schedules::sec2time($incident["begins"])."-".schedules::sec2time($incident["ends"]))?></td>
+            <td>
+              <a href="dynamic/editincidentcomment.php?id=<?=(int)$incident["id"]?>&continue=<?=$safeContinueUrl?>" data-dyndialog-href="dynamic/editincidentcomment.php?id=<?=(int)$incident["id"]?>&continue=<?=$safeContinueUrl?>" class="mdl-button mdl-js-button mdl-button--icon mdl-js-ripple-effect" title="Ver/editar las observaciones"><i class="material-icons icon"><?=(empty($incident["details"]) && empty($incident["workerdetails"]) ? "mode_" : "")?>comment</i></a>
+              <span<?=($attachments > 0 ? ' class="mdl-badge mdl-badge--overlap" data-badge="'.$attachments.'"' : '')?>><a href="dynamic/incidentattachments.php?id=<?=(int)$incident["id"]?>&continue=<?=$safeContinueUrl?>" data-dyndialog-href="dynamic/incidentattachments.php?id=<?=(int)$incident["id"]?>&continue=<?=$safeContinueUrl?>" class="mdl-button mdl-js-button mdl-button--icon mdl-js-ripple-effect" title="Ver/gestionar los archivos adjuntos"><i class="material-icons icon">attach_file</i></a></span>
+              <button id="actions<?=$id?>" class="mdl-button mdl-js-button mdl-button--icon mdl-js-ripple-effect custom-actions-btn"><i class="material-icons icon">more_vert</i></button>
+              <?php
+              $menu .= '<ul class="mdl-menu mdl-menu--unaligned mdl-js-menu mdl-js-ripple-effect" for="actions'.$id.'">';
+
+              if ($canEdit) $menu .= '<a href="dynamic/editincident.php?id='.(int)$incident["id"].'&continue='.$safeContinueUrl.'" data-dyndialog-href="dynamic/editincident.php?id='.(int)$incident["id"].'&continue='.$safeContinueUrl.'"><li class="mdl-menu__item">Editar</li></a>';
+
+              if ($canRemove) $menu .= '<a href="dynamic/deleteincident.php?id='.(int)$incident["id"].'&continue='.$safeContinueUrl.'" data-dyndialog-href="dynamic/deleteincident.php?id='.(int)$incident["id"].'&continue='.$safeContinueUrl.'"><li class="mdl-menu__item">Eliminar</li></a>';
+
+              if ($canInvalidate) $menu .= '<a href="dynamic/invalidateincident.php?id='.(int)$incident["id"].'&continue='.$safeContinueUrl.'" data-dyndialog-href="dynamic/invalidateincident.php?id='.(int)$incident["id"].'&continue='.$safeContinueUrl.'"><li class="mdl-menu__item">Invalidar</li></a>';
+
+              $menu .= '<a href="dynamic/authorsincident.php?id='.(int)$incident["id"].'&continue='.$safeContinueUrl.'" data-dyndialog-href="dynamic/authorsincident.php?id='.(int)$incident["id"].'&continue='.$safeContinueUrl.'"><li class="mdl-menu__item">Autoría</li></a></ul>';
+
+              if ($incident["state"] == incidents::STATE_UNVERIFIED && !$isForWorker) {
+                ?>
+                <form action="doverifyincident.php" method="POST" class="verification-actions">
+                  <input type="hidden" name="id" value="<?=(int)$incident["id"]?>">
+                  <?php visual::addContinueInput($continueUrl); ?>
+                  <button name="value" value="1" class="mdl-button mdl-js-button mdl-button--icon mdl-js-ripple-effect"><i class="material-icons mdl-color-text--green">check</i></button><button name="value" value="0" class="mdl-button mdl-js-button mdl-button--icon mdl-js-ripple-effect"><i class="material-icons mdl-color-text--red">close</i></button>
+                </form>
+                <?php
+              }
+              ?>
+            </td>
+          </tr>
+          <?php
+          $renderIncidentsAutoIncremental++;
+        }
+        ?>
+      </table>
+      <?php echo $menu; ?>
+    </div>
+  <?php
+  }
+
+  public static function renderIncidentForm(&$workers, $valueFunction, $textFunction, &$companies, $isForWorker = false, $recurrent = false, $continueUrl = false) {
+    $prefix = ($recurrent ? "recurring" : "");
+    ?>
+    <script>
+    window.addEventListener("load", e1 => {
+      document.getElementById("<?=$prefix?>allday").addEventListener("change", e => {
+        var partialtime = document.getElementById("<?=$prefix?>partialtime");
+        if (e.target.checked) {
+          partialtime.classList.add("notvisible");
+        } else {
+          partialtime.classList.remove("notvisible");
+        }
+      });
+      <?php
+      if ($recurrent) {
+        ?>
+        var defaultFields = [
+          {
+            "name": "day",
+            "min": 0,
+            "max": 4
+          },
+          {
+            "name": "type",
+            "min": 1,
+            "max": 2
+          }
+        ];
+
+        defaultFields.forEach(field => {
+          for (var i = field.min; i <= field.max; i++) {
+            document.querySelector("[for=\""+field.name+"-"+i+"\"]").MaterialCheckbox.check();
+
+            var checkbox = document.getElementById(field.name+"-"+i);
+            checkbox.checked = true;
+            if ("createEvent" in document) {
+              var evt = document.createEvent("HTMLEvents");
+              evt.initEvent("change", false, true);
+              checkbox.dispatchEvent(evt);
+            }
+          }
+        });
+        <?php
+      }
+      ?>
+    });
+    </script>
+    <dialog class="mdl-dialog" id="add<?=$prefix?>incident">
+      <form action="doadd<?=$prefix?>incident.php" method="POST" autocomplete="off">
+        <?php
+        if ($continueUrl !== false) visual::addContinueInput($continueUrl);
+        helpView::renderHelpButton(help::PLACE_INCIDENT_FORM, true, true);
+        ?>
+        <h4 class="mdl-dialog__title">Añade una incidencia<?=($recurrent ? " recurrente" : "")?></h4>
+        <div class="mdl-dialog__content">
+          <div class="mdlext-selectfield mdlext-js-selectfield mdlext-selectfield--floating-label">
+            <select name="worker" id="<?=$prefix?>worker" class="mdlext-selectfield__select" data-required>
+              <option></option>
+              <?php
+              foreach ($workers as $worker) {
+                if ($worker["hidden"] == 1) continue;
+                echo '<option value="'.security::htmlsafe($valueFunction($worker, $companies)).'">'.security::htmlsafe($textFunction($worker, $companies)).'</option>';
+              }
+              ?>
+            </select>
+            <label for="<?=$prefix?>worker" class="mdlext-selectfield__label">Trabajador</label>
+          </div>
+          <br>
+          <div class="mdlext-selectfield mdlext-js-selectfield mdlext-selectfield--floating-label">
+            <select name="type" id="<?=$prefix?>type" class="mdlext-selectfield__select" data-required>
+              <option></option>
+              <?php
+              foreach (incidents::getTypesForm($isForWorker) as $i) {
+                echo '<option value="'.(int)$i["id"].'">'.security::htmlsafe($i["name"]).'</option>';
+              }
+              ?>
+            </select>
+            <label for="<?=$prefix?>type" class="mdlext-selectfield__label">Tipo</label>
+          </div>
+
+          <?php
+          if ($isForWorker) {
+            echo "<p>Para usar un tipo de incidencia que no esté en la lista, debes ponerte en contacto con RRHH para que rellenen ellos la incidencia manualmente.</p>";
+          }
+          ?>
+
+          <?php
+          if ($recurrent) {
+            ?>
+            <h5>Recurrencia</h5>
+            <div class="mdl-textfield mdl-js-textfield mdl-textfield--floating-label">
+              <input class="mdl-textfield__input" type="date" name="firstday" id="<?=$prefix?>firstday" autocomplete="off" data-required>
+              <label class="mdl-textfield__label always-focused" for="<?=$prefix?>firstday">Día de inicio</label>
+            </div>
+            <br>
+            <div class="mdl-textfield mdl-js-textfield mdl-textfield--floating-label">
+              <input class="mdl-textfield__input" type="date" name="lastday" id="<?=$prefix?>lastday" autocomplete="off" data-required>
+              <label class="mdl-textfield__label always-focused" for="<?=$prefix?>lastday">Día de fin</label>
+            </div>
+            <br>
+            <div class="mdlext-selectfield mdlext-js-selectfield mdlext-selectfield--floating-label">
+              <div id="dayMenu" class="mdlext-selectfield__select mdl-custom-selectfield__select" tabindex="0">-</div>
+              <ul class="mdl-menu mdl-menu--bottom mdl-js-menu mdl-custom-multiselect mdl-custom-multiselect-js" for="dayMenu">
+                <?php
+                foreach (calendars::$days as $id => $day) {
+                  ?>
+                  <li class="mdl-menu__item mdl-custom-multiselect__item">
+                    <label class="mdl-checkbox mdl-js-checkbox" for="day-<?=(int)$id?>">
+                      <input type="checkbox" id="day-<?=(int)$id?>" name="day[]" value="<?=(int)$id?>" data-value="<?=(int)$id?>" class="mdl-checkbox__input">
+                      <span class="mdl-checkbox__label"><?=security::htmlsafe($day)?></span>
+                    </label>
+                  </li>
+                  <?php
+                }
+                ?>
+              </ul>
+              <label for="day" class="mdlext-selectfield__label always-focused mdl-color-text--primary">Día de la semana</label>
+            </div>
+            <br>
+            <div class="mdlext-selectfield mdlext-js-selectfield mdlext-selectfield--floating-label">
+              <div id="dayType" class="mdlext-selectfield__select mdl-custom-selectfield__select" tabindex="0">-</div>
+              <ul class="mdl-menu mdl-menu--bottom mdl-js-menu mdl-custom-multiselect mdl-custom-multiselect-js" for="dayType">
+                <?php
+                foreach (calendars::$types as $id => $type) {
+                  if ($id == calendars::TYPE_FESTIU) continue;
+                  ?>
+                  <li class="mdl-menu__item mdl-custom-multiselect__item">
+                    <label class="mdl-checkbox mdl-js-checkbox" for="type-<?=(int)$id?>">
+                      <input type="checkbox" id="type-<?=(int)$id?>" name="daytype[]" value="<?=(int)$id?>" data-value="<?=(int)$id?>" class="mdl-checkbox__input">
+                      <span class="mdl-checkbox__label"><?=security::htmlsafe($type)?></span>
+                    </label>
+                  </li>
+                  <?php
+                }
+                ?>
+              </ul>
+              <label for="day" class="mdlext-selectfield__label always-focused mdl-color-text--primary">Tipo de día</label>
+            </div>
+            <h5>Afectación</h5>
+            <?php
+          } else {
+            ?>
+            <h5>Afectación</h5>
+            <div class="mdl-textfield mdl-js-textfield mdl-textfield--floating-label">
+              <input class="mdl-textfield__input" type="date" name="day" id="<?=$prefix?>day" autocomplete="off" data-required>
+              <label class="mdl-textfield__label always-focused" for="<?=$prefix?>day">Día</label>
+            </div>
+            <br>
+            <?php
+          }
+          ?>
+          <p>
+            <label class="mdl-switch mdl-js-switch mdl-js-ripple-effect" for="<?=$prefix?>allday">
+              <input type="checkbox" id="<?=$prefix?>allday" name="allday" value="1" class="mdl-switch__input">
+              <span class="mdl-switch__label">Día entero</span>
+            </label>
+          </p>
+          <div id="<?=$prefix?>partialtime">De <input type="time" name="begins"> a <input type="time" name="ends"></div>
+
+          <h5>Detalles adicionales</h5>
+          <div class="mdl-textfield mdl-js-textfield mdl-textfield--floating-label">
+            <textarea class="mdl-textfield__input" name="details" id="<?=$prefix?>details"></textarea>
+            <label class="mdl-textfield__label" for="<?=$prefix?>details">Observaciones (opcional)</label>
+          </div>
+          <?php
+          if (!$isForWorker && !$recurrent) {
+            ?>
+            <p>
+              <label class="mdl-switch mdl-js-switch mdl-js-ripple-effect" for="<?=$prefix?>autoverify">
+                <input type="checkbox" id="<?=$prefix?>autoverify" name="autoverify" value="1" class="mdl-switch__input" checked>
+                <span class="mdl-switch__label">Saltar la cola de revisión</span>
+              </label>
+            </p>
+            <?php
+          }
+
+          if (!$isForWorker) echo "<p>Las observaciones aparecerán en los PDFs que se exporten.</p>";
+          else echo "<p>Las observaciones serán únicamente visibles para los administradores del sistema.</p><p>Al añadir la incidencia, se guardará tu <a href=\"https://help.gnome.org/users/gnome-help/stable/net-what-is-ip-address.html.es\" target=\"_blank\" rel=\"noopener noreferrer\">dirección IP</a> y la fecha y hora actual para autovalidar la incidencia.</p>";
+          if (!$recurrent) echo '<p>Después de crear la incidencia podrás añadir archivos adjuntos haciendo clic en el botón <i class="material-icons" style="vertical-align: middle;">attach_file</i>.</p>';
+          ?>
+        </div>
+        <div class="mdl-dialog__actions">
+          <button type="submit" class="mdl-button mdl-js-button mdl-js-ripple-effect mdl-button--accent">Añadir</button>
+          <button onclick="event.preventDefault(); document.querySelector('#add<?=$prefix?>incident').close();" class="mdl-button mdl-js-button mdl-js-ripple-effect cancel">Cancelar</button>
+        </div>
+      </form>
+    </dialog>
+    <?php
+  }
+
+  public static function renderFilterDialog($select) {
+    global $_GET;
+    ?>
+    <style>
+    #filter {
+      max-width: 300px;
+      width: auto;
+    }
+
+    #filter .mdl-checkbox {
+      height: auto;
+    }
+    </style>
+    <dialog class="mdl-dialog" id="filter">
+      <form action="incidents.php" method="GET" enctype="multipart/form-data">
+        <h4 class="mdl-dialog__title">Filtrar lista</h4>
+        <div class="mdl-dialog__content">
+          <h5>Por fecha</h5>
+          <div class="mdl-textfield mdl-js-textfield mdl-textfield--floating-label">
+            <input class="mdl-textfield__input" type="date" name="begins" id="begins" autocomplete="off" <?=$select["enabled"]["begins"] ? " value=\"".security::htmlsafe($select["selected"]["begins"])."\"" : ""?>>
+            <label class="mdl-textfield__label always-focused" for="begins">Fecha inicio (opcional)</label>
+          </div>
+          <br>
+          <div class="mdl-textfield mdl-js-textfield mdl-textfield--floating-label">
+            <input class="mdl-textfield__input" type="date" name="ends" id="ends" autocomplete="off" <?=$select["enabled"]["ends"] ? " value=\"".security::htmlsafe($select["selected"]["ends"])."\"" : ""?>>
+            <label class="mdl-textfield__label always-focused" for="ends">Fecha fin (opcional)</label>
+          </div>
+          <h5>Por estado <i id="tt_incidentstatusnotpaginated" class="material-icons help">info</i></h5>
+          <div class="mdl-tooltip" for="tt_incidentstatusnotpaginated">Al filtrar por estado, la página de resultados no estará paginada y saldrán todos los resultados en una misma página. La acción podría consumir bastantes recursos y tomar más tiempo del habitual.</div>
+          <?php
+          foreach (incidents::$statesOrderForFilters as $id) {
+            ?>
+            <label class="mdl-checkbox mdl-js-checkbox mdl-js-ripple-effect" for="state<?=(int)$id?>">
+              <input type="checkbox" id="state<?=(int)$id?>" name="states[<?=(int)$id?>]" class="mdl-checkbox__input" value="1"<?=(($select["enabled"]["states"] && in_array($id, $select["selected"]["states"])) ? " checked" : "")?>>
+              <span class="mdl-checkbox__label"><?=security::htmlsafe(incidents::$stateTooltips[$id])?></span>
+            </label>
+            <?php
+          }
+          ?>
+          <h5>Por tipo</h5>
+          <?php
+          $types = incidents::getTypes();
+          if ($types !== false) {
+            foreach ($types as $type) {
+              ?>
+              <label class="mdl-checkbox mdl-js-checkbox mdl-js-ripple-effect" for="type<?=(int)$type["id"]?>">
+                <input type="checkbox" id="type<?=(int)$type["id"]?>" name="types[<?=(int)$type["id"]?>]" class="mdl-checkbox__input" value="1"<?=(($select["enabled"]["types"] && in_array($type["id"], $select["selected"]["types"])) ? " checked" : "")?>>
+                <span class="mdl-checkbox__label"><?=security::htmlsafe($type["name"])?></span>
+              </label>
+              <?php
+            }
+          }
+          ?>
+          <h5>Miscelánea</h5>
+          <?php
+          foreach (incidents::$filtersSwitchHelper as $f => $helper) {
+            foreach (incidents::$filtersSwitchOptions as $value => $option) {
+              ?>
+              <label class="mdl-checkbox mdl-js-checkbox mdl-js-ripple-effect" for="<?=security::htmlsafe($f).(int)$value?>">
+                <input type="checkbox" id="<?=security::htmlsafe($f).(int)$value?>" name="<?=security::htmlsafe($f)?>[<?=(int)$value?>]" class="mdl-checkbox__input" value="1"<?=(($select["enabled"][$f] && in_array($value, $select["selected"][$f])) ? " checked" : "")?>>
+                <span class="mdl-checkbox__label"><?=security::htmlsafe($option." ".$helper)?></span>
+              </label>
+              <?php
+            }
+          }
+          ?>
+        </div>
+        <div class="mdl-dialog__actions">
+          <button type="submit" class="mdl-button mdl-js-button mdl-js-ripple-effect mdl-button--accent">Filtrar</button>
+          <button onclick="event.preventDefault(); document.querySelector('#filter').close();" class="mdl-button mdl-js-button mdl-js-ripple-effect cancel">Cancelar</button>
+        </div>
+      </form>
+    </dialog>
+    <?php
+  }
+
+  public static function buildSelect() {
+    global $select, $_GET;
+
+    $select = array("showPendingQueue" => false, "isAnyEnabled" => false, "pageUrl" => "", "pageUrlHasParameters" => false, "showResultsPaginated" => true, "enabled" => [], "selected" => []);
+
+    $parameters = [];
+
+    foreach (incidents::$filters as $f) {
+      $fType = incidents::$filtersType[$f];
+
+      switch ($fType) {
+        case incidents::FILTER_TYPE_ARRAY:
+        $select["enabled"][$f] = isset($_GET[$f]);
+        break;
+
+        case incidents::FILTER_TYPE_INT:
+        $select["enabled"][$f] = isset($_GET[$f]) && $_GET[$f] !== "";
+        break;
+
+        case incidents::FILTER_TYPE_STRING:
+        $select["enabled"][$f] = isset($_GET[$f]) && !empty($_GET[$f]);
+        break;
+      }
+
+      if ($select["enabled"][$f]) {
+        switch ($fType) {
+          case incidents::FILTER_TYPE_ARRAY:
+          $select["selected"][$f] = (isset($_GET[$f]) ? array_keys($_GET[$f]) : []);
+          foreach ($select["selected"][$f] as $value) {
+            $parameters[] = urlencode($f)."[".urlencode($value)."]=1";
+          }
+          break;
+
+          case incidents::FILTER_TYPE_INT:
+          $select["selected"][$f] = (int)$_GET[$f];
+          $parameters[] = urlencode($f)."=".(int)$_GET[$f];
+          break;
+
+          case incidents::FILTER_TYPE_STRING:
+          $select["selected"][$f] = $_GET[$f];
+          $parameters[] = urlencode($f)."=".urlencode($_GET[$f]);
+          break;
+        }
+      }
+    }
+
+    foreach ($select["enabled"] as $enabled) {
+      if ($enabled) {
+        $select["isAnyEnabled"] = true;
+        break;
+      }
+    }
+
+    if (!$select["isAnyEnabled"] || (isset($_GET["forceQueue"]) && $_GET["forceQueue"] == "1")) $select["showPendingQueue"] = true;
+
+    $select["pageUrlHasParameters"] = (count($parameters) > 0);
+    $select["pageUrl"] = "incidents.php".($select["pageUrlHasParameters"] ? "?".implode("&", $parameters) : "");
+    $select["showResultsPaginated"] = !$select["enabled"]["states"];
+  }
+
+  public static function handleIncidentShortcuts() {
+    global $_GET;
+    if (isset($_GET["goTo"])) {
+      switch ($_GET["goTo"]) {
+        case "today":
+        security::go("incidents.php?page=".(int)incidents::todayPage());
+        break;
+      }
+    }
+  }
+}
diff --git a/src/inc/intervals.php b/src/inc/intervals.php
new file mode 100644
index 0000000..10c31f7
--- /dev/null
+++ b/src/inc/intervals.php
@@ -0,0 +1,32 @@
+<?php
+class intervals {
+  public static function wellFormed($i) {
+    return (isset($i[0]) && isset($i[1]) && $i[0] <= $i[1]);
+  }
+
+  public static function measure($i) {
+    return ($i[1] - $i[0]);
+  }
+
+  // Does A overlap B? (with "$open = true" meaning [0, 100] does not overlap [100, 200])
+  public static function overlaps($a, $b, $open = true) {
+    return ($open ? ($a[0] < $b[1] && $a[1] > $b[0]) : ($a[0] <= $b[1] && $a[1] >= $b[0]));
+  }
+
+  // Is A inside of B?
+  public static function isSubset($a, $b) {
+    return ($a[0] >= $b[0] && $a[1] <= $b[1]);
+  }
+
+  // Intersect A and B and return the corresponding interval
+  public static function intersect($a, $b) {
+    $int = [max($a[0], $b[0]), min($a[1], $b[1])];
+
+    return (self::wellFormed($int) ? $int : false);
+  }
+
+  // Return measure of the intersection
+  public static function measureIntersection($a, $b) {
+    return self::measure(self::intersect($a, $b));
+  }
+}
diff --git a/src/inc/listings.php b/src/inc/listings.php
new file mode 100644
index 0000000..b3aa258
--- /dev/null
+++ b/src/inc/listings.php
@@ -0,0 +1,102 @@
+<?php
+class listings {
+  public static function renderFilterDialog($form, $select) {
+    global $_GET;
+    ?>
+    <dialog class="mdl-dialog" id="filter">
+      <form action="<?=$form?>" method="GET" enctype="multipart/form-data">
+        <h4 class="mdl-dialog__title">Filtrar lista</h4>
+        <div class="mdl-dialog__content">
+          <h5>Categorías</h5>
+          <label class="mdl-checkbox mdl-js-checkbox mdl-js-ripple-effect" for="cat-1">
+            <input type="checkbox" id="cat-1" name="categories[-1]" class="mdl-checkbox__input" value="1"<?=(($select["enabled"]["categories"] && in_array(-1, $select["selected"]["categories"])) ? " checked" : "")?>>
+            <span class="mdl-checkbox__label">Sin categoría</span>
+          </label>
+          <?php
+          foreach (categories::getAll(false, false, true) as $c) {
+            $haschilds = (count($c["childs"]) > 0);
+            if ($haschilds) {
+              $subcategories_arr = [];
+              foreach ($c["childs"] as $child) {
+                $subcategories_arr[] = "&ldquo;".security::htmlsafe($child["name"])."&rdquo;";
+              }
+              $subcategories = implode(", ", $subcategories_arr);
+            }
+            ?>
+            <label class="mdl-checkbox mdl-js-checkbox mdl-js-ripple-effect" for="cat<?=(int)$c["id"]?>">
+              <input type="checkbox" id="cat<?=(int)$c["id"]?>" name="categories[<?=(int)$c["id"]?>]" class="mdl-checkbox__input" value="1"<?=(($select["enabled"]["categories"] && in_array($c["id"], $select["selected"]["categories"])) ? " checked" : "")?>>
+              <span class="mdl-checkbox__label"><?=security::htmlsafe($c["name"])?> <?php if ($haschilds) { ?><i id="haschilds<?=(int)$c["id"]?>" class="material-icons help">info</i><?php } ?></span>
+            </label>
+            <?php
+            if ($haschilds) {
+              ?>
+              <div class="mdl-tooltip" for="haschilds<?=(int)$c["id"]?>">Esta categoría incluye la<?=(count($c["childs"]) == 1 ? "" : "s")?> subcategoría<?=(count($c["childs"]) == 1 ? "" : "s")?> <?=$subcategories?></div>
+              <?php
+            }
+          }
+          ?>
+          <h5>Empresas</h5>
+          <?php
+          foreach (companies::getAll() as $id => $company) {
+            ?>
+            <label class="mdl-checkbox mdl-js-checkbox mdl-js-ripple-effect" for="comp<?=(int)$id?>">
+              <input type="checkbox" id="comp<?=(int)$id?>" name="companies[<?=(int)$id?>]" class="mdl-checkbox__input" value="1"<?=(($select["enabled"]["companies"] && in_array($id, $select["selected"]["companies"])) ? " checked" : "")?>>
+              <span class="mdl-checkbox__label"><?=security::htmlsafe($company)?></span>
+            </label>
+            <?php
+          }
+          ?>
+          <h5>Tipos de usuario</h5>
+          <?php
+          foreach (security::$types as $id => $type) {
+            ?>
+            <label class="mdl-checkbox mdl-js-checkbox mdl-js-ripple-effect" for="type<?=(int)$id?>">
+              <input type="checkbox" id="type<?=(int)$id?>" name="types[<?=(int)$id?>]" class="mdl-checkbox__input" value="1"<?=(($select["enabled"]["types"] && in_array($id, $select["selected"]["types"])) ? " checked" : "")?>>
+              <span class="mdl-checkbox__label"><?=security::htmlsafe($type)?></span>
+            </label>
+            <?php
+          }
+
+          if ($form == "workers.php") {
+            echo "<h5>Horario actual</h5>";
+            foreach (schedules::$workerScheduleStatusShort as $id => $type) {
+              ?>
+              <label class="mdl-checkbox mdl-js-checkbox mdl-js-ripple-effect" for="schedulesstatus<?=(int)$id?>">
+                <input type="checkbox" id="schedulesstatus<?=(int)$id?>" name="schedulesstatus[<?=(int)$id?>]" class="mdl-checkbox__input" value="1"<?=(isset($_GET["schedulesstatus"][(int)$id]) ? " checked" : "")?>>
+                <span class="mdl-checkbox__label mdl-color-text--<?=security::htmlsafe(schedules::$workerScheduleStatusColors[$id])?>"><?=security::htmlsafe($type)?></span>
+              </label>
+              <?php
+            }
+          }
+          ?>
+        </div>
+        <div class="mdl-dialog__actions">
+          <button type="submit" class="mdl-button mdl-js-button mdl-js-ripple-effect mdl-button--accent">Filtrar</button>
+          <button onclick="event.preventDefault(); document.querySelector('#filter').close();" class="mdl-button mdl-js-button mdl-js-ripple-effect cancel">Cancelar</button>
+        </div>
+      </form>
+    </dialog>
+    <?php
+  }
+
+  public static function buildSelect($form) {
+    global $select, $selectedSchedulesStatus, $_GET;
+
+    $select = array("enabled" => [], "selected" => []);
+
+    foreach (people::$filters as $f) {
+      $select["enabled"][$f] = isset($_GET[$f]);
+      if ($select["enabled"][$f]) {
+        $select["selected"][$f] = (isset($_GET[$f]) ? array_keys($_GET[$f]) : []);
+      }
+    }
+
+    if ($form == "workers.php") {
+      if (isset($_GET["schedulesstatus"]) && is_array($_GET["schedulesstatus"]) && count($_GET["schedulesstatus"])) {
+        $selectedSchedulesStatus = array_keys($_GET["schedulesstatus"]);
+      } else {
+        $selectedSchedulesStatus = false;
+      }
+    }
+  }
+}
diff --git a/src/inc/mail.php b/src/inc/mail.php
new file mode 100644
index 0000000..7f3b353
--- /dev/null
+++ b/src/inc/mail.php
@@ -0,0 +1,65 @@
+<?php
+use PHPMailer\PHPMailer\PHPMailer;
+use PHPMailer\PHPMailer\Exception;
+use PHPMailer\PHPMailer\SMTP;
+
+require __DIR__.'/../lib/PHPMailer/Exception.php';
+require __DIR__.'/../lib/PHPMailer/PHPMailer.php';
+require __DIR__.'/../lib/PHPMailer/SMTP.php';
+
+class mail {
+  public static function send($to, $attachments, $subject, $body, $isHTML = true, $plainBody = "") {
+    global $conf;
+
+    if (!$conf["mail"]["enabled"]) return false;
+
+    $mail = new PHPMailer();
+
+    try {
+      if ($conf["debug"]) {
+        $mail->SMTPDebug = SMTP::DEBUG_SERVER;
+      }
+      $mail->CharSet = "UTF-8";
+      $mail->isSMTP();
+      $mail->Host = $conf["mail"]["host"];
+      $mail->SMTPAuth = $conf["mail"]["smtpauth"];
+      $mail->Username = $conf["mail"]["username"];
+      $mail->Password = $conf["mail"]["password"];
+      $mail->SMTPSecure = 'tls';
+      $mail->Port = $conf["mail"]["port"];
+      $mail->Encoding = 'base64';
+      $mail->Timeout = 30;
+
+      $mail->setFrom($conf["mail"]["remitent"], $conf["mail"]["remitentName"]);
+
+      foreach ($to as $address) {
+        if (isset($address["name"])) $mail->addAddress($address["email"], $address["name"]);
+        else $mail->addAddress($address["email"]);
+      }
+
+      foreach ($attachments as $attachment) {
+        if (isset($attachment["name"])) $mail->addAttachment($attachment["path"], $attachment["name"]);
+        else $mail->addAttachment($attachment["email"]);
+      }
+
+      if ($isHTML) {
+        $mail->isHTML(true);
+        if (!empty($plainBody)) $mail->AltBody = $plainBody;
+      }
+
+      $mail->Subject = (!empty($conf["mail"]["subjectPrefix"]) ? $conf["mail"]["subjectPrefix"]." " : "").$subject;
+      $mail->Body = $body;
+
+      if (!$mail->send()) return false;
+    } catch (Exception $e) {
+      if ($conf["debug"]) echo $e."\n";
+      return false;
+    }
+
+    return true;
+  }
+
+  public static function bodyTemplate($msg) {
+    return "<div style=\"font-family: 'Helvetica', 'Arial', sans-serif;\">".$msg."</div>";
+  }
+}
diff --git a/src/inc/people.php b/src/inc/people.php
new file mode 100644
index 0000000..deccc10
--- /dev/null
+++ b/src/inc/people.php
@@ -0,0 +1,244 @@
+<?php
+class people {
+  public static $filters = ["categories", "types", "companies"];
+  public static $mysqlFilters = ["categories", "types"];
+  public static $mysqlFiltersFields = ["p.category", "p.type"];
+
+  public static function add($username, $name, $dni, $email, $category, $password_hash, $type) {
+    global $con;
+
+    $susername = db::sanitize($username);
+    $sname = db::sanitize($name);
+    $sdni = db::sanitize($dni);
+    $semail = db::sanitize($email);
+    $scategory = (int)$category;
+    $spassword_hash = db::sanitize($password_hash);
+    $stype = (int)$type;
+
+    if (!categories::exists($category) || !security::existsType($type)) return false;
+
+    return mysqli_query($con, "INSERT INTO people (username, name, dni, email, category, password, type) VALUES ('$susername', '$sname', '$sdni', '$semail', $scategory, '$spassword_hash', $stype)");
+  }
+
+  public static function edit($id, $username, $name, $dni, $email, $category, $type) {
+    global $con;
+
+    $sid = (int)$id;
+    $susername = db::sanitize($username);
+    $sname = db::sanitize($name);
+    $sdni = db::sanitize($dni);
+    $semail = db::sanitize($email);
+    $scategory = (int)$category;
+    $stype = (int)$type;
+
+    return mysqli_query($con, "UPDATE people SET username = '$susername', name = '$sname', dni = '$sdni', email = '$semail', category = $scategory, type = $stype WHERE id = $sid LIMIT 1");
+  }
+
+  public static function updatePassword($id, $password_hash) {
+    global $con;
+
+    $sid = (int)$id;
+    $spassword_hash = db::sanitize($password_hash);
+
+    return mysqli_query($con, "UPDATE people SET password = '$spassword_hash' WHERE id = $sid LIMIT 1");
+  }
+
+  public static function workerViewChangePassword($oldpassword, $newpassword) {
+    global $_SESSION;
+
+    if (!security::isUserPassword(false, $oldpassword)) return false;
+
+    return self::updatePassword($_SESSION["id"], password_hash($newpassword, PASSWORD_DEFAULT));
+  }
+
+  private static function addCompaniesToRow(&$row, $isWorker = false, $showHiddenCompanies = true) {
+    global $con;
+
+    $query = mysqli_query($con, "SELECT w.id id, w.company company, h.status status
+      FROM workers w ".workers::sqlAddonToGetStatusAttribute($row["id"]));
+
+    $row["baixa"] = true;
+    if ($isWorker) $row["hidden"] = true;
+    $row["companies"] = [];
+    while ($row2 = mysqli_fetch_assoc($query)) {
+      $baixa = workers::isHidden($row2["status"]);
+
+      if ($isWorker && $row2["id"] == $row["workerid"]) $row["hidden"] = $baixa;
+      if (!$baixa) $row["baixa"] = false;
+      if (!$showHiddenCompanies && $baixa) continue;
+      $row["companies"][$row2["id"]] = $row2["company"];
+    }
+  }
+
+  private static function filterCompanies($fc, $pc) { // Filter Companies, Person Companies
+    foreach ($pc as $c) {
+      if (in_array($c, $fc)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  public static function get($id, $showHiddenCompanies = true) {
+    global $con;
+
+    $query = mysqli_query($con, "SELECT p.id id, p.username username, p.type type, p.name name, p.dni dni, p.email email, p.category categoryid, c.name category FROM people p LEFT JOIN categories c ON p.category = c.id WHERE p.id = ".(int)$id);
+
+    if (!mysqli_num_rows($query)) {
+      return false;
+    }
+
+    $row = mysqli_fetch_assoc($query);
+    self::addCompaniesToRow($row, false, $showHiddenCompanies);
+
+    return $row;
+  }
+
+  public static function getAll($select = false, $treatCompaniesSeparated = false) {
+    global $con, $conf;
+
+    $mysqlSelect = false;
+    if ($select !== false) {
+      $mysqlSelect = true;
+      $flag = false;
+      foreach (self::$mysqlFilters as $f) {
+        if ($select["enabled"][$f]) {
+          $flag = true;
+          break;
+        }
+      }
+
+      if (!$flag) {
+        $mysqlSelect = false;
+      }
+    }
+
+    if ($mysqlSelect !== false) {
+      $categoryChilds = categories::getChildren();
+      $where = " WHERE ";
+      $conditions = [];
+      foreach (self::$mysqlFilters as $i => $f) {
+        if ($select["enabled"][$f]) {
+          $insideconditions = [];
+          foreach ($select["selected"][$f] as $value) {
+            $insideconditions[] = self::$mysqlFiltersFields[$i]." = ".(int)$value;
+            if ($f == "categories" && isset($categoryChilds[(int)$value])) {
+              foreach ($categoryChilds[(int)$value] as $child) {
+                $insideconditions[] = self::$mysqlFiltersFields[$i]." = ".(int)$child;
+              }
+            }
+          }
+          $conditions[] = "(".implode(" OR ", $insideconditions).")";
+        }
+      }
+      $where .= implode(" AND ", $conditions);
+    } else {
+      $where = "";
+    }
+
+    $query = mysqli_query($con, "SELECT
+      p.id id,
+      p.username username,
+      p.type type,
+      p.name name,
+      p.dni dni,
+      p.email email,
+      p.category categoryid,
+      c.name category
+      ".($treatCompaniesSeparated ? ", w.id workerid, w.company companyid" : "")."
+    FROM people p
+    LEFT JOIN categories c
+      ON p.category = c.id
+    ".($treatCompaniesSeparated ? " RIGHT JOIN workers w
+      ON p.id = w.person" : "").$where);
+
+    $people = [];
+
+    while ($row = mysqli_fetch_assoc($query)) {
+      self::addCompaniesToRow($row, $treatCompaniesSeparated);
+
+      if ($select === false || !$select["enabled"]["companies"] || (!$treatCompaniesSeparated && self::filterCompanies($select["selected"]["companies"], $row["companies"]) || ($treatCompaniesSeparated && in_array($row["companyid"], $select["selected"]["companies"])))) {
+        $people[] = $row;
+      }
+    }
+
+    // Order people by name and baixa
+    if ($treatCompaniesSeparated) {
+      usort($people, function($a, $b) {
+        if ($a["hidden"] == 0 && $b["hidden"] == 1) return -1;
+        if ($a["hidden"] == 1 && $b["hidden"] == 0) return 1;
+        return ($a["name"] < $b["name"] ? -1 : ($a["name"] == $b["name"] ? 0 : 1));
+      });
+    } else {
+      usort($people, function($a, $b) {
+        if ($a["baixa"] == 0 && $b["baixa"] == 1) return -1;
+        if ($a["baixa"] == 1 && $b["baixa"] == 0) return 1;
+        return ($a["name"] < $b["name"] ? -1 : ($a["name"] == $b["name"] ? 0 : 1));
+      });
+    }
+
+    return $people;
+  }
+
+  public static function exists($id) {
+    global $con;
+
+    $query = mysqli_query($con, "SELECT id FROM people WHERE id = ".(int)$id);
+
+    return (mysqli_num_rows($query) > 0);
+  }
+
+  public static function addToCompany($id, $company) {
+    global $con;
+
+    $sid = (int)$id;
+    $scompany = (int)$company;
+
+    if (!companies::exists($scompany)) return false;
+    if (!people::exists($sid)) return false;
+
+    $query = mysqli_query($con, "SELECT id FROM workers WHERE person = $sid AND company = $scompany");
+    if (mysqli_num_rows($query)) return false;
+
+    $time = (int)time();
+
+    if (!mysqli_query($con, "INSERT INTO workers (person, company) VALUES ($sid, $scompany)")) return false;
+
+    $sworkerId = (int)mysqli_insert_id($con);
+    $stime = (int)time();
+
+    return mysqli_query($con, "INSERT INTO workhistory (worker, day, status) VALUES ($sworkerId, $stime, ".(int)workers::AFFILIATION_STATUS_AUTO_WORKING.")");
+  }
+
+  public static function userData($data, $id = "ME") {
+    global $con, $_SESSION;
+
+    if ($id == "ME" && $data == "id") return $_SESSION["id"];
+    if ($id == "ME") $id = $_SESSION["id"];
+    $sdata = preg_replace("/[^A-Za-z0-9 ]/", '', $data);
+    $sid = (int)$id;
+
+    $query = mysqli_query($con, "SELECT $sdata FROM people WHERE id = $sid");
+
+    if (!mysqli_num_rows($query)) return false;
+
+    $row = mysqli_fetch_assoc($query);
+
+    return $row[$sdata];
+  }
+
+  public static function workerData($data, $id) {
+    global $con, $_SESSION;
+
+    $sdata = preg_replace("/[^A-Za-z0-9 ]/", '', $data);
+    $sid = (int)$id;
+
+    $query = mysqli_query($con, "SELECT p.$sdata $sdata FROM people p INNER JOIN workers w ON p.id = w.person WHERE w.id = $sid");
+
+    if (!mysqli_num_rows($query)) return false;
+
+    $row = mysqli_fetch_assoc($query);
+
+    return $row[$sdata];
+  }
+}
diff --git a/src/inc/recovery.php b/src/inc/recovery.php
new file mode 100644
index 0000000..1f8307b
--- /dev/null
+++ b/src/inc/recovery.php
@@ -0,0 +1,140 @@
+<?php
+class recovery {
+  const TOKEN_BYTES = 32;
+  const EXPIRATION_TIME = 60*60*24;
+  const WELCOME_EXPIRATION_TIME = 60*60*24*90;
+
+  const EMAIL_TYPE_RECOVERY = 0;
+  const EMAIL_TYPE_WELCOME = 1;
+
+  const EMAIL_NOT_SET = 0;
+
+  public static function getUser($email, $dni) {
+    global $con;
+
+    $semail = db::sanitize($email);
+    $sdni = db::sanitize($dni);
+
+    $query = mysqli_query($con, "SELECT id FROM people WHERE email = '$semail' AND dni = '$sdni' LIMIT 1");
+
+    if (!mysqli_num_rows($query)) return false;
+
+    return mysqli_fetch_assoc($query)["id"];
+  }
+
+  private static function tokenIsUnique($token) {
+    global $con;
+
+    $stoken = db::sanitize($token);
+
+    $query = mysqli_query($con, "SELECT 1 FROM recovery WHERE token = '$stoken' LIMIT 1");
+
+    return !mysqli_num_rows($query);
+  }
+
+  private static function generateRandomToken() {
+    do {
+      $token = bin2hex(random_bytes(self::TOKEN_BYTES));
+    } while (!self::tokenIsUnique($token));
+
+    return $token;
+  }
+
+  private static function invalidateAllRecoveries($id) {
+    global $con;
+
+    $sid = (int)$id;
+
+    return mysqli_query($con, "UPDATE recovery SET used = 1 WHERE user = $sid");
+  }
+
+  public static function sendRecoveryMail($user, $token, $expires, $emailType = self::EMAIL_TYPE_RECOVERY) {
+    global $conf;
+
+    $to = [
+      ["email" => $user["email"]]
+    ];
+
+    $url = security::htmlsafe($conf["fullPath"]."recovery.php?token=".$token);
+
+    switch ($emailType) {
+      case self::EMAIL_TYPE_WELCOME:
+      $subject = "Datos de acceso de ".security::htmlsafe($user["name"]);
+      $body = mail::bodyTemplate("<p>Hola ".security::htmlsafe($user["name"]).",</p>
+      <p>Para poder acceder al aplicativo de control horario, adjuntamos un enlace donde podrás configurar tu contraseña:</p>
+      <ul>
+        <li><a href=\"$url\">$url</a></li>
+      </ul>
+      <p>Una vez establezcas tu contraseña, podrás iniciar sesión con la contraseña que has rellenado y tu usuario: <b><code>".security::htmlsafe($user["username"])."</code></b></p>
+      <p>Reciba un cordial saludo.</p>");
+      break;
+
+      default:
+      $subject = "Recuperar contraseña de ".security::htmlsafe($user["name"]);
+      $body = mail::bodyTemplate("<p>Hola ".security::htmlsafe($user["name"]).",</p>
+      <p>Alguien (seguramente tú) ha rellenado el formulario de recuperación de contraseñas para el aplicativo de registro horario.</p>
+      <p>Si no has sido tú, puede que alguien esté intentando entrar en tu zona de trabajador del aplicativo, y te agredeceríamos que lo comunicaras en la mayor brevedad posible a recursos humanos.</p>
+      <p>Si has sido tú, puedes restablecer tu contraseña haciendo clic en el siguiente enlace:</p>
+      <ul>
+        <li><a href=\"$url\">$url</a></li>
+      </ul>
+      <p>Además, aprovechamos para recordarte que tu usuario en el web es <b><code>".security::htmlsafe($user["username"])."</code></b></p>
+      <p>Reciba un cordial saludo.</p>");
+    }
+
+    return mail::send($to, [], $subject, $body);
+  }
+
+  public static function recover($id, $emailType = self::EMAIL_TYPE_RECOVERY) {
+    global $con;
+
+    $user = people::get($id);
+    if ($user === false) return false;
+    if (!isset($user["email"]) || empty($user["email"])) return self::EMAIL_NOT_SET;
+
+    $token = self::generateRandomToken();
+    $stoken = db::sanitize($token);
+    $sid = (int)$id;
+    $stime = (int)time();
+    $sexpires = (int)($stime + ($emailType === self::EMAIL_TYPE_WELCOME ? self::WELCOME_EXPIRATION_TIME : self::EXPIRATION_TIME));
+
+    if (!self::invalidateAllRecoveries($id)) return false;
+
+    if (!mysqli_query($con, "INSERT INTO recovery (user, token, timecreated, expires) VALUES ($sid, '$stoken', $stime, $sexpires)")) return false;
+
+    return self::sendRecoveryMail($user, $token, $sexpires, $emailType);
+  }
+
+  public static function getRecovery($token) {
+    global $con;
+
+    $stoken = db::sanitize($token);
+
+    $query = mysqli_query($con, "SELECT * FROM recovery WHERE token = '$stoken' LIMIT 1");
+
+    if (!mysqli_num_rows($query)) return false;
+
+    return mysqli_fetch_assoc($query);
+  }
+
+  public static function getUnusedRecovery($token) {
+    $recovery = self::getRecovery($token);
+    if ($recovery === false || $recovery["used"] != 0 || $recovery["expires"] < time()) return false;
+
+    return $recovery;
+  }
+
+  public static function finishRecovery($token, $password) {
+    global $con;
+
+    $stoken = db::sanitize($token);
+    $spassword = db::sanitize(password_hash($password, PASSWORD_DEFAULT));
+
+    $recovery = recovery::getUnusedRecovery($token);
+    if ($recovery === false) return false;
+
+    if (!self::invalidateAllRecoveries($recovery["user"])) return false;
+
+    return people::updatePassword($recovery["user"], $spassword);
+  }
+}
diff --git a/src/inc/recurringIncidents.php b/src/inc/recurringIncidents.php
new file mode 100644
index 0000000..b07bf66
--- /dev/null
+++ b/src/inc/recurringIncidents.php
@@ -0,0 +1,97 @@
+<?php
+class recurringIncidents {
+  /*public static function oldAdd($worker, $type, $details, $ifirstday, $ilastday, $begins, $ends, $creator = "ME", $typedays, $days, $alreadyTimestamp = false) {
+    global $con, $conf;
+
+    $sworker = (int)$worker;
+    $workerDetails = workers::get($sworker);
+    if ($workerDetails === false) return 1;
+
+    if ($creator === "ME") $creator = people::userData("id");
+    $screator = (int)$creator;
+
+    if (!security::isAllowed(security::ADMIN) && $workerDetails["person"] != $creator) return 5;
+
+    $stype = (int)$type;
+    $sverified = (int)$verified;
+    $sdetails = db::sanitize($details);
+
+    $incidenttype = self::getType($stype);
+    if ($incidenttype === false) return -1;
+
+    if ($alreadyTimestamp) {
+      $sfirstday = (int)$ifirstday;
+      $slastday = (int)$ilastday;
+    } else {
+      $firstday = new DateTime($ifirstday);
+      $sfirstday = (int)$firstday->getTimestamp();
+      $lastday = new DateTime($ilastday);
+      $slastday = (int)$lastday->getTimestamp();
+    }
+
+    if ($sfirstday >= $slastday) return 3;
+
+    $sbegins = (int)$begins;
+    $sends = (int)$ends;
+    if ($sbegins >= $sends) return 3;
+
+    $typedays = array_unique(array_map(function($el) {
+      return (int)$el;
+    }, $typedays));
+    foreach ($typedays as $typeday) {
+      if (!in_array($typeday, $workingTypes)) return 6;
+    }
+    $stypedays = json_encode($typedays);
+
+    if (!mysqli_query($con, "INSERT INTO recurringincidents (worker, creator, type, firstday, lastday, typedays, begins, ends, details) VALUES ($sworker, $screator, $stype, $sfirstday, $slastday, '$stypedays', $sbegins, $sends, '$sdetails')")) return -1;
+
+    return 0;
+  }*/ // NOTE: This was a first idea, to allow setting up recurring incidents like schedules, but we've changed how we'll handle them and so this is no longer useful.
+
+  public static function add($worker, $type, $details, $ifirstday, $ilastday, $begins, $ends, $creator = "ME", $typeDays, $days, $alreadyTimestamp = false) {
+    if ($alreadyTimestamp) {
+      $current = new DateTime();
+      $current->setTimestamp($ifirstday);
+      $lastday = new DateTime();
+      $lastday->setTimestamp($ilastday);
+    } else {
+      $current = new DateTime($ifirstday);
+      $lastday = new DateTime($ilastday);
+    }
+
+    $oneDay = new DateInterval("P1D");
+
+    $category = registry::getWorkerCategory($worker);
+    if ($category === false) return false;
+
+    $flag = true;
+
+    for (; $current->diff($lastday)->invert === 0; $current->add($oneDay)) {
+      $currentTimestamp = $current->getTimestamp();
+      $currentDay = (int)$current->format("N") - 1;
+
+      if (!in_array($currentDay, $days)) continue;
+
+      $calendarDays = registry::getDayTypes($currentTimestamp);
+      if ($calendarDays === false) return false;
+
+      if (isset($calendarDays[$category])) {
+        $typeDay = $calendarDays[$category];
+      } else if (isset($calendarDays[-1])) {
+        $typeDay = $calendarDays[-1];
+      } else {
+        $flag = false;
+        continue;
+      }
+
+      if (!in_array($typeDay, $typeDays)) continue;
+
+      if ($status = incidents::add($worker, $type, $details, $currentTimestamp, $begins, $ends, $creator, 1, true, false, false) !== 0) {
+        $flag = false;
+        continue;
+      }
+    }
+
+    return $flag;
+  }
+}
diff --git a/src/inc/registry.php b/src/inc/registry.php
new file mode 100644
index 0000000..363968d
--- /dev/null
+++ b/src/inc/registry.php
@@ -0,0 +1,393 @@
+<?php
+class registry {
+  const LOGS_PAGINATION_LIMIT = 30;
+  const REGISTRY_PAGINATION_LIMIT = 20;
+
+  const STATE_REGISTERED = 0;
+  const STATE_MANUALLY_INVALIDATED = 1;
+  const STATE_VALIDATED_BY_WORKER = 2;
+
+  public static $stateIcons = [
+    0 => "check",
+    1 => "delete_forever",
+    2 => "verified_user"
+  ];
+
+  public static $stateIconColors = [
+    0 => "mdl-color-text--green",
+    1 => "mdl-color-text--red",
+    2 => "mdl-color-text--green"
+  ];
+
+  public static $stateTooltips = [
+    0 => "Registrado",
+    1 => "Invalidado manualmente",
+    2 => "Validado"
+  ];
+
+  public static $workerPendingWhere = "r.workervalidated = 0";
+  public static $notInvalidatedWhere = "r.invalidated = 0";
+  public static $logsWarnings = "LOCATE('[warning]', logdetails) as warningpos, LOCATE('[error]', logdetails) as errorpos, LOCATE('[fatalerror]', logdetails) as fatalerrorpos";
+
+  private static function recordLog(&$log, $time, $executedby = -1, $quiet = false) {
+    global $con;
+
+    $slog = db::sanitize($log);
+    $sday = (int)$time;
+    $srealtime = (int)time();
+    $sexecutedby = (int)$executedby;
+
+    $status = mysqli_query($con, "INSERT INTO logs (realtime, day, executedby, logdetails) VALUES ($srealtime, $sday, $sexecutedby, '$slog')");
+
+    if (!$status) {
+      if (!$quiet) echo "[fatalerror] Couldn't record log into the database!\n";
+      return false;
+    } else {
+      if (!$quiet) echo "[success] Log recorded into the database.\n";
+      return mysqli_insert_id($con);
+    }
+  }
+
+  public static function getLogs($start = 0, $limit = self::LOGS_PAGINATION_LIMIT) {
+    global $con;
+
+    $query = mysqli_query($con, "SELECT id, realtime, day, executedby, ".self::$logsWarnings." FROM logs ORDER BY id DESC".db::limitPagination($start, $limit));
+
+    $return = [];
+    while ($row = mysqli_fetch_assoc($query)) {
+      $return[] = $row;
+    }
+
+    return $return;
+  }
+
+  public static function getLog($id) {
+    global $con;
+
+    $sid = (int)$id;
+
+    $query = mysqli_query($con, "SELECT logdetails, ".self::$logsWarnings." FROM logs WHERE id = $sid");
+
+    if (!mysqli_num_rows($query)) return false;
+
+    $row = mysqli_fetch_assoc($query);
+
+    return $row;
+  }
+
+  public static function beautifyLog($str) {
+    $str = str_replace("[info]", "<span style='font-weight: bold;' class='mdl-color-text--blue'>[info]</span>", $str);
+    $str = str_replace("[warning]", "<span style='font-weight: bold;' class='mdl-color-text--orange'>[warning]</span>", $str);
+    $str = str_replace("[error]", "<span style='font-weight: bold;' class='mdl-color-text--red'>[error]</span>", $str);
+    $str = str_replace("[fatalerror]", "<span style='font-weight: bold;' class='mdl-color-text--red-900'>[fatalerror]</span>", $str);
+    return $str;
+  }
+
+  private static function addToLog(&$log, $quiet, $msg) {
+    $log .= $msg;
+    if (!$quiet) echo $msg;
+  }
+
+  private static function alreadyRegistered($time, $worker) {
+    global $con;
+
+    $stime = (int)$time;
+    $sworker = (int)$worker;
+
+    $query = mysqli_query($con, "SELECT id FROM records WHERE worker = $sworker AND day = $stime AND invalidated = 0 LIMIT 1");
+
+    return (mysqli_num_rows($query) > 0);
+  }
+
+  private static function register($time, $worker, $schedule, $creator = -1) {
+    global $con;
+
+    $sworker = (int)$worker;
+    $stime = (int)$time;
+    $srealtime = (int)time();
+    $screator = (int)$creator;
+    $sbeginswork = (int)$schedule["beginswork"];
+    $sendswork = (int)$schedule["endswork"];
+    $sbeginsbreakfast = (int)$schedule["beginsbreakfast"];
+    $sendsbreakfast = (int)$schedule["endsbreakfast"];
+    $sbeginslunch = (int)$schedule["beginslunch"];
+    $sendslunch = (int)$schedule["endslunch"];
+
+    return mysqli_query($con, "INSERT INTO records (worker, day, created, creator, beginswork, endswork, beginsbreakfast, endsbreakfast, beginslunch, endslunch) VALUES ($sworker, $stime, $srealtime, $screator, $sbeginswork, $sendswork, $sbeginsbreakfast, $sendsbreakfast, $sbeginslunch, $sendslunch)");
+  }
+
+  public static function getWorkerCategory($id) {
+    global $con;
+
+    $sid = (int)$id;
+
+    $query = mysqli_query($con, "SELECT p.category category FROM workers w INNER JOIN people p ON w.person = p.id WHERE w.id = $sid");
+
+    if ($query === false || !mysqli_num_rows($query)) {
+      return false;
+    }
+
+    $row = mysqli_fetch_assoc($query);
+
+    return $row["category"];
+  }
+
+  public static function getDayTypes($time) {
+    global $con;
+
+    $stime = (int)$time;
+
+    $query = mysqli_query($con, "SELECT id, category, details FROM calendars WHERE begins <= $stime AND ends >= $stime");
+    if ($query === false) return false;
+
+    $calendars = [];
+    while ($row = mysqli_fetch_assoc($query)) {
+      $calendar = json_decode($row["details"], true);
+      if (json_last_error() !== JSON_ERROR_NONE) return false;
+      if (!isset($calendar[$time])) return false;
+
+      $calendars[$row["category"]] = $calendar[$time];
+    }
+
+    return $calendars;
+  }
+
+  private static function getApplicableSchedules($time) {
+    global $con;
+
+    $stime = (int)$time;
+
+    $query = mysqli_query($con, "SELECT id FROM schedules WHERE begins <= $stime AND ends >= $stime AND active = 1");
+    if ($query === false) return false;
+
+    $schedules = [];
+    while ($row = mysqli_fetch_assoc($query)) {
+      $schedules[] = $row["id"];
+    }
+
+    return $schedules;
+  }
+
+  public static function generateNow($originaltime, &$logId, $quiet = true, $executedby = -1, $workersWhitelist = false) {
+    global $con;
+
+    $log = "";
+
+    if ($workersWhitelist !== false) self::addToLog($log, $quiet, "[info] This is a partial registry generation, because a whitelist of workers was passed: [".implode(", ", $workersWhitelist)."]\n");
+
+    $datetime = new DateTime();
+    $datetime->setTimestamp($originaltime);
+    self::addToLog($log, $quiet, "[info] Time passed: ".$datetime->format("Y-m-d H:i:s")."\n");
+
+    $rawdate = $datetime->format("Y-m-d")."T00:00:00";
+    self::addToLog($log, $quiet, "[info] Working with this date: $rawdate\n");
+    $date = new DateTime($rawdate);
+
+    $time = $date->getTimestamp();
+    $dow = (int)$date->format("N") - 1;
+    self::addToLog($log, $quiet, "[info] Final date timestamp: $time, Dow: $dow\n");
+
+    $days = self::getDayTypes($time);
+    if ($days === false) {
+      self::addToLog($log, $quiet, "[fatalerror] An error occurred while loading the calendars.\n");
+      $logId = self::recordLog($log, $time, $executedby, $quiet);
+      return 1;
+    }
+
+    $schedules = self::getApplicableSchedules($time);
+    if ($schedules === false) {
+      self::addToLog($log, $quiet, "[fatalerror] An error occurred while loading the active schedules.\n");
+      $logId = self::recordLog($log, $time, $executedby, $quiet);
+      return 2;
+    }
+
+    self::addToLog($log, $quiet, "[info] Found ".count($schedules)." active schedule(s)\n");
+
+    foreach ($schedules as $scheduleid) {
+      self::addToLog($log, $quiet, "\n[info] Processing schedule $scheduleid\n");
+
+      $s = schedules::get($scheduleid);
+      if ($s === false) {
+        self::addToLog($log, $quiet, "[fatalerror] An error ocurred while loading schedule with id $scheduleid (it doesn't exist or there was an error with the SQL query)\n");
+        $logId = self::recordLog($log, $time, $executedby, $quiet);
+        return 3;
+      }
+
+      if ($workersWhitelist !== false && !in_array($s["worker"], $workersWhitelist)) {
+        self::addToLog($log, $quiet, "[info] This schedule's worker (".$s["worker"].") is not in the whitelist, so skipping\n");
+        continue;
+      }
+
+      $category = self::getWorkerCategory($s["worker"]);
+
+      if (isset($days[$category])) {
+        self::addToLog($log, $quiet, "[info] Using worker's (".$s["worker"].") category ($category) calendar\n");
+        $typeday = $days[$category];
+      } else if (isset($days[-1])) {
+        self::addToLog($log, $quiet, "[info] Using default calendar\n");
+        $typeday = $days[-1];
+      } else {
+        self::addToLog($log, $quiet, "[warning] No calendar applies, so skipping this schedule\n");
+        continue;
+      }
+
+      if (!isset($s["days"][$typeday])) {
+        self::addToLog($log, $quiet, "[info] This schedule doesn't have this type of day ($typeday) set up, so skipping\n");
+        continue;
+      }
+
+      if (!isset($s["days"][$typeday][$dow])) {
+        self::addToLog($log, $quiet, "[info] This schedule doesn't have a daily schedule for this day of the week ($dow) and type of day ($typeday), so skipping.\n");
+        continue;
+      }
+
+      self::addToLog($log, $quiet, "[info] Found matching daily schedule. We'll proceed to register it\n");
+
+      if (self::alreadyRegistered($time, $s["worker"])) {
+        self::addToLog($log, $quiet, "[warning] We're actually NOT going to register it because another registry already exists for this worker at the same day.\n");
+      } else {
+        if (self::register($time, $s["worker"], $s["days"][$typeday][$dow], $executedby)) {
+          self::addToLog($log, $quiet, "[info] Registered with id ".mysqli_insert_id($con)."\n");
+        } else {
+          self::addToLog($log, $quiet, "[error] Couldn't register this schedule because of an unknown error!\n");
+        }
+      }
+    }
+
+    $logId = self::recordLog($log, $time, $executedby, $quiet);
+
+    return 0;
+  }
+
+  public static function getStatus($row) {
+    if ($row["invalidated"] == 1) return self::STATE_MANUALLY_INVALIDATED;
+    elseif ($row["workervalidated"] == 1) return self::STATE_VALIDATED_BY_WORKER;
+    else return self::STATE_REGISTERED;
+  }
+
+  private static function magicRecord(&$row) {
+    $row["state"] = self::getStatus($row);
+  }
+
+  public static function get($id, $magic = false) {
+    global $con;
+
+    $sid = (int)$id;
+
+    $query = mysqli_query($con, "SELECT * FROM records WHERE id = $sid LIMIT 1");
+
+    if (!mysqli_num_rows($query)) return false;
+
+    $row = mysqli_fetch_assoc($query);
+    if ($magic) self::magicRecord($row);
+
+    return $row;
+  }
+
+  public static function getRecords($worker = false, $begins = false, $ends = false, $returnInvalid = false, $includeWorkerInfo = false, $sortByDateDesc = false, $start = 0, $limit = self::REGISTRY_PAGINATION_LIMIT, $treatWorkerAttributeAsUser = false, $onlyWorkerPending = false, $magic = true) {
+    global $con;
+
+    if ($treatWorkerAttributeAsUser && !$includeWorkerInfo) return false;
+
+    $where = [];
+
+    if ($worker !== false) $where[] = ($treatWorkerAttributeAsUser ? "w.person" : "r.worker")." = ".(int)$worker;
+
+    $dateLimit = ($begins !== false && $ends !== false);
+    if ($dateLimit) {
+      $where[] = "r.day >= ".(int)$begins;
+      $where[] = "r.day <= ".(int)$ends;
+    }
+
+    if (!$returnInvalid || $onlyWorkerPending) $where[] = self::$notInvalidatedWhere;
+
+    if ($onlyWorkerPending) $where[] = self::$workerPendingWhere;
+
+    $query = mysqli_query($con, "SELECT r.*".($includeWorkerInfo ? ", p.id personid, p.name workername, w.company companyid" : "")." FROM records r LEFT JOIN workers w ON r.worker = w.id".($includeWorkerInfo ? " LEFT JOIN people p ON w.person = p.id" : "").(count($where) ? " WHERE ".implode(" AND ", $where) : "")." ORDER BY".($sortByDateDesc ? " r.day DESC, w.company DESC," : "")." id DESC".db::limitPagination($start, $limit));
+
+    $return = [];
+    while ($row = mysqli_fetch_assoc($query)) {
+      if ($magic) self::magicRecord($row);
+      $return[] = $row;
+    }
+
+    return $return;
+  }
+
+  public static function getWorkerRecords($worker, $begins = false, $ends = false, $returnInvalid = false, $onlyWorkerPending = false) {
+    return self::getRecords($worker, $begins, $ends, $returnInvalid, false, true, 0, 0, false, $onlyWorkerPending);
+  }
+
+  public static function numRows($includingInvalidated = false) {
+    global $con;
+
+    $query = mysqli_query($con, "SELECT COUNT(*) count FROM records".($includingInvalidated ? "" : " WHERE invalidated = 0"));
+
+    if (!mysqli_num_rows($query)) return false;
+
+    $row = mysqli_fetch_assoc($query);
+
+    return (isset($row["count"]) ? (int)$row["count"] : false);
+  }
+
+  public static function numRowsUser($user, $includingInvalidated = true) {
+    global $con;
+
+    $where = [];
+    if (!$includingInvalidated) $where[] = "r.invlidated = 0";
+    $where[] = "w.person = ".(int)$user;
+
+    $query = mysqli_query($con, "SELECT COUNT(*) count FROM records r LEFT JOIN workers w ON r.worker = w.id".(count($where) ? " WHERE ".implode(" AND ", $where) : ""));
+
+    if (!mysqli_num_rows($query)) return false;
+
+    $row = mysqli_fetch_assoc($query);
+
+    return (isset($row["count"]) ? (int)$row["count"] : false);
+  }
+
+  public static function checkRecordIsFromPerson($record, $person = "ME", $boolean = false) {
+    global $con;
+
+    if ($person == "ME") $person = people::userData("id");
+
+    $srecord = (int)$record;
+    $sperson = (int)$person;
+
+    $query = mysqli_query($con, "SELECT r.id FROM records r INNER JOIN workers w ON r.worker = w.id INNER JOIN people p ON w.person = p.id WHERE p.id = $sperson AND r.id = $srecord LIMIT 1");
+
+    if (!mysqli_num_rows($query)) {
+      if ($boolean) return false;
+      security::denyUseMethod(security::METHOD_NOTFOUND);
+    }
+
+    return true;
+  }
+
+  public static function invalidate($id, $invalidatedby = "ME") {
+    global $con;
+
+    $sid = (int)$id;
+
+    if ($invalidatedby === "ME") $invalidatedby = people::userData("id");
+    $sinvalidatedby = (int)$invalidatedby;
+
+    return mysqli_query($con, "UPDATE records SET invalidated = 1, invalidatedby = $sinvalidatedby WHERE id = $sid LIMIT 1");
+  }
+
+  public static function invalidateAll($id, $beginsRawDate, $endsRawDate, $invalidatedby = "ME") {
+    global $con;
+
+    $beginsDate = new DateTime($beginsRawDate);
+    $sbegins = (int)$beginsDate->getTimestamp();
+    $endsDate = new DateTime($endsRawDate);
+    $sends = (int)$endsDate->getTimestamp();
+
+    $sid = (int)$id;
+
+    if ($invalidatedby === "ME") $invalidatedby = people::userData("id");
+    $sinvalidatedby = (int)$invalidatedby;
+
+    return mysqli_query($con, "UPDATE records SET invalidated = 1, invalidatedby = $sinvalidatedby WHERE worker = $sid AND invalidated = 0 AND day <= $sends AND day >= $sbegins");
+  }
+}
diff --git a/src/inc/registryView.php b/src/inc/registryView.php
new file mode 100644
index 0000000..1767bc3
--- /dev/null
+++ b/src/inc/registryView.php
@@ -0,0 +1,91 @@
+<?php
+class registryView {
+  public static function renderRegistry(&$registry, &$companies, $scrollable = false, $showPersonAndCompany = true, $isForWorker = false, $isForValidationView = false) {
+    global $conf, $renderRegistryAutoIncremental;
+    if (!isset($renderRegistryAutoIncremental)) $renderRegistryAutoIncremental = 0;
+    $menu = "";
+    ?>
+    <div class="mdl-shadow--2dp overflow-wrapper incidents-wrapper<?=($scrollable ? " incidents-wrapper--scrollable" : "")?>">
+      <table class="incidents">
+        <tr>
+          <?php if ($conf["debug"]) { ?><th>ID</th><?php } ?>
+          <th<?=($isForValidationView ? " class=\"has-checkbox\"" : "")?>>
+            <?php if ($isForValidationView) {
+              ?>
+              <label for="checkboxall_r_<?=$renderRegistryAutoIncremental?>" class="mdl-checkbox mdl-js-checkbox mdl-js-ripple-effect" data-check-all="true">
+                <input type="checkbox" id="checkboxall_r_<?=$renderRegistryAutoIncremental?>" class="mdl-checkbox__input" autocomplete="off">
+              </label>
+              <?php
+            }
+            ?>
+          </th>
+          <?php
+          if ($showPersonAndCompany) {
+            ?>
+            <th>Nombre</th>
+            <th>Empresa</th>
+            <?php
+          }
+          ?>
+          <th>Día</th>
+          <th>Jornada laboral</th>
+          <th>Desayuno</th>
+          <th>Comida</th>
+          <th></th>
+        </tr>
+        <?php
+        foreach ($registry as $record) {
+          $id = (int)$record["id"]."_".(int)$renderRegistryAutoIncremental;
+          $breakfastInt = [$record["beginsbreakfast"], $record["endsbreakfast"]];
+          $lunchInt = [$record["beginslunch"], $record["endslunch"]];
+          ?>
+          <tr<?=($record["invalidated"] == 1 ? ' class="mdl-color-text--grey-700 line-through"' : '')?>>
+            <?php if ($conf["debug"]) { ?><td><?=(int)$record["id"]?></td><?php } ?>
+            <td class="icon-cell<?=($isForValidationView ? " has-checkbox" : "")?>">
+              <?php
+              if ($isForValidationView) {
+                ?>
+                <label for="checkbox_r_<?=$id?>" class="mdl-checkbox mdl-js-checkbox mdl-js-ripple-effect">
+                  <input type="checkbox" id="checkbox_r_<?=$id?>" data-record="<?=(int)$record["id"]?>" class="mdl-checkbox__input" autocomplete="off">
+                </label>
+                <?php
+              } else {
+                ?>
+                <i id="state<?=$id?>" class="material-icons <?=security::htmlsafe(registry::$stateIconColors[$record["state"]])?>"><?=security::htmlsafe(registry::$stateIcons[$record["state"]])?></i>
+                <?php
+                visual::addTooltip("state".$id, security::htmlsafe(registry::$stateTooltips[$record["state"]]));
+              }
+              ?>
+            </td>
+            <?php
+            if ($showPersonAndCompany) {
+              ?>
+              <td class="can-strike"><span<?=($isForWorker ? '' : ' data-dyndialog-href="dynamic/user.php?id='.(int)$record["personid"].'"')?>><?=security::htmlsafe($record["workername"])?></span></td>
+              <td class="can-strike"><?=security::htmlsafe($companies[$record["companyid"]])?></td>
+              <?php
+            }
+            ?>
+            <td class="can-strike"><?=strftime("%d %b %Y", $record["day"])?></td>
+            <td class="centered can-strike"><?=schedules::sec2time($record["beginswork"])." - ".schedules::sec2time($record["endswork"])?></td>
+            <td class="centered can-strike"><?=(intervals::measure($breakfastInt) == 0 ? "-" : ($isForWorker ? export::sec2hours(intervals::measure($breakfastInt)) : schedules::sec2time($record["beginsbreakfast"])." - ".schedules::sec2time($record["endsbreakfast"])))?></td>
+            <td class="centered can-strike"><?=(intervals::measure($lunchInt) == 0 ? "-" : ($isForWorker ? export::sec2hours(intervals::measure($lunchInt)) : schedules::sec2time($record["beginslunch"])." - ".schedules::sec2time($record["endslunch"])))?></td>
+            <td><button id="actions<?=$id?>" class="mdl-button mdl-js-button mdl-button--icon mdl-js-ripple-effect custom-actions-btn"><i class="material-icons icon">more_vert</i></button></td>
+          </tr>
+          <?php
+          $menu .= '<ul class="mdl-menu mdl-menu--unaligned mdl-js-menu mdl-js-ripple-effect" for="actions'.$id.'">';
+
+          if (!$isForWorker && $record["invalidated"] == 0) {
+            $menu .= '<a href="dynamic/invalidaterecord.php?id='.(int)$record["id"].'" data-dyndialog-href="dynamic/invalidaterecord.php?id='.(int)$record["id"].'"><li class="mdl-menu__item">Invalidar</li></a>';
+          }
+
+          $menu .= '<a href="dynamic/authorsrecord.php?id='.(int)$record["id"].'" data-dyndialog-href="dynamic/authorsrecord.php?id='.(int)$record["id"].'"><li class="mdl-menu__item">Autoría</li></a></ul>';
+
+          $renderRegistryAutoIncremental++;
+        }
+        ?>
+      </table>
+      <?php echo $menu; ?>
+    </div>
+  <?php
+  }
+}
diff --git a/src/inc/schedules.php b/src/inc/schedules.php
new file mode 100644
index 0000000..87b5ed6
--- /dev/null
+++ b/src/inc/schedules.php
@@ -0,0 +1,456 @@
+<?php
+class schedules {
+  const STATUS_NO_ACTIVE_SCHEDULE = 0;
+  const STATUS_HALFWAY_CONFIGURED_SCHEDULE = 1;
+  const STATUS_ACTIVE_SCHEDULE = 2;
+
+  public static $allEvents = ["work", "breakfast", "lunch"];
+  public static $otherEvents = ["breakfast", "lunch"];
+  public static $otherEventsDescription = [
+    "breakfast" => "Desayuno",
+    "lunch" => "Comida"
+  ];
+  public static $workerScheduleStatus = [
+    0 => "No hay ningún calendario activo",
+    1 => "Hay un calendario activo pero no está completamente configurado",
+    2 => "Hay un calendario activo completamente configurado"
+  ];
+  public static $workerScheduleStatusShort = [
+    0 => "No hay un horario activo",
+    1 => "No está configurado del todo",
+    2 => "Existe uno configurado"
+  ];
+  public static $workerScheduleStatusColors = [
+    0 => "red",
+    1 => "orange",
+    2 => "green"
+  ];
+
+  public static function time2sec($time) {
+    $e = explode(":", $time);
+    return ((int)$e[0]*60 + (int)$e[1])*60;
+  }
+
+  public static function sec2time($sec, $autoShowSeconds = true) {
+    $min = floor($sec/60);
+    $secr = $sec % 60;
+    return visual::padNum(floor($min/60), 2).":".visual::padNum(($min % 60), 2).($autoShowSeconds && $secr != 0 ? ":".visual::padNum($secr, 2) : "");
+  }
+
+  // TEMPLATES:
+
+  public static function addTemplate($name, $ibegins, $iends) {
+    global $con;
+
+    $sname = db::sanitize($name);
+    $begins = new DateTime($ibegins);
+    $sbegins = (int)$begins->getTimestamp();
+    $ends = new DateTime($iends);
+    $sends = (int)$ends->getTimestamp();
+
+    if (!intervals::wellFormed([$sbegins, $sends])) return 2;
+
+    return (mysqli_query($con, "INSERT INTO scheduletemplates (name, begins, ends) VALUES ('$sname', $sbegins, $sends)") ? 0 : 1);
+  }
+
+  private static function _addDaysToReturn(&$return, $stableDays, $sfieldSchedule, $sid) {
+    global $con;
+
+    $return["days"] = [];
+
+    $query2 = mysqli_query($con, "SELECT * FROM $stableDays WHERE $sfieldSchedule = $sid ORDER BY typeday ASC, day ASC");
+
+    while ($row = mysqli_fetch_assoc($query2)) {
+      if (!isset($return["days"][$row["typeday"]]))
+        $return["days"][$row["typeday"]] = [];
+
+      $return["days"][$row["typeday"]][$row["day"]] = $row;
+    }
+  }
+
+  private static function _get($id, $table, $tableDays, $fieldSchedule) {
+    global $con;
+
+    $sid = (int)$id;
+    $stable = preg_replace("/[^A-Za-z0-9 ]/", '', $table);
+    $stableDays = preg_replace("/[^A-Za-z0-9 ]/", '', $tableDays);
+    $sfieldSchedule = preg_replace("/[^A-Za-z0-9 ]/", '', $fieldSchedule);
+
+    $query = mysqli_query($con, "SELECT * FROM $stable WHERE id = $sid");
+
+    if ($query === false || !mysqli_num_rows($query)) return false;
+
+    $return = mysqli_fetch_assoc($query);
+
+    self::_addDaysToReturn($return, $stableDays, $sfieldSchedule, $sid);
+
+    return $return;
+  }
+
+  public static function getTemplate($id) {
+    return self::_get($id, "scheduletemplates", "scheduletemplatesdays", "template");
+  }
+
+  public static function getTemplates() {
+    global $con, $conf;
+
+    $query = mysqli_query($con, "SELECT * FROM scheduletemplates ORDER BY ".($conf["debug"] ? "id" : "name")." ASC");
+
+    $templates = [];
+
+    while ($row = mysqli_fetch_assoc($query)) {
+      $templates[] = $row;
+    }
+
+    return $templates;
+  }
+
+  public static function editTemplate($id, $name, $ibegins, $iends) {
+    global $con;
+
+    $sid = (int)$id;
+    $sname = db::sanitize($name);
+    $begins = new DateTime($ibegins);
+    $sbegins = (int)$begins->getTimestamp();
+    $ends = new DateTime($iends);
+    $sends = (int)$ends->getTimestamp();
+
+    if (!intervals::wellFormed([$sbegins, $sends])) return 2;
+
+    return (mysqli_query($con, "UPDATE scheduletemplates SET name = '$sname', begins = $sbegins, ends = $sends WHERE id = $sid LIMIT 1") ? 0 : 1);
+  }
+
+  public static function _remove($id, $table, $tableDays, $fieldSchedule) {
+    global $con;
+
+    $sid = (int)$id;
+    $stable = preg_replace("/[^A-Za-z0-9 ]/", '', $table);
+    $stableDays = preg_replace("/[^A-Za-z0-9 ]/", '', $tableDays);
+    $sfieldSchedule = preg_replace("/[^A-Za-z0-9 ]/", '', $fieldSchedule);
+
+    return (mysqli_query($con, "DELETE FROM $stable WHERE id = $sid LIMIT 1") && mysqli_query($con, "DELETE FROM $stableDays WHERE $fieldSchedule = $sid"));
+  }
+
+  public static function removeTemplate($id) {
+    return self::_remove($id, "scheduletemplates", "scheduletemplatesdays", "template");
+  }
+
+  private static function _exists($id, $table) {
+    global $con;
+
+    $sid = (int)$id;
+    $stable = preg_replace("/[^A-Za-z0-9 ]/", '', $table);
+
+    $query = mysqli_query($con, "SELECT id FROM $stable WHERE id = ".(int)$id);
+
+    return (mysqli_num_rows($query) > 0);
+  }
+
+  public static function templateExists($id) {
+    return self::_exists($id, "scheduletemplates");
+  }
+
+  public static function checkAddDayGeneric($begins, $ends, $beginsb, $endsb, $beginsl, $endsl) {
+    global $con;
+
+    $times = [];
+    $times["work"] = [$begins, $ends];
+    $times["breakfast"] = [$beginsb, $endsb];
+    $times["lunch"] = [$beginsl, $endsl];
+
+    foreach ($times as $time) {
+      if (intervals::wellFormed($time) === false) return 1;
+    }
+
+    if (intervals::measure($times["work"]) == 0) return 4;
+
+    if ((!intervals::isSubset($times["breakfast"], $times["work"]) && intervals::measure($times["breakfast"]) != 0) || (!intervals::isSubset($times["lunch"], $times["work"]) && intervals::measure($times["lunch"]) != 0)) return 2;
+
+    if (intervals::overlaps($times["breakfast"], $times["lunch"]) && intervals::measure($times["breakfast"]) != 0 && intervals::measure($times["lunch"]) != 0) return 3;
+
+    return 0;
+  }
+
+  private static function _checkAddDayParticular($id, $dow, $typeday, $table, $fieldSchedule) {
+    global $con;
+
+    $sid = (int)$id;
+    $sdow = (int)$dow;
+    $stypeday = (int)$typeday;
+    $stable = preg_replace("/[^A-Za-z0-9 ]/", '', $table);
+    $sfieldSchedule = preg_replace("/[^A-Za-z0-9 ]/", '', $fieldSchedule);
+
+    $query = mysqli_query($con, "SELECT id FROM $stable WHERE $fieldSchedule = $sid AND day = $sdow AND typeday = $stypeday");
+
+    return (!mysqli_num_rows($query));
+  }
+
+  public static function checkAddDay2TemplateParticular($id, $dow, $typeday) {
+    return self::_checkAddDayParticular($id, $dow, $typeday, "scheduletemplatesdays", "template");
+  }
+
+  private static function _addDay($id, $dow, $typeday, $begins, $ends, $beginsb, $endsb, $beginsl, $endsl, $table, $fieldSchedule) {
+    global $con;
+
+    $sid = (int)$id;
+    $sdow = (int)$dow;
+    $stypeday = (int)$typeday;
+    $sbegins = (int)$begins;
+    $sends = (int)$ends;
+    $sbeginsb = (int)$beginsb;
+    $sendsb = (int)$endsb;
+    $sbeginsl = (int)$beginsl;
+    $sendsl = (int)$endsl;
+    $stable = preg_replace("/[^A-Za-z0-9 ]/", '', $table);
+    $sfieldSchedule = preg_replace("/[^A-Za-z0-9 ]/", '', $fieldSchedule);
+
+    return mysqli_query($con, "INSERT INTO $stable ($sfieldSchedule, day, typeday, beginswork, endswork, beginsbreakfast, endsbreakfast, beginslunch, endslunch) VALUES ($sid, $sdow, $stypeday, $sbegins, $sends, $sbeginsb, $sendsb, $sbeginsl, $sendsl)");
+  }
+
+  public static function addDay2Template($id, $dow, $typeday, $begins, $ends, $beginsb, $endsb, $beginsl, $endsl) {
+    return self::_addDay($id, $dow, $typeday, $begins, $ends, $beginsb, $endsb, $beginsl, $endsl, "scheduletemplatesdays", "template");
+  }
+
+  public static function _getDay($id, $table) {
+    global $con;
+
+    $sid = (int)$id;
+    $stable = preg_replace("/[^A-Za-z0-9 ]/", '', $table);
+
+    $query = mysqli_query($con, "SELECT * FROM $stable WHERE id = $sid");
+
+    if (!mysqli_num_rows($query)) return false;
+
+    return mysqli_fetch_assoc($query);
+  }
+
+  public static function getTemplateDay($id) {
+    return self::_getDay($id, "scheduletemplatesdays");
+    global $con;
+  }
+
+  private static function _editDay($id, $begins, $ends, $beginsb, $endsb, $beginsl, $endsl, $table) {
+    global $con;
+
+    $sid = (int)$id;
+    $sbegins = (int)$begins;
+    $sends = (int)$ends;
+    $sbeginsb = (int)$beginsb;
+    $sendsb = (int)$endsb;
+    $sbeginsl = (int)$beginsl;
+    $sendsl = (int)$endsl;
+    $stable = preg_replace("/[^A-Za-z0-9 ]/", '', $table);
+
+    return mysqli_query($con, "UPDATE $stable SET beginswork = $sbegins, endswork = $sends, beginsbreakfast = $sbeginsb, endsbreakfast = $sendsb, beginslunch = $sbeginsl, endslunch = $sendsl WHERE id = $sid LIMIT 1");
+  }
+
+  public static function editTemplateDay($id, $begins, $ends, $beginsb, $endsb, $beginsl, $endsl) {
+    return self::_editDay($id, $begins, $ends, $beginsb, $endsb, $beginsl, $endsl, "scheduletemplatesdays");
+  }
+
+  private static function _removeDay($id, $table) {
+    global $con;
+
+    $sid = (int)$id;
+    $stable = preg_replace("/[^A-Za-z0-9 ]/", '', $table);
+
+    return mysqli_query($con, "DELETE FROM $stable WHERE id = $sid LIMIT 1");
+  }
+
+  public static function removeTemplateDay($id) {
+    return self::_removeDay($id, "scheduletemplatesdays");
+  }
+
+  private static function _dayExists($id, $table) {
+    global $con;
+
+    $sid = (int)$id;
+    $stable = preg_replace("/[^A-Za-z0-9 ]/", '', $table);
+
+    $query = mysqli_query($con, "SELECT id FROM $stable WHERE id = $sid");
+
+    return (mysqli_num_rows($query) > 0);
+  }
+
+  public static function templateDayExists($id) {
+    return self::_dayExists($id, "scheduletemplatesdays");
+  }
+
+  // SCHEDULES:
+
+  public static function checkOverlap($worker, $begins, $ends, $sans = 0) {
+    global $con;
+
+    $sworker = (int)$worker;
+    $sbegins = (int)$begins;
+    $sends = (int)$ends;
+    $ssans = (int)$sans;
+
+    $query = mysqli_query($con, "SELECT * FROM schedules WHERE begins <= $sends AND ends >= $sbegins AND worker = $sworker".($sans == 0 ? "" : " AND id <> $ssans")." LIMIT 1");
+
+    return (mysqli_num_rows($query) > 0);
+  }
+
+  public static function get($id) {
+    return self::_get($id, "schedules", "schedulesdays", "schedule");
+  }
+
+  public static function getAll($id, $showNotActive = true) {
+    global $con, $conf;
+
+    $sid = (int)$id;
+
+    $query = mysqli_query($con, "SELECT * FROM schedules WHERE worker = $sid".($showNotActive ? "" : " AND active = 1")." ORDER BY ".($conf["debug"] ? "id ASC" : "begins DESC"));
+
+    $schedules = [];
+
+    while ($row = mysqli_fetch_assoc($query)) {
+      $schedules[] = $row;
+    }
+
+    return $schedules;
+  }
+
+  public static function getCurrent($id = "ME", $isWorker = false) {
+    global $con;
+
+    if ($id == "ME") $id = people::userData("id");
+    $sid = (int)$id;
+
+    $date = new DateTime(date("Y-m-d")."T00:00:00");
+    $timestamp = (int)$date->getTimestamp();
+
+    $query = mysqli_query($con, "SELECT s.* FROM schedules s ".($isWorker ? "WHERE s.worker = $sid" : "LEFT JOIN workers w ON s.worker = w.id WHERE w.person = $sid")." AND s.active = 1 AND s.begins <= $timestamp AND s.ends >= $timestamp");
+
+    $return = [];
+
+    while ($row = mysqli_fetch_assoc($query)) {
+      self::_addDaysToReturn($row, "schedulesdays", "schedule", $row["id"]);
+      $return[] = $row;
+    }
+
+    return $return;
+  }
+
+  public static function getWorkerScheduleStatus($id) {
+    $currentSchedules = self::getCurrent($id, true);
+    if ($currentSchedules === false || !count($currentSchedules)) return ["status" => self::STATUS_NO_ACTIVE_SCHEDULE];
+
+    $schedule =& $currentSchedules[0];
+
+    foreach (calendars::$workingTypes as $type) {
+      if (!isset($schedule["days"][$type]) || !count($schedule["days"][$type])) return ["status" => self::STATUS_HALFWAY_CONFIGURED_SCHEDULE, "schedule" => $schedule["id"]];
+    }
+
+    return ["status" => self::STATUS_ACTIVE_SCHEDULE, "schedule" => $schedule["id"]];
+  }
+
+  public static function add($worker, $ibegins, $iends, $active = 0, $alreadyTimestamp = false) {
+    global $con;
+
+    $sworker = (int)$worker;
+    $sactive = (int)$active;
+    if ($alreadyTimestamp) {
+      $sbegins = (int)$ibegins;
+      $sends = (int)$iends;
+    } else {
+      $begins = new DateTime($ibegins);
+      $sbegins = (int)$begins->getTimestamp();
+      $ends = new DateTime($iends);
+      $sends = (int)$ends->getTimestamp();
+    }
+
+    if (!intervals::wellFormed([$sbegins, $sends])) return 3;
+
+    if (self::checkOverlap($worker, $sbegins, $sends)) {
+      return 1;
+    }
+
+    return (mysqli_query($con, "INSERT INTO schedules (worker, begins, ends, active) VALUES ('$sworker', $sbegins, $sends, $sactive)") ? 0 : 2);
+  }
+
+  public static function edit($id, $ibegins, $iends) {
+    global $con;
+
+    $sid = (int)$id;
+    $begins = new DateTime($ibegins);
+    $sbegins = (int)$begins->getTimestamp();
+    $ends = new DateTime($iends);
+    $sends = (int)$ends->getTimestamp();
+
+    if (!intervals::wellFormed([$sbegins, $sends])) return 3;
+
+    $actual = self::get($sid);
+    if ($actual === false) return 4;
+
+    if (self::checkOverlap($actual["worker"], $sbegins, $sends, $sid)) {
+      return 1;
+    }
+
+    return (mysqli_query($con, "UPDATE schedules SET begins = $sbegins, ends = $sends WHERE id = $sid LIMIT 1") ? 0 : 2);
+  }
+
+  public static function remove($id) {
+    return self::_remove($id, "schedules", "schedulesdays", "schedule");
+  }
+
+  public static function switchActive($id, $value) {
+    global $con;
+
+    $sid = (int)$id;
+    $svalue = (int)$value;
+    if ($svalue > 1 || $svalue < 0) return false;
+
+    return mysqli_query($con, "UPDATE schedules SET active = $svalue WHERE id = $sid LIMIT 1");
+  }
+
+  public static function exists($id) {
+    return self::_exists($id, "schedules");
+  }
+
+  public static function checkAddDay2ScheduleParticular($id, $dow, $typeday) {
+    return self::_checkAddDayParticular($id, $dow, $typeday, "schedulesdays", "schedule");
+  }
+
+  public static function addDay2Schedule($id, $dow, $typeday, $begins, $ends, $beginsb, $endsb, $beginsl, $endsl) {
+    return self::_addDay($id, $dow, $typeday, $begins, $ends, $beginsb, $endsb, $beginsl, $endsl, "schedulesdays", "schedule");
+  }
+
+  public static function getDay($id) {
+    return self::_getDay($id, "schedulesdays");
+    global $con;
+  }
+
+  public static function editDay($id, $begins, $ends, $beginsb, $endsb, $beginsl, $endsl) {
+    return self::_editDay($id, $begins, $ends, $beginsb, $endsb, $beginsl, $endsl, "schedulesdays");
+  }
+
+  public static function dayExists($id) {
+    return self::_dayExists($id, "schedulesdays");
+  }
+
+  public static function removeDay($id) {
+    return self::_removeDay($id, "schedulesdays");
+  }
+
+  public static function copyTemplate($template, $worker, $active) {
+    global $con;
+
+    $template = self::getTemplate($template);
+    if ($template === false) return 1;
+
+    $status = self::add($worker, $template["begins"], $template["ends"], $active, true);
+    if ($status != 0) return ($status + 1);
+
+    $id = mysqli_insert_id($con);
+
+    foreach ($template["days"] as $typeday) {
+      foreach ($typeday as $day) {
+        $status2 = self::addDay2Schedule($id, $day["day"], $day["typeday"], $day["beginswork"], $day["endswork"], $day["beginsbreakfast"], $day["endsbreakfast"], $day["beginslunch"], $day["endslunch"]);
+        if (!$status2) return -1;
+      }
+    }
+
+    return 0;
+  }
+}
diff --git a/src/inc/schedulesView.php b/src/inc/schedulesView.php
new file mode 100644
index 0000000..aab3a2f
--- /dev/null
+++ b/src/inc/schedulesView.php
@@ -0,0 +1,124 @@
+<?php
+class schedulesView {
+  const HOUR_HEIGHT = 30;
+
+  public static function renderSchedule(&$week, $editEnabled = false, $editLink, $deleteLink) {
+    $secmin = 24*60*60;
+    $secmax = 0;
+
+    foreach ($week as $day) {
+      if ($day["beginswork"] < $secmin) $secmin = $day["beginswork"];
+      if ($day["endswork"] > $secmax) $secmax = $day["endswork"];
+    }
+
+    $min = max(floor($secmin/(60*60)) - 1, 0);
+    $max = min(ceil($secmax/(60*60)) + 1, 24);
+    ?>
+    <div class="overflow-wrapper overflow-wrapper--for-table" style="text-align: center;">
+      <div class="schedule mdl-shadow--2dp">
+        <div class="sidetime">
+          <?php
+          for ($hour = $min; $hour <= $max; $hour++) {
+            ?>
+            <div class="hour">
+              <div class="hour--text"><?=security::htmlsafe(visual::padNum($hour, 2).":00")?></div>
+            </div>
+            <?php
+          }
+          ?>
+        </div>
+        <?php
+        for ($day = 0; $day < 7; $day++) {
+          $isset = isset($week[$day]);
+
+          if (!$isset && $day >= 5 && !isset($week[5]) && !isset($week[6])) continue;
+
+          if ($isset) {
+            $size = [];
+            $size["work"] = [];
+            $size["work"]["top"] = (($week[$day]["beginswork"]/(60*60)) - $min)*self::HOUR_HEIGHT;
+            $size["work"]["height"] = intervals::measure([$week[$day]["beginswork"], $week[$day]["endswork"]])/(60*60)*self::HOUR_HEIGHT;
+
+            foreach (schedules::$otherEvents as $event) {
+              if (intervals::measure([$week[$day]["begins".$event], $week[$day]["ends".$event]]) > 0) {
+                $size[$event] = [];
+                $size[$event]["top"] = ($week[$day]["begins".$event] - $week[$day]["beginswork"])/(60*60)*self::HOUR_HEIGHT;
+                $size[$event]["height"] = intervals::measure([$week[$day]["begins".$event], $week[$day]["ends".$event]])/(60*60)*self::HOUR_HEIGHT;
+              }
+            }
+          }
+          ?>
+          <div class="day">
+            <div class="day--header"><?=security::htmlsafe(calendars::$days[$day])?></div>
+            <div class="day--content">
+              <?php
+              if ($isset) {
+                ?>
+                <div class="work-event" style="top: <?=(int)round($size["work"]["top"])?>px; height: <?=(int)round($size["work"]["height"])?>px;">
+                  <?php
+                  if ($editEnabled) {
+                    ?>
+                    <div class="event--actions">
+                      <a href='<?=$editLink($week[$day])?>' data-dyndialog-href='<?=$editLink($week[$day])?>' title='Modificar horario'><i class='material-icons'>edit</i></a>
+                      <a href='<?=$deleteLink($week[$day])?>' data-dyndialog-href='<?=$deleteLink($week[$day])?>' title='Eliminar horario'><i class='material-icons'>delete</i></a>
+                    </div>
+                    <?php
+                  }
+                  ?>
+                  <div class="event--header">Trabajo</div>
+                  <div class="event--body"><?=security::htmlsafe(schedules::sec2time($week[$day]["beginswork"]))?> - <?=security::htmlsafe(schedules::sec2time($week[$day]["endswork"]))?></div>
+                  <?php
+                  foreach (schedules::$otherEvents as $event) {
+                    if (isset($size[$event])) {
+                      ?>
+                      <div class="inline-event" style="top: <?=(int)round($size[$event]["top"])?>px; height: <?=(int)round($size[$event]["height"])?>px;">
+                        <div class="event--header"><?=security::htmlsafe(schedules::$otherEventsDescription[$event])?></div>
+                        <div class="event--body"></div>
+                      </div>
+                      <?php
+                    }
+                  }
+                  ?>
+                </div>
+                <?php
+              }
+
+              for ($hour = $min; $hour <= $max; $hour++) {
+                ?>
+                <div class="hour"></div>
+                <?php
+              }
+              ?>
+            </div>
+          </div>
+          <?php
+        }
+        ?>
+      </div>
+    </div>
+  <?php
+  }
+
+  public static function renderPlainSchedule($week, $editEnabled = false, $editLink, $deleteLink, &$flag) {
+    foreach ($week as $day) {
+      $flag = true;
+      ?>
+      <h4><?=security::htmlsafe(calendars::$days[$day["day"]])?> <span class="mdl-color-text--grey-600">(<?=security::htmlsafe(calendars::$types[$day["typeday"]])?>)</span>
+        <?php
+        if ($editEnabled) {
+          ?>
+          <a href='<?=$editLink($day)?>' data-dyndialog-href='<?=$editLink($day)?>' title='Modificar horario' class='mdl-button mdl-js-button mdl-button--icon mdl-js-ripple-effect'><i class='material-icons icon'>edit</i></a>
+          <a href='<?=$deleteLink($day)?>' data-dyndialog-href='<?=$deleteLink($day)?>' title='Eliminar horario' class='mdl-button mdl-js-button mdl-button--icon mdl-js-ripple-effect'><i class='material-icons icon'>delete</i></a>
+          <?php
+        }
+        ?>
+      </h4>
+      <ul>
+        <li><b>Horario de trabajo:</b> <?=security::htmlsafe(schedules::sec2time($day["beginswork"]))?> - <?=security::htmlsafe(schedules::sec2time($day["endswork"]))?></li>
+        <?php if (intervals::measure([$day["beginsbreakfast"], $day["endsbreakfast"]]) > 0) { ?><li><b>Desayuno:</b> <?=security::htmlsafe(schedules::sec2time($day["beginsbreakfast"]))?> - <?=security::htmlsafe(schedules::sec2time($day["endsbreakfast"]))?></li><?php } ?>
+        <?php if (intervals::measure([$day["beginslunch"], $day["endslunch"]]) > 0) { ?><li><b>Comida:</b> <?=security::htmlsafe(schedules::sec2time($day["beginslunch"]))?> - <?=security::htmlsafe(schedules::sec2time($day["endslunch"]))?></li><?php } ?>
+      </ul>
+      <?php
+    }
+  }
+}
diff --git a/src/inc/secondFactor.php b/src/inc/secondFactor.php
new file mode 100644
index 0000000..1b0de75
--- /dev/null
+++ b/src/inc/secondFactor.php
@@ -0,0 +1,288 @@
+<?php
+require_once(__DIR__."/../lib/GoogleAuthenticator/GoogleAuthenticator.php");
+require_once(__DIR__."/../lib/WebAuthn/WebAuthn.php");
+
+class secondFactor {
+  public static function isAvailable() {
+    global $conf;
+    return (($conf["secondFactor"]["enabled"] ?? false) === true);
+  }
+
+  public static function checkAvailability() {
+    global $conf;
+
+    if (!self::isAvailable()) {
+      security::notFound();
+    }
+  }
+
+  public static function isEnabled($person = "ME") {
+    if (!self::isAvailable()) return false;
+    return (people::userData("secondfactor", $person) == 1);
+  }
+
+  public static function generateSecret() {
+    $authenticator = new GoogleAuthenticator();
+    return $authenticator->generateSecret();
+  }
+
+  public static function isValidSecret($secret) {
+    return (strlen($secret) === 32);
+  }
+
+  public static function checkCode($secret, $code) {
+    $authenticator = new GoogleAuthenticator();
+    return $authenticator->checkCode($secret, $code);
+  }
+
+  public static function checkPersonCode($person, $code) {
+    global $con;
+
+    $sperson = (int)$person;
+    $query = mysqli_query($con, "SELECT secret FROM totp WHERE person = $sperson LIMIT 1");
+    if ($query === false || !mysqli_num_rows($query)) return false;
+
+    $row = mysqli_fetch_assoc($query);
+
+    return self::checkCode($row["secret"], $code);
+  }
+
+  public static function disable($person = "ME", $personDisable = true) {
+    global $con;
+
+    if ($person == "ME") $person = people::userData("id");
+    $sperson = (int)$person;
+
+    return (mysqli_query($con, "DELETE FROM totp WHERE person = $sperson") && (!$personDisable || mysqli_query($con, "UPDATE people SET secondfactor = 0 WHERE id = $sperson LIMIT 1")) && mysqli_query($con, "DELETE FROM securitykeys WHERE person = $sperson"));
+  }
+
+  public static function enable($secret, $person = "ME") {
+    global $con;
+
+    if (!self::isValidSecret($secret)) return false;
+
+    if ($person == "ME") $person = people::userData("id");
+
+    self::disable($person, false);
+
+    $sperson = (int)$person;
+    $ssecret = db::sanitize($secret);
+    return (mysqli_query($con, "INSERT INTO totp (person, secret) VALUES ($sperson, '$ssecret')") && mysqli_query($con, "UPDATE people SET secondfactor = 1 WHERE id = $sperson LIMIT 1"));
+  }
+
+  private static function completeLogin($success) {
+    if ($success === true) {
+      $_SESSION["id"] = $_SESSION["firstfactorid"];
+      unset($_SESSION["firstfactorid"]);
+      return true;
+    }
+
+    if ($success === false) {
+      unset($_SESSION["firstfactorid"]);
+      return true;
+    }
+
+    return false;
+  }
+
+  public static function completeCodeChallenge($code) {
+    global $_SESSION;
+
+    $success = self::checkPersonCode($_SESSION["firstfactorid"], $code);
+    self::completeLogin($success);
+    return $success;
+  }
+
+  private static function newWebAuthn() {
+    global $conf;
+
+    if (!isset($conf["secondFactor"]) || !isset($conf["secondFactor"]["origin"])) {
+      throw new Exception('secondFactor is not enabled (or the origin is not set) in config.php.');
+    }
+
+    return new \lbuchs\WebAuthn\WebAuthn($conf["appName"], $conf["secondFactor"]["origin"], ["none"]);
+  }
+
+  public static function createRegistrationChallenge() {
+    global $_SESSION;
+
+    $WebAuthn = self::newWebAuthn();
+
+    $credentialIds = self::getCredentialIds();
+    $createArgs = $WebAuthn->getCreateArgs(people::userData("id"), people::userData("username"), people::userData("name"), 20, false, false, ($credentialIds === false ? [] : $credentialIds));
+    $_SESSION['webauthnchallenge'] = $WebAuthn->getChallenge();
+
+    return $createArgs;
+  }
+
+  public static function addSecurityKeyToDB($credentialId, $credentialPublicKey, $name) {
+    global $con;
+
+    $person = people::userData("id");
+    if ($person === false) return false;
+    $sperson = (int)$person;
+
+    $sname = db::sanitize($name);
+    $scredentialId = db::sanitize($credentialId);
+    $scredentialPublicKey = db::sanitize($credentialPublicKey);
+    $stime = (int)time();
+
+    return mysqli_query($con, "INSERT INTO securitykeys (person, name, credentialid, credentialpublickey, added) VALUES ($sperson, '$sname', '$scredentialId', '$scredentialPublicKey', $stime)");
+  }
+
+  public static function completeRegistrationChallenge($clientDataJSON, $attestationObject, $name) {
+    global $_SESSION;
+
+    $clientDataJSON = base64_decode($clientDataJSON);
+    $attestationObject = base64_decode($attestationObject);
+
+    if (!isset($_SESSION["webauthnchallenge"])) {
+      throw new Exception('The user didn\'t start the webauthn challenge.');
+    }
+    $challenge = $_SESSION["webauthnchallenge"];
+    unset($_SESSION["webauthnchallenge"]);
+
+    $WebAuthn = self::newWebAuthn();
+    $data = $WebAuthn->processCreate($clientDataJSON, $attestationObject, $challenge);
+
+    if (!self::addSecurityKeyToDB($data->credentialId, $data->credentialPublicKey, $name)) {
+      throw new Exception('Failed adding security key to DB.');
+    }
+
+    return ["status" => "ok"];
+  }
+
+  public static function getSecurityKeys($person = "ME") {
+    global $con;
+
+    if ($person == "ME") $person = people::userData("id");
+    if ($person === false) return false;
+    $sperson = (int)$person;
+
+    $query = mysqli_query($con, "SELECT * FROM securitykeys WHERE person = $sperson");
+    if ($query === false) return false;
+
+    $securityKeys = [];
+    while ($row = mysqli_fetch_assoc($query)) {
+      $securityKeys[] = $row;
+    }
+
+    return $securityKeys;
+  }
+
+  public static function getCredentialIds($person = "ME") {
+    $securityKeys = self::getSecurityKeys($person);
+    if ($securityKeys === false) return false;
+
+    $credentials = [];
+    foreach ($securityKeys as $s) $credentials[] = $s["credentialid"];
+
+    return $credentials;
+  }
+
+  public static function hasSecurityKeys($person) {
+    global $con;
+
+    $sperson = (int)$person;
+
+    $query = mysqli_query($con, "SELECT 1 FROM securitykeys WHERE person = $sperson LIMIT 1");
+    if ($query === false) return false;
+
+    return (mysqli_num_rows($query) > 0);
+  }
+
+  public static function createValidationChallenge() {
+    global $_SESSION;
+
+    $WebAuthn = self::newWebAuthn();
+
+    if (!isset($_SESSION["firstfactorid"])) {
+      throw new Exception('User didn\'t log in with the first factor.');
+    }
+    $credentialIds = self::getCredentialIds($_SESSION["firstfactorid"]);
+    if ($credentialIds === false || empty($credentialIds)) {
+      throw new Exception('The user credentials could not be obtained.');
+    }
+
+    $getArgs = $WebAuthn->getGetArgs($credentialIds);
+    $_SESSION['webauthnvalidationchallenge'] = $WebAuthn->getChallenge();
+
+    return $getArgs;
+  }
+
+  public static function getSecurityKey($credentialId, $person) {
+    $securityKeys = self::getSecurityKeys($person);
+
+    foreach ($securityKeys as $s) {
+      if ($s["credentialid"] == $credentialId) {
+        return $s;
+      }
+    }
+
+    return null;
+  }
+
+  public static function getSecurityKeyById($id) {
+    global $con;
+
+    $query = mysqli_query($con, "SELECT * FROM securitykeys WHERE id = $id");
+    if ($query === false || !mysqli_num_rows($query)) return false;
+
+    return mysqli_fetch_assoc($query);
+  }
+
+  public static function removeSecurityKey($id) {
+    global $con;
+
+    $sid = (int)$id;
+    $sperson = (int)people::userData("id");
+    if ($sperson === false) return false;
+
+    return mysqli_query($con, "DELETE FROM securitykeys WHERE id = $sid and PERSON = $sperson LIMIT 1");
+  }
+
+  private static function recordSecurityKeyUsageToDB($id) {
+    global $con;
+
+    $sid = (int)$id;
+    $stime = (int)time();
+
+    return mysqli_query($con, "UPDATE securitykeys SET lastused = $stime WHERE id = $sid LIMIT 1");
+  }
+
+  public static function completeValidationChallenge($id, $clientDataJSON, $authenticatorData, $signature) {
+    global $_SESSION;
+
+    $id = base64_decode($id);
+    $clientDataJSON = base64_decode($clientDataJSON);
+    $authenticatorData = base64_decode($authenticatorData);
+    $signature = base64_decode($signature);
+
+    if (!isset($_SESSION["webauthnvalidationchallenge"])) {
+      throw new Exception('The user didn\'t start the webauthn challenge.');
+    }
+    $challenge = $_SESSION["webauthnvalidationchallenge"];
+    unset($_SESSION["webauthnvalidationchallenge"]);
+
+    $securityKey = self::getSecurityKey($id, $_SESSION["firstfactorid"]);
+    $credentialPublicKey = $securityKey["credentialpublickey"] ?? null;
+    if ($credentialPublicKey === null) {
+      self::completeLogin(false);
+      throw new Exception('The security key could not be found.');
+    }
+
+    try {
+      $WebAuthn = self::newWebAuthn();
+
+      $WebAuthn->processGet($clientDataJSON, $authenticatorData, $signature, $credentialPublicKey, $challenge);
+    } catch (Throwable $ex) {
+      self::completeLogin(false);
+      throw $ex;
+    }
+
+    self::recordSecurityKeyUsageToDB($securityKey["id"]);
+    self::completeLogin(true);
+
+    return ["status" => "ok"];
+  }
+}
diff --git a/src/inc/secondFactorView.php b/src/inc/secondFactorView.php
new file mode 100644
index 0000000..7ed9712
--- /dev/null
+++ b/src/inc/secondFactorView.php
@@ -0,0 +1,9 @@
+<?php
+class secondFactorView {
+  public static function renderSecret($secret) {
+    for ($i = 0; $i < strlen($secret); $i++) {
+      if ($i != 0 && $i % 4 == 0) echo " ";
+      echo $secret[$i];
+    }
+  }
+}
diff --git a/src/inc/security.php b/src/inc/security.php
new file mode 100644
index 0000000..1d3159b
--- /dev/null
+++ b/src/inc/security.php
@@ -0,0 +1,362 @@
+<?php
+class security {
+  const HYPERADMIN = 0;
+  const ADMIN = 2;
+  /*const SYSTEM_REVIEWER = 5;
+  const LOG_REVIEWER = 7;*/
+  const WORKER = 10;
+  const UNKNOWN = 1000;
+
+  const METHOD_UNSPECIFICED = -1;
+  const METHOD_REDIRECT = 0;
+  const METHOD_NOTFOUND = 1;
+
+  const PARAM_ISSET = 1;
+  const PARAM_NEMPTY = 2;
+  const PARAM_ISEMAIL = 4;
+  const PARAM_ISDATE = 8;
+  const PARAM_ISINT = 16;
+  const PARAM_ISTIME = 32;
+  const PARAM_ISARRAY = 64;
+  const PARAM_ISEMAILOREMPTY = 128;
+
+  const SIGNIN_STATE_SIGNED_IN = 0;
+  const SIGNIN_STATE_NEEDS_SECOND_FACTOR = 1;
+  const SIGNIN_STATE_SIGNED_OUT = 2;
+  const SIGNIN_STATE_THROTTLED = 3;
+
+  public static $types = [
+    10 => "Trabajador",
+    /*7 => "Revisor de logs",
+    5 => "Revisor del sistema",*/
+    2 => "Administrador",
+    0 => "Hiperadministrador"
+  ];
+
+  public static $automatedChecks = [self::PARAM_ISSET, self::PARAM_NEMPTY, self::PARAM_ISEMAIL, self::PARAM_ISDATE, self::PARAM_ISINT, self::PARAM_ISTIME, self::PARAM_ISARRAY, self::PARAM_ISEMAILOREMPTY];
+
+  public static $passwordHelperText = "La contraseña debe tener como mínimo 8 caracteres y una letra mayúscula.";
+
+  public static function go($page) {
+    global $conf;
+
+    if ($conf["superdebug"]) {
+      echo "Redirects are not enabled. We would like to redirect you here: <a href='".self::htmlsafe($page)."'>".self::htmlsafe($page)."</a>";
+    } else {
+      header("Location: ".$page);
+    }
+
+    exit();
+  }
+
+  public static function goHome() {
+    self::go("index.php");
+  }
+
+  public static function notFound() {
+    header('HTTP/1.0 404 Not Found');
+    exit();
+  }
+
+  public static function isSignedIn() {
+    global $_SESSION;
+
+    return isset($_SESSION["id"]);
+  }
+
+  public static function check() {
+    if (!self::isSignedIn()) {
+      self::goHome();
+    }
+  }
+
+  public static function userType() {
+    global $_SESSION, $con;
+
+    if (!isset($_SESSION["id"])) {
+      return self::UNKNOWN;
+    }
+
+    $query = mysqli_query($con, "SELECT type FROM people WHERE id = ".(int)$_SESSION["id"]);
+
+    if (!mysqli_num_rows($query)) {
+      return self::UNKNOWN;
+    }
+
+    $row = mysqli_fetch_assoc($query);
+
+    return $row["type"];
+  }
+
+  public static function isAllowed($type) {
+    $userType = (self::isSignedIn() ? self::userType() : self::UNKNOWN);
+
+    return $userType <= $type;
+  }
+
+  public static function denyUseMethod($method = self::METHOD_REDIRECT) {
+    if ($method === self::METHOD_NOTFOUND) {
+      self::notFound();
+    } else { // self::METHOD_REDIRECT or anything else
+      self::goHome();
+    }
+  }
+
+  public static function checkType($type, $method = self::METHOD_REDIRECT) {
+    if (!self::isAllowed($type)) {
+      self::denyUseMethod($method);
+    }
+  }
+
+  public static function checkWorkerUIEnabled() {
+    global $conf;
+
+    if (self::userType() >= self::WORKER && !$conf["enableWorkerUI"]) {
+      self::go("index.php?msg=unsupported");
+    }
+  }
+
+  // Code from https://timoh6.github.io/2015/05/07/Rate-limiting-web-application-login-attempts.html
+  private static function getIpAddresses() {
+    $ips = [];
+    $ips["remoteIp"] = inet_pton($_SERVER['REMOTE_ADDR']); // inet_pton can handle both IPv4 and IPv6 addresses, treat IPv6 addresses as /64 or /56 blocks.
+    $ips["remoteIpBlock"] = long2ip(ip2long($_SERVER['REMOTE_ADDR']) & 0xFFFFFF00); // Something like this to turn the last octet of IPv4 address into a 0.
+    return $ips;
+  }
+
+  public static function recordLoginAttempt($username) {
+    global $con;
+
+    $susername = db::sanitize($username);
+
+    $ips = self::getIpAddresses();
+    $sremoteIp = db::sanitize($ips["remoteIp"]);
+    $sremoteIpBlock = db::sanitize($ips["remoteIpBlock"]);
+
+    return mysqli_query($con, "INSERT INTO signinattempts (username, remoteip, remoteipblock, signinattempttime) VALUES ('$susername', '$sremoteIp', '$sremoteIpBlock', NOW())");
+  }
+
+  public static function getRateLimitingCounts($username) {
+    global $con, $conf;
+
+    $susername = db::sanitize($username);
+
+    $ips = self::getIpAddresses();
+    $sremoteIp = db::sanitize($ips["remoteIp"]);
+    $sremoteIpBlock = db::sanitize($ips["remoteIpBlock"]);
+
+    $query = mysqli_query($con, "SELECT
+        COUNT(*) AS global_attempt_count,
+        IFNULL(SUM(CASE WHEN remoteip = '$sremoteIp' THEN 1 ELSE 0 END), 0) AS ip_attempt_count,
+        IFNULL(SUM(CASE WHEN (remoteipblock = '$sremoteIpBlock') THEN 1 ELSE 0 END), 0) AS ip_block_attempt_count,
+        (SELECT COUNT(DISTINCT remoteipblock) FROM signinattempts WHERE username = '$susername' AND signinattempttime >= (NOW() - INTERVAL 10 SECOND )) AS ip_blocks_per_username_attempt_count,
+        (SELECT COUNT(*) FROM signinattempts WHERE username = '$susername' AND signinattempttime >= (NOW() - INTERVAL 10 SECOND )) AS username_attempt_count
+      FROM signinattempts
+      WHERE signinattempttime >= (NOW() - INTERVAL 10 second)");
+
+    if ($query === false) return false;
+
+    return mysqli_fetch_assoc($query);
+  }
+
+  public static function isSignInThrottled($username) {
+    global $conf;
+
+    $count = self::getRateLimitingCounts($username);
+
+    if ($count["global_attempt_count"] >= $conf["signinThrottling"]["attemptCountLimit"]["global"] ||
+        $count["ip_attempt_count"] >= $conf["signinThrottling"]["attemptCountLimit"]["ip"] ||
+        $count["ip_block_attempt_count"] >= $conf["signinThrottling"]["attemptCountLimit"]["ipBlock"] ||
+        $count["ip_blocks_per_username_attempt_count"] >= $conf["signinThrottling"]["attemptCountLimit"]["ipBlocksPerUsername"] ||
+        $count["username_attempt_count"] >= $conf["signinThrottling"]["attemptCountLimit"]["username"]) {
+      return true;
+    }
+
+    if (!self::recordLoginAttempt($username)) {
+      echo "There was an unexpected error, so you could not be authenticated. Please contact me@avm99963.com and let them know of the error. (2)";
+      exit();
+    }
+
+    return false;
+  }
+
+  public static function isUserPassword($username, $password) {
+    global $con, $_SESSION;
+
+    $susername = db::sanitize($username);
+    $query = mysqli_query($con, "SELECT id, password FROM people WHERE ".($username === false ? "id = ".(int)$_SESSION["id"] : "username = '".$susername."'"));
+
+    if (!mysqli_num_rows($query)) {
+      return false;
+    }
+
+    $row = mysqli_fetch_assoc($query);
+
+    if (!password_verify($password, $row["password"])) {
+      return false;
+    }
+
+    return $row["id"];
+  }
+
+  public static function signIn($username, $password) {
+    global $_SESSION;
+
+    if (self::isSignInThrottled($username)) return self::SIGNIN_STATE_THROTTLED;
+
+    $id = self::isUserPassword($username, $password);
+
+    if ($id !== false) {
+      if (secondFactor::isEnabled($id)) {
+        $_SESSION["firstfactorid"] = $id;
+        return self::SIGNIN_STATE_NEEDS_SECOND_FACTOR;
+      } else {
+        $_SESSION["id"] = $id;
+        return self::SIGNIN_STATE_SIGNED_IN;
+      }
+    }
+
+    return self::SIGNIN_STATE_SIGNED_OUT;
+  }
+
+  public static function redirectAfterSignIn() {
+    global $conf;
+
+    if (self::isAllowed(self::ADMIN)) {
+      self::changeActiveView(visual::VIEW_ADMIN);
+      self::go("home.php");
+    } else {
+      self::changeActiveView(visual::VIEW_WORKER);
+      self::go(($conf["enableWorkerUI"] ? "workerhome.php" : "index.php?msg=unsupported"));
+    }
+  }
+
+  public static function logout() {
+    global $_SESSION;
+
+    session_destroy();
+  }
+
+  public static function htmlsafe($string) {
+    if ($string === null) return '';
+    return htmlspecialchars((string)$string);
+  }
+
+  private static function failedCheckParams($parameter, $method, $check) {
+    global $conf;
+
+    if ($conf["superdebug"]) {
+      echo "Failed check ".(int)$check." using parameter '".self::htmlsafe($parameter)."' with method ".self::htmlsafe($method).".<br>";
+    }
+  }
+
+  public static function checkParam($param, $check) {
+    if ($check == self::PARAM_NEMPTY && empty($param)) {
+      return false;
+    }
+
+    if ($check == self::PARAM_ISEMAIL && filter_var($param, FILTER_VALIDATE_EMAIL) === false) {
+      return false;
+    }
+
+    if ($check == self::PARAM_ISDATE && preg_match("/^[0-9]+-[0-9]+-[0-9]+$/", $param) !== 1) {
+      return false;
+    }
+
+    if ($check == self::PARAM_ISINT && filter_var($param, FILTER_VALIDATE_INT) === false) {
+      return false;
+    }
+
+    if ($check == self::PARAM_ISTIME) {
+      if (preg_match("/^[0-9]+:[0-9]+$/", $param) !== 1) return false;
+
+      $time = explode(":", $param);
+      if ((int)$time[0] >= 24 || (int)$time[1] >= 60) return false;
+    }
+
+    if ($check == self::PARAM_ISARRAY) {
+      // Check whether the parameter is an array
+      if (is_array($param) === false) return false;
+
+      // Check that it is not a multidimensional array (we don't want that for any parameter!)
+      foreach ($param as &$el) {
+        if (is_array($el)) return false;
+      }
+    }
+
+    if ($check == self::PARAM_ISEMAILOREMPTY && $param !== "" && filter_var($param, FILTER_VALIDATE_EMAIL) === false) {
+      return false;
+    }
+
+    return true;
+  }
+
+  public static function checkParams($method, $parameters, $forceDisableDebug = false) {
+    global $_POST, $_GET;
+
+    if (!in_array($method, ["GET", "POST"])) {
+      return false;
+    }
+
+    foreach ($parameters as $p) {
+      if (!$p[1]) {
+        continue;
+      }
+
+      if (($method == "POST" && !isset($_POST[$p[0]])) || ($method == "GET" && !isset($_GET[$p[0]]))) {
+        if (!$forceDisableDebug) self::failedCheckParams($p[0], $method, self::PARAM_ISSET);
+        return false;
+      }
+
+      $value = ($method == "POST" ? $_POST[$p[0]] : $_GET[$p[0]]);
+
+      foreach (self::$automatedChecks as $check) {
+        if (($p[1] & $check) && !self::checkParam($value, $check)) {
+          if (!$forceDisableDebug) self::failedCheckParams($p[0], $method, $check);
+          return false;
+        }
+      }
+    }
+
+    return true;
+  }
+
+  public static function existsType($type) {
+    $types = array_keys(self::$types);
+
+    return in_array($type, $types);
+  }
+
+  public static function getActiveView() {
+    global $_SESSION;
+
+    if (!self::isAllowed(self::ADMIN)) {
+      return visual::VIEW_WORKER;
+    }
+
+    return (!isset($_SESSION["activeView"]) ? visual::VIEW_ADMIN : $_SESSION["activeView"]);
+  }
+
+  public static function isAdminView() {
+    return (self::getActiveView() === visual::VIEW_ADMIN);
+  }
+
+  public static function changeActiveView($view) {
+    $_SESSION["activeView"] = $view;
+  }
+
+  public static function passwordIsGoodEnough($password) {
+    return (strlen($password) >= 8 && preg_match('/[A-Z]/', $password));
+  }
+
+  public static function cleanSignInAttempts($retentionDays = "DEFAULT") {
+    global $con, $conf;
+
+    if ($retentionDays === "DEFAULT") $retentionDays = $conf["signinThrottling"]["retentionDays"];
+
+    $sretentionDays = (int)$retentionDays;
+    if ($retentionDays < 0) return false;
+
+    return mysqli_query($con, "DELETE FROM signinattempts WHERE signinattempttime < (NOW() - INTERVAL $sretentionDays DAY)");
+  }
+}
diff --git a/src/inc/validations.php b/src/inc/validations.php
new file mode 100644
index 0000000..d3c310f
--- /dev/null
+++ b/src/inc/validations.php
@@ -0,0 +1,234 @@
+<?php
+class validations {
+  const DEFAULT_REMINDER_GRACE_PERIOD = 3; // When sending email notifications about pending elements to verify,
+                                   // only elements effective until the current day minus the grace period
+                                   // will be checked for validation. (value is given in days)
+
+  const METHOD_SIMPLE = 0;
+  const METHOD_AUTOVALIDATION = 1;
+
+  public static $methodCodename = [
+    0 => "simple",
+    1 => "autovalidation"
+  ];
+
+  public static $methodName = [
+    0 => "Validación por dirección IP",
+    1 => "Validación automática"
+  ];
+
+  public static $methods = [self::METHOD_SIMPLE, self::METHOD_AUTOVALIDATION];
+  public static $manualMethods = [self::METHOD_SIMPLE];
+
+  public static function reminderGracePeriod() {
+    return $conf["validation"]["gracePeriod"] ?? self::DEFAULT_REMINDER_GRACE_PERIOD;
+  }
+
+  public static function numPending($userId = "ME", $gracePeriod = 0) {
+    global $con;
+
+    if ($userId === "ME") $userId = people::userData("id");
+    $suser = (int)$userId;
+
+    $query = mysqli_query($con, "SELECT COUNT(*) count FROM incidents i INNER JOIN workers w ON i.worker = w.id WHERE w.person = ".$suser." AND ".incidents::$workerPendingWhere." AND ".incidents::$activeWhere.($gracePeriod === false ? "" : " AND (i.day + i.begins) < ".(int)(time() - (int)$gracePeriod*(24*60*60))));
+
+    if (!mysqli_num_rows($query)) return "?";
+
+    $row = mysqli_fetch_assoc($query);
+
+    $count = (int)(isset($row["count"]) ? $row["count"] : 0);
+
+    $query2 = mysqli_query($con, "SELECT COUNT(*) count FROM records r INNER JOIN workers w ON r.worker = w.id WHERE w.person = ".$suser." AND ".registry::$notInvalidatedWhere." AND ".registry::$workerPendingWhere.($gracePeriod === false ? "" : " AND r.day < ".(int)(time() - (int)$gracePeriod*(24*60*60))));
+
+    if (!mysqli_num_rows($query2)) return "?";
+
+    $row2 = mysqli_fetch_assoc($query2);
+
+    return $count + (int)(isset($row2["count"]) ? $row2["count"] : 0);
+  }
+
+  public static function getAllowedMethods() {
+    global $conf;
+
+    $allowedMethods = [];
+    foreach (self::$manualMethods as $method) {
+      if (in_array($method, $conf["validation"]["allowedMethods"])) $allowedMethods[] = $method;
+    }
+
+    return $allowedMethods;
+  }
+
+  private static function string2array($string) {
+    if (!is_string($string) || empty($string)) return [];
+    $explode = explode(",", $string);
+
+    $array = [];
+    foreach ($explode as $el) {
+      $array[] = (int)$el;
+    }
+
+    return $array;
+  }
+
+  private static function createSimpleAttestation($method, $user) {
+    $attestation = [];
+    if (!isset($_SERVER["REMOTE_ADDR"])) return false;
+    $attestation["ipAddress"] = (string)$_SERVER["REMOTE_ADDR"];
+    if ($method === self::METHOD_AUTOVALIDATION) $attestation["user"] = (int)$user;
+    return $attestation;
+  }
+
+  private static function createIncidentAttestation(&$incident, $method, $user) {
+    switch ($method) {
+      case self::METHOD_SIMPLE:
+      case self::METHOD_AUTOVALIDATION:
+      return self::createSimpleAttestation($method, $user);
+      break;
+
+      default:
+      return false;
+    }
+  }
+
+  private static function createRecordAttestation(&$record, $method, $user) {
+    switch ($method) {
+      case self::METHOD_SIMPLE:
+      case self::METHOD_AUTOVALIDATION:
+      return self::createSimpleAttestation($method, $user);
+      break;
+
+      default:
+      return false;
+    }
+  }
+
+  private static function createValidation($method, $attestation) {
+    $validation = [];
+    $validation["method"] = (int)$method;
+    $validation["timestamp"] = time();
+    $validation["attestation"] = $attestation;
+
+    return $validation;
+  }
+
+  public static function validateIncident($id, $method, $user, $userCheck = true, $stateCheck = true) {
+    global $con;
+
+    if ($user == "ME") $user = people::userData("id");
+    if ($userCheck && !incidents::checkIncidentIsFromPerson($id, "ME", true)) return false;
+
+    $incident = incidents::get($id, true);
+    if ($incident === false || ($stateCheck && $incident["state"] !== incidents::STATE_REGISTERED)) return false;
+
+    $attestation = self::createIncidentAttestation($incident, $method, $user);
+    if ($attestation === false) return false;
+
+    $validation = self::createValidation($method, $attestation);
+
+    $svalidation = db::sanitize(json_encode($validation));
+    $sid = (int)$incident["id"];
+
+    return mysqli_query($con, "UPDATE incidents SET workervalidated = 1, workervalidation = '$svalidation' WHERE id = $sid LIMIT 1");
+  }
+
+  public static function validateRecord($id, $method, $user, $userCheck = true) {
+    global $con;
+
+    if ($user == "ME") $user = people::userData("id");
+    if ($userCheck && !registry::checkRecordIsFromPerson($id, "ME", true)) return false;
+
+    $record = registry::get($id, true);
+    if ($record === false || $record["state"] !== registry::STATE_REGISTERED) return false;
+
+    $attestation = self::createRecordAttestation($record, $method, $user);
+    if ($attestation === false) return false;
+
+    $validation = self::createValidation($method, $attestation);
+
+    $svalidation = db::sanitize(json_encode($validation));
+    $sid = (int)$record["id"];
+
+    return mysqli_query($con, "UPDATE records SET workervalidated = 1, workervalidation = '$svalidation' WHERE id = $sid LIMIT 1");
+  }
+
+  public static function validate($method, $incidents, $records, $user = "ME") {
+    if (!in_array($method, self::getAllowedMethods())) return -1;
+
+    $incidents = self::string2array($incidents);
+    $records = self::string2array($records);
+
+    if ($user == "ME") $user = people::userData("id");
+
+    $flag = false;
+    foreach ($incidents as $incident) {
+      if (!self::validateIncident($incident, $method, $user)) $flag = true;
+    }
+    foreach ($records as $record) {
+      if (!self::validateRecord($record, $method, $user)) $flag = true;
+    }
+
+    return ($flag ? 1 : 0);
+  }
+
+  public static function getPeopleWithPendingValidations($gracePeriod = "DEFAULT") {
+    if ($gracePeriod === "DEFAULT") $gracePeriod = self::reminderGracePeriod();
+
+    $pendingPeople = [];
+
+    $people = people::getAll();
+    foreach ($people as $p) {
+      $numPending = self::numPending((int)$p["id"], $gracePeriod);
+
+      if ($numPending > 0) {
+        $pending = [];
+        $pending["person"] = $p;
+        $pending["numPending"] = $numPending;
+
+        $pendingPeople[] = $pending;
+      }
+    }
+
+    return $pendingPeople;
+  }
+
+  public static function sendPendingValidationsReminder() {
+    global $conf;
+
+    if (!$conf["mail"]["enabled"]) {
+      echo "[error] The mail functionality is not enabled in config.php.\n";
+      return false;
+    }
+
+    if (!$conf["mail"]["capabilities"]["sendPendingValidationsReminder"]) {
+      echo "[error] The pending validation reminders functionality is not inabled in config.php.\n";
+      return false;
+    }
+
+    $pendingPeople = self::getPeopleWithPendingValidations();
+
+    foreach ($pendingPeople as $p) {
+      if (!isset($p["person"]["email"]) || empty($p["person"]["email"])) {
+        echo "[info] ".$p["person"]["id"]." doesn't have an email address defined.\n";
+        continue;
+      }
+
+      $to = [["email" => $p["person"]["email"]]];
+
+      $subject = "[Recordatorio] Tienes validaciones pendientes en el aplicativo de control horario";
+      $body = mail::bodyTemplate("<p>Hola ".security::htmlsafe(($p["person"]["name"]) ?? "").",</p>
+      <p>Este es un mensaje automático para avisarte de que tienes ".(int)$p["numPending"]." incidencias y/o registros pendientes de validar en el aplicativo de control horario.</p>
+      <p>Para validarlos, puedes acceder al aplicativo desde la siguiente dirección:</p>
+      <ul>
+        <li><a href='".security::htmlsafe($conf["fullPath"])."'>".security::htmlsafe($conf["fullPath"])."</a></li>
+      </ul>");
+
+      if (mail::send($to, [], $subject, $body)) {
+        echo "[info] Mail sent to ".$p["person"]["id"]." successfuly.\n";
+      } else {
+        echo "[error] The email couldn't be sent to ".$p["person"]["id"]."\n";
+      }
+    }
+
+    return true;
+  }
+}
diff --git a/src/inc/validationsView.php b/src/inc/validationsView.php
new file mode 100644
index 0000000..b6b2b68
--- /dev/null
+++ b/src/inc/validationsView.php
@@ -0,0 +1,105 @@
+<?php
+class validationsView {
+  public static function renderPendingValidations($userId) {
+    $workers = workers::getPersonWorkers((int)$userId);
+    $companies = companies::getAll();
+
+    if ($workers === false || $companies === false) {
+      return false;
+    }
+    ?>
+    <h4>Incidencias</h4>
+    <?php
+    $iFlag = false;
+    foreach ($workers as $w) {
+      $incidents = incidents::getAll(false, 0, 0, (int)$w["id"], null, null, true);
+      if ($incidents === false) continue;
+
+      if (count($incidents)) {
+        $iFlag = true;
+        echo "<h5>".security::htmlsafe($w["companyname"])."</h5>";
+        incidentsView::renderIncidents($incidents, $companies, false, false, true, true);
+      }
+
+      visual::printDebug("incidents::getAll(false, 0, 0, ".(int)$w["id"].", null, null, true)", $incidents);
+    }
+
+    if (!$iFlag) echo "<p>No hay incidencias pendientes para validar.</p>";
+    ?>
+
+    <h4>Registros de horario</h4>
+    <?php
+    $rFlag = false;
+    foreach ($workers as $w) {
+      $registry = registry::getWorkerRecords((int)$w["id"], false, false, false, true);
+      if ($registry === false) continue;
+
+      if (count($registry)) {
+        $rFlag = true;
+        echo "<h5>".security::htmlsafe($w["companyname"])."</h5>";
+        registryView::renderRegistry($registry, $companies, false, false, true, true);
+      }
+
+      visual::printDebug("registry::getWorkerRecords(".(int)$w["id"].", false, false, false, true)", $registry);
+    }
+
+    if (!$rFlag) echo "<p>No hay registros pendientes para validar.</p>";
+
+    if ($iFlag || $rFlag) {
+      ?>
+      <p style="margin-top: 16px;"><button id="submit" class="mdl-button mdl-js-button mdl-button--raised mdl-js-ripple-effect mdl-button--accent">Validar</button></p>
+      <?php
+    }
+  }
+
+  public static function renderChallengeInstructions($method) {
+    switch ($method) {
+      case validations::METHOD_SIMPLE:
+      ?>
+      <form action="dovalidate.php" method="POST">
+        <input type="hidden" name="incidents" value="<?=security::htmlsafe($_POST["incidents"] ?? "")?>">
+        <input type="hidden" name="records" value="<?=security::htmlsafe($_POST["records"] ?? "")?>">
+        <input type="hidden" name="method" value="<?=(int)validations::METHOD_SIMPLE?>">
+        <p>Para completar la validación guardaremos tu <a href="https://help.gnome.org/users/gnome-help/stable/net-what-is-ip-address.html.es" target="_blank" rel="noopener noreferrer">dirección IP</a> y la fecha y hora actual. Para proceder, haz clic en el siguiente botón:</p>
+        <p><button id="submit" class="mdl-button mdl-js-button mdl-button--raised mdl-js-ripple-effect mdl-button--accent">Confirmar validación</button></p>
+      </form>
+      <?php
+      break;
+
+      default:
+      echo "Undefined";
+    }
+  }
+
+  public static function noPIIIpAddress($validation) {
+    $ipAddress = $validation["attestation"]["ipAddress"] ?? "unknown";
+
+    if (security::isAdminView() || $validation["method"] == validations::METHOD_SIMPLE || ($validation["method"] == validations::METHOD_AUTOVALIDATION && $validation["attestation"]["user"] == people::userData("id"))) return $ipAddress;
+
+    return "oculta (solo visible para los administradores)";
+  }
+
+  public static function renderValidationInfo($validation) {
+    ?>
+    <li><b>Validada por el trabajador:</b></li>
+    <ul>
+      <li><b>Método de validación:</b> <?=security::htmlsafe(validations::$methodName[$validation["method"]])?></li>
+      <li><b>Fecha de validación:</b> <?=security::htmlsafe(date("d/m/Y H:i", $validation["timestamp"]))?></li>
+      <?php
+      switch ($validation["method"]) {
+        case validations::METHOD_SIMPLE:
+        echo "<li><b>Dirección IP:</b> ".security::htmlsafe(self::noPIIIpAddress($validation))."</li>";
+        break;
+
+        case validations::METHOD_AUTOVALIDATION:
+        echo "<li><b>Dirección IP:</b> ".security::htmlsafe(self::noPIIIpAddress($validation))."</li>";
+        echo "<li><b>Persona que autovalida la incidencia:</b> ".security::htmlsafe(people::userData("name", $validation["attestation"]["user"]))."</li>";
+        break;
+
+        default:
+      }
+      ?>
+    </ul>
+    <?php
+  }
+}
diff --git a/src/inc/visual.php b/src/inc/visual.php
new file mode 100644
index 0000000..02a3457
--- /dev/null
+++ b/src/inc/visual.php
@@ -0,0 +1,192 @@
+<?php
+class visual {
+  const VIEW_ADMIN = 0;
+  const VIEW_WORKER = 1;
+
+  // Old:
+  /*const YES = "✓";
+  const NO = "✗";*/
+
+  // New:
+  const YES = "✓";
+  const NO = "X";
+
+  public static function snackbar($msg, $timeout = 10000, $printHTML = true) {
+    if ($printHTML) echo '<div class="mdl-snackbar mdl-js-snackbar">
+      <div class="mdl-snackbar__text"></div>
+      <button type="button" class="mdl-snackbar__action"></button>
+    </div>';
+    echo '<script>
+    window.addEventListener("load", function() {
+      var notification = document.querySelector(".mdl-js-snackbar");
+      notification.MaterialSnackbar.showSnackbar(
+        {
+          message: "'.security::htmlsafe($msg).'",
+          timeout: '.(int)$timeout.'
+        }
+      );
+    });
+    </script>';
+  }
+
+  public static function smartSnackbar($msgs, $timeout = 10000, $printHTML = true) {
+    global $_GET;
+
+    if (!isset($_GET["msg"])) return;
+
+    foreach ($msgs as $msg) {
+      if ($_GET["msg"] == $msg[0]) {
+        self::snackbar($msg[1], $timeout, $printHTML);
+        return;
+      }
+    }
+  }
+
+  public static function debugJson($array) {
+    return security::htmlsafe(json_encode($array, JSON_PRETTY_PRINT));
+  }
+
+  public static function includeHead() {
+    include("includes/head.php");
+  }
+
+  public static function includeNav() {
+    global $conf, $mdHeaderRowMore, $mdHeaderMore, $mdHeaderRowBefore;
+
+    $activeView = security::getActiveView();
+    switch ($activeView) {
+      case self::VIEW_ADMIN:
+      include("includes/adminnav.php");
+      break;
+
+      case self::VIEW_WORKER:
+      include("includes/workernav.php");
+      break;
+
+      default:
+      exit();
+    }
+  }
+
+  public static function backBtn($url) {
+    return '<a class="backbtn mdl-button mdl-js-button mdl-button--icon mdl-js-ripple-effect" href="'.$url.'"><i id="auto_backbtn" class="material-icons">arrow_back</i></a><div class="mdl-tooltip" for="auto_backbtn">Atrás</div><div style="width: 16px;"></div>';
+  }
+
+  public static function printDebug($function, $return, $always=false, $notjson=false) {
+    global $conf;
+
+    if ($always || $conf["debug"])
+      echo '<details class="debug margintop">
+        <summary>Debug:</summary>
+        <p><b>'.security::htmlsafe($function).'</b></p>
+        <div class="overflow-wrapper"><pre>'.($notjson ? security::htmlsafe(print_r($return, true)) : self::debugJson($return)).'</pre></div>
+      </details>';
+  }
+
+  public static function renderPagination($rows, $page, $limit = 10, $showLimitLink = false, $alreadyHasParameters = false, $limitChange = false, $highlightedPage = false) {
+    global $_GET;
+
+    $numPages = ($limit == 0 ? 1 : ceil($rows/$limit));
+    if ($numPages > 1) {
+      $currentPage = ((isset($_GET["page"]) && $_GET["page"] <= $numPages && $_GET["page"] >= 1) ? $_GET["page"] : 1);
+
+      echo '<div class="pagination">';
+      for ($i = 1; $i <= $numPages; $i++) {
+        echo ($i != $currentPage ? '<a class="page'.($i == $highlightedPage ? " mdl-color-text--green" : "").'" href="'.security::htmlsafe($page).($alreadyHasParameters ? "&" : "?").'page='.(int)$i.($showLimitLink ? '&limit='.(int)$limit : '').'">'.(int)$i.'</a> ' : '<b class="page">'.(int)$i.'</b> ');
+      }
+      echo '</div>';
+    }
+
+    if ($limitChange !== false) {
+      ?>
+      <div class="limit-change-container">Ver <select id="limit-change">
+        <?php
+        if (isset($limitChange["options"])) {
+          if (!in_array($limit, $limitChange["options"])) {
+            echo "<option value=\"".(int)$limit."\" selected>".(int)$limit."</option>";
+          }
+          foreach ($limitChange["options"] as $option) {
+            echo "<option value=\"".(int)$option."\"".($option == $limit ? " selected" : "").">".(int)$option."</option>";
+          }
+        }
+        ?>
+      </select> <?=security::htmlsafe($limitChange["elementName"])?> por página.</div>
+      <?php
+    }
+  }
+
+  public static function padNum($num, $length) {
+    return str_pad($num, $length, "0", STR_PAD_LEFT);
+  }
+
+  public static function isMDColor() {
+    global $conf;
+
+    return (($conf["backgroundColor"][0] ?? "") != "#");
+  }
+
+  public static function printBodyTag() {
+    global $conf;
+
+    $conf["backgroundColorIsDark"];
+    echo "<body ".(!visual::isMDColor() ? "style=\"background-color: ".security::htmlsafe($conf["backgroundColor"]).";\"" : "")."class=\"".(visual::isMDColor() ? "mdl-color--".security::htmlsafe($conf["backgroundColor"]) : "").($conf["backgroundColorIsDark"] ? " dark-background" : "")."\">";
+  }
+
+  // WARNING: We will not sanitize $msg, so sanitize it before calling this function!
+  public static function addTooltip($id, $msg) {
+    global $_tooltips;
+
+    if (!isset($_tooltips)) $_tooltips = "";
+    $_tooltips .= '<div class="mdl-tooltip" for="'.security::htmlsafe($id).'">'.$msg.'</div>';
+  }
+
+  public static function renderTooltips() {
+    global $_tooltips;
+
+    echo ($_tooltips ?? "");
+  }
+
+  private static function addMsgToUrl($url, $msg = false) {
+    if ($msg === false) return $url;
+    return $url.(preg_match("/\?/", $url) == 1 ? "&" : "?")."msg=".urlencode($msg);
+  }
+
+  public static function getContinueUrl($defaultUrl, $msg = false, $method = "GET") {
+    global $_GET, $_POST;
+
+    $url = "";
+
+    switch ($method) {
+      case "GET":
+      if (!isset($_GET["continue"])) return self::addMsgToUrl($defaultUrl, $msg);
+      $url = (string)$_GET["continue"];
+      break;
+
+      case "POST":
+      if (!isset($_POST["continue"])) return self::addMsgToUrl($defaultUrl, $msg);
+      $url = (string)$_POST["continue"];
+      break;
+
+      default:
+      return self::addMsgToUrl($defaultUrl, $msg);
+    }
+
+    if (!preg_match("/^[^\/\\\\]*$/", $url)) return self::addMsgToUrl($defaultUrl, $msg);
+
+    if ($msg !== false) $url = self::addMsgToUrl($url, $msg);
+
+    return $url;
+  }
+
+  public static function addContinueInput($url = false) {
+    global $_GET, $_POST;
+
+    if ($url === false) {
+      if (isset($_GET["continue"])) $url = $_GET["continue"];
+      elseif (isset($_POST["continue"])) $url = $_POST["continue"];
+      else return;
+    }
+
+    echo '<input type="hidden" name="continue" value="'.security::htmlsafe($url).'">';
+  }
+}
diff --git a/src/inc/workers.php b/src/inc/workers.php
new file mode 100644
index 0000000..6c6e1d9
--- /dev/null
+++ b/src/inc/workers.php
@@ -0,0 +1,171 @@
+<?php
+class workers {
+  const AFFILIATION_STATUS_NOTWORKING = 0;
+  const AFFILIATION_STATUS_WORKING = 1;
+  const AFFILIATION_STATUS_AUTO_NOTWORKING = 2;
+  const AFFILIATION_STATUS_AUTO_WORKING = 3;
+
+  public static $affiliationStatuses = [self::AFFILIATION_STATUS_NOTWORKING, self::AFFILIATION_STATUS_WORKING, self::AFFILIATION_STATUS_AUTO_NOTWORKING, self::AFFILIATION_STATUS_AUTO_WORKING];
+  public static $affiliationStatusesNotWorking = [self::AFFILIATION_STATUS_NOTWORKING, self::AFFILIATION_STATUS_AUTO_NOTWORKING];
+  public static $affiliationStatusesWorking = [self::AFFILIATION_STATUS_WORKING, self::AFFILIATION_STATUS_AUTO_WORKING];
+  public static $affiliationStatusesAutomatic = [self::AFFILIATION_STATUS_AUTO_WORKING, self::AFFILIATION_STATUS_AUTO_NOTWORKING];
+  public static $affiliationStatusesManual = [self::AFFILIATION_STATUS_WORKING, self::AFFILIATION_STATUS_NOTWORKING];
+
+  private static $return = "w.id id, w.person person, w.company company, c.name companyname, p.name name, p.dni dni";
+
+  public static function get($id) {
+    global $con;
+
+    $sid = (int)$id;
+
+    $query = mysqli_query($con, "SELECT ".self::$return."
+      FROM workers w
+      LEFT JOIN companies c ON w.company = c.id
+      LEFT JOIN people p ON w.person = p.id
+      WHERE w.id = $sid");
+
+    if (!mysqli_num_rows($query)) return false;
+
+    return mysqli_fetch_assoc($query);
+  }
+
+  public static function sqlAddonToGetStatusAttribute($id) {
+    $sid = (int)$id;
+
+    return "LEFT JOIN workhistory h ON w.id = h.worker
+    WHERE
+      w.person = $sid AND
+      (
+        (
+          SELECT COUNT(*)
+          FROM workhistory
+          WHERE worker = w.id AND day <= UNIX_TIMESTAMP()
+          LIMIT 1
+        ) = 0 OR
+
+        h.id = (
+          SELECT id
+          FROM workhistory
+          WHERE worker = w.id AND day <= UNIX_TIMESTAMP()
+          ORDER BY day DESC
+          LIMIT 1
+        )
+      )";
+  }
+
+  public static function getPersonWorkers($person, $simplify = false) {
+    global $con;
+
+    $query = mysqli_query($con, "SELECT ".($simplify ? "w.id id" : self::$return).", h.status status, h.day lastupdated
+      FROM workers w
+      LEFT JOIN companies c ON w.company = c.id
+      LEFT JOIN people p ON w.person = p.id
+      ".self::sqlAddonToGetStatusAttribute($person)."
+      ORDER BY
+        w.id ASC");
+
+    $results = [];
+
+    while ($row = mysqli_fetch_assoc($query)) {
+      $row["hidden"] = self::isHidden($row["status"]);
+      $results[] = ($simplify ? $row["id"] : $row);
+    }
+
+    return $results;
+  }
+
+  public static function isHidden($status) {
+    return (in_array($status, self::$affiliationStatusesWorking) ? 0 : 1);
+  }
+
+  public static function affiliationStatusHelper($status) {
+    return (self::isHidden($status) ? "Baja" : "Alta");
+  }
+
+  public static function affiliationStatusIcon($status) {
+    return (self::isHidden($status) ? "work_off" : "work");
+  }
+
+  public static function isAutomaticAffiliation($status) {
+    return in_array($status, self::$affiliationStatusesAutomatic);
+  }
+
+  public static function getWorkHistory($id) {
+    global $con;
+
+    $sid = (int)$id;
+
+    $query = mysqli_query($con, "SELECT * FROM workhistory WHERE worker = $sid ORDER BY day DESC");
+    if ($query === false) return false;
+
+    $items = [];
+    while ($row = mysqli_fetch_assoc($query)) {
+      $items[] = $row;
+    }
+
+    return $items;
+  }
+
+  public static function getWorkHistoryItem($id) {
+    global $con;
+
+    $sid = (int)$id;
+
+    $query = mysqli_query($con, "SELECT * FROM workhistory WHERE id = $sid");
+    if ($query === false || !mysqli_num_rows($query)) return false;
+
+    return mysqli_fetch_assoc($query);
+  }
+
+  public static function addWorkHistoryItem($id, $day, $status, $internal = false) {
+    global $con;
+
+    $sid = (int)$id;
+    $stime = (int)$day;
+    $sstatus = (int)$status;
+
+    if ((!$internal && !in_array($sstatus, self::$affiliationStatusesManual)) || ($internal && !in_array($affiliationStatuses))) return false;
+
+    if (!workers::exists($sid)) return false;
+
+    return mysqli_query($con, "INSERT INTO workhistory (worker, day, status) VALUES ($sid, $stime, $sstatus)");
+  }
+
+  public static function editWorkHistoryItem($id, $day, $status, $internal = false) {
+    global $con;
+
+    $sid = (int)$id;
+    $stime = (int)$day;
+    $sstatus = (int)$status;
+
+    if ((!$internal && !in_array($sstatus, self::$affiliationStatusesManual)) || ($internal && !in_array($affiliationStatuses))) return false;
+
+    if (!self::existsWorkHistoryItem($id)) return false;
+
+    return mysqli_query($con, "UPDATE workhistory SET day = $stime, status = $sstatus WHERE id = $sid LIMIT 1");
+  }
+
+  public static function deleteWorkHistoryItem($id) {
+    global $con;
+
+    $sid = (int)$id;
+
+    return mysqli_query($con, "DELETE FROM workhistory WHERE id = $sid LIMIT 1");
+  }
+
+  public static function exists($id) {
+    global $con;
+
+    $query = mysqli_query($con, "SELECT id FROM workers WHERE id = ".(int)$id);
+
+    return (mysqli_num_rows($query) > 0);
+  }
+
+  public static function existsWorkHistoryItem($id) {
+    global $con;
+
+    $query = mysqli_query($con, "SELECT 1 FROM workhistory WHERE id = ".(int)$id);
+
+    return (mysqli_num_rows($query) > 0);
+  }
+}