added clockings edit + auto calculations

This commit is contained in:
Louis Heredero 2025-02-02 13:33:56 +01:00
parent 0380d18cd7
commit ee96598d92
Signed by: HEL
GPG Key ID: 8D83DE470F8544E7
11 changed files with 259 additions and 36 deletions

View File

@ -33,4 +33,5 @@ urlpatterns = [
path("projects/<int:id>/set_parent", views.set_parent, name="set_parent"), path("projects/<int:id>/set_parent", views.set_parent, name="set_parent"),
path("stats/by-month/<int:year>/<int:month>/", views.get_stats_by_month, name="stats_by_month"), path("stats/by-month/<int:year>/<int:month>/", views.get_stats_by_month, name="stats_by_month"),
path("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")
] ]

View File

@ -0,0 +1,25 @@
# Generated by Django 5.1.5 on 2025-01-31 17:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcher', '0004_task_unique_daily_task'),
]
operations = [
migrations.CreateModel(
name='Clocking',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('in_am', models.TimeField(default=None, null=True)),
('out_am', models.TimeField(default=None, null=True)),
('in_pm', models.TimeField(default=None, null=True)),
('out_pm', models.TimeField(default=None, null=True)),
('remote', models.TimeField(default=None, null=True)),
],
),
]

View File

@ -30,3 +30,12 @@ class Task(models.Model):
constraints = [ constraints = [
models.UniqueConstraint(fields=["date", "project", "name"], name="unique_daily_task") models.UniqueConstraint(fields=["date", "project", "name"], name="unique_daily_task")
] ]
class Clocking(models.Model):
date = models.DateField()
in_am = models.TimeField(null=True, default=None)
out_am = models.TimeField(null=True, default=None)
in_pm = models.TimeField(null=True, default=None)
out_pm = models.TimeField(null=True, default=None)
remote = models.TimeField(null=True, default=None)

31
dispatcher/serializers.py Normal file
View File

@ -0,0 +1,31 @@
from datetime import datetime, timedelta, time
from rest_framework import serializers
from rest_framework.serializers import ModelSerializer
from dispatcher.models import Clocking
class ClockingSerializer(ModelSerializer):
total = serializers.SerializerMethodField()
class Meta:
model = Clocking
fields = "__all__"
def get_total(self, obj: Clocking):
total = timedelta()
if obj.in_am is not None and obj.out_am is not None:
in_am = datetime.combine(obj.date, obj.in_am)
out_am = datetime.combine(obj.date, obj.out_am)
total += out_am - in_am
if obj.in_pm is not None and obj.out_pm is not None:
in_pm = datetime.combine(obj.date, obj.in_pm)
out_pm = datetime.combine(obj.date, obj.out_pm)
total += out_pm - in_pm
if obj.remote is not None:
total += timedelta(hours=obj.remote.hour, minutes=obj.remote.minute)
seconds = total.seconds
minutes = seconds // 60
return minutes

View File

@ -0,0 +1,7 @@
function req(url, options) {
let headers = options.headers || {}
let csrftoken = document.querySelector("input[name='csrfmiddlewaretoken']").value
headers["X-CSRFToken"] = csrftoken
options.headers = headers
return fetch(url, options)
}

View File

