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:
Konrad du Plessis 2026-02-22 22:31:32 +02:00
parent 2863f21844
commit 19e565a088
3 changed files with 558 additions and 86 deletions

View File

@ -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 + '/')

View File

@ -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 %}

View File

@ -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,