Ver 14 - Dashboard redesign, permissions, payslip preview, team tracking

- Dashboard: Outstanding Payments, Paid This Month, Active Loans cards
- Dashboard: This Week summary, Recent Activity, Quick Actions, Manage Resources
- Dashboard: Active/Inactive/All filter for resources
- Payroll: Preview payslip modal (no DB/email side effects)
- Payroll: Multi-select workers in adjustment modal
- History: Team column + direct team FK on WorkLog
- History: Shift+click multi-date selection on calendar
- Permissions: Replaced PIN system with Django groups (Admin, Work Logger)
- Permissions: Renamed Supervisor to Work Logger throughout
- Nav: Hide financial links (Payroll) from non-admin users
- Admin: Enhanced Django admin with group management
- New migrations: 0011 (remove pin/is_admin), 0012 (add team to WorkLog)
- New management command: setup_groups

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Konradzar 2026-02-08 23:43:38 +02:00
parent 1de0631044
commit d922a14ec5
12 changed files with 1293 additions and 284 deletions

View File

@ -1,26 +1,99 @@
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.models import User
from django.db import models
from .models import Worker, Project, Team, WorkLog, UserProfile from .models import Worker, Project, Team, WorkLog, UserProfile
@admin.register(UserProfile)
class UserProfileAdmin(admin.ModelAdmin): # Inline UserProfile on User admin
list_display = ('user', 'pin', 'is_admin') class UserProfileInline(admin.StackedInline):
model = UserProfile
can_delete = False
verbose_name_plural = 'Profile'
# Customise the User admin to show groups prominently
class UserAdmin(BaseUserAdmin):
inlines = [UserProfileInline]
list_display = ('username', 'first_name', 'last_name', 'is_staff', 'get_groups')
list_filter = ('is_staff', 'is_superuser', 'is_active', 'groups')
# Reorder fieldsets so Groups appears near the top
fieldsets = (
(None, {'fields': ('username', 'password')}),
('Personal info', {'fields': ('first_name', 'last_name', 'email')}),
('Role & Groups', {
'description': 'Assign user to "Admin" or "Work Logger" group to set permissions automatically.',
'fields': ('is_active', 'is_staff', 'groups'),
}),
('Individual Permissions', {
'classes': ('collapse',),
'description': 'Fine-tune permissions beyond what the group provides. Usually not needed.',
'fields': ('is_superuser', 'user_permissions'),
}),
('Important dates', {
'classes': ('collapse',),
'fields': ('last_login', 'date_joined'),
}),
)
def get_groups(self, obj):
return ', '.join(g.name for g in obj.groups.all()) or '-'
get_groups.short_description = 'Groups'
# Re-register User with our custom admin
admin.site.unregister(User)
admin.site.register(User, UserAdmin)
@admin.register(Worker) @admin.register(Worker)
class WorkerAdmin(admin.ModelAdmin): class WorkerAdmin(admin.ModelAdmin):
list_display = ('name', 'id_no', 'phone_no', 'monthly_salary', 'date_of_employment', 'projects_worked_on_count') list_display = ('name', 'id_no', 'phone_no', 'monthly_salary', 'date_of_employment', 'projects_worked_on_count')
search_fields = ('name', 'id_no') search_fields = ('name', 'id_no')
readonly_fields = ('projects_worked_on_count',) # Calculated field should be readonly in edit form readonly_fields = ('projects_worked_on_count',)
@admin.register(Project) @admin.register(Project)
class ProjectAdmin(admin.ModelAdmin): class ProjectAdmin(admin.ModelAdmin):
list_display = ('name', 'created_at') list_display = ('name', 'get_supervisors', 'is_active', 'created_at')
list_filter = ('is_active',)
filter_horizontal = ('supervisors',) filter_horizontal = ('supervisors',)
def get_supervisors(self, obj):
return ', '.join(u.username for u in obj.supervisors.all()) or '-'
get_supervisors.short_description = 'Supervisors'
def formfield_for_manytomany(self, db_field, request, **kwargs):
if db_field.name == 'supervisors':
# Only show users who are staff or in Work Logger/Admin group
kwargs['queryset'] = User.objects.filter(
models.Q(is_staff=True) |
models.Q(groups__name__in=['Admin', 'Work Logger'])
).distinct().order_by('username')
return super().formfield_for_manytomany(db_field, request, **kwargs)
@admin.register(Team) @admin.register(Team)
class TeamAdmin(admin.ModelAdmin): class TeamAdmin(admin.ModelAdmin):
list_display = ('name', 'supervisor', 'created_at') list_display = ('name', 'supervisor', 'worker_count', 'is_active', 'created_at')
list_filter = ('is_active', 'supervisor')
filter_horizontal = ('workers',) filter_horizontal = ('workers',)
def worker_count(self, obj):
return obj.workers.count()
worker_count.short_description = 'Workers'
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name == 'supervisor':
# Only show users who are staff or in Work Logger/Admin group
kwargs['queryset'] = User.objects.filter(
models.Q(is_staff=True) |
models.Q(groups__name__in=['Admin', 'Work Logger'])
).distinct().order_by('username')
return super().formfield_for_foreignkey(db_field, request, **kwargs)
@admin.register(WorkLog) @admin.register(WorkLog)
class WorkLogAdmin(admin.ModelAdmin): class WorkLogAdmin(admin.ModelAdmin):
list_display = ('date', 'project', 'supervisor') list_display = ('date', 'project', 'supervisor')

View File

@ -0,0 +1,69 @@
from django.core.management.base import BaseCommand
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
class Command(BaseCommand):
help = 'Creates Admin and Work Logger permission groups with pre-assigned permissions'
def handle(self, *args, **options):
# --- Admin Group ---
# Full access to all core business models + user management
admin_group, created = Group.objects.get_or_create(name='Admin')
admin_perms = []
# All core model permissions
for model in ['project', 'worker', 'team', 'worklog', 'payrollrecord',
'loan', 'payrolladjustment', 'expensereceipt', 'expenselineitem']:
ct = ContentType.objects.filter(app_label='core', model=model).first()
if ct:
admin_perms.extend(Permission.objects.filter(content_type=ct))
# User management permissions
user_ct = ContentType.objects.filter(app_label='auth', model='user').first()
if user_ct:
admin_perms.extend(Permission.objects.filter(content_type=user_ct))
group_ct = ContentType.objects.filter(app_label='auth', model='group').first()
if group_ct:
admin_perms.extend(Permission.objects.filter(content_type=group_ct))
admin_group.permissions.set(admin_perms)
status = 'Created' if created else 'Updated'
self.stdout.write(self.style.SUCCESS(
f'{status} "Admin" group with {admin_group.permissions.count()} permissions'
))
# --- Work Logger Group ---
# Can log work, view history, create receipts - restricted to their teams/projects
supervisor_group, created = Group.objects.get_or_create(name='Work Logger')
supervisor_codenames = [
# Projects - view only
'view_project',
# Workers - view only
'view_worker',
# Teams - view only
'view_team',
# Work logs - full access (log attendance, edit, view)
'add_worklog', 'change_worklog', 'view_worklog',
# Expense receipts - create and view
'add_expensereceipt', 'view_expensereceipt',
# Expense line items - create and view (needed for receipt creation)
'add_expenselineitem', 'view_expenselineitem',
]
supervisor_perms = Permission.objects.filter(
content_type__app_label='core',
codename__in=supervisor_codenames
)
supervisor_group.permissions.set(supervisor_perms)
status = 'Created' if created else 'Updated'
self.stdout.write(self.style.SUCCESS(
f'{status} "Work Logger" group with {supervisor_group.permissions.count()} permissions'
))
self.stdout.write('')
self.stdout.write('To assign a user to a group:')
self.stdout.write(' 1. Go to Admin Panel > Users > select user')
self.stdout.write(' 2. Under "Groups", add them to "Admin" or "Work Logger"')
self.stdout.write(' 3. For Work Loggers, also assign them to Projects/Teams')

View File

@ -0,0 +1,21 @@
# Generated by Django 5.2.7 on 2026-02-08 18:07
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('core', '0010_alter_expenselineitem_options_and_more'),
]
operations = [
migrations.RemoveField(
model_name='userprofile',
name='is_admin',
),
migrations.RemoveField(
model_name='userprofile',
name='pin',
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 5.2.7 on 2026-02-08 19:18
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0011_remove_userprofile_pin_and_is_admin'),
]
operations = [
migrations.AddField(
model_name='worklog',
name='team',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='work_logs', to='core.team'),
),
]

View File

@ -6,8 +6,6 @@ from django.utils import timezone
class UserProfile(models.Model): class UserProfile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile') user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
pin = models.CharField(max_length=4, help_text="4-digit PIN for login")
is_admin = models.BooleanField(default=False)
class Meta: class Meta:
verbose_name = "User Profile" verbose_name = "User Profile"
@ -78,6 +76,7 @@ class Team(models.Model):
class WorkLog(models.Model): class WorkLog(models.Model):
date = models.DateField() date = models.DateField()
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='logs') project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='logs')
team = models.ForeignKey(Team, on_delete=models.SET_NULL, null=True, blank=True, related_name='work_logs')
workers = models.ManyToManyField(Worker, related_name='work_logs') workers = models.ManyToManyField(Worker, related_name='work_logs')
supervisor = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) supervisor = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
notes = models.TextField(blank=True) notes = models.TextField(blank=True)

View File

