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)
|
||||
// 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');
|
||||
if (monthlyCtx) {
|
||||
new Chart(monthlyCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: chartLabels,
|
||||
datasets: [{
|
||||
label: 'Total Paid',
|
||||
data: chartTotals,
|
||||
borderColor: '#10b981',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return fmt(context.parsed.y);
|
||||
try {
|
||||
const monthlyCtx = document.getElementById('monthlyChart');
|
||||
if (monthlyCtx) {
|
||||
new Chart(monthlyCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: chartLabels,
|
||||
datasets: [{
|
||||
label: 'Total Paid',
|
||||
data: chartTotals,
|
||||
borderColor: '#10b981',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
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)
|
||||
// =================================================================
|
||||
const projectCtx = document.getElementById('projectChart');
|
||||
if (projectCtx) {
|
||||
// Color palette for projects
|
||||
const colors = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16'];
|
||||
const datasets = projectChartData.map(function(proj, i) {
|
||||
return {
|
||||
label: proj.name,
|
||||
data: proj.data,
|
||||
backgroundColor: colors[i % colors.length],
|
||||
};
|
||||
});
|
||||
try {
|
||||
const projectCtx = document.getElementById('projectChart');
|
||||
if (projectCtx) {
|
||||
// Color palette for projects
|
||||
const colors = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16'];
|
||||
const datasets = projectChartData.map(function(proj, i) {
|
||||
return {
|
||||
label: proj.name,
|
||||
data: proj.data,
|
||||
backgroundColor: colors[i % colors.length],
|
||||
};
|
||||
});
|
||||
|
||||
new Chart(projectCtx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: chartLabels,
|
||||
datasets: datasets,
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return context.dataset.label + ': ' + fmt(context.parsed.y);
|
||||
new Chart(projectCtx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: chartLabels,
|
||||
datasets: datasets,
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
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('deleteAdjWorker').textContent = adjWorker;
|
||||
// Close edit modal, open delete modal
|
||||
bootstrap.Modal.getInstance(document.getElementById('editAdjustmentModal')).hide();
|
||||
new bootstrap.Modal(document.getElementById('deleteConfirmModal')).show();
|
||||
var editModal = bootstrap.Modal.getOrCreateInstance(document.getElementById('editAdjustmentModal'));
|
||||
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
|
||||
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);
|
||||
|
||||
// Show modal
|
||||
new bootstrap.Modal(document.getElementById('previewPayslipModal')).show();
|
||||
bootstrap.Modal.getOrCreateInstance(document.getElementById('previewPayslipModal')).show();
|
||||
|
||||
// Fetch preview data
|
||||
fetch('/payroll/preview/' + workerId + '/')
|
||||
|
||||
@ -4,10 +4,30 @@
|
||||
{% block title %}Work History | Fox Fitt{% endblock %}
|
||||
|
||||
{% 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">
|
||||
|
||||
{# === PAGE HEADER with view toggle and export === #}
|
||||
<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>
|
||||
<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 #}
|
||||
<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">
|
||||
@ -19,10 +39,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# --- Filter Bar --- #}
|
||||
{# === FILTER BAR === #}
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-body py-3">
|
||||
<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 #}
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small text-muted mb-1">Worker</label>
|
||||
@ -64,7 +92,7 @@
|
||||
<button type="submit" class="btn btn-sm btn-accent">
|
||||
<i class="fas fa-filter me-1"></i> Filter
|
||||
</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
|
||||
</a>
|
||||
</div>
|
||||
@ -72,7 +100,306 @@
|
||||
</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-body p-0">
|
||||
<div class="table-responsive">
|
||||
@ -138,5 +465,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
140
core/views.py
140
core/views.py
@ -5,6 +5,7 @@
|
||||
import csv
|
||||
import json
|
||||
import datetime
|
||||
import calendar as cal_module
|
||||
from decimal import Decimal
|
||||
|
||||
from django.shortcuts import render, redirect, get_object_or_404
|
||||
@ -357,8 +358,9 @@ def attendance_log(request):
|
||||
|
||||
|
||||
# === 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.
|
||||
# The calendar view groups logs by day and lets you click a day to see details.
|
||||
|
||||
@login_required
|
||||
def work_history(request):
|
||||
@ -410,6 +412,20 @@ def work_history(request):
|
||||
active=True, supervisors=user
|
||||
).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 = {
|
||||
'logs': logs,
|
||||
'filter_workers': filter_workers,
|
||||
@ -418,7 +434,114 @@ def work_history(request):
|
||||
'selected_project': project_filter,
|
||||
'selected_status': status_filter,
|
||||
'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)
|
||||
|
||||
|
||||
@ -722,6 +845,11 @@ def payroll_dashboard(request):
|
||||
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 = {
|
||||
'workers_data': workers_data,
|
||||
'paid_records': paid_records,
|
||||
@ -731,15 +859,15 @@ def payroll_dashboard(request):
|
||||
'active_tab': status_filter,
|
||||
'all_workers': all_workers,
|
||||
'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,
|
||||
'active_projects': active_projects,
|
||||
'loans': loans,
|
||||
'loan_filter': loan_filter,
|
||||
'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),
|
||||
'chart_labels_json': chart_labels,
|
||||
'chart_totals_json': chart_totals,
|
||||
'project_chart_json': project_chart_data,
|
||||
'overtime_data_json': all_ot_data,
|
||||
'today': today, # For pre-filling date fields in modals
|
||||
'active_loans_count': active_loans_count,
|
||||
'active_loans_balance': active_loans_balance,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user