Fix payroll dashboard JS crash + add calendar view to work history
1. Fix json_script double-encoding bug: payroll_dashboard view was passing json.dumps() strings to template context, then json_script filter serialized them AGAIN. JavaScript received strings instead of arrays, crashing the entire DOMContentLoaded handler and preventing preview, edit/delete, and other features from working. Fix: pass raw Python objects, let json_script handle serialization. 2. Add defense-in-depth: wrap Chart.js initialization in try-catch blocks and use Bootstrap getOrCreateInstance() for modals. 3. Add calendar view to work history: monthly grid with day cells showing work log indicators, click-to-see-details panel, month navigation, and responsive mobile layout. Ported from V2. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2863f21844
commit
19e565a088
@ -659,94 +659,104 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
// CHART.JS — Monthly Totals (Line Chart)
|
// CHART.JS — Monthly Totals (Line Chart)
|
||||||
|
// Wrapped in try-catch so a Chart.js failure doesn't prevent
|
||||||
|
// the rest of the page's JavaScript from running.
|
||||||
// =================================================================
|
// =================================================================
|
||||||
const monthlyCtx = document.getElementById('monthlyChart');
|
try {
|
||||||
if (monthlyCtx) {
|
const monthlyCtx = document.getElementById('monthlyChart');
|
||||||
new Chart(monthlyCtx, {
|
if (monthlyCtx) {
|
||||||
type: 'line',
|
new Chart(monthlyCtx, {
|
||||||
data: {
|
type: 'line',
|
||||||
labels: chartLabels,
|
data: {
|
||||||
datasets: [{
|
labels: chartLabels,
|
||||||
label: 'Total Paid',
|
datasets: [{
|
||||||
data: chartTotals,
|
label: 'Total Paid',
|
||||||
borderColor: '#10b981',
|
data: chartTotals,
|
||||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
borderColor: '#10b981',
|
||||||
fill: true,
|
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||||
tension: 0.3,
|
fill: true,
|
||||||
pointRadius: 4,
|
tension: 0.3,
|
||||||
pointHoverRadius: 6,
|
pointRadius: 4,
|
||||||
}]
|
pointHoverRadius: 6,
|
||||||
},
|
}]
|
||||||
options: {
|
},
|
||||||
responsive: true,
|
options: {
|
||||||
maintainAspectRatio: false,
|
responsive: true,
|
||||||
plugins: {
|
maintainAspectRatio: false,
|
||||||
legend: { display: false },
|
plugins: {
|
||||||
tooltip: {
|
legend: { display: false },
|
||||||
callbacks: {
|
tooltip: {
|
||||||
label: function(context) {
|
callbacks: {
|
||||||
return fmt(context.parsed.y);
|
label: function(context) {
|
||||||
|
return fmt(context.parsed.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: {
|
||||||
|
callback: function(val) { return 'R ' + val.toLocaleString(); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
y: {
|
|
||||||
beginAtZero: true,
|
|
||||||
ticks: {
|
|
||||||
callback: function(val) { return 'R ' + val.toLocaleString(); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Monthly chart failed to render:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
// CHART.JS — Per-Project Costs (Stacked Bar Chart)
|
// CHART.JS — Per-Project Costs (Stacked Bar Chart)
|
||||||
// =================================================================
|
// =================================================================
|
||||||
const projectCtx = document.getElementById('projectChart');
|
try {
|
||||||
if (projectCtx) {
|
const projectCtx = document.getElementById('projectChart');
|
||||||
// Color palette for projects
|
if (projectCtx) {
|
||||||
const colors = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16'];
|
// Color palette for projects
|
||||||
const datasets = projectChartData.map(function(proj, i) {
|
const colors = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16'];
|
||||||
return {
|
const datasets = projectChartData.map(function(proj, i) {
|
||||||
label: proj.name,
|
return {
|
||||||
data: proj.data,
|
label: proj.name,
|
||||||
backgroundColor: colors[i % colors.length],
|
data: proj.data,
|
||||||
};
|
backgroundColor: colors[i % colors.length],
|
||||||
});
|
};
|
||||||
|
});
|
||||||
|
|
||||||
new Chart(projectCtx, {
|
new Chart(projectCtx, {
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: {
|
data: {
|
||||||
labels: chartLabels,
|
labels: chartLabels,
|
||||||
datasets: datasets,
|
datasets: datasets,
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
plugins: {
|
plugins: {
|
||||||
tooltip: {
|
tooltip: {
|
||||||
callbacks: {
|
callbacks: {
|
||||||
label: function(context) {
|
label: function(context) {
|
||||||
return context.dataset.label + ': ' + fmt(context.parsed.y);
|
return context.dataset.label + ': ' + fmt(context.parsed.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { stacked: true },
|
||||||
|
y: {
|
||||||
|
stacked: true,
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: {
|
||||||
|
callback: function(val) { return 'R ' + val.toLocaleString(); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
x: { stacked: true },
|
|
||||||
y: {
|
|
||||||
stacked: true,
|
|
||||||
beginAtZero: true,
|
|
||||||
ticks: {
|
|
||||||
callback: function(val) { return 'R ' + val.toLocaleString(); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Project chart failed to render:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
@ -971,12 +981,17 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
document.getElementById('deleteAdjType').textContent = adjType;
|
document.getElementById('deleteAdjType').textContent = adjType;
|
||||||
document.getElementById('deleteAdjWorker').textContent = adjWorker;
|
document.getElementById('deleteAdjWorker').textContent = adjWorker;
|
||||||
// Close edit modal, open delete modal
|
// Close edit modal, open delete modal
|
||||||
bootstrap.Modal.getInstance(document.getElementById('editAdjustmentModal')).hide();
|
var editModal = bootstrap.Modal.getOrCreateInstance(document.getElementById('editAdjustmentModal'));
|
||||||
new bootstrap.Modal(document.getElementById('deleteConfirmModal')).show();
|
editModal.hide();
|
||||||
|
// Wait for edit modal to finish hiding before showing delete modal
|
||||||
|
document.getElementById('editAdjustmentModal').addEventListener('hidden.bs.modal', function handler() {
|
||||||
|
document.getElementById('editAdjustmentModal').removeEventListener('hidden.bs.modal', handler);
|
||||||
|
bootstrap.Modal.getOrCreateInstance(document.getElementById('deleteConfirmModal')).show();
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Show the modal
|
// Show the modal
|
||||||
new bootstrap.Modal(document.getElementById('editAdjustmentModal')).show();
|
bootstrap.Modal.getOrCreateInstance(document.getElementById('editAdjustmentModal')).show();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1004,7 +1019,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
modalBody.appendChild(loadingDiv);
|
modalBody.appendChild(loadingDiv);
|
||||||
|
|
||||||
// Show modal
|
// Show modal
|
||||||
new bootstrap.Modal(document.getElementById('previewPayslipModal')).show();
|
bootstrap.Modal.getOrCreateInstance(document.getElementById('previewPayslipModal')).show();
|
||||||
|
|
||||||
// Fetch preview data
|
// Fetch preview data
|
||||||
fetch('/payroll/preview/' + workerId + '/')
|
fetch('/payroll/preview/' + workerId + '/')
|
||||||
|
|||||||
@ -4,10 +4,30 @@
|
|||||||
{% block title %}Work History | Fox Fitt{% endblock %}
|
{% block title %}Work History | Fox Fitt{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<!-- === WORK HISTORY PAGE ===
|
||||||
|
Two view modes: List (table) and Calendar (monthly grid).
|
||||||
|
Filters apply to both modes.
|
||||||
|
Calendar mode shows a month grid where each day cell lists the work logs.
|
||||||
|
Click a day cell to see full details in a panel below the calendar. -->
|
||||||
|
|
||||||
<div class="container py-4">
|
<div class="container py-4">
|
||||||
|
|
||||||
|
{# === PAGE HEADER with view toggle and export === #}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h1 class="h3 mb-0" style="font-family: 'Poppins', sans-serif;">Work History</h1>
|
<h1 class="h3 mb-0" style="font-family: 'Poppins', sans-serif;">Work History</h1>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
|
{# View toggle — List vs Calendar #}
|
||||||
|
<div class="btn-group" role="group" aria-label="View mode">
|
||||||
|
<a href="?view=list{{ filter_params }}"
|
||||||
|
class="btn btn-sm {% if view_mode == 'list' %}btn-accent{% else %}btn-outline-secondary{% endif %}">
|
||||||
|
<i class="fas fa-list me-1"></i> List
|
||||||
|
</a>
|
||||||
|
<a href="?view=calendar{{ filter_params }}"
|
||||||
|
class="btn btn-sm {% if view_mode == 'calendar' %}btn-accent{% else %}btn-outline-secondary{% endif %}">
|
||||||
|
<i class="fas fa-calendar-alt me-1"></i> Calendar
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
{# CSV Export button — keeps the current filters in the export URL #}
|
{# CSV Export button — keeps the current filters in the export URL #}
|
||||||
<a href="{% url 'export_work_log_csv' %}?worker={{ selected_worker }}&project={{ selected_project }}&status={{ selected_status }}"
|
<a href="{% url 'export_work_log_csv' %}?worker={{ selected_worker }}&project={{ selected_project }}&status={{ selected_status }}"
|
||||||
class="btn btn-outline-success btn-sm shadow-sm">
|
class="btn btn-outline-success btn-sm shadow-sm">
|
||||||
@ -19,10 +39,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# --- Filter Bar --- #}
|
{# === FILTER BAR === #}
|
||||||
<div class="card shadow-sm border-0 mb-4">
|
<div class="card shadow-sm border-0 mb-4">
|
||||||
<div class="card-body py-3">
|
<div class="card-body py-3">
|
||||||
<form method="GET" class="row g-2 align-items-end">
|
<form method="GET" class="row g-2 align-items-end">
|
||||||
|
{# Preserve current view mode when filtering #}
|
||||||
|
<input type="hidden" name="view" value="{{ view_mode }}">
|
||||||
|
{% if view_mode == 'calendar' %}
|
||||||
|
{# Preserve current calendar month when filtering #}
|
||||||
|
<input type="hidden" name="year" value="{{ curr_year }}">
|
||||||
|
<input type="hidden" name="month" value="{{ curr_month }}">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{# Filter by Worker #}
|
{# Filter by Worker #}
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<label class="form-label small text-muted mb-1">Worker</label>
|
<label class="form-label small text-muted mb-1">Worker</label>
|
||||||
@ -64,7 +92,7 @@
|
|||||||
<button type="submit" class="btn btn-sm btn-accent">
|
<button type="submit" class="btn btn-sm btn-accent">
|
||||||
<i class="fas fa-filter me-1"></i> Filter
|
<i class="fas fa-filter me-1"></i> Filter
|
||||||
</button>
|
</button>
|
||||||
<a href="{% url 'work_history' %}" class="btn btn-sm btn-outline-secondary">
|
<a href="{% url 'work_history' %}?view={{ view_mode }}" class="btn btn-sm btn-outline-secondary">
|
||||||
<i class="fas fa-times me-1"></i> Clear
|
<i class="fas fa-times me-1"></i> Clear
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@ -72,7 +100,306 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# --- Work Log Table --- #}
|
|
||||||
|
{% if view_mode == 'calendar' %}
|
||||||
|
{# =============================================================== #}
|
||||||
|
{# === CALENDAR VIEW === #}
|
||||||
|
{# =============================================================== #}
|
||||||
|
|
||||||
|
{# Month navigation header #}
|
||||||
|
<div class="card shadow-sm border-0 mb-3">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<a href="?view=calendar&year={{ prev_year }}&month={{ prev_month }}{{ filter_params }}"
|
||||||
|
class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="fas fa-chevron-left"></i>
|
||||||
|
</a>
|
||||||
|
<h5 class="mb-0 fw-bold" style="font-family: 'Poppins', sans-serif;">
|
||||||
|
{{ month_name }}
|
||||||
|
</h5>
|
||||||
|
<a href="?view=calendar&year={{ next_year }}&month={{ next_month }}{{ filter_params }}"
|
||||||
|
class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="fas fa-chevron-right"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Calendar grid #}
|
||||||
|
<div class="card shadow-sm border-0 mb-3">
|
||||||
|
<div class="card-body p-0 p-md-3">
|
||||||
|
{# Day-of-week header row #}
|
||||||
|
<div class="row g-0 d-none d-md-flex text-center fw-bold text-secondary border-bottom pb-2 mb-2" style="font-size: 0.85rem;">
|
||||||
|
<div class="col">Mon</div>
|
||||||
|
<div class="col">Tue</div>
|
||||||
|
<div class="col">Wed</div>
|
||||||
|
<div class="col">Thu</div>
|
||||||
|
<div class="col">Fri</div>
|
||||||
|
<div class="col">Sat</div>
|
||||||
|
<div class="col">Sun</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Calendar weeks — each row is 7 day cells #}
|
||||||
|
{% for week in calendar_weeks %}
|
||||||
|
<div class="row g-0 g-md-1 mb-0 mb-md-1">
|
||||||
|
{% for day in week %}
|
||||||
|
<div class="col cal-day {% if not day.is_current_month %}cal-day--other{% endif %}{% if day.is_today %} cal-day--today{% endif %}{% if day.count > 0 %} cal-day--has-logs{% endif %}"
|
||||||
|
{% if day.count > 0 %}data-date="{{ day.date|date:'Y-m-d' }}"{% endif %}>
|
||||||
|
{# Day number + badge count #}
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<span class="cal-day__number {% if day.is_today %}fw-bold{% endif %}">{{ day.day }}</span>
|
||||||
|
{% if day.count > 0 %}
|
||||||
|
<span class="badge bg-primary rounded-pill" style="font-size: 0.65rem;">{{ day.count }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{# Mini log indicators (show first 3 entries) #}
|
||||||
|
{% for log in day.records|slice:":3" %}
|
||||||
|
<div class="cal-entry text-truncate" title="{{ log.project.name }}">
|
||||||
|
<small>
|
||||||
|
{% if log.payroll_records.exists %}
|
||||||
|
<i class="fas fa-check-circle text-success" style="font-size: 0.55rem;"></i>
|
||||||
|
{% else %}
|
||||||
|
<i class="fas fa-clock text-warning" style="font-size: 0.55rem;"></i>
|
||||||
|
{% endif %}
|
||||||
|
{{ log.project.name }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{# "and X more" indicator #}
|
||||||
|
{% if day.count > 3 %}
|
||||||
|
<div class="cal-entry">
|
||||||
|
<small class="text-muted">+{{ day.count|add:"-3" }} more</small>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# === Day Detail Panel === #}
|
||||||
|
{# Hidden by default. When you click a day cell with logs, this panel
|
||||||
|
appears showing full details for all entries on that day. #}
|
||||||
|
<div class="card shadow-sm border-0 d-none" id="dayDetailPanel">
|
||||||
|
<div class="card-header py-2 bg-white d-flex justify-content-between align-items-center">
|
||||||
|
<h6 class="mb-0 fw-bold" id="dayDetailTitle">
|
||||||
|
<i class="fas fa-calendar-day me-2"></i>Details
|
||||||
|
</h6>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" id="closeDayDetail">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0" id="dayDetailBody">
|
||||||
|
{# Content built by JavaScript #}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Pass calendar detail data to JavaScript safely using json_script #}
|
||||||
|
{{ calendar_detail|json_script:"calDetailJson" }}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Parse calendar detail data (keyed by date string)
|
||||||
|
var calDetail = JSON.parse(document.getElementById('calDetailJson').textContent);
|
||||||
|
var detailPanel = document.getElementById('dayDetailPanel');
|
||||||
|
var detailTitle = document.getElementById('dayDetailTitle');
|
||||||
|
var detailBody = document.getElementById('dayDetailBody');
|
||||||
|
var closeBtn = document.getElementById('closeDayDetail');
|
||||||
|
var isAdmin = {{ is_admin|yesno:"true,false" }};
|
||||||
|
|
||||||
|
// === Click handler for day cells with logs ===
|
||||||
|
document.querySelectorAll('.cal-day--has-logs').forEach(function(cell) {
|
||||||
|
cell.addEventListener('click', function() {
|
||||||
|
var dateStr = this.dataset.date;
|
||||||
|
var entries = calDetail[dateStr] || [];
|
||||||
|
if (entries.length === 0) return;
|
||||||
|
|
||||||
|
// Remove "selected" class from all cells, add to clicked one
|
||||||
|
document.querySelectorAll('.cal-day--selected').forEach(function(c) {
|
||||||
|
c.classList.remove('cal-day--selected');
|
||||||
|
});
|
||||||
|
this.classList.add('cal-day--selected');
|
||||||
|
|
||||||
|
// Format date for display (e.g. "22 Feb 2026")
|
||||||
|
var parts = dateStr.split('-');
|
||||||
|
var dateObj = new Date(parts[0], parts[1] - 1, parts[2]);
|
||||||
|
var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||||
|
var displayDate = dateObj.getDate() + ' ' + months[dateObj.getMonth()] + ' ' + dateObj.getFullYear();
|
||||||
|
|
||||||
|
// Update panel title
|
||||||
|
detailTitle.textContent = '';
|
||||||
|
var icon = document.createElement('i');
|
||||||
|
icon.className = 'fas fa-calendar-day me-2';
|
||||||
|
detailTitle.appendChild(icon);
|
||||||
|
detailTitle.appendChild(document.createTextNode(displayDate + ' — ' + entries.length + ' log(s)'));
|
||||||
|
|
||||||
|
// Clear previous content
|
||||||
|
while (detailBody.firstChild) detailBody.removeChild(detailBody.firstChild);
|
||||||
|
|
||||||
|
// Build detail table
|
||||||
|
var table = document.createElement('table');
|
||||||
|
table.className = 'table table-sm table-hover mb-0';
|
||||||
|
|
||||||
|
var thead = document.createElement('thead');
|
||||||
|
thead.className = 'table-light';
|
||||||
|
var headRow = document.createElement('tr');
|
||||||
|
var headers = ['Project', 'Workers', 'Supervisor', 'OT', 'Status'];
|
||||||
|
if (isAdmin) headers.push('Amount');
|
||||||
|
headers.forEach(function(h) {
|
||||||
|
var th = document.createElement('th');
|
||||||
|
th.className = h === 'Project' ? 'ps-3' : '';
|
||||||
|
th.textContent = h;
|
||||||
|
headRow.appendChild(th);
|
||||||
|
});
|
||||||
|
thead.appendChild(headRow);
|
||||||
|
table.appendChild(thead);
|
||||||
|
|
||||||
|
var tbody = document.createElement('tbody');
|
||||||
|
entries.forEach(function(entry) {
|
||||||
|
var tr = document.createElement('tr');
|
||||||
|
|
||||||
|
// Project
|
||||||
|
var tdProj = document.createElement('td');
|
||||||
|
tdProj.className = 'ps-3';
|
||||||
|
var strong = document.createElement('strong');
|
||||||
|
strong.textContent = entry.project;
|
||||||
|
tdProj.appendChild(strong);
|
||||||
|
tr.appendChild(tdProj);
|
||||||
|
|
||||||
|
// Workers
|
||||||
|
var tdWork = document.createElement('td');
|
||||||
|
tdWork.textContent = entry.workers.join(', ');
|
||||||
|
tr.appendChild(tdWork);
|
||||||
|
|
||||||
|
// Supervisor
|
||||||
|
var tdSup = document.createElement('td');
|
||||||
|
tdSup.textContent = entry.supervisor;
|
||||||
|
tr.appendChild(tdSup);
|
||||||
|
|
||||||
|
// Overtime
|
||||||
|
var tdOt = document.createElement('td');
|
||||||
|
if (entry.overtime) {
|
||||||
|
var otBadge = document.createElement('span');
|
||||||
|
otBadge.className = 'badge bg-warning text-dark';
|
||||||
|
otBadge.textContent = entry.overtime;
|
||||||
|
tdOt.appendChild(otBadge);
|
||||||
|
} else {
|
||||||
|
tdOt.textContent = '-';
|
||||||
|
tdOt.className = 'text-muted';
|
||||||
|
}
|
||||||
|
tr.appendChild(tdOt);
|
||||||
|
|
||||||
|
// Status
|
||||||
|
var tdStatus = document.createElement('td');
|
||||||
|
var statusBadge = document.createElement('span');
|
||||||
|
if (entry.is_paid) {
|
||||||
|
statusBadge.className = 'badge bg-success';
|
||||||
|
statusBadge.textContent = 'Paid';
|
||||||
|
} else {
|
||||||
|
statusBadge.className = 'badge bg-danger bg-opacity-75';
|
||||||
|
statusBadge.textContent = 'Unpaid';
|
||||||
|
}
|
||||||
|
tdStatus.appendChild(statusBadge);
|
||||||
|
tr.appendChild(tdStatus);
|
||||||
|
|
||||||
|
// Amount (admin only)
|
||||||
|
if (isAdmin) {
|
||||||
|
var tdAmt = document.createElement('td');
|
||||||
|
tdAmt.textContent = entry.amount !== undefined ? 'R ' + entry.amount.toFixed(2) : '-';
|
||||||
|
tr.appendChild(tdAmt);
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
table.appendChild(tbody);
|
||||||
|
detailBody.appendChild(table);
|
||||||
|
|
||||||
|
// Show the panel and scroll to it
|
||||||
|
detailPanel.classList.remove('d-none');
|
||||||
|
detailPanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close detail panel
|
||||||
|
closeBtn.addEventListener('click', function() {
|
||||||
|
detailPanel.classList.add('d-none');
|
||||||
|
document.querySelectorAll('.cal-day--selected').forEach(function(c) {
|
||||||
|
c.classList.remove('cal-day--selected');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{# Calendar-specific CSS #}
|
||||||
|
<style>
|
||||||
|
/* === CALENDAR GRID STYLES === */
|
||||||
|
.cal-day {
|
||||||
|
min-height: 90px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
transition: background-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
.cal-day__number {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-main, #334155);
|
||||||
|
}
|
||||||
|
/* Days from previous/next month — faded */
|
||||||
|
.cal-day--other {
|
||||||
|
background-color: #f8fafc;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
/* Today's date — accent border */
|
||||||
|
.cal-day--today {
|
||||||
|
border-color: var(--accent-color, #10b981);
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
.cal-day--today .cal-day__number {
|
||||||
|
color: var(--accent-color, #10b981);
|
||||||
|
}
|
||||||
|
/* Days with logs — clickable */
|
||||||
|
.cal-day--has-logs {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.cal-day--has-logs:hover {
|
||||||
|
background-color: #f0fdfa;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
/* Selected day */
|
||||||
|
.cal-day--selected {
|
||||||
|
background-color: #ecfdf5 !important;
|
||||||
|
border-color: var(--accent-color, #10b981) !important;
|
||||||
|
border-width: 2px;
|
||||||
|
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.15);
|
||||||
|
}
|
||||||
|
/* Mini log entry indicators */
|
||||||
|
.cal-entry {
|
||||||
|
line-height: 1.3;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
/* Mobile: compact cells */
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.cal-day {
|
||||||
|
min-height: 55px;
|
||||||
|
padding: 4px 5px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.cal-entry {
|
||||||
|
display: none; /* Hide text indicators on mobile, just show badges */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
{# =============================================================== #}
|
||||||
|
{# === LIST VIEW (TABLE) === #}
|
||||||
|
{# =============================================================== #}
|
||||||
<div class="card shadow-sm border-0">
|
<div class="card shadow-sm border-0">
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
@ -138,5 +465,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
140
core/views.py
140
core/views.py
@ -5,6 +5,7 @@
|
|||||||
import csv
|
import csv
|
||||||
import json
|
import json
|
||||||
import datetime
|
import datetime
|
||||||
|
import calendar as cal_module
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from django.shortcuts import render, redirect, get_object_or_404
|
from django.shortcuts import render, redirect, get_object_or_404
|
||||||
@ -357,8 +358,9 @@ def attendance_log(request):
|
|||||||
|
|
||||||
|
|
||||||
# === WORK LOG HISTORY ===
|
# === WORK LOG HISTORY ===
|
||||||
# Shows a table of all work logs with filters.
|
# Shows work logs in two modes: a table list or a monthly calendar grid.
|
||||||
# Supervisors only see their own projects. Admins see everything.
|
# Supervisors only see their own projects. Admins see everything.
|
||||||
|
# The calendar view groups logs by day and lets you click a day to see details.
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def work_history(request):
|
def work_history(request):
|
||||||
@ -410,6 +412,20 @@ def work_history(request):
|
|||||||
active=True, supervisors=user
|
active=True, supervisors=user
|
||||||
).order_by('name')
|
).order_by('name')
|
||||||
|
|
||||||
|
# --- View mode: list or calendar ---
|
||||||
|
view_mode = request.GET.get('view', 'list')
|
||||||
|
today = timezone.now().date()
|
||||||
|
|
||||||
|
# Build a query string that preserves all current filters
|
||||||
|
# (used by the List/Calendar toggle links to keep filters when switching)
|
||||||
|
filter_params = ''
|
||||||
|
if worker_filter:
|
||||||
|
filter_params += '&worker=' + worker_filter
|
||||||
|
if project_filter:
|
||||||
|
filter_params += '&project=' + project_filter
|
||||||
|
if status_filter:
|
||||||
|
filter_params += '&status=' + status_filter
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'logs': logs,
|
'logs': logs,
|
||||||
'filter_workers': filter_workers,
|
'filter_workers': filter_workers,
|
||||||
@ -418,7 +434,114 @@ def work_history(request):
|
|||||||
'selected_project': project_filter,
|
'selected_project': project_filter,
|
||||||
'selected_status': status_filter,
|
'selected_status': status_filter,
|
||||||
'is_admin': is_admin(user),
|
'is_admin': is_admin(user),
|
||||||
|
'view_mode': view_mode,
|
||||||
|
'filter_params': filter_params,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# === CALENDAR MODE ===
|
||||||
|
# Build a monthly grid of days, each containing the work logs for that day.
|
||||||
|
# Also build a JSON object keyed by date string for the JavaScript
|
||||||
|
# click-to-see-details panel.
|
||||||
|
if view_mode == 'calendar':
|
||||||
|
# Get target month from URL (default: current month)
|
||||||
|
try:
|
||||||
|
target_year = int(request.GET.get('year', today.year))
|
||||||
|
target_month = int(request.GET.get('month', today.month))
|
||||||
|
if not (1 <= target_month <= 12):
|
||||||
|
target_year, target_month = today.year, today.month
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
target_year, target_month = today.year, today.month
|
||||||
|
|
||||||
|
# Build the calendar grid using Python's calendar module.
|
||||||
|
# monthdatescalendar() returns a list of weeks, where each week is
|
||||||
|
# a list of 7 datetime.date objects (including overflow from prev/next month).
|
||||||
|
cal = cal_module.Calendar(firstweekday=0) # Week starts on Monday
|
||||||
|
month_dates = cal.monthdatescalendar(target_year, target_month)
|
||||||
|
|
||||||
|
# Get the full date range for the calendar grid (includes overflow days)
|
||||||
|
first_display_date = month_dates[0][0]
|
||||||
|
last_display_date = month_dates[-1][-1]
|
||||||
|
|
||||||
|
# Filter logs to only this date range (improves performance)
|
||||||
|
month_logs = logs.filter(date__range=[first_display_date, last_display_date])
|
||||||
|
|
||||||
|
# Group logs by date string for quick lookup
|
||||||
|
logs_by_date = {}
|
||||||
|
for log in month_logs:
|
||||||
|
date_key = log.date.isoformat()
|
||||||
|
if date_key not in logs_by_date:
|
||||||
|
logs_by_date[date_key] = []
|
||||||
|
logs_by_date[date_key].append(log)
|
||||||
|
|
||||||
|
# Build the calendar_weeks structure that the template iterates over.
|
||||||
|
# Each day cell has: date, day number, whether it's the current month,
|
||||||
|
# a list of log objects, and a count badge number.
|
||||||
|
calendar_weeks = []
|
||||||
|
for week in month_dates:
|
||||||
|
week_data = []
|
||||||
|
for day in week:
|
||||||
|
date_key = day.isoformat()
|
||||||
|
day_logs = logs_by_date.get(date_key, [])
|
||||||
|
week_data.append({
|
||||||
|
'date': day,
|
||||||
|
'day': day.day,
|
||||||
|
'is_current_month': day.month == target_month,
|
||||||
|
'is_today': day == today,
|
||||||
|
'records': day_logs,
|
||||||
|
'count': len(day_logs),
|
||||||
|
})
|
||||||
|
calendar_weeks.append(week_data)
|
||||||
|
|
||||||
|
# Build detail data for JavaScript — when you click a day cell,
|
||||||
|
# the JS reads this JSON to populate the detail panel below the calendar.
|
||||||
|
# NOTE: Pass raw Python dict, not json.dumps() — the template's
|
||||||
|
# |json_script filter handles serialization.
|
||||||
|
calendar_detail = {}
|
||||||
|
for date_key, day_logs in logs_by_date.items():
|
||||||
|
calendar_detail[date_key] = []
|
||||||
|
for log in day_logs:
|
||||||
|
entry = {
|
||||||
|
'project': log.project.name,
|
||||||
|
'workers': [w.name for w in log.workers.all()],
|
||||||
|
'supervisor': (
|
||||||
|
log.supervisor.get_full_name() or log.supervisor.username
|
||||||
|
) if log.supervisor else '-',
|
||||||
|
'notes': log.notes or '',
|
||||||
|
'is_paid': log.payroll_records.exists(),
|
||||||
|
'overtime': log.get_overtime_amount_display() if log.overtime_amount > 0 else '',
|
||||||
|
}
|
||||||
|
# Only show cost data to admins
|
||||||
|
if is_admin(user):
|
||||||
|
entry['amount'] = float(
|
||||||
|
sum(w.daily_rate for w in log.workers.all())
|
||||||
|
)
|
||||||
|
calendar_detail[date_key].append(entry)
|
||||||
|
|
||||||
|
# Calculate previous/next month for navigation arrows
|
||||||
|
if target_month == 1:
|
||||||
|
prev_year, prev_month = target_year - 1, 12
|
||||||
|
else:
|
||||||
|
prev_year, prev_month = target_year, target_month - 1
|
||||||
|
|
||||||
|
if target_month == 12:
|
||||||
|
next_year, next_month = target_year + 1, 1
|
||||||
|
else:
|
||||||
|
next_year, next_month = target_year, target_month + 1
|
||||||
|
|
||||||
|
month_name = datetime.date(target_year, target_month, 1).strftime('%B %Y')
|
||||||
|
|
||||||
|
context.update({
|
||||||
|
'calendar_weeks': calendar_weeks,
|
||||||
|
'calendar_detail': calendar_detail,
|
||||||
|
'curr_year': target_year,
|
||||||
|
'curr_month': target_month,
|
||||||
|
'month_name': month_name,
|
||||||
|
'prev_year': prev_year,
|
||||||
|
'prev_month': prev_month,
|
||||||
|
'next_year': next_year,
|
||||||
|
'next_month': next_month,
|
||||||
|
})
|
||||||
|
|
||||||
return render(request, 'core/work_history.html', context)
|
return render(request, 'core/work_history.html', context)
|
||||||
|
|
||||||
|
|
||||||
@ -722,6 +845,11 @@ def payroll_dashboard(request):
|
|||||||
team.workers.filter(active=True).values_list('id', flat=True)
|
team.workers.filter(active=True).values_list('id', flat=True)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# NOTE: Pass raw Python objects here, NOT json.dumps() strings.
|
||||||
|
# The template uses Django's |json_script filter which handles
|
||||||
|
# JSON serialization. If we pre-serialize with json.dumps(), the
|
||||||
|
# filter double-encodes the data and JavaScript receives strings
|
||||||
|
# instead of arrays/objects, which crashes the entire script.
|
||||||
context = {
|
context = {
|
||||||
'workers_data': workers_data,
|
'workers_data': workers_data,
|
||||||
'paid_records': paid_records,
|
'paid_records': paid_records,
|
||||||
@ -731,15 +859,15 @@ def payroll_dashboard(request):
|
|||||||
'active_tab': status_filter,
|
'active_tab': status_filter,
|
||||||
'all_workers': all_workers,
|
'all_workers': all_workers,
|
||||||
'all_teams': all_teams,
|
'all_teams': all_teams,
|
||||||
'team_workers_map_json': json.dumps(team_workers_map),
|
'team_workers_map_json': team_workers_map,
|
||||||
'adjustment_types': PayrollAdjustment.TYPE_CHOICES,
|
'adjustment_types': PayrollAdjustment.TYPE_CHOICES,
|
||||||
'active_projects': active_projects,
|
'active_projects': active_projects,
|
||||||
'loans': loans,
|
'loans': loans,
|
||||||
'loan_filter': loan_filter,
|
'loan_filter': loan_filter,
|
||||||
'chart_labels_json': json.dumps(chart_labels),
|
'chart_labels_json': chart_labels,
|
||||||
'chart_totals_json': json.dumps(chart_totals),
|
'chart_totals_json': chart_totals,
|
||||||
'project_chart_json': json.dumps(project_chart_data),
|
'project_chart_json': project_chart_data,
|
||||||
'overtime_data_json': json.dumps(all_ot_data),
|
'overtime_data_json': all_ot_data,
|
||||||
'today': today, # For pre-filling date fields in modals
|
'today': today, # For pre-filling date fields in modals
|
||||||
'active_loans_count': active_loans_count,
|
'active_loans_count': active_loans_count,
|
||||||
'active_loans_balance': active_loans_balance,
|
'active_loans_balance': active_loans_balance,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user