Ver 14.07 Edit & delete unpaid payroll adjustments

Add ability for admins to edit or delete unpaid payroll adjustments
directly from the payroll dashboard. Clickable adjustment badges open
edit/delete modals with type-specific handling (LOAN cascades, OVERTIME
un-pricing). No model changes or migrations needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-02-19 22:29:10 +02:00
parent a66f75fe32
commit da805d9bb3
4 changed files with 296 additions and 2 deletions

68
CLAUDE.md Normal file
View File

@ -0,0 +1,68 @@
# Fox Fitt LabourPay
## Project Overview
Django payroll management system for Fox Fitt Construction, a civil works contractor specializing in solar farm foundation installations. Manages field worker attendance, payroll processing, employee loans, and business expenses for solar farm projects.
## Tech Stack
- Django 5.x, Python 3.11, MariaDB
- Bootstrap 5 with Material Design-inspired styling, vanilla JavaScript
- PDF generation for payslips and receipts
- SMTP email integration for automated document delivery
- Apache reverse proxy with systemd service management
## Project Structure
- `config/` — Django project settings, URLs, WSGI/ASGI entrypoints
- `core/` — Single main app containing ALL business logic, models, views, forms, templates, and migrations
- `ai/` — Local AI API integration module
- `media/workers/ids/` — Worker ID photo uploads
- `static/` — Source static files (custom CSS)
- `staticfiles/` — Collected static assets (Bootstrap, Popper.js, admin)
## Key Business Rules
- All business logic lives in the `core/` app — do not create additional apps
- Workers have a `day_rate` calculated automatically from monthly salary / 20 working days
- PIN authentication (4-digit) exists alongside password auth for quick field access
- Supervisors can only see their assigned projects and teams
- Admins have full access to payroll, adjustments, and resource management
- WorkLog is the central attendance record — links workers to projects on specific dates
- Attendance logging includes conflict detection to prevent double-logging
- Loans have automated repayment deductions during payroll processing
- Cascading deletes use SET_NULL for supervisors to preserve historical data
## Key Models
- **UserProfile** — extends User with PIN and is_admin flag
- **Project** — work sites/contracts with supervisor assignments
- **Worker** — profiles with salary, day_rate, employment details
- **Team** — groups of workers under a supervisor
- **WorkLog** — daily attendance records (worker + project + date)
- **PayrollRecord** — completed payments linked to WorkLog entries
- **PayrollAdjustment** — bonuses, deductions, overtime, loan repayments
- **Loan** — worker advances with balance tracking
- **ExpenseReceipt / ExpenseLineItem** — business expense records
## Commands
- `python manage.py runserver 0.0.0.0:8000` — run dev server
- `python manage.py migrate` — apply database migrations
- `python manage.py collectstatic` — collect static files for production
- `python manage.py setup_groups` — configure permission groups
- `python manage.py update_permission_names` — update permission display names
- `python manage.py check` — system check (ignore staticfiles warnings about missing `assets/` and `node_modules/` dirs — pre-existing, harmless)
## Development Workflow
- Active development branch: `ai-dev` (PR target: `master`)
- Admin check in views: `is_admin(request.user)` helper (imported at top of views.py)
- "Unpaid" adjustment = `payroll_record__isnull=True` (no linked PayrollRecord)
- POST-only mutation views pattern: check `request.method != 'POST'` → redirect, then process
- Template UI pattern: Bootstrap 5 modals with dynamic JS wiring, data-* attributes on clickable elements
## PayrollAdjustment Type Handling
- **BONUS / DEDUCTION** — standalone, no linked objects
- **LOAN** — links to `Loan` model via `adj.loan` FK; editing syncs loan amount/balance/reason; deleting cascades to Loan + unpaid repayments (blocked if paid repayments exist)
- **OVERTIME** — links to `WorkLog` via `adj.work_log` FK; deleting removes worker from `work_log.overtime_paid_to` M2M (un-prices the OT)
- **LOAN_REPAYMENT** — links to `Loan` via `adj.loan` FK; loan balance only changes during payment processing
## Important Context
- Read APP_DOCUMENTATION.md for detailed workflows (attendance, payroll, expenses)
- Environment variables loaded from `../.env`
- The owner (Konrad) is not a developer — explain changes clearly and avoid unnecessary complexity
- This system handles real payroll for 14 field workers — accuracy is critical

View File

