Adds _group_adjustments helper that buckets a flat queryset by type or
by worker_id, with signed net_sum (+ for additive, - for deductive)
and descending-magnitude ordering so the biggest-impact bucket sits
at the top.
Template branches on adj_groups: grouped view renders one <tbody>
per group with a Bootstrap-collapse-driven header row, wrapping
include of _adjustment_row.html for the actual rows (no duplication).
Flat view is the default when group_by is empty.
By Type headers get a 4px left-border accent in the matching badge
colour so grouped rows visually echo the badges below them.
Attribute-selector based ([data-type=Bonus] etc.) so the
CSS stays self-descriptive without per-type class explosion.
Adds |money_abs template filter for signed render ('-R 100.00' in
the template becomes money_abs(-100) -> '100.00' after the caller
emits its own sign; avoids 'R -100.00' which reads wrong).
Two new tests lock in the bucket structure + net_sum signing for
both axes. Tests 58 -> 60. url_replace template tag already shipped
in the CP1 pagination fix - reused here for the toggle hrefs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4643 lines
185 KiB
Python
4643 lines
185 KiB
Python
# === VIEWS ===
|
||
# All the page logic for the LabourPay app.
|
||
# Each function here handles a URL and decides what to show the user.
|
||
|
||
import csv
|
||
import json
|
||
import datetime
|
||
import calendar as cal_module
|
||
from decimal import Decimal
|
||
|
||
from django.shortcuts import render, redirect, get_object_or_404
|
||
from django.utils import timezone
|
||
from django.db import transaction
|
||
from django.db.models import Sum, Count, Q, F, Prefetch, Max, Min
|
||
from django.db.models.functions import TruncMonth
|
||
from django.contrib import messages
|
||
from django.contrib.auth.decorators import login_required
|
||
from django.http import JsonResponse, HttpResponseForbidden, HttpResponse
|
||
from django.middleware.csrf import get_token
|
||
from django.urls import reverse
|
||
from django.core.mail import EmailMultiAlternatives
|
||
from django.template.loader import render_to_string
|
||
from django.utils.html import strip_tags
|
||
from django.conf import settings
|
||
|
||
from .models import (
|
||
Worker, Project, WorkLog, Team, PayrollRecord, Loan, PayrollAdjustment,
|
||
WorkerCertificate, WorkerWarning,
|
||
)
|
||
from .forms import (
|
||
AttendanceLogForm, PayrollAdjustmentForm, ExpenseReceiptForm, ExpenseLineItemFormSet,
|
||
WorkerForm, WorkerCertificateFormSet, WorkerWarningFormSet,
|
||
TeamForm, ProjectForm,
|
||
)
|
||
# NOTE: render_to_pdf is NOT imported here at the top level.
|
||
# It's imported lazily inside process_payment() and create_receipt()
|
||
# to avoid crashing the entire app if xhtml2pdf is not installed on the server.
|
||
|
||
|
||
# === PAYROLL CONSTANTS ===
|
||
# These define which adjustment types ADD to a worker's pay vs SUBTRACT from it.
|
||
# "New Loan" and "Advance Payment" are additive — the worker receives money upfront.
|
||
# "Loan Repayment" and "Advance Repayment" are deductive — they reduce net pay.
|
||
ADDITIVE_TYPES = ['Bonus', 'Overtime', 'New Loan', 'Advance Payment']
|
||
DEDUCTIVE_TYPES = ['Deduction', 'Loan Repayment', 'Advance Repayment']
|
||
|
||
|
||
# === PERMISSION HELPERS ===
|
||
# These small functions check what kind of user is logged in.
|
||
# "Admin" = the boss (is_staff or is_superuser in Django).
|
||
# "Supervisor" = someone who manages teams or projects, or is in the Work Logger group.
|
||
|
||
def is_admin(user):
|
||
"""Returns True if the user is staff or superuser (the boss)."""
|
||
return user.is_staff or user.is_superuser
|
||
|
||
|
||
def is_supervisor(user):
|
||
"""Returns True if the user manages teams, has assigned projects, or is a Work Logger."""
|
||
return (
|
||
user.supervised_teams.exists()
|
||
or user.assigned_projects.exists()
|
||
or user.groups.filter(name='Work Logger').exists()
|
||
)
|
||
|
||
|
||
def is_staff_or_supervisor(user):
|
||
"""Returns True if the user is either an admin or a supervisor."""
|
||
return is_admin(user) or is_supervisor(user)
|
||
|
||
|
||
# === PAY SCHEDULE HELPERS ===
|
||
# These help figure out a worker's pay period based on their team's schedule.
|
||
|
||
def get_worker_active_team(worker):
|
||
"""Return the worker's active team (first one found), or None."""
|
||
return worker.teams.filter(active=True).first()
|
||
|
||
|
||
def get_pay_period(team, reference_date=None):
|
||
"""
|
||
Calculate the current pay period's start and end dates for a team.
|
||
|
||
Returns (period_start, period_end) or (None, None) if the team has
|
||
no pay schedule configured.
|
||
|
||
How it works:
|
||
- pay_start_date is the "anchor" — the first day of the very first pay period.
|
||
- pay_frequency determines the length of each period (7, 14, or ~30 days).
|
||
- We step forward from the anchor in period-length increments until
|
||
we find the period that contains reference_date (today by default).
|
||
"""
|
||
if not team or not team.pay_frequency or not team.pay_start_date:
|
||
return (None, None)
|
||
|
||
if reference_date is None:
|
||
reference_date = timezone.now().date()
|
||
|
||
anchor = team.pay_start_date
|
||
|
||
# === WEEKLY / FORTNIGHTLY ===
|
||
# Simple fixed-length periods (7 or 14 days).
|
||
if team.pay_frequency in ('weekly', 'fortnightly'):
|
||
period_days = 7 if team.pay_frequency == 'weekly' else 14
|
||
|
||
# How many full periods have passed since the anchor?
|
||
days_since_anchor = (reference_date - anchor).days
|
||
if days_since_anchor < 0:
|
||
# reference_date is before the anchor — use the first period
|
||
return (anchor, anchor + datetime.timedelta(days=period_days - 1))
|
||
|
||
periods_passed = days_since_anchor // period_days
|
||
period_start = anchor + datetime.timedelta(days=periods_passed * period_days)
|
||
period_end = period_start + datetime.timedelta(days=period_days - 1)
|
||
return (period_start, period_end)
|
||
|
||
# === MONTHLY ===
|
||
# Step through calendar months from the anchor's day-of-month.
|
||
# E.g., anchor = Jan 15 means periods are: Jan 15–Feb 14, Feb 15–Mar 14, etc.
|
||
elif team.pay_frequency == 'monthly':
|
||
anchor_day = anchor.day
|
||
current_start = anchor
|
||
|
||
# Walk forward month by month until we find the period containing today
|
||
for _ in range(120): # Safety limit — 10 years of months
|
||
if current_start.month == 12:
|
||
next_month, next_year = 1, current_start.year + 1
|
||
else:
|
||
next_month, next_year = current_start.month + 1, current_start.year
|
||
|
||
# Clamp anchor day to the max days in that month (e.g., 31 → 28 for Feb)
|
||
max_day = cal_module.monthrange(next_year, next_month)[1]
|
||
next_start = datetime.date(next_year, next_month, min(anchor_day, max_day))
|
||
current_end = next_start - datetime.timedelta(days=1)
|
||
|
||
if reference_date <= current_end:
|
||
return (current_start, current_end)
|
||
current_start = next_start
|
||
|
||
return (None, None)
|
||
|
||
|
||
# =============================================================================
|
||
# === OUTSTANDING PAYMENTS — SHARED HELPER ===
|
||
# Used by the home dashboard AND the payroll report. Computes:
|
||
# - outstanding_payments: Decimal total (unpaid wages + net unpaid adjustments)
|
||
# - unpaid_wages: Decimal (pure daily rates for unpaid workers)
|
||
# - pending_adj_add: Decimal (unpaid additive adjustments, e.g. bonuses)
|
||
# - pending_adj_sub: Decimal (unpaid deductive adjustments, e.g. loan repayments)
|
||
# - outstanding_by_project: dict[str project_name -> Decimal amount]
|
||
#
|
||
# Accepts optional project_ids / team_ids filters. Empty list or None = no filter.
|
||
# =============================================================================
|
||
|
||
def _compute_outstanding(project_ids=None, team_ids=None):
|
||
"""Return current-moment outstanding payment breakdown.
|
||
|
||
Plain-English: for each work log that hasn't been fully paid, adds up
|
||
each unpaid worker's daily rate. Then adds unpaid additive adjustments
|
||
(bonuses, overtime, new loans, advances) and subtracts unpaid deductive
|
||
adjustments (deductions, loan/advance repayments). Results are the
|
||
"as of right now" snapshot shown on the home dashboard's Outstanding
|
||
Payments card. Optional filters scope the answer to specific projects
|
||
and/or teams.
|
||
"""
|
||
# --- Work logs in scope ---
|
||
work_logs = WorkLog.objects.select_related('project').prefetch_related('workers', 'payroll_records')
|
||
if project_ids:
|
||
work_logs = work_logs.filter(project_id__in=project_ids)
|
||
if team_ids:
|
||
work_logs = work_logs.filter(team_id__in=team_ids)
|
||
|
||
unpaid_wages = Decimal('0.00')
|
||
outstanding_by_project = {}
|
||
|
||
for wl in work_logs:
|
||
paid_worker_ids = {pr.worker_id for pr in wl.payroll_records.all()}
|
||
project_name = wl.project.name if wl.project else 'No Project'
|
||
for worker in wl.workers.all():
|
||
if worker.id not in paid_worker_ids:
|
||
cost = worker.daily_rate
|
||
unpaid_wages += cost
|
||
outstanding_by_project.setdefault(project_name, Decimal('0.00'))
|
||
outstanding_by_project[project_name] += cost
|
||
|
||
# --- Unpaid adjustments in scope ---
|
||
adj_qs = PayrollAdjustment.objects.filter(payroll_record__isnull=True).select_related('project')
|
||
if project_ids:
|
||
adj_qs = adj_qs.filter(project_id__in=project_ids)
|
||
if team_ids:
|
||
# worker__teams is M2M — use subquery pattern (see CLAUDE.md Django ORM gotcha)
|
||
adj_qs = adj_qs.filter(
|
||
worker__in=Worker.objects.filter(teams__id__in=team_ids).values('id')
|
||
)
|
||
|
||
pending_adj_add = Decimal('0.00')
|
||
pending_adj_sub = Decimal('0.00')
|
||
for adj in adj_qs:
|
||
project_name = adj.project.name if adj.project else 'No Project'
|
||
outstanding_by_project.setdefault(project_name, Decimal('0.00'))
|
||
if adj.type in ADDITIVE_TYPES:
|
||
pending_adj_add += adj.amount
|
||
outstanding_by_project[project_name] += adj.amount
|
||
elif adj.type in DEDUCTIVE_TYPES:
|
||
pending_adj_sub += adj.amount
|
||
outstanding_by_project[project_name] -= adj.amount
|
||
|
||
outstanding_payments = unpaid_wages + pending_adj_add - pending_adj_sub
|
||
|
||
return {
|
||
'outstanding_payments': outstanding_payments,
|
||
'unpaid_wages': unpaid_wages,
|
||
'pending_adj_add': pending_adj_add,
|
||
'pending_adj_sub': pending_adj_sub,
|
||
'outstanding_by_project': outstanding_by_project,
|
||
}
|
||
|
||
|
||
# =============================================================================
|
||
# === COMPANY COST VELOCITY ===
|
||
# Lifetime "what does a typical FoxFitt working day cost us?" metric.
|
||
# Denominator = COUNT(DISTINCT work_log.date) — true working days, not
|
||
# calendar days (rain days, weekends, permit delays don't dilute the rate).
|
||
# Used by the hero KPI band on the payroll report.
|
||
# =============================================================================
|
||
|
||
def _company_cost_velocity():
|
||
"""Return company-wide avg daily and monthly labour cost (lifetime)."""
|
||
# Total lifetime labour cost: sum of (worker.daily_rate) over every
|
||
# (log, worker) pair that has ever been logged.
|
||
total_cost = Decimal('0.00')
|
||
for wl in WorkLog.objects.prefetch_related('workers').all():
|
||
for worker in wl.workers.all():
|
||
total_cost += worker.daily_rate
|
||
|
||
# Distinct work-log dates = working days
|
||
working_days = WorkLog.objects.values('date').distinct().count()
|
||
|
||
if working_days == 0:
|
||
avg_daily = Decimal('0.00')
|
||
else:
|
||
avg_daily = (total_cost / working_days).quantize(Decimal('0.01'))
|
||
|
||
# 30.44 = 365.25 / 12 — standard month-length approximation.
|
||
# Keeps annualised totals correct on average.
|
||
avg_monthly = (avg_daily * Decimal('30.44')).quantize(Decimal('0.01'))
|
||
|
||
return {
|
||
'avg_daily': avg_daily,
|
||
'avg_monthly': avg_monthly,
|
||
'working_days': working_days,
|
||
}
|
||
|
||
|
||
# =============================================================================
|
||
# === CURRENT OUTSTANDING — SCOPED FOR THE REPORT ===
|
||
# Thin wrapper around _compute_outstanding that shapes the output for
|
||
# the executive report's hero card 2. Includes a 'by_project' list
|
||
# sorted by amount desc, ready for direct template rendering.
|
||
# =============================================================================
|
||
|
||
def _current_outstanding_in_scope(project_ids=None, team_ids=None):
|
||
"""Return current outstanding payments, optionally scoped by project/team.
|
||
|
||
Calls _compute_outstanding and reshapes the by_project dict into a
|
||
list sorted by amount descending (for display). The 'total' field
|
||
is the net outstanding (unpaid wages + additive adjustments minus
|
||
deductive adjustments), matching the home dashboard card.
|
||
"""
|
||
raw = _compute_outstanding(project_ids=project_ids, team_ids=team_ids)
|
||
by_project_list = sorted(
|
||
[{'name': name, 'amount': amt} for name, amt in raw['outstanding_by_project'].items()],
|
||
key=lambda r: r['amount'],
|
||
reverse=True,
|
||
)
|
||
return {
|
||
'total': raw['outstanding_payments'],
|
||
'by_project': by_project_list,
|
||
}
|
||
|
||
|
||
# =============================================================================
|
||
# === TEAM × PROJECT ACTIVITY PIVOT ===
|
||
# Chapter IV of the executive report: "how many days did each team work
|
||
# on each project in this period?" Cell value = COUNT(DISTINCT work_log.date).
|
||
# Logs with no team (team IS NULL) are excluded — the pivot is meaningless
|
||
# without a team axis.
|
||
# =============================================================================
|
||
|
||
def _team_project_activity(work_logs_qs):
|
||
"""Return pivot data for team × project activity within a work-logs queryset.
|
||
|
||
Plain-English: for each team-project pair represented in the given
|
||
queryset, counts the number of distinct calendar dates the team worked
|
||
on that project. Rows and columns include only teams/projects that
|
||
actually appeared (zero-activity teams/projects aren't shown).
|
||
"""
|
||
# Narrow to logs that have both a team and a project (we can't pivot
|
||
# on NULL axes; also filters out the "No Project" ghost rows).
|
||
qs = work_logs_qs.filter(team__isnull=False, project__isnull=False)
|
||
|
||
# Aggregate: (team_id, project_id) -> distinct dates
|
||
rows_data = qs.values(
|
||
'team_id', 'team__name', 'project_id', 'project__name'
|
||
).annotate(days=Count('date', distinct=True)).order_by('team__name')
|
||
|
||
# Build column list (unique projects, ordered by name)
|
||
columns_seen = {}
|
||
for r in rows_data:
|
||
columns_seen.setdefault(r['project_id'], r['project__name'])
|
||
columns = [
|
||
{'id': pid, 'name': pname}
|
||
for pid, pname in sorted(columns_seen.items(), key=lambda kv: kv[1])
|
||
]
|
||
|
||
# Build rows: team_id -> cells_by_project_id dict
|
||
rows_by_team = {} # team_id -> {'team_id', 'team_name', 'cells_by_project_id', 'row_total'}
|
||
col_totals = {col['id']: 0 for col in columns}
|
||
grand_total = 0
|
||
|
||
for r in rows_data:
|
||
tid = r['team_id']
|
||
pid = r['project_id']
|
||
days = r['days']
|
||
row = rows_by_team.setdefault(tid, {
|
||
'team_id': tid,
|
||
'team_name': r['team__name'],
|
||
'cells_by_project_id': {},
|
||
'row_total': 0,
|
||
})
|
||
row['cells_by_project_id'][pid] = days
|
||
row['row_total'] += days
|
||
col_totals[pid] += days
|
||
grand_total += days
|
||
|
||
# Ordered rows list (by team name)
|
||
rows = sorted(rows_by_team.values(), key=lambda r: r['team_name'])
|
||
|
||
return {
|
||
'columns': columns,
|
||
'rows': rows,
|
||
'col_totals': col_totals,
|
||
'grand_total': grand_total,
|
||
}
|
||
|
||
|
||
# === HOME DASHBOARD ===
|
||
# The main page users see after logging in. Shows different content
|
||
# depending on whether the user is an admin or supervisor.
|
||
|
||
@login_required
|
||
def index(request):
|
||
user = request.user
|
||
|
||
if is_admin(user):
|
||
# --- ADMIN DASHBOARD ---
|
||
|
||
# === OUTSTANDING BREAKDOWN ===
|
||
# Uses the shared _compute_outstanding helper so the dashboard and the
|
||
# payroll report can't drift. Unscoped (no filters) = whole company.
|
||
_o = _compute_outstanding()
|
||
outstanding_payments = _o['outstanding_payments']
|
||
unpaid_wages = _o['unpaid_wages']
|
||
pending_adjustments_add = _o['pending_adj_add']
|
||
pending_adjustments_sub = _o['pending_adj_sub']
|
||
outstanding_by_project = _o['outstanding_by_project']
|
||
|
||
# Sum total paid out in the last 60 days
|
||
sixty_days_ago = timezone.now().date() - timezone.timedelta(days=60)
|
||
paid_this_month = PayrollRecord.objects.filter(
|
||
date__gte=sixty_days_ago
|
||
).aggregate(total=Sum('amount_paid'))['total'] or Decimal('0.00')
|
||
|
||
# Count and total balance of active loans
|
||
active_loans_qs = Loan.objects.filter(active=True)
|
||
active_loans_count = active_loans_qs.count()
|
||
active_loans_balance = active_loans_qs.aggregate(
|
||
total=Sum('remaining_balance')
|
||
)['total'] or Decimal('0.00')
|
||
|
||
# This week summary
|
||
start_of_week = timezone.now().date() - timezone.timedelta(
|
||
days=timezone.now().date().weekday()
|
||
)
|
||
this_week_logs = WorkLog.objects.filter(date__gte=start_of_week).count()
|
||
|
||
# Recent activity — last 5 work logs
|
||
recent_activity = WorkLog.objects.select_related(
|
||
'project', 'supervisor'
|
||
).prefetch_related('workers').order_by('-date', '-id')[:5]
|
||
|
||
# All workers, projects, and teams for the Manage Resources tab.
|
||
# The template uses a JS filter bar (Active / Inactive / All) to show/hide
|
||
# rows based on data-active attribute — defaults to showing only active items.
|
||
workers = Worker.objects.all().order_by('name')
|
||
projects = Project.objects.all().order_by('name')
|
||
teams = Team.objects.all().order_by('name')
|
||
|
||
# === CERT EXPIRY SUMMARY ===
|
||
# Count certificates that are expired or expire within the next 30 days.
|
||
# Only shown on the dashboard when the count is non-zero (so the stat
|
||
# card disappears when everything is in good standing).
|
||
today = datetime.date.today()
|
||
thirty_days_out = today + datetime.timedelta(days=30)
|
||
certs_expired_count = WorkerCertificate.objects.filter(
|
||
valid_until__lt=today, worker__active=True,
|
||
).count()
|
||
certs_expiring_count = WorkerCertificate.objects.filter(
|
||
valid_until__gte=today, valid_until__lte=thirty_days_out,
|
||
worker__active=True,
|
||
).count()
|
||
certs_alert_total = certs_expired_count + certs_expiring_count
|
||
|
||
context = {
|
||
'is_admin': True,
|
||
'outstanding_payments': outstanding_payments,
|
||
'unpaid_wages': unpaid_wages,
|
||
'pending_adjustments_add': pending_adjustments_add,
|
||
'pending_adjustments_sub': pending_adjustments_sub,
|
||
'paid_this_month': paid_this_month,
|
||
'active_loans_count': active_loans_count,
|
||
'active_loans_balance': active_loans_balance,
|
||
'outstanding_by_project': outstanding_by_project,
|
||
'this_week_logs': this_week_logs,
|
||
'recent_activity': recent_activity,
|
||
'workers': workers,
|
||
'projects': projects,
|
||
'teams': teams,
|
||
# Cert-expiry card (rendered only when > 0)
|
||
'certs_expired_count': certs_expired_count,
|
||
'certs_expiring_count': certs_expiring_count,
|
||
'certs_alert_total': certs_alert_total,
|
||
# Empty on the home dashboard — modal opens clean (no pre-selected filters)
|
||
'selected_project_ids': [],
|
||
'selected_team_ids': [],
|
||
}
|
||
return render(request, 'core/index.html', context)
|
||
|
||
else:
|
||
# --- SUPERVISOR DASHBOARD ---
|
||
|
||
# Count projects this supervisor is assigned to
|
||
my_projects_count = user.assigned_projects.filter(active=True).count()
|
||
|
||
# Count teams this supervisor manages
|
||
my_teams_count = user.supervised_teams.filter(active=True).count()
|
||
|
||
# Count unique workers across all their teams
|
||
my_workers_count = Worker.objects.filter(
|
||
active=True,
|
||
teams__supervisor=user,
|
||
teams__active=True
|
||
).distinct().count()
|
||
|
||
# This week summary — only their own logs
|
||
start_of_week = timezone.now().date() - timezone.timedelta(
|
||
days=timezone.now().date().weekday()
|
||
)
|
||
this_week_logs = WorkLog.objects.filter(
|
||
date__gte=start_of_week, supervisor=user
|
||
).count()
|
||
|
||
# Their last 5 work logs
|
||
recent_activity = WorkLog.objects.filter(
|
||
supervisor=user
|
||
).select_related('project').prefetch_related('workers').order_by('-date', '-id')[:5]
|
||
|
||
context = {
|
||
'is_admin': False,
|
||
'my_projects_count': my_projects_count,
|
||
'my_teams_count': my_teams_count,
|
||
'my_workers_count': my_workers_count,
|
||
'this_week_logs': this_week_logs,
|
||
'recent_activity': recent_activity,
|
||
}
|
||
return render(request, 'core/index.html', context)
|
||
|
||
|
||
# === ATTENDANCE LOGGING ===
|
||
# This is where supervisors log which workers showed up to work each day.
|
||
# Supports logging a single day or a date range (e.g. a whole week).
|
||
# Includes conflict detection to prevent double-logging workers.
|
||
|
||
@login_required
|
||
def attendance_log(request):
|
||
user = request.user
|
||
|
||
if request.method == 'POST':
|
||
form = AttendanceLogForm(request.POST, user=user)
|
||
|
||
if form.is_valid():
|
||
start_date = form.cleaned_data['date']
|
||
end_date = form.cleaned_data.get('end_date') or start_date
|
||
include_saturday = form.cleaned_data.get('include_saturday', False)
|
||
include_sunday = form.cleaned_data.get('include_sunday', False)
|
||
project = form.cleaned_data['project']
|
||
team = form.cleaned_data.get('team')
|
||
workers = form.cleaned_data['workers']
|
||
overtime_amount = form.cleaned_data['overtime_amount']
|
||
notes = form.cleaned_data.get('notes', '')
|
||
|
||
# --- Build list of dates to log ---
|
||
# Go through each day from start to end, skipping weekends
|
||
# unless the user checked the "Include Saturday/Sunday" boxes
|
||
dates_to_log = []
|
||
current_date = start_date
|
||
while current_date <= end_date:
|
||
day_of_week = current_date.weekday() # 0=Mon, 5=Sat, 6=Sun
|
||
if day_of_week == 5 and not include_saturday:
|
||
current_date += datetime.timedelta(days=1)
|
||
continue
|
||
if day_of_week == 6 and not include_sunday:
|
||
current_date += datetime.timedelta(days=1)
|
||
continue
|
||
dates_to_log.append(current_date)
|
||
current_date += datetime.timedelta(days=1)
|
||
|
||
if not dates_to_log:
|
||
messages.warning(request, 'No valid dates in the selected range.')
|
||
# Still need team_workers_json for the JS even on error re-render
|
||
tw_map = {}
|
||
for t in Team.objects.filter(active=True).prefetch_related('workers'):
|
||
tw_map[t.id] = list(t.workers.filter(active=True).values_list('id', flat=True))
|
||
return render(request, 'core/attendance_log.html', {
|
||
'form': form,
|
||
'is_admin': is_admin(user),
|
||
'team_workers_json': json.dumps(tw_map),
|
||
})
|
||
|
||
# --- Conflict detection ---
|
||
# Check if any selected workers already have a WorkLog on any of these dates
|
||
worker_ids = list(workers.values_list('id', flat=True))
|
||
existing_logs = WorkLog.objects.filter(
|
||
date__in=dates_to_log,
|
||
workers__id__in=worker_ids
|
||
).prefetch_related('workers').select_related('project')
|
||
|
||
conflicts = []
|
||
for log in existing_logs:
|
||
for w in log.workers.all():
|
||
if w.id in worker_ids:
|
||
conflicts.append({
|
||
'worker_name': w.name,
|
||
'date': log.date,
|
||
'project_name': log.project.name,
|
||
})
|
||
|
||
# If there are conflicts and the user hasn't chosen what to do yet
|
||
conflict_action = request.POST.get('conflict_action', '')
|
||
if conflicts and not conflict_action:
|
||
# Show the conflict warning — let user choose Skip or Overwrite
|
||
# Still need team_workers_json for the JS even on conflict re-render
|
||
tw_map = {}
|
||
for t in Team.objects.filter(active=True).prefetch_related('workers'):
|
||
tw_map[t.id] = list(t.workers.filter(active=True).values_list('id', flat=True))
|
||
|
||
# Pass the selected worker IDs explicitly for the conflict
|
||
# re-submission forms. We can't use form.data.workers in the
|
||
# template because QueryDict.__getitem__ returns only the last
|
||
# value, losing all other selections for multi-value fields.
|
||
selected_worker_ids = request.POST.getlist('workers')
|
||
|
||
return render(request, 'core/attendance_log.html', {
|
||
'form': form,
|
||
'conflicts': conflicts,
|
||
'is_admin': is_admin(user),
|
||
'team_workers_json': json.dumps(tw_map),
|
||
'selected_worker_ids': selected_worker_ids,
|
||
})
|
||
|
||
# --- Create work logs ---
|
||
created_count = 0
|
||
skipped_count = 0
|
||
|
||
for log_date in dates_to_log:
|
||
# Check which workers already have a log on this date
|
||
workers_with_existing = set(
|
||
WorkLog.objects.filter(
|
||
date=log_date,
|
||
workers__id__in=worker_ids
|
||
).values_list('workers__id', flat=True)
|
||
)
|
||
|
||
if conflict_action == 'overwrite':
|
||
# Remove conflicting workers from their existing logs
|
||
conflicting_logs = WorkLog.objects.filter(
|
||
date=log_date,
|
||
workers__id__in=worker_ids
|
||
)
|
||
for existing_log in conflicting_logs:
|
||
for w_id in worker_ids:
|
||
existing_log.workers.remove(w_id)
|
||
workers_to_add = workers
|
||
elif conflict_action == 'skip':
|
||
# Skip workers who already have logs on this date
|
||
workers_to_add = workers.exclude(id__in=workers_with_existing)
|
||
skipped_count += len(workers_with_existing & set(worker_ids))
|
||
else:
|
||
# No conflicts, or first submission — add all workers
|
||
workers_to_add = workers
|
||
|
||
if workers_to_add.exists():
|
||
# Create the WorkLog record
|
||
work_log = WorkLog.objects.create(
|
||
date=log_date,
|
||
project=project,
|
||
team=team,
|
||
supervisor=user, # Auto-set to logged-in user
|
||
overtime_amount=overtime_amount,
|
||
notes=notes,
|
||
)
|
||
work_log.workers.set(workers_to_add)
|
||
created_count += 1
|
||
|
||
# Show success message
|
||
if created_count > 0:
|
||
msg = f'Successfully created {created_count} work log(s).'
|
||
if skipped_count > 0:
|
||
msg += f' Skipped {skipped_count} conflicts.'
|
||
messages.success(request, msg)
|
||
else:
|
||
messages.warning(request, 'No work logs created — all entries were conflicts.')
|
||
|
||
return redirect('home')
|
||
else:
|
||
# Don't pre-fill the start date — force the user to pick one
|
||
# so they don't accidentally log work on the wrong day
|
||
form = AttendanceLogForm(user=user)
|
||
|
||
# Build a list of worker data for the estimated cost JavaScript
|
||
# (admins only — supervisors don't see the cost card)
|
||
worker_rates = {}
|
||
if is_admin(user):
|
||
for w in Worker.objects.filter(active=True):
|
||
worker_rates[str(w.id)] = str(w.daily_rate)
|
||
|
||
# Build team→workers mapping so the JS can auto-check workers when a
|
||
# team is selected from the dropdown. Key = team ID, Value = list of worker IDs.
|
||
team_workers_map = {}
|
||
teams_qs = Team.objects.filter(active=True).prefetch_related('workers')
|
||
if not is_admin(user):
|
||
# Supervisors only see their own teams
|
||
teams_qs = teams_qs.filter(supervisor=user)
|
||
for team in teams_qs:
|
||
active_worker_ids = list(
|
||
team.workers.filter(active=True).values_list('id', flat=True)
|
||
)
|
||
team_workers_map[team.id] = active_worker_ids
|
||
|
||
return render(request, 'core/attendance_log.html', {
|
||
'form': form,
|
||
'is_admin': is_admin(user),
|
||
'worker_rates_json': worker_rates,
|
||
'team_workers_json': json.dumps(team_workers_map),
|
||
})
|
||
|
||
|
||
# === WORK LOG HISTORY ===
|
||
# Shows work logs in two modes: a table list or a monthly calendar grid.
|
||
# Supervisors only see their own projects. Admins see everything.
|
||
# The calendar view groups logs by day and lets you click a day to see details.
|
||
|
||
@login_required
|
||
def work_history(request):
|
||
user = request.user
|
||
|
||
# Start with base queryset
|
||
if is_admin(user):
|
||
logs = WorkLog.objects.all()
|
||
else:
|
||
# Supervisors only see logs for their projects
|
||
logs = WorkLog.objects.filter(
|
||
Q(supervisor=user) | Q(project__supervisors=user)
|
||
).distinct()
|
||
|
||
# --- Filters ---
|
||
# Read filter values from the URL query string.
|
||
# Validate numeric params to prevent 500 errors from bad/malformed URLs.
|
||
worker_filter = request.GET.get('worker', '')
|
||
project_filter = request.GET.get('project', '')
|
||
status_filter = request.GET.get('status', '')
|
||
|
||
# Validate: worker and project must be numeric IDs (or empty)
|
||
try:
|
||
worker_filter = str(int(worker_filter)) if worker_filter else ''
|
||
except (ValueError, TypeError):
|
||
worker_filter = ''
|
||
try:
|
||
project_filter = str(int(project_filter)) if project_filter else ''
|
||
except (ValueError, TypeError):
|
||
project_filter = ''
|
||
|
||
# Count total logs BEFORE filtering (so we can show "X of Y" to the user)
|
||
total_log_count = logs.count()
|
||
|
||
if worker_filter:
|
||
logs = logs.filter(workers__id=worker_filter).distinct()
|
||
|
||
if project_filter:
|
||
logs = logs.filter(project__id=project_filter)
|
||
|
||
if status_filter == 'paid':
|
||
# "Paid" = has at least one PayrollRecord linked
|
||
logs = logs.filter(payroll_records__isnull=False).distinct()
|
||
elif status_filter == 'unpaid':
|
||
# "Unpaid" = has no PayrollRecord linked
|
||
logs = logs.filter(payroll_records__isnull=True)
|
||
|
||
# Track whether any filter is active (for showing feedback in the template)
|
||
has_active_filters = bool(worker_filter or project_filter or status_filter)
|
||
|
||
# Count filtered results BEFORE adding joins (more efficient SQL)
|
||
filtered_log_count = logs.count() if has_active_filters else 0
|
||
|
||
# If filtering by worker, look up the Worker object so the template can
|
||
# show just that worker's name instead of all workers on the log.
|
||
filtered_worker_obj = None
|
||
if worker_filter:
|
||
filtered_worker_obj = Worker.objects.filter(id=worker_filter).first()
|
||
|
||
# Add related data and order by date (newest first)
|
||
logs = logs.select_related(
|
||
'project', 'supervisor'
|
||
).prefetch_related('workers', 'payroll_records').order_by('-date', '-id')
|
||
|
||
# Get filter options for the dropdowns
|
||
if is_admin(user):
|
||
filter_workers = Worker.objects.filter(active=True).order_by('name')
|
||
filter_projects = Project.objects.filter(active=True).order_by('name')
|
||
else:
|
||
supervised_teams = Team.objects.filter(supervisor=user, active=True)
|
||
filter_workers = Worker.objects.filter(
|
||
active=True, teams__in=supervised_teams
|
||
).distinct().order_by('name')
|
||
filter_projects = Project.objects.filter(
|
||
active=True, supervisors=user
|
||
).order_by('name')
|
||
|
||
# --- View mode: list or calendar ---
|
||
view_mode = request.GET.get('view', 'list')
|
||
today = timezone.now().date()
|
||
|
||
# Build a query string that preserves all current filters
|
||
# (used by the List/Calendar toggle links to keep filters when switching)
|
||
filter_params = ''
|
||
if worker_filter:
|
||
filter_params += '&worker=' + worker_filter
|
||
if project_filter:
|
||
filter_params += '&project=' + project_filter
|
||
if status_filter:
|
||
filter_params += '&status=' + status_filter
|
||
|
||
context = {
|
||
'logs': logs,
|
||
'filter_workers': filter_workers,
|
||
'filter_projects': filter_projects,
|
||
'selected_worker': worker_filter,
|
||
'selected_project': project_filter,
|
||
'selected_status': status_filter,
|
||
'is_admin': is_admin(user),
|
||
'view_mode': view_mode,
|
||
'filter_params': filter_params,
|
||
'has_active_filters': has_active_filters,
|
||
'total_log_count': total_log_count,
|
||
'filtered_log_count': filtered_log_count,
|
||
'filtered_worker_obj': filtered_worker_obj,
|
||
}
|
||
|
||
# === CALENDAR MODE ===
|
||
# Build a monthly grid of days, each containing the work logs for that day.
|
||
# Also build a JSON object keyed by date string for the JavaScript
|
||
# click-to-see-details panel.
|
||
if view_mode == 'calendar':
|
||
# Get target month from URL (default: current month)
|
||
try:
|
||
target_year = int(request.GET.get('year', today.year))
|
||
target_month = int(request.GET.get('month', today.month))
|
||
if not (1 <= target_month <= 12):
|
||
target_year, target_month = today.year, today.month
|
||
except (ValueError, TypeError):
|
||
target_year, target_month = today.year, today.month
|
||
|
||
# Build the calendar grid using Python's calendar module.
|
||
# monthdatescalendar() returns a list of weeks, where each week is
|
||
# a list of 7 datetime.date objects (including overflow from prev/next month).
|
||
cal = cal_module.Calendar(firstweekday=0) # Week starts on Monday
|
||
month_dates = cal.monthdatescalendar(target_year, target_month)
|
||
|
||
# Get the full date range for the calendar grid (includes overflow days)
|
||
first_display_date = month_dates[0][0]
|
||
last_display_date = month_dates[-1][-1]
|
||
|
||
# Filter logs to only this date range (improves performance)
|
||
month_logs = logs.filter(date__range=[first_display_date, last_display_date])
|
||
|
||
# Group logs by date string for quick lookup
|
||
logs_by_date = {}
|
||
for log in month_logs:
|
||
date_key = log.date.isoformat()
|
||
if date_key not in logs_by_date:
|
||
logs_by_date[date_key] = []
|
||
logs_by_date[date_key].append(log)
|
||
|
||
# Build the calendar_weeks structure that the template iterates over.
|
||
# Each day cell has: date, day number, whether it's the current month,
|
||
# a list of log objects, and a count badge number.
|
||
calendar_weeks = []
|
||
for week in month_dates:
|
||
week_data = []
|
||
for day in week:
|
||
date_key = day.isoformat()
|
||
day_logs = logs_by_date.get(date_key, [])
|
||
week_data.append({
|
||
'date': day,
|
||
'day': day.day,
|
||
'is_current_month': day.month == target_month,
|
||
'is_today': day == today,
|
||
'records': day_logs,
|
||
'count': len(day_logs),
|
||
})
|
||
calendar_weeks.append(week_data)
|
||
|
||
# Build detail data for JavaScript — when you click a day cell,
|
||
# the JS reads this JSON to populate the detail panel below the calendar.
|
||
# NOTE: Pass raw Python dict, not json.dumps() — the template's
|
||
# |json_script filter handles serialization.
|
||
#
|
||
# IMPORTANT: When a worker filter is active, log.workers.all() would
|
||
# still return ALL workers on that WorkLog (not just the filtered one).
|
||
# We need to narrow the displayed workers to match the filter.
|
||
calendar_detail = {}
|
||
for date_key, day_logs in logs_by_date.items():
|
||
calendar_detail[date_key] = []
|
||
for log in day_logs:
|
||
# Get the workers to show — if filtering by worker,
|
||
# only show that worker (not everyone else on the log)
|
||
if worker_filter:
|
||
display_workers = [
|
||
w for w in log.workers.all()
|
||
if str(w.id) == worker_filter
|
||
]
|
||
else:
|
||
display_workers = list(log.workers.all())
|
||
|
||
entry = {
|
||
'project': log.project.name,
|
||
'workers': [w.name for w in display_workers],
|
||
'supervisor': (
|
||
log.supervisor.get_full_name() or log.supervisor.username
|
||
) if log.supervisor else '-',
|
||
'notes': log.notes or '',
|
||
'is_paid': log.payroll_records.exists(),
|
||
'overtime': log.get_overtime_amount_display() if log.overtime_amount > 0 else '',
|
||
}
|
||
# Only show cost data to admins — use filtered workers for amount
|
||
if is_admin(user):
|
||
entry['amount'] = float(
|
||
sum(w.daily_rate for w in display_workers)
|
||
)
|
||
calendar_detail[date_key].append(entry)
|
||
|
||
# Calculate previous/next month for navigation arrows
|
||
if target_month == 1:
|
||
prev_year, prev_month = target_year - 1, 12
|
||
else:
|
||
prev_year, prev_month = target_year, target_month - 1
|
||
|
||
if target_month == 12:
|
||
next_year, next_month = target_year + 1, 1
|
||
else:
|
||
next_year, next_month = target_year, target_month + 1
|
||
|
||
month_name = datetime.date(target_year, target_month, 1).strftime('%B %Y')
|
||
|
||
context.update({
|
||
'calendar_weeks': calendar_weeks,
|
||
'calendar_detail': calendar_detail,
|
||
'curr_year': target_year,
|
||
'curr_month': target_month,
|
||
'month_name': month_name,
|
||
'prev_year': prev_year,
|
||
'prev_month': prev_month,
|
||
'next_year': next_year,
|
||
'next_month': next_month,
|
||
})
|
||
|
||
return render(request, 'core/work_history.html', context)
|
||
|
||
|
||
# =============================================================================
|
||
# === WORK LOG PAYROLL CROSS-LINK ===
|
||
# From any historic work log, see which workers got paid, which didn't, and
|
||
# (for paid ones) which payslip it was. Admin-only; supervisors never see
|
||
# payroll data. Two endpoints share one helper so the modal and the full
|
||
# page can never drift apart.
|
||
# =============================================================================
|
||
|
||
def _build_work_log_payroll_context(log):
|
||
"""Return a context dict describing the payroll status of a work log.
|
||
|
||
Plain-English summary for future-you:
|
||
For the given work log, loop over each worker on it and decide which of
|
||
three buckets they fall into:
|
||
- "Paid" -> a PayrollRecord links this worker + this log
|
||
- "Priced, not paid" -> worker is in log.priced_workers but no record yet
|
||
- "Unpaid" -> neither
|
||
Also collects any PayrollAdjustments tied to this log (e.g. overtime).
|
||
Used by the AJAX endpoint AND the full detail page — keep them sharing
|
||
this helper so they can never show different data.
|
||
"""
|
||
# Prefetch payroll records once, rather than re-querying per worker.
|
||
payroll_records = list(
|
||
PayrollRecord.objects.filter(work_logs=log).select_related('worker')
|
||
)
|
||
# Lookup: worker_id -> first PayrollRecord found.
|
||
record_by_worker = {r.worker_id: r for r in payroll_records}
|
||
|
||
# IDs of workers who've been priced on this log but aren't necessarily paid yet.
|
||
priced_worker_ids = set(log.priced_workers.values_list('id', flat=True))
|
||
|
||
worker_rows = []
|
||
total_earned = Decimal('0.00')
|
||
total_paid = Decimal('0.00')
|
||
total_outstanding = Decimal('0.00')
|
||
|
||
# Loop each worker on the log and classify them into one of three buckets.
|
||
for worker in log.workers.all():
|
||
record = record_by_worker.get(worker.id)
|
||
if record:
|
||
status = 'Paid'
|
||
earned = worker.daily_rate
|
||
total_paid += earned
|
||
elif worker.id in priced_worker_ids:
|
||
status = 'Priced, not paid'
|
||
earned = worker.daily_rate
|
||
total_outstanding += earned
|
||
else:
|
||
status = 'Unpaid'
|
||
earned = worker.daily_rate
|
||
total_outstanding += earned
|
||
|
||
total_earned += earned
|
||
|
||
worker_rows.append({
|
||
'worker': worker,
|
||
'status': status,
|
||
'earned': earned,
|
||
'payroll_record': record,
|
||
'paid_date': record.date if record else None,
|
||
})
|
||
|
||
# Adjustments tied directly to this log (mostly overtime pricing).
|
||
# Reverse accessor is adjustments_by_work_log (see PayrollAdjustment.work_log related_name).
|
||
adjustments = list(
|
||
log.adjustments_by_work_log
|
||
.select_related('worker', 'payroll_record')
|
||
.order_by('type', 'id')
|
||
)
|
||
|
||
# Pay-period info (only if the team has a schedule configured).
|
||
# Use the log's own date as the reference so we report the period the
|
||
# log falls into — not whichever period happens to contain "today".
|
||
pay_period = get_pay_period(log.team, reference_date=log.date) if log.team else (None, None)
|
||
|
||
# Overtime "needs pricing" flag: log has OT hours but no priced_workers yet.
|
||
# log.overtime_amount is a Decimal with default=0.00 — always present on saved
|
||
# instances, so no defensive getattr needed. Compare via Decimal arithmetic.
|
||
log_overtime = log.overtime_amount or Decimal('0.00')
|
||
overtime_needs_pricing = log_overtime > 0 and not priced_worker_ids
|
||
|
||
return {
|
||
'log': log,
|
||
'worker_rows': worker_rows,
|
||
'adjustments': adjustments,
|
||
'total_earned': total_earned,
|
||
'total_paid': total_paid,
|
||
'total_outstanding': total_outstanding,
|
||
'pay_period': pay_period,
|
||
'overtime_needs_pricing': overtime_needs_pricing,
|
||
}
|
||
|
||
|
||
@login_required
|
||
def work_log_payroll_ajax(request, log_id):
|
||
"""Return JSON describing the payroll status of a work log.
|
||
|
||
Admin-only. The modal's JS builds its DOM from this JSON using
|
||
textContent/createElement (matches the worker_lookup_ajax pattern).
|
||
"""
|
||
# Only admins can see this data (salaries, adjustments, etc.)
|
||
if not is_admin(request.user):
|
||
return JsonResponse({'error': 'Not authorized'}, status=403)
|
||
|
||
# Fetch the log with related objects pre-loaded to avoid extra queries
|
||
log = get_object_or_404(
|
||
WorkLog.objects.select_related('project', 'team', 'supervisor'),
|
||
id=log_id,
|
||
)
|
||
# Shared helper also used by the full-page view (Task 4) — keeps the
|
||
# JSON payload and the HTML view in perfect sync.
|
||
ctx = _build_work_log_payroll_context(log)
|
||
|
||
# === SERIALIZE FOR JSON ===
|
||
# JSON can't represent Decimals or dates natively, so we convert:
|
||
# - Decimal -> float (JS does math in floats anyway)
|
||
# - date -> ISO 8601 string ("2026-04-10")
|
||
def _date_iso(d):
|
||
return d.strftime('%Y-%m-%d') if d else None
|
||
|
||
# One dict per worker row — small, hand-picked fields the modal needs.
|
||
worker_rows = [{
|
||
'worker_id': row['worker'].id,
|
||
'worker_name': row['worker'].name,
|
||
'worker_active': row['worker'].active,
|
||
'status': row['status'],
|
||
'earned': float(row['earned']),
|
||
'payroll_record_id': row['payroll_record'].pk if row['payroll_record'] else None,
|
||
'paid_date': _date_iso(row['paid_date']),
|
||
} for row in ctx['worker_rows']]
|
||
|
||
# Adjustments linked directly to this work_log (Overtime, etc.).
|
||
adjustments = [{
|
||
'type': adj.type,
|
||
'amount': float(adj.amount),
|
||
'worker_id': adj.worker.id,
|
||
'worker_name': adj.worker.name,
|
||
'payroll_record_id': adj.payroll_record.pk if adj.payroll_record else None,
|
||
} for adj in ctx['adjustments']]
|
||
|
||
return JsonResponse({
|
||
'log_id': log.id,
|
||
'date': _date_iso(log.date),
|
||
'project': {'id': log.project.id, 'name': log.project.name} if log.project else None,
|
||
'team': {'id': log.team.id, 'name': log.team.name} if log.team else None,
|
||
# get_full_name() returns "" if no first/last, so fall back to username.
|
||
'supervisor': (log.supervisor.get_full_name() or log.supervisor.username) if log.supervisor else None,
|
||
'worker_rows': worker_rows,
|
||
'adjustments': adjustments,
|
||
'total_earned': float(ctx['total_earned']),
|
||
'total_paid': float(ctx['total_paid']),
|
||
'total_outstanding': float(ctx['total_outstanding']),
|
||
'pay_period_start': _date_iso(ctx['pay_period'][0]),
|
||
'pay_period_end': _date_iso(ctx['pay_period'][1]),
|
||
'overtime_needs_pricing': ctx['overtime_needs_pricing'],
|
||
# Link to the full-page view (Task 4) for the "Open full page" button.
|
||
'full_page_url': reverse('work_log_payroll_detail', args=[log.id]),
|
||
})
|
||
|
||
|
||
@login_required
|
||
def work_log_payroll_detail(request, log_id):
|
||
"""Full-page payroll-status view for a single work log. Admin-only.
|
||
|
||
Shares the exact same context builder as the AJAX endpoint, so the
|
||
full page and the modal can never drift out of sync.
|
||
"""
|
||
# Admin-only: this page shows salary-level data.
|
||
if not is_admin(request.user):
|
||
return HttpResponseForbidden("Admin access required.")
|
||
|
||
# Fetch the log with related objects pre-loaded to avoid extra queries.
|
||
log = get_object_or_404(
|
||
WorkLog.objects.select_related('project', 'team', 'supervisor'),
|
||
id=log_id,
|
||
)
|
||
context = _build_work_log_payroll_context(log)
|
||
return render(request, 'core/work_log_payroll.html', context)
|
||
|
||
|
||
# === CSV EXPORT ===
|
||
# Downloads the filtered work log history as a CSV file.
|
||
# Uses the same filters as the work_history page.
|
||
|
||
@login_required
|
||
def export_work_log_csv(request):
|
||
user = request.user
|
||
|
||
# Build the same queryset as work_history, using the same filters
|
||
if is_admin(user):
|
||
logs = WorkLog.objects.all()
|
||
else:
|
||
logs = WorkLog.objects.filter(
|
||
Q(supervisor=user) | Q(project__supervisors=user)
|
||
).distinct()
|
||
|
||
worker_filter = request.GET.get('worker', '')
|
||
project_filter = request.GET.get('project', '')
|
||
status_filter = request.GET.get('status', '')
|
||
|
||
if worker_filter:
|
||
logs = logs.filter(workers__id=worker_filter).distinct()
|
||
if project_filter:
|
||
logs = logs.filter(project__id=project_filter)
|
||
if status_filter == 'paid':
|
||
logs = logs.filter(payroll_records__isnull=False).distinct()
|
||
elif status_filter == 'unpaid':
|
||
logs = logs.filter(payroll_records__isnull=True)
|
||
|
||
logs = logs.select_related(
|
||
'project', 'supervisor'
|
||
).prefetch_related('workers', 'payroll_records').order_by('-date', '-id')
|
||
|
||
# Create the CSV response
|
||
response = HttpResponse(content_type='text/csv')
|
||
response['Content-Disposition'] = 'attachment; filename="work_log_history.csv"'
|
||
|
||
writer = csv.writer(response)
|
||
writer.writerow(['Date', 'Project', 'Workers', 'Overtime', 'Payment Status', 'Supervisor'])
|
||
|
||
for log in logs:
|
||
worker_names = ', '.join(w.name for w in log.workers.all())
|
||
payment_status = 'Paid' if log.payroll_records.exists() else 'Unpaid'
|
||
overtime_display = log.get_overtime_amount_display() if log.overtime_amount > 0 else 'None'
|
||
supervisor_name = log.supervisor.get_full_name() or log.supervisor.username if log.supervisor else '-'
|
||
|
||
writer.writerow([
|
||
log.date.strftime('%Y-%m-%d'),
|
||
log.project.name,
|
||
worker_names,
|
||
overtime_display,
|
||
payment_status,
|
||
supervisor_name,
|
||
])
|
||
|
||
return response
|
||
|
||
|
||
# === EXPORT WORKERS CSV — FULL WORKER EXPORT ===
|
||
# Downloads every field we have on every worker as a CSV file.
|
||
# Admin-only (supervisors don't have access to salary / ID / banking data).
|
||
#
|
||
# Columns are organised into logical groups so the file reads naturally
|
||
# left-to-right in a spreadsheet:
|
||
# 1. Identity & Pay 2. Banking & Tax
|
||
# 3. Employment & Notes 4. PPE Sizing
|
||
# 5. Driver's License 6. Certifications (one column per type → valid_until date)
|
||
# 7. Warnings summary 8. Activity aggregates (days worked, payslips, total paid)
|
||
#
|
||
# For certs we show one column per cert type with the valid-until date
|
||
# as the value (or "Yes (no expiry)" if the worker has the cert with
|
||
# no expiry set, or empty if they don't hold it). This lets you sort
|
||
# and filter in Excel: "who has a Medical expiring before June?"
|
||
|
||
@login_required
|
||
def export_workers_csv(request):
|
||
"""Export ALL worker data to CSV (profile, banking, PPE, certs, warnings, history)."""
|
||
if not is_admin(request.user):
|
||
return HttpResponseForbidden("Admin access required.")
|
||
|
||
# Pull everything we'll need with prefetching so we don't N+1
|
||
workers = (
|
||
Worker.objects.all()
|
||
.prefetch_related('certificates', 'warnings', 'payroll_records', 'work_logs__project', 'teams')
|
||
.annotate(
|
||
_days_worked=Count('work_logs__date', distinct=True),
|
||
_first_payslip=Min('payroll_records__date'),
|
||
_last_payslip=Max('payroll_records__date'),
|
||
_total_paid=Sum('payroll_records__amount_paid'),
|
||
_payslip_count=Count('payroll_records', distinct=True),
|
||
)
|
||
.order_by('name')
|
||
)
|
||
|
||
# Cert types in the order we want them to appear in the CSV
|
||
cert_types = [
|
||
('skills', 'Skills Cert'),
|
||
('pdp', 'PDP'),
|
||
('first_aid', 'First Aid'),
|
||
('medical', 'Medical'),
|
||
('work_at_height', 'Work at Height'),
|
||
]
|
||
|
||
response = HttpResponse(content_type='text/csv; charset=utf-8')
|
||
response['Content-Disposition'] = 'attachment; filename="workers_full_export.csv"'
|
||
|
||
writer = csv.writer(response)
|
||
writer.writerow([
|
||
# Identity & pay
|
||
'Name', 'ID Number', 'Phone Number', 'Monthly Salary', 'Daily Rate',
|
||
# Banking & tax
|
||
'Tax No', 'UIF', 'Bank', 'Acc No.',
|
||
# Employment & notes
|
||
'Employment Date', 'Active', 'Notes',
|
||
# PPE sizing
|
||
'Shoe Size', 'Overall Top Size', 'Pants Size', 'T-Shirt Size',
|
||
# Driver's License
|
||
'Has Drivers License', 'License Code',
|
||
# Certifications — one column per type, value = valid_until date
|
||
*(f'{label} Valid Until' for _code, label in cert_types),
|
||
# Warnings
|
||
'Total Warnings', 'Last Warning Date', 'Last Warning Severity',
|
||
# Activity aggregates (lifetime)
|
||
'Days Worked', 'Projects Worked On', 'Teams',
|
||
'First Payslip', 'Last Payslip', 'Payslip Count', 'Total Paid Lifetime',
|
||
])
|
||
|
||
for w in workers:
|
||
# --- Build a cert-type → valid_until lookup for this worker ---
|
||
cert_by_type = {c.cert_type: c for c in w.certificates.all()}
|
||
cert_cells = []
|
||
for code, _label in cert_types:
|
||
c = cert_by_type.get(code)
|
||
if not c:
|
||
cert_cells.append('') # doesn't hold it
|
||
elif c.valid_until is None:
|
||
cert_cells.append('Yes (no expiry)') # has it, no expiry
|
||
else:
|
||
cert_cells.append(c.valid_until.strftime('%Y-%m-%d'))
|
||
|
||
# --- Warnings summary ---
|
||
warnings = list(w.warnings.all()) # already ordered -date
|
||
last_warning = warnings[0] if warnings else None
|
||
|
||
# --- Projects & teams worked (distinct names) ---
|
||
project_names = sorted({
|
||
log.project.name for log in w.work_logs.all() if log.project
|
||
})
|
||
team_names = sorted({t.name for t in w.teams.all()})
|
||
|
||
writer.writerow([
|
||
# Identity & pay
|
||
w.name,
|
||
w.id_number,
|
||
w.phone_number,
|
||
f'{w.monthly_salary:.2f}',
|
||
f'{w.daily_rate:.2f}',
|
||
# Banking & tax
|
||
w.tax_number,
|
||
w.uif_number,
|
||
w.bank_name,
|
||
w.bank_account_number,
|
||
# Employment & notes
|
||
w.employment_date.strftime('%Y-%m-%d') if w.employment_date else '',
|
||
'Yes' if w.active else 'No',
|
||
w.notes,
|
||
# PPE sizing
|
||
w.shoe_size,
|
||
w.overall_top_size,
|
||
w.pants_size,
|
||
w.tshirt_size,
|
||
# Driver's License
|
||
'Yes' if w.has_drivers_license else 'No',
|
||
w.drivers_license_code,
|
||
# Certifications (one per cert type)
|
||
*cert_cells,
|
||
# Warnings
|
||
len(warnings),
|
||
last_warning.date.strftime('%Y-%m-%d') if last_warning else '',
|
||
last_warning.get_severity_display() if last_warning else '',
|
||
# Activity aggregates
|
||
w._days_worked or 0,
|
||
'; '.join(project_names),
|
||
'; '.join(team_names),
|
||
w._first_payslip.strftime('%Y-%m-%d') if w._first_payslip else '',
|
||
w._last_payslip.strftime('%Y-%m-%d') if w._last_payslip else '',
|
||
w._payslip_count or 0,
|
||
f'{(w._total_paid or 0):.2f}',
|
||
])
|
||
|
||
return response
|
||
|
||
|
||
# =============================================================
|
||
# === WORKER MANAGEMENT (friendly UI — alternative to /admin/) ===
|
||
# =============================================================
|
||
|
||
@login_required
|
||
def worker_list(request):
|
||
"""Admin-friendly list of all workers with search + status filter.
|
||
|
||
Query params:
|
||
?q=search_term — search name / ID number / phone
|
||
?status=active — default, only active workers
|
||
?status=inactive — only inactive
|
||
?status=all — both
|
||
"""
|
||
if not is_admin(request.user):
|
||
return HttpResponseForbidden("Admin access required.")
|
||
|
||
q = (request.GET.get('q') or '').strip()
|
||
status = request.GET.get('status') or 'active'
|
||
|
||
workers = Worker.objects.all()
|
||
if status == 'active':
|
||
workers = workers.filter(active=True)
|
||
elif status == 'inactive':
|
||
workers = workers.filter(active=False)
|
||
# 'all' → no filter
|
||
|
||
if q:
|
||
workers = workers.filter(
|
||
Q(name__icontains=q) | Q(id_number__icontains=q) | Q(phone_number__icontains=q)
|
||
)
|
||
|
||
# Annotate days worked (distinct WorkLog dates) — shown in the table
|
||
workers = workers.annotate(
|
||
days_worked=Count('work_logs__date', distinct=True),
|
||
).order_by('name')
|
||
|
||
context = {
|
||
'workers': workers,
|
||
'q': q,
|
||
'status': status,
|
||
'total_count': workers.count(),
|
||
}
|
||
return render(request, 'core/workers/list.html', context)
|
||
|
||
|
||
@login_required
|
||
def worker_detail(request, worker_id):
|
||
"""Read-only worker profile with certs, warnings, and history tabs."""
|
||
if not is_admin(request.user):
|
||
return HttpResponseForbidden("Admin access required.")
|
||
|
||
worker = get_object_or_404(Worker, id=worker_id)
|
||
|
||
# --- History aggregates ---
|
||
projects_worked = (
|
||
Project.objects.filter(work_logs__workers=worker).distinct().order_by('name')
|
||
)
|
||
days_worked = worker.work_logs.values('date').distinct().count()
|
||
payslips = worker.payroll_records.order_by('-date')[:10]
|
||
first_payslip = worker.payroll_records.order_by('date').first()
|
||
last_payslip = worker.payroll_records.order_by('-date').first()
|
||
total_paid = worker.payroll_records.aggregate(t=Sum('amount_paid'))['t'] or Decimal('0.00')
|
||
|
||
# --- Certs grouped by status for the visual badges ---
|
||
certs = worker.certificates.all().order_by('cert_type')
|
||
|
||
# --- Warnings (already ordered -date in Meta) ---
|
||
warnings = worker.warnings.all()
|
||
|
||
# --- Active loans / advances ---
|
||
active_loans = worker.loans.filter(active=True).order_by('-date')
|
||
|
||
context = {
|
||
'worker': worker,
|
||
'projects_worked': projects_worked,
|
||
'days_worked': days_worked,
|
||
'payslips': payslips,
|
||
'first_payslip': first_payslip,
|
||
'last_payslip': last_payslip,
|
||
'total_paid': total_paid,
|
||
'certs': certs,
|
||
'warnings': warnings,
|
||
'active_loans': active_loans,
|
||
}
|
||
return render(request, 'core/workers/detail.html', context)
|
||
|
||
|
||
@login_required
|
||
def worker_edit(request, worker_id=None):
|
||
"""Create or edit a Worker plus their certs and warnings in one page.
|
||
|
||
- GET /workers/new/ → blank form
|
||
- GET /workers/<id>/edit/ → form pre-filled
|
||
- POST to either URL → validate + save + redirect to worker_detail
|
||
"""
|
||
if not is_admin(request.user):
|
||
return HttpResponseForbidden("Admin access required.")
|
||
|
||
worker = get_object_or_404(Worker, id=worker_id) if worker_id else None
|
||
is_new = worker is None
|
||
|
||
if request.method == 'POST':
|
||
form = WorkerForm(request.POST, request.FILES, instance=worker)
|
||
# Inline formsets need the parent instance bound early so they
|
||
# can scope queryset + handle created rows correctly.
|
||
if form.is_valid():
|
||
saved_worker = form.save()
|
||
cert_fs = WorkerCertificateFormSet(
|
||
request.POST, request.FILES, instance=saved_worker,
|
||
)
|
||
warn_fs = WorkerWarningFormSet(
|
||
request.POST, request.FILES, instance=saved_worker,
|
||
)
|
||
if cert_fs.is_valid() and warn_fs.is_valid():
|
||
cert_fs.save()
|
||
# Warnings save — set issued_by to current admin on new rows
|
||
warnings = warn_fs.save(commit=False)
|
||
for obj in warnings:
|
||
if obj.issued_by_id is None:
|
||
obj.issued_by = request.user
|
||
obj.save()
|
||
for obj in warn_fs.deleted_objects:
|
||
obj.delete()
|
||
action = 'added' if is_new else 'updated'
|
||
messages.success(request, f'Worker "{saved_worker.name}" {action} successfully.')
|
||
return redirect('worker_detail', worker_id=saved_worker.id)
|
||
# Re-bind forms with errors for re-render
|
||
form = WorkerForm(request.POST, request.FILES, instance=saved_worker)
|
||
else:
|
||
cert_fs = WorkerCertificateFormSet(request.POST, request.FILES, instance=worker)
|
||
warn_fs = WorkerWarningFormSet(request.POST, request.FILES, instance=worker)
|
||
else:
|
||
form = WorkerForm(instance=worker)
|
||
cert_fs = WorkerCertificateFormSet(instance=worker)
|
||
warn_fs = WorkerWarningFormSet(instance=worker)
|
||
|
||
context = {
|
||
'form': form,
|
||
'cert_formset': cert_fs,
|
||
'warn_formset': warn_fs,
|
||
'worker': worker,
|
||
'is_new': is_new,
|
||
}
|
||
return render(request, 'core/workers/edit.html', context)
|
||
|
||
|
||
# =============================================================
|
||
# === WORKER BATCH REPORT ===
|
||
# =============================================================
|
||
|
||
def _build_worker_report_context(status=None, project_id=None, team_id=None):
|
||
"""Build the per-worker aggregation list used by HTML / CSV / PDF views.
|
||
|
||
Returns a list of dicts — one per worker — with projects, teams,
|
||
days worked, first/last payslip dates, total paid, cert counts,
|
||
and warning counts. All aggregates are computed in a single query
|
||
via annotate/prefetch to avoid N+1 database hits.
|
||
"""
|
||
workers = Worker.objects.all()
|
||
if status == 'active':
|
||
workers = workers.filter(active=True)
|
||
elif status == 'inactive':
|
||
workers = workers.filter(active=False)
|
||
|
||
if project_id:
|
||
workers = workers.filter(work_logs__project_id=project_id).distinct()
|
||
if team_id:
|
||
workers = workers.filter(teams__id=team_id).distinct()
|
||
|
||
workers = workers.annotate(
|
||
_days_worked=Count('work_logs__date', distinct=True),
|
||
_first_payslip_date=Min('payroll_records__date'),
|
||
_last_payslip_date=Max('payroll_records__date'),
|
||
_total_paid_lifetime=Sum('payroll_records__amount_paid'),
|
||
_payslip_count=Count('payroll_records', distinct=True),
|
||
_active_warnings=Count('warnings', distinct=True),
|
||
).order_by('name')
|
||
|
||
today = datetime.date.today()
|
||
thirty_days_out = today + datetime.timedelta(days=30)
|
||
|
||
rows = []
|
||
for w in workers:
|
||
projects = list(
|
||
Project.objects.filter(work_logs__workers=w).distinct().values_list('name', flat=True)
|
||
)
|
||
teams = list(w.teams.values_list('name', flat=True))
|
||
|
||
certs = w.certificates.all()
|
||
certs_total = certs.count()
|
||
certs_active = 0
|
||
certs_expiring = 0
|
||
certs_expired = 0
|
||
for c in certs:
|
||
if c.valid_until is None:
|
||
certs_active += 1 # non-expiring counts as active
|
||
elif c.valid_until < today:
|
||
certs_expired += 1
|
||
elif c.valid_until <= thirty_days_out:
|
||
certs_expiring += 1
|
||
certs_active += 1
|
||
else:
|
||
certs_active += 1
|
||
|
||
rows.append({
|
||
'worker': w,
|
||
'projects': projects,
|
||
'teams': teams,
|
||
'days_worked': w._days_worked or 0,
|
||
'first_payslip_date': w._first_payslip_date,
|
||
'last_payslip_date': w._last_payslip_date,
|
||
'total_paid_lifetime': w._total_paid_lifetime or Decimal('0.00'),
|
||
'payslip_count': w._payslip_count or 0,
|
||
'certs_total': certs_total,
|
||
'certs_active': certs_active,
|
||
'certs_expiring': certs_expiring,
|
||
'certs_expired': certs_expired,
|
||
'warnings_count': w._active_warnings or 0,
|
||
})
|
||
return rows
|
||
|
||
|
||
@login_required
|
||
def worker_batch_report(request):
|
||
"""HTML table of every worker with aggregated project/team/day/payslip history."""
|
||
if not is_admin(request.user):
|
||
return HttpResponseForbidden("Admin access required.")
|
||
|
||
status = request.GET.get('status') or 'all'
|
||
project_id = request.GET.get('project') or None
|
||
team_id = request.GET.get('team') or None
|
||
|
||
rows = _build_worker_report_context(status=status, project_id=project_id, team_id=team_id)
|
||
|
||
context = {
|
||
'rows': rows,
|
||
'status': status,
|
||
'project_id': project_id,
|
||
'team_id': team_id,
|
||
'projects': Project.objects.all().order_by('name'),
|
||
'teams': Team.objects.all().order_by('name'),
|
||
'query_string': request.GET.urlencode(),
|
||
'total_workers': len(rows),
|
||
}
|
||
return render(request, 'core/workers/batch_report.html', context)
|
||
|
||
|
||
@login_required
|
||
def worker_batch_report_csv(request):
|
||
"""CSV download of the batch worker report."""
|
||
if not is_admin(request.user):
|
||
return HttpResponseForbidden("Admin access required.")
|
||
|
||
status = request.GET.get('status') or 'all'
|
||
project_id = request.GET.get('project') or None
|
||
team_id = request.GET.get('team') or None
|
||
|
||
rows = _build_worker_report_context(status=status, project_id=project_id, team_id=team_id)
|
||
|
||
response = HttpResponse(content_type='text/csv')
|
||
response['Content-Disposition'] = 'attachment; filename="worker_batch_report.csv"'
|
||
writer = csv.writer(response)
|
||
writer.writerow([
|
||
'Name', 'ID Number', 'Monthly Salary', 'Active', 'Days Worked',
|
||
'Projects', 'Teams', 'First Payslip', 'Last Payslip', 'Payslip Count',
|
||
'Total Paid Lifetime', 'Certs (Active/Total)', 'Warnings',
|
||
])
|
||
for r in rows:
|
||
w = r['worker']
|
||
writer.writerow([
|
||
w.name, w.id_number, f'{w.monthly_salary:.2f}',
|
||
'Yes' if w.active else 'No', r['days_worked'],
|
||
'; '.join(r['projects']),
|
||
'; '.join(r['teams']),
|
||
r['first_payslip_date'].strftime('%Y-%m-%d') if r['first_payslip_date'] else '',
|
||
r['last_payslip_date'].strftime('%Y-%m-%d') if r['last_payslip_date'] else '',
|
||
r['payslip_count'],
|
||
f'{r["total_paid_lifetime"]:.2f}',
|
||
f'{r["certs_active"]}/{r["certs_total"]}',
|
||
r['warnings_count'],
|
||
])
|
||
return response
|
||
|
||
|
||
@login_required
|
||
def worker_batch_report_pdf(request):
|
||
"""PDF version of the batch worker report."""
|
||
if not is_admin(request.user):
|
||
return HttpResponseForbidden("Admin access required.")
|
||
|
||
from .utils import render_to_pdf
|
||
|
||
status = request.GET.get('status') or 'all'
|
||
project_id = request.GET.get('project') or None
|
||
team_id = request.GET.get('team') or None
|
||
|
||
rows = _build_worker_report_context(status=status, project_id=project_id, team_id=team_id)
|
||
|
||
context = {
|
||
'rows': rows,
|
||
'status': status,
|
||
'project_name': (
|
||
Project.objects.get(id=project_id).name if project_id else 'All Projects'
|
||
),
|
||
'team_name': Team.objects.get(id=team_id).name if team_id else 'All Teams',
|
||
'now': timezone.now(),
|
||
'total_workers': len(rows),
|
||
}
|
||
pdf = render_to_pdf('core/pdf/workers_report_pdf.html', context)
|
||
if pdf:
|
||
response = HttpResponse(pdf, content_type='application/pdf')
|
||
response['Content-Disposition'] = 'attachment; filename="worker_batch_report.pdf"'
|
||
return response
|
||
messages.error(request, "PDF generation failed.")
|
||
return redirect('worker_batch_report')
|
||
|
||
|
||
# =============================================================
|
||
# === TEAM MANAGEMENT (friendly UI — alternative to /admin/) ===
|
||
# =============================================================
|
||
|
||
@login_required
|
||
def team_list(request):
|
||
"""Admin-friendly list of all teams with search + status filter."""
|
||
if not is_admin(request.user):
|
||
return HttpResponseForbidden("Admin access required.")
|
||
|
||
q = (request.GET.get('q') or '').strip()
|
||
status = request.GET.get('status') or 'active'
|
||
|
||
teams = Team.objects.all().select_related('supervisor')
|
||
if status == 'active':
|
||
teams = teams.filter(active=True)
|
||
elif status == 'inactive':
|
||
teams = teams.filter(active=False)
|
||
|
||
if q:
|
||
teams = teams.filter(name__icontains=q)
|
||
|
||
# Annotate counts for the list table (Django templates can't access
|
||
# attributes starting with underscore, so use a plain name).
|
||
teams = teams.annotate(
|
||
workers_count=Count('workers', distinct=True),
|
||
).order_by('name')
|
||
|
||
context = {
|
||
'teams': teams,
|
||
'q': q,
|
||
'status': status,
|
||
'total_count': teams.count(),
|
||
}
|
||
return render(request, 'core/teams/list.html', context)
|
||
|
||
|
||
@login_required
|
||
def team_detail(request, team_id):
|
||
"""Read-only team profile with pay schedule, workers, and history tabs."""
|
||
if not is_admin(request.user):
|
||
return HttpResponseForbidden("Admin access required.")
|
||
|
||
team = get_object_or_404(Team.objects.select_related('supervisor'), id=team_id)
|
||
|
||
# --- Workers (all, including inactive — flagged via template) ---
|
||
workers = team.workers.all().order_by('-active', 'name')
|
||
|
||
# --- Work history aggregates ---
|
||
work_logs = team.work_logs.select_related('project').prefetch_related('workers').order_by('-date')
|
||
days_worked = work_logs.values('date').distinct().count()
|
||
projects_worked = (
|
||
Project.objects.filter(work_logs__team=team).distinct().order_by('name')
|
||
)
|
||
recent_logs = work_logs[:10]
|
||
|
||
# --- Labour cost for this team (lifetime) using the existing helper ---
|
||
cost_breakdown = _get_labour_costs(work_logs, 'project__name', 'project')
|
||
total_labour_cost = sum((r['total'] for r in cost_breakdown), Decimal('0.00'))
|
||
|
||
# --- Pay schedule preview: current + next 2 periods (3 total) ---
|
||
pay_periods = []
|
||
if team.pay_frequency and team.pay_start_date:
|
||
today = datetime.date.today()
|
||
current = get_pay_period(team, today)
|
||
if current:
|
||
pay_periods.append(current)
|
||
next_ref = current[1] + datetime.timedelta(days=1)
|
||
for _ in range(2):
|
||
p = get_pay_period(team, next_ref)
|
||
if not p:
|
||
break
|
||
pay_periods.append(p)
|
||
next_ref = p[1] + datetime.timedelta(days=1)
|
||
|
||
context = {
|
||
'team': team,
|
||
'workers': workers,
|
||
'days_worked': days_worked,
|
||
'projects_worked': projects_worked,
|
||
'recent_logs': recent_logs,
|
||
'cost_breakdown': cost_breakdown,
|
||
'total_labour_cost': total_labour_cost,
|
||
'pay_periods': pay_periods,
|
||
}
|
||
return render(request, 'core/teams/detail.html', context)
|
||
|
||
|
||
@login_required
|
||
def team_edit(request, team_id=None):
|
||
"""Create or edit a Team."""
|
||
if not is_admin(request.user):
|
||
return HttpResponseForbidden("Admin access required.")
|
||
|
||
team = get_object_or_404(Team, id=team_id) if team_id else None
|
||
is_new = team is None
|
||
|
||
if request.method == 'POST':
|
||
form = TeamForm(request.POST, instance=team)
|
||
if form.is_valid():
|
||
saved = form.save()
|
||
action = 'added' if is_new else 'updated'
|
||
messages.success(request, f'Team "{saved.name}" {action} successfully.')
|
||
return redirect('team_detail', team_id=saved.id)
|
||
else:
|
||
form = TeamForm(instance=team)
|
||
|
||
context = {
|
||
'form': form,
|
||
'team': team,
|
||
'is_new': is_new,
|
||
}
|
||
return render(request, 'core/teams/edit.html', context)
|
||
|
||
|
||
def _build_team_report_context(status=None):
|
||
"""Build the per-team aggregation list used by HTML + CSV views."""
|
||
teams = Team.objects.all().select_related('supervisor')
|
||
if status == 'active':
|
||
teams = teams.filter(active=True)
|
||
elif status == 'inactive':
|
||
teams = teams.filter(active=False)
|
||
|
||
teams = teams.annotate(
|
||
_worker_count=Count('workers', distinct=True),
|
||
_days_worked=Count('work_logs__date', distinct=True),
|
||
).order_by('name')
|
||
|
||
rows = []
|
||
for t in teams:
|
||
projects = list(
|
||
Project.objects.filter(work_logs__team=t).distinct().values_list('name', flat=True)
|
||
)
|
||
cost_breakdown = _get_labour_costs(t.work_logs.all(), 'project__name', 'project')
|
||
total_cost = sum((r['total'] for r in cost_breakdown), Decimal('0.00'))
|
||
rows.append({
|
||
'team': t,
|
||
'worker_count': t._worker_count or 0,
|
||
'days_worked': t._days_worked or 0,
|
||
'projects': projects,
|
||
'total_labour_cost': total_cost,
|
||
})
|
||
return rows
|
||
|
||
|
||
@login_required
|
||
def team_batch_report(request):
|
||
"""HTML table of every team with aggregated stats."""
|
||
if not is_admin(request.user):
|
||
return HttpResponseForbidden("Admin access required.")
|
||
|
||
status = request.GET.get('status') or 'all'
|
||
rows = _build_team_report_context(status=status)
|
||
|
||
context = {
|
||
'rows': rows,
|
||
'status': status,
|
||
'query_string': request.GET.urlencode(),
|
||
'total_teams': len(rows),
|
||
}
|
||
return render(request, 'core/teams/batch_report.html', context)
|
||
|
||
|
||
@login_required
|
||
def team_batch_report_csv(request):
|
||
"""CSV download of the batch team report."""
|
||
if not is_admin(request.user):
|
||
return HttpResponseForbidden("Admin access required.")
|
||
|
||
status = request.GET.get('status') or 'all'
|
||
rows = _build_team_report_context(status=status)
|
||
|
||
response = HttpResponse(content_type='text/csv')
|
||
response['Content-Disposition'] = 'attachment; filename="team_batch_report.csv"'
|
||
writer = csv.writer(response)
|
||
writer.writerow([
|
||
'Team Name', 'Supervisor', 'Active', 'Pay Frequency', 'Pay Start Date',
|
||
'Worker Count', 'Days Worked', 'Projects Worked On', 'Total Labour Cost',
|
||
])
|
||
for r in rows:
|
||
t = r['team']
|
||
writer.writerow([
|
||
t.name,
|
||
t.supervisor.username if t.supervisor else '',
|
||
'Yes' if t.active else 'No',
|
||
t.get_pay_frequency_display() if t.pay_frequency else '',
|
||
t.pay_start_date.strftime('%Y-%m-%d') if t.pay_start_date else '',
|
||
r['worker_count'],
|
||
r['days_worked'],
|
||
'; '.join(r['projects']),
|
||
f'{r["total_labour_cost"]:.2f}',
|
||
])
|
||
return response
|
||
|
||
|
||
# =============================================================
|
||
# === PROJECT MANAGEMENT (friendly UI — alternative to /admin/) ===
|
||
# =============================================================
|
||
|
||
@login_required
|
||
def project_list(request):
|
||
"""Admin-friendly list of all projects with search + status filter."""
|
||
if not is_admin(request.user):
|
||
return HttpResponseForbidden("Admin access required.")
|
||
|
||
q = (request.GET.get('q') or '').strip()
|
||
status = request.GET.get('status') or 'active'
|
||
|
||
projects = Project.objects.all().prefetch_related('supervisors')
|
||
if status == 'active':
|
||
projects = projects.filter(active=True)
|
||
elif status == 'inactive':
|
||
projects = projects.filter(active=False)
|
||
|
||
if q:
|
||
projects = projects.filter(
|
||
Q(name__icontains=q) | Q(description__icontains=q)
|
||
)
|
||
|
||
projects = projects.annotate(
|
||
workers_count=Count('work_logs__workers', distinct=True),
|
||
).order_by('name')
|
||
|
||
context = {
|
||
'projects': projects,
|
||
'q': q,
|
||
'status': status,
|
||
'total_count': projects.count(),
|
||
}
|
||
return render(request, 'core/projects/list.html', context)
|
||
|
||
|
||
@login_required
|
||
def project_detail(request, project_id):
|
||
"""Read-only project profile with supervisors, teams, workers, history tabs."""
|
||
if not is_admin(request.user):
|
||
return HttpResponseForbidden("Admin access required.")
|
||
|
||
project = get_object_or_404(
|
||
Project.objects.prefetch_related('supervisors'),
|
||
id=project_id,
|
||
)
|
||
|
||
# --- Teams that have worked on this project ---
|
||
teams_worked = (
|
||
Team.objects.filter(work_logs__project=project).distinct().order_by('name')
|
||
)
|
||
|
||
# --- Workers who have worked on this project ---
|
||
workers_worked = (
|
||
Worker.objects.filter(work_logs__project=project).distinct().order_by('name')
|
||
)
|
||
|
||
# --- Work logs for history tab ---
|
||
work_logs = project.work_logs.prefetch_related('workers', 'team').order_by('-date')
|
||
days_worked = work_logs.values('date').distinct().count()
|
||
recent_logs = work_logs[:10]
|
||
|
||
# --- Activity date range ---
|
||
date_range = work_logs.aggregate(first=Min('date'), last=Max('date'))
|
||
|
||
# --- Labour cost for this project (lifetime) ---
|
||
cost_breakdown = _get_labour_costs(work_logs, 'team__name', 'team')
|
||
total_labour_cost = sum((r['total'] for r in cost_breakdown), Decimal('0.00'))
|
||
|
||
context = {
|
||
'project': project,
|
||
'teams_worked': teams_worked,
|
||
'workers_worked': workers_worked,
|
||
'days_worked': days_worked,
|
||
'recent_logs': recent_logs,
|
||
'first_activity': date_range.get('first'),
|
||
'last_activity': date_range.get('last'),
|
||
'cost_breakdown': cost_breakdown,
|
||
'total_labour_cost': total_labour_cost,
|
||
}
|
||
return render(request, 'core/projects/detail.html', context)
|
||
|
||
|
||
@login_required
|
||
def project_edit(request, project_id=None):
|
||
"""Create or edit a Project."""
|
||
if not is_admin(request.user):
|
||
return HttpResponseForbidden("Admin access required.")
|
||
|
||
project = get_object_or_404(Project, id=project_id) if project_id else None
|
||
is_new = project is None
|
||
|
||
if request.method == 'POST':
|
||
form = ProjectForm(request.POST, instance=project)
|
||
if form.is_valid():
|
||
saved = form.save()
|
||
action = 'added' if is_new else 'updated'
|
||
messages.success(request, f'Project "{saved.name}" {action} successfully.')
|
||
return redirect('project_detail', project_id=saved.id)
|
||
else:
|
||
form = ProjectForm(instance=project)
|
||
|
||
context = {
|
||
'form': form,
|
||
'project': project,
|
||
'is_new': is_new,
|
||
}
|
||
return render(request, 'core/projects/edit.html', context)
|
||
|
||
|
||
def _build_project_report_context(status=None):
|
||
"""Build per-project aggregation list used by HTML + CSV views."""
|
||
projects = Project.objects.all().prefetch_related('supervisors')
|
||
if status == 'active':
|
||
projects = projects.filter(active=True)
|
||
elif status == 'inactive':
|
||
projects = projects.filter(active=False)
|
||
|
||
projects = projects.annotate(
|
||
_worker_days=Count('work_logs__workers', distinct=False),
|
||
_distinct_workers=Count('work_logs__workers', distinct=True),
|
||
_first_date=Min('work_logs__date'),
|
||
_last_date=Max('work_logs__date'),
|
||
).order_by('name')
|
||
|
||
rows = []
|
||
for p in projects:
|
||
teams = list(
|
||
Team.objects.filter(work_logs__project=p).distinct().values_list('name', flat=True)
|
||
)
|
||
supervisors = list(p.supervisors.values_list('username', flat=True))
|
||
cost_breakdown = _get_labour_costs(p.work_logs.all(), 'team__name', 'team')
|
||
total_cost = sum((r['total'] for r in cost_breakdown), Decimal('0.00'))
|
||
rows.append({
|
||
'project': p,
|
||
'supervisors': supervisors,
|
||
'teams': teams,
|
||
'worker_days': p._worker_days or 0,
|
||
'distinct_workers': p._distinct_workers or 0,
|
||
'first_date': p._first_date,
|
||
'last_date': p._last_date,
|
||
'total_labour_cost': total_cost,
|
||
})
|
||
return rows
|
||
|
||
|
||
@login_required
|
||
def project_batch_report(request):
|
||
"""HTML table of every project with aggregated stats."""
|
||
if not is_admin(request.user):
|
||
return HttpResponseForbidden("Admin access required.")
|
||
|
||
status = request.GET.get('status') or 'all'
|
||
rows = _build_project_report_context(status=status)
|
||
|
||
context = {
|
||
'rows': rows,
|
||
'status': status,
|
||
'query_string': request.GET.urlencode(),
|
||
'total_projects': len(rows),
|
||
}
|
||
return render(request, 'core/projects/batch_report.html', context)
|
||
|
||
|
||
@login_required
|
||
def project_batch_report_csv(request):
|
||
"""CSV download of the batch project report."""
|
||
if not is_admin(request.user):
|
||
return HttpResponseForbidden("Admin access required.")
|
||
|
||
status = request.GET.get('status') or 'all'
|
||
rows = _build_project_report_context(status=status)
|
||
|
||
response = HttpResponse(content_type='text/csv')
|
||
response['Content-Disposition'] = 'attachment; filename="project_batch_report.csv"'
|
||
writer = csv.writer(response)
|
||
writer.writerow([
|
||
'Project Name', 'Active', 'Start Date', 'End Date',
|
||
'Supervisors', 'Teams Worked', 'Distinct Workers', 'Worker-Days',
|
||
'First Activity', 'Last Activity', 'Total Labour Cost',
|
||
])
|
||
for r in rows:
|
||
p = r['project']
|
||
writer.writerow([
|
||
p.name,
|
||
'Yes' if p.active else 'No',
|
||
p.start_date.strftime('%Y-%m-%d') if p.start_date else '',
|
||
p.end_date.strftime('%Y-%m-%d') if p.end_date else '',
|
||
'; '.join(r['supervisors']),
|
||
'; '.join(r['teams']),
|
||
r['distinct_workers'],
|
||
r['worker_days'],
|
||
r['first_date'].strftime('%Y-%m-%d') if r['first_date'] else '',
|
||
r['last_date'].strftime('%Y-%m-%d') if r['last_date'] else '',
|
||
f'{r["total_labour_cost"]:.2f}',
|
||
])
|
||
return response
|
||
|
||
|
||
# === REPORT GENERATION ===
|
||
# Builds a comprehensive payroll report for a given date range.
|
||
# Used by both the on-screen HTML report and the PDF download.
|
||
#
|
||
# TERMINOLOGY (used consistently throughout report):
|
||
# - "Worker-Days" = total individual worker×day entries (if 5 workers work 22 days = 110 worker-days)
|
||
# - "Days Worked" (per worker) = distinct dates that specific worker was logged
|
||
# - "Total Paid" = actual money transferred to a worker (net of all adjustments)
|
||
# - "Loans Outstanding" = current remaining balance on active loans
|
||
# - "Advances Outstanding" = current remaining balance on active advances
|
||
|
||
# === REPORT LABEL MAP ===
|
||
# Maps internal PayrollAdjustment type names to human-readable report labels.
|
||
# These are used in both the Adjustment Summary and Worker Breakdown tables.
|
||
REPORT_ADJ_LABELS = {
|
||
'Bonus': 'Bonuses',
|
||
'Overtime': 'Overtime',
|
||
'Deduction': 'Deductions',
|
||
'Loan Repayment': 'Loan Repaid',
|
||
'Advance Repayment': 'Advance Repaid',
|
||
'New Loan': 'Loans Issued',
|
||
'Advance Payment': 'Advances Issued',
|
||
}
|
||
|
||
|
||
def _get_labour_costs(work_logs_qs, group_by_field, name_key):
|
||
"""
|
||
Calculate labour cost (sum of daily rates) grouped by a field.
|
||
Used for project and team cost breakdowns.
|
||
|
||
Args:
|
||
work_logs_qs: Filtered WorkLog queryset
|
||
group_by_field: Field to group by (e.g. 'project__name', 'team__name')
|
||
name_key: Key name for the result dict (e.g. 'project', 'team')
|
||
|
||
Returns list of dicts: [{name_key: ..., 'worker_days': ..., 'total': ...}]
|
||
"""
|
||
data = list(
|
||
work_logs_qs
|
||
.values(group_by_field)
|
||
.annotate(
|
||
worker_days=Count('workers'),
|
||
labour_cost=Sum(F('workers__monthly_salary') / Decimal('20'))
|
||
)
|
||
.filter(worker_days__gt=0)
|
||
.order_by('-labour_cost')
|
||
)
|
||
return [
|
||
{
|
||
name_key: item[group_by_field] or 'Unknown',
|
||
'worker_days': item['worker_days'],
|
||
'total': item['labour_cost'] or Decimal('0.00'),
|
||
}
|
||
for item in data
|
||
]
|
||
|
||
|
||
def _build_report_context(start_date, end_date, project_ids=None, team_ids=None):
|
||
"""
|
||
Compute all report data for the given date range and filters.
|
||
Returns a dictionary of totals, breakdowns, and worker-level data.
|
||
|
||
project_ids / team_ids are lists of ints (from request.GET.getlist).
|
||
None or [] are treated as "no filter" — returning data for every project
|
||
or every team respectively. A single-element list like [3] reproduces
|
||
the old single-id behaviour (so old URLs like ?project=3 still work).
|
||
|
||
Key design decision: "Worker-Days" counts total worker×log entries
|
||
(not distinct calendar dates). This correlates correctly with cost —
|
||
if 5 workers work 22 days, that's 110 worker-days, and
|
||
cost / worker-days ≈ average daily rate.
|
||
"""
|
||
# --- Base filters ---
|
||
date_filter = Q(date__gte=start_date, date__lte=end_date)
|
||
|
||
# --- PayrollRecords in range ---
|
||
#
|
||
# IMPORTANT — avoid M2M double-JOIN inflation:
|
||
# Chaining `.filter(work_logs__project_id__in=X).distinct().filter(work_logs__team_id__in=Y)`
|
||
# creates TWO separate JOIN aliases on core_payrollrecord_work_logs. Any
|
||
# later `.values().annotate(Sum())` then aggregates across the cartesian
|
||
# product of matching rows, inflating per-worker and per-date totals by
|
||
# N × M (where N and M are the counts of matching logs per record).
|
||
# `.aggregate(Sum())` is safe because Django wraps it in a subquery when
|
||
# distinct() is in play, but `.values().annotate(Sum())` isn't — so we
|
||
# use id__in subqueries to keep the outer queryset JOIN-free.
|
||
# See ReportContextFilterInflationTests for regression coverage.
|
||
records = PayrollRecord.objects.filter(date_filter)
|
||
if project_ids:
|
||
records = records.filter(
|
||
id__in=PayrollRecord.objects.filter(
|
||
work_logs__project_id__in=project_ids
|
||
).values('id')
|
||
)
|
||
if team_ids:
|
||
records = records.filter(
|
||
id__in=PayrollRecord.objects.filter(
|
||
work_logs__team_id__in=team_ids
|
||
).values('id')
|
||
)
|
||
|
||
# --- Total Paid Out (sum of all payments made) ---
|
||
total_paid_out = records.aggregate(total=Sum('amount_paid'))['total'] or Decimal('0.00')
|
||
|
||
# --- Payments by Date (total paid per day) ---
|
||
payments_by_date = (
|
||
records.values('date')
|
||
.annotate(total=Sum('amount_paid'))
|
||
.order_by('date')
|
||
)
|
||
|
||
# --- Adjustments in range ---
|
||
# project_ids filters via an FK column (no JOIN inflation risk), but
|
||
# team_ids goes through worker__teams M2M — apply the same subquery
|
||
# pattern as above to keep adj_by_type's values().annotate(Sum()) safe.
|
||
adjustments = PayrollAdjustment.objects.filter(date_filter)
|
||
if project_ids:
|
||
adjustments = adjustments.filter(project_id__in=project_ids)
|
||
if team_ids:
|
||
adjustments = adjustments.filter(
|
||
worker__in=Worker.objects.filter(teams__id__in=team_ids).values('id')
|
||
)
|
||
|
||
# --- Work Logs in range (for calculating actual labour cost) ---
|
||
work_logs_qs = WorkLog.objects.filter(date__gte=start_date, date__lte=end_date)
|
||
if project_ids:
|
||
work_logs_qs = work_logs_qs.filter(project_id__in=project_ids)
|
||
if team_ids:
|
||
work_logs_qs = work_logs_qs.filter(team_id__in=team_ids)
|
||
|
||
# Total worker-days across all work logs (counts M2M worker entries)
|
||
total_worker_days = work_logs_qs.aggregate(
|
||
total=Count('workers'))['total'] or 0
|
||
|
||
# --- Labour Cost by Project (selected period) ---
|
||
# Uses daily rates (monthly_salary / 20) for the TRUE cost per project
|
||
cost_per_project = _get_labour_costs(work_logs_qs, 'project__name', 'project')
|
||
|
||
# --- Labour Cost by Team (selected period) ---
|
||
cost_per_team = _get_labour_costs(
|
||
work_logs_qs.filter(team__isnull=False), 'team__name', 'team'
|
||
)
|
||
|
||
# --- ALL TIME: project and team costs since the very first work log ---
|
||
all_time_logs = WorkLog.objects.all()
|
||
if project_ids:
|
||
all_time_logs = all_time_logs.filter(project_id__in=project_ids)
|
||
if team_ids:
|
||
all_time_logs = all_time_logs.filter(team_id__in=team_ids)
|
||
# === CHAPTER I — All Time Projects (enriched) ===
|
||
# Adds working_days and avg_per_working_day (the 2026-04-23 design).
|
||
# Can't just extend _get_labour_costs because that helper is used by
|
||
# other sections with different columns. Wrap it here instead.
|
||
alltime_projects_raw = _get_labour_costs(all_time_logs, 'project__name', 'project')
|
||
# Build a lookup of working_days per project (distinct work-log dates)
|
||
project_working_days = dict(
|
||
all_time_logs.filter(project__isnull=False)
|
||
.values('project_id', 'project__name')
|
||
.annotate(days=Count('date', distinct=True))
|
||
.values_list('project__name', 'days')
|
||
)
|
||
# Lookup project start_date from the Project model (authoritative source)
|
||
start_dates = dict(
|
||
Project.objects.values_list('name', 'start_date')
|
||
)
|
||
# Lookup the most recent WorkLog.date for each project (for the new
|
||
# "Last Activity" column — helps Konrad spot which projects are dormant
|
||
# without having to scroll through date pickers).
|
||
last_activity = dict(
|
||
all_time_logs.filter(project__isnull=False)
|
||
.values('project__name')
|
||
.annotate(last=Max('date'))
|
||
.values_list('project__name', 'last')
|
||
)
|
||
alltime_projects = []
|
||
for row in alltime_projects_raw:
|
||
name = row['project']
|
||
wdays = project_working_days.get(name, 0)
|
||
total = row['total'] or Decimal('0.00')
|
||
avg = (total / wdays).quantize(Decimal('0.01')) if wdays else Decimal('0.00')
|
||
alltime_projects.append({
|
||
'project': name,
|
||
'worker_days': row['worker_days'],
|
||
'total': total,
|
||
'start_date': start_dates.get(name), # may be None
|
||
'last_activity': last_activity.get(name), # may be None
|
||
'working_days': wdays,
|
||
'avg_per_working_day': avg,
|
||
})
|
||
alltime_teams = _get_labour_costs(
|
||
all_time_logs.filter(team__isnull=False), 'team__name', 'team'
|
||
)
|
||
|
||
# --- THIS YEAR: project and team costs for the current calendar year ---
|
||
current_year = timezone.now().year
|
||
year_start = datetime.date(current_year, 1, 1)
|
||
year_end = datetime.date(current_year, 12, 31)
|
||
year_logs = WorkLog.objects.filter(date__gte=year_start, date__lte=year_end)
|
||
if project_ids:
|
||
year_logs = year_logs.filter(project_id__in=project_ids)
|
||
if team_ids:
|
||
year_logs = year_logs.filter(team_id__in=team_ids)
|
||
year_projects = _get_labour_costs(year_logs, 'project__name', 'project')
|
||
year_teams = _get_labour_costs(
|
||
year_logs.filter(team__isnull=False), 'team__name', 'team'
|
||
)
|
||
|
||
# --- Loans & Advances Outstanding (current balances) ---
|
||
# team filter goes through worker__teams (M2M). Use the subquery pattern
|
||
# (CLAUDE.md Django ORM gotcha) so we don't pick up JOIN inflation on the
|
||
# aggregate.
|
||
active_loans = Loan.objects.filter(active=True, date__lte=end_date)
|
||
if team_ids:
|
||
active_loans = active_loans.filter(
|
||
worker__in=Worker.objects.filter(teams__id__in=team_ids).values('id')
|
||
)
|
||
loans_outstanding = active_loans.filter(loan_type='loan').aggregate(
|
||
total=Sum('remaining_balance'))['total'] or Decimal('0.00')
|
||
advances_outstanding = active_loans.filter(loan_type='advance').aggregate(
|
||
total=Sum('remaining_balance'))['total'] or Decimal('0.00')
|
||
|
||
# --- Loans & Advances Issued This Period ---
|
||
loans_issued_qs = Loan.objects.filter(date_filter, loan_type='loan')
|
||
advances_issued_qs = Loan.objects.filter(date_filter, loan_type='advance')
|
||
if team_ids:
|
||
team_worker_ids = Worker.objects.filter(teams__id__in=team_ids).values('id')
|
||
loans_issued_qs = loans_issued_qs.filter(worker__in=team_worker_ids)
|
||
advances_issued_qs = advances_issued_qs.filter(worker__in=team_worker_ids)
|
||
loans_issued = loans_issued_qs.aggregate(
|
||
total=Sum('principal_amount'))['total'] or Decimal('0.00')
|
||
advances_issued = advances_issued_qs.aggregate(
|
||
total=Sum('principal_amount'))['total'] or Decimal('0.00')
|
||
|
||
# --- Adjustment Summary ---
|
||
# Group by type, use readable labels, and sort by logical grouping
|
||
adj_by_type = (
|
||
adjustments.values('type')
|
||
.annotate(total=Sum('amount'))
|
||
.order_by('type')
|
||
)
|
||
adjustment_totals = [
|
||
{
|
||
'type': item['type'],
|
||
'label': REPORT_ADJ_LABELS.get(item['type'], item['type']),
|
||
'total': item['total'],
|
||
}
|
||
for item in adj_by_type
|
||
]
|
||
|
||
# --- Determine which adjustment types appear (for worker table columns) ---
|
||
# Only types with non-zero totals get a column — keeps the table readable
|
||
active_adj_types = list(
|
||
adjustments.values_list('type', flat=True).distinct().order_by('type')
|
||
)
|
||
# Create matching readable labels for column headers
|
||
active_adj_labels = [REPORT_ADJ_LABELS.get(t, t) for t in active_adj_types]
|
||
|
||
# --- Worker Breakdown ---
|
||
# Per worker: days worked, total paid, and each adjustment type
|
||
worker_records = (
|
||
records.values('worker__id', 'worker__name')
|
||
.annotate(total_paid=Sum('amount_paid'))
|
||
.order_by('worker__name')
|
||
)
|
||
|
||
# Days worked per worker = distinct dates they appear in work logs
|
||
days_per_worker = dict(
|
||
work_logs_qs.values('workers__id')
|
||
.annotate(days=Count('date', distinct=True))
|
||
.values_list('workers__id', 'days')
|
||
)
|
||
|
||
worker_breakdown = []
|
||
for wr in worker_records:
|
||
w_adjs = adjustments.filter(worker_id=wr['worker__id'])
|
||
# Per-type amounts for this worker (only for types that exist in the period)
|
||
adj_values = []
|
||
for adj_type in active_adj_types:
|
||
amt = w_adjs.filter(type=adj_type).aggregate(
|
||
t=Sum('amount'))['t'] or Decimal('0.00')
|
||
adj_values.append(amt)
|
||
|
||
worker_breakdown.append({
|
||
'name': wr['worker__name'],
|
||
'total_paid': wr['total_paid'],
|
||
'days': days_per_worker.get(wr['worker__id'], 0),
|
||
'adj_values': adj_values,
|
||
})
|
||
|
||
# === Hero KPI band data (executive report v2) ===
|
||
# Small helpers that power the new hero band at the top of the report.
|
||
# Kept separate so the big return dict stays easy to scan.
|
||
_cv = _company_cost_velocity()
|
||
|
||
return {
|
||
'start_date': start_date,
|
||
'end_date': end_date,
|
||
'project_name': (
|
||
', '.join(
|
||
Project.objects.filter(id__in=project_ids).values_list('name', flat=True)
|
||
)
|
||
if project_ids else 'All Projects'
|
||
),
|
||
'team_name': (
|
||
', '.join(
|
||
Team.objects.filter(id__in=team_ids).values_list('name', flat=True)
|
||
)
|
||
if team_ids else 'All Teams'
|
||
),
|
||
# --- Summary ---
|
||
'total_paid_out': total_paid_out,
|
||
'total_worker_days': total_worker_days,
|
||
'loans_outstanding': loans_outstanding,
|
||
'advances_outstanding': advances_outstanding,
|
||
'loans_issued': loans_issued,
|
||
'advances_issued': advances_issued,
|
||
# --- All Time & Year context ---
|
||
'alltime_projects': alltime_projects,
|
||
'alltime_teams': alltime_teams,
|
||
'current_year': current_year,
|
||
'year_projects': year_projects,
|
||
'year_teams': year_teams,
|
||
# --- Selected Period tables ---
|
||
'payments_by_date': payments_by_date,
|
||
'cost_per_project': cost_per_project,
|
||
'cost_per_team': cost_per_team,
|
||
'adjustment_totals': adjustment_totals,
|
||
'active_adj_types': active_adj_types,
|
||
'active_adj_labels': active_adj_labels,
|
||
'worker_breakdown': worker_breakdown,
|
||
# --- Hero KPI band (executive report v2) ---
|
||
'current_outstanding': _current_outstanding_in_scope(
|
||
project_ids=project_ids, team_ids=team_ids
|
||
),
|
||
'current_as_of': timezone.now(),
|
||
'company_avg_daily': _cv['avg_daily'],
|
||
'company_avg_monthly': _cv['avg_monthly'],
|
||
'company_working_days': _cv['working_days'],
|
||
'team_project_activity': _team_project_activity(work_logs_qs),
|
||
}
|
||
|
||
|
||
def _parse_report_dates(request):
|
||
"""
|
||
Parse report date range from GET params.
|
||
Supports two modes:
|
||
- "from_month" + "to_month" params (e.g. "2026-01" to "2026-03") → Jan 1 to Mar 31
|
||
- "start_date" + "end_date" params → custom range
|
||
Also supports legacy "month" param for backward compatibility.
|
||
Returns (start_date, end_date) as date objects, or (None, None) if invalid.
|
||
"""
|
||
from_month_str = request.GET.get('from_month', '').strip()
|
||
to_month_str = request.GET.get('to_month', '').strip()
|
||
start_str = request.GET.get('start_date', '').strip()
|
||
end_str = request.GET.get('end_date', '').strip()
|
||
# Legacy single month param
|
||
month_str = request.GET.get('month', '').strip()
|
||
|
||
if from_month_str:
|
||
# Multi-month mode: from_month → first day, to_month → last day
|
||
try:
|
||
fy, fm = map(int, from_month_str.split('-'))
|
||
start_date = datetime.date(fy, fm, 1)
|
||
# If to_month is missing, use same as from_month (single month)
|
||
if to_month_str:
|
||
ty, tm = map(int, to_month_str.split('-'))
|
||
else:
|
||
ty, tm = fy, fm
|
||
last_day = cal_module.monthrange(ty, tm)[1]
|
||
end_date = datetime.date(ty, tm, last_day)
|
||
return start_date, end_date
|
||
except (ValueError, TypeError):
|
||
return None, None
|
||
elif month_str:
|
||
# Legacy single month mode
|
||
try:
|
||
year, month = map(int, month_str.split('-'))
|
||
start_date = datetime.date(year, month, 1)
|
||
last_day = cal_module.monthrange(year, month)[1]
|
||
end_date = datetime.date(year, month, last_day)
|
||
return start_date, end_date
|
||
except (ValueError, TypeError):
|
||
return None, None
|
||
elif start_str and end_str:
|
||
# Custom range mode
|
||
try:
|
||
return datetime.date.fromisoformat(start_str), datetime.date.fromisoformat(end_str)
|
||
except ValueError:
|
||
return None, None
|
||
|
||
return None, None
|
||
|
||
|
||
@login_required
|
||
def generate_report(request):
|
||
"""Render on-screen payroll report with filters from GET params."""
|
||
if not is_admin(request.user):
|
||
return HttpResponseForbidden("Admin access required.")
|
||
|
||
# Parse dates — supports both "month" and "start_date/end_date" params
|
||
start_date, end_date = _parse_report_dates(request)
|
||
# Multi-value: ?project=1&project=2 comes in as getlist ['1','2'].
|
||
# Cast to ints; drop empties. None if list is empty (= "no filter").
|
||
def _ids(name):
|
||
return [int(v) for v in request.GET.getlist(name) if v.strip().isdigit()]
|
||
project_ids = _ids('project') or None
|
||
team_ids = _ids('team') or None
|
||
|
||
if not start_date or not end_date:
|
||
messages.error(request, "Please select a month or provide start and end dates.")
|
||
return redirect('home')
|
||
|
||
# Build report data using shared helper
|
||
context = _build_report_context(
|
||
start_date, end_date,
|
||
project_ids=project_ids, team_ids=team_ids,
|
||
)
|
||
# Pass the raw query params so the "Download PDF" button can use them
|
||
context['query_string'] = request.GET.urlencode()
|
||
# === FILTER PILL CLEAR LINKS ===
|
||
# For the filter-pill x buttons: rebuild the querystring with one filter removed.
|
||
# QueryDict.pop() only removes the first occurrence, so for multi-value keys
|
||
# (e.g. project=1&project=2) we follow up with setlist(key, []) to strip them all.
|
||
def _qs_without(key):
|
||
qd = request.GET.copy()
|
||
qd.pop(key, None)
|
||
qd.setlist(key, [])
|
||
return qd.urlencode()
|
||
context['query_string_without_project'] = _qs_without('project')
|
||
context['query_string_without_team'] = _qs_without('team')
|
||
# === Date-scoped pickers + cross-filter ===
|
||
# Admin UX decision (Konrad, 2026-04-23 Checkpoint 1 feedback):
|
||
# The project/team pills should only show entries that actually have
|
||
# WorkLog activity within the currently-selected date range. Same for
|
||
# the (project_id, team_id) pair map that powers the cross-filter.
|
||
# Rationale: "show me teams I'm actually looking at right now," not
|
||
# "every team that ever existed."
|
||
#
|
||
# Guarantee: a project or team that's currently in the URL selection
|
||
# MUST remain in the list — even if it has no logs in this window —
|
||
# so the user can always see and deselect their own picks.
|
||
logs_in_range = WorkLog.objects.filter(
|
||
date__gte=start_date, date__lte=end_date,
|
||
)
|
||
project_ids_in_range = set(
|
||
logs_in_range.values_list('project_id', flat=True).distinct()
|
||
)
|
||
team_ids_in_range = set(
|
||
logs_in_range.values_list('team_id', flat=True).distinct()
|
||
)
|
||
# Logs without a project/team contribute a None — drop it
|
||
project_ids_in_range.discard(None)
|
||
team_ids_in_range.discard(None)
|
||
|
||
# Union with the user's URL selections so picks never vanish
|
||
selected_p_int = {int(x) for x in (project_ids or [])}
|
||
selected_t_int = {int(x) for x in (team_ids or [])}
|
||
project_ids_to_show = project_ids_in_range | selected_p_int
|
||
team_ids_to_show = team_ids_in_range | selected_t_int
|
||
|
||
# Cross-filter pair map, scoped to the same date range
|
||
# (raw Python list — |json_script in the template handles serialisation)
|
||
pairs = list(
|
||
logs_in_range
|
||
.filter(project__isnull=False, team__isnull=False)
|
||
.values('project_id', 'team_id')
|
||
.distinct()
|
||
)
|
||
context['project_team_pairs_json'] = pairs
|
||
|
||
# Picker lists (only projects/teams with activity in this window,
|
||
# union'd with current URL selection)
|
||
context['projects'] = (
|
||
Project.objects.filter(id__in=project_ids_to_show).order_by('name')
|
||
)
|
||
context['teams'] = (
|
||
Team.objects.filter(id__in=team_ids_to_show).order_by('name')
|
||
)
|
||
# Template's `{% if p.id|stringformat:"s" in selected_project_ids %}`
|
||
# comparison needs strings on both sides.
|
||
context['selected_project_ids'] = [str(p) for p in (project_ids or [])]
|
||
context['selected_team_ids'] = [str(t) for t in (team_ids or [])]
|
||
|
||
return render(request, 'core/report.html', context)
|
||
|
||
|
||
@login_required
|
||
def generate_report_pdf(request):
|
||
"""Generate a PDF version of the payroll report (same data as HTML view)."""
|
||
if not is_admin(request.user):
|
||
return HttpResponseForbidden("Admin access required.")
|
||
|
||
from .utils import render_to_pdf
|
||
|
||
# Parse dates — same logic as the HTML view
|
||
start_date, end_date = _parse_report_dates(request)
|
||
# Multi-value: ?project=1&project=2 comes in as getlist ['1','2'].
|
||
# Cast to ints; drop empties. None if list is empty (= "no filter").
|
||
def _ids(name):
|
||
return [int(v) for v in request.GET.getlist(name) if v.strip().isdigit()]
|
||
project_ids = _ids('project') or None
|
||
team_ids = _ids('team') or None
|
||
|
||
if not start_date or not end_date:
|
||
messages.error(request, "Please select a month or provide start and end dates.")
|
||
return redirect('home')
|
||
|
||
context = _build_report_context(
|
||
start_date, end_date,
|
||
project_ids=project_ids, team_ids=team_ids,
|
||
)
|
||
context['now'] = timezone.now()
|
||
|
||
pdf = render_to_pdf('core/pdf/report_pdf.html', context)
|
||
if pdf:
|
||
response = HttpResponse(pdf, content_type='application/pdf')
|
||
filename = f"payroll_report_{start_date}_{end_date}.pdf"
|
||
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
||
return response
|
||
|
||
messages.error(request, "PDF generation failed. xhtml2pdf may not be installed.")
|
||
return redirect('home')
|
||
|
||
|
||
# === TOGGLE RESOURCE STATUS (AJAX) ===
|
||
# Called by the toggle switches on the dashboard to activate/deactivate
|
||
# workers, projects, or teams without reloading the page.
|
||
|
||
@login_required
|
||
def toggle_active(request, model_name, item_id):
|
||
if request.method != 'POST':
|
||
return HttpResponseForbidden("Only POST requests are allowed.")
|
||
|
||
if not is_admin(request.user):
|
||
return HttpResponseForbidden("Not authorized.")
|
||
|
||
# Map URL parameter to the correct model class
|
||
model_map = {
|
||
'worker': Worker,
|
||
'project': Project,
|
||
'team': Team
|
||
}
|
||
|
||
if model_name not in model_map:
|
||
return JsonResponse({'error': 'Invalid model'}, status=400)
|
||
|
||
model = model_map[model_name]
|
||
try:
|
||
item = model.objects.get(id=item_id)
|
||
item.active = not item.active
|
||
item.save()
|
||
return JsonResponse({
|
||
'status': 'success',
|
||
'active': item.active,
|
||
'message': f'{item.name} is now {"active" if item.active else "inactive"}.'
|
||
})
|
||
except model.DoesNotExist:
|
||
return JsonResponse({'error': 'Item not found'}, status=404)
|
||
|
||
|
||
# =============================================================================
|
||
# === ADJUSTMENT GROUPING HELPER ===
|
||
# Used by the Adjustments tab's By Type / By Worker render path.
|
||
# Plain-English: takes a flat list of PayrollAdjustment rows and regroups
|
||
# them into buckets keyed by adjustment type or by worker. The result is
|
||
# a list of group-dicts the template can iterate, each carrying a label,
|
||
# CSS-friendly slug, the list of rows in the bucket, a count, and the
|
||
# net signed sum of amounts (additives count +, deductives count -).
|
||
# =============================================================================
|
||
|
||
def _group_adjustments(adjustments, group_by):
|
||
"""Regroup a flat list/queryset of PayrollAdjustment into buckets.
|
||
|
||
`group_by` is 'type' or 'worker'. Returns a list of dicts:
|
||
{'label', 'slug', 'rows', 'count', 'net_sum'}
|
||
|
||
Ordered by descending magnitude of net_sum so the highest-impact
|
||
bucket sits at the top of the view (big groups first).
|
||
"""
|
||
from collections import defaultdict
|
||
buckets = defaultdict(list)
|
||
for adj in adjustments:
|
||
key = adj.type if group_by == 'type' else adj.worker_id
|
||
buckets[key].append(adj)
|
||
|
||
groups = []
|
||
for key, rows in buckets.items():
|
||
if group_by == 'type':
|
||
label = key
|
||
slug = key.lower().replace(' ', '-')
|
||
else: # worker
|
||
label = rows[0].worker.name
|
||
slug = f'worker-{key}'
|
||
net_sum = sum(
|
||
(r.amount if r.type in ADDITIVE_TYPES else -r.amount)
|
||
for r in rows
|
||
)
|
||
groups.append({
|
||
'label': label,
|
||
'slug': slug,
|
||
'rows': rows,
|
||
'count': len(rows),
|
||
'net_sum': net_sum,
|
||
})
|
||
groups.sort(key=lambda g: -abs(g['net_sum']))
|
||
return groups
|
||
|
||
|
||
# =============================================================================
|
||
# === PAYROLL DASHBOARD ===
|
||
# The main payroll page. Shows per-worker breakdown of what's owed,
|
||
# adjustment management, payment processing, and Chart.js analytics.
|
||
# Admin-only — supervisors cannot access this page.
|
||
# =============================================================================
|
||
|
||
@login_required
|
||
def payroll_dashboard(request):
|
||
if not is_admin(request.user):
|
||
messages.error(request, 'Only admins can access the payroll dashboard.')
|
||
return redirect('home')
|
||
|
||
status_filter = request.GET.get('status', 'pending')
|
||
|
||
# --- Per-worker pending payment data ---
|
||
# For each active worker, calculate: unpaid days × daily_rate + net adjustments
|
||
active_workers = Worker.objects.filter(active=True).prefetch_related(
|
||
Prefetch('work_logs', queryset=WorkLog.objects.prefetch_related(
|
||
'payroll_records', 'priced_workers'
|
||
).select_related('project')),
|
||
Prefetch('adjustments', queryset=PayrollAdjustment.objects.filter(
|
||
payroll_record__isnull=True
|
||
).select_related('project', 'loan', 'work_log'),
|
||
to_attr='pending_adjustments_list'),
|
||
).order_by('name')
|
||
|
||
workers_data = []
|
||
outstanding_total = Decimal('0.00')
|
||
# === OUTSTANDING BREAKDOWN (same as home dashboard) ===
|
||
unpaid_wages_total = Decimal('0.00') # Pure daily rates for unpaid workers
|
||
pending_adj_add_total = Decimal('0.00') # Unpaid additive adjustments
|
||
pending_adj_sub_total = Decimal('0.00') # Unpaid deductive adjustments
|
||
all_ot_data = [] # For the Price Overtime modal
|
||
|
||
for worker in active_workers:
|
||
# Find unpaid work logs for this worker.
|
||
# A log is "unpaid for this worker" if no PayrollRecord links
|
||
# to BOTH this log AND this worker.
|
||
unpaid_logs = []
|
||
for log in worker.work_logs.all():
|
||
paid_worker_ids = {pr.worker_id for pr in log.payroll_records.all()}
|
||
if worker.id not in paid_worker_ids:
|
||
unpaid_logs.append(log)
|
||
|
||
log_count = len(unpaid_logs)
|
||
log_amount = log_count * worker.daily_rate
|
||
|
||
# Find unpriced overtime in unpaid logs
|
||
ot_data_worker = []
|
||
for log in unpaid_logs:
|
||
if log.overtime_amount > 0:
|
||
priced_ids = {w.id for w in log.priced_workers.all()}
|
||
if worker.id not in priced_ids:
|
||
ot_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_amount),
|
||
'ot_label': log.get_overtime_amount_display(),
|
||
}
|
||
ot_data_worker.append(ot_entry)
|
||
all_ot_data.append(ot_entry)
|
||
|
||
# Calculate net adjustment amount
|
||
pending_adjs = worker.pending_adjustments_list
|
||
adj_total = Decimal('0.00')
|
||
worker_adj_add = Decimal('0.00')
|
||
worker_adj_sub = Decimal('0.00')
|
||
for adj in pending_adjs:
|
||
if adj.type in ADDITIVE_TYPES:
|
||
adj_total += adj.amount
|
||
worker_adj_add += adj.amount
|
||
elif adj.type in DEDUCTIVE_TYPES:
|
||
adj_total -= adj.amount
|
||
worker_adj_sub += adj.amount
|
||
|
||
total_payable = log_amount + adj_total
|
||
|
||
# Only include workers who have something pending
|
||
if log_count > 0 or pending_adjs:
|
||
# --- Overdue detection ---
|
||
# A worker is "overdue" if they have unpaid work from a completed pay period.
|
||
# Uses their team's pay schedule to determine the cutoff date.
|
||
team = get_worker_active_team(worker)
|
||
team_name = team.name if team else ''
|
||
earliest_unpaid = min((l.date for l in unpaid_logs), default=None) if unpaid_logs else None
|
||
is_overdue = False
|
||
if earliest_unpaid and team and team.pay_frequency and team.pay_start_date:
|
||
period_start, period_end = get_pay_period(team)
|
||
if period_start:
|
||
cutoff = period_start - datetime.timedelta(days=1)
|
||
is_overdue = earliest_unpaid <= cutoff
|
||
|
||
has_loan = Loan.objects.filter(worker=worker, active=True).exists()
|
||
|
||
# Most recent project — used by the "Adjust" button to pre-select project
|
||
last_project_id = unpaid_logs[-1].project_id if unpaid_logs else None
|
||
|
||
workers_data.append({
|
||
'worker': worker,
|
||
'unpaid_count': log_count,
|
||
'unpaid_amount': log_amount,
|
||
'adj_amount': adj_total,
|
||
'total_payable': total_payable,
|
||
'adjustments': pending_adjs,
|
||
'logs': unpaid_logs,
|
||
'ot_data': ot_data_worker,
|
||
'day_rate': float(worker.daily_rate),
|
||
'team_name': team_name,
|
||
'is_overdue': is_overdue,
|
||
'has_loan': has_loan,
|
||
'earliest_unpaid': earliest_unpaid,
|
||
'last_project_id': last_project_id,
|
||
})
|
||
outstanding_total += max(total_payable, Decimal('0.00'))
|
||
unpaid_wages_total += log_amount
|
||
pending_adj_add_total += worker_adj_add
|
||
pending_adj_sub_total += worker_adj_sub
|
||
|
||
# --- Payment history ---
|
||
paid_records = PayrollRecord.objects.select_related(
|
||
'worker'
|
||
).order_by('-date', '-id')
|
||
|
||
# --- Recent payments total (last 60 days) ---
|
||
sixty_days_ago = timezone.now().date() - timezone.timedelta(days=60)
|
||
recent_payments_total = PayrollRecord.objects.filter(
|
||
date__gte=sixty_days_ago
|
||
).aggregate(total=Sum('amount_paid'))['total'] or Decimal('0.00')
|
||
|
||
# --- Outstanding cost per project ---
|
||
# Check per-worker: a WorkLog is "unpaid for worker X" if no PayrollRecord
|
||
# links BOTH that log AND that worker. This handles partially-paid logs.
|
||
outstanding_project_costs = []
|
||
for project in Project.objects.filter(active=True):
|
||
project_outstanding = Decimal('0.00')
|
||
# Unpaid work log costs — check each worker individually
|
||
for log in project.work_logs.prefetch_related('payroll_records', 'workers').all():
|
||
paid_worker_ids = {pr.worker_id for pr in log.payroll_records.all()}
|
||
for w in log.workers.all():
|
||
if w.id not in paid_worker_ids:
|
||
project_outstanding += w.daily_rate
|
||
# Unpaid adjustments for this project
|
||
unpaid_adjs = PayrollAdjustment.objects.filter(
|
||
payroll_record__isnull=True
|
||
).filter(Q(project=project) | Q(work_log__project=project))
|
||
for adj in unpaid_adjs:
|
||
if adj.type in ADDITIVE_TYPES:
|
||
project_outstanding += adj.amount
|
||
elif adj.type in DEDUCTIVE_TYPES:
|
||
project_outstanding -= adj.amount
|
||
if project_outstanding != 0:
|
||
outstanding_project_costs.append({
|
||
'name': project.name,
|
||
'cost': project_outstanding,
|
||
})
|
||
|
||
# --- Chart data: last 6 months ---
|
||
today = timezone.now().date()
|
||
chart_months = []
|
||
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))
|
||
|
||
chart_labels = [
|
||
datetime.date(y, m, 1).strftime('%b %Y') for y, m in chart_months
|
||
]
|
||
|
||
# Monthly payroll totals
|
||
paid_by_month_qs = PayrollRecord.objects.annotate(
|
||
month=TruncMonth('date')
|
||
).values('month').annotate(total=Sum('amount_paid')).order_by('month')
|
||
paid_by_month = {
|
||
(r['month'].year, r['month'].month): float(r['total'])
|
||
for r in paid_by_month_qs
|
||
}
|
||
chart_totals = [paid_by_month.get((y, m), 0) for y, m in chart_months]
|
||
|
||
# Per-project monthly costs (for stacked bar chart)
|
||
project_chart_data = []
|
||
for project in Project.objects.filter(active=True):
|
||
monthly_data = []
|
||
for y, m in chart_months:
|
||
month_cost = Decimal('0.00')
|
||
month_logs = project.work_logs.filter(
|
||
date__year=y, date__month=m
|
||
).prefetch_related('workers')
|
||
for log in month_logs:
|
||
for w in log.workers.all():
|
||
month_cost += w.daily_rate
|
||
# Include paid adjustments for this project in this month
|
||
paid_adjs = PayrollAdjustment.objects.filter(
|
||
payroll_record__isnull=False,
|
||
date__year=y, date__month=m,
|
||
).filter(Q(project=project) | Q(work_log__project=project))
|
||
for adj in paid_adjs:
|
||
if adj.type in ADDITIVE_TYPES:
|
||
month_cost += adj.amount
|
||
elif adj.type in DEDUCTIVE_TYPES:
|
||
month_cost -= adj.amount
|
||
monthly_data.append(float(month_cost))
|
||
if any(v > 0 for v in monthly_data):
|
||
project_chart_data.append({
|
||
'name': project.name,
|
||
'data': monthly_data,
|
||
})
|
||
|
||
# === CHART DATA: Per-Worker Monthly Breakdown ===
|
||
# Pre-compute payment breakdown for each active worker over the last 6 months.
|
||
# This powers the "By Worker" toggle on the Monthly Payroll Totals chart.
|
||
# Only ~14 workers x 6 months = tiny dataset, so we embed it all as JSON
|
||
# and switching between workers is instant (no server round-trips).
|
||
|
||
# Starting date for the 6-month window (first day of the oldest chart month)
|
||
six_months_ago_date = datetime.date(chart_months[0][0], chart_months[0][1], 1)
|
||
|
||
# Query 1: Total amount paid per worker per month.
|
||
# Uses database-level grouping — one query for ALL workers at once.
|
||
worker_monthly_paid_qs = PayrollRecord.objects.filter(
|
||
worker__active=True,
|
||
date__gte=six_months_ago_date,
|
||
).values(
|
||
'worker_id',
|
||
month=TruncMonth('date'),
|
||
).annotate(total=Sum('amount_paid'))
|
||
|
||
# Build a fast lookup dict: {(worker_id, year, month): total_paid}
|
||
worker_paid_lookup = {}
|
||
for row in worker_monthly_paid_qs:
|
||
key = (row['worker_id'], row['month'].year, row['month'].month)
|
||
worker_paid_lookup[key] = float(row['total'])
|
||
|
||
# Query 2: Paid adjustment totals grouped by worker, type, and month.
|
||
# "Paid" means the adjustment has a linked PayrollRecord.
|
||
# We group by the PayrollRecord's date (not the adjustment date)
|
||
# so it lines up with when the payment actually happened.
|
||
worker_monthly_adj_qs = PayrollAdjustment.objects.filter(
|
||
payroll_record__isnull=False,
|
||
worker__active=True,
|
||
payroll_record__date__gte=six_months_ago_date,
|
||
).values(
|
||
'worker_id',
|
||
'type',
|
||
month=TruncMonth('payroll_record__date'),
|
||
).annotate(total=Sum('amount'))
|
||
|
||
# Build a fast lookup dict: {(worker_id, year, month, type): total_amount}
|
||
worker_adj_lookup = {}
|
||
for row in worker_monthly_adj_qs:
|
||
key = (row['worker_id'], row['month'].year, row['month'].month, row['type'])
|
||
worker_adj_lookup[key] = float(row['total'])
|
||
|
||
# Build the final data structure for JavaScript.
|
||
# For each worker with payment history, create 6 monthly entries showing
|
||
# how their pay breaks down into base pay, overtime, bonuses, etc.
|
||
#
|
||
# Base pay is reverse-engineered from the net total:
|
||
# amount_paid = base + overtime + bonus + new_loan - deduction - loan_repayment - advance
|
||
# So: base = amount_paid - overtime - bonus - new_loan + deduction + loan_repayment + advance
|
||
worker_chart_data = {}
|
||
for worker in Worker.objects.filter(active=True).order_by('name'):
|
||
months_data = []
|
||
has_any_data = False
|
||
|
||
for y, m in chart_months:
|
||
total_paid = worker_paid_lookup.get((worker.id, y, m), 0)
|
||
overtime = worker_adj_lookup.get((worker.id, y, m, 'Overtime'), 0)
|
||
bonus = worker_adj_lookup.get((worker.id, y, m, 'Bonus'), 0)
|
||
new_loan = worker_adj_lookup.get((worker.id, y, m, 'New Loan'), 0)
|
||
deduction = worker_adj_lookup.get((worker.id, y, m, 'Deduction'), 0)
|
||
loan_repayment = worker_adj_lookup.get((worker.id, y, m, 'Loan Repayment'), 0)
|
||
advance = worker_adj_lookup.get((worker.id, y, m, 'Advance Payment'), 0)
|
||
|
||
# Reverse-engineer base pay from the net total
|
||
base_pay = total_paid - overtime - bonus - new_loan + deduction + loan_repayment + advance
|
||
# Clamp to zero — a negative base can happen if adjustments exceed day-rate earnings
|
||
base_pay = max(base_pay, 0)
|
||
|
||
if total_paid > 0:
|
||
has_any_data = True
|
||
|
||
months_data.append({
|
||
'base': round(base_pay, 2),
|
||
'overtime': round(overtime, 2),
|
||
'bonus': round(bonus, 2),
|
||
'new_loan': round(new_loan, 2),
|
||
'deduction': round(deduction, 2),
|
||
'loan_repayment': round(loan_repayment, 2),
|
||
'advance': round(advance, 2),
|
||
'total': round(total_paid, 2),
|
||
})
|
||
|
||
# Only include workers who actually received at least one payment
|
||
if has_any_data:
|
||
worker_chart_data[str(worker.id)] = {
|
||
'name': worker.name,
|
||
'months': months_data,
|
||
}
|
||
|
||
# --- Loans ---
|
||
loan_filter = request.GET.get('loan_status', 'active')
|
||
if loan_filter == 'history':
|
||
loans = Loan.objects.filter(active=False).select_related('worker').order_by('-date')
|
||
else:
|
||
loans = Loan.objects.filter(active=True).select_related('worker').order_by('-date')
|
||
|
||
# Total active loan balance (always shown in analytics card, regardless of tab)
|
||
active_loans = Loan.objects.filter(active=True)
|
||
active_loans_count = active_loans.count()
|
||
active_loans_balance = active_loans.aggregate(
|
||
total=Sum('remaining_balance')
|
||
)['total'] or Decimal('0.00')
|
||
|
||
# --- Active projects and workers for modal dropdowns ---
|
||
active_projects = Project.objects.filter(active=True).order_by('name')
|
||
all_workers = Worker.objects.filter(active=True).order_by('name')
|
||
all_teams = Team.objects.filter(active=True).prefetch_related('workers').order_by('name')
|
||
|
||
# Team-workers map for auto-selecting workers when a team is picked
|
||
team_workers_map = {}
|
||
for team in all_teams:
|
||
team_workers_map[str(team.id)] = list(
|
||
team.workers.filter(active=True).values_list('id', flat=True)
|
||
)
|
||
|
||
# NOTE: Pass raw Python objects here, NOT json.dumps() strings.
|
||
# The template uses Django's |json_script filter which handles
|
||
# JSON serialization. If we pre-serialize with json.dumps(), the
|
||
# filter double-encodes the data and JavaScript receives strings
|
||
# instead of arrays/objects, which crashes the entire script.
|
||
context = {
|
||
'workers_data': workers_data,
|
||
'paid_records': paid_records,
|
||
'outstanding_total': outstanding_total,
|
||
'unpaid_wages_total': unpaid_wages_total,
|
||
'pending_adj_add_total': pending_adj_add_total,
|
||
'pending_adj_sub_total': pending_adj_sub_total,
|
||
'recent_payments_total': recent_payments_total,
|
||
'outstanding_project_costs': outstanding_project_costs,
|
||
'active_tab': status_filter,
|
||
'all_workers': all_workers,
|
||
'all_teams': all_teams,
|
||
'team_workers_map_json': team_workers_map,
|
||
'adjustment_types': PayrollAdjustment.TYPE_CHOICES,
|
||
'active_projects': active_projects,
|
||
'loans': loans,
|
||
'loan_filter': loan_filter,
|
||
'chart_labels_json': chart_labels,
|
||
'chart_totals_json': chart_totals,
|
||
'project_chart_json': project_chart_data,
|
||
'worker_chart_json': worker_chart_data,
|
||
'overtime_data_json': all_ot_data,
|
||
'today': today, # For pre-filling date fields in modals
|
||
'active_loans_count': active_loans_count,
|
||
'active_loans_balance': active_loans_balance,
|
||
}
|
||
|
||
# =========================================================================
|
||
# === ADJUSTMENTS TAB CONTEXT ===
|
||
# This block only runs when the user is on the Adjustments tab
|
||
# (i.e. the URL has ?status=adjustments). It builds a filtered, sorted,
|
||
# paginated list of adjustments plus the little stats cards above it.
|
||
#
|
||
# Group-by rendering, bulk-select, and Team->Workers cross-filter
|
||
# will be added in later tasks — this task just covers the basic data.
|
||
# =========================================================================
|
||
if status_filter == 'adjustments':
|
||
from django.core.paginator import Paginator
|
||
from django.utils.dateparse import parse_date
|
||
|
||
# --- Read the filter choices the user picked from the URL ---
|
||
# Lists come in as ?type=Bonus&type=Deduction etc.
|
||
type_filter = request.GET.getlist('type')
|
||
worker_filter = [
|
||
int(v) for v in request.GET.getlist('worker') if v.strip().isdigit()
|
||
]
|
||
team_filter = [
|
||
int(v) for v in request.GET.getlist('team') if v.strip().isdigit()
|
||
]
|
||
adj_status = request.GET.get('adj_status', '').strip()
|
||
adj_date_from = request.GET.get('adj_date_from', '').strip()
|
||
adj_date_to = request.GET.get('adj_date_to', '').strip()
|
||
sort_col = request.GET.get('sort', 'date').strip()
|
||
sort_order = request.GET.get('order', 'desc').strip()
|
||
|
||
# --- Base queryset with eager-loading of related tables ---
|
||
# select_related pulls worker/project/payment in the same SQL query
|
||
# so we don't hit the database once per row later.
|
||
adjustments = PayrollAdjustment.objects.select_related(
|
||
'worker', 'project', 'payroll_record'
|
||
).prefetch_related('worker__teams')
|
||
|
||
# --- Apply each filter only if the user actually set one ---
|
||
if type_filter:
|
||
adjustments = adjustments.filter(type__in=type_filter)
|
||
if worker_filter:
|
||
adjustments = adjustments.filter(worker_id__in=worker_filter)
|
||
if team_filter:
|
||
# SUBQUERY PATTERN (CLAUDE.md "M2M filter + aggregate inflation"):
|
||
# Joining straight on workers__teams would multiply the row count
|
||
# if a worker is on multiple teams, so we pick the matching worker
|
||
# IDs in a subquery first and then filter the outer queryset
|
||
# without any JOIN expansion.
|
||
adjustments = adjustments.filter(
|
||
worker__in=Worker.objects.filter(
|
||
teams__id__in=team_filter
|
||
).values('id')
|
||
)
|
||
if adj_status == 'unpaid':
|
||
adjustments = adjustments.filter(payroll_record__isnull=True)
|
||
elif adj_status == 'paid':
|
||
adjustments = adjustments.filter(payroll_record__isnull=False)
|
||
if adj_date_from:
|
||
parsed = parse_date(adj_date_from)
|
||
if parsed:
|
||
adjustments = adjustments.filter(date__gte=parsed)
|
||
if adj_date_to:
|
||
parsed = parse_date(adj_date_to)
|
||
if parsed:
|
||
adjustments = adjustments.filter(date__lte=parsed)
|
||
|
||
# --- Sort the results ---
|
||
# The URL's "sort" value is a short label; translate it to the
|
||
# actual database column. Unknown values fall back to date.
|
||
sort_map = {
|
||
'date': 'date',
|
||
'worker': 'worker__name',
|
||
'amount': 'amount',
|
||
'status': 'payroll_record',
|
||
}
|
||
sort_field = sort_map.get(sort_col, 'date')
|
||
if sort_order == 'desc':
|
||
sort_field = '-' + sort_field
|
||
# Secondary key "-id" keeps rows in a stable order when the
|
||
# main sort key has ties (e.g. two adjustments on the same date).
|
||
adjustments = adjustments.order_by(sort_field, '-id')
|
||
|
||
# --- Stats cards (all computed BEFORE pagination) ---
|
||
# These numbers always reflect what the current filter produces,
|
||
# not just what fits on the current page.
|
||
adj_total_count = adjustments.count()
|
||
unpaid_qs = adjustments.filter(payroll_record__isnull=True)
|
||
adj_unpaid_count = unpaid_qs.count()
|
||
adj_unpaid_sum = unpaid_qs.aggregate(
|
||
total=Sum('amount')
|
||
)['total'] or Decimal('0.00')
|
||
adj_additive_sum = adjustments.filter(
|
||
type__in=ADDITIVE_TYPES
|
||
).aggregate(total=Sum('amount'))['total'] or Decimal('0.00')
|
||
adj_deductive_sum = adjustments.filter(
|
||
type__in=DEDUCTIVE_TYPES
|
||
).aggregate(total=Sum('amount'))['total'] or Decimal('0.00')
|
||
|
||
# --- Pagination: 50 rows per page ---
|
||
paginator = Paginator(adjustments, 50)
|
||
adj_page = paginator.get_page(request.GET.get('page', 1))
|
||
|
||
# --- Group-by rendering (optional; None = flat view) ---
|
||
# When the user clicks the "By Type" or "By Worker" toggle above
|
||
# the table, we re-bucket the already-paginated rows. Empty/missing
|
||
# means the template falls back to the original flat list.
|
||
group_by = request.GET.get('group_by', '').strip()
|
||
adj_groups = None
|
||
if group_by in ('type', 'worker'):
|
||
adj_groups = _group_adjustments(adj_page.object_list, group_by)
|
||
|
||
# --- Everything the Adjustments tab template will need ---
|
||
context.update({
|
||
'adj_page': adj_page,
|
||
'adj_groups': adj_groups,
|
||
'adj_total_count': adj_total_count,
|
||
'adj_unpaid_count': adj_unpaid_count,
|
||
'adj_unpaid_sum': adj_unpaid_sum,
|
||
'adj_additive_sum': adj_additive_sum,
|
||
'adj_deductive_sum': adj_deductive_sum,
|
||
'adj_filter_values': {
|
||
'type': type_filter,
|
||
'worker': worker_filter,
|
||
'team': team_filter,
|
||
'adj_status': adj_status,
|
||
'adj_date_from': adj_date_from,
|
||
'adj_date_to': adj_date_to,
|
||
'sort': sort_col,
|
||
'order': sort_order,
|
||
'group_by': group_by,
|
||
},
|
||
# Flat list of type labels for the Adjustments tab filter dropdown.
|
||
# Stored under a separate key so we don't clobber the existing
|
||
# 'adjustment_types' context var (which is TYPE_CHOICES tuples
|
||
# used by the Add/Edit adjustment modals).
|
||
'adj_type_choices': list(ADDITIVE_TYPES) + list(DEDUCTIVE_TYPES),
|
||
'all_workers_for_filter': Worker.objects.filter(active=True).order_by('name'),
|
||
'all_teams_for_filter': Team.objects.filter(active=True).order_by('name'),
|
||
# Task 4 will use this to decide +/- signs on each row.
|
||
'additive_types': list(ADDITIVE_TYPES),
|
||
})
|
||
|
||
return render(request, 'core/payroll_dashboard.html', context)
|
||
|
||
|
||
# =============================================================================
|
||
# === SINGLE PAYMENT HELPER ===
|
||
# Core payment logic used by both individual payments and batch payments.
|
||
# Locks the worker row, creates a PayrollRecord, links logs/adjustments,
|
||
# and handles loan repayment deductions — all inside an atomic transaction.
|
||
# =============================================================================
|
||
|
||
def _process_single_payment(worker_id, selected_log_ids=None, selected_adj_ids=None):
|
||
"""
|
||
Process payment for one worker inside an atomic transaction.
|
||
Returns (payroll_record, log_count, logs_amount) on success, or None if nothing to pay.
|
||
|
||
- worker_id: the Worker's PK
|
||
- selected_log_ids: list of WorkLog IDs to include (None = all unpaid)
|
||
- selected_adj_ids: list of PayrollAdjustment IDs to include (None = all pending)
|
||
"""
|
||
with transaction.atomic():
|
||
# Lock this worker's row — any concurrent request for the same
|
||
# worker will wait here until this transaction commits.
|
||
worker = Worker.objects.select_for_update().get(id=worker_id)
|
||
|
||
# Get unpaid logs, filter to selected if IDs provided
|
||
all_unpaid_logs = worker.work_logs.exclude(payroll_records__worker=worker)
|
||
if selected_log_ids:
|
||
unpaid_logs = all_unpaid_logs.filter(id__in=selected_log_ids)
|
||
else:
|
||
unpaid_logs = all_unpaid_logs
|
||
|
||
log_count = unpaid_logs.count()
|
||
logs_amount = log_count * worker.daily_rate
|
||
|
||
# Get pending adjustments, filter to selected if IDs provided
|
||
all_pending_adjs = list(worker.adjustments.filter(payroll_record__isnull=True))
|
||
if selected_adj_ids:
|
||
selected_adj_set = set(selected_adj_ids)
|
||
pending_adjs = [a for a in all_pending_adjs if a.id in selected_adj_set]
|
||
else:
|
||
pending_adjs = all_pending_adjs
|
||
|
||
# Nothing to pay — already paid or nothing owed
|
||
if log_count == 0 and not pending_adjs:
|
||
return None
|
||
|
||
# Calculate net adjustment
|
||
adj_amount = Decimal('0.00')
|
||
for adj in pending_adjs:
|
||
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
|
||
|
||
# Create the PayrollRecord
|
||
payroll_record = PayrollRecord.objects.create(
|
||
worker=worker,
|
||
amount_paid=total_amount,
|
||
date=timezone.now().date(),
|
||
)
|
||
|
||
# Link work logs to this payment
|
||
payroll_record.work_logs.set(unpaid_logs)
|
||
|
||
# Link adjustments + handle loan repayments
|
||
for adj in pending_adjs:
|
||
adj.payroll_record = payroll_record
|
||
adj.save()
|
||
|
||
# If this is a loan or advance repayment, deduct from the balance
|
||
if adj.type in ('Loan Repayment', 'Advance Repayment') and adj.loan:
|
||
adj.loan.remaining_balance -= adj.amount
|
||
if adj.loan.remaining_balance <= 0:
|
||
adj.loan.remaining_balance = Decimal('0.00')
|
||
adj.loan.active = False
|
||
# === ADVANCE-TO-LOAN CONVERSION ===
|
||
# If an advance was only partially repaid, the remainder is
|
||
# now a regular loan. Change the type so it shows under
|
||
# "Loans" in the Loans tab and uses "Loan Repayment" going forward.
|
||
elif adj.type == 'Advance Repayment' and adj.loan.loan_type == 'advance':
|
||
adj.loan.loan_type = 'loan'
|
||
adj.loan.save()
|
||
|
||
return (payroll_record, log_count, logs_amount)
|
||
|
||
|
||
# =============================================================================
|
||
# === PROCESS PAYMENT ===
|
||
# HTTP endpoint for paying a single worker. Reads selected IDs from the POST
|
||
# form (split payslip), delegates to _process_single_payment, then emails.
|
||
# =============================================================================
|
||
|
||
@login_required
|
||
def process_payment(request, worker_id):
|
||
if request.method != 'POST':
|
||
return redirect('payroll_dashboard')
|
||
if not is_admin(request.user):
|
||
return HttpResponseForbidden("Not authorized.")
|
||
|
||
# Validate the worker exists (returns 404 if not found)
|
||
worker = get_object_or_404(Worker, id=worker_id)
|
||
|
||
# === SPLIT PAYSLIP SUPPORT ===
|
||
# If the POST includes specific log/adjustment IDs (from the preview
|
||
# modal's checkboxes), only pay those selected items.
|
||
# If no IDs provided (e.g., the quick "Pay" button on the table),
|
||
# fall back to paying everything — backward compatible.
|
||
selected_log_ids = [int(x) for x in request.POST.getlist('selected_log_ids') if x.isdigit()]
|
||
selected_adj_ids = [int(x) for x in request.POST.getlist('selected_adj_ids') if x.isdigit()]
|
||
|
||
result = _process_single_payment(
|
||
worker_id,
|
||
selected_log_ids=selected_log_ids or None,
|
||
selected_adj_ids=selected_adj_ids or None,
|
||
)
|
||
|
||
if result is None:
|
||
messages.warning(request, f'No pending payments for {worker.name} — already paid or nothing owed.')
|
||
return redirect('payroll_dashboard')
|
||
|
||
payroll_record, log_count, logs_amount = result
|
||
|
||
# =========================================================================
|
||
# EMAIL PAYSLIP (outside the transaction — if email fails, payment is
|
||
# still saved. We don't want a network error to roll back a real payment.)
|
||
# =========================================================================
|
||
_send_payslip_email(request, worker, payroll_record, log_count, logs_amount)
|
||
|
||
return redirect('payroll_dashboard')
|
||
|
||
|
||
# =============================================================================
|
||
# === PAYSLIP EMAIL HELPER ===
|
||
# Generates and sends a payslip (HTML email + PDF attachment).
|
||
# Used by both process_payment (regular salary) and add_adjustment (advances).
|
||
# =============================================================================
|
||
|
||
def _send_payslip_email(request, worker, payroll_record, log_count, logs_amount, suppress_messages=False):
|
||
"""
|
||
Generate and email a payslip for a completed payment.
|
||
Called after a PayrollRecord has been created and adjustments linked.
|
||
|
||
- request: Django request (for messages framework)
|
||
- worker: the Worker being paid
|
||
- payroll_record: the PayrollRecord just created
|
||
- log_count: number of work logs in this payment (0 for advance-only)
|
||
- logs_amount: total earnings from work logs (Decimal('0.00') for advance-only)
|
||
- suppress_messages: if True, skip Django messages (used by batch pay)
|
||
"""
|
||
# Lazy import — avoids crashing the app if xhtml2pdf isn't installed
|
||
from .utils import render_to_pdf
|
||
|
||
total_amount = payroll_record.amount_paid
|
||
|
||
# === DETECT STANDALONE PAYMENT (no work logs, single adjustment) ===
|
||
# Advance-only or Loan-only payments use a cleaner payslip layout
|
||
# showing just the amount instead of "0 days worked + adjustment".
|
||
advance_adj = None
|
||
loan_adj = None
|
||
if log_count == 0:
|
||
adjs_list = list(payroll_record.adjustments.all())
|
||
if len(adjs_list) == 1:
|
||
if adjs_list[0].type == 'Advance Payment':
|
||
advance_adj = adjs_list[0]
|
||
elif adjs_list[0].type == 'New Loan':
|
||
loan_adj = adjs_list[0]
|
||
|
||
is_advance = advance_adj is not None
|
||
is_loan = loan_adj is not None
|
||
if is_advance:
|
||
subject = f"Advance Payslip for {worker.name} - {payroll_record.date}"
|
||
elif is_loan:
|
||
subject = f"Loan Payslip for {worker.name} - {payroll_record.date}"
|
||
else:
|
||
subject = f"Payslip for {worker.name} - {payroll_record.date}"
|
||
|
||
# Context for both the HTML email body and the PDF attachment
|
||
email_context = {
|
||
'record': payroll_record,
|
||
'logs_count': log_count,
|
||
'logs_amount': logs_amount,
|
||
'adjustments': payroll_record.adjustments.all(),
|
||
'deductive_types': DEDUCTIVE_TYPES,
|
||
'is_advance': is_advance,
|
||
'advance_amount': advance_adj.amount if advance_adj else None,
|
||
'advance_description': advance_adj.description if advance_adj else '',
|
||
'is_loan': is_loan,
|
||
'loan_amount': loan_adj.amount if loan_adj else None,
|
||
'loan_description': loan_adj.description if loan_adj else '',
|
||
}
|
||
|
||
# 1. Render HTML email body
|
||
html_message = render_to_string('core/email/payslip_email.html', email_context)
|
||
plain_message = strip_tags(html_message)
|
||
|
||
# 2. Render PDF attachment (returns None if xhtml2pdf is not installed)
|
||
pdf_content = render_to_pdf('core/pdf/payslip_pdf.html', email_context)
|
||
|
||
# 3. Send email with PDF attached
|
||
recipient = getattr(settings, 'SPARK_RECEIPT_EMAIL', None)
|
||
if recipient:
|
||
try:
|
||
email = EmailMultiAlternatives(
|
||
subject,
|
||
plain_message,
|
||
settings.DEFAULT_FROM_EMAIL,
|
||
[recipient],
|
||
)
|
||
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()
|
||
if not suppress_messages:
|
||
messages.success(
|
||
request,
|
||
f'Payment of R {total_amount:,.2f} processed for {worker.name}. '
|
||
f'Payslip emailed successfully.'
|
||
)
|
||
except Exception as e:
|
||
# Payment is saved — just warn that email failed
|
||
if not suppress_messages:
|
||
messages.warning(
|
||
request,
|
||
f'Payment of R {total_amount:,.2f} processed for {worker.name}, '
|
||
f'but email delivery failed: {str(e)}'
|
||
)
|
||
raise # Re-raise so batch_pay can count failures
|
||
else:
|
||
# No SPARK_RECEIPT_EMAIL configured — just show success
|
||
if not suppress_messages:
|
||
messages.success(
|
||
request,
|
||
f'Payment of R {total_amount:,.2f} processed for {worker.name}. '
|
||
f'{log_count} work log(s) marked as paid.'
|
||
)
|
||
|
||
|
||
# =============================================================================
|
||
# === BATCH PAY PREVIEW ===
|
||
# AJAX GET endpoint — dry run showing which workers would be paid and how
|
||
# much, based on their team's pay schedule. No payments are made here.
|
||
# =============================================================================
|
||
|
||
@login_required
|
||
def batch_pay_preview(request):
|
||
"""Return JSON preview of batch payment — who gets paid and how much.
|
||
Accepts ?mode=all to skip pay-period cutoff and include ALL unpaid items.
|
||
Default mode is 'schedule' which splits at last completed pay period."""
|
||
if not is_admin(request.user):
|
||
return JsonResponse({'error': 'Not authorized'}, status=403)
|
||
|
||
# === MODE: 'schedule' (default) = split at last paydate, 'all' = pay everything ===
|
||
mode = request.GET.get('mode', 'schedule')
|
||
|
||
eligible = []
|
||
skipped = []
|
||
total_amount = Decimal('0.00')
|
||
|
||
# Get all active workers with their work logs and pending adjustments
|
||
active_workers = Worker.objects.filter(active=True).prefetch_related(
|
||
Prefetch(
|
||
'work_logs',
|
||
queryset=WorkLog.objects.prefetch_related('payroll_records').select_related('project')
|
||
),
|
||
Prefetch(
|
||
'adjustments',
|
||
queryset=PayrollAdjustment.objects.filter(payroll_record__isnull=True)
|
||
),
|
||
).order_by('name')
|
||
|
||
for worker in active_workers:
|
||
team = get_worker_active_team(worker)
|
||
|
||
# --- In 'schedule' mode, skip workers without a pay schedule ---
|
||
if mode == 'schedule':
|
||
if not team or not team.pay_frequency or not team.pay_start_date:
|
||
# Check if worker has ANY unpaid items before listing as skipped
|
||
has_unpaid = False
|
||
for log in worker.work_logs.all():
|
||
paid_ids = {pr.worker_id for pr in log.payroll_records.all()}
|
||
if worker.id not in paid_ids:
|
||
has_unpaid = True
|
||
break
|
||
if not has_unpaid:
|
||
has_unpaid = worker.adjustments.filter(payroll_record__isnull=True).exists()
|
||
|
||
if has_unpaid:
|
||
skipped.append({
|
||
'worker_name': worker.name,
|
||
'reason': 'No pay schedule configured',
|
||
})
|
||
continue
|
||
|
||
# --- Determine cutoff date (if applicable) ---
|
||
cutoff_date = None
|
||
if mode == 'schedule':
|
||
# cutoff_date = end of the last COMPLETED period.
|
||
# We pay ALL overdue work (across all past periods), not just one period.
|
||
period_start, period_end = get_pay_period(team)
|
||
if not period_start:
|
||
continue
|
||
cutoff_date = period_start - datetime.timedelta(days=1)
|
||
|
||
# --- Find unpaid logs (with or without cutoff) ---
|
||
unpaid_log_ids = []
|
||
for log in worker.work_logs.all():
|
||
paid_ids = {pr.worker_id for pr in log.payroll_records.all()}
|
||
if worker.id not in paid_ids:
|
||
# In 'all' mode: no date filter. In 'schedule' mode: only up to cutoff.
|
||
if cutoff_date is None or log.date <= cutoff_date:
|
||
unpaid_log_ids.append(log.id)
|
||
|
||
# --- Find pending adjustments (with or without cutoff) ---
|
||
unpaid_adj_ids = []
|
||
adj_amount = Decimal('0.00')
|
||
for adj in worker.adjustments.all():
|
||
if cutoff_date is None or (adj.date and adj.date <= cutoff_date):
|
||
unpaid_adj_ids.append(adj.id)
|
||
if adj.type in ADDITIVE_TYPES:
|
||
adj_amount += adj.amount
|
||
elif adj.type in DEDUCTIVE_TYPES:
|
||
adj_amount -= adj.amount
|
||
|
||
# Nothing due for this worker
|
||
if not unpaid_log_ids and not unpaid_adj_ids:
|
||
continue
|
||
|
||
log_count = len(unpaid_log_ids)
|
||
logs_amount = log_count * worker.daily_rate
|
||
net = logs_amount + adj_amount
|
||
|
||
# Skip workers with zero or negative net pay
|
||
if net <= 0:
|
||
skipped.append({
|
||
'worker_name': worker.name,
|
||
'reason': f'Net pay is R {net:,.2f} (zero or negative)',
|
||
})
|
||
continue
|
||
|
||
# --- Period display text ---
|
||
if cutoff_date:
|
||
# Use day integer to avoid platform-specific strftime issues
|
||
period_display = f"Up to {cutoff_date.day} {cutoff_date.strftime('%b %Y')}"
|
||
else:
|
||
period_display = "All unpaid"
|
||
|
||
# Check if worker has any active loans or advances
|
||
has_loan = Loan.objects.filter(worker=worker, active=True).exists()
|
||
|
||
eligible.append({
|
||
'worker_id': worker.id,
|
||
'worker_name': worker.name,
|
||
'team_name': team.name if team else '—',
|
||
'period': period_display,
|
||
'days': log_count,
|
||
'logs_amount': float(logs_amount),
|
||
'adj_amount': float(adj_amount),
|
||
'net_pay': float(net),
|
||
'log_ids': unpaid_log_ids,
|
||
'adj_ids': unpaid_adj_ids,
|
||
'has_loan': has_loan,
|
||
})
|
||
total_amount += net
|
||
|
||
return JsonResponse({
|
||
'eligible': eligible,
|
||
'skipped': skipped,
|
||
'total_amount': float(total_amount),
|
||
'worker_count': len(eligible),
|
||
'mode': mode,
|
||
})
|
||
|
||
|
||
# =============================================================================
|
||
# === BATCH PAY (PROCESS) ===
|
||
# POST endpoint — processes payments for multiple workers at once.
|
||
# Each worker gets their own atomic transaction and payslip email.
|
||
# =============================================================================
|
||
|
||
@login_required
|
||
def batch_pay(request):
|
||
"""Process batch payments for multiple workers using their team pay schedules."""
|
||
if request.method != 'POST':
|
||
return redirect('payroll_dashboard')
|
||
if not is_admin(request.user):
|
||
return HttpResponseForbidden("Not authorized.")
|
||
|
||
try:
|
||
body = json.loads(request.body)
|
||
except (json.JSONDecodeError, ValueError):
|
||
messages.error(request, 'Invalid request data.')
|
||
return redirect('payroll_dashboard')
|
||
|
||
workers_to_pay = body.get('workers', [])
|
||
if not workers_to_pay:
|
||
messages.warning(request, 'No workers selected for batch payment.')
|
||
return redirect('payroll_dashboard')
|
||
|
||
# === PROCESS EACH WORKER ===
|
||
# Each worker gets their own atomic transaction (independent row locks).
|
||
# This means if one worker fails, others still succeed.
|
||
paid_count = 0
|
||
paid_total = Decimal('0.00')
|
||
errors = []
|
||
email_queue = [] # Collect payslip data for emails (sent after all payments)
|
||
|
||
for entry in workers_to_pay:
|
||
worker_id = entry.get('worker_id')
|
||
log_ids = entry.get('log_ids', [])
|
||
adj_ids = entry.get('adj_ids', [])
|
||
|
||
try:
|
||
worker = Worker.objects.get(id=worker_id, active=True)
|
||
except Worker.DoesNotExist:
|
||
errors.append(f'Worker ID {worker_id} not found or inactive.')
|
||
continue
|
||
|
||
result = _process_single_payment(
|
||
worker_id,
|
||
selected_log_ids=log_ids or None,
|
||
selected_adj_ids=adj_ids or None,
|
||
)
|
||
|
||
if result is None:
|
||
continue # Nothing to pay — silently skip
|
||
|
||
payroll_record, log_count, logs_amount = result
|
||
paid_count += 1
|
||
paid_total += payroll_record.amount_paid
|
||
email_queue.append((worker, payroll_record, log_count, logs_amount))
|
||
|
||
# === SEND PAYSLIP EMAILS (outside all transactions) ===
|
||
# If an email fails, the payment is still saved — same pattern as individual pay.
|
||
email_failures = 0
|
||
for worker, pr, lc, la in email_queue:
|
||
try:
|
||
_send_payslip_email(request, worker, pr, lc, la, suppress_messages=True)
|
||
except Exception:
|
||
email_failures += 1
|
||
|
||
# === SUMMARY MESSAGE ===
|
||
if paid_count > 0:
|
||
msg = f'Batch payment complete: {paid_count} worker(s) paid, total R {paid_total:,.2f}.'
|
||
if email_failures:
|
||
msg += f' ({email_failures} email(s) failed to send.)'
|
||
messages.success(request, msg)
|
||
|
||
for err in errors:
|
||
messages.warning(request, err)
|
||
|
||
if paid_count == 0 and not errors:
|
||
messages.info(request, 'No payments were processed — all workers already paid or had zero/negative net pay.')
|
||
|
||
return redirect('payroll_dashboard')
|
||
|
||
|
||
# =============================================================================
|
||
# === PRICE OVERTIME ===
|
||
# Creates Overtime adjustments for workers who have unpriced overtime on
|
||
# their work logs. Called via AJAX from the Price Overtime modal.
|
||
# =============================================================================
|
||
|
||
@login_required
|
||
def price_overtime(request):
|
||
if request.method != 'POST':
|
||
return redirect('payroll_dashboard')
|
||
if not is_admin(request.user):
|
||
return HttpResponseForbidden("Not authorized.")
|
||
|
||
log_ids = request.POST.getlist('log_id[]')
|
||
worker_ids = request.POST.getlist('worker_id[]')
|
||
rate_pcts = request.POST.getlist('rate_pct[]')
|
||
|
||
created_count = 0
|
||
for log_id, w_id, pct in zip(log_ids, worker_ids, rate_pcts):
|
||
try:
|
||
worklog = WorkLog.objects.select_related('project').get(id=int(log_id))
|
||
worker = Worker.objects.get(id=int(w_id))
|
||
rate_pct = Decimal(pct)
|
||
|
||
# Calculate: daily_rate × overtime_fraction × (rate_percentage / 100)
|
||
amount = worker.daily_rate * worklog.overtime_amount * (rate_pct / Decimal('100'))
|
||
|
||
if amount > 0:
|
||
PayrollAdjustment.objects.create(
|
||
worker=worker,
|
||
type='Overtime',
|
||
amount=amount,
|
||
date=worklog.date,
|
||
description=f'Overtime ({worklog.get_overtime_amount_display()}) at {pct}% on {worklog.project.name}',
|
||
work_log=worklog,
|
||
project=worklog.project,
|
||
)
|
||
# Mark this worker as "priced" for this log's overtime
|
||
worklog.priced_workers.add(worker)
|
||
created_count += 1
|
||
except (WorkLog.DoesNotExist, Worker.DoesNotExist, Exception):
|
||
continue
|
||
|
||
messages.success(request, f'Priced {created_count} overtime adjustment(s).')
|
||
return redirect('payroll_dashboard')
|
||
|
||
|
||
# =============================================================================
|
||
# === ADD ADJUSTMENT ===
|
||
# Creates a new payroll adjustment (bonus, deduction, loan, etc.).
|
||
# Called via POST from the Add Adjustment modal.
|
||
# =============================================================================
|
||
|
||
@login_required
|
||
def add_adjustment(request):
|
||
if request.method != 'POST':
|
||
return redirect('payroll_dashboard')
|
||
if not is_admin(request.user):
|
||
return HttpResponseForbidden("Not authorized.")
|
||
|
||
worker_ids = request.POST.getlist('workers')
|
||
adj_type = request.POST.get('type', '')
|
||
amount_str = request.POST.get('amount', '0')
|
||
description = request.POST.get('description', '')
|
||
date_str = request.POST.get('date', '')
|
||
project_id = request.POST.get('project', '')
|
||
|
||
# Validate workers — at least one must be selected.
|
||
# The frontend also checks this, but this is a safety net in case
|
||
# the user has JavaScript disabled or submits via other means.
|
||
if not worker_ids:
|
||
messages.error(request, 'Please select at least one worker.')
|
||
return redirect('payroll_dashboard')
|
||
|
||
# Validate amount
|
||
try:
|
||
amount = Decimal(amount_str)
|
||
if amount <= 0:
|
||
raise ValueError
|
||
except (ValueError, Exception):
|
||
messages.error(request, 'Please enter a valid amount greater than zero.')
|
||
return redirect('payroll_dashboard')
|
||
|
||
# Validate date
|
||
try:
|
||
adj_date = datetime.datetime.strptime(date_str, '%Y-%m-%d').date() if date_str else timezone.now().date()
|
||
except ValueError:
|
||
adj_date = timezone.now().date()
|
||
|
||
# Validate project for types that require it
|
||
project = None
|
||
if project_id:
|
||
try:
|
||
project = Project.objects.get(id=int(project_id))
|
||
except Project.DoesNotExist:
|
||
pass
|
||
|
||
project_required_types = ('Overtime', 'Bonus', 'Deduction', 'Advance Payment')
|
||
if adj_type in project_required_types and not project:
|
||
messages.error(request, 'A project must be selected for this adjustment type.')
|
||
return redirect('payroll_dashboard')
|
||
|
||
created_count = 0
|
||
for w_id in worker_ids:
|
||
try:
|
||
worker = Worker.objects.get(id=int(w_id))
|
||
except Worker.DoesNotExist:
|
||
continue
|
||
|
||
loan = None
|
||
|
||
# === LOAN REPAYMENT — find the worker's active loan ===
|
||
if adj_type == 'Loan Repayment':
|
||
loan = worker.loans.filter(active=True, loan_type='loan').first()
|
||
if not loan:
|
||
messages.warning(request, f'{worker.name} has no active loan — skipped.')
|
||
continue
|
||
|
||
# === ADVANCE REPAYMENT — find the worker's active advance ===
|
||
if adj_type == 'Advance Repayment':
|
||
loan = worker.loans.filter(active=True, loan_type='advance').first()
|
||
if not loan:
|
||
messages.warning(request, f'{worker.name} has no active advance — skipped.')
|
||
continue
|
||
|
||
# === NEW LOAN — create a Loan record (loan_type='loan') ===
|
||
# If "Pay Immediately" is checked (default), the loan is processed
|
||
# right away — PayrollRecord is created, payslip emailed to Spark,
|
||
# and the adjustment is marked as paid. If unchecked, the loan sits
|
||
# in Pending Payments and is included in the next pay cycle.
|
||
if adj_type == 'New Loan':
|
||
loan = Loan.objects.create(
|
||
worker=worker,
|
||
loan_type='loan',
|
||
principal_amount=amount,
|
||
remaining_balance=amount,
|
||
date=adj_date,
|
||
reason=description,
|
||
)
|
||
|
||
pay_immediately = request.POST.get('pay_immediately') == '1'
|
||
if pay_immediately:
|
||
# Create the adjustment and immediately mark it as paid
|
||
loan_adj = PayrollAdjustment.objects.create(
|
||
worker=worker,
|
||
type='New Loan',
|
||
amount=amount,
|
||
date=adj_date,
|
||
description=description,
|
||
loan=loan,
|
||
)
|
||
payroll_record = PayrollRecord.objects.create(
|
||
worker=worker,
|
||
amount_paid=amount,
|
||
date=adj_date,
|
||
)
|
||
loan_adj.payroll_record = payroll_record
|
||
loan_adj.save()
|
||
|
||
# Send payslip email to Spark
|
||
_send_payslip_email(request, worker, payroll_record, 0, Decimal('0.00'))
|
||
created_count += 1
|
||
continue # Skip the generic PayrollAdjustment creation below
|
||
|
||
# === ADVANCE PAYMENT — immediate payment + auto-repayment ===
|
||
# An advance is a salary prepayment — worker gets money now, and
|
||
# the full amount is automatically deducted from their next salary.
|
||
# Unlike other adjustments, advances are processed IMMEDIATELY
|
||
# (they don't sit in Pending Payments waiting for a "Pay" click).
|
||
if adj_type == 'Advance Payment':
|
||
# VALIDATION: Worker must have unpaid work to justify an advance.
|
||
# If they have no logged work, this is a loan, not an advance.
|
||
has_unpaid_logs = False
|
||
for log in worker.work_logs.all():
|
||
paid_worker_ids = set(
|
||
log.payroll_records.values_list('worker_id', flat=True)
|
||
)
|
||
if worker.id not in paid_worker_ids:
|
||
has_unpaid_logs = True
|
||
break
|
||
|
||
if not has_unpaid_logs:
|
||
messages.warning(
|
||
request,
|
||
f'{worker.name} has no unpaid work days — cannot create '
|
||
f'an advance. Use "New Loan" instead.'
|
||
)
|
||
continue
|
||
|
||
# 1. Create the Loan record (tracks the advance balance)
|
||
loan = Loan.objects.create(
|
||
worker=worker,
|
||
loan_type='advance',
|
||
principal_amount=amount,
|
||
remaining_balance=amount,
|
||
date=adj_date,
|
||
reason=description or 'Salary advance',
|
||
)
|
||
|
||
# 2. Create the Advance Payment adjustment
|
||
advance_adj = PayrollAdjustment.objects.create(
|
||
worker=worker,
|
||
type='Advance Payment',
|
||
amount=amount,
|
||
date=adj_date,
|
||
description=description,
|
||
project=project,
|
||
loan=loan,
|
||
)
|
||
|
||
# 3. AUTO-PROCESS: Create PayrollRecord immediately
|
||
# (advance is paid now, not at the next payday)
|
||
payroll_record = PayrollRecord.objects.create(
|
||
worker=worker,
|
||
amount_paid=amount,
|
||
date=adj_date,
|
||
)
|
||
advance_adj.payroll_record = payroll_record
|
||
advance_adj.save()
|
||
|
||
# 4. AUTO-CREATE REPAYMENT for the next salary cycle
|
||
# This ensures the advance is automatically deducted from
|
||
# the worker's next salary without the admin having to remember.
|
||
PayrollAdjustment.objects.create(
|
||
worker=worker,
|
||
type='Advance Repayment',
|
||
amount=amount,
|
||
date=adj_date,
|
||
description=f'Auto-deduction for advance of R {amount:.2f}',
|
||
loan=loan,
|
||
project=project,
|
||
)
|
||
|
||
# 5. Send payslip email to SparkReceipt
|
||
_send_payslip_email(request, worker, payroll_record, 0, Decimal('0.00'))
|
||
created_count += 1
|
||
continue # Skip the generic PayrollAdjustment creation below
|
||
|
||
# === ALL OTHER TYPES — create a pending adjustment ===
|
||
PayrollAdjustment.objects.create(
|
||
worker=worker,
|
||
type=adj_type,
|
||
amount=amount,
|
||
date=adj_date,
|
||
description=description,
|
||
project=project,
|
||
loan=loan,
|
||
)
|
||
created_count += 1
|
||
|
||
messages.success(request, f'Created {created_count} {adj_type} adjustment(s).')
|
||
return redirect('payroll_dashboard')
|
||
|
||
|
||
# =============================================================================
|
||
# === EDIT ADJUSTMENT ===
|
||
# Updates an existing unpaid adjustment. Type changes are limited to
|
||
# Bonus ↔ Deduction swaps only.
|
||
# =============================================================================
|
||
|
||
@login_required
|
||
def edit_adjustment(request, adj_id):
|
||
if request.method != 'POST':
|
||
return redirect('payroll_dashboard')
|
||
if not is_admin(request.user):
|
||
return HttpResponseForbidden("Not authorized.")
|
||
|
||
adj = get_object_or_404(PayrollAdjustment, id=adj_id)
|
||
|
||
# Can't edit adjustments that have already been paid
|
||
if adj.payroll_record is not None:
|
||
messages.error(request, 'Cannot edit a paid adjustment.')
|
||
return redirect('payroll_dashboard')
|
||
|
||
# Can't edit Loan Repayment adjustments (managed by the loan system).
|
||
# Advance Repayments CAN be edited — the admin may want to reduce the
|
||
# auto-deduction amount (e.g., deduct R50 of a R100 advance this payday).
|
||
if adj.type == 'Loan Repayment':
|
||
messages.warning(request, 'Loan repayment adjustments cannot be edited directly.')
|
||
return redirect('payroll_dashboard')
|
||
|
||
# Update fields
|
||
try:
|
||
adj.amount = Decimal(request.POST.get('amount', str(adj.amount)))
|
||
except (ValueError, Exception):
|
||
pass
|
||
|
||
adj.description = request.POST.get('description', adj.description)
|
||
|
||
date_str = request.POST.get('date', '')
|
||
if date_str:
|
||
try:
|
||
adj.date = datetime.datetime.strptime(date_str, '%Y-%m-%d').date()
|
||
except ValueError:
|
||
pass
|
||
|
||
# Type change — only allow Bonus ↔ Deduction
|
||
new_type = request.POST.get('type', adj.type)
|
||
if adj.type in ('Bonus', 'Deduction') and new_type in ('Bonus', 'Deduction'):
|
||
adj.type = new_type
|
||
|
||
# Project
|
||
project_id = request.POST.get('project', '')
|
||
if project_id:
|
||
try:
|
||
adj.project = Project.objects.get(id=int(project_id))
|
||
except Project.DoesNotExist:
|
||
pass
|
||
else:
|
||
adj.project = None
|
||
|
||
# === ADVANCE REPAYMENT EDIT — cap amount at loan balance ===
|
||
# If the admin edits an auto-created advance repayment, make sure
|
||
# the amount doesn't exceed the loan's remaining balance.
|
||
if adj.type == 'Advance Repayment' and adj.loan:
|
||
if adj.amount > adj.loan.remaining_balance:
|
||
adj.amount = adj.loan.remaining_balance
|
||
messages.info(
|
||
request,
|
||
f'Amount capped at loan balance of R {adj.loan.remaining_balance:.2f}.'
|
||
)
|
||
|
||
adj.save()
|
||
|
||
# If it's a Loan or Advance adjustment, sync the loan details
|
||
if adj.type in ('New Loan', 'Advance Payment') and adj.loan:
|
||
adj.loan.principal_amount = adj.amount
|
||
adj.loan.remaining_balance = adj.amount
|
||
adj.loan.reason = adj.description
|
||
adj.loan.save()
|
||
|
||
messages.success(request, f'{adj.type} adjustment updated.')
|
||
return redirect('payroll_dashboard')
|
||
|
||
|
||
# =============================================================================
|
||
# === DELETE ADJUSTMENT ===
|
||
# Removes an unpaid adjustment. Handles cascade logic for Loans and Overtime.
|
||
# =============================================================================
|
||
|
||
@login_required
|
||
def delete_adjustment(request, adj_id):
|
||
if request.method != 'POST':
|
||
return redirect('payroll_dashboard')
|
||
if not is_admin(request.user):
|
||
return HttpResponseForbidden("Not authorized.")
|
||
|
||
adj = get_object_or_404(PayrollAdjustment, id=adj_id)
|
||
|
||
# Can't delete adjustments that have been paid
|
||
if adj.payroll_record is not None:
|
||
messages.error(request, 'Cannot delete a paid adjustment.')
|
||
return redirect('payroll_dashboard')
|
||
|
||
adj_type = adj.type
|
||
worker_name = adj.worker.name
|
||
|
||
# === CASCADE DELETE for New Loan and Advance Payment ===
|
||
# Both create Loan records that need cleanup when deleted.
|
||
if adj_type in ('New Loan', 'Advance Payment') and adj.loan:
|
||
# Determine which repayment type to look for
|
||
repayment_type = 'Advance Repayment' if adj_type == 'Advance Payment' else 'Loan Repayment'
|
||
|
||
# Check if any paid repayments exist for this loan/advance
|
||
paid_repayments = PayrollAdjustment.objects.filter(
|
||
loan=adj.loan,
|
||
type=repayment_type,
|
||
payroll_record__isnull=False,
|
||
)
|
||
if paid_repayments.exists():
|
||
label = 'advance' if adj_type == 'Advance Payment' else 'loan'
|
||
messages.error(
|
||
request,
|
||
f'Cannot delete {label} for {worker_name} — it has paid repayments.'
|
||
)
|
||
return redirect('payroll_dashboard')
|
||
|
||
# Delete all unpaid repayments for this loan/advance, then the loan itself
|
||
PayrollAdjustment.objects.filter(
|
||
loan=adj.loan,
|
||
type=repayment_type,
|
||
payroll_record__isnull=True,
|
||
).delete()
|
||
adj.loan.delete()
|
||
|
||
elif adj_type == 'Overtime' and adj.work_log:
|
||
# "Un-price" the overtime — remove worker from priced_workers M2M
|
||
adj.work_log.priced_workers.remove(adj.worker)
|
||
|
||
adj.delete()
|
||
messages.success(request, f'{adj_type} adjustment for {worker_name} deleted.')
|
||
return redirect('payroll_dashboard')
|
||
|
||
|
||
# =============================================================================
|
||
# === PREVIEW PAYSLIP (AJAX) ===
|
||
# Returns a JSON preview of what a worker's payslip would look like.
|
||
# Called from the Preview Payslip modal without saving anything.
|
||
# =============================================================================
|
||
|
||
@login_required
|
||
def preview_payslip(request, worker_id):
|
||
if not is_admin(request.user):
|
||
return JsonResponse({'error': 'Not authorized'}, status=403)
|
||
|
||
worker = get_object_or_404(Worker, id=worker_id)
|
||
|
||
# Find unpaid logs — include the log ID so the frontend can send
|
||
# selected IDs back for split payslip (selective payment).
|
||
unpaid_logs = []
|
||
for log in worker.work_logs.select_related('project').prefetch_related('payroll_records').all():
|
||
paid_worker_ids = {pr.worker_id for pr in log.payroll_records.all()}
|
||
if worker.id not in paid_worker_ids:
|
||
unpaid_logs.append({
|
||
'id': log.id,
|
||
'date': log.date.strftime('%Y-%m-%d'),
|
||
'project': log.project.name,
|
||
})
|
||
|
||
# Sort logs by date so the split makes visual sense (oldest first)
|
||
unpaid_logs.sort(key=lambda x: x['date'])
|
||
|
||
log_count = len(unpaid_logs)
|
||
log_amount = float(log_count * worker.daily_rate)
|
||
|
||
# Find pending adjustments — include ID and date for split payslip
|
||
pending_adjs = worker.adjustments.filter(
|
||
payroll_record__isnull=True
|
||
).select_related('project')
|
||
|
||
adjustments_list = []
|
||
adj_total = 0.0
|
||
for adj in pending_adjs:
|
||
sign = '+' if adj.type in ADDITIVE_TYPES else '-'
|
||
adj_total += float(adj.amount) if adj.type in ADDITIVE_TYPES else -float(adj.amount)
|
||
adjustments_list.append({
|
||
'id': adj.id,
|
||
'type': adj.type,
|
||
'amount': float(adj.amount),
|
||
'sign': sign,
|
||
'description': adj.description,
|
||
'project': adj.project.name if adj.project else '',
|
||
'date': adj.date.strftime('%Y-%m-%d'),
|
||
})
|
||
|
||
# === ACTIVE LOANS & ADVANCES ===
|
||
# Include the worker's outstanding balances so the admin can see the
|
||
# full picture and add repayments directly from the preview modal.
|
||
active_loans = worker.loans.filter(active=True).order_by('-date')
|
||
loans_list = []
|
||
for loan in active_loans:
|
||
loans_list.append({
|
||
'id': loan.id,
|
||
'type': loan.loan_type, # 'loan' or 'advance'
|
||
'type_label': loan.get_loan_type_display(), # 'Loan' or 'Advance'
|
||
'principal': float(loan.principal_amount),
|
||
'balance': float(loan.remaining_balance),
|
||
'date': loan.date.strftime('%Y-%m-%d'),
|
||
'reason': loan.reason or '',
|
||
})
|
||
|
||
# === PAY PERIOD INFO ===
|
||
# If the worker belongs to a team with a pay schedule, include the
|
||
# current period boundaries so the "Split at Pay Date" button can work.
|
||
team = get_worker_active_team(worker)
|
||
period_start, period_end = get_pay_period(team)
|
||
|
||
# cutoff_date = last day of the most recently COMPLETED pay period.
|
||
# All unpaid logs on or before this date are "due" for payment.
|
||
# E.g., fortnightly periods ending Mar 14, Mar 28, Apr 11...
|
||
# If today is Mar 20, cutoff_date = Mar 14 (pay everything through last completed period).
|
||
cutoff_date = (period_start - datetime.timedelta(days=1)) if period_start else None
|
||
|
||
pay_period = {
|
||
'has_schedule': period_start is not None,
|
||
'start': period_start.strftime('%Y-%m-%d') if period_start else None,
|
||
'end': period_end.strftime('%Y-%m-%d') if period_end else None,
|
||
'cutoff_date': cutoff_date.strftime('%Y-%m-%d') if cutoff_date else None,
|
||
'frequency': team.pay_frequency if team else None,
|
||
'team_name': team.name if team else None,
|
||
}
|
||
|
||
return JsonResponse({
|
||
'worker_id': worker.id,
|
||
'worker_name': worker.name,
|
||
'worker_id_number': worker.id_number,
|
||
'day_rate': float(worker.daily_rate),
|
||
'days_worked': log_count,
|
||
'log_amount': log_amount,
|
||
'adjustments': adjustments_list,
|
||
'adj_total': adj_total,
|
||
'net_pay': log_amount + adj_total,
|
||
'logs': unpaid_logs,
|
||
'active_loans': loans_list,
|
||
'pay_period': pay_period,
|
||
})
|
||
|
||
|
||
# =============================================================================
|
||
# === WORKER LOOKUP (AJAX) ===
|
||
# Returns a comprehensive financial report card for a single worker.
|
||
# Called via AJAX GET from the Worker Lookup modal on the payroll dashboard.
|
||
# Shows: amount payable, outstanding loans, recent payments, active loans,
|
||
# current project, PPE sizing, drivers license, and notes.
|
||
# =============================================================================
|
||
|
||
@login_required
|
||
def worker_lookup_ajax(request, worker_id):
|
||
"""AJAX endpoint — returns a comprehensive financial report card for a worker."""
|
||
if not is_admin(request.user):
|
||
return JsonResponse({'error': 'Not authorized'}, status=403)
|
||
|
||
worker = get_object_or_404(Worker, id=worker_id)
|
||
today = timezone.now().date()
|
||
|
||
# === AMOUNT PAYABLE ===
|
||
# Same logic as preview_payslip: find unpaid work logs for this worker
|
||
# A log is "unpaid" if no PayrollRecord links both this log and this worker
|
||
unpaid_log_count = 0
|
||
for log in worker.work_logs.prefetch_related('payroll_records').all():
|
||
paid_worker_ids = {pr.worker_id for pr in log.payroll_records.all()}
|
||
if worker.id not in paid_worker_ids:
|
||
unpaid_log_count += 1
|
||
|
||
log_amount = float(unpaid_log_count * worker.daily_rate)
|
||
|
||
# Net adjustment total: additive types increase pay, deductive types decrease it
|
||
pending_adjs = worker.adjustments.filter(payroll_record__isnull=True)
|
||
adj_total = 0.0
|
||
for adj in pending_adjs:
|
||
if adj.type in ADDITIVE_TYPES:
|
||
adj_total += float(adj.amount)
|
||
elif adj.type in DEDUCTIVE_TYPES:
|
||
adj_total -= float(adj.amount)
|
||
|
||
amount_payable = log_amount + adj_total
|
||
|
||
# === OUTSTANDING LOANS ===
|
||
# Total remaining balance across all active loans and advances
|
||
active_loans = worker.loans.filter(active=True).order_by('-date')
|
||
outstanding_loans = float(
|
||
active_loans.aggregate(total=Sum('remaining_balance'))['total'] or 0
|
||
)
|
||
|
||
# === PAID THIS MONTH ===
|
||
# Sum of all PayrollRecord amounts in the current calendar month
|
||
paid_this_month = float(PayrollRecord.objects.filter(
|
||
worker=worker, date__year=today.year, date__month=today.month
|
||
).aggregate(total=Sum('amount_paid'))['total'] or 0)
|
||
|
||
# === LOANS THIS YEAR ===
|
||
# Total principal of all loans issued to this worker in the current year
|
||
loans_this_year = float(Loan.objects.filter(
|
||
worker=worker, date__year=today.year
|
||
).aggregate(total=Sum('principal_amount'))['total'] or 0)
|
||
|
||
# === PAID THIS YEAR ===
|
||
# Sum of all PayrollRecord amounts in the current year
|
||
paid_this_year = float(PayrollRecord.objects.filter(
|
||
worker=worker, date__year=today.year
|
||
).aggregate(total=Sum('amount_paid'))['total'] or 0)
|
||
|
||
# === RECENT ACTIVITY ===
|
||
# Most recent of each type — used to show "last payslip", "last loan", etc.
|
||
last_payslip = PayrollRecord.objects.filter(
|
||
worker=worker).order_by('-date').first()
|
||
|
||
last_loan = Loan.objects.filter(
|
||
worker=worker).order_by('-date').first()
|
||
|
||
last_repayment = PayrollAdjustment.objects.filter(
|
||
worker=worker, type='Loan Repayment',
|
||
payroll_record__isnull=False).order_by('-date').first()
|
||
|
||
last_advance = PayrollAdjustment.objects.filter(
|
||
worker=worker, type='Advance Payment',
|
||
payroll_record__isnull=False).order_by('-date').first()
|
||
|
||
# === CURRENT PROJECT ===
|
||
# The project from the worker's most recent work log, plus how many
|
||
# days they've worked on that project in total
|
||
latest_log = worker.work_logs.select_related('project').order_by('-date').first()
|
||
current_project = None
|
||
days_on_project = 0
|
||
if latest_log and latest_log.project:
|
||
current_project = latest_log.project.name
|
||
days_on_project = worker.work_logs.filter(project=latest_log.project).count()
|
||
|
||
# === TEAM ===
|
||
team = get_worker_active_team(worker)
|
||
|
||
# === ACTIVE LOANS LIST ===
|
||
# Full details for the loans table in the modal
|
||
loans_list = []
|
||
for loan in active_loans:
|
||
loans_list.append({
|
||
'type': loan.get_loan_type_display(),
|
||
'principal': float(loan.principal_amount),
|
||
'balance': float(loan.remaining_balance),
|
||
'date': loan.date.strftime('%Y-%m-%d'),
|
||
'reason': loan.reason or '',
|
||
})
|
||
|
||
return JsonResponse({
|
||
# Identity
|
||
'worker_id': worker.id,
|
||
'name': worker.name,
|
||
'id_number': worker.id_number,
|
||
'phone': worker.phone_number,
|
||
'employment_date': worker.employment_date.strftime('%Y-%m-%d') if worker.employment_date else '',
|
||
'team': team.name if team else '',
|
||
'current_project': current_project or '',
|
||
'days_on_project': days_on_project,
|
||
|
||
# Quick stats (4 cards)
|
||
'amount_payable': amount_payable,
|
||
'outstanding_loans': outstanding_loans,
|
||
'paid_this_month': paid_this_month,
|
||
'loans_this_year': loans_this_year,
|
||
'paid_this_year': paid_this_year,
|
||
|
||
# Recent activity
|
||
'last_payslip': {
|
||
'date': last_payslip.date.strftime('%Y-%m-%d'),
|
||
'amount': float(last_payslip.amount_paid),
|
||
} if last_payslip else None,
|
||
'last_loan': {
|
||
'date': last_loan.date.strftime('%Y-%m-%d'),
|
||
'amount': float(last_loan.principal_amount),
|
||
'reason': last_loan.reason or '',
|
||
} if last_loan else None,
|
||
'last_repayment': {
|
||
'date': last_repayment.date.strftime('%Y-%m-%d'),
|
||
'amount': float(last_repayment.amount),
|
||
} if last_repayment else None,
|
||
'last_advance': {
|
||
'date': last_advance.date.strftime('%Y-%m-%d'),
|
||
'amount': float(last_advance.amount),
|
||
} if last_advance else None,
|
||
|
||
# Active loans table
|
||
'active_loans': loans_list,
|
||
|
||
# Sizing & info
|
||
'shoe_size': worker.shoe_size,
|
||
'overall_top_size': worker.overall_top_size,
|
||
'pants_size': worker.pants_size,
|
||
'tshirt_size': worker.tshirt_size,
|
||
'has_drivers_license': worker.has_drivers_license,
|
||
'notes': worker.notes,
|
||
})
|
||
|
||
|
||
# =============================================================================
|
||
# === ADD REPAYMENT (AJAX) ===
|
||
# Creates a Loan Repayment or Advance Repayment adjustment for a single worker.
|
||
# Called via AJAX POST from the Payslip Preview modal's inline repayment form.
|
||
# Returns JSON so the modal can refresh in-place without a page reload.
|
||
# =============================================================================
|
||
|
||
@login_required
|
||
def add_repayment_ajax(request, worker_id):
|
||
"""AJAX endpoint: add a repayment adjustment and return JSON response."""
|
||
if request.method != 'POST':
|
||
return JsonResponse({'error': 'POST required'}, status=405)
|
||
if not is_admin(request.user):
|
||
return JsonResponse({'error': 'Not authorized'}, status=403)
|
||
|
||
worker = get_object_or_404(Worker, id=worker_id)
|
||
|
||
# Parse the POST body (sent as JSON from fetch())
|
||
try:
|
||
body = json.loads(request.body)
|
||
except json.JSONDecodeError:
|
||
return JsonResponse({'error': 'Invalid JSON'}, status=400)
|
||
|
||
loan_id = body.get('loan_id')
|
||
amount_str = body.get('amount', '0')
|
||
description = body.get('description', '')
|
||
|
||
# Validate: loan exists, belongs to this worker, and is active
|
||
try:
|
||
loan = Loan.objects.get(id=int(loan_id), worker=worker, active=True)
|
||
except (Loan.DoesNotExist, ValueError, TypeError):
|
||
return JsonResponse({'error': 'No active loan/advance found.'}, status=400)
|
||
|
||
# Validate: amount is positive
|
||
try:
|
||
amount = Decimal(str(amount_str))
|
||
if amount <= 0:
|
||
raise ValueError
|
||
except (ValueError, Exception):
|
||
return JsonResponse({'error': 'Please enter a valid amount greater than zero.'}, status=400)
|
||
|
||
# Cap the repayment at the remaining balance (prevent over-repaying)
|
||
if amount > loan.remaining_balance:
|
||
amount = loan.remaining_balance
|
||
|
||
# Pick the right repayment type based on loan type
|
||
repayment_type = 'Advance Repayment' if loan.loan_type == 'advance' else 'Loan Repayment'
|
||
|
||
# Create the adjustment (balance deduction happens later during process_payment)
|
||
PayrollAdjustment.objects.create(
|
||
worker=worker,
|
||
type=repayment_type,
|
||
amount=amount,
|
||
date=timezone.now().date(),
|
||
description=description or f'{loan.get_loan_type_display()} repayment',
|
||
loan=loan,
|
||
)
|
||
|
||
return JsonResponse({
|
||
'success': True,
|
||
'message': f'{repayment_type} of R {amount:.2f} added for {worker.name}.',
|
||
})
|
||
|
||
|
||
# =============================================================================
|
||
# === PAYSLIP DETAIL ===
|
||
# Shows a completed payment (PayrollRecord) as a printable payslip page.
|
||
# Displays: worker details, work log table, adjustments table, totals.
|
||
# Reached from the "Payment History" tab on the payroll dashboard.
|
||
# =============================================================================
|
||
|
||
@login_required
|
||
def payslip_detail(request, pk):
|
||
"""Show a completed payslip with work logs, adjustments, and totals."""
|
||
if not is_admin(request.user):
|
||
return redirect('payroll_dashboard')
|
||
|
||
record = get_object_or_404(PayrollRecord, pk=pk)
|
||
|
||
# Get the work logs included in this payment
|
||
logs = record.work_logs.select_related('project').order_by('date')
|
||
|
||
# Get the adjustments linked to this payment
|
||
adjustments = record.adjustments.all().order_by('type')
|
||
|
||
# Calculate base pay from logs
|
||
# Each log = 1 day of work at the worker's daily rate
|
||
base_pay = record.worker.daily_rate * logs.count()
|
||
|
||
# Calculate net adjustment amount (additive minus deductive)
|
||
adjustments_net = record.amount_paid - base_pay
|
||
|
||
# === DETECT STANDALONE PAYMENT (no work logs, single adjustment) ===
|
||
# Advance-only or Loan-only payments use a cleaner layout.
|
||
adjs_list = list(adjustments)
|
||
advance_adj = None
|
||
loan_adj = None
|
||
if logs.count() == 0 and len(adjs_list) == 1:
|
||
if adjs_list[0].type == 'Advance Payment':
|
||
advance_adj = adjs_list[0]
|
||
elif adjs_list[0].type == 'New Loan':
|
||
loan_adj = adjs_list[0]
|
||
|
||
context = {
|
||
'record': record,
|
||
'logs': logs,
|
||
'adjustments': adjustments,
|
||
'base_pay': base_pay,
|
||
'adjustments_net': adjustments_net,
|
||
'adjustments_net_abs': abs(adjustments_net),
|
||
'deductive_types': DEDUCTIVE_TYPES,
|
||
'is_advance': advance_adj is not None,
|
||
'advance_adj': advance_adj,
|
||
'is_loan': loan_adj is not None,
|
||
'loan_adj': loan_adj,
|
||
}
|
||
return render(request, 'core/payslip.html', context)
|
||
|
||
|
||
# =============================================================================
|
||
# === CREATE EXPENSE RECEIPT ===
|
||
# Single-page form for recording business expenses.
|
||
# Supports dynamic line items (products + amounts) and VAT calculation.
|
||
# On save: emails an HTML + PDF receipt to Spark Receipt for accounting.
|
||
# =============================================================================
|
||
|
||
@login_required
|
||
def create_receipt(request):
|
||
"""Create a new expense receipt and email it to Spark Receipt."""
|
||
if not is_staff_or_supervisor(request.user):
|
||
return redirect('home')
|
||
|
||
if request.method == 'POST':
|
||
form = ExpenseReceiptForm(request.POST)
|
||
items = ExpenseLineItemFormSet(request.POST)
|
||
|
||
if form.is_valid() and items.is_valid():
|
||
# Save the receipt header (but don't commit yet — need to set user)
|
||
receipt = form.save(commit=False)
|
||
receipt.user = request.user
|
||
# Set temporary zero values so the first save doesn't fail.
|
||
# (subtotal and total_amount have no default in the model,
|
||
# so they'd be NULL — which MariaDB rejects.)
|
||
# We'll recalculate these properly after saving line items.
|
||
receipt.subtotal = Decimal('0.00')
|
||
receipt.vat_amount = Decimal('0.00')
|
||
receipt.total_amount = Decimal('0.00')
|
||
receipt.save()
|
||
|
||
# Save line items — link them to this receipt
|
||
items.instance = receipt
|
||
line_items = items.save()
|
||
|
||
# === BACKEND VAT CALCULATION ===
|
||
# The frontend shows live totals, but we recalculate on the server
|
||
# using Python Decimal for accuracy (no floating-point rounding errors).
|
||
sum_amount = sum(item.amount for item in line_items)
|
||
vat_type = receipt.vat_type
|
||
|
||
if vat_type == 'Included':
|
||
# "VAT Included" means the entered amounts already include 15% VAT.
|
||
# To find the pre-VAT subtotal: divide by 1.15
|
||
# Example: R100 entered → Subtotal R86.96, VAT R13.04, Total R100
|
||
receipt.total_amount = sum_amount
|
||
receipt.subtotal = (sum_amount / Decimal('1.15')).quantize(Decimal('0.01'))
|
||
receipt.vat_amount = receipt.total_amount - receipt.subtotal
|
||
elif vat_type == 'Excluded':
|
||
# "VAT Excluded" means the entered amounts are pre-VAT.
|
||
# Add 15% on top for the total.
|
||
# Example: R100 entered → Subtotal R100, VAT R15, Total R115
|
||
receipt.subtotal = sum_amount
|
||
receipt.vat_amount = (sum_amount * Decimal('0.15')).quantize(Decimal('0.01'))
|
||
receipt.total_amount = receipt.subtotal + receipt.vat_amount
|
||
else:
|
||
# "None" — no VAT applies
|
||
receipt.subtotal = sum_amount
|
||
receipt.vat_amount = Decimal('0.00')
|
||
receipt.total_amount = sum_amount
|
||
|
||
receipt.save()
|
||
|
||
# =================================================================
|
||
# EMAIL RECEIPT (same pattern as payslip email)
|
||
# If email fails, the receipt is still saved.
|
||
# =================================================================
|
||
|
||
# Lazy import — avoids crashing the app if xhtml2pdf isn't installed
|
||
from .utils import render_to_pdf
|
||
|
||
subject = f"Receipt from {receipt.vendor_name} - {receipt.date}"
|
||
email_context = {
|
||
'receipt': receipt,
|
||
'items': line_items,
|
||
}
|
||
|
||
# 1. Render HTML email body
|
||
html_message = render_to_string(
|
||
'core/email/receipt_email.html', email_context
|
||
)
|
||
plain_message = strip_tags(html_message)
|
||
|
||
# 2. Render PDF attachment (returns None if xhtml2pdf is not installed)
|
||
pdf_content = render_to_pdf(
|
||
'core/pdf/receipt_pdf.html', email_context
|
||
)
|
||
|
||
# 3. Send email with PDF attached
|
||
recipient = getattr(settings, 'SPARK_RECEIPT_EMAIL', None)
|
||
if recipient:
|
||
try:
|
||
email = EmailMultiAlternatives(
|
||
subject,
|
||
plain_message,
|
||
settings.DEFAULT_FROM_EMAIL,
|
||
[recipient],
|
||
)
|
||
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.'
|
||
)
|
||
except Exception as e:
|
||
messages.warning(
|
||
request,
|
||
f'Receipt saved, but email failed: {str(e)}'
|
||
)
|
||
else:
|
||
messages.success(request, 'Receipt saved successfully.')
|
||
|
||
# Redirect back to a blank form for the next receipt
|
||
return redirect('create_receipt')
|
||
|
||
else:
|
||
# GET request — show a blank form with today's date
|
||
form = ExpenseReceiptForm(initial={'date': timezone.now().date()})
|
||
items = ExpenseLineItemFormSet()
|
||
|
||
return render(request, 'core/create_receipt.html', {
|
||
'form': form,
|
||
'items': items,
|
||
})
|
||
|
||
|
||
# =============================================================================
|
||
# === IMPORT DATA (TEMPORARY) ===
|
||
# Runs the import_production_data command from the browser.
|
||
# Visit /import-data/ once to populate the database. Safe to re-run.
|
||
# REMOVE THIS VIEW once data is imported.
|
||
# =============================================================================
|
||
|
||
def import_data(request):
|
||
"""Runs the import_production_data management command from the browser."""
|
||
from django.core.management import call_command
|
||
from io import StringIO
|
||
|
||
output = StringIO()
|
||
try:
|
||
call_command('import_production_data', stdout=output)
|
||
result = output.getvalue()
|
||
lines = result.replace('\n', '<br>')
|
||
return HttpResponse(
|
||
'<html><body style="font-family: monospace; padding: 20px;">'
|
||
'<h2>Import Complete!</h2>'
|
||
'<div>' + lines + '</div>'
|
||
'<br><br>'
|
||
'<a href="/admin/">Go to Admin Panel</a> | '
|
||
'<a href="/payroll/">Go to Payroll Dashboard</a> | '
|
||
'<a href="/">Go to Dashboard</a>'
|
||
'</body></html>'
|
||
)
|
||
except Exception as e:
|
||
return HttpResponse(
|
||
'<html><body style="font-family: monospace; padding: 20px; color: red;">'
|
||
'<h2>Import Error</h2>'
|
||
'<pre>' + str(e) + '</pre>'
|
||
'</body></html>',
|
||
status=500,
|
||
)
|
||
|
||
|
||
# =============================================================================
|
||
# === RUN MIGRATIONS ===
|
||
# Runs pending database migrations from the browser. Useful when Flatlogic's
|
||
# "Pull Latest" doesn't automatically run migrations after a code update.
|
||
# Visit /run-migrate/ to apply any pending migrations to the production DB.
|
||
# =============================================================================
|
||
|
||
def run_migrate(request):
|
||
"""Runs Django migrate from the browser to apply pending migrations."""
|
||
from django.core.management import call_command
|
||
from io import StringIO
|
||
|
||
output = StringIO()
|
||
try:
|
||
call_command('migrate', stdout=output)
|
||
result = output.getvalue()
|
||
lines = result.replace('\n', '<br>')
|
||
return HttpResponse(
|
||
'<html><body style="font-family: monospace; padding: 20px;">'
|
||
'<h2>Migrations Complete!</h2>'
|
||
'<div>' + lines + '</div>'
|
||
'<br><br>'
|
||
'<a href="/">Go to Dashboard</a> | '
|
||
'<a href="/payroll/">Go to Payroll Dashboard</a>'
|
||
'</body></html>'
|
||
)
|
||
except Exception as e:
|
||
return HttpResponse(
|
||
'<html><body style="font-family: monospace; padding: 20px; color: red;">'
|
||
'<h2>Migration Error</h2>'
|
||
'<pre>' + str(e) + '</pre>'
|
||
'</body></html>',
|
||
status=500,
|
||
)
|
||
|
||
|
||
# === BACKUP / RESTORE (browser-accessible, admin-only) ===
|
||
# Flatlogic has no shell/SSH — admins need to backup and restore via browser.
|
||
# These views wrap the `backup_data` and `restore_data` management commands
|
||
# and render a minimal HTML UI. Safe to leave in place in production.
|
||
|
||
@login_required
|
||
def backup_data(request):
|
||
"""Download the complete app data as a timestamped JSON file.
|
||
|
||
Admin-only. Serves the backup as a browser download so it lands
|
||
safely on the admin's laptop rather than the server filesystem
|
||
(which is ephemeral on Flatlogic).
|
||
"""
|
||
if not is_admin(request.user):
|
||
return HttpResponseForbidden("Admin access required.")
|
||
|
||
from core.management.commands.backup_data import build_backup_payload
|
||
|
||
json_str, summary = build_backup_payload()
|
||
filename = f'foxlog_backup_{datetime.datetime.now().strftime("%Y%m%d_%H%M%S")}.json'
|
||
|
||
response = HttpResponse(json_str, content_type='application/json')
|
||
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
||
return response
|
||
|
||
|
||
@login_required
|
||
def restore_data(request):
|
||
"""Upload a .json backup to restore it into the current database.
|
||
|
||
GET → renders a minimal upload form + warning
|
||
POST → accepts the file, validates, and loads it (inside a transaction)
|
||
|
||
Admin-only. Requires explicit `confirm=yes` POST field to proceed,
|
||
so a stray click can't wipe production.
|
||
"""
|
||
if not is_admin(request.user):
|
||
return HttpResponseForbidden("Admin access required.")
|
||
|
||
from core.management.commands.restore_data import (
|
||
check_database_is_populated, restore_from_json_string,
|
||
)
|
||
|
||
db_has_data = check_database_is_populated()
|
||
|
||
if request.method == 'POST':
|
||
if request.POST.get('confirm') != 'yes':
|
||
return HttpResponse(
|
||
'<html><body style="font-family: monospace; padding: 20px; color: red;">'
|
||
'<h2>Restore cancelled</h2>'
|
||
'<p>You must tick the "Yes, I understand" checkbox to proceed.</p>'
|
||
'<a href="/restore-data/">Back</a></body></html>',
|
||
status=400,
|
||
)
|
||
|
||
uploaded = request.FILES.get('backup_file')
|
||
if not uploaded:
|
||
return HttpResponse(
|
||
'<html><body style="font-family: monospace; padding: 20px; color: red;">'
|
||
'<h2>No file uploaded</h2>'
|
||
'<a href="/restore-data/">Back</a></body></html>',
|
||
status=400,
|
||
)
|
||
|
||
json_str = uploaded.read().decode('utf-8', errors='replace')
|
||
ok, result = restore_from_json_string(json_str)
|
||
|
||
if not ok:
|
||
return HttpResponse(
|
||
'<html><body style="font-family: monospace; padding: 20px; color: red;">'
|
||
'<h2>Restore failed</h2>'
|
||
f'<pre>{result}</pre>'
|
||
'<a href="/restore-data/">Back</a></body></html>',
|
||
status=500,
|
||
)
|
||
|
||
rows_html = '<br>'.join(f'{k}: {v}' for k, v in result.items())
|
||
return HttpResponse(
|
||
'<html><body style="font-family: monospace; padding: 20px;">'
|
||
'<h2 style="color: #10b981;">Restore complete!</h2>'
|
||
f'<div>{rows_html}</div><br><br>'
|
||
'<a href="/">Go to Dashboard</a></body></html>'
|
||
)
|
||
|
||
# GET — render the upload form
|
||
warning_html = ''
|
||
if db_has_data:
|
||
warning_html = (
|
||
'<p style="color: #e8851a; border-left: 3px solid #e8851a; padding-left: 10px;">'
|
||
'<strong>⚠️ Warning:</strong> this database already contains data '
|
||
'(workers / work logs / payroll records). Restoring will UPDATE existing rows '
|
||
'by primary key and INSERT missing ones. This will NOT delete data that exists '
|
||
'in the DB but not in the backup. If you want a clean restore, run '
|
||
'<code>python manage.py flush</code> first (irreversible).'
|
||
'</p>'
|
||
)
|
||
|
||
return HttpResponse(
|
||
'<html><body style="font-family: monospace; padding: 20px; max-width: 700px;">'
|
||
'<h2>Restore from backup</h2>'
|
||
+ warning_html +
|
||
'<form method="post" enctype="multipart/form-data">'
|
||
f'<input type="hidden" name="csrfmiddlewaretoken" value="{get_token(request)}">'
|
||
'<p><label>Backup JSON file:<br>'
|
||
'<input type="file" name="backup_file" accept="application/json" required></label></p>'
|
||
'<p><label><input type="checkbox" name="confirm" value="yes" required> '
|
||
'Yes, I understand this will overwrite matching rows in the database.</label></p>'
|
||
'<p><button type="submit" style="padding: 10px 20px; background: #e8851a; '
|
||
'color: white; border: none; border-radius: 4px; cursor: pointer;">'
|
||
'Restore</button>'
|
||
' <a href="/" style="margin-left: 10px;">Cancel</a></p>'
|
||
'</form></body></html>'
|
||
)
|