mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-06 11:40:52 -06:00
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
794 lines
34 KiB
HTML
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 %}
|
|
|