21 Commits

Author SHA1 Message Date
af938f405d Merge pull request 'fixed minor issue with remote duration conversion' (#1) from dev into main
Reviewed-on: #1
2025-02-23 20:47:53 +00:00
c090b611e2 fixed minor issue with remote duration conversion 2025-02-23 21:45:51 +01:00
9d33377ac1 added README.md + main.py 2025-02-15 12:20:52 +01:00
72ad6ec081 added 404 page + prepared for production setup 2025-02-14 16:11:03 +01:00
4f5f12473d completely removed weekly stats + added .gitignore 2025-02-14 15:05:09 +01:00
cce82f02af removed weekly stats 2025-02-14 15:02:16 +01:00
58a8ae750a fixed duration conversion 2025-02-12 13:53:17 +01:00
b24cb55ba8 added theory/real SageX hours difference row 2025-02-04 17:06:34 +01:00
eb0cab6295 added real sagex hours + changed to DurationField 2025-02-04 00:19:05 +01:00
6777207f4e reworked dashboard with table summary 2025-02-03 12:06:59 +01:00
aeed1f5fbe added new project form 2025-02-02 23:19:18 +01:00
092812b9b7 added project delete + changed to list template 2025-02-02 23:01:17 +01:00
35d5a81e6f added parent delete popup + endpoint 2025-02-02 22:43:52 +01:00
8b0b56cb7b added new parent page + minor css/js improvements 2025-02-02 21:48:12 +01:00
6ff3f05272 added parent is_productive field 2025-02-02 18:47:41 +01:00
3b4ae7a55a changed remote totals to traditional time 2025-02-02 17:34:29 +01:00
87607da643 added parent edit page 2025-02-02 15:33:11 +01:00
d13127a273 added clockings/remote weekly total + minor improvements 2025-02-02 15:15:25 +01:00
ee96598d92 added clockings edit + auto calculations 2025-02-02 13:33:56 +01:00
0380d18cd7 added table view 2025-01-28 21:14:15 +01:00
01db4285c2 added weekly view 2025-01-27 18:50:24 +01:00
47 changed files with 2172 additions and 197 deletions

3
.env.template Normal file
View File

@ -0,0 +1,3 @@
DJANGO_SECRET_KEY=
DJANGO_ENV=prod
DJANGO_HOSTS=localhost,127.0.0.1

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
__pycache__
/db.sqlite3
/.env

87
README.md Normal file
View File

@ -0,0 +1,87 @@
# Time Dispatcher
### Table of contents
<!-- TOC -->
* [Prerequisites](#prerequisites)
* [Installation](#installation)
* [Run the app](#run-the-app)
* [Production](#production)
* [Development](#development)
* [Updating](#updating)
<!-- TOC -->
---
**Time Dispatcher** is a QoL tool to help you track, analyze and allocate your time spent on various projects.
Here is a list of the main features available :
- Import tasks, projects and time imputations from CSV (compatible with [Super Productivity](https://github.com/johannesjo/super-productivity))
- Create parents to group multiple projects
- Track your clock-ins / -outs and/or remote work
- Mark each parent as either productive or not, i.e. whether its projects take a share of the "clocked" time
- Automatically compute the time that should be reported as spent on each productive parent (for example to input in SageX)
- View your monthly working times in a nice table or in a summarized form on the dashboard
## Prerequisites
- Python 3+
## Installation
1. Clone the repository
```shell
git clone https://git.kb28.ch/HEL/TimeDispatcher.git
```
2. Go inside the project directory
```shell
cd TimeDispatcher/
```
3. Install the Python requirements
```shell
pip install -r requirements.txt
```
4. Apply the database migrations
```shell
python manage.py migrate
```
5. Set the appropriate settings in the `.env` file:
1. Copy `.env.template` to `.env`
```shell
cp .env.template .env
```
2. Fill in the settings
- `DJANGO_SECRET_KEY`: The secret key used by Django.
This is a long random string.
For example, it can be generated using Python with
```python
import secrets
secrets.token_urlsafe(64)
```
- `DJANGO_ENV`: Current environment, either `prod` or `dev`
- `DJANGO_HOSTS`: Comma-separated list of allowed hosts used by Django
## Run the app
### Production
1. Start the Uvicorn server
```shell
python main.py
```
2. Open [the app](http://localhost:8000/) in your browser
### Development
1. Start the Django server
```shell
python manage.py runserver
```
2. Open [the app](http://localhost:8000/) in your browser
## Updating
(Before updating to a new version, it is recommended to back up the database file, i.e. `db.sqlite3`)
To update to a new version:
1. Download the new sources
- from the [release page](https://git.kb28.ch/HEL/TimeDispatcher/releases)
- or pull the new version with git
2. Run the database migrations:
```shell
python manage.py migrate
```

View File

@ -0,0 +1,22 @@
from datetime import datetime
class DateConverter:
regex = r"\d{4}-\d{1,2}-\d{1,2}"
format = r"%Y-%m-%d"
def to_python(self, value):
return datetime.strptime(value, self.format).date()
def to_url(self, value):
return value.strftime(self.format)
class YearMonthConverter:
regex = r"\d{4}-\d{1,2}"
format = r"%Y-%m"
def to_python(self, value):
return datetime.strptime(value, self.format).date()
def to_url(self, value):
return value.strftime(self.format)

View File

@ -9,25 +9,28 @@ https://docs.djangoproject.com/en/5.1/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.1/ref/settings/
"""
import os
from pathlib import Path
from dotenv import load_dotenv
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
APP_VERSION = "0.0.1"
APP_VERSION = "0.1.0"
load_dotenv(BASE_DIR / ".env")
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-^*x5i_#=$9kuj6k^v0cy5dqefmzo%j*i&0w93i%!zmgsa_z)2z'
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
DEBUG = os.environ.get("DJANGO_ENV", "dev").lower() != "prod"
ALLOWED_HOSTS = []
ALLOWED_HOSTS = list(map(lambda h: h.strip(), os.environ.get("DJANGO_HOSTS", "localhost,127.0.0.1").split(",")))
# Application definition
@ -57,8 +60,7 @@ ROOT_URLCONF = 'TimeDispatcher.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'templates']
,
'DIRS': [BASE_DIR / 'templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
@ -111,7 +113,7 @@ AUTH_PASSWORD_VALIDATORS = [
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
TIME_ZONE = 'Europe/Zurich'
USE_I18N = True
@ -121,7 +123,12 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.1/howto/static-files/
STATICFILES_DIRS = [
BASE_DIR / "dispatcher" / "static"
]
STATIC_URL = 'static/'
_STATIC_ROOT = BASE_DIR / "dispatcher" / "static"
# Default primary key field type
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field

View File

@ -14,17 +14,34 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path
from django.urls import path, register_converter, re_path
from django.views.static import serve
from TimeDispatcher import settings
from TimeDispatcher.converters import DateConverter, YearMonthConverter
from dispatcher import views
register_converter(DateConverter, "date")
register_converter(YearMonthConverter, "year_month")
urlpatterns = [
path("", views.dashboard_view, name="dashboard"),
path("import/", views.import_view, name="import"),
path("projects/", views.projects_view, name="projects"),
path("projects/", views.ProjectsView.as_view(), name="projects"),
path("parents/", views.ParentsView.as_view(), name="parents"),
path("table/<date:start_date>/<date:end_date>/", views.get_table_data, name="table_data"),
path("table/", views.table_view, name="table"),
path("parents/new/", views.new_parent_view, name="new_parent"),
path("parents/<int:id>/on_delete/", views.parent_on_delete_view, name="parent_on_delete"),
path("parents/<int:id>/", views.parent_view, name="parent"),
path("projects/new/", views.new_project_view, name="new_project"),
path("projects/<int:id>/on_delete/", views.project_on_delete_view, name="project_on_delete"),
path("projects/<int:id>/set_parent/", views.set_parent, name="set_parent"),
path("projects/<int:id>/", views.project_view, name="project"),
path("projects/<int:id>/set_parent", views.set_parent, name="set_parent"),
path("stats/by-month/<int:year>/<int:month>/", views.get_stats_by_month, name="stats_by_month"),
path("clockings/<date:date>/", views.set_clocking, name="set_clocking"),
path("sagex/<int:id>/<year_month:month>/", views.set_real_sagex, name="set_real_sagex"),
]
if not settings.DEBUG:
urlpatterns.append(re_path(r"^static/(?P<path>.*)$", serve, {"document_root": settings._STATIC_ROOT}))

View File

@ -1,8 +1,9 @@
from TimeDispatcher.settings import APP_VERSION
from TimeDispatcher.settings import APP_VERSION, DEBUG
def version(request):
return {
"debug": DEBUG,
"version": APP_VERSION
}
@ -10,6 +11,7 @@ def navbar_links(request):
return {
"nav_links": [
{'view': 'dashboard', 'label': 'Dashboard'},
{'view': 'table', 'label': 'Table'},
{'view': 'projects', 'label': 'Projects'},
{'view': 'parents', 'label': 'Parents'},
{'view': 'import', 'label': 'Import'},

View File

@ -1,6 +1,22 @@
import datetime
from datetime import timedelta
from dispatcher.models import Task, Project
def convert_timedelta(td: datetime.timedelta):
total_seconds = td.total_seconds()
hours, remainder = divmod(total_seconds, 3600)
minutes, seconds = divmod(remainder, 60)
return f"{int(hours):02d}:{int(minutes):02d}:{int(seconds):02d}"
def str_to_timedelta(duration: str):
parts = duration.split(":")
hours = int(parts[0])
minutes = int(parts[1])
return timedelta(hours=hours, minutes=minutes)
def import_tasks(csv_content: str):
tasks = []
for line in csv_content.splitlines()[1:]:

View File

@ -1,8 +1,13 @@
from django import forms
from dispatcher.models import Project
from dispatcher.models import Project, Parent
class ParentForm(forms.ModelForm):
class Meta:
model = Parent
fields = "__all__"
class ProjectForm(forms.ModelForm):
class Meta:
model = Project

View File

@ -0,0 +1,25 @@
# Generated by Django 5.1.5 on 2025-01-31 17:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcher', '0004_task_unique_daily_task'),
]
operations = [
migrations.CreateModel(
name='Clocking',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('in_am', models.TimeField(default=None, null=True)),
('out_am', models.TimeField(default=None, null=True)),
('in_pm', models.TimeField(default=None, null=True)),
('out_pm', models.TimeField(default=None, null=True)),
('remote', models.TimeField(default=None, null=True)),
],
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.1.5 on 2025-02-02 14:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcher', '0005_clocking'),
]
operations = [
migrations.AlterField(
model_name='parent',
name='project_num',
field=models.CharField(blank=True, max_length=32),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.1.5 on 2025-02-02 16:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcher', '0006_alter_parent_project_num'),
]
operations = [
migrations.AddField(
model_name='parent',
name='is_productive',
field=models.BooleanField(default=False),
),
]

View File

@ -0,0 +1,38 @@
# Generated by Django 5.1.5 on 2025-02-02 17:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcher', '0007_parent_is_productive'),
]
operations = [
migrations.AlterField(
model_name='clocking',
name='in_am',
field=models.TimeField(default=None, null=True, verbose_name='Clock in AM'),
),
migrations.AlterField(
model_name='clocking',
name='in_pm',
field=models.TimeField(default=None, null=True, verbose_name='Clock in PM'),
),
migrations.AlterField(
model_name='clocking',
name='out_am',
field=models.TimeField(default=None, null=True, verbose_name='Clock out AM'),
),
migrations.AlterField(
model_name='clocking',
name='out_pm',
field=models.TimeField(default=None, null=True, verbose_name='Clock out PM'),
),
migrations.AlterField(
model_name='parent',
name='project_num',
field=models.CharField(blank=True, max_length=32, verbose_name='Project number'),
),
]

View File

@ -0,0 +1,66 @@
# Generated by Django 5.1.5 on 2025-02-03 17:53
import datetime
from datetime import timedelta
import django.db.models.deletion
from django.db import migrations, models
def time_to_duration(apps, schema_editor):
Clocking = apps.get_model("dispatcher", "Clocking")
for clocking in Clocking.objects.all().iterator():
if clocking.old_remote:
remote: datetime.time = clocking.old_remote
clocking.remote = timedelta(hours=remote.hour, minutes=remote.minute)
else:
clocking.remote = timedelta()
clocking.save()
def duration_to_time(apps, schema_editor):
Clocking = apps.get_model("dispatcher", "Clocking")
for clocking in Clocking.objects.all().iterator():
if clocking.remote.total_seconds() != 0:
remote: datetime.timedelta = clocking.remote
total_seconds = remote.total_seconds()
hours, remainder = divmod(total_seconds, 3600)
minutes, seconds = divmod(remainder, 60)
clocking.old_remote = datetime.time(int(hours), int(minutes), int(seconds))
else:
clocking.old_remote = None
clocking.save()
class Migration(migrations.Migration):
dependencies = [
('dispatcher', '0008_alter_clocking_in_am_alter_clocking_in_pm_and_more'),
]
operations = [
migrations.RenameField(
model_name='clocking',
old_name='remote',
new_name='old_remote'
),
migrations.AddField(
model_name='clocking',
name='remote',
field=models.DurationField(default=datetime.timedelta),
),
migrations.RunPython(time_to_duration, duration_to_time),
migrations.RemoveField(
model_name='clocking',
name='old_remote'
),
migrations.CreateModel(
name='RealSageXHours',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('hours', models.DurationField(default=datetime.timedelta)),
('parent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dispatcher.parent')),
],
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 5.1.5 on 2025-02-03 18:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcher', '0009_alter_clocking_remote_realsagexhours'),
]
operations = [
migrations.AddConstraint(
model_name='realsagexhours',
constraint=models.UniqueConstraint(fields=('parent', 'date'), name='unique_monthly_sagex'),
),
]

View File

@ -1,8 +1,11 @@
from datetime import timedelta
from django.db import models
class Parent(models.Model):
project_num = models.CharField(max_length=32)
project_num = models.CharField(max_length=32, blank=True, verbose_name="Project number")
name = models.CharField(max_length=256)
is_productive = models.BooleanField(default=False)
def __str__(self):
return self.name
@ -30,3 +33,23 @@ class Task(models.Model):
constraints = [
models.UniqueConstraint(fields=["date", "project", "name"], name="unique_daily_task")
]
class Clocking(models.Model):
date = models.DateField()
in_am = models.TimeField(null=True, default=None, verbose_name="Clock in AM")
out_am = models.TimeField(null=True, default=None, verbose_name="Clock out AM")
in_pm = models.TimeField(null=True, default=None, verbose_name="Clock in PM")
out_pm = models.TimeField(null=True, default=None, verbose_name="Clock out PM")
remote = models.DurationField(default=timedelta)
class RealSageXHours(models.Model):
parent = models.ForeignKey(Parent, on_delete=models.CASCADE)
date = models.DateField()
hours = models.DurationField(default=timedelta)
class Meta:
constraints = [
models.UniqueConstraint(fields=["parent", "date"], name="unique_monthly_sagex")
]

43
dispatcher/serializers.py Normal file
View File

@ -0,0 +1,43 @@
from datetime import datetime, timedelta, time
from rest_framework import serializers
from rest_framework.serializers import ModelSerializer
from dispatcher.core import convert_timedelta
from dispatcher.models import Clocking, RealSageXHours
class ClockingSerializer(ModelSerializer):
total = serializers.SerializerMethodField()
class Meta:
model = Clocking
fields = "__all__"
def get_total(self, obj: Clocking):
total = timedelta()
if obj.in_am is not None and obj.out_am is not None:
in_am = datetime.combine(obj.date, obj.in_am)
out_am = datetime.combine(obj.date, obj.out_am)
total += out_am - in_am
if obj.in_pm is not None and obj.out_pm is not None:
in_pm = datetime.combine(obj.date, obj.in_pm)
out_pm = datetime.combine(obj.date, obj.out_pm)
total += out_pm - in_pm
if obj.remote is not None:
total += obj.remote
seconds = total.seconds
minutes = seconds // 60
return minutes
class RealSageXHoursSerializer(ModelSerializer):
hours = serializers.SerializerMethodField()
class Meta:
model = RealSageXHours
fields = "__all__"
def get_hours(self, obj: RealSageXHours):
return convert_timedelta(obj.hours)

View File

@ -7,6 +7,8 @@
--light2: #e5e5e5;
--light3: #cccccc;
--light4: #c5c5c5;
--accent: #69c935;
--accent2: #61ba31;
}
* {
@ -43,6 +45,16 @@ nav a {
transition: background-color 0.2s;
}
nav a.logo {
padding-top: 0;
padding-bottom: 0;
}
nav a.logo img {
object-fit: contain;
height: 2em;
width: 2em;
}
nav a.active {
box-shadow: 0 -2px var(--light1) inset;
}
@ -65,11 +77,18 @@ footer .sep {
height: 1em;
}
footer .debug {
margin-right: auto;
font-style: italic;
color: #ffb33d;
}
main {
flex-grow: 1;
display: flex;
flex-direction: column;
padding: 2em;
overflow-y: auto;
}
button, input, select {
@ -95,5 +114,9 @@ button, select {
}
a {
color: #69c935
color: var(--accent);
}
ul {
padding-left: 2em;
}

View File

@ -0,0 +1,93 @@
const SEC_MS = 1000
const MIN_MS = SEC_MS * 60
const HOUR_MS = MIN_MS * 60
const DAY_MS = HOUR_MS * 24
const DAYS_SHORT = [
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun"
]
const MONTHS = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December"
]
//////////////////////////////////////////////////////////
// //
// Taken from: https://weeknumber.com/how-to/javascript //
// //
//////////////////////////////////////////////////////////
Date.prototype.getWeek = function() {
let date = new Date(this.getTime())
date.setHours(0, 0, 0, 0)
// Thursday in current week decides the year.
date.setDate(date.getDate() + 3 - (date.getDay() + 6) % 7)
// January 4 is always in week 1.
let week1 = new Date(date.getFullYear(), 0, 4)
// Adjust to Thursday in week 1 and count number of weeks from date to week1.
return 1 + Math.round(((date.getTime() - week1.getTime()) / 86400000 - 3 + (week1.getDay() + 6) % 7) / 7)
}
function formatDate(date) {
let year = date.getFullYear().toString().padStart(4, "0")
let month = (date.getMonth() + 1).toString().padStart(2, "0")
let day = date.getDate().toString().padStart(2, "0")
return `${year}-${month}-${day}`
}
function formatDuration(duration) {
let res = ""
if (duration < 0) {
duration *= -1
res += "-"
}
let hours = Math.floor(duration / 60)
duration -= hours * 60
duration = Math.round(duration)
if (hours > 0) {
res += hours.toString() + "h"
res += duration.toString().padStart(2, "0")
} else {
res += duration.toString() + "min"
}
return res
}
function formatPercentage(ratio) {
let percentage = Math.round(ratio * 100)
return percentage + "%"
}
function req(url, options = {}) {
let headers = options.headers || {}
let csrftoken = document.querySelector("input[name='csrfmiddlewaretoken']").value
headers["X-CSRFToken"] = csrftoken
options.headers = headers
return fetch(url, options)
}
function getTemplate(cls) {
let elmt = document.querySelector(".template." + cls).cloneNode(true)
elmt.classList.remove("template")
return elmt
}

View File

@ -1,4 +1,4 @@
.monthly {
.by-range {
display: flex;
flex-direction: column;
padding: 1.2em;
@ -6,13 +6,23 @@
gap: 1.2em;
}
.monthly .controls {
.by-range .controls {
display: flex;
gap: 1.2em;
gap: 2.4em;
align-items: center;
}
.monthly .controls button {
.by-range .controls .group {
display: flex;
gap: 0.8em;
align-items: center;
}
.by-range .controls .group.hidden {
display: none;
}
.by-range .controls button, .by-range .controls select {
background-color: var(--dark3);
color: var(--light1);
padding: 0.4em 0.8em;
@ -20,25 +30,93 @@
cursor: pointer;
}
.monthly .controls button:hover {
.by-range .controls button:hover, .by-range .controls select:hover {
background-color: var(--dark4);
}
.monthly #list {
.by-range .tables {
display: flex;
flex-direction: column;
gap: 0.8em;
}
.monthly #list .row {
display: flex;
gap: 1.2em;
padding: 0.4em 0.8em;
background-color: var(--dark3);
.by-range .tables table {
border-collapse: collapse;
}
.monthly #list .no-data {
font-style: italic;
padding: 0.4em 0.8em;
background-color: var(--dark2);
.by-range .tables tr {
height: 2em;
}
.by-range .tables td {
padding: 0 0.4em;
min-width: 3em;
text-align: right;
}
#headers-table th {
text-align: right;
padding: 0 0.8em;
}
#projects-table th {
text-align: center;
padding: 0 0.4em;
}
.by-range tr.project-nums {
border-bottom: solid var(--light1) 2px;
}
#projects-table tr.project-nums td {
text-align: center;
}
#projects-table th, #projects-table td {
border-left: solid var(--light4) 1px;
}
.by-range tr.real-sagex-hours .sagex-hours {
height: 2em;
input {
max-width: 3em;
background: none;
color: var(--light1);
&:focus-visible {
outline: solid var(--dark4) 1px;
}
&::-webkit-inner-spin-button {
display: none;
}
&.hours {
text-align: right;
}
&.minutes {
text-align: left;
max-width: 2em;
}
}
&.saved {
input {
animation-name: sagex-saved;
animation-duration: 1s;
}
}
}
@keyframes sagex-saved {
0% {
}
10%, 60% {
border-color: var(--accent);
}
100% {
}
}

View File

@ -1,34 +1,7 @@
let prevBtn, nextBtn, month
let prevMonthBtn, nextMonthBtn, month
let curYear = new Date().getFullYear()
let curMonth = new Date().getMonth()
const MONTHS = [
"Janvier",
"Février",
"Mars",
"Avril",
"Mai",
"Juin",
"Juillet",
"Août",
"Septembre",
"Octobre",
"Novembre",
"Décembre"
]
function formatDuration(duration) {
let hours = Math.floor(duration / 60)
duration -= hours * 60
let res = ""
if (hours > 0) {
res += hours.toString() + "h"
res += duration.toString().padStart(2, "0")
} else {
res += duration.toString() + "min"
}
return res
}
let curWeekDate = new Date()
function prevMonth() {
curMonth = curMonth - 1
@ -36,7 +9,7 @@ function prevMonth() {
curMonth += 12
curYear -= 1
}
updateList()
updateTableMonthly()
}
function nextMonth() {
curMonth = curMonth + 1
@ -44,10 +17,10 @@ function nextMonth() {
curMonth -= 12
curYear += 1
}
updateList()
updateTableMonthly()
}
function updateList() {
function updateTableMonthly() {
let year = curYear.toString().padStart(4, "0")
let month = (curMonth + 1).toString().padStart(2, "0")
let today = new Date()
@ -63,32 +36,152 @@ function updateList() {
if (res.status !== "success") {
return
}
let data = res.data.filter(parent => parent.duration !== null)
let list = document.getElementById("list")
let template = document.querySelector(".monthly .template.row").cloneNode(true)
template.classList.remove("template")
list.innerHTML = ""
if (data.length === 0) {
let noData = document.querySelector(".monthly .template.no-data").cloneNode(true)
noData.classList.remove("template")
list.appendChild(noData)
return
}
data.forEach(parent => {
let row = template.cloneNode(true)
row.querySelector(".name").innerText = `${parent.name} (${parent.project_num})`
row.querySelector(".duration").innerText = formatDuration(parent.duration)
list.appendChild(row)
updateTable(res.data)
})
}
function updateTable(data) {
let totalWorked = data.clockings.map(c => c.total).reduce((a, b) => a + b, 0)
let parents = data.parents.filter(parent => parent.duration !== null && parent.is_productive)
let headers = document.getElementById("headers-table")
let table = document.getElementById("projects-table")
let projNames = table.querySelector(".project-names")
let projNums = table.querySelector(".project-nums")
let totDurations = table.querySelector(".total-durations")
let workingRatios = table.querySelector(".working-time-ratios")
let imputedRatios = table.querySelector(".imputed-time-ratios")
let sagexHours = table.querySelector(".sagex-hours")
let realSagexHours = table.querySelector(".real-sagex-hours")
let differences = table.querySelector(".sagex-diff")
table.querySelectorAll("tr").forEach(row => row.innerHTML = "")
let totalImputed = 0
parents.forEach(parent => {
totalImputed += +parent.duration
})
headers.querySelector(".total-clocking").innerText = formatDuration(totalWorked)
headers.querySelector(".total-duration").innerText = formatDuration(totalImputed)
let sagexHoursTemplate = getTemplate("sagex-hours")
let totalSagex = 0
let totalRealSagex = 0
parents.forEach(parent => {
let duration = +parent.duration
let name = document.createElement("th")
name.innerText = parent.name
projNames.appendChild(name)
projNums.insertCell(-1).innerText = parent.project_num
table.querySelector(".clocking").insertCell(-1)
totDurations.insertCell(-1).innerText = formatDuration(duration)
let ratioWorking = duration / totalWorked
let ratioImputed = duration / totalImputed
workingRatios.insertCell(-1).innerText = formatPercentage(ratioWorking)
imputedRatios.insertCell(-1).innerText = formatPercentage(ratioImputed)
let sagexDuration = duration * totalWorked / totalImputed
totalSagex += sagexDuration
let sagexCell = sagexHours.insertCell(-1)
sagexCell.innerText = formatDuration(sagexDuration)
sagexCell.dataset.duration = sagexDuration
let td = sagexHoursTemplate.cloneNode(true)
let [h, m, s] = parent.real_sagex_hours.split(":")
let hoursInput = td.querySelector("input.hours")
let minutesInput = td.querySelector("input.minutes")
hoursInput.value = h
minutesInput.value = m
let timeout = null
minutesInput.addEventListener("input", () => reformatTime(minutesInput))
td.addEventListener("change", () => {
if (timeout !== null) {
clearTimeout(timeout)
}
timeout = setTimeout(() => {
updateSagex(parent.id, td)
}, 1000)
})
realSagexHours.appendChild(td)
let real = (+h)*60 + (+m)
let diff = real - sagexDuration
differences.insertCell(-1).innerText = formatDuration(diff)
totalRealSagex += real
})
headers.querySelector(".sagex-hours-total").innerText = formatDuration(totalSagex)
headers.querySelector(".real-sagex-hours-total").innerText = formatDuration(totalRealSagex)
headers.querySelector(".sagex-diff-total").innerText = formatDuration(totalRealSagex - totalSagex)
}
function reformatTime(input) {
let value = input.value
if (value.length === 1) {
input.value = "0" + value
} else if (value.length > 2) {
input.value = value.slice(value.length - 2)
}
}
function getDuration(td) {
let hours = +td.querySelector(".hours").value
let minutes = +td.querySelector(".minutes").value
return hours * 60 + minutes
}
function updateSagex(id, cell) {
let minutesInput = cell.querySelector(".minutes")
reformatTime(minutesInput)
let newDuration = getDuration(cell)
let year = curYear.toString().padStart(4, "0")
let month = (curMonth + 1).toString().padStart(2, "0")
let date = `${year}-${month}`
let fd = new FormData()
fd.set("minutes", newDuration)
req(`/sagex/${id}/${date}/`, {
method: "POST",
body: fd
}).then(res => {
return res.json()
}).then(res => {
if (res.status === "success") {
cell.classList.add("saved")
cell.addEventListener("animationend", () => {
cell.classList.remove("saved")
}, {once: true})
let theoreticalRow = document.querySelector("#projects-table tr.sagex-hours")
let realRow = document.querySelector("#projects-table tr.real-sagex-hours")
let diffRow = document.querySelector("#projects-table tr.sagex-diff")
let totalRealCell = document.querySelector("#headers-table tr.real-sagex-hours .real-sagex-hours-total")
let totalDiffCell = document.querySelector("#headers-table tr.sagex-diff .sagex-diff-total")
let durationsTheory = Array.from(theoreticalRow.cells).map(c => +c.dataset.duration)
let durationsReal = Array.from(realRow.cells).map(getDuration)
let totalTheory = durationsTheory.reduce((a, b) => a + b, 0)
let totalReal = durationsReal.reduce((a, b) => a + b, 0)
let totalDiff = totalReal - totalTheory
totalRealCell.innerText = formatDuration(totalReal)
totalDiffCell.innerText = formatDuration(totalDiff)
let theory = +theoreticalRow.cells[cell.cellIndex].dataset.duration
let diff = newDuration - theory
diffRow.cells[cell.cellIndex].innerText = formatDuration(diff)
} else {
alert(res.error)
}
})
}
window.addEventListener("load", () => {
prevBtn = document.getElementById("prev")
nextBtn = document.getElementById("next")
prevMonthBtn = document.getElementById("prev-month")
nextMonthBtn = document.getElementById("next-month")
month = document.getElementById("month")
prevBtn.addEventListener("click", () => prevMonth())
nextBtn.addEventListener("click", () => nextMonth())
updateList()
prevMonthBtn.addEventListener("click", () => prevMonth())
nextMonthBtn.addEventListener("click", () => nextMonth())
updateTableMonthly()
})

View File

@ -0,0 +1,20 @@
.wrapper {
margin: auto;
max-width: 25em;
width: 100%;
text-align: center;
display: flex;
flex-direction: column;
gap: 2em;
}
.desc {
line-height: 1.4;
}
.url {
font-family: monospace;
background-color: rgba(255, 255, 255, 0.11);
padding: 0.1em 0.4em;
border-radius: 4px;
}

View File

@ -0,0 +1,53 @@
.container {
display: flex;
flex-direction: column;
width: 100%;
max-width: 30em;
align-self: center;
gap: 2em;
}
form {
display: flex;
flex-direction: column;
gap: 1.6em;
}
form > div {
display: flex;
flex-direction: column;
gap: 0.4em;
}
form label {
font-style: italic;
color: var(--light2);
}
form button {
align-self: center;
padding: 0.4em 1.2em;
}
form input[type="checkbox"] {
width: 1.6em;
height: 1.6em;
border: solid var(--light1) 2px;
appearance: none;
background: none;
border-radius: 0;
position: relative;
cursor: pointer;
}
form input[type="checkbox"]:checked::after {
content: "";
background-color: var(--accent);
width: 80%;
height: 80%;
border-radius: 10%;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}

View File

@ -1,10 +1,29 @@
.list {
display: flex;
.list-wrapper {
width: 100%;
max-width: 40em;
align-self: center;
display: flex;
flex-direction: column;
gap: 1em;
overflow-y: auto;
}
.list-header {
display: flex;
justify-content: flex-end;
gap: 1em;
}
.list-header button {
padding: 0.4em 1.2em;
}
.list {
display: flex;
gap: 0.8em;
flex-direction: column;
padding: 0;
overflow-y: auto;
}
.list li {
@ -13,8 +32,66 @@
marker: none;
gap: 0.8em;
border: solid var(--dark4) 1px;
align-items: center;
}
.list li .actions {
margin-left: auto;
}
.popup {
background-color: #00000040;
position: fixed;
inset: 0;
display: grid;
place-items: center;
}
.popup:not(.show) {
display: none;
}
.popup .popup-container {
display: flex;
flex-direction: column;
gap: 1em;
padding: 2em;
background-color: var(--dark1);
border-radius: 1em;
width: 100%;
max-width: 40em;
box-shadow: 0 0.2em 0.8em var(--dark3);
}
.popup .popup-container .elmt-name {
text-decoration: underline;
}
.popup .popup-container .actions {
display: flex;
justify-content: space-evenly;
gap: 1em;
}
.popup .popup-container .actions button {
padding: 0.4em 1.2em;
}
.popup .popup-container .actions .cancel {
background-color: var(--light2);
}
.popup .popup-container .actions .cancel:hover {
background-color: var(--light3);
}
.popup .popup-container .actions .delete {
--col: #f95f4b;
color: var(--col);
border-color: var(--col);
background-color: var(--dark1);
}
.popup .popup-container .actions .delete:hover {
background-color: var(--dark2);
}

55
dispatcher/static/list.js Normal file
View File

@ -0,0 +1,55 @@
function showDeletePopup(id, name) {
let popup = document.getElementById("delete-popup")
popup.dataset.id = id
popup.querySelectorAll(".elmt-name").forEach(elmt => {
elmt.innerText = name
})
popup.classList.add("show")
}
function deleteElement(id) {
let url = window.location.href
if (!url.endsWith("/")) {
url += "/"
}
url += `${id}/`
req(url, {
method: "DELETE"
}).then(res => {
return res.json()
}).then(res => {
if (res.status === "success") {
window.location.reload()
}
})
}
let onBeforeDelete = null
window.addEventListener("load", () => {
document.querySelector("button.new").addEventListener("click", () => {
window.location.href = "new/"
})
document.querySelectorAll(".list li").forEach(row => {
let id = row.dataset.id
row.querySelector("button.edit")?.addEventListener("click", () => {
window.location.href = `${id}/`
})
row.querySelector("button.delete")?.addEventListener("click", async () => {
if (onBeforeDelete) {
await onBeforeDelete(row)
}
showDeletePopup(id, row.dataset.name)
})
})
let deletePopup = document.getElementById("delete-popup")
deletePopup.querySelector(".actions .cancel").addEventListener("click", () => {
deletePopup.classList.remove("show")
})
deletePopup.querySelector(".actions .delete").addEventListener("click", () => {
deleteElement(deletePopup.dataset.id)
})
})

BIN
dispatcher/static/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,99 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="64"
height="64"
viewBox="0 0 64 64"
version="1.1"
id="svg1"
sodipodi:docname="logo.svg"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="true"
inkscape:zoom="11.559708"
inkscape:cx="31.012894"
inkscape:cy="41.047749"
inkscape:window-width="1920"
inkscape:window-height="1016"
inkscape:window-x="0"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:current-layer="layer1">
<inkscape:grid
id="grid1"
units="px"
originx="0"
originy="0"
spacingx="1"
spacingy="1"
empcolor="#0099e5"
empopacity="0.30196078"
color="#0099e5"
opacity="0.14901961"
empspacing="8"
enabled="true"
visible="true" />
</sodipodi:namedview>
<defs
id="defs1">
<inkscape:path-effect
effect="fillet_chamfer"
id="path-effect3"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,1,1,0,2,0,1 @ F,0,1,1,0,2,0,1 @ F,0,1,1,0,2,0,1 @ F,0,1,1,0,2,0,1 @ F,0,1,1,0,2,0,1 @ F,0,0,1,0,2,0,1 @ F,0,1,1,0,2,0,1 @ F,0,1,1,0,2,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" />
</defs>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g4">
<path
style="fill:#69c935;fill-opacity:1;stroke-width:6;stroke-linecap:square;stroke:none"
d="m 8,10 v 4 a 2,2 45 0 0 2,2 h 7 a 2,2 45 0 1 2,2 v 36 a 2,2 45 0 0 2,2 h 4 a 2,2 135 0 0 2,-2 V 18 a 2,2 135 0 1 2,-2 h 7 a 2,2 135 0 0 2,-2 V 10 A 2,2 45 0 0 36,8 H 10 a 2,2 135 0 0 -2,2 z"
id="path1"
sodipodi:nodetypes="ccccccccc"
inkscape:path-effect="#path-effect3"
inkscape:original-d="m 8,8 v 8 h 11 v 40 h 8 V 16 H 38 V 8 Z" />
<g
id="g3"
style="stroke:#61ba31;stroke-opacity:1;fill:none">
<path
id="path2"
style="fill:none;stroke:#61ba31;stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
d="M 44.834929,13.274537 C 51.52452,17.296808 56,24.625752 56,33 56,45.702549 45.702549,56 33,56"
sodipodi:nodetypes="csc" />
<path
style="fill:none;stroke:#61ba31;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="M 44,47 33,33 38,26"
id="path3" />
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -0,0 +1,3 @@
.productive {
box-shadow: 0.2em 0 0 var(--accent) inset;
}

View File

@ -0,0 +1,11 @@
onBeforeDelete = async row => {
await req(`${row.dataset.id}/on_delete/`).then(res => {
return res.json()
}).then(res => {
if (res.status === "success") {
let popup = document.getElementById("delete-popup")
popup.querySelector(".project-count").innerText = res.projects
popup.querySelector(".task-count").innerText = res.tasks
}
})
}

View File

@ -1,4 +1,8 @@
.productive {
box-shadow: 0.2em 0 0 var(--accent) inset;
}
/*
.projects {
width: 100%;
max-width: 40em;
@ -21,4 +25,5 @@
.projects tbody tr:nth-child(even) {
background-color: var(--dark2);
}
}
*/

View File

@ -1,22 +1,25 @@
onBeforeDelete = async row => {
await req(`${row.dataset.id}/on_delete/`).then(res => {
return res.json()
}).then(res => {
if (res.status === "success") {
let popup = document.getElementById("delete-popup")
popup.querySelector(".task-count").innerText = res.tasks
}
})
}
window.addEventListener("load", () => {
document.querySelectorAll(".projects tbody tr").forEach(row => {
document.querySelectorAll(".list li").forEach(row => {
let id = row.dataset.id
let selector = row.querySelector(".parent-sel")
selector.addEventListener("change", () => {
let fd = new FormData()
fd.set("parent_id", selector.value)
let csrftoken = document.querySelector("input[name='csrfmiddlewaretoken']").value
fetch(`${id}/set_parent`, {
req(`${id}/set_parent/`, {
method: "POST",
body: fd,
headers: {
"X-CSRFToken": csrftoken
}
body: fd
})
})
row.querySelector("button.see").addEventListener("click", () => {
window.location.href = `${id}/`
})
})
})

161
dispatcher/static/table.css Normal file
View File

@ -0,0 +1,161 @@
#table {
border-collapse: collapse;
font-size: 80%;
table-layout: auto;
width: 100%;
--border: solid var(--light1) 1px;
th {
font-weight: bold;
padding: 0.4em 0.8em;
}
.clockings {
.dates {
th {
text-align: center;
}
}
.clocking {
th:first-child {
text-align: right;
}
.total-header {
writing-mode: vertical-lr;
transform: rotate(180deg);
padding: 0.8em 0.4em;
}
.time-input {
max-width: 4em; /* not perfect */
input {
width: 100%;
text-align: right;
background: none;
color: var(--light1);
}
input[type="time"]::-webkit-calendar-picker-indicator {
display: none;
}
}
&.total {
text-align: right;
border-top: solid #71717185 1px;
td {
padding: 0.4em;
}
}
&.total2, &.remote-total {
text-align: center;
td {
padding: 0.4em;
}
}
}
}
.week-names {
text-align: center;
font-weight: bold;
td {
padding: 0.4em;
&:not(:first-child) {
border: var(--border);
}
}
}
.day-names, .day-dates {
text-align: center;
td {
padding: 0.2em;
}
}
.day-dates {
border-bottom: var(--border);
}
col.day-5, col.day-6 {
background-color: var(--dark3);
}
colgroup.tailers, col.day-0 {
border-left: var(--border);
}
colgroup.headers, col.day-6 {
border-right: solid var(--light1) 1px;
}
.times .project td:nth-child(2) {
text-align: center;
}
.times {
td {
padding: 0.4em;
}
tr {
&.project {
border-top: solid #71717185 1px;
td {
text-align: right;
&:first-child {
text-align: left;
&:hover, &:hover ~ td {
background-color: rgba(255, 0, 0, 0.2);
}
}
&:nth-child(2) {
text-align: center;
}
}
}
&.parent {
border-top: var(--border);
td {
font-weight: bold;
}
}
}
}
.separator {
height: 4em;
}
}
.controls {
display: flex;
gap: 0.8em;
align-items: center;
margin-bottom: 1em;
button {
background-color: var(--dark3);
color: var(--light1);
padding: 0.4em 0.8em;
border: none;
cursor: pointer;
&:hover {
background-color: var(--dark4);
}
}
}

362
dispatcher/static/table.js Normal file
View File

@ -0,0 +1,362 @@
let table
let prevMonthBtn, nextMonthBtn, month
class Table {
constructor(elmt) {
this.table = elmt
this.clockings = elmt.querySelector(".clockings")
this.times = elmt.querySelector(".times")
this.weekNames = elmt.querySelector(".week-names")
this.dayNames = elmt.querySelector(".day-names")
this.dayDates = elmt.querySelector(".day-dates")
this.columns = elmt.querySelector(".columns")
this.timeInputTemplate = getTemplate("time-input")
this.nDays = 0
let today = new Date()
this.curMonth = today.getMonth()
this.curYear = today.getFullYear()
this.startDate = today
this.endDate = today
this.totalProjects = 0
this.clockingTotals = []
this.clockingRemotes = []
this.update()
}
update() {
let year = this.curYear.toString().padStart(4, "0")
let month = (this.curMonth + 1).toString().padStart(2, "0")
let date = new Date(`${year}-${month}-01`)
let txt = MONTHS[this.curMonth]
if (new Date().getFullYear() !== this.curYear) {
txt += " " + year
}
document.getElementById("month").innerText = txt
this.clear()
this.addMonth(date)
this.fetchData()
}
prevMonth() {
this.curMonth--
if (this.curMonth < 0) {
this.curMonth += 12
this.curYear -= 1
}
this.update()
}
nextMonth() {
this.curMonth++
if (this.curMonth >= 12) {
this.curMonth -= 12
this.curYear += 1
}
this.update()
}
clear() {
this.columns.innerHTML = ""
this.clockings.querySelectorAll(".dates th").forEach(c => c.remove())
this.clockings.querySelectorAll(".clocking td").forEach(c => c.remove())
this.weekNames.querySelectorAll("td").forEach(c => c.remove())
this.dayNames.querySelectorAll("td").forEach(c => c.remove())
this.dayDates.innerHTML = ""
this.times.querySelectorAll("tr.project").forEach(r => r.remove())
this.nDays = 0
}
addClockings(date) {
let dates = this.clockings.querySelector(".dates")
let dateCell = document.createElement("th")
let weekDay = DAYS_SHORT[(date.getDay() + 6) % 7]
let monthDay = date.getDate().toString().padStart(2, "0")
dateCell.innerText = `${weekDay} ${monthDay}`
dates.appendChild(dateCell)
let clockings = this.clockings.querySelectorAll(".clocking")
clockings.forEach((clocking, i) => {
if (!(clocking.dataset.type || clocking.classList.contains("total"))) {
return;
}
let cell = clocking.insertCell(this.nDays + 1)
if (clocking.classList.contains("total")) {
return
}
let inputCell = this.timeInputTemplate.cloneNode(true)
let input = inputCell.querySelector("input")
input.addEventListener("input", () => {
this.setClocking(date, clocking.dataset.type, input.value)
})
cell.replaceWith(inputCell)
})
}
addWeek(startDate, length=7) {
let weekNum = startDate.getWeek()
let weekName = this.weekNames.insertCell(-1)
weekName.innerText = `Week n° ${weekNum}`
weekName.colSpan = length
for (let i = 0; i < length; i++) {
let date = new Date(startDate.valueOf() + i * DAY_MS)
let dayPadded = date.getDate().toString().padStart(2, "0")
let monthPadded = (date.getMonth() + 1).toString().padStart(2, "0")
this.addClockings(date)
let dayName = this.dayNames.insertCell(this.nDays + 2)
let dayDate = this.dayDates.insertCell(this.nDays)
let weekDay = (date.getDay() + 6) % 7
dayName.innerText = DAYS_SHORT[weekDay]
dayDate.innerText = `${dayPadded}/${monthPadded}`
let col = document.createElement("col")
col.classList.add(`day-${weekDay}`)
this.columns.appendChild(col)
this.nDays++
}
let weekRemoteTotalCell = this.clockings.querySelector(".clocking.remote-total").insertCell(-1)
weekRemoteTotalCell.colSpan = length
let weekTotalCell = this.clockings.querySelector(".clocking.total2").insertCell(-1)
weekTotalCell.colSpan = length
}
addProject(id, name, sagexNum, times, isParent=false, isProductive=false) {
let row = this.times.insertRow(-1)
row.classList.add("project")
if (isParent) {
row.classList.add("parent")
if (isProductive) {
row.classList.add("productive")
}
}
row.dataset.id = id
row.insertCell(-1).innerText = name
row.insertCell(-1).innerText = sagexNum
let total = 0
times.forEach(time => {
if (time) {
total += time
}
let t = time.toString().split(".")
if (t.length > 1) {
t[1] = t[1].slice(0, 2)
}
row.insertCell(-1).innerText = t.join(".")
})
let totalStr = total.toString().split(".")
if (totalStr.length > 1) {
totalStr[1] = totalStr[1].slice(0, 2)
}
row.insertCell(-1).innerText = totalStr.join(".")
if (isParent) {
row.dataset.total = total
if (isProductive) {
this.totalProjects += total
}
}
row.insertCell(-1)
row.insertCell(-1)
row.insertCell(-1)
}
addMonth(anchorDate) {
this.startDate = new Date(anchorDate)
this.startDate.setDate(1)
let startDay = (this.startDate.getDay() + 6) % 7
let length = 7 - startDay
this.addWeek(this.startDate, length)
let monday = new Date(this.startDate.valueOf() + length * DAY_MS)
let nextMonday
let startMonth = this.startDate.getMonth()
while (monday.getMonth() === startMonth) {
nextMonday = new Date(monday.valueOf() + 7 * DAY_MS)
let len = 7
if (nextMonday.getMonth() !== startMonth) {
len -= nextMonday.getDate() - 1
}
this.addWeek(monday, len)
monday = nextMonday
}
this.clockings.querySelector(".clocking.total").insertCell(-1).rowSpan = 2
this.endDate = new Date(monday.valueOf() - monday.getDate() * DAY_MS)
}
fetchData() {
let startDate = formatDate(this.startDate)
let endDate = formatDate(this.endDate)
fetch(`/table/${startDate}/${endDate}/`).then(res => {
if (res.ok) {
return res.json()
}
}).then(res => {
if (res && res.status === "success") {
this.displayData(res.data)
}
})
}
displayData(data) {
this.displayClockings(data.clockings)
this.totalProjects = 0
data.parents.forEach(parent => {
let parentDurations = Array(this.nDays).fill(0)
let projects = parent.projects.map(project => {
let durations = Array(this.nDays).fill("")
project.tasks.forEach(task => {
let date = new Date(task.date)
let i = Math.floor((date.valueOf() - this.startDate.valueOf()) / DAY_MS)
let hours = task.duration / 60
durations[i] = hours
parentDurations[i] += hours
})
return {
id: project.id,
name: project.name,
durations: durations
}
})
this.addProject(
parent.id,
parent.name,
parent.project_num,
parentDurations.map(v => v === 0 ? "" : v),
true,
parent.is_productive
)
projects.forEach(project => {
this.addProject(project.id, project.name, "", project.durations)
})
})
this.updateTotals()
}
displayClockings(clockings) {
this.clockingTotals = Array(this.nDays).fill(0)
this.clockingRemotes = Array(this.nDays).fill(0)
clockings.forEach(clocking => {
let date = new Date(clocking.date)
if (date < this.startDate || date > this.endDate) {
return
}
let i = Math.floor((date - this.startDate) / DAY_MS)
this.clockings.querySelectorAll("tr.clocking[data-type]").forEach(row => {
let type = row.dataset.type
let cell = row.cells[i + 1]
if (clocking[type]) {
cell.querySelector("input").value = clocking[type]
}
})
let totalRow = this.clockings.querySelector(".clocking.total")
let totalCell = totalRow.cells[i + 1]
let hours = +clocking.total / 60
this.clockingTotals[i] = hours
totalCell.innerText = hours === 0 ? "" : Math.round(hours * 100) / 100
if (clocking.remote !== null) {
let remoteParts = clocking.remote.split(":").map(v => +v)
let remoteHours = remoteParts[0] + remoteParts[1] / 60
if (remoteParts.length >= 3) {
remoteHours += remoteParts[2] / 3600
}
this.clockingRemotes[i] = remoteHours
}
})
}
setClocking(date, type, time) {
this.post(`/clockings/${formatDate(date)}/`, {
[type]: time
}).then(res => {
let row = this.clockings.querySelector(".clocking.total")
let i = Math.floor((date - this.startDate) / DAY_MS)
let cell = row.cells[i + 1]
let minutes = +res.clocking.total
let hours = minutes / 60
this.clockingTotals[i] = hours
cell.innerText = hours === 0 ? "" : Math.round(hours * 100) / 100
this.updateTotals()
})
}
updateTotals() {
let totalClockings = this.clockingTotals.reduce((a, b) => a + b, 0)
let totalProjects = this.totalProjects
this.clockings.querySelector(".clocking.total").cells[this.nDays + 1].innerText = Math.round(totalClockings * 100) / 100
let startI = 0
Array.from(this.clockings.querySelector(".clocking.remote-total").cells).forEach((cell, i) => {
let endI = startI + cell.colSpan
let remote = this.clockingRemotes.slice(startI, endI).reduce((a, b) => a + b, 0)
let hour = Math.floor(remote)
let min = Math.floor((remote - hour) * 60)
cell.innerText = hour.toString().padStart(2, "0") + ":" + min.toString().padStart(2, "0")
startI = endI
})
startI = 0
Array.from(this.clockings.querySelector(".clocking.total2").cells).forEach((cell, i) => {
let endI = startI + cell.colSpan
let total = this.clockingTotals.slice(startI, endI).reduce((a, b) => a + b, 0)
cell.innerText = Math.round(total * 100) / 100
startI = endI
})
if (totalClockings === 0) {
console.log("Total clockings = 0")
return
}
if (totalProjects === 0) {
console.log("Total projects = 0")
return
}
this.times.querySelectorAll(".project.parent.productive").forEach(parent => {
let total = +parent.dataset.total
let workingTimeRatio = total / totalClockings
let imputedTimeRatio = total / totalProjects
parent.cells[this.nDays + 3].innerText = formatPercentage(workingTimeRatio)
parent.cells[this.nDays + 4].innerText = formatPercentage(imputedTimeRatio)
let sagexTime = imputedTimeRatio * totalClockings
parent.cells[this.nDays + 5].innerText = Math.round(sagexTime * 100) / 100
})
}
post(endpoint, data) {
let fd = new FormData()
Object.entries(data).forEach(([key, value]) => {
fd.set(key, value)
})
return req(endpoint, {
method: "POST",
body: fd
}).then(res => {
return res.json()
})
}
}
window.addEventListener("load", () => {
prevMonthBtn = document.getElementById("prev-month")
nextMonthBtn = document.getElementById("next-month")
month = document.getElementById("month")
table = new Table(document.getElementById("table"))
prevMonthBtn.addEventListener("click", () => table.prevMonth())
nextMonthBtn.addEventListener("click", () => table.nextMonth())
})

View File

@ -1,13 +1,19 @@
from django.core.files.uploadedfile import UploadedFile
from django.db.models import Sum, Q
import datetime
from datetime import timedelta
from django.db.models import Sum, Q, QuerySet, Subquery, OuterRef, DurationField, Value, DecimalField
from django.db.models.functions import Coalesce
from django.http import JsonResponse
from django.shortcuts import render, get_object_or_404, redirect
from django.views import generic
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from rest_framework.status import HTTP_400_BAD_REQUEST
from dispatcher.core import import_tasks
from dispatcher.forms import ProjectForm, ImportForm
from dispatcher.models import Project, Parent, Task
from dispatcher.core import import_tasks, convert_timedelta, str_to_timedelta
from dispatcher.forms import ProjectForm, ImportForm, ParentForm
from dispatcher.models import Project, Parent, Clocking, Task, RealSageXHours
from dispatcher.serializers import ClockingSerializer, RealSageXHoursSerializer
def dashboard_view(request):
@ -19,24 +25,97 @@ def dashboard_view(request):
def projects_view(request):
context = {
"class_name": "parent",
"projects": Project.objects.all(),
"parents": Parent.objects.all()
}
return render(request, "projects.html", context)
def parent_view(request, id):
parent = get_object_or_404(Parent, id=id)
if request.method == "DELETE":
parent.delete()
return JsonResponse({"status": "success"})
context = {
"class": "parent",
"id": id
}
form = ParentForm(request.POST or None, request.FILES or None, instance=parent)
if form.is_valid():
form.save()
return redirect("parents")
context["form"] = ParentForm(instance=parent)
return render(request, "edit.html", context)
def parent_on_delete_view(request, id):
parent = get_object_or_404(Parent, id=id)
projects = parent.project_set.count()
tasks = Task.objects.filter(project__parent=parent).count()
return JsonResponse({
"status": "success",
"projects": projects,
"tasks": tasks
})
def project_on_delete_view(request, id):
project = get_object_or_404(Project, id=id)
tasks = project.task_set.count()
return JsonResponse({
"status": "success",
"tasks": tasks
})
def new_parent_view(request):
context = {
"class": "parent"
}
form = ParentForm(request.POST or None, request.FILES or None)
if form.is_valid():
form.save()
return redirect("parents")
context["form"] = form
return render(request, "add.html", context)
def project_view(request, id):
project = get_object_or_404(Project, id=id)
context = {}
if request.method == "DELETE":
project.delete()
return JsonResponse({"status": "success"})
context = {
"class": "project",
"id": id
}
form = ProjectForm(request.POST or None, request.FILES or None, instance=project)
if form.is_valid():
form.save()
context["form"] = ProjectForm(instance=project)
return render(request, "project.html", context)
return render(request, "edit.html", context)
def new_project_view(request):
context = {
"class": "project"
}
form = ProjectForm(request.POST or None, request.FILES or None)
if form.is_valid():
form.save()
return redirect("projects")
context["form"] = form
return render(request, "add.html", context)
def table_view(request):
return render(request, "table.html")
@require_POST
def set_parent(request, id):
project = get_object_or_404(Project, id=id)
parent_id = request.POST.get("parent_id")
parent_id = request.POST.get("parent_id") or None
try:
parent = Parent.objects.get(id=parent_id)
except Parent.DoesNotExist:
@ -47,7 +126,6 @@ def set_parent(request, id):
return JsonResponse({"status": "success"})
def import_view(request):
if request.method == "POST":
form = ImportForm(request.POST, request.FILES)
@ -62,6 +140,12 @@ def get_stats_by_month(request, year: int, month: int):
if month < 1 or month > 12:
return JsonResponse({"status": "error", "error": f"Invalid month {month}"})
sagex_subquery = RealSageXHours.objects.filter(
parent=OuterRef("pk"),
date__year=year,
date__month=month
).values("hours")[:1]
parents = Parent.objects.annotate(
total_duration=Sum(
"project__task__duration",
@ -70,20 +154,158 @@ def get_stats_by_month(request, year: int, month: int):
project__task__date__month=month
)
)
).annotate(
real_sagex_hours=Coalesce(
Subquery(sagex_subquery, output_field=DurationField()),
timedelta(0)
)
)
data = [
{
"id": parent.id,
"name": parent.name,
"project_num": parent.project_num,
"duration": parent.total_duration
}
for parent in parents
]
return JsonResponse({"status": "success", "data": data})
projects = Project.objects.annotate(
total_duration=Sum(
"task__duration",
filter=Q(
task__date__year=year,
task__date__month=month
)
)
)
clockings = Clocking.objects.filter(
date__year=year,
date__month=month
)
return JsonResponse({"status": "success", "data": format_stats(parents, projects, clockings)})
def format_stats(parents: QuerySet[Parent], projects: QuerySet[Project], clockings: QuerySet[Clocking]):
data = {
"parents": [
{
"id": parent.id,
"name": parent.name,
"project_num": parent.project_num,
"is_productive": parent.is_productive,
"duration": parent.total_duration,
"real_sagex_hours": convert_timedelta(parent.real_sagex_hours)
}
for parent in parents
],
"projects": [
{
"id": project.id,
"name": project.name,
"duration": project.total_duration
}
for project in projects
],
"clockings": ClockingSerializer(clockings, many=True).data
}
Clocking.objects.filter()
return data
class ParentsView(generic.ListView):
model = Parent
template_name = "parents.html"
context_object_name = "elements"
class ProjectsView(generic.ListView):
model = Project
template_name = "projects.html"
context_object_name = "elements"
ordering = ["parent"]
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["parents"] = Parent.objects.all()
return context
def get_table_data(request, start_date: datetime.date, end_date: datetime.date):
end_date = end_date + timedelta(days=1)
clockings = Clocking.objects.filter(
date__gte=start_date,
date__lte=end_date
)
parents = Parent.objects.all().order_by("id")
data = {
"parents": [
{
"id": parent.id,
"name": parent.name,
"project_num": parent.project_num,
"is_productive": parent.is_productive,
"projects": [
{
"id": project.id,
"name": project.name,
"tasks": [
{
"date": task["date"],
"duration": task["duration"]
}
for task in project.task_set.filter(
date__gte=start_date,
date__lt=end_date
).values("date").order_by("date").annotate(duration=Sum("duration"))
]
}
for project in parent.project_set.order_by("id")
]
}
for parent in parents
],
"clockings": ClockingSerializer(clockings, many=True).data
}
return JsonResponse({"status": "success", "data": data})
@require_POST
@csrf_exempt
def set_clocking(request, date: datetime.date):
clocking, created = Clocking.objects.get_or_create(date=date)
clocking.in_am = request.POST.get("in_am", clocking.in_am) or None
clocking.out_am = request.POST.get("out_am", clocking.out_am) or None
clocking.in_pm = request.POST.get("in_pm", clocking.in_pm) or None
clocking.out_pm = request.POST.get("out_pm", clocking.out_pm) or None
remote = request.POST.get("remote", clocking.remote) or None
if remote is not None:
if isinstance(remote, str):
remote = str_to_timedelta(remote)
else:
remote = timedelta()
clocking.remote = remote
clocking.save()
clocking.refresh_from_db()
return JsonResponse({
"status": "success",
"clocking": ClockingSerializer(clocking).data
})
@require_POST
def set_real_sagex(request, id, month: datetime.date):
parent = get_object_or_404(Parent, id=id)
minutes = request.POST.get("minutes")
if minutes is None:
return JsonResponse({
"status": "error",
"error": "Missing minutes field"
}, status=HTTP_400_BAD_REQUEST)
try:
minutes = int(minutes)
except ValueError:
return JsonResponse({
"status": "error",
"error": "Invalid value for minutes, must be an int"
}, status=HTTP_400_BAD_REQUEST)
hours, minutes = divmod(minutes, 60)
entry, created = RealSageXHours.objects.get_or_create(parent=parent, date=month)
entry.hours = timedelta(hours=int(hours), minutes=int(minutes))
entry.save()
entry.refresh_from_db()
return JsonResponse({
"status": "success",
"sagex": RealSageXHoursSerializer(entry).data
})

16
main.py Normal file
View File

@ -0,0 +1,16 @@
import uvicorn
from TimeDispatcher.asgi import application
def main():
uvicorn.run(
application,
host="127.0.0.1",
port=8000,
workers=1
)
if __name__ == "__main__":
main()

View File

@ -1 +1,4 @@
Django~=5.1.5
Django~=5.1.5
djangorestframework~=3.15.2
python-dotenv~=1.0.1
uvicorn~=0.34.0

13
templates/404.html Normal file
View File

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% load static %}
{% block title %}404 Not Found{% endblock %}
{% block head %}
<link rel="stylesheet" href="{% static "error.css" %}">
{% endblock %}
{% block footer %}{% endblock %}
{% block main %}
<div class="wrapper">
<h1 class="title">Page not found</h1>
<p class="desc">Oops, page <span class="url">{{ request.path }}</span> could not be found. Go back to <a href="{% url "dashboard" %}">Dashboard</a></p>
</div>
{% endblock %}

16
templates/add.html Normal file
View File

@ -0,0 +1,16 @@
{% extends "base.html" %}
{% load static %}
{% block title %}New {{ class }}{% endblock %}
{% block head %}
<link rel="stylesheet" href="{% static "form.css" %}">
{% endblock %}
{% block main %}
<div class="container">
<h2>New {{ class }}</h2>
<form action="" method="POST">
{% csrf_token %}
{{ form }}
<button type="submit">Create</button>
</form>
</div>
{% endblock %}

View File

@ -4,6 +4,7 @@
<head>
<meta charset="UTF-8">
<title>{% block title %}Title{% endblock %}</title>
<link rel="shortcut icon" href="{% static "logo.svg" %}" type="image/x-svg">
<link rel="stylesheet" href="{% static "base.css" %}">
<script src="{% static "base.js" %}"></script>
{% block head %}{% endblock %}
@ -11,6 +12,7 @@
<body>
<header>
<nav>
<a class="logo" href="{% url "dashboard" %}"><img src="{% static "logo.svg" %}" alt="Time Dispatcher logo"></a>
{% for nav_link in nav_links %}
<a href="{% url nav_link.view %}" {% if request.resolver_match.view_name == nav_link.view %}class="active"{% endif %}>{{ nav_link.label }}</a>
{% endfor %}
@ -20,6 +22,7 @@
{% block main %}{% endblock %}
</main>
<footer>
{% if debug %}<span class="debug">Debug</span>{% endif %}
<span><i>Time Dispatcher v{{ version }}</i></span>
<span class="sep"></span>
<span><a href="https://git.kb28.ch/HEL/TimeDispatcher" target="_blank">Gitea</a></span>

View File

@ -7,17 +7,68 @@
{% endblock %}
{% block footer %}{% endblock %}
{% block main %}
<div class="monthly">
<div class="template row">
<div class="name"></div>
<div class="duration"></div>
</div>
<div class="template no-data">No Data</div>
{% csrf_token %}
<table class="template">
<td class="template sagex-hours">
<input type="number" class="hours" min="0" step="1" />:<input type="number" class="minutes" min="0" step="1" />
</td>
</table>
<div class="by-range">
<div class="controls">
<button id="prev"><</button>
<div id="month">Mois</div>
<button id="next">></button>
<div id="month-sel" class="group">
<button id="prev-month"><</button>
<div id="month">Month</div>
<button id="next-month">></button>
</div>
</div>
<div class="tables">
<table id="headers-table">
<tr class="project-names">
<th colspan="2"></th>
</tr>
<tr class="project-nums">
<th colspan="2">N° SageX</th>
</tr>
<tr class="clocking">
<th>Total clocked</th>
<td class="total-clocking"></td>
</tr>
<tr class="total-durations">
<th>Total</th>
<td class="total-duration"></td>
</tr>
<tr class="working-time-ratios">
<th>% working time</th>
<td class="working-time-ratio"></td>
</tr>
<tr class="imputed-time-ratios">
<th>% imputed time</th>
<td></td>
</tr>
<tr class="sagex-hours">
<th>Hours on SageX (theoretical)</th>
<td class="sagex-hours-total"></td>
</tr>
<tr class="real-sagex-hours">
<th>Hours on SageX (real)</th>
<td class="real-sagex-hours-total"></td>
</tr>
<tr class="sagex-diff">
<th>Difference</th>
<td class="sagex-diff-total"></td>
</tr>
</table>
<table id="projects-table">
<tr class="project-names"></tr>
<tr class="project-nums"></tr>
<tr class="clocking"></tr>
<tr class="total-durations"></tr>
<tr class="working-time-ratios"></tr>
<tr class="imputed-time-ratios"></tr>
<tr class="sagex-hours"></tr>
<tr class="real-sagex-hours"></tr>
<tr class="sagex-diff"></tr>
</table>
</div>
<div id="list"></div>
</div>
{% endblock %}

16
templates/edit.html Normal file
View File

@ -0,0 +1,16 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Editing {{ class }} {{ id }}{% endblock %}
{% block head %}
<link rel="stylesheet" href="{% static "form.css" %}">
{% endblock %}
{% block main %}
<div class="container">
<h2>Editing {{ class }} {{ id }}</h2>
<form action="" method="POST">
{% csrf_token %}
{{ form }}
<button type="submit">Save</button>
</form>
</div>
{% endblock %}

View File

@ -1,12 +1,19 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Import tasks{% endblock %}
{% block head %}
<link rel="stylesheet" href="{% static "form.css" %}">
{% endblock %}
{% block main %}
<form action="" method="POST" enctype="multipart/form-data">
{% csrf_token %}
<div>
<label for="file">File</label>
<input type="file" name="file" id="file">
</div>
<button type="submit">Import</button>
</form>
<div class="container">
<h1>Import tasks</h1>
<form action="" method="POST" enctype="multipart/form-data">
{% csrf_token %}
<div>
<label for="file">File</label>
<input type="file" name="file" id="file">
</div>
<button type="submit">Import</button>
</form>
</div>
{% endblock %}

View File

@ -2,18 +2,36 @@
{% load static %}
{% block head %}
<link rel="stylesheet" href="{% static "list.css" %}">
<script src="{% static "list.js" %}"></script>
{% block extra-head %}{% endblock %}
{% endblock %}
{% block main %}
<ul class="list">
{% for element in elements %}
<li data-id="{{ element.id }}">
{% block element %}{% endblock %}
<div class="actions">
{% block actions %}{% endblock %}
</div>
</li>
{% empty %}
<li class="empty">No {% block element_name %}{% endblock %} defined yet</li>
{% endfor %}
</ul>
{% csrf_token %}
<div class="list-wrapper">
<div class="list-header">
<button class="new">New {{ class_name }}</button>
</div>
<ul class="list">
{% for element in elements %}
<li data-id="{{ element.id }}" data-name="{% block element-name %}{% endblock %}" class="{% block extra-class %}{% endblock %}">
{% block element %}{% endblock %}
<div class="actions">
{% block actions %}{% endblock %}
</div>
</li>
{% empty %}
<li class="empty">No {{ class_name }} defined yet</li>
{% endfor %}
</ul>
</div>
<div class="popup" id="delete-popup">
<div class="popup-container">
<h2 class="title">Delete <span class="elmt-name"></span> ?</h2>
<p class="desc">{% block delete-desc %}{% endblock %}</p>
<div class="actions">
<button class="cancel">Cancel</button>
<button class="delete">Delete</button>
</div>
</div>
</div>
{% endblock %}

View File

@ -1,11 +1,27 @@
{% extends "list.html" %}
{% load static %}
{% block title %}Parents{% endblock %}
{% block element_name %}parent{% endblock %}
{% block extra-head %}
<link rel="stylesheet" href="{% static "parents.css" %}">
<script src="{% static "parents.js" %}"></script>
{% endblock %}
{% block extra-class %}{% if element.is_productive %}productive{% endif %}{% endblock %}
{% block element-name %}{{ element.name }}{% endblock %}
{% block element %}
<div class="name">{{ element.name }}</div>
<div class="project_num">({{ element.project_num }})</div>
{% if element.project_num %}
<div class="project_num">({{ element.project_num }})</div>
{% endif %}
{% endblock %}
{% block actions %}
<button class="edit">Edit</button>
{% endblock %}
<button class="delete">Delete</button>
{% endblock %}
{% block delete-desc %}
Are sure you want to delete <span class="elmt-name"></span> ?
This action is <u>IRREVERSIBLE</u> ! By deleting this element, you will also delete:
<ul>
<li><span class="project-count"></span> project(s)</li>
<li><span class="task-count"></span> task imputation(s)</li>
</ul>
{% endblock %}

View File

@ -1,10 +0,0 @@
{% extends "base.html" %}
{% block main %}
<h2>Editing project</h2>
<form action="" method="POST">
{% csrf_token %}
{{ form }}
<button type="submit">Save</button>
</form>
{% endblock %}

View File

@ -1,39 +1,29 @@
{% extends "base.html" %}
{% extends "list.html" %}
{% load static %}
{% block title %}Projects{% endblock %}
{% block head %}
{% block extra-head %}
<link rel="stylesheet" href="{% static "projects.css" %}">
<script src="{% static "projects.js" %}"></script>
{% endblock %}
{% block footer %}{% endblock %}
{% block main %}
{% csrf_token %}
<table class="projects">
<thead>
<tr>
<th>Project</th>
<th>Parent</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for project in projects %}
<tr data-id="{{ project.id }}">
<td>{{ project.name }}</td>
<td>
<select class="parent-sel">
<option value=""></option>
{% for parent in parents %}
<option value="{{ parent.id }}" {% if project.parent.id == parent.id %}selected{% endif %}>{{ parent.name }}</option>
{% endfor %}
</select>
</td>
<td>
<button class="see">See</button>
<button class="delete">Delete</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
{% block extra-class %}{% if element.parent.is_productive %}productive{% endif %}{% endblock %}
{% block element-name %}{{ element.name }}{% endblock %}
{% block element %}
<div class="name">{{ element.name }}</div>
{% endblock %}
{% block actions %}
<select class="parent-sel">
<option value=""></option>
{% for parent in parents %}
<option value="{{ parent.id }}" {% if element.parent.id == parent.id %}selected{% endif %}>{{ parent.name }}</option>
{% endfor %}
</select>
<button class="edit">Edit</button>
<button class="delete">Delete</button>
{% endblock %}
{% block delete-desc %}
Are sure you want to delete <span class="elmt-name"></span> ?
This action is <u>IRREVERSIBLE</u> ! By deleting this element, you will also delete:
<ul>
<li><span class="task-count"></span> task imputation(s)</li>
</ul>
{% endblock %}

68
templates/table.html Normal file
View File

@ -0,0 +1,68 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Table{% endblock %}
{% block head %}
<link rel="stylesheet" href="{% static "table.css" %}">
<script src="{% static "table.js" %}"></script>
{% endblock %}
{% block main %}
{% csrf_token %}
<div class="controls">
<button id="prev-month"><</button>
<div id="month">Month</div>
<button id="next-month">></button>
</div>
<table class="template">
<td class="template time-input"><input type="time"></td>
</table>
<table id="table">
<colgroup class="headers">
<col class="name">
<col class="num">
</colgroup>
<colgroup class="columns"></colgroup>
<colgroup class="tailers">
<col span="4">
</colgroup>
<tbody class="clockings">
<tr class="dates">
<td colspan="2"></td>
</tr>
<tr class="clocking" data-type="in_am">
<th colspan="2">Check-IN AM</th>
<th rowspan="6" class="total-header">TOTAL</th>
<th rowspan="8" class="total-header">% working time</th>
<th rowspan="8" class="total-header">% imputed time</th>
<th rowspan="8" class="total-header">time on sagex</th>
</tr>
<tr class="clocking" data-type="out_am">
<th colspan="2">Check-OUT AM</th>
</tr>
<tr class="clocking" data-type="in_pm">
<th colspan="2">Check-IN PM</th>
</tr>
<tr class="clocking" data-type="out_pm">
<th colspan="2">Check-OUT PM</th>
</tr>
<tr class="clocking" data-type="remote">
<th colspan="2" rowspan="2">Remote</th>
</tr>
<tr class="clocking remote-total"></tr>
<tr class="clocking total">
<th colspan="2" rowspan="2">TOTAL</th>
</tr>
<tr class="clocking total2"></tr>
</tbody>
<tr class="separator"></tr>
<tbody class="times">
<tr class="week-names">
<th colspan="2"></th>
</tr>
<tr class="day-names">
<th rowspan="2">Name</th>
<th rowspan="2">N° sagex</th>
</tr>
<tr class="day-dates"></tr>
</tbody>
</table>
{% endblock %}