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,28 +1,101 @@
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
@admin.register(UserProfile)
class UserProfileAdmin(admin.ModelAdmin):
list_display = ('user', 'pin', 'is_admin')
# Inline UserProfile on User 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)
class WorkerAdmin(admin.ModelAdmin):
list_display = ('name', 'id_no', 'phone_no', 'monthly_salary', 'date_of_employment', 'projects_worked_on_count')
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)
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',)
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)
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',)
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)
class WorkLogAdmin(admin.ModelAdmin):
list_display = ('date', 'project', 'supervisor')
list_filter = ('date', 'project', 'supervisor')
filter_horizontal = ('workers',)
filter_horizontal = ('workers',)

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):
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:
verbose_name = "User Profile"
@ -78,6 +76,7 @@ class Team(models.Model):
class WorkLog(models.Model):
date = models.DateField()
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')
supervisor = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
notes = models.TextField(blank=True)

View File

@ -33,47 +33,31 @@
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto align-items-center">
{% 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 %}
<li class="nav-item"><a class="nav-link" href="{% url 'home' %}">Dashboard</a></li>
{% endif %}
{# Log Work #}
{% if user.is_staff or user.is_superuser or perms.core.add_worklog %}
{# Log Work - visible to Admin & Work Logger #}
{% 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>
{% 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 %}
{# 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 %}
<li class="nav-item"><a class="nav-link" href="{% url 'work_log_list' %}">History</a></li>
{% endif %}
{# Payroll #}
{% if user.is_staff or user.is_superuser or perms.core.view_payrollrecord %}
{# Payroll - Admin only #}
{% if user.is_staff or user.is_superuser %}
<li class="nav-item"><a class="nav-link" href="{% url 'payroll_dashboard' %}">Payroll</a></li>
{% endif %}
{# Loans #}
{% if user.is_staff or user.is_superuser or perms.core.view_loan %}
<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 %}
{# Receipts - visible to Admin & Work Logger #}
{% 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 'create_receipt' %}">Receipts</a></li>
{% 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">
<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 }}

View File

@ -4,6 +4,11 @@
{% block title %}Dashboard | Fox Fitt{% endblock %}
{% 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="container">
<div class="row align-items-center">
@ -21,14 +26,16 @@
</div>
<div class="container mb-5">
<!-- Payroll Analytics Row -->
<!-- Stats Row -->
<div class="row g-4 mb-4">
{% if is_admin_user %}
<!-- Admin: Outstanding Payments -->
<div class="col-md-4">
<div class="card stat-card p-4 border-start border-4 border-warning">
<div class="d-flex justify-content-between align-items-center">
<div>
<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 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>
@ -37,12 +44,13 @@
<a href="{% url 'payroll_dashboard' %}" class="small text-muted mt-2 d-block stretched-link">View details &rarr;</a>
</div>
</div>
<!-- Admin: Paid This Month -->
<div class="col-md-4">
<div class="card stat-card p-4 border-start border-4 border-success">
<div class="d-flex justify-content-between align-items-center">
<div>
<p class="text-uppercase small fw-bold mb-1 opacity-75">Paid (Last 60 Days)</p>
<h2 class="mb-0">R {{ recent_payments_total|intcomma }}</h2>
<p class="text-uppercase small fw-bold mb-1 opacity-75">Paid This Month</p>
<h2 class="mb-0">R {{ recent_payments_total|floatformat:2|intcomma }}</h2>
</div>
<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>
@ -50,6 +58,52 @@
</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="card stat-card p-4">
<div class="d-flex justify-content-between align-items-center">
@ -63,100 +117,282 @@
</div>
</div>
</div>
{% endif %}
</div>
<div class="row">
<div class="row g-4">
<!-- Left Column -->
<div class="col-lg-8">
<!-- Project Costs -->
<!-- This Week Summary -->
<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 class="d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-calendar-week me-2"></i>This Week</h5>
<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>
{% else %}
<p class="text-muted">No cost data available for active projects.</p>
{% endif %}
</div>
<!-- Recent Logs -->
<!-- Recent Activity -->
<div class="card p-4 mb-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h3 class="mb-0">Recent Daily Logs</h3>
<a href="{% url 'work_log_list' %}" class="btn btn-sm btn-link text-decoration-none">View All</a>
<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>
{% if recent_logs %}
<div class="table-responsive">
<table class="table align-middle">
<table class="table table-sm align-middle mb-0">
<thead class="table-light">
<tr>
<th>Date</th>
<th>Project</th>
<th>Team</th>
<th>Workers</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for log in recent_logs %}
<tr>
<td>{{ log.date }}</td>
<td><strong>{{ log.project.name }}</strong></td>
<td>{{ log.workers.count }} labourers</td>
<td><span class="badge bg-success bg-opacity-10 text-success">Submitted</span></td>
<td class="text-muted small">{{ log.date|date:"D, d M" }}</td>
<td><strong class="small">{{ log.project.name }}</strong></td>
<td class="small">{% if log.team %}{{ log.team.name }}{% else %}<span class="text-muted">-</span>{% endif %}</td>
<td class="small">{{ log.workers.count }} labourer{{ log.workers.count|pluralize }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<div class="mb-3 opacity-25">
<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>
<div class="text-center py-4">
<p class="text-muted mb-2">No recent work logs found.</p>
<a href="{% url 'log_attendance' %}" class="btn btn-sm btn-outline-primary">Create First Log</a>
</div>
{% endif %}
</div>
</div>
<!-- Right Column -->
<div class="col-lg-4">
<div class="card p-4">
<h3 class="mb-4">Quick Links</h3>
<nav class="nav flex-column">
<a class="sidebar-link" href="{% url 'payroll_dashboard' %}">
<span class="me-2">💰</span> Payroll Dashboard
{% if is_admin_user %}
<!-- Quick Actions -->
<div class="card p-4 mb-4">
<h5 class="mb-3">Quick Actions</h5>
<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 class="sidebar-link" href="/admin/core/worker/">
<span class="me-2">👷</span> Manage Workers
<a href="{% url 'payroll_dashboard' %}" class="quick-action-btn btn btn-outline-success">
<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 class="sidebar-link" href="/admin/core/project/">
<span class="me-2">🏗️</span> Manage Projects
<a href="{% url 'work_log_list' %}" class="quick-action-btn btn btn-outline-secondary">
<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 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>
{% 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>
{% endblock %}
{% 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 %}

View File

@ -86,7 +86,8 @@
{% endif %}
</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="d-flex justify-content-between align-items-center">
<div>
@ -96,6 +97,7 @@
<h3 class="mb-0 fw-bold text-dark font-monospace" id="estimatedTotal">R 0.00</h3>
</div>
</div>
{% endif %}
<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>
@ -182,7 +184,8 @@
myModal.show();
}
// --- Cost Calculation Logic ---
// --- Cost Calculation Logic (Admin only) ---
{% if is_admin_user %}
const workerRates = {{ worker_rates_json|safe }};
const startDateInput = document.getElementById('{{ form.date.id_for_label }}');
const endDateInput = document.getElementById('{{ form.end_date.id_for_label }}');
@ -191,13 +194,13 @@
const workerCheckboxes = document.querySelectorAll('input[name="workers"]');
const totalDisplay = document.getElementById('estimatedTotal');
const detailsDisplay = document.getElementById('calculationDetails');
function calculateTotal() {
// 1. Calculate Days
let days = 0;
const start = startDateInput.value ? new Date(startDateInput.value) : null;
const end = endDateInput.value ? new Date(endDateInput.value) : null;
if (start) {
if (!end || end < start) {
days = 1;
@ -208,25 +211,25 @@
curr.setHours(0,0,0,0);
const last = new Date(end);
last.setHours(0,0,0,0);
while (curr <= last) {
const dayOfWeek = curr.getDay(); // 0 = Sun, 6 = Sat
let isWorkingDay = true;
if (dayOfWeek === 6 && !satCheckbox.checked) isWorkingDay = false;
if (dayOfWeek === 0 && !sunCheckbox.checked) isWorkingDay = false;
if (isWorkingDay) days++;
curr.setDate(curr.getDate() + 1);
}
}
}
// 2. Sum Worker Rates
let dailyRateSum = 0;
let workerCount = 0;
workerCheckboxes.forEach(cb => {
if (cb.checked) {
const rate = workerRates[cb.value] || 0;
@ -234,23 +237,23 @@
workerCount++;
}
});
// 3. Update UI
const total = dailyRateSum * days;
totalDisplay.textContent = 'R ' + total.toLocaleString('en-ZA', {minimumFractionDigits: 2, maximumFractionDigits: 2});
detailsDisplay.textContent = `${workerCount} worker${workerCount !== 1 ? 's' : ''} × ${days} day${days !== 1 ? 's' : ''}`;
}
// Attach Listeners
if (startDateInput) startDateInput.addEventListener('change', calculateTotal);
if (endDateInput) endDateInput.addEventListener('change', calculateTotal);
if (satCheckbox) satCheckbox.addEventListener('change', calculateTotal);
if (sunCheckbox) sunCheckbox.addEventListener('change', calculateTotal);
workerCheckboxes.forEach(cb => {
cb.addEventListener('change', calculateTotal);
});
// Also update when team changes (since it selects workers programmatically)
if (teamSelect) {
teamSelect.addEventListener('change', function() {
@ -258,9 +261,10 @@
setTimeout(calculateTotal, 100);
});
}
// Initial Run
calculateTotal();
{% endif %}
});
function submitConflict(action) {

View File

@ -3,12 +3,15 @@
{% 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 %}
<div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h2 fw-bold text-dark">Payroll Dashboard</h1>
<div>
<a href="{% url 'loan_list' %}" class="btn btn-outline-secondary me-2">Manage Loans</a>
<button class="btn btn-outline-primary" data-bs-toggle="modal" data-bs-target="#addAdjustmentModal">
+ Add Adjustment
</button>
@ -61,6 +64,26 @@
</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 -->
<ul class="nav nav-pills mb-4">
<li class="nav-item">
@ -72,6 +95,9 @@
<li class="nav-item">
<a class="nav-link {% if active_tab == 'all' %}active{% endif %}" href="?status=all">All Records</a>
</li>
<li class="nav-item">
<a class="nav-link {% if active_tab == 'loans' %}active{% endif %}" href="?status=loans">Loans</a>
</li>
</ul>
<!-- Pending Payments Table -->
@ -119,13 +145,17 @@
</td>
<td class="text-end pe-4">
{% if item.total_payable > 0 %}
<form action="{% url 'process_payment' item.worker.id %}" method="post">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-success"
onclick="return confirm('Confirm payment of R {{ item.total_payable }} to {{ item.worker.name }}? This will email the receipt.')">
Pay Now
</button>
</form>
<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 %}
<button type="submit" class="btn btn-sm btn-success"
onclick="return confirm('Confirm payment of R {{ item.total_payable }} to {{ item.worker.name }}? This will email the receipt.')">
Pay Now
</button>
</form>
</div>
{% else %}
<button class="btn btn-sm btn-secondary" disabled>Nothing to Pay</button>
{% endif %}
@ -196,6 +226,112 @@
{% 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>
<!-- Add Adjustment Modal -->
@ -210,13 +346,17 @@
{% 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>
<label class="form-label">Workers</label>
<select name="workers" id="adjWorkerSelect" class="form-select" multiple required style="height: auto; min-height: 42px;">
{% for worker in all_workers %}
<option value="{{ worker.id }}">{{ worker.name }}</option>
{% endfor %}
</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 class="mb-3">
<label class="form-label">Type</label>
@ -248,4 +388,179 @@
</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 %}

View File

@ -68,6 +68,7 @@
{% endfor %}
</select>
</div>
{% if is_admin_user %}
<div class="col-md-2">
<label class="form-label small text-muted text-uppercase fw-bold">Payment</label>
<select name="payment_status" class="form-select">
@ -76,6 +77,7 @@
<option value="unpaid" {% if selected_payment_status == 'unpaid' %}selected{% endif %}>Unpaid</option>
</select>
</div>
{% endif %}
<div class="col-md-2 d-flex align-items-end">
<button type="submit" class="btn btn-primary w-100">Filter</button>
</div>
@ -93,10 +95,12 @@
<small class="text-muted">Showing all records</small>
{% endif %}
</div>
{% if is_admin_user %}
<div class="text-end">
<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>
</div>
{% endif %}
</div>
</div>
</div>
@ -138,7 +142,9 @@
{% for week in calendar_weeks %}
<tr>
{% for day_info in week %}
<td class="{% if not day_info.is_current_month %}bg-light text-muted bg-opacity-10{% endif %}" style="height: 140px; vertical-align: top; padding: 10px;">
<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">
<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 %}
@ -149,13 +155,11 @@
{% for log in day_info.logs %}
<div class="mb-1 p-1 rounded border small bg-white shadow-sm" style="font-size: 0.75rem; line-height: 1.2; border-left: 3px solid var(--bs-primary) !important;">
<div class="fw-bold text-truncate">{{ log.project.name }}</div>
{% with team=log.workers.first.teams.first %}
{% if team %}
<div class="text-muted small text-truncate" style="font-size: 0.7rem;">
<i class="bi bi-people-fill me-1"></i>{{ team.name }}
</div>
{% endif %}
{% endwith %}
{% if log.team %}
<div class="text-muted small text-truncate" style="font-size: 0.7rem;">
<i class="bi bi-people-fill me-1"></i>{{ log.team.name }}
</div>
{% endif %}
<div class="text-muted text-truncate" title="{{ log.workers.count }} workers">
{% if selected_worker %}
{{ log.workers.first.name }}
@ -177,6 +181,16 @@
</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 %}
<!-- LIST VIEW -->
@ -190,8 +204,8 @@
<th class="ps-4">Date</th>
<th>Project</th>
<th>Labourers</th>
<th>Amount</th>
<th>Status / Payslip</th>
{% if is_admin_user %}<th>Amount</th>{% endif %}
{% if is_admin_user %}<th>Status / Payslip</th>{% endif %}
<th>Supervisor</th>
<th class="pe-4 text-end">Action</th>
</tr>
@ -230,6 +244,7 @@
</div>
{% endif %}
</td>
{% if is_admin_user %}
<td>
<span class="fw-bold font-monospace text-dark">R {{ log.display_amount|floatformat:2 }}</span>
</td>
@ -248,6 +263,7 @@
{% endif %}
{% endwith %}
</td>
{% endif %}
<td>
<small class="text-muted">{{ log.supervisor.username|default:"System" }}</small>
</td>
@ -268,6 +284,7 @@
</tr>
{% endfor %}
</tbody>
{% if is_admin_user %}
<tfoot class="table-light border-top-2">
<tr>
<td colspan="3" class="text-end fw-bold ps-4">Total:</td>
@ -275,6 +292,7 @@
<td colspan="3"></td>
</tr>
</tfoot>
{% endif %}
</table>
</div>
{% else %}
@ -295,7 +313,7 @@
.bg-accent { background-color: var(--bs-accent); }
.btn-accent { background-color: #ffde59; color: #000; border: none; font-weight: 600; }
.btn-accent:hover { background-color: #e6c850; }
/* Calendar Scrollbar custom */
.calendar-events::-webkit-scrollbar {
width: 4px;
@ -304,5 +322,118 @@
background-color: #ccc;
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>
{% 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 %}

View File

@ -1,13 +1,14 @@
from django.urls import path
from .views import (
home,
log_attendance,
home,
log_attendance,
work_log_list,
export_work_log_csv,
manage_resources,
export_work_log_csv,
manage_resources,
toggle_resource_status,
payroll_dashboard,
process_payment,
preview_payslip,
payslip_detail,
loan_list,
add_loan,
@ -24,6 +25,7 @@ urlpatterns = [
path("manage-resources/toggle/<str:model_type>/<int:pk>/", toggle_resource_status, name="toggle_resource_status"),
path("payroll/", payroll_dashboard, name="payroll_dashboard"),
path("payroll/pay/<int:worker_id>/", process_payment, name="process_payment"),
path("payroll/preview/<int:worker_id>/", preview_payslip, name="preview_payslip"),
path("payroll/payslip/<int:pk>/", payslip_detail, name="payslip_detail"),
path("loans/", loan_list, name="loan_list"),
path("loans/add/", add_loan, name="add_loan"),

View File

@ -20,14 +20,20 @@ from datetime import timedelta
from decimal import Decimal
from core.utils import render_to_pdf
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
if user.has_perm('core.view_project'):
def is_admin(user):
"""Check if user has admin-level access (staff, superuser, or in Admin group)."""
return user.is_staff or user.is_superuser
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 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
def home(request):
"""Render the landing screen with dashboard stats."""
@ -35,50 +41,75 @@ def home(request):
if not is_staff_or_supervisor(request.user):
return redirect('log_attendance')
workers_count = Worker.objects.count()
projects_count = Project.objects.count()
teams_count = Team.objects.count()
recent_logs = WorkLog.objects.order_by('-date')[:5]
user_is_admin = is_admin(request.user)
now = timezone.now()
today = now.date()
# Analytics
# 1. Outstanding Payments (Approximate, from logs only)
# Counts (used by both admin and non-admin)
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
active_workers = Worker.objects.filter(is_active=True)
for worker in active_workers:
# Find unpaid logs for this worker
unpaid_logs_count = worker.work_logs.exclude(paid_in__worker=worker).count()
outstanding_total += unpaid_logs_count * worker.day_rate
recent_payments_total = 0
active_loans_count = 0
active_loans_total = 0
week_worker_days = 0
week_projects = 0
# 2. Project Costs (Active Projects)
# Calculate sum of day_rates for all workers in all logs for each project
project_costs = []
active_projects = Project.objects.filter(is_active=True)
# Simple iteration for calculation (safer than complex annotations given properties)
for project in active_projects:
cost = 0
logs = project.logs.all()
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})
if user_is_admin:
# 1. Outstanding Payments
active_workers = Worker.objects.filter(is_active=True)
for worker in active_workers:
unpaid_logs_count = worker.work_logs.exclude(paid_in__worker=worker).count()
outstanding_total += unpaid_logs_count * worker.day_rate
# 2. Paid This Month
recent_payments_total = PayrollRecord.objects.filter(
date__year=today.year, date__month=today.month
).aggregate(total=Sum('amount'))['total'] or 0
# 3. Active Loans
active_loans = Loan.objects.filter(is_active=True)
active_loans_count = active_loans.count()
active_loans_total = active_loans.aggregate(total=Sum('balance'))['total'] or 0
# 4. This Week stats (Mon-Sun) - visible to all users
week_start = today - timedelta(days=today.weekday()) # Monday
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 []
# 3. Previous 2 months payments
two_months_ago = timezone.now().date() - timedelta(days=60)
recent_payments_total = PayrollRecord.objects.filter(date__gte=two_months_ago).aggregate(total=Sum('amount'))['total'] or 0
context = {
"is_admin_user": user_is_admin,
"workers_count": workers_count,
"projects_count": projects_count,
"teams_count": teams_count,
"recent_logs": recent_logs,
"current_time": timezone.now(),
"current_time": now,
# Admin financials
"outstanding_total": outstanding_total,
"project_costs": project_costs,
"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)
@ -104,6 +135,7 @@ def log_attendance(request):
include_sun = form.cleaned_data.get('include_sunday')
selected_workers = form.cleaned_data['workers']
project = form.cleaned_data['project']
team = form.cleaned_data.get('team')
notes = form.cleaned_data['notes']
conflict_action = request.POST.get('conflict_action')
@ -192,6 +224,7 @@ def log_attendance(request):
log = WorkLog.objects.create(
date=d,
project=project,
team=team,
notes=notes,
supervisor=request.user if request.user.is_authenticated else None
)
@ -210,16 +243,21 @@ def log_attendance(request):
else:
form = WorkLogForm(user=request.user if request.user.is_authenticated else None)
# Pass worker rates for frontend total calculation
worker_qs = form.fields['workers'].queryset
worker_rates = {w.id: float(w.day_rate) for w in worker_qs}
# 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_rates = {w.id: float(w.day_rate) for w in worker_qs}
else:
worker_rates = {}
context = {
'form': form,
'is_admin_user': user_is_admin,
'team_workers_json': json.dumps(team_workers_map),
'worker_rates_json': json.dumps(worker_rates)
}
return render(request, 'core/log_attendance.html', context)
@login_required
@ -233,19 +271,17 @@ def work_log_list(request):
project_id = request.GET.get('project')
payment_status = request.GET.get('payment_status') # 'paid', 'unpaid', 'all'
view_mode = request.GET.get('view', 'list')
logs = WorkLog.objects.all().prefetch_related('workers', 'workers__teams', 'project', 'supervisor', 'paid_in').order_by('-date', '-id')
logs = WorkLog.objects.all().select_related('team', 'project', 'supervisor').prefetch_related('workers', 'paid_in').order_by('-date', '-id')
target_worker = None
if worker_id:
logs = logs.filter(workers__id=worker_id)
# Fetch the worker to get the day rate reliably
target_worker = Worker.objects.filter(id=worker_id).first()
if team_id:
# Find workers in this team and filter logs containing them
team_workers = Worker.objects.filter(teams__id=team_id)
logs = logs.filter(workers__in=team_workers).distinct()
logs = logs.filter(team_id=team_id)
if 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))
user_is_admin = is_admin(request.user)
for log in logs:
if target_worker:
log.display_amount = target_worker.day_rate
if user_is_admin:
if target_worker:
log.display_amount = target_worker.day_rate
else:
log.display_amount = sum(w.day_rate for w in log.workers.all())
total_amount += log.display_amount
else:
# Sum of all workers in this log
log.display_amount = sum(w.day_rate for w in log.workers.all())
log.display_amount = None
final_logs.append(log)
total_amount += log.display_amount
# Context for filters
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'),
'teams': Team.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)
# 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
prev_month_date = start_date - datetime.timedelta(days=1)
next_month_date = end_date + datetime.timedelta(days=1)
context.update({
'calendar_detail_json': json.dumps(calendar_detail_data),
'calendar_weeks': calendar_weeks,
'curr_month': curr_month,
'curr_year': curr_year,
@ -389,58 +447,52 @@ def export_work_log_csv(request):
else:
logs = logs.filter(paid_in__isnull=True)
user_is_admin = is_admin(request.user)
response = HttpResponse(content_type='text/csv')
response['Content-Disposition'] = 'attachment; filename="work_logs.csv"'
writer = csv.writer(response)
writer.writerow(['Date', 'Project', 'Workers', 'Amount', 'Payment Status', 'Supervisor'])
if user_is_admin:
writer.writerow(['Date', 'Project', 'Workers', 'Amount', 'Payment Status', 'Supervisor'])
else:
writer.writerow(['Date', 'Project', 'Workers', 'Supervisor'])
for log in logs:
# Amount Logic
if target_worker:
display_amount = target_worker.day_rate
workers_str = target_worker.name
else:
display_amount = sum(w.day_rate for w in log.workers.all())
workers_str = ", ".join([w.name for w in log.workers.all()])
# Payment Status Logic
is_paid = log.paid_in.exists()
status_str = "Paid" if is_paid else "Pending"
writer.writerow([
log.date,
log.project.name,
workers_str,
f"{display_amount:.2f}",
status_str,
log.supervisor.username if log.supervisor else "System"
])
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()
status_str = "Paid" if is_paid else "Pending"
writer.writerow([
log.date, log.project.name, workers_str,
f"{display_amount:.2f}", status_str,
log.supervisor.username if log.supervisor else "System"
])
else:
writer.writerow([
log.date, log.project.name, workers_str,
log.supervisor.username if log.supervisor else "System"
])
return response
@login_required
def manage_resources(request):
"""View to manage active status of resources."""
if not request.user.is_staff and not request.user.is_superuser:
return redirect('log_attendance')
# Prefetch teams for workers to avoid N+1 in template
workers = Worker.objects.all().prefetch_related('teams').order_by('name')
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)
"""Redirect to dashboard which now includes manage resources."""
return redirect('home')
@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:
if not is_admin(request.user):
return redirect('log_attendance')
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'}."
})
return redirect('manage_resources')
return redirect('home')
@login_required
def payroll_dashboard(request):
"""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')
status_filter = request.GET.get('status', 'pending') # pending, paid, all
status_filter = request.GET.get('status', 'pending') # pending, paid, all, loans
# Common Analytics
outstanding_total = 0
@ -536,7 +588,67 @@ def payroll_dashboard(request):
# Active Loans for dropdowns/modals
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 = {
'workers_data': workers_data,
'paid_records': paid_records,
@ -546,13 +658,18 @@ def payroll_dashboard(request):
'active_tab': status_filter,
'all_workers': all_workers,
'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)
@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:
if not is_admin(request.user):
return redirect('log_attendance')
worker = get_object_or_404(Worker, pk=worker_id)
@ -643,10 +760,55 @@ def process_payment(request, worker_id):
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
def payslip_detail(request, pk):
"""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')
record = get_object_or_404(PayrollRecord, pk=pk)
@ -672,28 +834,13 @@ def payslip_detail(request, pk):
@login_required
def loan_list(request):
"""List outstanding and historical loans."""
if not request.user.is_staff and not request.user.is_superuser:
return redirect('log_attendance')
filter_status = request.GET.get('status', 'active') # active, history
if filter_status == 'history':
loans = Loan.objects.filter(is_active=False).order_by('-date')
else:
loans = Loan.objects.filter(is_active=True).order_by('-date')
context = {
'loans': loans,
'filter_status': filter_status,
'workers': Worker.objects.filter(is_active=True).order_by('name'), # For modal
}
return render(request, 'core/loan_list.html', context)
"""Redirect to payroll dashboard loans tab."""
return redirect('/payroll/?status=loans')
@login_required
def add_loan(request):
"""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')
if request.method == 'POST':
@ -712,55 +859,64 @@ def add_loan(request):
)
messages.success(request, f"Loan of R{amount} recorded for {worker.name}.")
return redirect('loan_list')
return redirect('/payroll/?status=loans')
@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:
"""Add a payroll adjustment (Bonus, Overtime, Deduction, Repayment) for one or more workers."""
if not is_admin(request.user):
return redirect('log_attendance')
if request.method == 'POST':
worker_id = request.POST.get('worker')
worker_ids = request.POST.getlist('workers')
adj_type = request.POST.get('type')
amount = request.POST.get('amount')
description = request.POST.get('description')
date = request.POST.get('date') or timezone.now().date()
loan_id = request.POST.get('loan_id') # Optional, for repayments
if worker_id and amount and adj_type:
worker = get_object_or_404(Worker, pk=worker_id)
# Validation for repayment OR Creation for New Loan
loan = None
if adj_type == 'LOAN_REPAYMENT':
if loan_id:
loan = get_object_or_404(Loan, pk=loan_id)
else:
# Try to find an active loan
loan = worker.loans.filter(is_active=True).first()
if not loan:
messages.warning(request, f"Cannot add repayment: {worker.name} has no active loans.")
return redirect('payroll_dashboard')
elif adj_type == 'LOAN':
# Create the Loan object tracking the debt
loan = Loan.objects.create(
loan_id = request.POST.get('loan_id') # Optional, for repayments
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)
# Validation for repayment OR Creation for New Loan
loan = None
if adj_type == 'LOAN_REPAYMENT':
if loan_id:
loan = get_object_or_404(Loan, pk=loan_id)
else:
loan = worker.loans.filter(is_active=True).first()
if not loan:
skip_names.append(worker.name)
continue
elif adj_type == 'LOAN':
loan = Loan.objects.create(
worker=worker,
amount=amount,
date=date,
reason=description
)
PayrollAdjustment.objects.create(
worker=worker,
type=adj_type,
amount=amount,
description=description,
date=date,
reason=description
loan=loan
)
PayrollAdjustment.objects.create(
worker=worker,
type=adj_type,
amount=amount,
description=description,
date=date,
loan=loan
)
messages.success(request, f"{adj_type} of R{amount} added for {worker.name}.")
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')
@login_required