24 Commits

Author SHA1 Message Date
8393855514 Merge pull request 'v0.1.3: minor fixes' (#4) from dev into main
Reviewed-on: #4
2025-03-02 15:10:42 +00:00
756b51309a bumped to version 0.1.3 2025-03-02 16:09:09 +01:00
cef66f2bd4 made parent empty lines hidden 2025-03-02 16:08:14 +01:00
2aa80e094a fixed daily totals only for productive parents 2025-03-02 14:46:24 +01:00
f48cbdb03a Merge pull request 'v0.1.2: Minor ui improvements' (#3) from dev into main
Reviewed-on: #3
2025-03-01 13:25:13 +00:00
57231c0d34 Merge pull request 'minor ui improvements' (#2) from feat/ui-improvements-1 into dev
Reviewed-on: #2
2025-03-01 13:23:52 +00:00
842d0ff11e bumped version to 0.1.2 2025-03-01 14:21:22 +01:00
87005efcf5 added weekly total + highlight clocking inconsistencies 2025-03-01 14:20:16 +01:00
7f845dcb1d made empty project lines hidden 2025-03-01 13:40:24 +01:00
9e2566ba03 updated app version to 0.1.1 2025-03-01 13:13:02 +01:00
af938f405d Merge pull request 'fixed minor issue with remote duration conversion' (#1) from dev into main
Reviewed-on: #1
2025-02-23 20:47:53 +00:00
c090b611e2 fixed minor issue with remote duration conversion 2025-02-23 21:45:51 +01:00
9d33377ac1 added README.md + main.py 2025-02-15 12:20:52 +01:00
72ad6ec081 added 404 page + prepared for production setup 2025-02-14 16:11:03 +01:00
4f5f12473d completely removed weekly stats + added .gitignore 2025-02-14 15:05:09 +01:00
cce82f02af removed weekly stats 2025-02-14 15:02:16 +01:00
58a8ae750a fixed duration conversion 2025-02-12 13:53:17 +01:00
b24cb55ba8 added theory/real SageX hours difference row 2025-02-04 17:06:34 +01:00
eb0cab6295 added real sagex hours + changed to DurationField 2025-02-04 00:19:05 +01:00
6777207f4e reworked dashboard with table summary 2025-02-03 12:06:59 +01:00
aeed1f5fbe added new project form 2025-02-02 23:19:18 +01:00
092812b9b7 added project delete + changed to list template 2025-02-02 23:01:17 +01:00
35d5a81e6f added parent delete popup + endpoint 2025-02-02 22:43:52 +01:00
8b0b56cb7b added new parent page + minor css/js improvements 2025-02-02 21:48:12 +01:00
41 changed files with 1179 additions and 243 deletions

3
.env.template Normal file
View File

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

3
.gitignore vendored Normal file
View File

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

87
README.md Normal file
View File

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

View File

@ -2,8 +2,18 @@ from datetime import datetime
class DateConverter:
regex = '\d{4}-\d{1,2}-\d{1,2}'
format = '%Y-%m-%d'
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()

View File

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

View File

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

View File

@ -1,8 +1,9 @@
from TimeDispatcher.settings import APP_VERSION
from TimeDispatcher.settings import APP_VERSION, DEBUG
def version(request):
return {
"debug": DEBUG,
"version": APP_VERSION
}

View File

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

View File

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

View File

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

View File

@ -1,3 +1,5 @@
from datetime import timedelta
from django.db import models
class Parent(models.Model):
@ -39,4 +41,15 @@ class Clocking(models.Model):
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.TimeField(null=True, default=None)
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")
]

View File

@ -3,7 +3,8 @@ from datetime import datetime, timedelta, time
from rest_framework import serializers
from rest_framework.serializers import ModelSerializer
from dispatcher.models import Clocking
from dispatcher.core import convert_timedelta
from dispatcher.models import Clocking, RealSageXHours
class ClockingSerializer(ModelSerializer):
@ -24,8 +25,19 @@ class ClockingSerializer(ModelSerializer):
out_pm = datetime.combine(obj.date, obj.out_pm)
total += out_pm - in_pm
if obj.remote is not None:
total += timedelta(hours=obj.remote.hour, minutes=obj.remote.minute)
total += obj.remote
seconds = total.seconds
minutes = seconds // 60
return minutes
class RealSageXHoursSerializer(ModelSerializer):
hours = serializers.SerializerMethodField()
class Meta:
model = RealSageXHours
fields = "__all__"
def get_hours(self, obj: RealSageXHours):
return convert_timedelta(obj.hours)

View File

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

View File

@ -55,9 +55,15 @@ function formatDate(date) {
}
function formatDuration(duration) {
let res = ""
if (duration < 0) {
duration *= -1
res += "-"
}
let hours = Math.floor(duration / 60)
duration -= hours * 60
let res = ""
duration = Math.round(duration)
if (hours > 0) {
res += hours.toString() + "h"
res += duration.toString().padStart(2, "0")
@ -72,7 +78,7 @@ function formatPercentage(ratio) {
return percentage + "%"
}
function req(url, options) {
function req(url, options = {}) {
let headers = options.headers || {}
let csrftoken = document.querySelector("input[name='csrfmiddlewaretoken']").value
headers["X-CSRFToken"] = csrftoken

View File

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

View File

@ -1,5 +1,4 @@
let prevMonthBtn, nextMonthBtn, month
let prevWeekBtn, nextWeekBtn, week
let curYear = new Date().getFullYear()
let curMonth = new Date().getMonth()
let curWeekDate = new Date()
@ -10,7 +9,7 @@ function prevMonth() {
curMonth += 12
curYear -= 1
}
updateListMonthly()
updateTableMonthly()
}
function nextMonth() {
curMonth = curMonth + 1
@ -18,18 +17,10 @@ function nextMonth() {
curMonth -= 12
curYear += 1
}
updateListMonthly()
}
function prevWeek() {
curWeekDate = new Date(curWeekDate.valueOf() - 7 * DAY_MS)
updateListWeekly()
}
function nextWeek() {
curWeekDate = new Date(curWeekDate.valueOf() + 7 * DAY_MS)
updateListWeekly()
updateTableMonthly()
}
function updateListMonthly() {
function updateTableMonthly() {
let year = curYear.toString().padStart(4, "0")
let month = (curMonth + 1).toString().padStart(2, "0")
let today = new Date()
@ -45,50 +36,142 @@ function updateListMonthly() {
if (res.status !== "success") {
return
}
setListElements(res.data.parents)
updateTable(res.data)
})
}
function updateListWeekly() {
let weekNum = curWeekDate.getWeek()
let weekDay = (curWeekDate.getDay() + 6) % 7
let startDate = new Date(curWeekDate.valueOf() - weekDay * DAY_MS)
let endDate = new Date(startDate.valueOf() + 6 * DAY_MS)
function updateTable(data) {
let totalWorked = data.clockings.map(c => c.total).reduce((a, b) => a + b, 0)
let today = new Date()
let date = `${MONTHS[startDate.getMonth()]} ${startDate.getDate()}`
if (startDate.getFullYear() !== today.getFullYear()) {
date += " " + startDate.getFullYear().toString().padStart(4, "0")
let parents = data.parents.filter(parent => parent.duration !== null && parent.is_productive)
let headers = document.getElementById("headers-table")
let table = document.getElementById("projects-table")
let projNames = table.querySelector(".project-names")
let projNums = table.querySelector(".project-nums")
let totDurations = table.querySelector(".total-durations")
let workingRatios = table.querySelector(".working-time-ratios")
let imputedRatios = table.querySelector(".imputed-time-ratios")
let sagexHours = table.querySelector(".sagex-hours")
let realSagexHours = table.querySelector(".real-sagex-hours")
let differences = table.querySelector(".sagex-diff")
table.querySelectorAll("tr").forEach(row => row.innerHTML = "")
let totalImputed = 0
parents.forEach(parent => {
totalImputed += +parent.duration
})
headers.querySelector(".total-clocking").innerText = formatDuration(totalWorked)
headers.querySelector(".total-duration").innerText = formatDuration(totalImputed)
let sagexHoursTemplate = getTemplate("sagex-hours")
let totalSagex = 0
let totalRealSagex = 0
parents.forEach(parent => {
let duration = +parent.duration
let name = document.createElement("th")
name.innerText = parent.name
projNames.appendChild(name)
projNums.insertCell(-1).innerText = parent.project_num
table.querySelector(".clocking").insertCell(-1)
totDurations.insertCell(-1).innerText = formatDuration(duration)
let ratioWorking = duration / totalWorked
let ratioImputed = duration / totalImputed
workingRatios.insertCell(-1).innerText = formatPercentage(ratioWorking)
imputedRatios.insertCell(-1).innerText = formatPercentage(ratioImputed)
let sagexDuration = duration * totalWorked / totalImputed
totalSagex += sagexDuration
let sagexCell = sagexHours.insertCell(-1)
sagexCell.innerText = formatDuration(sagexDuration)
sagexCell.dataset.duration = sagexDuration
let td = sagexHoursTemplate.cloneNode(true)
let [h, m, s] = parent.real_sagex_hours.split(":")
let hoursInput = td.querySelector("input.hours")
let minutesInput = td.querySelector("input.minutes")
hoursInput.value = h
minutesInput.value = m
let timeout = null
minutesInput.addEventListener("input", () => reformatTime(minutesInput))
td.addEventListener("change", () => {
if (timeout !== null) {
clearTimeout(timeout)
}
timeout = setTimeout(() => {
updateSagex(parent.id, td)
}, 1000)
})
realSagexHours.appendChild(td)
let real = (+h)*60 + (+m)
let diff = real - sagexDuration
differences.insertCell(-1).innerText = formatDuration(diff)
totalRealSagex += real
})
headers.querySelector(".sagex-hours-total").innerText = formatDuration(totalSagex)
headers.querySelector(".real-sagex-hours-total").innerText = formatDuration(totalRealSagex)
headers.querySelector(".sagex-diff-total").innerText = formatDuration(totalRealSagex - totalSagex)
}
document.getElementById("week").innerText = `Week ${weekNum} (${date})`
fetch(`stats/between/${formatDate(startDate)}/${formatDate(endDate)}/`).then(res => {
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") {
return
}
setListElements(res.data.parents)
})
}
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")
function setListElements(data) {
data = data.filter(parent => parent.duration !== null)
let list = document.getElementById("list")
let template = document.querySelector(".by-range .template.row").cloneNode(true)
template.classList.remove("template")
list.innerHTML = ""
if (data.length === 0) {
let noData = document.querySelector(".by-range .template.no-data").cloneNode(true)
noData.classList.remove("template")
list.appendChild(noData)
return
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)
}
data.forEach(parent => {
let row = template.cloneNode(true)
row.querySelector(".name").innerText = `${parent.name} (${parent.project_num})`
row.querySelector(".duration").innerText = formatDuration(parent.duration)
list.appendChild(row)
})
}
@ -97,31 +180,8 @@ window.addEventListener("load", () => {
nextMonthBtn = document.getElementById("next-month")
month = document.getElementById("month")
prevWeekBtn = document.getElementById("prev-week")
nextWeekBtn = document.getElementById("next-week")
week = document.getElementById("week")
prevMonthBtn.addEventListener("click", () => prevMonth())
nextMonthBtn.addEventListener("click", () => nextMonth())
prevWeekBtn.addEventListener("click", () => prevWeek())
nextWeekBtn.addEventListener("click", () => nextWeek())
let monthGrp = document.getElementById("month-sel")
let weekGrp = document.getElementById("week-sel")
rangeSel = document.getElementById("range-sel")
rangeSel.addEventListener("change", () => {
let mode = rangeSel.value
if (mode === "weekly") {
monthGrp.classList.add("hidden")
weekGrp.classList.remove("hidden")
updateListWeekly()
} else {
monthGrp.classList.remove("hidden")
weekGrp.classList.add("hidden")
updateListMonthly()
}
})
updateListMonthly()
updateTableMonthly()
})

View File

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

View File

@ -42,7 +42,7 @@ form input[type="checkbox"] {
form input[type="checkbox"]:checked::after {
content: "";
background-color: #69c935;
background-color: var(--accent);
width: 80%;
height: 80%;
border-radius: 10%;

View File

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

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

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

BIN
dispatcher/static/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

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

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

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

View File

@ -1,8 +1,11 @@
window.addEventListener("load", () => {
document.querySelectorAll(".list li").forEach(row => {
let id = row.dataset.id
row.querySelector("button.edit").addEventListener("click", () => {
window.location.href = `${id}/`
})
})
onBeforeDelete = async row => {
await req(`${row.dataset.id}/on_delete/`).then(res => {
return res.json()
}).then(res => {
if (res.status === "success") {
let popup = document.getElementById("delete-popup")
popup.querySelector(".project-count").innerText = res.projects
popup.querySelector(".task-count").innerText = res.tasks
}
})
}

View File

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

View File

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

View File

@ -1,3 +1,7 @@
:root {
--invalid-col: #f1232394;
}
#table {
border-collapse: collapse;
font-size: 80%;
@ -49,6 +53,11 @@
border-top: solid #71717185 1px;
td {
padding: 0.4em;
font-style: italic;
&.invalid {
background-color: var(--invalid-col);
font-weight: bold;
}
}
}
@ -85,6 +94,17 @@
border-bottom: var(--border);
}
.day-totals {
td {
text-align: right;
font-style: italic;
&.invalid {
background-color: var(--invalid-col);
font-weight: bold;
}
}
}
col.day-5, col.day-6 {
background-color: var(--dark3);
}

View File

@ -9,6 +9,7 @@ class Table {
this.weekNames = elmt.querySelector(".week-names")
this.dayNames = elmt.querySelector(".day-names")
this.dayDates = elmt.querySelector(".day-dates")
this.dayTotals = elmt.querySelector(".day-totals")
this.columns = elmt.querySelector(".columns")
this.timeInputTemplate = getTemplate("time-input")
@ -22,6 +23,7 @@ class Table {
this.endDate = today
this.totalProjects = 0
this.dailyTotals = []
this.clockingTotals = []
this.clockingRemotes = []
@ -67,6 +69,7 @@ class Table {
this.weekNames.querySelectorAll("td").forEach(c => c.remove())
this.dayNames.querySelectorAll("td").forEach(c => c.remove())
this.dayDates.innerHTML = ""
this.dayTotals.querySelectorAll("td").forEach(c => c.remove())
this.times.querySelectorAll("tr.project").forEach(r => r.remove())
this.nDays = 0
}
@ -112,6 +115,7 @@ class Table {
this.addClockings(date)
let dayName = this.dayNames.insertCell(this.nDays + 2)
let dayDate = this.dayDates.insertCell(this.nDays)
this.dayTotals.insertCell(this.nDays + 1)
let weekDay = (date.getDay() + 6) % 7
dayName.innerText = DAYS_SHORT[weekDay]
dayDate.innerText = `${dayPadded}/${monthPadded}`
@ -211,25 +215,33 @@ class Table {
displayData(data) {
this.displayClockings(data.clockings)
this.totalProjects = 0
this.dailyTotals = Array(this.nDays).fill(0)
data.parents.forEach(parent => {
let parentDurations = Array(this.nDays).fill(0)
let projects = parent.projects.map(project => {
let durations = Array(this.nDays).fill("")
let total = 0
project.tasks.forEach(task => {
let date = new Date(task.date)
let i = Math.floor((date.valueOf() - this.startDate.valueOf()) / DAY_MS)
let hours = task.duration / 60
durations[i] = hours
total += hours
parentDurations[i] += hours
if (parent.is_productive) {
this.dailyTotals[i] += hours
}
})
return {
id: project.id,
name: project.name,
durations: durations
durations: durations,
total: total
}
})
if (parentDurations.reduce((a, b) => a + b, 0) !== 0) {
this.addProject(
parent.id,
parent.name,
@ -239,9 +251,10 @@ class Table {
parent.is_productive
)
projects.forEach(project => {
projects.filter(p => p.total !== 0).forEach(project => {
this.addProject(project.id, project.name, "", project.durations)
})
}
})
this.updateTotals()
}
@ -334,6 +347,24 @@ class Table {
let sagexTime = imputedTimeRatio * totalClockings
parent.cells[this.nDays + 5].innerText = Math.round(sagexTime * 100) / 100
})
for (let i = 0; i < this.nDays; i++) {
let total = this.dailyTotals[i]
let cell = this.dayTotals.cells[i + 1]
if (total === 0) {
cell.innerText = ""
} else {
cell.innerText = Math.round(total * 100) / 100
}
let clocking = this.clockings.querySelector(".clocking.total").cells[i + 1]
if (total > this.clockingTotals[i]) {
clocking.classList.add("invalid")
cell.classList.add("invalid")
} else {
clocking.classList.remove("invalid")
cell.classList.remove("invalid")
}
}
}
post(endpoint, data) {

View File

@ -1,17 +1,19 @@
import datetime
from datetime import timedelta
from django.db.models import Sum, Q, QuerySet
from django.db.models import Sum, Q, QuerySet, Subquery, OuterRef, DurationField, Value, DecimalField
from django.db.models.functions import Coalesce
from django.http import JsonResponse
from django.shortcuts import render, get_object_or_404, redirect
from django.views import generic
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from rest_framework.status import HTTP_400_BAD_REQUEST
from dispatcher.core import import_tasks
from dispatcher.core import import_tasks, convert_timedelta, str_to_timedelta
from dispatcher.forms import ProjectForm, ImportForm, ParentForm
from dispatcher.models import Project, Parent, Clocking
from dispatcher.serializers import ClockingSerializer
from dispatcher.models import Project, Parent, Clocking, Task, RealSageXHours
from dispatcher.serializers import ClockingSerializer, RealSageXHoursSerializer
def dashboard_view(request):
@ -23,6 +25,7 @@ def dashboard_view(request):
def projects_view(request):
context = {
"class_name": "parent",
"projects": Project.objects.all(),
"parents": Parent.objects.all()
}
@ -31,6 +34,11 @@ def projects_view(request):
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
@ -38,11 +46,48 @@ def parent_view(request, id):
form = ParentForm(request.POST or None, request.FILES or None, instance=parent)
if form.is_valid():
form.save()
return redirect("parents")
context["form"] = ParentForm(instance=parent)
return render(request, "edit.html", context)
def parent_on_delete_view(request, id):
parent = get_object_or_404(Parent, id=id)
projects = parent.project_set.count()
tasks = Task.objects.filter(project__parent=parent).count()
return JsonResponse({
"status": "success",
"projects": projects,
"tasks": tasks
})
def project_on_delete_view(request, id):
project = get_object_or_404(Project, id=id)
tasks = project.task_set.count()
return JsonResponse({
"status": "success",
"tasks": tasks
})
def new_parent_view(request):
context = {
"class": "parent"
}
form = ParentForm(request.POST or None, request.FILES or None)
if form.is_valid():
form.save()
return redirect("parents")
context["form"] = form
return render(request, "add.html", context)
def project_view(request, id):
project = get_object_or_404(Project, id=id)
if request.method == "DELETE":
project.delete()
return JsonResponse({"status": "success"})
context = {
"class": "project",
"id": id
@ -53,13 +98,24 @@ def project_view(request, id):
context["form"] = ProjectForm(instance=project)
return render(request, "edit.html", context)
def new_project_view(request):
context = {
"class": "project"
}
form = ProjectForm(request.POST or None, request.FILES or None)
if form.is_valid():
form.save()
return redirect("projects")
context["form"] = form
return render(request, "add.html", context)
def table_view(request):
return render(request, "table.html")
@require_POST
def set_parent(request, id):
project = get_object_or_404(Project, id=id)
parent_id = request.POST.get("parent_id")
parent_id = request.POST.get("parent_id") or None
try:
parent = Parent.objects.get(id=parent_id)
except Parent.DoesNotExist:
@ -84,6 +140,12 @@ def get_stats_by_month(request, year: int, month: int):
if month < 1 or month > 12:
return JsonResponse({"status": "error", "error": f"Invalid month {month}"})
sagex_subquery = RealSageXHours.objects.filter(
parent=OuterRef("pk"),
date__year=year,
date__month=month
).values("hours")[:1]
parents = Parent.objects.annotate(
total_duration=Sum(
"project__task__duration",
@ -92,6 +154,11 @@ def get_stats_by_month(request, year: int, month: int):
project__task__date__month=month
)
)
).annotate(
real_sagex_hours=Coalesce(
Subquery(sagex_subquery, output_field=DurationField()),
timedelta(0)
)
)
projects = Project.objects.annotate(
total_duration=Sum(
@ -102,37 +169,22 @@ def get_stats_by_month(request, year: int, month: int):
)
)
)
return JsonResponse({"status": "success", "data": format_stats(parents, projects)})
clockings = Clocking.objects.filter(
date__year=year,
date__month=month
)
return JsonResponse({"status": "success", "data": format_stats(parents, projects, clockings)})
def get_stats_between(request, start_date: datetime.date, end_date: datetime.date):
parents = Parent.objects.annotate(
total_duration=Sum(
"project__task__duration",
filter=Q(
project__task__date__gte=start_date,
project__task__date__lt=end_date + timedelta(days=1)
)
)
)
projects = Project.objects.annotate(
total_duration=Sum(
"task__duration",
filter=Q(
task__date__gte=start_date,
task__date__lt=end_date + timedelta(days=1)
)
)
)
return JsonResponse({"status": "success", "data": format_stats(parents, projects)})
def format_stats(parents: QuerySet[Parent], projects: QuerySet[Project]):
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,
"duration": parent.total_duration
"is_productive": parent.is_productive,
"duration": parent.total_duration,
"real_sagex_hours": convert_timedelta(parent.real_sagex_hours)
}
for parent in parents
],
@ -143,8 +195,10 @@ def format_stats(parents: QuerySet[Parent], projects: QuerySet[Project]):
"duration": project.total_duration
}
for project in projects
]
],
"clockings": ClockingSerializer(clockings, many=True).data
}
Clocking.objects.filter()
return data
class ParentsView(generic.ListView):
@ -152,6 +206,17 @@ class ParentsView(generic.ListView):
template_name = "parents.html"
context_object_name = "elements"
class ProjectsView(generic.ListView):
model = Project
template_name = "projects.html"
context_object_name = "elements"
ordering = ["parent"]
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["parents"] = Parent.objects.all()
return context
def get_table_data(request, start_date: datetime.date, end_date: datetime.date):
end_date = end_date + timedelta(days=1)
@ -200,7 +265,13 @@ def set_clocking(request, date: datetime.date):
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
clocking.remote = request.POST.get("remote", clocking.remote) 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()
@ -208,3 +279,33 @@ def set_clocking(request, date: datetime.date):
"status": "success",
"clocking": ClockingSerializer(clocking).data
})
@require_POST
def set_real_sagex(request, id, month: datetime.date):
parent = get_object_or_404(Parent, id=id)
minutes = request.POST.get("minutes")
if minutes is None:
return JsonResponse({
"status": "error",
"error": "Missing minutes field"
}, status=HTTP_400_BAD_REQUEST)
try:
minutes = int(minutes)
except ValueError:
return JsonResponse({
"status": "error",
"error": "Invalid value for minutes, must be an int"
}, status=HTTP_400_BAD_REQUEST)
hours, minutes = divmod(minutes, 60)
entry, created = RealSageXHours.objects.get_or_create(parent=parent, date=month)
entry.hours = timedelta(hours=int(hours), minutes=int(minutes))
entry.save()
entry.refresh_from_db()
return JsonResponse({
"status": "success",
"sagex": RealSageXHoursSerializer(entry).data
})

16
main.py Normal file
View File

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

View File

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

13
templates/404.html Normal file
View File

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

16
templates/add.html Normal file
View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@
{% load static %}
{% block title %}Editing {{ class }} {{ id }}{% endblock %}
{% block head %}
<link rel="stylesheet" href="{% static "edit.css" %}">
<link rel="stylesheet" href="{% static "form.css" %}">
{% endblock %}
{% block main %}
<div class="container">

View File

@ -1,6 +1,12 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Import tasks{% endblock %}
{% block head %}
<link rel="stylesheet" href="{% static "form.css" %}">
{% endblock %}
{% block main %}
<div class="container">
<h1>Import tasks</h1>
<form action="" method="POST" enctype="multipart/form-data">
{% csrf_token %}
<div>
@ -9,4 +15,5 @@
</div>
<button type="submit">Import</button>
</form>
</div>
{% endblock %}

View File

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

View File

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

View File

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

View File

@ -63,6 +63,9 @@
<th rowspan="2">N° sagex</th>
</tr>
<tr class="day-dates"></tr>
<tr class="day-totals">
<th colspan="2">Total Productive</th>
</tr>
</tbody>
</table>
{% endblock %}