@ -33,47 +33,31 @@
<div class="collapse navbar-collapse" id="navbarNav"> <div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto align-items-center"> <ul class="navbar-nav ms-auto align-items-center">
{% if user.is_authenticated %} {% if user.is_authenticated %}
{# Dashboard #} {# Dashboard - visible to Admin & Work Logger #}
{% if user.is_staff or user.is_superuser or perms.core.view_project or user.managed_teams.exists or user.assigned_projects.exists %} {% if user.is_staff or user.is_superuser or perms.core.view_project or user.managed_teams.exists or user.assigned_projects.exists %}
<li class="nav-item"><a class="nav-link" href="{% url 'home' %}">Dashboard</a></li> <li class="nav-item"><a class="nav-link" href="{% url 'home' %}">Dashboard</a></li>
{% endif %} {% endif %}
{# Log Work #} {# Log Work - visible to Admin & Work Logger #}
{% if user.is_staff or user.is_superuser or perms.core.add_worklog %} {% if user.is_staff or user.is_superuser or perms.core.add_worklog or user.managed_teams.exists or user.assigned_projects.exists %}
<li class="nav-item"><a class="nav-link" href="{% url 'log_attendance' %}">Log Work</a></li> <li class="nav-item"><a class="nav-link" href="{% url 'log_attendance' %}">Log Work</a></li>
{% else %}
{# Fallback for existing users if strict mode is not desired, but user requested hiding. #}
{# I will leave it visible ONLY if they are supervisors to maintain status quo for them if they lack permissions #}
{% if user.managed_teams.exists or user.assigned_projects.exists %}
<li class="nav-item"><a class="nav-link" href="{% url 'log_attendance' %}">Log Work</a></li>
{% endif %}
{% endif %} {% endif %}
{# History #} {# History - visible to Admin & Work Logger #}
{% if user.is_staff or user.is_superuser or perms.core.view_worklog or user.managed_teams.exists or user.assigned_projects.exists %} {% if user.is_staff or user.is_superuser or perms.core.view_worklog 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 'work_log_list' %}">History</a></li>
{% endif %} {% endif %}
{# Payroll #} {# Payroll - Admin only #}
{% if user.is_staff or user.is_superuser or perms.core.view_payrollrecord %} {% if user.is_staff or user.is_superuser %}
<li class="nav-item"><a class="nav-link" href="{% url 'payroll_dashboard' %}">Payroll</a></li> <li class="nav-item"><a class="nav-link" href="{% url 'payroll_dashboard' %}">Payroll</a></li>
{% endif %} {% endif %}
{# Loans #} {# Receipts - visible to Admin & Work Logger #}
{% if user.is_staff or user.is_superuser or perms.core.view_loan %} {% if user.is_staff or user.is_superuser or perms.core.add_expensereceipt or user.managed_teams.exists or user.assigned_projects.exists %}
<li class="nav-item"><a class="nav-link" href="{% url 'loan_list' %}">Loans</a></li>
{% endif %}
{# Receipts #}
{% if user.is_staff or user.is_superuser or perms.core.add_expensereceipt %}
<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 'create_receipt' %}">Receipts</a></li>
{% endif %} {% endif %}
{# Manage #}
{% if user.is_staff or user.is_superuser or perms.core.change_worker %}
<li class="nav-item"><a class="nav-link" href="{% url 'manage_resources' %}">Manage</a></li>
{% endif %}
<li class="nav-item dropdown ms-lg-3"> <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"> <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 }} {{ user.username }}

View File

@ -4,6 +4,11 @@
{% block title %}Dashboard | Fox Fitt{% endblock %} {% block title %}Dashboard | Fox Fitt{% endblock %}
{% block content %} {% block content %}
<style>
.resource-hidden { display: none !important; }
.quick-action-btn { display: flex; align-items: center; gap: 0.5rem; padding: 0.6rem 1rem; border-radius: 0.5rem; font-weight: 500; font-size: 0.875rem; transition: all 0.15s; text-decoration: none; }
.quick-action-btn:hover { transform: translateX(3px); }
</style>
<div class="dashboard-header"> <div class="dashboard-header">
<div class="container"> <div class="container">
<div class="row align-items-center"> <div class="row align-items-center">
@ -21,14 +26,16 @@
</div> </div>
<div class="container mb-5"> <div class="container mb-5">
<!-- Payroll Analytics Row --> <!-- Stats Row -->
<div class="row g-4 mb-4"> <div class="row g-4 mb-4">
{% if is_admin_user %}
<!-- Admin: Outstanding Payments -->
<div class="col-md-4"> <div class="col-md-4">
<div class="card stat-card p-4 border-start border-4 border-warning"> <div class="card stat-card p-4 border-start border-4 border-warning">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<div> <div>
<p class="text-uppercase small fw-bold mb-1 opacity-75">Outstanding Payments</p> <p class="text-uppercase small fw-bold mb-1 opacity-75">Outstanding Payments</p>
<h2 class="mb-0">R {{ outstanding_total|intcomma }}</h2> <h2 class="mb-0">R {{ outstanding_total|floatformat:2|intcomma }}</h2>
</div> </div>
<div class="p-3 bg-warning bg-opacity-10 rounded-circle text-warning"> <div class="p-3 bg-warning bg-opacity-10 rounded-circle text-warning">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="1" x2="12" y2="23"></line><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path></svg> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="1" x2="12" y2="23"></line><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path></svg>
@ -37,12 +44,13 @@
<a href="{% url 'payroll_dashboard' %}" class="small text-muted mt-2 d-block stretched-link">View details &rarr;</a> <a href="{% url 'payroll_dashboard' %}" class="small text-muted mt-2 d-block stretched-link">View details &rarr;</a>
</div> </div>
</div> </div>
<!-- Admin: Paid This Month -->
<div class="col-md-4"> <div class="col-md-4">
<div class="card stat-card p-4 border-start border-4 border-success"> <div class="card stat-card p-4 border-start border-4 border-success">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<div> <div>
<p class="text-uppercase small fw-bold mb-1 opacity-75">Paid (Last 60 Days)</p> <p class="text-uppercase small fw-bold mb-1 opacity-75">Paid This Month</p>
<h2 class="mb-0">R {{ recent_payments_total|intcomma }}</h2> <h2 class="mb-0">R {{ recent_payments_total|floatformat:2|intcomma }}</h2>
</div> </div>
<div class="p-3 bg-success bg-opacity-10 rounded-circle text-success"> <div class="p-3 bg-success bg-opacity-10 rounded-circle text-success">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
@ -50,6 +58,52 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Admin: Active Loans -->
<div class="col-md-4">
<div class="card stat-card p-4 border-start border-4 border-danger">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="text-uppercase small fw-bold mb-1 opacity-75">Active Loans</p>
<h2 class="mb-0">R {{ active_loans_total|floatformat:2|intcomma }}</h2>
<small class="text-muted">{{ active_loans_count }} loan{{ active_loans_count|pluralize }}</small>
</div>
<div class="p-3 bg-danger bg-opacity-10 rounded-circle text-danger">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="1" y="4" width="22" height="16" rx="2" ry="2"></rect><line x1="1" y1="10" x2="23" y2="10"></line></svg>
</div>
</div>
<a href="{% url 'payroll_dashboard' %}?status=loans" class="small text-muted mt-1 d-block stretched-link">View loans &rarr;</a>
</div>
</div>
{% else %}
<!-- Non-admin: Active Projects -->
<div class="col-md-4">
<div class="card stat-card p-4 border-start border-4 border-info">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="text-uppercase small fw-bold mb-1 opacity-75">Active Projects</p>
<h2 class="mb-0">{{ projects_count }}</h2>
</div>
<div class="p-3 bg-info bg-opacity-10 rounded-circle text-info">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line></svg>
</div>
</div>
</div>
</div>
<!-- Non-admin: Active Teams -->
<div class="col-md-4">
<div class="card stat-card p-4 border-start border-4 border-secondary">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="text-uppercase small fw-bold mb-1 opacity-75">Active Teams</p>
<h2 class="mb-0">{{ teams_count }}</h2>
</div>
<div class="p-3 bg-secondary bg-opacity-10 rounded-circle text-secondary">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
</div>
</div>
</div>
</div>
<!-- Non-admin: Active Workers -->
<div class="col-md-4"> <div class="col-md-4">
<div class="card stat-card p-4"> <div class="card stat-card p-4">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
@ -63,100 +117,282 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="row">
<div class="col-lg-8">
<!-- Project Costs -->
<div class="card p-4 mb-4">
<h3 class="mb-3">Project Costs (Active)</h3>
{% if project_costs %}
<div class="table-responsive">
<table class="table align-middle">
<thead class="table-light">
<tr>
<th>Project Name</th>
<th class="text-end">Total Labor Cost</th>
</tr>
</thead>
<tbody>
{% for p in project_costs %}
<tr>
<td class="fw-bold">{{ p.name }}</td>
<td class="text-end">R {{ p.cost|intcomma }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">No cost data available for active projects.</p>
{% endif %} {% endif %}
</div> </div>
<!-- Recent Logs --> <div class="row g-4">
<!-- Left Column -->
<div class="col-lg-8">
<!-- This Week Summary -->
<div class="card p-4 mb-4"> <div class="card p-4 mb-4">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center">
<h3 class="mb-0">Recent Daily Logs</h3> <h5 class="mb-0"><i class="bi bi-calendar-week me-2"></i>This Week</h5>
<a href="{% url 'work_log_list' %}" class="btn btn-sm btn-link text-decoration-none">View All</a> <a href="{% url 'work_log_list' %}?view=calendar" class="btn btn-sm btn-link text-decoration-none">View Calendar &rarr;</a>
</div>
<div class="d-flex gap-4 mt-3">
<div class="text-center">
<div class="fs-3 fw-bold text-primary">{{ week_worker_days }}</div>
<div class="text-muted small">Worker-Days</div>
</div>
<div class="text-center">
<div class="fs-3 fw-bold text-primary">{{ week_projects }}</div>
<div class="text-muted small">Projects</div>
</div>
<div class="text-center">
<div class="fs-3 fw-bold text-primary">{{ workers_count }}</div>
<div class="text-muted small">Active Workers</div>
</div>
</div>
</div>
<!-- Recent Activity -->
<div class="card p-4 mb-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0">Recent Activity</h5>
<a href="{% url 'work_log_list' %}" class="btn btn-sm btn-link text-decoration-none">View All &rarr;</a>
</div> </div>
{% if recent_logs %} {% if recent_logs %}
<div class="table-responsive"> <div class="table-responsive">
<table class="table align-middle"> <table class="table table-sm align-middle mb-0">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th>Date</th> <th>Date</th>
<th>Project</th> <th>Project</th>
<th>Team</th>
<th>Workers</th> <th>Workers</th>
<th>Status</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for log in recent_logs %} {% for log in recent_logs %}
<tr> <tr>
<td>{{ log.date }}</td> <td class="text-muted small">{{ log.date|date:"D, d M" }}</td>
<td><strong>{{ log.project.name }}</strong></td> <td><strong class="small">{{ log.project.name }}</strong></td>
<td>{{ log.workers.count }} labourers</td> <td class="small">{% if log.team %}{{ log.team.name }}{% else %}<span class="text-muted">-</span>{% endif %}</td>
<td><span class="badge bg-success bg-opacity-10 text-success">Submitted</span></td> <td class="small">{{ log.workers.count }} labourer{{ log.workers.count|pluralize }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
{% else %} {% else %}
<div class="text-center py-5"> <div class="text-center py-4">
<div class="mb-3 opacity-25"> <p class="text-muted mb-2">No recent work logs found.</p>
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
</div>
<p class="text-muted">No recent work logs found.</p>
<a href="{% url 'log_attendance' %}" class="btn btn-sm btn-outline-primary">Create First Log</a> <a href="{% url 'log_attendance' %}" class="btn btn-sm btn-outline-primary">Create First Log</a>
</div> </div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<!-- Right Column -->
<div class="col-lg-4"> <div class="col-lg-4">
<div class="card p-4"> {% if is_admin_user %}
<h3 class="mb-4">Quick Links</h3> <!-- Quick Actions -->
<nav class="nav flex-column"> <div class="card p-4 mb-4">
<a class="sidebar-link" href="{% url 'payroll_dashboard' %}"> <h5 class="mb-3">Quick Actions</h5>
<span class="me-2">💰</span> Payroll Dashboard <div class="d-grid gap-2">
<a href="{% url 'log_attendance' %}" class="quick-action-btn btn btn-outline-primary">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="12" y1="18" x2="12" y2="12"></line><line x1="9" y1="15" x2="15" y2="15"></line></svg>
Log Daily Work
</a> </a>
<a class="sidebar-link" href="/admin/core/worker/"> <a href="{% url 'payroll_dashboard' %}" class="quick-action-btn btn btn-outline-success">
<span class="me-2">👷</span> Manage Workers <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="1" x2="12" y2="23"></line><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path></svg>
Run Payroll
</a> </a>
<a class="sidebar-link" href="/admin/core/project/"> <a href="{% url 'work_log_list' %}" class="quick-action-btn btn btn-outline-secondary">
<span class="me-2">🏗️</span> Manage Projects <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline></svg>
View History
</a> </a>
<a class="sidebar-link" href="/admin/core/team/">
<span class="me-2">👥</span> Manage Teams
</a>
<hr>
<a class="sidebar-link text-primary fw-bold" href="/admin/core/worker/add/">
+ Add New Worker
</a>
</nav>
</div> </div>
</div> </div>
{% endif %}
{% if user.is_staff or user.is_superuser %}
<!-- Manage Resources -->
<div class="card p-4">
<h5 class="mb-2">Manage Resources</h5>
<p class="text-muted small mb-3">Toggle active status. Inactive items are hidden from forms.</p>
<!-- 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.</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>
<ul class="nav nav-tabs nav-fill mb-3" id="manageTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active py-1 px-2 small" id="m-workers-tab" data-bs-toggle="tab" data-bs-target="#m-workers" type="button" role="tab">Workers</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link py-1 px-2 small" id="m-projects-tab" data-bs-toggle="tab" data-bs-target="#m-projects" type="button" role="tab">Projects</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link py-1 px-2 small" id="m-teams-tab" data-bs-toggle="tab" data-bs-target="#m-teams" type="button" role="tab">Teams</button>
</li>
</ul>
<!-- Filter: Active / Inactive / All -->
<div class="btn-group btn-group-sm w-100 mb-3" id="resourceFilter" role="group">
<button type="button" class="btn btn-outline-secondary active" data-filter="active">Active</button>
<button type="button" class="btn btn-outline-secondary" data-filter="inactive">Inactive</button>
<button type="button" class="btn btn-outline-secondary" data-filter="all">All</button>
</div>
<div class="tab-content" style="max-height: 350px; overflow-y: auto;">
<!-- Workers -->
<div class="tab-pane fade show active" id="m-workers" role="tabpanel">
{% for worker in all_workers %}
<div class="resource-row d-flex justify-content-between align-items-center py-2 border-bottom {% if not worker.is_active %}resource-hidden{% endif %}" data-active="{% if worker.is_active %}true{% else %}false{% endif %}">
<div>
<strong class="small">{{ worker.name }}</strong>
<div class="text-muted" style="font-size: 0.7rem;">{{ worker.id_no }}</div>
</div>
<div class="form-check form-switch">
<input class="form-check-input resource-toggle" type="checkbox" role="switch"
data-model="worker" data-id="{{ worker.id }}"
{% if worker.is_active %}checked{% endif %}>
</div>
</div>
{% empty %}
<p class="text-muted small">No workers found.</p>
{% endfor %}
<p class="text-muted small text-center mt-2 resource-empty" style="display:none;">No matching items.</p>
</div>
<!-- Projects -->
<div class="tab-pane fade" id="m-projects" role="tabpanel">
{% for project in all_projects %}
<div class="resource-row d-flex justify-content-between align-items-center py-2 border-bottom {% if not project.is_active %}resource-hidden{% endif %}" data-active="{% if project.is_active %}true{% else %}false{% endif %}">
<strong class="small">{{ project.name }}</strong>
<div class="form-check form-switch">
<input class="form-check-input resource-toggle" type="checkbox" role="switch"
data-model="project" data-id="{{ project.id }}"
{% if project.is_active %}checked{% endif %}>
</div>
</div>
{% empty %}
<p class="text-muted small">No projects found.</p>
{% endfor %}
<p class="text-muted small text-center mt-2 resource-empty" style="display:none;">No matching items.</p>
</div>
<!-- Teams -->
<div class="tab-pane fade" id="m-teams" role="tabpanel">
{% for team in all_teams %}
<div class="resource-row d-flex justify-content-between align-items-center py-2 border-bottom {% if not team.is_active %}resource-hidden{% endif %}" data-active="{% if team.is_active %}true{% else %}false{% endif %}">
<div>
<strong class="small">{{ team.name }}</strong>
<div class="text-muted" style="font-size: 0.7rem;">{{ team.workers.count }} workers</div>
</div>
<div class="form-check form-switch">
<input class="form-check-input resource-toggle" type="checkbox" role="switch"
data-model="team" data-id="{{ team.id }}"
{% if team.is_active %}checked{% endif %}>
</div>
</div>
{% empty %}
<p class="text-muted small">No teams found.</p>
{% endfor %}
<p class="text-muted small text-center mt-2 resource-empty" style="display:none;">No matching items.</p>
</div>
</div>
</div>
{% endif %}
</div>
</div> </div>
</div> </div>
{% if user.is_staff or user.is_superuser %}
<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');
// --- Resource Filter (Active / Inactive / All) ---
let currentFilter = 'active';
const filterBtns = document.querySelectorAll('#resourceFilter button');
function applyFilter() {
document.querySelectorAll('.resource-row').forEach(row => {
const isActive = row.dataset.active === 'true';
let show = false;
if (currentFilter === 'all') show = true;
else if (currentFilter === 'active') show = isActive;
else if (currentFilter === 'inactive') show = !isActive;
if (show) {
row.classList.remove('resource-hidden');
} else {
row.classList.add('resource-hidden');
}
});
document.querySelectorAll('.tab-pane').forEach(pane => {
const rows = pane.querySelectorAll('.resource-row');
const visibleRows = Array.from(rows).filter(r => !r.classList.contains('resource-hidden'));
const emptyMsg = pane.querySelector('.resource-empty');
if (emptyMsg) {
emptyMsg.style.display = (rows.length > 0 && visibleRows.length === 0) ? '' : 'none';
}
});
}
filterBtns.forEach(btn => {
btn.addEventListener('click', function() {
filterBtns.forEach(b => b.classList.remove('active'));
this.classList.add('active');
currentFilter = this.dataset.filter;
applyFilter();
});
});
applyFilter();
// --- Toggle handler ---
toggles.forEach(toggle => {
toggle.addEventListener('change', function() {
const model = this.dataset.model;
const id = this.dataset.id;
const isChecked = this.checked;
const row = this.closest('.resource-row');
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 error');
})
.then(data => {
if (data.success) {
row.dataset.active = isChecked ? 'true' : 'false';
toastMessage.textContent = data.message;
toastEl.classList.remove('bg-danger');
toastEl.classList.add('bg-success');
toast.show();
applyFilter();
} else {
this.checked = !isChecked;
}
})
.catch(error => {
this.checked = !isChecked;
toastMessage.textContent = "Error updating status.";
toastEl.classList.remove('bg-success');
toastEl.classList.add('bg-danger');
toast.show();
});
});
});
});
</script>
{% endif %}
{% endblock %} {% endblock %}

View File

@ -86,7 +86,8 @@
{% endif %} {% endif %}
</div> </div>
<!-- Total Cost Estimation --> {% if is_admin_user %}
<!-- Total Cost Estimation (Admin only) -->
<div class="card p-3 mb-4 bg-light border-0 shadow-sm"> <div class="card p-3 mb-4 bg-light border-0 shadow-sm">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<div> <div>
@ -96,6 +97,7 @@
<h3 class="mb-0 fw-bold text-dark font-monospace" id="estimatedTotal">R 0.00</h3> <h3 class="mb-0 fw-bold text-dark font-monospace" id="estimatedTotal">R 0.00</h3>
</div> </div>
</div> </div>
{% endif %}
<div class="d-grid gap-2 d-md-flex justify-content-md-end"> <div class="d-grid gap-2 d-md-flex justify-content-md-end">
<a href="{% url 'home' %}" class="btn btn-light px-4">Cancel</a> <a href="{% url 'home' %}" class="btn btn-light px-4">Cancel</a>
@ -182,7 +184,8 @@
myModal.show(); myModal.show();
} }
// --- Cost Calculation Logic --- // --- Cost Calculation Logic (Admin only) ---
{% if is_admin_user %}
const workerRates = {{ worker_rates_json|safe }}; const workerRates = {{ worker_rates_json|safe }};
const startDateInput = document.getElementById('{{ form.date.id_for_label }}'); const startDateInput = document.getElementById('{{ form.date.id_for_label }}');
const endDateInput = document.getElementById('{{ form.end_date.id_for_label }}'); const endDateInput = document.getElementById('{{ form.end_date.id_for_label }}');
@ -261,6 +264,7 @@
// Initial Run // Initial Run
calculateTotal(); calculateTotal();
{% endif %}
}); });
function submitConflict(action) { function submitConflict(action) {

View File

@ -3,12 +3,15 @@
{% block title %}Payroll Dashboard - Fox Fitt{% endblock %} {% block title %}Payroll Dashboard - Fox Fitt{% endblock %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
{% endblock %}
{% block content %} {% block content %}
<div class="container py-5"> <div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h2 fw-bold text-dark">Payroll Dashboard</h1> <h1 class="h2 fw-bold text-dark">Payroll Dashboard</h1>
<div> <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"> <button class="btn btn-outline-primary" data-bs-toggle="modal" data-bs-target="#addAdjustmentModal">
+ Add Adjustment + Add Adjustment
</button> </button>
@ -61,6 +64,26 @@
</div> </div>
</div> </div>
<!-- Charts Section -->
<div class="row mb-5">
<div class="col-md-6 mb-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white fw-bold small text-uppercase text-muted">Monthly Payroll Totals</div>
<div class="card-body">
<canvas id="payrollTotalsChart" height="220"></canvas>
</div>
</div>
</div>
<div class="col-md-6 mb-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white fw-bold small text-uppercase text-muted">Labour Cost per Project</div>
<div class="card-body">
<canvas id="projectCostsChart" height="220"></canvas>
</div>
</div>
</div>
</div>
<!-- Filter Tabs --> <!-- Filter Tabs -->
<ul class="nav nav-pills mb-4"> <ul class="nav nav-pills mb-4">
<li class="nav-item"> <li class="nav-item">
@ -72,6 +95,9 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if active_tab == 'all' %}active{% endif %}" href="?status=all">All Records</a> <a class="nav-link {% if active_tab == 'all' %}active{% endif %}" href="?status=all">All Records</a>
</li> </li>
<li class="nav-item">
<a class="nav-link {% if active_tab == 'loans' %}active{% endif %}" href="?status=loans">Loans</a>
</li>
</ul> </ul>
<!-- Pending Payments Table --> <!-- Pending Payments Table -->
@ -119,13 +145,17 @@
</td> </td>
<td class="text-end pe-4"> <td class="text-end pe-4">
{% if item.total_payable > 0 %} {% if item.total_payable > 0 %}
<form action="{% url 'process_payment' item.worker.id %}" method="post"> <div class="d-flex gap-1 justify-content-end">
<button type="button" class="btn btn-sm btn-outline-secondary preview-payslip-btn"
data-worker-id="{{ item.worker.id }}">Preview</button>
<form action="{% url 'process_payment' item.worker.id %}" method="post" class="d-inline">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="btn btn-sm btn-success" <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.')"> onclick="return confirm('Confirm payment of R {{ item.total_payable }} to {{ item.worker.name }}? This will email the receipt.')">
Pay Now Pay Now
</button> </button>
</form> </form>
</div>
{% else %} {% else %}
<button class="btn btn-sm btn-secondary" disabled>Nothing to Pay</button> <button class="btn btn-sm btn-secondary" disabled>Nothing to Pay</button>
{% endif %} {% endif %}
@ -196,6 +226,112 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
<!-- Loans Section -->
{% if active_tab == 'loans' %}
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link {% if loan_filter == 'active' %}active{% endif %}" href="?status=loans&loan_status=active">Outstanding</a>
</li>
<li class="nav-item">
<a class="nav-link {% if loan_filter == 'history' %}active{% endif %}" href="?status=loans&loan_status=history">Repaid</a>
</li>
</ul>
</div>
<button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#addLoanModal">
+ New Loan
</button>
</div>
<div class="card border-0 shadow-sm">
<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</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>
{% endif %}
</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 all_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> </div>
<!-- Add Adjustment Modal --> <!-- Add Adjustment Modal -->
@ -210,13 +346,17 @@
{% csrf_token %} {% csrf_token %}
<div class="modal-body"> <div class="modal-body">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Worker</label> <label class="form-label">Workers</label>
<select name="worker" class="form-select" required> <select name="workers" id="adjWorkerSelect" class="form-select" multiple required style="height: auto; min-height: 42px;">
<option value="">Select a worker...</option>
{% for worker in all_workers %} {% for worker in all_workers %}
<option value="{{ worker.id }}">{{ worker.name }}</option> <option value="{{ worker.id }}">{{ worker.name }}</option>
{% endfor %} {% endfor %}
</select> </select>
<div class="form-text">Hold Ctrl (or Cmd) to select multiple workers.</div>
<div class="mt-2">
<a href="#" id="adjSelectAll" class="small text-primary text-decoration-none me-3">Select All</a>
<a href="#" id="adjDeselectAll" class="small text-muted text-decoration-none">Clear</a>
</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Type</label> <label class="form-label">Type</label>
@ -248,4 +388,179 @@
</div> </div>
</div> </div>
</div> </div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Adjustment modal: Select All / Clear helpers
const adjSelect = document.getElementById('adjWorkerSelect');
const adjSelectAll = document.getElementById('adjSelectAll');
const adjDeselectAll = document.getElementById('adjDeselectAll');
if (adjSelect && adjSelectAll) {
adjSelectAll.addEventListener('click', function(e) {
e.preventDefault();
for (const opt of adjSelect.options) opt.selected = true;
});
adjDeselectAll.addEventListener('click', function(e) {
e.preventDefault();
for (const opt of adjSelect.options) opt.selected = false;
});
}
const labels = {{ chart_labels_json|safe }};
const totals = {{ chart_totals_json|safe }};
const projectData = {{ project_chart_json|safe }};
// Colour palette
const colours = ['#10b981','#3b82f6','#f59e0b','#ef4444','#8b5cf6','#ec4899','#06b6d4','#84cc16'];
// Chart 1: Monthly Payroll Totals
new Chart(document.getElementById('payrollTotalsChart'), {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: 'Paid Out (R)',
data: totals,
backgroundColor: '#10b981',
borderRadius: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: {
beginAtZero: true,
ticks: { callback: v => 'R ' + v.toLocaleString() }
}
}
}
});
// Chart 2: Per-Project Monthly Costs (stacked)
const projectDatasets = projectData.map(function(p, i) {
return {
label: p.name,
data: p.data,
backgroundColor: colours[i % colours.length],
borderRadius: 2
};
});
new Chart(document.getElementById('projectCostsChart'), {
type: 'bar',
data: {
labels: labels,
datasets: projectDatasets
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom', labels: { boxWidth: 12, padding: 15 } }
},
scales: {
x: { stacked: true },
y: {
stacked: true,
beginAtZero: true,
ticks: { callback: v => 'R ' + v.toLocaleString() }
}
}
}
});
});
</script>
<!-- Preview Payslip Modal -->
<div class="modal fade" id="previewPayslipModal" tabindex="-1" aria-labelledby="previewPayslipLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title" id="previewPayslipLabel">Payslip Preview</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="previewPayslipBody">
<div class="text-center py-4">
<div class="spinner-border text-primary" role="status"></div>
<p class="text-muted mt-2 small">Loading preview...</p>
</div>
</div>
<div class="modal-footer border-0 pt-0">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const modal = new bootstrap.Modal(document.getElementById('previewPayslipModal'));
const modalBody = document.getElementById('previewPayslipBody');
document.querySelectorAll('.preview-payslip-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
const workerId = this.dataset.workerId;
// Show modal with spinner
modalBody.innerHTML = '<div class="text-center py-4"><div class="spinner-border text-primary" role="status"></div><p class="text-muted mt-2 small">Loading preview...</p></div>';
modal.show();
fetch('/payroll/preview/' + workerId + '/', {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(r => r.json())
.then(data => {
const fmt = v => 'R ' + parseFloat(v).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
let html = '';
// Header
html += '<div class="text-center border-bottom pb-3 mb-3">';
html += '<div class="text-muted small text-uppercase">Payment to Beneficiary</div>';
html += '<h3 class="fw-bold mb-0">' + data.worker_name + '</h3>';
html += '<span class="badge bg-warning text-dark">PREVIEW</span>';
html += '</div>';
// Worker info
html += '<div class="bg-light rounded p-3 mb-3 small">';
html += '<div class="row">';
html += '<div class="col-sm-4"><strong>Beneficiary:</strong> ' + data.worker_name + '</div>';
html += '<div class="col-sm-4"><strong>ID Number:</strong> ' + data.worker_id_no + '</div>';
html += '<div class="col-sm-4"><strong>Date:</strong> ' + data.date + '</div>';
html += '</div></div>';
// Items table
html += '<table class="table table-sm mb-3">';
html += '<thead class="table-light"><tr><th>Description</th><th class="text-end">Amount</th></tr></thead><tbody>';
// Base pay
html += '<tr><td>Base Pay (' + data.days_worked + ' days @ ' + fmt(data.day_rate) + '/day)</td>';
html += '<td class="text-end">' + fmt(data.base_pay) + '</td></tr>';
// Adjustments
data.adjustments.forEach(function(adj) {
const sign = adj.is_deduction ? '-' : '+';
const cls = adj.is_deduction ? 'text-danger' : 'text-success';
html += '<tr><td>' + adj.type + ': ' + adj.description + '</td>';
html += '<td class="text-end ' + cls + '">' + sign + ' ' + fmt(adj.amount) + '</td></tr>';
});
html += '</tbody></table>';
// Net pay
const netClass = data.net_pay >= 0 ? 'text-success' : 'text-danger';
html += '<div class="border-top pt-2 text-end">';
html += '<span class="fs-5 fw-bold ' + netClass + '">Net Pay: ' + fmt(data.net_pay) + '</span>';
html += '</div>';
modalBody.innerHTML = html;
})
.catch(function(err) {
modalBody.innerHTML = '<div class="text-center py-4 text-danger"><i class="bi bi-exclamation-triangle fs-3"></i><p class="mt-2">Could not load preview.</p></div>';
});
});
});
});
</script>
{% endblock %} {% endblock %}

