diff --git a/address_normalization_fix.py b/address_normalization_fix.py index fda4532..3a2caec 100644 --- a/address_normalization_fix.py +++ b/address_normalization_fix.py @@ -47,30 +47,53 @@ def extract_street_address(address): Extract the core street address (number + street name) from an address string. 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: address: Normalized address string Returns: - Core street address string (e.g., "3402 s glebe rd") + Core street address string (e.g., "735 18th st s") """ if not address: return "" - # Pattern to match: street number + optional directional + street name + street type - # 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))' + addr_lower = address.lower() - 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: street_num = match.group(1).strip() street_name = match.group(2).strip() - # Clean up extra spaces - street_name = re.sub(r'\s+', ' ', street_name) - return f"{street_num} {street_name}" + direction = match.group(3) if match.group(3) else "" + + # Clean up extra spaces and commas + street_name = re.sub(r'[\s,]+', ' ', street_name).strip() + + # 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 extract just number + next few words - simple_pattern = r'^(\d+[-\w]*)\s+([\w\s]+)' - match = re.search(simple_pattern, address.lower()) + # 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: street_num = match.group(1).strip() # 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, Aurora Hills, 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): 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*,\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 street_abbrev = { r'\broad\b': 'rd', @@ -154,39 +188,134 @@ def normalize_address(address): for full_form, abbrev in directionals.items(): 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 # Examples: "Aurora Hills", "Downtown", etc. parts = [p.strip() for p in normalized.split(',')] # Keep: street address, city, state, zip - # Remove: neighborhood names, building names, country suffixes + # Remove: neighborhood names, building names, country suffixes, county names filtered_parts = [] # 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', 'downtown', 'midtown', 'uptown', 'district', 'center', 'crossing', 'corner', 'square', 'point', 'landing', '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): 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: - 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 # Skip empty parts if not part_clean: continue - # Skip country suffixes - if part_clean in ['usa', 'us', 'united states']: + # Skip country suffixes (multiple languages) + if part_clean in country_suffixes: + print(f" Removing country: '{part_clean}'") 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 for keyword in neighborhood_keywords: if keyword in part_clean and not re.search(r'\d', part_clean): @@ -214,8 +343,8 @@ def normalize_address(address): # Reconstruct address normalized = ', '.join(filtered_parts) - # Remove common country suffixes that don't affect location - normalized = re.sub(r',?\s*(usa|united states|us)$', '', normalized) + # Remove common country suffixes that don't affect location (final cleanup) + 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 normalized = normalized.strip(', ') @@ -230,14 +359,16 @@ def normalize_address(address): def extract_address_components(address): """ 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: address: Address string (raw or normalized) Returns: Dictionary with extracted components: - - street_number: The street number (e.g., "3402") - - street_name: The street name with type (e.g., "s glebe rd") + - street_number: The street number (e.g., "735") + - street_name: The street name with type (e.g., "18th st s") - city: City name if found - state: State abbreviation if found - zip_code: ZIP code if found @@ -255,49 +386,62 @@ def extract_address_components(address): 'zip_code': None } - # Extract street number (at the beginning) - 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 + # Extract ZIP code first (most reliable) zip_match = re.search(r'\b(\d{5})(?:-\d{4})?\b', addr_lower) if zip_match: components['zip_code'] = zip_match.group(1) - # Extract state (2-letter abbreviation before or after zip) - state_match = re.search(r'\b([a-z]{2})\s*(?:\d{5}|$)', addr_lower) + # Extract state (2-letter abbreviation, typically before zip or at end) + # 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: 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: components['state'] = potential_state - # Extract street name (between number and city/state/zip) - # This is the trickiest part - if components['street_number']: - # Remove street number from beginning - remainder = addr_lower[len(components['street_number']):].strip() - remainder = remainder.lstrip(',').strip() - - # 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' - match = re.search(pattern, remainder) - if match: - components['street_name'] = match.group(1).strip() - break + # Extract street number - look for it ANYWHERE in the address + # Pattern: standalone number that's likely a street number (not a zip code or ordinal in street name) + # Match numbers like "735" or "3402" but not "22202" (zip) or "18th" (ordinal) + + # First, try to find a number followed by a street-like pattern + 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))' + + match = re.search(street_num_pattern, addr_lower) + if match: + components['street_number'] = match.group(1) + street_name_raw = match.group(2).strip() + # Clean up the street name + 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 diff --git a/app.py b/app.py index 0846096..8b126fe 100644 --- a/app.py +++ b/app.py @@ -5348,9 +5348,12 @@ def verification_review(): status_filter = request.args.get('status', 'pending') date_from = request.args.get('date_from', '') 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 - query = AttendanceData.query.filter( + # Build query - join with QRCode to access project_id + query = AttendanceData.query.join(QRCode).filter( AttendanceData.verification_required == True ) @@ -5363,11 +5366,54 @@ def verification_review(): if 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 - verifications = query.join(QRCode).order_by( + verifications = query.order_by( AttendanceData.verification_timestamp.desc() ).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 pending_count = AttendanceData.query.filter( AttendanceData.verification_status == 'pending' @@ -5381,6 +5427,20 @@ def verification_review(): AttendanceData.verification_status == 'rejected' ).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', verifications=verifications, pending_count=pending_count, @@ -5388,7 +5448,14 @@ def verification_review(): rejected_count=rejected_count, status_filter=status_filter, 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: 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 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 logger_handler.logger.info( f"User {session.get('username')} ({session.get('role')}) " @@ -5573,7 +5656,9 @@ def verification_review_detail(record_id): record=record, qr_code=qr_code, 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: 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: 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 converted_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: 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', (), { 'id': record.id, 'employee_id': str(record.employee_id), 'check_in_date': record.attendance_date, '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, 'longitude': None, '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 '', 'recorded_address': record.recorded_address or '', 'qr_code': type('QRCode', (), { - 'location': record.location_name, + 'location': base_location_name, # Keep original for QR code matching 'location_address': record.recorded_address or '', 'project': None })() })() 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 calculator = WorkingHoursCalculator() 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 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) # Log PT hours export 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 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') @login_required @log_user_activity('time_attendance_records_view') diff --git a/templates/time_attendance_records.html b/templates/time_attendance_records.html index 88a0be0..d69eba1 100644 --- a/templates/time_attendance_records.html +++ b/templates/time_attendance_records.html @@ -37,6 +37,10 @@ Export to Excel + @@ -276,7 +280,7 @@ {{ page_num }} {% endif %} {% else %} - ... + ... {% endif %} {% endfor %} @@ -297,16 +301,9 @@ {% endif %} {% else %} -
-
- -
-

