Compare commits
21 Commits
500ff54537
...
v0.1.1
Author | SHA1 | Date | |
---|---|---|---|
af938f405d | |||
c090b611e2
|
|||
9d33377ac1
|
|||
72ad6ec081
|
|||
4f5f12473d
|
|||
cce82f02af
|
|||
58a8ae750a
|
|||
b24cb55ba8
|
|||
eb0cab6295
|
|||
6777207f4e
|
|||
aeed1f5fbe
|
|||
092812b9b7
|
|||
35d5a81e6f
|
|||
8b0b56cb7b
|
|||
6ff3f05272
|
|||
3b4ae7a55a
|
|||
87607da643
|
|||
d13127a273
|
|||
ee96598d92
|
|||
0380d18cd7
|
|||
01db4285c2
|
3
.env.template
Normal file
3
.env.template
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
DJANGO_SECRET_KEY=
|
||||||
|
DJANGO_ENV=prod
|
||||||
|
DJANGO_HOSTS=localhost,127.0.0.1
|
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
__pycache__
|
||||||
|
/db.sqlite3
|
||||||
|
/.env
|
87
README.md
Normal file
87
README.md
Normal 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
|
||||||
|
```
|
22
TimeDispatcher/converters.py
Normal file
22
TimeDispatcher/converters.py
Normal 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)
|
@ -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.0"
|
||||||
|
|
||||||
|
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
|
||||||
|
@ -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}))
|
@ -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'},
|
||||||
|
@ -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:]:
|
||||||
|
@ -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
|
||||||
|
25
dispatcher/migrations/0005_clocking.py
Normal file
25
dispatcher/migrations/0005_clocking.py
Normal 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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
18
dispatcher/migrations/0006_alter_parent_project_num.py
Normal file
18
dispatcher/migrations/0006_alter_parent_project_num.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
18
dispatcher/migrations/0007_parent_is_productive.py
Normal file
18
dispatcher/migrations/0007_parent_is_productive.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
@ -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')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
@ -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
43
dispatcher/serializers.py
Normal 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)
|
@ -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;
|
||||||
}
|
}
|
@ -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
|
||||||
|
}
|
@ -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% {
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
@ -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()
|
||||||
})
|
})
|
20
dispatcher/static/error.css
Normal file
20
dispatcher/static/error.css
Normal 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;
|
||||||
|
}
|
53
dispatcher/static/form.css
Normal file
53
dispatcher/static/form.css
Normal 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%);
|
||||||
|
}
|
@ -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
55
dispatcher/static/list.js
Normal 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
BIN
dispatcher/static/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.3 KiB |
99
dispatcher/static/logo.svg
Normal file
99
dispatcher/static/logo.svg
Normal 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 |
3
dispatcher/static/parents.css
Normal file
3
dispatcher/static/parents.css
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
.productive {
|
||||||
|
box-shadow: 0.2em 0 0 var(--accent) inset;
|
||||||
|
}
|
11
dispatcher/static/parents.js
Normal file
11
dispatcher/static/parents.js
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
@ -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);
|
||||||
}
|
}
|
||||||
|
*/
|
@ -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}/`
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
161
dispatcher/static/table.css
Normal file
161
dispatcher/static/table.css
Normal 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
362
dispatcher/static/table.js
Normal 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())
|
||||||
|
})
|
@ -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
16
main.py
Normal 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()
|
@ -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
13
templates/404.html
Normal 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
16
templates/add.html
Normal 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 %}
|
@ -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>
|
||||||
|
@ -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
16
templates/edit.html
Normal 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 %}
|
@ -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 %}
|
@ -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 %}
|
@ -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 %}
|
||||||
|
@ -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 %}
|
|
@ -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 %}
|
|
||||||
|
68
templates/table.html
Normal file
68
templates/table.html
Normal 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 %}
|
Reference in New Issue
Block a user