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):
|
def __str__(self):
|
||||||
return f"Payment to {self.worker.name} on {self.date}"
|
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 '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 '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 '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"><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>
|
<li class="nav-item ms-lg-3"><a class="btn btn-sm btn-outline-light" href="/admin/">Admin Panel</a></li>
|
||||||
</ul>
|
</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="container py-5">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h1 class="h2 fw-bold text-dark">Payroll Dashboard</h1>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Analytics Section -->
|
<!-- Analytics Section -->
|
||||||
@ -17,7 +23,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h6 class="text-uppercase text-muted fw-bold small">Outstanding Payments</h6>
|
<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>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -79,8 +85,8 @@
|
|||||||
<thead class="bg-light">
|
<thead class="bg-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="ps-4">Worker Name</th>
|
<th class="ps-4">Worker Name</th>
|
||||||
<th>Unpaid Logs</th>
|
<th>Breakdown</th>
|
||||||
<th>Total Owed</th>
|
<th>Net Payable</th>
|
||||||
<th class="text-end pe-4">Action</th>
|
<th class="text-end pe-4">Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -90,20 +96,39 @@
|
|||||||
<td class="ps-4">
|
<td class="ps-4">
|
||||||
<div class="fw-bold text-dark">{{ item.worker.name }}</div>
|
<div class="fw-bold text-dark">{{ item.worker.name }}</div>
|
||||||
<div class="small text-muted">ID: {{ item.worker.id_no }}</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>
|
||||||
<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>
|
||||||
<td class="fw-bold text-success">
|
<td class="fw-bold fs-5 {% if item.total_payable < 0 %}text-danger{% else %}text-success{% endif %}">
|
||||||
R {{ item.unpaid_amount|intcomma }}
|
R {{ item.total_payable|intcomma }}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end pe-4">
|
<td class="text-end pe-4">
|
||||||
|
{% if item.total_payable > 0 %}
|
||||||
<form action="{% url 'process_payment' item.worker.id %}" method="post">
|
<form action="{% url 'process_payment' item.worker.id %}" method="post">
|
||||||
{% csrf_token %}
|
{% 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
|
Pay Now
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<button class="btn btn-sm btn-secondary" disabled>Nothing to Pay</button>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@ -138,7 +163,7 @@
|
|||||||
<th class="ps-4">Date</th>
|
<th class="ps-4">Date</th>
|
||||||
<th>Payslip ID</th>
|
<th>Payslip ID</th>
|
||||||
<th>Worker</th>
|
<th>Worker</th>
|
||||||
<th>Amount</th>
|
<th>Net Amount</th>
|
||||||
<th class="text-end pe-4">Action</th>
|
<th class="text-end pe-4">Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -172,4 +197,55 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
|
||||||
|
<!-- 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>
|
</div>
|
||||||
|
|
||||||
<!-- Work Details -->
|
<!-- 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">
|
<div class="table-responsive mb-4">
|
||||||
<table class="table table-bordered">
|
<table class="table table-bordered mb-0">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
@ -60,17 +60,84 @@
|
|||||||
<td>{{ log.notes|default:"-"|truncatechars:50 }}</td>
|
<td>{{ log.notes|default:"-"|truncatechars:50 }}</td>
|
||||||
<td class="text-end">R {{ record.worker.day_rate|intcomma }}</td>
|
<td class="text-end">R {{ record.worker.day_rate|intcomma }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="text-center text-muted">No work logs in this period.</td>
|
||||||
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot class="table-light">
|
<tfoot class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="3" class="text-end fw-bold">Total</td>
|
<td colspan="3" class="text-end fw-bold">Base Pay Subtotal</td>
|
||||||
<td class="text-end fw-bold">R {{ record.amount|intcomma }}</td>
|
<td class="text-end fw-bold">R {{ base_pay|intcomma }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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 -->
|
<!-- Footer -->
|
||||||
<div class="text-center text-muted small mt-5 pt-4 border-top">
|
<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>
|
<p>This is a computer-generated document and does not require a signature.</p>
|
||||||
@ -79,4 +146,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
10
core/urls.py
10
core/urls.py
@ -8,7 +8,10 @@ from .views import (
|
|||||||
toggle_resource_status,
|
toggle_resource_status,
|
||||||
payroll_dashboard,
|
payroll_dashboard,
|
||||||
process_payment,
|
process_payment,
|
||||||
payslip_detail
|
payslip_detail,
|
||||||
|
loan_list,
|
||||||
|
add_loan,
|
||||||
|
add_adjustment
|
||||||
)
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
@ -21,4 +24,7 @@ urlpatterns = [
|
|||||||
path("payroll/", payroll_dashboard, name="payroll_dashboard"),
|
path("payroll/", payroll_dashboard, name="payroll_dashboard"),
|
||||||
path("payroll/pay/<int:worker_id>/", process_payment, name="process_payment"),
|
path("payroll/pay/<int:worker_id>/", process_payment, name="process_payment"),
|
||||||
path("payroll/payslip/<int:pk>/", payslip_detail, name="payslip_detail"),
|
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"),
|
||||||
|
]
|
||||||
|
|||||||
180
core/views.py
180
core/views.py
@ -10,9 +10,10 @@ from django.core.mail import send_mail
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.http import JsonResponse, HttpResponse
|
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 .forms import WorkLogForm
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
def home(request):
|
def home(request):
|
||||||
"""Render the landing screen with dashboard stats."""
|
"""Render the landing screen with dashboard stats."""
|
||||||
@ -22,7 +23,7 @@ def home(request):
|
|||||||
recent_logs = WorkLog.objects.order_by('-date')[:5]
|
recent_logs = WorkLog.objects.order_by('-date')[:5]
|
||||||
|
|
||||||
# Analytics
|
# Analytics
|
||||||
# 1. Outstanding Payments
|
# 1. Outstanding Payments (Approximate, from logs only)
|
||||||
outstanding_total = 0
|
outstanding_total = 0
|
||||||
active_workers = Worker.objects.filter(is_active=True)
|
active_workers = Worker.objects.filter(is_active=True)
|
||||||
for worker in active_workers:
|
for worker in active_workers:
|
||||||
@ -317,22 +318,40 @@ def payroll_dashboard(request):
|
|||||||
|
|
||||||
# Common Analytics
|
# Common Analytics
|
||||||
outstanding_total = 0
|
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
|
workers_data = [] # For pending payments
|
||||||
|
|
||||||
for worker in active_workers:
|
for worker in active_workers:
|
||||||
|
# Unpaid Work Logs
|
||||||
unpaid_logs = worker.work_logs.exclude(paid_in__worker=worker)
|
unpaid_logs = worker.work_logs.exclude(paid_in__worker=worker)
|
||||||
count = unpaid_logs.count()
|
log_count = unpaid_logs.count()
|
||||||
amount = count * worker.day_rate
|
log_amount = log_count * worker.day_rate
|
||||||
|
|
||||||
if count > 0:
|
# Pending Adjustments (unlinked to any payroll record)
|
||||||
outstanding_total += amount
|
pending_adjustments = worker.adjustments.filter(payroll_record__isnull=True)
|
||||||
|
adj_total = Decimal('0.00')
|
||||||
|
for adj in pending_adjustments:
|
||||||
|
if adj.type in ['BONUS', 'OVERTIME', 'LOAN']:
|
||||||
|
adj_total += adj.amount
|
||||||
|
elif adj.type in ['DEDUCTION', 'LOAN_REPAYMENT']:
|
||||||
|
adj_total -= adj.amount
|
||||||
|
|
||||||
|
total_payable = log_amount + adj_total
|
||||||
|
|
||||||
|
# Only show if there is something to pay or negative (e.g. loan repayment greater than work)
|
||||||
|
# Note: If total_payable is negative, it implies they owe money.
|
||||||
|
if log_count > 0 or pending_adjustments.exists():
|
||||||
|
outstanding_total += max(total_payable, Decimal('0.00')) # Only count positive payable for grand total
|
||||||
|
|
||||||
if status_filter in ['pending', 'all']:
|
if status_filter in ['pending', 'all']:
|
||||||
workers_data.append({
|
workers_data.append({
|
||||||
'worker': worker,
|
'worker': worker,
|
||||||
'unpaid_count': count,
|
'unpaid_count': log_count,
|
||||||
'unpaid_amount': amount,
|
'unpaid_amount': log_amount,
|
||||||
|
'adj_amount': adj_total,
|
||||||
|
'total_payable': total_payable,
|
||||||
|
'adjustments': pending_adjustments,
|
||||||
'logs': unpaid_logs
|
'logs': unpaid_logs
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -357,6 +376,9 @@ def payroll_dashboard(request):
|
|||||||
# Analytics: Previous 2 months payments
|
# Analytics: Previous 2 months payments
|
||||||
two_months_ago = timezone.now().date() - timedelta(days=60)
|
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
|
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 = {
|
context = {
|
||||||
'workers_data': workers_data,
|
'workers_data': workers_data,
|
||||||
@ -365,30 +387,57 @@ def payroll_dashboard(request):
|
|||||||
'project_costs': project_costs,
|
'project_costs': project_costs,
|
||||||
'recent_payments_total': recent_payments_total,
|
'recent_payments_total': recent_payments_total,
|
||||||
'active_tab': status_filter,
|
'active_tab': status_filter,
|
||||||
|
'all_workers': all_workers,
|
||||||
|
'adjustment_types': PayrollAdjustment.ADJUSTMENT_TYPES,
|
||||||
}
|
}
|
||||||
return render(request, 'core/payroll_dashboard.html', context)
|
return render(request, 'core/payroll_dashboard.html', context)
|
||||||
|
|
||||||
def process_payment(request, worker_id):
|
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)
|
worker = get_object_or_404(Worker, pk=worker_id)
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
# Find unpaid logs
|
# Find unpaid logs
|
||||||
unpaid_logs = worker.work_logs.exclude(paid_in__worker=worker)
|
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:
|
# Find pending adjustments
|
||||||
amount = count * worker.day_rate
|
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
|
# Create Payroll Record
|
||||||
payroll_record = PayrollRecord.objects.create(
|
payroll_record = PayrollRecord.objects.create(
|
||||||
worker=worker,
|
worker=worker,
|
||||||
amount=amount,
|
amount=total_amount,
|
||||||
date=timezone.now().date()
|
date=timezone.now().date()
|
||||||
)
|
)
|
||||||
|
|
||||||
# Link logs
|
# Link logs
|
||||||
payroll_record.work_logs.set(unpaid_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()
|
payroll_record.save()
|
||||||
|
|
||||||
# Email Notification
|
# Email Notification
|
||||||
@ -397,16 +446,18 @@ def process_payment(request, worker_id):
|
|||||||
f"Payslip Generated\n\n"
|
f"Payslip Generated\n\n"
|
||||||
f"Record ID: #{payroll_record.id}\n"
|
f"Record ID: #{payroll_record.id}\n"
|
||||||
f"Worker: {worker.name}\n"
|
f"Worker: {worker.name}\n"
|
||||||
f"ID Number: {worker.id_no}\n"
|
|
||||||
f"Date: {payroll_record.date}\n"
|
f"Date: {payroll_record.date}\n"
|
||||||
f"Amount Paid: R {payroll_record.amount}\n\n"
|
f"Total Paid: R {payroll_record.amount}\n\n"
|
||||||
f"This is an automated notification from Fox Fitt Payroll."
|
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']
|
recipient_list = ['foxfitt-ed9wc+expense@to.sparkreceipt.com']
|
||||||
|
|
||||||
try:
|
try:
|
||||||
send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, recipient_list)
|
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:
|
except Exception as e:
|
||||||
messages.warning(request, f"Payment processed, but email delivery failed: {str(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
|
# Get the logs included in this payment
|
||||||
logs = record.work_logs.all().order_by('date')
|
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 = {
|
context = {
|
||||||
'record': record,
|
'record': record,
|
||||||
'logs': logs,
|
'logs': logs,
|
||||||
|
'adjustments': adjustments,
|
||||||
|
'base_pay': base_pay,
|
||||||
|
'adjustments_net': adjustments_net,
|
||||||
}
|
}
|
||||||
return render(request, 'core/payslip.html', context)
|
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