Compare commits
No commits in common. "d3e1dcb6b3cca56ceea3efe93db1d5de06441d3e" and "6d8d7d41b2137dfe69c5ce7153ee8eee9170af37" have entirely different histories.
d3e1dcb6b3
...
6d8d7d41b2
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,101 +1,28 @@
|
||||
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
|
||||
|
||||
|
||||
# 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(UserProfile)
|
||||
class UserProfileAdmin(admin.ModelAdmin):
|
||||
list_display = ('user', 'pin', 'is_admin')
|
||||
|
||||
@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',)
|
||||
|
||||
readonly_fields = ('projects_worked_on_count',) # Calculated field should be readonly in edit form
|
||||
|
||||
@admin.register(Project)
|
||||
class ProjectAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'get_supervisors', 'is_active', 'created_at')
|
||||
list_filter = ('is_active',)
|
||||
list_display = ('name', 'created_at')
|
||||
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', 'worker_count', 'is_active', 'created_at')
|
||||
list_filter = ('is_active', 'supervisor')
|
||||
list_display = ('name', 'supervisor', 'created_at')
|
||||
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',)
|
||||
Binary file not shown.
@ -1,69 +0,0 @@
|
||||
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')
|
||||
@ -1,21 +0,0 @@
|
||||
# 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',
|
||||
),
|
||||
]
|
||||
@ -1,19 +0,0 @@
|
||||
# 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'),
|
||||
),
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
@ -6,6 +6,8 @@ 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"
|
||||
@ -76,7 +78,6 @@ 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)
|
||||
|
||||
@ -33,31 +33,47 @@
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto align-items-center">
|
||||
{% if user.is_authenticated %}
|
||||
{# Dashboard - visible to Admin & Work Logger #}
|
||||
{# Dashboard #}
|
||||
{% 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 - 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 %}
|
||||
|
||||
{# Log Work #}
|
||||
{% if user.is_staff or user.is_superuser or perms.core.add_worklog %}
|
||||
<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 - visible to Admin & Work Logger #}
|
||||
|
||||
{# History #}
|
||||
{% 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 - Admin only #}
|
||||
{% if user.is_staff or user.is_superuser %}
|
||||
{# Payroll #}
|
||||
{% if user.is_staff or user.is_superuser or perms.core.view_payrollrecord %}
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'payroll_dashboard' %}">Payroll</a></li>
|
||||
{% endif %}
|
||||
|
||||
{# 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 %}
|
||||
{# 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 %}
|
||||
<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 }}
|
||||
|
||||
@ -4,11 +4,6 @@
|
||||
{% 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">
|
||||
@ -26,16 +21,14 @@
|
||||
</div>
|
||||
|
||||
<div class="container mb-5">
|
||||
<!-- Stats Row -->
|
||||
<!-- Payroll Analytics 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|floatformat:2|intcomma }}</h2>
|
||||
<h2 class="mb-0">R {{ outstanding_total|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>
|
||||
@ -44,13 +37,12 @@
|
||||
<a href="{% url 'payroll_dashboard' %}" class="small text-muted mt-2 d-block stretched-link">View details →</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 This Month</p>
|
||||
<h2 class="mb-0">R {{ recent_payments_total|floatformat:2|intcomma }}</h2>
|
||||
<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>
|
||||
</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>
|
||||
@ -58,52 +50,6 @@
|
||||
</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 →</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">
|
||||
@ -117,282 +63,100 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<!-- Left Column -->
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<!-- This Week Summary -->
|
||||
<!-- Project Costs -->
|
||||
<div class="card p-4 mb-4">
|
||||
<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 →</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 →</a>
|
||||
</div>
|
||||
{% if recent_logs %}
|
||||
<h3 class="mb-3">Project Costs (Active)</h3>
|
||||
{% if project_costs %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<table class="table align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Project</th>
|
||||
<th>Team</th>
|
||||
<th>Workers</th>
|
||||
<th>Project Name</th>
|
||||
<th class="text-end">Total Labor Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in recent_logs %}
|
||||
{% for p in project_costs %}
|
||||
<tr>
|
||||
<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>
|
||||
<td class="fw-bold">{{ p.name }}</td>
|
||||
<td class="text-end">R {{ p.cost|intcomma }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<p class="text-muted mb-2">No recent work logs found.</p>
|
||||
<p class="text-muted">No cost data available for active projects.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Recent Logs -->
|
||||
<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>
|
||||
{% if recent_logs %}
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Project</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>
|
||||
</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>
|
||||
<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">
|
||||
{% 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 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 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>
|
||||
</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>
|
||||
<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
|
||||
</a>
|
||||
<a class="sidebar-link" href="/admin/core/worker/">
|
||||
<span class="me-2">👷</span> Manage Workers
|
||||
</a>
|
||||
<a class="sidebar-link" href="/admin/core/project/">
|
||||
<span class="me-2">🏗️</span> Manage Projects
|
||||
</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>
|
||||
{% endif %}
|
||||
</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 %}
|
||||
@ -86,8 +86,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if is_admin_user %}
|
||||
<!-- Total Cost Estimation (Admin only) -->
|
||||
<!-- Total Cost Estimation -->
|
||||
<div class="card p-3 mb-4 bg-light border-0 shadow-sm">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
@ -97,7 +96,6 @@
|
||||
<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>
|
||||
@ -184,8 +182,7 @@
|
||||
myModal.show();
|
||||
}
|
||||
|
||||
// --- Cost Calculation Logic (Admin only) ---
|
||||
{% if is_admin_user %}
|
||||
// --- Cost Calculation Logic ---
|
||||
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 }}');
|
||||
@ -194,13 +191,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;
|
||||
@ -211,25 +208,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;
|
||||
@ -237,23 +234,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() {
|
||||
@ -261,10 +258,9 @@
|
||||
setTimeout(calculateTotal, 100);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Initial Run
|
||||
calculateTotal();
|
||||
{% endif %}
|
||||
});
|
||||
|
||||
function submitConflict(action) {
|
||||
|
||||
@ -3,15 +3,12 @@
|
||||
|
||||
{% 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>
|
||||
@ -64,26 +61,6 @@
|
||||
</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">
|
||||
@ -95,9 +72,6 @@
|
||||
<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 -->
|
||||
@ -145,17 +119,13 @@
|
||||
</td>
|
||||
<td class="text-end pe-4">
|
||||
{% if item.total_payable > 0 %}
|
||||
<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>
|
||||
<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>
|
||||
{% else %}
|
||||
<button class="btn btn-sm btn-secondary" disabled>Nothing to Pay</button>
|
||||
{% endif %}
|
||||
@ -226,112 +196,6 @@
|
||||
{% 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 -->
|
||||
@ -346,17 +210,13 @@
|
||||
{% csrf_token %}
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Workers</label>
|
||||
<select name="workers" id="adjWorkerSelect" class="form-select" multiple required style="height: auto; min-height: 42px;">
|
||||
<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 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>
|
||||
@ -388,179 +248,4 @@
|
||||
</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 %}
|
||||
@ -68,7 +68,6 @@
|
||||
{% 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">
|
||||
@ -77,7 +76,6 @@
|
||||
<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>
|
||||
@ -95,12 +93,10 @@
|
||||
<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>
|
||||
@ -142,9 +138,7 @@
|
||||
{% for week in calendar_weeks %}
|
||||
<tr>
|
||||
{% for day_info in week %}
|
||||
<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;">
|
||||
<td class="{% if not day_info.is_current_month %}bg-light text-muted bg-opacity-10{% endif %}" style="height: 140px; vertical-align: top; padding: 10px;">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<span class="fw-bold {% if day_info.date == current_time.date %}text-primary bg-primary bg-opacity-10 px-2 rounded-circle{% endif %}">{{ day_info.day }}</span>
|
||||
{% if day_info.logs %}
|
||||
@ -155,11 +149,13 @@
|
||||
{% 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>
|
||||
{% 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 %}
|
||||
{% with team=log.workers.first.teams.first %}
|
||||
{% if team %}
|
||||
<div class="text-muted small text-truncate" style="font-size: 0.7rem;">
|
||||
<i class="bi bi-people-fill me-1"></i>{{ team.name }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<div class="text-muted text-truncate" title="{{ log.workers.count }} workers">
|
||||
{% if selected_worker %}
|
||||
{{ log.workers.first.name }}
|
||||
@ -181,16 +177,6 @@
|
||||
</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 -->
|
||||
@ -204,8 +190,8 @@
|
||||
<th class="ps-4">Date</th>
|
||||
<th>Project</th>
|
||||
<th>Labourers</th>
|
||||
{% if is_admin_user %}<th>Amount</th>{% endif %}
|
||||
{% if is_admin_user %}<th>Status / Payslip</th>{% endif %}
|
||||
<th>Amount</th>
|
||||
<th>Status / Payslip</th>
|
||||
<th>Supervisor</th>
|
||||
<th class="pe-4 text-end">Action</th>
|
||||
</tr>
|
||||
@ -244,7 +230,6 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% if is_admin_user %}
|
||||
<td>
|
||||
<span class="fw-bold font-monospace text-dark">R {{ log.display_amount|floatformat:2 }}</span>
|
||||
</td>
|
||||
@ -263,7 +248,6 @@
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</td>
|
||||
{% endif %}
|
||||
<td>
|
||||
<small class="text-muted">{{ log.supervisor.username|default:"System" }}</small>
|
||||
</td>
|
||||
@ -284,7 +268,6 @@
|
||||
</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>
|
||||
@ -292,7 +275,6 @@
|
||||
<td colspan="3"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
@ -313,7 +295,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;
|
||||
@ -322,118 +304,5 @@
|
||||
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 %}
|
||||
10
core/urls.py
10
core/urls.py
@ -1,14 +1,13 @@
|
||||
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,
|
||||
@ -25,7 +24,6 @@ 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"),
|
||||
|
||||
450
core/views.py
450
core/views.py
@ -20,20 +20,14 @@ from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from core.utils import render_to_pdf
|
||||
|
||||
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():
|
||||
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'):
|
||||
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."""
|
||||
@ -41,75 +35,50 @@ def home(request):
|
||||
if not is_staff_or_supervisor(request.user):
|
||||
return redirect('log_attendance')
|
||||
|
||||
user_is_admin = is_admin(request.user)
|
||||
now = timezone.now()
|
||||
today = now.date()
|
||||
workers_count = Worker.objects.count()
|
||||
projects_count = Project.objects.count()
|
||||
teams_count = Team.objects.count()
|
||||
recent_logs = WorkLog.objects.order_by('-date')[:5]
|
||||
|
||||
# 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 ---
|
||||
# Analytics
|
||||
# 1. Outstanding Payments (Approximate, from logs only)
|
||||
outstanding_total = 0
|
||||
recent_payments_total = 0
|
||||
active_loans_count = 0
|
||||
active_loans_total = 0
|
||||
week_worker_days = 0
|
||||
week_projects = 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
|
||||
|
||||
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 []
|
||||
# 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})
|
||||
|
||||
# 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": now,
|
||||
# Admin financials
|
||||
"current_time": timezone.now(),
|
||||
"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)
|
||||
|
||||
@ -135,7 +104,6 @@ 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')
|
||||
|
||||
@ -224,7 +192,6 @@ 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
|
||||
)
|
||||
@ -243,21 +210,16 @@ def log_attendance(request):
|
||||
else:
|
||||
form = WorkLogForm(user=request.user if request.user.is_authenticated else None)
|
||||
|
||||
# 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 = {}
|
||||
# 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}
|
||||
|
||||
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
|
||||
@ -271,17 +233,19 @@ 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().select_related('team', 'project', 'supervisor').prefetch_related('workers', 'paid_in').order_by('-date', '-id')
|
||||
|
||||
|
||||
logs = WorkLog.objects.all().prefetch_related('workers', 'workers__teams', 'project', 'supervisor', 'paid_in').order_by('-date', '-id')
|
||||
|
||||
target_worker = None
|
||||
if worker_id:
|
||||
logs = logs.filter(workers__id=worker_id)
|
||||
# Fetch the worker to get the day rate reliably
|
||||
target_worker = Worker.objects.filter(id=worker_id).first()
|
||||
|
||||
|
||||
if team_id:
|
||||
logs = logs.filter(team_id=team_id)
|
||||
# Find workers in this team and filter logs containing them
|
||||
team_workers = Worker.objects.filter(teams__id=team_id)
|
||||
logs = logs.filter(workers__in=team_workers).distinct()
|
||||
|
||||
if project_id:
|
||||
logs = logs.filter(project_id=project_id)
|
||||
@ -325,23 +289,18 @@ 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 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
|
||||
if target_worker:
|
||||
log.display_amount = target_worker.day_rate
|
||||
else:
|
||||
log.display_amount = None
|
||||
# Sum of all workers in this log
|
||||
log.display_amount = sum(w.day_rate for w in log.workers.all())
|
||||
final_logs.append(log)
|
||||
total_amount += log.display_amount
|
||||
|
||||
# Context for filters
|
||||
context = {
|
||||
'is_admin_user': user_is_admin,
|
||||
'total_amount': total_amount if user_is_admin else None,
|
||||
'total_amount': total_amount,
|
||||
'workers': Worker.objects.filter(is_active=True).order_by('name'),
|
||||
'teams': Team.objects.filter(is_active=True).order_by('name'),
|
||||
'projects': Project.objects.filter(is_active=True).order_by('name'),
|
||||
@ -377,28 +336,11 @@ 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,
|
||||
@ -447,52 +389,58 @@ 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)
|
||||
if user_is_admin:
|
||||
writer.writerow(['Date', 'Project', 'Workers', 'Amount', 'Payment Status', 'Supervisor'])
|
||||
else:
|
||||
writer.writerow(['Date', 'Project', 'Workers', 'Supervisor'])
|
||||
writer.writerow(['Date', 'Project', 'Workers', 'Amount', 'Payment Status', 'Supervisor'])
|
||||
|
||||
for log in logs:
|
||||
# Amount Logic
|
||||
if target_worker:
|
||||
display_amount = target_worker.day_rate
|
||||
workers_str = target_worker.name
|
||||
else:
|
||||
display_amount = sum(w.day_rate for w in log.workers.all())
|
||||
workers_str = ", ".join([w.name for w in log.workers.all()])
|
||||
|
||||
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"
|
||||
])
|
||||
# Payment Status Logic
|
||||
is_paid = log.paid_in.exists()
|
||||
status_str = "Paid" if is_paid else "Pending"
|
||||
|
||||
writer.writerow([
|
||||
log.date,
|
||||
log.project.name,
|
||||
workers_str,
|
||||
f"{display_amount:.2f}",
|
||||
status_str,
|
||||
log.supervisor.username if log.supervisor else "System"
|
||||
])
|
||||
|
||||
return response
|
||||
|
||||
@login_required
|
||||
def manage_resources(request):
|
||||
"""Redirect to dashboard which now includes manage resources."""
|
||||
return redirect('home')
|
||||
"""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)
|
||||
|
||||
@login_required
|
||||
def toggle_resource_status(request, model_type, pk):
|
||||
"""Toggle the is_active status of a resource."""
|
||||
if not is_admin(request.user):
|
||||
if not request.user.is_staff and not request.user.is_superuser:
|
||||
return redirect('log_attendance')
|
||||
|
||||
if request.method == 'POST':
|
||||
@ -515,15 +463,15 @@ def toggle_resource_status(request, model_type, pk):
|
||||
'message': f"{obj.name} is now {'Active' if obj.is_active else 'Inactive'}."
|
||||
})
|
||||
|
||||
return redirect('home')
|
||||
return redirect('manage_resources')
|
||||
|
||||
@login_required
|
||||
def payroll_dashboard(request):
|
||||
"""Dashboard for payroll management with filtering."""
|
||||
if not is_admin(request.user):
|
||||
if not request.user.is_staff and not request.user.is_superuser:
|
||||
return redirect('log_attendance')
|
||||
|
||||
status_filter = request.GET.get('status', 'pending') # pending, paid, all, loans
|
||||
status_filter = request.GET.get('status', 'pending') # pending, paid, all
|
||||
|
||||
# Common Analytics
|
||||
outstanding_total = 0
|
||||
@ -588,67 +536,7 @@ 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,
|
||||
@ -658,18 +546,13 @@ 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 is_admin(request.user):
|
||||
if not request.user.is_staff and not request.user.is_superuser:
|
||||
return redirect('log_attendance')
|
||||
|
||||
worker = get_object_or_404(Worker, pk=worker_id)
|
||||
@ -760,55 +643,10 @@ 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 is_admin(request.user):
|
||||
if not request.user.is_staff and not request.user.is_superuser:
|
||||
return redirect('log_attendance')
|
||||
|
||||
record = get_object_or_404(PayrollRecord, pk=pk)
|
||||
@ -834,13 +672,28 @@ def payslip_detail(request, pk):
|
||||
|
||||
@login_required
|
||||
def loan_list(request):
|
||||
"""Redirect to payroll dashboard loans tab."""
|
||||
return redirect('/payroll/?status=loans')
|
||||
"""List outstanding and historical loans."""
|
||||
if not request.user.is_staff and not request.user.is_superuser:
|
||||
return redirect('log_attendance')
|
||||
|
||||
filter_status = request.GET.get('status', 'active') # active, history
|
||||
|
||||
if filter_status == 'history':
|
||||
loans = Loan.objects.filter(is_active=False).order_by('-date')
|
||||
else:
|
||||
loans = Loan.objects.filter(is_active=True).order_by('-date')
|
||||
|
||||
context = {
|
||||
'loans': loans,
|
||||
'filter_status': filter_status,
|
||||
'workers': Worker.objects.filter(is_active=True).order_by('name'), # For modal
|
||||
}
|
||||
return render(request, 'core/loan_list.html', context)
|
||||
|
||||
@login_required
|
||||
def add_loan(request):
|
||||
"""Create a new loan."""
|
||||
if not is_admin(request.user):
|
||||
if not request.user.is_staff and not request.user.is_superuser:
|
||||
return redirect('log_attendance')
|
||||
|
||||
if request.method == 'POST':
|
||||
@ -859,64 +712,55 @@ def add_loan(request):
|
||||
)
|
||||
messages.success(request, f"Loan of R{amount} recorded for {worker.name}.")
|
||||
|
||||
return redirect('/payroll/?status=loans')
|
||||
return redirect('loan_list')
|
||||
|
||||
@login_required
|
||||
def add_adjustment(request):
|
||||
"""Add a payroll adjustment (Bonus, Overtime, Deduction, Repayment) for one or more workers."""
|
||||
if not is_admin(request.user):
|
||||
"""Add a payroll adjustment (Bonus, Overtime, Deduction, Repayment)."""
|
||||
if not request.user.is_staff and not request.user.is_superuser:
|
||||
return redirect('log_attendance')
|
||||
|
||||
if request.method == 'POST':
|
||||
worker_ids = request.POST.getlist('workers')
|
||||
worker_id = request.POST.get('worker')
|
||||
adj_type = request.POST.get('type')
|
||||
amount = request.POST.get('amount')
|
||||
description = request.POST.get('description')
|
||||
date = request.POST.get('date') or timezone.now().date()
|
||||
loan_id = request.POST.get('loan_id') # Optional, for repayments
|
||||
|
||||
if worker_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(
|
||||
loan_id = request.POST.get('loan_id') # Optional, for repayments
|
||||
|
||||
if worker_id and amount and adj_type:
|
||||
worker = get_object_or_404(Worker, pk=worker_id)
|
||||
|
||||
# Validation for repayment OR Creation for New Loan
|
||||
loan = None
|
||||
if adj_type == 'LOAN_REPAYMENT':
|
||||
if loan_id:
|
||||
loan = get_object_or_404(Loan, pk=loan_id)
|
||||
else:
|
||||
# Try to find an active loan
|
||||
loan = worker.loans.filter(is_active=True).first()
|
||||
if not loan:
|
||||
messages.warning(request, f"Cannot add repayment: {worker.name} has no active loans.")
|
||||
return redirect('payroll_dashboard')
|
||||
elif adj_type == 'LOAN':
|
||||
# Create the Loan object tracking the debt
|
||||
loan = Loan.objects.create(
|
||||
worker=worker,
|
||||
type=adj_type,
|
||||
amount=amount,
|
||||
description=description,
|
||||
date=date,
|
||||
loan=loan
|
||||
reason=description
|
||||
)
|
||||
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}.")
|
||||
|
||||
|
||||
PayrollAdjustment.objects.create(
|
||||
worker=worker,
|
||||
type=adj_type,
|
||||
amount=amount,
|
||||
description=description,
|
||||
date=date,
|
||||
loan=loan
|
||||
)
|
||||
messages.success(request, f"{adj_type} of R{amount} added for {worker.name}.")
|
||||
|
||||
return redirect('payroll_dashboard')
|
||||
|
||||
@login_required
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user