Ver 10 Receipts added

This commit is contained in:
Flatlogic Bot 2026-02-04 13:33:29 +00:00
parent ee87ae82bf
commit 4c6eb17d09
18 changed files with 652 additions and 17 deletions

View File

@ -181,3 +181,8 @@ 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,6 +21,7 @@ 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,5 +1,6 @@
from django import forms
from .models import WorkLog, Project, Worker, Team
from django.forms import inlineformset_factory
from .models import WorkLog, Project, Worker, Team, ExpenseReceipt, ExpenseLineItem
class WorkLogForm(forms.ModelForm):
end_date = forms.DateField(
@ -57,3 +58,26 @@ 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

@ -0,0 +1,42 @@
# 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

@ -102,3 +102,41 @@ class PayrollAdjustment(models.Model):
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,6 +12,8 @@
<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>
@ -30,13 +32,41 @@
</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>
<li class="nav-item ms-lg-3"><a class="btn btn-sm btn-outline-light" href="/admin/">Admin Panel</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 %}
</ul>
</div>
</div>

View File

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

@ -0,0 +1,59 @@
<!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

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

@ -11,7 +11,8 @@ from .views import (
payslip_detail,
loan_list,
add_loan,
add_adjustment
add_adjustment,
create_receipt
)
urlpatterns = [
@ -27,4 +28,5 @@ urlpatterns = [
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

@ -6,19 +6,32 @@ 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
from django.contrib.auth.decorators import login_required, user_passes_test
from django.db.models import Sum, Q, Prefetch
from django.core.mail import send_mail
from django.conf import settings
from django.contrib import messages
from django.http import JsonResponse, HttpResponse
from .models import Worker, Project, Team, WorkLog, PayrollRecord, Loan, PayrollAdjustment
from .forms import WorkLogForm
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from .models import Worker, Project, Team, WorkLog, PayrollRecord, Loan, PayrollAdjustment, ExpenseReceipt, ExpenseLineItem
from .forms import WorkLogForm, ExpenseReceiptForm, ExpenseLineItemFormSet
from datetime import timedelta
from decimal import Decimal
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()
@ -66,6 +79,7 @@ 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)
@ -183,6 +197,7 @@ def log_attendance(request):
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)
@ -194,8 +209,12 @@ 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')
worker_id = request.GET.get('worker')
team_id = request.GET.get('team')
project_id = request.GET.get('project')
@ -323,8 +342,12 @@ def work_log_list(request):
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')
@ -383,8 +406,12 @@ def export_work_log_csv(request):
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')
projects = Project.objects.all().order_by('name')
@ -397,8 +424,12 @@ 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,
@ -416,13 +447,17 @@ def toggle_resource_status(request, model_type, pk):
return JsonResponse({
'success': True,
'is_active': obj.is_active,
'message': f"{obj.name} is now {'Active' if obj.is_active else 'Inactive'}ணை."
'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
@ -501,8 +536,12 @@ def payroll_dashboard(request):
}
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')
worker = get_object_or_404(Worker, pk=worker_id)
if request.method == 'POST':
@ -574,8 +613,12 @@ 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
@ -597,8 +640,12 @@ def payslip_detail(request, pk):
}
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':
@ -613,8 +660,12 @@ def loan_list(request):
}
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')
@ -633,8 +684,12 @@ def add_loan(request):
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')
@ -677,3 +732,73 @@ def add_adjustment(request):
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
})