Ver 14.05 overtime implementation
This commit is contained in:
parent
9ac3e9428f
commit
75d2e669b3
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -22,9 +22,17 @@ class WorkLogForm(forms.ModelForm):
|
||||
)
|
||||
team = forms.ModelChoiceField(queryset=Team.objects.none(), required=False, empty_label="Select Team", widget=forms.Select(attrs={'class': 'form-control'}))
|
||||
|
||||
# Explicitly defining overtime to ensure it's not required and has correct widget
|
||||
overtime = forms.ChoiceField(
|
||||
choices=WorkLog.OT_CHOICES,
|
||||
required=False,
|
||||
initial=0,
|
||||
widget=forms.Select(attrs={'class': 'form-control'})
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = WorkLog
|
||||
fields = ['date', 'project', 'workers', 'notes']
|
||||
fields = ['date', 'project', 'workers', 'notes', 'overtime']
|
||||
widgets = {
|
||||
'date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
|
||||
'project': forms.Select(attrs={'class': 'form-control'}),
|
||||
@ -80,4 +88,4 @@ ExpenseLineItemFormSet = inlineformset_factory(
|
||||
'product': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Item Name'}),
|
||||
'amount': forms.NumberInput(attrs={'class': 'form-control item-amount', 'step': '0.01'}),
|
||||
}
|
||||
)
|
||||
)
|
||||
@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.2.7 on 2026-02-10 09:14
|
||||
|
||||
from decimal import Decimal
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0012_add_team_to_worklog'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='worklog',
|
||||
name='overtime',
|
||||
field=models.DecimalField(choices=[(Decimal('0'), 'None'), (Decimal('0.25'), '1/4 Day'), (Decimal('0.5'), '1/2 Day'), (Decimal('0.75'), '3/4 Day'), (Decimal('1.0'), 'Full Day')], decimal_places=2, default=0, max_digits=3),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='worklog',
|
||||
name='overtime_priced',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
Binary file not shown.
@ -74,12 +74,23 @@ class Team(models.Model):
|
||||
return self.name
|
||||
|
||||
class WorkLog(models.Model):
|
||||
OT_CHOICES = [
|
||||
(Decimal('0'), 'None'),
|
||||
(Decimal('0.25'), '1/4 Day'),
|
||||
(Decimal('0.5'), '1/2 Day'),
|
||||
(Decimal('0.75'), '3/4 Day'),
|
||||
(Decimal('1.0'), 'Full Day'),
|
||||
]
|
||||
|
||||
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)
|
||||
|
||||
overtime = models.DecimalField(max_digits=3, decimal_places=2, default=0, choices=OT_CHOICES)
|
||||
overtime_priced = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Work Log / Attendance"
|
||||
|
||||
@ -56,6 +56,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Overtime Row -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label fw-bold">Overtime (all workers)</label>
|
||||
{{ form.overtime }}
|
||||
<div class="form-text text-muted small">Optional — extra time beyond the standard day</div>
|
||||
{% if form.overtime.errors %}
|
||||
<div class="text-danger mt-1 small">{{ form.overtime.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<label class="form-label fw-bold mb-0">Select Labourers</label>
|
||||
@ -288,4 +300,4 @@
|
||||
form.submit();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
@ -120,7 +120,12 @@
|
||||
{% for item in workers_data %}
|
||||
<tr>
|
||||
<td class="ps-4">
|
||||
<div class="fw-bold text-dark">{{ item.worker.name }}</div>
|
||||
<div class="fw-bold text-dark">
|
||||
{{ item.worker.name }}
|
||||
{% if item.ot_hours_unpriced > 0 %}
|
||||
<span class="badge bg-warning text-dark ms-2">OT: {{ item.ot_hours_unpriced|floatformat:2 }} Days</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="small text-muted">ID: {{ item.worker.id_no }}</div>
|
||||
{% if item.adjustments %}
|
||||
<div class="mt-1">
|
||||
@ -146,6 +151,12 @@
|
||||
<td class="text-end pe-4">
|
||||
{% if item.total_payable > 0 %}
|
||||
<div class="d-flex gap-1 justify-content-end">
|
||||
{% if item.ot_hours_unpriced > 0 %}
|
||||
<button type="button" class="btn btn-sm btn-warning text-dark price-ot-btn"
|
||||
data-worker-id="{{ item.worker.id }}" data-worker-name="{{ item.worker.name }}">
|
||||
Price OT
|
||||
</button>
|
||||
{% endif %}
|
||||
<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">
|
||||
@ -292,6 +303,48 @@
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Price Overtime Modal -->
|
||||
<div class="modal fade" id="priceOTModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title fw-bold">Price Overtime for <span id="otModalWorkerName"></span></h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row mb-3 align-items-end">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-bold">Bulk Rate %</label>
|
||||
<div class="input-group">
|
||||
<input type="number" id="otBulkRate" class="form-control" value="50" min="0" step="5">
|
||||
<button class="btn btn-outline-secondary" type="button" id="otApplyBulk">Apply to All</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Project</th>
|
||||
<th>OT Duration</th>
|
||||
<th style="width: 150px;">Rate %</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="otModalTableBody">
|
||||
<!-- Rows injected by JS -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="otSaveBtn">Create Adjustments</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Loan Modal -->
|
||||
<div class="modal fade" id="addLoanModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
@ -409,6 +462,95 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const labels = {{ chart_labels_json|safe }};
|
||||
const totals = {{ chart_totals_json|safe }};
|
||||
const projectData = {{ project_chart_json|safe }};
|
||||
const allOtData = {{ overtime_data_json|default:"[]"|safe }};
|
||||
|
||||
// Overtime Modal Logic
|
||||
const otModalEl = document.getElementById('priceOTModal');
|
||||
if (otModalEl) {
|
||||
const otModal = new bootstrap.Modal(otModalEl);
|
||||
|
||||
document.body.addEventListener('click', function(e) {
|
||||
if (e.target.classList.contains('price-ot-btn')) {
|
||||
const workerId = parseInt(e.target.dataset.workerId);
|
||||
const workerName = e.target.dataset.workerName;
|
||||
|
||||
document.getElementById('otModalWorkerName').textContent = workerName;
|
||||
|
||||
const tbody = document.getElementById('otModalTableBody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
// Filter data for this worker
|
||||
const workerLogs = allOtData.filter(d => d.worker_id === workerId);
|
||||
|
||||
workerLogs.forEach(log => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${log.date}</td>
|
||||
<td>${log.project}</td>
|
||||
<td>${log.ot_label}</td>
|
||||
<td>
|
||||
<input type="number" class="form-control form-control-sm ot-rate-input"
|
||||
data-log-id="${log.log_id}"
|
||||
data-worker-id="${log.worker_id}"
|
||||
value="50" min="0" step="5">
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
otModal.show();
|
||||
}
|
||||
});
|
||||
|
||||
const bulkBtn = document.getElementById('otApplyBulk');
|
||||
if (bulkBtn) {
|
||||
bulkBtn.addEventListener('click', function() {
|
||||
const rate = document.getElementById('otBulkRate').value;
|
||||
document.querySelectorAll('.ot-rate-input').forEach(input => {
|
||||
input.value = rate;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const saveBtn = document.getElementById('otSaveBtn');
|
||||
if (saveBtn) {
|
||||
saveBtn.addEventListener('click', function() {
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '{% url "price_overtime" %}';
|
||||
|
||||
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value || '{{ csrf_token }}';
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrfmiddlewaretoken';
|
||||
csrfInput.value = csrfToken;
|
||||
form.appendChild(csrfInput);
|
||||
|
||||
document.querySelectorAll('.ot-rate-input').forEach(input => {
|
||||
const logId = document.createElement('input');
|
||||
logId.type = 'hidden';
|
||||
logId.name = 'log_id';
|
||||
logId.value = input.dataset.logId;
|
||||
form.appendChild(logId);
|
||||
|
||||
const workerId = document.createElement('input');
|
||||
workerId.type = 'hidden';
|
||||
workerId.name = 'worker_id';
|
||||
workerId.value = input.dataset.workerId;
|
||||
form.appendChild(workerId);
|
||||
|
||||
const rate = document.createElement('input');
|
||||
rate.type = 'hidden';
|
||||
rate.name = 'rate_pct';
|
||||
rate.value = input.value;
|
||||
form.appendChild(rate);
|
||||
});
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Colour palette
|
||||
const colours = ['#10b981','#3b82f6','#f59e0b','#ef4444','#8b5cf6','#ec4899','#06b6d4','#84cc16'];
|
||||
|
||||
@ -13,7 +13,8 @@ from .views import (
|
||||
loan_list,
|
||||
add_loan,
|
||||
add_adjustment,
|
||||
create_receipt
|
||||
create_receipt,
|
||||
price_overtime
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
@ -26,6 +27,7 @@ urlpatterns = [
|
||||
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/price-overtime/", price_overtime, name="price_overtime"),
|
||||
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"),
|
||||
|
||||
@ -137,6 +137,7 @@ def log_attendance(request):
|
||||
project = form.cleaned_data['project']
|
||||
team = form.cleaned_data.get('team')
|
||||
notes = form.cleaned_data['notes']
|
||||
overtime = form.cleaned_data.get('overtime', 0) # Read overtime
|
||||
conflict_action = request.POST.get('conflict_action')
|
||||
|
||||
# Generate Target Dates
|
||||
@ -226,7 +227,8 @@ def log_attendance(request):
|
||||
project=project,
|
||||
team=team,
|
||||
notes=notes,
|
||||
supervisor=request.user if request.user.is_authenticated else None
|
||||
supervisor=request.user if request.user.is_authenticated else None,
|
||||
overtime=overtime # Save overtime
|
||||
)
|
||||
log.workers.set(workers_to_save)
|
||||
created_count += len(workers_to_save)
|
||||
@ -652,12 +654,32 @@ def payroll_dashboard(request):
|
||||
active_workers = Worker.objects.filter(is_active=True).order_by('name').prefetch_related('adjustments')
|
||||
|
||||
workers_data = [] # For pending payments
|
||||
all_ot_data = [] # For JSON context
|
||||
|
||||
for worker in active_workers:
|
||||
# Unpaid Work Logs
|
||||
unpaid_logs = worker.work_logs.exclude(paid_in__worker=worker)
|
||||
log_count = unpaid_logs.count()
|
||||
log_amount = log_count * worker.day_rate
|
||||
|
||||
# Overtime Logic (Step 5)
|
||||
ot_logs = worker.work_logs.filter(overtime__gt=0, overtime_priced=False).exclude(paid_in__worker=worker).select_related('project')
|
||||
ot_data_worker = []
|
||||
ot_hours_unpriced = Decimal('0.0')
|
||||
|
||||
for log in ot_logs:
|
||||
entry = {
|
||||
'worker_id': worker.id,
|
||||
'worker_name': worker.name,
|
||||
'log_id': log.id,
|
||||
'date': log.date.strftime('%Y-%m-%d'),
|
||||
'project': log.project.name,
|
||||
'overtime': float(log.overtime),
|
||||
'ot_label': log.get_overtime_display(),
|
||||
}
|
||||
ot_data_worker.append(entry)
|
||||
all_ot_data.append(entry)
|
||||
ot_hours_unpriced += log.overtime
|
||||
|
||||
# Pending Adjustments (unlinked to any payroll record)
|
||||
pending_adjustments = worker.adjustments.filter(payroll_record__isnull=True)
|
||||
@ -683,7 +705,10 @@ def payroll_dashboard(request):
|
||||
'adj_amount': adj_total,
|
||||
'total_payable': total_payable,
|
||||
'adjustments': pending_adjustments,
|
||||
'logs': unpaid_logs
|
||||
'logs': unpaid_logs,
|
||||
'ot_data': ot_data_worker,
|
||||
'ot_hours_unpriced': float(ot_hours_unpriced),
|
||||
'day_rate': float(worker.day_rate),
|
||||
})
|
||||
|
||||
# Paid History
|
||||
@ -785,6 +810,7 @@ def payroll_dashboard(request):
|
||||
'chart_labels_json': json.dumps(chart_labels),
|
||||
'chart_totals_json': json.dumps(chart_totals),
|
||||
'project_chart_json': json.dumps(project_chart_data),
|
||||
'overtime_data_json': json.dumps(all_ot_data),
|
||||
}
|
||||
return render(request, 'core/payroll_dashboard.html', context)
|
||||
|
||||
@ -926,6 +952,48 @@ def preview_payslip(request, worker_id):
|
||||
'net_pay': total_amount,
|
||||
})
|
||||
|
||||
@login_required
|
||||
def price_overtime(request):
|
||||
"""Create OVERTIME PayrollAdjustments from unpriced overtime logs."""
|
||||
if not is_admin(request.user):
|
||||
return redirect('payroll_dashboard')
|
||||
|
||||
if request.method != 'POST':
|
||||
return redirect('payroll_dashboard')
|
||||
|
||||
log_ids = request.POST.getlist('log_id')
|
||||
worker_ids = request.POST.getlist('worker_id')
|
||||
rate_pcts = request.POST.getlist('rate_pct')
|
||||
|
||||
created = 0
|
||||
|
||||
for log_id, worker_id, rate_pct in zip(log_ids, worker_ids, rate_pcts):
|
||||
try:
|
||||
worklog = WorkLog.objects.get(pk=log_id)
|
||||
worker = Worker.objects.get(pk=worker_id)
|
||||
rate = Decimal(rate_pct)
|
||||
|
||||
# Formula: Day Rate * Overtime Fraction * (Rate % / 100)
|
||||
amount = worker.day_rate * worklog.overtime * (rate / Decimal('100'))
|
||||
|
||||
if amount > 0:
|
||||
PayrollAdjustment.objects.create(
|
||||
worker=worker,
|
||||
type='OVERTIME',
|
||||
amount=amount,
|
||||
date=worklog.date,
|
||||
description=f"Overtime: {worklog.get_overtime_display()} @ {rate_pct}% - {worklog.date.strftime('%d %b %Y')}"
|
||||
)
|
||||
created += 1
|
||||
|
||||
worklog.overtime_priced = True
|
||||
worklog.save()
|
||||
|
||||
except (WorkLog.DoesNotExist, Worker.DoesNotExist, Exception):
|
||||
continue
|
||||
|
||||
messages.success(request, f"Created {created} overtime adjustment(s).")
|
||||
return redirect('payroll_dashboard')
|
||||
|
||||
@login_required
|
||||
def payslip_detail(request, pk):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user