Compare commits

..

No commits in common. "4c6eb17d09e788ec7b69105718c7e134eeb2d1fd" and "c6cc41cce34da463cbacf6ca6e4a54b516cd19e5" have entirely different histories.

29 changed files with 218 additions and 1921 deletions

View File

@ -181,8 +181,3 @@ if EMAIL_USE_SSL:
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# Authentication
LOGIN_URL = 'login'
LOGIN_REDIRECT_URL = 'home'
LOGOUT_REDIRECT_URL = 'login'

View File

@ -21,7 +21,6 @@ from django.conf.urls.static import static
urlpatterns = [
path("admin/", admin.site.urls),
path("accounts/", include("django.contrib.auth.urls")), # Adds login, logout, password management
path("", include("core.urls")),
]

View File

@ -1,25 +1,7 @@
from django import forms
from django.forms import inlineformset_factory
from .models import WorkLog, Project, Worker, Team, ExpenseReceipt, ExpenseLineItem
from .models import WorkLog, Project, Worker, Team
class WorkLogForm(forms.ModelForm):
end_date = forms.DateField(
required=False,
widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
label="End Date (Optional)"
)
include_saturday = forms.BooleanField(
required=False,
label="Include Saturday",
initial=False,
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'})
)
include_sunday = forms.BooleanField(
required=False,
label="Include Sunday",
initial=False,
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'})
)
team = forms.ModelChoiceField(queryset=Team.objects.none(), required=False, empty_label="Select Team", widget=forms.Select(attrs={'class': 'form-control'}))
class Meta:
@ -58,26 +40,3 @@ class WorkLogForm(forms.ModelForm):
self.fields['project'].queryset = projects_qs
self.fields['workers'].queryset = workers_qs
self.fields['team'].queryset = teams_qs
class ExpenseReceiptForm(forms.ModelForm):
class Meta:
model = ExpenseReceipt
fields = ['date', 'vendor', 'description', 'payment_method', 'vat_type']
widgets = {
'date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
'vendor': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Vendor Name'}),
'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 2}),
'payment_method': forms.Select(attrs={'class': 'form-control'}),
'vat_type': forms.RadioSelect(attrs={'class': 'form-check-input'}),
}
ExpenseLineItemFormSet = inlineformset_factory(
ExpenseReceipt, ExpenseLineItem,
fields=['product', 'amount'],
extra=1,
can_delete=True,
widgets={
'product': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Item Name'}),
'amount': forms.NumberInput(attrs={'class': 'form-control item-amount', 'step': '0.01'}),
}
)

View File

@ -1,40 +0,0 @@
# 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')),
],
),
]

View File

@ -1,18 +0,0 @@
# 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),
),
]

View File

@ -1,42 +0,0 @@
# Generated by Django 5.2.7 on 2026-02-04 13:11
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0006_alter_payrolladjustment_type'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ExpenseReceipt',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField(default=django.utils.timezone.now)),
('vendor', models.CharField(max_length=200)),
('description', models.TextField(blank=True)),
('payment_method', models.CharField(choices=[('CASH', 'Cash'), ('CARD', 'Card'), ('EFT', 'EFT'), ('OTHER', 'Other')], default='CARD', max_length=10)),
('vat_type', models.CharField(choices=[('INCLUDED', 'VAT Included'), ('EXCLUDED', 'VAT Excluded'), ('NONE', 'No VAT')], default='INCLUDED', max_length=10)),
('subtotal', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
('vat_amount', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
('total_amount', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='generated_receipts', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='ExpenseLineItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('product', models.CharField(max_length=255, verbose_name='Product/Item')),
('amount', models.DecimalField(decimal_places=2, max_digits=10)),
('receipt', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='core.expensereceipt')),
],
),
]

View File

@ -66,77 +66,3 @@ 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}"
class ExpenseReceipt(models.Model):
VAT_CHOICES = [
('INCLUDED', 'VAT Included'),
('EXCLUDED', 'VAT Excluded'),
('NONE', 'No VAT'),
]
PAYMENT_METHODS = [
('CASH', 'Cash'),
('CARD', 'Card'),
('EFT', 'EFT'),
('OTHER', 'Other'),
]
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='generated_receipts')
date = models.DateField(default=timezone.now)
vendor = models.CharField(max_length=200)
description = models.TextField(blank=True)
payment_method = models.CharField(max_length=10, choices=PAYMENT_METHODS, default='CARD')
vat_type = models.CharField(max_length=10, choices=VAT_CHOICES, default='INCLUDED')
# Financials (Stored for record keeping)
subtotal = models.DecimalField(max_digits=12, decimal_places=2, default=0)
vat_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0)
total_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"Receipt from {self.vendor} - {self.date}"
class ExpenseLineItem(models.Model):
receipt = models.ForeignKey(ExpenseReceipt, on_delete=models.CASCADE, related_name='items')
product = models.CharField(max_length=255, verbose_name="Product/Item")
amount = models.DecimalField(max_digits=10, decimal_places=2)
def __str__(self):
return f"{self.product} - {self.amount}"

View File

@ -12,8 +12,6 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Google Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<!-- Custom CSS -->
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
<style>
@ -32,41 +30,12 @@
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto align-items-center">
{% if user.is_authenticated %}
{% if user.is_staff or user.is_superuser or user.managed_teams.exists or user.assigned_projects.exists %}
<li class="nav-item"><a class="nav-link" href="{% url 'home' %}">Dashboard</a></li>
{% endif %}
<li class="nav-item"><a class="nav-link" href="{% url 'log_attendance' %}">Log Work</a></li>
{% if user.is_staff or user.is_superuser or user.managed_teams.exists or user.assigned_projects.exists %}
<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 'create_receipt' %}">Receipts</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'manage_resources' %}">Manage</a></li>
{% endif %}
<li class="nav-item dropdown ms-lg-3">
<a class="nav-link dropdown-toggle btn btn-sm btn-outline-light text-white px-3" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
{{ user.username }}
</a>
<ul class="dropdown-menu dropdown-menu-end shadow border-0" aria-labelledby="userDropdown">
{% if user.is_staff or user.is_superuser %}
<li><a class="dropdown-item" href="/admin/">Admin Panel</a></li>
<li><hr class="dropdown-divider"></li>
{% endif %}
<li>
<form action="{% url 'logout' %}" method="post" style="display: inline;">
{% csrf_token %}
<button type="submit" class="dropdown-item">Logout</button>
</form>
</li>
</ul>
</li>
{% else %}
<li class="nav-item"><a class="nav-link btn btn-sm btn-primary px-3" href="{% url 'login' %}">Login</a></li>
{% endif %}
<li class="nav-item"><a class="nav-link" href="{% url 'home' %}">Dashboard</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 'payroll_dashboard' %}">Payroll</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>
</div>
</div>

View File

