diff --git a/app.py b/app.py index d6fa6f4..a60a0fe 100644 --- a/app.py +++ b/app.py @@ -1,4 +1,4 @@ -from flask import Flask, render_template, request, redirect, url_for, flash, session, jsonify, send_file +from flask import Flask, render_template, request, redirect, url_for, flash, session, jsonify, send_file, Response from flask_sqlalchemy import SQLAlchemy from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.utils import secure_filename @@ -6541,7 +6541,7 @@ def time_attendance_dashboard(): return redirect(url_for('dashboard')) @app.route('/time-attendance/import', methods=['GET', 'POST']) -@admin_required +@login_required @log_database_operations('time_attendance_import') def import_time_attendance(): """Enhanced import with duplicate review""" @@ -6678,7 +6678,7 @@ def import_time_attendance(): @app.route('/time-attendance/import/analyze-duplicates', methods=['POST']) -@admin_required +@login_required def analyze_import_duplicates(): """AJAX endpoint to analyze file for duplicates""" try: @@ -6744,7 +6744,7 @@ def analyze_import_duplicates(): @app.route('/time-attendance/import/cancel-pending') -@admin_required +@login_required def cancel_pending_import(): """Cancel pending import and cleanup temp file""" try: @@ -6766,7 +6766,7 @@ def cancel_pending_import(): @app.route('/time-attendance/import/validate', methods=['POST']) -@admin_required +@login_required def validate_import_file(): """AJAX endpoint to validate Excel file before import""" try: @@ -6967,6 +6967,322 @@ def download_import_template(): logger_handler.logger.error(f"Error generating template: {e}") flash('Error generating template file.', 'error') return redirect(url_for('import_time_attendance')) + +@app.route('/time-attendance/export') +@login_required +@log_user_activity('time_attendance_export') +def export_time_attendance(): + """Export time attendance records to CSV or Excel""" + try: + # Get export format (default to CSV) + export_format = request.args.get('format', 'csv').lower() + + # Get filter parameters (same as records page) + employee_filter = request.args.get('employee_id') + location_filter = request.args.get('location_name') + start_date = request.args.get('start_date') + end_date = request.args.get('end_date') + import_batch = request.args.get('import_batch') + + # Build query with same filters as the view + from models.time_attendance import TimeAttendance + query = TimeAttendance.query + + # Apply filters + if employee_filter: + query = query.filter(TimeAttendance.employee_id == employee_filter) + + if location_filter: + query = query.filter(TimeAttendance.location_name == location_filter) + + if start_date: + try: + start_date_obj = datetime.strptime(start_date, '%Y-%m-%d').date() + query = query.filter(TimeAttendance.attendance_date >= start_date_obj) + except ValueError: + flash('Invalid start date format.', 'error') + return redirect(url_for('time_attendance_records')) + + if end_date: + try: + end_date_obj = datetime.strptime(end_date, '%Y-%m-%d').date() + query = query.filter(TimeAttendance.attendance_date <= end_date_obj) + except ValueError: + flash('Invalid end date format.', 'error') + return redirect(url_for('time_attendance_records')) + + if import_batch: + query = query.filter(TimeAttendance.import_batch_id == import_batch) + + # Order by date and time (most recent first) + records = query.order_by( + TimeAttendance.attendance_date.desc(), + TimeAttendance.attendance_time.desc() + ).all() + + if not records: + flash('No records found to export.', 'warning') + return redirect(url_for('time_attendance_records')) + + # Log export + logger_handler.logger.info( + f"User {session['username']} exported {len(records)} time attendance records " + f"in {export_format.upper()} format" + ) + + # Generate filename + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + filter_desc = [] + if employee_filter: + filter_desc.append(f"emp_{employee_filter}") + if location_filter: + filter_desc.append(f"loc_{location_filter[:10]}") + if start_date: + filter_desc.append(f"from_{start_date}") + if end_date: + filter_desc.append(f"to_{end_date}") + + filter_str = "_".join(filter_desc) if filter_desc else "all" + + # Export based on format + if export_format == 'excel' or export_format == 'xlsx': + return export_time_attendance_excel(records, timestamp, filter_str) + else: + return export_time_attendance_csv(records, timestamp, filter_str) + + except Exception as e: + logger_handler.logger.error(f"Error exporting time attendance records: {e}") + flash('Error generating export file. Please try again.', 'error') + return redirect(url_for('time_attendance_records')) + + +def export_time_attendance_csv(records, timestamp, filter_str): + """Generate CSV export of time attendance records""" + import csv + import io + + # Create CSV in memory + output = io.StringIO() + writer = csv.writer(output) + + # Write header + writer.writerow([ + 'ID', + 'Employee ID', + 'Employee Name', + 'Platform', + 'Date', + 'Time', + 'Location Name', + 'Action Description', + 'Event Description', + 'Recorded Address', + 'Import Batch ID', + 'Import Date', + 'Import Source', + 'Created Date' + ]) + + # Write data rows + for record in records: + writer.writerow([ + record.id, + record.employee_id, + record.employee_name, + record.platform or '', + record.attendance_date.strftime('%Y-%m-%d') if record.attendance_date else '', + record.attendance_time.strftime('%H:%M:%S') if record.attendance_time else '', + record.location_name, + record.action_description, + record.event_description or '', + record.recorded_address or '', + record.import_batch_id or '', + record.import_date.strftime('%Y-%m-%d %H:%M:%S') if record.import_date else '', + record.import_source or '', + record.created_date.strftime('%Y-%m-%d %H:%M:%S') if record.created_date else '' + ]) + + # Prepare response + output.seek(0) + filename = f'time_attendance_{filter_str}_{timestamp}.csv' + + return Response( + output.getvalue(), + mimetype='text/csv', + headers={ + 'Content-Disposition': f'attachment; filename={filename}' + } + ) + +def export_time_attendance_excel(records, timestamp, filter_str): + """Generate Excel export of time attendance records""" + import io + from openpyxl import Workbook + from openpyxl.styles import Font, PatternFill, Alignment, Border, Side + from openpyxl.utils import get_column_letter + + # Create workbook + wb = Workbook() + ws = wb.active + ws.title = "Time Attendance Records" + + # Define styles + header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid") + header_font = Font(bold=True, color="FFFFFF", size=11) + border = Border( + left=Side(style='thin'), + right=Side(style='thin'), + top=Side(style='thin'), + bottom=Side(style='thin') + ) + + # Headers + headers = [ + 'ID', 'Employee ID', 'Employee Name', 'Platform', 'Date', 'Time', + 'Location Name', 'Action Description', 'Event Description', + 'Recorded Address', 'Import Batch ID', 'Import Date', 'Import Source' + ] + + # Write headers + for col_num, header in enumerate(headers, 1): + cell = ws.cell(row=1, column=col_num) + cell.value = header + cell.fill = header_fill + cell.font = header_font + cell.alignment = Alignment(horizontal='center', vertical='center') + cell.border = border + + # Write data + for row_num, record in enumerate(records, 2): + data = [ + record.id, + record.employee_id, + record.employee_name, + record.platform or '', + record.attendance_date.strftime('%Y-%m-%d') if record.attendance_date else '', + record.attendance_time.strftime('%H:%M:%S') if record.attendance_time else '', + record.location_name, + record.action_description, + record.event_description or '', + record.recorded_address or '', + record.import_batch_id or '', + record.import_date.strftime('%Y-%m-%d %H:%M:%S') if record.import_date else '', + record.import_source or '' + ] + + for col_num, value in enumerate(data, 1): + cell = ws.cell(row=row_num, column=col_num) + cell.value = value + cell.border = border + + # Special formatting for specific columns + if col_num in [5, 6]: # Date and Time columns + cell.alignment = Alignment(horizontal='center') + elif col_num in [1, 2]: # ID columns + cell.alignment = Alignment(horizontal='right') + + # Adjust column widths + column_widths = { + 'A': 8, # ID + 'B': 12, # Employee ID + 'C': 20, # Employee Name + 'D': 15, # Platform + 'E': 12, # Date + 'F': 10, # Time + 'G': 20, # Location Name + 'H': 18, # Action Description + 'I': 25, # Event Description + 'J': 30, # Recorded Address + 'K': 15, # Import Batch ID + 'L': 18, # Import Date + 'M': 25 # Import Source + } + + for col, width in column_widths.items(): + ws.column_dimensions[col].width = width + + # Freeze header row + ws.freeze_panes = 'A2' + + # Add summary sheet + ws_summary = wb.create_sheet("Summary") + + # Summary statistics + total_records = len(records) + unique_employees = len(set(r.employee_id for r in records)) + unique_locations = len(set(r.location_name for r in records)) + date_range_start = min(r.attendance_date for r in records if r.attendance_date) + date_range_end = max(r.attendance_date for r in records if r.attendance_date) + + # Action breakdown + action_counts = {} + for record in records: + action = record.action_description + action_counts[action] = action_counts.get(action, 0) + 1 + + # Write summary + summary_data = [ + ['Time Attendance Export Summary'], + [''], + ['Export Date:', datetime.now().strftime('%Y-%m-%d %H:%M:%S')], + ['Exported By:', session.get('username', 'Unknown')], + [''], + ['Statistics'], + ['Total Records:', total_records], + ['Unique Employees:', unique_employees], + ['Unique Locations:', unique_locations], + ['Date Range:', f"{date_range_start.strftime('%Y-%m-%d')} to {date_range_end.strftime('%Y-%m-%d')}"], + [''], + ['Actions Breakdown'] + ] + + for action, count in sorted(action_counts.items()): + summary_data.append([action, count]) + + for row_num, row_data in enumerate(summary_data, 1): + for col_num, value in enumerate(row_data, 1): + cell = ws_summary.cell(row=row_num, column=col_num) + cell.value = value + + # Style the title + if row_num == 1: + cell.font = Font(bold=True, size=14) + elif row_num in [6, 12]: # Section headers + cell.font = Font(bold=True, size=11) + + # Adjust summary column widths + ws_summary.column_dimensions['A'].width = 25 + ws_summary.column_dimensions['B'].width = 15 + + # Save to bytes + output = io.BytesIO() + wb.save(output) + output.seek(0) + + filename = f'time_attendance_{filter_str}_{timestamp}.xlsx' + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=filename + ) + +@app.route('/time-attendance/export/quick-csv') +@login_required +@log_user_activity('time_attendance_quick_csv_export') +def quick_csv_export_time_attendance(): + """Quick CSV export with current page filters""" + # Redirect to main export with CSV format + return redirect(url_for('export_time_attendance', format='csv', **request.args)) + +@app.route('/time-attendance/export/excel') +@login_required +@log_user_activity('time_attendance_excel_export') +def excel_export_time_attendance(): + """Excel export with current page filters""" + # Redirect to main export with Excel format + return redirect(url_for('export_time_attendance', format='excel', **request.args)) @app.route('/time-attendance/records') @login_required diff --git a/templates/time_attendance_import.html b/templates/time_attendance_import.html index e4f1eb2..9d371f2 100644 --- a/templates/time_attendance_import.html +++ b/templates/time_attendance_import.html @@ -111,6 +111,105 @@ @keyframes spin { to { transform: rotate(360deg); } } + +/* Fixed styles for instructions section */ +.instructions-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; +} + +.instruction-item { + text-align: center; + padding: 1.5rem; + background: #f8fafc; + border-radius: 0.75rem; + border: 1px solid #e2e8f0; +} + +.instruction-icon { + width: 60px; + height: 60px; + margin: 0 auto 1rem; + background: linear-gradient(135deg, #8b5cf6, #7c3aed); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: #ffffff; + font-size: 1.5rem; +} + +.instruction-item h3 { + font-size: 1.125rem; + font-weight: 600; + color: #0f172a; + margin-bottom: 0.5rem; +} + +.instruction-item p { + color: #64748b; + font-size: 0.875rem; +} + +.sample-format { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 0.75rem; + padding: 1.5rem; + margin-top: 2rem; +} + +.sample-format h4 { + font-size: 1.125rem; + font-weight: 600; + color: #0f172a; + margin-bottom: 1rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.format-table { + overflow-x: auto; + border-radius: 0.5rem; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); +} + +.format-table table { + width: 100%; + border-collapse: collapse; + background: white; + font-size: 0.875rem; +} + +.format-table thead { + background: linear-gradient(135deg, #4299e1, #3182ce); + color: white; +} + +.format-table th { + padding: 0.75rem 1rem; + text-align: left; + font-weight: 600; + border-bottom: 2px solid #2b6cb0; + white-space: nowrap; +} + +.format-table td { + padding: 0.75rem 1rem; + border-bottom: 1px solid #e2e8f0; + color: #4a5568; +} + +.format-table tbody tr:last-child td { + border-bottom: none; +} + +.format-table tbody tr:hover { + background: #f7fafc; +} {% endblock %} @@ -277,14 +376,6 @@ Import Options
| ID | Employee | -Date & Time | -Action | +Date | +Time | Location | +Action | Platform | -Address | +Import Source | Actions | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| {{ record.id }} |
-
-
-
- {{ record.employee_name[0].upper() if record.employee_name else record.employee_id[0].upper() }}
-
-
-
- {{ record.employee_name }}
- ID: {{ record.employee_id }}
- |
-
-
- {{ record.attendance_date.strftime('%Y-%m-%d') }}
- {{ record.attendance_time.strftime('%H:%M:%S') }}
+
+ {{ record.employee_name }}
+ ID: {{ record.employee_id }}
|
+ {{ record.attendance_date.strftime('%Y-%m-%d') if record.attendance_date else 'N/A' }} | +{{ record.attendance_time.strftime('%H:%M:%S') if record.attendance_time else 'N/A' }} | +{{ record.location_name }} | - - {% if record.action_description.lower() == 'check in' %} - - {% elif record.action_description.lower() == 'check out' %} - - {% else %} - - {% endif %} + {{ record.action_description }} | -
-
-
- {{ record.location_name }}
+ {{ record.platform or 'N/A' }} |
+
+ |
-
+ {{ record.import_source or 'Unknown' }}
+ {{ record.import_date.strftime('%Y-%m-%d') if record.import_date else '' }}
- {{ record.platform or 'Unknown' }}
- |
-
- {% if record.recorded_address %}
-
- {{ record.recorded_address[:30] }}{% if record.recorded_address|length > 30 %}...{% endif %}
-
- {% else %}
- No address
- {% endif %}
- |
-
- | |
- {% if current_filters.employee_id or current_filters.location_name or current_filters.start_date or current_filters.end_date %} - No attendance records match your current filter criteria. - Try adjusting your filters or clearing them to see all records. - {% else %} - No time attendance records have been imported yet. - Import your first Excel file to get started. - {% endif %} -
+ {% else %} + ++ {% if current_filters.employee_id or current_filters.location_name or current_filters.start_date or current_filters.end_date %} + No attendance records match your current filter criteria. + Try adjusting your filters or clearing them to see all records. + {% else %} + No time attendance records have been imported yet. + Import your first Excel file to get started. + {% endif %} +
+