This commit is contained in:
2025-12-29 13:31:50 -05:00
5 changed files with 1688 additions and 340 deletions
+199 -55
View File
@@ -47,30 +47,53 @@ def extract_street_address(address):
Extract the core street address (number + street name) from an address string. Extract the core street address (number + street name) from an address string.
This is the most reliable identifier for location matching. This is the most reliable identifier for location matching.
Handles cases where:
- Street number is at the beginning: "735 18th St S"
- Building name comes first: "Aurora Hills Library, 735, 18th Street South"
Args: Args:
address: Normalized address string address: Normalized address string
Returns: Returns:
Core street address string (e.g., "3402 s glebe rd") Core street address string (e.g., "735 18th st s")
""" """
if not address: if not address:
return "" return ""
# Pattern to match: street number + optional directional + street name + street type addr_lower = address.lower()
# Examples: "3402 south glebe road", "7100 gordon rd", "123 n main st"
street_pattern = r'^(\d+[-\w]*)\s+([nsew]?\s*[\w\s]+?\s*(?:rd|st|ave|dr|ln|ct|blvd|pkwy|cir|pl|ter|hwy|way|trail|pike|run|walk|path|loop))'
match = re.search(street_pattern, address.lower()) # Pattern to match: street number + optional directional + street name + street type
# This pattern searches ANYWHERE in the string, not just at the beginning
# Examples: "735 18th st s", "3402 south glebe road", "7100 gordon rd"
street_types = r'(?:rd|st|ave|dr|ln|ct|blvd|pkwy|cir|pl|ter|hwy|way|trail|pike|run|walk|path|loop|road|street|avenue|drive|lane|court|boulevard|parkway|circle|place|terrace|highway)'
# Pattern: number + ordinal/street name + optional directional + street type
# Handles: "735 18th st s", "735, 18th street south"
street_pattern = rf'(\d+)[\s,]+(\d*(?:st|nd|rd|th)?\s*[\w\s]*?{street_types})(?:\s+([nsew]|north|south|east|west))?'
match = re.search(street_pattern, addr_lower)
if match: if match:
street_num = match.group(1).strip() street_num = match.group(1).strip()
street_name = match.group(2).strip() street_name = match.group(2).strip()
# Clean up extra spaces direction = match.group(3) if match.group(3) else ""
street_name = re.sub(r'\s+', ' ', street_name)
return f"{street_num} {street_name}"
# Fallback: try to extract just number + next few words # Clean up extra spaces and commas
simple_pattern = r'^(\d+[-\w]*)\s+([\w\s]+)' street_name = re.sub(r'[\s,]+', ' ', street_name).strip()
match = re.search(simple_pattern, address.lower())
# Normalize direction
dir_map = {'north': 'n', 'south': 's', 'east': 'e', 'west': 'w'}
if direction:
direction = dir_map.get(direction, direction)
result = f"{street_num} {street_name}"
if direction:
result += f" {direction}"
return result
# Fallback: try to find just a street number followed by some words
simple_pattern = r'(\d+)[\s,]+([\w\s]+)'
match = re.search(simple_pattern, addr_lower)
if match: if match:
street_num = match.group(1).strip() street_num = match.group(1).strip()
# Take words until we hit something that looks like a city/state # Take words until we hit something that looks like a city/state
@@ -108,6 +131,10 @@ def normalize_address(address):
"3402 South Glebe Road Arlington VA 22202" "3402 South Glebe Road Arlington VA 22202"
"3402, South Glebe Road, Aurora Hills, Arlington VA 22202" "3402, South Glebe Road, Aurora Hills, Arlington VA 22202"
Both normalize to: "3402 s glebe rd, arlington, va 22202" Both normalize to: "3402 s glebe rd, arlington, va 22202"
"Aurora Hills Branch Library, 735, 18th Street South, Arlington, VA 22202"
"735 18th St S, Arlington, VA 22202"
Both normalize to: "735 18th st s, arlington, va 22202"
""" """
if not address or not isinstance(address, str): if not address or not isinstance(address, str):
return address return address
@@ -119,6 +146,13 @@ def normalize_address(address):
normalized = re.sub(r'\s+', ' ', normalized) # Multiple spaces to single space normalized = re.sub(r'\s+', ' ', normalized) # Multiple spaces to single space
normalized = re.sub(r'\s*,\s*', ', ', normalized) # Normalize comma spacing normalized = re.sub(r'\s*,\s*', ', ', normalized) # Normalize comma spacing
# Remove building/location names that come BEFORE the street number
# Pattern: remove text before a street number if it looks like a building name
# Examples: "Aurora Hills Branch Library, 735" → "735"
# "Fire Station #7, 123 Main St" → "123 Main St"
building_pattern = r'^[^,\d]*(?:library|station|center|building|plaza|tower|hall|office|school|church|hospital|clinic|bank|hotel|restaurant|store|shop|mall|complex|headquarters|hq|branch)[^,\d]*,\s*'
normalized = re.sub(building_pattern, '', normalized, flags=re.IGNORECASE)
# Standardize common street abbreviations to short forms # Standardize common street abbreviations to short forms
street_abbrev = { street_abbrev = {
r'\broad\b': 'rd', r'\broad\b': 'rd',
@@ -154,39 +188,134 @@ def normalize_address(address):
for full_form, abbrev in directionals.items(): for full_form, abbrev in directionals.items():
normalized = re.sub(full_form, abbrev, normalized) normalized = re.sub(full_form, abbrev, normalized)
# Convert full state names to abbreviations
state_names = {
r'\bvirginia\b': 'va',
r'\bmaryland\b': 'md',
r'\bdistrict of columbia\b': 'dc',
r'\bcalifornia\b': 'ca',
r'\bnew york\b': 'ny',
r'\btexas\b': 'tx',
r'\bflorida\b': 'fl',
r'\bpennsylvania\b': 'pa',
r'\billinois\b': 'il',
r'\bohio\b': 'oh',
r'\bgeorgia\b': 'ga',
r'\bnorth carolina\b': 'nc',
r'\bnew jersey\b': 'nj',
r'\bwashington\b': 'wa',
r'\bmassachusetts\b': 'ma',
r'\barizona\b': 'az',
r'\bcolorado\b': 'co',
r'\btennessee\b': 'tn',
r'\bindiana\b': 'in',
r'\bmissouri\b': 'mo',
r'\bwisconsin\b': 'wi',
r'\bminnesota\b': 'mn',
r'\bsouth carolina\b': 'sc',
r'\balabama\b': 'al',
r'\blouisiana\b': 'la',
r'\bkentucky\b': 'ky',
r'\boregon\b': 'or',
r'\boklahoma\b': 'ok',
r'\bconnecticut\b': 'ct',
r'\biowa\b': 'ia',
r'\bmississippi\b': 'ms',
r'\barkansas\b': 'ar',
r'\bkansas\b': 'ks',
r'\butah\b': 'ut',
r'\bnevada\b': 'nv',
r'\bnew mexico\b': 'nm',
r'\bwest virginia\b': 'wv',
r'\bnebraska\b': 'ne',
r'\bidaho\b': 'id',
r'\bhawaii\b': 'hi',
r'\bmaine\b': 'me',
r'\bnew hampshire\b': 'nh',
r'\brhode island\b': 'ri',
r'\bmontana\b': 'mt',
r'\bdelaware\b': 'de',
r'\bsouth dakota\b': 'sd',
r'\bnorth dakota\b': 'nd',
r'\balaska\b': 'ak',
r'\bvermont\b': 'vt',
r'\bwyoming\b': 'wy'
}
for full_name, abbrev in state_names.items():
normalized = re.sub(full_name, abbrev, normalized)
# Remove neighborhood/district names that aren't essential for location # Remove neighborhood/district names that aren't essential for location
# Examples: "Aurora Hills", "Downtown", etc. # Examples: "Aurora Hills", "Downtown", etc.
parts = [p.strip() for p in normalized.split(',')] parts = [p.strip() for p in normalized.split(',')]
# Keep: street address, city, state, zip # Keep: street address, city, state, zip
# Remove: neighborhood names, building names, country suffixes # Remove: neighborhood names, building names, country suffixes, county names
filtered_parts = [] filtered_parts = []
# Known neighborhood keywords to remove (these don't affect geocoding) # Known neighborhood keywords to remove (these don't affect geocoding)
neighborhood_keywords = ['hills', 'heights', 'park', 'village', 'estates', # NOTE: These should only match if NOT followed by a street type suffix
neighborhood_keywords = ['hills', 'heights', 'village', 'estates',
'manor', 'gardens', 'terrace', 'commons', 'plaza', 'manor', 'gardens', 'terrace', 'commons', 'plaza',
'downtown', 'midtown', 'uptown', 'district', 'center', 'downtown', 'midtown', 'uptown', 'district', 'center',
'crossing', 'corner', 'square', 'point', 'landing', 'crossing', 'corner', 'square', 'point', 'landing',
'aurora', 'crystal', 'forest', 'lake', 'river', 'creek', 'aurora', 'crystal', 'forest', 'lake', 'river', 'creek',
'meadow', 'valley', 'ridge', 'grove', 'glen', 'woods'] 'meadow', 'valley', 'ridge', 'grove', 'glen', 'woods',
'addison', 'colonial', 'fairfax', 'heritage', 'liberty',
'ballston', 'clarendon', 'rosslyn', 'shirlington']
# Street type suffixes - if a part contains these, it's likely a street address, not a neighborhood
street_type_suffixes = ['rd', 'st', 'ave', 'dr', 'ln', 'ct', 'blvd', 'pkwy', 'cir',
'pl', 'ter', 'hwy', 'way', 'road', 'street', 'avenue',
'drive', 'lane', 'court', 'boulevard', 'parkway', 'circle',
'place', 'terrace', 'highway', 'trail', 'pike', 'run',
'walk', 'path', 'loop']
# Country names and suffixes to remove (English and other languages)
country_suffixes = ['usa', 'us', 'united states', 'united states of america',
'estados unidos', 'estados unidos de américa', 'estados unidos de america',
'eeuu', 'e.u.', 'u.s.a.', 'u.s.', 'america', 'américas']
for i, part in enumerate(parts): for i, part in enumerate(parts):
part_clean = part.strip() part_clean = part.strip()
# Always keep first part (street address) # Always keep first part (street address) - but only if it contains a number
if i == 0: if i == 0:
filtered_parts.append(part_clean) # Check if this looks like a building name (no street number)
if re.search(r'\d', part_clean):
filtered_parts.append(part_clean)
else:
print(f" Removing building name: '{part_clean}'")
continue continue
# Skip empty parts # Skip empty parts
if not part_clean: if not part_clean:
continue continue
# Skip country suffixes # Skip country suffixes (multiple languages)
if part_clean in ['usa', 'us', 'united states']: if part_clean in country_suffixes:
print(f" Removing country: '{part_clean}'")
continue continue
# Skip if it's a neighborhood name (contains neighborhood keywords but no numbers) # Skip county names (e.g., "Arlington County", "Fairfax County")
if 'county' in part_clean:
print(f" Removing county: '{part_clean}'")
continue
# Check if this part contains a street type suffix - if so, it's a street address, KEEP IT
has_street_suffix = False
for suffix in street_type_suffixes:
# Match word boundary to avoid partial matches (e.g., "dr" in "andra")
if re.search(rf'\b{suffix}\b', part_clean):
has_street_suffix = True
break
if has_street_suffix:
# This is a street address (e.g., "n park dr", "18th st s"), keep it
filtered_parts.append(part_clean)
continue
# Skip if it's a neighborhood name (contains neighborhood keywords but no numbers and no street suffix)
is_neighborhood = False is_neighborhood = False
for keyword in neighborhood_keywords: for keyword in neighborhood_keywords:
if keyword in part_clean and not re.search(r'\d', part_clean): if keyword in part_clean and not re.search(r'\d', part_clean):
@@ -214,8 +343,8 @@ def normalize_address(address):
# Reconstruct address # Reconstruct address
normalized = ', '.join(filtered_parts) normalized = ', '.join(filtered_parts)
# Remove common country suffixes that don't affect location # Remove common country suffixes that don't affect location (final cleanup)
normalized = re.sub(r',?\s*(usa|united states|us)$', '', normalized) normalized = re.sub(r',?\s*(usa|united states|us|estados unidos.*?|eeuu|u\.s\.a?\.|america|américas?)$', '', normalized, flags=re.IGNORECASE)
# Final cleanup: remove trailing commas and spaces # Final cleanup: remove trailing commas and spaces
normalized = normalized.strip(', ') normalized = normalized.strip(', ')
@@ -230,14 +359,16 @@ def normalize_address(address):
def extract_address_components(address): def extract_address_components(address):
""" """
Extract key components from an address for comparison. Extract key components from an address for comparison.
Handles addresses where the street number may not be at the beginning
(e.g., "Aurora Hills Library, 735, 18th Street South")
Args: Args:
address: Address string (raw or normalized) address: Address string (raw or normalized)
Returns: Returns:
Dictionary with extracted components: Dictionary with extracted components:
- street_number: The street number (e.g., "3402") - street_number: The street number (e.g., "735")
- street_name: The street name with type (e.g., "s glebe rd") - street_name: The street name with type (e.g., "18th st s")
- city: City name if found - city: City name if found
- state: State abbreviation if found - state: State abbreviation if found
- zip_code: ZIP code if found - zip_code: ZIP code if found
@@ -255,49 +386,62 @@ def extract_address_components(address):
'zip_code': None 'zip_code': None
} }
# Extract street number (at the beginning) # Extract ZIP code first (most reliable)
street_num_match = re.match(r'^(\d+[-\w]*)', addr_lower)
if street_num_match:
components['street_number'] = street_num_match.group(1)
# Extract ZIP code
zip_match = re.search(r'\b(\d{5})(?:-\d{4})?\b', addr_lower) zip_match = re.search(r'\b(\d{5})(?:-\d{4})?\b', addr_lower)
if zip_match: if zip_match:
components['zip_code'] = zip_match.group(1) components['zip_code'] = zip_match.group(1)
# Extract state (2-letter abbreviation before or after zip) # Extract state (2-letter abbreviation, typically before zip or at end)
state_match = re.search(r'\b([a-z]{2})\s*(?:\d{5}|$)', addr_lower) # Also handle full state names that might not have been normalized
valid_states = ['al', 'ak', 'az', 'ar', 'ca', 'co', 'ct', 'de', 'fl', 'ga',
'hi', 'id', 'il', 'in', 'ia', 'ks', 'ky', 'la', 'me', 'md',
'ma', 'mi', 'mn', 'ms', 'mo', 'mt', 'ne', 'nv', 'nh', 'nj',
'nm', 'ny', 'nc', 'nd', 'oh', 'ok', 'or', 'pa', 'ri', 'sc',
'sd', 'tn', 'tx', 'ut', 'vt', 'va', 'wa', 'wv', 'wi', 'wy', 'dc']
state_match = re.search(r'\b([a-z]{2})\s*(?:,?\s*\d{5}|,|$)', addr_lower)
if state_match: if state_match:
potential_state = state_match.group(1) potential_state = state_match.group(1)
# Validate it's a real state abbreviation
valid_states = ['al', 'ak', 'az', 'ar', 'ca', 'co', 'ct', 'de', 'fl', 'ga',
'hi', 'id', 'il', 'in', 'ia', 'ks', 'ky', 'la', 'me', 'md',
'ma', 'mi', 'mn', 'ms', 'mo', 'mt', 'ne', 'nv', 'nh', 'nj',
'nm', 'ny', 'nc', 'nd', 'oh', 'ok', 'or', 'pa', 'ri', 'sc',
'sd', 'tn', 'tx', 'ut', 'vt', 'va', 'wa', 'wv', 'wi', 'wy', 'dc']
if potential_state in valid_states: if potential_state in valid_states:
components['state'] = potential_state components['state'] = potential_state
# Extract street name (between number and city/state/zip) # Extract street number - look for it ANYWHERE in the address
# This is the trickiest part # Pattern: standalone number that's likely a street number (not a zip code or ordinal in street name)
if components['street_number']: # Match numbers like "735" or "3402" but not "22202" (zip) or "18th" (ordinal)
# Remove street number from beginning
remainder = addr_lower[len(components['street_number']):].strip()
remainder = remainder.lstrip(',').strip()
# Look for street type keywords # First, try to find a number followed by a street-like pattern
street_types = ['rd', 'st', 'ave', 'dr', 'ln', 'ct', 'blvd', 'pkwy', 'cir', street_num_pattern = r'(?:^|,\s*)(\d{1,5})(?:\s*,\s*|\s+)(\d*(?:st|nd|rd|th)?\s*[\w\s]*?(?:rd|st|ave|dr|ln|ct|blvd|pkwy|cir|pl|ter|hwy|way|street|road|avenue|drive|lane|court|boulevard))'
'pl', 'ter', 'hwy', 'way', 'trail', 'pike', 'run', 'walk',
'path', 'loop', 'road', 'street', 'avenue', 'drive', 'lane',
'court', 'boulevard', 'parkway', 'circle', 'place', 'terrace',
'highway']
for st_type in street_types: match = re.search(street_num_pattern, addr_lower)
pattern = rf'^([\w\s]+?\s*{st_type})\b' if match:
match = re.search(pattern, remainder) components['street_number'] = match.group(1)
if match: street_name_raw = match.group(2).strip()
components['street_name'] = match.group(1).strip() # Clean up the street name
break street_name_raw = re.sub(r'[\s,]+', ' ', street_name_raw)
components['street_name'] = street_name_raw
else:
# Fallback: try simpler pattern - just find a number at the start or after comma
simple_num_match = re.search(r'(?:^|,\s*)(\d{1,5})(?:\s*,|\s+)(?!\d{4,5}\b)', addr_lower)
if simple_num_match:
components['street_number'] = simple_num_match.group(1)
# Try to extract street name after the number
remainder = addr_lower[simple_num_match.end():]
remainder = remainder.lstrip(', ')
# Look for street type keywords
street_types = ['rd', 'st', 'ave', 'dr', 'ln', 'ct', 'blvd', 'pkwy', 'cir',
'pl', 'ter', 'hwy', 'way', 'trail', 'pike', 'run', 'walk',
'path', 'loop', 'road', 'street', 'avenue', 'drive', 'lane',
'court', 'boulevard', 'parkway', 'circle', 'place', 'terrace',
'highway']
for st_type in street_types:
pattern = rf'^([\w\s]+?\s*{st_type})\b'
st_match = re.search(pattern, remainder)
if st_match:
components['street_name'] = st_match.group(1).strip()
break
return components return components
+729 -8
View File
@@ -5348,9 +5348,12 @@ def verification_review():
status_filter = request.args.get('status', 'pending') status_filter = request.args.get('status', 'pending')
date_from = request.args.get('date_from', '') date_from = request.args.get('date_from', '')
date_to = request.args.get('date_to', '') date_to = request.args.get('date_to', '')
project_filter = request.args.get('project', '')
location_filter = request.args.get('location', '')
employee_filter = request.args.get('employee', '')
# Build query # Build query - join with QRCode to access project_id
query = AttendanceData.query.filter( query = AttendanceData.query.join(QRCode).filter(
AttendanceData.verification_required == True AttendanceData.verification_required == True
) )
@@ -5363,11 +5366,54 @@ def verification_review():
if date_to: if date_to:
query = query.filter(AttendanceData.check_in_date <= date_to) query = query.filter(AttendanceData.check_in_date <= date_to)
# Apply project filter
if project_filter:
try:
query = query.filter(QRCode.project_id == int(project_filter))
except (ValueError, TypeError):
pass
# Apply location filter
if location_filter:
query = query.filter(AttendanceData.location_name.ilike(f'%{location_filter}%'))
# Apply employee ID filter
if employee_filter:
query = query.filter(AttendanceData.employee_id.ilike(f'%{employee_filter}%'))
# Get records with QR code information # Get records with QR code information
verifications = query.join(QRCode).order_by( verifications = query.order_by(
AttendanceData.verification_timestamp.desc() AttendanceData.verification_timestamp.desc()
).all() ).all()
# Build a dictionary for employee names lookup
employee_names = {}
for record in verifications:
if record.employee_id and record.employee_id not in employee_names:
try:
employee = Employee.query.filter_by(id=int(record.employee_id)).first()
if employee:
employee_names[record.employee_id] = f"{employee.lastName}, {employee.firstName}"
else:
employee_names[record.employee_id] = None
except (ValueError, TypeError):
employee_names[record.employee_id] = None
# Build a dictionary for project names lookup
project_names = {}
for record in verifications:
if record.qr_code and record.qr_code.project_id:
project_id = record.qr_code.project_id
if project_id not in project_names:
try:
project = Project.query.get(project_id)
if project:
project_names[project_id] = project.name
else:
project_names[project_id] = None
except Exception:
project_names[project_id] = None
# Get counts for status badges # Get counts for status badges
pending_count = AttendanceData.query.filter( pending_count = AttendanceData.query.filter(
AttendanceData.verification_status == 'pending' AttendanceData.verification_status == 'pending'
@@ -5381,6 +5427,20 @@ def verification_review():
AttendanceData.verification_status == 'rejected' AttendanceData.verification_status == 'rejected'
).count() ).count()
# Get all projects for filter dropdown
projects = Project.query.filter_by(active_status=True).order_by(Project.name).all()
# Get unique locations for filter dropdown
locations = db.session.query(AttendanceData.location_name).filter(
AttendanceData.verification_required == True
).distinct().order_by(AttendanceData.location_name).all()
location_list = [loc[0] for loc in locations if loc[0]]
# Log access
logger_handler.logger.info(
f"User {session.get('username')} ({session.get('role')}) accessed verification review page"
)
return render_template('verification_review.html', return render_template('verification_review.html',
verifications=verifications, verifications=verifications,
pending_count=pending_count, pending_count=pending_count,
@@ -5388,7 +5448,14 @@ def verification_review():
rejected_count=rejected_count, rejected_count=rejected_count,
status_filter=status_filter, status_filter=status_filter,
date_from=date_from, date_from=date_from,
date_to=date_to) date_to=date_to,
project_filter=project_filter,
location_filter=location_filter,
employee_filter=employee_filter,
projects=projects,
locations=location_list,
employee_names=employee_names,
project_names=project_names)
except Exception as e: except Exception as e:
logger_handler.logger.error(f"Error in verification review: {e}") logger_handler.logger.error(f"Error in verification review: {e}")
@@ -5552,6 +5619,22 @@ def verification_review_detail(record_id):
# Get the QR code information for additional context # Get the QR code information for additional context
qr_code = QRCode.query.get(record.qr_code_id) if record.qr_code_id else None qr_code = QRCode.query.get(record.qr_code_id) if record.qr_code_id else None
# Get employee name from Employee table
employee_name = None
try:
if record.employee_id:
employee = Employee.query.filter_by(id=int(record.employee_id)).first()
if employee:
employee_name = f"{employee.lastName}, {employee.firstName}"
else:
employee_name = f"Unknown (ID: {record.employee_id})"
except (ValueError, TypeError) as e:
logger_handler.logger.warning(f"Could not lookup employee name for ID {record.employee_id}: {e}")
employee_name = f"Unknown (ID: {record.employee_id})"
# Get event type from QR code (Check In/Check Out)
location_event = qr_code.location_event if qr_code and qr_code.location_event else 'N/A'
# Log the access for audit trail # Log the access for audit trail
logger_handler.logger.info( logger_handler.logger.info(
f"User {session.get('username')} ({session.get('role')}) " f"User {session.get('username')} ({session.get('role')}) "
@@ -5573,7 +5656,9 @@ def verification_review_detail(record_id):
record=record, record=record,
qr_code=qr_code, qr_code=qr_code,
check_in_date=check_in_date, check_in_date=check_in_date,
check_in_time=check_in_time) check_in_time=check_in_time,
employee_name=employee_name,
location_event=location_event)
except Exception as e: except Exception as e:
logger_handler.logger.error(f"Error loading verification review detail: {e}") logger_handler.logger.error(f"Error loading verification review detail: {e}")
@@ -8512,6 +8597,9 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
else: else:
return None return None
# Import parse function at the beginning for work type detection
from working_hours_calculator import parse_employee_id_for_work_type
# Convert TimeAttendance records to format expected by calculator # Convert TimeAttendance records to format expected by calculator
converted_records = [] converted_records = []
for record in records: for record in records:
@@ -8525,12 +8613,24 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
if 'out' in action_lower or 'checkout' in action_lower: if 'out' in action_lower or 'checkout' in action_lower:
record_type = 'check_out' record_type = 'check_out'
# Extract work type (PT, SP, PW) from employee_id for location display in Excel
_, work_type = parse_employee_id_for_work_type(str(record.employee_id))
# Create display location name with work type suffix if applicable
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', (), { converted_record = type('Record', (), {
'id': record.id, 'id': record.id,
'employee_id': str(record.employee_id), 'employee_id': str(record.employee_id),
'check_in_date': record.attendance_date, 'check_in_date': record.attendance_date,
'check_in_time': record.attendance_time, 'check_in_time': record.attendance_time,
'location_name': record.location_name, 'location_name': display_location_name, # Use display name with work type for Excel export
'original_location_name': base_location_name, # Keep original for internal grouping
'work_type': work_type, # Store work type for reference
'latitude': None, 'latitude': None,
'longitude': None, 'longitude': None,
'distance': distance_value, 'distance': distance_value,
@@ -8539,13 +8639,29 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
'event_description': record.event_description or '', 'event_description': record.event_description or '',
'recorded_address': record.recorded_address or '', 'recorded_address': record.recorded_address or '',
'qr_code': type('QRCode', (), { 'qr_code': type('QRCode', (), {
'location': record.location_name, 'location': base_location_name, # Keep original for QR code matching
'location_address': record.recorded_address or '', 'location_address': record.recorded_address or '',
'project': None 'project': None
})() })()
})() })()
converted_records.append(converted_record) converted_records.append(converted_record)
# Log count of records with work types for audit trail
work_type_counts = {'PT': 0, 'SP': 0, 'PW': 0, 'Regular': 0}
for r in converted_records:
wt = getattr(r, 'work_type', None)
if wt in work_type_counts:
work_type_counts[wt] += 1
else:
work_type_counts['Regular'] += 1
if any(work_type_counts[wt] > 0 for wt in ['PT', 'SP', 'PW']):
logger_handler.logger.info(
f"Excel Export: Processing records with work types - "
f"Regular: {work_type_counts['Regular']}, PT: {work_type_counts['PT']}, "
f"SP: {work_type_counts['SP']}, PW: {work_type_counts['PW']}"
)
# Calculate working hours using SingleCheckInCalculator # Calculate working hours using SingleCheckInCalculator
calculator = WorkingHoursCalculator() calculator = WorkingHoursCalculator()
hours_data = calculator.calculate_all_employees_hours( hours_data = calculator.calculate_all_employees_hours(
@@ -9113,7 +9229,7 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
# Write PT row if hours > 0 # Write PT row if hours > 0
if pt_hours > 0: if pt_hours > 0:
ws.cell(row=current_row, column=7, value='Part-Time (PT): ').font = Font(name='Arial', size=10, bold=True, italic=True) ws.cell(row=current_row, column=7, value='Project Team (PT): ').font = Font(name='Arial', size=10, bold=True, italic=True)
ws.cell(row=current_row, column=9, value=round(pt_hours, 2)).font = Font(name='Arial', size=10, bold=True, italic=True) ws.cell(row=current_row, column=9, value=round(pt_hours, 2)).font = Font(name='Arial', size=10, bold=True, italic=True)
# Log PT hours export # Log PT hours export
logger_handler.logger.info(f"Export: Employee {employee_id} PT hours: {pt_hours:.2f}") logger_handler.logger.info(f"Export: Employee {employee_id} PT hours: {pt_hours:.2f}")
@@ -9177,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')
+41 -11
View File
@@ -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 %}
+282 -8
View File
@@ -60,6 +60,36 @@
</select> </select>
</div> </div>
<div class="filter-group">
<label for="project">Project</label>
<select name="project" id="project" class="filter-select">
<option value="">All Projects</option>
{% for project in projects %}
<option value="{{ project.id }}" {% if project_filter == project.id|string %}selected{% endif %}>
{{ project.name }}
</option>
{% endfor %}
</select>
</div>
<div class="filter-group">
<label for="location">Location</label>
<select name="location" id="location" class="filter-select">
<option value="">All Locations</option>
{% for location in locations %}
<option value="{{ location }}" {% if location_filter == location %}selected{% endif %}>
{{ location }}
</option>
{% endfor %}
</select>
</div>
<div class="filter-group">
<label for="employee">Employee ID</label>
<input type="text" name="employee" id="employee" value="{{ employee_filter }}"
class="filter-input" placeholder="Search by ID...">
</div>
<div class="filter-group"> <div class="filter-group">
<label for="date_from">From Date</label> <label for="date_from">From Date</label>
<input type="date" name="date_from" id="date_from" value="{{ date_from }}" class="filter-input"> <input type="date" name="date_from" id="date_from" value="{{ date_from }}" class="filter-input">
@@ -70,10 +100,13 @@
<input type="date" name="date_to" id="date_to" value="{{ date_to }}" class="filter-input"> <input type="date" name="date_to" id="date_to" value="{{ date_to }}" class="filter-input">
</div> </div>
<div class="filter-group"> <div class="filter-group filter-buttons">
<button type="submit" class="btn-filter"> <button type="submit" class="btn-filter">
<i class="fas fa-filter"></i> Apply Filters <i class="fas fa-filter"></i> Apply Filters
</button> </button>
<a href="{{ url_for('verification_review') }}" class="btn-reset">
<i class="fas fa-undo"></i> Reset
</a>
</div> </div>
</div> </div>
</form> </form>
@@ -89,7 +122,10 @@
<div class="verification-info"> <div class="verification-info">
<h3> <h3>
<i class="fas fa-user"></i> <i class="fas fa-user"></i>
Employee {{ record.employee_id }} {{ record.employee_id }}
{% if employee_names.get(record.employee_id) %}
<span class="employee-name">- {{ employee_names.get(record.employee_id) }}</span>
{% endif %}
</h3> </h3>
<div class="verification-meta"> <div class="verification-meta">
<span class="meta-item"> <span class="meta-item">
@@ -104,7 +140,21 @@
<i class="fas fa-map-marker-alt"></i> <i class="fas fa-map-marker-alt"></i>
{{ record.location_name }} {{ record.location_name }}
</span> </span>
{% if record.qr_code and record.qr_code.location_event %}
<span class="meta-item event-badge {{ 'check-in' if record.qr_code.location_event == 'Check In' else 'check-out' }}">
<i class="fas fa-{{ 'sign-in-alt' if record.qr_code.location_event == 'Check In' else 'sign-out-alt' }}"></i>
{{ record.qr_code.location_event }}
</span>
{% endif %}
</div> </div>
{% if record.qr_code and record.qr_code.project_id and project_names.get(record.qr_code.project_id) %}
<div class="verification-project">
<span class="project-badge">
<i class="fas fa-folder"></i>
{{ project_names.get(record.qr_code.project_id) }}
</span>
</div>
{% endif %}
</div> </div>
<div class="verification-status"> <div class="verification-status">
{% if record.verification_status == 'pending' %} {% if record.verification_status == 'pending' %}
@@ -127,7 +177,7 @@
<div class="photo-section"> <div class="photo-section">
<div class="photo-container"> <div class="photo-container">
{% if record.verification_photo %} {% if record.verification_photo %}
<img src="{{ record.verification_photo }}" alt="Verification Photo" class="verification-photo" /> <img src="{{ record.verification_photo }}" alt="Verification Photo" class="verification-photo" data-record-id="{{ record.id }}" />
{% else %} {% else %}
<div class="no-photo"> <div class="no-photo">
<i class="fas fa-image"></i> <i class="fas fa-image"></i>
@@ -135,6 +185,19 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% if record.verification_photo %}
<div class="photo-actions">
<button type="button" class="btn-photo download" onclick="downloadPhoto({{ record.id }}, '{{ record.employee_id }}', '{{ record.check_in_date.strftime('%Y-%m-%d') }}')" title="Download Photo">
<i class="fas fa-download"></i>
</button>
<button type="button" class="btn-photo email" onclick="sendByEmail({{ record.id }}, '{{ record.employee_id }}', '{{ employee_names.get(record.employee_id) or 'Unknown' }}', '{{ record.check_in_date.strftime('%b %d, %Y') }}', '{{ record.check_in_time.strftime('%I:%M %p') }}', '{{ record.location_name }}', '{{ record.qr_code.location_event if record.qr_code and record.qr_code.location_event else 'N/A' }}', '{{ record.verification_status }}', '{{ record.qr_code.location_address if record.qr_code and record.qr_code.location_address else 'N/A' }}', '{{ record.address or 'N/A' }}', '{{ '%.3f'|format(record.location_accuracy) if record.location_accuracy else 'N/A' }}', '{{ record.device_info or 'Unknown' }}')" title="Send by Email">
<i class="fas fa-envelope"></i>
</button>
<a href="{{ url_for('verification_review_detail', record_id=record.id) }}" class="btn-photo view" title="View Details">
<i class="fas fa-expand"></i>
</a>
</div>
{% endif %}
</div> </div>
<div class="details-section"> <div class="details-section">
@@ -330,6 +393,71 @@
background: #2563eb; background: #2563eb;
} }
.btn-reset {
width: 100%;
padding: 0.625rem 1.5rem;
background: #6b7280;
color: white;
border: none;
border-radius: 0.375rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
text-decoration: none;
text-align: center;
display: inline-block;
margin-top: 0.5rem;
}
.btn-reset:hover {
background: #4b5563;
color: white;
}
.filter-buttons {
display: flex;
flex-direction: column;
}
.employee-name {
font-weight: 500;
color: #4b5563;
font-size: 0.95em;
}
.verification-project {
margin-top: 0.5rem;
}
.project-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.75rem;
background: #e0e7ff;
color: #3730a3;
border-radius: 9999px;
font-size: 0.8rem;
font-weight: 500;
}
.event-badge {
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-weight: 600;
font-size: 0.75rem;
}
.event-badge.check-in {
background: #d1fae5;
color: #065f46;
}
.event-badge.check-out {
background: #fef3c7;
color: #92400e;
}
.verifications-grid { .verifications-grid {
display: grid; display: grid;
gap: 2rem; gap: 2rem;
@@ -439,6 +567,56 @@
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.photo-actions {
display: flex;
gap: 0.5rem;
justify-content: center;
margin-top: 0.75rem;
}
.btn-photo {
width: 36px;
height: 36px;
border: none;
border-radius: 0.5rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
text-decoration: none;
}
.btn-photo.download {
background: #3b82f6;
color: white;
}
.btn-photo.download:hover {
background: #2563eb;
transform: translateY(-2px);
}
.btn-photo.email {
background: #8b5cf6;
color: white;
}
.btn-photo.email:hover {
background: #7c3aed;
transform: translateY(-2px);
}
.btn-photo.view {
background: #10b981;
color: white;
}
.btn-photo.view:hover {
background: #059669;
transform: translateY(-2px);
}
.details-section { .details-section {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -546,11 +724,22 @@
return; return;
} }
// Optional: Add note // Prompt for reason
let note = ''; const reasonPrompt = status === 'approved'
if (status === 'rejected') { ? 'Optional: Enter a reason for approval:'
note = prompt('Optional: Add a note explaining why this was rejected:'); : 'Please enter a reason for rejection:';
if (note === null) return; // User cancelled
let note = prompt(reasonPrompt);
// For rejection, reason is required
if (status === 'rejected' && (!note || note.trim() === '')) {
alert('A reason is required when rejecting a verification.');
return;
}
// User cancelled the prompt
if (note === null) {
return;
} }
// Disable buttons // Disable buttons
@@ -593,5 +782,90 @@
}); });
}); });
}); });
// Download verification photo
function downloadPhoto(recordId, employeeId, checkInDate) {
const card = document.querySelector(`[data-record-id="${recordId}"]`);
const photoElement = card.querySelector('.verification-photo');
if (!photoElement) {
alert('No photo available to download.');
return;
}
const photoSrc = photoElement.src;
const fileName = `verification_photo_${employeeId}_${checkInDate}.jpg`;
// Handle base64 data URL
if (photoSrc.startsWith('data:')) {
const link = document.createElement('a');
link.href = photoSrc;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
console.log('[LOG] Photo downloaded:', fileName);
} else {
// Handle regular URL - fetch and download
fetch(photoSrc)
.then(response => response.blob())
.then(blob => {
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
console.log('[LOG] Photo downloaded:', fileName);
})
.catch(error => {
console.error('[ERROR] Failed to download photo:', error);
alert('Failed to download photo. Please try again.');
});
}
}
// Send verification details by email
function sendByEmail(recordId, employeeId, employeeName, checkInDate, checkInTime, locationName, locationEvent, verificationStatus, qrAddress, checkInAddress, distance, deviceInfo) {
// Build the verification record URL
const recordUrl = window.location.origin + `/verification-review/${recordId}`;
// Build email subject
const subject = encodeURIComponent(`Verification Review - Employee ${employeeId} (${employeeName}) - ${checkInDate}`);
// Build email body with verification details
let body = `VERIFICATION PHOTO REVIEW DETAILS\n`;
body += `================================\n\n`;
body += `VIEW VERIFICATION RECORD ONLINE:\n`;
body += `${recordUrl}\n\n`;
body += `EMPLOYEE INFORMATION\n`;
body += `--------------------\n`;
body += `Employee ID: ${employeeId}\n`;
body += `Employee Name: ${employeeName}\n`;
body += `Check-in Date: ${checkInDate}\n`;
body += `Check-in Time: ${checkInTime}\n`;
body += `Verification Status: ${verificationStatus.toUpperCase()}\n\n`;
body += `LOCATION INFORMATION\n`;
body += `--------------------\n`;
body += `Location Name: ${locationName}\n`;
body += `Event Type: ${locationEvent}\n`;
body += `Distance from QR: ${distance} miles\n`;
body += `QR Code Address: ${qrAddress}\n`;
body += `Check-in Address: ${checkInAddress}\n`;
body += `Device: ${deviceInfo}\n\n`;
body += `--------------------\n`;
body += `Record ID: ${recordId}\n\n`;
body += `Note: Click the link above to view the verification photo and full details.\n`;
const encodedBody = encodeURIComponent(body);
// Open default email client
const mailtoLink = `mailto:?subject=${subject}&body=${encodedBody}`;
window.location.href = mailtoLink;
console.log('[LOG] Email client opened for verification record:', recordId);
}
</script> </script>
{% endblock %} {% endblock %}
+428 -249
View File
@@ -1,53 +1,50 @@
{% extends "base_authenticated.html" %} {% extends "base_authenticated.html" %} {% block title %}Verification Review -
QR Code Management{% endblock %} {% block extra_head %}
{% block title %}Verification Review - QR Code Management{% endblock %}
{% block extra_head %}
<style> <style>
.verification-review-page { .verification-review-page {
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 30px 20px; padding: 30px 20px;
} }
.review-header { .review-header {
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%); background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);
color: white; color: white;
padding: 30px; padding: 30px;
border-radius: 12px; border-radius: 12px;
margin-bottom: 30px; margin-bottom: 30px;
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2); box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2);
} }
.review-header h1 { .review-header h1 {
margin: 0 0 10px 0; margin: 0 0 10px 0;
font-size: 2rem; font-size: 2rem;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 15px; gap: 15px;
} }
.review-header p { .review-header p {
margin: 0; margin: 0;
opacity: 0.9; opacity: 0.9;
font-size: 1.1rem; font-size: 1.1rem;
} }
.review-content { .review-content {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 30px; gap: 30px;
margin-bottom: 30px; margin-bottom: 30px;
} }
.review-section { .review-section {
background: white; background: white;
border-radius: 12px; border-radius: 12px;
padding: 25px; padding: 25px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
} }
.review-section h2 { .review-section h2 {
margin: 0 0 20px 0; margin: 0 0 20px 0;
font-size: 1.3rem; font-size: 1.3rem;
color: #1f2937; color: #1f2937;
@@ -56,107 +53,107 @@
gap: 10px; gap: 10px;
padding-bottom: 15px; padding-bottom: 15px;
border-bottom: 2px solid #e5e7eb; border-bottom: 2px solid #e5e7eb;
} }
.info-row { .info-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
padding: 12px 0; padding: 12px 0;
border-bottom: 1px solid #f3f4f6; border-bottom: 1px solid #f3f4f6;
} }
.info-row:last-child { .info-row:last-child {
border-bottom: none; border-bottom: none;
} }
.info-label { .info-label {
color: #6b7280; color: #6b7280;
font-weight: 500; font-weight: 500;
text-transform: uppercase; text-transform: uppercase;
font-size: 0.85rem; font-size: 0.85rem;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
.info-value { .info-value {
color: #1f2937; color: #1f2937;
font-weight: 600; font-weight: 600;
text-align: right; text-align: right;
} }
.distance-badge { .distance-badge {
display: inline-block; display: inline-block;
padding: 6px 12px; padding: 6px 12px;
border-radius: 6px; border-radius: 6px;
font-weight: 600; font-weight: 600;
font-size: 0.9rem; font-size: 0.9rem;
} }
.distance-high { .distance-high {
background: #fee2e2; background: #fee2e2;
color: #991b1b; color: #991b1b;
} }
.distance-medium { .distance-medium {
background: #fef3c7; background: #fef3c7;
color: #92400e; color: #92400e;
} }
.distance-low { .distance-low {
background: #d1fae5; background: #d1fae5;
color: #065f46; color: #065f46;
} }
.status-badge { .status-badge {
display: inline-block; display: inline-block;
padding: 8px 16px; padding: 8px 16px;
border-radius: 20px; border-radius: 20px;
font-weight: 600; font-weight: 600;
font-size: 0.95rem; font-size: 0.95rem;
} }
.status-pending { .status-pending {
background: #fef3c7; background: #fef3c7;
color: #92400e; color: #92400e;
} }
.status-approved { .status-approved {
background: #d1fae5; background: #d1fae5;
color: #065f46; color: #065f46;
} }
.status-rejected { .status-rejected {
background: #fee2e2; background: #fee2e2;
color: #991b1b; color: #991b1b;
} }
.photo-section { .photo-section {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
.verification-photo { .verification-photo {
max-width: 100%; max-width: 100%;
height: auto; height: auto;
border-radius: 12px; border-radius: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
display: block; display: block;
margin: 20px auto; margin: 20px auto;
} }
.no-photo { .no-photo {
text-align: center; text-align: center;
padding: 60px 20px; padding: 60px 20px;
background: #f9fafb; background: #f9fafb;
border-radius: 12px; border-radius: 12px;
color: #6b7280; color: #6b7280;
} }
.no-photo i { .no-photo i {
font-size: 4rem; font-size: 4rem;
margin-bottom: 20px; margin-bottom: 20px;
color: #d1d5db; color: #d1d5db;
} }
.action-buttons { .action-buttons {
display: flex; display: flex;
gap: 15px; gap: 15px;
justify-content: center; justify-content: center;
@@ -164,9 +161,9 @@
background: white; background: white;
border-radius: 12px; border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
} }
.action-buttons .btn { .action-buttons .btn {
padding: 14px 40px; padding: 14px 40px;
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 600; font-weight: 600;
@@ -178,263 +175,445 @@
gap: 10px; gap: 10px;
transition: all 0.2s; transition: all 0.2s;
text-decoration: none; text-decoration: none;
} }
.btn-approve { .btn-approve {
background: #10b981; background: #10b981;
color: white; color: white;
} }
.btn-approve:hover { .btn-approve:hover {
background: #059669; background: #059669;
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(16, 185, 129, 0.3); box-shadow: 0 6px 16px rgba(16, 185, 129, 0.3);
} }
.btn-reject { .btn-reject {
background: #ef4444; background: #ef4444;
color: white; color: white;
} }
.btn-reject:hover { .btn-reject:hover {
background: #dc2626; background: #dc2626;
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(239, 68, 68, 0.3); box-shadow: 0 6px 16px rgba(239, 68, 68, 0.3);
} }
.btn-back { .btn-back {
background: #6b7280; background: #6b7280;
color: white; color: white;
} }
.btn-back:hover { .btn-back:hover {
background: #4b5563; background: #4b5563;
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(107, 114, 128, 0.3); box-shadow: 0 6px 16px rgba(107, 114, 128, 0.3);
} }
.alert { .btn-download {
background: #3b82f6;
color: white;
}
.btn-download:hover {
background: #2563eb;
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(59, 130, 246, 0.3);
}
.btn-email {
background: #8b5cf6;
color: white;
}
.btn-email:hover {
background: #7c3aed;
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(139, 92, 246, 0.3);
}
.photo-actions {
display: flex;
gap: 10px;
justify-content: center;
margin-top: 15px;
flex-wrap: wrap;
}
.photo-actions .btn {
padding: 10px 20px;
font-size: 0.9rem;
font-weight: 600;
border-radius: 6px;
border: none;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
transition: all 0.2s;
text-decoration: none;
}
.alert {
padding: 15px 20px; padding: 15px 20px;
border-radius: 8px; border-radius: 8px;
margin-bottom: 20px; margin-bottom: 20px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
} }
.alert-warning { .alert-warning {
background: #fef3c7; background: #fef3c7;
color: #92400e; color: #92400e;
border-left: 4px solid #f59e0b; border-left: 4px solid #f59e0b;
} }
.alert-info { .alert-info {
background: #dbeafe; background: #dbeafe;
color: #1e40af; color: #1e40af;
border-left: 4px solid #3b82f6; border-left: 4px solid #3b82f6;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.review-content { .review-content {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.action-buttons { .action-buttons {
flex-direction: column; flex-direction: column;
} }
.action-buttons .btn { .action-buttons .btn {
width: 100%; width: 100%;
justify-content: center; justify-content: center;
} }
.review-header h1 { .review-header h1 {
font-size: 1.5rem; font-size: 1.5rem;
} }
} }
</style> </style>
{% endblock %} {% endblock %} {% block content %}
{% block content %}
<div class="verification-review-page"> <div class="verification-review-page">
<!-- Header --> <!-- Header -->
<div class="review-header"> <div class="review-header">
<h1> <h1>
<i class="fas fa-camera-retro"></i> <i class="fas fa-camera-retro"></i>
Verification Photo Review Verification Photo Review
</h1> </h1>
<p>Review and approve/reject employee verification photo for off-site check-in</p> <p>
Review and approve/reject employee verification photo for off-site
check-in
</p>
</div>
<!-- Alert for already reviewed records -->
{% if record.verification_status != 'pending' %}
<div class="alert alert-info">
<i class="fas fa-info-circle"></i>
<span
>This verification has already been {{ record.verification_status
}}.</span
>
</div>
{% endif %}
<!-- Review Content -->
<div class="review-content">
<!-- Employee Information -->
<div class="review-section">
<h2>
<i class="fas fa-user"></i>
Employee Information
</h2>
<div class="info-row">
<span class="info-label">Employee ID</span>
<span class="info-value">{{ record.employee_id }}</span>
</div>
<div class="info-row">
<span class="info-label">Employee Name</span>
<span class="info-value">{{ employee_name or 'Unknown' }}</span>
</div>
<div class="info-row">
<span class="info-label">Check-in Date</span>
<span class="info-value">{{ check_in_date }}</span>
</div>
<div class="info-row">
<span class="info-label">Check-in Time</span>
<span class="info-value">{{ check_in_time }}</span>
</div>
<div class="info-row">
<span class="info-label">Status</span>
<span class="info-value">
<span class="status-badge status-{{ record.verification_status }}">
{{ record.verification_status.upper() }}
</span>
</span>
</div>
</div> </div>
<!-- Alert for already reviewed records --> <!-- Location Information -->
{% if record.verification_status != 'pending' %} <div class="review-section">
<div class="alert alert-info"> <h2>
<i class="fas fa-info-circle"></i> <i class="fas fa-map-marker-alt"></i>
<span>This verification has already been {{ record.verification_status }}.</span> Location Information
</div> </h2>
{% endif %} <div class="info-row">
<span class="info-label">Location Name</span>
<!-- Review Content --> <span class="info-value">{{ record.location_name or 'Unknown' }}</span>
<div class="review-content"> </div>
<!-- Employee Information --> <div class="info-row">
<div class="review-section"> <span class="info-label">Event</span>
<h2> <span class="info-value">{{ location_event or 'N/A' }}</span>
<i class="fas fa-user"></i> </div>
Employee Information <div class="info-row">
</h2> <span class="info-label">Distance from QR</span>
<div class="info-row"> <span class="info-value">
<span class="info-label">Employee ID</span> {% if record.location_accuracy %} {% if record.location_accuracy > 0.5
<span class="info-value">{{ record.employee_id }}</span> %}
</div> <span class="distance-badge distance-high"
<div class="info-row"> >{{ "%.3f"|format(record.location_accuracy) }} miles</span
<span class="info-label">Employee Name</span> >
<span class="info-value">{{ record.employee_name or 'Unknown' }}</span> {% elif record.location_accuracy > 0.2 %}
</div> <span class="distance-badge distance-medium"
<div class="info-row"> >{{ "%.3f"|format(record.location_accuracy) }} miles</span
<span class="info-label">Check-in Date</span> >
<span class="info-value">{{ check_in_date }}</span> {% else %}
</div> <span class="distance-badge distance-low"
<div class="info-row"> >{{ "%.3f"|format(record.location_accuracy) }} miles</span
<span class="info-label">Check-in Time</span> >
<span class="info-value">{{ check_in_time }}</span> {% endif %} {% else %} N/A {% endif %}
</div> </span>
<div class="info-row"> </div>
<span class="info-label">Status</span> <div class="info-row">
<span class="info-value"> <span class="info-label">QR Address</span>
<span class="status-badge status-{{ record.verification_status }}"> <span class="info-value"
{{ record.verification_status.upper() }} >{{ qr_code.location_address if qr_code else 'N/A' }}</span
</span> >
</span> </div>
</div> <div class="info-row">
</div> <span class="info-label">Check-in Address</span>
<span class="info-value">{{ record.address or 'N/A' }}</span>
<!-- Location Information --> </div>
<div class="review-section"> <div class="info-row">
<h2> <span class="info-label">Device</span>
<i class="fas fa-map-marker-alt"></i> <span class="info-value">{{ record.device_info or 'Unknown' }}</span>
Location Information </div>
</h2>
<div class="info-row">
<span class="info-label">Location Name</span>
<span class="info-value">{{ record.location_name or 'Unknown' }}</span>
</div>
<div class="info-row">
<span class="info-label">Event</span>
<span class="info-value">{{ record.location_event or 'N/A' }}</span>
</div>
<div class="info-row">
<span class="info-label">Distance from QR</span>
<span class="info-value">
{% if record.location_accuracy %}
{% if record.location_accuracy > 0.5 %}
<span class="distance-badge distance-high">{{ "%.3f"|format(record.location_accuracy) }} miles</span>
{% elif record.location_accuracy > 0.2 %}
<span class="distance-badge distance-medium">{{ "%.3f"|format(record.location_accuracy) }} miles</span>
{% else %}
<span class="distance-badge distance-low">{{ "%.3f"|format(record.location_accuracy) }} miles</span>
{% endif %}
{% else %}
N/A
{% endif %}
</span>
</div>
<div class="info-row">
<span class="info-label">QR Address</span>
<span class="info-value">{{ qr_code.address if qr_code else 'N/A' }}</span>
</div>
<div class="info-row">
<span class="info-label">Check-in Address</span>
<span class="info-value">{{ record.address or 'N/A' }}</span>
</div>
<div class="info-row">
<span class="info-label">Device</span>
<span class="info-value">{{ record.device_info or 'Unknown' }}</span>
</div>
</div>
<!-- Verification Photo -->
<div class="review-section photo-section">
<h2>
<i class="fas fa-camera"></i>
Verification Photo
</h2>
{% if record.verification_photo %}
<img src="{{ record.verification_photo }}"
alt="Verification Photo for {{ record.employee_id }}"
class="verification-photo">
{% else %}
<div class="no-photo">
<i class="fas fa-image"></i>
<h3>No Photo Available</h3>
<p>This record does not have a verification photo.</p>
</div>
{% endif %}
</div>
</div> </div>
<!-- Action Buttons --> <!-- Verification Photo -->
{% if record.verification_status == 'pending' %} <div class="review-section photo-section">
<div class="action-buttons"> <h2>
<button onclick="updateStatus('approved')" class="btn btn-approve"> <i class="fas fa-camera"></i>
<i class="fas fa-check"></i> Verification Photo
Approve Verification </h2>
{% if record.verification_photo %}
<img
src="{{ record.verification_photo }}"
alt="Verification Photo for {{ record.employee_id }}"
class="verification-photo"
id="verificationPhoto"
/>
<div class="photo-actions">
<button onclick="downloadPhoto()" class="btn btn-download">
<i class="fas fa-download"></i>
Download Photo
</button> </button>
<button onclick="updateStatus('rejected')" class="btn btn-reject"> <button onclick="sendByEmail()" class="btn btn-email">
<i class="fas fa-times"></i> <i class="fas fa-envelope"></i>
Reject Verification Send by Email
</button> </button>
<a href="{{ url_for('attendance_report') }}" class="btn btn-back"> </div>
<i class="fas fa-arrow-left"></i> {% else %}
Back to Attendance <div class="no-photo">
</a> <i class="fas fa-image"></i>
<h3>No Photo Available</h3>
<p>This record does not have a verification photo.</p>
</div>
{% endif %}
</div> </div>
{% else %} </div>
<div class="action-buttons">
<a href="{{ url_for('attendance_report') }}" class="btn btn-back"> <!-- Action Buttons -->
<i class="fas fa-arrow-left"></i> {% if record.verification_status == 'pending' %}
Back to Attendance <div class="action-buttons">
</a> <button onclick="updateStatus('approved')" class="btn btn-approve">
</div> <i class="fas fa-check"></i>
{% endif %} Approve Verification
</button>
<button onclick="updateStatus('rejected')" class="btn btn-reject">
<i class="fas fa-times"></i>
Reject Verification
</button>
<a href="{{ url_for('attendance_report') }}" class="btn btn-back">
<i class="fas fa-arrow-left"></i>
Back to Attendance
</a>
</div>
{% else %}
<div class="action-buttons">
<a href="{{ url_for('attendance_report') }}" class="btn btn-back">
<i class="fas fa-arrow-left"></i>
Back to Attendance
</a>
</div>
{% endif %}
</div> </div>
<script> <script>
function updateStatus(status) { function updateStatus(status) {
const confirmMessage = status === 'approved' const statusText = status.charAt(0).toUpperCase() + status.slice(1);
? 'Are you sure you want to APPROVE this verification?' const confirmMessage = status === 'approved'
: 'Are you sure you want to REJECT this verification?'; ? 'Are you sure you want to APPROVE this verification?'
: 'Are you sure you want to REJECT this verification?';
if (!confirm(confirmMessage)) { if (!confirm(confirmMessage)) {
return; return;
} }
console.log(`[LOG] Updating verification status to ${status} for record {{ record.id }}`); // Prompt for reason
const reasonPrompt = status === 'approved'
? 'Optional: Enter a reason for approval:'
: 'Please enter a reason for rejection:';
// Send update request let reason = prompt(reasonPrompt);
fetch('/verification-review/{{ record.id }}/update', {
method: 'POST', // For rejection, reason is required
headers: { if (status === 'rejected' && (!reason || reason.trim() === '')) {
'Content-Type': 'application/json', alert('A reason is required when rejecting a verification.');
}, return;
body: JSON.stringify({ }
status: status
}) // User cancelled the prompt
}) if (reason === null) {
.then(response => response.json()) return;
.then(data => { }
if (data.success) {
console.log(`[LOG] Successfully updated verification status to ${status}`); console.log(`[LOG] Updating verification status to ${status} for record {{ record.id }}`);
alert(`Verification ${status} successfully!`);
// Redirect back to attendance report // Send update request
window.location.href = '{{ url_for('attendance_report') }}'; fetch('/verification-review/{{ record.id }}/update', {
} else { method: 'POST',
throw new Error(data.message || 'Failed to update verification status'); headers: {
} 'Content-Type': 'application/json',
}) },
.catch(error => { body: JSON.stringify({
console.error('[ERROR] Failed to update verification status:', error); status: status,
alert(`Error: ${error.message}`); note: reason
}); })
} })
.then(response => response.json())
.then(data => {
if (data.success) {
console.log(`[LOG] Successfully updated verification status to ${status}`);
alert(`Verification ${status} successfully!`);
// Redirect back to attendance report
window.location.href = '{{ url_for('attendance_report') }}';
} else {
throw new Error(data.message || 'Failed to update verification status');
}
})
.catch(error => {
console.error('[ERROR] Failed to update verification status:', error);
alert(`Error: ${error.message}`);
});
}
// Download verification photo
function downloadPhoto() {
const photoElement = document.getElementById('verificationPhoto');
if (!photoElement) {
alert('No photo available to download.');
return;
}
const photoSrc = photoElement.src;
const employeeId = '{{ record.employee_id }}';
const checkInDate = '{{ check_in_date }}'.replace(/\//g, '-');
const fileName = `verification_photo_${employeeId}_${checkInDate}.jpg`;
// Handle base64 data URL
if (photoSrc.startsWith('data:')) {
const link = document.createElement('a');
link.href = photoSrc;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
console.log('[LOG] Photo downloaded:', fileName);
} else {
// Handle regular URL - fetch and download
fetch(photoSrc)
.then(response => response.blob())
.then(blob => {
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
console.log('[LOG] Photo downloaded:', fileName);
})
.catch(error => {
console.error('[ERROR] Failed to download photo:', error);
alert('Failed to download photo. Please try again.');
});
}
}
// Send verification details by email
function sendByEmail() {
const employeeId = '{{ record.employee_id }}';
const employeeName = '{{ employee_name or "Unknown" }}';
const checkInDate = '{{ check_in_date }}';
const checkInTime = '{{ check_in_time }}';
const locationName = '{{ record.location_name or "Unknown" }}';
const locationEvent = '{{ location_event or "N/A" }}';
const verificationStatus = '{{ record.verification_status }}';
const qrAddress = '{{ qr_code.location_address if qr_code else "N/A" }}';
const checkInAddress = '{{ record.address or "N/A" }}';
const distance = '{% if record.location_accuracy %}{{ "%.3f"|format(record.location_accuracy) }} miles{% else %}N/A{% endif %}';
const deviceInfo = '{{ record.device_info or "Unknown" }}';
const recordId = '{{ record.id }}';
const recordUrl = window.location.origin + '{{ url_for("verification_review_detail", record_id=record.id) }}';
// Build email subject
const subject = encodeURIComponent(`Verification Review - Employee ${employeeId} (${employeeName}) - ${checkInDate}`);
// Build email body with verification details
let body = `VERIFICATION PHOTO REVIEW DETAILS\n`;
body += `================================\n\n`;
body += `VIEW VERIFICATION RECORD ONLINE:\n`;
body += `${recordUrl}\n\n`;
body += `EMPLOYEE INFORMATION\n`;
body += `--------------------\n`;
body += `Employee ID: ${employeeId}\n`;
body += `Employee Name: ${employeeName}\n`;
body += `Check-in Date: ${checkInDate}\n`;
body += `Check-in Time: ${checkInTime}\n`;
body += `Verification Status: ${verificationStatus.toUpperCase()}\n\n`;
body += `LOCATION INFORMATION\n`;
body += `--------------------\n`;
body += `Location Name: ${locationName}\n`;
body += `Event Type: ${locationEvent}\n`;
body += `Distance from QR: ${distance}\n`;
body += `QR Code Address: ${qrAddress}\n`;
body += `Check-in Address: ${checkInAddress}\n`;
body += `Device: ${deviceInfo}\n\n`;
body += `--------------------\n`;
body += `Record ID: ${recordId}\n\n`;
body += `Note: Click the link above to view the verification photo and full details.\n`;
const encodedBody = encodeURIComponent(body);
// Open default email client
const mailtoLink = `mailto:?subject=${subject}&body=${encodedBody}`;
window.location.href = mailtoLink;
console.log('[LOG] Email client opened for verification record:', recordId);
}
</script> </script>
{% endblock %} {% endblock %}