Compare commits
14 Commits
4f5f12473d
...
v0.1.3
Author | SHA1 | Date | |
---|---|---|---|
8393855514 | |||
756b51309a
|
|||
cef66f2bd4
|
|||
2aa80e094a
|
|||
f48cbdb03a | |||
57231c0d34 | |||
842d0ff11e
|
|||
87005efcf5
|
|||
7f845dcb1d
|
|||
9e2566ba03
|
|||
af938f405d | |||
c090b611e2
|
|||
9d33377ac1
|
|||
72ad6ec081
|
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
3
.gitignore
vendored
@ -1,2 +1,3 @@
|
|||||||
__pycache__
|
__pycache__
|
||||||
db.sqlite3
|
/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
|
||||||
|
```
|
@ -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.3"
|
||||||
|
|
||||||
|
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 = ["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
|
# 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,9 +14,10 @@ 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, register_converter
|
from django.views.static import serve
|
||||||
|
|
||||||
|
from TimeDispatcher import settings
|
||||||
from TimeDispatcher.converters import DateConverter, YearMonthConverter
|
from TimeDispatcher.converters import DateConverter, YearMonthConverter
|
||||||
from dispatcher import views
|
from dispatcher import views
|
||||||
|
|
||||||
@ -41,3 +42,6 @@ urlpatterns = [
|
|||||||
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"),
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -77,6 +77,12 @@ 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;
|
||||||
|
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;
|
||||||
|
}
|
@ -1,3 +1,7 @@
|
|||||||
|
:root {
|
||||||
|
--invalid-col: #f1232394;
|
||||||
|
}
|
||||||
|
|
||||||
#table {
|
#table {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 80%;
|
font-size: 80%;
|
||||||
@ -49,6 +53,11 @@
|
|||||||
border-top: solid #71717185 1px;
|
border-top: solid #71717185 1px;
|
||||||
td {
|
td {
|
||||||
padding: 0.4em;
|
padding: 0.4em;
|
||||||
|
font-style: italic;
|
||||||
|
&.invalid {
|
||||||
|
background-color: var(--invalid-col);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -85,6 +94,17 @@
|
|||||||
border-bottom: var(--border);
|
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 {
|
col.day-5, col.day-6 {
|
||||||
background-color: var(--dark3);
|
background-color: var(--dark3);
|
||||||
}
|
}
|
||||||
|
@ -9,6 +9,7 @@ class Table {
|
|||||||
this.weekNames = elmt.querySelector(".week-names")
|
this.weekNames = elmt.querySelector(".week-names")
|
||||||
this.dayNames = elmt.querySelector(".day-names")
|
this.dayNames = elmt.querySelector(".day-names")
|
||||||
this.dayDates = elmt.querySelector(".day-dates")
|
this.dayDates = elmt.querySelector(".day-dates")
|
||||||
|
this.dayTotals = elmt.querySelector(".day-totals")
|
||||||
this.columns = elmt.querySelector(".columns")
|
this.columns = elmt.querySelector(".columns")
|
||||||
|
|
||||||
this.timeInputTemplate = getTemplate("time-input")
|
this.timeInputTemplate = getTemplate("time-input")
|
||||||
@ -22,6 +23,7 @@ class Table {
|
|||||||
this.endDate = today
|
this.endDate = today
|
||||||
|
|
||||||
this.totalProjects = 0
|
this.totalProjects = 0
|
||||||
|
this.dailyTotals = []
|
||||||
this.clockingTotals = []
|
this.clockingTotals = []
|
||||||
this.clockingRemotes = []
|
this.clockingRemotes = []
|
||||||
|
|
||||||
@ -67,6 +69,7 @@ class Table {
|
|||||||
this.weekNames.querySelectorAll("td").forEach(c => c.remove())
|
this.weekNames.querySelectorAll("td").forEach(c => c.remove())
|
||||||
this.dayNames.querySelectorAll("td").forEach(c => c.remove())
|
this.dayNames.querySelectorAll("td").forEach(c => c.remove())
|
||||||
this.dayDates.innerHTML = ""
|
this.dayDates.innerHTML = ""
|
||||||
|
this.dayTotals.querySelectorAll("td").forEach(c => c.remove())
|
||||||
this.times.querySelectorAll("tr.project").forEach(r => r.remove())
|
this.times.querySelectorAll("tr.project").forEach(r => r.remove())
|
||||||
this.nDays = 0
|
this.nDays = 0
|
||||||
}
|
}
|
||||||
@ -112,6 +115,7 @@ class Table {
|
|||||||
this.addClockings(date)
|
this.addClockings(date)
|
||||||
let dayName = this.dayNames.insertCell(this.nDays + 2)
|
let dayName = this.dayNames.insertCell(this.nDays + 2)
|
||||||
let dayDate = this.dayDates.insertCell(this.nDays)
|
let dayDate = this.dayDates.insertCell(this.nDays)
|
||||||
|
this.dayTotals.insertCell(this.nDays + 1)
|
||||||
let weekDay = (date.getDay() + 6) % 7
|
let weekDay = (date.getDay() + 6) % 7
|
||||||
dayName.innerText = DAYS_SHORT[weekDay]
|
dayName.innerText = DAYS_SHORT[weekDay]
|
||||||
dayDate.innerText = `${dayPadded}/${monthPadded}`
|
dayDate.innerText = `${dayPadded}/${monthPadded}`
|
||||||
@ -211,37 +215,46 @@ class Table {
|
|||||||
displayData(data) {
|
displayData(data) {
|
||||||
this.displayClockings(data.clockings)
|
this.displayClockings(data.clockings)
|
||||||
this.totalProjects = 0
|
this.totalProjects = 0
|
||||||
|
this.dailyTotals = Array(this.nDays).fill(0)
|
||||||
data.parents.forEach(parent => {
|
data.parents.forEach(parent => {
|
||||||
let parentDurations = Array(this.nDays).fill(0)
|
let parentDurations = Array(this.nDays).fill(0)
|
||||||
|
|
||||||
let projects = parent.projects.map(project => {
|
let projects = parent.projects.map(project => {
|
||||||
let durations = Array(this.nDays).fill("")
|
let durations = Array(this.nDays).fill("")
|
||||||
|
let total = 0
|
||||||
project.tasks.forEach(task => {
|
project.tasks.forEach(task => {
|
||||||
let date = new Date(task.date)
|
let date = new Date(task.date)
|
||||||
let i = Math.floor((date.valueOf() - this.startDate.valueOf()) / DAY_MS)
|
let i = Math.floor((date.valueOf() - this.startDate.valueOf()) / DAY_MS)
|
||||||
let hours = task.duration / 60
|
let hours = task.duration / 60
|
||||||
durations[i] = hours
|
durations[i] = hours
|
||||||
|
total += hours
|
||||||
parentDurations[i] += hours
|
parentDurations[i] += hours
|
||||||
|
if (parent.is_productive) {
|
||||||
|
this.dailyTotals[i] += hours
|
||||||
|
}
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
id: project.id,
|
id: project.id,
|
||||||
name: project.name,
|
name: project.name,
|
||||||
durations: durations
|
durations: durations,
|
||||||
|
total: total
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
this.addProject(
|
if (parentDurations.reduce((a, b) => a + b, 0) !== 0) {
|
||||||
parent.id,
|
this.addProject(
|
||||||
parent.name,
|
parent.id,
|
||||||
parent.project_num,
|
parent.name,
|
||||||
parentDurations.map(v => v === 0 ? "" : v),
|
parent.project_num,
|
||||||
true,
|
parentDurations.map(v => v === 0 ? "" : v),
|
||||||
parent.is_productive
|
true,
|
||||||
)
|
parent.is_productive
|
||||||
|
)
|
||||||
|
|
||||||
projects.forEach(project => {
|
projects.filter(p => p.total !== 0).forEach(project => {
|
||||||
this.addProject(project.id, project.name, "", project.durations)
|
this.addProject(project.id, project.name, "", project.durations)
|
||||||
})
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
this.updateTotals()
|
this.updateTotals()
|
||||||
}
|
}
|
||||||
@ -334,6 +347,24 @@ class Table {
|
|||||||
let sagexTime = imputedTimeRatio * totalClockings
|
let sagexTime = imputedTimeRatio * totalClockings
|
||||||
parent.cells[this.nDays + 5].innerText = Math.round(sagexTime * 100) / 100
|
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) {
|
post(endpoint, data) {
|
||||||
|
@ -267,7 +267,8 @@ def set_clocking(request, date: datetime.date):
|
|||||||
clocking.out_pm = request.POST.get("out_pm", clocking.out_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
|
remote = request.POST.get("remote", clocking.remote) or None
|
||||||
if remote is not None:
|
if remote is not None:
|
||||||
remote = str_to_timedelta(remote)
|
if isinstance(remote, str):
|
||||||
|
remote = str_to_timedelta(remote)
|
||||||
else:
|
else:
|
||||||
remote = timedelta()
|
remote = timedelta()
|
||||||
clocking.remote = remote
|
clocking.remote = remote
|
||||||
@ -307,4 +308,4 @@ def set_real_sagex(request, id, month: datetime.date):
|
|||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"sagex": RealSageXHoursSerializer(entry).data
|
"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,2 +1,4 @@
|
|||||||
Django~=5.1.5
|
Django~=5.1.5
|
||||||
djangorestframework~=3.15.2
|
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 %}
|
@ -22,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>
|
||||||
|
@ -63,6 +63,9 @@
|
|||||||
<th rowspan="2">N° sagex</th>
|
<th rowspan="2">N° sagex</th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="day-dates"></tr>
|
<tr class="day-dates"></tr>
|
||||||
|
<tr class="day-totals">
|
||||||
|
<th colspan="2">Total Productive</th>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{% endblock %}
|
{% endblock %}
|
Reference in New Issue
Block a user