diff --git a/.gitignore b/.gitignore index 0554d6e..3336fdb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ .env +# Cached report / employee data +data/ + # External tools downloaded at runtime repositories/ diff --git a/simple_org_chart/app_main.py b/simple_org_chart/app_main.py index 58232f6..55a7abb 100644 --- a/simple_org_chart/app_main.py +++ b/simple_org_chart/app_main.py @@ -96,11 +96,14 @@ def flock(fd, op): apply_filtered_user_filters, apply_last_login_filters, apply_missing_manager_filters, + apply_missing_photo_filters, + apply_tagpicker_filters, load_disabled_users_data, load_filtered_license_data, load_filtered_user_data, load_last_login_data, load_missing_manager_data, + load_missing_photo_data, load_recently_hired_data, ) from simple_org_chart.scheduler import ( @@ -288,6 +291,7 @@ def add_security_headers(response): LAST_LOGIN_FILE = str(app_config.LAST_LOGIN_FILE) RECENTLY_DISABLED_FILE = str(app_config.RECENTLY_DISABLED_FILE) RECENTLY_HIRED_FILE = str(app_config.RECENTLY_HIRED_FILE) +MISSING_PHOTO_FILE = str(app_config.MISSING_PHOTO_FILE) DATA_UPDATE_STATUS_FILE = os.path.join(DATA_DIR, 'data_update_status.json') logger.info(f"DATA_DIR set to: {DATA_DIR}") @@ -833,11 +837,13 @@ def get_metadata_options(): employees = get_employee_list_for_metadata() job_titles = collect_unique_field_values(employees, 'title') departments = collect_unique_field_values(employees, 'department') + countries = collect_unique_field_values(employees, 'country') employee_options = collect_employee_option_labels(employees) return jsonify({ 'jobTitles': job_titles, 'departments': departments, + 'countries': countries, 'employees': employee_options }) @@ -1520,29 +1526,14 @@ def _get_disabled_records_from_request(*, force_refresh=False, apply_filters=Tru def get_missing_manager_report(): try: refresh = _parse_bool_arg(request.args.get('refresh'), default=False) - include_user_mailboxes = _parse_bool_arg(request.args.get('includeUserMailboxes'), default=True) - include_shared_mailboxes = _parse_bool_arg(request.args.get('includeSharedMailboxes'), default=False) - include_room_equipment_mailboxes = _parse_bool_arg(request.args.get('includeRoomEquipmentMailboxes'), default=False) - include_enabled = _parse_bool_arg(request.args.get('includeEnabled'), default=True) - include_disabled = _parse_bool_arg(request.args.get('includeDisabled'), default=False) - include_licensed = _parse_bool_arg(request.args.get('includeLicensed'), default=True) - include_unlicensed = _parse_bool_arg(request.args.get('includeUnlicensed'), default=True) - include_members = _parse_bool_arg(request.args.get('includeMembers'), default=True) - include_guests = _parse_bool_arg(request.args.get('includeGuests'), default=False) + scope = request.args.get('scope', 'orgChart') + toggles = _parse_standard_toggle_args(request.args) + tp = _parse_tagpicker_args(request.args) records = load_missing_manager_data(report_cache, force_refresh=refresh) - filtered_records = apply_missing_manager_filters( - records, - include_user_mailboxes=include_user_mailboxes, - include_shared_mailboxes=include_shared_mailboxes, - include_room_equipment_mailboxes=include_room_equipment_mailboxes, - include_enabled=include_enabled, - include_disabled=include_disabled, - include_licensed=include_licensed, - include_unlicensed=include_unlicensed, - include_members=include_members, - include_guests=include_guests, - ) + records = _apply_scope_filter(records, scope) + filtered_records = apply_missing_manager_filters(records, **toggles) + filtered_records = apply_tagpicker_filters(filtered_records, **tp) generated_at = None if os.path.exists(MISSING_MANAGER_FILE): generated_at = datetime.fromtimestamp(os.path.getmtime(MISSING_MANAGER_FILE)).isoformat() @@ -1551,17 +1542,7 @@ def get_missing_manager_report(): 'records': filtered_records, 'count': len(filtered_records), 'generatedAt': generated_at, - 'appliedFilters': { - 'includeUserMailboxes': include_user_mailboxes, - 'includeSharedMailboxes': include_shared_mailboxes, - 'includeRoomEquipmentMailboxes': include_room_equipment_mailboxes, - 'includeEnabled': include_enabled, - 'includeDisabled': include_disabled, - 'includeLicensed': include_licensed, - 'includeUnlicensed': include_unlicensed, - 'includeMembers': include_members, - 'includeGuests': include_guests, - } + 'appliedFilters': toggles, }) except Exception as e: logger.error(f"Error loading missing manager report: {e}") @@ -1576,29 +1557,14 @@ def export_missing_manager_report(): try: refresh = _parse_bool_arg(request.args.get('refresh'), default=False) - include_user_mailboxes = _parse_bool_arg(request.args.get('includeUserMailboxes'), default=True) - include_shared_mailboxes = _parse_bool_arg(request.args.get('includeSharedMailboxes'), default=False) - include_room_equipment_mailboxes = _parse_bool_arg(request.args.get('includeRoomEquipmentMailboxes'), default=False) - include_enabled = _parse_bool_arg(request.args.get('includeEnabled'), default=True) - include_disabled = _parse_bool_arg(request.args.get('includeDisabled'), default=False) - include_licensed = _parse_bool_arg(request.args.get('includeLicensed'), default=True) - include_unlicensed = _parse_bool_arg(request.args.get('includeUnlicensed'), default=True) - include_members = _parse_bool_arg(request.args.get('includeMembers'), default=True) - include_guests = _parse_bool_arg(request.args.get('includeGuests'), default=False) + scope = request.args.get('scope', 'orgChart') + toggles = _parse_standard_toggle_args(request.args) + tp = _parse_tagpicker_args(request.args) records = load_missing_manager_data(report_cache, force_refresh=refresh) - filtered_records = apply_missing_manager_filters( - records, - include_user_mailboxes=include_user_mailboxes, - include_shared_mailboxes=include_shared_mailboxes, - include_room_equipment_mailboxes=include_room_equipment_mailboxes, - include_enabled=include_enabled, - include_disabled=include_disabled, - include_licensed=include_licensed, - include_unlicensed=include_unlicensed, - include_members=include_members, - include_guests=include_guests, - ) + records = _apply_scope_filter(records, scope) + filtered_records = apply_missing_manager_filters(records, **toggles) + filtered_records = apply_tagpicker_filters(filtered_records, **tp) wb = Workbook() ws = wb.active @@ -1656,6 +1622,94 @@ def export_missing_manager_report(): return jsonify({'error': 'Failed to export report'}), 500 +@app.route('/api/reports/missing-photo') +@require_auth +def get_missing_photo_report(): + try: + refresh = _parse_bool_arg(request.args.get('refresh'), default=False) + scope = request.args.get('scope', 'orgChart') + toggles = _parse_standard_toggle_args(request.args) + tp = _parse_tagpicker_args(request.args) + + records = load_missing_photo_data(report_cache, force_refresh=refresh) + records = _apply_scope_filter(records, scope) + filtered_records = apply_missing_photo_filters(records, **toggles) + filtered_records = apply_tagpicker_filters(filtered_records, **tp) + generated_at = None + if os.path.exists(MISSING_PHOTO_FILE): + generated_at = datetime.fromtimestamp(os.path.getmtime(MISSING_PHOTO_FILE)).isoformat() + + return jsonify({ + 'records': filtered_records, + 'count': len(filtered_records), + 'generatedAt': generated_at, + 'appliedFilters': toggles, + }) + except Exception as e: + logger.error(f"Error loading missing photo report: {e}") + return jsonify({'error': 'Failed to load report data'}), 500 + + +@app.route('/api/reports/missing-photo/export') +@require_auth +def export_missing_photo_report(): + if not Workbook: + return jsonify({'error': 'XLSX export not available - openpyxl not installed'}), 500 + + try: + refresh = _parse_bool_arg(request.args.get('refresh'), default=False) + scope = request.args.get('scope', 'orgChart') + toggles = _parse_standard_toggle_args(request.args) + tp = _parse_tagpicker_args(request.args) + + records = load_missing_photo_data(report_cache, force_refresh=refresh) + records = _apply_scope_filter(records, scope) + filtered_records = apply_missing_photo_filters(records, **toggles) + filtered_records = apply_tagpicker_filters(filtered_records, **tp) + + wb = Workbook() + ws = wb.active + ws.title = "Missing Photos" + + headers = [ + ('name', 'Name'), + ('title', 'Title'), + ('department', 'Department'), + ('email', 'Email'), + ('country', 'Country'), + ] + + for column_index, (_, header_text) in enumerate(headers, 1): + cell = ws.cell(row=1, column=column_index, value=header_text) + cell.font = Font(bold=True, color="FFFFFF") + cell.fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid") + cell.alignment = Alignment(horizontal="center") + + for row_index, record in enumerate(filtered_records, start=2): + for column_index, (key, _) in enumerate(headers, 1): + ws.cell(row=row_index, column=column_index, value=record.get(key)) + + for col in range(1, len(headers) + 1): + column_letter = get_column_letter(col) + ws.column_dimensions[column_letter].width = 22 + + output = BytesIO() + wb.save(output) + output.seek(0) + + filename = f"missing-photos-{datetime.now().strftime('%Y-%m-%d')}.xlsx" + + return send_file( + output, + as_attachment=True, + download_name=filename, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ) + except Exception as e: + logger.error(f"Error exporting missing photo report: {e}") + return jsonify({'error': 'Failed to export report'}), 500 + + @app.route('/api/reports/disabled-users') @require_auth def get_disabled_users_report(): @@ -1884,7 +1938,11 @@ def export_recently_disabled_report(): def get_recently_hired_report(): try: refresh = request.args.get('refresh', 'false').lower() == 'true' + scope = request.args.get('scope', 'orgChart') + tp = _parse_tagpicker_args(request.args) records = load_recently_hired_data(report_cache, force_refresh=refresh) + records = _apply_scope_filter(records, scope) + records = apply_tagpicker_filters(records, **tp) generated_at = None if os.path.exists(RECENTLY_HIRED_FILE): generated_at = datetime.fromtimestamp(os.path.getmtime(RECENTLY_HIRED_FILE)).isoformat() @@ -1907,7 +1965,11 @@ def export_recently_hired_report(): try: refresh = request.args.get('refresh', 'false').lower() == 'true' + scope = request.args.get('scope', 'orgChart') + tp = _parse_tagpicker_args(request.args) records = load_recently_hired_data(report_cache, force_refresh=refresh) + records = _apply_scope_filter(records, scope) + records = apply_tagpicker_filters(records, **tp) wb = Workbook() ws = wb.active @@ -1971,11 +2033,95 @@ def _parse_bool_arg(value, default=True): return default +def _parse_tagpicker_args(args): + """Parse the three tagpicker filter query params (title, department, country).""" + def _extract_list(key): + values = args.getlist(key) + if not values: + return None + + # Backward compatibility for legacy single-param comma-separated values. + if len(values) == 1: + return [v.strip() for v in values[0].split(',') if v.strip()] + + # Repeated query params preserve commas inside individual values. + cleaned = [] + for value in values: + stripped = (value or '').strip() + if stripped: + cleaned.append(stripped) + return cleaned or None + + filter_titles = _extract_list('filterTitles') + filter_titles_mode = (args.get('filterTitlesMode') or 'exclude').strip().lower() + if filter_titles_mode not in ('include', 'exclude'): + filter_titles_mode = 'exclude' + + filter_departments = _extract_list('filterDepartments') + filter_departments_mode = (args.get('filterDepartmentsMode') or 'exclude').strip().lower() + if filter_departments_mode not in ('include', 'exclude'): + filter_departments_mode = 'exclude' + + filter_countries = _extract_list('filterCountries') + filter_countries_mode = (args.get('filterCountriesMode') or 'exclude').strip().lower() + if filter_countries_mode not in ('include', 'exclude'): + filter_countries_mode = 'exclude' + + return { + 'filter_titles': filter_titles, + 'filter_titles_mode': filter_titles_mode, + 'filter_departments': filter_departments, + 'filter_departments_mode': filter_departments_mode, + 'filter_countries': filter_countries, + 'filter_countries_mode': filter_countries_mode, + } + + +def _parse_standard_toggle_args(args, defaults=None): + """Parse the standard 9 toggle filter params from request args. + + Returns a dict suitable for passing as **kwargs to apply_filtered_user_filters + and similar functions. `defaults` can override the fallback for any key. + """ + d = defaults or {} + return { + 'include_user_mailboxes': _parse_bool_arg(args.get('includeUserMailboxes'), default=d.get('include_user_mailboxes', True)), + 'include_shared_mailboxes': _parse_bool_arg(args.get('includeSharedMailboxes'), default=d.get('include_shared_mailboxes', False)), + 'include_room_equipment_mailboxes': _parse_bool_arg(args.get('includeRoomEquipmentMailboxes'), default=d.get('include_room_equipment_mailboxes', False)), + 'include_enabled': _parse_bool_arg(args.get('includeEnabled'), default=d.get('include_enabled', True)), + 'include_disabled': _parse_bool_arg(args.get('includeDisabled'), default=d.get('include_disabled', False)), + 'include_licensed': _parse_bool_arg(args.get('includeLicensed'), default=d.get('include_licensed', True)), + 'include_unlicensed': _parse_bool_arg(args.get('includeUnlicensed'), default=d.get('include_unlicensed', True)), + 'include_members': _parse_bool_arg(args.get('includeMembers'), default=d.get('include_members', True)), + 'include_guests': _parse_bool_arg(args.get('includeGuests'), default=d.get('include_guests', False)), + } + + +def _apply_scope_filter(records, scope): + """Filter records by scope. + + scope='orgChart' keeps only users present in the org-chart employee list. + scope='all' returns records unmodified. + """ + if not records or scope != 'orgChart': + return records + try: + if os.path.exists(EMPLOYEE_LIST_FILE): + with open(EMPLOYEE_LIST_FILE, 'r') as f: + org_employees = json.load(f) + org_ids = {str(emp.get('id')) for emp in org_employees if emp.get('id')} + return [r for r in records if str(r.get('id', '')) in org_ids] + except Exception as e: + logger.warning(f"Could not apply org chart scope filter: {e}") + return records + + @app.route('/api/reports/last-logins') @require_auth def get_last_logins_report(): try: refresh = _parse_bool_arg(request.args.get('refresh'), default=False) + scope = request.args.get('scope', 'orgChart') include_enabled = _parse_bool_arg(request.args.get('includeEnabled'), default=True) include_disabled = _parse_bool_arg(request.args.get('includeDisabled'), default=True) @@ -1987,6 +2133,7 @@ def get_last_logins_report(): include_user_mailboxes = _parse_bool_arg(request.args.get('includeUserMailboxes'), default=True) include_shared_mailboxes = _parse_bool_arg(request.args.get('includeSharedMailboxes'), default=True) include_room_equipment_mailboxes = _parse_bool_arg(request.args.get('includeRoomEquipmentMailboxes'), default=True) + tp = _parse_tagpicker_args(request.args) inactive_days_raw = request.args.get('inactiveDays') inactive_days = None @@ -1999,6 +2146,7 @@ def get_last_logins_report(): inactive_days_max = inactive_days_max_raw records = load_last_login_data(report_cache, force_refresh=refresh) + records = _apply_scope_filter(records, scope) filtered_records = apply_last_login_filters( records, include_enabled=include_enabled, @@ -2014,6 +2162,7 @@ def get_last_logins_report(): inactive_days=inactive_days, inactive_days_max=inactive_days_max ) + filtered_records = apply_tagpicker_filters(filtered_records, **tp) generated_at = None if os.path.exists(LAST_LOGIN_FILE): @@ -2052,6 +2201,7 @@ def export_last_logins_report(): try: refresh = _parse_bool_arg(request.args.get('refresh'), default=False) + scope = request.args.get('scope', 'orgChart') include_enabled = _parse_bool_arg(request.args.get('includeEnabled'), default=True) include_disabled = _parse_bool_arg(request.args.get('includeDisabled'), default=True) @@ -2073,7 +2223,10 @@ def export_last_logins_report(): if inactive_days_max_raw not in (None, '', 'null', 'None'): inactive_days_max = inactive_days_max_raw + tp = _parse_tagpicker_args(request.args) + records = load_last_login_data(report_cache, force_refresh=refresh) + records = _apply_scope_filter(records, scope) filtered_records = apply_last_login_filters( records, include_enabled=include_enabled, @@ -2089,6 +2242,7 @@ def export_last_logins_report(): inactive_days=inactive_days, inactive_days_max=inactive_days_max ) + filtered_records = apply_tagpicker_filters(filtered_records, **tp) wb = Workbook() ws = wb.active @@ -2263,6 +2417,7 @@ def export_disabled_licensed_report(): def get_filtered_users_report(): try: refresh = _parse_bool_arg(request.args.get('refresh'), default=False) + scope = request.args.get('scope', 'all') include_enabled = _parse_bool_arg(request.args.get('includeEnabled'), default=True) include_disabled = _parse_bool_arg(request.args.get('includeDisabled'), default=True) @@ -2273,6 +2428,7 @@ def get_filtered_users_report(): include_user_mailboxes = _parse_bool_arg(request.args.get('includeUserMailboxes'), default=True) include_shared_mailboxes = _parse_bool_arg(request.args.get('includeSharedMailboxes'), default=True) include_room_equipment_mailboxes = _parse_bool_arg(request.args.get('includeRoomEquipmentMailboxes'), default=True) + tp = _parse_tagpicker_args(request.args) if 'licensedOnly' in request.args: legacy_licensed_only = _parse_bool_arg(request.args.get('licensedOnly'), default=True) @@ -2282,6 +2438,7 @@ def get_filtered_users_report(): include_unlicensed = not legacy_licensed_only records = load_filtered_user_data(report_cache, force_refresh=refresh) + records = _apply_scope_filter(records, scope) filtered_records = apply_filtered_user_filters( records, include_user_mailboxes=include_user_mailboxes, @@ -2294,6 +2451,7 @@ def get_filtered_users_report(): include_members=include_members, include_guests=include_guests ) + filtered_records = apply_tagpicker_filters(filtered_records, **tp) generated_at = None if os.path.exists(FILTERED_USERS_FILE): @@ -2328,6 +2486,7 @@ def export_filtered_users_report(): try: refresh = _parse_bool_arg(request.args.get('refresh'), default=False) + scope = request.args.get('scope', 'all') include_enabled = _parse_bool_arg(request.args.get('includeEnabled'), default=True) include_disabled = _parse_bool_arg(request.args.get('includeDisabled'), default=True) @@ -2338,6 +2497,7 @@ def export_filtered_users_report(): include_user_mailboxes = _parse_bool_arg(request.args.get('includeUserMailboxes'), default=True) include_shared_mailboxes = _parse_bool_arg(request.args.get('includeSharedMailboxes'), default=True) include_room_equipment_mailboxes = _parse_bool_arg(request.args.get('includeRoomEquipmentMailboxes'), default=True) + tp = _parse_tagpicker_args(request.args) if 'licensedOnly' in request.args: legacy_licensed_only = _parse_bool_arg(request.args.get('licensedOnly'), default=True) @@ -2347,6 +2507,7 @@ def export_filtered_users_report(): include_unlicensed = not legacy_licensed_only records = load_filtered_user_data(report_cache, force_refresh=refresh) + records = _apply_scope_filter(records, scope) filtered_records = apply_filtered_user_filters( records, include_user_mailboxes=include_user_mailboxes, @@ -2359,6 +2520,7 @@ def export_filtered_users_report(): include_members=include_members, include_guests=include_guests ) + filtered_records = apply_tagpicker_filters(filtered_records, **tp) wb = Workbook() ws = wb.active @@ -2530,10 +2692,10 @@ def auth_check(): def _load_all_scannable_users(): - """Load all non-guest users from the last-login cache (includes disabled, + """Load all users from the last-login cache (includes disabled, guests, filtered, etc.). Falls back to the org-chart tree if the login cache is unavailable. Returns the full record from last_login_records.json so - callers can apply filters (mailbox type, account status, etc.).""" + callers can apply filters (mailbox type, account status, user type, etc.).""" users = [] # Primary source: last_login_records.json — has every user from Graph @@ -2542,8 +2704,6 @@ def _load_all_scannable_users(): with open(LAST_LOGIN_FILE, 'r') as f: records = json.load(f) for r in records: - if (r.get('userType') or '').lower() == 'guest': - continue email = r.get('email') or '' if not email: continue @@ -2867,12 +3027,34 @@ def user_scanner_full_scan(): include_members = body.get('includeMembers', True) include_guests = body.get('includeGuests', False) + # Tagpicker filters (Title / Department / Country) + def _parse_filter_values(value): + if isinstance(value, list): + parsed = [] + for item in value: + stripped = str(item).strip() + if stripped: + parsed.append(stripped) + return parsed + return [v.strip() for v in (value or '').split(',') if v.strip()] + + filter_titles = _parse_filter_values(body.get('filterTitles')) + filter_titles_mode = body.get('filterTitlesMode', 'exclude') + filter_departments = _parse_filter_values(body.get('filterDepartments')) + filter_departments_mode = body.get('filterDepartmentsMode', 'exclude') + filter_countries = _parse_filter_values(body.get('filterCountries')) + filter_countries_mode = body.get('filterCountriesMode', 'exclude') + # Load all non-guest users (includes disabled, filtered, etc.) employees = _load_all_scannable_users() if not employees: return jsonify({'error': 'No employee data available. Run a data sync first.'}), 400 + # Apply scope filter (orgChart / all) + scope = body.get('scope', 'all') + employees = _apply_scope_filter(employees, scope) + # Apply user-level filters to narrow the scan population employees = apply_last_login_filters( employees, @@ -2887,6 +3069,17 @@ def user_scanner_full_scan(): include_guests=include_guests, ) + # Apply tagpicker filters (Title / Department / Country) + employees = apply_tagpicker_filters( + employees, + filter_titles=filter_titles, + filter_titles_mode=filter_titles_mode, + filter_departments=filter_departments, + filter_departments_mode=filter_departments_mode, + filter_countries=filter_countries, + filter_countries_mode=filter_countries_mode, + ) + if not employees: return jsonify({'error': 'No employees match the selected filters.'}), 400 @@ -3050,6 +3243,9 @@ def user_scanner_full_scan_results(): """Return cached full-scan results.""" cached = user_scanner_service.load_cached_full_scan() if cached: + scope = request.args.get('scope', 'orgChart') + records = _apply_scope_filter(cached.get('records', []), scope) + cached['records'] = records return jsonify(cached) return jsonify({'records': [], 'scannedAt': None, 'totalEmployees': 0}) @@ -3338,4 +3534,4 @@ def count_employees(node): if __name__ == '__main__': debug_mode = os.getenv('FLASK_DEBUG', '').lower() in ('1', 'true', 'yes') - app.run(debug=debug_mode, host='0.0.0.0', port=APP_PORT) \ No newline at end of file + app.run(debug=debug_mode, host='0.0.0.0', port=APP_PORT) diff --git a/simple_org_chart/config.py b/simple_org_chart/config.py index dd295bd..267c058 100644 --- a/simple_org_chart/config.py +++ b/simple_org_chart/config.py @@ -27,6 +27,7 @@ LAST_LOGIN_FILE = DATA_DIR / "last_login_records.json" RECENTLY_DISABLED_FILE = DATA_DIR / "recently_disabled_employees.json" RECENTLY_HIRED_FILE = DATA_DIR / "recently_hired_employees.json" +MISSING_PHOTO_FILE = DATA_DIR / "missing_photo_records.json" def ensure_directories() -> None: @@ -61,6 +62,7 @@ def as_posix_env(mapping: Dict[str, Path]) -> Dict[str, str]: "LAST_LOGIN_FILE", "RECENTLY_DISABLED_FILE", "RECENTLY_HIRED_FILE", + "MISSING_PHOTO_FILE", "ensure_directories", "as_posix_env", ] diff --git a/simple_org_chart/data_update.py b/simple_org_chart/data_update.py index 31334ce..07d71ce 100644 --- a/simple_org_chart/data_update.py +++ b/simple_org_chart/data_update.py @@ -11,6 +11,7 @@ import simple_org_chart.config as app_config from simple_org_chart.msgraph import ( + batch_check_photos, calculate_days_since, collect_disabled_users, collect_last_login_records, @@ -45,6 +46,7 @@ LAST_LOGIN_FILE = str(app_config.LAST_LOGIN_FILE) FILTERED_LICENSE_FILE = str(app_config.FILTERED_LICENSE_FILE) FILTERED_USERS_FILE = str(app_config.FILTERED_USERS_FILE) +MISSING_PHOTO_FILE = str(app_config.MISSING_PHOTO_FILE) DATA_UPDATE_STATUS_FILE = os.path.join(DATA_DIR, 'data_update_status.json') _DATA_UPDATE_STATUS_LOCK = threading.Lock() @@ -231,6 +233,7 @@ def collect_recently_hired_employees( 'phone': employee.get('phone') or '', 'businessPhone': employee.get('businessPhone') or '', 'location': employee.get('location') or employee.get('officeLocation') or '', + 'country': employee.get('country') or '', 'hireDate': datetime_to_iso(hire_date), 'daysSinceHire': calculate_days_since(hire_date), 'managerName': '', @@ -323,9 +326,55 @@ def update_employee_data(source: str = 'unknown') -> None: ) if employees: + # Check photos on ALL users (employees + filtered_users) so the + # "All" scope can show every user without a photo, regardless of + # org-chart visibility filters applied by fetch_all_employees. + try: + all_users = list(employees) + (filtered_users or []) + all_ids = [emp.get('id') for emp in all_users if emp.get('id')] + has_photo_ids = batch_check_photos(all_ids, token) + missing_photo_records = [] + for emp in all_users: + uid = emp.get('id') + if uid and uid not in has_photo_ids: + missing_photo_records.append({ + 'id': uid, + 'name': emp.get('name') or '', + 'title': emp.get('title') or '', + 'department': emp.get('department') or '', + 'email': emp.get('email') or '', + 'country': emp.get('country') or '', + 'accountEnabled': emp.get('accountEnabled', True), + 'userType': emp.get('userType') or '', + 'licenseCount': emp.get('licenseCount', 0), + 'licenseSkus': emp.get('licenseSkus', []), + 'licenseSkuIds': emp.get('licenseSkuIds', []), + 'mailboxType': emp.get('mailboxType'), + 'isSharedMailbox': emp.get('isSharedMailbox'), + }) + with open(MISSING_PHOTO_FILE, 'w') as report_file: + json.dump(missing_photo_records, report_file, indent=2) + logger.info( + f"Updated missing photo report cache with {len(missing_photo_records)} records" + ) + except Exception as report_error: + logger.error(f"Failed to write missing photo report cache: {report_error}") + ignored_employee_set = parse_ignored_employees(settings) ignored_department_set = parse_ignored_departments(settings) + # Collect recently hired from ALL users (before ignore filtering) + try: + all_users_for_hired = list(employees) + (filtered_users or []) + recently_hired_records = collect_recently_hired_employees(all_users_for_hired, days=365) + with open(RECENTLY_HIRED_FILE, 'w') as report_file: + json.dump(recently_hired_records, report_file, indent=2) + logger.info( + f"Updated recently hired employees report cache with {len(recently_hired_records)} records" + ) + except Exception as report_error: + logger.error(f"Failed to write recently hired employees report cache: {report_error}") + if ignored_employee_set: before = len(employees) employees = [ @@ -421,15 +470,6 @@ def update_new_status(node): else: logger.error("Could not build hierarchy from employee data") - try: - recently_hired_records = collect_recently_hired_employees(employees, days=365) - with open(RECENTLY_HIRED_FILE, 'w') as report_file: - json.dump(recently_hired_records, report_file, indent=2) - logger.info( - f"Updated recently hired employees report cache with {len(recently_hired_records)} records" - ) - except Exception as report_error: - logger.error(f"Failed to write recently hired employees report cache: {report_error}") else: logger.error("No employees fetched from Graph API") diff --git a/simple_org_chart/hierarchy.py b/simple_org_chart/hierarchy.py index 3fc8c43..b212029 100644 --- a/simple_org_chart/hierarchy.py +++ b/simple_org_chart/hierarchy.py @@ -224,6 +224,7 @@ def traverse(node): 'phone': emp.get('phone'), 'businessPhone': emp.get('businessPhone'), 'location': emp.get('location') or emp.get('officeLocation') or '', + 'country': emp.get('country') or '', 'managerName': manager_name, 'reason': effective_reason, 'missingReason': reason, diff --git a/simple_org_chart/msgraph.py b/simple_org_chart/msgraph.py index 9f4c852..3be08b3 100644 --- a/simple_org_chart/msgraph.py +++ b/simple_org_chart/msgraph.py @@ -561,7 +561,7 @@ def collect_last_login_records(*, token: Optional[str] = None) -> list[dict]: base_fields = ( "id,displayName,jobTitle,department,mail,userPrincipalName," - "signInActivity,accountEnabled,userType,assignedLicenses" + "signInActivity,accountEnabled,userType,assignedLicenses,country" ) def build_users_url(select_fields: str) -> str: @@ -663,6 +663,7 @@ def _map_licenses(license_entries: Optional[Iterable[dict]]) -> Tuple[list[str], "title": user.get("jobTitle") or "No Title", "department": user.get("department") or "No Department", "email": user.get("mail") or user.get("userPrincipalName") or "", + "country": user.get("country") or "", "accountEnabled": user.get("accountEnabled", True), "userType": (user.get("userType") or "").lower(), "licenseCount": len(sku_ids), @@ -914,7 +915,67 @@ def fetch_presence_by_user_ids( return result +def batch_check_photos(user_ids: list[str], token: str) -> set[str]: + """Check which users have profile photos using the Graph $batch API. + + Returns a set of user IDs that DO have a photo. Uses batches of 20 + (the Graph limit per $batch request). + """ + has_photo: set[str] = set() + if not user_ids or not token: + return has_photo + + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + batch_url = f"{GRAPH_API_ENDPOINT}/$batch" + chunk_size = 20 + + for start in range(0, len(user_ids), chunk_size): + chunk = user_ids[start : start + chunk_size] + batch_requests = [] + for idx, uid in enumerate(chunk): + batch_requests.append({ + "id": str(idx), + "method": "GET", + "url": f"/users/{uid}/photo", + }) + resp = requests.post( + batch_url, + headers=headers, + json={"requests": batch_requests}, + timeout=30, + ) + if not resp.ok: + raise RuntimeError( + f"Batch photo check failed with status {resp.status_code}" + ) + data = resp.json() + for item in data.get("responses", []): + raw_id = item.get("id") + try: + idx = int(raw_id) + except (TypeError, ValueError): + logger.warning("Batch photo check returned non-numeric response id: %s", raw_id) + continue + if not (0 <= idx < len(chunk)): + logger.warning("Batch photo check returned invalid response id: %s", raw_id) + continue + status = item.get("status") + if status == 200: + has_photo.add(chunk[idx]) + elif status != 404: + raise RuntimeError( + f"Batch photo check returned unexpected status {status} " + f"for user {chunk[idx]}" + ) + + return has_photo + + __all__ = [ + "batch_check_photos", "calculate_days_since", "collect_disabled_licensed_users", "collect_disabled_users", diff --git a/simple_org_chart/reports.py b/simple_org_chart/reports.py index 47c5052..108130b 100644 --- a/simple_org_chart/reports.py +++ b/simple_org_chart/reports.py @@ -22,6 +22,7 @@ LAST_LOGIN_FILE = str(app_config.LAST_LOGIN_FILE) FILTERED_LICENSE_FILE = str(app_config.FILTERED_LICENSE_FILE) FILTERED_USERS_FILE = str(app_config.FILTERED_USERS_FILE) +MISSING_PHOTO_FILE = str(app_config.MISSING_PHOTO_FILE) class ReportCacheManager: @@ -83,6 +84,14 @@ def load_missing_manager_data(cache: ReportCacheManager, *, force_refresh: bool ) +def load_missing_photo_data(cache: ReportCacheManager, *, force_refresh: bool = False): + return cache.load_json( + MISSING_PHOTO_FILE, + refresh=force_refresh, + description="missing photo report cache", + ) + + def load_disabled_license_data(cache: ReportCacheManager, *, force_refresh: bool = False): return cache.load_json( DISABLED_LICENSE_FILE, @@ -386,12 +395,95 @@ def apply_missing_manager_filters( ) +def apply_missing_photo_filters( + records: Optional[Sequence[dict]], + *, + include_user_mailboxes: bool = True, + include_shared_mailboxes: bool = True, + include_room_equipment_mailboxes: bool = True, + include_enabled: bool = True, + include_disabled: bool = True, + include_licensed: bool = True, + include_unlicensed: bool = True, + include_members: bool = True, + include_guests: bool = True, +): + return apply_filtered_user_filters( + records, + include_user_mailboxes=include_user_mailboxes, + include_shared_mailboxes=include_shared_mailboxes, + include_room_equipment_mailboxes=include_room_equipment_mailboxes, + include_enabled=include_enabled, + include_disabled=include_disabled, + include_licensed=include_licensed, + include_unlicensed=include_unlicensed, + include_members=include_members, + include_guests=include_guests, + ) + + +def apply_tagpicker_filters( + records: Sequence[dict], + *, + filter_titles: Optional[List[str]] = None, + filter_titles_mode: str = "exclude", + filter_departments: Optional[List[str]] = None, + filter_departments_mode: str = "exclude", + filter_countries: Optional[List[str]] = None, + filter_countries_mode: str = "exclude", +) -> List[dict]: + """Apply optional title/department/country include/exclude filters.""" + if not records: + return [] + + title_set = {v.strip().lower() for v in (filter_titles or []) if v and v.strip()} + dept_set = {v.strip().lower() for v in (filter_departments or []) if v and v.strip()} + country_set = {v.strip().lower() for v in (filter_countries or []) if v and v.strip()} + + # Nothing to filter + if not title_set and not dept_set and not country_set: + return list(records) + + filtered: List[dict] = [] + for record in records: + title_val = (record.get("title") or "").strip().lower() + dept_val = (record.get("department") or "").strip().lower() + country_val = (record.get("country") or "").strip().lower() + + if title_set: + matched = title_val in title_set + if filter_titles_mode == "include" and not matched: + continue + if filter_titles_mode == "exclude" and matched: + continue + + if dept_set: + matched = dept_val in dept_set + if filter_departments_mode == "include" and not matched: + continue + if filter_departments_mode == "exclude" and matched: + continue + + if country_set: + matched = country_val in country_set + if filter_countries_mode == "include" and not matched: + continue + if filter_countries_mode == "exclude" and matched: + continue + + filtered.append(record) + + return filtered + + __all__ = [ "ReportCacheManager", "apply_disabled_filters", "apply_filtered_user_filters", "apply_last_login_filters", "apply_missing_manager_filters", + "apply_missing_photo_filters", + "apply_tagpicker_filters", "calculate_license_totals", "load_disabled_license_data", "load_disabled_users_data", @@ -399,6 +491,7 @@ def apply_missing_manager_filters( "load_filtered_user_data", "load_last_login_data", "load_missing_manager_data", + "load_missing_photo_data", "load_recently_disabled_data", "load_recently_hired_data", ] diff --git a/static/locales/en-US.json b/static/locales/en-US.json index dd0c561..2b07ef2 100644 --- a/static/locales/en-US.json +++ b/static/locales/en-US.json @@ -480,11 +480,12 @@ "selector": { "label": "Report type", "options": { - "missingManager": "Employees without managers", + "missingManager": "Users without managers", + "missingPhoto": "Users without profile picture", "lastLogins": "Users by last sign-in activity", - "hiredThisYear": "Employees hired in the last 365 days", + "hiredThisYear": "Users hired in the last 365 days", "filteredUsers": "Users hidden by filters", - "userScanner": "User Scanner (OSINT)" + "userScanner": "Users scanner (OSINT)" } }, "userScanner": { @@ -526,6 +527,13 @@ "exportPdfTooltip": "Download this report as a PDF document" }, "filters": { + "scope": { + "label": "Data Scope", + "options": { + "orgChart": "Org Chart", + "all": "All" + } + }, "title": "Filters", "groups": { "mailboxTypes": "Mailbox type", @@ -584,18 +592,42 @@ }, "includeMembers": { "label": "Member" - } + }, + "titleFilter": { + "label": "Title", + "placeholder": "Type a job title...", + "reset": "Reset", + "modeInclude": "Include", + "modeExclude": "Exclude" + }, + "departmentFilter": { + "label": "Department", + "placeholder": "Type a department...", + "reset": "Reset", + "modeInclude": "Include", + "modeExclude": "Exclude" + }, + "countryFilter": { + "label": "Country", + "placeholder": "Type a country...", + "reset": "Reset", + "modeInclude": "Include", + "modeExclude": "Exclude" + }, + "tagpickerRemoveLabel": "Remove {value}", + "tagpickerNoMatches": "No matches found", + "tagpickerNoOptions": "No options available" }, "summary": { - "totalLabel": "Employees without managers", + "totalLabel": "Users without managers", "generatedLabel": "Last Generated", "generatedPending": "Pending", "licensesLabel": "Total Licenses" }, "table": { - "title": "Employees without managers", + "title": "Users without managers", "loading": "Loading report...", - "countSummary": "Showing {count} employees without managers", + "countSummary": "Showing {count} users without managers", "empty": "No users are currently missing manager information. Great job!", "columns": { "name": "Name", @@ -618,7 +650,8 @@ "daysSinceInteractiveSignIn": "Days since interactive sign-in", "lastNonInteractiveSignIn": "Last non-interactive sign-in", "daysSinceNonInteractiveSignIn": "Days since non-interactive sign-in", - "neverSignedIn": "Never signed in" + "neverSignedIn": "Never signed in", + "country": "Country" }, "reasonLabels": { "no_manager": "No manager", @@ -637,10 +670,16 @@ "countSummary": "Showing {count} users matching the report criteria" }, "hiredThisYear": { - "summaryLabel": "Employees hired in the last 365 days", - "tableTitle": "Employees hired in the last 365 days", - "empty": "No employees have been hired in the last 365 days.", - "countSummary": "Showing {count} employees hired in the last 365 days" + "summaryLabel": "Users hired in the last 365 days", + "tableTitle": "Users hired in the last 365 days", + "empty": "No users have been hired in the last 365 days.", + "countSummary": "Showing {count} users hired in the last 365 days" + }, + "missingPhoto": { + "summaryLabel": "Users without profile picture", + "tableTitle": "Users without profile picture", + "empty": "All users have a profile picture. Great job!", + "countSummary": "Showing {count} users without a profile picture" }, "filteredLicensed": { "columns": { @@ -662,10 +701,10 @@ "countSummary": "Showing {count} users hidden by filters" }, "userScanner": { - "summaryLabel": "User Scanner Results", - "tableTitle": "User Scanner OSINT Results", + "summaryLabel": "Users Scanner Results", + "tableTitle": "Users Scanner OSINT Results", "empty": "No scan results yet. Run a scan to see results here.", - "countSummary": "Showing results for {count} employees", + "countSummary": "Showing results for {count} users", "noResults": "No results found for this scan.", "scanning": "Scanning…", "singleResultTitle": "Scan Results", @@ -695,6 +734,7 @@ }, "errors": { "loadFailed": "Unable to load the report.", + "loadTimeout": "The server took too long to respond. Please try again.", "exportFailed": "Unable to export the report.", "initializationFailed": "Failed to initialize the reports page.", "pdfLibraryMissing": "PDF export library is not available. Please refresh the page.", diff --git a/static/reports.css b/static/reports.css index 9cf249a..35ac1fe 100644 --- a/static/reports.css +++ b/static/reports.css @@ -163,9 +163,12 @@ html.i18n-loading body { } .summary-value { - font-size: 1.75rem; + font-size: 1.1rem; font-weight: 700; color: var(--primary-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .table-panel { @@ -245,6 +248,86 @@ html.i18n-loading body { background: #0056a6; } +/* ── Report tagpicker filter rows ───────────────────────────── */ + +.filter-tagpicker-row { + display: flex; + align-items: center; + gap: 8px; + background: rgba(15, 23, 42, 0.03); + border-radius: 12px; + padding: 8px 12px; + flex-wrap: wrap; +} + +.filter-tagpicker-row__label { + font-size: 0.8rem; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.06em; + margin-right: 4px; + flex-shrink: 0; +} + +.filter-tagpicker-toggle { + display: inline-flex; + border-radius: 999px; + overflow: hidden; + flex-shrink: 0; +} + +.filter-tagpicker-toggle__btn { + border: none; + border-radius: 999px; + padding: 6px 12px; + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; + background: rgba(15, 23, 42, 0.05); + color: #1f2937; + transition: background 0.2s ease, color 0.2s ease, transform 0.15s ease; +} + +.filter-tagpicker-toggle__btn:hover { + background: rgba(15, 23, 42, 0.12); + transform: translateY(-1px); +} + +.filter-tagpicker-toggle__btn--active.filter-tagpicker-toggle__btn--include { + background: var(--success-color); + color: #fff; + box-shadow: 0 6px 14px rgba(46, 125, 50, 0.22); +} + +.filter-tagpicker-toggle__btn--active.filter-tagpicker-toggle__btn--exclude { + background: var(--danger-color); + color: #fff; + box-shadow: 0 6px 14px rgba(198, 40, 40, 0.22); +} + +.filter-tagpicker-picker { + flex: 1 1 200px; + min-width: 0; +} + +.filter-tagpicker-picker .tag-picker__control { + min-height: 34px; + padding: 4px 8px; + gap: 6px; +} + +.filter-tagpicker-picker .tag-picker__input { + flex: 1 0 120px; + min-width: 100px; + font-size: 0.88rem; + padding: 4px 4px; +} + +.filter-tagpicker-reset { + flex-shrink: 0; +} + .table-header { display: flex; align-items: baseline; @@ -421,23 +504,17 @@ tbody tr:hover { .scanner-tab-content { display: none; flex-direction: column; - gap: 24px; + gap: 8px; } .scanner-tab-content.is-active { display: flex; } -/* Full-scan user-filter area — reuses .filter-toolbar / .filter-group / .filter-chip */ -.scanner-user-filters { - display: grid; - gap: 8px; -} - .user-scanner-full { display: flex; flex-direction: column; - gap: 20px; + gap: 8px; } .user-scanner-full > h3 { @@ -445,7 +522,7 @@ tbody tr:hover { } .user-scanner-full > p { - margin: -14px 0 0; + margin: 0 0 8px; color: var(--text-muted); font-size: 0.92rem; } @@ -458,6 +535,10 @@ tbody tr:hover { } /* Search row */ +.user-scanner-search { + margin-bottom: 4px; +} + .user-scanner-search__row { display: flex; gap: 10px; @@ -510,28 +591,32 @@ tbody tr:hover { } /* Site filter – tag-picker component (mirrors configure.css) */ -.user-scanner-site-filter { - align-items: flex-start; -} .tag-picker-row { display: flex; - gap: 12px; - align-items: flex-end; + gap: 8px; + align-items: center; + background: rgba(15, 23, 42, 0.03); + border-radius: 12px; + padding: 8px 12px; } .user-scanner-site-filter .tag-picker-field { - flex: 1 1 100%; + flex: 1 1 0%; + min-width: 0; display: flex; - flex-direction: column; + align-items: center; gap: 8px; - min-width: 260px; } .user-scanner-site-filter .label-prefix { - font-size: 0.92rem; + font-size: 0.8rem; font-weight: 600; - color: #374151; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.06em; + white-space: nowrap; + flex-shrink: 0; } .tag-picker { @@ -652,7 +737,7 @@ tbody tr:hover { } .tag-picker-row .btn { - align-self: center; + flex-shrink: 0; } .btn-ghost { @@ -678,6 +763,7 @@ tbody tr:hover { display: flex; flex-direction: column; gap: 10px; + margin-top: 8px; } .user-scanner-options__item { @@ -739,7 +825,7 @@ tbody tr:hover { .user-scanner-divider { border: none; border-top: 1px solid #e5e7eb; - margin: 0; + margin: 8px 0; } /* Full scan section */ diff --git a/static/reports.js b/static/reports.js index 540463b..d7613bf 100644 --- a/static/reports.js +++ b/static/reports.js @@ -1,5 +1,5 @@ const API_BASE_URL = window.location.origin; -let currentReportKey = 'missing-manager'; +let currentReportKey = 'last-logins'; let latestRecords = []; const FILTER_REASON_I18N_KEYS = { @@ -21,52 +21,45 @@ let fullScanSiteFilterPicker = null; // TagPicker-style instance (all tab let fullScanCategoryFilterPicker = null; // TagPicker-style instance for categories (all tab) let scannerLoudSites = new Set(); // loud sites set, populated after fetch -/** State for the full-scan user-level filters (mirrors last-logins filter toggles). */ -const fullScanFilterState = { - includeUserMailboxes: true, - includeSharedMailboxes: false, - includeRoomEquipmentMailboxes: false, - includeEnabled: true, - includeDisabled: false, - includeLicensed: true, - includeUnlicensed: false, - includeMembers: true, - includeGuests: false, -}; +/** + * Build the standard 9 toggle filters used across most reports. + * @param {Object} [defaults] - Override default values per key. + */ +function _standardToggleFilters(defaults = {}) { + const d = (key, fallback) => key in defaults ? defaults[key] : fallback; + return [ + { type: 'toggle', key: 'includeUserMailboxes', labelKey: 'reports.filters.includeUserMailboxes.label', queryParam: 'includeUserMailboxes', default: d('includeUserMailboxes', true), groupId: 'mailboxTypes', groupLabelKey: 'reports.filters.groups.mailboxTypes' }, + { type: 'toggle', key: 'includeSharedMailboxes', labelKey: 'reports.filters.includeSharedMailboxes.label', queryParam: 'includeSharedMailboxes', default: d('includeSharedMailboxes', false), groupId: 'mailboxTypes', groupLabelKey: 'reports.filters.groups.mailboxTypes' }, + { type: 'toggle', key: 'includeRoomEquipmentMailboxes', labelKey: 'reports.filters.includeRoomEquipmentMailboxes.label', queryParam: 'includeRoomEquipmentMailboxes', default: d('includeRoomEquipmentMailboxes', false), groupId: 'mailboxTypes', groupLabelKey: 'reports.filters.groups.mailboxTypes' }, + { type: 'toggle', key: 'includeEnabled', labelKey: 'reports.filters.includeEnabled.label', queryParam: 'includeEnabled', default: d('includeEnabled', true), groupId: 'accountStatus', groupLabelKey: 'reports.filters.groups.accountStatus' }, + { type: 'toggle', key: 'includeDisabled', labelKey: 'reports.filters.includeDisabled.label', queryParam: 'includeDisabled', default: d('includeDisabled', false), groupId: 'accountStatus', groupLabelKey: 'reports.filters.groups.accountStatus' }, + { type: 'toggle', key: 'includeLicensed', labelKey: 'reports.filters.includeLicensed.label', queryParam: 'includeLicensed', default: d('includeLicensed', true), groupId: 'licenseStatus', groupLabelKey: 'reports.filters.groups.licenseStatus' }, + { type: 'toggle', key: 'includeUnlicensed', labelKey: 'reports.filters.includeUnlicensed.label', queryParam: 'includeUnlicensed', default: d('includeUnlicensed', true), groupId: 'licenseStatus', groupLabelKey: 'reports.filters.groups.licenseStatus' }, + { type: 'toggle', key: 'includeMembers', labelKey: 'reports.filters.includeMembers.label', queryParam: 'includeMembers', default: d('includeMembers', true), groupId: 'userScope', groupLabelKey: 'reports.filters.groups.userScope' }, + { type: 'toggle', key: 'includeGuests', labelKey: 'reports.filters.includeGuests.label', queryParam: 'includeGuests', default: d('includeGuests', false), groupId: 'userScope', groupLabelKey: 'reports.filters.groups.userScope' }, + ]; +} -/** Filter definitions for the full-scan user filter panel. */ -const FULL_SCAN_FILTER_GROUPS = [ - { - labelKey: 'reports.filters.groups.mailboxTypes', - filters: [ - { key: 'includeUserMailboxes', labelKey: 'reports.filters.includeUserMailboxes.label' }, - { key: 'includeSharedMailboxes', labelKey: 'reports.filters.includeSharedMailboxes.label' }, - { key: 'includeRoomEquipmentMailboxes', labelKey: 'reports.filters.includeRoomEquipmentMailboxes.label' }, - ], - }, - { - labelKey: 'reports.filters.groups.accountStatus', - filters: [ - { key: 'includeEnabled', labelKey: 'reports.filters.includeEnabled.label' }, - { key: 'includeDisabled', labelKey: 'reports.filters.includeDisabled.label' }, - ], - }, - { - labelKey: 'reports.filters.groups.licenseStatus', - filters: [ - { key: 'includeLicensed', labelKey: 'reports.filters.includeLicensed.label' }, - { key: 'includeUnlicensed', labelKey: 'reports.filters.includeUnlicensed.label' }, - ], - }, - { - labelKey: 'reports.filters.groups.userScope', - filters: [ - { key: 'includeMembers', labelKey: 'reports.filters.includeMembers.label' }, - { key: 'includeGuests', labelKey: 'reports.filters.includeGuests.label' }, - ], - }, +/** The 3 standard tagpicker filters (title, department, country). */ +const TAGPICKER_FILTERS = [ + { type: 'tagpicker', key: 'filterTitles', labelKey: 'reports.filters.titleFilter.label', placeholderKey: 'reports.filters.titleFilter.placeholder', resetLabelKey: 'reports.filters.titleFilter.reset', modeIncludeLabelKey: 'reports.filters.titleFilter.modeInclude', modeExcludeLabelKey: 'reports.filters.titleFilter.modeExclude', queryParam: 'filterTitles', modeQueryParam: 'filterTitlesMode', optionsSourceKey: 'jobTitles', default: { values: [], mode: 'exclude' } }, + { type: 'tagpicker', key: 'filterDepartments', labelKey: 'reports.filters.departmentFilter.label', placeholderKey: 'reports.filters.departmentFilter.placeholder', resetLabelKey: 'reports.filters.departmentFilter.reset', modeIncludeLabelKey: 'reports.filters.departmentFilter.modeInclude', modeExcludeLabelKey: 'reports.filters.departmentFilter.modeExclude', queryParam: 'filterDepartments', modeQueryParam: 'filterDepartmentsMode', optionsSourceKey: 'departments', default: { values: [], mode: 'exclude' } }, + { type: 'tagpicker', key: 'filterCountries', labelKey: 'reports.filters.countryFilter.label', placeholderKey: 'reports.filters.countryFilter.placeholder', resetLabelKey: 'reports.filters.countryFilter.reset', modeIncludeLabelKey: 'reports.filters.countryFilter.modeInclude', modeExcludeLabelKey: 'reports.filters.countryFilter.modeExclude', queryParam: 'filterCountries', modeQueryParam: 'filterCountriesMode', optionsSourceKey: 'countries', default: { values: [], mode: 'exclude' } }, ]; +/** Scope filter — first filter on every report. */ +const SCOPE_FILTER = { + type: 'segmented', + key: 'scope', + labelKey: 'reports.filters.scope.label', + queryParam: 'scope', + default: 'orgChart', + options: [ + { value: 'orgChart', labelKey: 'reports.filters.scope.options.orgChart' }, + { value: 'all', labelKey: 'reports.filters.scope.options.all' }, + ], +}; + const REPORT_CONFIGS = { 'missing-manager': { dataPath: '/api/reports/missing-manager', @@ -77,87 +70,9 @@ const REPORT_CONFIGS = { countSummaryKey: 'reports.table.countSummary', buildStatusParams: (records) => ({ count: records.length }), filters: [ - { - type: 'toggle', - key: 'includeUserMailboxes', - labelKey: 'reports.filters.includeUserMailboxes.label', - queryParam: 'includeUserMailboxes', - default: true, - groupId: 'mailboxTypes', - groupLabelKey: 'reports.filters.groups.mailboxTypes', - }, - { - type: 'toggle', - key: 'includeSharedMailboxes', - labelKey: 'reports.filters.includeSharedMailboxes.label', - queryParam: 'includeSharedMailboxes', - default: false, - groupId: 'mailboxTypes', - groupLabelKey: 'reports.filters.groups.mailboxTypes', - }, - { - type: 'toggle', - key: 'includeRoomEquipmentMailboxes', - labelKey: 'reports.filters.includeRoomEquipmentMailboxes.label', - queryParam: 'includeRoomEquipmentMailboxes', - default: false, - groupId: 'mailboxTypes', - groupLabelKey: 'reports.filters.groups.mailboxTypes', - }, - { - type: 'toggle', - key: 'includeEnabled', - labelKey: 'reports.filters.includeEnabled.label', - queryParam: 'includeEnabled', - default: true, - groupId: 'accountStatus', - groupLabelKey: 'reports.filters.groups.accountStatus', - }, - { - type: 'toggle', - key: 'includeDisabled', - labelKey: 'reports.filters.includeDisabled.label', - queryParam: 'includeDisabled', - default: false, - groupId: 'accountStatus', - groupLabelKey: 'reports.filters.groups.accountStatus', - }, - { - type: 'toggle', - key: 'includeLicensed', - labelKey: 'reports.filters.includeLicensed.label', - queryParam: 'includeLicensed', - default: true, - groupId: 'licenseStatus', - groupLabelKey: 'reports.filters.groups.licenseStatus', - }, - { - type: 'toggle', - key: 'includeUnlicensed', - labelKey: 'reports.filters.includeUnlicensed.label', - queryParam: 'includeUnlicensed', - default: true, - groupId: 'licenseStatus', - groupLabelKey: 'reports.filters.groups.licenseStatus', - }, - { - type: 'toggle', - key: 'includeMembers', - labelKey: 'reports.filters.includeMembers.label', - queryParam: 'includeMembers', - default: true, - groupId: 'userScope', - groupLabelKey: 'reports.filters.groups.userScope', - }, - { - type: 'toggle', - key: 'includeGuests', - labelKey: 'reports.filters.includeGuests.label', - queryParam: 'includeGuests', - default: false, - groupId: 'userScope', - groupLabelKey: 'reports.filters.groups.userScope', - }, + SCOPE_FILTER, + ..._standardToggleFilters(), + ...TAGPICKER_FILTERS, ], columns: [ { key: 'name', labelKey: 'reports.table.columns.name' }, @@ -172,6 +87,28 @@ const REPORT_CONFIGS = { }, ], }, + 'missing-photo': { + dataPath: '/api/reports/missing-photo', + exportPath: '/api/reports/missing-photo/export', + summaryLabelKey: 'reports.types.missingPhoto.summaryLabel', + tableTitleKey: 'reports.types.missingPhoto.tableTitle', + emptyKey: 'reports.types.missingPhoto.empty', + countSummaryKey: 'reports.types.missingPhoto.countSummary', + buildStatusParams: (records) => ({ count: records.length }), + filters: [ + SCOPE_FILTER, + ..._standardToggleFilters(), + ...TAGPICKER_FILTERS, + ], + columns: [ + { key: 'name', labelKey: 'reports.table.columns.name' }, + { key: 'title', labelKey: 'reports.table.columns.title' }, + { key: 'department', labelKey: 'reports.table.columns.department' }, + { key: 'email', labelKey: 'reports.table.columns.email' }, + { key: 'managerName', labelKey: 'reports.table.columns.manager' }, + { key: 'country', labelKey: 'reports.table.columns.country' }, + ], + }, 'last-logins': { dataPath: '/api/reports/last-logins', exportPath: '/api/reports/last-logins/export', @@ -182,93 +119,15 @@ const REPORT_CONFIGS = { showLicenseSummary: true, licenseSummaryLabelKey: 'reports.summary.licensesLabel', filters: [ - { - type: 'toggle', - key: 'includeUserMailboxes', - labelKey: 'reports.filters.includeUserMailboxes.label', - queryParam: 'includeUserMailboxes', - default: true, - groupId: 'mailboxTypes', - groupLabelKey: 'reports.filters.groups.mailboxTypes', - }, - { - type: 'toggle', - key: 'includeSharedMailboxes', - labelKey: 'reports.filters.includeSharedMailboxes.label', - queryParam: 'includeSharedMailboxes', - default: true, - groupId: 'mailboxTypes', - groupLabelKey: 'reports.filters.groups.mailboxTypes', - }, - { - type: 'toggle', - key: 'includeRoomEquipmentMailboxes', - labelKey: 'reports.filters.includeRoomEquipmentMailboxes.label', - queryParam: 'includeRoomEquipmentMailboxes', - default: true, - groupId: 'mailboxTypes', - groupLabelKey: 'reports.filters.groups.mailboxTypes', - }, - { - type: 'toggle', - key: 'includeEnabled', - labelKey: 'reports.filters.includeEnabled.label', - queryParam: 'includeEnabled', - default: true, - groupId: 'accountStatus', - groupLabelKey: 'reports.filters.groups.accountStatus', - }, - { - type: 'toggle', - key: 'includeDisabled', - labelKey: 'reports.filters.includeDisabled.label', - queryParam: 'includeDisabled', - default: true, - groupId: 'accountStatus', - groupLabelKey: 'reports.filters.groups.accountStatus', - }, - { - type: 'toggle', - key: 'includeLicensed', - labelKey: 'reports.filters.includeLicensed.label', - queryParam: 'includeLicensed', - default: true, - groupId: 'licenseStatus', - groupLabelKey: 'reports.filters.groups.licenseStatus', - }, - { - type: 'toggle', - key: 'includeUnlicensed', - labelKey: 'reports.filters.includeUnlicensed.label', - queryParam: 'includeUnlicensed', - default: true, - groupId: 'licenseStatus', - groupLabelKey: 'reports.filters.groups.licenseStatus', - }, - { - type: 'toggle', - key: 'includeMembers', - labelKey: 'reports.filters.includeMembers.label', - queryParam: 'includeMembers', - default: true, - groupId: 'userScope', - groupLabelKey: 'reports.filters.groups.userScope', - }, - { - type: 'toggle', - key: 'includeGuests', - labelKey: 'reports.filters.includeGuests.label', - queryParam: 'includeGuests', - default: true, - groupId: 'userScope', - groupLabelKey: 'reports.filters.groups.userScope', - }, + SCOPE_FILTER, + ..._standardToggleFilters({ includeSharedMailboxes: true, includeRoomEquipmentMailboxes: true, includeDisabled: true, includeGuests: true }), { type: 'segmented', key: 'inactiveDays', labelKey: 'reports.filters.inactiveDays.label', queryParam: 'inactiveDays', default: null, + renderAfter: 'tagpickers', options: [ { value: null, labelKey: 'reports.filters.inactiveDays.options.all' }, { value: 30, labelKey: 'reports.filters.inactiveDays.options.thirty' }, @@ -285,6 +144,7 @@ const REPORT_CONFIGS = { labelKey: 'reports.filters.inactiveDaysMax.label', queryParam: 'inactiveDaysMax', default: null, + renderAfter: 'tagpickers', options: [ { value: null, labelKey: 'reports.filters.inactiveDaysMax.options.noLimit' }, { value: 30, labelKey: 'reports.filters.inactiveDaysMax.options.thirty' }, @@ -296,6 +156,7 @@ const REPORT_CONFIGS = { { value: 1095, labelKey: 'reports.filters.inactiveDaysMax.options.threeYears' }, ], }, + ...TAGPICKER_FILTERS, ], buildStatusParams: (records) => ({ count: records.length, @@ -354,6 +215,7 @@ const REPORT_CONFIGS = { emptyKey: 'reports.types.hiredThisYear.empty', countSummaryKey: 'reports.types.hiredThisYear.countSummary', buildStatusParams: (records) => ({ count: records.length }), + filters: [SCOPE_FILTER, ...TAGPICKER_FILTERS], columns: [ { key: 'name', labelKey: 'reports.table.columns.name' }, { key: 'title', labelKey: 'reports.table.columns.title' }, @@ -375,106 +237,18 @@ const REPORT_CONFIGS = { tableTitleKey: 'reports.types.filteredUsers.tableTitle', emptyKey: 'reports.types.filteredUsers.empty', countSummaryKey: 'reports.types.filteredUsers.countSummary', - showLicenseSummary: true, - licenseSummaryLabelKey: 'reports.summary.licensesLabel', filters: [ - { - type: 'toggle', - key: 'includeUserMailboxes', - labelKey: 'reports.filters.includeUserMailboxes.label', - queryParam: 'includeUserMailboxes', - default: true, - groupId: 'mailboxTypes', - groupLabelKey: 'reports.filters.groups.mailboxTypes', - }, - { - type: 'toggle', - key: 'includeSharedMailboxes', - labelKey: 'reports.filters.includeSharedMailboxes.label', - queryParam: 'includeSharedMailboxes', - default: true, - groupId: 'mailboxTypes', - groupLabelKey: 'reports.filters.groups.mailboxTypes', - }, - { - type: 'toggle', - key: 'includeRoomEquipmentMailboxes', - labelKey: 'reports.filters.includeRoomEquipmentMailboxes.label', - queryParam: 'includeRoomEquipmentMailboxes', - default: true, - groupId: 'mailboxTypes', - groupLabelKey: 'reports.filters.groups.mailboxTypes', - }, - { - type: 'toggle', - key: 'includeEnabled', - labelKey: 'reports.filters.includeEnabled.label', - queryParam: 'includeEnabled', - default: true, - groupId: 'accountStatus', - groupLabelKey: 'reports.filters.groups.accountStatus', - }, - { - type: 'toggle', - key: 'includeDisabled', - labelKey: 'reports.filters.includeDisabled.label', - queryParam: 'includeDisabled', - default: true, - groupId: 'accountStatus', - groupLabelKey: 'reports.filters.groups.accountStatus', - }, - { - type: 'toggle', - key: 'includeLicensed', - labelKey: 'reports.filters.includeLicensed.label', - queryParam: 'includeLicensed', - default: true, - groupId: 'licenseStatus', - groupLabelKey: 'reports.filters.groups.licenseStatus', - }, - { - type: 'toggle', - key: 'includeUnlicensed', - labelKey: 'reports.filters.includeUnlicensed.label', - queryParam: 'includeUnlicensed', - default: true, - groupId: 'licenseStatus', - groupLabelKey: 'reports.filters.groups.licenseStatus', - }, - { - type: 'toggle', - key: 'includeMembers', - labelKey: 'reports.filters.includeMembers.label', - queryParam: 'includeMembers', - default: true, - groupId: 'userScope', - groupLabelKey: 'reports.filters.groups.userScope', - }, - { - type: 'toggle', - key: 'includeGuests', - labelKey: 'reports.filters.includeGuests.label', - queryParam: 'includeGuests', - default: true, - groupId: 'userScope', - groupLabelKey: 'reports.filters.groups.userScope', - }, + { ...SCOPE_FILTER, default: 'all' }, + ..._standardToggleFilters({ includeSharedMailboxes: true, includeRoomEquipmentMailboxes: true, includeDisabled: true, includeGuests: true }), + ...TAGPICKER_FILTERS, ], - buildStatusParams: (records) => ({ - count: records.length, - licenses: records.reduce((total, item) => total + (item.licenseCount || 0), 0), - }), + buildStatusParams: (records) => ({ count: records.length }), columns: [ { key: 'name', labelKey: 'reports.table.columns.name' }, { key: 'title', labelKey: 'reports.table.columns.title' }, { key: 'department', labelKey: 'reports.table.columns.department' }, { key: 'email', labelKey: 'reports.table.columns.email' }, - { key: 'licenseCount', labelKey: 'reports.table.columns.licenseCount' }, - { - key: 'licenseSkus', - labelKey: 'reports.table.columns.licenses', - render: (record) => (record.licenseSkus || []).join(', '), - }, + { key: 'managerName', labelKey: 'reports.table.columns.manager' }, { key: 'filterReasons', labelKey: 'reports.types.filteredLicensed.columns.filterReasons', @@ -490,6 +264,11 @@ const REPORT_CONFIGS = { emptyKey: 'reports.types.userScanner.empty', countSummaryKey: 'reports.types.userScanner.countSummary', isCustom: true, + filters: [ + { ...SCOPE_FILTER, default: 'all' }, + ..._standardToggleFilters(), + ...TAGPICKER_FILTERS, + ], buildStatusParams: (records) => ({ count: records.length }), columns: [ { key: 'name', labelKey: 'reports.table.columns.name' }, @@ -502,6 +281,36 @@ const REPORT_CONFIGS = { const reportFiltersState = {}; +/** Shared tagpicker filter options cache (fetched once from /api/metadata/options). */ +let _tagpickerOptionsCache = null; +let _tagpickerOptionsFetching = false; +const _tagpickerOptionsCallbacks = []; + +/** Per-report-key tagpicker picker instances keyed by filter.key. */ +const _reportTagpickers = {}; + +function _fetchTagpickerOptions() { + if (_tagpickerOptionsCache) return Promise.resolve(_tagpickerOptionsCache); + if (_tagpickerOptionsFetching) { + return new Promise((resolve) => _tagpickerOptionsCallbacks.push(resolve)); + } + _tagpickerOptionsFetching = true; + return fetch(`${API_BASE_URL}/api/metadata/options`, { credentials: 'include' }) + .then((r) => r.ok ? r.json() : Promise.reject(r.status)) + .then((data) => { + _tagpickerOptionsCache = data; + _tagpickerOptionsFetching = false; + _tagpickerOptionsCallbacks.forEach((cb) => cb(data)); + _tagpickerOptionsCallbacks.length = 0; + return data; + }) + .catch((err) => { + _tagpickerOptionsFetching = false; + console.error('Failed to fetch tagpicker options:', err); + return null; + }); +} + function resolveFilterDefault(filter) { if (filter.type === 'toggle') { return Boolean(filter.default); @@ -509,6 +318,10 @@ function resolveFilterDefault(filter) { if (filter.type === 'segmented') { return filter.default ?? null; } + if (filter.type === 'tagpicker') { + const def = filter.default || {}; + return { values: Array.isArray(def.values) ? def.values.slice() : [], mode: def.mode || 'exclude' }; + } return filter.default; } @@ -563,6 +376,12 @@ function normalizeFilterValue(filter, value) { const parsed = Number(value); return Number.isNaN(parsed) ? value : parsed; } + if (filter.type === 'tagpicker') { + if (value && typeof value === 'object') { + return { values: Array.isArray(value.values) ? value.values : [], mode: value.mode || 'exclude' }; + } + return { values: [], mode: 'exclude' }; + } return value; } @@ -598,12 +417,15 @@ function updateFilterValue(reportKey, filter, value) { return; } } + // tagpicker: always accept the value (complex object) state[filter.key] = normalizedValue; if (reportKey === currentReportKey) { const config = REPORT_CONFIGS[reportKey]; - renderFilters(config, reportKey); + if (filter.type !== 'tagpicker') { + renderFilters(config, reportKey); + } loadReport().catch((error) => { console.error('Failed to load report with updated filters:', error); }); @@ -617,6 +439,8 @@ function renderFilters(config, reportKey) { } const filters = config.filters || []; + const tagpickerFilters = filters.filter((f) => f.type === 'tagpicker'); + if (!filters.length) { container.classList.add('is-hidden'); container.innerHTML = ''; @@ -624,6 +448,8 @@ function renderFilters(config, reportKey) { } const t = getTranslator(); + const state = ensureFilterState(reportKey); + container.classList.remove('is-hidden'); container.innerHTML = ''; @@ -632,26 +458,27 @@ function renderFilters(config, reportKey) { title.textContent = t('reports.filters.title'); container.appendChild(title); - const state = ensureFilterState(reportKey); - const groups = []; + const deferredGroups = []; filters.forEach((filter) => { + if (filter.type === 'tagpicker') return; // rendered separately below + const targetList = filter.renderAfter === 'tagpickers' ? deferredGroups : groups; const groupId = filter.groupId || filter.key; - let group = groups.find((entry) => entry.id === groupId); + let group = targetList.find((entry) => entry.id === groupId); if (!group) { group = { id: groupId, labelKey: filter.groupLabelKey || null, filters: [], }; - groups.push(group); + targetList.push(group); } else if (!group.labelKey && filter.groupLabelKey) { group.labelKey = filter.groupLabelKey; } group.filters.push(filter); }); - groups.forEach((group) => { + const _renderGroup = (group) => { if (group.filters.length === 1 && group.filters[0].type === 'segmented') { const filter = group.filters[0]; const groupElement = document.createElement('div'); @@ -714,6 +541,285 @@ function renderFilters(config, reportKey) { }); container.appendChild(groupElement); + }; + + groups.forEach(_renderGroup); + + // Render tagpicker filters + if (tagpickerFilters.length) { + _renderTagpickerFilters(container, tagpickerFilters, state, reportKey, t); + } + + // Render groups that should appear after tagpickers + deferredGroups.forEach(_renderGroup); +} + +/** + * Lightweight tag-picker for report filters. + * Reuses the tag-picker CSS classes from the scanner section. + */ +class ReportTagPicker { + constructor({ container, options = [], placeholder = '', noMatchText = '', noOptionsText = '', removeLabel, onChange }) { + this.onChange = typeof onChange === 'function' ? onChange : () => {}; + this.options = (Array.isArray(options) ? options.slice() : []).filter(Boolean); + this.options.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); + this.selected = []; + this.selectedSet = new Set(); + this.filteredOptions = []; + this.noMatchText = noMatchText || 'No matches found'; + this.noOptionsText = noOptionsText || 'No options available'; + this.removeLabel = typeof removeLabel === 'function' ? removeLabel : (value) => `Remove ${value}`; + + // Build DOM + this.root = document.createElement('div'); + this.root.className = 'tag-picker'; + + const control = document.createElement('div'); + control.className = 'tag-picker__control'; + + this.tagContainer = document.createElement('div'); + this.tagContainer.className = 'tag-picker__tags'; + this.tagContainer.setAttribute('data-role', 'tag-container'); + control.appendChild(this.tagContainer); + + this.input = document.createElement('input'); + this.input.type = 'text'; + this.input.className = 'tag-picker__input'; + this.input.autocomplete = 'off'; + this.input.placeholder = placeholder; + control.appendChild(this.input); + this.root.appendChild(control); + + this.dropdown = document.createElement('div'); + this.dropdown.className = 'tag-picker__dropdown'; + this.dropdown.setAttribute('data-role', 'dropdown'); + this.dropdown.hidden = true; + this.root.appendChild(this.dropdown); + + container.appendChild(this.root); + + // Event listeners + this._onDocClick = (e) => { if (!this.root.contains(e.target)) this.dropdown.hidden = true; }; + this.tagContainer.addEventListener('click', (e) => { + const btn = e.target.closest('.tag-picker__remove'); + if (btn) this._removeValue(btn.getAttribute('data-value') || ''); + }); + this.dropdown.addEventListener('click', (e) => { + const opt = e.target.closest('[data-value]'); + if (opt) { e.preventDefault(); e.stopPropagation(); this._addValue(opt.getAttribute('data-value') || ''); } + }); + this.input.addEventListener('input', () => { this._refreshDropdown(); this.dropdown.hidden = false; }); + this.input.addEventListener('focus', () => { this._refreshDropdown(); this.dropdown.hidden = false; }); + this.input.addEventListener('keydown', (e) => { + if (e.key === 'Backspace' && !this.input.value && this.selected.length) { + this._removeValue(this.selected[this.selected.length - 1]); e.preventDefault(); + } else if ((e.key === 'Enter' || e.key === 'Tab') && this.input.value.trim()) { + this._addValue(this.filteredOptions.length ? this.filteredOptions[0] : this.input.value.trim()); + e.preventDefault(); + } + }); + document.addEventListener('click', this._onDocClick); + this._renderTags(); + } + + setOptions(opts) { + this.options = (Array.isArray(opts) ? opts.slice() : []).filter(Boolean); + this.options.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); + } + + setValue(values) { + this.selected = []; this.selectedSet = new Set(); + (values || []).forEach((v) => { + const n = (v || '').trim(); if (!n) return; + const k = n.toLowerCase(); + if (!this.selectedSet.has(k)) { this.selected.push(n); this.selectedSet.add(k); } + }); + this._renderTags(); + this.input.value = ''; + this.dropdown.hidden = true; + } + + getValue() { return this.selected.slice(); } + + clear() { this.setValue([]); this.onChange(); } + + destroy() { document.removeEventListener('click', this._onDocClick); } + + _addValue(raw) { + const n = (raw || '').trim(); if (!n) return; + const k = n.toLowerCase(); + if (this.selectedSet.has(k)) { this.input.value = ''; this.dropdown.hidden = true; return; } + this.selected.push(n); this.selectedSet.add(k); + this._renderTags(); + this.input.value = ''; + this._refreshDropdown(); this.dropdown.hidden = false; + requestAnimationFrame(() => this.input.focus()); + this.onChange(); + } + + _removeValue(raw) { + const n = (raw || '').trim(); if (!n) return; + const k = n.toLowerCase(); + if (!this.selectedSet.has(k)) return; + this.selected = this.selected.filter((i) => i.toLowerCase() !== k); + this.selectedSet.delete(k); + this._renderTags(); + this._refreshDropdown(); this.dropdown.hidden = false; + requestAnimationFrame(() => this.input.focus()); + this.onChange(); + } + + _renderTags() { + this.tagContainer.innerHTML = ''; + this.selected.forEach((value) => { + const tag = document.createElement('span'); + tag.className = 'tag-picker__tag'; + const label = document.createElement('span'); + label.textContent = value; + tag.appendChild(label); + const btn = document.createElement('button'); + btn.type = 'button'; btn.className = 'tag-picker__remove'; + btn.setAttribute('aria-label', this.removeLabel(value)); + btn.dataset.value = value; btn.innerHTML = '×'; + tag.appendChild(btn); + this.tagContainer.appendChild(tag); + }); + } + + _refreshDropdown() { + const q = this.input.value.trim().toLowerCase(); + const avail = this.options.filter((o) => !this.selectedSet.has(o.toLowerCase())); + let filtered = avail; + if (q) filtered = avail.filter((o) => o.toLowerCase().includes(q)); + this.filteredOptions = filtered.slice(0, 60); + this.dropdown.innerHTML = ''; + if (!this.filteredOptions.length) { + const empty = document.createElement('div'); + empty.className = 'tag-picker__option tag-picker__option--empty'; + empty.textContent = q ? this.noMatchText : this.noOptionsText; + this.dropdown.appendChild(empty); + return; + } + this.filteredOptions.forEach((opt) => { + const el = document.createElement('div'); + el.className = 'tag-picker__option'; el.dataset.value = opt; + const titleEl = document.createElement('span'); + titleEl.className = 'tag-picker__option-title'; titleEl.textContent = opt; + el.appendChild(titleEl); + this.dropdown.appendChild(el); + }); + } +} + +function _renderTagpickerFilters(container, tagpickerFilters, state, reportKey, t) { + // Ensure we have a pickers map for this report + if (!_reportTagpickers[reportKey]) { + _reportTagpickers[reportKey] = {}; + } + + // Destroy previous pickers for this report if they exist in the DOM + Object.values(_reportTagpickers[reportKey]).forEach((p) => { + if (p && p.destroy) p.destroy(); + }); + _reportTagpickers[reportKey] = {}; + + tagpickerFilters.forEach((filter) => { + const currentState = Object.prototype.hasOwnProperty.call(state, filter.key) + ? state[filter.key] + : resolveFilterDefault(filter); + const values = (currentState && currentState.values) || []; + const mode = (currentState && currentState.mode) || 'exclude'; + + const row = document.createElement('div'); + row.className = 'filter-tagpicker-row'; + + // Label + const label = document.createElement('span'); + label.className = 'filter-tagpicker-row__label'; + label.textContent = t(filter.labelKey); + row.appendChild(label); + + // Include/Exclude toggle + const toggleWrap = document.createElement('div'); + toggleWrap.className = 'filter-tagpicker-toggle'; + + const includeBtn = document.createElement('button'); + includeBtn.type = 'button'; + includeBtn.className = `filter-tagpicker-toggle__btn${mode === 'include' ? ' filter-tagpicker-toggle__btn--active filter-tagpicker-toggle__btn--include' : ''}`; + includeBtn.textContent = t(filter.modeIncludeLabelKey); + includeBtn.setAttribute('aria-pressed', String(mode === 'include')); + + const excludeBtn = document.createElement('button'); + excludeBtn.type = 'button'; + excludeBtn.className = `filter-tagpicker-toggle__btn${mode === 'exclude' ? ' filter-tagpicker-toggle__btn--active filter-tagpicker-toggle__btn--exclude' : ''}`; + excludeBtn.textContent = t(filter.modeExcludeLabelKey); + excludeBtn.setAttribute('aria-pressed', String(mode === 'exclude')); + + const setMode = (newMode) => { + const curState = state[filter.key] || resolveFilterDefault(filter); + updateFilterValue(reportKey, filter, { values: curState.values || [], mode: newMode }); + // Update toggle button styles without full re-render + if (newMode === 'include') { + includeBtn.classList.add('filter-tagpicker-toggle__btn--active', 'filter-tagpicker-toggle__btn--include'); + excludeBtn.classList.remove('filter-tagpicker-toggle__btn--active', 'filter-tagpicker-toggle__btn--exclude'); + } else { + excludeBtn.classList.add('filter-tagpicker-toggle__btn--active', 'filter-tagpicker-toggle__btn--exclude'); + includeBtn.classList.remove('filter-tagpicker-toggle__btn--active', 'filter-tagpicker-toggle__btn--include'); + } + includeBtn.setAttribute('aria-pressed', String(newMode === 'include')); + excludeBtn.setAttribute('aria-pressed', String(newMode === 'exclude')); + }; + + includeBtn.addEventListener('click', () => setMode('include')); + excludeBtn.addEventListener('click', () => setMode('exclude')); + + toggleWrap.appendChild(includeBtn); + toggleWrap.appendChild(excludeBtn); + row.appendChild(toggleWrap); + + // Tag picker container + const pickerWrap = document.createElement('div'); + pickerWrap.className = 'filter-tagpicker-picker'; + + const picker = new ReportTagPicker({ + container: pickerWrap, + options: [], + placeholder: t(filter.placeholderKey), + noMatchText: t('reports.filters.tagpickerNoMatches'), + noOptionsText: t('reports.filters.tagpickerNoOptions'), + removeLabel: (value) => t('reports.filters.tagpickerRemoveLabel', { value }), + onChange: () => { + const curState = state[filter.key] || resolveFilterDefault(filter); + updateFilterValue(reportKey, filter, { values: picker.getValue(), mode: curState.mode || 'exclude' }); + }, + }); + picker.setValue(values); + _reportTagpickers[reportKey][filter.key] = picker; + + row.appendChild(pickerWrap); + + // Reset button + const resetBtn = document.createElement('button'); + resetBtn.type = 'button'; + resetBtn.className = 'btn btn-ghost filter-tagpicker-reset'; + resetBtn.textContent = t(filter.resetLabelKey); + resetBtn.addEventListener('click', () => { + picker.clear(); + }); + row.appendChild(resetBtn); + + container.appendChild(row); + }); + + // Fetch options and populate pickers + _fetchTagpickerOptions().then((data) => { + if (!data) return; + tagpickerFilters.forEach((filter) => { + const picker = _reportTagpickers[reportKey] && _reportTagpickers[reportKey][filter.key]; + if (picker && data[filter.optionsSourceKey]) { + picker.setOptions(data[filter.optionsSourceKey]); + } + }); }); } @@ -743,6 +849,20 @@ function applyFiltersToUrl(url, config, reportKey) { } else { url.searchParams.set(paramName, value); } + } else if (filter.type === 'tagpicker') { + const tagVal = value || {}; + const vals = tagVal.values || []; + const mode = tagVal.mode || 'exclude'; + if (vals.length) { + url.searchParams.delete(paramName); + vals.forEach((tag) => { + url.searchParams.append(paramName, tag); + }); + url.searchParams.set(filter.modeQueryParam, mode); + } else { + url.searchParams.delete(paramName); + url.searchParams.delete(filter.modeQueryParam); + } } else if (value !== undefined && value !== null && value !== '') { url.searchParams.set(paramName, value); } @@ -1036,19 +1156,47 @@ function renderTable(records, config) { async function loadReport({ refresh = false } = {}) { const config = REPORT_CONFIGS[currentReportKey] || REPORT_CONFIGS['missing-manager']; - renderFilters(config, currentReportKey); + // Only re-render non-tagpicker filter groups before fetch; + // tagpicker pickers are preserved to avoid destroying picker state. + const hasTagpickers = (config.filters || []).some((f) => f.type === 'tagpicker'); + if (!hasTagpickers) { + renderFilters(config, currentReportKey); + } toggleLoading(true, config); clearError(); + const TRANSIENT_CODES = [502, 503, 504]; + const MAX_RETRIES = 2; + const RETRY_DELAY_MS = 2000; + try { const url = new URL(config.dataPath, API_BASE_URL); if (refresh) { url.searchParams.set('refresh', 'true'); } applyFiltersToUrl(url, config, currentReportKey); - const response = await fetch(url, { credentials: 'include' }); + + let response; + let lastStatus; + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + if (attempt > 0) { + await new Promise(r => setTimeout(r, RETRY_DELAY_MS * attempt)); + } + response = await fetch(url, { credentials: 'include' }); + lastStatus = response.status; + + if (lastStatus === 401) { + window.location.href = '/login?next=reports'; + return; + } + if (!TRANSIENT_CODES.includes(lastStatus)) break; + } + if (!response.ok) { - throw new Error(`${response.status}`); + const errorKey = TRANSIENT_CODES.includes(lastStatus) + ? 'reports.errors.loadTimeout' + : 'reports.errors.loadFailed'; + throw { messageKey: errorKey, detail: `${lastStatus}` }; } const payload = await response.json(); latestRecords = Array.isArray(payload.records) ? payload.records : []; @@ -1061,7 +1209,11 @@ async function loadReport({ refresh = false } = {}) { toggleLoading(false, config, latestRecords); } catch (error) { toggleLoading(false, config, []); - showError('reports.errors.loadFailed', error.message); + if (error && error.messageKey) { + showError(error.messageKey, error.detail); + } else { + showError('reports.errors.loadFailed', error.message); + } renderSummary([], null, config); renderTable([], config); } @@ -1071,12 +1223,32 @@ async function exportReport() { const config = REPORT_CONFIGS[currentReportKey] || REPORT_CONFIGS['missing-manager']; clearError(); + const TRANSIENT_CODES = [502, 503, 504]; + const MAX_RETRIES = 2; + const RETRY_DELAY_MS = 2000; + try { const url = new URL(config.exportPath, API_BASE_URL); applyFiltersToUrl(url, config, currentReportKey); - const response = await fetch(url, { credentials: 'include' }); + + let response; + let lastStatus; + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + if (attempt > 0) { + await new Promise(r => setTimeout(r, RETRY_DELAY_MS * attempt)); + } + response = await fetch(url, { credentials: 'include' }); + lastStatus = response.status; + + if (lastStatus === 401) { + window.location.href = '/login?next=reports'; + return; + } + if (!TRANSIENT_CODES.includes(lastStatus)) break; + } + if (!response.ok) { - throw new Error(`${response.status}`); + throw new Error(`${lastStatus}`); } const blob = await response.blob(); @@ -1312,15 +1484,10 @@ function toggleUserScannerPanel(show) { const tablePanel = document.querySelector('.table-panel'); const summaryPanel = document.querySelector('.summary-panel'); const headerActions = document.querySelector('.header-actions'); - const filterPanel = document.querySelector('.filter-panel'); - const reportFilters = qs('reportFilters'); if (panel) panel.classList.toggle('is-hidden', !show); if (tablePanel) tablePanel.classList.toggle('is-hidden', show); if (summaryPanel) summaryPanel.classList.toggle('is-hidden', show); if (headerActions) headerActions.classList.toggle('is-hidden', show); - if (filterPanel) filterPanel.classList.toggle('is-hidden', show); - // Hide report-level filter chips (Mailbox Type, Account Status, etc.) - if (reportFilters) { reportFilters.classList.add('is-hidden'); reportFilters.innerHTML = ''; } } async function checkUserScannerEnabled() { @@ -1574,9 +1741,18 @@ class SiteFilterPicker { if (this.dropdown) this.dropdown.addEventListener('click', this._onDropClick); if (this.input) { this.input.addEventListener('input', this._onInput); - this.input.addEventListener('focus', () => this._openDropdown()); + this.input.addEventListener('focus', () => { this._refreshDropdown(); this._openDropdown(); }); this.input.addEventListener('keydown', this._onKeyDown); } + // Allow clicking anywhere on the control area to focus the input + const control = this.root.querySelector('.tag-picker__control'); + if (control) { + control.addEventListener('click', (e) => { + if (e.target.closest('.tag-picker__remove')) return; + if (this.input) this.input.focus(); + }); + control.style.cursor = 'text'; + } document.addEventListener('click', this._onDocClick); this._renderTags(); this._closeDropdown(); @@ -1762,50 +1938,35 @@ function _getFullScanOptions() { /** Read the user-level filter state for the All tab. */ function _getFullScanUserFilters() { - return { ...fullScanFilterState }; -} - -/** - * Render the full-scan user filters using the same filter-group / filter-chip - * pattern as the report filter toolbar. - */ -function renderFullScanFilters() { - const container = document.getElementById('fullScanFilters'); - if (!container) return; - - const t = getTranslator(); - container.innerHTML = ''; - - const title = document.createElement('span'); - title.className = 'filter-toolbar__title'; - title.textContent = t('reports.userScanner.fullScan.filtersTitle'); - container.appendChild(title); - - FULL_SCAN_FILTER_GROUPS.forEach(group => { - const groupEl = document.createElement('div'); - groupEl.className = 'filter-group'; - - const label = document.createElement('span'); - label.className = 'filter-group__label'; - label.textContent = t(group.labelKey); - groupEl.appendChild(label); + const base = {}; + + // Read all filter values (scope, toggles, tagpickers) from the report state + const state = ensureFilterState('user-scanner'); + const config = REPORT_CONFIGS['user-scanner']; + if (config && config.filters) { + config.filters.forEach((filter) => { + const paramName = filter.queryParam || filter.key; + const val = Object.prototype.hasOwnProperty.call(state, filter.key) + ? state[filter.key] + : resolveFilterDefault(filter); - group.filters.forEach(filter => { - const isActive = !!fullScanFilterState[filter.key]; - const btn = document.createElement('button'); - btn.type = 'button'; - btn.className = `filter-chip${isActive ? ' filter-chip--active' : ''}`; - btn.textContent = t(filter.labelKey); - btn.setAttribute('aria-pressed', String(isActive)); - btn.addEventListener('click', () => { - fullScanFilterState[filter.key] = !fullScanFilterState[filter.key]; - renderFullScanFilters(); - }); - groupEl.appendChild(btn); + if (filter.type === 'toggle') { + base[paramName] = !!val; + } else if (filter.type === 'segmented') { + if (val !== null && val !== undefined && val !== '') { + base[paramName] = val; + } + } else if (filter.type === 'tagpicker') { + const tagVal = val || { values: [], mode: 'exclude' }; + if (tagVal.values && tagVal.values.length) { + base[paramName] = tagVal.values; + base[filter.modeQueryParam] = tagVal.mode || 'exclude'; + } + } }); + } - container.appendChild(groupEl); - }); + return base; } function _syncSiteFilterFromPicker() { @@ -2181,9 +2342,15 @@ function initUserScannerPanel() { // Organization tab: hide results table, only show terminal + downloads if (tablePanel) tablePanel.classList.add('is-hidden'); loadFullScanHistory(); + // Show scope filter for Organization tab + const reportFilters = qs('reportFilters'); + if (reportFilters) reportFilters.classList.remove('is-hidden'); } else { // Individual tab: show the results table for single-user scans if (tablePanel) tablePanel.classList.remove('is-hidden'); + // Hide scope filter for Individual tab (not applicable) + const reportFilters = qs('reportFilters'); + if (reportFilters) reportFilters.classList.add('is-hidden'); } }); }); @@ -2298,7 +2465,6 @@ function initUserScannerPanel() { }); initSiteFilter(); - renderFullScanFilters(); } async function initializeReportsPage() { @@ -2323,7 +2489,7 @@ async function initializeReportsPage() { scannerOption.style.display = 'none'; // If scanner was selected but unavailable, fall back to default if (currentReportKey === 'user-scanner') { - currentReportKey = 'missing-manager'; + currentReportKey = 'last-logins'; } } @@ -2331,12 +2497,22 @@ async function initializeReportsPage() { // Show scanner panel if it's the initial selection if (currentReportKey === 'user-scanner' && scannerStatus.enabled) { toggleUserScannerPanel(true); + const config = REPORT_CONFIGS[currentReportKey]; + ensureFilterState(currentReportKey); + renderFilters(config, currentReportKey); + loadFullScanHistory(); } reportSelect.addEventListener('change', () => { currentReportKey = reportSelect.value; const isScanner = currentReportKey === 'user-scanner'; toggleUserScannerPanel(isScanner); - if (isScanner) return; + if (isScanner) { + const config = REPORT_CONFIGS[currentReportKey]; + ensureFilterState(currentReportKey); + renderFilters(config, currentReportKey); + loadFullScanHistory(); + return; + } const config = REPORT_CONFIGS[currentReportKey] || REPORT_CONFIGS['missing-manager']; ensureFilterState(currentReportKey); renderSummary([], null, config); diff --git a/templates/reports.html b/templates/reports.html index 8b06b9f..49c5e9b 100644 --- a/templates/reports.html +++ b/templates/reports.html @@ -37,24 +37,27 @@
Scan all employees by email. Results are downloaded automatically when complete.
- - -