@ -163,8 +163,16 @@
{% if item.adjustments %}
<div class="mt-1">
{% for adj in item.adjustments %}
<span class="badge bg-secondary opacity-75 small">
{{ adj.get_type_display }}: R {{ adj.amount }}
<span class="badge bg-secondary opacity-75 small adj-badge" role="button"
data-adj-id="{{ adj.id }}"
data-adj-type="{{ adj.type }}"
data-adj-type-display="{{ adj.get_type_display }}"
data-adj-amount="{{ adj.amount }}"
data-adj-description="{{ adj.description }}"
data-adj-date="{{ adj.date|date:'Y-m-d' }}"
data-adj-worker="{{ item.worker.name }}"
title="Click to edit">
{{ adj.get_type_display }}: R {{ adj.amount }} &#9998;
</span>
{% endfor %}
</div>
@ -475,6 +483,74 @@
</div>
</div>
<!-- Edit Adjustment Modal -->
<div class="modal fade" id="editAdjustmentModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title fw-bold">Edit Adjustment — <span id="editAdjWorkerName"></span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form id="editAdjForm" method="POST">
{% csrf_token %}
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Type</label>
<select name="type" id="editAdjType" class="form-select">
{% for code, label in adjustment_types %}
<option value="{{ code }}">{{ label }}</option>
{% endfor %}
</select>
<div class="form-text" id="editAdjTypeHint" style="display:none;">Type cannot be changed for Loan, Overtime, or Repayment adjustments.</div>
</div>
<div class="mb-3">
<label class="form-label">Amount (R)</label>
<input type="number" name="amount" id="editAdjAmount" class="form-control" step="0.01" min="0.01" required>
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<input type="text" name="description" id="editAdjDescription" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Date</label>
<input type="date" name="date" id="editAdjDate" class="form-control" required>
</div>
</div>
<div class="modal-footer d-flex justify-content-between">
<button type="button" class="btn btn-outline-danger" id="editAdjDeleteBtn">Delete</button>
<div>
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</div>
</form>
</div>
</div>
</div>
<!-- Delete Adjustment Confirmation Modal -->
<div class="modal fade" id="deleteAdjustmentModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header bg-danger bg-opacity-10">
<h5 class="modal-title fw-bold text-danger">Confirm Delete</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="mb-1">Delete <strong><span id="deleteAdjTypeDisplay"></span></strong> of <strong>R <span id="deleteAdjAmount"></span></strong>?</p>
<div id="deleteAdjWarning" class="alert alert-warning small mt-2" style="display:none;"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button>
<form id="deleteAdjForm" method="POST" class="d-inline">
{% csrf_token %}
<button type="submit" class="btn btn-danger">Yes, Delete</button>
</form>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Adjustment modal: Select All / Clear helpers
@ -492,6 +568,65 @@ document.addEventListener('DOMContentLoaded', function() {
});
}
// Edit/Delete Adjustment Modal Logic
const editModalEl = document.getElementById('editAdjustmentModal');
const deleteModalEl = document.getElementById('deleteAdjustmentModal');
if (editModalEl) {
const editModal = new bootstrap.Modal(editModalEl);
const deleteModal = new bootstrap.Modal(deleteModalEl);
let currentAdjId = null;
let currentAdjType = null;
// Badge click → open Edit modal
document.querySelectorAll('.adj-badge').forEach(function(badge) {
badge.addEventListener('click', function() {
currentAdjId = this.dataset.adjId;
currentAdjType = this.dataset.adjType;
document.getElementById('editAdjWorkerName').textContent = this.dataset.adjWorker;
document.getElementById('editAdjAmount').value = this.dataset.adjAmount;
document.getElementById('editAdjDescription').value = this.dataset.adjDescription;
document.getElementById('editAdjDate').value = this.dataset.adjDate;
const typeSelect = document.getElementById('editAdjType');
typeSelect.value = currentAdjType;
// Lock type field for non-BONUS/DEDUCTION
const locked = !['BONUS', 'DEDUCTION'].includes(currentAdjType);
typeSelect.disabled = locked;
document.getElementById('editAdjTypeHint').style.display = locked ? 'block' : 'none';
// Set form action
document.getElementById('editAdjForm').action = '/payroll/adjustment/' + currentAdjId + '/edit/';
editModal.show();
});
});
// Delete button in edit modal → open Delete confirmation
document.getElementById('editAdjDeleteBtn').addEventListener('click', function() {
document.getElementById('deleteAdjTypeDisplay').textContent = document.getElementById('editAdjType').selectedOptions[0].text;
document.getElementById('deleteAdjAmount').textContent = document.getElementById('editAdjAmount').value;
// Type-specific warnings
const warning = document.getElementById('deleteAdjWarning');
if (currentAdjType === 'LOAN') {
warning.textContent = 'This will also delete the linked Loan and any unpaid repayment adjustments.';
warning.style.display = 'block';
} else if (currentAdjType === 'OVERTIME') {
warning.textContent = 'This will un-price the overtime so it can be re-priced later.';
warning.style.display = 'block';
} else {
warning.style.display = 'none';
}
document.getElementById('deleteAdjForm').action = '/payroll/adjustment/' + currentAdjId + '/delete/';
editModal.hide();
deleteModal.show();
});
}
const labels = {{ chart_labels_json|safe }};
const totals = {{ chart_totals_json|safe }};
const projectData = {{ project_chart_json|safe }};

