1633 lines
63 KiB
Python
1633 lines
63 KiB
Python
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 import transaction
|
|
from django.db.models import Sum, Q, Prefetch
|
|
from django.db.models.functions import TruncMonth
|
|
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
|
|
|
|
|
|
# === CONSTANTS ===
|
|
|
|
ADDITIVE_TYPES = ['BONUS', 'OVERTIME', 'LOAN']
|
|
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
|
|
|
|
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)
|
|
|
|
|
|
# === HOME DASHBOARD ===
|
|
|
|
@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 (prefetch all related data to avoid per-worker queries)
|
|
active_workers = Worker.objects.filter(is_active=True).prefetch_related(
|
|
Prefetch(
|
|
'work_logs',
|
|
queryset=WorkLog.objects.prefetch_related(
|
|
Prefetch('paid_in', queryset=PayrollRecord.objects.only('id', 'worker_id'))
|
|
),
|
|
),
|
|
Prefetch(
|
|
'adjustments',
|
|
queryset=PayrollAdjustment.objects.filter(payroll_record__isnull=True),
|
|
to_attr='pending_adjustments_list'
|
|
),
|
|
)
|
|
for worker in active_workers:
|
|
# Unpaid logs (filter in Python using prefetch cache)
|
|
unpaid_logs_count = 0
|
|
for log in worker.work_logs.all():
|
|
paid_worker_ids = {pr.worker_id for pr in log.paid_in.all()}
|
|
if worker.id not in paid_worker_ids:
|
|
unpaid_logs_count += 1
|
|
log_amount = unpaid_logs_count * worker.day_rate
|
|
|
|
# Pending Adjustments (use prefetched list)
|
|
adj_total = Decimal('0.00')
|
|
for adj in worker.pending_adjustments_list:
|
|
if adj.type in ADDITIVE_TYPES:
|
|
adj_total += adj.amount
|
|
elif adj.type in DEDUCTIVE_TYPES:
|
|
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 += len(log.workers.all())
|
|
|
|
# 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').prefetch_related(
|
|
Prefetch('logs', queryset=WorkLog.objects.prefetch_related('workers', 'paid_in')),
|
|
) if user_is_admin else []
|
|
all_teams = Team.objects.all().prefetch_related('workers').order_by('name') if user_is_admin else []
|
|
|
|
# Outstanding Project Costs (Admin only) - Added for Dashboard visibility
|
|
outstanding_project_costs = []
|
|
if user_is_admin:
|
|
# Bulk-fetch all pending project-linked adjustments in one query
|
|
pending_proj_adjs = {}
|
|
for adj in PayrollAdjustment.objects.filter(
|
|
work_log__project__isnull=False,
|
|
payroll_record__isnull=True
|
|
).select_related('work_log'):
|
|
pid = adj.work_log.project_id
|
|
pending_proj_adjs.setdefault(pid, []).append(adj)
|
|
|
|
for project in all_projects:
|
|
outstanding_cost = 0
|
|
|
|
# Unpaid WorkLogs (use prefetch cache, check paid_in in Python)
|
|
for log in project.logs.all():
|
|
if not list(log.paid_in.all()):
|
|
for worker in log.workers.all():
|
|
outstanding_cost += worker.day_rate
|
|
|
|
# Unpaid Adjustments linked to this project (from bulk-fetched dict)
|
|
for adj in pending_proj_adjs.get(project.id, []):
|
|
if adj.type in ADDITIVE_TYPES:
|
|
outstanding_cost += adj.amount
|
|
elif adj.type in DEDUCTIVE_TYPES:
|
|
outstanding_cost -= adj.amount
|
|
|
|
if outstanding_cost > 0:
|
|
outstanding_project_costs.append({'name': project.name, 'cost': outstanding_cost})
|
|
|
|
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,
|
|
"outstanding_project_costs": outstanding_project_costs,
|
|
# 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)
|
|
|
|
|
|
# === LOG ATTENDANCE ===
|
|
|
|
@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)
|
|
|
|
|
|
# === WORK LOG LIST ===
|
|
|
|
@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')
|
|
|
|
# Validate numeric GET params to prevent 500 on bad input
|
|
try:
|
|
worker_id = str(int(worker_id)) if worker_id else None
|
|
except (ValueError, TypeError):
|
|
worker_id = None
|
|
try:
|
|
team_id = str(int(team_id)) if team_id else None
|
|
except (ValueError, TypeError):
|
|
team_id = None
|
|
try:
|
|
project_id = str(int(project_id)) if project_id else None
|
|
except (ValueError, TypeError):
|
|
project_id = None
|
|
|
|
# --- 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', 'work_log__project')
|
|
|
|
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 = []
|
|
|
|
# Prepare Chart Data (Overtime) - Admin only
|
|
ot_chart_labels = []
|
|
ot_chart_data = []
|
|
|
|
if user_is_admin:
|
|
from django.db.models.functions import TruncMonth
|
|
ot_stats = adjustments.filter(type='OVERTIME') \
|
|
.annotate(month=TruncMonth('date')) \
|
|
.values('month') \
|
|
.annotate(total=Sum('amount')) \
|
|
.order_by('month')
|
|
|
|
ot_chart_labels = [s['month'].strftime('%b %Y') for s in ot_stats]
|
|
ot_chart_data = [float(s['total']) for s in ot_stats]
|
|
|
|
# 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 DEDUCTIVE_TYPES:
|
|
amt = -amt
|
|
|
|
record = {
|
|
'type': 'ADJ',
|
|
'date': adj.date,
|
|
'obj': adj,
|
|
'project_name': f"{adj.get_type_display()} ({adj.work_log.project.name})" if adj.work_log and getattr(adj.work_log, 'project', None) else 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,
|
|
'ot_chart_labels': json.dumps(ot_chart_labels),
|
|
'ot_chart_data': json.dumps(ot_chart_data),
|
|
}
|
|
|
|
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)
|
|
|
|
|
|
# === EXPORT WORK LOG CSV ===
|
|
|
|
@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', 'work_log__project')
|
|
|
|
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 DEDUCTIVE_TYPES:
|
|
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
|
|
|
|
|
|
# === RESOURCE MANAGEMENT ===
|
|
|
|
@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')
|
|
|
|
|
|
# === PAYROLL DASHBOARD ===
|
|
|
|
@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 (prefetch all related data to avoid per-worker queries)
|
|
outstanding_total = 0
|
|
active_workers = Worker.objects.filter(is_active=True).order_by('name').prefetch_related(
|
|
Prefetch(
|
|
'work_logs',
|
|
queryset=WorkLog.objects.select_related('project').prefetch_related(
|
|
Prefetch('paid_in', queryset=PayrollRecord.objects.only('id', 'worker_id')),
|
|
'overtime_paid_to',
|
|
),
|
|
),
|
|
Prefetch(
|
|
'adjustments',
|
|
queryset=PayrollAdjustment.objects.filter(payroll_record__isnull=True),
|
|
to_attr='pending_adjustments_list'
|
|
),
|
|
)
|
|
|
|
workers_data = [] # For pending payments
|
|
all_ot_data = [] # For JSON context
|
|
|
|
for worker in active_workers:
|
|
# Unpaid Work Logs (filter in Python using prefetch cache)
|
|
unpaid_logs = []
|
|
for log in worker.work_logs.all():
|
|
paid_worker_ids = {pr.worker_id for pr in log.paid_in.all()}
|
|
if worker.id not in paid_worker_ids:
|
|
unpaid_logs.append(log)
|
|
log_count = len(unpaid_logs)
|
|
log_amount = log_count * worker.day_rate
|
|
|
|
# Overtime Logic (filter from unpaid logs using prefetch cache)
|
|
ot_data_worker = []
|
|
ot_hours_unpriced = Decimal('0.0')
|
|
|
|
for log in unpaid_logs:
|
|
if log.overtime > 0:
|
|
ot_paid_ids = {w.id for w in log.overtime_paid_to.all()}
|
|
if worker.id not in ot_paid_ids:
|
|
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 (use prefetched list — no extra queries)
|
|
pending_adjustments = worker.pending_adjustments_list
|
|
adj_total = Decimal('0.00')
|
|
for adj in pending_adjustments:
|
|
if adj.type in ADDITIVE_TYPES:
|
|
adj_total += adj.amount
|
|
elif adj.type in DEDUCTIVE_TYPES:
|
|
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 len(pending_adjustments) > 0:
|
|
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),
|
|
'has_pending_advances': any(a.type == 'ADVANCE' for a in pending_adjustments),
|
|
})
|
|
|
|
# Paid History
|
|
paid_records = []
|
|
if status_filter in ['paid', 'all']:
|
|
paid_records = PayrollRecord.objects.select_related('worker').order_by('-date', '-id')
|
|
|
|
# Analytics: Project Costs (prefetch all logs+workers in bulk)
|
|
project_costs = []
|
|
outstanding_project_costs = []
|
|
active_projects = Project.objects.filter(is_active=True).prefetch_related(
|
|
Prefetch('logs', queryset=WorkLog.objects.prefetch_related('workers', 'paid_in')),
|
|
)
|
|
|
|
# Bulk-fetch all pending project-linked adjustments in one query
|
|
pending_proj_adjs = {}
|
|
for adj in PayrollAdjustment.objects.filter(
|
|
work_log__project__isnull=False,
|
|
work_log__project__is_active=True,
|
|
payroll_record__isnull=True,
|
|
).select_related('work_log'):
|
|
pid = adj.work_log.project_id
|
|
pending_proj_adjs.setdefault(pid, []).append(adj)
|
|
|
|
for project in active_projects:
|
|
# 1. Total Historical Cost
|
|
cost = 0
|
|
for log in project.logs.all():
|
|
for worker in log.workers.all():
|
|
cost += worker.day_rate
|
|
if cost > 0:
|
|
project_costs.append({'name': project.name, 'cost': cost})
|
|
|
|
# 2. Outstanding Cost (Unpaid)
|
|
outstanding_cost = 0
|
|
|
|
# Unpaid WorkLogs (check paid_in in Python using prefetch cache)
|
|
for log in project.logs.all():
|
|
if not list(log.paid_in.all()):
|
|
for worker in log.workers.all():
|
|
outstanding_cost += worker.day_rate
|
|
|
|
# Unpaid Adjustments linked to this project (from bulk-fetched dict)
|
|
for adj in pending_proj_adjs.get(project.id, []):
|
|
if adj.type in ADDITIVE_TYPES:
|
|
outstanding_cost += adj.amount
|
|
elif adj.type in DEDUCTIVE_TYPES:
|
|
outstanding_cost -= adj.amount
|
|
|
|
if outstanding_cost > 0:
|
|
outstanding_project_costs.append({'name': project.name, 'cost': outstanding_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')
|
|
|
|
# Teams and team→worker mapping for adjustment modal
|
|
all_teams = Team.objects.filter(is_active=True).order_by('name')
|
|
team_workers_map = {}
|
|
for team in all_teams:
|
|
team_workers_map[team.id] = list(team.workers.filter(is_active=True).values_list('id', flat=True))
|
|
|
|
# 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):
|
|
m = today.month - i
|
|
y = today.year
|
|
while m <= 0:
|
|
m += 12
|
|
y -= 1
|
|
chart_months.append((y, m))
|
|
|
|
# Build a lookup: (year, month) → index in chart_months
|
|
chart_month_index = {ym: idx for idx, ym in enumerate(chart_months)}
|
|
chart_start = datetime.date(chart_months[0][0], chart_months[0][1], 1)
|
|
|
|
chart_labels = [f"{calendar.month_abbr[m]} {y}" for y, m in chart_months]
|
|
|
|
# 1 query: monthly payroll totals (grouped by month)
|
|
paid_by_month = {}
|
|
for row in PayrollRecord.objects.filter(date__gte=chart_start).annotate(
|
|
month=TruncMonth('date')
|
|
).values('month').annotate(total=Sum('amount')):
|
|
paid_by_month[(row['month'].year, row['month'].month)] = float(row['total'])
|
|
|
|
chart_totals = [paid_by_month.get(ym, 0) for ym in chart_months]
|
|
|
|
# 1 query: monthly overtime totals (grouped by month)
|
|
ot_by_month = {}
|
|
for row in PayrollAdjustment.objects.filter(
|
|
type='OVERTIME', date__gte=chart_start
|
|
).annotate(month=TruncMonth('date')).values('month').annotate(total=Sum('amount')):
|
|
ot_by_month[(row['month'].year, row['month'].month)] = float(row['total'])
|
|
|
|
ot_history_totals = [ot_by_month.get(ym, 0) for ym in chart_months]
|
|
|
|
# 1 query + prefetch: all work logs in the 6-month period for per-project costs
|
|
all_project_names = list(Project.objects.values_list('name', flat=True).order_by('name'))
|
|
project_monthly = {name: [0] * len(chart_months) for name in all_project_names}
|
|
|
|
all_chart_logs = WorkLog.objects.filter(
|
|
date__gte=chart_start
|
|
).select_related('project').prefetch_related('workers')
|
|
|
|
for log in all_chart_logs:
|
|
month_key = (log.date.year, log.date.month)
|
|
idx = chart_month_index.get(month_key)
|
|
if idx is not None:
|
|
pname = log.project.name
|
|
if pname in project_monthly:
|
|
for worker in log.workers.all():
|
|
project_monthly[pname][idx] += float(worker.day_rate)
|
|
|
|
# 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,
|
|
'outstanding_project_costs': outstanding_project_costs,
|
|
'recent_payments_total': recent_payments_total,
|
|
'active_tab': status_filter,
|
|
'all_workers': all_workers,
|
|
'all_teams': all_teams,
|
|
'all_projects': Project.objects.filter(is_active=True).order_by('name'),
|
|
'team_workers_map_json': json.dumps(team_workers_map),
|
|
'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),
|
|
'ot_history_json': json.dumps(ot_history_totals),
|
|
}
|
|
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."""
|
|
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 ADDITIVE_TYPES:
|
|
adj_amount += adj.amount
|
|
elif adj.type in DEDUCTIVE_TYPES:
|
|
adj_amount -= adj.amount
|
|
|
|
total_amount = logs_amount + adj_amount
|
|
|
|
if log_count > 0 or pending_adjustments.exists():
|
|
with transaction.atomic():
|
|
# 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 (outside transaction — failure should not roll back payment)
|
|
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')
|
|
|
|
|
|
# === PREVIEW PAYSLIP ===
|
|
|
|
@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 DEDUCTIVE_TYPES,
|
|
})
|
|
|
|
adj_amount = Decimal('0.00')
|
|
for adj in pending_adjustments:
|
|
if adj.type in ADDITIVE_TYPES:
|
|
adj_amount += adj.amount
|
|
elif adj.type in DEDUCTIVE_TYPES:
|
|
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,
|
|
})
|
|
|
|
|
|
# === PRICE OVERTIME ===
|
|
|
|
@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')
|
|
|
|
|
|
# === PAYSLIP DETAIL ===
|
|
|
|
@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)
|
|
|
|
|
|
# === LOANS ===
|
|
|
|
@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_str = request.POST.get('amount')
|
|
reason = request.POST.get('reason')
|
|
date = request.POST.get('date') or timezone.now().date()
|
|
|
|
try:
|
|
amount = Decimal(amount_str) if amount_str else None
|
|
except Exception:
|
|
messages.error(request, "Invalid amount.")
|
|
return redirect('/payroll/?status=loans')
|
|
|
|
if worker_id and amount and amount > 0:
|
|
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}.")
|
|
elif amount is not None and amount <= 0:
|
|
messages.error(request, "Amount must be greater than zero.")
|
|
|
|
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."""
|
|
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_str = 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
|
|
project_id = request.POST.get('project_id') # Optional, for linking to a project
|
|
|
|
try:
|
|
amount = Decimal(amount_str) if amount_str else None
|
|
except Exception:
|
|
messages.error(request, "Invalid amount.")
|
|
return redirect('payroll_dashboard')
|
|
|
|
if amount is not None and amount <= 0:
|
|
messages.error(request, "Amount must be greater than zero.")
|
|
return redirect('payroll_dashboard')
|
|
|
|
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)
|
|
|
|
# --- ADVANCE: validate, create adjustment + standalone PayrollRecord, send payslip ---
|
|
if adj_type == 'ADVANCE':
|
|
advance_amount = amount
|
|
|
|
# Must have unpaid work logs
|
|
unpaid_logs = worker.work_logs.exclude(paid_in__worker=worker)
|
|
log_count = unpaid_logs.count()
|
|
if log_count == 0:
|
|
skip_names.append(f"{worker.name} (no unpaid work)")
|
|
continue
|
|
|
|
# Calculate max available (earned + existing adjustments)
|
|
logs_amount = log_count * worker.day_rate
|
|
existing_pending = worker.adjustments.filter(payroll_record__isnull=True)
|
|
existing_adj_total = Decimal('0.00')
|
|
for existing_adj in existing_pending:
|
|
if existing_adj.type in ADDITIVE_TYPES:
|
|
existing_adj_total += existing_adj.amount
|
|
elif existing_adj.type in DEDUCTIVE_TYPES:
|
|
existing_adj_total -= existing_adj.amount
|
|
max_available = logs_amount + existing_adj_total
|
|
|
|
if advance_amount > max_available:
|
|
skip_names.append(f"{worker.name} (R{advance_amount} exceeds available R{max_available:.2f})")
|
|
continue
|
|
|
|
# Create ADVANCE adjustment + PayrollRecord atomically
|
|
with transaction.atomic():
|
|
work_log_link = None
|
|
if project_id:
|
|
# Try to find a worklog for this worker and project to link the adjustment to the project
|
|
work_log_link = worker.work_logs.filter(project_id=project_id).order_by('-date').first()
|
|
if not work_log_link:
|
|
# Fallback: any worklog for project
|
|
from .models import WorkLog
|
|
work_log_link = WorkLog.objects.filter(project_id=project_id).order_by('-date').first()
|
|
|
|
PayrollAdjustment.objects.create(
|
|
worker=worker,
|
|
type='ADVANCE',
|
|
amount=advance_amount,
|
|
description=description or 'Advance payment',
|
|
date=date,
|
|
work_log=work_log_link,
|
|
)
|
|
|
|
advance_date = date if isinstance(date, datetime.date) else timezone.now().date()
|
|
advance_record = PayrollRecord.objects.create(
|
|
worker=worker,
|
|
amount=advance_amount,
|
|
date=advance_date,
|
|
type='ADVANCE',
|
|
notes=description or 'Advance payment'
|
|
)
|
|
|
|
# Send advance payslip to Spark (outside transaction)
|
|
subject = f"Advance Payment for {worker.name} - {advance_record.date}"
|
|
email_context = {
|
|
'record': advance_record,
|
|
'logs_count': 0,
|
|
'logs_amount': Decimal('0.00'),
|
|
'adjustments': [],
|
|
'is_advance': True,
|
|
'advance_amount': advance_amount,
|
|
'advance_description': description or 'Advance payment',
|
|
}
|
|
html_message = render_to_string('core/email/payslip_email.html', email_context)
|
|
plain_message = strip_tags(html_message)
|
|
pdf_content = render_to_pdf('core/pdf/payslip_pdf.html', email_context)
|
|
|
|
try:
|
|
email_obj = EmailMultiAlternatives(
|
|
subject,
|
|
plain_message,
|
|
settings.DEFAULT_FROM_EMAIL,
|
|
[settings.SPARK_RECEIPT_EMAIL],
|
|
)
|
|
email_obj.attach_alternative(html_message, "text/html")
|
|
if pdf_content:
|
|
email_obj.attach(
|
|
f"Advance_{worker.id}_{advance_record.date}.pdf",
|
|
pdf_content,
|
|
'application/pdf'
|
|
)
|
|
email_obj.send()
|
|
except Exception as e:
|
|
messages.warning(request, f"Advance recorded for {worker.name}, but email failed: {e}")
|
|
|
|
success_names.append(worker.name)
|
|
continue
|
|
|
|
# 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
|
|
)
|
|
|
|
work_log_link = None
|
|
if project_id:
|
|
# Try to find a worklog for this worker and project to link the adjustment to the project
|
|
work_log_link = worker.work_logs.filter(project_id=project_id).order_by('-date').first()
|
|
if not work_log_link:
|
|
# Fallback: any worklog for project
|
|
from .models import WorkLog
|
|
work_log_link = WorkLog.objects.filter(project_id=project_id).order_by("-date").first()
|
|
|
|
PayrollAdjustment.objects.create(
|
|
worker=worker,
|
|
type=adj_type,
|
|
amount=amount,
|
|
description=description,
|
|
date=date,
|
|
loan=loan,
|
|
work_log=work_log_link
|
|
)
|
|
success_names.append(worker.name)
|
|
|
|
if success_names:
|
|
names = ', '.join(success_names)
|
|
if adj_type == 'ADVANCE':
|
|
messages.success(request, f"Advance of R{amount} processed and payslip sent for {names}.")
|
|
else:
|
|
messages.success(request, f"{adj_type} of R{amount} added for {names}.")
|
|
if skip_names:
|
|
names = ', '.join(skip_names)
|
|
messages.warning(request, f"Skipped: {names}.")
|
|
|
|
return redirect('payroll_dashboard')
|
|
|
|
@login_required
|
|
def edit_adjustment(request, pk):
|
|
"""Edit an unpaid payroll adjustment. Admin only, POST only."""
|
|
if not is_admin(request.user):
|
|
return redirect('log_attendance')
|
|
|
|
if request.method != 'POST':
|
|
return redirect('payroll_dashboard')
|
|
|
|
adj = get_object_or_404(PayrollAdjustment, pk=pk)
|
|
|
|
# Only allow editing unpaid adjustments
|
|
if adj.payroll_record is not None:
|
|
messages.error(request, "Cannot edit a paid adjustment.")
|
|
return redirect('payroll_dashboard')
|
|
|
|
# Advance amounts cannot be edited — payslip was already sent
|
|
if adj.type == 'ADVANCE':
|
|
messages.warning(request, "Advance amounts cannot be edited after creation. Delete and re-create instead.")
|
|
return redirect('payroll_dashboard')
|
|
|
|
amount = request.POST.get('amount')
|
|
description = request.POST.get('description')
|
|
date = request.POST.get('date')
|
|
new_type = request.POST.get('type')
|
|
project_id = request.POST.get('project_id')
|
|
|
|
if amount:
|
|
adj.amount = Decimal(amount)
|
|
if description:
|
|
adj.description = description
|
|
if date:
|
|
adj.date = date
|
|
|
|
if project_id:
|
|
work_log_link = adj.worker.work_logs.filter(project_id=project_id).order_by('-date').first()
|
|
if not work_log_link:
|
|
from .models import WorkLog
|
|
work_log_link = WorkLog.objects.filter(project_id=project_id).order_by('-date').first()
|
|
adj.work_log = work_log_link
|
|
elif project_id == '':
|
|
adj.work_log = None
|
|
|
|
# Only allow type change for BONUS/DEDUCTION (others have linked objects)
|
|
if new_type and adj.type in ('BONUS', 'DEDUCTION') and new_type in ('BONUS', 'DEDUCTION'):
|
|
adj.type = new_type
|
|
|
|
adj.save()
|
|
|
|
# If LOAN type, sync the linked Loan object
|
|
if adj.type == 'LOAN' and adj.loan:
|
|
adj.loan.amount = adj.amount
|
|
adj.loan.balance = adj.amount
|
|
adj.loan.reason = adj.description
|
|
adj.loan.save()
|
|
|
|
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."""
|
|
if not is_admin(request.user):
|
|
return redirect('log_attendance')
|
|
|
|
if request.method != 'POST':
|
|
return redirect('payroll_dashboard')
|
|
|
|
adj = get_object_or_404(PayrollAdjustment, pk=pk)
|
|
|
|
# Only allow deleting unpaid adjustments
|
|
if adj.payroll_record is not None:
|
|
messages.error(request, "Cannot delete a paid adjustment.")
|
|
return redirect('payroll_dashboard')
|
|
|
|
worker_name = adj.worker.name
|
|
type_display = adj.get_type_display()
|
|
|
|
if adj.type == 'LOAN' and adj.loan:
|
|
loan = adj.loan
|
|
# Check if any repayments for this loan have been paid
|
|
paid_repayments = PayrollAdjustment.objects.filter(
|
|
loan=loan, type='LOAN_REPAYMENT', payroll_record__isnull=False
|
|
).exists()
|
|
if paid_repayments:
|
|
messages.warning(request, f"Cannot delete loan for {worker_name} — it has paid repayments. Delete unpaid repayments manually first.")
|
|
return redirect('payroll_dashboard')
|
|
# Delete any unpaid repayment adjustments linked to this loan
|
|
PayrollAdjustment.objects.filter(
|
|
loan=loan, type='LOAN_REPAYMENT', payroll_record__isnull=True
|
|
).delete()
|
|
# Delete the loan itself
|
|
loan.delete()
|
|
|
|
elif adj.type == 'OVERTIME' and adj.work_log:
|
|
# Remove worker from overtime_paid_to so OT can be re-priced
|
|
adj.work_log.overtime_paid_to.remove(adj.worker)
|
|
|
|
adj.delete()
|
|
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."""
|
|
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
|
|
}) |