diff --git a/app.py b/app.py index 06c9e41..9265d21 100644 --- a/app.py +++ b/app.py @@ -3518,7 +3518,7 @@ def export_configuration(): @app.route('/generate-excel-export', methods=['POST']) @admin_required def generate_excel_export(): - """Generate and download Excel file with selected columns""" + """Generate and download Excel file with selected columns in specified order""" try: print("📊 Excel export generation started") @@ -3529,8 +3529,31 @@ def generate_excel_export(): print(f"⚠️ Logging error (non-critical): {log_error}") # Get selected columns and custom names from form - selected_columns = request.form.getlist('selected_columns') - print(f"📊 Selected columns: {selected_columns}") + selected_columns_raw = request.form.getlist('selected_columns') + print(f"📊 Selected columns (raw): {selected_columns_raw}") + + # Get column order from form + column_order_json = request.form.get('column_order', '[]') + try: + column_order = json.loads(column_order_json) if column_order_json else [] + except (json.JSONDecodeError, TypeError): + column_order = [] + + print(f"📊 Column order from form: {column_order}") + + # Determine final column order + if column_order: + # Use the specified order, but only include actually selected columns + selected_columns = [col for col in column_order if col in selected_columns_raw] + # Add any selected columns that weren't in the order (shouldn't happen, but safety check) + for col in selected_columns_raw: + if col not in selected_columns: + selected_columns.append(col) + else: + # Fallback to raw selection order + selected_columns = selected_columns_raw + + print(f"📊 Final column order: {selected_columns}") if not selected_columns: flash('Please select at least one column to export.', 'error') @@ -3549,15 +3572,17 @@ def generate_excel_export(): } print(f"📊 Export filters: {filters}") + print(f"📊 Column names: {column_names}") # Save user preferences in session for next time session['export_preferences'] = { 'selected_columns': selected_columns, - 'column_names': column_names + 'column_names': column_names, + 'column_order': selected_columns # This is now the ordered list } - # Generate Excel file - excel_file = create_excel_export(selected_columns, column_names, filters) + # Generate Excel file with ordered columns + excel_file = create_excel_export_ordered(selected_columns, column_names, filters) if excel_file: # Generate filename with timestamp @@ -3565,10 +3590,11 @@ def generate_excel_export(): filename = f'attendance_report_{timestamp}.xlsx' print(f"📊 Excel file generated successfully: {filename}") + print(f"📊 Column order in export: {selected_columns}") # Log successful export using your existing logger try: - logger_handler.logger.info(f"Excel export generated successfully with {len(selected_columns)} columns by user {session.get('username', 'unknown')}") + logger_handler.logger.info(f"Excel export generated successfully with {len(selected_columns)} columns in custom order by user {session.get('username', 'unknown')}") except Exception as log_error: print(f"⚠️ Logging error (non-critical): {log_error}") @@ -3757,7 +3783,163 @@ def create_excel_export(selected_columns, column_names, filters): import traceback print(f"❌ Traceback: {traceback.format_exc()}") return None - + +def create_excel_export_ordered(selected_columns, column_names, filters): + """Create Excel file with selected attendance data in specified column order""" + try: + print(f"📊 Creating Excel export with {len(selected_columns)} columns in order: {selected_columns}") + + # Import openpyxl modules + try: + from openpyxl import Workbook + from openpyxl.styles import Font, Alignment, PatternFill + except ImportError as e: + print(f"❌ openpyxl import error: {e}") + print("💡 Install openpyxl: pip install openpyxl") + return None + + # Build query based on filters - JOIN with QRCode to get location_event and location_address + query = db.session.query(AttendanceData, QRCode).join(QRCode, AttendanceData.qr_code_id == QRCode.id) + + # Apply date filters + if filters.get('date_from'): + try: + date_from = datetime.strptime(filters['date_from'], '%Y-%m-%d').date() + query = query.filter(AttendanceData.check_in_date >= date_from) + print(f"📊 Applied date_from filter: {date_from}") + except ValueError as e: + print(f"⚠️ Invalid date_from format: {e}") + + if filters.get('date_to'): + try: + date_to = datetime.strptime(filters['date_to'], '%Y-%m-%d').date() + query = query.filter(AttendanceData.check_in_date <= date_to) + print(f"📊 Applied date_to filter: {date_to}") + except ValueError as e: + print(f"⚠️ Invalid date_to format: {e}") + + # Apply location filter + if filters.get('location_filter'): + query = query.filter(AttendanceData.location_name.ilike(f"%{filters['location_filter']}%")) + print(f"📊 Applied location filter: {filters['location_filter']}") + + # Apply employee filter + if filters.get('employee_filter'): + query = query.filter(AttendanceData.employee_id.ilike(f"%{filters['employee_filter']}%")) + print(f"📊 Applied employee filter: {filters['employee_filter']}") + + # Execute query and get results + results = query.order_by(AttendanceData.check_in_date.desc(), AttendanceData.check_in_time.desc()).all() + print(f"📊 Found {len(results)} records for export") + + if not results: + print("⚠️ No records found for export") + return None + + # Create workbook + wb = Workbook() + ws = wb.active + ws.title = "Attendance Report" + + # Define header style + header_font = Font(bold=True, color="FFFFFF") + header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid") + header_alignment = Alignment(horizontal="center", vertical="center") + + # Add headers in the specified order + for idx, column_key in enumerate(selected_columns, 1): + cell = ws.cell(row=1, column=idx) + cell.value = column_names.get(column_key, column_key) + cell.font = header_font + cell.fill = header_fill + cell.alignment = header_alignment + print(f"📊 Column {idx}: {column_key} -> '{cell.value}'") + + # Add data in the same column order + for row_idx, (attendance_record, qr_code) in enumerate(results, 2): + for col_idx, column_key in enumerate(selected_columns, 1): + cell = ws.cell(row=row_idx, column=col_idx) + + # Get value based on column key (same logic as before) + try: + if column_key == 'employee_id': + cell.value = attendance_record.employee_id + elif column_key == 'location_name': + cell.value = attendance_record.location_name + elif column_key == 'status': + # Use location_event from QR code instead of status + cell.value = qr_code.location_event if qr_code.location_event else 'Check In' + elif column_key == 'check_in_date': + cell.value = attendance_record.check_in_date.strftime('%Y-%m-%d') if attendance_record.check_in_date else '' + elif column_key == 'check_in_time': + cell.value = attendance_record.check_in_time.strftime('%H:%M:%S') if attendance_record.check_in_time else '' + elif column_key == 'qr_address': + # Use QR Code address (location_address), not location + cell.value = qr_code.location_address if qr_code and qr_code.location_address else '' + elif column_key == 'address': + # Check-in address logic based on location accuracy + if hasattr(attendance_record, 'location_accuracy') and attendance_record.location_accuracy is not None: + try: + accuracy_value = float(attendance_record.location_accuracy) + if accuracy_value <= 0.5: + # High accuracy - use QR code ADDRESS (not location) + cell.value = qr_code.location_address if qr_code and qr_code.location_address else '' + else: + # Lower accuracy - use actual check-in address + cell.value = attendance_record.address or '' + except (ValueError, TypeError): + # If accuracy can't be converted to float, use check-in address + cell.value = attendance_record.address or '' + else: + # No location accuracy data - use actual check-in address + cell.value = attendance_record.address or '' + elif column_key == 'device_info': + cell.value = attendance_record.device_info or '' + elif column_key == 'ip_address': + cell.value = attendance_record.ip_address or '' + elif column_key == 'user_agent': + cell.value = attendance_record.user_agent or '' + elif column_key == 'latitude': + cell.value = attendance_record.latitude or '' + elif column_key == 'longitude': + cell.value = attendance_record.longitude or '' + elif column_key == 'accuracy': + cell.value = attendance_record.accuracy or '' + elif column_key == 'location_accuracy': + cell.value = attendance_record.location_accuracy or '' + else: + cell.value = '' + except Exception as cell_error: + print(f"⚠️ Error setting cell value for {column_key}: {cell_error}") + cell.value = '' + + # Auto-adjust column widths + for column in ws.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = min(max_length + 2, 50) + ws.column_dimensions[column_letter].width = adjusted_width + + # Save to BytesIO + excel_buffer = io.BytesIO() + wb.save(excel_buffer) + excel_buffer.seek(0) + + print("📊 Excel file created successfully with custom column order") + return excel_buffer + + except Exception as e: + print(f"❌ Error creating Excel export: {e}") + import traceback + print(f"❌ Traceback: {traceback.format_exc()}") + return None + # Jinja2 filters for better template functionality @app.template_filter('days_since') def days_since_filter(date): diff --git a/static/css/export_configuration.css b/static/css/export_configuration.css index 5bb7793..2b24266 100644 --- a/static/css/export_configuration.css +++ b/static/css/export_configuration.css @@ -446,4 +446,301 @@ .submit-actions { width: 100%; } +} + +/* Instructions Card */ +.instructions-card { + background: linear-gradient(135deg, #e3f2fd, #f3e5f5); + border: 2px solid #4fc3f7; + border-radius: 12px; + padding: 1.5rem; + margin-bottom: 2rem; + box-shadow: 0 4px 12px rgba(79, 195, 247, 0.15); +} + +.instructions-content h4 { + color: #1565c0; + font-size: 1.2rem; + font-weight: 600; + margin-bottom: 1rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.instructions-content ul { + list-style: none; + padding: 0; + margin: 0; +} + +.instructions-content li { + padding: 0.5rem 0; + color: #424242; + font-size: 0.95rem; + display: flex; + align-items: flex-start; + gap: 0.75rem; +} + +.instructions-content li::before { + content: "✓"; + color: #4caf50; + font-weight: bold; + font-size: 1.1rem; + margin-top: 0.1rem; +} + +.instructions-content .highlight { + background: linear-gradient(135deg, #ffeb3b, #ffc107); + padding: 0.2rem 0.5rem; + border-radius: 4px; + font-weight: 600; + color: #f57c00; +} + +/* Columns Section */ +.columns-section { + margin-bottom: 2rem; +} + +.columns-section h4 { + color: #2d3748; + font-size: 1.4rem; + font-weight: 600; + margin-bottom: 1.5rem; + display: flex; + align-items: center; + gap: 0.75rem; +} + +/* Selected Columns Section */ +.selected-columns-section { + background: linear-gradient(135deg, #f8f9fa, #e9ecef); + border: 2px dashed #6c757d; + border-radius: 12px; + padding: 2rem; + margin-top: 2rem; + transition: all 0.3s ease; +} + +.selected-columns-section.has-columns { + border-color: #28a745; + border-style: solid; + background: linear-gradient(135deg, #f8fff8, #e8f5e8); +} + +.selected-columns-section h4 { + color: #495057; + font-size: 1.3rem; + font-weight: 600; + margin-bottom: 1.5rem; + display: flex; + align-items: center; + gap: 0.75rem; +} + +.drag-hint { + font-size: 0.85rem; + color: #6c757d; + font-weight: normal; + font-style: italic; +} + +/* Selected Columns List */ +.selected-columns-list { + display: flex; + flex-direction: column; + gap: 0.75rem; + min-height: 60px; +} + +.selected-column-item { + background: white; + border: 2px solid #e2e8f0; + border-radius: 8px; + padding: 1rem; + cursor: move; + transition: all 0.3s ease; + display: flex; + align-items: center; + justify-content: space-between; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.selected-column-item:hover { + border-color: #4299e1; + transform: translateY(-2px); + box-shadow: 0 4px 16px rgba(66, 153, 225, 0.15); +} + +.selected-column-item.sortable-drag { + opacity: 0.8; + transform: rotate(5deg); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); + z-index: 1000; +} + +.selected-column-item.sortable-ghost { + opacity: 0.4; + background: #f1f5f9; +} + +.selected-column-item.sortable-chosen { + border-color: #3b82f6; + background: #eff6ff; +} + +.selected-column-info { + display: flex; + align-items: center; + gap: 1rem; + flex: 1; +} + +.column-drag-handle { + color: #9ca3af; + font-size: 1.2rem; + cursor: grab; + padding: 0.5rem; + border-radius: 4px; + transition: all 0.2s ease; +} + +.column-drag-handle:hover { + color: #4b5563; + background: #f3f4f6; +} + +.column-drag-handle:active { + cursor: grabbing; +} + +.selected-column-details { + flex: 1; +} + +.selected-column-name { + font-weight: 600; + color: #1f2937; + font-size: 1rem; + margin-bottom: 0.25rem; +} + +.selected-column-export-name { + font-size: 0.85rem; + color: #6b7280; + font-style: italic; +} + +.column-order-number { + background: linear-gradient(135deg, #4f46e5, #7c3aed); + color: white; + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 0.9rem; + box-shadow: 0 2px 8px rgba(79, 70, 229, 0.3); +} + +/* Empty state for selected columns */ +.selected-columns-empty { + text-align: center; + padding: 2rem; + color: #6b7280; + font-style: italic; +} + +.selected-columns-empty i { + font-size: 2rem; + margin-bottom: 1rem; + color: #9ca3af; +} + +/* Enhanced column items in grid */ +.column-item.selected { + border-color: #10b981; + background: linear-gradient(135deg, #f0fdf4, #ecfdf5); + transform: translateY(-2px); + box-shadow: 0 4px 16px rgba(16, 185, 129, 0.15); +} + +.column-item.selected .column-checkbox label { + background: linear-gradient(135deg, #10b981, #059669); + color: white; +} + +/* Responsive design for drag & drop */ +@media (max-width: 768px) { + .selected-columns-section { + padding: 1rem; + } + + .selected-column-item { + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; + } + + .selected-column-info { + width: 100%; + flex-direction: column; + align-items: flex-start; + } + + .column-order-number { + align-self: flex-end; + } + + .drag-hint { + display: none; + } +} + +/* Animation for smooth transitions */ +.selected-columns-list .selected-column-item { + animation: slideInUp 0.3s ease-out; +} + +@keyframes slideInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Sortable.js additional styles */ +.sortable-fallback { + display: none; +} + +/* Enhanced preview table to show column order */ +.preview-table thead th { + position: relative; +} + +.preview-table thead th::before { + content: attr(data-order); + position: absolute; + top: -8px; + right: -8px; + background: #4f46e5; + color: white; + font-size: 0.7rem; + font-weight: 600; + width: 18px; + height: 18px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); } \ No newline at end of file diff --git a/static/js/export_configuration.js b/static/js/export_configuration.js index 5c552ba..df38f6b 100644 --- a/static/js/export_configuration.js +++ b/static/js/export_configuration.js @@ -1,54 +1,264 @@ +/** + * Enhanced Export Configuration JavaScript with Drag & Drop + * Handles column selection, preview updates, drag & drop reordering, and preference management + */ + +// Global variables +let availableColumns = []; +let sortableInstance = null; +let savedColumnOrder = []; + +// Initialize when DOM is loaded document.addEventListener('DOMContentLoaded', function() { - console.log('Simple Export Configuration loaded'); + console.log('Enhanced Export Configuration with Drag & Drop initialized'); - // Setup basic event listeners - setupBasicListeners(); + // Initialize available columns data + initializeColumnsData(); - // Initial preview update - setTimeout(updatePreview, 100); + // Set up event listeners + setupEventListeners(); + + // Load saved preferences if available + loadSavedPreferences(); + + // Update preview on load + updatePreview(); + + // Initialize drag & drop + initializeDragDrop(); }); -function setupBasicListeners() { - // Add listeners to checkboxes - const checkboxes = document.querySelectorAll('input[name="selected_columns"]'); - checkboxes.forEach(function(checkbox) { - checkbox.addEventListener('change', function() { - toggleColumnName(this.value); - setTimeout(updatePreview, 50); +function initializeColumnsData() { + try { + // Extract column data from the form + const checkboxes = document.querySelectorAll('input[name="selected_columns"]'); + availableColumns = Array.from(checkboxes).map(cb => { + const columnKey = cb.value; + const label = cb.parentElement.querySelector('label').textContent.trim(); + const nameInput = document.getElementById('name_' + columnKey); + + return { + key: columnKey, + label: label, + defaultName: nameInput ? nameInput.value : label, + enabled: cb.checked + }; }); - }); - - // Add listeners to name inputs - const nameInputs = document.querySelectorAll('input[id^="name_"]'); - nameInputs.forEach(function(input) { - input.addEventListener('input', function() { - setTimeout(updatePreview, 300); + + console.log('Initialized columns data:', availableColumns); + } catch (error) { + console.error('Error initializing columns data:', error); + } +} + +function setupEventListeners() { + try { + // Add change listeners to all column checkboxes + const checkboxes = document.querySelectorAll('input[name="selected_columns"]'); + checkboxes.forEach(checkbox => { + checkbox.addEventListener('change', function() { + toggleColumnName(this.value); + updateSelectedColumnsList(); + updatePreview(); + + // Add visual feedback + const columnItem = this.closest('.column-item'); + if (this.checked) { + columnItem.classList.add('selected'); + } else { + columnItem.classList.remove('selected'); + } + }); }); - }); - - // Form validation - const form = document.getElementById('exportForm'); - if (form) { - form.addEventListener('submit', function(e) { - const selected = document.querySelectorAll('input[name="selected_columns"]:checked'); - if (selected.length === 0) { - e.preventDefault(); - alert('Please select at least one column to export.'); - } + + // Add input listeners to all column name inputs + const nameInputs = document.querySelectorAll('input[id^="name_"]'); + nameInputs.forEach(input => { + input.addEventListener('input', debounce(() => { + updateSelectedColumnsList(); + updatePreview(); + }, 300)); }); + + // Form validation before submit + const form = document.getElementById('exportForm'); + if (form) { + form.addEventListener('submit', function(e) { + if (!validateForm()) { + e.preventDefault(); + } + }); + } + } catch (error) { + console.error('Error setting up event listeners:', error); } } function toggleColumnName(columnKey) { - const checkbox = document.getElementById('col_' + columnKey); - const nameGroup = document.getElementById('name_group_' + columnKey); - - if (checkbox && nameGroup) { - if (checkbox.checked) { - nameGroup.style.display = 'block'; - } else { - nameGroup.style.display = 'none'; + try { + const checkbox = document.getElementById('col_' + columnKey); + const nameGroup = document.getElementById('name_group_' + columnKey); + + if (checkbox && nameGroup) { + if (checkbox.checked) { + nameGroup.style.display = 'block'; + nameGroup.style.opacity = '0'; + setTimeout(() => { + nameGroup.style.opacity = '1'; + }, 10); + } else { + nameGroup.style.opacity = '0'; + setTimeout(() => { + nameGroup.style.display = 'none'; + }, 300); + } } + } catch (error) { + console.error('Error toggling column name:', error); + } +} + +function initializeDragDrop() { + try { + const selectedColumnsList = document.getElementById('selectedColumnsList'); + if (selectedColumnsList) { + sortableInstance = Sortable.create(selectedColumnsList, { + animation: 200, + ghostClass: 'sortable-ghost', + chosenClass: 'sortable-chosen', + dragClass: 'sortable-drag', + handle: '.column-drag-handle', + onStart: function(evt) { + console.log('Drag started:', evt.oldIndex); + }, + onEnd: function(evt) { + console.log('Drag ended:', evt.oldIndex, '->', evt.newIndex); + updateColumnOrderNumbers(); + updatePreview(); + + // Save the new order + savePreferences(); + } + }); + + console.log('Drag & drop initialized successfully'); + } + } catch (error) { + console.error('Error initializing drag & drop:', error); + } +} + +function updateSelectedColumnsList() { + try { + const selectedColumnsList = document.getElementById('selectedColumnsList'); + const selectedColumnsSection = document.getElementById('selectedColumnsSection'); + + if (!selectedColumnsList || !selectedColumnsSection) return; + + // Get currently selected columns + const selectedColumns = Array.from(document.querySelectorAll('input[name="selected_columns"]:checked')); + + if (selectedColumns.length === 0) { + selectedColumnsSection.style.display = 'none'; + return; + } + + selectedColumnsSection.style.display = 'block'; + selectedColumnsSection.classList.add('has-columns'); + + // Get current order if exists, otherwise use selection order + let orderedColumns = []; + if (savedColumnOrder.length > 0) { + // Use saved order, but only include currently selected columns + orderedColumns = savedColumnOrder.filter(key => + selectedColumns.some(cb => cb.value === key) + ); + // Add any newly selected columns that weren't in saved order + selectedColumns.forEach(cb => { + if (!orderedColumns.includes(cb.value)) { + orderedColumns.push(cb.value); + } + }); + } else { + orderedColumns = selectedColumns.map(cb => cb.value); + } + + // Build the selected columns list HTML + let listHTML = ''; + orderedColumns.forEach((columnKey, index) => { + const nameInput = document.getElementById('name_' + columnKey); + const columnData = availableColumns.find(col => col.key === columnKey); + const customName = nameInput ? nameInput.value : (columnData ? columnData.label : columnKey); + + listHTML += ` +
Select columns above to see them here for reordering
+Customize your attendance report export with selected columns and custom names
+Customize your attendance report export with selected columns, custom names, and column ordering
- Your column preferences will be saved for next time + Your column preferences and order will be saved for next time