HH/templates/reports/report_builder.html
2026-03-09 16:10:24 +03:00

1036 lines
42 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends 'layouts/base.html' %}
{% load static %}
{% block title %}Report Builder - PX360{% endblock %}
{% block extra_css %}
<style>
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-in {
animation: fadeIn 0.5s ease-out forwards;
}
</style>
{% endblock %}
{% block content %}
<div class="p-6 md:p-8 bg-gradient-to-br from-light to-blue-50 min-h-screen">
<!-- Breadcrumb -->
<nav class="mb-6 animate-in">
<ol class="flex items-center gap-2 text-sm text-slate">
<li><a href="{% url 'dashboard:my_dashboard' %}" class="text-blue hover:text-navy font-medium">Dashboard</a></li>
<li><i data-lucide="chevron-right" class="w-4 h-4 text-slate"></i></li>
<li class="text-navy font-semibold">Report Builder</li>
</ol>
</nav>
<!-- Page Header -->
<div class="bg-white rounded-2xl shadow-sm border border-blue-100 p-6 mb-6 animate-in">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div class="flex items-center gap-3">
<div class="w-14 h-14 bg-gradient-to-br from-blue to-indigo-500 rounded-2xl flex items-center justify-center shadow-lg shadow-blue-200">
<i data-lucide="file-spreadsheet" class="w-8 h-8 text-white"></i>
</div>
<div>
<h1 class="text-2xl font-bold text-navy">Report Builder</h1>
<p class="text-slate text-sm">Create custom reports with filters and export options</p>
</div>
</div>
<div class="flex gap-3">
<a href="{% url 'reports:saved_reports' %}" class="inline-flex items-center gap-2 px-5 py-2.5 bg-white border-2 border-blue-200 text-blue-700 rounded-xl font-semibold hover:bg-blue-50 transition">
<i data-lucide="folder-open" class="w-4 h-4"></i>Saved Reports
</a>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
<!-- Left Panel - Configuration -->
<div class="lg:col-span-1">
<div class="bg-white rounded-2xl shadow-sm border border-blue-100 p-6 sticky top-6">
<!-- Data Source Selection -->
<div class="mb-6">
<label class="block text-sm font-bold text-navy mb-2">Data Source</label>
<select id="dataSource" class="w-full px-4 py-2.5 border-2 border-blue-100 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition bg-white">
{% for value, label in data_sources %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<!-- Date Range -->
<div class="mb-6">
<label class="block text-sm font-bold text-navy mb-2">Date Range</label>
<select id="dateRange" class="w-full px-4 py-2.5 border-2 border-blue-100 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition bg-white">
<option value="7d">Last 7 Days</option>
<option value="30d" selected>Last 30 Days</option>
<option value="90d">Last 90 Days</option>
<option value="ytd">Year to Date</option>
<option value="custom">Custom Range</option>
</select>
<div id="customDateRange" class="hidden mt-3 space-y-2">
<input type="date" id="startDate" class="w-full px-4 py-2.5 border-2 border-blue-100 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition">
<input type="date" id="endDate" class="w-full px-4 py-2.5 border-2 border-blue-100 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition">
</div>
</div>
<!-- Hospital Filter -->
<div class="mb-6">
<label class="block text-sm font-bold text-navy mb-2">Hospital</label>
<select id="hospitalFilter" class="w-full px-4 py-2.5 border-2 border-blue-100 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition bg-white">
<option value="">All Hospitals</option>
{% for hospital in hospitals %}
<option value="{{ hospital.id }}">{{ hospital.name }}</option>
{% endfor %}
</select>
</div>
<!-- Department Filter -->
<div class="mb-6">
<label class="block text-sm font-bold text-navy mb-2">Department</label>
<select id="departmentFilter" class="w-full px-4 py-2.5 border-2 border-blue-100 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition bg-white">
<option value="">All Departments</option>
</select>
</div>
<!-- Status Filter -->
<div class="mb-6" id="statusFilterContainer">
<label class="block text-sm font-bold text-navy mb-2">Status</label>
<select id="statusFilter" class="w-full px-4 py-2.5 border-2 border-blue-100 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition bg-white">
<option value="">All Statuses</option>
</select>
</div>
<!-- Column Selection -->
<div class="mb-6" id="columnFilterContainer">
<div class="flex items-center justify-between mb-2">
<label class="text-sm font-bold text-navy">Columns</label>
<div class="flex gap-2">
<button type="button" id="selectAllColumns" class="text-xs text-blue-600 hover:text-blue-800 font-medium">Select All</button>
<span class="text-slate-300">|</span>
<button type="button" id="clearAllColumns" class="text-xs text-slate-500 hover:text-slate-700 font-medium">Clear All</button>
</div>
</div>
<div id="columnCheckboxes" class="max-h-48 overflow-y-auto border-2 border-blue-100 rounded-xl p-3 space-y-2 bg-slate-50">
<!-- Column checkboxes will be populated dynamically -->
<p class="text-sm text-slate-400 italic">Select a data source to see available columns</p>
</div>
</div>
<!-- Actions -->
<div class="space-y-3 pt-4 border-t border-blue-100">
<button id="previewBtn" class="w-full inline-flex items-center justify-center gap-2 px-6 py-3 bg-gradient-to-r from-blue to-navy text-white rounded-xl font-semibold hover:from-navy hover:to-blue transition shadow-lg shadow-blue-200">
<i data-lucide="eye" class="w-4 h-4"></i>Generate Report
</button>
<button id="saveBtn" class="w-full inline-flex items-center justify-center gap-2 px-6 py-3 bg-white border-2 border-blue-200 text-blue-700 rounded-xl font-semibold hover:bg-blue-50 transition">
<i data-lucide="save" class="w-4 h-4"></i>Save Report
</button>
</div>
</div>
</div>
<!-- Right Panel - Preview -->
<div class="lg:col-span-3 space-y-6">
<!-- Summary Cards -->
<div id="summaryCards" class="grid grid-cols-1 md:grid-cols-4 gap-4" style="display:none;">
<div class="bg-white rounded-2xl shadow-sm border border-blue-100 p-6">
<div class="flex items-center gap-3">
<div class="w-12 h-12 bg-gradient-to-br from-blue to-navy rounded-xl flex items-center justify-center">
<i data-lucide="database" class="w-6 h-6 text-white"></i>
</div>
<div>
<div class="text-2xl font-bold text-navy" id="totalCount">0</div>
<div class="text-xs text-slate uppercase font-semibold">Total Records</div>
</div>
</div>
</div>
</div>
<!-- Export Buttons -->
<div id="exportButtons" class="flex flex-wrap gap-3" style="display:none;">
<button id="exportCsvBtn" class="inline-flex items-center gap-2 px-4 py-2 bg-white border-2 border-green-200 text-green-700 rounded-xl font-semibold hover:bg-green-50 transition">
<i data-lucide="file-text" class="w-4 h-4"></i>Export CSV
</button>
<button id="exportExcelBtn" class="inline-flex items-center gap-2 px-4 py-2 bg-white border-2 border-emerald-200 text-emerald-700 rounded-xl font-semibold hover:bg-emerald-50 transition">
<i data-lucide="file-spreadsheet" class="w-4 h-4"></i>Export Excel
</button>
<button id="copyToClipboardBtn" class="inline-flex items-center gap-2 px-4 py-2 bg-white border-2 border-blue-200 text-blue-700 rounded-xl font-semibold hover:bg-blue-50 transition">
<i data-lucide="copy" class="w-4 h-4"></i>Copy to Clipboard
</button>
<button id="printTableBtn" class="inline-flex items-center gap-2 px-4 py-2 bg-white border-2 border-slate-200 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition">
<i data-lucide="printer" class="w-4 h-4"></i>Print
</button>
<button id="columnVisibilityBtn" class="inline-flex items-center gap-2 px-4 py-2 bg-white border-2 border-indigo-200 text-indigo-700 rounded-xl font-semibold hover:bg-indigo-50 transition">
<i data-lucide="eye" class="w-4 h-4"></i>Columns
</button>
</div>
<!-- Data Table -->
<div id="dataTableContainer" class="bg-white rounded-2xl shadow-sm border border-blue-100 overflow-hidden">
<div class="text-center py-16">
<div class="w-20 h-20 bg-gradient-to-br from-blue-100 to-indigo-100 rounded-full flex items-center justify-center mx-auto mb-4">
<i data-lucide="file-spreadsheet" class="w-10 h-10 text-blue-500"></i>
</div>
<p class="text-slate font-medium text-lg">Configure your report and click "Generate Report" to see results</p>
</div>
</div>
</div>
</div>
</div>
<!-- Save Report Modal -->
<div id="saveModal" class="fixed inset-0 bg-black/50 z-50 hidden flex items-center justify-center backdrop-blur-sm">
<div class="bg-white rounded-2xl shadow-2xl max-w-md w-full mx-4 overflow-hidden">
<div class="px-6 py-5 border-b border-blue-100 bg-gradient-to-r from-blue-50 to-transparent">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-gradient-to-br from-blue to-navy rounded-xl flex items-center justify-center">
<i data-lucide="save" class="w-5 h-5 text-white"></i>
</div>
<h3 class="text-lg font-bold text-navy">Save Report</h3>
</div>
</div>
<div class="p-6 space-y-4">
<div>
<label class="block text-sm font-bold text-navy mb-2">Report Name</label>
<input type="text" id="reportName" class="w-full px-4 py-2.5 border-2 border-blue-100 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition" placeholder="Enter report name">
</div>
<div>
<label class="block text-sm font-bold text-navy mb-2">Description</label>
<textarea id="reportDescription" rows="3" class="w-full px-4 py-2.5 border-2 border-blue-100 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition" placeholder="Enter description (optional)"></textarea>
</div>
<div class="flex items-center gap-3 p-4 bg-blue-50 rounded-xl">
<input type="checkbox" id="isShared" class="w-5 h-5 text-blue-600 border-blue-300 rounded focus:ring-blue">
<label for="isShared" class="text-sm font-medium text-navy">Share with my hospital</label>
</div>
</div>
<div class="px-6 py-5 bg-gradient-to-r from-blue-50 to-transparent flex justify-end gap-3">
<button id="cancelSave" class="inline-flex items-center gap-2 px-6 py-2.5 border-2 border-slate-200 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition">
<i data-lucide="x" class="w-4 h-4"></i>Cancel
</button>
<button id="confirmSave" class="inline-flex items-center gap-2 px-6 py-2.5 bg-gradient-to-r from-blue to-navy text-white rounded-xl font-semibold hover:from-navy hover:to-blue transition shadow-lg shadow-blue-200">
<i data-lucide="check" class="w-4 h-4"></i>Save Report
</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
// CSRF Token from template
const CSRF_TOKEN = '{{ csrf_token }}';
// DOM elements
let dataSourceSelect = null;
let dateRangeSelect = null;
let hospitalFilter = null;
let departmentFilter = null;
let statusFilter = null;
let previewBtn = null;
let saveBtn = null;
let saveModal = null;
let cancelSaveBtn = null;
let confirmSaveBtn = null;
let exportCsvBtn = null;
let exportExcelBtn = null;
let currentReportId = null;
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', function() {
lucide.createIcons();
// Initialize DOM element references
dataSourceSelect = document.getElementById('dataSource');
dateRangeSelect = document.getElementById('dateRange');
hospitalFilter = document.getElementById('hospitalFilter');
departmentFilter = document.getElementById('departmentFilter');
statusFilter = document.getElementById('statusFilter');
previewBtn = document.getElementById('previewBtn');
saveBtn = document.getElementById('saveBtn');
saveModal = document.getElementById('saveModal');
cancelSaveBtn = document.getElementById('cancelSave');
confirmSaveBtn = document.getElementById('confirmSave');
exportCsvBtn = document.getElementById('exportCsvBtn');
exportExcelBtn = document.getElementById('exportExcelBtn');
copyToClipboardBtn = document.getElementById('copyToClipboardBtn');
printTableBtn = document.getElementById('printTableBtn');
columnVisibilityBtn = document.getElementById('columnVisibilityBtn');
// Event listeners
if (dataSourceSelect) dataSourceSelect.addEventListener('change', loadFilterOptions);
if (dateRangeSelect) dateRangeSelect.addEventListener('change', toggleCustomDateRange);
if (hospitalFilter) hospitalFilter.addEventListener('change', loadDepartments);
if (previewBtn) previewBtn.addEventListener('click', generateReport);
if (saveBtn) saveBtn.addEventListener('click', showSaveModal);
if (cancelSaveBtn) cancelSaveBtn.addEventListener('click', hideSaveModal);
if (confirmSaveBtn) confirmSaveBtn.addEventListener('click', saveReport);
if (exportCsvBtn) exportCsvBtn.addEventListener('click', () => exportReport('csv'));
if (exportExcelBtn) exportExcelBtn.addEventListener('click', () => exportReport('excel'));
if (copyToClipboardBtn) copyToClipboardBtn.addEventListener('click', copyTableToClipboard);
if (printTableBtn) printTableBtn.addEventListener('click', printTable);
if (columnVisibilityBtn) columnVisibilityBtn.addEventListener('click', toggleColumnVisibility);
// Close modal on outside click
if (saveModal) {
saveModal.addEventListener('click', function(e) {
if (e.target === saveModal) {
hideSaveModal();
}
});
}
// Initialize
loadFilterOptions();
});
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
function getCSRFToken() {
if (typeof CSRF_TOKEN !== 'undefined' && CSRF_TOKEN) {
return CSRF_TOKEN;
}
return getCookie('csrftoken');
}
function toggleCustomDateRange() {
const customRange = document.getElementById('customDateRange');
if (dateRangeSelect.value === 'custom') {
customRange.classList.remove('hidden');
} else {
customRange.classList.add('hidden');
}
}
function showSaveModal() {
saveModal.classList.remove('hidden');
}
function hideSaveModal() {
saveModal.classList.add('hidden');
}
async function loadFilterOptions() {
const dataSource = dataSourceSelect.value;
try {
const response = await fetch(`/reports/api/filter-options/?data_source=${dataSource}`);
const data = await response.json();
// Populate status filter
if (data.status) {
statusFilter.innerHTML = '<option value="">All Statuses</option>';
data.status.forEach(status => {
const option = document.createElement('option');
option.value = status;
option.textContent = status;
statusFilter.appendChild(option);
});
}
// Populate column checkboxes
if (data.columns) {
renderColumnCheckboxes(data.columns);
}
} catch (error) {
console.error('Error loading filter options:', error);
}
}
function renderColumnCheckboxes(columns) {
const container = document.getElementById('columnCheckboxes');
if (!columns || columns.length === 0) {
container.innerHTML = '<p class="text-sm text-slate-400 italic">No columns available</p>';
return;
}
let html = '';
columns.forEach(col => {
const checked = col.selected ? 'checked' : '';
html += `
<label class="flex items-center gap-2 cursor-pointer hover:bg-white p-1.5 rounded-lg transition">
<input type="checkbox" name="column" value="${col.key}" ${checked}
class="w-4 h-4 text-blue-600 border-blue-300 rounded focus:ring-blue">
<span class="text-sm text-navy">${col.label}</span>
</label>
`;
});
container.innerHTML = html;
// Setup select all / clear all handlers
document.getElementById('selectAllColumns').onclick = function() {
container.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = true);
};
document.getElementById('clearAllColumns').onclick = function() {
container.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = false);
};
}
function getSelectedColumns() {
const checkboxes = document.querySelectorAll('#columnCheckboxes input[type="checkbox"]:checked');
return Array.from(checkboxes).map(cb => cb.value);
}
async function loadDepartments() {
const hospitalId = hospitalFilter.value;
if (!hospitalId) {
departmentFilter.innerHTML = '<option value="">All Departments</option>';
return;
}
try {
const response = await fetch(`/reports/api/filter-options/?hospital=${hospitalId}`);
const data = await response.json();
departmentFilter.innerHTML = '<option value="">All Departments</option>';
if (data.departments) {
data.departments.forEach(dept => {
const option = document.createElement('option');
option.value = dept.id;
option.textContent = dept.name;
departmentFilter.appendChild(option);
});
}
} catch (error) {
console.error('Error loading departments:', error);
}
}
function getDateRange() {
const range = dateRangeSelect.value;
if (range === 'custom') {
return {
start: document.getElementById('startDate').value,
end: document.getElementById('endDate').value
};
}
const days = { '7d': 7, '30d': 30, '90d': 90, 'ytd': 365 };
const endDate = new Date();
const startDate = new Date();
startDate.setDate(startDate.getDate() - (days[range] || 30));
return {
start: startDate.toISOString().split('T')[0],
end: endDate.toISOString().split('T')[0]
};
}
async function generateReport() {
const dateRange = getDateRange();
const selectedColumns = getSelectedColumns();
const payload = {
data_source: dataSourceSelect.value,
filter_config: {
date_range: dateRangeSelect.value,
date_start: dateRange.start,
date_end: dateRange.end,
hospital: hospitalFilter.value,
department: departmentFilter.value,
status: statusFilter.value,
},
column_config: selectedColumns,
grouping_config: {},
chart_config: {},
sort_config: []
};
previewBtn.disabled = true;
previewBtn.innerHTML = '<i data-lucide="loader-2" class="w-4 h-4 animate-spin"></i> Loading...';
try {
const response = await fetch('/reports/preview/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCSRFToken()
},
body: JSON.stringify(payload)
});
const data = await response.json();
if (data.success) {
renderReport(data);
} else {
alert('Error: ' + (data.error || 'Failed to generate report'));
}
} catch (error) {
console.error('Error generating report:', error);
alert('Error generating report. Please try again.');
} finally {
previewBtn.disabled = false;
previewBtn.innerHTML = '<i data-lucide="eye" class="w-4 h-4"></i>Generate Report';
lucide.createIcons();
}
}
function renderReport(response) {
const data = response.data || {};
const summary = response.summary || {};
// Show summary cards
const summaryCards = document.getElementById('summaryCards');
summaryCards.style.display = 'grid';
document.getElementById('totalCount').textContent = summary.total_count || data.rows?.length || 0;
// Show export buttons
document.getElementById('exportButtons').style.display = 'flex';
// Render data table
const rows = data.rows || [];
const columns = data.columns || [];
const columnKeys = data.column_keys || [];
renderDataTable(rows, columns, columnKeys);
}
function renderDataTable(data, columns, columnKeys) {
const container = document.getElementById('dataTableContainer');
if (!data || data.length === 0) {
container.innerHTML = `
<div class="text-center py-16">
<div class="w-20 h-20 bg-gradient-to-br from-blue-100 to-indigo-100 rounded-full flex items-center justify-center mx-auto mb-4">
<i data-lucide="inbox" class="w-10 h-10 text-blue-500"></i>
</div>
<p class="text-slate font-medium text-lg">No data found for the selected filters</p>
<p class="text-sm text-slate mt-2">Try adjusting your filters or selecting different columns</p>
</div>
`;
lucide.createIcons();
return;
}
// Use columnKeys for data access, columns for display
const displayColumns = columns.length > 0 ? columns : Object.keys(data[0]);
const dataKeys = columnKeys && columnKeys.length > 0 ? columnKeys : Object.keys(data[0]);
// Store data globally for sorting/filtering
window.currentReportData = data;
window.currentReportColumns = displayColumns;
window.currentReportKeys = dataKeys;
window.currentSortColumn = null;
window.currentSortDirection = 'asc';
// Initialize column visibility
if (!window.columnVisibility) {
window.columnVisibility = {};
dataKeys.forEach(key => {
window.columnVisibility[key] = true;
});
}
let html = `
<div class="overflow-x-auto">
<!-- Table Search -->
<div class="p-4 border-b border-slate-200 bg-white sticky top-0 z-10">
<div class="flex items-center justify-between gap-4">
<div class="relative flex-1 max-w-md">
<input type="text" id="tableSearch"
placeholder="Search in results..."
class="w-full pl-10 pr-4 py-2.5 border-2 border-blue-100 rounded-xl text-navy focus:ring-2 focus:ring-blue focus:border-transparent transition"
onkeyup="searchTable(this.value)">
<i data-lucide="search" class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate"></i>
</div>
<div class="text-sm text-slate">
<span class="font-semibold text-navy">${data.length}</span> records × <span class="font-semibold text-navy">${displayColumns.length}</span> columns
</div>
</div>
</div>
<table class="w-full" id="reportTable">
<thead class="bg-slate-50 border-b-2 border-slate-200 sticky top-[73px] z-10">
<tr>
<th class="px-4 py-3 text-center text-xs font-bold text-slate-400 uppercase tracking-wider w-16">#</th>
${displayColumns.map((col, index) => {
const key = dataKeys[index];
const isVisible = window.columnVisibility[key] !== false;
return `
<th class="px-4 py-3 text-left text-xs font-bold text-slate-600 uppercase tracking-wider cursor-pointer hover:bg-slate-100 transition select-none ${!isVisible ? 'hidden' : ''}"
onclick="sortTable('${key}')"
data-key="${key}"
data-column-index="${index}">
<div class="flex items-center gap-2">
${col}
<span class="sort-indicator text-blue-600"></span>
</div>
</th>
`}).join('')}
</tr>
</thead>
<tbody class="divide-y divide-slate-100" id="tableBody">
`;
// Render rows with pagination (first 100)
const pageSize = 100;
data.slice(0, pageSize).forEach((row, rowIndex) => {
const actualIndex = rowIndex + 1;
html += `<tr class="hover:bg-blue-50 transition group" data-index="${rowIndex}">`;
html += `<td class="px-4 py-3 text-sm text-slate-400 text-center font-mono">${actualIndex}</td>`;
dataKeys.forEach((key, index) => {
const isVisible = window.columnVisibility[key] !== false;
const value = row[key] || '-';
html += `<td class="px-4 py-3 text-sm text-navy ${!isVisible ? 'hidden' : ''}">${value}</td>`;
});
html += '</tr>';
});
html += '</tbody></table></div>';
// Add pagination if more data
if (data.length > pageSize) {
html += `
<div class="flex items-center justify-between px-4 py-3 border-t border-slate-200 bg-slate-50">
<p class="text-sm text-slate">
Showing <span class="font-semibold text-navy">1-${pageSize}</span> of <span class="font-semibold text-navy">${data.length}</span> records
</p>
<div class="flex gap-2">
<span class="px-4 py-2 text-slate-400 text-sm">More pages coming soon</span>
</div>
</div>
`;
}
container.innerHTML = html;
lucide.createIcons();
}
// Search within table results
function searchTable(searchTerm) {
if (!window.currentReportData) return;
const term = searchTerm.toLowerCase().trim();
const dataKeys = window.currentReportKeys;
let filteredData = window.currentReportData;
if (term) {
filteredData = window.currentReportData.filter(row => {
return dataKeys.some(key => {
const value = row[key];
return value && String(value).toLowerCase().includes(term);
});
});
}
// Re-render with filtered data
renderTableBody(filteredData);
}
// Sort table by column
function sortTable(columnKey) {
if (!window.currentReportData) return;
// Toggle sort direction
if (window.currentSortColumn === columnKey) {
window.currentSortDirection = window.currentSortDirection === 'asc' ? 'desc' : 'asc';
} else {
window.currentSortColumn = columnKey;
window.currentSortDirection = 'asc';
}
// Sort data
const sortedData = [...window.currentReportData].sort((a, b) => {
let aVal = a[columnKey];
let bVal = b[columnKey];
// Handle null/undefined
if (!aVal) aVal = '';
if (!bVal) bVal = '';
// Try numeric comparison
const aNum = parseFloat(aVal);
const bNum = parseFloat(bVal);
if (!isNaN(aNum) && !isNaN(bNum)) {
return window.currentSortDirection === 'asc' ? aNum - bNum : bNum - aNum;
}
// String comparison
const aStr = String(aVal).toLowerCase();
const bStr = String(bVal).toLowerCase();
if (window.currentSortDirection === 'asc') {
return aStr.localeCompare(bStr, 'en', { numeric: true });
} else {
return bStr.localeCompare(aStr, 'en', { numeric: true });
}
});
// Update stored data
window.currentReportData = sortedData;
// Update sort indicators
document.querySelectorAll('.sort-indicator').forEach(span => {
span.innerHTML = '';
});
const activeHeader = document.querySelector(`th[data-key="${columnKey}"] .sort-indicator`);
if (activeHeader) {
activeHeader.innerHTML = window.currentSortDirection === 'asc'
? '<i data-lucide="arrow-up" class="w-4 h-4"></i>'
: '<i data-lucide="arrow-down" class="w-4 h-4"></i>';
lucide.createIcons();
}
// Re-render
renderTableBody(sortedData);
}
// Render table body only (for search/sort)
function renderTableBody(dataToRender) {
const tbody = document.getElementById('tableBody');
if (!tbody) return;
const dataKeys = window.currentReportKeys;
const pageSize = 100;
const data = dataToRender || window.currentReportData;
let html = '';
data.slice(0, pageSize).forEach((row, rowIndex) => {
const actualIndex = rowIndex + 1;
html += `<tr class="hover:bg-blue-50 transition group" data-index="${rowIndex}">`;
html += `<td class="px-4 py-3 text-sm text-slate-400 text-center font-mono">${actualIndex}</td>`;
dataKeys.forEach((key, index) => {
const isVisible = window.columnVisibility[key] !== false;
const value = row[key] || '-';
html += `<td class="px-4 py-3 text-sm text-navy ${!isVisible ? 'hidden' : ''}">${value}</td>`;
});
html += '</tr>';
});
tbody.innerHTML = html;
}
// Copy table to clipboard
function copyTableToClipboard() {
if (!window.currentReportData || !window.currentReportColumns) {
alert('No data to copy. Please generate a report first.');
return;
}
const data = window.currentReportData;
const columns = window.currentReportColumns;
const keys = window.currentReportKeys;
// Create TSV (Tab-Separated Values) format
let tsv = columns.join('\t') + '\n';
data.forEach(row => {
const values = keys.map(key => {
let val = row[key] || '';
// Escape tabs and newlines
val = String(val).replace(/[\t\n\r]/g, ' ');
return val;
});
tsv += values.join('\t') + '\n';
});
// Copy to clipboard
navigator.clipboard.writeText(tsv).then(() => {
// Show success feedback
const originalText = copyToClipboardBtn.innerHTML;
copyToClipboardBtn.innerHTML = '<i data-lucide="check" class="w-4 h-4"></i>Copied!';
copyToClipboardBtn.classList.add('bg-green-50', 'border-green-300', 'text-green-700');
lucide.createIcons();
setTimeout(() => {
copyToClipboardBtn.innerHTML = originalText;
copyToClipboardBtn.classList.remove('bg-green-50', 'border-green-300', 'text-green-700');
lucide.createIcons();
}, 2000);
}).catch(err => {
console.error('Failed to copy:', err);
alert('Failed to copy to clipboard. Please try again.');
});
}
// Print table
function printTable() {
if (!window.currentReportData || !window.currentReportColumns) {
alert('No data to print. Please generate a report first.');
return;
}
const data = window.currentReportData.slice(0, 500); // Limit to 500 rows for print
const columns = window.currentReportColumns;
const keys = window.currentReportKeys;
// Create print window
const printWindow = window.open('', '_blank');
let html = `
<!DOCTYPE html>
<html>
<head>
<title>Report - ${new Date().toLocaleDateString()}</title>
<style>
@media print {
body { font-family: Arial, sans-serif; margin: 20px; }
table { width: 100%; border-collapse: collapse; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; font-size: 10px; }
th { background-color: #f5f5f5; font-weight: bold; }
tr:nth-child(even) { background-color: #fafafa; }
.header { margin-bottom: 20px; }
.footer { margin-top: 20px; font-size: 10px; color: #666; }
}
</style>
</head>
<body>
<div class="header">
<h2>Report</h2>
<p>Generated: ${new Date().toLocaleString()}</p>
<p>Records: ${data.length}</p>
</div>
<table>
<thead>
<tr>
${columns.map(col => `<th>${col}</th>`).join('')}
</tr>
</thead>
<tbody>
`;
data.forEach(row => {
html += '<tr>';
keys.forEach(key => {
html += `<td>${row[key] || '-'}</td>`;
});
html += '</tr>';
});
html += `
</tbody>
</table>
<div class="footer">
<p>PX360 Report Builder</p>
</div>
</body>
</html>
`;
printWindow.document.write(html);
printWindow.document.close();
printWindow.focus();
setTimeout(() => {
printWindow.print();
}, 250);
}
// Toggle column visibility
let columnVisibilityMenu = null;
function toggleColumnVisibility() {
if (!window.currentReportColumns) {
alert('No data to configure. Please generate a report first.');
return;
}
// Remove existing menu if open
if (columnVisibilityMenu) {
columnVisibilityMenu.remove();
columnVisibilityMenu = null;
return;
}
const columns = window.currentReportColumns;
const keys = window.currentReportKeys;
// Create dropdown menu
const menu = document.createElement('div');
menu.className = 'absolute right-0 mt-2 w-64 bg-white rounded-xl shadow-lg border-2 border-blue-100 z-50 overflow-hidden';
menu.style.top = '100%';
let html = `
<div class="p-3 border-b border-slate-200 bg-slate-50">
<h4 class="font-bold text-navy text-sm">Column Visibility</h4>
<div class="flex gap-2 mt-2">
<button onclick="showAllColumns()" class="text-xs px-2 py-1 bg-blue-100 text-blue-700 rounded hover:bg-blue-200">Show All</button>
<button onclick="hideAllColumns()" class="text-xs px-2 py-1 bg-slate-200 text-slate-700 rounded hover:bg-slate-300">Hide All</button>
</div>
</div>
<div class="max-h-64 overflow-y-auto p-2">
`;
// Store initial visibility state if not exists
if (!window.columnVisibility) {
window.columnVisibility = {};
keys.forEach(key => {
window.columnVisibility[key] = true;
});
}
columns.forEach((col, index) => {
const key = keys[index];
const isVisible = window.columnVisibility[key] !== false;
html += `
<label class="flex items-center gap-2 p-2 hover:bg-blue-50 rounded-lg cursor-pointer">
<input type="checkbox"
data-column-key="${key}"
data-column-index="${index}"
${isVisible ? 'checked' : ''}
onchange="toggleColumn(${index})"
class="w-4 h-4 text-blue-600 border-blue-300 rounded focus:ring-blue">
<span class="text-sm text-navy">${col}</span>
</label>
`;
});
html += '</div>';
menu.innerHTML = html;
// Position menu
columnVisibilityBtn.style.position = 'relative';
columnVisibilityBtn.appendChild(menu);
columnVisibilityMenu = menu;
}
function toggleColumn(index) {
const key = window.currentReportKeys[index];
window.columnVisibility[key] = !window.columnVisibility[key];
// Update table visibility
const table = document.getElementById('reportTable');
if (!table) return;
const cells = table.querySelectorAll(`tr > :nth-child(${index + 2})`); // +2 for row number column
cells.forEach(cell => {
cell.style.display = window.columnVisibility[key] ? '' : 'none';
});
}
function showAllColumns() {
window.currentReportKeys.forEach(key => {
window.columnVisibility[key] = true;
});
// Re-render table
if (window.currentReportData) {
renderDataTable(window.currentReportData, window.currentReportColumns, window.currentReportKeys);
}
// Close menu
if (columnVisibilityMenu) {
columnVisibilityMenu.remove();
columnVisibilityMenu = null;
}
}
function hideAllColumns() {
window.currentReportKeys.forEach(key => {
window.columnVisibility[key] = false;
});
// Re-render table
if (window.currentReportData) {
renderDataTable(window.currentReportData, window.currentReportColumns, window.currentReportKeys);
}
// Close menu
if (columnVisibilityMenu) {
columnVisibilityMenu.remove();
columnVisibilityMenu = null;
}
}
// Close column menu when clicking outside
document.addEventListener('click', function(e) {
if (columnVisibilityMenu && !columnVisibilityBtn.contains(e.target)) {
columnVisibilityMenu.remove();
columnVisibilityMenu = null;
}
});
async function saveReport() {
const name = document.getElementById('reportName').value.trim();
const description = document.getElementById('reportDescription').value.trim();
const isShared = document.getElementById('isShared').checked;
if (!name) {
alert('Please enter a report name');
return;
}
confirmSaveBtn.disabled = true;
confirmSaveBtn.innerHTML = '<i data-lucide="loader-2" class="w-4 h-4 animate-spin"></i> Saving...';
const dateRange = getDateRange();
const selectedColumns = getSelectedColumns();
const payload = {
name: name,
description: description,
is_shared: isShared,
data_source: dataSourceSelect.value,
filter_config: {
date_range: dateRangeSelect.value,
date_start: dateRange.start,
date_end: dateRange.end,
hospital: hospitalFilter.value,
department: departmentFilter.value,
status: statusFilter.value,
},
column_config: selectedColumns,
grouping_config: {},
chart_config: {},
sort_config: []
};
try {
const response = await fetch('/reports/save/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCSRFToken()
},
body: JSON.stringify(payload)
});
const data = await response.json();
if (data.success) {
currentReportId = data.report_id;
hideSaveModal();
alert('Report saved successfully!');
document.getElementById('reportName').value = '';
document.getElementById('reportDescription').value = '';
document.getElementById('isShared').checked = false;
} else {
alert('Error: ' + (data.error || 'Failed to save report'));
}
} catch (error) {
console.error('Error saving report:', error);
alert('Error saving report. Please try again.');
} finally {
confirmSaveBtn.disabled = false;
confirmSaveBtn.innerHTML = '<i data-lucide="check" class="w-4 h-4"></i>Save Report';
lucide.createIcons();
}
}
function exportReport(format) {
if (!currentReportId) {
alert('Please save the report first before exporting.');
return;
}
window.location.href = `/reports/${currentReportId}/export/${format}/`;
}
</script>
{% endblock extra_js %}