diff --git a/app.py b/app.py index 7155c02..f15a5e1 100644 --- a/app.py +++ b/app.py @@ -5404,6 +5404,89 @@ def save_manual_attendance(): return redirect(url_for('add_manual_attendance')) +@app.route('/api/time-attendance/locations') +@login_required +def time_attendance_locations_api(): + """Return distinct location_name values from time_attendance, optionally filtered by project_id. + Used by the time attendance records page to dynamically scope the location dropdown.""" + try: + project_id = request.args.get('project_id', '').strip() + + if project_id: + try: + project_id_int = int(project_id) + except (ValueError, TypeError): + return jsonify({'success': False, 'error': 'Invalid project_id'}), 400 + + result = db.session.execute(text(""" + SELECT DISTINCT location_name + FROM time_attendance + WHERE project_id = :project_id + AND location_name IS NOT NULL + ORDER BY location_name + """), {'project_id': project_id_int}) + else: + result = db.session.execute(text(""" + SELECT DISTINCT location_name + FROM time_attendance + WHERE location_name IS NOT NULL + ORDER BY location_name + """)) + + locations = [row[0] for row in result.fetchall()] + logger_handler.logger.info( + f"User {session.get('username', 'unknown')} fetched time attendance locations" + + (f" for project_id={project_id}" if project_id else " (all projects)") + ) + return jsonify({'success': True, 'locations': locations}) + + except Exception as e: + logger_handler.logger.error(f"Error in time_attendance_locations_api: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/attendance/locations') +@login_required +def attendance_locations_api(): + """Return distinct location_name values from attendance_data, optionally filtered by project_id. + Used by the attendance report page to dynamically scope the location dropdown when a project is selected.""" + try: + project_id = request.args.get('project_id', '').strip() + + if project_id: + try: + project_id_int = int(project_id) + except (ValueError, TypeError): + return jsonify({'success': False, 'error': 'Invalid project_id'}), 400 + + result = db.session.execute(text(""" + SELECT DISTINCT ad.location_name + FROM attendance_data ad + INNER JOIN qr_codes qc ON ad.qr_code_id = qc.id + WHERE qc.project_id = :project_id + AND ad.location_name IS NOT NULL + ORDER BY ad.location_name + """), {'project_id': project_id_int}) + else: + result = db.session.execute(text(""" + SELECT DISTINCT location_name + FROM attendance_data + WHERE location_name IS NOT NULL + ORDER BY location_name + """)) + + locations = [row[0] for row in result.fetchall()] + logger_handler.logger.info( + f"User {session.get('username', 'unknown')} fetched attendance locations" + + (f" for project_id={project_id}" if project_id else " (all projects)") + ) + return jsonify({'success': True, 'locations': locations}) + + except Exception as e: + logger_handler.logger.error(f"Error in attendance_locations_api: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + @app.route('/api/search_employees') @login_required def search_employees_api(): @@ -9230,26 +9313,27 @@ def export_time_attendance(): from models.time_attendance import TimeAttendance query = TimeAttendance.query - # Apply filters + # Apply filters — employee_id supports comma-separated multi-employee values if employee_filter: - # Include all SP/PW/PT work-type variants of the base employee ID so that - # cross-type pairing (e.g. regular IN + SP OUT) works correctly in the export. + employee_ids_export = [e.strip() for e in employee_filter.split(',') if e.strip()] from working_hours_calculator import parse_employee_id_for_work_type as _parse_wt - _base_emp_id, _ = _parse_wt(str(employee_filter)) - _emp_id_variants = [ - _base_emp_id, - f"{_base_emp_id} SP", f"{_base_emp_id}SP", - f"SP {_base_emp_id}", f"SP{_base_emp_id}", - f"{_base_emp_id} PW", f"{_base_emp_id}PW", - f"PW {_base_emp_id}", f"PW{_base_emp_id}", - f"{_base_emp_id} PT", f"{_base_emp_id}PT", - f"PT {_base_emp_id}", f"PT{_base_emp_id}", - ] - query = query.filter(TimeAttendance.employee_id.in_(_emp_id_variants)) - + all_variants = [] + for eid in employee_ids_export: + _base_emp_id, _ = _parse_wt(str(eid)) + all_variants += [ + _base_emp_id, + f"{_base_emp_id} SP", f"{_base_emp_id}SP", + f"SP {_base_emp_id}", f"SP{_base_emp_id}", + f"{_base_emp_id} PW", f"{_base_emp_id}PW", + f"PW {_base_emp_id}", f"PW{_base_emp_id}", + f"{_base_emp_id} PT", f"{_base_emp_id}PT", + f"PT {_base_emp_id}", f"PT{_base_emp_id}", + ] + query = query.filter(TimeAttendance.employee_id.in_(all_variants)) + 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() @@ -9257,7 +9341,7 @@ def export_time_attendance(): 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() @@ -9271,23 +9355,23 @@ def export_time_attendance(): 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 date and time (most recent first) records = query.order_by( TimeAttendance.attendance_date.desc(), TimeAttendance.attendance_time.desc() ).all() - + if not records: flash('No records found to export.', 'warning') return redirect(url_for('time_attendance_records')) - + # Get project name if project filter exists project_name_for_filename = '' if project_filter: @@ -9300,7 +9384,7 @@ def export_time_attendance(): 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 " @@ -10611,25 +10695,27 @@ def export_time_attendance_by_building(): from models.time_attendance import TimeAttendance query = TimeAttendance.query - # Apply filters + # Apply filters — employee_id supports comma-separated multi-employee values if employee_filter: - # Include all SP/PW/PT work-type variants of the base employee ID. + employee_ids_export = [e.strip() for e in employee_filter.split(',') if e.strip()] from working_hours_calculator import parse_employee_id_for_work_type as _parse_wt - _base_emp_id, _ = _parse_wt(str(employee_filter)) - _emp_id_variants = [ - _base_emp_id, - f"{_base_emp_id} SP", f"{_base_emp_id}SP", - f"SP {_base_emp_id}", f"SP{_base_emp_id}", - f"{_base_emp_id} PW", f"{_base_emp_id}PW", - f"PW {_base_emp_id}", f"PW{_base_emp_id}", - f"{_base_emp_id} PT", f"{_base_emp_id}PT", - f"PT {_base_emp_id}", f"PT{_base_emp_id}", - ] - query = query.filter(TimeAttendance.employee_id.in_(_emp_id_variants)) - + all_variants = [] + for eid in employee_ids_export: + _base_emp_id, _ = _parse_wt(str(eid)) + all_variants += [ + _base_emp_id, + f"{_base_emp_id} SP", f"{_base_emp_id}SP", + f"SP {_base_emp_id}", f"SP{_base_emp_id}", + f"{_base_emp_id} PW", f"{_base_emp_id}PW", + f"PW {_base_emp_id}", f"PW{_base_emp_id}", + f"{_base_emp_id} PT", f"{_base_emp_id}PT", + f"PT {_base_emp_id}", f"PT{_base_emp_id}", + ] + query = query.filter(TimeAttendance.employee_id.in_(all_variants)) + 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() @@ -10637,7 +10723,7 @@ def export_time_attendance_by_building(): 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() @@ -10650,24 +10736,24 @@ def export_time_attendance_by_building(): 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: @@ -10680,7 +10766,7 @@ def export_time_attendance_by_building(): 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 " @@ -11471,20 +11557,61 @@ def time_attendance_records(): """Display time attendance records with filtering options""" try: # Get filter parameters - employee_filter = request.args.get('employee_id') + 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') project_filter = request.args.get('project_id') page = request.args.get('page', 1, type=int) per_page = 50 # Records per page - + + # Build list of selected employee IDs (comma-separated multi-employee support) + employee_ids = [e.strip() for e in employee_filter.split(',') if e.strip()] if employee_filter else [] + + # Build display names for each selected employee + import re as _re + employee_display_names = [] + for eid in employee_ids: + try: + numeric_only = _re.search(r'\d+', str(eid)) + if numeric_only: + emp = Employee.query.filter_by(id=int(numeric_only.group(0))).first() + if emp: + employee_display_names.append({'id': eid, 'name': f"{emp.lastName}, {emp.firstName}"}) + else: + employee_display_names.append({'id': eid, 'name': f"ID: {eid}"}) + else: + employee_display_names.append({'id': eid, 'name': eid}) + except (ValueError, TypeError): + employee_display_names.append({'id': eid, 'name': eid}) + + employee_display_name = ', '.join([e['name'] for e in employee_display_names]) + # Build query query = TimeAttendance.query - + # Apply filters - if employee_filter: - query = query.filter(TimeAttendance.employee_id == employee_filter) + if employee_ids: + # Expand each base ID to include all SP/PW/PT work-type variants so that + # cross-type pairs are included in results and exports. + from working_hours_calculator import parse_employee_id_for_work_type as _parse_wt + all_variants = [] + for eid in employee_ids: + _base_emp_id, _ = _parse_wt(str(eid)) + all_variants += [ + _base_emp_id, + f"{_base_emp_id} SP", f"{_base_emp_id}SP", + f"SP {_base_emp_id}", f"SP{_base_emp_id}", + f"{_base_emp_id} PW", f"{_base_emp_id}PW", + f"PW {_base_emp_id}", f"PW{_base_emp_id}", + f"{_base_emp_id} PT", f"{_base_emp_id}PT", + f"PT {_base_emp_id}", f"PT{_base_emp_id}", + ] + query = query.filter(TimeAttendance.employee_id.in_(all_variants)) + logger_handler.logger.info( + f"Time attendance records filtered by employee IDs: {employee_ids} " + f"by user {session.get('username', 'unknown')}" + ) if location_filter: query = query.filter(TimeAttendance.location_name == location_filter) @@ -11563,23 +11690,6 @@ def time_attendance_records(): unique_locations = TimeAttendance.get_unique_locations() projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() - # Resolve display name for the employee filter input - employee_display_name = '' - if employee_filter: - try: - import re as _re - numeric_only = _re.search(r'\d+', str(employee_filter)) - if numeric_only: - emp = Employee.query.filter_by(id=int(numeric_only.group(0))).first() - if emp: - employee_display_name = f"{emp.lastName}, {emp.firstName}" - else: - employee_display_name = f"ID: {employee_filter}" - else: - employee_display_name = f"ID: {employee_filter}" - except (ValueError, TypeError): - employee_display_name = employee_filter - return render_template( 'time_attendance_records.html', records=records, @@ -11587,7 +11697,9 @@ def time_attendance_records(): unique_locations=unique_locations, projects=projects, employee_display_name=employee_display_name, - employee_filter=employee_filter or '' + employee_display_names=employee_display_names, + employee_filter=employee_filter, + employee_ids=employee_ids ) except Exception as e: diff --git a/templates/attendance_report.html b/templates/attendance_report.html index b93e2bd..e1f6221 100644 --- a/templates/attendance_report.html +++ b/templates/attendance_report.html @@ -1003,4 +1003,83 @@ document.addEventListener('DOMContentLoaded', function() { }; }()); + {% endblock %} \ No newline at end of file diff --git a/templates/time_attendance_records.html b/templates/time_attendance_records.html index d360a1f..d33006a 100644 --- a/templates/time_attendance_records.html +++ b/templates/time_attendance_records.html @@ -62,6 +62,90 @@ .filter-input { padding-right: 35px !important; } +/* ── Multi-Employee Chip Filter ── */ +.employee-chips-wrapper { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + min-height: 38px; + padding: 4px 36px 4px 8px; + border: 2px solid #e2e8f0; + border-radius: 0.5rem; + background: #fff; + cursor: text; + position: relative; + box-sizing: border-box; + width: 100%; + transition: border-color 0.2s, box-shadow 0.2s; +} +.employee-chips-wrapper:focus-within { + border-color: #2563eb; + box-shadow: 0 0 0 3px rgba(37,99,235,.1); +} +.employee-chip { + display: inline-flex; + align-items: center; + gap: 5px; + background: #eef2ff; + color: #4338ca; + border: 1px solid #c7d2fe; + border-radius: 4px; + padding: 2px 6px 2px 8px; + font-size: 0.8rem; + font-weight: 500; + white-space: nowrap; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; +} +.employee-chip-remove { + background: none; + border: none; + cursor: pointer; + color: #6366f1; + padding: 0; + line-height: 1; + font-size: 0.7rem; + display: flex; + align-items: center; + border-radius: 2px; + transition: color 0.15s; + flex-shrink: 0; +} +.employee-chip-remove:hover { color: #dc2626; } +.employee-chip-input { + border: none; + outline: none; + font-size: 0.875rem; + background: transparent; + flex: 1; + min-width: 120px; + padding: 2px 0; + color: #1f2937; +} +.employee-chip-input::placeholder { color: #9ca3af; } +.employee-chips-clear-all { + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + background: #dc3545; + color: white; + border: none; + border-radius: 50%; + width: 22px; + height: 22px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.2s; + padding: 0; +} +.employee-chips-clear-all:hover { background: #c82333; } +.employee-chips-clear-all i { font-size: 0.7rem; } + /* ── Mirror attendance.css filter layout on this page ── */ .filters-section { margin-bottom: 2rem; } @@ -260,30 +344,33 @@