@ -1,249 +0,0 @@
{% extends 'base.html' %}
{% load static %}
{% block content %}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card shadow-lg border-0">
<div class="card-header text-white py-3" style="background-color: var(--primary-color); border-top-left-radius: 1rem; border-top-right-radius: 1rem;">
<h4 class="mb-0 fw-bold"><i class="fas fa-file-invoice-dollar me-2"></i>Receipt Generator</h4>
</div>
<div class="card-body p-4">
<form method="post" id="receiptForm">
{% csrf_token %}
<!-- Header Info -->
<div class="row g-3 mb-4">
<div class="col-md-6">
<label class="form-label fw-bold text-secondary">Date</label>
{{ form.date }}
</div>
<div class="col-md-6">
<label class="form-label fw-bold text-secondary">Vendor Name</label>
{{ form.vendor }}
<div class="form-text text-muted small"><i class="fas fa-info-circle"></i> Will appear as the main title on the receipt.</div>
</div>
<div class="col-md-6">
<label class="form-label fw-bold text-secondary">Payment Method</label>
{{ form.payment_method }}
</div>
<div class="col-12">
<label class="form-label fw-bold text-secondary">Description</label>
{{ form.description }}
</div>
</div>
<hr class="text-muted opacity-25">
<!-- Line Items -->
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="fw-bold text-dark m-0">Items</h5>
<button type="button" id="add-item" class="btn btn-sm btn-outline-secondary rounded-pill">
<i class="fas fa-plus me-1"></i> Add Line
</button>
</div>
{{ items.management_form }}
<div id="items-container">
{% for form in items %}
<div class="item-row row g-2 align-items-center mb-2">
{{ form.id }}
<div class="col-7">
{{ form.product }}
</div>
<div class="col-4">
<div class="input-group">
<span class="input-group-text bg-light border-end-0">R</span>
{{ form.amount }}
</div>
</div>
<div class="col-1 text-center">
{% if items.can_delete %}
<div class="form-check d-none">
{{ form.DELETE }}
</div>
<button type="button" class="btn btn-outline-danger btn-sm delete-row rounded-circle" title="Remove">
<i class="fas fa-times"></i>
</button>
{% endif %}
</div>
</div>
{% endfor %}
</div>
<hr class="text-muted opacity-25 mt-4">
<!-- VAT and Totals -->
<div class="row">
<div class="col-md-6 mb-3 mb-md-0">
<label class="form-label d-block fw-bold text-secondary mb-2">VAT Configuration (15%)</label>
<div class="card bg-light border-0 p-3">
{% for radio in form.vat_type %}
<div class="form-check mb-2">
{{ radio.tag }}
<label class="form-check-label" for="{{ radio.id_for_label }}">
{{ radio.choice_label }}
</label>
</div>
{% endfor %}
</div>
</div>
<div class="col-md-6">
<div class="p-3 rounded" style="background-color: #f8fafc; border: 1px solid #e2e8f0;">
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">Subtotal (Excl. VAT):</span>
<span class="fw-bold">R <span id="display-subtotal">0.00</span></span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-secondary">VAT (15%):</span>
<span class="fw-bold">R <span id="display-vat">0.00</span></span>
</div>
<div class="d-flex justify-content-between border-top pt-2 mt-2">
<span class="h5 mb-0 fw-bold">Total:</span>
<span class="h5 mb-0" style="color: var(--accent-color);">R <span id="display-total">0.00</span></span>
</div>
</div>
</div>
</div>
<div class="d-grid mt-5">
<button type="submit" class="btn btn-accent btn-lg shadow-sm">
<i class="fas fa-paper-plane me-2"></i>Create & Send Receipt
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- Empty Form Template for JS -->
<div id="empty-form" class="d-none">
<div class="item-row row g-2 align-items-center mb-2">
<div class="col-7">
{{ items.empty_form.product }}
</div>
<div class="col-4">
<div class="input-group">
<span class="input-group-text bg-light border-end-0">R</span>
{{ items.empty_form.amount }}
</div>
</div>
<div class="col-1 text-center">
<div class="form-check d-none">
{{ items.empty_form.DELETE }}
</div>
<button type="button" class="btn btn-outline-danger btn-sm delete-row rounded-circle" title="Remove">
<i class="fas fa-times"></i>
</button>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const itemsContainer = document.getElementById('items-container');
const addItemBtn = document.getElementById('add-item');
const totalForms = document.getElementById('id_items-TOTAL_FORMS');
const emptyFormHtml = document.getElementById('empty-form').innerHTML;
// Elements for calculation
const displaySubtotal = document.getElementById('display-subtotal');
const displayVat = document.getElementById('display-vat');
const displayTotal = document.getElementById('display-total');
const vatRadios = document.querySelectorAll('input[name="vat_type"]');
function updateCalculations() {
let sum = 0;
const amounts = document.querySelectorAll('.item-row:not(.deleted) .item-amount');
amounts.forEach(input => {
const val = parseFloat(input.value) || 0;
sum += val;
});
let vat = 0;
let total = 0;
let subtotal = 0;
// Find selected VAT type
let vatType = 'INCLUDED'; // Default
vatRadios.forEach(r => {
if (r.checked) vatType = r.value;
});
if (vatType === 'INCLUDED') {
// Reverse calc: Total is Sum. VAT is part of it.
// Amount = Base + VAT
// Base = Amount / 1.15
// VAT = Amount - Base
total = sum;
const base = total / 1.15;
vat = total - base;
subtotal = base;
} else if (vatType === 'EXCLUDED') {
// Forward calc: Sum is Subtotal. Add VAT on top.
subtotal = sum;
vat = subtotal * 0.15;
total = subtotal + vat;
} else {
// NONE
subtotal = sum;
vat = 0;
total = sum;
}
displaySubtotal.textContent = subtotal.toFixed(2);
displayVat.textContent = vat.toFixed(2);
displayTotal.textContent = total.toFixed(2);
}
// Add Row
addItemBtn.addEventListener('click', function() {
const formIdx = parseInt(totalForms.value);
const newHtml = emptyFormHtml.replace(/__prefix__/g, formIdx);
const newRow = document.createElement('div');
newRow.innerHTML = newHtml; // wrapper
// Unwrap logic to append cleanly
while (newRow.firstChild) {
itemsContainer.appendChild(newRow.firstChild);
}
totalForms.value = formIdx + 1;
updateCalculations();
});
// Delete Row (Event Delegation)
itemsContainer.addEventListener('click', function(e) {
if (e.target.closest('.delete-row')) {
const row = e.target.closest('.item-row');
const deleteCheckbox = row.querySelector('input[name$="-DELETE"]');
if (deleteCheckbox) {
deleteCheckbox.checked = true;
row.classList.add('d-none', 'deleted');
} else {
row.remove();
}
updateCalculations();
}
});
// Input Changes
itemsContainer.addEventListener('input', function(e) {
if (e.target.classList.contains('item-amount')) {
updateCalculations();
}
});
// VAT Change
vatRadios.forEach(r => {
r.addEventListener('change', updateCalculations);
});
// Initial Calc
updateCalculations();
});
</script>
{% endblock %}

View File

