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:
parent
1de0631044
commit
d922a14ec5
@ -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',)
|
||||
|
||||
69
core/management/commands/setup_groups.py
Normal file
69
core/management/commands/setup_groups.py
Normal 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')
|
||||
21
core/migrations/0011_remove_userprofile_pin_and_is_admin.py
Normal file
21
core/migrations/0011_remove_userprofile_pin_and_is_admin.py
Normal 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',
|
||||
),
|
||||
]
|
||||
19
core/migrations/0012_add_team_to_worklog.py
Normal file
19
core/migrations/0012_add_team_to_worklog.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@ -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)
|
||||
|
||||
@ -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 }}
|
||||
|
||||
@ -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 →</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 →</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 →</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 →</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 %}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 %}
|
||||
@ -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 %}
|
||||
10
core/urls.py
10
core/urls.py
@ -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"),
|
||||
|
||||
450
core/views.py
450
core/views.py
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user