Add minimal section headers to all core/ files and CLAUDE.md coding style
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a79cb0b435
commit
370b0e9815
@ -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.
|
||||
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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'}),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@ -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")
|
||||
|
||||
15
core/urls.py
15
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/<str:model_type>/<int:pk>/", toggle_resource_status, name="toggle_resource_status"),
|
||||
|
||||
# === PAYROLL ===
|
||||
path("payroll/", payroll_dashboard, name="payroll_dashboard"),
|
||||
path("payroll/pay/<int:worker_id>/", process_payment, name="process_payment"),
|
||||
path("payroll/preview/<int:worker_id>/", preview_payslip, name="preview_payslip"),
|
||||
path("payroll/price-overtime/", price_overtime, name="price_overtime"),
|
||||
path("payroll/payslip/<int:pk>/", 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/<int:pk>/edit/", edit_adjustment, name="edit_adjustment"),
|
||||
path("payroll/adjustment/<int:pk>/delete/", delete_adjustment, name="delete_adjustment"),
|
||||
|
||||
# === EXPENSES ===
|
||||
path("receipts/create/", create_receipt, name="create_receipt"),
|
||||
]
|
||||
]
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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."""
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user