View File

@ -68,6 +68,7 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
{% if is_admin_user %}
<div class="col-md-2"> <div class="col-md-2">
<label class="form-label small text-muted text-uppercase fw-bold">Payment</label> <label class="form-label small text-muted text-uppercase fw-bold">Payment</label>
<select name="payment_status" class="form-select"> <select name="payment_status" class="form-select">
@ -76,6 +77,7 @@
<option value="unpaid" {% if selected_payment_status == 'unpaid' %}selected{% endif %}>Unpaid</option> <option value="unpaid" {% if selected_payment_status == 'unpaid' %}selected{% endif %}>Unpaid</option>
</select> </select>
</div> </div>
{% endif %}
<div class="col-md-2 d-flex align-items-end"> <div class="col-md-2 d-flex align-items-end">
<button type="submit" class="btn btn-primary w-100">Filter</button> <button type="submit" class="btn btn-primary w-100">Filter</button>
</div> </div>
@ -93,10 +95,12 @@
<small class="text-muted">Showing all records</small> <small class="text-muted">Showing all records</small>
{% endif %} {% endif %}
</div> </div>
{% if is_admin_user %}
<div class="text-end"> <div class="text-end">
<small class="text-uppercase text-muted fw-bold ls-1 d-block mb-1">Total Value</small> <small class="text-uppercase text-muted fw-bold ls-1 d-block mb-1">Total Value</small>
<span class="h2 fw-bold text-primary mb-0">R {{ total_amount|floatformat:2 }}</span> <span class="h2 fw-bold text-primary mb-0">R {{ total_amount|floatformat:2 }}</span>
</div> </div>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@ -138,7 +142,9 @@
{% for week in calendar_weeks %} {% for week in calendar_weeks %}
<tr> <tr>
{% for day_info in week %} {% 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;"> <td class="cal-day {% if day_info.logs %}cal-day--has-logs{% endif %} {% if not day_info.is_current_month %}bg-light text-muted bg-opacity-10{% endif %}"
data-date="{{ day_info.date|date:'Y-m-d' }}"
style="height: 140px; vertical-align: top; padding: 10px;">
<div class="d-flex justify-content-between align-items-start mb-2"> <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> <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 %} {% if day_info.logs %}
@ -149,13 +155,11 @@
{% for log in day_info.logs %} {% 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="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> <div class="fw-bold text-truncate">{{ log.project.name }}</div>
{% with team=log.workers.first.teams.first %} {% if log.team %}
{% if team %}
<div class="text-muted small text-truncate" style="font-size: 0.7rem;"> <div class="text-muted small text-truncate" style="font-size: 0.7rem;">
<i class="bi bi-people-fill me-1"></i>{{ team.name }} <i class="bi bi-people-fill me-1"></i>{{ log.team.name }}
</div> </div>
{% endif %} {% endif %}
{% endwith %}
<div class="text-muted text-truncate" title="{{ log.workers.count }} workers"> <div class="text-muted text-truncate" title="{{ log.workers.count }} workers">
{% if selected_worker %} {% if selected_worker %}
{{ log.workers.first.name }} {{ log.workers.first.name }}
@ -178,6 +182,16 @@
</div> </div>
</div> </div>
<!-- Day Detail Panel (shown on click) -->
<div id="dayDetailPanel" class="card shadow-sm border-0 mt-3" style="display: none;">
<div class="card-header bg-white py-3 border-bottom d-flex justify-content-between align-items-center">
<h5 class="mb-0 fw-bold text-dark" id="dayDetailTitle">-</h5>
<button type="button" class="btn-close" id="dayDetailClose" aria-label="Close"></button>
</div>
<div class="card-body p-0" id="dayDetailBody">
</div>
</div>
{% else %} {% else %}
<!-- LIST VIEW --> <!-- LIST VIEW -->
<div class="card shadow-sm border-0"> <div class="card shadow-sm border-0">
@ -190,8 +204,8 @@
<th class="ps-4">Date</th> <th class="ps-4">Date</th>
<th>Project</th> <th>Project</th>
<th>Labourers</th> <th>Labourers</th>
<th>Amount</th> {% if is_admin_user %}<th>Amount</th>{% endif %}
<th>Status / Payslip</th> {% if is_admin_user %}<th>Status / Payslip</th>{% endif %}
<th>Supervisor</th> <th>Supervisor</th>
<th class="pe-4 text-end">Action</th> <th class="pe-4 text-end">Action</th>
</tr> </tr>
@ -230,6 +244,7 @@
</div> </div>
{% endif %} {% endif %}
</td> </td>
{% if is_admin_user %}
<td> <td>
<span class="fw-bold font-monospace text-dark">R {{ log.display_amount|floatformat:2 }}</span> <span class="fw-bold font-monospace text-dark">R {{ log.display_amount|floatformat:2 }}</span>
</td> </td>
@ -248,6 +263,7 @@
{% endif %} {% endif %}
{% endwith %} {% endwith %}
</td> </td>
{% endif %}
<td> <td>
<small class="text-muted">{{ log.supervisor.username|default:"System" }}</small> <small class="text-muted">{{ log.supervisor.username|default:"System" }}</small>
</td> </td>
@ -268,6 +284,7 @@
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
{% if is_admin_user %}
<tfoot class="table-light border-top-2"> <tfoot class="table-light border-top-2">
<tr> <tr>
<td colspan="3" class="text-end fw-bold ps-4">Total:</td> <td colspan="3" class="text-end fw-bold ps-4">Total:</td>
@ -275,6 +292,7 @@
<td colspan="3"></td> <td colspan="3"></td>
</tr> </tr>
</tfoot> </tfoot>
{% endif %}
</table> </table>
</div> </div>
{% else %} {% else %}
@ -304,5 +322,118 @@
background-color: #ccc; background-color: #ccc;
border-radius: 4px; border-radius: 4px;
} }
/* Clickable calendar cells */
.cal-day--has-logs { cursor: pointer; transition: background-color 0.15s; }
.cal-day--has-logs:hover { background-color: rgba(13, 110, 253, 0.04) !important; }
.cal-day--selected { background-color: rgba(13, 110, 253, 0.08) !important; box-shadow: inset 0 0 0 2px var(--bs-primary); }
</style> </style>
{% if view_mode == 'calendar' %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const detailData = {{ calendar_detail_json|safe }};
const panel = document.getElementById('dayDetailPanel');
const panelTitle = document.getElementById('dayDetailTitle');
const panelBody = document.getElementById('dayDetailBody');
const panelClose = document.getElementById('dayDetailClose');
let selectedDates = new Set();
const dateOpts = { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' };
function renderPanel() {
// Sort selected dates chronologically
const sorted = Array.from(selectedDates).sort();
if (!sorted.length) {
panel.style.display = 'none';
return;
}
// Build title
if (sorted.length === 1) {
const d = new Date(sorted[0] + 'T00:00:00');
const longOpts = { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' };
panelTitle.textContent = d.toLocaleDateString('en-ZA', longOpts);
} else {
panelTitle.textContent = sorted.length + ' dates selected';
}
// Build detail HTML with date grouping for multi-select
let html = '<div class="table-responsive"><table class="table table-hover align-middle mb-0">';
html += '<thead class="bg-light"><tr>';
if (sorted.length > 1) html += '<th class="ps-4">Date</th>';
html += '<th class="' + (sorted.length === 1 ? 'ps-4' : '') + '">Project</th><th>Team(s)</th><th>Workers</th><th>Supervisor</th><th>Notes</th></tr></thead><tbody>';
sorted.forEach(function(dateStr) {
const logs = detailData[dateStr] || [];
const d = new Date(dateStr + 'T00:00:00');
const dateLabel = d.toLocaleDateString('en-ZA', dateOpts);
logs.forEach(function(log, idx) {
const teams = log.teams.length ? log.teams.join(', ') : '<span class="text-muted">-</span>';
const workers = log.workers.map(function(w) {
return '<span class="small bg-light px-2 py-1 rounded border d-inline-block mb-1">' + w + '</span>';
}).join(' ');
const notes = log.notes ? '<small class="text-muted">' + log.notes + '</small>' : '<span class="text-muted">-</span>';
html += '<tr>';
if (sorted.length > 1) {
if (idx === 0) {
html += '<td class="ps-4 fw-bold" rowspan="' + logs.length + '">' + dateLabel + '</td>';
}
}
html += '<td class="' + (sorted.length === 1 ? 'ps-4 ' : '') + 'fw-bold">' + log.project + '</td>';
html += '<td>' + teams + '</td>';
html += '<td>' + workers + '</td>';
html += '<td><small class="text-muted">' + log.supervisor + '</small></td>';
html += '<td>' + notes + '</td>';
html += '</tr>';
});
});
html += '</tbody></table></div>';
panelBody.innerHTML = html;
panel.style.display = 'block';
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
document.querySelectorAll('.cal-day--has-logs').forEach(function(cell) {
cell.addEventListener('click', function(e) {
const dateStr = this.dataset.date;
const logs = detailData[dateStr];
if (!logs || !logs.length) return;
if (e.shiftKey) {
// Shift+click: toggle this date in multi-selection
if (selectedDates.has(dateStr)) {
selectedDates.delete(dateStr);
this.classList.remove('cal-day--selected');
} else {
selectedDates.add(dateStr);
this.classList.add('cal-day--selected');
}
} else {
// Normal click: single-select (clear others)
selectedDates.clear();
document.querySelectorAll('.cal-day--selected').forEach(function(el) {
el.classList.remove('cal-day--selected');
});
selectedDates.add(dateStr);
this.classList.add('cal-day--selected');
}
renderPanel();
});
});
panelClose.addEventListener('click', function() {
selectedDates.clear();
panel.style.display = 'none';
document.querySelectorAll('.cal-day--selected').forEach(function(el) {
el.classList.remove('cal-day--selected');
});
});
});
</script>
{% endif %}
{% endblock %} {% endblock %}

View File

@ -8,6 +8,7 @@ from .views import (
toggle_resource_status, toggle_resource_status,
payroll_dashboard, payroll_dashboard,
process_payment, process_payment,
preview_payslip,
payslip_detail, payslip_detail,
loan_list, loan_list,
add_loan, add_loan,
@ -24,6 +25,7 @@ urlpatterns = [
path("manage-resources/toggle/<str:model_type>/<int:pk>/", toggle_resource_status, name="toggle_resource_status"), 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/", payroll_dashboard, name="payroll_dashboard"),
path("payroll/pay/<int:worker_id>/", process_payment, name="process_payment"), path("payroll/pay/<int:worker_id>/", process_payment, name="process_payment"),
path("payroll/preview/<int:worker_id>/", preview_payslip, name="preview_payslip"),
path("payroll/payslip/<int:pk>/", payslip_detail, name="payslip_detail"), path("payroll/payslip/<int:pk>/", payslip_detail, name="payslip_detail"),
path("loans/", loan_list, name="loan_list"), path("loans/", loan_list, name="loan_list"),
path("loans/add/", add_loan, name="add_loan"), path("loans/add/", add_loan, name="add_loan"),

View File

@ -20,14 +20,20 @@ from datetime import timedelta
from decimal import Decimal from decimal import Decimal
from core.utils import render_to_pdf from core.utils import render_to_pdf
def is_staff_or_supervisor(user): def is_admin(user):
"""Check if user is staff or manages at least one team/project.""" """Check if user has admin-level access (staff, superuser, or in Admin group)."""
if user.is_staff or user.is_superuser: return user.is_staff or user.is_superuser
return True
if user.has_perm('core.view_project'): def is_supervisor(user):
"""Check if user is a work logger (assigned to teams or projects, or in Work Logger group)."""
if user.groups.filter(name='Work Logger').exists():
return True return True
return user.managed_teams.exists() or user.assigned_projects.exists() return user.managed_teams.exists() or user.assigned_projects.exists()
def is_staff_or_supervisor(user):
"""Check if user has at least supervisor-level access."""
return is_admin(user) or is_supervisor(user)
@login_required @login_required
def home(request): def home(request):
"""Render the landing screen with dashboard stats.""" """Render the landing screen with dashboard stats."""
@ -35,50 +41,75 @@ def home(request):
if not is_staff_or_supervisor(request.user): if not is_staff_or_supervisor(request.user):
return redirect('log_attendance') return redirect('log_attendance')
workers_count = Worker.objects.count() user_is_admin = is_admin(request.user)
projects_count = Project.objects.count() now = timezone.now()
teams_count = Team.objects.count() today = now.date()
recent_logs = WorkLog.objects.order_by('-date')[:5]
# Analytics # Counts (used by both admin and non-admin)
# 1. Outstanding Payments (Approximate, from logs only) workers_count = Worker.objects.filter(is_active=True).count()
projects_count = Project.objects.filter(is_active=True).count()
teams_count = Team.objects.filter(is_active=True).count()
# Recent logs with team info
recent_logs = WorkLog.objects.select_related('team', 'project').prefetch_related('workers').order_by('-date', '-id')[:5]
# --- Admin-only analytics ---
outstanding_total = 0 outstanding_total = 0
recent_payments_total = 0
active_loans_count = 0
active_loans_total = 0
week_worker_days = 0
week_projects = 0
if user_is_admin:
# 1. Outstanding Payments
active_workers = Worker.objects.filter(is_active=True) active_workers = Worker.objects.filter(is_active=True)
for worker in active_workers: for worker in active_workers:
# Find unpaid logs for this worker
unpaid_logs_count = worker.work_logs.exclude(paid_in__worker=worker).count() unpaid_logs_count = worker.work_logs.exclude(paid_in__worker=worker).count()
outstanding_total += unpaid_logs_count * worker.day_rate outstanding_total += unpaid_logs_count * worker.day_rate
# 2. Project Costs (Active Projects) # 2. Paid This Month
# Calculate sum of day_rates for all workers in all logs for each project recent_payments_total = PayrollRecord.objects.filter(
project_costs = [] date__year=today.year, date__month=today.month
active_projects = Project.objects.filter(is_active=True) ).aggregate(total=Sum('amount'))['total'] or 0
# Simple iteration for calculation (safer than complex annotations given properties) # 3. Active Loans
for project in active_projects: active_loans = Loan.objects.filter(is_active=True)
cost = 0 active_loans_count = active_loans.count()
logs = project.logs.all() active_loans_total = active_loans.aggregate(total=Sum('balance'))['total'] or 0
for log in logs:
# We need to sum the day_rate of all workers in this log
# Optimization: prefetch workers if slow, but for now just iterate
for worker in log.workers.all():
cost += worker.day_rate
if cost > 0:
project_costs.append({'name': project.name, 'cost': cost})
# 3. Previous 2 months payments # 4. This Week stats (Mon-Sun) - visible to all users
two_months_ago = timezone.now().date() - timedelta(days=60) week_start = today - timedelta(days=today.weekday()) # Monday
recent_payments_total = PayrollRecord.objects.filter(date__gte=two_months_ago).aggregate(total=Sum('amount'))['total'] or 0 week_end = week_start + timedelta(days=6) # Sunday
week_logs = WorkLog.objects.filter(date__range=(week_start, week_end)).prefetch_related('workers')
week_projects = week_logs.values('project').distinct().count()
for log in week_logs:
week_worker_days += log.workers.count()
# Manage Resources data (admin only)
all_workers = Worker.objects.all().prefetch_related('teams').order_by('name') if user_is_admin else []
all_projects = Project.objects.all().order_by('name') if user_is_admin else []
all_teams = Team.objects.all().prefetch_related('workers').order_by('name') if user_is_admin else []
context = { context = {
"is_admin_user": user_is_admin,
"workers_count": workers_count, "workers_count": workers_count,
"projects_count": projects_count, "projects_count": projects_count,
"teams_count": teams_count, "teams_count": teams_count,
"recent_logs": recent_logs, "recent_logs": recent_logs,
"current_time": timezone.now(), "current_time": now,
# Admin financials
"outstanding_total": outstanding_total, "outstanding_total": outstanding_total,
"project_costs": project_costs,
"recent_payments_total": recent_payments_total, "recent_payments_total": recent_payments_total,
"active_loans_count": active_loans_count,
"active_loans_total": active_loans_total,
# This week
"week_worker_days": week_worker_days,
"week_projects": week_projects,
# Manage resources
"all_workers": all_workers,
"all_projects": all_projects,
"all_teams": all_teams,
} }
return render(request, "core/index.html", context) return render(request, "core/index.html", context)
@ -104,6 +135,7 @@ def log_attendance(request):
include_sun = form.cleaned_data.get('include_sunday') include_sun = form.cleaned_data.get('include_sunday')
selected_workers = form.cleaned_data['workers'] selected_workers = form.cleaned_data['workers']
project = form.cleaned_data['project'] project = form.cleaned_data['project']
team = form.cleaned_data.get('team')
notes = form.cleaned_data['notes'] notes = form.cleaned_data['notes']
conflict_action = request.POST.get('conflict_action') conflict_action = request.POST.get('conflict_action')
@ -192,6 +224,7 @@ def log_attendance(request):
log = WorkLog.objects.create( log = WorkLog.objects.create(
date=d, date=d,
project=project, project=project,
team=team,
notes=notes, notes=notes,
supervisor=request.user if request.user.is_authenticated else None supervisor=request.user if request.user.is_authenticated else None
) )
@ -210,12 +243,17 @@ def log_attendance(request):
else: else:
form = WorkLogForm(user=request.user if request.user.is_authenticated else None) form = WorkLogForm(user=request.user if request.user.is_authenticated else None)
# Pass worker rates for frontend total calculation # Pass worker rates for frontend total calculation (admin only)
user_is_admin = is_admin(request.user)
if user_is_admin:
worker_qs = form.fields['workers'].queryset worker_qs = form.fields['workers'].queryset
worker_rates = {w.id: float(w.day_rate) for w in worker_qs} worker_rates = {w.id: float(w.day_rate) for w in worker_qs}
else:
worker_rates = {}
context = { context = {
'form': form, 'form': form,
'is_admin_user': user_is_admin,
'team_workers_json': json.dumps(team_workers_map), 'team_workers_json': json.dumps(team_workers_map),
'worker_rates_json': json.dumps(worker_rates) 'worker_rates_json': json.dumps(worker_rates)
} }
@ -234,7 +272,7 @@ def work_log_list(request):
payment_status = request.GET.get('payment_status') # 'paid', 'unpaid', 'all' payment_status = request.GET.get('payment_status') # 'paid', 'unpaid', 'all'
view_mode = request.GET.get('view', 'list') view_mode = request.GET.get('view', 'list')
logs = WorkLog.objects.all().prefetch_related('workers', 'workers__teams', 'project', 'supervisor', 'paid_in').order_by('-date', '-id') logs = WorkLog.objects.all().select_related('team', 'project', 'supervisor').prefetch_related('workers', 'paid_in').order_by('-date', '-id')
target_worker = None target_worker = None
if worker_id: if worker_id:
@ -243,9 +281,7 @@ def work_log_list(request):
target_worker = Worker.objects.filter(id=worker_id).first() target_worker = Worker.objects.filter(id=worker_id).first()
if team_id: if team_id:
# Find workers in this team and filter logs containing them logs = logs.filter(team_id=team_id)
team_workers = Worker.objects.filter(teams__id=team_id)
logs = logs.filter(workers__in=team_workers).distinct()
if project_id: if project_id:
logs = logs.filter(project_id=project_id) logs = logs.filter(project_id=project_id)
@ -289,18 +325,23 @@ def work_log_list(request):
logs = logs.filter(date__range=(start_date, end_date)) logs = logs.filter(date__range=(start_date, end_date))
user_is_admin = is_admin(request.user)
for log in logs: for log in logs:
if user_is_admin:
if target_worker: if target_worker:
log.display_amount = target_worker.day_rate log.display_amount = target_worker.day_rate
else: else:
# Sum of all workers in this log
log.display_amount = sum(w.day_rate for w in log.workers.all()) log.display_amount = sum(w.day_rate for w in log.workers.all())
final_logs.append(log)
total_amount += log.display_amount total_amount += log.display_amount
else:
log.display_amount = None
final_logs.append(log)
# Context for filters # Context for filters
context = { context = {
'total_amount': total_amount, 'is_admin_user': user_is_admin,
'total_amount': total_amount if user_is_admin else None,
'workers': Worker.objects.filter(is_active=True).order_by('name'), 'workers': Worker.objects.filter(is_active=True).order_by('name'),
'teams': Team.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'), 'projects': Project.objects.filter(is_active=True).order_by('name'),
@ -336,11 +377,28 @@ def work_log_list(request):
}) })
calendar_weeks.append(week_data) calendar_weeks.append(week_data)
# Build JSON lookup for day detail panel
calendar_detail_data = {}
for date_key, day_logs in logs_map.items():
date_str = date_key.strftime('%Y-%m-%d')
calendar_detail_data[date_str] = []
for log in day_logs:
workers_list = list(log.workers.all().order_by('name'))
team_name = log.team.name if log.team else 'No Team'
calendar_detail_data[date_str].append({
'project': log.project.name,
'teams': [team_name],
'workers': [w.name for w in workers_list],
'supervisor': log.supervisor.username if log.supervisor else 'System',
'notes': log.notes or '',
})
# Nav Links # Nav Links
prev_month_date = start_date - datetime.timedelta(days=1) prev_month_date = start_date - datetime.timedelta(days=1)
next_month_date = end_date + datetime.timedelta(days=1) next_month_date = end_date + datetime.timedelta(days=1)
context.update({ context.update({
'calendar_detail_json': json.dumps(calendar_detail_data),
'calendar_weeks': calendar_weeks, 'calendar_weeks': calendar_weeks,
'curr_month': curr_month, 'curr_month': curr_month,
'curr_year': curr_year, 'curr_year': curr_year,
@ -389,31 +447,38 @@ def export_work_log_csv(request):
else: else:
logs = logs.filter(paid_in__isnull=True) logs = logs.filter(paid_in__isnull=True)
user_is_admin = is_admin(request.user)
response = HttpResponse(content_type='text/csv') response = HttpResponse(content_type='text/csv')
response['Content-Disposition'] = 'attachment; filename="work_logs.csv"' response['Content-Disposition'] = 'attachment; filename="work_logs.csv"'
writer = csv.writer(response) writer = csv.writer(response)
if user_is_admin:
writer.writerow(['Date', 'Project', 'Workers', 'Amount', 'Payment Status', 'Supervisor']) writer.writerow(['Date', 'Project', 'Workers', 'Amount', 'Payment Status', 'Supervisor'])
else:
writer.writerow(['Date', 'Project', 'Workers', 'Supervisor'])
for log in logs: for log in logs:
# Amount Logic
if target_worker: if target_worker:
display_amount = target_worker.day_rate
workers_str = target_worker.name workers_str = target_worker.name
else: else:
display_amount = sum(w.day_rate for w in log.workers.all())
workers_str = ", ".join([w.name for w in log.workers.all()]) workers_str = ", ".join([w.name for w in log.workers.all()])
# Payment Status Logic if user_is_admin:
if target_worker:
display_amount = target_worker.day_rate
else:
display_amount = sum(w.day_rate for w in log.workers.all())
is_paid = log.paid_in.exists() is_paid = log.paid_in.exists()
status_str = "Paid" if is_paid else "Pending" status_str = "Paid" if is_paid else "Pending"
writer.writerow([ writer.writerow([
log.date, log.date, log.project.name, workers_str,
log.project.name, f"{display_amount:.2f}", status_str,
workers_str, log.supervisor.username if log.supervisor else "System"
f"{display_amount:.2f}", ])
status_str, else:
writer.writerow([
log.date, log.project.name, workers_str,
log.supervisor.username if log.supervisor else "System" log.supervisor.username if log.supervisor else "System"
]) ])
@ -421,26 +486,13 @@ def export_work_log_csv(request):
@login_required @login_required
def manage_resources(request): def manage_resources(request):
"""View to manage active status of resources.""" """Redirect to dashboard which now includes manage resources."""
if not request.user.is_staff and not request.user.is_superuser: return redirect('home')
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')
teams = Team.objects.all().prefetch_related('workers').order_by('name')
context = {
'workers': workers,
'projects': projects,
'teams': teams,
}
return render(request, 'core/manage_resources.html', context)
@login_required @login_required
def toggle_resource_status(request, model_type, pk): def toggle_resource_status(request, model_type, pk):
"""Toggle the is_active status of a resource.""" """Toggle the is_active status of a resource."""
if not request.user.is_staff and not request.user.is_superuser: if not is_admin(request.user):
return redirect('log_attendance') return redirect('log_attendance')
if request.method == 'POST': if request.method == 'POST':
@ -463,15 +515,15 @@ def toggle_resource_status(request, model_type, pk):
'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') return redirect('home')
@login_required @login_required
def payroll_dashboard(request): def payroll_dashboard(request):
"""Dashboard for payroll management with filtering.""" """Dashboard for payroll management with filtering."""
if not request.user.is_staff and not request.user.is_superuser: if not is_admin(request.user):
return redirect('log_attendance') return redirect('log_attendance')
status_filter = request.GET.get('status', 'pending') # pending, paid, all status_filter = request.GET.get('status', 'pending') # pending, paid, all, loans
# Common Analytics # Common Analytics
outstanding_total = 0 outstanding_total = 0
@ -537,6 +589,66 @@ def payroll_dashboard(request):
# Active Loans for dropdowns/modals # Active Loans for dropdowns/modals
all_workers = Worker.objects.filter(is_active=True).order_by('name') all_workers = Worker.objects.filter(is_active=True).order_by('name')
# Loans data (for loans tab)
loan_filter = request.GET.get('loan_status', 'active')
if loan_filter == 'history':
loans = Loan.objects.filter(is_active=False).order_by('-date')
else:
loans = Loan.objects.filter(is_active=True).order_by('-date')
# --- Chart Data: Monthly payroll totals & per-project costs (last 6 months) ---
today = timezone.now().date()
chart_months = [] # list of (year, month) tuples, oldest first
for i in range(5, -1, -1):
# Go back i months from current month
m = today.month - i
y = today.year
while m <= 0:
m += 12
y -= 1
chart_months.append((y, m))
chart_labels = [] # e.g. ["Sep 2025", "Oct 2025", ...]
chart_totals = [] # total payroll paid per month
# For per-project chart: {project_name: [month0_cost, month1_cost, ...]}
all_project_names = list(Project.objects.values_list('name', flat=True).order_by('name'))
project_monthly = {name: [] for name in all_project_names}
for year, month in chart_months:
chart_labels.append(f"{calendar.month_abbr[month]} {year}")
# Total paid out this month
_, last_day = calendar.monthrange(year, month)
month_start = datetime.date(year, month, 1)
month_end = datetime.date(year, month, last_day)
month_paid = PayrollRecord.objects.filter(
date__gte=month_start, date__lte=month_end
).aggregate(total=Sum('amount'))['total'] or 0
chart_totals.append(float(month_paid))
# Per-project labour cost this month (from work logs × day rates)
month_logs = WorkLog.objects.filter(
date__gte=month_start, date__lte=month_end
).prefetch_related('workers', 'project')
project_cost_month = {name: 0 for name in all_project_names}
for log in month_logs:
pname = log.project.name
for worker in log.workers.all():
project_cost_month[pname] = project_cost_month.get(pname, 0) + float(worker.day_rate)
for name in all_project_names:
project_monthly[name].append(project_cost_month.get(name, 0))
# Filter out projects with zero cost across all months
project_chart_data = [
{'name': name, 'data': costs}
for name, costs in project_monthly.items()
if any(c > 0 for c in costs)
]
context = { context = {
'workers_data': workers_data, 'workers_data': workers_data,
'paid_records': paid_records, 'paid_records': paid_records,
@ -546,13 +658,18 @@ def payroll_dashboard(request):
'active_tab': status_filter, 'active_tab': status_filter,
'all_workers': all_workers, 'all_workers': all_workers,
'adjustment_types': PayrollAdjustment.ADJUSTMENT_TYPES, 'adjustment_types': PayrollAdjustment.ADJUSTMENT_TYPES,
'loans': loans,
'loan_filter': loan_filter,
'chart_labels_json': json.dumps(chart_labels),
'chart_totals_json': json.dumps(chart_totals),
'project_chart_json': json.dumps(project_chart_data),
} }
return render(request, 'core/payroll_dashboard.html', context) return render(request, 'core/payroll_dashboard.html', context)
@login_required @login_required
def process_payment(request, worker_id): def process_payment(request, worker_id):
"""Process payment for a worker, mark logs as paid, link adjustments, and email receipt.""" """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: if not is_admin(request.user):
return redirect('log_attendance') return redirect('log_attendance')
worker = get_object_or_404(Worker, pk=worker_id) worker = get_object_or_404(Worker, pk=worker_id)
@ -643,10 +760,55 @@ def process_payment(request, worker_id):
return redirect('payroll_dashboard') return redirect('payroll_dashboard')
@login_required
def preview_payslip(request, worker_id):
"""Return payslip preview data as JSON (no DB changes, no email)."""
if not is_admin(request.user):
return JsonResponse({'error': 'Unauthorized'}, status=403)
worker = get_object_or_404(Worker, pk=worker_id)
# Calculate the same data as process_payment, but read-only
unpaid_logs = worker.work_logs.exclude(paid_in__worker=worker)
log_count = unpaid_logs.count()
logs_amount = float(log_count * worker.day_rate)
pending_adjustments = worker.adjustments.filter(payroll_record__isnull=True)
adj_list = []
for adj in pending_adjustments:
adj_list.append({
'type': adj.get_type_display(),
'description': adj.description or '',
'amount': float(adj.amount),
'is_deduction': adj.type in ['DEDUCTION', 'LOAN_REPAYMENT'],
})
adj_amount = Decimal('0.00')
for adj in pending_adjustments:
if adj.type in ['BONUS', 'OVERTIME', 'LOAN']:
adj_amount += adj.amount
elif adj.type in ['DEDUCTION', 'LOAN_REPAYMENT']:
adj_amount -= adj.amount
total_amount = float(logs_amount + float(adj_amount))
return JsonResponse({
'worker_name': worker.name,
'worker_id_no': worker.id_no or '',
'date': timezone.now().date().strftime('%Y-%m-%d'),
'day_rate': float(worker.day_rate),
'days_worked': log_count,
'base_pay': logs_amount,
'adjustments': adj_list,
'net_pay': total_amount,
})
@login_required @login_required
def payslip_detail(request, pk): def payslip_detail(request, pk):
"""Show details of a payslip (Payment Record).""" """Show details of a payslip (Payment Record)."""
if not request.user.is_staff and not request.user.is_superuser: if not is_admin(request.user):
return redirect('log_attendance') return redirect('log_attendance')
record = get_object_or_404(PayrollRecord, pk=pk) record = get_object_or_404(PayrollRecord, pk=pk)
@ -672,28 +834,13 @@ def payslip_detail(request, pk):
@login_required @login_required
def loan_list(request): def loan_list(request):
"""List outstanding and historical loans.""" """Redirect to payroll dashboard loans tab."""
if not request.user.is_staff and not request.user.is_superuser: return redirect('/payroll/?status=loans')
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 @login_required
def add_loan(request): def add_loan(request):
"""Create a new loan.""" """Create a new loan."""
if not request.user.is_staff and not request.user.is_superuser: if not is_admin(request.user):
return redirect('log_attendance') return redirect('log_attendance')
if request.method == 'POST': if request.method == 'POST':
@ -712,23 +859,27 @@ def add_loan(request):
) )
messages.success(request, f"Loan of R{amount} recorded for {worker.name}.") messages.success(request, f"Loan of R{amount} recorded for {worker.name}.")
return redirect('loan_list') return redirect('/payroll/?status=loans')
@login_required @login_required
def add_adjustment(request): def add_adjustment(request):
"""Add a payroll adjustment (Bonus, Overtime, Deduction, Repayment).""" """Add a payroll adjustment (Bonus, Overtime, Deduction, Repayment) for one or more workers."""
if not request.user.is_staff and not request.user.is_superuser: if not is_admin(request.user):
return redirect('log_attendance') return redirect('log_attendance')
if request.method == 'POST': if request.method == 'POST':
worker_id = request.POST.get('worker') worker_ids = request.POST.getlist('workers')
adj_type = request.POST.get('type') adj_type = request.POST.get('type')
amount = request.POST.get('amount') amount = request.POST.get('amount')
description = request.POST.get('description') description = request.POST.get('description')
date = request.POST.get('date') or timezone.now().date() date = request.POST.get('date') or timezone.now().date()
loan_id = request.POST.get('loan_id') # Optional, for repayments loan_id = request.POST.get('loan_id') # Optional, for repayments
if worker_id and amount and adj_type: if worker_ids and amount and adj_type:
success_names = []
skip_names = []
for worker_id in worker_ids:
worker = get_object_or_404(Worker, pk=worker_id) worker = get_object_or_404(Worker, pk=worker_id)
# Validation for repayment OR Creation for New Loan # Validation for repayment OR Creation for New Loan
@ -737,13 +888,11 @@ def add_adjustment(request):
if loan_id: if loan_id:
loan = get_object_or_404(Loan, pk=loan_id) loan = get_object_or_404(Loan, pk=loan_id)
else: else:
# Try to find an active loan
loan = worker.loans.filter(is_active=True).first() loan = worker.loans.filter(is_active=True).first()
if not loan: if not loan:
messages.warning(request, f"Cannot add repayment: {worker.name} has no active loans.") skip_names.append(worker.name)
return redirect('payroll_dashboard') continue
elif adj_type == 'LOAN': elif adj_type == 'LOAN':
# Create the Loan object tracking the debt
loan = Loan.objects.create( loan = Loan.objects.create(
worker=worker, worker=worker,
amount=amount, amount=amount,
@ -759,7 +908,14 @@ def add_adjustment(request):
date=date, date=date,
loan=loan loan=loan
) )
messages.success(request, f"{adj_type} of R{amount} added for {worker.name}.") success_names.append(worker.name)
if success_names:
names = ', '.join(success_names)
messages.success(request, f"{adj_type} of R{amount} added for {names}.")
if skip_names:
names = ', '.join(skip_names)
messages.warning(request, f"Skipped (no active loan): {names}.")
return redirect('payroll_dashboard') return redirect('payroll_dashboard')