From 370b0e9815d08117da8b8f915e3b39d78664f2b8 Mon Sep 17 00:00:00 2001 From: Konrad du Plessis Date: Fri, 20 Feb 2026 00:07:26 +0200 Subject: [PATCH] Add minimal section headers to all core/ files and CLAUDE.md coding style Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 6 +++++ core/admin.py | 13 ++++++++--- core/context_processors.py | 3 +++ core/forms.py | 25 ++++++++++---------- core/models.py | 48 +++++++++++++++++++++++++++++--------- core/urls.py | 15 +++++++++++- core/utils.py | 3 +++ core/views.py | 47 +++++++++++++++++++++++++++++++++++-- 8 files changed, 130 insertions(+), 30 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 574f647..71cd77a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,11 @@ # Fox Fitt LabourPay +## Coding Style +- Always add clear section header comments using the format: # === SECTION NAME === +- Add plain English comments explaining what complex logic does +- The project owner is not a programmer — comments should be understandable by a non-technical person +- When creating or editing code, maintain the existing comment structure + ## Project Overview Django payroll management system for Fox Fitt Construction, a civil works contractor specializing in solar farm foundation installations. Manages field worker attendance, payroll processing, employee loans, and business expenses for solar farm projects. diff --git a/core/admin.py b/core/admin.py index f2861a0..f92bff2 100644 --- a/core/admin.py +++ b/core/admin.py @@ -5,14 +5,14 @@ from django.db import models from .models import Worker, Project, Team, WorkLog, UserProfile -# Inline UserProfile on User admin +# === USER ADMIN === + class UserProfileInline(admin.StackedInline): model = UserProfile can_delete = False verbose_name_plural = 'Profile' -# Customise the User admin to show groups prominently class UserAdmin(BaseUserAdmin): inlines = [UserProfileInline] list_display = ('username', 'first_name', 'last_name', 'is_staff', 'get_groups') @@ -42,11 +42,12 @@ class UserAdmin(BaseUserAdmin): get_groups.short_description = 'Groups' -# Re-register User with our custom admin admin.site.unregister(User) admin.site.register(User, UserAdmin) +# === WORKER ADMIN === + @admin.register(Worker) class WorkerAdmin(admin.ModelAdmin): list_display = ('name', 'id_no', 'phone_no', 'monthly_salary', 'date_of_employment', 'projects_worked_on_count') @@ -54,6 +55,8 @@ class WorkerAdmin(admin.ModelAdmin): readonly_fields = ('projects_worked_on_count',) +# === PROJECT ADMIN === + @admin.register(Project) class ProjectAdmin(admin.ModelAdmin): list_display = ('name', 'get_supervisors', 'is_active', 'created_at') @@ -74,6 +77,8 @@ class ProjectAdmin(admin.ModelAdmin): return super().formfield_for_manytomany(db_field, request, **kwargs) +# === TEAM ADMIN === + @admin.register(Team) class TeamAdmin(admin.ModelAdmin): list_display = ('name', 'supervisor', 'worker_count', 'is_active', 'created_at') @@ -94,6 +99,8 @@ class TeamAdmin(admin.ModelAdmin): return super().formfield_for_foreignkey(db_field, request, **kwargs) +# === WORK LOG ADMIN === + @admin.register(WorkLog) class WorkLogAdmin(admin.ModelAdmin): list_display = ('date', 'project', 'supervisor') diff --git a/core/context_processors.py b/core/context_processors.py index 0bf87c3..4ac3a71 100644 --- a/core/context_processors.py +++ b/core/context_processors.py @@ -1,6 +1,9 @@ import os import time + +# === TEMPLATE CONTEXT PROCESSORS === + def project_context(request): """ Adds project-specific environment variables to the template context globally. diff --git a/core/forms.py b/core/forms.py index 860b65c..5f58498 100644 --- a/core/forms.py +++ b/core/forms.py @@ -2,6 +2,8 @@ from django import forms from django.forms import inlineformset_factory from .models import WorkLog, Project, Worker, Team, ExpenseReceipt, ExpenseLineItem + +# === WORK LOG FORM === class WorkLogForm(forms.ModelForm): end_date = forms.DateField( required=False, @@ -9,20 +11,19 @@ class WorkLogForm(forms.ModelForm): label="End Date (Optional)" ) include_saturday = forms.BooleanField( - required=False, + required=False, label="Include Saturday", initial=False, widget=forms.CheckboxInput(attrs={'class': 'form-check-input'}) ) include_sunday = forms.BooleanField( - required=False, + required=False, label="Include Sunday", initial=False, widget=forms.CheckboxInput(attrs={'class': 'form-check-input'}) ) team = forms.ModelChoiceField(queryset=Team.objects.none(), required=False, empty_label="Select Team", widget=forms.Select(attrs={'class': 'form-control'})) - # Explicitly defining overtime to ensure it's not required and has correct widget overtime = forms.ChoiceField( choices=WorkLog.OT_CHOICES, required=False, @@ -43,30 +44,26 @@ class WorkLogForm(forms.ModelForm): def __init__(self, *args, **kwargs): user = kwargs.pop('user', None) super().__init__(*args, **kwargs) - - # Base querysets with active filter + projects_qs = Project.objects.filter(is_active=True) workers_qs = Worker.objects.filter(is_active=True) teams_qs = Team.objects.filter(is_active=True) if user and not user.is_superuser: - # Filter projects and workers based on user assignment self.fields['project'].queryset = projects_qs.filter(supervisors=user) - - # For workers, we might want to show workers from teams supervised by the user - # OR just all active workers if that's the business rule. - # The previous code filtered workers by managed teams. Let's keep that logic but respecting is_active. + managed_teams = user.managed_teams.all() worker_ids = managed_teams.values_list('workers__id', flat=True).distinct() self.fields['workers'].queryset = workers_qs.filter(id__in=worker_ids) - - # Filter teams + self.fields['team'].queryset = teams_qs.filter(supervisor=user) else: self.fields['project'].queryset = projects_qs self.fields['workers'].queryset = workers_qs self.fields['team'].queryset = teams_qs + +# === EXPENSE RECEIPT FORM === class ExpenseReceiptForm(forms.ModelForm): class Meta: model = ExpenseReceipt @@ -79,6 +76,8 @@ class ExpenseReceiptForm(forms.ModelForm): 'vat_type': forms.RadioSelect(attrs={'class': 'form-check-input'}), } + +# === EXPENSE LINE ITEM FORMSET === ExpenseLineItemFormSet = inlineformset_factory( ExpenseReceipt, ExpenseLineItem, fields=['product', 'amount'], @@ -88,4 +87,4 @@ ExpenseLineItemFormSet = inlineformset_factory( 'product': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Item Name'}), 'amount': forms.NumberInput(attrs={'class': 'form-control item-amount', 'step': '0.01'}), } -) \ No newline at end of file +) diff --git a/core/models.py b/core/models.py index 79a543f..87efb54 100644 --- a/core/models.py +++ b/core/models.py @@ -4,6 +4,9 @@ from decimal import Decimal from django.contrib.auth.models import User from django.utils import timezone + +# === USER PROFILE === + class UserProfile(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile') @@ -14,6 +17,9 @@ class UserProfile(models.Model): def __str__(self): return f"{self.user.username}'s profile" + +# === PROJECT === + class Project(models.Model): name = models.CharField(max_length=200) description = models.TextField(blank=True) @@ -28,18 +34,20 @@ class Project(models.Model): def __str__(self): return self.name + +# === WORKER === + class Worker(models.Model): name = models.CharField(max_length=200) id_no = models.CharField(max_length=50, unique=True, verbose_name="ID Number") phone_no = models.CharField(max_length=20, verbose_name="Phone Number") monthly_salary = models.DecimalField(max_digits=10, decimal_places=2, validators=[MinValueValidator(Decimal('0.00'))]) - - # New fields + photo = models.ImageField(upload_to='workers/photos/', blank=True, null=True) id_photo = models.ImageField(upload_to='workers/ids/', blank=True, null=True, verbose_name="ID Document") date_of_employment = models.DateField(default=timezone.now) notes = models.TextField(blank=True) - + created_at = models.DateTimeField(auto_now_add=True) is_active = models.BooleanField(default=True) @@ -53,12 +61,14 @@ class Worker(models.Model): @property def projects_worked_on_count(self): - """Returns the number of distinct projects this worker has worked on.""" return self.work_logs.values('project').distinct().count() def __str__(self): return self.name + +# === TEAM === + class Team(models.Model): name = models.CharField(max_length=200) workers = models.ManyToManyField(Worker, related_name='teams') @@ -73,6 +83,9 @@ class Team(models.Model): def __str__(self): return self.name + +# === WORK LOG === + class WorkLog(models.Model): OT_CHOICES = [ (Decimal('0'), 'None'), @@ -88,7 +101,7 @@ class WorkLog(models.Model): workers = models.ManyToManyField(Worker, related_name='work_logs') supervisor = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) notes = models.TextField(blank=True) - + overtime = models.DecimalField(max_digits=3, decimal_places=2, default=0, choices=OT_CHOICES) overtime_paid_to = models.ManyToManyField(Worker, blank=True, related_name='overtime_paid_logs') @@ -99,6 +112,9 @@ class WorkLog(models.Model): def __str__(self): return f"{self.date} - {self.project.name}" + +# === PAYROLL RECORD === + class PayrollRecord(models.Model): worker = models.ForeignKey(Worker, on_delete=models.CASCADE, related_name='payroll_records') date = models.DateField(default=timezone.now) @@ -113,6 +129,9 @@ class PayrollRecord(models.Model): def __str__(self): return f"Payment to {self.worker.name} on {self.date}" + +# === LOAN === + class Loan(models.Model): worker = models.ForeignKey(Worker, on_delete=models.CASCADE, related_name='loans') amount = models.DecimalField(max_digits=10, decimal_places=2, help_text="Principal amount borrowed") @@ -133,6 +152,9 @@ class Loan(models.Model): def __str__(self): return f"Loan for {self.worker.name} - R{self.amount}" + +# === PAYROLL ADJUSTMENT === + class PayrollAdjustment(models.Model): ADJUSTMENT_TYPES = [ ('BONUS', 'Bonus'), @@ -142,13 +164,12 @@ class PayrollAdjustment(models.Model): ('LOAN', 'New Loan'), ('ADVANCE', 'Advance Payment'), ] - + worker = models.ForeignKey(Worker, on_delete=models.CASCADE, related_name='adjustments') payroll_record = models.ForeignKey(PayrollRecord, on_delete=models.SET_NULL, null=True, blank=True, related_name='adjustments') loan = models.ForeignKey(Loan, on_delete=models.SET_NULL, null=True, blank=True, related_name='repayments') - # Link back to WorkLog to track Project/Team context for Overtime work_log = models.ForeignKey(WorkLog, on_delete=models.SET_NULL, null=True, blank=True, related_name='adjustments') - + amount = models.DecimalField(max_digits=10, decimal_places=2, help_text="Positive adds to pay, negative subtracts (except for Loan Repayment which is auto-handled)") date = models.DateField(default=timezone.now) description = models.CharField(max_length=255) @@ -161,6 +182,9 @@ class PayrollAdjustment(models.Model): def __str__(self): return f"{self.get_type_display()} - {self.amount} for {self.worker.name}" + +# === EXPENSE RECEIPT === + class ExpenseReceipt(models.Model): VAT_CHOICES = [ ('INCLUDED', 'VAT Included'), @@ -180,12 +204,11 @@ class ExpenseReceipt(models.Model): description = models.TextField(blank=True) payment_method = models.CharField(max_length=10, choices=PAYMENT_METHODS, default='CASH') vat_type = models.CharField(max_length=10, choices=VAT_CHOICES, default='NONE') - - # Financials (Stored for record keeping) + subtotal = models.DecimalField(max_digits=12, decimal_places=2, default=0) vat_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0) total_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0) - + created_at = models.DateTimeField(auto_now_add=True) class Meta: @@ -195,6 +218,9 @@ class ExpenseReceipt(models.Model): def __str__(self): return f"Receipt from {self.vendor} - {self.date}" + +# === EXPENSE LINE ITEM === + class ExpenseLineItem(models.Model): receipt = models.ForeignKey(ExpenseReceipt, on_delete=models.CASCADE, related_name='items') product = models.CharField(max_length=255, verbose_name="Product/Item") diff --git a/core/urls.py b/core/urls.py index 5e4a6c6..76c2a18 100644 --- a/core/urls.py +++ b/core/urls.py @@ -20,21 +20,34 @@ from .views import ( ) urlpatterns = [ + # === HOME & ATTENDANCE === path("", home, name="home"), path("log-attendance/", log_attendance, name="log_attendance"), + + # === WORK LOGS === path("work-logs/", work_log_list, name="work_log_list"), path("work-logs/export/", export_work_log_csv, name="export_work_log_csv"), + + # === RESOURCE MANAGEMENT === path("manage-resources/", manage_resources, name="manage_resources"), path("manage-resources/toggle///", toggle_resource_status, name="toggle_resource_status"), + + # === PAYROLL === path("payroll/", payroll_dashboard, name="payroll_dashboard"), path("payroll/pay//", process_payment, name="process_payment"), path("payroll/preview//", preview_payslip, name="preview_payslip"), path("payroll/price-overtime/", price_overtime, name="price_overtime"), path("payroll/payslip//", payslip_detail, name="payslip_detail"), + + # === LOANS === path("loans/", loan_list, name="loan_list"), path("loans/add/", add_loan, name="add_loan"), + + # === ADJUSTMENTS === path("payroll/adjustment/add/", add_adjustment, name="add_adjustment"), path("payroll/adjustment//edit/", edit_adjustment, name="edit_adjustment"), path("payroll/adjustment//delete/", delete_adjustment, name="delete_adjustment"), + + # === EXPENSES === path("receipts/create/", create_receipt, name="create_receipt"), -] \ No newline at end of file +] diff --git a/core/utils.py b/core/utils.py index e84415b..4cbba04 100644 --- a/core/utils.py +++ b/core/utils.py @@ -4,6 +4,9 @@ from xhtml2pdf import pisa from django.conf import settings import os + +# === PDF GENERATION === + def render_to_pdf(template_src, context_dict={}): template = get_template(template_src) html = template.render(context_dict) diff --git a/core/views.py b/core/views.py index 20a44ff..db75421 100644 --- a/core/views.py +++ b/core/views.py @@ -20,11 +20,15 @@ from datetime import timedelta from decimal import Decimal from core.utils import render_to_pdf -# Adjustment types that ADD to pay + +# === CONSTANTS === + ADDITIVE_TYPES = ['BONUS', 'OVERTIME', 'LOAN'] -# Adjustment types that SUBTRACT from pay DEDUCTIVE_TYPES = ['DEDUCTION', 'LOAN_REPAYMENT', 'ADVANCE'] + +# === PERMISSION HELPERS === + def is_admin(user): """Check if user has admin-level access (staff, superuser, or in Admin group).""" return user.is_staff or user.is_superuser @@ -39,6 +43,9 @@ def is_staff_or_supervisor(user): """Check if user has at least supervisor-level access.""" return is_admin(user) or is_supervisor(user) + +# === HOME DASHBOARD === + @login_required def home(request): """Render the landing screen with dashboard stats.""" @@ -158,6 +165,9 @@ def home(request): } return render(request, "core/index.html", context) + +# === LOG ATTENDANCE === + @login_required def log_attendance(request): # Build team workers map for frontend JS (needed for both GET and POST if re-rendering) @@ -307,6 +317,9 @@ def log_attendance(request): return render(request, 'core/log_attendance.html', context) + +# === WORK LOG LIST === + @login_required def work_log_list(request): """View work log history and payroll adjustments with advanced filtering.""" @@ -547,6 +560,9 @@ def work_log_list(request): return render(request, 'core/work_log_list.html', context) + +# === EXPORT WORK LOG CSV === + @login_required def export_work_log_csv(request): """Export filtered work logs and adjustments to CSV.""" @@ -672,6 +688,9 @@ def export_work_log_csv(request): return response + +# === RESOURCE MANAGEMENT === + @login_required def manage_resources(request): """Redirect to dashboard which now includes manage resources.""" @@ -705,6 +724,9 @@ def toggle_resource_status(request, model_type, pk): return redirect('home') + +# === PAYROLL DASHBOARD === + @login_required def payroll_dashboard(request): """Dashboard for payroll management with filtering.""" @@ -924,6 +946,9 @@ def payroll_dashboard(request): } return render(request, 'core/payroll_dashboard.html', context) + +# === PROCESS PAYMENT === + @login_required def process_payment(request, worker_id): """Process payment for a worker, mark logs as paid, link adjustments, and email receipt.""" @@ -1019,6 +1044,8 @@ def process_payment(request, worker_id): return redirect('payroll_dashboard') +# === PREVIEW PAYSLIP === + @login_required def preview_payslip(request, worker_id): """Return payslip preview data as JSON (no DB changes, no email).""" @@ -1062,6 +1089,9 @@ def preview_payslip(request, worker_id): 'net_pay': total_amount, }) + +# === PRICE OVERTIME === + @login_required def price_overtime(request): """Create OVERTIME PayrollAdjustments from unpriced overtime logs.""" @@ -1106,6 +1136,9 @@ def price_overtime(request): messages.success(request, f"Created {created} overtime adjustment(s).") return redirect('payroll_dashboard') + +# === PAYSLIP DETAIL === + @login_required def payslip_detail(request, pk): """Show details of a payslip (Payment Record).""" @@ -1133,6 +1166,9 @@ def payslip_detail(request, pk): } return render(request, 'core/payslip.html', context) + +# === LOANS === + @login_required def loan_list(request): """Redirect to payroll dashboard loans tab.""" @@ -1162,6 +1198,9 @@ def add_loan(request): return redirect('/payroll/?status=loans') + +# === PAYROLL ADJUSTMENTS === + @login_required def add_adjustment(request): """Add a payroll adjustment (Bonus, Overtime, Deduction, Repayment) for one or more workers.""" @@ -1351,6 +1390,7 @@ def edit_adjustment(request, pk): messages.success(request, f"Updated {adj.get_type_display()} for {adj.worker.name}.") return redirect('payroll_dashboard') + @login_required def delete_adjustment(request, pk): """Delete an unpaid payroll adjustment. Admin only, POST only.""" @@ -1394,6 +1434,9 @@ def delete_adjustment(request, pk): messages.success(request, f"Deleted {type_display} for {worker_name}.") return redirect('payroll_dashboard') + +# === EXPENSE RECEIPTS === + @login_required def create_receipt(request): """Create a new expense receipt and email it."""