Update: add Export by Building
This commit is contained in:
@@ -9293,6 +9293,611 @@ def excel_export_time_attendance():
|
|||||||
# Redirect to main export with Excel format
|
# Redirect to main export with Excel format
|
||||||
return redirect(url_for('export_time_attendance', format='excel', **request.args))
|
return redirect(url_for('export_time_attendance', format='excel', **request.args))
|
||||||
|
|
||||||
|
@app.route('/time-attendance/export-by-building')
|
||||||
|
@login_required
|
||||||
|
@log_user_activity('time_attendance_export_by_building')
|
||||||
|
def export_time_attendance_by_building():
|
||||||
|
"""Export time attendance records grouped by building/location to Excel"""
|
||||||
|
try:
|
||||||
|
# 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')
|
||||||
|
project_filter = request.args.get('project_id')
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
if project_filter:
|
||||||
|
query = query.filter(TimeAttendance.project_id == project_filter)
|
||||||
|
|
||||||
|
# Order by location, date, and time
|
||||||
|
records = query.order_by(
|
||||||
|
TimeAttendance.location_name,
|
||||||
|
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'))
|
||||||
|
|
||||||
|
# Get project name if project filter exists
|
||||||
|
project_name_for_filename = ''
|
||||||
|
if project_filter:
|
||||||
|
try:
|
||||||
|
from models.project import Project
|
||||||
|
project = Project.query.get(int(project_filter))
|
||||||
|
if project:
|
||||||
|
# Replace spaces and special characters with underscores
|
||||||
|
project_name_safe = project.name.replace(' ', '_').replace('/', '_').replace('\\', '_')
|
||||||
|
project_name_for_filename = f"{project_name_safe}_"
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Error getting project name for filename: {e}")
|
||||||
|
|
||||||
|
# Log export
|
||||||
|
logger_handler.logger.info(
|
||||||
|
f"User {session['username']} exported {len(records)} time attendance records "
|
||||||
|
f"by building in Excel format"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Format dates for filename (MMDDYYYY format)
|
||||||
|
date_from_formatted = ''
|
||||||
|
date_to_formatted = ''
|
||||||
|
if start_date:
|
||||||
|
try:
|
||||||
|
date_obj = datetime.strptime(start_date, '%Y-%m-%d')
|
||||||
|
date_from_formatted = date_obj.strftime('%m%d%Y')
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if end_date:
|
||||||
|
try:
|
||||||
|
date_obj = datetime.strptime(end_date, '%Y-%m-%d')
|
||||||
|
date_to_formatted = date_obj.strftime('%m%d%Y')
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Build filename with date range
|
||||||
|
date_range_str = ''
|
||||||
|
if date_from_formatted and date_to_formatted:
|
||||||
|
date_range_str = f"{date_from_formatted}_{date_to_formatted}"
|
||||||
|
elif date_from_formatted:
|
||||||
|
date_range_str = f"from_{date_from_formatted}"
|
||||||
|
elif date_to_formatted:
|
||||||
|
date_range_str = f"to_{date_to_formatted}"
|
||||||
|
|
||||||
|
return export_time_attendance_by_building_excel(records, project_name_for_filename, date_range_str, start_date, end_date)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger_handler.logger.error(f"Error exporting time attendance records by building: {e}")
|
||||||
|
flash('Error generating export file. Please try again.', 'error')
|
||||||
|
return redirect(url_for('time_attendance_records'))
|
||||||
|
|
||||||
|
def export_time_attendance_by_building_excel(records, project_name_for_filename, date_range_str, start_date_filter=None, end_date_filter=None):
|
||||||
|
"""Generate Excel export grouped by building/location with template format"""
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from openpyxl.styles import Font, PatternFill, Border, Side, Alignment
|
||||||
|
from openpyxl.utils import get_column_letter
|
||||||
|
import io
|
||||||
|
|
||||||
|
# Create workbook
|
||||||
|
wb = Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "Sheet0"
|
||||||
|
|
||||||
|
# Get date range for calculations
|
||||||
|
if start_date_filter and end_date_filter:
|
||||||
|
if isinstance(start_date_filter, str):
|
||||||
|
start_date = datetime.strptime(start_date_filter, '%Y-%m-%d').date()
|
||||||
|
else:
|
||||||
|
start_date = start_date_filter
|
||||||
|
|
||||||
|
if isinstance(end_date_filter, str):
|
||||||
|
end_date = datetime.strptime(end_date_filter, '%Y-%m-%d').date()
|
||||||
|
else:
|
||||||
|
end_date = end_date_filter
|
||||||
|
elif records:
|
||||||
|
start_date = min(r.attendance_date for r in records)
|
||||||
|
end_date = max(r.attendance_date for r in records)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Import parse function for work type detection
|
||||||
|
from working_hours_calculator import parse_employee_id_for_work_type
|
||||||
|
|
||||||
|
# Convert TimeAttendance records to format expected by calculator
|
||||||
|
converted_records = []
|
||||||
|
for record in records:
|
||||||
|
distance_value = getattr(record, 'distance', None)
|
||||||
|
|
||||||
|
record_type = 'check_in'
|
||||||
|
if hasattr(record, 'action_description') and record.action_description:
|
||||||
|
action_lower = record.action_description.lower()
|
||||||
|
if 'out' in action_lower or 'checkout' in action_lower:
|
||||||
|
record_type = 'check_out'
|
||||||
|
|
||||||
|
_, work_type = parse_employee_id_for_work_type(str(record.employee_id))
|
||||||
|
|
||||||
|
base_location_name = record.location_name
|
||||||
|
if work_type and work_type in ('PT', 'SP', 'PW'):
|
||||||
|
display_location_name = f"{base_location_name} ({work_type})"
|
||||||
|
else:
|
||||||
|
display_location_name = base_location_name
|
||||||
|
|
||||||
|
converted_record = type('Record', (), {
|
||||||
|
'id': record.id,
|
||||||
|
'employee_id': str(record.employee_id),
|
||||||
|
'employee_name': record.employee_name,
|
||||||
|
'check_in_date': record.attendance_date,
|
||||||
|
'check_in_time': record.attendance_time,
|
||||||
|
'location_name': display_location_name,
|
||||||
|
'original_location_name': base_location_name,
|
||||||
|
'work_type': work_type,
|
||||||
|
'latitude': None,
|
||||||
|
'longitude': None,
|
||||||
|
'distance': distance_value,
|
||||||
|
'record_type': record_type,
|
||||||
|
'action_description': record.action_description,
|
||||||
|
'event_description': record.event_description or '',
|
||||||
|
'recorded_address': record.recorded_address or '',
|
||||||
|
'qr_code': type('QRCode', (), {
|
||||||
|
'location': base_location_name,
|
||||||
|
'location_address': record.recorded_address or '',
|
||||||
|
'project': None
|
||||||
|
})()
|
||||||
|
})()
|
||||||
|
converted_records.append(converted_record)
|
||||||
|
|
||||||
|
# Group records by location (building)
|
||||||
|
location_groups = {}
|
||||||
|
for record in converted_records:
|
||||||
|
loc_name = record.original_location_name or 'Unknown Location'
|
||||||
|
if loc_name not in location_groups:
|
||||||
|
location_groups[loc_name] = []
|
||||||
|
location_groups[loc_name].append(record)
|
||||||
|
|
||||||
|
# Sort locations alphabetically
|
||||||
|
sorted_locations = sorted(location_groups.keys())
|
||||||
|
|
||||||
|
# Log grouping info
|
||||||
|
logger_handler.logger.info(
|
||||||
|
f"Export by Building: Grouped {len(converted_records)} records into {len(sorted_locations)} locations"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate working hours using WorkingHoursCalculator for SP/PT/PW hours
|
||||||
|
calculator = WorkingHoursCalculator()
|
||||||
|
hours_data = calculator.calculate_all_employees_hours(
|
||||||
|
datetime.combine(start_date, datetime.min.time()),
|
||||||
|
datetime.combine(end_date, datetime.max.time()),
|
||||||
|
converted_records
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get employee names map
|
||||||
|
employee_names = {}
|
||||||
|
for record in records:
|
||||||
|
base_id, _ = parse_employee_id_for_work_type(str(record.employee_id))
|
||||||
|
if base_id not in employee_names:
|
||||||
|
employee_names[base_id] = record.employee_name
|
||||||
|
|
||||||
|
# Setup styles
|
||||||
|
header_font = Font(name='Arial', size=11, bold=True, color='FFFFFF')
|
||||||
|
header_fill = PatternFill(start_color='000000', end_color='000000', fill_type='solid')
|
||||||
|
data_font = Font(name='Arial', size=10)
|
||||||
|
bold_font = Font(name='Arial', size=10, bold=True)
|
||||||
|
italic_bold_font = Font(name='Arial', size=10, bold=True, italic=True)
|
||||||
|
border = Border(
|
||||||
|
left=Side(style='thin'),
|
||||||
|
right=Side(style='thin'),
|
||||||
|
top=Side(style='thin'),
|
||||||
|
bottom=Side(style='thin')
|
||||||
|
)
|
||||||
|
missed_punch_fill = PatternFill(start_color='FFC000', end_color='FFC000', fill_type='solid')
|
||||||
|
|
||||||
|
# Write main headers
|
||||||
|
current_row = 1
|
||||||
|
|
||||||
|
# Row 1: Company name
|
||||||
|
ws.merge_cells(f'A{current_row}:N{current_row}')
|
||||||
|
title_cell = ws.cell(row=current_row, column=1, value=os.environ.get('COMPANY_NAME', 'Your Company'))
|
||||||
|
title_cell.font = Font(name='Arial', size=14, bold=True)
|
||||||
|
title_cell.alignment = Alignment(horizontal='left')
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
# Row 2: Summary title
|
||||||
|
ws.merge_cells(f'A{current_row}:N{current_row}')
|
||||||
|
summary_cell = ws.cell(row=current_row, column=1, value='Summary report of Hours worked')
|
||||||
|
summary_cell.font = Font(name='Arial', size=12, bold=True)
|
||||||
|
summary_cell.alignment = Alignment(horizontal='left')
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
# Row 3: Project name
|
||||||
|
project_display = project_name_for_filename.replace('_', ' ').strip() if project_name_for_filename else "[Project Name]"
|
||||||
|
project_cell = ws.cell(row=current_row, column=1, value=project_display)
|
||||||
|
project_cell.font = Font(name='Arial', size=11, bold=True)
|
||||||
|
project_cell.alignment = Alignment(horizontal='left')
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
# Row 4: Date range
|
||||||
|
date_range_text = f"Date range: {start_date.strftime('%m/%d/%Y')} to {end_date.strftime('%m/%d/%Y')}"
|
||||||
|
ws.merge_cells(f'A{current_row}:N{current_row}')
|
||||||
|
date_cell = ws.cell(row=current_row, column=1, value=date_range_text)
|
||||||
|
date_cell.font = Font(name='Arial', size=11)
|
||||||
|
date_cell.alignment = Alignment(horizontal='left')
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
# Empty rows before first building
|
||||||
|
current_row += 2
|
||||||
|
|
||||||
|
# Process each building/location
|
||||||
|
for location_index, location_name in enumerate(sorted_locations, 1):
|
||||||
|
location_records = location_groups[location_name]
|
||||||
|
|
||||||
|
# Get zone info from QR code if available
|
||||||
|
zone_info = ''
|
||||||
|
try:
|
||||||
|
qr_code = QRCode.query.filter_by(location=location_name).first()
|
||||||
|
if qr_code:
|
||||||
|
zone_info = getattr(qr_code, 'zone', '') or ''
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Building header row
|
||||||
|
building_header = f"{location_index}) {location_name} - Zone {zone_info}"
|
||||||
|
ws.merge_cells(f'A{current_row}:O{current_row}')
|
||||||
|
building_cell = ws.cell(row=current_row, column=1, value=building_header)
|
||||||
|
building_cell.font = Font(name='Arial', size=11, bold=True)
|
||||||
|
building_cell.alignment = Alignment(horizontal='left')
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
# Get unique employees for this location
|
||||||
|
employees_at_location = {}
|
||||||
|
for record in location_records:
|
||||||
|
base_id, _ = parse_employee_id_for_work_type(record.employee_id)
|
||||||
|
if base_id not in employees_at_location:
|
||||||
|
employees_at_location[base_id] = []
|
||||||
|
employees_at_location[base_id].append(record)
|
||||||
|
|
||||||
|
# Sort employees by name
|
||||||
|
sorted_employee_ids = sorted(
|
||||||
|
employees_at_location.keys(),
|
||||||
|
key=lambda emp_id: employee_names.get(emp_id, f'Employee {emp_id}').lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process each employee at this location
|
||||||
|
for employee_id in sorted_employee_ids:
|
||||||
|
emp_records = employees_at_location[employee_id]
|
||||||
|
emp_name = employee_names.get(employee_id, f'Employee {employee_id}')
|
||||||
|
|
||||||
|
# Get SP/PT/PW hours from the calculator's hours_data for this employee
|
||||||
|
emp_hours_data = hours_data.get('employees', {}).get(employee_id, {})
|
||||||
|
grand_totals = emp_hours_data.get('grand_totals', {})
|
||||||
|
sp_hours = grand_totals.get('sp_hours', 0.0)
|
||||||
|
pw_hours = grand_totals.get('pw_hours', 0.0)
|
||||||
|
pt_hours = grand_totals.get('pt_hours', 0.0)
|
||||||
|
|
||||||
|
# Employee header row
|
||||||
|
ws.merge_cells(f'A{current_row}:O{current_row}')
|
||||||
|
emp_header = ws.cell(row=current_row, column=1,
|
||||||
|
value=f'Employee ID {employee_id}: {emp_name}')
|
||||||
|
emp_header.font = Font(name='Arial', size=11, bold=True)
|
||||||
|
emp_header.alignment = Alignment(horizontal='left')
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
# Column headers
|
||||||
|
headers = ['Day', 'Date', 'In', 'Out', 'Location', 'Zone', 'Hours/Building',
|
||||||
|
'Daily Total', 'Regular Hours', 'OT Hours', 'Building Address',
|
||||||
|
'Recorded Location', 'Distance (Mile)', 'Possible Violation']
|
||||||
|
|
||||||
|
for col, header in enumerate(headers, 1):
|
||||||
|
cell = ws.cell(row=current_row, column=col, value=header)
|
||||||
|
cell.font = header_font
|
||||||
|
cell.fill = header_fill
|
||||||
|
cell.border = border
|
||||||
|
cell.alignment = Alignment(horizontal='center', vertical='center')
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
# Group employee records by date
|
||||||
|
daily_records = {}
|
||||||
|
for record in emp_records:
|
||||||
|
date_key = record.check_in_date.strftime('%Y-%m-%d')
|
||||||
|
if date_key not in daily_records:
|
||||||
|
daily_records[date_key] = []
|
||||||
|
daily_records[date_key].append(record)
|
||||||
|
|
||||||
|
# Track weekly hours for overtime calculation
|
||||||
|
weekly_total_hours = 0
|
||||||
|
current_week_start = None
|
||||||
|
grand_regular_hours = 0
|
||||||
|
grand_ot_hours = 0
|
||||||
|
|
||||||
|
# Sort dates
|
||||||
|
sorted_dates = sorted(daily_records.keys())
|
||||||
|
|
||||||
|
for date_str in sorted_dates:
|
||||||
|
date_obj = datetime.strptime(date_str, '%Y-%m-%d')
|
||||||
|
day_records = sorted(daily_records[date_str], key=lambda x: x.check_in_time)
|
||||||
|
|
||||||
|
# Check for week boundary
|
||||||
|
week_start = date_obj - timedelta(days=date_obj.weekday())
|
||||||
|
if current_week_start is not None and week_start != current_week_start:
|
||||||
|
# Write weekly total row
|
||||||
|
week_regular = min(weekly_total_hours, 40.0)
|
||||||
|
week_overtime = max(0, weekly_total_hours - 40.0)
|
||||||
|
|
||||||
|
ws.cell(row=current_row, column=7, value='Weekly Total: ').font = bold_font
|
||||||
|
ws.cell(row=current_row, column=8, value=round(weekly_total_hours, 2)).font = bold_font
|
||||||
|
ws.cell(row=current_row, column=9, value=round(week_regular, 2)).font = bold_font
|
||||||
|
ws.cell(row=current_row, column=10, value=round(week_overtime, 2)).font = bold_font
|
||||||
|
|
||||||
|
grand_regular_hours += week_regular
|
||||||
|
grand_ot_hours += week_overtime
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
weekly_total_hours = 0
|
||||||
|
|
||||||
|
current_week_start = week_start
|
||||||
|
|
||||||
|
# Process day records - create IN/OUT pairs
|
||||||
|
record_info = []
|
||||||
|
for record in day_records:
|
||||||
|
action_desc = record.action_description.lower() if record.action_description else ''
|
||||||
|
is_out = 'out' in action_desc or 'checkout' in action_desc
|
||||||
|
record_info.append({
|
||||||
|
'record': record,
|
||||||
|
'is_out': is_out,
|
||||||
|
'used': False
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create pairs
|
||||||
|
pairs = []
|
||||||
|
i = 0
|
||||||
|
while i < len(record_info):
|
||||||
|
if record_info[i]['used']:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not record_info[i]['is_out']: # IN
|
||||||
|
out_found = False
|
||||||
|
for j in range(i + 1, len(record_info)):
|
||||||
|
if record_info[j]['used']:
|
||||||
|
continue
|
||||||
|
if record_info[j]['is_out']:
|
||||||
|
pairs.append({
|
||||||
|
'check_in': record_info[i]['record'],
|
||||||
|
'check_out': record_info[j]['record'],
|
||||||
|
'is_miss_punch': False
|
||||||
|
})
|
||||||
|
record_info[i]['used'] = True
|
||||||
|
record_info[j]['used'] = True
|
||||||
|
out_found = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not out_found:
|
||||||
|
pairs.append({
|
||||||
|
'check_in': record_info[i]['record'],
|
||||||
|
'check_out': None,
|
||||||
|
'is_miss_punch': True
|
||||||
|
})
|
||||||
|
record_info[i]['used'] = True
|
||||||
|
else: # Orphaned OUT
|
||||||
|
pairs.append({
|
||||||
|
'check_in': None,
|
||||||
|
'check_out': record_info[i]['record'],
|
||||||
|
'is_miss_punch': True
|
||||||
|
})
|
||||||
|
record_info[i]['used'] = True
|
||||||
|
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# Calculate daily hours
|
||||||
|
daily_hours = 0
|
||||||
|
for pair in pairs:
|
||||||
|
if pair['check_in'] and pair['check_out'] and not pair['is_miss_punch']:
|
||||||
|
pair_in = datetime.combine(date_obj, pair['check_in'].check_in_time)
|
||||||
|
pair_out = datetime.combine(date_obj, pair['check_out'].check_in_time)
|
||||||
|
daily_hours += (pair_out - pair_in).total_seconds() / 3600.0
|
||||||
|
|
||||||
|
daily_hours = round(daily_hours, 2)
|
||||||
|
weekly_total_hours += daily_hours
|
||||||
|
|
||||||
|
# Write pairs
|
||||||
|
for pair_idx, pair in enumerate(pairs):
|
||||||
|
check_in = pair['check_in']
|
||||||
|
check_out = pair['check_out']
|
||||||
|
is_miss_punch = pair['is_miss_punch']
|
||||||
|
|
||||||
|
# Day/date only on first row
|
||||||
|
day_display = date_obj.strftime('%A').upper() if pair_idx == 0 else ''
|
||||||
|
date_display = date_obj.strftime('%m/%d/%Y') if pair_idx == 0 else ''
|
||||||
|
|
||||||
|
# Calculate hours for this pair
|
||||||
|
if check_in and check_out and not is_miss_punch:
|
||||||
|
pair_hours = round((datetime.combine(date_obj, check_out.check_in_time) -
|
||||||
|
datetime.combine(date_obj, check_in.check_in_time)).total_seconds() / 3600.0, 2)
|
||||||
|
else:
|
||||||
|
pair_hours = 'Missed Punch'
|
||||||
|
|
||||||
|
# Daily total only on last row of day
|
||||||
|
daily_total_display = daily_hours if pair_idx == len(pairs) - 1 else ''
|
||||||
|
|
||||||
|
# Get record for address/distance info
|
||||||
|
ref_record = check_in or check_out
|
||||||
|
|
||||||
|
# Build row data
|
||||||
|
row_data = [
|
||||||
|
day_display,
|
||||||
|
date_display,
|
||||||
|
check_in.check_in_time.strftime('%I:%M:%S %p') if check_in else '',
|
||||||
|
check_out.check_in_time.strftime('%I:%M:%S %p') if check_out else '',
|
||||||
|
ref_record.location_name if ref_record else '',
|
||||||
|
zone_info,
|
||||||
|
pair_hours,
|
||||||
|
daily_total_display if daily_total_display else '',
|
||||||
|
'', # Regular Hours
|
||||||
|
'', # OT Hours
|
||||||
|
'', # Building Address (will be HYPERLINK)
|
||||||
|
'', # Recorded Location (will be HYPERLINK)
|
||||||
|
getattr(ref_record, 'distance', None) or '' if ref_record else '',
|
||||||
|
calculate_possible_violation(getattr(ref_record, 'distance', None)) if ref_record else ''
|
||||||
|
]
|
||||||
|
|
||||||
|
for col, value in enumerate(row_data, 1):
|
||||||
|
cell = ws.cell(row=current_row, column=col, value=value)
|
||||||
|
cell.font = data_font
|
||||||
|
cell.border = border
|
||||||
|
if col == 7 and value == 'Missed Punch':
|
||||||
|
cell.fill = missed_punch_fill
|
||||||
|
|
||||||
|
# Add HYPERLINK formulas for addresses
|
||||||
|
if ref_record:
|
||||||
|
building_address = ref_record.event_description or ''
|
||||||
|
if building_address:
|
||||||
|
encoded_addr = building_address.replace(' ', '+').replace(',', '%2C')
|
||||||
|
hyperlink_formula = f'=HYPERLINK("https://www.google.com/maps/place/{encoded_addr}","{building_address}")'
|
||||||
|
ws.cell(row=current_row, column=11, value=hyperlink_formula)
|
||||||
|
|
||||||
|
recorded_addr = ref_record.recorded_address or ''
|
||||||
|
if recorded_addr:
|
||||||
|
encoded_recorded = recorded_addr.replace(' ', '+').replace(',', '%2C')
|
||||||
|
recorded_hyperlink = f'=HYPERLINK("https://www.google.com/maps/place/{encoded_recorded}","{recorded_addr}")'
|
||||||
|
ws.cell(row=current_row, column=12, value=recorded_hyperlink)
|
||||||
|
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
# Write final weekly total
|
||||||
|
if weekly_total_hours > 0:
|
||||||
|
week_regular = min(weekly_total_hours, 40.0)
|
||||||
|
week_overtime = max(0, weekly_total_hours - 40.0)
|
||||||
|
|
||||||
|
ws.cell(row=current_row, column=7, value='Weekly Total: ').font = bold_font
|
||||||
|
ws.cell(row=current_row, column=8, value=round(weekly_total_hours, 2)).font = bold_font
|
||||||
|
ws.cell(row=current_row, column=9, value=round(week_regular, 2)).font = bold_font
|
||||||
|
ws.cell(row=current_row, column=10, value=round(week_overtime, 2)).font = bold_font
|
||||||
|
|
||||||
|
grand_regular_hours += week_regular
|
||||||
|
grand_ot_hours += week_overtime
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# Write extra working hours rows (SP/PW/PT) if employee has any
|
||||||
|
# This matches the behavior of the regular Export to Excel
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
# Write SP row if hours > 0
|
||||||
|
if sp_hours > 0:
|
||||||
|
ws.cell(row=current_row, column=7, value='Special Project (SP): ').font = italic_bold_font
|
||||||
|
ws.cell(row=current_row, column=9, value=round(sp_hours, 2)).font = italic_bold_font
|
||||||
|
# Log SP hours export
|
||||||
|
logger_handler.logger.info(f"Export by Building: Employee {employee_id} SP hours: {sp_hours:.2f}")
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
# Write PW row if hours > 0
|
||||||
|
if pw_hours > 0:
|
||||||
|
ws.cell(row=current_row, column=7, value='Periodic Work (PW): ').font = italic_bold_font
|
||||||
|
ws.cell(row=current_row, column=9, value=round(pw_hours, 2)).font = italic_bold_font
|
||||||
|
# Log PW hours export
|
||||||
|
logger_handler.logger.info(f"Export by Building: Employee {employee_id} PW hours: {pw_hours:.2f}")
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
# Write PT row if hours > 0
|
||||||
|
if pt_hours > 0:
|
||||||
|
ws.cell(row=current_row, column=7, value='Project Team (PT): ').font = italic_bold_font
|
||||||
|
ws.cell(row=current_row, column=9, value=round(pt_hours, 2)).font = italic_bold_font
|
||||||
|
# Log PT hours export
|
||||||
|
logger_handler.logger.info(f"Export by Building: Employee {employee_id} PT hours: {pt_hours:.2f}")
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# End of extra working hours section
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
# Write GRAND TOTAL row
|
||||||
|
ws.cell(row=current_row, column=7, value='GRAND TOTAL: ').font = Font(name='Arial', size=10, bold=True)
|
||||||
|
ws.cell(row=current_row, column=9, value=round(grand_regular_hours, 2)).font = Font(name='Arial', size=10, bold=True)
|
||||||
|
ws.cell(row=current_row, column=10, value=round(grand_ot_hours, 2)).font = Font(name='Arial', size=10, bold=True)
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
# Empty row after each employee
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
# Empty row after each building
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
# Auto-size columns
|
||||||
|
for col_idx in range(1, 15):
|
||||||
|
column_letter = get_column_letter(col_idx)
|
||||||
|
|
||||||
|
if col_idx == 1:
|
||||||
|
ws.column_dimensions[column_letter].width = 18
|
||||||
|
continue
|
||||||
|
|
||||||
|
max_length = 0
|
||||||
|
for row in ws.iter_rows(min_col=col_idx, max_col=col_idx):
|
||||||
|
for cell in row:
|
||||||
|
if isinstance(cell, openpyxl.cell.cell.MergedCell):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if cell.value and len(str(cell.value)) > max_length:
|
||||||
|
max_length = len(str(cell.value))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
adjusted_width = min(max_length + 2, 50)
|
||||||
|
ws.column_dimensions[column_letter].width = adjusted_width
|
||||||
|
|
||||||
|
# Save to BytesIO
|
||||||
|
output = io.BytesIO()
|
||||||
|
wb.save(output)
|
||||||
|
output.seek(0)
|
||||||
|
|
||||||
|
# Filename
|
||||||
|
if date_range_str:
|
||||||
|
filename = f'{project_name_for_filename}time_attendance_by_building_{date_range_str}.xlsx'
|
||||||
|
else:
|
||||||
|
filename = f'{project_name_for_filename}time_attendance_by_building.xlsx'
|
||||||
|
|
||||||
|
# Log successful export
|
||||||
|
logger_handler.logger.info(
|
||||||
|
f"Export by Building completed: {filename} with {len(sorted_locations)} buildings"
|
||||||
|
)
|
||||||
|
|
||||||
|
return send_file(
|
||||||
|
output,
|
||||||
|
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=filename
|
||||||
|
)
|
||||||
|
|
||||||
@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')
|
||||||
|
|||||||
@@ -37,6 +37,10 @@
|
|||||||
<i class="fas fa-file-excel"></i>
|
<i class="fas fa-file-excel"></i>
|
||||||
Export to Excel
|
Export to Excel
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-secondary" onclick="exportByBuilding()">
|
||||||
|
<i class="fas fa-building"></i>
|
||||||
|
Export by Building
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -276,7 +280,7 @@
|
|||||||
<span class="pagination-btn active">{{ page_num }}</span>
|
<span class="pagination-btn active">{{ page_num }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="pagination-ellipsis">...</span>
|
<span class="pagination-ellipsis">...</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
@@ -297,16 +301,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty-state">
|
<div class="no-records">
|
||||||
<div class="empty-icon">
|
<i class="fas fa-folder-open"></i>
|
||||||
<i class="fas fa-inbox"></i>
|
<p>No attendance records found matching your criteria.</p>
|
||||||
</div>
|
|
||||||
<h3>No Records Found</h3>
|
|
||||||
<p>No time attendance records match your current filters.</p>
|
|
||||||
<a href="{{ url_for('time_attendance_records') }}" class="btn btn-primary">
|
|
||||||
<i class="fas fa-refresh"></i>
|
|
||||||
Clear Filters
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -369,6 +366,26 @@ function exportRecords(format) {
|
|||||||
window.location.href = `/time-attendance/export?${params.toString()}`;
|
window.location.href = `/time-attendance/export?${params.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function exportByBuilding() {
|
||||||
|
// Get current filter values
|
||||||
|
const employeeId = document.querySelector('select[name="employee_id"]')?.value || '';
|
||||||
|
const locationName = document.querySelector('select[name="location_name"]')?.value || '';
|
||||||
|
const projectId = document.querySelector('select[name="project_id"]')?.value || '';
|
||||||
|
const startDate = document.querySelector('input[name="start_date"]')?.value || '';
|
||||||
|
const endDate = document.querySelector('input[name="end_date"]')?.value || '';
|
||||||
|
|
||||||
|
// Build query parameters
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (employeeId) params.append('employee_id', employeeId);
|
||||||
|
if (locationName) params.append('location_name', locationName);
|
||||||
|
if (projectId) params.append('project_id', projectId);
|
||||||
|
if (startDate) params.append('start_date', startDate);
|
||||||
|
if (endDate) params.append('end_date', endDate);
|
||||||
|
|
||||||
|
// Redirect to export by building endpoint
|
||||||
|
window.location.href = `/time-attendance/export-by-building?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Close dropdown when clicking outside
|
// Close dropdown when clicking outside
|
||||||
document.addEventListener('click', function(event) {
|
document.addEventListener('click', function(event) {
|
||||||
const menu = document.getElementById('exportMenu');
|
const menu = document.getElementById('exportMenu');
|
||||||
@@ -489,6 +506,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
.header-actions {
|
.header-actions {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.export-dropdown-menu {
|
.export-dropdown-menu {
|
||||||
@@ -590,6 +610,16 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
right: auto;
|
right: auto;
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions .btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user