38156-vm/core/views.py
2026-02-10 11:49:00 +00:00

1208 lines
46 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import os
import platform
import json
import csv
import calendar
import datetime
from django.shortcuts import render, redirect, get_object_or_404
from django.utils import timezone
from django.contrib.auth.decorators import login_required, user_passes_test
from django.db.models import Sum, Q, Prefetch
from django.core.mail import send_mail, EmailMultiAlternatives
from django.conf import settings
from django.contrib import messages
from django.http import JsonResponse, HttpResponse
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from .models import Worker, Project, Team, WorkLog, PayrollRecord, Loan, PayrollAdjustment, ExpenseReceipt, ExpenseLineItem
from .forms import WorkLogForm, ExpenseReceiptForm, ExpenseLineItemFormSet
from datetime import timedelta
from decimal import Decimal
from core.utils import render_to_pdf
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
def is_supervisor(user):
"""Check if user is a work logger (assigned to teams or projects, or in Work Logger group)."""
if user.groups.filter(name='Work Logger').exists():
return True
return user.managed_teams.exists() or user.assigned_projects.exists()
def is_staff_or_supervisor(user):
"""Check if user has at least supervisor-level access."""
return is_admin(user) or is_supervisor(user)
@login_required
def home(request):
"""Render the landing screen with dashboard stats."""
# If not staff or supervisor, redirect to log attendance
if not is_staff_or_supervisor(request.user):
return redirect('log_attendance')
user_is_admin = is_admin(request.user)
now = timezone.now()
today = now.date()
# Counts (used by both admin and non-admin)
workers_count = Worker.objects.filter(is_active=True).count()
projects_count = Project.objects.filter(is_active=True).count()
teams_count = Team.objects.filter(is_active=True).count()
# Recent logs with team info
recent_logs = WorkLog.objects.select_related('team', 'project').prefetch_related('workers').order_by('-date', '-id')[:5]
# --- Admin-only analytics ---
outstanding_total = 0
recent_payments_total = 0
active_loans_count = 0
active_loans_total = 0
week_worker_days = 0
week_projects = 0
if user_is_admin:
# 1. Outstanding Payments
active_workers = Worker.objects.filter(is_active=True).prefetch_related('work_logs', 'adjustments')
for worker in active_workers:
# Unpaid logs
unpaid_logs_count = worker.work_logs.exclude(paid_in__worker=worker).count()
log_amount = unpaid_logs_count * worker.day_rate
# Pending Adjustments
pending_adjustments = worker.adjustments.filter(payroll_record__isnull=True)
adj_total = Decimal('0.00')
for adj in pending_adjustments:
if adj.type in ['BONUS', 'OVERTIME', 'LOAN']:
adj_total += adj.amount
elif adj.type in ['DEDUCTION', 'LOAN_REPAYMENT']:
adj_total -= adj.amount
total_payable = log_amount + adj_total
outstanding_total += max(total_payable, Decimal('0.00'))
# 2. Paid This Month
recent_payments_total = PayrollRecord.objects.filter(
date__year=today.year, date__month=today.month
).aggregate(total=Sum('amount'))['total'] or 0
# 3. Active Loans
active_loans = Loan.objects.filter(is_active=True)
active_loans_count = active_loans.count()
active_loans_total = active_loans.aggregate(total=Sum('balance'))['total'] or 0
# 4. This Week stats (Mon-Sun) - visible to all users
week_start = today - timedelta(days=today.weekday()) # Monday
week_end = week_start + timedelta(days=6) # Sunday
week_logs = WorkLog.objects.filter(date__range=(week_start, week_end)).prefetch_related('workers')
week_projects = week_logs.values('project').distinct().count()
for log in week_logs:
week_worker_days += log.workers.count()
# Manage Resources data (admin only)
all_workers = Worker.objects.all().prefetch_related('teams').order_by('name') if user_is_admin else []
all_projects = Project.objects.all().order_by('name') if user_is_admin else []
all_teams = Team.objects.all().prefetch_related('workers').order_by('name') if user_is_admin else []
context = {
"is_admin_user": user_is_admin,
"workers_count": workers_count,
"projects_count": projects_count,
"teams_count": teams_count,
"recent_logs": recent_logs,
"current_time": now,
# Admin financials
"outstanding_total": outstanding_total,
"recent_payments_total": recent_payments_total,
"active_loans_count": active_loans_count,
"active_loans_total": active_loans_total,
# This week
"week_worker_days": week_worker_days,
"week_projects": week_projects,
# Manage resources
"all_workers": all_workers,
"all_projects": all_projects,
"all_teams": all_teams,
}
return render(request, "core/index.html", context)
@login_required
def log_attendance(request):
# Build team workers map for frontend JS (needed for both GET and POST if re-rendering)
teams_qs = Team.objects.filter(is_active=True)
if request.user.is_authenticated and not request.user.is_superuser:
teams_qs = teams_qs.filter(supervisor=request.user)
team_workers_map = {}
for team in teams_qs:
# Get active workers for the team
active_workers = team.workers.filter(is_active=True).values_list('id', flat=True)
team_workers_map[team.id] = list(active_workers)
if request.method == 'POST':
form = WorkLogForm(request.POST, user=request.user)
if form.is_valid():
start_date = form.cleaned_data['date']
end_date = form.cleaned_data.get('end_date')
include_sat = form.cleaned_data.get('include_saturday')
include_sun = form.cleaned_data.get('include_sunday')
selected_workers = form.cleaned_data['workers']
project = form.cleaned_data['project']
team = form.cleaned_data.get('team')
notes = form.cleaned_data['notes']
overtime = form.cleaned_data.get('overtime', 0) # Read overtime
conflict_action = request.POST.get('conflict_action')
# Generate Target Dates
target_dates = []
if end_date and end_date >= start_date:
curr = start_date
while curr <= end_date:
# 5 = Saturday, 6 = Sunday
if (curr.weekday() == 5 and not include_sat) or (curr.weekday() == 6 and not include_sun):
curr += timedelta(days=1)
continue
target_dates.append(curr)
curr += timedelta(days=1)
else:
target_dates = [start_date]
if not target_dates:
messages.warning(request, "No valid dates selected (check weekends).")
return render(request, 'core/log_attendance.html', {
'form': form, 'team_workers_json': json.dumps(team_workers_map)
})
# Check Conflicts - Scan all target dates
if not conflict_action:
conflicts = []
for d in target_dates:
# Find workers who already have a log on this date
existing_logs = WorkLog.objects.filter(date=d, workers__in=selected_workers).distinct()
for log in existing_logs:
# Which of the selected workers are in this log?
for w in log.workers.all():
if w in selected_workers:
# Avoid adding duplicates if multiple logs exist for same worker/day (rare but possible)
conflict_entry = {'name': f"{w.name} ({d.strftime('%Y-%m-%d')})"}
if conflict_entry not in conflicts:
conflicts.append(conflict_entry)
if conflicts:
# Prepare worker rates for JS calculation
worker_qs = form.fields['workers'].queryset
worker_rates = {w.id: float(w.day_rate) for w in worker_qs}
context = {
'form': form,
'team_workers_json': json.dumps(team_workers_map),
'conflicting_workers': conflicts,
'is_conflict': True,
'worker_rates_json': json.dumps(worker_rates),
}
return render(request, 'core/log_attendance.html', context)
# Execution Phase
created_count = 0
skipped_count = 0
overwritten_count = 0
for d in target_dates:
# Find conflicts for this specific day
day_conflicts = Worker.objects.filter(
work_logs__date=d,
id__in=selected_workers.values_list('id', flat=True)
).distinct()
workers_to_save = list(selected_workers)
if day_conflicts.exists():
if conflict_action == 'skip':
conflicting_ids = day_conflicts.values_list('id', flat=True)
workers_to_save = [w for w in selected_workers if w.id not in conflicting_ids]
skipped_count += day_conflicts.count()
elif conflict_action == 'overwrite':
# Remove conflicting workers from their OLD logs
for worker in day_conflicts:
old_logs = WorkLog.objects.filter(date=d, workers=worker)
for log in old_logs:
log.workers.remove(worker)
if log.workers.count() == 0:
log.delete()
overwritten_count += day_conflicts.count()
# workers_to_save remains full list
if workers_to_save:
# Create Log
log = WorkLog.objects.create(
date=d,
project=project,
team=team,
notes=notes,
supervisor=request.user if request.user.is_authenticated else None,
overtime=overtime # Save overtime
)
log.workers.set(workers_to_save)
created_count += len(workers_to_save)
msg = f"Logged {created_count} entries."
if skipped_count:
msg += f" Skipped {skipped_count} duplicates."
if overwritten_count:
msg += f" Overwrote {overwritten_count} previous entries."
messages.success(request, msg)
# Redirect to home, which will then redirect back to log_attendance if restricted
return redirect('home')
else:
form = WorkLogForm(user=request.user if request.user.is_authenticated else None)
# Pass worker rates for frontend total calculation (admin only)
user_is_admin = is_admin(request.user)
if user_is_admin:
worker_qs = form.fields['workers'].queryset
worker_rates = {w.id: float(w.day_rate) for w in worker_qs}
else:
worker_rates = {}
context = {
'form': form,
'is_admin_user': user_is_admin,
'team_workers_json': json.dumps(team_workers_map),
'worker_rates_json': json.dumps(worker_rates)
}
return render(request, 'core/log_attendance.html', context)
@login_required
def work_log_list(request):
"""View work log history and payroll adjustments with advanced filtering."""
if not is_staff_or_supervisor(request.user):
return redirect('log_attendance')
worker_id = request.GET.get('worker')
team_id = request.GET.get('team')
project_id = request.GET.get('project')
payment_status = request.GET.get('payment_status') # 'paid', 'unpaid', 'all'
view_mode = request.GET.get('view', 'list')
# --- 1. Fetch WorkLogs ---
logs = WorkLog.objects.all().select_related('team', 'project', 'supervisor').prefetch_related('workers', 'paid_in').order_by('-date', '-id')
target_worker = None
if worker_id:
logs = logs.filter(workers__id=worker_id)
target_worker = Worker.objects.filter(id=worker_id).first()
if team_id:
logs = logs.filter(team_id=team_id)
if project_id:
logs = logs.filter(project_id=project_id)
if payment_status == 'paid':
logs = logs.filter(paid_in__isnull=False).distinct()
elif payment_status == 'unpaid':
if worker_id:
worker = get_object_or_404(Worker, pk=worker_id)
logs = logs.exclude(paid_in__worker=worker)
else:
logs = logs.filter(paid_in__isnull=True)
# --- 2. Fetch Adjustments ---
adjustments = PayrollAdjustment.objects.all().select_related('worker', 'payroll_record', 'work_log')
if worker_id:
adjustments = adjustments.filter(worker_id=worker_id)
if project_id:
# Include only adjustments linked to this project (via work_log)
adjustments = adjustments.filter(work_log__project_id=project_id)
if team_id:
adjustments = adjustments.filter(work_log__team_id=team_id)
if payment_status == 'paid':
adjustments = adjustments.filter(payroll_record__isnull=False)
elif payment_status == 'unpaid':
adjustments = adjustments.filter(payroll_record__isnull=True)
# --- 3. Date Filtering for Calendar View (Applied to both) ---
start_date = None
end_date = None
curr_year = timezone.now().year
curr_month = timezone.now().month
if view_mode == 'calendar':
today = timezone.now().date()
try:
curr_year = int(request.GET.get('year', today.year))
curr_month = int(request.GET.get('month', today.month))
except ValueError:
curr_year = today.year
curr_month = today.month
if curr_month < 1: curr_month = 1;
if curr_month > 12: curr_month = 12;
_, num_days = calendar.monthrange(curr_year, curr_month)
start_date = datetime.date(curr_year, curr_month, 1)
end_date = datetime.date(curr_year, curr_month, num_days)
logs = logs.filter(date__range=(start_date, end_date))
# No 'show_adjustments' check needed as query is already filtered
adjustments = adjustments.filter(date__range=(start_date, end_date))
# --- 4. Combine and Sort ---
user_is_admin = is_admin(request.user)
total_amount = 0
combined_records = []
# Process Logs
for log in logs:
record = {
'type': 'WORK',
'date': log.date,
'obj': log,
'project_name': log.project.name,
'team_name': log.team.name if log.team else None,
'workers': list(log.workers.all()),
'supervisor': log.supervisor.username if log.supervisor else "System",
'is_paid': log.paid_in.exists() if not worker_id else log.paid_in.filter(worker_id=worker_id).exists(),
'paid_record': log.paid_in.first() if not worker_id else log.paid_in.filter(worker_id=worker_id).first(),
'notes': log.notes,
'sort_id': log.id
}
# Calculate amount
if user_is_admin:
if target_worker:
amt = target_worker.day_rate
else:
amt = sum(w.day_rate for w in log.workers.all())
record['amount'] = amt
total_amount += amt
else:
record['amount'] = None
combined_records.append(record)
# Process Adjustments
for adj in adjustments:
# Determine signed amount for display/total
amt = adj.amount
if adj.type in ['DEDUCTION', 'LOAN_REPAYMENT']:
amt = -amt
record = {
'type': 'ADJ',
'date': adj.date,
'obj': adj,
'project_name': f"{adj.get_type_display()}", # Use project column for Type
'team_name': None,
'workers': [adj.worker],
'supervisor': "System",
'is_paid': adj.payroll_record is not None,
'paid_record': adj.payroll_record,
'notes': adj.description,
'amount': amt if user_is_admin else None,
'sort_id': adj.id
}
if user_is_admin:
total_amount += amt
combined_records.append(record)
# Sort combined list by Date Descending, then ID Descending
combined_records.sort(key=lambda x: (x['date'], x['sort_id']), reverse=True)
# Context for filters
context = {
'is_admin_user': user_is_admin,
'total_amount': total_amount if user_is_admin else None,
'workers': Worker.objects.filter(is_active=True).order_by('name'),
'teams': Team.objects.filter(is_active=True).order_by('name'),
'projects': Project.objects.filter(is_active=True).order_by('name'),
'selected_worker': int(worker_id) if worker_id else None,
'selected_team': int(team_id) if team_id else None,
'selected_project': int(project_id) if project_id else None,
'selected_payment_status': payment_status,
'target_worker': target_worker,
'view_mode': view_mode,
}
if view_mode == 'calendar':
# Group by date for easy lookup in template
records_map = {}
for rec in combined_records:
d = rec['date']
if d not in records_map:
records_map[d] = []
records_map[d].append(rec)
cal = calendar.Calendar(firstweekday=0) # Monday is 0
month_dates = cal.monthdatescalendar(curr_year, curr_month)
# Prepare structured data for template
calendar_weeks = []
for week in month_dates:
week_data = []
for d in week:
week_data.append({
'date': d,
'day': d.day,
'is_current_month': d.month == curr_month,
'records': records_map.get(d, [])
})
calendar_weeks.append(week_data)
# Build JSON lookup for day detail panel
calendar_detail_data = {}
for date_key, day_records in records_map.items():
date_str = date_key.strftime('%Y-%m-%d')
calendar_detail_data[date_str] = []
for rec in day_records:
workers_list = [w.name for w in rec['workers']]
team_name = rec['team_name'] if rec['team_name'] else ''
# Format for JS
calendar_detail_data[date_str].append({
'project': rec['project_name'], # This holds Type for ADJ
'teams': [team_name] if team_name else [],
'workers': workers_list,
'supervisor': rec['supervisor'],
'notes': rec['notes'] or '',
'type': rec['type'],
'amount': float(rec['amount']) if rec['amount'] is not None else 0
})
# Nav Links
prev_month_date = start_date - datetime.timedelta(days=1)
next_month_date = end_date + datetime.timedelta(days=1)
context.update({
'calendar_detail_json': json.dumps(calendar_detail_data),
'calendar_weeks': calendar_weeks,
'curr_month': curr_month,
'curr_year': curr_year,
'month_name': calendar.month_name[curr_month],
'prev_month': prev_month_date.month,
'prev_year': prev_month_date.year,
'next_month': next_month_date.month,
'next_year': next_month_date.year,
})
else:
context['records'] = combined_records
return render(request, 'core/work_log_list.html', context)
@login_required
def export_work_log_csv(request):
"""Export filtered work logs and adjustments to CSV."""
if not is_staff_or_supervisor(request.user):
return HttpResponse("Unauthorized", status=401)
worker_id = request.GET.get('worker')
team_id = request.GET.get('team')
project_id = request.GET.get('project')
payment_status = request.GET.get('payment_status')
# --- 1. Fetch WorkLogs ---
logs = WorkLog.objects.all().prefetch_related('workers', 'workers__teams', 'project', 'supervisor', 'paid_in').order_by('-date', '-id')
target_worker = None
if worker_id:
logs = logs.filter(workers__id=worker_id)
target_worker = Worker.objects.filter(id=worker_id).first()
if team_id:
team_workers = Worker.objects.filter(teams__id=team_id)
logs = logs.filter(workers__in=team_workers).distinct()
if project_id:
logs = logs.filter(project_id=project_id)
if payment_status == 'paid':
logs = logs.filter(paid_in__isnull=False).distinct()
elif payment_status == 'unpaid':
if worker_id:
worker = get_object_or_404(Worker, pk=worker_id)
logs = logs.exclude(paid_in__worker=worker)
else:
logs = logs.filter(paid_in__isnull=True)
# --- 2. Fetch Adjustments ---
adjustments = PayrollAdjustment.objects.all().select_related('worker', 'payroll_record', 'work_log')
if worker_id:
adjustments = adjustments.filter(worker_id=worker_id)
if project_id:
adjustments = adjustments.filter(work_log__project_id=project_id)
if team_id:
adjustments = adjustments.filter(work_log__team_id=team_id)
if payment_status == 'paid':
adjustments = adjustments.filter(payroll_record__isnull=False)
elif payment_status == 'unpaid':
adjustments = adjustments.filter(payroll_record__isnull=True)
adjustments = list(adjustments)
user_is_admin = is_admin(request.user)
response = HttpResponse(content_type='text/csv')
response['Content-Disposition'] = 'attachment; filename="work_logs_and_adjustments.csv"'
writer = csv.writer(response)
if user_is_admin:
writer.writerow(['Date', 'Description', 'Workers', 'Amount', 'Payment Status', 'Supervisor'])
else:
writer.writerow(['Date', 'Description', 'Workers', 'Supervisor'])
# Combine and Sort
combined = []
for log in logs:
if target_worker:
workers_str = target_worker.name
else:
workers_str = ", ".join([w.name for w in log.workers.all()])
amt = 0
is_paid = False
if user_is_admin:
if target_worker:
amt = target_worker.day_rate
is_paid = log.paid_in.filter(worker=target_worker).exists()
else:
amt = sum(w.day_rate for w in log.workers.all())
is_paid = log.paid_in.exists()
combined.append({
'date': log.date,
'desc': log.project.name,
'workers': workers_str,
'amount': amt,
'status': "Paid" if is_paid else "Pending",
'supervisor': log.supervisor.username if log.supervisor else "System"
})
for adj in adjustments:
amt = adj.amount
if adj.type in ['DEDUCTION', 'LOAN_REPAYMENT']:
amt = -amt
is_paid = adj.payroll_record is not None
combined.append({
'date': adj.date,
'desc': f"{adj.get_type_display()} - {adj.description}",
'workers': adj.worker.name,
'amount': amt,
'status': "Paid" if is_paid else "Pending",
'supervisor': "System"
})
# Sort
combined.sort(key=lambda x: x['date'], reverse=True)
for row in combined:
if user_is_admin:
writer.writerow([
row['date'], row['desc'], row['workers'],
f"{row['amount']:.2f}", row['status'], row['supervisor']
])
else:
writer.writerow([
row['date'], row['desc'], row['workers'], row['supervisor']
])
return response
@login_required
def manage_resources(request):
"""Redirect to dashboard which now includes manage resources."""
return redirect('home')
@login_required
def toggle_resource_status(request, model_type, pk):
"""Toggle the is_active status of a resource."""
if not is_admin(request.user):
return redirect('log_attendance')
if request.method == 'POST':
model_map = {
'worker': Worker,
'project': Project,
'team': Team,
}
model_class = model_map.get(model_type)
if model_class:
obj = get_object_or_404(model_class, pk=pk)
obj.is_active = not obj.is_active
obj.save()
if request.headers.get('x-requested-with') == 'XMLHttpRequest':
return JsonResponse({
'success': True,
'is_active': obj.is_active,
'message': f"{obj.name} is now {'Active' if obj.is_active else 'Inactive'}."
})
return redirect('home')
@login_required
def payroll_dashboard(request):
"""Dashboard for payroll management with filtering."""
if not is_admin(request.user):
return redirect('log_attendance')
status_filter = request.GET.get('status', 'pending') # pending, paid, all, loans
# Common Analytics
outstanding_total = 0
active_workers = Worker.objects.filter(is_active=True).order_by('name').prefetch_related('adjustments')
workers_data = [] # For pending payments
all_ot_data = [] # For JSON context
for worker in active_workers:
# Unpaid Work Logs
unpaid_logs = worker.work_logs.exclude(paid_in__worker=worker)
log_count = unpaid_logs.count()
log_amount = log_count * worker.day_rate
# Overtime Logic (Updated: Check M2M field)
ot_logs = worker.work_logs.filter(overtime__gt=0).exclude(overtime_paid_to=worker).exclude(paid_in__worker=worker).select_related('project')
ot_data_worker = []
ot_hours_unpriced = Decimal('0.0')
for log in ot_logs:
entry = {
'worker_id': worker.id,
'worker_name': worker.name,
'log_id': log.id,
'date': log.date.strftime('%Y-%m-%d'),
'project': log.project.name,
'overtime': float(log.overtime),
'ot_label': log.get_overtime_display(),
}
ot_data_worker.append(entry)
all_ot_data.append(entry)
ot_hours_unpriced += log.overtime
# Pending Adjustments (unlinked to any payroll record)
pending_adjustments = worker.adjustments.filter(payroll_record__isnull=True)
adj_total = Decimal('0.00')
for adj in pending_adjustments:
if adj.type in ['BONUS', 'OVERTIME', 'LOAN']:
adj_total += adj.amount
elif adj.type in ['DEDUCTION', 'LOAN_REPAYMENT']:
adj_total -= adj.amount
total_payable = log_amount + adj_total
# Only show if there is something to pay or negative (e.g. loan repayment greater than work)
# Note: If total_payable is negative, it implies they owe money.
if log_count > 0 or pending_adjustments.exists():
outstanding_total += max(total_payable, Decimal('0.00')) # Only count positive payable for grand total
if status_filter in ['pending', 'all']:
workers_data.append({
'worker': worker,
'unpaid_count': log_count,
'unpaid_amount': log_amount,
'adj_amount': adj_total,
'total_payable': total_payable,
'adjustments': pending_adjustments,
'logs': unpaid_logs,
'ot_data': ot_data_worker,
'ot_hours_unpriced': float(ot_hours_unpriced),
'day_rate': float(worker.day_rate),
})
# Paid History
paid_records = []
if status_filter in ['paid', 'all']:
paid_records = PayrollRecord.objects.select_related('worker').order_by('-date', '-id')
# Analytics: Project Costs (Active Projects)
project_costs = []
active_projects = Project.objects.filter(is_active=True)
for project in active_projects:
cost = 0
logs = project.logs.all()
for log in logs:
for worker in log.workers.all():
cost += worker.day_rate
if cost > 0:
project_costs.append({'name': project.name, 'cost': cost})
# Analytics: Previous 2 months payments
two_months_ago = timezone.now().date() - timedelta(days=60)
recent_payments_total = PayrollRecord.objects.filter(date__gte=two_months_ago).aggregate(total=Sum('amount'))['total'] or 0
# Active Loans for dropdowns/modals
all_workers = Worker.objects.filter(is_active=True).order_by('name')
# Loans data (for loans tab)
loan_filter = request.GET.get('loan_status', 'active')
if loan_filter == 'history':
loans = Loan.objects.filter(is_active=False).order_by('-date')
else:
loans = Loan.objects.filter(is_active=True).order_by('-date')
# --- Chart Data: Monthly payroll totals & per-project costs (last 6 months) ---
today = timezone.now().date()
chart_months = [] # list of (year, month) tuples, oldest first
for i in range(5, -1, -1):
# Go back i months from current month
m = today.month - i
y = today.year
while m <= 0:
m += 12
y -= 1
chart_months.append((y, m))
chart_labels = [] # e.g. ["Sep 2025", "Oct 2025", ...]
chart_totals = [] # total payroll paid per month
# For per-project chart: {project_name: [month0_cost, month1_cost, ...]}
all_project_names = list(Project.objects.values_list('name', flat=True).order_by('name'))
project_monthly = {name: [] for name in all_project_names}
for year, month in chart_months:
chart_labels.append(f"{calendar.month_abbr[month]} {year}")
# Total paid out this month
_, last_day = calendar.monthrange(year, month)
month_start = datetime.date(year, month, 1)
month_end = datetime.date(year, month, last_day)
month_paid = PayrollRecord.objects.filter(
date__gte=month_start, date__lte=month_end
).aggregate(total=Sum('amount'))['total'] or 0
chart_totals.append(float(month_paid))
# Per-project labour cost this month (from work logs × day rates)
month_logs = WorkLog.objects.filter(
date__gte=month_start, date__lte=month_end
).prefetch_related('workers', 'project')
project_cost_month = {name: 0 for name in all_project_names}
for log in month_logs:
pname = log.project.name
for worker in log.workers.all():
project_cost_month[pname] = project_cost_month.get(pname, 0) + float(worker.day_rate)
for name in all_project_names:
project_monthly[name].append(project_cost_month.get(name, 0))
# Filter out projects with zero cost across all months
project_chart_data = [
{'name': name, 'data': costs}
for name, costs in project_monthly.items()
if any(c > 0 for c in costs)
]
context = {
'workers_data': workers_data,
'paid_records': paid_records,
'outstanding_total': outstanding_total,
'project_costs': project_costs,
'recent_payments_total': recent_payments_total,
'active_tab': status_filter,
'all_workers': all_workers,
'adjustment_types': PayrollAdjustment.ADJUSTMENT_TYPES,
'loans': loans,
'loan_filter': loan_filter,
'chart_labels_json': json.dumps(chart_labels),
'chart_totals_json': json.dumps(chart_totals),
'project_chart_json': json.dumps(project_chart_data),
'overtime_data_json': json.dumps(all_ot_data),
}
return render(request, 'core/payroll_dashboard.html', context)
@login_required
def process_payment(request, worker_id):
"""Process payment for a worker, mark logs as paid, link adjustments, and email receipt."""
if not is_admin(request.user):
return redirect('log_attendance')
worker = get_object_or_404(Worker, pk=worker_id)
if request.method == 'POST':
# Find unpaid logs
unpaid_logs = worker.work_logs.exclude(paid_in__worker=worker)
log_count = unpaid_logs.count()
logs_amount = log_count * worker.day_rate
# Find pending adjustments
pending_adjustments = worker.adjustments.filter(payroll_record__isnull=True)
adj_amount = Decimal('0.00')
for adj in pending_adjustments:
if adj.type in ['BONUS', 'OVERTIME', 'LOAN']:
adj_amount += adj.amount
elif adj.type in ['DEDUCTION', 'LOAN_REPAYMENT']:
adj_amount -= adj.amount
total_amount = logs_amount + adj_amount
if log_count > 0 or pending_adjustments.exists():
# Create Payroll Record
payroll_record = PayrollRecord.objects.create(
worker=worker,
amount=total_amount,
date=timezone.now().date()
)
# Link logs
payroll_record.work_logs.set(unpaid_logs)
# Link Adjustments and Handle Loans
for adj in pending_adjustments:
adj.payroll_record = payroll_record
adj.save()
# Update Loan Balance if it's a repayment
if adj.type == 'LOAN_REPAYMENT' and adj.loan:
adj.loan.balance -= adj.amount
if adj.loan.balance <= 0:
adj.loan.balance = 0
adj.loan.is_active = False
adj.loan.save()
payroll_record.save()
# Email Notification
subject = f"Payslip for {worker.name} - {payroll_record.date}"
# Prepare Context
context = {
'record': payroll_record,
'logs_count': log_count,
'logs_amount': logs_amount,
'adjustments': payroll_record.adjustments.all(),
}
# 1. Render HTML Body
html_message = render_to_string('core/email/payslip_email.html', context)
plain_message = strip_tags(html_message)
# 2. Render PDF Attachment
pdf_content = render_to_pdf('core/pdf/payslip_pdf.html', context)
recipient_list = [settings.SPARK_RECEIPT_EMAIL]
try:
# Construct Email with Attachment
email = EmailMultiAlternatives(
subject,
plain_message,
settings.DEFAULT_FROM_EMAIL,
recipient_list,
)
email.attach_alternative(html_message, "text/html")
if pdf_content:
email.attach(f"Payslip_{worker.id}_{payroll_record.date}.pdf", pdf_content, 'application/pdf')
email.send()
messages.success(request, f"Payment processed for {worker.name}. Net Pay: R {payroll_record.amount}")
except Exception as e:
messages.warning(request, f"Payment processed, but email delivery failed: {str(e)}")
return redirect('payroll_dashboard')
return redirect('payroll_dashboard')
@login_required
def preview_payslip(request, worker_id):
"""Return payslip preview data as JSON (no DB changes, no email)."""
if not is_admin(request.user):
return JsonResponse({'error': 'Unauthorized'}, status=403)
worker = get_object_or_404(Worker, pk=worker_id)
# Calculate the same data as process_payment, but read-only
unpaid_logs = worker.work_logs.exclude(paid_in__worker=worker)
log_count = unpaid_logs.count()
logs_amount = float(log_count * worker.day_rate)
pending_adjustments = worker.adjustments.filter(payroll_record__isnull=True)
adj_list = []
for adj in pending_adjustments:
adj_list.append({
'type': adj.get_type_display(),
'description': adj.description or '',
'amount': float(adj.amount),
'is_deduction': adj.type in ['DEDUCTION', 'LOAN_REPAYMENT'],
})
adj_amount = Decimal('0.00')
for adj in pending_adjustments:
if adj.type in ['BONUS', 'OVERTIME', 'LOAN']:
adj_amount += adj.amount
elif adj.type in ['DEDUCTION', 'LOAN_REPAYMENT']:
adj_amount -= adj.amount
total_amount = float(logs_amount + float(adj_amount))
return JsonResponse({
'worker_name': worker.name,
'worker_id_no': worker.id_no or '',
'date': timezone.now().date().strftime('%Y-%m-%d'),
'day_rate': float(worker.day_rate),
'days_worked': log_count,
'base_pay': logs_amount,
'adjustments': adj_list,
'net_pay': total_amount,
})
@login_required
def price_overtime(request):
"""Create OVERTIME PayrollAdjustments from unpriced overtime logs."""
if not is_admin(request.user):
return redirect('payroll_dashboard')
if request.method != 'POST':
return redirect('payroll_dashboard')
log_ids = request.POST.getlist('log_id')
worker_ids = request.POST.getlist('worker_id')
rate_pcts = request.POST.getlist('rate_pct')
created = 0
for log_id, worker_id, rate_pct in zip(log_ids, worker_ids, rate_pcts):
try:
worklog = WorkLog.objects.get(pk=log_id)
worker = Worker.objects.get(pk=worker_id)
rate = Decimal(rate_pct)
# Formula: Day Rate * Overtime Fraction * (Rate % / 100)
amount = worker.day_rate * worklog.overtime * (rate / Decimal('100'))
if amount > 0:
PayrollAdjustment.objects.create(
worker=worker,
type='OVERTIME',
amount=amount,
date=worklog.date,
description=f"Overtime: {worklog.get_overtime_display()} @ {rate_pct}% - {worklog.date.strftime('%d %b %Y')}",
work_log=worklog
)
created += 1
# Updated: Use M2M field
worklog.overtime_paid_to.add(worker)
except (WorkLog.DoesNotExist, Worker.DoesNotExist, Exception):
continue
messages.success(request, f"Created {created} overtime adjustment(s).")
return redirect('payroll_dashboard')
@login_required
def payslip_detail(request, pk):
"""Show details of a payslip (Payment Record)."""
if not is_admin(request.user):
return redirect('log_attendance')
record = get_object_or_404(PayrollRecord, pk=pk)
# Get the logs included in this payment
logs = record.work_logs.all().order_by('date')
adjustments = record.adjustments.all().order_by('type')
# Calculate base pay from logs (re-verify logic)
# The record.amount is the final NET.
# We can reconstruct the display.
base_pay = sum(w.day_rate for l in logs for w in l.workers.all() if w == record.worker)
adjustments_net = record.amount - base_pay
context = {
'record': record,
'logs': logs,
'adjustments': adjustments,
'base_pay': base_pay,
'adjustments_net': adjustments_net,
}
return render(request, 'core/payslip.html', context)
@login_required
def loan_list(request):
"""Redirect to payroll dashboard loans tab."""
return redirect('/payroll/?status=loans')
@login_required
def add_loan(request):
"""Create a new loan."""
if not is_admin(request.user):
return redirect('log_attendance')
if request.method == 'POST':
worker_id = request.POST.get('worker')
amount = request.POST.get('amount')
reason = request.POST.get('reason')
date = request.POST.get('date') or timezone.now().date()
if worker_id and amount:
worker = get_object_or_404(Worker, pk=worker_id)
Loan.objects.create(
worker=worker,
amount=amount,
date=date,
reason=reason
)
messages.success(request, f"Loan of R{amount} recorded for {worker.name}.")
return redirect('/payroll/?status=loans')
@login_required
def add_adjustment(request):
"""Add a payroll adjustment (Bonus, Overtime, Deduction, Repayment) for one or more workers."""
if not is_admin(request.user):
return redirect('log_attendance')
if request.method == 'POST':
worker_ids = request.POST.getlist('workers')
adj_type = request.POST.get('type')
amount = request.POST.get('amount')
description = request.POST.get('description')
date = request.POST.get('date') or timezone.now().date()
loan_id = request.POST.get('loan_id') # Optional, for repayments
if worker_ids and amount and adj_type:
success_names = []
skip_names = []
for worker_id in worker_ids:
worker = get_object_or_404(Worker, pk=worker_id)
# Validation for repayment OR Creation for New Loan
loan = None
if adj_type == 'LOAN_REPAYMENT':
if loan_id:
loan = get_object_or_404(Loan, pk=loan_id)
else:
loan = worker.loans.filter(is_active=True).first()
if not loan:
skip_names.append(worker.name)
continue
elif adj_type == 'LOAN':
loan = Loan.objects.create(
worker=worker,
amount=amount,
date=date,
reason=description
)
PayrollAdjustment.objects.create(
worker=worker,
type=adj_type,
amount=amount,
description=description,
date=date,
loan=loan
)
success_names.append(worker.name)
if success_names:
names = ', '.join(success_names)
messages.success(request, f"{adj_type} of R{amount} added for {names}.")
if skip_names:
names = ', '.join(skip_names)
messages.warning(request, f"Skipped (no active loan): {names}.")
return redirect('payroll_dashboard')
@login_required
def create_receipt(request):
"""Create a new expense receipt and email it."""
if not is_staff_or_supervisor(request.user):
return redirect('log_attendance')
if request.method == 'POST':
form = ExpenseReceiptForm(request.POST)
items = ExpenseLineItemFormSet(request.POST)
if form.is_valid() and items.is_valid():
receipt = form.save(commit=False)
receipt.user = request.user
receipt.save()
items.instance = receipt
line_items = items.save()
# Backend Calculation for Consistency
sum_amount = sum(item.amount for item in line_items)
vat_type = receipt.vat_type
if vat_type == 'INCLUDED':
receipt.total_amount = sum_amount
receipt.subtotal = sum_amount / Decimal('1.15')
receipt.vat_amount = receipt.total_amount - receipt.subtotal
elif vat_type == 'EXCLUDED':
receipt.subtotal = sum_amount
receipt.vat_amount = sum_amount * Decimal('0.15')
receipt.total_amount = receipt.subtotal + receipt.vat_amount
else: # NONE
receipt.subtotal = sum_amount
receipt.vat_amount = Decimal('0.00')
receipt.total_amount = sum_amount
receipt.save()
# Email Generation
subject = f"Receipt from {receipt.vendor} - {receipt.date}"
recipient_list = [settings.SPARK_RECEIPT_EMAIL]
# Prepare Context
context = {
'receipt': receipt,
'items': line_items,
}
# 1. Render HTML Body
html_message = render_to_string('core/email/receipt_email.html', context)
plain_message = strip_tags(html_message)
# 2. Render PDF Attachment
pdf_content = render_to_pdf('core/pdf/receipt_pdf.html', context)
try:
# Construct Email with Attachment
email = EmailMultiAlternatives(
subject,
plain_message,
settings.DEFAULT_FROM_EMAIL,
recipient_list,
)
email.attach_alternative(html_message, "text/html")
if pdf_content:
email.attach(f"Receipt_{receipt.id}.pdf", pdf_content, 'application/pdf')
email.send()
messages.success(request, "Receipt created and sent to SparkReceipt.")
return redirect('create_receipt')
except Exception as e:
messages.warning(request, f"Receipt saved, but email failed: {e}")
else:
form = ExpenseReceiptForm(initial={'date': timezone.now().date()})
items = ExpenseLineItemFormSet()
return render(request, 'core/create_receipt.html', {
'form': form,
'items': items
})