diff --git a/config/__pycache__/settings.cpython-311.pyc b/config/__pycache__/settings.cpython-311.pyc index e3cb086..99b0063 100644 Binary files a/config/__pycache__/settings.cpython-311.pyc and b/config/__pycache__/settings.cpython-311.pyc differ diff --git a/config/__pycache__/urls.cpython-311.pyc b/config/__pycache__/urls.cpython-311.pyc index 0b85e94..ecbd4a1 100644 Binary files a/config/__pycache__/urls.cpython-311.pyc and b/config/__pycache__/urls.cpython-311.pyc differ diff --git a/config/settings.py b/config/settings.py index a2b098a..714f84e 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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' \ No newline at end of file diff --git a/config/urls.py b/config/urls.py index bcfc074..2cb6937 100644 --- a/config/urls.py +++ b/config/urls.py @@ -21,9 +21,10 @@ 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")), ] if settings.DEBUG: urlpatterns += static("/assets/", document_root=settings.BASE_DIR / "assets") - urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) \ No newline at end of file diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc index 3e95dc8..1b9f2f5 100644 Binary files a/core/__pycache__/forms.cpython-311.pyc and b/core/__pycache__/forms.cpython-311.pyc differ diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc index a8b9e8e..c43fb03 100644 Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index a816d12..2b150b2 100644 Binary files a/core/__pycache__/urls.cpython-311.pyc and b/core/__pycache__/urls.cpython-311.pyc differ diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index 2f4097c..bed590a 100644 Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ diff --git a/core/forms.py b/core/forms.py index 17cd42a..3a149ed 100644 --- a/core/forms.py +++ b/core/forms.py @@ -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( @@ -56,4 +57,27 @@ class WorkLogForm(forms.ModelForm): else: self.fields['project'].queryset = projects_qs self.fields['workers'].queryset = workers_qs - self.fields['team'].queryset = teams_qs \ No newline at end of file + 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'}), + } +) diff --git a/core/migrations/0007_expensereceipt_expenselineitem.py b/core/migrations/0007_expensereceipt_expenselineitem.py new file mode 100644 index 0000000..91bd871 --- /dev/null +++ b/core/migrations/0007_expensereceipt_expenselineitem.py @@ -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')), + ], + ), + ] diff --git a/core/migrations/__pycache__/0007_expensereceipt_expenselineitem.cpython-311.pyc b/core/migrations/__pycache__/0007_expensereceipt_expenselineitem.cpython-311.pyc new file mode 100644 index 0000000..499f0df Binary files /dev/null and b/core/migrations/__pycache__/0007_expensereceipt_expenselineitem.cpython-311.pyc differ diff --git a/core/models.py b/core/models.py index 1f48574..ce5f02d 100644 --- a/core/models.py +++ b/core/models.py @@ -101,4 +101,42 @@ class PayrollAdjustment(models.Model): 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}" \ No newline at end of file + 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}" diff --git a/core/templates/base.html b/core/templates/base.html index 65cf9e5..f1305e7 100644 --- a/core/templates/base.html +++ b/core/templates/base.html @@ -12,6 +12,8 @@ + + + + +
+
+
RECEIPT FROM
+
{{ receipt.vendor }}
+
+ +
+ Date: {{ receipt.date }}
+ Payment Method: {{ receipt.get_payment_method_display }}
+ Description: {{ receipt.description|default:"-" }} +
+ + + + + + + + + + {% for item in items %} + + + + + {% endfor %} + +
ItemAmount
{{ item.product }}R {{ item.amount }}
+ +
+

Subtotal: R {{ receipt.subtotal }}

+

VAT ({{ receipt.get_vat_type_display }}): R {{ receipt.vat_amount }}

+

Total: R {{ receipt.total_amount }}

+
+ + +
+ + diff --git a/core/templates/registration/login.html b/core/templates/registration/login.html new file mode 100644 index 0000000..85d56e9 --- /dev/null +++ b/core/templates/registration/login.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} +{% block title %}Login - Fox Fitt{% endblock %} + +{% block content %} +
+
+
+
+
+
+

Fox Fitt

+

Sign in to manage work & payroll

+
+ + {% if form.errors %} +
+ Your username and password didn't match. Please try again. +
+ {% endif %} + + {% if next %} + {% if user.is_authenticated %} +
+ Your account doesn't have access to this page. To proceed, + please login with an account that has access. +
+ {% else %} +
+ Please login to see this page. +
+ {% endif %} + {% endif %} + +
+ {% csrf_token %} +
+ + +
+
+ + +
+ +
+ +
+ + +
+
+
+ +
+

Forgot your password? Please contact your administrator.

+
+
+
+
+{% endblock %} diff --git a/core/urls.py b/core/urls.py index 0bc5561..3b34f7b 100644 --- a/core/urls.py +++ b/core/urls.py @@ -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"), +] \ No newline at end of file diff --git a/core/views.py b/core/views.py index 91a8460..1e77768 100644 --- a/core/views.py +++ b/core/views.py @@ -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 + }) \ No newline at end of file