@ -5,13 +5,9 @@ window.addEventListener("load", () => {
selector.addEventListener("change", () => { selector.addEventListener("change", () => {
let fd = new FormData() let fd = new FormData()
fd.set("parent_id", selector.value) fd.set("parent_id", selector.value)
let csrftoken = document.querySelector("input[name='csrfmiddlewaretoken']").value req(`${id}/set_parent`, {
fetch(`${id}/set_parent`, {
method: "POST", method: "POST",
body: fd, body: fd
headers: {
"X-CSRFToken": csrftoken
}
}) })
}) })

View File

@ -41,6 +41,13 @@
display: none; display: none;
} }
} }
&.total {
text-align: right;
td {
padding: 0.4em;
}
}
} }
} }
@ -97,6 +104,10 @@
&:first-child { &:first-child {
text-align: left; text-align: left;
&:hover, &:hover ~ td {
background-color: rgba(255, 0, 0, 0.2);
}
} }
&:nth-child(2) { &:nth-child(2) {
text-align: center; text-align: center;

View File

@ -1,6 +1,5 @@
let table let table
let prevMonthBtn, nextMonthBtn, month let prevMonthBtn, nextMonthBtn, month
let curMonth = new Date().getMonth()
const DAYS_SHORT = [ const DAYS_SHORT = [
"Mon", "Mon",
@ -58,6 +57,11 @@ function formatDate(date) {
return `${year}-${month}-${day}` return `${year}-${month}-${day}`
} }
function formatPercentage(ratio) {
let percentage = Math.round(ratio * 100)
return percentage + "%"
}
function getTemplate(cls) { function getTemplate(cls) {
let elmt = document.querySelector(".template." + cls).cloneNode(true) let elmt = document.querySelector(".template." + cls).cloneNode(true)
elmt.classList.remove("template") elmt.classList.remove("template")
@ -84,6 +88,9 @@ class Table {
this.startDate = today this.startDate = today
this.endDate = today this.endDate = today
this.totalProjects = 0
this.clockingTotals = []
this.update() this.update()
} }
@ -133,7 +140,7 @@ class Table {
addClockings(date) { addClockings(date) {
let dates = this.clockings.querySelector(".dates") let dates = this.clockings.querySelector(".dates")
let dateCell = document.createElement("th") let dateCell = document.createElement("th")
let weekDay = DAYS_SHORT[(date.getDay() + 1) % 7] let weekDay = DAYS_SHORT[(date.getDay() + 6) % 7]
let monthDay = date.getDate().toString().padStart(2, "0") let monthDay = date.getDate().toString().padStart(2, "0")
dateCell.innerText = `${weekDay} ${monthDay}` dateCell.innerText = `${weekDay} ${monthDay}`
dates.appendChild(dateCell) dates.appendChild(dateCell)
@ -146,7 +153,12 @@ class Table {
return return
} }
cell.replaceWith(this.timeInputTemplate.cloneNode(true)) let inputCell = this.timeInputTemplate.cloneNode(true)
let input = inputCell.querySelector("input")
input.addEventListener("input", () => {
this.setClocking(date, clocking.dataset.type, input.value)
})
cell.replaceWith(inputCell)
}) })
} }
@ -184,13 +196,30 @@ class Table {
row.dataset.id = id row.dataset.id = id
row.insertCell(-1).innerText = name row.insertCell(-1).innerText = name
row.insertCell(-1).innerText = sagexNum row.insertCell(-1).innerText = sagexNum
let total = 0
times.forEach(time => { times.forEach(time => {
if (time) {
total += time
}
let t = time.toString().split(".") let t = time.toString().split(".")
if (t.length > 1 ){ if (t.length > 1) {
t[1] = t[1].slice(0, 2) t[1] = t[1].slice(0, 2)
} }
row.insertCell(-1).innerText = t.join(".") row.insertCell(-1).innerText = t.join(".")
}) })
let totalStr = total.toString().split(".")
if (totalStr.length > 1) {
totalStr[1] = totalStr[1].slice(0, 2)
}
row.insertCell(-1).innerText = totalStr.join(".")
if (isParent) {
row.dataset.total = total
this.totalProjects += total
row.insertCell(-1)
row.insertCell(-1)
row.insertCell(-1)
}
} }
addMonth(anchorDate) { addMonth(anchorDate) {
@ -215,6 +244,8 @@ class Table {
this.addWeek(monday, len) this.addWeek(monday, len)
monday = nextMonday monday = nextMonday
} }
this.clockings.querySelector(".clocking.total").insertCell(-1)
this.endDate = new Date(monday.valueOf() - monday.getDate() * DAY_MS) this.endDate = new Date(monday.valueOf() - monday.getDate() * DAY_MS)
} }
@ -233,29 +264,114 @@ class Table {
} }
displayData(data) { displayData(data) {
this.displayClockings(data.clockings)
this.totalProjects = 0
data.parents.forEach(parent => { data.parents.forEach(parent => {
this.addProject( let parentDurations = Array(this.nDays).fill(0)
parent.id,
parent.name,
parent.project_num,
Array(this.nDays).fill(""),
true
)
parent.projects.forEach(project => { let projects = parent.projects.map(project => {
let durations = Array(this.nDays).fill("") let durations = Array(this.nDays).fill("")
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)
durations[i] = task.duration / 60 let hours = task.duration / 60
durations[i] = hours
parentDurations[i] += hours
}) })
this.addProject( return {
project.id, id: project.id,
project.name, name: project.name,
"", durations: durations
durations }
)
}) })
this.addProject(
parent.id,
parent.name,
parent.project_num,
parentDurations.map(v => v === 0 ? "" : v),
true
)
projects.forEach(project => {
this.addProject(project.id, project.name, "", project.durations)
})
})
this.updateTotals()
}
displayClockings(clockings) {
this.clockingTotals = Array(this.nDays).fill(0)
clockings.forEach(clocking => {
let date = new Date(clocking.date)
if (date < this.startDate || date > this.endDate) {
return
}
let i = Math.floor((date - this.startDate) / DAY_MS)
this.clockings.querySelectorAll("tr.clocking[data-type]").forEach(row => {
let type = row.dataset.type
let cell = row.cells[i + 1]
if (clocking[type]) {
cell.querySelector("input").value = clocking[type]
}
})
let totalRow = this.clockings.querySelector(".clocking.total")
let totalCell = totalRow.cells[i + 1]
let hours = +clocking.total / 60
this.clockingTotals[i] = hours
totalCell.innerText = hours === 0 ? "" : Math.round(hours * 100) / 100
})
}
setClocking(date, type, time) {
this.post(`/clockings/${formatDate(date)}/`, {
[type]: time
}).then(res => {
let row = this.clockings.querySelector(".clocking.total")
let i = Math.floor((date - this.startDate) / DAY_MS)
let cell = row.cells[i + 1]
let minutes = +res.clocking.total
let hours = minutes / 60
this.clockingTotals[i] = hours
cell.innerText = hours === 0 ? "" : Math.round(hours * 100) / 100
this.updateTotals()
})
}
updateTotals() {
let totalClockings = this.clockingTotals.reduce((a, b) => a + b, 0)
let totalProjects = this.totalProjects
this.clockings.querySelector(".clocking.total").cells[this.nDays + 1].innerText = Math.round(totalClockings * 100) / 100
if (totalClockings === 0) {
console.log("Total clockings = 0")
return
}
if (totalProjects === 0) {
console.log("Total projects = 0")
return
}
this.times.querySelectorAll(".project.parent").forEach(parent => {
let total = +parent.dataset.total
let workingTimeRatio = total / totalClockings
let imputedTimeRatio = total / totalProjects
parent.cells[this.nDays + 3].innerText = formatPercentage(workingTimeRatio)
parent.cells[this.nDays + 4].innerText = formatPercentage(imputedTimeRatio)
let sagexTime = imputedTimeRatio * totalClockings
parent.cells[this.nDays + 5].innerText = Math.round(sagexTime * 100) / 100
})
}
post(endpoint, data) {
let fd = new FormData()
Object.entries(data).forEach(([key, value]) => {
fd.set(key, value)
})
return req(endpoint, {
method: "POST",
body: fd
}).then(res => {
return res.json()
}) })
} }
} }