View File

@ -13,6 +13,8 @@ from .views import (
loan_list,
add_loan,
add_adjustment,
edit_adjustment,
delete_adjustment,
create_receipt,
price_overtime
)
@ -32,5 +34,7 @@ urlpatterns = [
path("loans/", loan_list, name="loan_list"),
path("loans/add/", add_loan, name="add_loan"),
path("payroll/adjustment/add/", add_adjustment, name="add_adjustment"),
path("payroll/adjustment/<int:pk>/edit/", edit_adjustment, name="edit_adjustment"),
path("payroll/adjustment/<int:pk>/delete/", delete_adjustment, name="delete_adjustment"),
path("receipts/create/", create_receipt, name="create_receipt"),
]

View File

@ -1206,6 +1206,93 @@ def add_adjustment(request):
return redirect('payroll_dashboard')
@login_required
def edit_adjustment(request, pk):
"""Edit an unpaid payroll adjustment. Admin only, POST only."""
if not is_admin(request.user):
return redirect('log_attendance')
if request.method != 'POST':
return redirect('payroll_dashboard')
adj = get_object_or_404(PayrollAdjustment, pk=pk)
# Only allow editing unpaid adjustments
if adj.payroll_record is not None:
messages.error(request, "Cannot edit a paid adjustment.")
return redirect('payroll_dashboard')
amount = request.POST.get('amount')
description = request.POST.get('description')
date = request.POST.get('date')
new_type = request.POST.get('type')
if amount:
adj.amount = Decimal(amount)
if description:
adj.description = description
if date:
adj.date = date
# Only allow type change for BONUS/DEDUCTION (others have linked objects)
if new_type and adj.type in ('BONUS', 'DEDUCTION') and new_type in ('BONUS', 'DEDUCTION'):
adj.type = new_type
adj.save()
# If LOAN type, sync the linked Loan object
if adj.type == 'LOAN' and adj.loan:
adj.loan.amount = adj.amount
adj.loan.balance = adj.amount
adj.loan.reason = adj.description
adj.loan.save()
messages.success(request, f"Updated {adj.get_type_display()} for {adj.worker.name}.")
return redirect('payroll_dashboard')
@login_required
def delete_adjustment(request, pk):
"""Delete an unpaid payroll adjustment. Admin only, POST only."""
if not is_admin(request.user):
return redirect('log_attendance')
if request.method != 'POST':
return redirect('payroll_dashboard')
adj = get_object_or_404(PayrollAdjustment, pk=pk)
# Only allow deleting unpaid adjustments
if adj.payroll_record is not None:
messages.error(request, "Cannot delete a paid adjustment.")
return redirect('payroll_dashboard')
worker_name = adj.worker.name
type_display = adj.get_type_display()
if adj.type == 'LOAN' and adj.loan:
loan = adj.loan
# Check if any repayments for this loan have been paid
paid_repayments = PayrollAdjustment.objects.filter(
loan=loan, type='LOAN_REPAYMENT', payroll_record__isnull=False
).exists()
if paid_repayments:
messages.warning(request, f"Cannot delete loan for {worker_name} — it has paid repayments. Delete unpaid repayments manually first.")
return redirect('payroll_dashboard')
# Delete any unpaid repayment adjustments linked to this loan
PayrollAdjustment.objects.filter(
loan=loan, type='LOAN_REPAYMENT', payroll_record__isnull=True
).delete()
# Delete the loan itself
loan.delete()
elif adj.type == 'OVERTIME' and adj.work_log:
# Remove worker from overtime_paid_to so OT can be re-priced
adj.work_log.overtime_paid_to.remove(adj.worker)
adj.delete()
messages.success(request, f"Deleted {type_display} for {worker_name}.")
return redirect('payroll_dashboard')
@login_required
def create_receipt(request):
"""Create a new expense receipt and email it."""