1036 lines
42 KiB
HTML
1036 lines
42 KiB
HTML
{% 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 %} |