Files
TimeTracker/app/templates/reports/builder.html
Dries Peeters 88656c3d34 feat: Advanced Report Builder with iterative generation and email distribution
Implement comprehensive enhancements to the Report Builder system with support
for iterative report generation, flexible email distribution, and improved
error handling.

Features:
- Add iterative report generation: generate one report per custom field value
- Add email distribution modes: mapping, template, and single recipient modes
- Add recipient email templates with {value} placeholder support
- Enhance scheduled reports with better error handling and validation
- Add fix endpoint for invalid scheduled reports
- Improve report builder UI with iterative generation options
- Add comprehensive management views for report schemes

Fixes:
- Fix template errors in iterative report view (dict access issues)
- Fix empty report builder when editing saved reports
- Fix PWA install toast notification handling
- Fix migration revision ID length issue (shortened to fit 32 char limit)
- Add idempotent migration checks to prevent duplicate column errors
- Improve error handling in scheduled reports list view

Database Changes:
- Add iterative_report_generation and iterative_custom_field_name to saved_report_views
- Add email_distribution_mode and recipient_email_template to report_email_schedules
- Migration 090_report_builder_iteration (idempotent)

UI/UX Improvements:
- Display iterative generation status in saved views list
- Show distribution mode and template in scheduled reports
- Add error badges and fix buttons for invalid schedules
- Improve report builder form loading for saved configurations

Technical:
- Enhance ScheduledReportService with recipient resolution logic
- Add validation for report configurations
- Improve error handling and logging throughout
- Update templates to use safe dictionary access patterns
2025-12-12 22:11:57 +01:00

794 lines
34 KiB
HTML