@ -1,59 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; border: 1px solid #ddd; padding: 20px; }
.header { text-align: center; border-bottom: 2px solid #333; padding-bottom: 10px; margin-bottom: 20px; }
.vendor-name { font-size: 24px; font-weight: bold; text-transform: uppercase; color: #000; }
.meta { margin-bottom: 20px; }
.items-table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
.items-table th, .items-table td { border-bottom: 1px solid #eee; padding: 8px; text-align: left; }
.items-table th { background-color: #f8f9fa; }
.totals { text-align: right; margin-top: 20px; }
.total-row { font-size: 18px; font-weight: bold; }
.footer { margin-top: 30px; font-size: 12px; color: #777; text-align: center; border-top: 1px solid #eee; padding-top: 10px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div style="font-size: 14px; color: #666;">RECEIPT FROM</div>
<div class="vendor-name">{{ receipt.vendor }}</div>
</div>
<div class="meta">
<strong>Date:</strong> {{ receipt.date }}<br>
<strong>Payment Method:</strong> {{ receipt.get_payment_method_display }}<br>
<strong>Description:</strong> {{ receipt.description|default:"-" }}
</div>
<table class="items-table">
<thead>
<tr>
<th>Item</th>
<th style="text-align: right;">Amount</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>{{ item.product }}</td>
<td style="text-align: right;">R {{ item.amount }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="totals">
<p>Subtotal: R {{ receipt.subtotal }}</p>
<p>VAT ({{ receipt.get_vat_type_display }}): R {{ receipt.vat_amount }}</p>
<p class="total-row">Total: R {{ receipt.total_amount }}</p>
</div>
<div class="footer">
<p>Generated by {{ receipt.user.get_full_name|default:receipt.user.username }} via Fox Fitt App</p>
</div>
</div>
</body>
</html>

View File

@ -1,116 +0,0 @@
{% 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 %}

View File

@ -13,41 +13,27 @@
<div class="container mb-5 mt-n4">
<div class="row justify-content-center">
<div class="col-lg-10">
<div class="col-lg-8">
<div class="card p-4 shadow-sm">
<form method="post" id="workLogForm">
{% csrf_token %}
<div class="row mb-4">
<div class="col-md-3">
<label class="form-label fw-bold">Start Date</label>
<div class="col-md-4">
<label class="form-label fw-bold">Date</label>
{{ form.date }}
{% if form.date.errors %}
<div class="text-danger mt-1 small">{{ form.date.errors }}</div>
{% endif %}
</div>
<div class="col-md-3">
<label class="form-label fw-bold">End Date (Optional)</label>
{{ form.end_date }}
<div class="mt-2">
<div class="form-check form-check-inline">
{{ form.include_saturday }}
<label class="form-check-label small" for="{{ form.include_saturday.id_for_label }}">Sat</label>
</div>
<div class="form-check form-check-inline">
{{ form.include_sunday }}
<label class="form-check-label small" for="{{ form.include_sunday.id_for_label }}">Sun</label>
</div>
</div>
</div>
<div class="col-md-3">
<div class="col-md-4">
<label class="form-label fw-bold">Project</label>
{{ form.project }}
{% if form.project.errors %}
<div class="text-danger mt-1 small">{{ form.project.errors }}</div>
{% endif %}
</div>
<div class="col-md-3">
<div class="col-md-4">
<label class="form-label fw-bold">Team (Optional)</label>
{{ form.team }}
{% if form.team.errors %}
@ -57,10 +43,7 @@
</div>
<div class="mb-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<label class="form-label fw-bold mb-0">Select Labourers</label>
<a href="{% url 'manage_resources' %}" class="small text-decoration-none text-primary">Manage Resources</a>
</div>
<label class="form-label fw-bold d-block mb-3">Select Labourers</label>
<div class="row">
{% for checkbox in form.workers %}
<div class="col-md-6 col-lg-4 mb-2">
@ -107,22 +90,22 @@
</h5>
</div>
<div class="modal-body">
<p class="fw-bold">The following duplicate entries were found:</p>
<div class="card bg-light mb-3" style="max-height: 200px; overflow-y: auto;">
<p class="fw-bold">The following workers have already been logged for {{ conflict_date }}:</p>
<div class="card bg-light mb-3">
<ul class="list-group list-group-flush">
{% for conflict in conflicting_workers %}
<li class="list-group-item bg-transparent">{{ conflict.name }}</li>
{% for worker in conflicting_workers %}
<li class="list-group-item bg-transparent">{{ worker.name }}</li>
{% endfor %}
</ul>
</div>
<p>How would you like to proceed?</p>
<div class="alert alert-info small mb-0">
<strong>Skip:</strong> Log only the new entries. Existing logs remain unchanged.<br>
<strong>Overwrite:</strong> Update existing logs for these dates with the new project/team selection.
<strong>Skip:</strong> Log only the new workers. Keep existing logs as they are.<br>
<strong>Overwrite:</strong> Remove these workers from previous logs for this date and add them here.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="submitConflict('skip')">Skip Duplicates</button>
<button type="button" class="btn btn-secondary" onclick="submitConflict('skip')">Skip Duplicate Workers</button>
<button type="button" class="btn btn-primary" onclick="submitConflict('overwrite')">Overwrite Existing</button>
</div>
</div>
@ -153,14 +136,9 @@
const teamId = this.value;
if (teamId && teamWorkersMap[teamId]) {
const workerIds = teamWorkersMap[teamId];
// Uncheck all first? No, maybe append. Let's append as per common expectations unless explicit clear needed.
// Actually, if I change team, I probably expect to select THAT team's workers.
// Let's clear and select.
// But maybe I want to mix teams.
// User didn't specify. Previous logic was: select workers belonging to team.
// Let's stick to "select", don't uncheck others.
// Select workers belonging to the team
workerIds.forEach(function(id) {
// Find the checkbox for this worker ID
const checkbox = document.querySelector(`input[name="workers"][value="${id}"]`);
if (checkbox) {
checkbox.checked = true;

View File

@ -7,22 +7,10 @@
<div class="row mb-4">
<div class="col">
<h1 class="fw-bold text-dark">Manage Resources</h1>
<p class="text-muted">Quickly toggle the active status of workers, projects, and teams. Inactive items will be hidden from selection forms.</p>
<p class="text-muted">Toggle the active status of workers, projects, and teams. Inactive items will be hidden from selection forms.</p>
</div>
</div>
<!-- Feedback Toast -->
<div class="position-fixed bottom-0 end-0 p-3" style="z-index: 11">
<div id="liveToast" class="toast align-items-center text-white bg-success border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body" id="toastMessage">
Status updated successfully.
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
</div>
<!-- Tabs -->
<ul class="nav nav-tabs mb-4" id="resourceTabs" role="tablist">
<li class="nav-item" role="presentation">
@ -49,30 +37,31 @@
<tr>
<th>Name</th>
<th>ID Number</th>
<th>Teams</th>
<th class="text-end">Active Status</th>
<th>Status</th>
<th class="text-end">Action</th>
</tr>
</thead>
<tbody>
{% for worker in workers %}
<tr>
<td><strong>{{ worker.name }}</strong></td>
<td>{{ worker.name }}</td>
<td>{{ worker.id_no }}</td>
<td>
{% for team in worker.teams.all %}
<span class="badge bg-light text-dark border">{{ team.name }}</span>
{% empty %}
<span class="text-muted small">No Team</span>
{% endfor %}
{% if worker.is_active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-secondary">Inactive</span>
{% endif %}
</td>
<td class="text-end">
<div class="form-check form-switch d-flex justify-content-end">
<input class="form-check-input resource-toggle" type="checkbox" role="switch"
id="worker-{{ worker.id }}"
data-model="worker"
data-id="{{ worker.id }}"
{% if worker.is_active %}checked{% endif %}>
</div>
<form method="post" action="{% url 'toggle_resource_status' 'worker' worker.id %}">
{% csrf_token %}
{% if worker.is_active %}
<button type="submit" class="btn btn-sm btn-outline-danger">Deactivate</button>
{% else %}
<button type="submit" class="btn btn-sm btn-outline-success">Activate</button>
{% endif %}
</form>
</td>
</tr>
{% empty %}
@ -95,26 +84,35 @@
<tr>
<th>Name</th>
<th>Description</th>
<th class="text-end">Active Status</th>
<th>Status</th>
<th class="text-end">Action</th>
</tr>
</thead>
<tbody>
{% for project in projects %}
<tr>
<td><strong>{{ project.name }}</strong></td>
<td>{{ project.name }}</td>
<td>{{ project.description|truncatechars:50 }}</td>
<td>
{% if project.is_active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-secondary">Inactive</span>
{% endif %}
</td>
<td class="text-end">
<div class="form-check form-switch d-flex justify-content-end">
<input class="form-check-input resource-toggle" type="checkbox" role="switch"
id="project-{{ project.id }}"
data-model="project"
data-id="{{ project.id }}"
{% if project.is_active %}checked{% endif %}>
</div>
<form method="post" action="{% url 'toggle_resource_status' 'project' project.id %}">
{% csrf_token %}
{% if project.is_active %}
<button type="submit" class="btn btn-sm btn-outline-danger">Deactivate</button>
{% else %}
<button type="submit" class="btn btn-sm btn-outline-success">Activate</button>
{% endif %}
</form>
</td>
</tr>
{% empty %}
<tr><td colspan="3" class="text-center text-muted py-4">No projects found.</td></tr>
<tr><td colspan="4" class="text-center text-muted py-4">No projects found.</td></tr>
{% endfor %}
</tbody>
</table>
@ -134,27 +132,36 @@
<th>Name</th>
<th>Supervisor</th>
<th>Workers Count</th>
<th class="text-end">Active Status</th>
<th>Status</th>
<th class="text-end">Action</th>
</tr>
</thead>
<tbody>
{% for team in teams %}
<tr>
<td><strong>{{ team.name }}</strong></td>
<td>{{ team.name }}</td>
<td>{{ team.supervisor.username|default:"-" }}</td>
<td>{{ team.workers.count }}</td>
<td>
{% if team.is_active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-secondary">Inactive</span>
{% endif %}
</td>
<td class="text-end">
<div class="form-check form-switch d-flex justify-content-end">
<input class="form-check-input resource-toggle" type="checkbox" role="switch"
id="team-{{ team.id }}"
data-model="team"
data-id="{{ team.id }}"
{% if team.is_active %}checked{% endif %}>
</div>
<form method="post" action="{% url 'toggle_resource_status' 'team' team.id %}">
{% csrf_token %}
{% if team.is_active %}
<button type="submit" class="btn btn-sm btn-outline-danger">Deactivate</button>
{% else %}
<button type="submit" class="btn btn-sm btn-outline-success">Activate</button>
{% endif %}
</form>
</td>
</tr>
{% empty %}
<tr><td colspan="4" class="text-center text-muted py-4">No teams found.</td></tr>
<tr><td colspan="5" class="text-center text-muted py-4">No teams found.</td></tr>
{% endfor %}
</tbody>
</table>
@ -165,57 +172,4 @@
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const toggles = document.querySelectorAll('.resource-toggle');
const toastEl = document.getElementById('liveToast');
const toast = new bootstrap.Toast(toastEl);
const toastMessage = document.getElementById('toastMessage');
toggles.forEach(toggle => {
toggle.addEventListener('change', function() {
const model = this.dataset.model;
const id = this.dataset.id;
const isChecked = this.checked;
const url = `/manage-resources/toggle/${model}/${id}/`;
fetch(url, {
method: 'POST',
headers: {
'X-CSRFToken': '{{ csrf_token }}',
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => {
if (response.ok) {
return response.json();
}
throw new Error('Network response was not ok.');
})
.then(data => {
if (data.success) {
toastMessage.textContent = data.message;
toastEl.classList.remove('bg-danger');
toastEl.classList.add('bg-success');
toast.show();
} else {
// Revert switch if failed
this.checked = !isChecked;
alert('Failed to update status.');
}
})
.catch(error => {
console.error('Error:', error);
// Revert switch on error
this.checked = !isChecked;
toastMessage.textContent = "Error updating status.";
toastEl.classList.remove('bg-success');
toastEl.classList.add('bg-danger');
toast.show();
});
});
});
});
</script>
{% endblock %}

View File

@ -7,12 +7,6 @@
<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 -->
@ -23,7 +17,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 (including adjustments)</p>
<p class="text-muted small mb-0">Total pending for active workers</p>
</div>
</div>
</div>
@ -85,8 +79,8 @@
<thead class="bg-light">
<tr>
<th class="ps-4">Worker Name</th>
<th>Breakdown</th>
<th>Net Payable</th>
<th>Unpaid Logs</th>
<th>Total Owed</th>
<th class="text-end pe-4">Action</th>
</tr>
</thead>
@ -96,39 +90,20 @@
<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>
<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>
<span class="badge bg-secondary">{{ item.unpaid_count }} days</span>
</td>
<td class="fw-bold fs-5 {% if item.total_payable < 0 %}text-danger{% else %}text-success{% endif %}">
R {{ item.total_payable|intcomma }}
<td class="fw-bold text-success">
R {{ item.unpaid_amount|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.total_payable }} to {{ item.worker.name }}? This will email the receipt.')">
<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.')">
Pay Now
</button>
</form>
{% else %}
<button class="btn btn-sm btn-secondary" disabled>Nothing to Pay</button>
{% endif %}
</td>
</tr>
{% endfor %}
@ -163,7 +138,7 @@
<th class="ps-4">Date</th>
<th>Payslip ID</th>
<th>Worker</th>
<th>Net Amount</th>
<th>Amount</th>
<th class="text-end pe-4">Action</th>
</tr>
</thead>
@ -197,55 +172,4 @@
{% 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 %}

View File

@ -6,7 +6,7 @@
{% block content %}
<div class="container py-5">
<div class="d-print-none mb-4">
<a href="{% url 'payroll_dashboard' %}?status=paid" class="btn btn-outline-secondary me-2">← Back to Payment History</a>
<a href="{% url 'payroll_dashboard' %}" class="btn btn-outline-secondary me-2">← Back to Payroll</a>
<button onclick="window.print()" class="btn btn-primary">Print Payslip</button>
</div>
@ -41,9 +41,9 @@
</div>
<!-- Work Details -->
<h6 class="text-uppercase text-muted fw-bold small mb-3">Work Log Details (Attendance)</h6>
<h6 class="text-uppercase text-muted fw-bold small mb-3">Work Log Details</h6>
<div class="table-responsive mb-4">
<table class="table table-bordered mb-0">
<table class="table table-bordered">
<thead class="table-light">
<tr>
<th>Date</th>
@ -60,84 +60,17 @@
<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">Base Pay Subtotal</td>
<td class="text-end fw-bold">R {{ base_pay|intcomma }}</td>
<td colspan="3" class="text-end fw-bold">Total</td>
<td class="text-end fw-bold">R {{ record.amount|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 adjustments_net >= 0 %}
+ R {{ adjustments_net|intcomma }}
{% else %}
- R {{ adjustments_net|stringformat:".2f"|slice:"1:"|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>

View File

@ -4,294 +4,61 @@
{% block title %}Work Log History | LabourFlow{% endblock %}
{% block content %}
<div class="dashboard-header pb-5">
<div class="dashboard-header">
<div class="container">
<div class="d-flex justify-content-between align-items-center mb-4">
<div class="d-flex justify-content-between align-items-center">
<div>
<h1 class="display-5 mb-2">Work Log History</h1>
<p class="lead opacity-75">Filter and review historical daily work logs.</p>
</div>
<div class="d-flex gap-2 align-items-center">
<!-- View Switcher -->
<div class="btn-group me-2 shadow-sm" role="group">
<a href="?view=list&worker={{ selected_worker|default:'' }}&team={{ selected_team|default:'' }}&project={{ selected_project|default:'' }}&payment_status={{ selected_payment_status|default:'' }}" class="btn btn-outline-secondary bg-white {% if view_mode != 'calendar' %}active fw-bold{% endif %}">
<i class="bi bi-list-ul"></i> List
</a>
<a href="?view=calendar&worker={{ selected_worker|default:'' }}&team={{ selected_team|default:'' }}&project={{ selected_project|default:'' }}&payment_status={{ selected_payment_status|default:'' }}" class="btn btn-outline-secondary bg-white {% if view_mode == 'calendar' %}active fw-bold{% endif %}">
<i class="bi bi-calendar3"></i> Calendar
</a>
</div>
<a href="{% url 'export_work_log_csv' %}?{{ request.GET.urlencode }}" class="btn btn-outline-secondary shadow-sm border-0 bg-white text-dark">
<i class="bi bi-download me-1"></i> Export CSV
</a>
<a href="{% url 'log_attendance' %}" class="btn btn-accent shadow-sm">
+ New Entry
</a>
</div>
</div>
<!-- Filter Card -->
<div class="card border-0 shadow-sm">
<div class="card-body p-4">
<form method="get" class="row g-3">
<input type="hidden" name="view" value="{{ view_mode }}">
{% if view_mode == 'calendar' %}
<input type="hidden" name="month" value="{{ curr_month }}">
<input type="hidden" name="year" value="{{ curr_year }}">
{% endif %}
<div class="col-md-3">
<label class="form-label small text-muted text-uppercase fw-bold">Worker</label>
<select name="worker" class="form-select">
<option value="">All Workers</option>
{% for w in workers %}
<option value="{{ w.id }}" {% if selected_worker == w.id %}selected{% endif %}>{{ w.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<label class="form-label small text-muted text-uppercase fw-bold">Team</label>
<select name="team" class="form-select">
<option value="">All Teams</option>
{% for t in teams %}
<option value="{{ t.id }}" {% if selected_team == t.id %}selected{% endif %}>{{ t.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<label class="form-label small text-muted text-uppercase fw-bold">Project</label>
<select name="project" class="form-select">
<option value="">All Projects</option>
{% for p in projects %}
<option value="{{ p.id }}" {% if selected_project == p.id %}selected{% endif %}>{{ p.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<label class="form-label small text-muted text-uppercase fw-bold">Payment</label>
<select name="payment_status" class="form-select">
<option value="all" {% if selected_payment_status == 'all' %}selected{% endif %}>All Status</option>
<option value="paid" {% if selected_payment_status == 'paid' %}selected{% endif %}>Paid</option>
<option value="unpaid" {% if selected_payment_status == 'unpaid' %}selected{% endif %}>Unpaid</option>
</select>
</div>
<div class="col-md-2 d-flex align-items-end">
<button type="submit" class="btn btn-primary w-100">Filter</button>
</div>
</form>
{% if selected_worker or selected_team or selected_project or selected_payment_status and selected_payment_status != 'all' %}
<div class="mt-3">
<a href="{% url 'work_log_list' %}?view={{ view_mode }}" class="btn btn-sm btn-link text-decoration-none text-muted">
<i class="bi bi-x-circle"></i> Clear all filters
</a>
</div>
{% endif %}
<p class="lead opacity-75">View and filter historical daily work logs.</p>
</div>
<a href="{% url 'log_attendance' %}" class="btn btn-accent shadow-sm">
+ New Entry
</a>
</div>
</div>
</div>
<div class="container mb-5 mt-n4">
{% if view_mode == 'calendar' %}
<!-- CALENDAR VIEW -->
<div class="card shadow-sm border-0">
<div class="card-header bg-white py-3 border-bottom">
<div class="d-flex justify-content-between align-items-center">
<h3 class="mb-0 fw-bold text-dark">{{ month_name }} {{ curr_year }}</h3>
<div class="btn-group shadow-sm">
<a href="?view=calendar&month={{ prev_month }}&year={{ prev_year }}&worker={{ selected_worker|default:'' }}&team={{ selected_team|default:'' }}&project={{ selected_project|default:'' }}&payment_status={{ selected_payment_status|default:'' }}" class="btn btn-light border bg-white">
<i class="bi bi-chevron-left"></i> Previous
</a>
<a href="?view=calendar&month={{ next_month }}&year={{ next_year }}&worker={{ selected_worker|default:'' }}&team={{ selected_team|default:'' }}&project={{ selected_project|default:'' }}&payment_status={{ selected_payment_status|default:'' }}" class="btn btn-light border bg-white">
Next <i class="bi bi-chevron-right"></i>
</a>
</div>
</div>
<div class="card p-4 shadow-sm">
{% if logs %}
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-light">
<tr>
<th>Date</th>
<th>Project</th>
<th>Supervisor</th>
<th>Labourers</th>
<th>Notes</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr>
<td>{{ log.date }}</td>
<td><strong>{{ log.project.name }}</strong></td>
<td>{{ log.supervisor.username|default:"System" }}</td>
<td>
<span class="badge bg-primary bg-opacity-10 text-primary">
{{ log.workers.count }} Workers
</span>
</td>
<td><small class="text-muted">{{ log.notes|truncatechars:30 }}</small></td>
<td>
<a href="/admin/core/worklog/{{ log.id }}/change/" class="btn btn-sm btn-outline-secondary">Edit</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-bordered mb-0 calendar-table" style="table-layout: fixed;">
<thead class="bg-light text-center text-uppercase text-muted small">
<tr>
<th style="width: 14.28%">Mon</th>
<th style="width: 14.28%">Tue</th>
<th style="width: 14.28%">Wed</th>
<th style="width: 14.28%">Thu</th>
<th style="width: 14.28%">Fri</th>
<th style="width: 14.28%">Sat</th>
<th style="width: 14.28%">Sun</th>
</tr>
</thead>
<tbody>
{% for week in calendar_weeks %}
<tr>
{% for day_info in week %}
<td class="{% if not day_info.is_current_month %}bg-light text-muted bg-opacity-10{% endif %}" style="height: 140px; vertical-align: top; padding: 10px;">
<div class="d-flex justify-content-between align-items-start mb-2">
<span class="fw-bold {% if day_info.date == current_time.date %}text-primary bg-primary bg-opacity-10 px-2 rounded-circle{% endif %}">{{ day_info.day }}</span>
{% if day_info.logs %}
<span class="badge bg-secondary opacity-50">{{ day_info.logs|length }}</span>
{% endif %}
</div>
<div class="calendar-events" style="max-height: 100px; overflow-y: auto;">
{% for log in day_info.logs %}
<div class="mb-1 p-1 rounded border small bg-white shadow-sm" style="font-size: 0.75rem; line-height: 1.2; border-left: 3px solid var(--bs-primary) !important;">
<div class="fw-bold text-truncate">{{ log.project.name }}</div>
{% with team=log.workers.first.teams.first %}
{% if team %}
<div class="text-muted small text-truncate" style="font-size: 0.7rem;">
<i class="bi bi-people-fill me-1"></i>{{ team.name }}
</div>
{% endif %}
{% endwith %}
<div class="text-muted text-truncate" title="{{ log.workers.count }} workers">
{% if selected_worker %}
{{ log.workers.first.name }}
{% elif log.workers.count == 1 %}
{{ log.workers.first.name }}
{% else %}
{{ log.workers.count }} workers
{% endif %}
</div>
</div>
{% endfor %}
</div>
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<p class="text-muted">No work logs recorded yet.</p>
<a href="{% url 'log_attendance' %}" class="btn btn-primary">Log First Attendance</a>
</div>
{% endif %}
</div>
{% else %}
<!-- LIST VIEW -->
<div class="card shadow-sm border-0">
<div class="card-body p-0">
{% if logs %}
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="bg-light">
<tr>
<th class="ps-4">Date</th>
<th>Project</th>
<th>Labourers</th>
<th>Amount</th>
<th>Status / Payslip</th>
<th>Supervisor</th>
<th class="pe-4 text-end">Action</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr>
<td class="ps-4">
<span class="fw-bold">{{ log.date|date:"D, d M Y" }}</span>
</td>
<td>
<span class="badge bg-secondary bg-opacity-10 text-secondary border border-secondary border-opacity-25 px-2 py-1">
{{ log.project.name }}
</span>
</td>
<td>
{% if selected_worker %}
<span class="text-primary fw-medium">
{% for w in log.workers.all %}
{% if w.id == selected_worker %}
{{ w.name }}
{% endif %}
{% endfor %}
</span>
{% if log.workers.count > 1 %}
<small class="text-muted">(+{{ log.workers.count|add:"-1" }} others)</small>
{% endif %}
{% else %}
<div class="d-flex flex-wrap gap-1">
{% for w in log.workers.all|slice:":3" %}
<span class="small bg-light px-2 py-1 rounded border">{{ w.name|truncatechars:12 }}</span>
{% endfor %}
{% if log.workers.count > 3 %}
<span class="small text-muted align-self-center ms-1">+{{ log.workers.count|add:"-3" }}</span>
{% endif %}
</div>
{% endif %}
</td>
<td>
<span class="fw-bold font-monospace text-dark">R {{ log.display_amount|floatformat:2 }}</span>
</td>
<td>
{% with payslip=log.paid_in.first %}
{% if payslip %}
<a href="{% url 'payslip_detail' payslip.id %}" class="text-decoration-none">
<span class="badge bg-success bg-opacity-10 text-success border border-success border-opacity-25">
<i class="bi bi-check-circle-fill"></i> Paid (Slip #{{ payslip.id }})
</span>
</a>
{% else %}
<span class="badge bg-warning bg-opacity-10 text-warning border border-warning border-opacity-25">
Pending
</span>
{% endif %}
{% endwith %}
</td>
<td>
<small class="text-muted">{{ log.supervisor.username|default:"System" }}</small>
</td>
<td class="pe-4 text-end">
<div class="dropdown">
<button class="btn btn-sm btn-outline-light text-dark border-0" type="button" data-bs-toggle="dropdown">
<i class="bi bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-content dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="/admin/core/worklog/{{ log.id }}/change/">Edit Entry</a></li>
{% if log.notes %}
<li><hr class="dropdown-divider"></li>
<li class="px-3 py-1"><small class="text-muted">Note: {{ log.notes }}</small></li>
{% endif %}
</ul>
</div>
</td>
</tr>
{% endfor %}
</tbody>
<tfoot class="table-light border-top-2">
<tr>
<td colspan="3" class="text-end fw-bold ps-4">Total:</td>
<td class="fw-bold font-monospace text-dark bg-warning bg-opacity-10">R {{ total_amount|floatformat:2 }}</td>
<td colspan="3"></td>
</tr>
</tfoot>
</table>
</div>
{% else %}
<div class="text-center py-5">
<i class="bi bi-search display-1 text-muted opacity-25 mb-3 d-block"></i>
<h4 class="text-muted">No logs found matching filters.</h4>
<p class="text-muted mb-4">Try adjusting your filters or record a new entry.</p>
<a href="{% url 'log_attendance' %}" class="btn btn-primary">Log Attendance</a>
</div>
{% endif %}
</div>
</div>
{% endif %}
</div>
<style>
.mt-n4 { margin-top: -1.5rem !important; }
.bg-accent { background-color: var(--bs-accent); }
.btn-accent { background-color: #ffde59; color: #000; border: none; font-weight: 600; }
.btn-accent:hover { background-color: #e6c850; }
/* Calendar Scrollbar custom */
.calendar-events::-webkit-scrollbar {
width: 4px;
}
.calendar-events::-webkit-scrollbar-thumb {
background-color: #ccc;
border-radius: 4px;
}
</style>
{% endblock %}

View File

@ -1,60 +0,0 @@
{% extends "base.html" %}
{% block title %}Login - Fox Fitt{% endblock %}
{% block content %}
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-md-5">
<div class="card shadow-sm border-0">
<div class="card-body p-5">
<div class="text-center mb-4">
<h2 class="fw-bold heading-font"><span style="color: #10b981;">Fox</span> Fitt</h2>
<p class="text-muted">Sign in to manage work & payroll</p>
</div>
{% if form.errors %}
<div class="alert alert-danger">
Your username and password didn't match. Please try again.
</div>
{% endif %}
{% if next %}
{% if user.is_authenticated %}
<div class="alert alert-warning">
Your account doesn't have access to this page. To proceed,
please login with an account that has access.
</div>
{% else %}
<div class="alert alert-info">
Please login to see this page.
</div>
{% endif %}
{% endif %}
<form method="post" action="{% url 'login' %}">
{% csrf_token %}
<div class="mb-3">
<label for="id_username" class="form-label fw-medium text-secondary">Username</label>
<input type="text" name="username" autofocus maxlength="150" required id="id_username" class="form-control" placeholder="Enter your username">
</div>
<div class="mb-4">
<label for="id_password" class="form-label fw-medium text-secondary">Password</label>
<input type="password" name="password" required id="id_password" class="form-control" placeholder="Enter your password">
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary py-2 fw-bold" style="background-color: #0f172a; border: none;">Login</button>
</div>
<input type="hidden" name="next" value="{{ next }}">
</form>
</div>
</div>
<div class="text-center mt-4">
<p class="text-muted small">Forgot your password? Please contact your administrator.</p>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -3,30 +3,20 @@ from .views import (
home,
log_attendance,
work_log_list,
export_work_log_csv,
manage_resources,
toggle_resource_status,
payroll_dashboard,
process_payment,
payslip_detail,
loan_list,
add_loan,
add_adjustment,
create_receipt
payslip_detail
)
urlpatterns = [
path("", home, name="home"),
path("log-attendance/", log_attendance, name="log_attendance"),
path("work-logs/", work_log_list, name="work_log_list"),
path("work-logs/export/", export_work_log_csv, name="export_work_log_csv"),
path("manage-resources/", manage_resources, name="manage_resources"),
path("manage-resources/toggle/<str:model_type>/<int:pk>/", toggle_resource_status, name="toggle_resource_status"),
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"),
path("receipts/create/", create_receipt, name="create_receipt"),
]

View File

@ -1,44 +1,26 @@
import os
import platform
import json
import csv
import calendar
import datetime
from django.shortcuts import render, redirect, get_object_or_404
from django.utils import timezone
from django.contrib.auth.decorators import login_required, user_passes_test
from django.db.models import Sum, Q, Prefetch
from django.contrib.auth.decorators import login_required
from django.db.models import Sum, Q
from django.core.mail import send_mail
from django.conf import settings
from django.contrib import messages
from django.http import JsonResponse, HttpResponse
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from .models import Worker, Project, Team, WorkLog, PayrollRecord, Loan, PayrollAdjustment, ExpenseReceipt, ExpenseLineItem
from .forms import WorkLogForm, ExpenseReceiptForm, ExpenseLineItemFormSet
from .models import Worker, Project, Team, WorkLog, PayrollRecord
from .forms import WorkLogForm
from datetime import timedelta
from decimal import Decimal
def is_staff_or_supervisor(user):
"""Check if user is staff or manages at least one team/project."""
if user.is_staff or user.is_superuser:
return True
return user.managed_teams.exists() or user.assigned_projects.exists()
@login_required
def home(request):
"""Render the landing screen with dashboard stats."""
# If not staff or supervisor, redirect to log attendance
if not is_staff_or_supervisor(request.user):
return redirect('log_attendance')
workers_count = Worker.objects.count()
projects_count = Project.objects.count()
teams_count = Team.objects.count()
recent_logs = WorkLog.objects.order_by('-date')[:5]
# Analytics
# 1. Outstanding Payments (Approximate, from logs only)
# 1. Outstanding Payments
outstanding_total = 0
active_workers = Worker.objects.filter(is_active=True)
for worker in active_workers:
@ -79,7 +61,6 @@ def home(request):
}
return render(request, "core/index.html", context)
@login_required
def log_attendance(request):
# Build team workers map for frontend JS (needed for both GET and POST if re-rendering)
teams_qs = Team.objects.filter(is_active=True)
@ -95,109 +76,65 @@ def log_attendance(request):
if request.method == 'POST':
form = WorkLogForm(request.POST, user=request.user)
if form.is_valid():
start_date = form.cleaned_data['date']
end_date = form.cleaned_data.get('end_date')
include_sat = form.cleaned_data.get('include_saturday')
include_sun = form.cleaned_data.get('include_sunday')
date = form.cleaned_data['date']
selected_workers = form.cleaned_data['workers']
project = form.cleaned_data['project']
notes = form.cleaned_data['notes']
conflict_action = request.POST.get('conflict_action')
# Generate Target Dates
target_dates = []
if end_date and end_date >= start_date:
curr = start_date
while curr <= end_date:
# 5 = Saturday, 6 = Sunday
if (curr.weekday() == 5 and not include_sat) or (curr.weekday() == 6 and not include_sun):
curr += timedelta(days=1)
continue
target_dates.append(curr)
curr += timedelta(days=1)
# Check for existing logs for these workers on this date
# We want to find workers who ARE in selected_workers AND have a WorkLog on 'date'
conflicting_workers = Worker.objects.filter(
work_logs__date=date,
id__in=selected_workers.values_list('id', flat=True)
).distinct()
if conflicting_workers.exists() and not conflict_action:
context = {
'form': form,
'team_workers_json': json.dumps(team_workers_map),
'conflicting_workers': conflicting_workers,
'is_conflict': True,
'conflict_date': date,
}
return render(request, 'core/log_attendance.html', context)
# If we are here, either no conflicts or action is chosen
workers_to_save = list(selected_workers)
if conflict_action == 'skip':
# Exclude conflicting workers
conflicting_ids = conflicting_workers.values_list('id', flat=True)
workers_to_save = [w for w in selected_workers if w.id not in conflicting_ids]
if not workers_to_save:
messages.warning(request, "No new workers to log (all skipped).")
return redirect('home')
messages.success(request, f"Logged {len(workers_to_save)} workers (skipped {conflicting_workers.count()} duplicates).")
elif conflict_action == 'overwrite':
# Remove conflicting workers from their OLD logs
for worker in conflicting_workers:
old_logs = WorkLog.objects.filter(date=date, workers=worker)
for log in old_logs:
log.workers.remove(worker)
# Cleanup empty logs
if log.workers.count() == 0:
log.delete()
messages.success(request, f"Logged {len(workers_to_save)} workers (overwrote {conflicting_workers.count()} previous entries).")
else:
target_dates = [start_date]
# No conflicts initially
messages.success(request, "Work log saved successfully.")
if not target_dates:
messages.warning(request, "No valid dates selected (check weekends).")
return render(request, 'core/log_attendance.html', {
'form': form, 'team_workers_json': json.dumps(team_workers_map)
})
# Save the new log
work_log = form.save(commit=False)
if request.user.is_authenticated:
work_log.supervisor = request.user
work_log.save()
# Check Conflicts - Scan all target dates
if not conflict_action:
conflicts = []
for d in target_dates:
# Find workers who already have a log on this date
existing_logs = WorkLog.objects.filter(date=d, workers__in=selected_workers).distinct()
for log in existing_logs:
# Which of the selected workers are in this log?
for w in log.workers.all():
if w in selected_workers:
# Avoid adding duplicates if multiple logs exist for same worker/day (rare but possible)
conflict_entry = {'name': f"{w.name} ({d.strftime('%Y-%m-%d')})"}
if conflict_entry not in conflicts:
conflicts.append(conflict_entry)
# Manually set workers
work_log.workers.set(workers_to_save)
if conflicts:
context = {
'form': form,
'team_workers_json': json.dumps(team_workers_map),
'conflicting_workers': conflicts,
'is_conflict': True,
}
return render(request, 'core/log_attendance.html', context)
# Execution Phase
created_count = 0
skipped_count = 0
overwritten_count = 0
for d in target_dates:
# Find conflicts for this specific day
day_conflicts = Worker.objects.filter(
work_logs__date=d,
id__in=selected_workers.values_list('id', flat=True)
).distinct()
workers_to_save = list(selected_workers)
if day_conflicts.exists():
if conflict_action == 'skip':
conflicting_ids = day_conflicts.values_list('id', flat=True)
workers_to_save = [w for w in selected_workers if w.id not in conflicting_ids]
skipped_count += day_conflicts.count()
elif conflict_action == 'overwrite':
# Remove conflicting workers from their OLD logs
for worker in day_conflicts:
old_logs = WorkLog.objects.filter(date=d, workers=worker)
for log in old_logs:
log.workers.remove(worker)
if log.workers.count() == 0:
log.delete()
overwritten_count += day_conflicts.count()
# workers_to_save remains full list
if workers_to_save:
# Create Log
log = WorkLog.objects.create(
date=d,
project=project,
notes=notes,
supervisor=request.user if request.user.is_authenticated else None
)
log.workers.set(workers_to_save)
created_count += len(workers_to_save)
msg = f"Logged {created_count} entries."
if skipped_count:
msg += f" Skipped {skipped_count} duplicates."
if overwritten_count:
msg += f" Overwrote {overwritten_count} previous entries."
messages.success(request, msg)
# Redirect to home, which will then redirect back to log_attendance if restricted
return redirect('home')
else:
form = WorkLogForm(user=request.user if request.user.is_authenticated else None)
@ -209,213 +146,15 @@ def log_attendance(request):
return render(request, 'core/log_attendance.html', context)
@login_required
def work_log_list(request):
"""View work log history with advanced filtering."""
if not is_staff_or_supervisor(request.user):
return redirect('log_attendance')
logs = WorkLog.objects.all().order_by('-date')
return render(request, 'core/work_log_list.html', {'logs': logs})
worker_id = request.GET.get('worker')
team_id = request.GET.get('team')
project_id = request.GET.get('project')
payment_status = request.GET.get('payment_status') # 'paid', 'unpaid', 'all'
view_mode = request.GET.get('view', 'list')
logs = WorkLog.objects.all().prefetch_related('workers', 'workers__teams', 'project', 'supervisor', 'paid_in').order_by('-date', '-id')
target_worker = None
if worker_id:
logs = logs.filter(workers__id=worker_id)
# Fetch the worker to get the day rate reliably
target_worker = Worker.objects.filter(id=worker_id).first()
if team_id:
# Find workers in this team and filter logs containing them
team_workers = Worker.objects.filter(teams__id=team_id)
logs = logs.filter(workers__in=team_workers).distinct()
if project_id:
logs = logs.filter(project_id=project_id)
if payment_status == 'paid':
# Logs that are linked to at least one PayrollRecord
logs = logs.filter(paid_in__isnull=False).distinct()
elif payment_status == 'unpaid':
# This is tricky because a log can have multiple workers, some paid some not.
# But usually a WorkLog is marked paid when its workers are paid.
# If we filtered by worker, we can check if THAT worker is paid in that log.
if worker_id:
worker = get_object_or_404(Worker, pk=worker_id)
logs = logs.exclude(paid_in__worker=worker)
else:
logs = logs.filter(paid_in__isnull=True)
# Calculate amounts for display
# Convert to list to attach attributes
final_logs = []
total_amount = 0
# If Calendar View: Filter logs by Month BEFORE iterating to prevent fetching ALL history
if view_mode == 'calendar':
today = timezone.now().date()
try:
curr_year = int(request.GET.get('year', today.year))
curr_month = int(request.GET.get('month', today.month))
except ValueError:
curr_year = today.year
curr_month = today.month
# Bounds safety
if curr_month < 1: curr_month = 1;
if curr_month > 12: curr_month = 12;
# Get range
_, num_days = calendar.monthrange(curr_year, curr_month)
start_date = datetime.date(curr_year, curr_month, 1)
end_date = datetime.date(curr_year, curr_month, num_days)
logs = logs.filter(date__range=(start_date, end_date))
for log in logs:
if target_worker:
log.display_amount = target_worker.day_rate
else:
# Sum of all workers in this log
log.display_amount = sum(w.day_rate for w in log.workers.all())
final_logs.append(log)
total_amount += log.display_amount
# Context for filters
context = {
'total_amount': total_amount,
'workers': Worker.objects.filter(is_active=True).order_by('name'),
'teams': Team.objects.filter(is_active=True).order_by('name'),
'projects': Project.objects.filter(is_active=True).order_by('name'),
'selected_worker': int(worker_id) if worker_id else None,
'selected_team': int(team_id) if team_id else None,
'selected_project': int(project_id) if project_id else None,
'selected_payment_status': payment_status,
'target_worker': target_worker,
'view_mode': view_mode,
}
if view_mode == 'calendar':
# Group by date for easy lookup in template
logs_map = {}
for log in final_logs:
if log.date not in logs_map:
logs_map[log.date] = []
logs_map[log.date].append(log)
cal = calendar.Calendar(firstweekday=0) # Monday is 0
month_dates = cal.monthdatescalendar(curr_year, curr_month)
# Prepare structured data for template
calendar_weeks = []
for week in month_dates:
week_data = []
for d in week:
week_data.append({
'date': d,
'day': d.day,
'is_current_month': d.month == curr_month,
'logs': logs_map.get(d, [])
})
calendar_weeks.append(week_data)
# Nav Links
prev_month_date = start_date - datetime.timedelta(days=1)
next_month_date = end_date + datetime.timedelta(days=1)
context.update({
'calendar_weeks': calendar_weeks,
'curr_month': curr_month,
'curr_year': curr_year,
'month_name': calendar.month_name[curr_month],
'prev_month': prev_month_date.month,
'prev_year': prev_month_date.year,
'next_month': next_month_date.month,
'next_year': next_month_date.year,
})
else:
context['logs'] = final_logs
return render(request, 'core/work_log_list.html', context)
@login_required
def export_work_log_csv(request):
"""Export filtered work logs to CSV."""
if not is_staff_or_supervisor(request.user):
return HttpResponse("Unauthorized", status=401)
worker_id = request.GET.get('worker')
team_id = request.GET.get('team')
project_id = request.GET.get('project')
payment_status = request.GET.get('payment_status')
logs = WorkLog.objects.all().prefetch_related('workers', 'workers__teams', 'project', 'supervisor', 'paid_in').order_by('-date', '-id')
target_worker = None
if worker_id:
logs = logs.filter(workers__id=worker_id)
target_worker = Worker.objects.filter(id=worker_id).first()
if team_id:
team_workers = Worker.objects.filter(teams__id=team_id)
logs = logs.filter(workers__in=team_workers).distinct()
if project_id:
logs = logs.filter(project_id=project_id)
if payment_status == 'paid':
logs = logs.filter(paid_in__isnull=False).distinct()
elif payment_status == 'unpaid':
if worker_id:
worker = get_object_or_404(Worker, pk=worker_id)
logs = logs.exclude(paid_in__worker=worker)
else:
logs = logs.filter(paid_in__isnull=True)
response = HttpResponse(content_type='text/csv')
response['Content-Disposition'] = 'attachment; filename="work_logs.csv"'
writer = csv.writer(response)
writer.writerow(['Date', 'Project', 'Workers', 'Amount', 'Payment Status', 'Supervisor'])
for log in logs:
# Amount Logic
if target_worker:
display_amount = target_worker.day_rate
workers_str = target_worker.name
else:
display_amount = sum(w.day_rate for w in log.workers.all())
workers_str = ", ".join([w.name for w in log.workers.all()])
# Payment Status Logic
is_paid = log.paid_in.exists()
status_str = "Paid" if is_paid else "Pending"
writer.writerow([
log.date,
log.project.name,
workers_str,
f"{display_amount:.2f}",
status_str,
log.supervisor.username if log.supervisor else "System"
])
return response
@login_required
def manage_resources(request):
"""View to manage active status of resources."""
if not request.user.is_staff and not request.user.is_superuser:
return redirect('log_attendance')
# Prefetch teams for workers to avoid N+1 in template
workers = Worker.objects.all().prefetch_related('teams').order_by('name')
workers = Worker.objects.all().order_by('name')
projects = Project.objects.all().order_by('name')
teams = Team.objects.all().prefetch_related('workers').order_by('name')
teams = Team.objects.all().order_by('name')
context = {
'workers': workers,
@ -424,12 +163,8 @@ def manage_resources(request):
}
return render(request, 'core/manage_resources.html', context)
@login_required
def toggle_resource_status(request, model_type, pk):
"""Toggle the is_active status of a resource."""
if not request.user.is_staff and not request.user.is_superuser:
return redirect('log_attendance')
if request.method == 'POST':
model_map = {
'worker': Worker,
@ -443,59 +178,30 @@ def toggle_resource_status(request, model_type, pk):
obj.is_active = not obj.is_active
obj.save()
if request.headers.get('x-requested-with') == 'XMLHttpRequest':
return JsonResponse({
'success': True,
'is_active': obj.is_active,
'message': f"{obj.name} is now {'Active' if obj.is_active else 'Inactive'}."
})
return redirect('manage_resources')
@login_required
def payroll_dashboard(request):
"""Dashboard for payroll management with filtering."""
if not request.user.is_staff and not request.user.is_superuser:
return redirect('log_attendance')
status_filter = request.GET.get('status', 'pending') # pending, paid, all
# Common Analytics
outstanding_total = 0
active_workers = Worker.objects.filter(is_active=True).order_by('name').prefetch_related('adjustments')
active_workers = Worker.objects.filter(is_active=True).order_by('name')
workers_data = [] # For pending payments
for worker in active_workers:
# Unpaid Work Logs
unpaid_logs = worker.work_logs.exclude(paid_in__worker=worker)
log_count = unpaid_logs.count()
log_amount = log_count * worker.day_rate
# 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
count = unpaid_logs.count()
amount = count * worker.day_rate
if count > 0:
outstanding_total += amount
if status_filter in ['pending', 'all']:
workers_data.append({
'worker': worker,
'unpaid_count': log_count,
'unpaid_amount': log_amount,
'adj_amount': adj_total,
'total_payable': total_payable,
'adjustments': pending_adjustments,
'unpaid_count': count,
'unpaid_amount': amount,
'logs': unpaid_logs
})
@ -521,9 +227,6 @@ 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,
@ -531,61 +234,30 @@ 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)
@login_required
def process_payment(request, worker_id):
"""Process payment for a worker, mark logs as paid, link adjustments, and email receipt."""
if not request.user.is_staff and not request.user.is_superuser:
return redirect('log_attendance')
"""Process payment for a worker, mark logs as paid, 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)
log_count = unpaid_logs.count()
logs_amount = log_count * worker.day_rate
count = unpaid_logs.count()
# Find pending adjustments
pending_adjustments = worker.adjustments.filter(payroll_record__isnull=True)
adj_amount = Decimal('0.00')
if count > 0:
amount = count * worker.day_rate
for adj in pending_adjustments:
if adj.type in ['BONUS', 'OVERTIME', 'LOAN']:
adj_amount += adj.amount
elif adj.type in ['DEDUCTION', 'LOAN_REPAYMENT']:
adj_amount -= adj.amount
total_amount = logs_amount + adj_amount
if log_count > 0 or pending_adjustments.exists():
# Create Payroll Record
payroll_record = PayrollRecord.objects.create(
worker=worker,
amount=total_amount,
amount=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
@ -594,18 +266,16 @@ 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"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."
f"Amount Paid: R {payroll_record.amount}\n\n"
f"This is an automated notification from Fox Fitt Payroll."
)
recipient_list = ['foxfitt-ed9wc+expense@to.sparkreceipt.com']
try:
send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, recipient_list)
messages.success(request, f"Payment processed for {worker.name}. Net Pay: R {payroll_record.amount}")
messages.success(request, f"Payment of R {payroll_record.amount} processed for {worker.name}. Email sent to accounting.")
except Exception as e:
messages.warning(request, f"Payment processed, but email delivery failed: {str(e)}")
@ -613,192 +283,15 @@ def process_payment(request, worker_id):
return redirect('payroll_dashboard')
@login_required
def payslip_detail(request, pk):
"""Show details of a payslip (Payment Record)."""
if not request.user.is_staff and not request.user.is_superuser:
return redirect('log_attendance')
record = get_object_or_404(PayrollRecord, pk=pk)
# Get the logs included in this payment
logs = record.work_logs.all().order_by('date')
adjustments = record.adjustments.all().order_by('type')
# Calculate base pay from logs (re-verify logic)
# The record.amount is the final NET.
# We can reconstruct the display.
base_pay = sum(w.day_rate for l in logs for w in l.workers.all() if w == record.worker)
adjustments_net = record.amount - base_pay
context = {
'record': record,
'logs': logs,
'adjustments': adjustments,
'base_pay': base_pay,
'adjustments_net': adjustments_net,
}
return render(request, 'core/payslip.html', context)
@login_required
def loan_list(request):
"""List outstanding and historical loans."""
if not request.user.is_staff and not request.user.is_superuser:
return redirect('log_attendance')
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)
@login_required
def add_loan(request):
"""Create a new loan."""
if not request.user.is_staff and not request.user.is_superuser:
return redirect('log_attendance')
if request.method == 'POST':
worker_id = request.POST.get('worker')
amount = request.POST.get('amount')
reason = request.POST.get('reason')
date = request.POST.get('date') or timezone.now().date()
if worker_id and amount:
worker = get_object_or_404(Worker, pk=worker_id)
Loan.objects.create(
worker=worker,
amount=amount,
date=date,
reason=reason
)
messages.success(request, f"Loan of R{amount} recorded for {worker.name}.")
return redirect('loan_list')
@login_required
def add_adjustment(request):
"""Add a payroll adjustment (Bonus, Overtime, Deduction, Repayment)."""
if not request.user.is_staff and not request.user.is_superuser:
return redirect('log_attendance')
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')
@login_required
def create_receipt(request):
"""Create a new expense receipt and email it."""
if not is_staff_or_supervisor(request.user):
return redirect('log_attendance')
if request.method == 'POST':
form = ExpenseReceiptForm(request.POST)
items = ExpenseLineItemFormSet(request.POST)
if form.is_valid() and items.is_valid():
receipt = form.save(commit=False)
receipt.user = request.user
receipt.save()
items.instance = receipt
line_items = items.save()
# Backend Calculation for Consistency
sum_amount = sum(item.amount for item in line_items)
vat_type = receipt.vat_type
if vat_type == 'INCLUDED':
receipt.total_amount = sum_amount
receipt.subtotal = sum_amount / Decimal('1.15')
receipt.vat_amount = receipt.total_amount - receipt.subtotal
elif vat_type == 'EXCLUDED':
receipt.subtotal = sum_amount
receipt.vat_amount = sum_amount * Decimal('0.15')
receipt.total_amount = receipt.subtotal + receipt.vat_amount
else: # NONE
receipt.subtotal = sum_amount
receipt.vat_amount = Decimal('0.00')
receipt.total_amount = sum_amount
receipt.save()
# Email Generation
subject = f"Receipt from {receipt.vendor} - {receipt.date}"
recipient_list = ['foxfitt-ed9wc+expense@to.sparkreceipt.com']
# Prepare HTML content
html_message = render_to_string('core/email/receipt_email.html', {
'receipt': receipt,
'items': line_items,
})
plain_message = strip_tags(html_message)
try:
send_mail(
subject,
plain_message,
settings.DEFAULT_FROM_EMAIL,
recipient_list,
html_message=html_message
)
messages.success(request, "Receipt created and sent to SparkReceipt.")
return redirect('create_receipt')
except Exception as e:
messages.warning(request, f"Receipt saved, but email failed: {e}")
else:
form = ExpenseReceiptForm(initial={'date': timezone.now().date()})
items = ExpenseLineItemFormSet()
return render(request, 'core/create_receipt.html', {
'form': form,
'items': items
})

View File

@ -23,12 +23,6 @@ h1, h2, h3, .heading-font {
font-weight: 700;
}
.navbar {
position: sticky;
top: 0;
z-index: 1000;
}
.dashboard-header {
background: linear-gradient(135deg, var(--primary-color) 0%, #334155 100%);
color: var(--white);