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:
parent
a66f75fe32
commit
da805d9bb3
68
CLAUDE.md
Normal file
68
CLAUDE.md
Normal 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
|
||||
@ -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 }} ✎
|
||||
</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 }};
|
||||
|
||||
@ -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"),
|
||||
]
|
||||
@ -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."""
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user