added clockings edit + auto calculations
This commit is contained in:
parent
0380d18cd7
commit
ee96598d92
@ -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")
|
||||||
]
|
]
|
||||||
|
25
dispatcher/migrations/0005_clocking.py
Normal file
25
dispatcher/migrations/0005_clocking.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# Generated by Django 5.1.5 on 2025-01-31 17:56
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('dispatcher', '0004_task_unique_daily_task'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Clocking',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('date', models.DateField()),
|
||||||
|
('in_am', models.TimeField(default=None, null=True)),
|
||||||
|
('out_am', models.TimeField(default=None, null=True)),
|
||||||
|
('in_pm', models.TimeField(default=None, null=True)),
|
||||||
|
('out_pm', models.TimeField(default=None, null=True)),
|
||||||
|
('remote', models.TimeField(default=None, null=True)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
@ -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
31
dispatcher/serializers.py
Normal 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
|
@ -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)
|
||||||
|
}
|
@ -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
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
@ -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()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
})
|
@ -1 +1,2 @@
|
|||||||
Django~=5.1.5
|
Django~=5.1.5
|
||||||
|
djangorestframework~=3.15.2
|
@ -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>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user