diff --git a/TimeDispatcher/urls.py b/TimeDispatcher/urls.py index c049238..15acdbf 100644 --- a/TimeDispatcher/urls.py +++ b/TimeDispatcher/urls.py @@ -33,4 +33,5 @@ urlpatterns = [ path("projects//set_parent", views.set_parent, name="set_parent"), path("stats/by-month///", views.get_stats_by_month, name="stats_by_month"), path("stats/between///", views.get_stats_between, name="stats_between"), + path("clockings//", views.set_clocking, name="set_clocking") ] diff --git a/dispatcher/migrations/0005_clocking.py b/dispatcher/migrations/0005_clocking.py new file mode 100644 index 0000000..a80ea2b --- /dev/null +++ b/dispatcher/migrations/0005_clocking.py @@ -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)), + ], + ), + ] diff --git a/dispatcher/models.py b/dispatcher/models.py index 79fcd02..10e6b54 100644 --- a/dispatcher/models.py +++ b/dispatcher/models.py @@ -30,3 +30,12 @@ class Task(models.Model): constraints = [ 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) \ No newline at end of file diff --git a/dispatcher/serializers.py b/dispatcher/serializers.py new file mode 100644 index 0000000..91abd00 --- /dev/null +++ b/dispatcher/serializers.py @@ -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 diff --git a/dispatcher/static/base.js b/dispatcher/static/base.js index e69de29..d985181 100644 --- a/dispatcher/static/base.js +++ b/dispatcher/static/base.js @@ -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) +} \ No newline at end of file diff --git a/dispatcher/static/projects.js b/dispatcher/static/projects.js index 9c39b6a..b171c65 100644 --- a/dispatcher/static/projects.js +++ b/dispatcher/static/projects.js @@ -5,13 +5,9 @@ window.addEventListener("load", () => { selector.addEventListener("change", () => { let fd = new FormData() fd.set("parent_id", selector.value) - let csrftoken = document.querySelector("input[name='csrfmiddlewaretoken']").value - fetch(`${id}/set_parent`, { + req(`${id}/set_parent`, { method: "POST", - body: fd, - headers: { - "X-CSRFToken": csrftoken - } + body: fd }) }) diff --git a/dispatcher/static/table.css b/dispatcher/static/table.css index 533b880..b364357 100644 --- a/dispatcher/static/table.css +++ b/dispatcher/static/table.css @@ -41,6 +41,13 @@ display: none; } } + + &.total { + text-align: right; + td { + padding: 0.4em; + } + } } } @@ -97,6 +104,10 @@ &:first-child { text-align: left; + + &:hover, &:hover ~ td { + background-color: rgba(255, 0, 0, 0.2); + } } &:nth-child(2) { text-align: center; diff --git a/dispatcher/static/table.js b/dispatcher/static/table.js index 2963b66..9dedb11 100644 --- a/dispatcher/static/table.js +++ b/dispatcher/static/table.js @@ -1,6 +1,5 @@ let table let prevMonthBtn, nextMonthBtn, month -let curMonth = new Date().getMonth() const DAYS_SHORT = [ "Mon", @@ -58,6 +57,11 @@ function formatDate(date) { return `${year}-${month}-${day}` } +function formatPercentage(ratio) { + let percentage = Math.round(ratio * 100) + return percentage + "%" +} + function getTemplate(cls) { let elmt = document.querySelector(".template." + cls).cloneNode(true) elmt.classList.remove("template") @@ -84,6 +88,9 @@ class Table { this.startDate = today this.endDate = today + this.totalProjects = 0 + this.clockingTotals = [] + this.update() } @@ -133,7 +140,7 @@ class Table { addClockings(date) { let dates = this.clockings.querySelector(".dates") 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") dateCell.innerText = `${weekDay} ${monthDay}` dates.appendChild(dateCell) @@ -146,7 +153,12 @@ class Table { 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.insertCell(-1).innerText = name row.insertCell(-1).innerText = sagexNum + let total = 0 times.forEach(time => { + if (time) { + total += time + } let t = time.toString().split(".") - if (t.length > 1 ){ + if (t.length > 1) { t[1] = t[1].slice(0, 2) } 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) { @@ -215,6 +244,8 @@ class Table { this.addWeek(monday, len) monday = nextMonday } + + this.clockings.querySelector(".clocking.total").insertCell(-1) this.endDate = new Date(monday.valueOf() - monday.getDate() * DAY_MS) } @@ -233,29 +264,114 @@ class Table { } displayData(data) { + this.displayClockings(data.clockings) + this.totalProjects = 0 data.parents.forEach(parent => { - this.addProject( - parent.id, - parent.name, - parent.project_num, - Array(this.nDays).fill(""), - true - ) + let parentDurations = Array(this.nDays).fill(0) - parent.projects.forEach(project => { + let projects = parent.projects.map(project => { let durations = Array(this.nDays).fill("") project.tasks.forEach(task => { let date = new Date(task.date) 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( - project.id, - project.name, - "", - durations - ) + return { + id: project.id, + name: project.name, + 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() }) } } diff --git a/dispatcher/views.py b/dispatcher/views.py index a9094a2..8a1f6f8 100644 --- a/dispatcher/views.py +++ b/dispatcher/views.py @@ -5,11 +5,13 @@ from django.db.models import Sum, Q, QuerySet 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 dispatcher.core import import_tasks 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): @@ -138,6 +140,11 @@ class ParentsView(generic.ListView): def get_table_data(request, start_date: datetime.date, end_date: datetime.date): 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") data = { "parents": [ @@ -164,6 +171,24 @@ def get_table_data(request, start_date: datetime.date, end_date: datetime.date): ] } for parent in parents - ] + ], + "clockings": ClockingSerializer(clockings, many=True).data } - return JsonResponse({"status": "success", "data": data}) \ No newline at end of file + 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 + }) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 338a411..edaee23 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ -Django~=5.1.5 \ No newline at end of file +Django~=5.1.5 +djangorestframework~=3.15.2 \ No newline at end of file diff --git a/templates/table.html b/templates/table.html index 5a4a3eb..330e2bf 100644 --- a/templates/table.html +++ b/templates/table.html @@ -6,6 +6,7 @@ {% endblock %} {% block main %} + {% csrf_token %}
Month
@@ -27,26 +28,26 @@ - + Check-IN AM - TOTAL + TOTAL % working time % imputed time time on sagex - + Check-OUT AM - + Check-IN PM - + Check-OUT PM - + Remote - + TOTAL