{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block title %}{% if saved_view %}{{ _('Edit Report') }}: {{ saved_view.name }} - {{ app_name }}{% else %}{{ _('Custom Report Builder') }} - {{ app_name }}{% endif %}{% endblock %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Reports', 'url': url_for('reports.reports')},
{'text': 'Report Builder' if not saved_view else saved_view.name}
] %}
{{ page_header(
icon_class='fas fa-chart-bar',
title_text=saved_view.name if saved_view else 'Custom Report Builder',
subtitle_text='Edit report' if saved_view else 'Create custom reports with drag-and-drop',
breadcrumbs=breadcrumbs,
actions_html='<a href="' + url_for("custom_reports.list_saved_views") + '" class="bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 px-4 py-2 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"><i class="fas fa-list mr-2"></i>' + _('Saved Views') + '</a>'
) }}
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
<!-- Sidebar: Data Sources & Components -->
<div class="lg:col-span-1">
<div class="bg-card-light dark:bg-card-dark p-4 rounded-lg shadow sticky top-4">
<h3 class="font-semibold mb-4">{{ _('Data Sources') }}</h3>
<div class="space-y-2" id="dataSources">
{% for source in data_sources %}
<div class="p-3 border border-border-light dark:border-border-dark rounded-lg cursor-move hover:bg-gray-50 dark:hover:bg-gray-700" draggable="true" data-source="{{ source.id }}">
<i class="fas fa-{{ source.icon }} mr-2"></i>{{ source.name }}
</div>
{% endfor %}
</div>
<h3 class="font-semibold mb-4 mt-6">{{ _('Components') }}</h3>
<div class="space-y-2" id="components">
<div class="p-3 border border-border-light dark:border-border-dark rounded-lg cursor-move hover:bg-gray-50 dark:hover:bg-gray-700" draggable="true" data-component="table">
<i class="fas fa-table mr-2"></i>{{ _('Table') }}
</div>
<div class="p-3 border border-border-light dark:border-border-dark rounded-lg cursor-move hover:bg-gray-50 dark:hover:bg-gray-700" draggable="true" data-component="chart">
<i class="fas fa-chart-line mr-2"></i>{{ _('Chart') }}
</div>
<div class="p-3 border border-border-light dark:border-border-dark rounded-lg cursor-move hover:bg-gray-50 dark:hover:bg-gray-700" draggable="true" data-component="summary">
<i class="fas fa-calculator mr-2"></i>{{ _('Summary') }}
</div>
</div>
</div>
</div>
<!-- Main Canvas -->
<div class="lg:col-span-3">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold">{{ _('Report Canvas') }}</h3>
<div class="flex gap-2">
<button onclick="previewReport()" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700">
<i class="fas fa-eye mr-2"></i>{{ _('Preview') }}
</button>
<button onclick="saveReport()" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90">
<i class="fas fa-save mr-2"></i>{{ _('Save') }}
</button>
</div>
</div>
<div id="reportCanvas" class="min-h-96 border-2 border-dashed border-border-light dark:border-border-dark rounded-lg p-4">
<p class="text-center text-text-muted-light dark:text-text-muted-dark py-8">
{{ _('Drag data sources and components here to build your report') }}
</p>
</div>
</div>
<!-- Filters Panel -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mt-6">
<h3 class="text-lg font-semibold mb-4">{{ _('Filters') }}</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ _('Start Date') }}</label>
<input type="date" id="filterStartDate" class="form-input">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ _('End Date') }}</label>
<input type="date" id="filterEndDate" class="form-input">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ _('Project') }}</label>
<select id="filterProject" class="form-input">
<option value="">{{ _('All Projects') }}</option>
</select>
</div>
</div>
<!-- Advanced Filters (for Time Entries) -->
<div id="timeEntriesFilters" class="mt-4 pt-4 border-t border-border-light dark:border-border-dark">
<h4 class="text-sm font-semibold mb-3 text-gray-700 dark:text-gray-300">{{ _('Advanced Filters') }}</h4>
<!-- Unpaid Hours Filter -->
<div class="mb-4">
<label class="flex items-center cursor-pointer">
<input type="checkbox" id="filterUnpaidOnly" class="form-checkbox mr-2">
<span class="text-sm text-gray-700 dark:text-gray-300">
<i class="fas fa-exclamation-triangle text-orange-500 mr-1"></i>
{{ _('Unpaid Hours Only') }}
</span>
</label>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 ml-6">
{{ _('Show only billable hours that have not been invoiced') }}
</p>
</div>
<!-- Custom Field Filter -->
{% if custom_field_keys %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ _('Custom Field') }}
</label>
<select id="filterCustomFieldName" class="form-input">
<option value="">{{ _('No Filter') }}</option>
{% for field_key in custom_field_keys %}
<option value="{{ field_key }}">{{ field_key|replace('_', ' ')|title }}</option>
{% endfor %}
</select>
</div>
<div id="filterCustomFieldValueContainer" class="hidden">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ _('Field Value') }}
</label>
<input type="text" id="filterCustomFieldValue" class="form-input"
placeholder="{{ _('e.g., MM, PB') }}">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
{{ _('Enter the value to filter by (e.g., salesman initial)') }}
</p>
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Preview Modal -->
<div id="previewModal" class="hidden fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-xl p-6 max-w-6xl w-full max-h-[90vh] flex flex-col">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold">{{ _('Report Preview') }}</h3>
<button onclick="closePreviewModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<div id="previewContent" class="flex-1 overflow-auto">
<div id="previewLoading" class="text-center py-8">
<i class="fas fa-spinner fa-spin text-2xl text-primary mb-2"></i>
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Loading preview...') }}</p>
</div>
<div id="previewError" class="hidden text-center py-8">
<i class="fas fa-exclamation-triangle text-2xl text-red-500 mb-2"></i>
<p class="text-red-600 dark:text-red-400" id="previewErrorMessage"></p>
</div>
<div id="previewData" class="hidden">
<!-- Preview content will be inserted here -->
</div>
</div>
</div>
</div>
<!-- Save Modal -->
<div id="saveModal" class="hidden fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-xl p-6 max-w-md w-full">
<h3 class="text-lg font-semibold mb-4">{{ _('Save Report') }}</h3>
<form id="saveForm">
<div class="mb-4">
<label for="reportName" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ _('Report Name') }} *</label>
<input type="text" id="reportName" required class="form-input" placeholder="{{ _('My Custom Report') }}" value="{% if saved_view %}{{ saved_view.name }}{% endif %}">
</div>
<div class="mb-4">
<label for="reportScope" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ _('Scope') }}</label>
<select id="reportScope" class="form-input">
<option value="private" {% if saved_view and saved_view.scope == 'private' %}selected{% endif %}>{{ _('Private') }}</option>
<option value="team" {% if saved_view and saved_view.scope == 'team' %}selected{% endif %}>{{ _('Team') }}</option>
<option value="public" {% if saved_view and saved_view.scope == 'public' %}selected{% endif %}>{{ _('Public') }}</option>
</select>
</div>
<!-- Iterative Report Generation -->
<div class="mb-4 pt-4 border-t border-border-light dark:border-border-dark">
<label class="flex items-center cursor-pointer mb-3">
<input type="checkbox" id="iterativeReportGeneration" class="form-checkbox mr-2" {% if saved_view and saved_view.iterative_report_generation %}checked{% endif %}>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
<i class="fas fa-sync-alt text-blue-500 mr-1"></i>
{{ _('Iterative Report Generation') }}
</span>
</label>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3 ml-6">
{{ _('Generate one report per custom field value (e.g., one report per salesman)') }}
</p>
<div id="iterativeFieldContainer" class="ml-6 {% if not saved_view or not saved_view.iterative_report_generation %}hidden{% endif %}">
<label for="iterativeCustomFieldName" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ _('Custom Field Name') }}</label>
<select id="iterativeCustomFieldName" class="form-input">
<option value="">{{ _('Select Field') }}</option>
{% for field_key in custom_field_keys %}
<option value="{{ field_key }}" {% if saved_view and saved_view.iterative_custom_field_name == field_key %}selected{% endif %}>{{ field_key|replace('_', ' ')|title }}</option>
{% endfor %}
</select>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
{{ _('Select the custom field to iterate over (e.g., "salesman")') }}
</p>
</div>
</div>
<div class="flex justify-end gap-4">
<button type="button" onclick="closeSaveModal()" class="px-4 py-2 border border-border-light dark:border-border-dark rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">{{ _('Cancel') }}</button>
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90">{{ _('Save') }}</button>
</div>
</form>
</div>
</div>
<script>
let reportConfig = {
data_source: null,
components: [],
filters: {}
};
{% if saved_view %}
// Load saved configuration when editing
(function() {
const savedConfig = {{ (config or {})|tojson }};
reportConfig = {
data_source: savedConfig.data_source || null,
components: savedConfig.components ? [...savedConfig.components] : [],
filters: savedConfig.filters ? {...savedConfig.filters} : {},
columns: savedConfig.columns || [],
grouping: savedConfig.grouping || {}
};
// Clear canvas first
const canvas = document.getElementById('reportCanvas');
canvas.innerHTML = '<p class="text-center text-text-muted-light dark:text-text-muted-dark py-8">{{ _("Drag data sources and components here to build your report") }}</p>';
// Load data source
if (reportConfig.data_source) {
addDataSourceToCanvas(reportConfig.data_source);
}
// Load components
if (reportConfig.components && Array.isArray(reportConfig.components)) {
reportConfig.components.forEach(component => {
addComponentToCanvas(component);
});
}
// Load filters
if (reportConfig.filters) {
if (reportConfig.filters.start_date) {
document.getElementById('filterStartDate').value = reportConfig.filters.start_date;
}
if (reportConfig.filters.end_date) {
document.getElementById('filterEndDate').value = reportConfig.filters.end_date;
}
if (reportConfig.filters.project_id) {
const projectSelect = document.getElementById('filterProject');
if (projectSelect) {
projectSelect.value = reportConfig.filters.project_id;
}
}
if (reportConfig.filters.unpaid_only) {
const unpaidCheckbox = document.getElementById('filterUnpaidOnly');
if (unpaidCheckbox) {
unpaidCheckbox.checked = true;
}
}
if (reportConfig.filters.custom_field_filter) {
const fieldName = Object.keys(reportConfig.filters.custom_field_filter)[0];
const fieldValue = reportConfig.filters.custom_field_filter[fieldName];
const fieldNameSelect = document.getElementById('filterCustomFieldName');
const fieldValueInput = document.getElementById('filterCustomFieldValue');
if (fieldNameSelect && fieldValueInput) {
fieldNameSelect.value = fieldName;
fieldValueInput.value = fieldValue;
document.getElementById('filterCustomFieldValueContainer')?.classList.remove('hidden');
fieldValueInput.required = true;
}
}
}
// Update filter visibility
updateFiltersVisibility();
})();
{% endif %}
// Drag and drop handlers
document.querySelectorAll('[draggable="true"]').forEach(item => {
item.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', JSON.stringify({
type: item.dataset.source ? 'source' : 'component',
id: item.dataset.source || item.dataset.component
}));
});
});
const canvas = document.getElementById('reportCanvas');
canvas.addEventListener('dragover', (e) => {
e.preventDefault();
canvas.classList.add('border-primary');
});
canvas.addEventListener('dragleave', () => {
canvas.classList.remove('border-primary');
});
canvas.addEventListener('drop', (e) => {
e.preventDefault();
canvas.classList.remove('border-primary');
const data = JSON.parse(e.dataTransfer.getData('text/plain'));
if (data.type === 'source') {
reportConfig.data_source = data.id;
addDataSourceToCanvas(data.id);
} else {
addComponentToCanvas(data.id);
}
});
function addDataSourceToCanvas(sourceId) {
// Remove existing data source if any
const existingSource = canvas.querySelector('[data-report-source]');
if (existingSource) {
existingSource.remove();
}
const sourceNames = {
'time_entries': '{{ _("Time Entries") }}',
'projects': '{{ _("Projects") }}',
'tasks': '{{ _("Tasks") }}',
'invoices': '{{ _("Invoices") }}',
'expenses': '{{ _("Expenses") }}'
};
const element = document.createElement('div');
element.className = 'p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg mb-4';
element.setAttribute('data-report-source', sourceId);
element.innerHTML = `
<div class="flex justify-between items-center">
<div>
<i class="fas fa-database mr-2"></i>
<strong>${sourceNames[sourceId] || sourceId}</strong>
</div>
<button type="button" onclick="removeDataSource()" class="text-red-600 hover:text-red-800">
<i class="fas fa-times"></i>
</button>
</div>
`;
canvas.querySelector('p')?.remove();
canvas.appendChild(element);
reportConfig.data_source = sourceId;
}
function removeDataSource() {
const sourceElement = canvas.querySelector('[data-report-source]');
if (sourceElement) {
sourceElement.remove();
reportConfig.data_source = null;
// Show empty state if canvas is empty
if (canvas.children.length === 0) {
canvas.innerHTML = '<p class="text-center text-text-muted-light dark:text-text-muted-dark py-8">{{ _("Drag data sources and components here to build your report") }}</p>';
}
}
}
function addComponentToCanvas(componentId) {
// Check if component already exists
const existingComponent = canvas.querySelector(`[data-report-component="${componentId}"]`);
if (existingComponent) {
return; // Don't add duplicates
}
const componentNames = {
'table': '{{ _("Table") }}',
'chart': '{{ _("Chart") }}',
'summary': '{{ _("Summary") }}'
};
const element = document.createElement('div');
element.className = 'p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg mb-4';
element.setAttribute('data-report-component', componentId);
element.innerHTML = `
<div class="flex justify-between items-center">
<div>
<i class="fas fa-${componentId === 'table' ? 'table' : componentId === 'chart' ? 'chart-line' : 'calculator'} mr-2"></i>
<strong>${componentNames[componentId] || componentId}</strong>
</div>
<button type="button" onclick="removeComponent('${componentId}')" class="text-red-600 hover:text-red-800">
<i class="fas fa-times"></i>
</button>
</div>
`;
canvas.querySelector('p')?.remove();
canvas.appendChild(element);
// Add to components array if not already present
if (!reportConfig.components.includes(componentId)) {
reportConfig.components.push(componentId);
}
}
function removeComponent(componentId) {
const componentElement = canvas.querySelector(`[data-report-component="${componentId}"]`);
if (componentElement) {
componentElement.remove();
// Remove from components array
const index = reportConfig.components.indexOf(componentId);
if (index > -1) {
reportConfig.components.splice(index, 1);
}
// Show empty state if canvas is empty
if (canvas.children.length === 0) {
canvas.innerHTML = '<p class="text-center text-text-muted-light dark:text-text-muted-dark py-8">{{ _("Drag data sources and components here to build your report") }}</p>';
}
}
}
function previewReport() {
// Check if data source is selected
if (!reportConfig.data_source) {
alert('{{ _("Please select a data source first") }}');
return;
}
// Collect current filters
const projectSelect = document.getElementById('filterProject');
const projectValue = projectSelect ? projectSelect.value : '';
reportConfig.filters = {
start_date: document.getElementById('filterStartDate').value || null,
end_date: document.getElementById('filterEndDate').value || null,
project_id: projectValue || null,
unpaid_only: document.getElementById('filterUnpaidOnly')?.checked || false
};
// Add custom field filter if set
const customFieldName = document.getElementById('filterCustomFieldName')?.value;
const customFieldValue = document.getElementById('filterCustomFieldValue')?.value;
if (customFieldName && customFieldValue) {
reportConfig.filters.custom_field_filter = {
[customFieldName]: customFieldValue
};
}
// Show preview modal
const modal = document.getElementById('previewModal');
const loading = document.getElementById('previewLoading');
const error = document.getElementById('previewError');
const errorMessage = document.getElementById('previewErrorMessage');
const data = document.getElementById('previewData');
modal.classList.remove('hidden');
loading.classList.remove('hidden');
error.classList.add('hidden');
data.classList.add('hidden');
// Debug: Log what we're sending
console.log('Preview report config:', reportConfig);
// Get CSRF token from meta tag
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
// Fetch preview data
fetch('{{ url_for("custom_reports.preview_report") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
credentials: 'same-origin',
body: JSON.stringify({
config: reportConfig
})
})
.then(async response => {
// Check if response is OK first
if (!response.ok) {
// Clone before reading error response
const errorResponseClone = response.clone();
// Try to get error message from response
let errorMsg = `HTTP ${response.status}: ${response.statusText}`;
try {
const errorData = await errorResponseClone.json();
errorMsg = errorData.message || errorData.error || errorMsg;
console.error('Preview error response:', errorData);
} catch (parseError) {
// If JSON parsing fails, try to get text from a new clone
try {
const textClone = response.clone();
const errorText = await textClone.text();
console.error('Preview error text:', errorText);
if (errorText) {
errorMsg = errorText;
}
} catch (textError) {
console.error('Could not parse error response:', textError);
}
}
throw new Error(errorMsg);
}
// Response is OK, parse JSON
const result = await response.json();
return result;
})
.then(result => {
loading.classList.add('hidden');
if (result.success && result.data) {
renderPreview(result.data);
data.classList.remove('hidden');
} else {
const msg = result.message || '{{ _("Failed to generate preview") }}';
errorMessage.textContent = msg;
error.classList.remove('hidden');
console.error('Preview failed:', result);
}
})
.catch(err => {
loading.classList.add('hidden');
const msg = err.message || '{{ _("Unknown error occurred") }}';
errorMessage.textContent = '{{ _("Error loading preview") }}: ' + msg;
error.classList.remove('hidden');
console.error('Preview error:', err);
});
}
function renderPreview(reportData) {
const previewData = document.getElementById('previewData');
const data = reportData.data || [];
const summary = reportData.summary || {};
let html = '';
// Render summary
if (Object.keys(summary).length > 0) {
html += '<div class="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">';
html += '<h4 class="font-semibold mb-2">{{ _("Summary") }}</h4>';
html += '<div class="grid grid-cols-2 md:grid-cols-4 gap-4">';
for (const [key, value] of Object.entries(summary)) {
html += `<div><span class="text-sm text-text-muted-light dark:text-text-muted-dark">${key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}:</span> <span class="font-semibold">${value}</span></div>`;
}
html += '</div></div>';
}
// Render data table
if (data.length > 0) {
html += '<div class="overflow-x-auto">';
html += '<table class="min-w-full divide-y divide-border-light dark:divide-border-dark">';
html += '<thead class="bg-gray-50 dark:bg-gray-800">';
html += '<tr>';
// Get column headers from first data item
const columns = Object.keys(data[0]);
columns.forEach(col => {
html += `<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">${col.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}</th>`;
});
html += '</tr></thead><tbody class="bg-white dark:bg-gray-900 divide-y divide-border-light dark:divide-border-dark">';
data.forEach(row => {
html += '<tr>';
columns.forEach(col => {
html += `<td class="px-6 py-4 whitespace-nowrap text-sm text-text-light dark:text-text-dark">${row[col] || ''}</td>`;
});
html += '</tr>';
});
html += '</tbody></table></div>';
} else {
html += '<div class="text-center py-8 text-text-muted-light dark:text-text-muted-dark">';
html += '<i class="fas fa-inbox text-4xl mb-2"></i>';
html += '<p>{{ _("No data found for the selected filters") }}</p>';
html += '</div>';
}
previewData.innerHTML = html;
}
function closePreviewModal() {
document.getElementById('previewModal').classList.add('hidden');
}
// Close preview modal on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const previewModal = document.getElementById('previewModal');
if (!previewModal.classList.contains('hidden')) {
closePreviewModal();
}
}
});
// Close preview modal when clicking outside (on the overlay)
document.getElementById('previewModal')?.addEventListener('click', (e) => {
// Only close if clicking directly on the modal overlay, not on child elements
if (e.target === e.currentTarget) {
closePreviewModal();
}
});
function saveReport() {
document.getElementById('saveModal').classList.remove('hidden');
}
function closeSaveModal() {
document.getElementById('saveModal').classList.add('hidden');
}
// Toggle iterative field container based on checkbox
document.getElementById('iterativeReportGeneration')?.addEventListener('change', function(e) {
const container = document.getElementById('iterativeFieldContainer');
if (e.target.checked) {
container.classList.remove('hidden');
} else {
container.classList.add('hidden');
}
});
document.getElementById('saveForm').addEventListener('submit', async (e) => {
e.preventDefault();
const name = document.getElementById('reportName').value.trim();
const scope = document.getElementById('reportScope').value;
// Validate name
if (!name) {
alert('{{ _("Please enter a report name") }}');
return;
}
// Validate data source is selected
if (!reportConfig.data_source) {
alert('{{ _("Please select a data source first") }}');
return;
}
// Collect filters
reportConfig.filters = {
start_date: document.getElementById('filterStartDate').value || null,
end_date: document.getElementById('filterEndDate').value || null,
project_id: document.getElementById('filterProject').value || null,
unpaid_only: document.getElementById('filterUnpaidOnly')?.checked || false
};
// Add custom field filter if set
const customFieldName = document.getElementById('filterCustomFieldName')?.value;
const customFieldValue = document.getElementById('filterCustomFieldValue')?.value;
if (customFieldName && customFieldValue) {
reportConfig.filters.custom_field_filter = {
[customFieldName]: customFieldValue
};
}
// Ensure columns and grouping exist
if (!reportConfig.columns) {
reportConfig.columns = [];
}
if (!reportConfig.grouping) {
reportConfig.grouping = {};
}
// Rebuild components array from DOM to ensure it matches what's visible
const componentElements = canvas.querySelectorAll('[data-report-component]');
reportConfig.components = Array.from(componentElements).map(el => el.getAttribute('data-report-component'));
// Ensure data source matches what's in the DOM
const sourceElement = canvas.querySelector('[data-report-source]');
if (sourceElement) {
reportConfig.data_source = sourceElement.getAttribute('data-report-source');
}
console.log('Saving report config:', reportConfig);
try {
// Get CSRF token from meta tag
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
const response = await fetch('{{ url_for("custom_reports.save_report_view") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken // Flask-WTF default header name for JSON requests
},
credentials: 'same-origin',
body: JSON.stringify({
name: name,
scope: scope,
config: reportConfig,
view_id: {% if saved_view %}{{ saved_view.id }}{% else %}null{% endif %},
iterative_report_generation: document.getElementById('iterativeReportGeneration')?.checked || false,
iterative_custom_field_name: document.getElementById('iterativeCustomFieldName')?.value || null
})
});
if (!response.ok) {
const errorText = await response.text();
try {
const errorJson = JSON.parse(errorText);
throw new Error(errorJson.message || errorJson.error || `HTTP ${response.status}: ${response.statusText}`);
} catch {
throw new Error(`HTTP ${response.status}: ${response.statusText}. ${errorText}`);
}
}
const result = await response.json();
if (result.success) {
// Show toast notification
const message = result.action === 'created'
? '{{ _("Report created successfully!") }}'
: '{{ _("Report updated successfully!") }}';
if (window.toastManager) {
window.toastManager.success(message, '{{ _("Success") }}', 3000);
} else if (window.showToast) {
window.showToast(message, 'success');
} else {
alert(message);
}
closeSaveModal();
// If editing, stay on edit page; if creating, reload to show in list
{% if saved_view %}
// Editing - reload to show updated config
window.location.reload();
{% else %}
// Creating - redirect to saved views list
window.location.href = '{{ url_for("custom_reports.list_saved_views") }}';
{% endif %}
} else {
const errorMsg = result.message || '{{ _("Failed to save report") }}';
if (window.toastManager) {
window.toastManager.error(errorMsg, '{{ _("Error") }}', 5000);
} else if (window.showToast) {
window.showToast(errorMsg, 'error');
} else {
alert(errorMsg);
}
}
} catch (error) {
console.error('Error saving report:', error);
const errorMsg = '{{ _("Error saving report") }}: ' + error.message;
if (window.toastManager) {
window.toastManager.error(errorMsg, '{{ _("Error") }}', 5000);
} else if (window.showToast) {
window.showToast(errorMsg, 'error');
} else {
alert(errorMsg);
}
}
});
// Show/hide custom field value input based on field selection
document.getElementById('filterCustomFieldName')?.addEventListener('change', function() {
const valueContainer = document.getElementById('filterCustomFieldValueContainer');
const valueInput = document.getElementById('filterCustomFieldValue');
if (this.value) {
valueContainer.classList.remove('hidden');
valueInput.required = true;
} else {
valueContainer.classList.add('hidden');
valueInput.required = false;
valueInput.value = '';
}
});
// Show time entries filters only when time_entries is selected
function updateFiltersVisibility() {
const timeEntriesFilters = document.getElementById('timeEntriesFilters');
if (reportConfig.data_source === 'time_entries') {
timeEntriesFilters?.classList.remove('hidden');
} else {
timeEntriesFilters?.classList.add('hidden');
}
}
// Update filter visibility when data source changes
const originalAddDataSource = addDataSourceToCanvas;
addDataSourceToCanvas = function(sourceId) {
originalAddDataSource(sourceId);
updateFiltersVisibility();
};
</script>
{% endblock %}