View File

@ -5,11 +5,13 @@ from django.db.models import Sum, Q, QuerySet
from django.http import JsonResponse from django.http import JsonResponse
from django.shortcuts import render, get_object_or_404, redirect from django.shortcuts import render, get_object_or_404, redirect
from django.views import generic from django.views import generic
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from dispatcher.core import import_tasks from dispatcher.core import import_tasks
from dispatcher.forms import ProjectForm, ImportForm from dispatcher.forms import ProjectForm, ImportForm
from dispatcher.models import Project, Parent from dispatcher.models import Project, Parent, Clocking
from dispatcher.serializers import ClockingSerializer
def dashboard_view(request): def dashboard_view(request):
@ -138,6 +140,11 @@ class ParentsView(generic.ListView):
def get_table_data(request, start_date: datetime.date, end_date: datetime.date): def get_table_data(request, start_date: datetime.date, end_date: datetime.date):
end_date = end_date + timedelta(days=1) end_date = end_date + timedelta(days=1)
clockings = Clocking.objects.filter(
date__gte=start_date,
date__lte=end_date
)
parents = Parent.objects.all().order_by("id") parents = Parent.objects.all().order_by("id")
data = { data = {
"parents": [ "parents": [
@ -164,6 +171,24 @@ def get_table_data(request, start_date: datetime.date, end_date: datetime.date):
] ]
} }
for parent in parents for parent in parents
] ],
"clockings": ClockingSerializer(clockings, many=True).data
} }
return JsonResponse({"status": "success", "data": data}) return JsonResponse({"status": "success", "data": data})
@require_POST
@csrf_exempt
def set_clocking(request, date: datetime.date):
clocking, created = Clocking.objects.get_or_create(date=date)
clocking.in_am = request.POST.get("in_am", clocking.in_am) or None
clocking.out_am = request.POST.get("out_am", clocking.out_am) or None
clocking.in_pm = request.POST.get("in_pm", clocking.in_pm) or None
clocking.out_pm = request.POST.get("out_pm", clocking.out_pm) or None
clocking.remote = request.POST.get("remote", clocking.remote) or None
clocking.save()
clocking.refresh_from_db()
return JsonResponse({
"status": "success",
"clocking": ClockingSerializer(clocking).data
})

