Updated TA export function
This commit is contained in:
@@ -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:
|
||||
@@ -6968,6 +6968,322 @@ def download_import_template():
|
||||
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
|
||||
@log_user_activity('time_attendance_records_view')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -277,14 +376,6 @@
|
||||
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="analyze_duplicates" name="analyze_duplicates" value="true">
|
||||
<label for="analyze_duplicates" class="checkbox-label">
|
||||
@@ -293,6 +384,14 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
|
||||
@@ -3,6 +3,94 @@
|
||||
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/time_attendance.css') }}">
|
||||
<style>
|
||||
/* Export dropdown styles */
|
||||
.export-dropdown {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.export-menu {
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
margin-top: 0.5rem;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
|
||||
z-index: 9999;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.export-menu.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.export-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: none;
|
||||
background: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
font-size: 0.875rem;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.export-menu-item:first-child {
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.export-menu-item:last-child {
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
|
||||
.export-menu-item:hover {
|
||||
background: #f7fafc;
|
||||
}
|
||||
|
||||
.export-menu-item i {
|
||||
font-size: 1.125rem;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.export-menu-item .fa-file-csv {
|
||||
color: #4299e1;
|
||||
}
|
||||
|
||||
.export-menu-item .fa-file-excel {
|
||||
color: #48bb78;
|
||||
}
|
||||
|
||||
.export-menu-divider {
|
||||
height: 1px;
|
||||
background: #e2e8f0;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.export-menu-info {
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: #718096;
|
||||
background: #f7fafc;
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
|
||||
.time-attendance-page {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.time-attendance-header {
|
||||
overflow: visible !important;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block page_title %}Time Attendance Records{% endblock %}
|
||||
@@ -35,10 +123,40 @@
|
||||
Import Data
|
||||
</a>
|
||||
{% endif %}
|
||||
<button class="btn btn-secondary" onclick="exportRecords()">
|
||||
<i class="fas fa-download"></i>
|
||||
Export
|
||||
</button>
|
||||
|
||||
<!-- Export Dropdown -->
|
||||
<div class="export-dropdown">
|
||||
<button class="btn btn-secondary" onclick="toggleExportMenu()">
|
||||
<i class="fas fa-download"></i>
|
||||
Export
|
||||
<i class="fas fa-chevron-down" style="margin-left: 0.5rem; font-size: 0.75rem;"></i>
|
||||
</button>
|
||||
|
||||
<div class="export-menu" id="exportMenu">
|
||||
<button class="export-menu-item" onclick="exportToCSV()">
|
||||
<i class="fas fa-file-csv"></i>
|
||||
<div>
|
||||
<strong>Export to CSV</strong>
|
||||
<div style="font-size: 0.75rem; color: #718096;">Quick download, all data</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button class="export-menu-item" onclick="exportToExcel()">
|
||||
<i class="fas fa-file-excel"></i>
|
||||
<div>
|
||||
<strong>Export to Excel</strong>
|
||||
<div style="font-size: 0.75rem; color: #718096;">Formatted with summary sheet</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="export-menu-divider"></div>
|
||||
|
||||
<div class="export-menu-info">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
Export will include current filters
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -99,117 +217,84 @@
|
||||
value="{{ current_filters.end_date or '' }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label"> </label>
|
||||
<div class="filter-actions">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-search"></i>
|
||||
Apply Filters
|
||||
</button>
|
||||
<a href="{{ url_for('time_attendance_records') }}" class="btn btn-outline">
|
||||
<i class="fas fa-times"></i>
|
||||
Clear
|
||||
</a>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-search"></i>
|
||||
Apply Filters
|
||||
</button>
|
||||
<a href="{{ url_for('time_attendance_records') }}" class="btn btn-outline">
|
||||
<i class="fas fa-undo"></i>
|
||||
Clear Filters
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Records Table -->
|
||||
<div class="records-section">
|
||||
<div class="records-header">
|
||||
<h2>
|
||||
<i class="fas fa-table"></i>
|
||||
Attendance Records
|
||||
</h2>
|
||||
<div class="records-meta">
|
||||
{% if records.items %}
|
||||
Showing {{ records.per_page * (records.page - 1) + 1 }} -
|
||||
{{ records.per_page * (records.page - 1) + records.items|length }}
|
||||
of {{ records.total }} records
|
||||
{% else %}
|
||||
No records found
|
||||
{% endif %}
|
||||
</div>
|
||||
<!-- Records Count Info -->
|
||||
{% if records %}
|
||||
<div class="records-info">
|
||||
<div class="info-card">
|
||||
<i class="fas fa-database"></i>
|
||||
<span>Showing {{ records.items|length }} of {{ records.total }} records</span>
|
||||
{% if current_filters.employee_id or current_filters.location_name or current_filters.start_date or current_filters.end_date %}
|
||||
<span class="filter-badge">Filtered</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if records.items %}
|
||||
<div class="records-table-container">
|
||||
<!-- Records Table -->
|
||||
{% if records and records.items %}
|
||||
<div class="table-section">
|
||||
<div class="table-container">
|
||||
<table class="records-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Employee</th>
|
||||
<th>Date & Time</th>
|
||||
<th>Action</th>
|
||||
<th>Date</th>
|
||||
<th>Time</th>
|
||||
<th>Location</th>
|
||||
<th>Action</th>
|
||||
<th>Platform</th>
|
||||
<th>Address</th>
|
||||
<th>Import Source</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for record in records.items %}
|
||||
<tr>
|
||||
<td>{{ record.id }}</td>
|
||||
<td>
|
||||
<div class="employee-cell">
|
||||
<div class="employee-avatar">
|
||||
{{ record.employee_name[0].upper() if record.employee_name else record.employee_id[0].upper() }}
|
||||
</div>
|
||||
<div class="employee-info">
|
||||
<div class="employee-name">{{ record.employee_name }}</div>
|
||||
<div class="employee-id">ID: {{ record.employee_id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="datetime-cell">
|
||||
<div class="datetime-info">
|
||||
<div class="date">{{ record.attendance_date.strftime('%Y-%m-%d') }}</div>
|
||||
<div class="time">{{ record.attendance_time.strftime('%H:%M:%S') }}</div>
|
||||
<div class="employee-info">
|
||||
<strong>{{ record.employee_name }}</strong>
|
||||
<small>ID: {{ record.employee_id }}</small>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ record.attendance_date.strftime('%Y-%m-%d') if record.attendance_date else 'N/A' }}</td>
|
||||
<td>{{ record.attendance_time.strftime('%H:%M:%S') if record.attendance_time else 'N/A' }}</td>
|
||||
<td>{{ record.location_name }}</td>
|
||||
<td>
|
||||
<span class="action-badge {{ record.action_description.lower().replace(' ', '-') }}">
|
||||
{% if record.action_description.lower() == 'check in' %}
|
||||
<i class="fas fa-sign-in-alt"></i>
|
||||
{% elif record.action_description.lower() == 'check out' %}
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
{% else %}
|
||||
<i class="fas fa-clock"></i>
|
||||
{% endif %}
|
||||
<span class="action-badge {% if 'Check In' in record.action_description %}check-in{% else %}check-out{% endif %}">
|
||||
{{ record.action_description }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="location-cell">
|
||||
<div class="location-info">
|
||||
<i class="fas fa-map-marker-alt"></i>
|
||||
{{ record.location_name }}
|
||||
<td>{{ record.platform or 'N/A' }}</td>
|
||||
<td>
|
||||
<div class="import-info">
|
||||
{{ record.import_source or 'Unknown' }}
|
||||
<small>{{ record.import_date.strftime('%Y-%m-%d') if record.import_date else '' }}</small>
|
||||
</div>
|
||||
</td>
|
||||
<td class="platform-cell">
|
||||
{{ record.platform or 'Unknown' }}
|
||||
</td>
|
||||
<td class="address-cell">
|
||||
{% if record.recorded_address %}
|
||||
<span title="{{ record.recorded_address }}">
|
||||
{{ record.recorded_address[:30] }}{% if record.recorded_address|length > 30 %}...{% endif %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-muted">No address</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="actions-cell">
|
||||
<div class="action-buttons">
|
||||
<a href="{{ url_for('time_attendance_record_detail', record_id=record.id) }}"
|
||||
class="action-btn view">
|
||||
<td>
|
||||
<div class="record-actions">
|
||||
<button class="action-btn btn-view" onclick="viewRecord({{ record.id }})" title="View Details">
|
||||
<i class="fas fa-eye"></i>
|
||||
View
|
||||
</a>
|
||||
</button>
|
||||
{% if session.role == 'admin' %}
|
||||
<button class="action-btn delete"
|
||||
onclick="confirmDelete({{ record.id }}, '{{ record.employee_name }}', '{{ record.attendance_date }}')">
|
||||
<button class="action-btn btn-delete" onclick="confirmDelete({{ record.id }}, '{{ record.employee_name }}', '{{ record.attendance_date.strftime('%Y-%m-%d') if record.attendance_date else 'N/A' }}')" title="Delete">
|
||||
<i class="fas fa-trash"></i>
|
||||
Delete
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -268,37 +353,40 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<!-- Empty State -->
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<i class="fas fa-search"></i>
|
||||
</div>
|
||||
<h3>No Records Found</h3>
|
||||
<p>
|
||||
{% 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 %}
|
||||
</p>
|
||||
{% else %}
|
||||
<!-- Empty State -->
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<i class="fas fa-search"></i>
|
||||
</div>
|
||||
<h3>No Records Found</h3>
|
||||
<p>
|
||||
{% 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 %}
|
||||
</p>
|
||||
<div class="empty-actions">
|
||||
{% if current_filters.employee_id or current_filters.location_name or current_filters.start_date or current_filters.end_date %}
|
||||
<a href="{{ url_for('time_attendance_records') }}" class="btn btn-primary">
|
||||
<i class="fas fa-times"></i>
|
||||
<i class="fas fa-undo"></i>
|
||||
Clear Filters
|
||||
</a>
|
||||
{% elif session.role == 'admin' %}
|
||||
<a href="{{ url_for('import_time_attendance') }}" class="btn btn-primary">
|
||||
{% endif %}
|
||||
{% if session.role == 'admin' %}
|
||||
<a href="{{ url_for('import_time_attendance') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-upload"></i>
|
||||
Import Records
|
||||
Import Data
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
@@ -306,7 +394,7 @@
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>
|
||||
<i class="fas fa-exclamation-triangle text-danger"></i>
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
Confirm Deletion
|
||||
</h3>
|
||||
<button class="modal-close" onclick="closeDeleteModal()">
|
||||
@@ -315,14 +403,9 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete this attendance record?</p>
|
||||
<div class="record-details">
|
||||
<div><strong>Employee:</strong> <span id="deleteEmployeeName"></span></div>
|
||||
<div><strong>Date:</strong> <span id="deleteDate"></span></div>
|
||||
</div>
|
||||
<p class="warning-text">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
<p><strong>Employee:</strong> <span id="deleteEmployeeName"></span></p>
|
||||
<p><strong>Date:</strong> <span id="deleteDate"></span></p>
|
||||
<p class="text-danger">This action cannot be undone.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline" onclick="closeDeleteModal()">Cancel</button>
|
||||
@@ -336,207 +419,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.datetime-info {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.datetime-info .date {
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.datetime-info .time {
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.location-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.location-info i {
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.address-cell {
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.8125rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #9ca3af;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Modal Styles */
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: #ffffff;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
font-size: 1.25rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
transition: color 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.record-details {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.record-details div {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.record-details div:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
color: #dc2626;
|
||||
font-size: 0.875rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
padding: 1.5rem;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.records-table {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.records-table th,
|
||||
.records-table td {
|
||||
padding: 0.5rem 0.25rem;
|
||||
}
|
||||
|
||||
.employee-cell {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.employee-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.pagination-section {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
margin: 1rem;
|
||||
width: calc(100% - 2rem);
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Filter toggle functionality
|
||||
// Toggle filters
|
||||
function toggleFilters() {
|
||||
const filterBody = document.getElementById('filterBody');
|
||||
const filterIcon = document.getElementById('filterIcon');
|
||||
@@ -552,6 +436,58 @@ function toggleFilters() {
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle export menu
|
||||
function toggleExportMenu() {
|
||||
const menu = document.getElementById('exportMenu');
|
||||
menu.classList.toggle('show');
|
||||
}
|
||||
|
||||
// Close export menu when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
const exportDropdown = document.querySelector('.export-dropdown');
|
||||
if (exportDropdown && !exportDropdown.contains(e.target)) {
|
||||
document.getElementById('exportMenu').classList.remove('show');
|
||||
}
|
||||
});
|
||||
|
||||
// Get current filters as URL parameters
|
||||
function getCurrentFilters() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return {
|
||||
employee_id: params.get('employee_id') || '',
|
||||
location_name: params.get('location_name') || '',
|
||||
start_date: params.get('start_date') || '',
|
||||
end_date: params.get('end_date') || '',
|
||||
import_batch: params.get('import_batch') || ''
|
||||
};
|
||||
}
|
||||
|
||||
// Export to CSV
|
||||
function exportToCSV() {
|
||||
const filters = getCurrentFilters();
|
||||
const params = new URLSearchParams(filters);
|
||||
params.set('format', 'csv');
|
||||
|
||||
window.location.href = `{{ url_for('export_time_attendance') }}?${params.toString()}`;
|
||||
toggleExportMenu();
|
||||
}
|
||||
|
||||
// Export to Excel
|
||||
function exportToExcel() {
|
||||
const filters = getCurrentFilters();
|
||||
const params = new URLSearchParams(filters);
|
||||
params.set('format', 'excel');
|
||||
|
||||
window.location.href = `{{ url_for('export_time_attendance') }}?${params.toString()}`;
|
||||
toggleExportMenu();
|
||||
}
|
||||
|
||||
// View record details
|
||||
function viewRecord(recordId) {
|
||||
// Implement view details functionality
|
||||
alert(`View details for record ID: ${recordId}\nThis feature can be implemented to show a modal with full record details.`);
|
||||
}
|
||||
|
||||
// Delete confirmation
|
||||
function confirmDelete(recordId, employeeName, date) {
|
||||
document.getElementById('deleteEmployeeName').textContent = employeeName;
|
||||
@@ -564,13 +500,6 @@ function closeDeleteModal() {
|
||||
document.getElementById('deleteModal').style.display = 'none';
|
||||
}
|
||||
|
||||
// Export functionality
|
||||
function exportRecords() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set('export', 'csv');
|
||||
window.location.href = `{{ url_for('time_attendance_records') }}?${params.toString()}`;
|
||||
}
|
||||
|
||||
// Close modal when clicking outside
|
||||
document.getElementById('deleteModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
|
||||
Reference in New Issue
Block a user