Ver 14.03 Adjustments added to work log

This commit is contained in:
Flatlogic Bot 2026-02-08 23:26:12 +00:00
parent 8724b9d7bb
commit df4cadf024
3 changed files with 253 additions and 106 deletions

View File

@ -9,7 +9,7 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="display-5 mb-2">Work Log History</h1>
<p class="lead opacity-75">Filter and review historical daily work logs.</p>
<p class="lead opacity-75">Filter and review historical daily work logs and adjustments.</p>
</div>
<div class="d-flex gap-2 align-items-center">
<!-- View Switcher -->
@ -142,31 +142,31 @@
{% for week in calendar_weeks %}
<tr>
{% for day_info in week %}
<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 %}"
<td class="cal-day {% if day_info.records %}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 %}
<span class="badge bg-secondary opacity-50">{{ day_info.logs|length }}</span>
{% if day_info.records %}
<span class="badge bg-secondary opacity-50">{{ day_info.records|length }}</span>
{% endif %}
</div>
<div class="calendar-events" style="max-height: 100px; overflow-y: auto;">
{% 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>
{% if log.team %}
{% for record in day_info.records %}
<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 {% if record.type == 'ADJ' %}#dc3545{% else %}var(--bs-primary){% endif %} !important;">
<div class="fw-bold text-truncate">{{ record.project_name }}</div>
{% if record.type == 'WORK' and record.team_name %}
<div class="text-muted small text-truncate" style="font-size: 0.7rem;">
<i class="bi bi-people-fill me-1"></i>{{ log.team.name }}
<i class="bi bi-people-fill me-1"></i>{{ record.team_name }}
</div>
{% endif %}
<div class="text-muted text-truncate" title="{{ log.workers.count }} workers">
<div class="text-muted text-truncate">
{% if selected_worker %}
{{ log.workers.first.name }}
{% elif log.workers.count == 1 %}
{{ log.workers.first.name }}
{{ record.workers.0.name }}
{% elif record.workers|length == 1 %}
{{ record.workers.0.name }}
{% else %}
{{ log.workers.count }} workers
{{ record.workers|length }} workers
{% endif %}
</div>
</div>
@ -196,14 +196,14 @@
<!-- LIST VIEW -->
<div class="card shadow-sm border-0">
<div class="card-body p-0">
{% if logs %}
{% if records %}
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="bg-light">
<tr>
<th class="ps-4">Date</th>
<th>Project</th>
<th>Labourers</th>
<th>Description / Project</th>
<th>Worker(s)</th>
{% if is_admin_user %}<th>Amount</th>{% endif %}
{% if is_admin_user %}<th>Status / Payslip</th>{% endif %}
<th>Supervisor</th>
@ -211,61 +211,70 @@
</tr>
</thead>
<tbody>
{% for log in logs %}
{% for record in records %}
<tr>
<td class="ps-4">
<span class="fw-bold">{{ log.date|date:"D, d M Y" }}</span>
<span class="fw-bold">{{ record.date|date:"D, d M Y" }}</span>
</td>
<td>
<span class="badge bg-secondary bg-opacity-10 text-secondary border border-secondary border-opacity-25 px-2 py-1">
{{ log.project.name }}
</span>
{% if record.type == 'WORK' %}
<span class="badge bg-secondary bg-opacity-10 text-secondary border border-secondary border-opacity-25 px-2 py-1">
{{ record.project_name }}
</span>
{% if record.team_name %}
<small class="text-muted ms-1"><i class="bi bi-people-fill"></i> {{ record.team_name }}</small>
{% endif %}
{% else %}
<span class="badge bg-danger bg-opacity-10 text-danger border border-danger border-opacity-25 px-2 py-1">
{{ record.project_name }}
</span>
{% endif %}
</td>
<td>
{% if selected_worker %}
<span class="text-primary fw-medium">
{% for w in log.workers.all %}
{% for w in record.workers %}
{% if w.id == selected_worker %}
{{ w.name }}
{% endif %}
{% endfor %}
</span>
{% if log.workers.count > 1 %}
<small class="text-muted">(+{{ log.workers.count|add:"-1" }} others)</small>
{% if record.workers|length > 1 %}
<small class="text-muted">(+{{ record.workers|length|add:"-1" }} others)</small>
{% endif %}
{% else %}
<div class="d-flex flex-wrap gap-1">
{% for w in log.workers.all|slice:":3" %}
{% for w in record.workers|slice:":3" %}
<span class="small bg-light px-2 py-1 rounded border">{{ w.name|truncatechars:12 }}</span>
{% endfor %}
{% if log.workers.count > 3 %}
<span class="small text-muted align-self-center ms-1">+{{ log.workers.count|add:"-3" }}</span>
{% if record.workers|length > 3 %}
<span class="small text-muted align-self-center ms-1">+{{ record.workers|length|add:"-3" }}</span>
{% endif %}
</div>
{% endif %}
</td>
{% if is_admin_user %}
<td>
<span class="fw-bold font-monospace text-dark">R {{ log.display_amount|floatformat:2 }}</span>
<span class="fw-bold font-monospace {% if record.amount < 0 %}text-danger{% else %}text-dark{% endif %}">
R {{ record.amount|floatformat:2 }}
</span>
</td>
<td>
{% with payslip=log.paid_in.first %}
{% if payslip %}
<a href="{% url 'payslip_detail' payslip.id %}" class="text-decoration-none">
<span class="badge bg-success bg-opacity-10 text-success border border-success border-opacity-25">
<i class="bi bi-check-circle-fill"></i> Paid (Slip #{{ payslip.id }})
</span>
</a>
{% else %}
<span class="badge bg-warning bg-opacity-10 text-warning border border-warning border-opacity-25">
Pending
{% if record.paid_record %}
<a href="{% url 'payslip_detail' record.paid_record.id %}" class="text-decoration-none">
<span class="badge bg-success bg-opacity-10 text-success border border-success border-opacity-25">
<i class="bi bi-check-circle-fill"></i> Paid (Slip #{{ record.paid_record.id }})
</span>
{% endif %}
{% endwith %}
</a>
{% else %}
<span class="badge bg-warning bg-opacity-10 text-warning border border-warning border-opacity-25">
Pending
</span>
{% endif %}
</td>
{% endif %}
<td>
<small class="text-muted">{{ log.supervisor.username|default:"System" }}</small>
<small class="text-muted">{{ record.supervisor }}</small>
</td>
<td class="pe-4 text-end">
<div class="dropdown">
@ -273,10 +282,15 @@
<i class="bi bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-content dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="/admin/core/worklog/{{ log.id }}/change/">Edit Entry</a></li>
{% if log.notes %}
{% if record.type == 'WORK' %}
<li><a class="dropdown-item" href="/admin/core/worklog/{{ record.obj.id }}/change/">Edit Entry</a></li>
{% else %}
<li><a class="dropdown-item" href="/admin/core/payrolladjustment/{{ record.obj.id }}/change/">Edit Adjustment</a></li>
{% endif %}
{% if record.notes %}
<li><hr class="dropdown-divider"></li>
<li class="px-3 py-1"><small class="text-muted">Note: {{ log.notes }}</small></li>
<li class="px-3 py-1"><small class="text-muted">Note: {{ record.notes }}</small></li>
{% endif %}
</ul>
</div>
@ -298,7 +312,7 @@
{% else %}
<div class="text-center py-5">
<i class="bi bi-search display-1 text-muted opacity-25 mb-3 d-block"></i>
<h4 class="text-muted">No logs found matching filters.</h4>
<h4 class="text-muted">No records found matching filters.</h4>
<p class="text-muted mb-4">Try adjusting your filters or record a new entry.</p>
<a href="{% url 'log_attendance' %}" class="btn btn-primary">Log Attendance</a>
</div>
@ -362,30 +376,41 @@ document.addEventListener('DOMContentLoaded', function() {
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>';
html += '<th class="' + (sorted.length === 1 ? 'ps-4' : '') + '">Description / Project</th><th>Workers</th><th>Amount</th><th>Supervisor</th><th>Notes</th></tr></thead><tbody>';
sorted.forEach(function(dateStr) {
const logs = detailData[dateStr] || [];
const records = 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) {
records.forEach(function(rec, idx) {
const teams = rec.teams.length ? rec.teams.join(', ') : '';
let desc = rec.project;
if(rec.type === 'ADJ') {
desc = '<span class="badge bg-danger bg-opacity-10 text-danger border border-danger border-opacity-25">' + rec.project + '</span>';
} else {
desc = '<span class="badge bg-secondary bg-opacity-10 text-secondary border border-secondary border-opacity-25">' + rec.project + '</span>';
if (teams) desc += ' <small class="text-muted ms-1">' + teams + '</small>';
}
const workers = rec.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>';
const notes = rec.notes ? '<small class="text-muted">' + rec.notes + '</small>' : '<span class="text-muted">-</span>';
const amount = rec.amount ? 'R ' + rec.amount.toFixed(2) : '-';
html += '<tr>';
if (sorted.length > 1) {
if (idx === 0) {
html += '<td class="ps-4 fw-bold" rowspan="' + logs.length + '">' + dateLabel + '</td>';
html += '<td class="ps-4 fw-bold" rowspan="' + records.length + '">' + dateLabel + '</td>';
}
}
html += '<td class="' + (sorted.length === 1 ? 'ps-4 ' : '') + 'fw-bold">' + log.project + '</td>';
html += '<td>' + teams + '</td>';
html += '<td class="' + (sorted.length === 1 ? 'ps-4 ' : '') + '">' + desc + '</td>';
html += '<td>' + workers + '</td>';
html += '<td><small class="text-muted">' + log.supervisor + '</small></td>';
html += '<td class="font-monospace fw-bold">' + amount + '</td>';
html += '<td><small class="text-muted">' + rec.supervisor + '</small></td>';
html += '<td>' + notes + '</td>';
html += '</tr>';
});
@ -400,8 +425,8 @@ document.addEventListener('DOMContentLoaded', function() {
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;
const records = detailData[dateStr];
if (!records || !records.length) return;
if (e.shiftKey) {
// Shift+click: toggle this date in multi-selection

View File

@ -262,7 +262,7 @@ def log_attendance(request):
@login_required
def work_log_list(request):
"""View work log history with advanced filtering."""
"""View work log history and payroll adjustments with advanced filtering."""
if not is_staff_or_supervisor(request.user):
return redirect('log_attendance')
@ -272,12 +272,12 @@ def work_log_list(request):
payment_status = request.GET.get('payment_status') # 'paid', 'unpaid', 'all'
view_mode = request.GET.get('view', 'list')
# --- 1. Fetch WorkLogs ---
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:
@ -287,24 +287,38 @@ def work_log_list(request):
logs = logs.filter(project_id=project_id)
if payment_status == 'paid':
# Logs that are linked to at least one PayrollRecord
logs = logs.filter(paid_in__isnull=False).distinct()
elif payment_status == 'unpaid':
# This is tricky because a log can have multiple workers, some paid some not.
# But usually a WorkLog is marked paid when its workers are paid.
# If we filtered by worker, we can check if THAT worker is paid in that log.
if worker_id:
worker = get_object_or_404(Worker, pk=worker_id)
logs = logs.exclude(paid_in__worker=worker)
else:
logs = logs.filter(paid_in__isnull=True)
# Calculate amounts for display
# Convert to list to attach attributes
final_logs = []
total_amount = 0
# --- 2. Fetch Adjustments ---
# Adjustments are shown unless a Project/Team filter is active (as they don't belong to projects/teams),
# OR if a specific worker is selected (then we always show their adjustments).
show_adjustments = True
if (project_id or team_id) and not worker_id:
show_adjustments = False
adjustments = PayrollAdjustment.objects.none()
if show_adjustments:
adjustments = PayrollAdjustment.objects.all().select_related('worker', 'payroll_record')
if worker_id:
adjustments = adjustments.filter(worker_id=worker_id)
if payment_status == 'paid':
adjustments = adjustments.filter(payroll_record__isnull=False)
elif payment_status == 'unpaid':
adjustments = adjustments.filter(payroll_record__isnull=True)
# --- 3. Date Filtering for Calendar View (Applied to both) ---
start_date = None
end_date = None
curr_year = timezone.now().year
curr_month = timezone.now().month
# If Calendar View: Filter logs by Month BEFORE iterating to prevent fetching ALL history
if view_mode == 'calendar':
today = timezone.now().date()
try:
@ -314,29 +328,81 @@ def work_log_list(request):
curr_year = today.year
curr_month = today.month
# Bounds safety
if curr_month < 1: curr_month = 1;
if curr_month > 12: curr_month = 12;
# Get range
_, num_days = calendar.monthrange(curr_year, curr_month)
start_date = datetime.date(curr_year, curr_month, 1)
end_date = datetime.date(curr_year, curr_month, num_days)
logs = logs.filter(date__range=(start_date, end_date))
if show_adjustments:
adjustments = adjustments.filter(date__range=(start_date, end_date))
# --- 4. Combine and Sort ---
user_is_admin = is_admin(request.user)
total_amount = 0
combined_records = []
# Process Logs
for log in logs:
record = {
'type': 'WORK',
'date': log.date,
'obj': log,
'project_name': log.project.name,
'team_name': log.team.name if log.team else None,
'workers': list(log.workers.all()),
'supervisor': log.supervisor.username if log.supervisor else "System",
'is_paid': log.paid_in.exists() if not worker_id else log.paid_in.filter(worker_id=worker_id).exists(),
'paid_record': log.paid_in.first() if not worker_id else log.paid_in.filter(worker_id=worker_id).first(),
'notes': log.notes,
'sort_id': log.id
}
# Calculate amount
if user_is_admin:
if target_worker:
log.display_amount = target_worker.day_rate
amt = target_worker.day_rate
else:
log.display_amount = sum(w.day_rate for w in log.workers.all())
total_amount += log.display_amount
amt = sum(w.day_rate for w in log.workers.all())
record['amount'] = amt
total_amount += amt
else:
log.display_amount = None
final_logs.append(log)
record['amount'] = None
combined_records.append(record)
# Process Adjustments
if show_adjustments:
for adj in adjustments:
# Determine signed amount for display/total
amt = adj.amount
if adj.type in ['DEDUCTION', 'LOAN_REPAYMENT']:
amt = -amt
record = {
'type': 'ADJ',
'date': adj.date,
'obj': adj,
'project_name': f"{adj.get_type_display()}", # Use project column for Type
'team_name': None,
'workers': [adj.worker],
'supervisor': "System",
'is_paid': adj.payroll_record is not None,
'paid_record': adj.payroll_record,
'notes': adj.description,
'amount': amt if user_is_admin else None,
'sort_id': adj.id
}
if user_is_admin:
total_amount += amt
combined_records.append(record)
# Sort combined list by Date Descending, then ID Descending
combined_records.sort(key=lambda x: (x['date'], x['sort_id']), reverse=True)
# Context for filters
context = {
@ -355,11 +421,12 @@ def work_log_list(request):
if view_mode == 'calendar':
# Group by date for easy lookup in template
logs_map = {}
for log in final_logs:
if log.date not in logs_map:
logs_map[log.date] = []
logs_map[log.date].append(log)
records_map = {}
for rec in combined_records:
d = rec['date']
if d not in records_map:
records_map[d] = []
records_map[d].append(rec)
cal = calendar.Calendar(firstweekday=0) # Monday is 0
month_dates = cal.monthdatescalendar(curr_year, curr_month)
@ -373,24 +440,28 @@ def work_log_list(request):
'date': d,
'day': d.day,
'is_current_month': d.month == curr_month,
'logs': logs_map.get(d, [])
'records': records_map.get(d, [])
})
calendar_weeks.append(week_data)
# Build JSON lookup for day detail panel
calendar_detail_data = {}
for date_key, day_logs in logs_map.items():
for date_key, day_records in records_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'
for rec in day_records:
workers_list = [w.name for w in rec['workers']]
team_name = rec['team_name'] if rec['team_name'] else ''
# Format for JS
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 '',
'project': rec['project_name'], # This holds Type for ADJ
'teams': [team_name] if team_name else [],
'workers': workers_list,
'supervisor': rec['supervisor'],
'notes': rec['notes'] or '',
'type': rec['type'],
'amount': float(rec['amount']) if rec['amount'] is not None else 0
})
# Nav Links
@ -409,13 +480,13 @@ def work_log_list(request):
'next_year': next_month_date.year,
})
else:
context['logs'] = final_logs
context['records'] = combined_records
return render(request, 'core/work_log_list.html', context)
@login_required
def export_work_log_csv(request):
"""Export filtered work logs to CSV."""
"""Export filtered work logs and adjustments to CSV."""
if not is_staff_or_supervisor(request.user):
return HttpResponse("Unauthorized", status=401)
@ -424,6 +495,7 @@ def export_work_log_csv(request):
project_id = request.GET.get('project')
payment_status = request.GET.get('payment_status')
# --- 1. Fetch WorkLogs ---
logs = WorkLog.objects.all().prefetch_related('workers', 'workers__teams', 'project', 'supervisor', 'paid_in').order_by('-date', '-id')
target_worker = None
@ -447,16 +519,35 @@ def export_work_log_csv(request):
else:
logs = logs.filter(paid_in__isnull=True)
# --- 2. Fetch Adjustments ---
show_adjustments = True
if (project_id or team_id) and not worker_id:
show_adjustments = False
adjustments = []
if show_adjustments:
qs = PayrollAdjustment.objects.all().select_related('worker', 'payroll_record')
if worker_id:
qs = qs.filter(worker_id=worker_id)
if payment_status == 'paid':
qs = qs.filter(payroll_record__isnull=False)
elif payment_status == 'unpaid':
qs = qs.filter(payroll_record__isnull=True)
adjustments = list(qs)
user_is_admin = is_admin(request.user)
response = HttpResponse(content_type='text/csv')
response['Content-Disposition'] = 'attachment; filename="work_logs.csv"'
response['Content-Disposition'] = 'attachment; filename="work_logs_and_adjustments.csv"'
writer = csv.writer(response)
if user_is_admin:
writer.writerow(['Date', 'Project', 'Workers', 'Amount', 'Payment Status', 'Supervisor'])
writer.writerow(['Date', 'Description', 'Workers', 'Amount', 'Payment Status', 'Supervisor'])
else:
writer.writerow(['Date', 'Project', 'Workers', 'Supervisor'])
writer.writerow(['Date', 'Description', 'Workers', 'Supervisor'])
# Combine and Sort
combined = []
for log in logs:
if target_worker:
@ -464,22 +555,53 @@ def export_work_log_csv(request):
else:
workers_str = ", ".join([w.name for w in log.workers.all()])
amt = 0
is_paid = False
if user_is_admin:
if target_worker:
display_amount = target_worker.day_rate
amt = target_worker.day_rate
is_paid = log.paid_in.filter(worker=target_worker).exists()
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"
amt = sum(w.day_rate for w in log.workers.all())
is_paid = log.paid_in.exists()
combined.append({
'date': log.date,
'desc': log.project.name,
'workers': workers_str,
'amount': amt,
'status': "Paid" if is_paid else "Pending",
'supervisor': log.supervisor.username if log.supervisor else "System"
})
for adj in adjustments:
amt = adj.amount
if adj.type in ['DEDUCTION', 'LOAN_REPAYMENT']:
amt = -amt
is_paid = adj.payroll_record is not None
combined.append({
'date': adj.date,
'desc': f"{adj.get_type_display()} - {adj.description}",
'workers': adj.worker.name,
'amount': amt,
'status': "Paid" if is_paid else "Pending",
'supervisor': "System"
})
# Sort
combined.sort(key=lambda x: x['date'], reverse=True)
for row in combined:
if user_is_admin:
writer.writerow([
log.date, log.project.name, workers_str,
f"{display_amount:.2f}", status_str,
log.supervisor.username if log.supervisor else "System"
row['date'], row['desc'], row['workers'],
f"{row['amount']:.2f}", row['status'], row['supervisor']
])
else:
writer.writerow([
log.date, log.project.name, workers_str,
log.supervisor.username if log.supervisor else "System"
row['date'], row['desc'], row['workers'], row['supervisor']
])
return response