diff --git a/TimeDispatcher/converters.py b/TimeDispatcher/converters.py index 361f617..0d14235 100644 --- a/TimeDispatcher/converters.py +++ b/TimeDispatcher/converters.py @@ -2,11 +2,21 @@ from datetime import datetime class DateConverter: - regex = '\d{4}-\d{1,2}-\d{1,2}' - format = '%Y-%m-%d' + regex = r"\d{4}-\d{1,2}-\d{1,2}" + format = r"%Y-%m-%d" def to_python(self, value): return datetime.strptime(value, self.format).date() def to_url(self, value): - return value.strftime(self.format) \ No newline at end of file + 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) diff --git a/TimeDispatcher/urls.py b/TimeDispatcher/urls.py index 992887a..f7e3ad5 100644 --- a/TimeDispatcher/urls.py +++ b/TimeDispatcher/urls.py @@ -17,10 +17,11 @@ Including another URLconf from django.contrib import admin from django.urls import path, register_converter -from TimeDispatcher.converters import DateConverter +from TimeDispatcher.converters import DateConverter, YearMonthConverter from dispatcher import views register_converter(DateConverter, "date") +register_converter(YearMonthConverter, "year_month") urlpatterns = [ path("", views.dashboard_view, name="dashboard"), @@ -38,5 +39,6 @@ urlpatterns = [ path("projects//", views.project_view, name="project"), 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") + path("clockings//", views.set_clocking, name="set_clocking"), + path("sagex///", views.set_real_sagex, name="set_real_sagex"), ] diff --git a/dispatcher/core.py b/dispatcher/core.py index fa4a2f5..90c74b1 100644 --- a/dispatcher/core.py +++ b/dispatcher/core.py @@ -1,6 +1,15 @@ +import datetime + 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): tasks = [] for line in csv_content.splitlines()[1:]: diff --git a/dispatcher/migrations/0009_alter_clocking_remote_realsagexhours.py b/dispatcher/migrations/0009_alter_clocking_remote_realsagexhours.py new file mode 100644 index 0000000..536204f --- /dev/null +++ b/dispatcher/migrations/0009_alter_clocking_remote_realsagexhours.py @@ -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')), + ], + ), + ] diff --git a/dispatcher/migrations/0010_realsagexhours_unique_monthly_sagex.py b/dispatcher/migrations/0010_realsagexhours_unique_monthly_sagex.py new file mode 100644 index 0000000..453d849 --- /dev/null +++ b/dispatcher/migrations/0010_realsagexhours_unique_monthly_sagex.py @@ -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'), + ), + ] diff --git a/dispatcher/models.py b/dispatcher/models.py index 3d5df08..5b5b41b 100644 --- a/dispatcher/models.py +++ b/dispatcher/models.py @@ -1,3 +1,5 @@ +from datetime import timedelta + from django.db import models 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") 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") - remote = models.TimeField(null=True, default=None) \ No newline at end of file + 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") + ] diff --git a/dispatcher/serializers.py b/dispatcher/serializers.py index 91abd00..7499e7d 100644 --- a/dispatcher/serializers.py +++ b/dispatcher/serializers.py @@ -3,7 +3,8 @@ from datetime import datetime, timedelta, time from rest_framework import serializers 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): @@ -24,8 +25,19 @@ class ClockingSerializer(ModelSerializer): 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) + total += obj.remote seconds = total.seconds minutes = seconds // 60 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) diff --git a/dispatcher/static/base.js b/dispatcher/static/base.js index 817435e..c4ba282 100644 --- a/dispatcher/static/base.js +++ b/dispatcher/static/base.js @@ -57,6 +57,7 @@ function formatDate(date) { function formatDuration(duration) { let hours = Math.floor(duration / 60) duration -= hours * 60 + duration = Math.round(duration) let res = "" if (hours > 0) { res += hours.toString() + "h" diff --git a/dispatcher/static/dashboard.css b/dispatcher/static/dashboard.css index 1e94192..1b3f9d2 100644 --- a/dispatcher/static/dashboard.css +++ b/dispatcher/static/dashboard.css @@ -92,4 +92,51 @@ #projects-table th, #projects-table td { 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% { + + } } \ No newline at end of file diff --git a/dispatcher/static/dashboard.js b/dispatcher/static/dashboard.js index bf79aa2..615bdae 100644 --- a/dispatcher/static/dashboard.js +++ b/dispatcher/static/dashboard.js @@ -95,6 +95,8 @@ function updateTable(data) { headers.querySelector(".total-clocking").innerText = formatDuration(totalWorked) headers.querySelector(".total-duration").innerText = formatDuration(totalImputed) + let sagexHoursTemplate = getTemplate("sagex-hours") + let totalSagex = 0 parents.forEach(parent => { let duration = +parent.duration @@ -112,11 +114,66 @@ function updateTable(data) { let sagexDuration = duration * totalWorked / totalImputed totalSagex += 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) } +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", () => { prevMonthBtn = document.getElementById("prev-month") nextMonthBtn = document.getElementById("next-month") diff --git a/dispatcher/static/logo.png b/dispatcher/static/logo.png new file mode 100644 index 0000000..6e9b18e Binary files /dev/null and b/dispatcher/static/logo.png differ diff --git a/dispatcher/views.py b/dispatcher/views.py index cc4b46e..4290a06 100644 --- a/dispatcher/views.py +++ b/dispatcher/views.py @@ -1,17 +1,19 @@ import datetime 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.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 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.models import Project, Parent, Clocking, Task -from dispatcher.serializers import ClockingSerializer +from dispatcher.models import Project, Parent, Clocking, Task, RealSageXHours +from dispatcher.serializers import ClockingSerializer, RealSageXHoursSerializer def dashboard_view(request): @@ -138,6 +140,12 @@ def get_stats_by_month(request, year: int, month: int): if month < 1 or month > 12: 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( total_duration=Sum( "project__task__duration", @@ -146,6 +154,11 @@ def get_stats_by_month(request, year: int, month: int): project__task__date__month=month ) ) + ).annotate( + real_sagex_hours=Coalesce( + Subquery(sagex_subquery, output_field=DurationField()), + timedelta(0) + ) ) projects = Project.objects.annotate( total_duration=Sum( @@ -195,7 +208,8 @@ def format_stats(parents: QuerySet[Parent], projects: QuerySet[Project], clockin "name": parent.name, "project_num": parent.project_num, "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 ], @@ -283,4 +297,34 @@ def set_clocking(request, date: datetime.date): return JsonResponse({ "status": "success", "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 }) \ No newline at end of file diff --git a/templates/dashboard.html b/templates/dashboard.html index f9ec4cf..d08b22c 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -7,6 +7,12 @@ {% endblock %} {% block footer %}{% endblock %} {% block main %} + {% csrf_token %} + + +
+ : +
@@ -33,7 +39,7 @@ N° SageX - Total imputed + Total clocked