Initial commit
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
from django.contrib import admin
|
||||
from .models import TimeEntry
|
||||
|
||||
|
||||
@admin.register(TimeEntry)
|
||||
class TimeEntryAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"project",
|
||||
"user",
|
||||
"entry_type",
|
||||
"activity",
|
||||
"start",
|
||||
"end",
|
||||
"break_minutes",
|
||||
)
|
||||
|
||||
list_filter = (
|
||||
"entry_type",
|
||||
"project",
|
||||
"user",
|
||||
)
|
||||
|
||||
search_fields = (
|
||||
"activity",
|
||||
"project__name",
|
||||
"user__username",
|
||||
"user__first_name",
|
||||
"user__last_name",
|
||||
)
|
||||
|
||||
autocomplete_fields = (
|
||||
"project",
|
||||
"user",
|
||||
)
|
||||
|
||||
ordering = (
|
||||
"-start",
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class TimeTrackingConfig(AppConfig):
|
||||
name = 'time_tracking'
|
||||
@@ -0,0 +1,40 @@
|
||||
# Generated by Django 6.0.6 on 2026-06-23 22:12
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('projects', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='TimeEntry',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('entry_type', models.CharField(choices=[('travel', 'Anfahrt'), ('work', 'Arbeitszeit')], default='work', max_length=20, verbose_name='Typ')),
|
||||
('activity', models.CharField(blank=True, max_length=255, verbose_name='Tätigkeit')),
|
||||
('start', models.DateTimeField(verbose_name='Beginn')),
|
||||
('end', models.DateTimeField(blank=True, null=True)),
|
||||
('break_minutes', models.PositiveIntegerField(default=0, verbose_name='Pause (Minuten)')),
|
||||
('note', models.TextField(blank=True, verbose_name='Notiz')),
|
||||
('invoiced', models.BooleanField(default=False, verbose_name='Abgerechnet')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='time_entries', to='projects.project', verbose_name='Projekt')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='time_entries', to=settings.AUTH_USER_MODEL, verbose_name='Mitarbeiter')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Zeiterfassung',
|
||||
'verbose_name_plural': 'Zeiterfassungen',
|
||||
'ordering': ['-start'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,105 @@
|
||||
from django.db import models
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class TimeEntry(models.Model):
|
||||
|
||||
class EntryType(models.TextChoices):
|
||||
TRAVEL = "travel", "Anfahrt"
|
||||
WORK = "work", "Arbeitszeit"
|
||||
|
||||
project = models.ForeignKey(
|
||||
"projects.Project",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="time_entries",
|
||||
verbose_name="Projekt"
|
||||
)
|
||||
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="time_entries",
|
||||
verbose_name="Mitarbeiter"
|
||||
)
|
||||
|
||||
entry_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=EntryType.choices,
|
||||
default=EntryType.WORK,
|
||||
verbose_name="Typ"
|
||||
)
|
||||
|
||||
activity = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name="Tätigkeit",
|
||||
blank=True,
|
||||
)
|
||||
|
||||
start = models.DateTimeField(
|
||||
verbose_name="Beginn"
|
||||
)
|
||||
|
||||
end = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
break_minutes = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name="Pause (Minuten)"
|
||||
)
|
||||
|
||||
note = models.TextField(
|
||||
blank=True,
|
||||
verbose_name="Notiz"
|
||||
)
|
||||
|
||||
invoiced = models.BooleanField(default=False,verbose_name="Abgerechnet")
|
||||
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True
|
||||
)
|
||||
|
||||
updated_at = models.DateTimeField(
|
||||
auto_now=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Zeiterfassung"
|
||||
verbose_name_plural = "Zeiterfassungen"
|
||||
ordering = ["-start"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user} - {self.start:%d.%m.%Y} - {self.duration_hours} Stunden"
|
||||
|
||||
@property
|
||||
def duration_minutes(self):
|
||||
|
||||
if not self.end:
|
||||
return 0
|
||||
|
||||
duration = (self.end - self.start).total_seconds() / 60
|
||||
return max(0, int(duration) - self.break_minutes)
|
||||
|
||||
@property
|
||||
def duration_hours(self):
|
||||
|
||||
if not self.end:
|
||||
return 0
|
||||
|
||||
return round(self.duration_minutes / 60, 2)
|
||||
|
||||
@property
|
||||
def duration_minutes_total(self):
|
||||
end = self.end or None
|
||||
if not end:
|
||||
return 0
|
||||
return max(0, int((end - self.start).total_seconds() / 60) - self.break_minutes)
|
||||
|
||||
@property
|
||||
def duration_hhmm(self):
|
||||
total_minutes = self.duration_minutes_total
|
||||
|
||||
hours = total_minutes // 60
|
||||
minutes = total_minutes % 60
|
||||
|
||||
return f"{hours:02d}:{minutes:02d}"
|
||||
@@ -0,0 +1,91 @@
|
||||
import django_tables2 as tables
|
||||
|
||||
from .models import TimeEntry
|
||||
|
||||
|
||||
class TimeEntryTable(tables.Table):
|
||||
|
||||
entry_type = tables.TemplateColumn(
|
||||
template_name="time_tracking/tables/entry_type.html",
|
||||
verbose_name="Typ",
|
||||
orderable=False,
|
||||
)
|
||||
|
||||
activity = tables.TemplateColumn(
|
||||
template_name="time_tracking/tables/activity.html",
|
||||
verbose_name="Aktivität",
|
||||
orderable=False,
|
||||
)
|
||||
|
||||
start = tables.TemplateColumn(
|
||||
template_name="time_tracking/tables/start.html",
|
||||
verbose_name="Start",
|
||||
orderable=False,
|
||||
)
|
||||
|
||||
end = tables.TemplateColumn(
|
||||
template_name="time_tracking/tables/end.html",
|
||||
verbose_name="Ende",
|
||||
orderable=False,
|
||||
)
|
||||
|
||||
break_minutes = tables.TemplateColumn(
|
||||
template_name="time_tracking/tables/break.html",
|
||||
verbose_name="Pause (Min)",
|
||||
orderable=False,
|
||||
)
|
||||
|
||||
duration = tables.Column(
|
||||
accessor="duration_hhmm",
|
||||
verbose_name="Dauer",
|
||||
orderable=False,
|
||||
)
|
||||
|
||||
invoiced = tables.TemplateColumn(
|
||||
template_name="time_tracking/tables/invoiced.html",
|
||||
verbose_name="Abgerechnet",
|
||||
orderable=False,
|
||||
)
|
||||
|
||||
actions = tables.TemplateColumn(
|
||||
template_name="time_tracking/tables/actions.html",
|
||||
verbose_name="Aktionen",
|
||||
orderable=False,
|
||||
)
|
||||
|
||||
|
||||
class Meta:
|
||||
|
||||
model = TimeEntry
|
||||
|
||||
template_name = "django_tables2/table.html"
|
||||
|
||||
attrs = {
|
||||
"class": "w-full text-sm min-w-[1200px]"
|
||||
}
|
||||
|
||||
fields = (
|
||||
"entry_type",
|
||||
"activity",
|
||||
"start",
|
||||
"end",
|
||||
"break_minutes",
|
||||
"duration",
|
||||
"invoiced",
|
||||
"actions",
|
||||
)
|
||||
|
||||
|
||||
def __init__(self, *args, types=None, project=None, **kwargs):
|
||||
|
||||
self.types = types
|
||||
self.project = project
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
def before_render(self, request):
|
||||
|
||||
for row in self.rows:
|
||||
row.record.project = self.project
|
||||
row.record.types = self.types
|
||||
@@ -0,0 +1,78 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
{{ project.name }} - Zeiterfassung
|
||||
{% endblock %}
|
||||
|
||||
{% block header_title %}
|
||||
Zeiterfassung
|
||||
{% endblock %}
|
||||
|
||||
{% block header_description %}
|
||||
Zeiten erfassen und verwalten
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% load django_tables2 %}
|
||||
|
||||
<div class="mb-8">
|
||||
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold">
|
||||
Aktionen
|
||||
</h2>
|
||||
|
||||
<p class="text-slate-400 text-sm mt-1">
|
||||
Arbeitszeiten und Anfahrten erfassen
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-2">
|
||||
|
||||
<!-- START -->
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="start">
|
||||
<button class="btn btn-action-start">
|
||||
▶ Start
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- STOP -->
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="stop">
|
||||
<button class="btn btn-action-stop">
|
||||
■ Stop
|
||||
</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
|
||||
|
||||
<div class="mb-6">
|
||||
|
||||
<h2 class="text-xl font-bold">
|
||||
Zeiterfassungen
|
||||
</h2>
|
||||
|
||||
<p class="text-slate-400 text-sm mt-1">
|
||||
Erfasste Zeiten des Projekts
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto rounded-xl">
|
||||
{% render_table table %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,30 @@
|
||||
<div class="flex gap-2">
|
||||
|
||||
<form method="post"
|
||||
action="{% url 'time_tracking:edit' record.project_id record.id %}"
|
||||
id="save-form-{{ record.id }}"
|
||||
class="inline">
|
||||
|
||||
{% csrf_token %}
|
||||
|
||||
<button type="submit"
|
||||
class="btn btn-save">
|
||||
Speichern
|
||||
</button>
|
||||
|
||||
</form>
|
||||
|
||||
<form method="post"
|
||||
action="{% url 'time_tracking:delete' record.project_id record.id %}"
|
||||
class="inline">
|
||||
|
||||
{% csrf_token %}
|
||||
|
||||
<button type="submit"
|
||||
class="btn btn-delete">
|
||||
Löschen
|
||||
</button>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,6 @@
|
||||
<input
|
||||
type="text"
|
||||
name="activity"
|
||||
form="save-form-{{ record.id }}"
|
||||
value="{{ record.activity }}"
|
||||
class="border rounded px-2 py-1 w-full">
|
||||
@@ -0,0 +1,6 @@
|
||||
<input
|
||||
type="number"
|
||||
name="break_minutes"
|
||||
form="save-form-{{ record.id }}"
|
||||
value="{{ record.break_minutes }}"
|
||||
class="border rounded px-2 py-1 w-24">
|
||||
@@ -0,0 +1,6 @@
|
||||
<input
|
||||
type="datetime-local"
|
||||
name="end"
|
||||
form="save-form-{{ record.id }}"
|
||||
value="{% if record.end %}{{ record.end|date:'Y-m-d' }}T{{ record.end|date:'H:i' }}{% endif %}"
|
||||
class="border rounded px-2 py-1">
|
||||
@@ -0,0 +1,13 @@
|
||||
<select
|
||||
name="entry_type"
|
||||
form="save-form-{{ record.id }}"
|
||||
class="border rounded px-2 py-1 w-full">
|
||||
|
||||
{% for value,label in types %}
|
||||
<option value="{{ value }}"
|
||||
{% if record.entry_type == value %}selected{% endif %}>
|
||||
{{ label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
|
||||
</select>
|
||||
@@ -0,0 +1,5 @@
|
||||
{% if record.invoiced %}
|
||||
<span class="text-slate-400 font-semibold">Ja</span>
|
||||
{% else %}
|
||||
<span class="text-slate-400">Nein</span>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,6 @@
|
||||
<input
|
||||
type="datetime-local"
|
||||
name="start"
|
||||
form="save-form-{{ record.id }}"
|
||||
value="{{ record.start|date:'Y-m-d' }}T{{ record.start|date:'H:i' }}"
|
||||
class="border rounded px-2 py-1">
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1,10 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = "time_tracking"
|
||||
|
||||
urlpatterns = [
|
||||
path("<int:project_id>/time_tracking/",views.time_tracking_overview,name="overview"),
|
||||
path("<int:project_id>/time_tracking/<int:entry_id>/edit/",views.time_tracking_edit,name="edit"),
|
||||
path("<int:project_id>/time_tracking/<int:entry_id>/delete/",views.time_tracking_delete,name="delete"),
|
||||
]
|
||||
@@ -0,0 +1,263 @@
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from accounts.decorators import permission_required
|
||||
from django.shortcuts import render, get_object_or_404, redirect
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_datetime
|
||||
from datetime import datetime
|
||||
from projects.models import ProjectMembership
|
||||
from .models import TimeEntry
|
||||
from django_tables2 import RequestConfig
|
||||
from .tables import TimeEntryTable
|
||||
|
||||
@login_required
|
||||
@permission_required("projects_time_tracking_view")
|
||||
def time_tracking_overview(request, project_id):
|
||||
|
||||
membership = get_object_or_404(
|
||||
ProjectMembership,
|
||||
project_id=project_id,
|
||||
user=request.user
|
||||
)
|
||||
|
||||
project = membership.project
|
||||
|
||||
|
||||
# ==========================
|
||||
# POST ACTIONS
|
||||
# ==========================
|
||||
|
||||
if request.method == "POST":
|
||||
|
||||
action = request.POST.get("action")
|
||||
|
||||
|
||||
# --------------------------
|
||||
# START TIMER
|
||||
# --------------------------
|
||||
|
||||
if action == "start":
|
||||
|
||||
open_entry = TimeEntry.objects.filter(
|
||||
project=project,
|
||||
user=request.user,
|
||||
end__isnull=True
|
||||
).first()
|
||||
|
||||
|
||||
if not open_entry:
|
||||
|
||||
TimeEntry.objects.create(
|
||||
project=project,
|
||||
user=request.user,
|
||||
entry_type=TimeEntry.EntryType.WORK,
|
||||
activity="",
|
||||
start=timezone.now(),
|
||||
break_minutes=0,
|
||||
)
|
||||
|
||||
|
||||
return redirect(
|
||||
"time_tracking:overview",
|
||||
project.id
|
||||
)
|
||||
|
||||
|
||||
# --------------------------
|
||||
# STOP TIMER
|
||||
# --------------------------
|
||||
|
||||
if action == "stop":
|
||||
|
||||
open_entry = TimeEntry.objects.filter(
|
||||
project=project,
|
||||
user=request.user,
|
||||
end__isnull=True
|
||||
).order_by("-start").first()
|
||||
|
||||
|
||||
if open_entry:
|
||||
|
||||
open_entry.end = timezone.now()
|
||||
|
||||
open_entry.save(
|
||||
update_fields=[
|
||||
"end"
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
return redirect(
|
||||
"time_tracking:overview",
|
||||
project.id
|
||||
)
|
||||
|
||||
|
||||
# --------------------------
|
||||
# ADD ENTRY
|
||||
# --------------------------
|
||||
|
||||
if action == "add":
|
||||
|
||||
start_str = request.POST.get("start", "")
|
||||
end_str = request.POST.get("end", "")
|
||||
|
||||
start_dt = None
|
||||
end_dt = None
|
||||
|
||||
try:
|
||||
start_dt = datetime.strptime(start_str, "%Y-%m-%dT%H:%M")
|
||||
start_dt = timezone.make_aware(start_dt)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
if end_str:
|
||||
try:
|
||||
end_dt = datetime.strptime(end_str, "%Y-%m-%dT%H:%M")
|
||||
end_dt = timezone.make_aware(end_dt)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
TimeEntry.objects.create(
|
||||
project=project,
|
||||
user=request.user,
|
||||
entry_type=request.POST.get("entry_type"),
|
||||
activity=request.POST.get("activity"),
|
||||
start=start_dt or timezone.now(),
|
||||
end=end_dt,
|
||||
break_minutes=request.POST.get("break_minutes") or 0,
|
||||
note=request.POST.get("note", "")
|
||||
)
|
||||
|
||||
|
||||
return redirect(
|
||||
"time_tracking:overview",
|
||||
project.id
|
||||
)
|
||||
|
||||
|
||||
|
||||
# ==========================
|
||||
# TABLE
|
||||
# ==========================
|
||||
|
||||
entries = project.time_entries.select_related(
|
||||
"user"
|
||||
).order_by(
|
||||
"-start"
|
||||
)
|
||||
|
||||
|
||||
table = TimeEntryTable(
|
||||
entries,
|
||||
request=request,
|
||||
)
|
||||
|
||||
RequestConfig(
|
||||
request,
|
||||
paginate={
|
||||
"per_page": 25
|
||||
}
|
||||
).configure(table)
|
||||
|
||||
|
||||
|
||||
# ==========================
|
||||
# RESPONSE
|
||||
# ==========================
|
||||
|
||||
return render(
|
||||
request,
|
||||
"time_tracking/overview.html",
|
||||
{
|
||||
"project": project,
|
||||
"table": table,
|
||||
"types": TimeEntry.EntryType.choices,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required("projects_time_tracking_edit")
|
||||
def time_tracking_edit(request, project_id, entry_id):
|
||||
|
||||
membership = get_object_or_404(
|
||||
ProjectMembership,
|
||||
project_id=project_id,
|
||||
user=request.user
|
||||
)
|
||||
|
||||
project = membership.project
|
||||
|
||||
|
||||
if request.method != "POST":
|
||||
return redirect(
|
||||
"time_tracking:overview",
|
||||
project.id
|
||||
)
|
||||
|
||||
|
||||
entry = get_object_or_404(
|
||||
TimeEntry,
|
||||
id=entry_id,
|
||||
project=project
|
||||
)
|
||||
|
||||
# Parse datetime fields
|
||||
start_str = request.POST.get("start", "")
|
||||
end_str = request.POST.get("end", "")
|
||||
|
||||
try:
|
||||
# Format: "Y-m-dTH:i" (e.g., "2026-06-27T13:36")
|
||||
entry.start = datetime.strptime(start_str, "%Y-%m-%dT%H:%M")
|
||||
entry.start = timezone.make_aware(entry.start)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
if end_str:
|
||||
try:
|
||||
entry.end = datetime.strptime(end_str, "%Y-%m-%dT%H:%M")
|
||||
entry.end = timezone.make_aware(entry.end)
|
||||
except (ValueError, TypeError):
|
||||
entry.end = None
|
||||
else:
|
||||
entry.end = None
|
||||
|
||||
entry.entry_type = request.POST.get("entry_type")
|
||||
entry.activity = request.POST.get("activity")
|
||||
entry.break_minutes = request.POST.get("break_minutes") or 0
|
||||
entry.note = request.POST.get("note", "")
|
||||
|
||||
entry.save()
|
||||
|
||||
|
||||
return redirect(
|
||||
"time_tracking:overview",
|
||||
project.id
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required("projects_time_tracking_delete")
|
||||
def time_tracking_delete(request, project_id, entry_id):
|
||||
|
||||
membership = get_object_or_404(
|
||||
ProjectMembership,
|
||||
project_id=project_id,
|
||||
user=request.user
|
||||
)
|
||||
|
||||
project = membership.project
|
||||
|
||||
|
||||
if request.method == "POST":
|
||||
|
||||
TimeEntry.objects.filter(
|
||||
id=entry_id,
|
||||
project=project
|
||||
).delete()
|
||||
|
||||
|
||||
return redirect(
|
||||
"time_tracking:overview",
|
||||
project.id
|
||||
)
|
||||
Reference in New Issue
Block a user