Ver 14.05 overtime implementation

This commit is contained in:
Flatlogic Bot 2026-02-10 10:21:43 +00:00
parent 9ac3e9428f
commit 75d2e669b3
12 changed files with 274 additions and 7 deletions

View File

@ -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'}),
}
)
)

View File

@ -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),
),
]

View File

@ -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"

View File

@ -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 %}

View File

@ -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'];

View File

@ -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"),

View File

@ -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):