No Records Found

-

No time attendance records match your current filters.

- - - Clear Filters - +
+ +

No attendance records found matching your criteria.

{% endif %}
@@ -369,6 +366,26 @@ function exportRecords(format) { 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 document.addEventListener('click', function(event) { const menu = document.getElementById('exportMenu'); @@ -489,6 +506,9 @@ document.addEventListener('DOMContentLoaded', function() { .header-actions { position: relative; z-index: 100; + display: flex; + gap: 0.5rem; + flex-wrap: wrap; } .export-dropdown-menu { @@ -590,6 +610,16 @@ document.addEventListener('DOMContentLoaded', function() { right: auto; min-width: 100%; } + + .header-actions { + flex-direction: column; + width: 100%; + } + + .header-actions .btn { + width: 100%; + justify-content: center; + } } {% endblock %} \ No newline at end of file diff --git a/templates/verification_review.html b/templates/verification_review.html index 62cd69c..2bcc304 100644 --- a/templates/verification_review.html +++ b/templates/verification_review.html @@ -60,6 +60,36 @@ +
+ + +
+ +
+ + +
+ +
+ + +
+
@@ -70,10 +100,13 @@
-
+
+ + Reset +
@@ -89,7 +122,10 @@

- Employee {{ record.employee_id }} + {{ record.employee_id }} + {% if employee_names.get(record.employee_id) %} + - {{ employee_names.get(record.employee_id) }} + {% endif %}

@@ -104,7 +140,21 @@ {{ record.location_name }} + {% if record.qr_code and record.qr_code.location_event %} + + + {{ record.qr_code.location_event }} + + {% endif %}
+ {% if record.qr_code and record.qr_code.project_id and project_names.get(record.qr_code.project_id) %} +
+ + + {{ project_names.get(record.qr_code.project_id) }} + +
+ {% endif %}
{% if record.verification_status == 'pending' %} @@ -127,7 +177,7 @@
{% if record.verification_photo %} - Verification Photo + Verification Photo {% else %}
@@ -135,6 +185,19 @@
{% endif %}
+ {% if record.verification_photo %} +
+ + + + + +
+ {% endif %}
@@ -330,6 +393,71 @@ 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 { display: grid; gap: 2rem; @@ -439,6 +567,56 @@ 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 { display: flex; flex-direction: column; @@ -546,11 +724,22 @@ return; } - // Optional: Add note - let note = ''; - if (status === 'rejected') { - note = prompt('Optional: Add a note explaining why this was rejected:'); - if (note === null) return; // User cancelled + // Prompt for reason + const reasonPrompt = status === 'approved' + ? 'Optional: Enter a reason for approval:' + : 'Please enter a reason for rejection:'; + + 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 @@ -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); + } {% endblock %} \ No newline at end of file diff --git a/templates/verification_review_detail.html b/templates/verification_review_detail.html index b8b3ebe..b6b7b57 100644 --- a/templates/verification_review_detail.html +++ b/templates/verification_review_detail.html @@ -1,53 +1,50 @@ -{% extends "base_authenticated.html" %} - -{% block title %}Verification Review - QR Code Management{% endblock %} - -{% block extra_head %} +{% extends "base_authenticated.html" %} {% block title %}Verification Review - +QR Code Management{% endblock %} {% block extra_head %} -{% endblock %} - -{% block content %} +{% endblock %} {% block content %}
- -
-

- - Verification Photo Review -

-

Review and approve/reject employee verification photo for off-site check-in

+ +
+

+ + Verification Photo Review +

+

+ Review and approve/reject employee verification photo for off-site + check-in +

+
+ + + {% if record.verification_status != 'pending' %} +
+ + This verification has already been {{ record.verification_status + }}. +
+ {% endif %} + + +
+ +
+

+ + Employee Information +

+
+ Employee ID + {{ record.employee_id }} +
+
+ Employee Name + {{ employee_name or 'Unknown' }} +
+
+ Check-in Date + {{ check_in_date }} +
+
+ Check-in Time + {{ check_in_time }} +
+
+ Status + + + {{ record.verification_status.upper() }} + + +
- - {% if record.verification_status != 'pending' %} -
- - This verification has already been {{ record.verification_status }}. -
- {% endif %} - - -
- -
-

- - Employee Information -

-
- Employee ID - {{ record.employee_id }} -
-
- Employee Name - {{ record.employee_name or 'Unknown' }} -
-
- Check-in Date - {{ check_in_date }} -
-
- Check-in Time - {{ check_in_time }} -
-
- Status - - - {{ record.verification_status.upper() }} - - -
-
- - -
-

- - Location Information -

-
- Location Name - {{ record.location_name or 'Unknown' }} -
-
- Event - {{ record.location_event or 'N/A' }} -
-
- Distance from QR - - {% if record.location_accuracy %} - {% if record.location_accuracy > 0.5 %} - {{ "%.3f"|format(record.location_accuracy) }} miles - {% elif record.location_accuracy > 0.2 %} - {{ "%.3f"|format(record.location_accuracy) }} miles - {% else %} - {{ "%.3f"|format(record.location_accuracy) }} miles - {% endif %} - {% else %} - N/A - {% endif %} - -
-
- QR Address - {{ qr_code.address if qr_code else 'N/A' }} -
-
- Check-in Address - {{ record.address or 'N/A' }} -
-
- Device - {{ record.device_info or 'Unknown' }} -
-
- - -
-

- - Verification Photo -

- {% if record.verification_photo %} - Verification Photo for {{ record.employee_id }} - {% else %} -
- -

No Photo Available

-

This record does not have a verification photo.

-
- {% endif %} -
+ +
+

+ + Location Information +

+
+ Location Name + {{ record.location_name or 'Unknown' }} +
+
+ Event + {{ location_event or 'N/A' }} +
+
+ Distance from QR + + {% if record.location_accuracy %} {% if record.location_accuracy > 0.5 + %} + {{ "%.3f"|format(record.location_accuracy) }} miles + {% elif record.location_accuracy > 0.2 %} + {{ "%.3f"|format(record.location_accuracy) }} miles + {% else %} + {{ "%.3f"|format(record.location_accuracy) }} miles + {% endif %} {% else %} N/A {% endif %} + +
+
+ QR Address + {{ qr_code.location_address if qr_code else 'N/A' }} +
+
+ Check-in Address + {{ record.address or 'N/A' }} +
+
+ Device + {{ record.device_info or 'Unknown' }} +
- - {% if record.verification_status == 'pending' %} -
- - - - - Back to Attendance - +
+ {% else %} +
+ +

No Photo Available

+

This record does not have a verification photo.

+
+ {% endif %}
- {% else %} - - {% endif %} +
+ + + {% if record.verification_status == 'pending' %} +
+ + + + + Back to Attendance + +
+ {% else %} + + {% endif %}
-{% endblock %} \ No newline at end of file +{% endblock %}