ver 8 loans added
This commit is contained in:
parent
fa07cb69a8
commit
99fb2f4e10
Binary file not shown.
Binary file not shown.
Binary file not shown.
40
core/migrations/0005_loan_payrolladjustment.py
Normal file
40
core/migrations/0005_loan_payrolladjustment.py
Normal file
@ -0,0 +1,40 @@
|
||||
# Generated by Django 5.2.7 on 2026-02-03 23:09
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0004_payrollrecord'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Loan',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('amount', models.DecimalField(decimal_places=2, help_text='Principal amount borrowed', max_digits=10)),
|
||||
('balance', models.DecimalField(decimal_places=2, default=0, help_text='Remaining amount to be repaid', max_digits=10)),
|
||||
('date', models.DateField(default=django.utils.timezone.now)),
|
||||
('reason', models.TextField(blank=True)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('worker', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='loans', to='core.worker')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PayrollAdjustment',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('amount', models.DecimalField(decimal_places=2, help_text='Positive adds to pay, negative subtracts (except for Loan Repayment which is auto-handled)', max_digits=10)),
|
||||
('date', models.DateField(default=django.utils.timezone.now)),
|
||||
('description', models.CharField(max_length=255)),
|
||||
('type', models.CharField(choices=[('BONUS', 'Bonus'), ('OVERTIME', 'Overtime'), ('DEDUCTION', 'Deduction'), ('LOAN_REPAYMENT', 'Loan Repayment')], default='DEDUCTION', max_length=20)),
|
||||
('loan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='repayments', to='core.loan')),
|
||||
('payroll_record', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='adjustments', to='core.payrollrecord')),
|
||||
('worker', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='adjustments', to='core.worker')),
|
||||
],
|
||||
),
|
||||
]
|
||||
18
core/migrations/0006_alter_payrolladjustment_type.py
Normal file
18
core/migrations/0006_alter_payrolladjustment_type.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2026-02-03 23:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0005_loan_payrolladjustment'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='payrolladjustment',
|
||||
name='type',
|
||||
field=models.CharField(choices=[('BONUS', 'Bonus'), ('OVERTIME', 'Overtime'), ('DEDUCTION', 'Deduction'), ('LOAN_REPAYMENT', 'Loan Repayment'), ('LOAN', 'New Loan')], default='DEDUCTION', max_length=20),
|
||||
),
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
@ -66,3 +66,39 @@ class PayrollRecord(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return f"Payment to {self.worker.name} on {self.date}"
|
||||
|
||||
class Loan(models.Model):
|
||||
worker = models.ForeignKey(Worker, on_delete=models.CASCADE, related_name='loans')
|
||||
amount = models.DecimalField(max_digits=10, decimal_places=2, help_text="Principal amount borrowed")
|
||||
balance = models.DecimalField(max_digits=10, decimal_places=2, default=0, help_text="Remaining amount to be repaid")
|
||||
date = models.DateField(default=timezone.now)
|
||||
reason = models.TextField(blank=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.pk: # On creation
|
||||
self.balance = self.amount
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"Loan for {self.worker.name} - R{self.amount}"
|
||||
|
||||
class PayrollAdjustment(models.Model):
|
||||
ADJUSTMENT_TYPES = [
|
||||
('BONUS', 'Bonus'),
|
||||
('OVERTIME', 'Overtime'),
|
||||
('DEDUCTION', 'Deduction'),
|
||||
('LOAN_REPAYMENT', 'Loan Repayment'),
|
||||
('LOAN', 'New Loan'),
|
||||
]
|
||||
|
||||
worker = models.ForeignKey(Worker, on_delete=models.CASCADE, related_name='adjustments')
|
||||
payroll_record = models.ForeignKey(PayrollRecord, on_delete=models.SET_NULL, null=True, blank=True, related_name='adjustments')
|
||||
loan = models.ForeignKey(Loan, on_delete=models.SET_NULL, null=True, blank=True, related_name='repayments')
|
||||
amount = models.DecimalField(max_digits=10, decimal_places=2, help_text="Positive adds to pay, negative subtracts (except for Loan Repayment which is auto-handled)")
|
||||
date = models.DateField(default=timezone.now)
|
||||
description = models.CharField(max_length=255)
|
||||
type = models.CharField(max_length=20, choices=ADJUSTMENT_TYPES, default='DEDUCTION')
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_type_display()} - {self.amount} for {self.worker.name}"
|
||||
@ -34,6 +34,7 @@
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'log_attendance' %}">Log Work</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'work_log_list' %}">History</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'payroll_dashboard' %}">Payroll</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'loan_list' %}">Loans</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'manage_resources' %}">Manage</a></li>
|
||||
<li class="nav-item ms-lg-3"><a class="btn btn-sm btn-outline-light" href="/admin/">Admin Panel</a></li>
|
||||
</ul>
|
||||
|
||||
116
core/templates/core/loan_list.html
Normal file
116
core/templates/core/loan_list.html
Normal file
@ -0,0 +1,116 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Loan Management - Fox Fitt{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="fw-bold mb-0">Loan Management</h2>
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addLoanModal">
|
||||
+ New Loan
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-body p-2">
|
||||
<ul class="nav nav-pills nav-fill">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if filter_status == 'active' %}active{% endif %}" href="?status=active">Outstanding Loans</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if filter_status == 'history' %}active{% endif %}" href="?status=history">Loan History (Repaid)</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loan Table -->
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th class="ps-4">Date Issued</th>
|
||||
<th>Worker</th>
|
||||
<th>Original Amount</th>
|
||||
<th>Balance (Outstanding)</th>
|
||||
<th>Reason</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for loan in loans %}
|
||||
<tr>
|
||||
<td class="ps-4 text-nowrap">{{ loan.date|date:"M d, Y" }}</td>
|
||||
<td class="fw-medium">{{ loan.worker.name }}</td>
|
||||
<td>R {{ loan.amount }}</td>
|
||||
<td class="fw-bold {% if loan.balance > 0 %}text-danger{% else %}text-success{% endif %}">
|
||||
R {{ loan.balance }}
|
||||
</td>
|
||||
<td><small class="text-muted">{{ loan.reason|default:"-" }}</small></td>
|
||||
<td>
|
||||
{% if loan.is_active %}
|
||||
<span class="badge bg-warning text-dark">Active</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success">Repaid</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-center py-5 text-muted">
|
||||
No loans found in this category.
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Loan Modal -->
|
||||
<div class="modal fade" id="addLoanModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title fw-bold">Issue New Loan</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<form action="{% url 'add_loan' %}" method="POST">
|
||||
{% csrf_token %}
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Worker</label>
|
||||
<select name="worker" class="form-select" required>
|
||||
<option value="">Select a worker...</option>
|
||||
{% for worker in workers %}
|
||||
<option value="{{ worker.id }}">{{ worker.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Amount (R)</label>
|
||||
<input type="number" name="amount" class="form-control" step="0.01" min="1" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Date Issued</label>
|
||||
<input type="date" name="date" class="form-control" value="{% now 'Y-m-d' %}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Reason / Notes</label>
|
||||
<textarea name="reason" class="form-control" rows="2"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save Loan</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -7,6 +7,12 @@
|
||||
<div class="container py-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h2 fw-bold text-dark">Payroll Dashboard</h1>
|
||||
<div>
|
||||
<a href="{% url 'loan_list' %}" class="btn btn-outline-secondary me-2">Manage Loans</a>
|
||||
<button class="btn btn-outline-primary" data-bs-toggle="modal" data-bs-target="#addAdjustmentModal">
|
||||
+ Add Adjustment
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Analytics Section -->
|
||||
@ -17,7 +23,7 @@
|
||||
<div class="card-body">
|
||||
<h6 class="text-uppercase text-muted fw-bold small">Outstanding Payments</h6>
|
||||
<div class="display-6 fw-bold text-dark">R {{ outstanding_total|intcomma }}</div>
|
||||
<p class="text-muted small mb-0">Total pending for active workers</p>
|
||||
<p class="text-muted small mb-0">Total pending (including adjustments)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -79,8 +85,8 @@
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th class="ps-4">Worker Name</th>
|
||||
<th>Unpaid Logs</th>
|
||||
<th>Total Owed</th>
|
||||
<th>Breakdown</th>
|
||||
<th>Net Payable</th>
|
||||
<th class="text-end pe-4">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -90,20 +96,39 @@
|
||||
<td class="ps-4">
|
||||
<div class="fw-bold text-dark">{{ item.worker.name }}</div>
|
||||
<div class="small text-muted">ID: {{ item.worker.id_no }}</div>
|
||||
{% if item.adjustments %}
|
||||
<div class="mt-1">
|
||||
{% for adj in item.adjustments %}
|
||||
<span class="badge bg-secondary opacity-75 small">
|
||||
{{ adj.get_type_display }}: R {{ adj.amount }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-secondary">{{ item.unpaid_count }} days</span>
|
||||
<div class="small">
|
||||
<div>Work: {{ item.unpaid_count }} days (R {{ item.unpaid_amount|intcomma }})</div>
|
||||
<div class="{% if item.adj_amount < 0 %}text-danger{% else %}text-success{% endif %}">
|
||||
Adjustments: R {{ item.adj_amount|intcomma }}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="fw-bold text-success">
|
||||
R {{ item.unpaid_amount|intcomma }}
|
||||
<td class="fw-bold fs-5 {% if item.total_payable < 0 %}text-danger{% else %}text-success{% endif %}">
|
||||
R {{ item.total_payable|intcomma }}
|
||||
</td>
|
||||
<td class="text-end pe-4">
|
||||
{% if item.total_payable > 0 %}
|
||||
<form action="{% url 'process_payment' item.worker.id %}" method="post">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-sm btn-success" onclick="return confirm('Confirm payment of R {{ item.unpaid_amount }} to {{ item.worker.name }}? This will email the receipt to accounting.')">
|
||||
<button type="submit" class="btn btn-sm btn-success"
|
||||
onclick="return confirm('Confirm payment of R {{ item.total_payable }} to {{ item.worker.name }}? This will email the receipt.')">
|
||||
Pay Now
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<button class="btn btn-sm btn-secondary" disabled>Nothing to Pay</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@ -138,7 +163,7 @@
|
||||
<th class="ps-4">Date</th>
|
||||
<th>Payslip ID</th>
|
||||
<th>Worker</th>
|
||||
<th>Amount</th>
|
||||
<th>Net Amount</th>
|
||||
<th class="text-end pe-4">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -172,4 +197,55 @@
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Add Adjustment Modal -->
|
||||
<div class="modal fade" id="addAdjustmentModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title fw-bold">Add Payroll Adjustment</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<form action="{% url 'add_adjustment' %}" method="POST">
|
||||
{% csrf_token %}
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Worker</label>
|
||||
<select name="worker" class="form-select" required>
|
||||
<option value="">Select a worker...</option>
|
||||
{% for worker in all_workers %}
|
||||
<option value="{{ worker.id }}">{{ worker.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Type</label>
|
||||
<select name="type" class="form-select" required>
|
||||
{% for code, label in adjustment_types %}
|
||||
<option value="{{ code }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Amount (R)</label>
|
||||
<input type="number" name="amount" class="form-control" step="0.01" min="1" required>
|
||||
<div class="form-text">For deductions, enter a positive number. It will be subtracted automatically.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<input type="text" name="description" class="form-control" placeholder="e.g. Public Holiday Bonus" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Date</label>
|
||||
<input type="date" name="date" class="form-control" value="{% now 'Y-m-d' %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save Adjustment</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -41,9 +41,9 @@
|
||||
</div>
|
||||
|
||||
<!-- Work Details -->
|
||||
<h6 class="text-uppercase text-muted fw-bold small mb-3">Work Log Details</h6>
|
||||
<h6 class="text-uppercase text-muted fw-bold small mb-3">Work Log Details (Attendance)</h6>
|
||||
<div class="table-responsive mb-4">
|
||||
<table class="table table-bordered">
|
||||
<table class="table table-bordered mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
@ -60,17 +60,84 @@
|
||||
<td>{{ log.notes|default:"-"|truncatechars:50 }}</td>
|
||||
<td class="text-end">R {{ record.worker.day_rate|intcomma }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center text-muted">No work logs in this period.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot class="table-light">
|
||||
<tr>
|
||||
<td colspan="3" class="text-end fw-bold">Total</td>
|
||||
<td class="text-end fw-bold">R {{ record.amount|intcomma }}</td>
|
||||
<td colspan="3" class="text-end fw-bold">Base Pay Subtotal</td>
|
||||
<td class="text-end fw-bold">R {{ base_pay|intcomma }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Adjustments -->
|
||||
{% if adjustments %}
|
||||
<h6 class="text-uppercase text-muted fw-bold small mb-3 mt-4">Adjustments (Bonuses, Deductions, Loans)</h6>
|
||||
<div class="table-responsive mb-4">
|
||||
<table class="table table-bordered mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
<th class="text-end">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for adj in adjustments %}
|
||||
<tr>
|
||||
<td>{{ adj.date|date:"M d, Y" }}</td>
|
||||
<td>
|
||||
<span class="badge bg-secondary text-uppercase">{{ adj.get_type_display }}</span>
|
||||
</td>
|
||||
<td>{{ adj.description }}</td>
|
||||
<td class="text-end {% if adj.type == 'DEDUCTION' or adj.type == 'LOAN_REPAYMENT' %}text-danger{% else %}text-success{% endif %}">
|
||||
{% if adj.type == 'DEDUCTION' or adj.type == 'LOAN_REPAYMENT' %}
|
||||
- R {{ adj.amount|intcomma }}
|
||||
{% else %}
|
||||
+ R {{ adj.amount|intcomma }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Grand Total -->
|
||||
<div class="row justify-content-end mt-4">
|
||||
<div class="col-md-5">
|
||||
<table class="table table-sm border-0">
|
||||
<tr>
|
||||
<td class="text-end border-0 text-muted">Base Pay:</td>
|
||||
<td class="text-end border-0" width="120">R {{ base_pay|intcomma }}</td>
|
||||
</tr>
|
||||
{% if adjustments %}
|
||||
<tr>
|
||||
<td class="text-end border-0 text-muted">Adjustments Net:</td>
|
||||
<td class="text-end border-0">
|
||||
{% if record.amount >= base_pay %}
|
||||
+ R {{ record.amount|sub:base_pay|intcomma }}
|
||||
{% else %}
|
||||
- R {{ base_pay|sub:record.amount|intcomma }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr class="border-top border-dark">
|
||||
<td class="text-end border-0 fw-bold fs-5">Net Payable:</td>
|
||||
<td class="text-end border-0 fw-bold fs-5">R {{ record.amount|intcomma }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="text-center text-muted small mt-5 pt-4 border-top">
|
||||
<p>This is a computer-generated document and does not require a signature.</p>
|
||||
|
||||
@ -8,7 +8,10 @@ from .views import (
|
||||
toggle_resource_status,
|
||||
payroll_dashboard,
|
||||
process_payment,
|
||||
payslip_detail
|
||||
payslip_detail,
|
||||
loan_list,
|
||||
add_loan,
|
||||
add_adjustment
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
@ -21,4 +24,7 @@ urlpatterns = [
|
||||
path("payroll/", payroll_dashboard, name="payroll_dashboard"),
|
||||
path("payroll/pay/<int:worker_id>/", process_payment, name="process_payment"),
|
||||
path("payroll/payslip/<int:pk>/", payslip_detail, name="payslip_detail"),
|
||||
path("loans/", loan_list, name="loan_list"),
|
||||
path("loans/add/", add_loan, name="add_loan"),
|
||||
path("payroll/adjustment/add/", add_adjustment, name="add_adjustment"),
|
||||
]
|
||||
178
core/views.py
178
core/views.py
@ -10,9 +10,10 @@ from django.core.mail import send_mail
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.http import JsonResponse, HttpResponse
|
||||
from .models import Worker, Project, Team, WorkLog, PayrollRecord
|
||||
from .models import Worker, Project, Team, WorkLog, PayrollRecord, Loan, PayrollAdjustment
|
||||
from .forms import WorkLogForm
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
def home(request):
|
||||
"""Render the landing screen with dashboard stats."""
|
||||
@ -22,7 +23,7 @@ def home(request):
|
||||
recent_logs = WorkLog.objects.order_by('-date')[:5]
|
||||
|
||||
# Analytics
|
||||
# 1. Outstanding Payments
|
||||
# 1. Outstanding Payments (Approximate, from logs only)
|
||||
outstanding_total = 0
|
||||
active_workers = Worker.objects.filter(is_active=True)
|
||||
for worker in active_workers:
|
||||
@ -317,22 +318,40 @@ def payroll_dashboard(request):
|
||||
|
||||
# Common Analytics
|
||||
outstanding_total = 0
|
||||
active_workers = Worker.objects.filter(is_active=True).order_by('name')
|
||||
active_workers = Worker.objects.filter(is_active=True).order_by('name').prefetch_related('adjustments')
|
||||
|
||||
workers_data = [] # For pending payments
|
||||
|
||||
for worker in active_workers:
|
||||
# Unpaid Work Logs
|
||||
unpaid_logs = worker.work_logs.exclude(paid_in__worker=worker)
|
||||
count = unpaid_logs.count()
|
||||
amount = count * worker.day_rate
|
||||
log_count = unpaid_logs.count()
|
||||
log_amount = log_count * worker.day_rate
|
||||
|
||||
# Pending Adjustments (unlinked to any payroll record)
|
||||
pending_adjustments = worker.adjustments.filter(payroll_record__isnull=True)
|
||||
adj_total = Decimal('0.00')
|
||||
for adj in pending_adjustments:
|
||||
if adj.type in ['BONUS', 'OVERTIME', 'LOAN']:
|
||||
adj_total += adj.amount
|
||||
elif adj.type in ['DEDUCTION', 'LOAN_REPAYMENT']:
|
||||
adj_total -= adj.amount
|
||||
|
||||
total_payable = log_amount + adj_total
|
||||
|
||||
# Only show if there is something to pay or negative (e.g. loan repayment greater than work)
|
||||
# Note: If total_payable is negative, it implies they owe money.
|
||||
if log_count > 0 or pending_adjustments.exists():
|
||||
outstanding_total += max(total_payable, Decimal('0.00')) # Only count positive payable for grand total
|
||||
|
||||
if count > 0:
|
||||
outstanding_total += amount
|
||||
if status_filter in ['pending', 'all']:
|
||||
workers_data.append({
|
||||
'worker': worker,
|
||||
'unpaid_count': count,
|
||||
'unpaid_amount': amount,
|
||||
'unpaid_count': log_count,
|
||||
'unpaid_amount': log_amount,
|
||||
'adj_amount': adj_total,
|
||||
'total_payable': total_payable,
|
||||
'adjustments': pending_adjustments,
|
||||
'logs': unpaid_logs
|
||||
})
|
||||
|
||||
@ -358,6 +377,9 @@ def payroll_dashboard(request):
|
||||
two_months_ago = timezone.now().date() - timedelta(days=60)
|
||||
recent_payments_total = PayrollRecord.objects.filter(date__gte=two_months_ago).aggregate(total=Sum('amount'))['total'] or 0
|
||||
|
||||
# Active Loans for dropdowns/modals
|
||||
all_workers = Worker.objects.filter(is_active=True).order_by('name')
|
||||
|
||||
context = {
|
||||
'workers_data': workers_data,
|
||||
'paid_records': paid_records,
|
||||
@ -365,30 +387,57 @@ def payroll_dashboard(request):
|
||||
'project_costs': project_costs,
|
||||
'recent_payments_total': recent_payments_total,
|
||||
'active_tab': status_filter,
|
||||
'all_workers': all_workers,
|
||||
'adjustment_types': PayrollAdjustment.ADJUSTMENT_TYPES,
|
||||
}
|
||||
return render(request, 'core/payroll_dashboard.html', context)
|
||||
|
||||
def process_payment(request, worker_id):
|
||||
"""Process payment for a worker, mark logs as paid, and email receipt."""
|
||||
"""Process payment for a worker, mark logs as paid, link adjustments, and email receipt."""
|
||||
worker = get_object_or_404(Worker, pk=worker_id)
|
||||
|
||||
if request.method == 'POST':
|
||||
# Find unpaid logs
|
||||
unpaid_logs = worker.work_logs.exclude(paid_in__worker=worker)
|
||||
count = unpaid_logs.count()
|
||||
log_count = unpaid_logs.count()
|
||||
logs_amount = log_count * worker.day_rate
|
||||
|
||||
if count > 0:
|
||||
amount = count * worker.day_rate
|
||||
# Find pending adjustments
|
||||
pending_adjustments = worker.adjustments.filter(payroll_record__isnull=True)
|
||||
adj_amount = Decimal('0.00')
|
||||
|
||||
for adj in pending_adjustments:
|
||||
if adj.type in ['BONUS', 'OVERTIME', 'LOAN']:
|
||||
adj_amount += adj.amount
|
||||
elif adj.type in ['DEDUCTION', 'LOAN_REPAYMENT']:
|
||||
adj_amount -= adj.amount
|
||||
|
||||
total_amount = logs_amount + adj_amount
|
||||
|
||||
if log_count > 0 or pending_adjustments.exists():
|
||||
# Create Payroll Record
|
||||
payroll_record = PayrollRecord.objects.create(
|
||||
worker=worker,
|
||||
amount=amount,
|
||||
amount=total_amount,
|
||||
date=timezone.now().date()
|
||||
)
|
||||
|
||||
# Link logs
|
||||
payroll_record.work_logs.set(unpaid_logs)
|
||||
|
||||
# Link Adjustments and Handle Loans
|
||||
for adj in pending_adjustments:
|
||||
adj.payroll_record = payroll_record
|
||||
adj.save()
|
||||
|
||||
# Update Loan Balance if it's a repayment
|
||||
if adj.type == 'LOAN_REPAYMENT' and adj.loan:
|
||||
adj.loan.balance -= adj.amount
|
||||
if adj.loan.balance <= 0:
|
||||
adj.loan.balance = 0
|
||||
adj.loan.is_active = False
|
||||
adj.loan.save()
|
||||
|
||||
payroll_record.save()
|
||||
|
||||
# Email Notification
|
||||
@ -397,16 +446,18 @@ def process_payment(request, worker_id):
|
||||
f"Payslip Generated\n\n"
|
||||
f"Record ID: #{payroll_record.id}\n"
|
||||
f"Worker: {worker.name}\n"
|
||||
f"ID Number: {worker.id_no}\n"
|
||||
f"Date: {payroll_record.date}\n"
|
||||
f"Amount Paid: R {payroll_record.amount}\n\n"
|
||||
f"This is an automated notification from Fox Fitt Payroll."
|
||||
f"Total Paid: R {payroll_record.amount}\n\n"
|
||||
f"Breakdown:\n"
|
||||
f"Base Pay ({log_count} days): R {logs_amount}\n"
|
||||
f"Adjustments: R {adj_amount}\n\n"
|
||||
f"This is an automated notification."
|
||||
)
|
||||
recipient_list = ['foxfitt-ed9wc+expense@to.sparkreceipt.com']
|
||||
|
||||
try:
|
||||
send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, recipient_list)
|
||||
messages.success(request, f"Payment of R {payroll_record.amount} processed for {worker.name}. Email sent to accounting.")
|
||||
messages.success(request, f"Payment processed for {worker.name}. Net Pay: R {payroll_record.amount}")
|
||||
except Exception as e:
|
||||
messages.warning(request, f"Payment processed, but email delivery failed: {str(e)}")
|
||||
|
||||
@ -420,9 +471,100 @@ def payslip_detail(request, pk):
|
||||
|
||||
# Get the logs included in this payment
|
||||
logs = record.work_logs.all().order_by('date')
|
||||
adjustments = record.adjustments.all().order_by('type')
|
||||
|
||||
# Calculate base pay from logs (re-verify logic)
|
||||
# The record.amount is the final NET.
|
||||
# We can reconstruct the display.
|
||||
base_pay = sum(w.day_rate for l in logs for w in l.workers.all() if w == record.worker)
|
||||
adjustments_net = record.amount - base_pay
|
||||
|
||||
context = {
|
||||
'record': record,
|
||||
'logs': logs,
|
||||
'adjustments': adjustments,
|
||||
'base_pay': base_pay,
|
||||
'adjustments_net': adjustments_net,
|
||||
}
|
||||
return render(request, 'core/payslip.html', context)
|
||||
|
||||
def loan_list(request):
|
||||
"""List outstanding and historical loans."""
|
||||
filter_status = request.GET.get('status', 'active') # active, history
|
||||
|
||||
if filter_status == 'history':
|
||||
loans = Loan.objects.filter(is_active=False).order_by('-date')
|
||||
else:
|
||||
loans = Loan.objects.filter(is_active=True).order_by('-date')
|
||||
|
||||
context = {
|
||||
'loans': loans,
|
||||
'filter_status': filter_status,
|
||||
'workers': Worker.objects.filter(is_active=True).order_by('name'), # For modal
|
||||
}
|
||||
return render(request, 'core/loan_list.html', context)
|
||||
|
||||
def add_loan(request):
|
||||
"""Create a new loan."""
|
||||
if request.method == 'POST':
|
||||
worker_id = request.POST.get('worker')
|
||||
amount = request.POST.get('amount')
|
||||
reason = request.POST.get('reason')
|
||||
date = request.POST.get('date') or timezone.now().date()
|
||||
|
||||
if worker_id and amount:
|
||||
worker = get_object_or_404(Worker, pk=worker_id)
|
||||
Loan.objects.create(
|
||||
worker=worker,
|
||||
amount=amount,
|
||||
date=date,
|
||||
reason=reason
|
||||
)
|
||||
messages.success(request, f"Loan of R{amount} recorded for {worker.name}.")
|
||||
|
||||
return redirect('loan_list')
|
||||
|
||||
def add_adjustment(request):
|
||||
"""Add a payroll adjustment (Bonus, Overtime, Deduction, Repayment)."""
|
||||
if request.method == 'POST':
|
||||
worker_id = request.POST.get('worker')
|
||||
adj_type = request.POST.get('type')
|
||||
amount = request.POST.get('amount')
|
||||
description = request.POST.get('description')
|
||||
date = request.POST.get('date') or timezone.now().date()
|
||||
loan_id = request.POST.get('loan_id') # Optional, for repayments
|
||||
|
||||
if worker_id and amount and adj_type:
|
||||
worker = get_object_or_404(Worker, pk=worker_id)
|
||||
|
||||
# Validation for repayment OR Creation for New Loan
|
||||
loan = None
|
||||
if adj_type == 'LOAN_REPAYMENT':
|
||||
if loan_id:
|
||||
loan = get_object_or_404(Loan, pk=loan_id)
|
||||
else:
|
||||
# Try to find an active loan
|
||||
loan = worker.loans.filter(is_active=True).first()
|
||||
if not loan:
|
||||
messages.warning(request, f"Cannot add repayment: {worker.name} has no active loans.")
|
||||
return redirect('payroll_dashboard')
|
||||
elif adj_type == 'LOAN':
|
||||
# Create the Loan object tracking the debt
|
||||
loan = Loan.objects.create(
|
||||
worker=worker,
|
||||
amount=amount,
|
||||
date=date,
|
||||
reason=description
|
||||
)
|
||||
|
||||
PayrollAdjustment.objects.create(
|
||||
worker=worker,
|
||||
type=adj_type,
|
||||
amount=amount,
|
||||
description=description,
|
||||
date=date,
|
||||
loan=loan
|
||||
)
|
||||
messages.success(request, f"{adj_type} of R{amount} added for {worker.name}.")
|
||||
|
||||
return redirect('payroll_dashboard')
|
||||
Loading…
x
Reference in New Issue
Block a user