Updated Time Attendance functionality
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
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
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
from werkzeug.security import generate_password_hash, check_password_hash
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from datetime import datetime, date, time, timedelta
|
from datetime import datetime, date, time, timedelta
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
@@ -15,6 +16,7 @@ from logger_handler import AppLogger, log_user_activity, log_database_operations
|
|||||||
from single_checkin_calculator import SingleCheckInCalculator
|
from single_checkin_calculator import SingleCheckInCalculator
|
||||||
from payroll_excel_exporter import PayrollExcelExporter
|
from payroll_excel_exporter import PayrollExcelExporter
|
||||||
from enhanced_payroll_excel_exporter import EnhancedPayrollExcelExporter
|
from enhanced_payroll_excel_exporter import EnhancedPayrollExcelExporter
|
||||||
|
from time_attendance_import_service import TimeAttendanceImportService
|
||||||
|
|
||||||
# Load environment variables in .env
|
# Load environment variables in .env
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
@@ -6542,7 +6544,7 @@ def time_attendance_dashboard():
|
|||||||
@admin_required
|
@admin_required
|
||||||
@log_database_operations('time_attendance_import')
|
@log_database_operations('time_attendance_import')
|
||||||
def import_time_attendance():
|
def import_time_attendance():
|
||||||
"""Import time attendance data from Excel file"""
|
"""Enhanced import time attendance data from Excel file"""
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
try:
|
try:
|
||||||
# Check if file is uploaded
|
# Check if file is uploaded
|
||||||
@@ -6564,12 +6566,19 @@ def import_time_attendance():
|
|||||||
filename = secure_filename(file.filename)
|
filename = secure_filename(file.filename)
|
||||||
temp_path = os.path.join(app.config.get('UPLOAD_FOLDER', '/tmp'),
|
temp_path = os.path.join(app.config.get('UPLOAD_FOLDER', '/tmp'),
|
||||||
f"temp_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{filename}")
|
f"temp_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{filename}")
|
||||||
|
|
||||||
|
# Ensure upload directory exists
|
||||||
|
os.makedirs(os.path.dirname(temp_path), exist_ok=True)
|
||||||
file.save(temp_path)
|
file.save(temp_path)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Initialize import service
|
# Initialize enhanced import service
|
||||||
import_service = TimeAttendanceImportService(db, logger_handler)
|
import_service = TimeAttendanceImportService(db, logger_handler)
|
||||||
|
|
||||||
|
# Get import options
|
||||||
|
skip_duplicates = request.form.get('skip_duplicates', 'true').lower() == 'true'
|
||||||
|
validate_only = request.form.get('validate_only', 'false').lower() == 'true'
|
||||||
|
|
||||||
# Validate file first
|
# Validate file first
|
||||||
validation_result = import_service.validate_excel_file(temp_path)
|
validation_result = import_service.validate_excel_file(temp_path)
|
||||||
|
|
||||||
@@ -6578,12 +6587,24 @@ def import_time_attendance():
|
|||||||
return render_template('time_attendance_import.html',
|
return render_template('time_attendance_import.html',
|
||||||
validation_result=validation_result)
|
validation_result=validation_result)
|
||||||
|
|
||||||
|
# Show warnings if any
|
||||||
|
if validation_result['warnings']:
|
||||||
|
for warning in validation_result['warnings']:
|
||||||
|
flash(warning, 'warning')
|
||||||
|
|
||||||
|
# If validate only, return validation results
|
||||||
|
if validate_only:
|
||||||
|
flash(f"File validation successful! Found {validation_result['valid_rows']} valid records.", 'success')
|
||||||
|
return render_template('time_attendance_import.html',
|
||||||
|
validation_result=validation_result)
|
||||||
|
|
||||||
# Proceed with import
|
# Proceed with import
|
||||||
import_source = request.form.get('import_source', f"Manual Import - {filename}")
|
import_source = request.form.get('import_source', f"Manual Import - {filename}")
|
||||||
import_result = import_service.import_from_excel(
|
import_result = import_service.import_from_excel(
|
||||||
temp_path,
|
temp_path,
|
||||||
created_by=session['user_id'],
|
created_by=session['user_id'],
|
||||||
import_source=import_source
|
import_source=import_source,
|
||||||
|
skip_duplicates=skip_duplicates
|
||||||
)
|
)
|
||||||
|
|
||||||
if import_result['success']:
|
if import_result['success']:
|
||||||
@@ -6591,12 +6612,17 @@ def import_time_attendance():
|
|||||||
logger_handler.logger.info(
|
logger_handler.logger.info(
|
||||||
f"User {session['username']} successfully imported time attendance data - "
|
f"User {session['username']} successfully imported time attendance data - "
|
||||||
f"Batch: {import_result['batch_id']}, "
|
f"Batch: {import_result['batch_id']}, "
|
||||||
f"Records: {import_result['imported_records']}/{import_result['total_records']}"
|
f"Records: {import_result['imported_records']}/{import_result['total_records']}, "
|
||||||
|
f"Duplicates: {import_result['duplicate_records']}, "
|
||||||
|
f"Failed: {import_result['failed_records']}"
|
||||||
)
|
)
|
||||||
|
|
||||||
flash(f"Import successful! Imported {import_result['imported_records']} records "
|
flash(f"Import successful! Imported {import_result['imported_records']} records "
|
||||||
f"out of {import_result['total_records']} total records.", 'success')
|
f"out of {import_result['total_records']} total records.", 'success')
|
||||||
|
|
||||||
|
if import_result['duplicate_records'] > 0:
|
||||||
|
flash(f"Skipped {import_result['duplicate_records']} duplicate records.", 'info')
|
||||||
|
|
||||||
if import_result['failed_records'] > 0:
|
if import_result['failed_records'] > 0:
|
||||||
flash(f"Note: {import_result['failed_records']} records failed to import. "
|
flash(f"Note: {import_result['failed_records']} records failed to import. "
|
||||||
f"Check the error details below.", 'warning')
|
f"Check the error details below.", 'warning')
|
||||||
@@ -6604,14 +6630,19 @@ def import_time_attendance():
|
|||||||
return render_template('time_attendance_import_result.html',
|
return render_template('time_attendance_import_result.html',
|
||||||
import_result=import_result)
|
import_result=import_result)
|
||||||
else:
|
else:
|
||||||
flash(f"Import failed: {'; '.join(import_result['errors'])}", 'error')
|
flash(f"Import failed: {'; '.join(import_result['errors'][:3])}", 'error')
|
||||||
|
if len(import_result['errors']) > 3:
|
||||||
|
flash(f"...and {len(import_result['errors']) - 3} more errors", 'warning')
|
||||||
return render_template('time_attendance_import.html',
|
return render_template('time_attendance_import.html',
|
||||||
import_result=import_result)
|
import_result=import_result)
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# Clean up temporary file
|
# Clean up temporary file
|
||||||
if os.path.exists(temp_path):
|
if os.path.exists(temp_path):
|
||||||
os.remove(temp_path)
|
try:
|
||||||
|
os.remove(temp_path)
|
||||||
|
except Exception as cleanup_error:
|
||||||
|
logger_handler.logger.warning(f"Failed to cleanup temp file: {cleanup_error}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger_handler.log_database_error('time_attendance_import', e)
|
logger_handler.log_database_error('time_attendance_import', e)
|
||||||
@@ -6620,6 +6651,209 @@ def import_time_attendance():
|
|||||||
|
|
||||||
return render_template('time_attendance_import.html')
|
return render_template('time_attendance_import.html')
|
||||||
|
|
||||||
|
@app.route('/time-attendance/import/validate', methods=['POST'])
|
||||||
|
@admin_required
|
||||||
|
def validate_import_file():
|
||||||
|
"""AJAX endpoint to validate Excel file before import"""
|
||||||
|
try:
|
||||||
|
if 'file' not in request.files:
|
||||||
|
return jsonify({'success': False, 'message': 'No file provided'}), 400
|
||||||
|
|
||||||
|
file = request.files['file']
|
||||||
|
if file.filename == '':
|
||||||
|
return jsonify({'success': False, 'message': 'No file selected'}), 400
|
||||||
|
|
||||||
|
# Validate file extension
|
||||||
|
if not file.filename.lower().endswith(('.xlsx', '.xls')):
|
||||||
|
return jsonify({'success': False, 'message': 'Invalid file format'}), 400
|
||||||
|
|
||||||
|
# Save temporarily
|
||||||
|
filename = secure_filename(file.filename)
|
||||||
|
temp_path = os.path.join(app.config.get('UPLOAD_FOLDER', '/tmp'),
|
||||||
|
f"validate_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{filename}")
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(temp_path), exist_ok=True)
|
||||||
|
file.save(temp_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Validate file
|
||||||
|
import_service = TimeAttendanceImportService(db, logger_handler)
|
||||||
|
validation_result = import_service.validate_excel_file(temp_path)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'validation': validation_result
|
||||||
|
})
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Cleanup
|
||||||
|
if os.path.exists(temp_path):
|
||||||
|
os.remove(temp_path)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger_handler.logger.error(f"Validation error: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': f'Validation failed: {str(e)}'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
@app.route('/time-attendance/import/batch/<batch_id>')
|
||||||
|
@login_required
|
||||||
|
@log_user_activity('view_import_batch')
|
||||||
|
def view_import_batch(batch_id):
|
||||||
|
"""View details of a specific import batch"""
|
||||||
|
try:
|
||||||
|
import_service = TimeAttendanceImportService(db, logger_handler)
|
||||||
|
batch_summary = import_service.get_import_summary(batch_id)
|
||||||
|
|
||||||
|
if not batch_summary:
|
||||||
|
flash('Import batch not found.', 'error')
|
||||||
|
return redirect(url_for('time_attendance_dashboard'))
|
||||||
|
|
||||||
|
return render_template('time_attendance_batch_detail.html',
|
||||||
|
batch_summary=batch_summary)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger_handler.logger.error(f"Error viewing batch {batch_id}: {e}")
|
||||||
|
flash('Error loading batch details.', 'error')
|
||||||
|
return redirect(url_for('time_attendance_dashboard'))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/time-attendance/import/batch/<batch_id>/delete', methods=['POST'])
|
||||||
|
@admin_required
|
||||||
|
@log_database_operations('delete_import_batch')
|
||||||
|
def delete_import_batch(batch_id):
|
||||||
|
"""Delete an entire import batch"""
|
||||||
|
try:
|
||||||
|
import_service = TimeAttendanceImportService(db, logger_handler)
|
||||||
|
result = import_service.delete_import_batch(batch_id, deleted_by=session['user_id'])
|
||||||
|
|
||||||
|
if result['success']:
|
||||||
|
flash(result['message'], 'success')
|
||||||
|
logger_handler.logger.info(
|
||||||
|
f"User {session['username']} deleted import batch {batch_id} - "
|
||||||
|
f"{result['deleted_count']} records removed"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
flash(result['message'], 'error')
|
||||||
|
|
||||||
|
return redirect(url_for('time_attendance_dashboard'))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger_handler.logger.error(f"Error deleting batch {batch_id}: {e}")
|
||||||
|
flash('Error deleting import batch.', 'error')
|
||||||
|
return redirect(url_for('time_attendance_dashboard'))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/time-attendance/import/download-template')
|
||||||
|
@login_required
|
||||||
|
def download_import_template():
|
||||||
|
"""Download Excel template for time attendance import"""
|
||||||
|
try:
|
||||||
|
import io
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from openpyxl.styles import Font, PatternFill, Alignment
|
||||||
|
from flask import send_file
|
||||||
|
|
||||||
|
# Create workbook
|
||||||
|
wb = Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "Time Attendance Template"
|
||||||
|
|
||||||
|
# Define headers
|
||||||
|
headers = ['ID', 'Name', 'Platform', 'Date', 'Time', 'Location Name',
|
||||||
|
'Action Description', 'Event Description', 'Recorded Address']
|
||||||
|
|
||||||
|
# Style headers
|
||||||
|
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
|
||||||
|
header_font = Font(bold=True, color="FFFFFF")
|
||||||
|
|
||||||
|
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')
|
||||||
|
|
||||||
|
# Add sample data rows
|
||||||
|
sample_data = [
|
||||||
|
['12345', 'John Doe', 'iPhone - iOS', '2025-10-06', '09:00:00',
|
||||||
|
'HQ Suite 210', 'Check In', 'Morning Entry', '123 Main Street'],
|
||||||
|
['12345', 'John Doe', 'iPhone - iOS', '2025-10-06', '17:30:00',
|
||||||
|
'HQ Suite 210', 'Check Out', 'Evening Exit', '123 Main Street'],
|
||||||
|
['67890', 'Jane Smith', 'Android', '2025-10-06', '08:45:00',
|
||||||
|
'Branch Office', 'Check In', 'Morning Entry', '456 Oak Avenue'],
|
||||||
|
]
|
||||||
|
|
||||||
|
for row_num, row_data in enumerate(sample_data, 2):
|
||||||
|
for col_num, value in enumerate(row_data, 1):
|
||||||
|
ws.cell(row=row_num, column=col_num, value=value)
|
||||||
|
|
||||||
|
# Adjust column widths
|
||||||
|
for col in ws.columns:
|
||||||
|
max_length = 0
|
||||||
|
col_letter = col[0].column_letter
|
||||||
|
for cell in col:
|
||||||
|
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[col_letter].width = adjusted_width
|
||||||
|
|
||||||
|
# Add instructions sheet
|
||||||
|
ws_instructions = wb.create_sheet("Instructions")
|
||||||
|
instructions = [
|
||||||
|
["Time Attendance Import Template - Instructions"],
|
||||||
|
[""],
|
||||||
|
["Required Columns:"],
|
||||||
|
["- ID: Employee ID (required)"],
|
||||||
|
["- Name: Employee full name (required)"],
|
||||||
|
["- Date: Attendance date in YYYY-MM-DD format (required)"],
|
||||||
|
["- Time: Attendance time in HH:MM:SS format (required)"],
|
||||||
|
["- Location Name: Location where attendance was recorded (required)"],
|
||||||
|
["- Action Description: Type of action (e.g., Check In, Check Out) (required)"],
|
||||||
|
[""],
|
||||||
|
["Optional Columns:"],
|
||||||
|
["- Platform: Device platform (e.g., iPhone - iOS, Android)"],
|
||||||
|
["- Event Description: Additional event details"],
|
||||||
|
["- Recorded Address: Physical address where attendance was recorded"],
|
||||||
|
[""],
|
||||||
|
["Important Notes:"],
|
||||||
|
["- Do not modify the header row"],
|
||||||
|
["- Ensure all required fields have values"],
|
||||||
|
["- Date format must be YYYY-MM-DD (e.g., 2025-10-06)"],
|
||||||
|
["- Time format must be HH:MM:SS (e.g., 09:00:00)"],
|
||||||
|
["- Remove the sample data rows before importing your actual data"],
|
||||||
|
["- Duplicate records will be automatically detected and skipped"],
|
||||||
|
]
|
||||||
|
|
||||||
|
for row_num, instruction in enumerate(instructions, 1):
|
||||||
|
ws_instructions.cell(row=row_num, column=1, value=instruction[0])
|
||||||
|
|
||||||
|
ws_instructions.column_dimensions['A'].width = 80
|
||||||
|
|
||||||
|
# Save to bytes
|
||||||
|
output = io.BytesIO()
|
||||||
|
wb.save(output)
|
||||||
|
output.seek(0)
|
||||||
|
|
||||||
|
# Log download
|
||||||
|
logger_handler.logger.info(f"User {session['username']} downloaded import template")
|
||||||
|
|
||||||
|
return send_file(
|
||||||
|
output,
|
||||||
|
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=f'time_attendance_template_{datetime.now().strftime("%Y%m%d")}.xlsx'
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
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/records')
|
@app.route('/time-attendance/records')
|
||||||
@login_required
|
@login_required
|
||||||
@log_user_activity('time_attendance_records_view')
|
@log_user_activity('time_attendance_records_view')
|
||||||
|
|||||||
@@ -554,6 +554,34 @@ body.has-sidebar .time-attendance-page {
|
|||||||
background: #fecaca;
|
background: #fecaca;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Enhanced action buttons */
|
||||||
|
.action-btn.btn-info {
|
||||||
|
background: #4299e1;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.btn-info:hover {
|
||||||
|
background: #3182ce;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.btn-delete {
|
||||||
|
background: #f56565;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.btn-delete:hover {
|
||||||
|
background: #e53e3e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress indicator animation */
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
/* Pagination */
|
/* Pagination */
|
||||||
.pagination-section {
|
.pagination-section {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
{% extends "base_authenticated.html" %}
|
||||||
|
{% block title %}Import Batch Details - {{ COMPANY_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/time_attendance.css') }}">
|
||||||
|
<style>
|
||||||
|
.batch-detail-page {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card {
|
||||||
|
background: white;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card .value {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #4299e1;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card .label {
|
||||||
|
color: #718096;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section {
|
||||||
|
background: white;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section h3 {
|
||||||
|
color: #2d3748;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-breakdown {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #f7fafc;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-list {
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block page_title %}Import Batch Details{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="batch-detail-page">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="time-attendance-header">
|
||||||
|
<div class="header-navigation">
|
||||||
|
<a href="{{ url_for('time_attendance_dashboard') }}" class="back-button">
|
||||||
|
<i class="fas fa-arrow-left"></i>
|
||||||
|
Back to Dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header-content">
|
||||||
|
<h1>
|
||||||
|
<i class="fas fa-database"></i>
|
||||||
|
Import Batch Details
|
||||||
|
</h1>
|
||||||
|
<p class="header-description">
|
||||||
|
Batch ID: <code>{{ batch_summary.batch_id }}</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header-actions">
|
||||||
|
<a href="{{ url_for('time_attendance_records', import_batch=batch_summary.batch_id) }}"
|
||||||
|
class="btn btn-primary">
|
||||||
|
<i class="fas fa-eye"></i>
|
||||||
|
View All Records
|
||||||
|
</a>
|
||||||
|
{% if session.role == 'admin' %}
|
||||||
|
<button onclick="deleteBatch()" class="btn btn-danger">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
Delete Batch
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary Cards -->
|
||||||
|
<div class="summary-grid">
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="value">{{ batch_summary.total_records }}</div>
|
||||||
|
<div class="label">Total Records</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="value">{{ batch_summary.unique_employees }}</div>
|
||||||
|
<div class="label">Unique Employees</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="value">{{ batch_summary.unique_locations }}</div>
|
||||||
|
<div class="label">Locations</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="value">{{ batch_summary.date_range.start.strftime('%Y-%m-%d') }}</div>
|
||||||
|
<div class="label">Start Date</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="value">{{ batch_summary.date_range.end.strftime('%Y-%m-%d') }}</div>
|
||||||
|
<div class="label">End Date</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Import Information -->
|
||||||
|
<div class="detail-section">
|
||||||
|
<h3>
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
Import Information
|
||||||
|
</h3>
|
||||||
|
<p><strong>Import Date:</strong> {{ batch_summary.import_date.strftime('%Y-%m-%d %H:%M:%S') if batch_summary.import_date else 'Unknown' }}</p>
|
||||||
|
<p><strong>Import Source:</strong> {{ batch_summary.import_source or 'Not specified' }}</p>
|
||||||
|
<p><strong>Batch ID:</strong> <code>{{ batch_summary.batch_id }}</code></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Breakdown -->
|
||||||
|
<div class="detail-section">
|
||||||
|
<h3>
|
||||||
|
<i class="fas fa-chart-bar"></i>
|
||||||
|
Actions Breakdown
|
||||||
|
</h3>
|
||||||
|
<div class="action-breakdown">
|
||||||
|
{% for action, count in batch_summary.actions.items() %}
|
||||||
|
<div class="action-item">
|
||||||
|
<span><strong>{{ action }}</strong></span>
|
||||||
|
<span>{{ count }} records</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Employee Summary -->
|
||||||
|
<div class="detail-section">
|
||||||
|
<h3>
|
||||||
|
<i class="fas fa-users"></i>
|
||||||
|
Employee Summary
|
||||||
|
</h3>
|
||||||
|
<div class="employee-list">
|
||||||
|
{% for emp_id, emp_data in batch_summary.employee_summary.items() %}
|
||||||
|
<div class="employee-item">
|
||||||
|
<span><strong>{{ emp_data.name }}</strong> (ID: {{ emp_id }})</span>
|
||||||
|
<span>{{ emp_data.count }} records</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function deleteBatch() {
|
||||||
|
if (confirm('Are you sure you want to delete this entire import batch? This will remove all {{ batch_summary.total_records }} records. This action cannot be undone.')) {
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.method = 'POST';
|
||||||
|
form.action = '/time-attendance/import/batch/{{ batch_summary.batch_id }}/delete';
|
||||||
|
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -257,9 +257,19 @@
|
|||||||
<td>
|
<td>
|
||||||
<div class="record-actions">
|
<div class="record-actions">
|
||||||
<a href="{{ url_for('time_attendance_records', import_batch=import_batch.import_batch_id) }}"
|
<a href="{{ url_for('time_attendance_records', import_batch=import_batch.import_batch_id) }}"
|
||||||
class="action-btn btn-view" title="View Records">
|
class="action-btn btn-view" title="View Records">
|
||||||
<i class="fas fa-eye"></i>
|
<i class="fas fa-eye"></i>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="{{ url_for('view_import_batch', batch_id=import_batch.import_batch_id) }}"
|
||||||
|
class="action-btn btn-info" title="Batch Details">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
</a>
|
||||||
|
{% if session.role == 'admin' %}
|
||||||
|
<button onclick="deleteBatch('{{ import_batch.import_batch_id }}')"
|
||||||
|
class="action-btn btn-delete" title="Delete Batch">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -430,5 +440,25 @@ function deleteRecord(recordId, employeeName, date) {
|
|||||||
form.submit();
|
form.submit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deleteBatch(batchId) {
|
||||||
|
if (confirm('Are you sure you want to delete this entire import batch? This action cannot be undone.')) {
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.method = 'POST';
|
||||||
|
form.action = `/time-attendance/import/batch/${batchId}/delete`;
|
||||||
|
|
||||||
|
const csrfToken = document.querySelector('meta[name="csrf-token"]');
|
||||||
|
if (csrfToken) {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'hidden';
|
||||||
|
input.name = 'csrf_token';
|
||||||
|
input.value = csrfToken.content;
|
||||||
|
form.appendChild(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -3,6 +3,115 @@
|
|||||||
|
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/time_attendance.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/time_attendance.css') }}">
|
||||||
|
<style>
|
||||||
|
.validation-preview {
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 2px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-stat {
|
||||||
|
background: white;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-stat .number {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #2d3748;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-stat .label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #718096;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-stat.success .number { color: #48bb78; }
|
||||||
|
.validation-stat.warning .number { color: #ed8936; }
|
||||||
|
.validation-stat.error .number { color: #f56565; }
|
||||||
|
|
||||||
|
.import-options {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-item input[type="checkbox"] {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label strong {
|
||||||
|
display: block;
|
||||||
|
color: #2d3748;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label span {
|
||||||
|
color: #718096;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-indicator {
|
||||||
|
display: none;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #ebf8ff;
|
||||||
|
border: 1px solid #90cdf4;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-indicator.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 3px solid #e2e8f0;
|
||||||
|
border-top-color: #4299e1;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block page_title %}Import Time Attendance{% endblock %}
|
{% block page_title %}Import Time Attendance{% endblock %}
|
||||||
@@ -24,9 +133,16 @@
|
|||||||
Import Time Attendance Data
|
Import Time Attendance Data
|
||||||
</h1>
|
</h1>
|
||||||
<p class="header-description">
|
<p class="header-description">
|
||||||
Upload Excel files containing employee time attendance records
|
Upload Excel files containing employee time attendance records with enhanced validation
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="header-actions">
|
||||||
|
<a href="{{ url_for('download_import_template') }}" class="btn btn-secondary">
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
Download Template
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Import Instructions -->
|
<!-- Import Instructions -->
|
||||||
@@ -59,8 +175,16 @@
|
|||||||
<div class="instruction-icon">
|
<div class="instruction-icon">
|
||||||
<i class="fas fa-shield-alt"></i>
|
<i class="fas fa-shield-alt"></i>
|
||||||
</div>
|
</div>
|
||||||
<h3>Data Validation</h3>
|
<h3>Smart Validation</h3>
|
||||||
<p>Automatic validation and error reporting for invalid data</p>
|
<p>Automatic duplicate detection and data validation</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="instruction-item">
|
||||||
|
<div class="instruction-icon">
|
||||||
|
<i class="fas fa-copy"></i>
|
||||||
|
</div>
|
||||||
|
<h3>Duplicate Handling</h3>
|
||||||
|
<p>Automatically skip duplicate records during import</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -89,7 +213,7 @@
|
|||||||
<td>12345</td>
|
<td>12345</td>
|
||||||
<td>John Doe</td>
|
<td>John Doe</td>
|
||||||
<td>iPhone - iOS</td>
|
<td>iPhone - iOS</td>
|
||||||
<td>2025-09-12</td>
|
<td>2025-10-06</td>
|
||||||
<td>09:00:00</td>
|
<td>09:00:00</td>
|
||||||
<td>HQ Suite 210</td>
|
<td>HQ Suite 210</td>
|
||||||
<td>Check In</td>
|
<td>Check In</td>
|
||||||
@@ -100,7 +224,7 @@
|
|||||||
<td>67890</td>
|
<td>67890</td>
|
||||||
<td>Jane Smith</td>
|
<td>Jane Smith</td>
|
||||||
<td>Android</td>
|
<td>Android</td>
|
||||||
<td>2025-09-12</td>
|
<td>2025-10-06</td>
|
||||||
<td>17:30:00</td>
|
<td>17:30:00</td>
|
||||||
<td>HQ Suite 210</td>
|
<td>HQ Suite 210</td>
|
||||||
<td>Check Out</td>
|
<td>Check Out</td>
|
||||||
@@ -147,6 +271,31 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Import Options -->
|
<!-- Import Options -->
|
||||||
|
<div class="import-options">
|
||||||
|
<h3>
|
||||||
|
<i class="fas fa-cog"></i>
|
||||||
|
Import Options
|
||||||
|
</h3>
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<div class="checkbox-item">
|
||||||
|
<input type="checkbox" id="skip_duplicates" name="skip_duplicates" value="true" checked>
|
||||||
|
<label for="skip_duplicates" class="checkbox-label">
|
||||||
|
<strong>Skip Duplicate Records</strong>
|
||||||
|
<span>Automatically detect and skip records that already exist in the system</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="checkbox-item">
|
||||||
|
<input type="checkbox" id="validate_only" name="validate_only" value="true">
|
||||||
|
<label for="validate_only" class="checkbox-label">
|
||||||
|
<strong>Validate Only (Don't Import)</strong>
|
||||||
|
<span>Check file for errors without actually importing the data</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Import Source -->
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="import_source" class="form-label">
|
<label for="import_source" class="form-label">
|
||||||
@@ -157,12 +306,18 @@
|
|||||||
id="import_source"
|
id="import_source"
|
||||||
name="import_source"
|
name="import_source"
|
||||||
class="form-input"
|
class="form-input"
|
||||||
placeholder="e.g., Monthly Attendance Report - September 2025"
|
placeholder="e.g., Monthly Attendance Report - October 2025"
|
||||||
value="Excel Import - {{ current_date }}">
|
value="Excel Import - {{ current_date }}">
|
||||||
<small class="form-help">Optional description for this import batch</small>
|
<small class="form-help">Optional description for this import batch</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress Indicator -->
|
||||||
|
<div id="progressIndicator" class="progress-indicator">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p style="margin-top: 0.5rem;">Processing your file, please wait...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Form Actions -->
|
<!-- Form Actions -->
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="button" class="btn btn-outline" onclick="resetForm()">
|
<button type="button" class="btn btn-outline" onclick="resetForm()">
|
||||||
@@ -172,17 +327,13 @@
|
|||||||
<button type="submit" class="btn btn-primary" id="submitBtn" disabled>
|
<button type="submit" class="btn btn-primary" id="submitBtn" disabled>
|
||||||
<i class="fas fa-upload"></i>
|
<i class="fas fa-upload"></i>
|
||||||
<span class="btn-text">Import Data</span>
|
<span class="btn-text">Import Data</span>
|
||||||
<span class="btn-loading" style="display: none;">
|
|
||||||
<i class="fas fa-spinner fa-spin"></i>
|
|
||||||
Processing...
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Validation Results (if any) -->
|
<!-- Validation Results -->
|
||||||
{% if validation_result %}
|
{% if validation_result %}
|
||||||
<div class="import-results {% if validation_result.valid %}success{% else %}error{% endif %}">
|
<div class="import-results {% if validation_result.valid %}success{% else %}error{% endif %}">
|
||||||
<div class="results-header">
|
<div class="results-header">
|
||||||
@@ -197,29 +348,31 @@
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="results-body">
|
<div class="results-body">
|
||||||
<div class="results-summary">
|
<div class="validation-stats">
|
||||||
<div class="summary-item">
|
<div class="validation-stat">
|
||||||
<div class="number">{{ validation_result.total_rows }}</div>
|
<div class="number">{{ validation_result.total_rows }}</div>
|
||||||
<div class="label">Total Rows</div>
|
<div class="label">Total Rows</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-item">
|
<div class="validation-stat success">
|
||||||
|
<div class="number">{{ validation_result.valid_rows }}</div>
|
||||||
|
<div class="label">Valid Rows</div>
|
||||||
|
</div>
|
||||||
|
{% if validation_result.invalid_rows > 0 %}
|
||||||
|
<div class="validation-stat warning">
|
||||||
|
<div class="number">{{ validation_result.invalid_rows }}</div>
|
||||||
|
<div class="label">Invalid Rows</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="validation-stat">
|
||||||
<div class="number">{{ validation_result.columns|length }}</div>
|
<div class="number">{{ validation_result.columns|length }}</div>
|
||||||
<div class="label">Columns Found</div>
|
<div class="label">Columns Found</div>
|
||||||
</div>
|
</div>
|
||||||
{% if validation_result.errors %}
|
|
||||||
<div class="summary-item error">
|
|
||||||
<div class="number">{{ validation_result.errors|length }}</div>
|
|
||||||
<div class="label">Errors</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if validation_result.warnings %}
|
|
||||||
<div class="summary-item">
|
|
||||||
<div class="number">{{ validation_result.warnings|length }}</div>
|
|
||||||
<div class="label">Warnings</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if validation_result.file_info %}
|
||||||
|
<p><strong>File Size:</strong> {{ validation_result.file_info.size_mb }} MB</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if validation_result.errors %}
|
{% if validation_result.errors %}
|
||||||
<div class="error-list">
|
<div class="error-list">
|
||||||
<h4>
|
<h4>
|
||||||
@@ -252,7 +405,7 @@
|
|||||||
<div class="sample-data">
|
<div class="sample-data">
|
||||||
<h4>
|
<h4>
|
||||||
<i class="fas fa-eye"></i>
|
<i class="fas fa-eye"></i>
|
||||||
Sample Data Preview
|
Sample Data Preview (First 3 Rows)
|
||||||
</h4>
|
</h4>
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<table class="preview-table">
|
<table class="preview-table">
|
||||||
@@ -280,7 +433,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Import Results (if any) -->
|
<!-- Import Results -->
|
||||||
{% if import_result %}
|
{% if import_result %}
|
||||||
<div class="import-results {% if import_result.success %}success{% else %}error{% endif %}">
|
<div class="import-results {% if import_result.success %}success{% else %}error{% endif %}">
|
||||||
<div class="results-header">
|
<div class="results-header">
|
||||||
@@ -295,17 +448,23 @@
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="results-body">
|
<div class="results-body">
|
||||||
<div class="results-summary">
|
<div class="validation-stats">
|
||||||
<div class="summary-item">
|
<div class="validation-stat">
|
||||||
<div class="number">{{ import_result.total_records }}</div>
|
<div class="number">{{ import_result.total_records }}</div>
|
||||||
<div class="label">Total Records</div>
|
<div class="label">Total Records</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-item success">
|
<div class="validation-stat success">
|
||||||
<div class="number">{{ import_result.imported_records }}</div>
|
<div class="number">{{ import_result.imported_records }}</div>
|
||||||
<div class="label">Successfully Imported</div>
|
<div class="label">Successfully Imported</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% if import_result.duplicate_records > 0 %}
|
||||||
|
<div class="validation-stat warning">
|
||||||
|
<div class="number">{{ import_result.duplicate_records }}</div>
|
||||||
|
<div class="label">Duplicates Skipped</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% if import_result.failed_records > 0 %}
|
{% if import_result.failed_records > 0 %}
|
||||||
<div class="summary-item error">
|
<div class="validation-stat error">
|
||||||
<div class="number">{{ import_result.failed_records }}</div>
|
<div class="number">{{ import_result.failed_records }}</div>
|
||||||
<div class="label">Failed Records</div>
|
<div class="label">Failed Records</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -335,12 +494,32 @@
|
|||||||
<div class="error-list">
|
<div class="error-list">
|
||||||
<h4>
|
<h4>
|
||||||
<i class="fas fa-exclamation-triangle"></i>
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
Import Errors
|
Import Errors (Showing first 10)
|
||||||
</h4>
|
</h4>
|
||||||
<ul>
|
<ul>
|
||||||
{% for error in import_result.errors %}
|
{% for error in import_result.errors[:10] %}
|
||||||
<li>{{ error }}</li>
|
<li>{{ error }}</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
{% if import_result.errors|length > 10 %}
|
||||||
|
<li><em>...and {{ import_result.errors|length - 10 }} more errors</em></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if import_result.warnings %}
|
||||||
|
<div class="warning-list">
|
||||||
|
<h4>
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
Import Warnings (Showing first 10)
|
||||||
|
</h4>
|
||||||
|
<ul>
|
||||||
|
{% for warning in import_result.warnings[:10] %}
|
||||||
|
<li>{{ warning }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
{% if import_result.warnings|length > 10 %}
|
||||||
|
<li><em>...and {{ import_result.warnings|length - 10 }} more warnings</em></li>
|
||||||
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -349,344 +528,87 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
|
||||||
.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 {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #0f172a;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.format-table {
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.format-table table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
background: #ffffff;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.format-table th,
|
|
||||||
.format-table td {
|
|
||||||
padding: 0.75rem;
|
|
||||||
text-align: left;
|
|
||||||
border-bottom: 1px solid #e2e8f0;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.format-table th {
|
|
||||||
background: #f1f5f9;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
.format-table tr:last-child td {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-info {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
background: #f0fdf4;
|
|
||||||
border: 1px solid #bbf7d0;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
color: #166534;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-info i {
|
|
||||||
color: #22c55e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-name {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-size {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #15803d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
justify-content: flex-end;
|
|
||||||
padding-top: 1.5rem;
|
|
||||||
border-top: 1px solid #e2e8f0;
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-loading {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
background: #ffffff;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-table th,
|
|
||||||
.preview-table td {
|
|
||||||
padding: 0.75rem;
|
|
||||||
text-align: left;
|
|
||||||
border-bottom: 1px solid #e2e8f0;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-table th {
|
|
||||||
background: #f8fafc;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-container {
|
|
||||||
overflow-x: auto;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-success {
|
|
||||||
color: #10b981;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-danger {
|
|
||||||
color: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning-list {
|
|
||||||
background: #fffbeb;
|
|
||||||
border: 1px solid #fed7aa;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
padding: 1rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning-list h4 {
|
|
||||||
color: #92400e;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning-list ul {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning-list li {
|
|
||||||
color: #d97706;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
padding: 0.25rem 0;
|
|
||||||
border-bottom: 1px solid #fed7aa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning-list li:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sample-data {
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sample-data h4 {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #0f172a;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.success-actions {
|
|
||||||
text-align: center;
|
|
||||||
padding: 1rem;
|
|
||||||
background: #f0fdf4;
|
|
||||||
border: 1px solid #bbf7d0;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.success-actions p {
|
|
||||||
color: #166534;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.success-actions code {
|
|
||||||
background: #dcfce7;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
font-family: monospace;
|
|
||||||
color: #15803d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-buttons {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.instructions-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-buttons {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.format-table,
|
|
||||||
.table-container {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
// File upload handling
|
||||||
const fileUploadArea = document.getElementById('fileUploadArea');
|
const fileInput = document.getElementById('fileInput');
|
||||||
const fileInput = document.getElementById('fileInput');
|
const fileUploadArea = document.getElementById('fileUploadArea');
|
||||||
const fileInfo = document.getElementById('fileInfo');
|
const fileInfo = document.getElementById('fileInfo');
|
||||||
const submitBtn = document.getElementById('submitBtn');
|
const submitBtn = document.getElementById('submitBtn');
|
||||||
const importForm = document.getElementById('importForm');
|
const importForm = document.getElementById('importForm');
|
||||||
|
const progressIndicator = document.getElementById('progressIndicator');
|
||||||
|
|
||||||
// File upload handling
|
// Drag and drop handlers
|
||||||
fileUploadArea.addEventListener('click', () => fileInput.click());
|
fileUploadArea.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
fileUploadArea.addEventListener('dragover', (e) => {
|
fileUploadArea.classList.add('dragover');
|
||||||
e.preventDefault();
|
|
||||||
fileUploadArea.classList.add('dragover');
|
|
||||||
});
|
|
||||||
|
|
||||||
fileUploadArea.addEventListener('dragleave', () => {
|
|
||||||
fileUploadArea.classList.remove('dragover');
|
|
||||||
});
|
|
||||||
|
|
||||||
fileUploadArea.addEventListener('drop', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
fileUploadArea.classList.remove('dragover');
|
|
||||||
const files = e.dataTransfer.files;
|
|
||||||
if (files.length > 0) {
|
|
||||||
fileInput.files = files;
|
|
||||||
handleFileSelect();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
fileInput.addEventListener('change', handleFileSelect);
|
|
||||||
|
|
||||||
function handleFileSelect() {
|
|
||||||
const file = fileInput.files[0];
|
|
||||||
if (file) {
|
|
||||||
const fileName = file.name;
|
|
||||||
const fileSize = formatFileSize(file.size);
|
|
||||||
|
|
||||||
fileInfo.querySelector('.file-name').textContent = fileName;
|
|
||||||
fileInfo.querySelector('.file-size').textContent = fileSize;
|
|
||||||
fileInfo.style.display = 'flex';
|
|
||||||
|
|
||||||
submitBtn.disabled = false;
|
|
||||||
|
|
||||||
// Validate file type
|
|
||||||
const validExtensions = ['.xlsx', '.xls'];
|
|
||||||
const fileExtension = fileName.toLowerCase().substring(fileName.lastIndexOf('.'));
|
|
||||||
|
|
||||||
if (!validExtensions.includes(fileExtension)) {
|
|
||||||
alert('Please select a valid Excel file (.xlsx or .xls)');
|
|
||||||
resetForm();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatFileSize(bytes) {
|
|
||||||
if (bytes === 0) return '0 Bytes';
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Form submission handling
|
|
||||||
importForm.addEventListener('submit', function(e) {
|
|
||||||
const btnText = submitBtn.querySelector('.btn-text');
|
|
||||||
const btnLoading = submitBtn.querySelector('.btn-loading');
|
|
||||||
|
|
||||||
btnText.style.display = 'none';
|
|
||||||
btnLoading.style.display = 'inline-flex';
|
|
||||||
submitBtn.disabled = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset form function
|
|
||||||
window.resetForm = function() {
|
|
||||||
fileInput.value = '';
|
|
||||||
fileInfo.style.display = 'none';
|
|
||||||
submitBtn.disabled = true;
|
|
||||||
fileUploadArea.classList.remove('dragover');
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fileUploadArea.addEventListener('dragleave', () => {
|
||||||
|
fileUploadArea.classList.remove('dragover');
|
||||||
|
});
|
||||||
|
|
||||||
|
fileUploadArea.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
fileUploadArea.classList.remove('dragover');
|
||||||
|
|
||||||
|
const files = e.dataTransfer.files;
|
||||||
|
if (files.length > 0) {
|
||||||
|
fileInput.files = files;
|
||||||
|
handleFileSelect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fileUploadArea.addEventListener('click', () => {
|
||||||
|
fileInput.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', handleFileSelect);
|
||||||
|
|
||||||
|
function handleFileSelect() {
|
||||||
|
const file = fileInput.files[0];
|
||||||
|
if (file) {
|
||||||
|
const fileName = file.name;
|
||||||
|
const fileSize = (file.size / 1024 / 1024).toFixed(2) + ' MB';
|
||||||
|
|
||||||
|
fileInfo.querySelector('.file-name').textContent = fileName;
|
||||||
|
fileInfo.querySelector('.file-size').textContent = fileSize;
|
||||||
|
fileInfo.style.display = 'flex';
|
||||||
|
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
|
||||||
|
// Validate file extension
|
||||||
|
if (!fileName.toLowerCase().endsWith('.xlsx') && !fileName.toLowerCase().endsWith('.xls')) {
|
||||||
|
alert('Please select an Excel file (.xlsx or .xls)');
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form submission
|
||||||
|
importForm.addEventListener('submit', (e) => {
|
||||||
|
if (!fileInput.files[0]) {
|
||||||
|
e.preventDefault();
|
||||||
|
alert('Please select a file to import');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show progress indicator
|
||||||
|
progressIndicator.classList.add('active');
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Processing...';
|
||||||
|
});
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
importForm.reset();
|
||||||
|
fileInfo.style.display = 'none';
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
progressIndicator.classList.remove('active');
|
||||||
|
submitBtn.innerHTML = '<i class="fas fa-upload"></i> <span class="btn-text">Import Data</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent form resubmission
|
||||||
|
if (window.history.replaceState) {
|
||||||
|
window.history.replaceState(null, null, window.location.href);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,20 +1,21 @@
|
|||||||
"""
|
"""
|
||||||
Time Attendance Import Service
|
Enhanced Time Attendance Import Service
|
||||||
=============================
|
========================================
|
||||||
|
|
||||||
Service to handle importing time attendance data from Excel files.
|
Improved service with duplicate detection, advanced validation,
|
||||||
Provides functionality to parse Excel files and import data into the time_attendance table.
|
and better error handling for Excel imports.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime, time
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
from typing import Dict, List, Any, Optional, Tuple
|
from typing import Dict, List, Any, Optional, Tuple
|
||||||
import traceback
|
import traceback
|
||||||
|
import hashlib
|
||||||
|
|
||||||
class TimeAttendanceImportService:
|
class TimeAttendanceImportService:
|
||||||
"""Service to handle time attendance data import from Excel files"""
|
"""Enhanced service to handle time attendance data import from Excel files"""
|
||||||
|
|
||||||
def __init__(self, db, logger_handler=None):
|
def __init__(self, db, logger_handler=None):
|
||||||
"""Initialize the import service with database and logger"""
|
"""Initialize the import service with database and logger"""
|
||||||
@@ -22,14 +23,15 @@ class TimeAttendanceImportService:
|
|||||||
self.logger = logger_handler
|
self.logger = logger_handler
|
||||||
|
|
||||||
def import_from_excel(self, file_path: str, created_by: int = None,
|
def import_from_excel(self, file_path: str, created_by: int = None,
|
||||||
import_source: str = None) -> Dict[str, Any]:
|
import_source: str = None, skip_duplicates: bool = True) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Import time attendance data from Excel file
|
Import time attendance data from Excel file with enhanced validation
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file_path: Path to the Excel file
|
file_path: Path to the Excel file
|
||||||
created_by: User ID who initiated the import
|
created_by: User ID who initiated the import
|
||||||
import_source: Description of import source
|
import_source: Description of import source
|
||||||
|
skip_duplicates: Whether to skip duplicate records
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary containing import results
|
Dictionary containing import results
|
||||||
@@ -40,7 +42,10 @@ class TimeAttendanceImportService:
|
|||||||
'total_records': 0,
|
'total_records': 0,
|
||||||
'imported_records': 0,
|
'imported_records': 0,
|
||||||
'failed_records': 0,
|
'failed_records': 0,
|
||||||
|
'duplicate_records': 0,
|
||||||
|
'skipped_records': 0,
|
||||||
'errors': [],
|
'errors': [],
|
||||||
|
'warnings': [],
|
||||||
'success': False,
|
'success': False,
|
||||||
'import_date': datetime.utcnow()
|
'import_date': datetime.utcnow()
|
||||||
}
|
}
|
||||||
@@ -48,10 +53,22 @@ class TimeAttendanceImportService:
|
|||||||
try:
|
try:
|
||||||
# Log import start
|
# Log import start
|
||||||
if self.logger:
|
if self.logger:
|
||||||
self.logger.logger.info(f"Starting time attendance import from {file_path} by user {created_by}")
|
self.logger.logger.info(f"Starting enhanced time attendance import from {file_path} by user {created_by}")
|
||||||
|
|
||||||
# Read Excel file
|
# Read Excel file with multiple sheet support
|
||||||
df = pd.read_excel(file_path, sheet_name=0) # Read first sheet
|
try:
|
||||||
|
excel_file = pd.ExcelFile(file_path)
|
||||||
|
sheet_name = excel_file.sheet_names[0] # Use first sheet
|
||||||
|
df = pd.read_excel(file_path, sheet_name=sheet_name)
|
||||||
|
|
||||||
|
if self.logger:
|
||||||
|
self.logger.logger.info(f"Reading sheet: {sheet_name} with {len(df)} rows")
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Failed to read Excel file: {str(e)}"
|
||||||
|
import_results['errors'].append(error_msg)
|
||||||
|
if self.logger:
|
||||||
|
self.logger.logger.error(error_msg)
|
||||||
|
return import_results
|
||||||
|
|
||||||
# Validate required columns
|
# Validate required columns
|
||||||
required_columns = ['ID', 'Name', 'Date', 'Time', 'Location Name', 'Action Description']
|
required_columns = ['ID', 'Name', 'Date', 'Time', 'Location Name', 'Action Description']
|
||||||
@@ -61,38 +78,78 @@ class TimeAttendanceImportService:
|
|||||||
error_msg = f"Missing required columns: {', '.join(missing_columns)}"
|
error_msg = f"Missing required columns: {', '.join(missing_columns)}"
|
||||||
import_results['errors'].append(error_msg)
|
import_results['errors'].append(error_msg)
|
||||||
if self.logger:
|
if self.logger:
|
||||||
self.logger.logger.error(f"Import failed - {error_msg}")
|
self.logger.logger.error(error_msg)
|
||||||
return import_results
|
return import_results
|
||||||
|
|
||||||
|
# Remove completely empty rows
|
||||||
|
df = df.dropna(how='all')
|
||||||
import_results['total_records'] = len(df)
|
import_results['total_records'] = len(df)
|
||||||
|
|
||||||
# Process each row
|
if import_results['total_records'] == 0:
|
||||||
|
error_msg = "No valid data rows found in Excel file"
|
||||||
|
import_results['errors'].append(error_msg)
|
||||||
|
return import_results
|
||||||
|
|
||||||
|
# Track duplicates using hash
|
||||||
|
duplicate_hashes = set()
|
||||||
|
if skip_duplicates:
|
||||||
|
duplicate_hashes = self._get_existing_record_hashes()
|
||||||
|
|
||||||
|
# Process each row with enhanced validation
|
||||||
for index, row in df.iterrows():
|
for index, row in df.iterrows():
|
||||||
try:
|
try:
|
||||||
# Parse date and time
|
# Skip empty rows
|
||||||
attendance_date = pd.to_datetime(row['Date']).date()
|
if pd.isna(row['ID']) or pd.isna(row['Name']):
|
||||||
|
import_results['skipped_records'] += 1
|
||||||
|
import_results['warnings'].append(f"Row {index + 2}: Skipped due to missing ID or Name")
|
||||||
|
continue
|
||||||
|
|
||||||
# Handle time parsing - could be string or time object
|
# Validate and parse date
|
||||||
time_str = str(row['Time'])
|
try:
|
||||||
if ':' in time_str:
|
attendance_date = pd.to_datetime(row['Date']).date()
|
||||||
attendance_time = datetime.strptime(time_str, '%H:%M:%S').time()
|
except Exception as date_error:
|
||||||
else:
|
import_results['failed_records'] += 1
|
||||||
# Handle Excel time format
|
import_results['errors'].append(f"Row {index + 2}: Invalid date format - {str(date_error)}")
|
||||||
attendance_time = pd.to_datetime(row['Time']).time()
|
continue
|
||||||
|
|
||||||
|
# Validate and parse time with multiple format support
|
||||||
|
try:
|
||||||
|
attendance_time = self._parse_time_field(row['Time'])
|
||||||
|
except Exception as time_error:
|
||||||
|
import_results['failed_records'] += 1
|
||||||
|
import_results['errors'].append(f"Row {index + 2}: Invalid time format - {str(time_error)}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Prepare record data
|
||||||
|
record_data = {
|
||||||
|
'employee_id': str(row['ID']).strip(),
|
||||||
|
'employee_name': str(row['Name']).strip(),
|
||||||
|
'platform': str(row.get('Platform', '')).strip() if pd.notna(row.get('Platform')) else None,
|
||||||
|
'attendance_date': attendance_date,
|
||||||
|
'attendance_time': attendance_time,
|
||||||
|
'location_name': str(row['Location Name']).strip(),
|
||||||
|
'action_description': str(row['Action Description']).strip(),
|
||||||
|
'event_description': str(row.get('Event Description', '')).strip() if pd.notna(row.get('Event Description')) else None,
|
||||||
|
'recorded_address': str(row.get('Recorded Address', '')).strip() if pd.notna(row.get('Recorded Address')) else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check for duplicates
|
||||||
|
if skip_duplicates:
|
||||||
|
record_hash = self._generate_record_hash(record_data)
|
||||||
|
if record_hash in duplicate_hashes:
|
||||||
|
import_results['duplicate_records'] += 1
|
||||||
|
import_results['warnings'].append(
|
||||||
|
f"Row {index + 2}: Duplicate record for {record_data['employee_name']} "
|
||||||
|
f"on {attendance_date} at {attendance_time} - Skipped"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
duplicate_hashes.add(record_hash)
|
||||||
|
|
||||||
# Create TimeAttendance record
|
# Create TimeAttendance record
|
||||||
from models.time_attendance import TimeAttendance
|
from models.time_attendance import TimeAttendance
|
||||||
|
|
||||||
time_attendance_record = TimeAttendance(
|
time_attendance_record = TimeAttendance(
|
||||||
employee_id=str(row['ID']).strip(),
|
**record_data,
|
||||||
employee_name=str(row['Name']).strip(),
|
|
||||||
platform=str(row.get('Platform', '')).strip() if pd.notna(row.get('Platform')) else None,
|
|
||||||
attendance_date=attendance_date,
|
|
||||||
attendance_time=attendance_time,
|
|
||||||
location_name=str(row['Location Name']).strip(),
|
|
||||||
action_description=str(row['Action Description']).strip(),
|
|
||||||
event_description=str(row.get('Event Description', '')).strip() if pd.notna(row.get('Event Description')) else None,
|
|
||||||
recorded_address=str(row.get('Recorded Address', '')).strip() if pd.notna(row.get('Recorded Address')) else None,
|
|
||||||
import_batch_id=batch_id,
|
import_batch_id=batch_id,
|
||||||
import_source=import_source or f"Excel Import - {file_path}",
|
import_source=import_source or f"Excel Import - {file_path}",
|
||||||
created_by=created_by
|
created_by=created_by
|
||||||
@@ -101,6 +158,10 @@ class TimeAttendanceImportService:
|
|||||||
self.db.session.add(time_attendance_record)
|
self.db.session.add(time_attendance_record)
|
||||||
import_results['imported_records'] += 1
|
import_results['imported_records'] += 1
|
||||||
|
|
||||||
|
# Commit in batches for better performance
|
||||||
|
if import_results['imported_records'] % 100 == 0:
|
||||||
|
self.db.session.flush()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import_results['failed_records'] += 1
|
import_results['failed_records'] += 1
|
||||||
error_msg = f"Row {index + 2}: {str(e)}"
|
error_msg = f"Row {index + 2}: {str(e)}"
|
||||||
@@ -111,9 +172,9 @@ class TimeAttendanceImportService:
|
|||||||
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Commit all records
|
# Final commit
|
||||||
self.db.session.commit()
|
self.db.session.commit()
|
||||||
import_results['success'] = True
|
import_results['success'] = import_results['imported_records'] > 0
|
||||||
|
|
||||||
# Log successful import
|
# Log successful import
|
||||||
if self.logger:
|
if self.logger:
|
||||||
@@ -121,7 +182,9 @@ class TimeAttendanceImportService:
|
|||||||
f"Time attendance import completed - Batch: {batch_id}, "
|
f"Time attendance import completed - Batch: {batch_id}, "
|
||||||
f"Total: {import_results['total_records']}, "
|
f"Total: {import_results['total_records']}, "
|
||||||
f"Imported: {import_results['imported_records']}, "
|
f"Imported: {import_results['imported_records']}, "
|
||||||
f"Failed: {import_results['failed_records']}"
|
f"Failed: {import_results['failed_records']}, "
|
||||||
|
f"Duplicates: {import_results['duplicate_records']}, "
|
||||||
|
f"Skipped: {import_results['skipped_records']}"
|
||||||
)
|
)
|
||||||
|
|
||||||
except SQLAlchemyError as e:
|
except SQLAlchemyError as e:
|
||||||
@@ -144,9 +207,99 @@ class TimeAttendanceImportService:
|
|||||||
|
|
||||||
return import_results
|
return import_results
|
||||||
|
|
||||||
|
def _parse_time_field(self, time_value) -> time:
|
||||||
|
"""
|
||||||
|
Parse time field with multiple format support
|
||||||
|
|
||||||
|
Args:
|
||||||
|
time_value: Time value from Excel (string, datetime, or time object)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
time object
|
||||||
|
"""
|
||||||
|
if pd.isna(time_value):
|
||||||
|
raise ValueError("Time value is empty")
|
||||||
|
|
||||||
|
# If already a time object
|
||||||
|
if isinstance(time_value, time):
|
||||||
|
return time_value
|
||||||
|
|
||||||
|
# Convert to string and try parsing
|
||||||
|
time_str = str(time_value).strip()
|
||||||
|
|
||||||
|
# Try common time formats
|
||||||
|
time_formats = [
|
||||||
|
'%H:%M:%S',
|
||||||
|
'%H:%M',
|
||||||
|
'%I:%M:%S %p',
|
||||||
|
'%I:%M %p',
|
||||||
|
]
|
||||||
|
|
||||||
|
for fmt in time_formats:
|
||||||
|
try:
|
||||||
|
return datetime.strptime(time_str, fmt).time()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Try pandas datetime parsing as fallback
|
||||||
|
try:
|
||||||
|
return pd.to_datetime(time_value).time()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
raise ValueError(f"Unable to parse time value: {time_value}")
|
||||||
|
|
||||||
|
def _generate_record_hash(self, record_data: Dict) -> str:
|
||||||
|
"""
|
||||||
|
Generate unique hash for a record to detect duplicates
|
||||||
|
|
||||||
|
Args:
|
||||||
|
record_data: Dictionary containing record information
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Hash string
|
||||||
|
"""
|
||||||
|
hash_string = (
|
||||||
|
f"{record_data['employee_id']}_"
|
||||||
|
f"{record_data['attendance_date']}_"
|
||||||
|
f"{record_data['attendance_time']}_"
|
||||||
|
f"{record_data['location_name']}_"
|
||||||
|
f"{record_data['action_description']}"
|
||||||
|
)
|
||||||
|
return hashlib.md5(hash_string.encode()).hexdigest()
|
||||||
|
|
||||||
|
def _get_existing_record_hashes(self) -> set:
|
||||||
|
"""
|
||||||
|
Get hashes of existing records to detect duplicates
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Set of record hashes
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from models.time_attendance import TimeAttendance
|
||||||
|
|
||||||
|
existing_records = TimeAttendance.query.all()
|
||||||
|
hashes = set()
|
||||||
|
|
||||||
|
for record in existing_records:
|
||||||
|
record_data = {
|
||||||
|
'employee_id': record.employee_id,
|
||||||
|
'attendance_date': record.attendance_date,
|
||||||
|
'attendance_time': record.attendance_time,
|
||||||
|
'location_name': record.location_name,
|
||||||
|
'action_description': record.action_description
|
||||||
|
}
|
||||||
|
hashes.add(self._generate_record_hash(record_data))
|
||||||
|
|
||||||
|
return hashes
|
||||||
|
except Exception as e:
|
||||||
|
if self.logger:
|
||||||
|
self.logger.logger.warning(f"Failed to get existing record hashes: {e}")
|
||||||
|
return set()
|
||||||
|
|
||||||
def validate_excel_file(self, file_path: str) -> Dict[str, Any]:
|
def validate_excel_file(self, file_path: str) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Validate Excel file structure before import
|
Enhanced Excel file validation with detailed analysis
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file_path: Path to the Excel file
|
file_path: Path to the Excel file
|
||||||
@@ -157,19 +310,41 @@ class TimeAttendanceImportService:
|
|||||||
validation_results = {
|
validation_results = {
|
||||||
'valid': False,
|
'valid': False,
|
||||||
'total_rows': 0,
|
'total_rows': 0,
|
||||||
|
'valid_rows': 0,
|
||||||
|
'invalid_rows': 0,
|
||||||
'columns': [],
|
'columns': [],
|
||||||
'sample_data': [],
|
'sample_data': [],
|
||||||
'errors': [],
|
'errors': [],
|
||||||
'warnings': []
|
'warnings': [],
|
||||||
|
'file_info': {}
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Get file information
|
||||||
|
import os
|
||||||
|
file_stats = os.stat(file_path)
|
||||||
|
validation_results['file_info'] = {
|
||||||
|
'size': file_stats.st_size,
|
||||||
|
'size_mb': round(file_stats.st_size / (1024 * 1024), 2)
|
||||||
|
}
|
||||||
|
|
||||||
# Read Excel file
|
# Read Excel file
|
||||||
df = pd.read_excel(file_path, sheet_name=0)
|
excel_file = pd.ExcelFile(file_path)
|
||||||
|
sheet_name = excel_file.sheet_names[0]
|
||||||
|
df = pd.read_excel(file_path, sheet_name=sheet_name)
|
||||||
|
|
||||||
|
# Remove empty rows
|
||||||
|
original_row_count = len(df)
|
||||||
|
df = df.dropna(how='all')
|
||||||
|
|
||||||
validation_results['total_rows'] = len(df)
|
validation_results['total_rows'] = len(df)
|
||||||
validation_results['columns'] = df.columns.tolist()
|
validation_results['columns'] = df.columns.tolist()
|
||||||
|
|
||||||
|
if original_row_count > len(df):
|
||||||
|
validation_results['warnings'].append(
|
||||||
|
f"Removed {original_row_count - len(df)} completely empty rows"
|
||||||
|
)
|
||||||
|
|
||||||
# Get sample data (first 5 rows)
|
# Get sample data (first 5 rows)
|
||||||
sample_rows = df.head(5).to_dict('records')
|
sample_rows = df.head(5).to_dict('records')
|
||||||
validation_results['sample_data'] = sample_rows
|
validation_results['sample_data'] = sample_rows
|
||||||
@@ -179,35 +354,86 @@ class TimeAttendanceImportService:
|
|||||||
missing_columns = [col for col in required_columns if col not in df.columns]
|
missing_columns = [col for col in required_columns if col not in df.columns]
|
||||||
|
|
||||||
if missing_columns:
|
if missing_columns:
|
||||||
validation_results['errors'].append(f"Missing required columns: {', '.join(missing_columns)}")
|
validation_results['errors'].append(
|
||||||
|
f"Missing required columns: {', '.join(missing_columns)}"
|
||||||
|
)
|
||||||
|
|
||||||
# Check for empty required fields
|
# Check for empty required fields
|
||||||
for col in required_columns:
|
valid_row_count = 0
|
||||||
if col in df.columns:
|
for index, row in df.iterrows():
|
||||||
empty_count = df[col].isna().sum()
|
is_valid = True
|
||||||
if empty_count > 0:
|
for col in required_columns:
|
||||||
validation_results['warnings'].append(
|
if col in df.columns and pd.isna(row[col]):
|
||||||
f"Column '{col}' has {empty_count} empty values"
|
is_valid = False
|
||||||
)
|
break
|
||||||
|
|
||||||
|
if is_valid:
|
||||||
|
valid_row_count += 1
|
||||||
|
|
||||||
|
validation_results['valid_rows'] = valid_row_count
|
||||||
|
validation_results['invalid_rows'] = len(df) - valid_row_count
|
||||||
|
|
||||||
|
if validation_results['invalid_rows'] > 0:
|
||||||
|
validation_results['warnings'].append(
|
||||||
|
f"{validation_results['invalid_rows']} rows have missing required data"
|
||||||
|
)
|
||||||
|
|
||||||
# Validate date format
|
# Validate date format
|
||||||
if 'Date' in df.columns:
|
if 'Date' in df.columns:
|
||||||
try:
|
invalid_dates = 0
|
||||||
pd.to_datetime(df['Date'], errors='coerce')
|
for idx, date_val in df['Date'].items():
|
||||||
except:
|
if pd.notna(date_val):
|
||||||
validation_results['errors'].append("Invalid date format in 'Date' column")
|
try:
|
||||||
|
pd.to_datetime(date_val)
|
||||||
|
except:
|
||||||
|
invalid_dates += 1
|
||||||
|
|
||||||
|
if invalid_dates > 0:
|
||||||
|
validation_results['warnings'].append(
|
||||||
|
f"{invalid_dates} rows have invalid date format"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate time format
|
||||||
|
if 'Time' in df.columns:
|
||||||
|
invalid_times = 0
|
||||||
|
for idx, time_val in df['Time'].items():
|
||||||
|
if pd.notna(time_val):
|
||||||
|
try:
|
||||||
|
self._parse_time_field(time_val)
|
||||||
|
except:
|
||||||
|
invalid_times += 1
|
||||||
|
|
||||||
|
if invalid_times > 0:
|
||||||
|
validation_results['warnings'].append(
|
||||||
|
f"{invalid_times} rows have invalid time format"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for potential duplicates
|
||||||
|
if all(col in df.columns for col in ['ID', 'Date', 'Time', 'Location Name']):
|
||||||
|
duplicate_check = df[['ID', 'Date', 'Time', 'Location Name']].duplicated()
|
||||||
|
duplicate_count = duplicate_check.sum()
|
||||||
|
|
||||||
|
if duplicate_count > 0:
|
||||||
|
validation_results['warnings'].append(
|
||||||
|
f"{duplicate_count} potential duplicate records detected"
|
||||||
|
)
|
||||||
|
|
||||||
# Set valid flag
|
# Set valid flag
|
||||||
validation_results['valid'] = len(validation_results['errors']) == 0
|
validation_results['valid'] = (
|
||||||
|
len(validation_results['errors']) == 0 and
|
||||||
|
validation_results['valid_rows'] > 0
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
validation_results['errors'].append(f"Failed to read Excel file: {str(e)}")
|
validation_results['errors'].append(f"Failed to validate Excel file: {str(e)}")
|
||||||
|
if self.logger:
|
||||||
|
self.logger.logger.error(f"Validation error: {e}")
|
||||||
|
|
||||||
return validation_results
|
return validation_results
|
||||||
|
|
||||||
def get_import_summary(self, batch_id: str) -> Optional[Dict[str, Any]]:
|
def get_import_summary(self, batch_id: str) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get summary of imported data by batch ID
|
Get detailed summary of imported data by batch ID
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
batch_id: Import batch identifier
|
batch_id: Import batch identifier
|
||||||
@@ -238,6 +464,17 @@ class TimeAttendanceImportService:
|
|||||||
action = record.action_description
|
action = record.action_description
|
||||||
actions[action] = actions.get(action, 0) + 1
|
actions[action] = actions.get(action, 0) + 1
|
||||||
|
|
||||||
|
# Group by employee
|
||||||
|
employee_summary = {}
|
||||||
|
for record in records:
|
||||||
|
emp_id = record.employee_id
|
||||||
|
if emp_id not in employee_summary:
|
||||||
|
employee_summary[emp_id] = {
|
||||||
|
'name': record.employee_name,
|
||||||
|
'count': 0
|
||||||
|
}
|
||||||
|
employee_summary[emp_id]['count'] += 1
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'batch_id': batch_id,
|
'batch_id': batch_id,
|
||||||
'total_records': total_records,
|
'total_records': total_records,
|
||||||
@@ -245,6 +482,7 @@ class TimeAttendanceImportService:
|
|||||||
'unique_locations': unique_locations,
|
'unique_locations': unique_locations,
|
||||||
'date_range': date_range,
|
'date_range': date_range,
|
||||||
'actions': actions,
|
'actions': actions,
|
||||||
|
'employee_summary': employee_summary,
|
||||||
'import_date': records[0].import_date if records else None,
|
'import_date': records[0].import_date if records else None,
|
||||||
'import_source': records[0].import_source if records else None
|
'import_source': records[0].import_source if records else None
|
||||||
}
|
}
|
||||||
@@ -252,4 +490,58 @@ class TimeAttendanceImportService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
if self.logger:
|
if self.logger:
|
||||||
self.logger.logger.error(f"Failed to get import summary for batch {batch_id}: {e}")
|
self.logger.logger.error(f"Failed to get import summary for batch {batch_id}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def delete_import_batch(self, batch_id: str, deleted_by: int = None) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Delete all records from a specific import batch
|
||||||
|
|
||||||
|
Args:
|
||||||
|
batch_id: Import batch identifier
|
||||||
|
deleted_by: User ID who initiated the deletion
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing deletion results
|
||||||
|
"""
|
||||||
|
result = {
|
||||||
|
'success': False,
|
||||||
|
'deleted_count': 0,
|
||||||
|
'message': ''
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
from models.time_attendance import TimeAttendance
|
||||||
|
|
||||||
|
records = TimeAttendance.query.filter_by(import_batch_id=batch_id).all()
|
||||||
|
deleted_count = len(records)
|
||||||
|
|
||||||
|
if deleted_count == 0:
|
||||||
|
result['message'] = 'No records found for this batch'
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Delete records
|
||||||
|
for record in records:
|
||||||
|
self.db.session.delete(record)
|
||||||
|
|
||||||
|
self.db.session.commit()
|
||||||
|
|
||||||
|
# Log deletion
|
||||||
|
if self.logger:
|
||||||
|
self.logger.logger.info(
|
||||||
|
f"User {deleted_by} deleted import batch {batch_id} - "
|
||||||
|
f"Removed {deleted_count} records"
|
||||||
|
)
|
||||||
|
|
||||||
|
result['success'] = True
|
||||||
|
result['deleted_count'] = deleted_count
|
||||||
|
result['message'] = f'Successfully deleted {deleted_count} records'
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.db.session.rollback()
|
||||||
|
result['message'] = f'Error deleting batch: {str(e)}'
|
||||||
|
|
||||||
|
if self.logger:
|
||||||
|
self.logger.logger.error(f"Failed to delete batch {batch_id}: {e}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
Reference in New Issue
Block a user