View File

@ -1 +1,2 @@
Django~=5.1.5 Django~=5.1.5
djangorestframework~=3.15.2

View File

@ -6,6 +6,7 @@
<script src="{% static "table.js" %}"></script> <script src="{% static "table.js" %}"></script>
{% endblock %} {% endblock %}
{% block main %} {% block main %}
{% csrf_token %}
<div class="controls"> <div class="controls">
<button id="prev-month"><</button> <button id="prev-month"><</button>
<div id="month">Month</div> <div id="month">Month</div>
@ -27,26 +28,26 @@
<tr class="dates"> <tr class="dates">
<td colspan="2"></td> <td colspan="2"></td>
</tr> </tr>
<tr class="clocking"> <tr class="clocking" data-type="in_am">
<th colspan="2">Check-IN AM</th> <th colspan="2">Check-IN AM</th>
<th rowspan="6" class="total-header">TOTAL</th> <th rowspan="5" class="total-header">TOTAL</th>
<th rowspan="6" class="total-header">% working time</th> <th rowspan="6" class="total-header">% working time</th>
<th rowspan="6" class="total-header">% imputed time</th> <th rowspan="6" class="total-header">% imputed time</th>
<th rowspan="6" class="total-header">time on sagex</th> <th rowspan="6" class="total-header">time on sagex</th>
</tr> </tr>
<tr class="clocking"> <tr class="clocking" data-type="out_am">
<th colspan="2">Check-OUT AM</th> <th colspan="2">Check-OUT AM</th>
</tr> </tr>
<tr class="clocking"> <tr class="clocking" data-type="in_pm">
<th colspan="2">Check-IN PM</th> <th colspan="2">Check-IN PM</th>
</tr> </tr>
<tr class="clocking"> <tr class="clocking" data-type="out_pm">
<th colspan="2">Check-OUT PM</th> <th colspan="2">Check-OUT PM</th>
</tr> </tr>
<tr class="clocking"> <tr class="clocking" data-type="remote">
<th colspan="2">Remote</th> <th colspan="2">Remote</th>
</tr> </tr>
<tr class="clocking"> <tr class="clocking total">
<th colspan="2">TOTAL</th> <th colspan="2">TOTAL</th>
</tr> </tr>
</tbody> </tbody>