diff --git a/core/templates/core/payroll_dashboard.html b/core/templates/core/payroll_dashboard.html
index 038b418..1a20f2e 100644
--- a/core/templates/core/payroll_dashboard.html
+++ b/core/templates/core/payroll_dashboard.html
@@ -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 + '/')
diff --git a/core/templates/core/work_history.html b/core/templates/core/work_history.html
index 4388ffb..8e4b194 100644
--- a/core/templates/core/work_history.html
+++ b/core/templates/core/work_history.html
@@ -4,10 +4,30 @@
{% block title %}Work History | Fox Fitt{% endblock %}
{% block content %}
+
+
+
+ {# === PAGE HEADER with view toggle and export === #}
Work History
+ {# View toggle — List vs Calendar #}
+
+
{# CSV Export button — keeps the current filters in the export URL #}
@@ -19,10 +39,18 @@
- {# --- Filter Bar --- #}
+ {# === FILTER BAR === #}
- {# --- Work Log Table --- #}
+
+ {% if view_mode == 'calendar' %}
+ {# =============================================================== #}
+ {# === CALENDAR VIEW === #}
+ {# =============================================================== #}
+
+ {# Month navigation header #}
+
+
+
+
+
+
+
+ {{ month_name }}
+
+
+
+
+
+
+
+
+ {# Calendar grid #}
+
+
+ {# Day-of-week header row #}
+
+
Mon
+
Tue
+
Wed
+
Thu
+
Fri
+
Sat
+
Sun
+
+
+ {# Calendar weeks — each row is 7 day cells #}
+ {% for week in calendar_weeks %}
+
+ {% for day in week %}
+
0 %}data-date="{{ day.date|date:'Y-m-d' }}"{% endif %}>
+ {# Day number + badge count #}
+
+ {{ day.day }}
+ {% if day.count > 0 %}
+ {{ day.count }}
+ {% endif %}
+
+ {# Mini log indicators (show first 3 entries) #}
+ {% for log in day.records|slice:":3" %}
+
+
+ {% if log.payroll_records.exists %}
+
+ {% else %}
+
+ {% endif %}
+ {{ log.project.name }}
+
+
+ {% endfor %}
+ {# "and X more" indicator #}
+ {% if day.count > 3 %}
+
+ +{{ day.count|add:"-3" }} more
+
+ {% endif %}
+
+ {% endfor %}
+
+ {% endfor %}
+
+
+
+ {# === 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. #}
+
+
+
+ {# Content built by JavaScript #}
+
+
+
+ {# Pass calendar detail data to JavaScript safely using json_script #}
+ {{ calendar_detail|json_script:"calDetailJson" }}
+
+
+
+ {# Calendar-specific CSS #}
+
+
+
+ {% else %}
+ {# =============================================================== #}
+ {# === LIST VIEW (TABLE) === #}
+ {# =============================================================== #}
+ {% endif %}
+
{% endblock %}
diff --git a/core/views.py b/core/views.py
index 0236a76..395b2e9 100644
--- a/core/views.py
+++ b/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,