added real sagex hours + changed to DurationField
This commit is contained in:
parent
6777207f4e
commit
eb0cab6295
@ -2,11 +2,21 @@ from datetime import datetime
|
|||||||
|
|
||||||
|
|
||||||
class DateConverter:
|
class DateConverter:
|
||||||
regex = '\d{4}-\d{1,2}-\d{1,2}'
|
regex = r"\d{4}-\d{1,2}-\d{1,2}"
|
||||||
format = '%Y-%m-%d'
|
format = r"%Y-%m-%d"
|
||||||
|
|
||||||
def to_python(self, value):
|
def to_python(self, value):
|
||||||
return datetime.strptime(value, self.format).date()
|
return datetime.strptime(value, self.format).date()
|
||||||
|
|
||||||
def to_url(self, value):
|
def to_url(self, value):
|
||||||
return value.strftime(self.format)
|
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)
|
||||||
|
@ -17,10 +17,11 @@ Including another URLconf
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import path, register_converter
|
from django.urls import path, register_converter
|
||||||
|
|
||||||
from TimeDispatcher.converters import DateConverter
|
from TimeDispatcher.converters import DateConverter, YearMonthConverter
|
||||||
from dispatcher import views
|
from dispatcher import views
|
||||||
|
|
||||||
register_converter(DateConverter, "date")
|
register_converter(DateConverter, "date")
|
||||||
|
register_converter(YearMonthConverter, "year_month")
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", views.dashboard_view, name="dashboard"),
|
path("", views.dashboard_view, name="dashboard"),
|
||||||
@ -38,5 +39,6 @@ urlpatterns = [
|
|||||||
path("projects/<int:id>/", views.project_view, name="project"),
|
path("projects/<int:id>/", views.project_view, name="project"),
|
||||||
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("stats/between/<date:start_date>/<date:end_date>/", views.get_stats_between, name="stats_between"),
|
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"),
|
||||||
]
|
]
|
||||||
|
@ -1,6 +1,15 @@
|
|||||||
|
import datetime
|
||||||
|
|
||||||
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 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:]:
|
||||||
|
@ -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,3 +1,5 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
class Parent(models.Model):
|
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")
|
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")
|
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")
|
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")
|
||||||
|
]
|
||||||
|
@ -3,7 +3,8 @@ from datetime import datetime, timedelta, time
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.serializers import ModelSerializer
|
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):
|
class ClockingSerializer(ModelSerializer):
|
||||||
@ -24,8 +25,19 @@ class ClockingSerializer(ModelSerializer):
|
|||||||
out_pm = datetime.combine(obj.date, obj.out_pm)
|
out_pm = datetime.combine(obj.date, obj.out_pm)
|
||||||
total += out_pm - in_pm
|
total += out_pm - in_pm
|
||||||
if obj.remote is not None:
|
if obj.remote is not None:
|
||||||
total += timedelta(hours=obj.remote.hour, minutes=obj.remote.minute)
|
total += obj.remote
|
||||||
|
|
||||||
seconds = total.seconds
|
seconds = total.seconds
|
||||||
minutes = seconds // 60
|
minutes = seconds // 60
|
||||||
return minutes
|
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)
|
||||||
|
@ -57,6 +57,7 @@ function formatDate(date) {
|
|||||||
function formatDuration(duration) {
|
function formatDuration(duration) {
|
||||||
let hours = Math.floor(duration / 60)
|
let hours = Math.floor(duration / 60)
|
||||||
duration -= hours * 60
|
duration -= hours * 60
|
||||||
|
duration = Math.round(duration)
|
||||||
let res = ""
|
let res = ""
|
||||||
if (hours > 0) {
|
if (hours > 0) {
|
||||||
res += hours.toString() + "h"
|
res += hours.toString() + "h"
|
||||||
|
@ -92,4 +92,51 @@
|
|||||||
|
|
||||||
#projects-table th, #projects-table td {
|
#projects-table th, #projects-table td {
|
||||||
border-left: solid var(--light4) 1px;
|
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% {
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
@ -95,6 +95,8 @@ function updateTable(data) {
|
|||||||
headers.querySelector(".total-clocking").innerText = formatDuration(totalWorked)
|
headers.querySelector(".total-clocking").innerText = formatDuration(totalWorked)
|
||||||
headers.querySelector(".total-duration").innerText = formatDuration(totalImputed)
|
headers.querySelector(".total-duration").innerText = formatDuration(totalImputed)
|
||||||
|
|
||||||
|
let sagexHoursTemplate = getTemplate("sagex-hours")
|
||||||
|
|
||||||
let totalSagex = 0
|
let totalSagex = 0
|
||||||
parents.forEach(parent => {
|
parents.forEach(parent => {
|
||||||
let duration = +parent.duration
|
let duration = +parent.duration
|
||||||
@ -112,11 +114,66 @@ function updateTable(data) {
|
|||||||
let sagexDuration = duration * totalWorked / totalImputed
|
let sagexDuration = duration * totalWorked / totalImputed
|
||||||
totalSagex += sagexDuration
|
totalSagex += sagexDuration
|
||||||
sagexHours.insertCell(-1).innerText = formatDuration(sagexDuration)
|
sagexHours.insertCell(-1).innerText = formatDuration(sagexDuration)
|
||||||
realSagexHours.insertCell(-1)
|
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)
|
||||||
})
|
})
|
||||||
headers.querySelector(".sagex-hours-total").innerText = formatDuration(totalSagex)
|
headers.querySelector(".sagex-hours-total").innerText = formatDuration(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 updateSagex(id, cell) {
|
||||||
|
let hoursInput = cell.querySelector(".hours")
|
||||||
|
let minutesInput = cell.querySelector(".minutes")
|
||||||
|
reformatTime(minutesInput)
|
||||||
|
|
||||||
|
let hours = +hoursInput.value
|
||||||
|
let minutes = +minutesInput.value
|
||||||
|
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", hours * 60 + minutes)
|
||||||
|
|
||||||
|
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})
|
||||||
|
} else {
|
||||||
|
alert(res.error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
window.addEventListener("load", () => {
|
window.addEventListener("load", () => {
|
||||||
prevMonthBtn = document.getElementById("prev-month")
|
prevMonthBtn = document.getElementById("prev-month")
|
||||||
nextMonthBtn = document.getElementById("next-month")
|
nextMonthBtn = document.getElementById("next-month")
|
||||||
|
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 |
@ -1,17 +1,19 @@
|
|||||||
import datetime
|
import datetime
|
||||||
from datetime import timedelta
|
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.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.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
|
||||||
from dispatcher.forms import ProjectForm, ImportForm, ParentForm
|
from dispatcher.forms import ProjectForm, ImportForm, ParentForm
|
||||||
from dispatcher.models import Project, Parent, Clocking, Task
|
from dispatcher.models import Project, Parent, Clocking, Task, RealSageXHours
|
||||||
from dispatcher.serializers import ClockingSerializer
|
from dispatcher.serializers import ClockingSerializer, RealSageXHoursSerializer
|
||||||
|
|
||||||
|
|
||||||
def dashboard_view(request):
|
def dashboard_view(request):
|
||||||
@ -138,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",
|
||||||
@ -146,6 +154,11 @@ 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)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
projects = Project.objects.annotate(
|
projects = Project.objects.annotate(
|
||||||
total_duration=Sum(
|
total_duration=Sum(
|
||||||
@ -195,7 +208,8 @@ def format_stats(parents: QuerySet[Parent], projects: QuerySet[Project], clockin
|
|||||||
"name": parent.name,
|
"name": parent.name,
|
||||||
"project_num": parent.project_num,
|
"project_num": parent.project_num,
|
||||||
"is_productive": parent.is_productive,
|
"is_productive": parent.is_productive,
|
||||||
"duration": parent.total_duration
|
"duration": parent.total_duration,
|
||||||
|
"real_sagex_hours": convert_timedelta(parent.real_sagex_hours)
|
||||||
}
|
}
|
||||||
for parent in parents
|
for parent in parents
|
||||||
],
|
],
|
||||||
@ -283,4 +297,34 @@ def set_clocking(request, date: datetime.date):
|
|||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"clocking": ClockingSerializer(clocking).data
|
"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
|
||||||
})
|
})
|
@ -7,6 +7,12 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block footer %}{% endblock %}
|
{% block footer %}{% endblock %}
|
||||||
{% block main %}
|
{% 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="by-range">
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<div id="month-sel" class="group">
|
<div id="month-sel" class="group">
|
||||||
@ -33,7 +39,7 @@
|
|||||||
<th colspan="2">N° SageX</th>
|
<th colspan="2">N° SageX</th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="clocking">
|
<tr class="clocking">
|
||||||
<th>Total imputed</th>
|
<th>Total clocked</th>
|
||||||
<td class="total-clocking"></td>
|
<td class="total-clocking"></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="total-durations">
|
<tr class="total-durations">
|
||||||
|
Loading…
x
Reference in New Issue
Block a user