27 Commits

Author SHA1 Message Date
f48cbdb03a Merge pull request 'v0.1.2: Minor ui improvements' (#3) from dev into main
Reviewed-on: #3
2025-03-01 13:25:13 +00:00
57231c0d34 Merge pull request 'minor ui improvements' (#2) from feat/ui-improvements-1 into dev
Reviewed-on: #2
2025-03-01 13:23:52 +00:00
842d0ff11e bumped version to 0.1.2 2025-03-01 14:21:22 +01:00
87005efcf5 added weekly total + highlight clocking inconsistencies 2025-03-01 14:20:16 +01:00
7f845dcb1d made empty project lines hidden 2025-03-01 13:40:24 +01:00
9e2566ba03 updated app version to 0.1.1 2025-03-01 13:13:02 +01:00
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 2222 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 For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.1/ref/settings/ https://docs.djangoproject.com/en/5.1/ref/settings/
""" """
import os
from pathlib import Path from pathlib import Path
from dotenv import load_dotenv
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
APP_VERSION = "0.0.1" APP_VERSION = "0.1.2"
load_dotenv(BASE_DIR / ".env")
# Quick-start development settings - unsuitable for production # Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret! # 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! # 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 # Application definition
@ -57,8 +60,7 @@ ROOT_URLCONF = 'TimeDispatcher.urls'
TEMPLATES = [ TEMPLATES = [
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', 'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'templates'] 'DIRS': [BASE_DIR / 'templates'],
,
'APP_DIRS': True, 'APP_DIRS': True,
'OPTIONS': { 'OPTIONS': {
'context_processors': [ 'context_processors': [
@ -111,7 +113,7 @@ AUTH_PASSWORD_VALIDATORS = [
LANGUAGE_CODE = 'en-us' LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC' TIME_ZONE = 'Europe/Zurich'
USE_I18N = True USE_I18N = True
@ -121,7 +123,12 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.1/howto/static-files/ # https://docs.djangoproject.com/en/5.1/howto/static-files/
STATICFILES_DIRS = [
BASE_DIR / "dispatcher" / "static"
]
STATIC_URL = 'static/' STATIC_URL = 'static/'
_STATIC_ROOT = BASE_DIR / "dispatcher" / "static"
# Default primary key field type # Default primary key field type
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field # 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 1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
""" """
from django.contrib import admin from django.urls import path, register_converter, re_path
from django.urls import path from django.views.static import serve
from TimeDispatcher import settings
from TimeDispatcher.converters import DateConverter, YearMonthConverter
from dispatcher import views from dispatcher import views
register_converter(DateConverter, "date")
register_converter(YearMonthConverter, "year_month")
urlpatterns = [ urlpatterns = [
path("", views.dashboard_view, name="dashboard"), path("", views.dashboard_view, name="dashboard"),
path("import/", views.import_view, name="import"), 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("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>/", 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("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): def version(request):
return { return {
"debug": DEBUG,
"version": APP_VERSION "version": APP_VERSION
} }
@ -10,6 +11,7 @@ def navbar_links(request):
return { return {
"nav_links": [ "nav_links": [
{'view': 'dashboard', 'label': 'Dashboard'}, {'view': 'dashboard', 'label': 'Dashboard'},
{'view': 'table', 'label': 'Table'},
{'view': 'projects', 'label': 'Projects'}, {'view': 'projects', 'label': 'Projects'},
{'view': 'parents', 'label': 'Parents'}, {'view': 'parents', 'label': 'Parents'},
{'view': 'import', 'label': 'Import'}, {'view': 'import', 'label': 'Import'},

View File

@ -1,6 +1,22 @@
import datetime
from datetime import timedelta
from dispatcher.models import Task, Project 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): def import_tasks(csv_content: str):
tasks = [] tasks = []
for line in csv_content.splitlines()[1:]: for line in csv_content.splitlines()[1:]:

View File

@ -1,8 +1,13 @@
from django import forms 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 ProjectForm(forms.ModelForm):
class Meta: class Meta:
model = Project 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 from django.db import models
class Parent(models.Model): 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) name = models.CharField(max_length=256)
is_productive = models.BooleanField(default=False)
def __str__(self): def __str__(self):
return self.name return self.name
@ -30,3 +33,23 @@ class Task(models.Model):
constraints = [ constraints = [
models.UniqueConstraint(fields=["date", "project", "name"], name="unique_daily_task") 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; --light2: #e5e5e5;
--light3: #cccccc; --light3: #cccccc;
--light4: #c5c5c5; --light4: #c5c5c5;
--accent: #69c935;
--accent2: #61ba31;
} }
* { * {
@ -43,6 +45,16 @@ nav a {
transition: background-color 0.2s; 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 { nav a.active {
box-shadow: 0 -2px var(--light1) inset; box-shadow: 0 -2px var(--light1) inset;
} }
@ -65,11 +77,18 @@ footer .sep {
height: 1em; height: 1em;
} }
footer .debug {
margin-right: auto;
font-style: italic;
color: #ffb33d;
}
main { main {
flex-grow: 1; flex-grow: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 2em; padding: 2em;
overflow-y: auto;
} }
button, input, select { button, input, select {
@ -95,5 +114,9 @@ button, select {
} }
a { 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; display: flex;
flex-direction: column; flex-direction: column;
padding: 1.2em; padding: 1.2em;
@ -6,13 +6,23 @@
gap: 1.2em; gap: 1.2em;
} }
.monthly .controls { .by-range .controls {
display: flex; display: flex;
gap: 1.2em; gap: 2.4em;
align-items: center; 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); background-color: var(--dark3);
color: var(--light1); color: var(--light1);
padding: 0.4em 0.8em; padding: 0.4em 0.8em;
@ -20,25 +30,93 @@
cursor: pointer; cursor: pointer;
} }
.monthly .controls button:hover { .by-range .controls button:hover, .by-range .controls select:hover {
background-color: var(--dark4); background-color: var(--dark4);
} }
.monthly #list { .by-range .tables {
display: flex; display: flex;
flex-direction: column;
gap: 0.8em;
} }
.monthly #list .row { .by-range .tables table {
display: flex; border-collapse: collapse;
gap: 1.2em;
padding: 0.4em 0.8em;
background-color: var(--dark3);
} }
.monthly #list .no-data { .by-range .tables tr {
font-style: italic; height: 2em;
padding: 0.4em 0.8em; }
background-color: var(--dark2);
.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 curYear = new Date().getFullYear()
let curMonth = new Date().getMonth() let curMonth = new Date().getMonth()
let curWeekDate = new Date()
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
}
function prevMonth() { function prevMonth() {
curMonth = curMonth - 1 curMonth = curMonth - 1
@ -36,7 +9,7 @@ function prevMonth() {
curMonth += 12 curMonth += 12
curYear -= 1 curYear -= 1
} }
updateList() updateTableMonthly()
} }
function nextMonth() { function nextMonth() {
curMonth = curMonth + 1 curMonth = curMonth + 1
@ -44,10 +17,10 @@ function nextMonth() {
curMonth -= 12 curMonth -= 12
curYear += 1 curYear += 1
} }
updateList() updateTableMonthly()
} }
function updateList() { function updateTableMonthly() {
let year = curYear.toString().padStart(4, "0") let year = curYear.toString().padStart(4, "0")
let month = (curMonth + 1).toString().padStart(2, "0") let month = (curMonth + 1).toString().padStart(2, "0")
let today = new Date() let today = new Date()
@ -63,32 +36,152 @@ function updateList() {
if (res.status !== "success") { if (res.status !== "success") {
return return
} }
let data = res.data.filter(parent => parent.duration !== null) updateTable(res.data)
let list = document.getElementById("list") })
let template = document.querySelector(".monthly .template.row").cloneNode(true) }
template.classList.remove("template")
list.innerHTML = "" function updateTable(data) {
if (data.length === 0) { let totalWorked = data.clockings.map(c => c.total).reduce((a, b) => a + b, 0)
let noData = document.querySelector(".monthly .template.no-data").cloneNode(true)
noData.classList.remove("template") let parents = data.parents.filter(parent => parent.duration !== null && parent.is_productive)
list.appendChild(noData)
return let headers = document.getElementById("headers-table")
} let table = document.getElementById("projects-table")
data.forEach(parent => { let projNames = table.querySelector(".project-names")
let row = template.cloneNode(true) let projNums = table.querySelector(".project-nums")
row.querySelector(".name").innerText = `${parent.name} (${parent.project_num})` let totDurations = table.querySelector(".total-durations")
row.querySelector(".duration").innerText = formatDuration(parent.duration) let workingRatios = table.querySelector(".working-time-ratios")
list.appendChild(row) 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", () => { window.addEventListener("load", () => {
prevBtn = document.getElementById("prev") prevMonthBtn = document.getElementById("prev-month")
nextBtn = document.getElementById("next") nextMonthBtn = document.getElementById("next-month")
month = document.getElementById("month") month = document.getElementById("month")
prevBtn.addEventListener("click", () => prevMonth()) prevMonthBtn.addEventListener("click", () => prevMonth())
nextBtn.addEventListener("click", () => nextMonth()) nextMonthBtn.addEventListener("click", () => nextMonth())
updateList()
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 { .list-wrapper {
display: flex;
width: 100%; width: 100%;
max-width: 40em; max-width: 40em;
align-self: center; 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; gap: 0.8em;
flex-direction: column; flex-direction: column;
padding: 0;
overflow-y: auto;
} }
.list li { .list li {
@ -13,8 +32,66 @@
marker: none; marker: none;
gap: 0.8em; gap: 0.8em;
border: solid var(--dark4) 1px; border: solid var(--dark4) 1px;
align-items: center;
} }
.list li .actions { .list li .actions {
margin-left: auto; 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 { .projects {
width: 100%; width: 100%;
max-width: 40em; max-width: 40em;
@ -21,4 +25,5 @@
.projects tbody tr:nth-child(even) { .projects tbody tr:nth-child(even) {
background-color: var(--dark2); 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", () => { window.addEventListener("load", () => {
document.querySelectorAll(".projects tbody tr").forEach(row => { document.querySelectorAll(".list li").forEach(row => {
let id = row.dataset.id let id = row.dataset.id
let selector = row.querySelector(".parent-sel") let selector = row.querySelector(".parent-sel")
selector.addEventListener("change", () => { selector.addEventListener("change", () => {
let fd = new FormData() let fd = new FormData()
fd.set("parent_id", selector.value) fd.set("parent_id", selector.value)
let csrftoken = document.querySelector("input[name='csrfmiddlewaretoken']").value req(`${id}/set_parent/`, {
fetch(`${id}/set_parent`, {
method: "POST", method: "POST",
body: fd, body: fd
headers: {
"X-CSRFToken": csrftoken
}
}) })
}) })
row.querySelector("button.see").addEventListener("click", () => {
window.location.href = `${id}/`
})
}) })
}) })

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

@ -0,0 +1,181 @@
:root {
--invalid-col: #f1232394;
}
#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;
font-style: italic;
&.invalid {
background-color: var(--invalid-col);
font-weight: bold;
}
}
}
&.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);
}
.day-totals {
td {
text-align: right;
font-style: italic;
&.invalid {
background-color: var(--invalid-col);
font-weight: bold;
}
}
}
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);
}
}
}

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

@ -0,0 +1,389 @@
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.dayTotals = elmt.querySelector(".day-totals")
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.dailyTotals = []
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.dayTotals.querySelectorAll("td").forEach(c => c.remove())
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)
this.dayTotals.insertCell(this.nDays + 1)
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
this.dailyTotals = Array(this.nDays).fill(0)
data.parents.forEach(parent => {
let parentDurations = Array(this.nDays).fill(0)
let projects = parent.projects.map(project => {
let durations = Array(this.nDays).fill("")
let total = 0
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
total += hours
parentDurations[i] += hours
this.dailyTotals[i] += hours
})
return {
id: project.id,
name: project.name,
durations: durations,
total: total
}
})
this.addProject(
parent.id,
parent.name,
parent.project_num,
parentDurations.map(v => v === 0 ? "" : v),
true,
parent.is_productive
)
projects.filter(p => p.total !== 0).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
})
for (let i = 0; i < this.nDays; i++) {
let total = this.dailyTotals[i]
let cell = this.dayTotals.cells[i + 1]
if (total === 0) {
cell.innerText = ""
} else {
cell.innerText = Math.round(total * 100) / 100
}
let clocking = this.clockings.querySelector(".clocking.total").cells[i + 1]
if (total > this.clockingTotals[i]) {
clocking.classList.add("invalid")
cell.classList.add("invalid")
} else {
clocking.classList.remove("invalid")
cell.classList.remove("invalid")
}
}
}
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 import datetime
from django.db.models import Sum, Q 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.http import JsonResponse
from django.shortcuts import render, get_object_or_404, redirect from django.shortcuts import render, get_object_or_404, redirect
from django.views import generic from django.views import generic
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST 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.core import import_tasks, convert_timedelta, str_to_timedelta
from dispatcher.forms import ProjectForm, ImportForm from dispatcher.forms import ProjectForm, ImportForm, ParentForm
from dispatcher.models import Project, Parent, Task from dispatcher.models import Project, Parent, Clocking, Task, RealSageXHours
from dispatcher.serializers import ClockingSerializer, RealSageXHoursSerializer
def dashboard_view(request): def dashboard_view(request):
@ -19,24 +25,97 @@ def dashboard_view(request):
def projects_view(request): def projects_view(request):
context = { context = {
"class_name": "parent",
"projects": Project.objects.all(), "projects": Project.objects.all(),
"parents": Parent.objects.all() "parents": Parent.objects.all()
} }
return render(request, "projects.html", context) 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): def project_view(request, id):
project = get_object_or_404(Project, id=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) form = ProjectForm(request.POST or None, request.FILES or None, instance=project)
if form.is_valid(): if form.is_valid():
form.save() form.save()
context["form"] = ProjectForm(instance=project) 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 @require_POST
def set_parent(request, id): def set_parent(request, id):
project = get_object_or_404(Project, id=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: try:
parent = Parent.objects.get(id=parent_id) parent = Parent.objects.get(id=parent_id)
except Parent.DoesNotExist: except Parent.DoesNotExist:
@ -47,7 +126,6 @@ def set_parent(request, id):
return JsonResponse({"status": "success"}) return JsonResponse({"status": "success"})
def import_view(request): def import_view(request):
if request.method == "POST": if request.method == "POST":
form = ImportForm(request.POST, request.FILES) 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: if month < 1 or month > 12:
return JsonResponse({"status": "error", "error": f"Invalid month {month}"}) 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( parents = Parent.objects.annotate(
total_duration=Sum( total_duration=Sum(
"project__task__duration", "project__task__duration",
@ -70,20 +154,158 @@ def get_stats_by_month(request, year: int, month: int):
project__task__date__month=month project__task__date__month=month
) )
) )
).annotate(
real_sagex_hours=Coalesce(
Subquery(sagex_subquery, output_field=DurationField()),
timedelta(0)
)
) )
data = [ projects = Project.objects.annotate(
{ total_duration=Sum(
"id": parent.id, "task__duration",
"name": parent.name, filter=Q(
"project_num": parent.project_num, task__date__year=year,
"duration": parent.total_duration task__date__month=month
} )
for parent in parents )
] )
return JsonResponse({"status": "success", "data": data}) 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): class ParentsView(generic.ListView):
model = Parent model = Parent
template_name = "parents.html" template_name = "parents.html"
context_object_name = "elements" 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> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>{% block title %}Title{% endblock %}</title> <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" %}"> <link rel="stylesheet" href="{% static "base.css" %}">
<script src="{% static "base.js" %}"></script> <script src="{% static "base.js" %}"></script>
{% block head %}{% endblock %} {% block head %}{% endblock %}
@ -11,6 +12,7 @@
<body> <body>
<header> <header>
<nav> <nav>
<a class="logo" href="{% url "dashboard" %}"><img src="{% static "logo.svg" %}" alt="Time Dispatcher logo"></a>
{% for nav_link in nav_links %} {% 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> <a href="{% url nav_link.view %}" {% if request.resolver_match.view_name == nav_link.view %}class="active"{% endif %}>{{ nav_link.label }}</a>
{% endfor %} {% endfor %}
@ -20,6 +22,7 @@
{% block main %}{% endblock %} {% block main %}{% endblock %}
</main> </main>
<footer> <footer>
{% if debug %}<span class="debug">Debug</span>{% endif %}
<span><i>Time Dispatcher v{{ version }}</i></span> <span><i>Time Dispatcher v{{ version }}</i></span>
<span class="sep"></span> <span class="sep"></span>
<span><a href="https://git.kb28.ch/HEL/TimeDispatcher" target="_blank">Gitea</a></span> <span><a href="https://git.kb28.ch/HEL/TimeDispatcher" target="_blank">Gitea</a></span>

View File

@ -7,17 +7,68 @@
{% endblock %} {% endblock %}
{% block footer %}{% endblock %} {% block footer %}{% endblock %}
{% block main %} {% block main %}
<div class="monthly"> {% csrf_token %}
<div class="template row"> <table class="template">
<div class="name"></div> <td class="template sagex-hours">
<div class="duration"></div> <input type="number" class="hours" min="0" step="1" />:<input type="number" class="minutes" min="0" step="1" />
</div> </td>
<div class="template no-data">No Data</div> </table>
<div class="by-range">
<div class="controls"> <div class="controls">
<button id="prev"><</button> <div id="month-sel" class="group">
<div id="month">Mois</div> <button id="prev-month"><</button>
<button id="next">></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>
<div id="list"></div>
</div> </div>
{% endblock %} {% 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" %} {% extends "base.html" %}
{% load static %}
{% block title %}Import tasks{% endblock %} {% block title %}Import tasks{% endblock %}
{% block head %}
<link rel="stylesheet" href="{% static "form.css" %}">
{% endblock %}
{% block main %} {% block main %}
<form action="" method="POST" enctype="multipart/form-data"> <div class="container">
{% csrf_token %} <h1>Import tasks</h1>
<div> <form action="" method="POST" enctype="multipart/form-data">
<label for="file">File</label> {% csrf_token %}
<input type="file" name="file" id="file"> <div>
</div> <label for="file">File</label>
<button type="submit">Import</button> <input type="file" name="file" id="file">
</form> </div>
<button type="submit">Import</button>
</form>
</div>
{% endblock %} {% endblock %}

View File

@ -2,18 +2,36 @@
{% load static %} {% load static %}
{% block head %} {% block head %}
<link rel="stylesheet" href="{% static "list.css" %}"> <link rel="stylesheet" href="{% static "list.css" %}">
<script src="{% static "list.js" %}"></script>
{% block extra-head %}{% endblock %}
{% endblock %} {% endblock %}
{% block main %} {% block main %}
<ul class="list"> {% csrf_token %}
{% for element in elements %} <div class="list-wrapper">
<li data-id="{{ element.id }}"> <div class="list-header">
{% block element %}{% endblock %} <button class="new">New {{ class_name }}</button>
<div class="actions"> </div>
{% block actions %}{% endblock %} <ul class="list">
</div> {% for element in elements %}
</li> <li data-id="{{ element.id }}" data-name="{% block element-name %}{% endblock %}" class="{% block extra-class %}{% endblock %}">
{% empty %} {% block element %}{% endblock %}
<li class="empty">No {% block element_name %}{% endblock %} defined yet</li> <div class="actions">
{% endfor %} {% block actions %}{% endblock %}
</ul> </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 %} {% endblock %}

View File

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

71
templates/table.html Normal file
View File

@ -0,0 +1,71 @@
{% 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>
<tr class="day-totals">
<th colspan="2">TOTAL</th>
</tr>
</tbody>
</table>
{% endblock %}