diff --git a/core/admin.py b/core/admin.py index c20a78b..66e717a 100644 --- a/core/admin.py +++ b/core/admin.py @@ -88,10 +88,16 @@ class WorkerWarningAdmin(admin.ModelAdmin): @admin.register(Absence) class AbsenceAdmin(admin.ModelAdmin): - list_display = ('worker', 'date', 'reason', 'is_paid', 'logged_by', 'created_at') - list_filter = ('reason', 'is_paid', 'date') + # `project` shown alongside reason/is_paid so the admin index reads as + # "worker — project — reason — paid" at a glance. + list_display = ('worker', 'project', 'date', 'reason', 'is_paid', 'logged_by', 'created_at') + # `project` filter sits next to reason — handy for "which workers were + # absent on Solar Farm Alpha last month". + list_filter = ('reason', 'is_paid', 'project', 'date') search_fields = ('worker__name', 'worker__id_number', 'notes') - raw_id_fields = ('worker', 'logged_by', 'payroll_adjustment') + # `project` is a small set normally but raw_id keeps the form fast + # even if it grows. Same treatment as the other FKs. + raw_id_fields = ('worker', 'logged_by', 'payroll_adjustment', 'project') readonly_fields = ('created_at', 'updated_at') date_hierarchy = 'date' diff --git a/core/forms.py b/core/forms.py index d7febf7..f7492e1 100644 --- a/core/forms.py +++ b/core/forms.py @@ -668,9 +668,12 @@ class AbsenceLogForm(forms.ModelForm): class Meta: model = Absence - fields = ['date', 'reason', 'is_paid', 'notes'] + # `project` slots in between date and reason — it's part of the + # "what happened" header, not a per-row notes detail. Optional. + fields = ['date', 'project', 'reason', 'is_paid', 'notes'] widgets = { 'date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), + 'project': forms.Select(attrs={'class': 'form-select'}), 'reason': forms.Select(attrs={'class': 'form-select'}), 'is_paid': forms.CheckboxInput(attrs={'class': 'form-check-input'}), 'notes': forms.Textarea(attrs={'rows': 3, 'class': 'form-control'}), @@ -679,8 +682,13 @@ class AbsenceLogForm(forms.ModelForm): def __init__(self, *args, user=None, **kwargs): super().__init__(*args, **kwargs) self.user = user - # Supervisor scope: limit team + workers querysets to the user's - # reach. Admins (staff/superuser) keep the default "all active" lists. + # Project is optional — admins can leave it blank for non-project + # absences (e.g. Annual Leave). When set + is_paid=True, the auto- + # created Bonus PayrollAdjustment will inherit it for cost-attribution. + self.fields['project'].required = False + # Supervisor scope: limit team + workers + project querysets to + # the user's reach. Admins (staff/superuser) keep the default + # "all active" lists. if user is not None and not (user.is_staff or user.is_superuser): self.fields['team'].queryset = ( Team.objects.filter(active=True, supervisor=user) @@ -695,6 +703,14 @@ class AbsenceLogForm(forms.ModelForm): teams__active=True, ).distinct() ) + # Project dropdown — only projects this supervisor is assigned to. + # Mirrors the AttendanceLogForm supervisor scoping pattern. + self.fields['project'].queryset = Project.objects.filter( + active=True, supervisors=user, + ) + else: + # Admins see every active project. + self.fields['project'].queryset = Project.objects.filter(active=True) def clean(self): cleaned = super().clean() @@ -787,13 +803,29 @@ class AbsenceQuickForm(forms.ModelForm): class Meta: model = Absence - fields = ['reason', 'is_paid', 'notes'] + # `project` is optional — the modal may be opened from a worker row + # that already has a current project context (e.g. quick-mark from + # /attendance/log/), in which case the view can pre-fill it. + fields = ['project', 'reason', 'is_paid', 'notes'] widgets = { + 'project': forms.Select(attrs={'class': 'form-select'}), 'reason': forms.Select(attrs={'class': 'form-select'}), 'is_paid': forms.CheckboxInput(attrs={'class': 'form-check-input'}), 'notes': forms.Textarea(attrs={'rows': 2, 'class': 'form-control'}), } + def __init__(self, *args, user=None, **kwargs): + super().__init__(*args, **kwargs) + self.fields['project'].required = False + # Supervisor scope: project dropdown only shows their assigned projects. + # Admin / staff sees every active project. + if user is not None and not (user.is_staff or user.is_superuser): + self.fields['project'].queryset = Project.objects.filter( + active=True, supervisors=user, + ) + else: + self.fields['project'].queryset = Project.objects.filter(active=True) + class AbsenceEditForm(forms.ModelForm): """Edit one existing Absence. Lets admin correct worker/date as well @@ -802,10 +834,13 @@ class AbsenceEditForm(forms.ModelForm): class Meta: model = Absence - fields = ['worker', 'date', 'reason', 'is_paid', 'notes'] + # `project` is editable — admins can add or change the project link + # after the fact. Optional (matches the model's blank=True, null=True). + fields = ['worker', 'date', 'project', 'reason', 'is_paid', 'notes'] widgets = { 'worker': forms.Select(attrs={'class': 'form-select'}), 'date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), + 'project': forms.Select(attrs={'class': 'form-select'}), 'reason': forms.Select(attrs={'class': 'form-select'}), 'is_paid': forms.CheckboxInput(attrs={'class': 'form-check-input'}), 'notes': forms.Textarea(attrs={'rows': 3, 'class': 'form-control'}), @@ -815,16 +850,24 @@ class AbsenceEditForm(forms.ModelForm): super().__init__(*args, **kwargs) # Default: every active worker. Admins (staff/superuser) keep this list. self.fields['worker'].queryset = Worker.objects.filter(active=True) + # Project is optional — leave blank for non-project absences. + self.fields['project'].required = False # Supervisor scope: when a non-admin opens the edit form, the worker # dropdown is restricted to workers on their own active supervised # teams. Prevents a supervisor from silently re-assigning an absence - # to a worker they don't supervise. + # to a worker they don't supervise. Project dropdown also scoped + # to supervisor's assigned projects. if user is not None and not (user.is_staff or user.is_superuser): self.fields['worker'].queryset = Worker.objects.filter( active=True, teams__supervisor=user, teams__active=True, ).distinct() + self.fields['project'].queryset = Project.objects.filter( + active=True, supervisors=user, + ) + else: + self.fields['project'].queryset = Project.objects.filter(active=True) def clean(self): cleaned = super().clean() diff --git a/core/migrations/0015_absence_project.py b/core/migrations/0015_absence_project.py new file mode 100644 index 0000000..37db385 --- /dev/null +++ b/core/migrations/0015_absence_project.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.7 on 2026-05-14 19:55 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0014_add_absence'), + ] + + operations = [ + migrations.AddField( + model_name='absence', + name='project', + field=models.ForeignKey(blank=True, help_text='Which project was this worker absent from? (optional)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='absences', to='core.project'), + ), + ] diff --git a/core/models.py b/core/models.py index 025e984..0d5be01 100644 --- a/core/models.py +++ b/core/models.py @@ -485,6 +485,20 @@ class Absence(models.Model): worker = models.ForeignKey( Worker, related_name='absences', on_delete=models.CASCADE, ) + # === PROJECT LINK (optional) === + # Records which project the worker was absent FROM that day. Optional + # because not every absence is project-specific (e.g. "Annual Leave" + # might not be tied to a project). When set AND is_paid=True, the + # auto-created Bonus PayrollAdjustment inherits this project for + # cost-attribution. SET_NULL on delete so we keep the absence record + # (HR audit trail) even if the project is later removed. + project = models.ForeignKey( + 'Project', + on_delete=models.SET_NULL, + null=True, blank=True, + related_name='absences', + help_text='Which project was this worker absent from? (optional)', + ) date = models.DateField(default=timezone.now) reason = models.CharField(max_length=20, choices=REASON_CHOICES) notes = models.TextField( diff --git a/core/templates/core/absences/edit.html b/core/templates/core/absences/edit.html index 10d9046..ed364e5 100644 --- a/core/templates/core/absences/edit.html +++ b/core/templates/core/absences/edit.html @@ -49,6 +49,12 @@ form. {{ form.date }} {{ form.date.errors }} +
{{ absence_count }} absence(s) will be created:
-Reason: {{ reason }}. Paid: {% if is_paid %}Yes{% else %}No{% endif %}.
++ Reason: {{ reason }}. + Paid: {% if is_paid %}Yes{% else %}No{% endif %}. + {# Project line is conditional — older flows / non-project absences leave it blank. #} + {% if project_name %}Project: {{ project_name }}.{% endif %} +