diff --git a/addons_extensions/hr_recruitment_web_app/__init__.py b/addons_extensions/hr_recruitment_web_app/__init__.py new file mode 100644 index 000000000..19240f4ea --- /dev/null +++ b/addons_extensions/hr_recruitment_web_app/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models \ No newline at end of file diff --git a/addons_extensions/hr_recruitment_web_app/__manifest__.py b/addons_extensions/hr_recruitment_web_app/__manifest__.py new file mode 100644 index 000000000..bd4406087 --- /dev/null +++ b/addons_extensions/hr_recruitment_web_app/__manifest__.py @@ -0,0 +1,50 @@ +# __manifest__.py +{ + 'name': 'Recruitment web app', + 'version': '18.0', + 'category': 'Tools', + "author": "FTPROTECH PVT LTD", + "website": "https://www.ftprotech.in/", + 'summary': 'Extracts the information of candidates from the resumes and creates applications in recruitment.', + 'depends': ['base', 'web', 'web_editor', 'sms', 'hr_recruitment', 'hr_recruitment_extended', 'base_setup','website_hr_recruitment_extended'], + + 'assets': { + 'web.assets_frontend': [ + 'hr_recruitment_web_app/static/lib/ckeditor/ckeditor.js', + + 'hr_recruitment_web_app/static/src/js/ats.js', + 'hr_recruitment_web_app/static/src/js/job_requests.js', + 'hr_recruitment_web_app/static/src/js/applicants.js', + 'hr_recruitment_web_app/static/src/css/candidate.css', + # + # + 'hr_recruitment_web_app/static/src/css/colors.css', + 'hr_recruitment_web_app/static/src/css/ats.css', + 'hr_recruitment_web_app/static/src/css/list.css', + 'hr_recruitment_web_app/static/src/css/content.css', + 'hr_recruitment_web_app/static/src/css/applicants.css', + 'hr_recruitment_web_app/static/src/css/jd.css', + # 'hr_recruitment_web_application/static/src/css/applicants_details.css', + # 'hr_recruitment_web_application/static/src/css/ats_candidate.css', + ], + }, + + 'data': [ + "security/ir.model.access.csv", + 'views/recruitmnet_doc_upload_wizard.xml', + 'views/hr_candidate.xml', + 'views/res_config_view.xml', + 'views/main.xml', + 'views/recruitment.xml', + 'views/jd.xml', + 'views/applicants.xml', + 'views/candidate.xml', + + ], + 'images': ['static/description/banner.png'], + 'external_dependencies': { + 'python': ['pytesseract', 'pdf2image', 'pypdf'], + }, + 'installable': True, + 'application': True, +} diff --git a/addons_extensions/hr_recruitment_web_app/controllers/__init__.py b/addons_extensions/hr_recruitment_web_app/controllers/__init__.py new file mode 100644 index 000000000..72a8479c9 --- /dev/null +++ b/addons_extensions/hr_recruitment_web_app/controllers/__init__.py @@ -0,0 +1 @@ +from . import web_recruitment \ No newline at end of file diff --git a/addons_extensions/hr_recruitment_web_app/controllers/web_recruitment.py b/addons_extensions/hr_recruitment_web_app/controllers/web_recruitment.py new file mode 100644 index 000000000..a62a8e26a --- /dev/null +++ b/addons_extensions/hr_recruitment_web_app/controllers/web_recruitment.py @@ -0,0 +1,1294 @@ +import os +import tempfile + +from odoo import http +from odoo.http import request, Response, content_disposition +import json +import requests +import re +import base64 + + + +class ATSController(http.Controller): + + @http.route('/myATS', type='http', auth='user', website=True) + def index(self, **kw): + return request.render('hr_recruitment_web_app.ats_main_home_page') + + @http.route('/myATS/data', type='json', auth='user') + def my_data(self): + records = request.env['res.partner'].search([], limit=5) + return [{'name': r.name} for r in records] + + @http.route('/myATS/page/', type='http', auth='user', website=True) + def render_partial_content(self, page, **kwargs): + if page == "job_requests": + jobs = request.env['hr.job.recruitment'].search([], order='create_date desc') + return request.render('hr_recruitment_web_app.job_list_partial_page', {'jobs': jobs}) + elif page == "applicants": + applicants = request.env['hr.applicant'].search([], order='create_date desc') + return request.render('hr_recruitment_web_app.applicants_list_partial_page', {'applicants': applicants}) + elif page == "candidates": + candidates = request.env['hr.candidate'].search([], order='create_date desc') + return request.render('hr_recruitment_web_app.candidates_list_partial_page', {'candidates': candidates}) + return "

Page Not Found

" + + @http.route('/myATS/job/detail/', type='http', auth='user', csrf=False) + def job_detail_ajax(self, job_id): + job = request.env['hr.job.recruitment'].sudo().browse(job_id) + if not job.exists(): + return request.not_found() + + job_categories = request.env['job.category'].sudo().search([]) + job_ids = request.env['hr.job'].sudo().search([]) + progress = 0 + if job.no_of_recruitment: + progress = min(100, (job.no_of_hired_employee / job.no_of_recruitment) * 100) + + return request.env['ir.ui.view']._render_template( + 'hr_recruitment_web_app.job_detail_partial', + { + 'job': job, + 'job_ids': job_ids, + 'job_categories': job_categories, + 'progress': progress, # 👈 add this + } + ) + + @http.route('/myATS/job/create', type='http', auth='user', methods=['POST'], csrf=False) + def create_job_request(self, **post): + try: + # Get the JSON data from the request body + try: + data = json.loads(request.httprequest.data) + except ValueError: + return Response( + json.dumps({ + 'success': False, + 'error': 'Invalid JSON data' + }), + content_type='application/json', + status=400 + ) + # Prepare vals dictionary for job creation + vals = {} + + job_requests = request.env['hr.job.recruitment'].sudo().search( + [('recruitment_sequence', '=', str(data['sequence']))]) + if job_requests: + return Response(json.dumps({ + 'success': False, + 'error': '\n\nJD creation Failed Duplicate Sequence,\n\n(%s) Already Exists\n\nCreated by: "%s"' % ( + str(data['sequence']), job_requests.create_uid.display_name), + }), + content_type='application/json', + ) + # Add only if value exists + if data.get('position_id'): + vals['job_id'] = int(data['position_id']) + + if 'sequence' in data: + vals['recruitment_sequence'] = data['sequence'] + + if data.get('category'): + vals['job_category'] = int(data['category']) + + if 'priority' in data: + vals['job_priority'] = data.get('priority', 'medium') + + if data.get('employment_type_id'): + vals['contract_type_id'] = int(data['employment_type_id']) + + if 'no_of_positions' in data: + vals['no_of_recruitment'] = int(data.get('no_of_positions', 1)) + + if 'description' in data: + vals['description'] = data.get('description', '') + + if data.get('primary_recruiter_id'): + vals['user_id'] = int(data['primary_recruiter_id']) + else: + vals['user_id'] = request.env.user.id + + if data.get('client_company_id'): + vals['address_id'] = int(data['client_company_id']) + + if data.get('client_id'): + vals['requested_by'] = int(data['client_id']) + + if data.get('target_from'): + vals['target_from'] = data['target_from'] + + if data.get('target_to'): + vals['target_to'] = data['target_to'] + + if 'eligible_submissions' in data: + vals['no_of_eligible_submissions'] = int(data.get('eligible_submissions', 0)) + + if 'budget' in data: + vals['budget'] = data.get('budget', 0) or 'N/A' + + if 'work_type' in data: + vals['recruitment_type'] = data.get('work_type', 'internal') + + if 'experience_id' in data: + vals['experience'] = int(data.get('experience_id', 0)) + + vals['create_uid'] = request.env.user.id + # Create the job position + job = request.env['hr.job.recruitment'].sudo().create(vals) + + # Handle many2many fields + if data.get('primary_skill_ids'): + job.write({ + 'skill_ids': [(6, 0, [int(id) for id in data['primary_skill_ids']])] + }) + + if data.get('secondary_skill_ids'): + job.write({ + 'secondary_skill_ids': [(6, 0, [int(id) for id in data['secondary_skill_ids']])] + }) + + if data.get('location_ids'): + job.write({ + 'locations': [(6, 0, [int(id) for id in data['location_ids']])] + }) + + if data.get('stage_ids'): + job.write({ + 'recruitment_stage_ids': [(6, 0, [int(id) for id in data['stage_ids']])] + }) + + if data.get('secondary_recruiter_ids'): + job.write({ + 'interviewer_ids': [(6, 0, [int(id) for id in data['secondary_recruiter_ids']])] + }) + # Return proper JSON response + return Response( + json.dumps({ + 'success': True, + 'job_id': job.id, + 'message': 'JD created successfully' + }), + content_type='application/json', + ) + + except Exception as e: + error_msg = str(e) + print("Job creation error: %s", error_msg) # Add this line + return Response( + json.dumps({ + 'success': False, + 'error': error_msg, + 'message': 'Failed to create JD', + }), + content_type='application/json', + status=400 if 'duplicate key' in error_msg else 500 + ) + + @http.route('/myATS/job/save', type='http', auth='user', methods=['POST'], csrf=False) + def save_job_details(self, **post): + try: + # Parse the JSON data from the request body + data = json.loads(request.httprequest.data) + id = int(data.get('id', 0)) + + if not id: + return Response( + json.dumps({'success': False, 'error': 'Invalid job ID'}), + content_type='application/json', + status=400 + ) + + job = request.env['hr.job.recruitment'].sudo().browse(id) + if not job.exists(): + return Response( + json.dumps({'success': False, 'error': 'Job not found'}), + content_type='application/json', + status=404 + ) + + vals = { + 'recruitment_sequence': data.get('job_sequence', ''), + 'job_id': int(data.get('job_id', 0)), + 'job_category': int(data.get('category', 0)), + 'description': data.get('description', ''), + } + + job.write(vals) + + return Response( + json.dumps({'success': True}), + content_type='application/json' + ) + + except Exception as e: + return Response( + json.dumps({'success': False, 'error': str(e)}), + content_type='application/json', + status=500 + ) + + @http.route('/get_client_companies', type='json', auth='public', csrf=False) + def get_client_companies(self): + # Safely access the JSON body from the request + data = request.httprequest.get_json(force=True, silent=True) or {} + + work_type = data.get('type') + domain = [('is_company', '=', True)] + if work_type: + domain.append(('contact_type', '=', work_type)) + + companies = request.env['res.partner'].sudo().search(domain) + return [{'id': c.id, 'name': c.name} for c in companies] + + @http.route('/get_clients_by_company', type='json', auth='public', csrf=False) + def get_clients_by_company(self): + data = request.httprequest.get_json(force=True, silent=True) or {} + company_id = data.get('company_id') + type = data.get('type') + if not company_id and not type: + return [] + domain = [] + if type: + domain.append(('contact_type', '=', type)) + if company_id: + domain.append('|') + domain.append(('parent_id', '=', int(company_id))) + domain.append(('id', '=', int(company_id))) + + clients = request.env['res.partner'].sudo().search(domain) + + return [{'id': c.id, 'name': c.name, 'company_id': c.parent_id.id if c.parent_id else request.env.company.id} + for c in clients] + + @http.route('/jd/upload', type='http', auth='user', methods=['POST'], csrf=False) + def upload_jd(self, **post): + try: + file = http.request.httprequest.files.get('file') + if not file: + return http.Response( + json.dumps({"error": "No file uploaded"}), + status=400, + content_type='application/json' + ) + + # Extract text from file + text_content = self._extract_text_from_file(file) + if not text_content: + return http.Response( + json.dumps({"error": "Could not extract text from file"}), + status=400, + content_type='application/json' + ) + + # Get structured JD data + jd_data = self._get_jd_data_from_text(text_content) + print(jd_data) + if not jd_data: + return http.Response( + json.dumps({"error": "Failed to parse JD data"}), + status=400, + content_type='application/json' + ) + + # Process skills and locations + processed_data = self._process_skills_and_locations(jd_data) + + return http.Response( + json.dumps(processed_data), + status=200, + content_type='application/json' + ) + + except Exception as e: + print("JD Upload Error: %s", str(e)) + return http.Response( + json.dumps({"error": str(e)}), + status=500, + content_type='application/json' + ) + + def _process_skills_and_locations(self, jd_data): + """Create missing skills and locations, return IDs with names""" + env = http.request.env + + # Process skills + skill_type = request.env.ref('hr_recruitment_extended.hr_skill_type_additional') + primary_skills = [] + secondary_skills = [] + + if jd_data.get('primary_skills'): + for skill_name in jd_data['primary_skills']: + skill = env['hr.skill'].search([('name', 'ilike', skill_name)], limit=1) + if not skill: + skill = request.env['hr.skill'].create({ + 'name': skill_name, + 'skill_type_id': skill_type.id + }) + primary_skills.append({ + 'id': skill.id, + 'name': skill.name + }) + + if jd_data.get('secondary_skills'): + for skill_name in jd_data['secondary_skills']: + skill = request.env['hr.skill'].search([('name', 'ilike', skill_name)], limit=1) + if not skill: + skill = request.env['hr.skill'].create({ + 'name': skill_name, + 'skill_type_id': skill_type.id + }) + secondary_skills.append({ + 'id': skill.id, + 'name': skill.name + }) + + # Process locations + locations = [] + if jd_data.get('locations'): + for loc_name in jd_data['locations']: + location = request.env['hr.location'].search([('location_name', 'ilike', loc_name)], limit=1) + if not location: + location = request.env['hr.location'].create({ + 'location_name': loc_name + }) + locations.append({ + 'id': location.id, + 'name': location.location_name + }) + + # Process experience + experience_id = None + experience_name = None + if jd_data.get('required_experience'): + try: + # Extract numeric value from experience string (e.g., "4+" -> 4) + exp_years = int(re.search(r'\d+', jd_data['required_experience']).group()) + + # Get all experiences ordered by range + experiences = env['candidate.experience'].search([], order='id') + + # Find the most appropriate experience range + for exp in experiences: + # Parse the range from display name (format: "eX (Y - Z years)" or similar) + range_match = re.search(r'(\d+)\s*-\s*(\d+)', exp.display_name) + if range_match: + min_exp, max_exp = map(int, range_match.groups()) + if min_exp <= exp_years <= max_exp: + experience_id = exp.id + experience_name = exp.display_name + break + # Handle "+" ranges (e.g., "4+ years") + elif '+' in jd_data['required_experience']: + min_match = re.search(r'(\d+)\+', exp.display_name) + if min_match and exp_years >= int(min_match.group(1)): + experience_id = exp.id + experience_name = exp.display_name + break + + # If no range matched but we have a "+" requirement, find closest + if not experience_id and '+' in jd_data['required_experience']: + closest_exp = None + for exp in experiences: + nums = re.findall(r'\d+', exp.display_name) + if nums and int(nums[0]) <= exp_years: + if not closest_exp or int(nums[0]) > int(re.findall(r'\d+', closest_exp.display_name)[0]): + closest_exp = exp + if closest_exp: + experience_id = closest_exp.id + experience_name = closest_exp.display_name + + # Final fallback to first experience if still no match + if not experience_id and experiences: + experience_id = experiences[0].id + experience_name = experiences[0].display_name + + except Exception as e: + print(f"Error processing experience: {str(e)}") + if experiences: + experience_id = experiences[0].id + experience_name = experiences[0].display_name + + # Return enhanced data + return { + **jd_data, + 'primary_skills': primary_skills, + 'secondary_skills': secondary_skills, + 'locations': locations, + 'experience': { + 'id': experience_id, + 'name': experience_name + } if experience_id else None + } + + def _extract_text_from_file(self, file): + """Extract text content from uploaded file, including links""" + try: + # Save to temporary file + with tempfile.NamedTemporaryFile(delete=False) as temp_file: + file.save(temp_file) + temp_path = temp_file.name + + ext = os.path.splitext(file.filename)[1].lower() + + text = "" + if ext == '.pdf': + import fitz # PyMuPDF + doc = fitz.open(temp_path) + for page in doc: + text += page.get_text() + for link in page.get_links(): + if "uri" in link: + text += f"\n[LINK]: {link['uri']}" + + elif ext == '.docx': + import docx + doc = docx.Document(temp_path) + for para in doc.paragraphs: + text += para.text + "\n" + + # Extract hyperlinks + for rel in doc.part.rels.values(): + if "hyperlink" in rel.reltype: + text += f"\n[LINK]: {rel.target_ref}" + + elif ext in ('.txt', '.text'): + with open(temp_path, 'r', encoding='utf-8') as f: + text = f.read() + + else: + raise ValueError(f"Unsupported file type: {ext}") + + os.unlink(temp_path) + return text.strip() + + except Exception as e: + print("Text extraction error: %s" % str(e)) + try: + os.unlink(temp_path) + except: + pass + return None + + def _get_jd_data_from_text(self, text_content): + """Enhanced JD parser with intelligent field extraction""" + # First get all available jobs for matching + all_jobs = request.env['hr.job'].search_read([], ['name']) + job_titles = [job['name'].lower() for job in all_jobs] + + api_url = "https://api.together.xyz/v1/chat/completions" + together_api_key = request.env['ir.config_parameter'].sudo().get_param( + 'hr_recruitment_web_app.together_api_key', + '5b7b753820a6985179720e4b0faeb9dea58a56c3ef126c71dd4d50bb20a57e0e' + ) + + headers = { + 'Authorization': f'Bearer {together_api_key}', + 'Content-Type': 'application/json', + } + + payload = { + "messages": [ + { + "role": "system", + "content": f"""Analyze this job description and extract structured data with these rules: + + 1. SEQUENCE: + - Look for any unique identification code/number in the text + - It might be labeled as: Request ID, JD Number, Ref No, etc. + - Or standalone patterns like: ABC-123, 123-456, etc. + - Return the first matching unique code found + + 2. DATES: + - Find any date ranges in the text (formats: MM/DD/YYYY, DD-MM-YYYY, etc.) + - If exactly two dates found, assume start/end dates + - Convert to YYYY-MM-DD format + + 3. LOCATIONS: + - Extract only the city/region name (remove building/office details) + - Clean special formats like: 'Hyderabad_SEZ' → 'Hyderabad' + - Return as array + + 4. JOB TITLE: + - Match against these existing job titles: {job_titles} + - Use closest match if not exact + - Remove department prefixes if needed (e.g., 'Engineering - ' prefix) + + 5. SKILLS CLASSIFICATION: + - PRIMARY: Must-have requirements (after words like 'essential', 'required', 'must have') + - SECONDARY: Nice-to-have (after words like 'preferred', 'plus', 'bonus') + - Technical skills only (no soft skills) + + 6. EDUCATION: + - Include in description as bullet point: "• Education: [requirements]" + - Also extract separately for structured data + + 7. DESCRIPTION FORMAT: + - Keep original bullet points/numbering + - Add education point at end + - Preserve all technical details + - Make it look very very professional and correct mistakes + + Return JSON with these fields: + - sequence (string) + - target_start_date (string/YYYY-MM-DD or null) + - target_end_date (string/YYYY-MM-DD or null) + - locations (array) + - required_experience (string) + - primary_skills (array) + - secondary_skills (array) + - description (string) + - job_title (string - matched to existing jobs) + - employment_type (string) + - education_requirements (array)""" + }, + { + "role": "user", + "content": text_content + } + ], + "model": "Qwen/Qwen2.5-72B-Instruct-Turbo", + "response_format": {"type": "json_object"}, + "temperature": 0.2 # More deterministic output + } + + try: + response = requests.post(api_url, json=payload, headers=headers, timeout=30) + response.raise_for_status() + + json_response = response.json() + if 'choices' in json_response and len(json_response['choices']) > 0: + try: + result = json.loads(json_response['choices'][0]['message']['content']) + + # Post-processing cleanup + result = self._clean_parsed_jd_data(result, text_content) + return result + + except (json.JSONDecodeError, KeyError) as e: + print("Failed to parse API response: %s", str(e)) + return None + return None + + except requests.exceptions.RequestException as e: + print("API request failed: %s", str(e)) + return None + + def _clean_parsed_jd_data(self, data, original_text): + """Additional cleaning and validation of parsed JD data""" + # Clean sequence number + if 'sequence' in data: + # Remove any non-alphanumeric characters except hyphens + data['sequence'] = re.sub(r'[^\w-]', '', str(data['sequence'])).strip() + + # Clean locations - extract just city names + if 'locations' in data and isinstance(data['locations'], list): + cleaned_locations = [] + for loc in data['locations']: + # Remove special suffixes and building info + clean_loc = re.sub(r'(_SEZ|_Park|-Bldg.*|Building.*)', '', loc, flags=re.IGNORECASE) + clean_loc = clean_loc.split('-')[0].strip() + cleaned_locations.append(clean_loc) + data['locations'] = list(set(cleaned_locations)) # Remove duplicates + + # Normalize experience + if 'required_experience' in data: + # Extract first number found + exp_match = re.search(r'(\d+)', str(data['required_experience'])) + data['required_experience'] = exp_match.group(1) if exp_match else None + + # Ensure description includes education + if 'education_requirements' in data and data['education_requirements']: + edu_text = "• Education: " + ", ".join(data['education_requirements']) + if 'description' in data and edu_text not in data['description']: + data['description'] += "\n\n" + edu_text + + # Fallback for dates if not found in structured parsing + if ('target_start_date' not in data or 'target_end_date' not in data): + dates_found = re.findall(r'\b\d{1,2}[/-]\d{1,2}[/-]\d{2,4}\b', original_text) + if len(dates_found) >= 2: + try: + data['target_start_date'] = self._convert_date_format(dates_found[0]) + data['target_end_date'] = self._convert_date_format(dates_found[1]) + except: + pass + + return data + + def _convert_date_format(self, date_str): + """Convert various date formats to YYYY-MM-DD""" + try: + # Handle different separators + for sep in ['/', '-']: + if sep in date_str: + parts = date_str.split(sep) + if len(parts) == 3: + # Handle MM/DD/YYYY or DD-MM-YYYY formats + if len(parts[2]) == 4: # Has 4-digit year + if len(parts[0]) == 2: # DD-MM-YYYY format + day, month, year = parts + else: # MM/DD/YYYY format + month, day, year = parts + return f"{year}-{month.zfill(2)}-{day.zfill(2)}" + return None + except: + return None + + @http.route('/myATS/applicant/detail/', type='http', auth='user', csrf=False) + def applicant_detail_ajax(self, applicant_id): + applicant = request.env['hr.applicant'].sudo().browse(applicant_id) + if not applicant.exists(): + return request.not_found() + + # Prepare resume data + resume_attachment = None + if applicant.resume: + request.env.cr.execute( + """select id from ir_attachment where res_model = 'hr.candidate' and res_id=%s and res_field='resume'""" % ( + applicant.candidate_id.id)) + resume_attachment = request.env.cr.fetchone() + + resume_data = { + 'attachment_id': resume_attachment[0] if resume_attachment else None, + 'name': applicant.resume_name or 'Resume', + 'review_status': 'pass' # Default status for resume + } + + # Prepare post onboarding attachments + post_onboarding_attachments = [] + for attachment in applicant.joining_attachment_ids: + # Find the related ir.attachment + + request.env.cr.execute( + """select id from ir_attachment where res_model = 'employee.recruitment.attachments' and res_id=%s""" % ( + attachment.id)) + attachment_id = request.env.cr.fetchone() + + post_onboarding_attachments.append({ + 'id': attachment.id, + 'attachment_id': attachment_id[0] if attachment_id else None, + 'recruitment_attachment': attachment.recruitment_attachment_id.display_name, + 'name': attachment.name, + 'review_status': attachment.review_status or 'pending' + }) + + return request.env['ir.ui.view']._render_template( + 'hr_recruitment_web_app.applicants_detail_partial', + { + 'applicant': applicant, + 'resume': resume_data, + 'post_onboarding_attachments': post_onboarding_attachments + } + ) + + @http.route('/resume/upload', type='http', auth='user', methods=['POST'], csrf=False) + def upload_resume(self, **post): + try: + file = http.request.httprequest.files.get('file') + type = http.request.httprequest.files.get('type') + if not type: + type = 'applicant' + if not file: + return http.Response( + json.dumps({"error": "No file uploaded"}), + status=400, + content_type='application/json' + ) + + # Extract text from file + text_content = self._extract_text_from_file(file) + if not text_content: + return http.Response( + json.dumps({"error": "Could not extract text from file"}), + status=400, + content_type='application/json' + ) + + # Get structured resume data + resume_data = self._get_resume_data_from_text(text_content, type) + if not resume_data: + return http.Response( + json.dumps({"error": "Failed to parse resume data"}), + status=400, + content_type='application/json' + ) + + # Process skills and locations + processed_data = self._process_resume_data(resume_data) + + return http.Response( + json.dumps(processed_data), + status=200, + content_type='application/json' + ) + + except Exception as e: + print("Resume Upload Error: %s", str(e)) + return http.Response( + json.dumps({"error": str(e)}), + status=500, + content_type='application/json' + ) + + def _process_resume_data(self, resume_data): + """Process resume data - create missing skills, locations, etc.""" + env = http.request.env + + # Process skills + skill_type = request.env.ref('hr_recruitment_extended.hr_skill_type_additional') + skills = [] + # Safely get personal info with defaults + personal_info = resume_data.get('personal_info', {}) + email = personal_info.get('email', '').strip() + phone = personal_info.get('phone', '').strip() + + # Only search for candidate if we have at least one identifier + if email or phone: + domain = [] + if email: + domain.append(('email_from', '=', email)) + if phone: + if len(domain) > 0: + domain.insert(0, '|') + domain.append(('partner_phone', '=', phone)) + + candidate = request.env['hr.candidate'].sudo().search(domain, limit=1) + if candidate: + resume_data['candidate_id'] = candidate.id # Store just the ID + + if resume_data.get('skills'): + for skill_name in resume_data['skills']: + skill = env['hr.skill'].search([('name', 'ilike', skill_name)], limit=1) + if not skill: + skill = request.env['hr.skill'].create({ + 'name': skill_name, + 'skill_type_id': skill_type.id + }) + skills.append({ + 'id': skill.id, + 'name': skill.name + }) + + # Process experience + experience_id = None + experience_name = None + if resume_data.get('total_experience'): + try: + experiences = env['candidate.experience'].search([], order='id') + exp_years = float(resume_data['total_experience']) + + for exp in experiences: + range_match = re.search(r'(\d+)\s*-\s*(\d+)', exp.display_name) + if range_match: + min_exp, max_exp = map(float, range_match.groups()) + if min_exp <= exp_years <= max_exp: + experience_id = exp.id + experience_name = exp.display_name + break + + if not experience_id and experiences: + experience_id = experiences[0].id + experience_name = experiences[0].display_name + + except Exception as e: + print(f"Error processing experience: {str(e)}") + if experiences: + experience_id = experiences[0].id + experience_name = experiences[0].display_name + + # Return enhanced data + return { + **resume_data, + 'skills': skills, + 'experience': { + 'id': experience_id, + 'name': experience_name + } if experience_id else None + } + + def _get_resume_data_from_text(self, text_content, type): + """Enhanced resume parser with intelligent field extraction""" + api_content = """Analyze this resume/CV and extract structured data with these rules: + + 1. PERSONAL INFORMATION: + - Extract full name (first + last) + - Extract email (validate format) + - Extract phone numbers (prioritize mobile numbers) + - Extract LinkedIn profile URL if present + - Extract date of birth if present (convert to YYYY-MM-DD) + - Extract gender if mentioned + + 2. CONTACT INFORMATION: + - Current location (city, country) + - Address if present (split into street, street2, city, state, zip, country) + - Permanent address if different + + 3. PROFESSIONAL INFORMATION: + - Current/most recent job title + - Current company + - Current location + - Total experience in years (calculate from work history) + - Notice period in days (extract from text or calculate standard) + - Current salary if mentioned + - Expected salary if mentioned + - Skills (technical only, no soft skills) + - Holding offer status if mentioned + + + 4. SKILLS CLASSIFICATION: + - all Technical skills (no soft skills) + + Return JSON with these fields: + - personal_info (object with name, email, LinkedIn, phone, date of birth, marital status, gender, etc.) + - contact_info (object with addresses, location) + - professional_info (object with current job, experience, current company etc.) + - skills (array) + - total_experience (float) + - notice_period (int) + - current_ctc (float) + - expected_salary (float) + - holding_offer (boolean)""" + if type == 'candidate': + api_content = """Analyze this resume/CV and extract structured data with these rules: + + 1. PERSONAL INFORMATION: + - Extract full name (first + last) + - Extract email (validate format) + - Extract phone numbers (prioritize mobile numbers) + - Extract LinkedIn profile URL if present + - Extract date of birth if present (convert to YYYY-MM-DD) + - Extract gender if mentioned + + 2. CONTACT INFORMATION: + - Current location (city, country) + - Address if present (split into street, street2, city, state, zip, country) + - Permanent address if different + + + 3. SKILLS CLASSIFICATION: + - all Technical skills (no soft skills) + + Return JSON with these fields: + - personal_info (object with name, email, LinkedIn, phone, date of birth, marital status, gender, etc.) + - contact_info (object with addresses, location) + - skills (array) + - total_experience (float) + - notice_period (int) + - current_ctc (float) + - expected_salary (float) + - holding_offer (boolean)""" + api_url = "https://api.together.xyz/v1/chat/completions" + together_api_key = request.env['ir.config_parameter'].sudo().get_param( + 'hr_recruitment_web_app.together_api_key', + '5b7b753820a6985179720e4b0faeb9dea58a56c3ef126c71dd4d50bb20a57e0e' + ) + + headers = { + 'Authorization': f'Bearer {together_api_key}', + 'Content-Type': 'application/json', + } + + payload = { + "messages": [ + { + "role": "system", + "content": api_content + }, + { + "role": "user", + "content": text_content + } + ], + "model": "Qwen/Qwen2.5-72B-Instruct-Turbo", + "response_format": {"type": "json_object"}, + "temperature": 0.2 + } + + try: + response = requests.post(api_url, json=payload, headers=headers, timeout=30) + response.raise_for_status() + + json_response = response.json() + if 'choices' in json_response and len(json_response['choices']) > 0: + try: + result = json.loads(json_response['choices'][0]['message']['content']) + return self._clean_parsed_resume_data(result, text_content) + except (json.JSONDecodeError, KeyError) as e: + print("Failed to parse API response: %s", str(e)) + return None + return None + + except requests.exceptions.RequestException as e: + print("API request failed: %s", str(e)) + return None + + def _clean_parsed_resume_data(self, data, original_text): + """Additional cleaning and validation of parsed resume data""" + # Clean phone numbers + if 'personal_info' in data and 'phone' in data['personal_info']: + phone = re.sub(r'[^\d+]', '', data['personal_info']['phone']) + data['personal_info']['phone'] = phone[:15] # Limit to 15 chars + + # Clean email + if 'personal_info' in data and 'email' in data['personal_info']: + data['personal_info']['email'] = data['personal_info']['email'].strip().lower() + + # Convert experience to float + if 'total_experience' in data: + try: + data['total_experience'] = float(data['total_experience']) + except: + data['total_experience'] = 0.0 + + # Convert notice period to days + if 'notice_period' in data: + if isinstance(data['notice_period'], str): + # Handle "2 months" or "60 days" formats + if 'month' in data['notice_period'].lower(): + try: + months = float(re.search(r'(\d+)', data['notice_period']).group(1)) + data['notice_period'] = int(months * 30) + except: + data['notice_period'] = 60 # Default to 2 months + else: + try: + data['notice_period'] = int(re.search(r'(\d+)', data['notice_period']).group(1)) + except: + data['notice_period'] = 60 + + # Ensure skills array exists + if 'skills' not in data: + data['skills'] = [] + + return data + + @http.route('/myATS/candidate/detail/', type='http', auth='user', csrf=False) + def candidate_detail_ajax(self, candidate_id): + candidate = request.env['hr.candidate'].sudo().browse(candidate_id) + if not candidate.exists(): + return request.not_found() + + # Prepare resume data + resume_attachment = None + if candidate.resume: + request.env.cr.execute( + """select id from ir_attachment where res_model = 'hr.candidate' and res_id=%s and res_field='resume'""" % ( + candidate.id)) + resume_attachment = request.env.cr.fetchone() + + resume_data = { + 'attachment_id': resume_attachment[0] if resume_attachment else None, + 'name': candidate.resume_name or 'Resume', + 'review_status': 'pass' # Default status for resume + } + + return request.env['ir.ui.view']._render_template( + 'hr_recruitment_web_app.candidates_detail_partial', + { + 'candidate': candidate, + 'resume': resume_data, + } + ) + + @http.route('/myATS/candidate/create', type='http', auth='user', methods=['POST'], csrf=False) + def create_new_candidate(self, **data): + try: + # Prepare vals dictionary for job creation + vals = {} + if len(data['sequence']) > 0: + candidates = request.env['hr.candidate'].sudo().search( + [('candidate_sequence', '=', str(data['sequence']))]) + + if candidates: + return Response(json.dumps({ + 'success': False, + 'error': '\n\nCandidate creation Failed Duplicate Sequence,\n\n(%s) Already Exists\n\nCreated by: "%s"' % ( + str(data['sequence']), candidates.create_uid.display_name), + }), + content_type='application/json', + ) + # Add only if value exists + if data.get('partner_name'): + vals['partner_name'] = data['partner_name'] + + if 'sequence' in data: + vals['candidate_sequence'] = data['sequence'] if len(data['sequence']) > 0 else '/' + + if data.get('email'): + vals['email_from'] = data['email'] + + if data.get('phone'): + vals['partner_phone'] = data['phone'] + + if data.get('mobile'): + vals['alternate_phone'] = data['mobile'] + + if data.get('linkedin_profile'): + vals['linkedin_profile'] = data['linkedin_profile'] + + if data.get('type_id'): + vals['type_id'] = int(data['type_id']) + + if data.get('user_id'): + vals['user_id'] = int(data['user_id']) + else: + vals['user_id'] = request.env.user.id + + if data.get('availability'): + vals['availability'] = data['availability'] + + vals['create_uid'] = request.env.user.id + + # Handle image upload + + image_file = request.httprequest.files.get('image_1920') + resume_file = request.httprequest.files.get('resume_file') + + if image_file: + vals['candidate_image'] = base64.b64encode(image_file.read()) + + if resume_file: + vals['resume'] = base64.b64encode(resume_file.read()) + if vals['resume']: + attachment = request.env.ref("hr_recruitment_extended.employee_recruitment_attachments_preview") + file = attachment.sudo().write({ + 'datas': vals['resume'], + }) + if file: + vals['resume_type'] = attachment.mimetype + vals['resume_name'] = resume_file.filename + else: + vals['resume_type'] = '' + vals['resume_name'] = resume_file.filename + # Create the candidate first (needs to exist before we can attach files) + candidate = request.env['hr.candidate'].sudo().create(vals) + + skill_ids = request.httprequest.form.getlist('skill_ids') + if skill_ids: + Skill = request.env['hr.skill'].sudo() + CandidateSkill = request.env['hr.candidate.skill'].sudo() + + valid_skills = Skill.browse([int(sid) for sid in skill_ids]).exists() + + created_candidate_skill_ids = [] + for skill in valid_skills: + skill_level = request.env['hr.skill.level'].sudo().search([ + ('skill_type_id', '=', skill.skill_type_id.id) + ], order='level_progress', limit=1) + + if not skill_level: + continue + + candidate_skill = CandidateSkill.create({ + 'candidate_id': candidate.id, + 'skill_id': skill.id, + 'skill_type_id': skill.skill_type_id.id, + 'skill_level_id': skill_level.id, + }) + created_candidate_skill_ids.append(candidate_skill.id) + + if created_candidate_skill_ids: + candidate.write({ + 'candidate_skill_ids': [(6, 0, created_candidate_skill_ids)] + }) + + return Response( + json.dumps({ + 'success': True, + 'candidate_id': candidate.id, + 'message': 'Candidate created successfully' + }), + content_type='application/json', + ) + + except Exception as e: + error_msg = str(e) + print("candidate creation error: %s", error_msg) # Add this line + return Response( + json.dumps({ + 'success': False, + 'error': error_msg, + 'message': 'Failed to create candidate', + }), + content_type='application/json', + status=400 if 'duplicate key' in error_msg else 500 + ) + + @http.route('/myATS/job/matching_candidates/', type='http', auth='user') + def matching_candidates(self, job_id, min_match=10, **kwargs): + """ + Returns HTML partial with matching candidates for a job + min_match: Minimum percentage match to include (default 20%) + """ + try: + job = request.env['hr.job.recruitment'].browse(job_id) + if not job.exists(): + return request.render('hr_recruitment_web_app.error_template', { + 'message': "Job not found" + }) + + job_primary_skills = job.skill_ids + job_secondary_skills = job.secondary_skill_ids + + Candidate = request.env['hr.candidate'] + candidates = Candidate.search([('active', '=', True)]) + + matched_candidates = [] + for candidate in candidates: + candidate_skills = candidate.skill_ids + + # Calculate matches + matched_primary = set(job_primary_skills.ids) & set(candidate_skills.ids) + primary_pct = (len(matched_primary) / len(job_primary_skills.ids)) * 100 if job_primary_skills else 0 + + matched_secondary = set(job_secondary_skills.ids) & set(candidate_skills.ids) + secondary_pct = (len(matched_secondary) / len( + job_secondary_skills.ids)) * 100 if job_secondary_skills else 0 + + # Only include if meets minimum match threshold + if primary_pct >= min_match or secondary_pct >= min_match: + matched_candidates.append({ + 'candidate': candidate, + 'primary_pct': round(primary_pct, 1), + 'secondary_pct': round(secondary_pct, 1), + 'matched_primary_skills': job_primary_skills.filtered(lambda s: s.id in matched_primary).mapped( + 'name'), + 'matched_secondary_skills': job_secondary_skills.filtered( + lambda s: s.id in matched_secondary).mapped('name'), + 'candidate_data': { + 'email': candidate.email_from, + 'phone': candidate.partner_phone, + 'manager': candidate.user_id.display_name if candidate.user_id else 'Unassigned', + 'applications': [(app.display_name, app.recruitment_stage_id.display_name) for app in + request.env['hr.applicant'].sudo().search( + [('candidate_id', '=', candidate.id)])] + } + }) + + # Sort by primary match (highest first), then secondary + matched_candidates.sort(key=lambda x: (-x['primary_pct'], -x['secondary_pct'])) + + return request.render('hr_recruitment_web_app.matching_candidates_content', { + 'candidates': [mc['candidate'] for mc in matched_candidates], + 'match_data': {mc['candidate'].id: mc for mc in matched_candidates}, + 'min_match': min_match + }) + + except Exception as e: + return request.render('hr_recruitment_web_app.error_template', { + 'message': f"Error loading matching candidates: {str(e)}" + }) + + @http.route('/myATS/job/candidate_details/', type='json', auth='user') + def get_candidate_details(self, candidate_id, **kwargs): + """ + Returns JSON data with detailed candidate information + """ + try: + candidate = request.env['hr.candidate'].browse(candidate_id) + if not candidate.exists(): + return {'error': 'Candidate not found'} + + # Get the current job ID from session or parameters if needed + job_id = request.session.get('current_job_id') + job = request.env['hr.job.recruitment'].browse(job_id) if job_id else None + + # Recalculate matches if needed, or use stored data + if job: + job_primary_skills = job.skill_ids + job_secondary_skills = job.secondary_skill_ids + candidate_skills = candidate.skill_ids + + matched_primary = set(job_primary_skills.ids) & set(candidate_skills.ids) + primary_pct = (len(matched_primary) / len(job_primary_skills.ids)) * 100 if job_primary_skills else 0 + + matched_secondary = set(job_secondary_skills.ids) & set(candidate_skills.ids) + secondary_pct = (len(matched_secondary) / len( + job_secondary_skills.ids)) * 100 if job_secondary_skills else 0 + + matched_primary_skills = job_primary_skills.filtered(lambda s: s.id in matched_primary).mapped('name') + matched_secondary_skills = job_secondary_skills.filtered(lambda s: s.id in matched_secondary).mapped( + 'name') + else: + primary_pct = 0 + secondary_pct = 0 + matched_primary_skills = [] + matched_secondary_skills = [] + + applications = [(app.display_name, app.recruitment_stage_id.display_name) + for app in request.env['hr.applicant'].sudo().search([('candidate_id', '=', candidate.id)])] + + return { + 'candidate': { + 'id': candidate.id, + 'display_name': candidate.display_name, + 'candidate_image': candidate.candidate_image, + 'email_from': candidate.email_from, + 'partner_phone': candidate.partner_phone, + }, + 'primary_pct': round(primary_pct, 1), + 'secondary_pct': round(secondary_pct, 1), + 'matched_primary_skills': matched_primary_skills, + 'matched_secondary_skills': matched_secondary_skills, + 'candidate_data': { + 'email': candidate.email_from, + 'phone': candidate.partner_phone, + 'manager': candidate.user_id.display_name if candidate.user_id else 'Unassigned', + 'applications': applications + } + } + except Exception as e: + return {'error': str(e)} + + @http.route('/myATS/job/applicants/', type='http', auth='user') + def applicants(self, job_id, **kwargs): + """ + Returns HTML partial with applicants grouped by stage + """ + try: + # 1. Get the job from database + job = request.env['hr.job.recruitment'].browse(job_id) + if not job.exists(): + return request.render('hr_recruitment_web_app.error_template', { + 'message': "Job not found" + }) + + # 2. Get all applicants for this job + applicants = request.env['hr.applicant'].search([ + ('hr_job_recruitment', '=', job.id), + ('active', '=', True) + ], order='create_date desc') + + # 3. Group by stage + stages = {} + for applicant in applicants: + stage = applicant.stage_id + if stage.id not in stages: + stages[stage.id] = { + 'name': stage.name, + 'applicants': [] + } + + stages[stage.id]['applicants'].append({ + 'id': applicant.id, + 'name': applicant.partner_name or applicant.name, + 'application_date': applicant.create_date.strftime('%Y-%m-%d'), + 'status': applicant.priority, + 'resume_url': '/web/content/%s?download=true' % applicant.attachment_ids[ + 0].id if applicant.attachment_ids else '#', + 'stage_name': stage.name + }) + + # 4. Return HTML partial + return request.render('hr_recruitment_web_app.applicants_content', { + 'stages': stages.values(), + 'job_title': job.name, + 'total_applicants': len(applicants) + }) + + except Exception as e: + return request.render('hr_recruitment_web_app.error_template', { + 'message': "Error loading applicants" + }) \ No newline at end of file diff --git a/addons_extensions/hr_recruitment_web_app/models/__init__.py b/addons_extensions/hr_recruitment_web_app/models/__init__.py new file mode 100644 index 000000000..ae1fcc97b --- /dev/null +++ b/addons_extensions/hr_recruitment_web_app/models/__init__.py @@ -0,0 +1,2 @@ +from . import recruitment_doc_upload_wizard +from . import res_config_settings \ No newline at end of file diff --git a/addons_extensions/hr_recruitment_web_app/models/recruitment_doc_upload_wizard.py b/addons_extensions/hr_recruitment_web_app/models/recruitment_doc_upload_wizard.py new file mode 100644 index 000000000..605aaf474 --- /dev/null +++ b/addons_extensions/hr_recruitment_web_app/models/recruitment_doc_upload_wizard.py @@ -0,0 +1,216 @@ +from odoo import models, fields, api +from pypdf import PdfReader +from datetime import datetime +import base64 +import re +from dateutil.relativedelta import relativedelta +import logging +import requests +from io import BytesIO +from pdf2image import convert_from_bytes +from PIL import Image +import pytesseract +import json +from docx import Document +# import binascii + +from odoo.tools.mimetypes import guess_mimetype +_logger = logging.getLogger(__name__) + + +class RecruitmentDocUploadWizard(models.TransientModel): + _name = 'recruitment.doc.upload.wizard' + _description = 'Recruitment Document Upload Wizard' + + # Define the fields in the wizard + name = fields.Char("Name") + json_data = fields.Text("Json Data") + file_name = fields.Char('File Name') + file_data = fields.Binary('File Data', required=True) + active = fields.Boolean(default=True) + mimetype = fields.Char(string="Type", readonly=True) + file_html_text = fields.Html() + + def compute_mimetype(self): + for record in self: + record.mimetype = '' + if record.file_data: + try: + # Fix padding + padded_data = record.file_data + b'=' * (-len(record.file_data) % 4) + binary = base64.b64decode(padded_data) + record.mimetype = guess_mimetype(binary) + except Exception: + record.mimetype = 'Invalid base64' + + def action_upload(self): + # Implement the logic for file upload here + # You can use the fields file_data, file_name, etc., to save the data in the desired model + pass + + + def action_fetch_json(self): + for record in self: + record.compute_mimetype() + if not record.file_data: + record.json_data = "No file content provided." + continue + + binary = base64.b64decode(record.file_data) + file_type = record.mimetype + text_content = "" + + try: + if file_type == "application/pdf": + try: + pdf_reader = PdfReader(BytesIO(binary)) + for page in pdf_reader.pages: + page_text = page.extract_text() + text_content += page_text + if not text_content: + images = convert_from_bytes(binary, dpi=300) + extracted_text = [] + for image in images: + text = pytesseract.image_to_string(image) + extracted_text.append(text) + text_content = "\n".join(extracted_text) + + except Exception as e: + _logger.error("Error reading PDF: %s", str(e)) + text_content = "" + + elif file_type in ["image/png", "image/jpeg", "image/jpg"]: + try: + image = Image.open(BytesIO(binary)) + text_content = pytesseract.image_to_string(image) + except Exception as e: + _logger.error("Error processing image: %s", str(e)) + text_content = "" + + elif file_type == 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': + try: + doc = Document(BytesIO(binary)) + text_content = '' + + for section in doc.sections: + header = section.header + for paragraph in header.paragraphs: + text_content += paragraph.text + '\n' + + for paragraph in doc.paragraphs: + text_content += paragraph.text + '\n' + except Exception as e: + _logger.error("Error processing DOCX: %s", str(e)) + text_content = "" + + else: + _logger.error("Unsupported file type: %s", file_type) + record.json_data = f"Unsupported file type: {file_type}" + continue + record.file_html_text = text_content + json_response = record.get_json_from_model(text_content) + import pdb + pdb.set_trace() + if json_response and "choices" in json_response and len(json_response["choices"]) > 0: + message_content = json_response["choices"][0].get("message", {}).get("content", "") + + if message_content: + match = re.search(r'```json\n(.*?)\n```', message_content, re.DOTALL) + if match: + clean_json_str = match.group(1).strip() # Extract JSON content + try: + parsed_json = json.loads(clean_json_str) + record.json_data = json.dumps(parsed_json, indent=4) + file_name = parsed_json.get("name", "") + if file_name: + self.write({'file_name': file_name}) + + except json.JSONDecodeError as e: + _logger.error("Error parsing JSON: %s", str(e)) + record.json_data = "Error parsing JSON" + else: + _logger.error("No valid JSON found in the content") + record.json_data = "No valid JSON format found" + else: + _logger.error("No message content found in the response") + record.json_data = "No message content found" + else: + _logger.error("No valid response or choices in the API response") + record.json_data = "No valid JSON data received." + + except Exception as e: + _logger.error("Unexpected error during OCR processing: %s", str(e)) + record.json_data = "An unexpected error occurred during file processing." + + _logger.info("Stored JSON data for file: %s", record.file_name) + + + def normalize_gender(self, gender): + if gender: + return gender.replace(" ", "").lower() + return gender + + def normalize_marital_status(self, marital_status): + if marital_status: + return marital_status.replace(" ", "").lower() + return marital_status + + def parse_experience(self, experience_str): + years = 0 + months = 0 + + year_match = re.search(r'(\d+)\s*[\+]*\s*years?', experience_str, re.IGNORECASE) + month_match = re.search(r'(\d+)\s*months?', experience_str, re.IGNORECASE) + + if year_match: + years = int(year_match.group(1)) + if month_match: + months = int(month_match.group(1)) + + return years, months + + def get_json_from_model(self, text_content): + print(text_content) + api_url = "https://api.together.xyz/v1/chat/completions" + together_api_key = self.env['ir.config_parameter'].sudo().get_param('hr_recruitment_web_app.together_api_key') + headers = { + 'Authorization': 'Bearer %s' % together_api_key, + 'Content-Type': 'application/json', + } + current_date = datetime.now() + previous_month_date = current_date - relativedelta(months=1) + previous_month_year = previous_month_date.strftime("%B %Y") + payload = { + "messages": [ + { + "role": "system", + "content": "provide the json data from the above content for below fields---\n\nname-- particularly the full name of the candidate\nskills-- the skills of the candidate mentioned in the text specifically under skills section (add both soft and technical skills in this). \nemail -- the contact email of the candidate \nphone -- contact number of candidate usually a 10-12 number digits\ndegree-- the degree or qualification of the candidate mentioned in resume (only the name of the degrees or qualifications and not the whole details) " + f"\n experience in years and months in the format: Title of the experience (type of experience) (from month/year to month/year) -> years and months (If the end date is marked as 'present' or 'till now', assume today's date is {previous_month_year} and calculate the months also properly). \n" + "\n Total Experience (Non-Overlapping) : in years and months \n" + "\n location-- search for the location or place mentioned in the resume where the candidate belong to. \n gender-- the gender of the candidate if mentioned. \ndate_of_birth-- the date of birth of the candidate in the format-%d/%m/%Y \nmarital_status-- the marital status of the candidate \n languages-- the languages known by the candidate mentioned in resume(not technical languages but the spoken ones specifically mentioned under languages section)\n" + }, + { + "role": "user", + "content": text_content + } + ], + "model": "Qwen/Qwen2.5-72B-Instruct-Turbo", + } + + try: + response = requests.post(api_url, json=payload, headers=headers) + + if response.status_code == 200: + try: + json_response = response.json() + return json_response + except ValueError as e: + _logger.error("Error parsing JSON: %s", str(e)) + return {} + else: + _logger.error("Error in API call: %s", response.text) + return {} + + except Exception as e: + _logger.error("Exception during API call: %s", str(e)) + return {} \ No newline at end of file diff --git a/addons_extensions/hr_recruitment_web_app/models/res_config_settings.py b/addons_extensions/hr_recruitment_web_app/models/res_config_settings.py new file mode 100644 index 000000000..f0e17d45a --- /dev/null +++ b/addons_extensions/hr_recruitment_web_app/models/res_config_settings.py @@ -0,0 +1,7 @@ +from odoo import api, fields, models, _ + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + together_api_key = fields.Char(config_parameter='hr_recruitment_web_app.together_api_key', string="Together API key") \ No newline at end of file diff --git a/addons_extensions/hr_recruitment_web_app/security/ir.model.access.csv b/addons_extensions/hr_recruitment_web_app/security/ir.model.access.csv new file mode 100644 index 000000000..9b3e0fca2 --- /dev/null +++ b/addons_extensions/hr_recruitment_web_app/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_recruitment_doc_upload_wizard_user,recruitment.doc.upload.wizard user,model_recruitment_doc_upload_wizard,base.group_user,1,1,1,1 diff --git a/addons_extensions/hr_recruitment_web_app/static/description/banner.png b/addons_extensions/hr_recruitment_web_app/static/description/banner.png new file mode 100644 index 000000000..2306fb0ff Binary files /dev/null and b/addons_extensions/hr_recruitment_web_app/static/description/banner.png differ diff --git a/addons_extensions/hr_recruitment_web_app/static/description/banner_copy.png b/addons_extensions/hr_recruitment_web_app/static/description/banner_copy.png new file mode 100644 index 000000000..8ca6a3d1f Binary files /dev/null and b/addons_extensions/hr_recruitment_web_app/static/description/banner_copy.png differ diff --git a/addons_extensions/hr_recruitment_web_app/static/src/css/applicants.css b/addons_extensions/hr_recruitment_web_app/static/src/css/applicants.css new file mode 100644 index 000000000..9416f2c65 --- /dev/null +++ b/addons_extensions/hr_recruitment_web_app/static/src/css/applicants.css @@ -0,0 +1,498 @@ +@import url('colors.css'); + +/* ========= application creation css ========= */ +/* ===== Application Modal Styles ===== */ +.application-creation-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + z-index: 1000; + justify-content: center; + align-items: center; + opacity: 0; + transition: opacity 0.3s ease; +} + +.application-creation-modal.show { + display: flex; + opacity: 1; +} + +.application-creation-modal .application-creation-content { + background-color: var(--white); + width: 85%; + max-width: 1200px; + border-radius: 8px; + box-shadow: 0 5px 20px var(--shadow-dark); + transform: translateY(-20px); + transition: transform 0.3s ease; + max-height: 90vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.application-creation-modal.show .application-creation-content { + transform: translateY(0); +} + +.application-creation-modal .application-creation-header { + padding: 20px; + background: linear-gradient(135deg, var(--secondary-purple) 0%, var(--primary-blue) 100%); + color: var(--white); + border-radius: 8px 8px 0 0; + display: flex; + justify-content: space-between; + align-items: center; + position: sticky; + top: 0; + z-index: 100; +} + +.application-creation-modal .header-icon-container { + display: flex; + align-items: center; + gap: 15px; +} + +.application-creation-modal .header-icon { + font-size: 24px; + color: var(--white); +} + +.application-creation-modal .application-creation-header h3 { + margin: 0; + font-size: 1.4rem; + font-weight: 600; + color: var(--white); +} + +.application-creation-modal .application-creation-close { + font-size: 28px; + cursor: pointer; + transition: transform 0.2s; + color: var(--white); + background: none; + border: none; +} + +.application-creation-modal .application-creation-close:hover { + transform: scale(1.2); + color: var(--gray-200); +} + +.application-creation-modal .application-creation-body { + padding: 25px; + overflow-y: auto; + flex-grow: 1; +} + +.application-creation-modal .form-section { + background-color: var(--white); + border-radius: 6px; + padding: 20px; + box-shadow: 0 2px 5px var(--shadow-color); + border-left: 4px solid var(--secondary-purple); + margin-bottom: 20px; +} + +.application-creation-modal .section-title { + margin-top: 0; + margin-bottom: 20px; + color: var(--text-primary); + font-size: 18px; + display: flex; + align-items: center; + gap: 10px; +} + +.application-creation-modal .section-title i { + color: var(--secondary-purple); +} + +.application-creation-modal .form-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 20px; +} + +.application-creation-modal .form-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.application-creation-modal .form-group label { + font-weight: 500; + color: var(--text-secondary); + font-size: 14px; +} + +.application-creation-modal .form-input, +.application-creation-modal .form-select, +.application-creation-modal .form-textarea { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 14px; + transition: border-color 0.3s, box-shadow 0.3s; + background-color: var(--white); + color: var(--text-primary); +} + +.application-creation-modal .form-input:focus, +.application-creation-modal .form-select:focus, +.application-creation-modal .form-textarea:focus { + border-color: var(--secondary-purple); + box-shadow: 0 0 0 3px var(--primary-blue-light); + outline: none; +} + +/* Checkbox styles */ +.application-creation-modal .same-as-current { + display: flex; + align-items: center; + gap: 8px; + margin-top: 10px; +} + +.application-creation-modal .same-as-current input[type="checkbox"] { + width: auto; +} + +/* Education entries */ +.application-creation-modal .education-entries { + margin-bottom: 15px; +} + +.application-creation-modal .education-entry { + position: relative; + padding: 15px; + background-color: var(--gray-50); + border-radius: 6px; + margin-bottom: 15px; + border: 1px solid var(--border-light); +} + +.application-creation-modal .remove-education { + position: absolute; + top: 10px; + right: 10px; + background: none; + border: none; + color: var(--danger); + cursor: pointer; + font-size: 12px; + display: flex; + align-items: center; + gap: 5px; +} + +.application-creation-modal .remove-education:hover { + text-decoration: underline; +} + +.application-creation-modal .add-education { + background-color: var(--primary-blue); + color: var(--white); + border: none; + padding: 8px 15px; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 20px; + transition: background-color 0.3s; +} + +.application-creation-modal .add-education:hover { + background-color: var(--primary-blue-dark); +} + +/* Skills section */ +.application-creation-modal .skills-container { + margin-top: 20px; +} + +/* Upload area */ + +/* Resume Section */ +.application-creation-modal .resume-upload-container { + display: flex; + gap: 20px; +} + +.application-creation-modal .upload-area { + flex: 1; + border: 2px dashed var(--border-color); + border-radius: 6px; + padding: 30px; + text-align: center; + cursor: pointer; + transition: all 0.3s; + max-width: 30%; +} + +.application-creation-modal .upload-area:hover { + border-color: var(--primary-blue); + background-color: var(--gray-50); +} + +.application-creation-modal .upload-icon { + font-size: 40px; + color: var(--primary-blue); + margin-bottom: 10px; +} + +.application-creation-modal .upload-area h5 { + margin: 0 0 5px; + color: var(--text-primary); +} + +.application-creation-modal .upload-area p { + margin: 0; + color: var(--text-muted); + font-size: 14px; +} + +.application-creation-modal .resume-preview { + flex: 1; + border: 1px solid var(--border-light); + border-radius: 6px; + padding: 15px; + min-height: 200px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background-color: var(--gray-50); +} + +.application-creation-modal .resume-preview-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + color: var(--text-muted); + height: 100%; +} + +.application-creation-modal .resume-preview-placeholder i { + font-size: 40px; + margin-bottom: 10px; + color: var(--primary-blue); +} + +.application-creation-modal .btn-danger { + background-color: var(--danger); + color: white; + border: none; + padding: 5px 10px; + border-radius: 4px; + cursor: pointer; +} + +.application-creation-modal .btn-danger:hover { + background-color: #dc3545; +} + +/* Additional attachments */ +.application-creation-modal .additional-attachments { + margin-top: 20px; +} + +.application-creation-modal .attachments-list { + margin: 10px 0; +} + +.application-creation-modal .add-attachment { + background-color: transparent; + color: var(--primary-blue); + border: 1px solid var(--primary-blue); + padding: 8px 15px; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + transition: all 0.3s; +} + +.application-creation-modal .add-attachment:hover { + background-color: var(--primary-blue-light); +} + +/* Form actions */ +.application-creation-modal .form-actions { + position: sticky; + bottom: 0; + background: var(--white); + padding: 15px 25px; + border-top: 1px solid var(--border-light); + z-index: 100; + margin-top: auto; + display: flex; + justify-content: space-between; + align-items: center; +} + +.application-creation-modal .btn-cancel { + padding: 10px 20px; + border-radius: 4px; + cursor: pointer; + font-weight: 500; + transition: transform 0.2s, box-shadow 0.2s; + background-color: var(--gray-100); + color: var(--text-secondary); + border: 1px solid var(--border-color); +} + +.application-creation-modal .btn-cancel:hover { + background-color: var(--gray-200); +} + +.application-creation-modal .btn-application-primary { + padding: 10px 20px; + border-radius: 4px; + cursor: pointer; + font-weight: 500; + transition: transform 0.2s, box-shadow 0.2s; + display: flex; + align-items: center; + gap: 8px; + background: linear-gradient(135deg, var(--secondary-purple) 0%, var(--primary-blue) 100%); + color: var(--white); + border: none; +} + +.application-creation-modal .btn-application-primary:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px var(--shadow-color); + background: linear-gradient(135deg, var(--secondary-purple) 0%, var(--primary-blue-dark) 100%); +} + +.application-creation-modal .footer-right { + display: flex; + gap: 10px; +} + +/* SELECT2 custom styles */ +.application-creation-modal .select2-container { + z-index: 10000 !important; + width: 100% !important; +} + +.application-creation-modal .select2-container--default .select2-selection--multiple, +.application-creation-modal .select2-container--default .select2-selection--single { + border: 1px solid var(--border-color) !important; + border-radius: 4px !important; + min-height: 40px; + background-color: var(--white); + padding: 6px 8px !important; + flex-wrap: wrap !important; + gap: 4px; +} + +.application-creation-modal .select2-container--default .select2-selection--multiple .select2-selection__choice { + background-color: var(--primary-blue) !important; + border: none !important; + color: var(--white) !important; + padding: 2px 8px !important; + +} + +.application-creation-modal .select2-container--default .select2-selection--multiple .select2-selection__choice__remove { + color: var(--white) !important; +} + +.application-creation-modal .select2-dropdown { + border: 1px solid var(--border-color) !important; + box-shadow: 0 2px 5px var(--shadow-color) !important; +} + +.application-creation-modal .select2-container--default .select2-selection__rendered { + display: flex !important; + align-items: center !important; + vertical-align: middle !important; + line-height: normal !important; + color: var(--text-primary); +} + +.application-creation-modal .select2-container--default .select2-selection--multiple .select2-selection__rendered { + display: flex !important; + flex-wrap: wrap !important; + align-items: center !important; + width: 100% !important; + padding: 2px 5px !important; + overflow: visible !important; + box-sizing: border-box !important; +} + +.application-creation-modal .select2-container--default .select2-results__option--highlighted[aria-selected] { + background-color: var(--primary-blue) !important; + color: var(--white) !important; +} + +/* Scrollbar styling */ +.application-creation-modal .application-creation-body::-webkit-scrollbar { + width: 8px; +} + +.application-creation-modal .application-creation-body::-webkit-scrollbar-track { + background: var(--gray-100); +} + +.application-creation-modal .application-creation-body::-webkit-scrollbar-thumb { + background: var(--primary-blue); + border-radius: 4px; +} + +.application-creation-modal .application-creation-body::-webkit-scrollbar-thumb:hover { + background: var(--primary-blue-dark); +} + +/* Responsive adjustments */ +@media (max-width: 1024px) { + .application-creation-modal .application-creation-content { + width: 90%; + } +} + +@media (max-width: 768px) { + .application-creation-modal .application-creation-content { + width: 95%; + height: 95vh; + } + + .application-creation-modal .form-grid { + grid-template-columns: 1fr; + } +} + +/* Special cases */ +.application-creation-modal .marital-anniversary { + display: none; +} + +.application-creation-modal #application-marital[value="married"] ~ .marital-anniversary { + display: flex; +} + +.application-creation-modal .form-section.profile-section:first-child { + margin-top: 0; +} + +.application-creation-modal .select2-dropdown { + border: 1px solid var(--border-color) !important; + box-shadow: 0 2px 5px var(--shadow-color) !important; +} + + + diff --git a/addons_extensions/hr_recruitment_web_app/static/src/css/ats.css b/addons_extensions/hr_recruitment_web_app/static/src/css/ats.css new file mode 100644 index 000000000..53d7ae7b9 --- /dev/null +++ b/addons_extensions/hr_recruitment_web_app/static/src/css/ats.css @@ -0,0 +1,217 @@ +@import url('colors.css'); + +/* ===== ATS Custom Layout Reset ===== */ +body.ats-app { + margin: 0; + padding: 0; + font-family: "Segoe UI", sans-serif; + background-color: var(--body-bg); + color: var(--text-primary); + height: 100vh; + overflow: hidden; +} + +/* ===== Header ===== */ +.ats-app .main-header { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 1rem; + font-size: 1.8rem; + font-weight: 600; + padding: 1rem; + position: relative; /* For absolute positioning of toggle button */ +} + +/* Logo and title alignment */ +.ats-app .main-header img { + height: 40px; + width: auto; +} + +.ats-app .main-header span { + display: flex; + align-items: center; + height: 40px; /* Match logo height */ +} + +/* ===== Layout Container ===== */ +.ats-app .layout-container { + display: flex; + height: 100%; /* Adjust for header height */ + overflow: hidden; +} + +/* ===== Sidebar ===== */ +.ats-app .sidebar { + background-color: var(--sidebar-bg); + color: var(--sidebar-text); + padding: 1rem 1rem 2rem; + box-shadow: 2px 0 10px rgba(0, 0, 0, 0.05); + transition: all 0.4s ease-in-out; + position: relative; + overflow: hidden; + display: flex; + flex-direction: column; +} + +/* Expanded Sidebar */ +.ats-app .sidebar.expanded { + width: 240px; /* Use fixed width for better consistency */ + min-width: 240px; +} + +/* Collapsed Sidebar */ +.ats-app .sidebar.collapsed { + width: 60px; + min-width: 60px; + padding: 1rem 0.4rem 2rem; +} + +/* Hide content in collapsed state */ +.ats-app .sidebar.collapsed .menu-list, +.ats-app .sidebar.collapsed .main-header span { + display: none; +} + +/* ===== Sidebar Menu ===== */ +.ats-app .menu-list { + list-style: none; + padding-top: 1rem; + margin: 2rem 0; + flex-grow: 1; +} + +.ats-app .list-title { + color: var(--text-secondary); + font-size: 0.9rem; +} + +.ats-app .menu-list li { + margin-bottom: 1.2rem; +} + +.ats-app .menu-list a { + display: block; + color: var(--sidebar-text); + text-decoration: none; + padding: 0.7rem 1rem; + border-radius: 8px; + transition: all 0.3s ease; + font-size: 1.1rem; +} + +.ats-app .menu-list a:hover { + background-color: #DBEDFE; + color: #0061FF; + transform: translateX(6px); + box-shadow: 3px 3px 10px rgba(0, 0, 0, 0.2); + font-weight: 600; + letter-spacing: 0.5px; +} + +/* ===== Toggle Button ===== */ +.ats-app .toggle-btn { + position: absolute; + top: 1.5rem; + right: -12px; + background-color: var(--sidebar-bg); + color: var(--text-primary); + border: 1px solid var(--border-color); + padding: 0.5rem; + cursor: pointer; + border-radius: 50%; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); + z-index: 10; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; +} + +.ats-app .toggle-btn:hover { + background-color: #e1e5eb; +} + +/* ===== Main Content Area ===== */ +.ats-app .content-area { + flex: 1; + padding: 2rem; + background-color: var(--content-bg); + overflow-y: auto; + box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.02); + border-left: 1px solid #dcdde1; + min-width: 0; +} + +/* ===== Responsive Design ===== */ +@media (max-width: 1024px) { + .ats-app .layout-container { + flex-direction: column; + height: auto; + } + + .ats-app .sidebar, + .ats-app .sidebar.expanded, + .ats-app .sidebar.collapsed { + width: 100%; + padding: 1rem; + text-align: center; + } + + .ats-app .sidebar { + overflow: hidden; + max-height: 15%; /* expanded */ + transition: all 0.4s ease-in-out; + } + + .ats-app .sidebar.collapsed { + max-height: 1%; + padding: 0; + } + + .ats-app .sidebar.collapsed .menu-list, + .ats-app .sidebar.collapsed .main-header h1 { + display: none; + } + + .ats-app .menu-list { + display: flex; + justify-content: center; + flex-wrap: wrap; + gap: 0.5rem; + } + + .menu-list i { + margin-right: 8px; + font-size: 1.1rem; + vertical-align: middle; + } + + .ats-app .menu-list li { + margin-bottom: 0; + } + + .ats-app .menu-list a { + padding: 0.5rem 1rem; + font-size: 1rem; + } + + .ats-app .toggle-btn { + position: relative; + top: auto; + right: auto; + transform: none; + margin: 1rem auto 0 auto; + display: block; + border-radius: 6px; + } + + .ats-app .content-area { + padding: 1rem; + border-left: none; + } +} + diff --git a/addons_extensions/hr_recruitment_web_app/static/src/css/candidate.css b/addons_extensions/hr_recruitment_web_app/static/src/css/candidate.css new file mode 100644 index 000000000..1733db7b0 --- /dev/null +++ b/addons_extensions/hr_recruitment_web_app/static/src/css/candidate.css @@ -0,0 +1,667 @@ +@import url('colors.css'); + + +/* ====== candidate creation template ======= */ + +/* ===== Candidate Form Styles ===== */ +.candidate-form-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + z-index: 1000; + justify-content: center; + align-items: center; + opacity: 0; + transition: opacity 0.3s ease; +} + +.candidate-form-modal.show { + display: flex; + opacity: 1; +} + +.candidate-form-modal .candidate-form-content { + background-color: var(--white); + width: 90%; + max-width: 1400px; + border-radius: 8px; + box-shadow: 0 5px 20px var(--shadow-dark); + transform: translateY(-20px); + transition: transform 0.3s ease; + max-height: 90vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.candidate-form-modal.show .candidate-form-content { + transform: translateY(0); +} + +.candidate-form-modal .candidate-form-header { + padding: 20px; + background: linear-gradient(135deg, var(--secondary-purple) 0%, var(--primary-blue) 100%); + color: var(--white); + border-radius: 8px 8px 0 0; + display: flex; + justify-content: space-between; + align-items: center; + position: sticky; + top: 0; + z-index: 100; +} + +.candidate-form-modal .header-icon-container { + display: flex; + align-items: center; + gap: 15px; +} + +.candidate-form-modal .header-icon { + font-size: 24px; + color: var(--white); +} + +.candidate-form-modal .candidate-form-header h3 { + margin: 0; + font-size: 1.4rem; + font-weight: 600; + color: var(--white); +} + +.candidate-form-modal .candidate-form-close { + font-size: 28px; + cursor: pointer; + transition: transform 0.2s; + color: var(--white); + background: none; + border: none; +} + +.candidate-form-modal .candidate-form-close:hover { + transform: scale(1.2); + color: var(--gray-200); +} + +.candidate-form-modal .candidate-form-body { + padding: 25px; + overflow-y: auto; + flex-grow: 1; +} + +/* Header Section with Avatar */ +.candidate-form-modal .header-section { + display: flex; + align-items: center; + margin-bottom: 20px; + padding-bottom: 20px; + border-bottom: 1px solid var(--border-light); +} + +.candidate-form-modal .avatar-container { + display: flex; + align-items: center; + gap: 20px; + width: 100%; +} + +.candidate-form-modal .candidate-avatar { + position: relative; + width: 120px; + height: 120px; + border-radius: 50%; + overflow: hidden; + border: 3px solid var(--primary-blue); + box-shadow: 0 3px 10px var(--shadow-color); + margin-right: 20px; +} + +.candidate-form-modal .candidate-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.candidate-form-modal .avatar-upload { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background-color: rgba(0, 0, 0, 0.5); + color: white; + text-align: center; + padding: 5px; + cursor: pointer; + transition: background-color 0.3s; +} + +.candidate-form-modal .avatar-upload:hover { + background-color: rgba(0, 0, 0, 0.7); +} + +.candidate-form-modal .avatar-upload i { + margin-right: 5px; +} + +.candidate-form-modal .avatar-upload input { + display: none; +} + +.candidate-form-modal .basic-info { + flex-grow: 1; +} + +/* Button Box Section */ +.candidate-form-modal .button-box { + display: flex; + gap: 15px; + margin-bottom: 20px; + flex-wrap: wrap; +} + +.candidate-form-modal .stat-button { + flex: 1; + min-width: 200px; + display: flex; + align-items: center; + gap: 10px; + padding: 12px 15px; + background-color: var(--gray-50); + border: 1px solid var(--border-light); + border-radius: 6px; + cursor: pointer; + transition: all 0.3s; +} + +.candidate-form-modal .stat-button:hover { + background-color: var(--gray-100); + transform: translateY(-2px); + box-shadow: 0 2px 5px var(--shadow-color); +} + +.candidate-form-modal .stat-button i { + font-size: 24px; + color: var(--primary-blue); +} + +.candidate-form-modal .stat-info { + display: flex; + flex-direction: column; +} + +.candidate-form-modal .stat-value { + font-weight: 600; + color: var(--text-primary); +} + +.candidate-form-modal .stat-text { + font-size: 12px; + color: var(--text-muted); +} + +/* Form Sections */ +.candidate-form-modal .form-section { + background-color: var(--white); + border-radius: 6px; + padding: 20px; + box-shadow: 0 2px 5px var(--shadow-color); + border-left: 4px solid var(--secondary-purple); + margin-bottom: 20px; +} + +.candidate-form-modal .section-title { + margin-top: 0; + margin-bottom: 20px; + color: var(--text-primary); + font-size: 18px; + display: flex; + align-items: center; + gap: 10px; +} + +.candidate-form-modal .section-title i { + color: var(--secondary-purple); +} + +.candidate-form-modal .form-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 20px; +} + +.candidate-form-modal .form-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.candidate-form-modal .form-group label { + font-weight: 500; + color: var(--text-secondary); + font-size: 14px; +} + +.candidate-form-modal .form-input, +.candidate-form-modal .form-select, +.candidate-form-modal .form-textarea { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 14px; + transition: border-color 0.3s, box-shadow 0.3s; + background-color: var(--white); + color: var(--text-primary); +} + +.candidate-form-modal .form-input:focus, +.candidate-form-modal .form-select:focus, +.candidate-form-modal .form-textarea:focus { + border-color: var(--secondary-purple); + box-shadow: 0 0 0 3px var(--primary-blue-light); + outline: none; +} + +/* Resume Section */ +.candidate-form-modal .resume-upload-container { + display: flex; + gap: 20px; +} + +.candidate-form-modal .resume-upload-area { + flex: 1; + border: 2px dashed var(--border-color); + border-radius: 6px; + padding: 30px; + text-align: center; + cursor: pointer; + transition: all 0.3s; + max-width: 30%; +} + +.candidate-form-modal .resume-upload-area:hover { + border-color: var(--primary-blue); + background-color: var(--gray-50); +} + +.candidate-form-modal .upload-icon { + font-size: 40px; + color: var(--primary-blue); + margin-bottom: 10px; +} + +.candidate-form-modal .resume-upload-area h5 { + margin: 0 0 5px; + color: var(--text-primary); +} + +.candidate-form-modal .resume-upload-area p { + margin: 0; + color: var(--text-muted); + font-size: 14px; +} + +.candidate-form-modal .resume-preview { + flex: 1; + border: 1px solid var(--border-light); + border-radius: 6px; + padding: 15px; + min-height: 200px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background-color: var(--gray-50); +} + +.candidate-form-modal .resume-preview-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + color: var(--text-muted); + height: 100%; +} + +.candidate-form-modal .resume-preview-placeholder i { + font-size: 40px; + margin-bottom: 10px; + color: var(--primary-blue); +} + +.candidate-form-modal .btn-danger { + background-color: var(--danger); + color: white; + border: none; + padding: 5px 10px; + border-radius: 4px; + cursor: pointer; +} + +.candidate-form-modal .btn-danger:hover { + background-color: #dc3545; +} + +/* Make sure dropzone doesn't interfere with child elements */ +.candidate-form-modal .resume-upload-area > * { + pointer-events: none; +} + +.candidate-form-modal .resume-upload-area input { + pointer-events: auto; +} + +.candidate-form-modal #resume-iframe { + width: 100%; + height: 500px; + border: none; +} + +.candidate-form-modal #resume-image { + max-width: 100%; + max-height: 500px; + object-fit: contain; +} + +.candidate-form-modal #unsupported-format { + text-align: center; + color: var(--danger); +} + +.candidate-form-modal #unsupported-format i { + font-size: 40px; + margin-bottom: 10px; +} + +.candidate-form-modal #download-resume { + display: inline-flex; + align-items: center; + gap: 5px; +} + +/* Skills Section */ +.candidate-form-modal .skills-container { + margin-top: 15px; +} + +.candidate-form-modal .skills-list { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 15px; +} + +.candidate-form-modal .skill-tag { + background-color: var(--primary-blue); + color: white; + padding: 5px 10px; + border-radius: 4px; + font-size: 14px; + display: flex; + align-items: center; + gap: 5px; +} + +.candidate-form-modal .skill-tag i { + cursor: pointer; + font-size: 12px; +} + +.candidate-form-modal .btn-add-skill { + background-color: transparent; + color: var(--primary-blue); + border: 1px solid var(--primary-blue); + padding: 8px 15px; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + transition: all 0.3s; +} + +.candidate-form-modal .btn-add-skill:hover { + background-color: var(--primary-blue-light); +} + +/* Notebook Tabs */ +.candidate-form-modal .notebook-tabs { + display: flex; + border-bottom: 1px solid var(--border-light); + margin-bottom: 20px; +} + +.candidate-form-modal .tab { + padding: 10px 20px; + cursor: pointer; + border-bottom: 3px solid transparent; + transition: all 0.3s; +} + +.candidate-form-modal .tab.active { + border-bottom-color: var(--primary-blue); + color: var(--primary-blue); + font-weight: 600; +} + +.candidate-form-modal .tab:hover:not(.active) { + background-color: var(--gray-50); +} + +.candidate-form-modal .notebook-content { + margin-top: 15px; +} + +.candidate-form-modal .tab-content { + display: none; +} + +.candidate-form-modal .tab-content.active { + display: block; +} + +/* Sub-sections in notebook */ +.candidate-form-modal .sub-section { + margin-bottom: 30px; +} + +.candidate-form-modal .sub-section-title { + display: flex; + align-items: center; + gap: 10px; + color: var(--text-primary); + font-size: 16px; + margin-bottom: 15px; +} + +.candidate-form-modal .sub-section-title i { + color: var(--secondary-purple); +} + +/* Form Actions */ +.candidate-form-modal .form-actions { + position: sticky; + bottom: 0; + background: var(--white); + padding: 15px 25px; + border-top: 1px solid var(--border-light); + z-index: 100; + margin-top: auto; + display: flex; + justify-content: space-between; + align-items: center; +} + +.candidate-form-modal .btn-cancel { + padding: 10px 20px; + border-radius: 4px; + cursor: pointer; + font-weight: 500; + transition: transform 0.2s, box-shadow 0.2s; + background-color: var(--gray-100); + color: var(--text-secondary); + border: 1px solid var(--border-color); +} + +.candidate-form-modal .btn-cancel:hover { + background-color: var(--gray-200); +} + +.candidate-form-modal .btn-candidate-primary { + padding: 10px 20px; + border-radius: 4px; + cursor: pointer; + font-weight: 500; + transition: transform 0.2s, box-shadow 0.2s; + display: flex; + align-items: center; + gap: 8px; + background: linear-gradient(135deg, var(--secondary-purple) 0%, var(--primary-blue) 100%); + color: var(--white); + border: none; +} + +.candidate-form-modal .btn-candidate-primary:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px var(--shadow-color); + background: linear-gradient(135deg, var(--secondary-purple) 0%, var(--primary-blue-dark) 100%); +} + +.candidate-form-modal .footer-right { + display: flex; + gap: 10px; +} + +.candidate-form-modal .select2-container { + z-index: 10000 !important; + width: 100% !important; +} + +.candidate-form-modal .select2-container--default .select2-selection--multiple, +.candidate-form-modal .select2-container--default .select2-selection--single { + border: 1px solid var(--border-color) !important; + border-radius: 4px !important; + min-height: 40px; + background-color: var(--white); + padding: 5px 5px 0 5px !important; +} + +.candidate-form-modal .select2-container--default .select2-selection--multiple .select2-selection__choice { + background-color: var(--primary-blue) !important; + border: none !important; + color: var(--white) !important; + padding: 2px 8px !important; +} + +.candidate-form-modal .select2-container--default .select2-selection--multiple .select2-selection__choice__remove { + color: var(--white) !important; +} + +.application-creation-modal .select2-dropdown { + border: 1px solid var(--border-color) !important; + box-shadow: 0 2px 5px var(--shadow-color) !important; +} + +.candidate-form-modal .select2-container--default .select2-selection__rendered { + display: flex !important; + align-items: center !important; + vertical-align: middle !important; + line-height: normal !important; + color: var(--text-primary); + padding: 2px 5px !important; +} +.candidate-form-modal .select2-container--default .select2-selection--multiple .select2-selection__rendered { + display: flex !important; + flex-wrap: wrap !important; + align-items: center !important; + width: 100% !important; + padding: 2px 5px !important; + overflow: visible !important; + box-sizing: border-box !important; +} + +.candidate-form-modal .select2-container--default .select2-results__option--highlighted[aria-selected] { + background-color: var(--primary-blue) !important; + color: var(--white) !important; +} + +/* Scrollbar styling */ +.candidate-form-modal .candidate-form-body::-webkit-scrollbar { + width: 8px; +} + +.candidate-form-modal .candidate-form-body::-webkit-scrollbar-track { + background: var(--gray-100); +} + +.candidate-form-modal .candidate-form-body::-webkit-scrollbar-thumb { + background: var(--primary-blue); + border-radius: 4px; +} + +.candidate-form-modal .candidate-form-body::-webkit-scrollbar-thumb:hover { + background: var(--primary-blue-dark); +} + + + +.candidate-form-modal .btn-candidate-primary { + padding: 10px 20px; + border-radius: 4px; + cursor: pointer; + font-weight: 500; + transition: transform 0.2s, box-shadow 0.2s; + display: flex; + align-items: center; + gap: 8px; + background: linear-gradient(135deg, var(--secondary-purple) 0%, var(--primary-blue) 100%); + color: var(--white); + border: none; +} + +.candidate-form-modal .btn-candidate-primary:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px var(--shadow-color); + background: linear-gradient(135deg, var(--secondary-purple) 0%, var(--primary-blue-dark) 100%); +} + + +/* Responsive adjustments */ +@media (max-width: 1024px) { + .candidate-form-modal .candidate-form-content { + width: 95%; + } + + .candidate-form-modal .resume-upload-container { + flex-direction: column; + } +} + +@media (max-width: 768px) { + .candidate-form-modal .candidate-form-content { + width: 98%; + height: 95vh; + } + + .candidate-form-modal .form-grid { + grid-template-columns: 1fr; + } + + .candidate-form-modal .avatar-container { + flex-direction: column; + text-align: center; + } + + .candidate-form-modal .button-box { + justify-content: center; + } + + .candidate-form-modal .stat-button { + min-width: 150px; + } +} diff --git a/addons_extensions/hr_recruitment_web_app/static/src/css/colors.css b/addons_extensions/hr_recruitment_web_app/static/src/css/colors.css new file mode 100644 index 000000000..10a62fe2f --- /dev/null +++ b/addons_extensions/hr_recruitment_web_app/static/src/css/colors.css @@ -0,0 +1,66 @@ +:root { + /* Primary Colors */ + --primary-blue: #3498db; + --primary-blue-dark: #2980b9; + --primary-blue-light: #e8f0ff; + + /* Secondary Colors */ + --secondary-purple: #6f42c1; + --secondary-green: #28a745; + --secondary-red: #dc3545; + --secondary-yellow: #ffc107; + + /* Grayscale */ + --white: #ffffff; + --gray-100: #f8f9fa; + --gray-200: #e9ecef; + --gray-300: #dee2e6; + --gray-400: #ced4da; + --gray-500: #adb5bd; + --gray-600: #6c757d; + --gray-700: #495057; + --gray-800: #343a40; + --gray-900: #212529; + --black: #000000; + + /* Semantic Colors */ + --success: #28a745; + --info: #17a2b8; + --warning: #ffc107; + --danger: #dc3545; + + /* Background Colors */ + --body-bg: #f5f6fa; + --sidebar-bg: #FAFCFF; + --create-model-bg: #FAFCFF; + --content-bg: #E3E9EF; + --active-search-bg: #ffffff; + --active-search-hover-bg: #f0f0f0; + --add-btn-bg: #3498db; + --add-btn-hover-bg: #2980b9; + --side-panel-bg: #FAFCFF; + --side-panel-item-hover: #f0f8ff; + --side-panel-item-selected: #d7eaff; + + /* Text Colors */ + --text-primary: #2f3542; + --text-secondary: #4B5865; + --text-muted: #6c757d; + --sidebar-text: #0F1419; + --active-search-text: #333; + --add-btn-color: #ffffff; + + /* Border Colors */ + --border-color: #dcdde1; + --border-light: #e0e0e0; + + /* Shadow Colors */ + --shadow-color: rgba(0, 0, 0, 0.1); + --shadow-dark: rgba(0, 0, 0, 0.2); + + /* Status Colors */ + --status-new: #3498db; + --status-interview: #f39c12; + --status-hired: #2ecc71; + --status-rejected: #e74c3c; +} \ No newline at end of file diff --git a/addons_extensions/hr_recruitment_web_app/static/src/css/content.css b/addons_extensions/hr_recruitment_web_app/static/src/css/content.css new file mode 100644 index 000000000..c86066b67 --- /dev/null +++ b/addons_extensions/hr_recruitment_web_app/static/src/css/content.css @@ -0,0 +1,1055 @@ +@import url('colors.css'); + +/* Main Container */ +#ats-details-container { + overflow-y: auto; + max-height: 100%; + position: relative; + background-color: var(--content-bg); +} + +/* Grid Layout */ +.ats-grid { + display: grid; + grid-template-columns: repeat(12, 1fr); + grid-auto-rows: minmax(100px, auto); + gap: 20px; + padding: 20px; + font-family: 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + color: var(--text-primary); + background-color: var(--content-bg); + max-width: 100%; + margin: 0 auto; + transition: all 0.3s ease; + overflow: hidden; +} + +/* Card Base */ +.ats-grid .ats-card { + overflow: hidden; + background-color: var(--sidebar-bg); + border-radius: 12px; + padding: 25px; + margin-bottom: 25px; + box-shadow: 0 4px 20px var(--shadow-color); + transition: all 0.3s ease; + border: 1px solid var(--border-light); +} + +/* Hover Effect */ +.ats-grid .ats-card:hover { + box-shadow: 0 8px 30px var(--shadow-dark); + transform: translateY(-5px); +} + +/* Width Span Utilities */ +.ats-grid .span-1 { grid-column: span 1; } +.ats-grid .span-2 { grid-column: span 2; } +.ats-grid .span-3 { grid-column: span 3; } +.ats-grid .span-4 { grid-column: span 4; } +.ats-grid .span-5 { grid-column: span 5; } +.ats-grid .span-6 { grid-column: span 6; } +.ats-grid .span-7 { grid-column: span 7; } +.ats-grid .span-8 { grid-column: span 8; } +.ats-grid .span-9 { grid-column: span 9; } +.ats-grid .span-10 { grid-column: span 10; } +.ats-grid .span-11 { grid-column: span 11; } +.ats-grid .span-12 { grid-column: span 12; } + +/* Navigation Sidebar */ +.ats-grid #ats-overview { + background-color: var(--gray-800); + color: var(--white); + padding: 20px; + border-radius: 12px; + box-shadow: 0 8px 24px var(--shadow-dark); + transition: all 0.3s ease; + font-family: 'Segoe UI', Tahoma, sans-serif; + width: 220px; + border: 1px solid var(--gray-700); +} + +.ats-grid #ats-overview h3, +.ats-grid #ats-overview .section-title { + border-bottom: 1px solid var(--gray-700); + margin-bottom: 12px; + padding-bottom: 8px; + font-weight: 600; + color: var(--gray-200); +} + +.ats-grid .overview-nav { + padding: 10px 0; +} + +.ats-grid .nav-list { + list-style: none; + padding: 0; + margin: 0; +} + +.ats-grid .nav-list li { + margin-bottom: 8px; + position: relative; + padding-left: 0px; +} + +.ats-grid .nav-link { + display: flex; + align-items: center; + padding-top: 5px; + padding-bottom: 5px; + color: var(--gray-300); + text-decoration: none; + border-radius: 6px; + transition: all 0.3s ease; + font-size: 14px; +} + +.ats-grid .nav-link:hover { + background-color: var(--gray-700); + color: var(--white); + transform: translateX(6px); + box-shadow: 3px 3px 10px var(--shadow-dark); + font-weight: 600; + letter-spacing: 0.5px; +} + +.ats-grid .nav-link i { + width: 20px; + text-align: center; +} + +/* Header Section */ +.ats-grid .ats-title { + font-size: 28px; + font-weight: 600; + margin-bottom: 15px; + color: var(--text-primary); +} + +.ats-grid .ats-meta { + display: flex; + flex-wrap: wrap; + gap: 15px; + margin-bottom: 20px; + color: var(--text-muted); +} + +.ats-grid .meta-item { + display: flex; + align-items: center; + font-size: 14px; +} + +/* Status Bar */ +.ats-grid .status-bar { + background-color: var(--gray-100); + padding: 15px; + border-radius: 8px; + margin-bottom: 20px; +} + +.ats-grid .recruitment-status { + margin-bottom: 10px; +} + +.ats-grid .status-label { + font-weight: 600; + color: var(--text-secondary); +} + +.ats-grid .status-value { + font-weight: 500; + color: var(--text-primary); +} + +.ats-grid .recruitment-progress .progress { + height: 10px; + border-radius: 5px; + background-color: var(--gray-200); +} + +.ats-grid .progress-bar { + background-color: var(--primary-blue); +} + +/* Section Titles */ +.ats-grid .section-title { + margin: 0; + padding-bottom: 20px; + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + display: flex; + align-items: center; + border-bottom: 2px solid var(--gray-200); + transition: all 0.3s ease; +} + +.ats-grid .ats-card:hover .section-title { + border-color: var(--gray-300); +} + +.ats-grid .section-title i { + color: var(--text-muted); + transition: all 0.3s ease; +} + + +.ats-grid .section-title small { + margin-left: auto; /* This pushes the small element to the right */ + font-weight: normal; + font-size: 0.85em; + color: var(--text-muted); +} + +/* Detail Grid */ +.ats-grid .detail-grid { + display: grid; + grid-template-columns: 1fr; + gap: 10px; + margin-top: 0px; +} + +.ats-grid .detail-row { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; +} + +.ats-grid .detail-row:hover { + background-color: var(--gray-100); + border-radius: 3px; +} + +.ats-grid .detail-label { + font-weight: 500; + color: var(--text-secondary); + min-width: 120px; + font-size: 15px; + flex-shrink: 0; +} + +.ats-grid .span-6 .detail-label { + min-width: 160px; +} + +.ats-grid .detail-value { + color: var(--text-primary); + font-weight: 400; + flex: 1; + font-size: 13px; + word-break: break-word; + overflow-wrap: break-word; +} + +/* Skills and Location Badges */ +.ats-grid .skills-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.ats-grid .skill-badge, +.ats-grid .location-badge { + background-color: var(--gray-100); + color: var(--primary-blue); + padding: 6px 12px; + border-radius: 20px; + transition: all 0.3s ease; +} + +.ats-grid .skill-badge:hover, +.ats-grid .location-badge:hover { + background-color: var(--gray-200); + transform: translateY(-3px); + box-shadow: 0 4px 8px rgba(15, 81, 50, 0.1); +} + +/* Team Members */ +.ats-grid .team-member { + margin-bottom: 20px; +} + +.ats-grid .member-header { + font-weight: 600; + color: var(--text-primary); + margin-bottom: 10px; + display: flex; + align-items: center; +} + +.ats-grid .member-title { + margin-left: 5px; +} + +.ats-grid .recruiter-photo { + width: 60px; + height: 60px; + object-fit: cover; + border: 2px solid var(--gray-200); +} + +.ats-grid .member-name { + font-weight: 600; + color: var(--text-primary); +} + +.ats-grid .member-email { + font-size: 13px; + color: var(--text-muted); +} + +/* Description Content */ +.ats-grid .description-content { + line-height: 1.6; + color: var(--text-primary); +} + +.ats-grid .description-content p { + margin-bottom: 15px; +} + +.ats-grid .description-content ul, +.ats-grid .description-content ol { + padding-left: 20px; + margin-bottom: 15px; +} + +/* Responsive fallback */ +@media (max-width: 768px) { + .ats-grid { + grid-template-columns: repeat(2, 1fr); + } + .ats-grid .ats-card { + grid-column: span 2 !important; + } +} + +@media print { + #ats-details-container { + max-height: none; + overflow-y: visible; + } +} + + +/* Smart Button Container */ +.smart-button-container { + position: fixed; + top: 20%; + right: 1%; + z-index: 1000; + display: flex; + gap: 10px; + flex-direction: column; + align-items: flex-end; +} + +.smart-button { + background-color: var(--primary-blue); + color: var(--white); + border: none; + border-radius: 8px; + padding: 10px 16px; + font-weight: 500; + font-size: 14px; + box-shadow: 0 4px 12px rgba(52, 152, 219, 0.3); + transition: all 0.3s ease; + display: flex; + align-items: center; + gap: 8px; + min-width: 180px; + justify-content: center; +} + +.smart-button:hover { + background-color: var(--primary-blue-dark); + transform: translateY(-3px); + box-shadow: 0 6px 16px rgba(52, 152, 219, 0.4); +} + +.smart-button:active { + transform: translateY(1px); + box-shadow: 0 2px 8px rgba(52, 152, 219, 0.3); +} + +.smart-button i { + transition: transform 0.3s ease; +} + +.smart-button:hover i { + transform: rotate(15deg); +} + +/* Published Ribbon */ +.status-ribbon { + position: absolute; + top: 35px; + right: -30px; + transform: rotate(45deg); + padding: 8px 40px; + font-size: 14px; + font-weight: 600; + color: var(--white); + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2); + z-index: 100; + transition: all 0.3s ease; + text-transform: uppercase; + letter-spacing: 1px; +} + +.status-ribbon.ribbon-published { + background: linear-gradient(45deg, var(--success), #2ecc71); +} + +.status-ribbon.ribbon-not-published { + background: linear-gradient(45deg, var(--danger), #e74c3c); +} + +.status-ribbon:hover { + transform: rotate(45deg) scale(1.05); + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3); +} + +/* Professional Typography */ +.ats-grid { + font-family: 'Inter', 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + letter-spacing: 0.01em; +} + +.ats-grid .ats-title { + font-size: 28px; + font-weight: 700; + letter-spacing: -0.01em; + line-height: 1.2; + margin-bottom: 16px; + color: var(--text-primary); +} + +.ats-grid .section-title { + font-size: 18px; + font-weight: 600; + letter-spacing: -0.01em; + margin-bottom: 20px; + padding-bottom: 12px; + border-bottom: 2px solid var(--gray-200); +} + +.ats-grid .detail-label { + font-weight: 600; + color: var(--text-secondary); + font-size: 15px; +} + +.ats-grid .detail-value { + font-weight: 500; + color: var(--text-primary); + font-size: 15px; +} + +.ats-grid .meta-item { + font-size: 14px; + font-weight: 500; + color: var(--text-secondary); +} + +.ats-grid .status-label { + font-weight: 600; + color: var(--text-secondary); + font-size: 14px; +} + +.ats-grid .status-value { + font-weight: 500; + color: var(--text-primary); + font-size: 14px; +} + +.ats-grid .skill-badge, +.ats-grid .location-badge { + font-size: 13px; + font-weight: 500; +} + +/* Smooth animations for all interactive elements */ +.ats-grid .ats-card { + transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +.ats-grid .detail-row { + transition: background-color 0.2s ease; +} + +.ats-grid .nav-link { + transition: all 0.3s ease; +} + +.ats-grid .skill-badge, +.ats-grid .location-badge { + transition: all 0.3s ease; +} + +/* Professional shadows and borders */ +.ats-grid .ats-card { + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); + border: 1px solid rgba(0, 0, 0, 0.05); +} + +.ats-grid .ats-card:hover { + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12); + border-color: rgba(0, 0, 0, 0.08); +} + +/* Close button styling */ +.close-detail { + position: absolute; + top: 15px; + left: 15px; + width: 36px; + height: 36px; + border-radius: 50%; + background-color: var(--white); + border: none; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + z-index: 100; + cursor: pointer; + transition: all 0.3s ease; +} + +.close-detail:hover { + background-color: var(--gray-100); + transform: rotate(90deg); +} + +.close-detail span { + font-size: 20px; + color: var(--text-secondary); + line-height: 1; +} + + +/* ===== Work Experience Section ===== */ +.ats-grid .experience-list { + display: flex; + flex-direction: column; + gap: 20px; + margin-top: 15px; +} + +.ats-grid .experience-item { + background-color: var(--white); + border-radius: 10px; + padding: 20px; + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.08); + border-left: 4px solid var(--primary-blue); + transition: all 0.3s ease; + position: relative; +} + +.ats-grid .experience-item:hover { + transform: translateY(-3px); + box-shadow: 0 6px 15px rgba(0, 0, 0, 0.12); +} + +.ats-grid .exp-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 12px; +} + +.ats-grid .exp-header h5 { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + margin: 0; +} + +.ats-grid .exp-company { + font-size: 16px; + color: var(--primary-blue); + font-weight: 500; + background-color: rgba(52, 152, 219, 0.1); + padding: 4px 12px; + border-radius: 20px; +} + +.ats-grid .exp-duration { + display: flex; + align-items: center; + font-size: 14px; + color: var(--text-secondary); + margin-bottom: 8px; +} + +.ats-grid .exp-duration i { + color: var(--primary-blue); + margin-right: 8px; +} + +.ats-grid .exp-ctc { + display: flex; + align-items: center; + font-size: 14px; + color: var(--text-primary); + font-weight: 500; +} + +.ats-grid .exp-ctc i { + color: var(--success); + margin-right: 8px; +} + +/* ===== Education Section ===== */ +.ats-grid .education-list { + display: flex; + flex-direction: column; + gap: 20px; + margin-top: 15px; +} + +.ats-grid .education-item { + background-color: var(--white); + border-radius: 10px; + padding: 20px; + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.08); + border-left: 4px solid var(--success); + transition: all 0.3s ease; + position: relative; +} + +.ats-grid .education-item:hover { + transform: translateY(-3px); + box-shadow: 0 6px 15px rgba(0, 0, 0, 0.12); +} + +.ats-grid .edu-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 12px; +} + +.ats-grid .edu-header h5 { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + margin: 0; +} + +.ats-grid .edu-type { + font-size: 14px; + color: var(--white); + font-weight: 500; + background-color: var(--success); + padding: 4px 12px; + border-radius: 20px; +} + +.ats-grid .edu-university { + font-size: 15px; + color: var(--text-secondary); + margin-bottom: 8px; + font-style: italic; +} + +.ats-grid .edu-duration { + display: flex; + align-items: center; + font-size: 14px; + color: var(--text-secondary); + margin-bottom: 8px; +} + +.ats-grid .edu-duration i { + color: var(--success); + margin-right: 8px; +} + +.ats-grid .edu-marks { + display: flex; + align-items: center; + font-size: 14px; + color: var(--text-primary); + font-weight: 500; +} + +.ats-grid .edu-marks i { + color: var(--warning); + margin-right: 8px; +} + +/* ===== Skills Section ===== */ +.ats-grid .skills-container { + display: flex; + flex-direction: column; + gap: 15px; + margin-top: 15px; +} + +.ats-grid .skill-item { + background-color: var(--white); + border-radius: 10px; + padding: 15px 20px; + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.08); + transition: all 0.3s ease; +} + +.ats-grid .skill-item:hover { + transform: translateY(-3px); + box-shadow: 0 6px 15px rgba(0, 0, 0, 0.12); +} + +.ats-grid .skill-info { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.ats-grid .skill-name { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); +} + +.ats-grid .skill-type { + font-size: 13px; + color: var(--white); + background-color: var(--primary-blue); + padding: 3px 10px; + border-radius: 15px; +} + +.ats-grid .skill-level { + display: flex; + flex-direction: column; + gap: 8px; +} + +.ats-grid .skill-level-name { + font-size: 13px; + color: var(--text-secondary); + text-align: right; +} + +.ats-grid .progress { + height: 8px; + border-radius: 4px; + background-color: var(--gray-200); + overflow: hidden; +} + +.ats-grid .progress-bar { + height: 100%; + border-radius: 4px; + background: linear-gradient(90deg, var(--primary-blue), #3498db); + transition: width 1s ease-in-out; +} + +/* ===== Attachments Section ===== */ +.ats-grid .attachments-list { + display: flex; + flex-direction: column; + gap: 15px; + margin-top: 15px; +} + +.ats-grid .attachment-card { + background-color: var(--white); + border-radius: 10px; + padding: 15px 20px; + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.08); + transition: all 0.3s ease; +} + +.ats-grid .attachment-card:hover { + transform: translateY(-3px); + box-shadow: 0 6px 15px rgba(0, 0, 0, 0.12); +} + +.ats-grid .attachment-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.ats-grid .attachment-title { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + word-break: break-word; + max-width: 70%; +} + +.ats-grid .attachment-badge { + font-size: 12px; + color: var(--white); + padding: 3px 10px; + border-radius: 15px; +} + +.ats-grid .attachment-badge.bg-success { + background-color: var(--success); +} + +.ats-grid .attachment-badge.bg-danger { + background-color: var(--danger); +} + +.ats-grid .attachment-badge.bg-secondary { + background-color: var(--gray-500); +} + +.ats-grid .attachment-actions { + display: flex; + gap: 10px; +} + +.ats-grid .document-action-btn { + display: flex; + align-items: center; + gap: 5px; + font-size: 13px; + padding: 6px 12px; + border-radius: 6px; + transition: all 0.2s ease; +} + +.ats-grid .document-action-btn:hover { + transform: translateY(-2px); +} + +.ats-grid .preview-btn { + background-color: var(--primary-blue); + color: var(--white); + border: 1px solid var(--primary-blue); +} + +.ats-grid .preview-btn:hover { + background-color: var(--white); + color: var(--primary-blue); +} + +.ats-grid .download-btn { + background-color: var(--success); + color: var(--white); + border: 1px solid var(--success); +} + +.ats-grid .download-btn:hover { + background-color: var(--white); + color: var(--success); +} + +/* ===== Timeline Section ===== */ +.ats-grid .timeline { + position: relative; + margin-top: 20px; + padding-left: 30px; +} + +.ats-grid .timeline::before { + content: ''; + position: absolute; + top: 0; + left: 8px; + height: 100%; + width: 2px; + background-color: var(--gray-300); +} + +.ats-grid .timeline-item { + position: relative; + margin-bottom: 25px; +} + +.ats-grid .timeline-point { + position: absolute; + left: -30px; + width: 16px; + height: 16px; + border-radius: 50%; + background-color: var(--primary-blue); + border: 3px solid var(--white); + box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.3); +} + +.ats-grid .timeline-point.point-interview { + background-color: var(--warning); + box-shadow: 0 0 0 3px rgba(243, 156, 18, 0.3); +} + +.ats-grid .timeline-content { + background-color: var(--white); + border-radius: 8px; + padding: 15px 20px; + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.08); + transition: all 0.3s ease; +} + +.ats-grid .timeline-content:hover { + transform: translateY(-3px); + box-shadow: 0 6px 15px rgba(0, 0, 0, 0.12); +} + +.ats-grid .timeline-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.ats-grid .timeline-header h5 { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + margin: 0; +} + +.ats-grid .timeline-date { + font-size: 13px; + color: var(--text-secondary); +} + +.ats-grid .timeline-body { + font-size: 14px; + color: var(--text-secondary); + line-height: 1.5; +} + +.ats-grid .timeline-body p { + margin: 0; +} + +/* ===== Alert Messages ===== */ +.ats-grid .alert { + border-radius: 8px; + padding: 15px 20px; + border: none; + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.08); +} + +.ats-grid .alert-info { + background-color: rgba(23, 162, 184, 0.1); + color: var(--info); + border-left: 4px solid var(--info); +} + +/* ===== Responsive Design ===== */ +@media (max-width: 768px) { + .ats-grid .exp-header, .ats-grid .edu-header { + flex-direction: column; + gap: 8px; + } + + .ats-grid .skill-info { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + + .ats-grid .attachment-header { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + + .ats-grid .attachment-title { + max-width: 100%; + } + + .ats-grid .timeline-header { + flex-direction: column; + align-items: flex-start; + gap: 5px; + } +} + +/* ===== Avatar Styling ===== */ +.ats-grid .avatar-wrapper { + position: relative; + width: 120px; + height: 120px; + margin-left: auto; +} + +.ats-grid .avatar-img { + width: 100%; + height: 100%; + object-fit: cover; + border: 3px solid var(--primary-blue); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +.ats-grid .avatar-placeholder { + width: 100%; + height: 100%; + background: linear-gradient(135deg, var(--primary-blue), #3498db); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + border: 3px solid var(--primary-blue); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + position: relative; + overflow: hidden; +} + +.ats-grid .avatar-placeholder::before { + content: ''; + position: absolute; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: linear-gradient( + to right, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, 0.1) 50%, + rgba(255, 255, 255, 0) 100% + ); + transform: rotate(30deg); +} + +.ats-grid .applicant-img-placeholder { + font-size: 48px; + font-weight: 700; + color: white; + text-transform: uppercase; + position: relative; + z-index: 1; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.ats-grid .avatar-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.3s ease; + cursor: pointer; +} + +.ats-grid .avatar-wrapper:hover .avatar-overlay { + opacity: 1; +} + +.ats-grid .avatar-overlay i { + color: white; + font-size: 24px; +} \ No newline at end of file diff --git a/addons_extensions/hr_recruitment_web_app/static/src/css/jd.css b/addons_extensions/hr_recruitment_web_app/static/src/css/jd.css new file mode 100644 index 000000000..c13626614 --- /dev/null +++ b/addons_extensions/hr_recruitment_web_app/static/src/css/jd.css @@ -0,0 +1,1122 @@ +@import url('colors.css'); + +/* ===== JD Modal Styles - Professional Version ===== */ +.new-jd-container.jd-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + z-index: 1000; + justify-content: center; + align-items: center; + opacity: 0; + transition: opacity 0.3s ease; +} + +.new-jd-container.jd-modal.show { + display: flex; + opacity: 1; +} + +.new-jd-container .jd-modal-content { + background-color: var(--white); + width: 85%; + max-width: 1200px; + border-radius: 8px; + box-shadow: 0 5px 20px var(--shadow-dark); + transform: translateY(-20px); + transition: transform 0.3s ease; + max-height: 90vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.new-jd-container.jd-modal.show .jd-modal-content { + transform: translateY(0); +} + +.new-jd-container .jd-modal-header { + padding: 20px; + background: linear-gradient(135deg, var(--secondary-purple) 0%, var(--primary-blue) 100%); + color: var(--white); + border-radius: 8px 8px 0 0; + display: flex; + justify-content: space-between; + align-items: center; + position: sticky; + top: 0; + z-index: 100; +} + +.new-jd-container .header-content { + display: flex; + align-items: center; + gap: 15px; +} + +.new-jd-container .header-icon { + font-size: 24px; + color: var(--white); +} + +.new-jd-container .jd-modal-header h3 { + margin: 0; + font-size: 1.4rem; + font-weight: 600; + color: var(--white); +} + +.new-jd-container .jd-modal-close { + font-size: 28px; + cursor: pointer; + transition: transform 0.2s; + color: var(--white); + background: none; + border: none; +} + +.new-jd-container .jd-modal-close:hover { + transform: scale(1.2); + color: var(--gray-200); +} + +.new-jd-container .jd-modal-body { + padding: 25px; + overflow-y: auto; + flex-grow: 1; +} + +.new-jd-container .form-section { + background-color: var(--white); + border-radius: 6px; + padding: 20px; + box-shadow: 0 2px 5px var(--shadow-color); + border-left: 4px solid var(--secondary-purple); + margin-bottom: 20px; +} + +.new-jd-container .section-title { + margin-top: 0; + margin-bottom: 20px; + color: var(--text-primary); + font-size: 18px; + display: flex; + align-items: center; + gap: 10px; +} + +.new-jd-container .section-title i { + color: var(--secondary-purple); +} + +.new-jd-container .form-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 20px; +} + +.new-jd-container .form-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.new-jd-container .form-group label { + font-weight: 500; + color: var(--text-secondary); + font-size: 14px; +} + +.new-jd-container .form-control, +.new-jd-container .form-input, +.new-jd-container .form-select, +.new-jd-container .form-textarea { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 14px; + transition: border-color 0.3s, box-shadow 0.3s; + background-color: var(--white); + color: var(--text-primary); +} + +.new-jd-container .form-control:focus, +.new-jd-container .form-input:focus, +.new-jd-container .form-select:focus, +.new-jd-container .form-textarea:focus { + border-color: var(--secondary-purple); + box-shadow: 0 0 0 3px var(--primary-blue-light); + outline: none; +} + +.new-jd-container .radio-group { + display: flex; + gap: 15px; +} + +.new-jd-container .radio-option { + flex: 1; +} + +.new-jd-container .radio-option input[type="radio"] { + display: none; +} + +.new-jd-container .radio-option label { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px; + border: 1px solid var(--border-color); + border-radius: 6px; + cursor: pointer; + transition: all 0.3s; + background-color: var(--gray-100); + font-weight: normal; + margin: 0; +} + +.new-jd-container .radio-option input[type="radio"]:checked + label { + border-color: var(--primary-blue); + background-color: rgba(52, 152, 219, 0.1); + color: var(--primary-blue); +} + +.new-jd-container .two-columns { + display: flex; + gap: 15px; +} + +.new-jd-container .two-columns .half-width { + width: 48%; +} + +.new-jd-container .input-with-icon { + position: relative; + display: flex; + align-items: center; +} + +.new-jd-container .input-with-icon i { + position: absolute; + left: 10px; + color: var(--text-muted); +} + +.new-jd-container .input-with-icon input, +.new-jd-container .input-with-icon select { + padding-left: 35px; + width: 100%; +} + +.new-jd-container .jd-modal-footer { + padding: 15px 20px; + background-color: var(--white); + border-top: 1px solid var(--border-light); + display: flex; + justify-content: space-between; + align-items: center; + position: sticky; + bottom: 0; + z-index: 100; +} + +.new-jd-container .btn { + padding: 10px 20px; + border-radius: 4px; + cursor: pointer; + font-weight: 500; + transition: transform 0.2s, box-shadow 0.2s; + display: flex; + align-items: center; + gap: 8px; +} + +.new-jd-container .btn-secondary { + background-color: var(--gray-100); + color: var(--text-secondary); + border: 1px solid var(--border-color); +} + +.new-jd-container .btn-secondary:hover { + background-color: var(--gray-200); +} + +.new-jd-container .btn-primary { + background: linear-gradient(135deg, var(--secondary-purple) 0%, var(--primary-blue) 100%); + color: var(--white); + border: none; +} + +.new-jd-container .btn-primary:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px var(--shadow-color); + background: linear-gradient(135deg, var(--secondary-purple) 0%, var(--primary-blue-dark) 100%); +} + +.new-jd-container .footer-right { + display: flex; + gap: 10px; +} + +/* Select2 custom styles to match the theme */ +.new-jd-container .select2-container--default .select2-selection--multiple, +.new-jd-container .select2-container--default .select2-selection--single { + border: 1px solid var(--border-color) !important; + border-radius: 4px !important; + min-height: 40px; + background-color: var(--white); +} + +.new-jd-container .select2-container--default .select2-selection--multiple .select2-selection__choice { + background-color: var(--primary-blue) !important; + border: none !important; + color: var(--white) !important; +} + +.new-jd-container .select2-container--default .select2-selection--multiple .select2-selection__choice__remove { + color: var(--white) !important; +} + +.new-jd-container .select2-dropdown { + border: 1px solid var(--border-color) !important; + box-shadow: 0 2px 5px var(--shadow-color) !important; +} + +/* Scrollbar styling */ +.new-jd-container .jd-modal-body::-webkit-scrollbar { + width: 8px; +} + +.new-jd-container .jd-modal-body::-webkit-scrollbar-track { + background: var(--gray-100); +} + +.new-jd-container .jd-modal-body::-webkit-scrollbar-thumb { + background: var(--primary-blue); + border-radius: 4px; +} + +.new-jd-container .jd-modal-body::-webkit-scrollbar-thumb:hover { + background: var(--primary-blue-dark); +} + +/* CKEditor styles */ +.new-jd-container .ck.ck-label.ck-voice-label { + display: none !important; +} + +.new-jd-container .ck.ck-editor__editable { + min-height: 200px !important; + background-color: var(--white); + color: var(--text-primary); + border: 1px solid var(--gray-300) !important; +} + +.new-jd-container #job-description-editor { + min-height: 200px; + min-width: 100%; +} + +.new-jd-container .oe_editor * { + white-space: pre-wrap !important; + word-break: break-word !important; +} + +/* SELECT2 custom styles */ +.new-jd-container .select2-container { + z-index: 10000 !important; + width: 100% !important; +} + +.new-jd-container .select2-container--default .select2-selection__rendered { + display: flex !important; + align-items: center !important; + vertical-align: middle !important; + line-height: normal !important; + color: var(--text-primary); +} + +.new-jd-container .select2-container--default .select2-results__option--highlighted[aria-selected] { + background-color: var(--primary-blue) !important; + color: var(--white) !important; +} + +.new-jd-container .select2-container--default .select2-results__option[aria-selected=true] { + background-color: var(--gray-200) !important; + color: var(--text-primary) !important; +} + +.new-jd-container .select2-container--default .select2-search--dropdown .select2-search__field { + border: 1px solid var(--gray-300) !important; + border-radius: 4px !important; + padding: 6px 8px !important; +} + +.new-jd-container .select2-container--default .select2-selection--multiple .select2-selection__rendered { + display: flex !important; + flex-wrap: wrap !important; + align-items: center !important; + width: 100% !important; + padding: 2px 5px !important; + overflow: visible !important; + box-sizing: border-box !important; +} + +.new-jd-container .select2-container--default .select2-selection--multiple .select2-selection__choice { + display: inline-flex !important; + align-items: center !important; + background-color: var(--primary-blue) !important; + color: var(--white) !important; + border: none !important; + border-radius: 4px !important; + padding: 2px 8px !important; + margin: 2px !important; + max-width: 100% !important; + box-sizing: border-box !important; + white-space: nowrap !important; + overflow: hidden !important; + text-overflow: ellipsis !important; + float: none !important; + position: relative !important; + line-height: 1.5 !important; +} + +.new-jd-container .select2-container--default .select2-selection--multiple .select2-selection__choice__remove { + color: var(--white) !important; + margin-right: 5px !important; + order: -1 !important; +} + +/* Secondary Recruiter Specific Styles */ +.new-jd-container #secondary-recruiter + .select2-container--default .select2-selection--multiple { + height: auto !important; + min-height: 40px !important; + padding: 5px 5px 0 5px !important; +} + +.new-jd-container #secondary-recruiter + .select2-container--default .select2-selection__rendered { + display: block !important; + padding: 0 !important; + width: 100% !important; +} + +.new-jd-container #secondary-recruiter + .select2-container--default .select2-selection__choice { + display: flex !important; + align-items: center !important; + background-color: var(--primary-blue) !important; + color: var(--white) !important; + border: none !important; + border-radius: 4px !important; + padding: 4px 8px !important; + margin: 0 5px 5px 0 !important; + width: calc(100% - 10px) !important; + box-sizing: border-box !important; + white-space: nowrap !important; + overflow: hidden !important; + text-overflow: ellipsis !important; + float: none !important; +} + +/* Avatar styles for secondary recruiters */ +.new-jd-container #secondary-recruiter + .select2-container--default .select2-selection__choice .user-avatar { + width: 18px !important; + height: 18px !important; + border-radius: 50% !important; + margin-right: 8px !important; + object-fit: cover !important; + display: inline-block !important; + vertical-align: middle !important; +} + +/* Input field in multiple select */ +.new-jd-container .select2-container--default .select2-search--inline .select2-search__field { + margin: 0 !important; + padding: 0 !important; + height: auto !important; + min-height: 28px !important; + line-height: 28px !important; + width: 100% !important; + flex-grow: 1 !important; +} + +.new-jd-container .select2-selection__choice { + cursor: move; + user-select: none; +} + +.new-jd-container .select2-selection__choice:hover { + background-color: var(--active-search-hover-bg); +} + +/* Highlight drop target */ +.new-jd-container .select2-container--default .select2-selection--multiple.drag-over { + background-color: var(--side-panel-item-hover); + border: 2px dashed #3498db; +} + + +.new-jd-container .form-actions { + position: sticky; + bottom: 0; + background: var(--white); + padding: 15px 25px; + border-top: 1px solid var(--border-light); + z-index: 100; + margin-top: auto; + display: flex; + justify-content: space-between; + align-items: center; +} + +.new-jd-container .btn-cancel, .application-creation-modal .btn-jd-primary { + padding: 10px 20px; + border-radius: 4px; + cursor: pointer; + font-weight: 500; + transition: transform 0.2s, box-shadow 0.2s; +} + +.new-jd-container .btn-cancel { + background-color: var(--gray-100); + color: var(--text-secondary); + border: 1px solid var(--border-color); +} + +.new-jd-container .btn-cancel:hover { + background-color: var(--gray-200); +} + +.new-jd-container .btn-jd-primary { + background: linear-gradient(135deg, var(--secondary-purple) 0%, var(--primary-blue) 100%); + color: var(--white); + border: none; +} + +.new-jd-container .btn-jd-primary:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px var(--shadow-color); + background: linear-gradient(135deg, var(--secondary-purple) 0%, var(--primary-blue-dark) 100%); +} + +.new-jd-container .footer-right { + display: flex; + gap: 10px; +} + +/* Responsive adjustments */ +@media (max-width: 1024px) { + .new-jd-container .jd-modal-content { + width: 90%; + } +} + +@media (max-width: 768px) { + .new-jd-container .jd-modal-content { + width: 95%; + height: 95vh; + } + + .new-jd-container .form-grid { + grid-template-columns: 1fr; + } + + .new-jd-container .two-columns { + flex-direction: column; + } + + .new-jd-container .two-columns .half-width { + width: 100% !important; + } + + .new-jd-container .radio-group { + flex-direction: column; + } +} + +.new-jd-container .form-section.profile-section:first-child { + margin-top: 0; +} + +/* Client Information Section */ +.new-jd-container .client-session { + padding-top: 16px; +} + +.new-jd-container .client-session .form-grid, +.new-jd-container .additional-info .form-grid{ + grid-template-columns: repeat(2, 1fr); + gap: 8px; +} + +/* Description Section */ +.new-jd-container .form-section.description-section { + margin-bottom: 0; +} + +.new-jd-container .form-group.full-width { + grid-column: 1 / -1; +} + +/* Make sure the editor takes full width */ +.new-jd-container #job-description-editor { + width: 100%; + min-width: 100%; +} + +/* Adjust form grid for better spacing */ +.new-jd-container .form-grid { + gap: 15px; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .new-jd-container .form-section.profile-section:nth-child(3) .form-grid, + .new-jd-container .form-grid { + grid-template-columns: 1fr; + } + + .new-jd-container .form-group.full-width { + grid-column: auto; + } +} + + +/* Popup container styles */ +.popup-container { + position: fixed; + width: 400px; + max-height: 70vh; + min-height: 70vh; + background: white; + border-radius: 8px 8px 0 0; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); + z-index: 1000; + display: flex; + flex-direction: column; + overflow: hidden; + transition: transform 0.3s ease, opacity 0.3s ease; + transform: translateY(100%); + opacity: 0; + bottom: 30px; /* Consistent bottom spacing */ + right: 30px; /* Base right spacing */ +} + +.popup-container.visible { + transform: translateY(0); + opacity: 1; +} + +.popup-header { + padding: 12px 16px; + background: #f8f9fa; + border-bottom: 1px solid #e9ecef; + display: flex; + justify-content: space-between; + align-items: center; +} + +.popup-header h5 { + margin: 0; + font-size: 16px; + font-weight: 600; +} + +.popup-close { + background: none; + border: none; + font-size: 24px; + line-height: 1; + cursor: pointer; + padding: 0 0 4px 0; + color: #6c757d; +} + +.popup-body { + flex: 1; + overflow-y: auto; + position: relative; +} + +.popup-content { + padding: 16px; +} + +.loading-spinner { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.8); +} + +.spinner { + width: 40px; + height: 40px; + border: 4px solid #f3f3f3; + border-top: 4px solid #3498db; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Stacking multiple popups - now using CSS variables for consistency */ +.popup-container { + --popup-width: 400px; + --spacing: 30px; + --gap: 20px; +} + +.popup-container:nth-last-child(1) { + right: var(--spacing); + bottom: var(--spacing); +} + +.popup-container:nth-last-child(2) { + right: calc(var(--spacing) + var(--popup-width) + var(--gap)); + bottom: var(--spacing); +} + +.popup-container:nth-last-child(3) { + right: calc(var(--spacing) + (var(--popup-width) + var(--gap)) * 2); + bottom: var(--spacing); +} + + + + +/* ===== Main Container ===== */ +.matching-candidates-container { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + padding: 20px; + max-width: 1200px; + margin: 0 auto; +} + +/* ===== Search Box ===== */ +.matching-candidates-container .mc-search-box { + position: relative; + display: flex; + align-items: center; + gap: 15px; + margin-bottom: 25px; + max-width: 500px; +} + +.matching-candidates-container .mc-search-input { + padding: 12px 15px 12px 40px; + border: 1px solid var(--gray-300); + border-radius: 8px; + font-size: 14px; + width: 100%; + transition: all 0.3s ease; + background-color: var(--gray-100); + box-shadow: 0 2px 5px rgba(0,0,0,0.05); +} + +.matching-candidates-container .mc-search-input:focus { + outline: none; + border-color: var(--primary-blue); + box-shadow: 0 0 0 3px var(--primary-blue-light); + background-color: var(--white); +} + +.matching-candidates-container .mc-search-icon { + position: absolute; + left: 15px; + color: var(--gray-500); +} + +.matching-candidates-container .mc-match-threshold { + background-color: var(--primary-blue-light); + color: var(--primary-blue-dark); + padding: 8px 15px; + border-radius: 20px; + font-size: 13px; + font-weight: 500; + white-space: nowrap; +} + +/* ===== Cards Container ===== */ +.matching-candidates-container .mc-cards-container { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 20px; +} + +/* ===== Individual Card Styles ===== */ +.matching-candidates-container .mc-card { + background: var(--white); + border-radius: 12px; + padding: 20px; + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.08); + transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); + cursor: pointer; + text-align: center; + border: 1px solid var(--gray-200); +} + +.matching-candidates-container .mc-card:hover { + transform: translateY(-5px); + box-shadow: 0 10px 20px rgba(0, 0, 0, 0.12); + border-color: var(--primary-blue); +} + +/* ===== Avatar Wrapper ===== */ +.matching-candidates-container .mc-avatar-wrapper { + position: relative; + margin-bottom: 15px; +} + +.matching-candidates-container .mc-avatar { + width: 100px; + height: 100px; + border-radius: 50%; + background-color: var(--gray-200); + display: inline-flex; + align-items: center; + justify-content: center; + overflow: hidden; + position: relative; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + transition: transform 0.3s ease; +} + +.matching-candidates-container .mc-card:hover .mc-avatar { + transform: scale(1.05); +} + +.matching-candidates-container .mc-avatar-img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.matching-candidates-container .mc-avatar-initials { + font-size: 36px; + font-weight: bold; + color: var(--primary-blue-dark); +} +/* ===== Percentage Circles ===== */ +.matching-candidates-container .mc-percentage-circles { + display: flex; + justify-content: center; + gap: 15px; + margin-top: 15px; +} + +.matching-candidates-container .mc-percentage-circle { + width: 70px; + height: 70px; + border-radius: 50%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + position: relative; + box-shadow: 0 4px 8px rgba(0,0,0,0.1); +} + +.matching-candidates-container .mc-primary-circle { + background: var(--gray-200); + position: relative; + overflow: hidden; +} + +.matching-candidates-container .mc-primary-circle::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: conic-gradient(var(--primary-blue) 0% var(--primary-percent), transparent var(--primary-percent) 100%); +} + +.matching-candidates-container .mc-secondary-circle { + background: var(--gray-200); + position: relative; + overflow: hidden; +} + +.matching-candidates-container .mc-secondary-circle::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: conic-gradient(var(--secondary-green) 0% var(--secondary-percent), transparent var(--secondary-percent) 100%); +} + +.matching-candidates-container .mc-percentage-value { + font-size: 18px; + font-weight: 700; + color: var(--text-primary); + position: relative; + z-index: 1; +} + +.matching-candidates-container .mc-percentage-label { + font-size: 11px; + font-weight: 600; + color: var(--text-primary); + text-transform: uppercase; + margin-top: 2px; + position: relative; + z-index: 1; +} + +/* Add this to your existing CSS */ +.matching-candidates-container .mc-percentage-circle::after { + content: ''; + position: absolute; + top: 5px; + left: 5px; + right: 5px; + bottom: 5px; + background: var(--white); + border-radius: 50%; + z-index: 0; +} + +/* ===== Modal Styles ===== */ +.matching-candidates-container .mc-modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0,0,0,0.5); + animation: fadeIn 0.3s; +} + +.matching-candidates-container .mc-modal-content { + background-color: var(--white); + margin: 5% auto; + padding: 30px; + border-radius: 12px; + box-shadow: 0 5px 30px rgba(0,0,0,0.3); + width: 80%; + max-width: 700px; + max-height: 80vh; + overflow-y: auto; + position: relative; + animation: slideIn 0.3s; +} + +.matching-candidates-container .mc-close-modal { + position: absolute; + right: 25px; + top: 20px; + font-size: 28px; + font-weight: bold; + color: var(--gray-500); + cursor: pointer; + transition: color 0.3s; +} + +.matching-candidates-container .mc-close-modal:hover { + color: var(--primary-blue); +} + + + +/* ===== Detail View Styles ===== */ +.mc-detail-container { + padding: 20px; +} + +.mc-detail-container .mc-detail-header { + display: flex; + align-items: center; + margin-bottom: 30px; + padding-bottom: 20px; + border-bottom: 1px solid var(--gray-200); +} + +.mc-detail-container .mc-detail-avatar { + width: 80px; + height: 80px; + border-radius: 50%; + background-color: var(--gray-200); + overflow: hidden; + margin-right: 20px; + flex-shrink: 0; +} + +.mc-detail-container .mc-detail-avatar-img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.mc-detail-container .mc-detail-avatar-initials { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 32px; + font-weight: bold; + color: var(--primary-blue-dark); +} + +.mc-detail-container .mc-detail-name { + margin: 0; + color: var(--text-primary); + font-size: 1.8rem; +} + +.mc-detail-container .mc-detail-section { + margin-bottom: 25px; +} + +.mc-detail-container .mc-detail-section h4 { + color: var(--primary-blue-dark); + margin-bottom: 15px; + padding-bottom: 8px; + border-bottom: 1px solid var(--gray-200); +} + +.mc-detail-container .mc-detail-row { + display: flex; + margin-bottom: 10px; +} + +.mc-detail-container .mc-detail-label { + font-weight: 600; + color: var(--text-primary); + width: 120px; + flex-shrink: 0; +} + +.mc-detail-container .mc-detail-value { + color: var(--text-secondary); +} + +.mc-detail-container .mc-detail-skills { + display: flex; + gap: 30px; + margin-top: 15px; +} + +.mc-detail-container .mc-detail-skill-category { + flex: 1; +} + +.mc-detail-container .mc-detail-skill-category h5 { + color: var(--text-primary); + margin-bottom: 10px; + font-size: 1rem; +} + +.mc-detail-container .mc-skill-list { + list-style: none; + padding: 0; + margin: 0; +} + +.mc-detail-container .mc-skill-list li { + padding: 6px 0; + border-bottom: 1px dashed var(--gray-200); +} + +.mc-detail-container .mc-applications-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.mc-detail-container .mc-application { + display: flex; + justify-content: space-between; + padding: 10px; + background-color: var(--gray-100); + border-radius: 6px; +} + +.mc-detail-container .mc-application-name { + font-weight: 500; +} + +.mc-detail-container .mc-application-stage { + background-color: var(--primary-blue-light); + color: var(--primary-blue-dark); + padding: 3px 8px; + border-radius: 12px; + font-size: 12px; +} + +/* ===== Empty State ===== */ +.mc-detail-container .mc-empty-state { + text-align: center; + padding: 60px 20px; + background-color: var(--gray-100); + border-radius: 12px; + margin-top: 30px; +} + +.mc-detail-container .mc-empty-icon { + font-size: 48px; + margin-bottom: 15px; + opacity: 0.7; +} + +.mc-detail-container .mc-empty-state h4 { + color: var(--text-primary); + margin-bottom: 10px; +} + +.mc-detail-container .mc-empty-state p { + color: var(--text-secondary); + margin: 0; +} + +/* ===== Responsive Adjustments ===== */ +@media (max-width: 768px) { + .matching-candidates-container .mc-cards-container { + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + } + + .matching-candidates-container .mc-percentage-circles { + flex-direction: column; + align-items: center; + } + + .matching-candidates-container .mc-modal-content { + width: 95%; + margin: 10% auto; + } + + .mc-detail-container .mc-detail-skills { + flex-direction: column; + gap: 20px; + } + + .mc-detail-container .mc-detail-header { + flex-direction: column; + text-align: center; + } + + .mc-detail-container .mc-detail-avatar { + margin-right: 0; + margin-bottom: 15px; + } +} \ No newline at end of file diff --git a/addons_extensions/hr_recruitment_web_app/static/src/css/list.css b/addons_extensions/hr_recruitment_web_app/static/src/css/list.css new file mode 100644 index 000000000..b3ccde4dc --- /dev/null +++ b/addons_extensions/hr_recruitment_web_app/static/src/css/list.css @@ -0,0 +1,708 @@ +@import url('colors.css'); +/* ===== Job List View Styling ===== */ +.ats-list-container { + display: flex; + flex-direction: column; + height: 99%; + width: 100%; + border: 1px solid var(--border-color); + border-radius: 6px; + overflow: hidden; + box-shadow: 0 2px 8px var(--shadow-color); + background-color: var(--body-bg); + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; +} +.ats-list-container .ats-list-search { + flex: 0 0 auto; + padding: 12px; + background-color: var(--gray-100); + border-bottom: 1px solid var(--border-light); +} +.ats-list-container .ats-list-search input { + width: 100%; + padding: 10px 14px; + font-size: 14px; + border: 1px solid var(--gray-300); + border-radius: 6px; + outline: none; + transition: border-color 0.3s ease; + background-color: var(--white); + color: var(--text-primary); +} +.ats-list-container .ats-list-search input:focus { + border-color: var(--primary-blue); +} +.ats-list-container .ats-list-body { + flex: 1; + display: flex; + height: calc(100vh - 70px); /* header + search box approx height */ + overflow: hidden; + position: relative; +} + +.ats-list-container .ats-actions-header { + padding: 10px 15px; + background-color: var(--content-bg); + border-radius: 6px; + border-left: 4px solid var(--primary-blue); + display: flex; + align-items: center; +} +.ats-list-container .ats-actions-header .section-title { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); +} +.ats-list-container .ats-actions-header .btn { + font-size: 14px; + font-weight: 500; + padding: 6px 15px; + border-radius: 4px; + transition: all 0.3s ease; +} +.ats-list-container .ats-actions-header #activeRecords { + margin-right: auto; + background-color: var(--active-search-bg); + color: var(--active-search-text); + border: 1px solid var(--gray-300); + padding: 10px 20px; + border-radius: 6px; + font-weight: 500; + box-shadow: 0 2px 4px var(--shadow-color); + transition: all 0.3s ease-in-out; + cursor: pointer; +} +.ats-list-container .job-actions-header #activeRecords:hover { + background-color: var(--active-search-hover-bg); + color: var(--active-search-text); + box-shadow: 0 4px 8px var(--shadow-dark); +} +/* Button Styles */ +.ats-list-container .ats-actions-header .add-create-btn { + margin-left: auto; + background-color: var(--add-btn-bg); + color: var(--add-btn-color); + border: none; + padding: 10px 20px; + border-radius: 6px; + font-weight: bold; + display: flex; + align-items: center; + gap: 8px; + box-shadow: 0 2px 6px rgba(52, 152, 219, 0.2); + transition: all 0.3s ease-in-out; + cursor: pointer; +} +.ats-list-container .ats-actions-header .add-create-btn:hover { + background-color: var(--add-btn-hover-bg); + transform: scale(1.05); + box-shadow: 0 6px 12px rgba(41, 128, 185, 0.3); +} +.ats-list-container .ats-actions-header .add-create-btn .plus-icon { + font-size: 20px; + font-weight: bold; + line-height: 1; + display: inline-block; + transition: transform 0.3s ease; +} +.ats-list-container .ats-actions-header .add-create-btn:hover .plus-icon { + transform: scale(1.3); +} +/* ===== Job List Panel ===== */ +.ats-list-container .ats-list-left { + width: 30%; + padding: 0; + overflow-y: auto; + background-color: var(--content-bg); + position: relative; + max-height: 100%; + display: flex; + flex-direction: column; +} +.ats-list-container .ats-list-left ul { + list-style: none; + padding: 12px 12px 20px 12px; + margin: 0; + flex: 1; + overflow-y: auto; +} +/* ===== Kanban View (Full Screen) ===== */ +.ats-list-container:not(.ats-selected) .ats-list-left { + width: 100%; + display: flex; + flex-direction: column; +} +.ats-list-container:not(.ats-selected) .ats-list-left ul { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + grid-auto-rows: 1fr; /* Equal height rows */ + grid-gap: 16px; + padding: 16px; + overflow-y: auto; + width: 100%; + box-sizing: border-box; + align-content: start; /* Align items to the top */ +} +.ats-list-container:not(.ats-selected) .ats-item { + padding: 16px; + margin-bottom: 0; + border: 1px solid var(--gray-200); + border-radius: 8px; + cursor: pointer; + transition: all 0.3s ease; + background-color: var(--white); + box-shadow: 0 2px 6px var(--shadow-color); + display: flex; + flex-direction: column; + position: relative; + overflow: hidden; + height: auto; /* Fill the grid cell */ + min-height: 150; /* Minimum height */ +} +.ats-list-container:not(.ats-selected) .ats-item::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 6px; + height: 100%; + background-color: var(--primary-blue); + transform: scaleY(0); + transition: transform 0.3s ease; +} +.ats-list-container:not(.ats-selected) .ats-item:hover { + transform: translateY(-4px); + box-shadow: 0 8px 16px var(--shadow-dark); + border-color: var(--primary-blue); +} +.ats-list-container:not(.ats-selected) .ats-item:hover::before { + transform: scaleY(1); +} +.ats-list-container:not(.ats-selected) .ats-item .ats-title { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 8px; + padding-right: 20px; +} +.ats-list-container:not(.ats-selected) .ats-item .ats-meta { + font-size: 14px; + color: var(--text-secondary); + margin-bottom: 12px; + display: flex; + align-items: center; +} +.ats-list-container:not(.ats-selected) .ats-item .ats-meta i { + margin-right: 6px; + color: var(--primary-blue); +} +.ats-list-container:not(.ats-selected) .ats-item .ats-badges { + margin-top: auto; + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.ats-list-container:not(.ats-selected) .ats-item .job-badge { + margin-right: 0; + padding: 4px 10px; + font-size: 12px; + border-radius: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 28px; + height: 24px; +} +.ats-list-container:not(.ats-selected) .ats-item .badge-primary { + background-color: var(--primary-blue); +} +.ats-list-container:not(.ats-selected) .ats-item .badge-warning { + background-color: var(--warning); + color: var(--black); +} +.ats-list-container:not(.ats-selected) .ats-item .badge-success { + background-color: var(--success); +} +.ats-list-container:not(.ats-selected) .ats-item .badge-danger { + background-color: var(--danger); +} +/* ===== List View (When Job Selected) ===== */ +.ats-list-container.ats-selected .ats-item { + padding: 10px 12px; + margin-bottom: 8px; + border: 1px solid var(--gray-200); + border-radius: 6px; + cursor: pointer; + transition: background-color 0.2s, box-shadow 0.2s; + background-color: var(--sidebar-bg); +} +.ats-list-container.ats-selected .ats-item:hover { + background-color: var(--side-panel-item-hover); + box-shadow: 0 1px 4px var(--shadow-color); +} +.ats-list-container.ats-selected .ats-item.selected { + background-color: var(--side-panel-item-selected); + border: 1px solid var(--primary-blue); +} +/* ===== Job Detail Panel ===== */ +.ats-list-container .ats-detail { + width: 70%; + padding: 20px; + overflow-y: auto; + overflow-x: hidden; + background-color: var(--content-bg); + color: var(--text-primary); + position: relative; + flex: 1; + transition: all 0.3s ease; +} +.ats-list-container .ats-detail h3 { + margin-top: 0; + font-size: 20px; + color: var(--primary-blue); +} +.ats-list-container .ats-detail p { + margin: 8px 0; + line-height: 1.6; + color: var(--text-secondary); +} +.ats-list-container .ats-detail em { + color: var(--text-muted); +} +/* ===== Panel Controls ===== */ +.ats-list-container .panel-controls { + position: sticky; + top: 0; + background: var(--white); + z-index: 100; + padding: 8px 0; + display: flex; + justify-content: flex-end; +} +.ats-list-container .panel-controls button { + background-color: var(--gray-200); + border: none; + color: var(--text-primary); + font-size: 14px; + margin-left: 5px; + padding: 2px 6px; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.2s ease; +} +.ats-list-container .panel-controls button:hover { + background-color: var(--gray-300); +} +/* ======Job stats====== */ +.ats-list-container .job-stats-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + background-color: var(--content-bg); + border-bottom: 1px solid var(--border-color); + position: sticky; + top: 0; + z-index: 10; +} +.ats-list-container .job-stats-values { + display: flex; + gap: 12px; + flex-wrap: wrap; +} +.ats-list-container .close-stats { + background: none; + border: none; + font-size: 18px; + color: var(--gray-600); + cursor: pointer; + padding: 4px 8px; + transition: color 0.3s; +} +.ats-list-container .close-stats:hover { + color: var(--gray-900); +} +.ats-list-container .badge { + padding: 6px 10px; + border-radius: 14px; + font-size: 12px; + font-weight: 600; + color: var(--white); +} +.ats-list-container .stat-toggle.crossed { + text-decoration: line-through; + opacity: 0.6; + cursor: pointer; +} +.ats-list-container .stat-toggle { + cursor: pointer; + margin-right: 8px; +} +.ats-list-container .job-badges { + margin-top: 6px; +} +.ats-list-container .job-badge { + margin-right: 6px; + padding: 4px 10px; + font-size: 12px; + border-radius: 12px; + display: inline-block; +} +.ats-list-container .badge-primary { background-color: var(--primary-blue); } +.ats-list-container .badge-warning { background-color: var(--warning); color: var(--black); } +.ats-list-container .badge-success { background-color: var(--success); } +.ats-list-container .badge-danger { background-color: var(--danger); } +/* ===== Sidebar Toggle ===== */ +.ats-list-container .ats-list-left { + width: 30%; + min-width: 300px; + transition: all 0.3s ease; + position: relative; + overflow-x: hidden; +} +.ats-list-container .ats-list-left.collapsed { + width: 5%; + min-width: 5%; + padding: 0; + border-right: none; +} +.ats-list-container .ats-detail { + width: 70%; + transition: all 0.3s ease; +} +.ats-list-container .ats-list-left.collapsed + .ats-detail { + width: 95%; +} +/* Toggle Button */ +.ats-list-container .ats-list-toggle-btn { + position: absolute; + top: 50%; + right: -12px; + transform: translateY(-50%); + background-color: var(--gray-100); + color: var(--text-primary); + border: none; + padding: 0.5rem 0.7rem; + cursor: pointer; + border-radius: 0 6px 6px 0; + box-shadow: 0 2px 5px var(--shadow-color); + z-index: 20; + display: flex; + align-items: center; + justify-content: center; + transition: right 0.3s ease; +} +/* Button in collapsed state */ +.ats-list-container .ats-list-left.collapsed .ats-list-toggle-btn { + right: -12px; + transform: translateY(-50%); +} +.ats-list-container .ats-list-toggle-btn:hover { + background-color: var(--gray-200); +} +/* Hide content in collapsed state */ +.ats-list-container .ats-list-left.collapsed > *:not(.job-list-toggle-btn) { + display: none; +} +/* ===== Layout States ===== */ +/* Initial state: job list takes full width */ +.ats-list-container:not(.ats-selected) .ats-list-left { + width: 100%; +} +.ats-list-container:not(.ats-selected) .ats-detail { + display: none; +} +/* When a job is selected */ +.ats-list-container.ats-selected .ats-list-left { + width: 30%; +} +.ats-list-container.ats-selected .ats-detail { + width: 70%; + display: block; + overflow: hidden; +} +/* When sidebar is collapsed */ +.ats-list-container.ats-selected .ats-list-left.collapsed { + width: 5%; + min-width: 5%; +} +.ats-list-container.ats-selected .ats-list-left.collapsed + .ats-detail { + width: 95%; +} + +.ats-list-container:not(.ats-selected) .ats-list-toggle-btn { + display: none; +} + +.ats-list-container.ats-selected .ats-list-toggle-btn { + display: flex; +} +/* Close button styling */ +.close-detail { + position: absolute; + top: 10px; + right: 10px; + z-index: 100; + background: transparent; + border: none; + font-size: 1.5rem; + color: var(--gray-600); + cursor: pointer; + padding: 0; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: all 0.2s ease; +} +.close-detail:hover { + background-color: var(--gray-200); + color: var(--gray-900); +} +/* Transitions for smooth resizing */ +.ats-list-container .ats-list-left, +.ats-list-container .ats-detail { + transition: width 0.3s ease; +} +/* ===== Responsive Adjustments ===== */ +@media (max-width: 768px) { + .ats-list-container:not(.ats-selected) .ats-list-left ul { + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + grid-gap: 12px; + padding: 12px; + } + + .ats-list-container:not(.ats-selected) .ats-item { + padding: 12px; + min-height: 160px; + } + + .ats-list-container.ats-selected .ats-list-left { + width: 40%; + } + + .ats-list-container.ats-selected .ats-detail { + width: 60%; + } + + .ats-list-container.ats-selected .ats-list-left.collapsed { + width: 10%; + min-width: 10%; + } + + .ats-list-container.ats-selected .ats-list-left.collapsed + .ats-detail { + width: 90%; + } +} + + +/* ===== Applicant List Styling ===== */ +.ats-list-container .ats-list { + list-style: none; + padding: 12px 12px 20px 12px; + margin: 0; + flex: 1; + overflow-y: auto; +} + +.ats-list-container .ats-item-content { + display: flex; + justify-content: space-between; + align-items: center; +} + +.ats-list-container .ats-info { + flex: 1; +} + +.ats-list-container .ats-avatar { + margin-left: 12px; + flex-shrink: 0; +} + +.ats-list-container .ats-item-image { + width: 50px; + height: 50px; + border-radius: 50%; + object-fit: cover; +} + +.ats-list-container .ats-item-initials { + width: 50px; + height: 50px; + border-radius: 50%; + background-color: var(--primary-blue); + color: white; + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; + font-size: 18px; +} + +/* ===== Applicant Modal Styling ===== */ +.applicant-detail-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 1000; +} + +.applicant-detail-modal.active { + display: flex; + align-items: center; + justify-content: center; +} + +.modal-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); +} + +.applicant-modal-content { + position: relative; + background-color: var(--white); + border-radius: 8px; + width: 90%; + max-width: 600px; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3); + z-index: 1001; +} + +.applicant-close-modal { + position: absolute; + top: 10px; + right: 15px; + font-size: 24px; + font-weight: bold; + color: var(--gray-600); + cursor: pointer; + z-index: 1002; +} + +.applicant-close-modal:hover { + color: var(--gray-900); +} + +.applicant-status-ribbon { + position: absolute; + top: 0; + left: 0; + width: 100%; + padding: 8px 15px; + background-color: var(--primary-blue); + color: var(--white); + font-weight: 600; + border-radius: 8px 8px 0 0; +} + +.modal-applicant-container { + display: flex; + padding: 50px 20px 20px; +} + +.modal-applicant-left { + flex: 0 0 auto; + margin-right: 20px; +} + +.modal-applicant-image { + width: 120px; + height: 120px; + border-radius: 50%; + object-fit: cover; + border: 3px solid var(--primary-blue); +} + +.modal-applicant-initials { + width: 120px; + height: 120px; + border-radius: 50%; + background-color: var(--primary-blue); + color: white; + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; + font-size: 48px; + border: 3px solid var(--primary-blue); +} + +.modal-applicant-right { + flex: 1; +} + +.modal-applicant-name { + margin-top: 0; + color: var(--text-primary); + border-bottom: 1px solid var(--border-color); + padding-bottom: 10px; +} + +.modal-applicant-details { + margin-top: 15px; +} + +.detail-row { + display: flex; + margin-bottom: 10px; +} + +.detail-label { + flex: 0 0 140px; + font-weight: 600; + color: var(--text-secondary); +} + +.detail-value { + flex: 1; + color: var(--text-primary); +} + +.recruiter-info { + margin-top: 20px; + padding-top: 15px; + border-top: 1px solid var(--border-color); +} + +.recruiter-avatar { + display: flex; + align-items: center; +} + +.recruiter-image { + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; + margin-right: 10px; +} + +.recruiter-initials { + width: 40px; + height: 40px; + border-radius: 50%; + background-color: var(--gray-300); + color: var(--text-primary); + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; + margin-right: 10px; +} + +.recruiter-tooltip { + font-weight: 500; + color: var(--text-primary); +} \ No newline at end of file diff --git a/addons_extensions/hr_recruitment_web_app/static/src/css/select2.min.css b/addons_extensions/hr_recruitment_web_app/static/src/css/select2.min.css new file mode 100644 index 000000000..7c18ad59d --- /dev/null +++ b/addons_extensions/hr_recruitment_web_app/static/src/css/select2.min.css @@ -0,0 +1 @@ +.select2-container{box-sizing:border-box;display:inline-block;margin:0;position:relative;vertical-align:middle}.select2-container .select2-selection--single{box-sizing:border-box;cursor:pointer;display:block;height:28px;user-select:none;-webkit-user-select:none}.select2-container .select2-selection--single .select2-selection__rendered{display:block;padding-left:8px;padding-right:20px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.select2-container .select2-selection--single .select2-selection__clear{position:relative}.select2-container[dir="rtl"] .select2-selection--single .select2-selection__rendered{padding-right:8px;padding-left:20px}.select2-container .select2-selection--multiple{box-sizing:border-box;cursor:pointer;display:block;min-height:32px;user-select:none;-webkit-user-select:none}.select2-container .select2-selection--multiple .select2-selection__rendered{display:inline-block;overflow:hidden;padding-left:8px;text-overflow:ellipsis;white-space:nowrap}.select2-container .select2-search--inline{float:left}.select2-container .select2-search--inline .select2-search__field{box-sizing:border-box;border:none;font-size:100%;margin-top:5px;padding:0}.select2-container .select2-search--inline .select2-search__field::-webkit-search-cancel-button{-webkit-appearance:none}.select2-dropdown{background-color:white;border:1px solid #aaa;border-radius:4px;box-sizing:border-box;display:block;position:absolute;left:-100000px;width:100%;z-index:1051}.select2-results{display:block}.select2-results__options{list-style:none;margin:0;padding:0}.select2-results__option{padding:6px;user-select:none;-webkit-user-select:none}.select2-results__option[aria-selected]{cursor:pointer}.select2-container--open .select2-dropdown{left:0}.select2-container--open .select2-dropdown--above{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--open .select2-dropdown--below{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-search--dropdown{display:block;padding:4px}.select2-search--dropdown .select2-search__field{padding:4px;width:100%;box-sizing:border-box}.select2-search--dropdown .select2-search__field::-webkit-search-cancel-button{-webkit-appearance:none}.select2-search--dropdown.select2-search--hide{display:none}.select2-close-mask{border:0;margin:0;padding:0;display:block;position:fixed;left:0;top:0;min-height:100%;min-width:100%;height:auto;width:auto;opacity:0;z-index:99;background-color:#fff;filter:alpha(opacity=0)}.select2-hidden-accessible{border:0 !important;clip:rect(0 0 0 0) !important;-webkit-clip-path:inset(50%) !important;clip-path:inset(50%) !important;height:1px !important;overflow:hidden !important;padding:0 !important;position:absolute !important;width:1px !important;white-space:nowrap !important}.select2-container--default .select2-selection--single{background-color:#fff;border:1px solid #aaa;border-radius:4px}.select2-container--default .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--default .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:bold}.select2-container--default .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--default .select2-selection--single .select2-selection__arrow{height:26px;position:absolute;top:1px;right:1px;width:20px}.select2-container--default .select2-selection--single .select2-selection__arrow b{border-color:#888 transparent transparent transparent;border-style:solid;border-width:5px 4px 0 4px;height:0;left:50%;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__clear{float:left}.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__arrow{left:1px;right:auto}.select2-container--default.select2-container--disabled .select2-selection--single{background-color:#eee;cursor:default}.select2-container--default.select2-container--disabled .select2-selection--single .select2-selection__clear{display:none}.select2-container--default.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888 transparent;border-width:0 4px 5px 4px}.select2-container--default .select2-selection--multiple{background-color:white;border:1px solid #aaa;border-radius:4px;cursor:text}.select2-container--default .select2-selection--multiple .select2-selection__rendered{box-sizing:border-box;list-style:none;margin:0;padding:0 5px;width:100%}.select2-container--default .select2-selection--multiple .select2-selection__rendered li{list-style:none}.select2-container--default .select2-selection--multiple .select2-selection__clear{cursor:pointer;float:right;font-weight:bold;margin-top:5px;margin-right:10px;padding:1px}.select2-container--default .select2-selection--multiple .select2-selection__choice{background-color:#e4e4e4;border:1px solid #aaa;border-radius:4px;cursor:default;float:left;margin-right:5px;margin-top:5px;padding:0 5px}.select2-container--default .select2-selection--multiple .select2-selection__choice__remove{color:#999;cursor:pointer;display:inline-block;font-weight:bold;margin-right:2px}.select2-container--default .select2-selection--multiple .select2-selection__choice__remove:hover{color:#333}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice,.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-search--inline{float:right}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice{margin-left:5px;margin-right:auto}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove{margin-left:2px;margin-right:auto}.select2-container--default.select2-container--focus .select2-selection--multiple{border:solid black 1px;outline:0}.select2-container--default.select2-container--disabled .select2-selection--multiple{background-color:#eee;cursor:default}.select2-container--default.select2-container--disabled .select2-selection__choice__remove{display:none}.select2-container--default.select2-container--open.select2-container--above .select2-selection--single,.select2-container--default.select2-container--open.select2-container--above .select2-selection--multiple{border-top-left-radius:0;border-top-right-radius:0}.select2-container--default.select2-container--open.select2-container--below .select2-selection--single,.select2-container--default.select2-container--open.select2-container--below .select2-selection--multiple{border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--default .select2-search--dropdown .select2-search__field{border:1px solid #aaa}.select2-container--default .select2-search--inline .select2-search__field{background:transparent;border:none;outline:0;box-shadow:none;-webkit-appearance:textfield}.select2-container--default .select2-results>.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--default .select2-results__option[role=group]{padding:0}.select2-container--default .select2-results__option[aria-disabled=true]{color:#999}.select2-container--default .select2-results__option[aria-selected=true]{background-color:#ddd}.select2-container--default .select2-results__option .select2-results__option{padding-left:1em}.select2-container--default .select2-results__option .select2-results__option .select2-results__group{padding-left:0}.select2-container--default .select2-results__option .select2-results__option .select2-results__option{margin-left:-1em;padding-left:2em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-2em;padding-left:3em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-3em;padding-left:4em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-4em;padding-left:5em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-5em;padding-left:6em}.select2-container--default .select2-results__option--highlighted[aria-selected]{background-color:#5897fb;color:white}.select2-container--default .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic .select2-selection--single{background-color:#f7f7f7;border:1px solid #aaa;border-radius:4px;outline:0;background-image:-webkit-linear-gradient(top, #fff 50%, #eee 100%);background-image:-o-linear-gradient(top, #fff 50%, #eee 100%);background-image:linear-gradient(to bottom, #fff 50%, #eee 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic .select2-selection--single:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--classic .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:bold;margin-right:10px}.select2-container--classic .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--classic .select2-selection--single .select2-selection__arrow{background-color:#ddd;border:none;border-left:1px solid #aaa;border-top-right-radius:4px;border-bottom-right-radius:4px;height:26px;position:absolute;top:1px;right:1px;width:20px;background-image:-webkit-linear-gradient(top, #eee 50%, #ccc 100%);background-image:-o-linear-gradient(top, #eee 50%, #ccc 100%);background-image:linear-gradient(to bottom, #eee 50%, #ccc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFCCCCCC', GradientType=0)}.select2-container--classic .select2-selection--single .select2-selection__arrow b{border-color:#888 transparent transparent transparent;border-style:solid;border-width:5px 4px 0 4px;height:0;left:50%;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__clear{float:left}.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__arrow{border:none;border-right:1px solid #aaa;border-radius:0;border-top-left-radius:4px;border-bottom-left-radius:4px;left:1px;right:auto}.select2-container--classic.select2-container--open .select2-selection--single{border:1px solid #5897fb}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow{background:transparent;border:none}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888 transparent;border-width:0 4px 5px 4px}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--single{border-top:none;border-top-left-radius:0;border-top-right-radius:0;background-image:-webkit-linear-gradient(top, #fff 0%, #eee 50%);background-image:-o-linear-gradient(top, #fff 0%, #eee 50%);background-image:linear-gradient(to bottom, #fff 0%, #eee 50%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--single{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0;background-image:-webkit-linear-gradient(top, #eee 50%, #fff 100%);background-image:-o-linear-gradient(top, #eee 50%, #fff 100%);background-image:linear-gradient(to bottom, #eee 50%, #fff 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFFFFFFF', GradientType=0)}.select2-container--classic .select2-selection--multiple{background-color:white;border:1px solid #aaa;border-radius:4px;cursor:text;outline:0}.select2-container--classic .select2-selection--multiple:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--multiple .select2-selection__rendered{list-style:none;margin:0;padding:0 5px}.select2-container--classic .select2-selection--multiple .select2-selection__clear{display:none}.select2-container--classic .select2-selection--multiple .select2-selection__choice{background-color:#e4e4e4;border:1px solid #aaa;border-radius:4px;cursor:default;float:left;margin-right:5px;margin-top:5px;padding:0 5px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove{color:#888;cursor:pointer;display:inline-block;font-weight:bold;margin-right:2px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove:hover{color:#555}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice{float:right;margin-left:5px;margin-right:auto}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove{margin-left:2px;margin-right:auto}.select2-container--classic.select2-container--open .select2-selection--multiple{border:1px solid #5897fb}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--multiple{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--multiple{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--classic .select2-search--dropdown .select2-search__field{border:1px solid #aaa;outline:0}.select2-container--classic .select2-search--inline .select2-search__field{outline:0;box-shadow:none}.select2-container--classic .select2-dropdown{background-color:#fff;border:1px solid transparent}.select2-container--classic .select2-dropdown--above{border-bottom:none}.select2-container--classic .select2-dropdown--below{border-top:none}.select2-container--classic .select2-results>.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--classic .select2-results__option[role=group]{padding:0}.select2-container--classic .select2-results__option[aria-disabled=true]{color:grey}.select2-container--classic .select2-results__option--highlighted[aria-selected]{background-color:#3875d7;color:#fff}.select2-container--classic .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic.select2-container--open .select2-dropdown{border-color:#5897fb} diff --git a/addons_extensions/hr_recruitment_web_app/static/src/img/logo.jpeg b/addons_extensions/hr_recruitment_web_app/static/src/img/logo.jpeg new file mode 100644 index 000000000..a13842359 Binary files /dev/null and b/addons_extensions/hr_recruitment_web_app/static/src/img/logo.jpeg differ diff --git a/addons_extensions/hr_recruitment_web_app/static/src/js/applicants.js b/addons_extensions/hr_recruitment_web_app/static/src/js/applicants.js new file mode 100644 index 000000000..65e995538 --- /dev/null +++ b/addons_extensions/hr_recruitment_web_app/static/src/js/applicants.js @@ -0,0 +1,850 @@ +/** @odoo-module **/ +function initApplicantsPage() { + console.log("Applicants Page Loaded"); + const applicantDetailArea = document.getElementById("applicants-detail"); + const container = document.querySelector('.ats-list-container'); + const toggleBtn = document.getElementById("applicants-list-sidebar-toggle-btn"); + const sidebar = document.getElementById("applicants-list-panel"); + + // Fix: Use correct class for applicant items - using both classes + document.querySelectorAll(".ats-item.applicants-item").forEach(item => { + item.addEventListener("click", function() { + console.log("Applicant item clicked"); // Add this for debugging + document.querySelectorAll(".ats-item.applicants-item.selected").forEach(el => el.classList.remove("selected")); + this.classList.add("selected"); + + applicantDetailArea.style.display = 'block'; + container.classList.add('ats-selected'); + sidebar.classList.remove('collapsed'); + toggleBtn.style.display = 'flex'; + + const applicantId = this.dataset.id; + console.log("Applicant ID:", applicantId); // Add this for debugging + + // Show loading state + if (applicantDetailArea) { + applicantDetailArea.innerHTML = '

Loading applicant details...

'; + } + + fetch(`/myATS/applicant/detail/${applicantId}`, { + headers: { "X-Requested-With": "XMLHttpRequest" } + }) + .then(res => { + if (!res.ok) throw new Error('Network response was not ok'); + return res.text(); + }) + .then(html => { + console.log("Response received"); // Add this for debugging + if (applicantDetailArea) { + applicantDetailArea.innerHTML = html; + initApplicantDetailEdit(); // Initialize edit functionality + + // Add close button functionality + const closeBtn = applicantDetailArea.querySelector('.close-detail'); + if (closeBtn) { + closeBtn.addEventListener('click', function() { + applicantDetailArea.style.display = 'none'; + container.classList.remove('ats-selected'); + document.querySelectorAll(".ats-item.applicants-item.selected").forEach(el => el.classList.remove("selected")); + }); + } + } + }) + .catch(error => { + console.error('Error loading applicant details:', error); + if (applicantDetailArea) { + applicantDetailArea.innerHTML = '
Error loading applicant details. Please try again.
'; + } + }); + }); + }); + + // Search functionality - use correct ID + const search = document.getElementById("applicants-search"); + if (search) { + search.addEventListener("input", function() { + const query = this.value.toLowerCase(); + let visibleCount = 0; + // Also fix this selector to use both classes + document.querySelectorAll(".ats-item.applicants-item").forEach(item => { + const match = item.textContent.toLowerCase().includes(query); + item.style.display = match ? "" : "none"; + if (match) visibleCount++; + }); + const countElement = document.getElementById("active-records-count"); + if (countElement) { + countElement.textContent = visibleCount; + } + }); + } + + // Sidebar Toggle + if (toggleBtn && sidebar) { + toggleBtn.addEventListener("click", function(e) { + e.stopPropagation(); + sidebar.classList.toggle("collapsed"); + }); + } + + // Applicant Modal Handling + const modal = document.querySelector('.applicant-detail-modal'); + if (modal) { + const closeModal = modal.querySelector('.applicant-close-modal'); + + // Event delegation for image clicks - use correct classes + document.addEventListener('click', function(e) { + const img = e.target.closest('.ats-item-image, .ats-item-initials'); + if (!img) return; + + const applicantItem = img.closest('.ats-item.applicants-item'); + if (!applicantItem) return; + + e.preventDefault(); + e.stopImmediatePropagation(); + + // Get applicant data with null checks + const applicantId = applicantItem.dataset.id; + const nameElement = applicantItem.querySelector('.ats-title'); + const jobElement = applicantItem.querySelector('#job_request'); + const stageElement = applicantItem.querySelector('#applicant_stage'); + const createDate = applicantItem.querySelector('#create_date'); + const applicantEmail = applicantItem.querySelector('#applicant_email'); + const applicantPhone = applicantItem.querySelector('#applicant_phone'); + const alternatePhone = applicantItem.querySelector('#alternate_phone'); + const recruiterName = applicantItem.querySelector('#recruiter_name'); + const recruiterImage = applicantItem.querySelector('#recruiter_image'); + const recruiterUserId = applicantItem.querySelector('#recruiter_id'); + + const name = nameElement ? nameElement.textContent : 'N/A'; + const job = jobElement ? jobElement.textContent : 'N/A'; + const stage = stageElement ? stageElement.textContent : 'N/A'; + const date = createDate ? createDate.textContent : 'N/A'; + const email = applicantEmail ? applicantEmail.textContent : 'N/A'; + const phone = applicantPhone ? applicantPhone.textContent : 'N/A'; + const altPhone = alternatePhone ? alternatePhone.textContent : 'N/A'; + const recruiter = recruiterName ? recruiterName.textContent : 'N/A'; + const recruiterId = recruiterUserId ? recruiterUserId.textContent: null; + const photoSrc = img.classList.contains('ats-item-image') ? img.src : null; + const initials = img.classList.contains('ats-item-initials') ? img.textContent : null; + + // Populate modal with applicant data + modal.querySelector('.modal-applicant-name').textContent = name; + modal.querySelector('.modal-applicant-job').textContent = job; + modal.querySelector('.modal-applicant-stage').textContent = stage; + modal.querySelector('.modal-applicant-date').textContent = date; + modal.querySelector('.modal-applicant-email').textContent = email; + modal.querySelector('.modal-applicant-phone').textContent = phone; + modal.querySelector('.modal-applicant-altphone').textContent = altPhone; + + // Handle applicant image + if (photoSrc) { + const modalImg = modal.querySelector('.modal-applicant-image'); + modalImg.src = photoSrc; + modalImg.style.display = 'block'; + modal.querySelector('.modal-applicant-initials').style.display = 'none'; + } else { + modal.querySelector('.modal-applicant-initials').textContent = initials; + modal.querySelector('.modal-applicant-initials').style.display = 'flex'; + modal.querySelector('.modal-applicant-image').style.display = 'none'; + } + + // Handle recruiter info + const recruiterImageEl = modal.querySelector('.recruiter-image'); + const recruiterInitialsEl = modal.querySelector('.recruiter-initials'); + const recruiterTooltip = modal.querySelector('.recruiter-tooltip'); + + if (recruiterImage && recruiterId) { + const recruiterSrc = `/web/image/res.users/${recruiterId}/image_128`; + recruiterImageEl.src = recruiterSrc; + recruiterImageEl.style.display = 'block'; + recruiterInitialsEl.style.display = 'none'; + recruiterSrc.onerror = function () { + recruiterImageEl.style.display = 'none'; + }; + } else if (recruiterName) { + recruiterInitialsEl.textContent = recruiterName.textContent.charAt(0).toUpperCase(); + recruiterInitialsEl.style.display = 'flex'; + recruiterImageEl.style.display = 'none'; + } + recruiterTooltip.textContent = recruiter; + + // Set status ribbon class based on stage - use correct selector + const statusRibbon = modal.querySelector('.applicant-status-ribbon'); + if (statusRibbon) { + // Remove all existing status classes + statusRibbon.classList.remove('new', 'interview', 'hired', 'rejected'); + // Add appropriate class based on stage + if (stage.toLowerCase().includes('interview')) { + statusRibbon.classList.add('interview'); + } else if (stage.toLowerCase().includes('hired')) { + statusRibbon.classList.add('hired'); + } else if (stage.toLowerCase().includes('reject')) { + statusRibbon.classList.add('rejected'); + } else { + statusRibbon.classList.add('new'); + } + } + + // Show modal + modal.style.display = 'flex'; + setTimeout(() => { + modal.classList.add('show'); + }, 10); + document.body.style.overflow = 'hidden'; + }); + + // Close modal handlers + closeModal.addEventListener('click', function() { + modal.classList.remove('show'); + setTimeout(() => { + modal.style.display = 'none'; + }, 300); + document.body.style.overflow = ''; + }); + + modal.addEventListener('click', function(e) { + if (e.target === modal) { + modal.classList.remove('show'); + document.body.style.overflow = ''; + } + }); + + // Close with ESC key + document.addEventListener('keydown', function(e) { + if (e.key === 'Escape' && modal.classList.contains('show')) { + modal.classList.remove('show'); + document.body.style.overflow = ''; + } + }); + } + + const createApplication = document.getElementById('add-application-create-btn'); + const applicantModal = document.getElementById('application-creation-modal'); + const closeModal = document.querySelectorAll('.application-creation-close, .btn-cancel'); + if (createApplication) { + createApplication.addEventListener('click', function(e) { + e.preventDefault(); + applicantModal.style.display = 'flex'; + setTimeout(() => { + applicantModal.classList.add('show'); + }, 10); + document.body.style.overflow = 'hidden'; + setTimeout(() => { + initSelect2(); + initResumeUploadHandlers(); + }, 100); + setTimeout(createApplicationForm, 100); + }); + } + if (closeModal) { + closeModal.forEach(btn => { + btn.addEventListener('click', function() { + applicantModal.classList.remove('show'); + setTimeout(() => { + applicantModal.style.display = 'none'; + }, 300); + document.body.style.overflow = ''; + }); + }); + } + // Close modal when clicking outside of it + applicantModal.addEventListener('click', function(e) { + if (e.target === applicantModal) { + applicantModal.classList.remove('show'); + setTimeout(() => { + applicantModal.style.display = 'none'; + }, 300); + document.body.style.overflow = ''; + } + }); + // File Upload Handling + const resumeUpload = document.getElementById('resume-upload'); + const resumeDropzone = document.getElementById('resume-dropzone'); + const resumePreview = document.getElementById('resume-preview'); + const resumePlaceholder = resumePreview.querySelector('.resume-preview-placeholder'); + const resumeIframe = document.getElementById('resume-iframe'); + const resumeImage = document.getElementById('resume-image'); + const unsupportedFormat = document.getElementById('unsupported-format'); + const downloadResume = document.getElementById('download-resume'); + const attachmentsList = document.querySelector('.attachments-list'); + const addAttachmentBtn = document.querySelector('.add-attachment'); + + function initResumeUploadHandlers() { + // Create remove button + const removeResumeBtn = document.createElement('button'); + removeResumeBtn.innerHTML = ' Remove Resume'; + removeResumeBtn.className = 'btn btn-danger btn-sm mt-2'; + removeResumeBtn.style.display = 'none'; + resumePreview.appendChild(removeResumeBtn); + + // Handle remove resume + removeResumeBtn.addEventListener('click', function() { + resetResumePreview(); + }); + + function resetResumePreview() { + // Clear file input + resumeUpload.value = ''; + currentResumeFile = null; + // Reset preview + resumePlaceholder.style.display = 'flex'; + resumeIframe.style.display = 'none'; + resumeImage.style.display = 'none'; + unsupportedFormat.style.display = 'none'; + removeResumeBtn.style.display = 'none'; + // Reset iframe/src to prevent memory leaks + if (resumeIframe.src) { + URL.revokeObjectURL(resumeIframe.src); + resumeIframe.src = ''; + } + if (resumeImage.src) { + URL.revokeObjectURL(resumeImage.src); + resumeImage.src = ''; + } + if (downloadResume.href) { + URL.revokeObjectURL(downloadResume.href); + downloadResume.href = '#'; + } + } + + // Handle click on dropzone + resumeDropzone.addEventListener('click', function(e) { + if (e.target === this || e.target.classList.contains('upload-icon') || + e.target.tagName === 'H5' || e.target.tagName === 'P') { + resumeUpload.click(); + } + }); + + // Handle drag and drop + resumeDropzone.addEventListener('dragover', function(e) { + e.preventDefault(); + e.stopPropagation(); + this.classList.add('dragover'); + this.style.borderColor = '#3498db'; + this.style.backgroundColor = 'rgba(52, 152, 219, 0.1)'; + }); + + resumeDropzone.addEventListener('dragleave', function(e) { + e.preventDefault(); + e.stopPropagation(); + this.classList.remove('dragover'); + this.style.borderColor = ''; + this.style.backgroundColor = ''; + }); + + resumeDropzone.addEventListener('drop', function(e) { + e.preventDefault(); + e.stopPropagation(); + this.classList.remove('dragover'); + this.style.borderColor = ''; + this.style.backgroundColor = ''; + if (e.dataTransfer.files.length) { + const file = e.dataTransfer.files[0]; + handleResumeFile(file); + } + }); + + // Handle file selection from the regular input + resumeUpload.addEventListener('change', function(e) { + if (this.files.length) { + handleResumeFile(this.files[0]); + } + }); + + function handleResumeFile(file) { + const validTypes = [ + 'application/pdf', + 'application/msword', + 'application/wps-office.docx', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'image/jpeg', + 'image/png', + 'text/plain' + ]; + if (!validTypes.includes(file.type)) { + alert('Please upload a valid file type (PDF, Word, Image, or Text)'); + return; + } + currentResumeFile = file; + // Hide placeholder + resumePlaceholder.style.display = 'none'; + // Set up download link + const fileURL = URL.createObjectURL(file); + downloadResume.href = fileURL; + downloadResume.download = file.name; + removeResumeBtn.style.display = 'block'; + // Check file type and show appropriate preview + if (file.type === 'application/pdf') { + // PDF preview + resumeIframe.src = fileURL; + resumeIframe.style.display = 'block'; + resumeImage.style.display = 'none'; + unsupportedFormat.style.display = 'none'; + } else if (file.type.match('image.*')) { + // Image preview + resumeImage.src = fileURL; + resumeImage.style.display = 'block'; + resumeIframe.style.display = 'none'; + unsupportedFormat.style.display = 'none'; + } else { + // Unsupported format for preview + unsupportedFormat.style.display = 'flex'; + resumeIframe.style.display = 'none'; + resumeImage.style.display = 'none'; + } + // Update the actual resume-upload input + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file); + resumeUpload.files = dataTransfer.files; + } + } + + if (addAttachmentBtn) { + addAttachmentBtn.addEventListener('click', () => { + const input = document.createElement('input'); + input.type = 'file'; + input.multiple = true; + input.accept = '.pdf,.doc,.docx,.jpg,.png'; + input.onchange = (e) => { + if (e.target.files.length) { + handleFiles(e.target.files, true); + } + }; + input.click(); + }); + } + + function createApplicationForm() { + const anniversaryField = document.getElementById('marital-anniversary-field'); + const maritalStatus = document.getElementById('application-marital'); + maritalStatus.addEventListener('change', () => { + if (maritalStatus.value !== 'married') { + document.getElementById('marital-anniversary-field').style.display = 'none'; + document.getElementById('application-anniversary').setAttribute('disabled', 'disabled'); + } else { + document.getElementById('marital-anniversary-field').style.display = 'block'; + document.getElementById('application-anniversary').removeAttribute('disabled'); + } + }); + } + + function initSelect2() { + // Check if Select2 is already initialized + const applicantSkills = document.getElementById('application-skills'); + if (applicantSkills) { + $(applicantSkills).select2({ + placeholder: 'Select skills', + allowClear: true, + dropdownParent: $('.application-creation-modal'), + width: '100%', + escapeMarkup: function(m) { return m; } + }); + } + + const applicantPosition = document.getElementById('application-position'); + if (applicantPosition) { + $(applicantPosition).select2({ + placeholder: 'Select Job', + allowClear: false, + dropdownParent: $('.application-creation-modal'), + width: '100%', + escapeMarkup: function(m) { return m; } + }); + } + + const applicantCandidate = document.getElementById('application-candidate'); + if (applicantCandidate) { + $(applicantCandidate).select2({ + placeholder: 'Select Candidate', + allowClear: true, + dropdownParent: $('.application-creation-modal'), + width: '100%', + templateResult: formatCandidate, + templateSelection: formatCandidateSelection, + escapeMarkup: function(m) { return m; } + }).on('change', function() { + const selectedOption = $(this).find('option:selected'); + if (selectedOption.val()) { + // Populate fields from candidate data + $('#application-email').val(selectedOption.data('email') || ''); + $('#application-phone').val(selectedOption.data('phone') || ''); + $('#application-alt-phone').val(selectedOption.data('altPhone') || ''); + $('#application-linkedin').val(selectedOption.data('linkedin') || ''); + // For skills + let skillIds = selectedOption.data('skillIds'); + // Convert to array if it's a string + if (typeof skillIds === 'string') { + try { + skillIds = JSON.parse(skillIds); + } catch (e) { + skillIds = []; + } + } + // Ensure skillIds is an array + skillIds = Array.isArray(skillIds) ? skillIds : []; + // Set the values in Select2 and trigger change + $('#application-skills').val(skillIds).trigger('change'); + } else { + $('#application-email').val(''); + $('#application-phone').val(''); + $('#application-alt-phone').val(''); + $('#application-linkedin').val(''); + // For skills + let skillIds = []; + + // Ensure skillIds is an array + skillIds = Array.isArray(skillIds) ? skillIds : []; + $('#application-skills').val(skillIds).trigger('change'); + } + }); + } + } + + function formatCandidate(candidate) { + if (!candidate.id) { + return candidate.text; + } + var imageUrl = $(candidate.element).data('image') || '/web/static/img/placeholder.png'; + var $candidate = $( + '' + + '' + + '' + candidate.text + '' + + '' + ); + return $candidate; + } + + function formatCandidateSelection(candidate) { + if (!candidate.id) { + return candidate.text; + } + var imageUrl = $(candidate.element).data('image') || '/web/static/img/placeholder.png'; + var $candidate = $( + '' + + '' + + '' + candidate.text + '' + + '' + ); + return $candidate; + } + + function handleFiles(files, isAdditional = false) { + Array.from(files).forEach(file => { + if (!file.type.match('application/pdf|application/msword|application/vnd.openxmlformats-officedocument.wordprocessingml.document|image.*')) { + alert('Only PDF, DOC, DOCX, JPG, and PNG files are allowed'); + return; + } + if (file.size > 10 * 1024 * 1024) { // 10MB limit + alert('File size must be less than 10MB'); + return; + } + if (isAdditional) { + addAttachmentToList(file); + } else { + updateResumeUpload(file); + } + }); + } + + function updateResumeUpload(file) { + // You can preview or process the resume file here + const dropzone = document.getElementById('resume-dropzone'); + dropzone.innerHTML = ` + +
${file.name}
+

${(file.size / 1024 / 1024).toFixed(2)} MB

+ + + `; + // Re-attach event listeners + document.querySelector('.remove-resume').addEventListener('click', () => { + resetResumeUpload(); + }); + } + + function resetResumeUpload() { + const dropzone = document.getElementById('resume-dropzone'); + dropzone.innerHTML = ` + +
Upload Resume
+

Drag & drop your resume here or click to browse

+ + `; + // Re-attach event listeners + setupFileUpload(); + } + + function addAttachmentToList(file) { + const attachmentItem = document.createElement('div'); + attachmentItem.className = 'attachment-item'; + attachmentItem.innerHTML = ` +
+
+ ${file.name} + ${(file.size / 1024 / 1024).toFixed(2)} MB +
+
+ +
+
+ `; + attachmentsList.appendChild(attachmentItem); + attachmentItem.querySelector('.remove-attachment').addEventListener('click', () => { + attachmentItem.remove(); + }); + } + + function setupFileUpload() { + // Re-initialize event listeners if needed + const resumeDropzone = document.getElementById('resume-dropzone'); + const resumeUpload = document.getElementById('resume-upload'); + if (resumeDropzone && resumeUpload) { + resumeDropzone.addEventListener('click', () => { + resumeUpload.click(); + }); + } + } + + const uploadBtn = document.getElementById('upload-resume') + if (uploadBtn) { + uploadBtn.addEventListener('click', function (e) { + e.preventDefault(); + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.accept = '.pdf,.doc,.docx,.txt'; + fileInput.onchange = async (e) => { + const file = e.target.files[0]; + if (!file) return; + // Show loading state + const button = document.getElementById('upload-resume'); + const originalText = button.innerHTML; + button.innerHTML = ' Processing Resume...'; + button.disabled = true; + try { + const formData = new FormData(); + formData.append('file', file); + formData.append('type', 'applicant'); + const response = await fetch('/resume/upload', { + method: 'POST', + body: formData, + credentials: 'same-origin' + }); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const result = await response.json(); + populateApplicationForm(result); + } catch (error) { + console.error('Error parsing Resume:', error); + showNotification('Failed to parse Resume. Please try again or enter manually.', 'danger'); + } finally { + button.innerHTML = originalText; + button.disabled = false; + } + }; + fileInput.click(); + }); + } + + function populateApplicationForm(resumeData) { + // Add this CSS class definition dynamically + const style = document.createElement('style'); + style.textContent = ` + .populated-field { + background-color: #f5f5f5 !important; + transition: background-color 0.3s ease; + } + `; + document.head.appendChild(style); + + // Helper function to set value with visual feedback + function setValueWithFeedback(element, value) { + if (element && value) { + element.value = value; + element.classList.add('populated-field'); + return true; + } + return false; + } + + // Helper function for select elements + function setSelectValueWithFeedback(selectElement, value, compareAsText = false) { + if (!selectElement || !value) return false; + for (let i = 0; i < selectElement.options.length; i++) { + const option = selectElement.options[i]; + const match = compareAsText + ? option.text.toLowerCase().includes(value.toLowerCase()) + : option.value.toLowerCase() === value.toLowerCase(); + if (match) { + selectElement.value = option.value; + selectElement.classList.add('populated-field'); + return true; + } + } + return false; + } + + // Section 1: Basic Information + if (resumeData.personal_info) { + const personal = resumeData.personal_info; + if (resumeData.candidate_id) { + let candidateId = resumeData.candidate_id; + $('#application-candidate').val(candidateId).trigger('change') + .addClass('populated-field'); + } else { + setValueWithFeedback(document.getElementById('application-fullname'), personal.name); + setValueWithFeedback(document.getElementById('application-email'), personal.email); + setValueWithFeedback(document.getElementById('application-phone'), personal.phone); + setValueWithFeedback(document.getElementById('application-linkedin'), personal.linkedin); + // Skills + if (resumeData.skills?.length) { + const skillValues = resumeData.skills.map(skill => skill.id); + $('#application-skills').val(skillValues).trigger('change') + .addClass('populated-field'); + } + } + setSelectValueWithFeedback( + document.getElementById('application-gender'), + personal.gender + ); + setValueWithFeedback(document.getElementById('application-dob'), personal.dob); + } + + // Professional Information + if (resumeData.professional_info) { + const prof = resumeData.professional_info; + setValueWithFeedback(document.getElementById('application-current-org'), prof.current_company); + setValueWithFeedback( + document.getElementById('application-current-location'), + prof.current_location?.city + ); + setValueWithFeedback(document.getElementById('application-notice-period'), prof.notice_period); + if (prof.notice_period) { + setSelectValueWithFeedback( + document.getElementById('application-notice-negotiable'), + 'yes' + ); + } + setSelectValueWithFeedback( + document.getElementById('application-holding-offer'), + prof.holding_offer ? 'yes' : 'no' + ); + setValueWithFeedback(document.getElementById('application-total-exp'), prof.total_experience); + } + + // Salary Information + setValueWithFeedback(document.getElementById('application-current-ctc'), resumeData.current_ctc); + setValueWithFeedback(document.getElementById('application-expected-salary'), resumeData.expected_salary); + + // Address Information + if (resumeData.contact_info?.current_address) { + const addr = resumeData.contact_info.current_address; + setValueWithFeedback(document.getElementById('application-current-street'), addr.street); + setValueWithFeedback(document.getElementById('application-current-city'), addr.city); + setSelectValueWithFeedback( + document.getElementById('application-current-state'), + addr.state, + true // Compare as text + ); + setSelectValueWithFeedback( + document.getElementById('application-current-country'), + addr.country, + true // Compare as text + ); + setValueWithFeedback(document.getElementById('application-current-zip'), addr.zip); + } + + // Show notification + showNotification('Resume uploaded and fields populated successfully!', 'success'); + } + + // Helper function to show notifications + function showNotification(message, type = 'info') { + // Check if notification container exists, create if not + let notificationContainer = document.getElementById('notification-container'); + if (!notificationContainer) { + notificationContainer = document.createElement('div'); + notificationContainer.id = 'notification-container'; + notificationContainer.style.position = 'fixed'; + notificationContainer.style.top = '20px'; + notificationContainer.style.right = '20px'; + notificationContainer.style.zIndex = '9999'; + document.body.appendChild(notificationContainer); + } + + // Create notification element + const notification = document.createElement('div'); + notification.className = `alert alert-${type} alert-dismissible fade show`; + notification.innerHTML = ` + ${message} + + `; + + // Add to container + notificationContainer.appendChild(notification); + + // Auto remove after 5 seconds + setTimeout(() => { + notification.classList.remove('show'); + setTimeout(() => { + notification.remove(); + }, 300); + }, 5000); + } +} + +function scrollToTarget(targetElement, offset = 100) { + const targetPosition = targetElement.getBoundingClientRect().top + window.pageYOffset - offset; + // First try scrolling the container + const container = document.getElementById('ats-details-container'); + if (container && container.scrollHeight > container.clientHeight) { + const containerTop = container.getBoundingClientRect().top; + const scrollPosition = targetPosition - containerTop - offset; + container.scrollTo({ + top: scrollPosition, + behavior: 'smooth' + }); + } else { + // Fallback to window scrolling + window.scrollTo({ + top: targetPosition, + behavior: 'smooth' + }); + } +} + +function initApplicantDetailEdit() { + // Recruiter photo toggle + const recruiterTrigger = document.getElementById('recruiter-photo-trigger'); + const recruiterInfo = document.getElementById('recruiter-info'); + if (recruiterTrigger && recruiterInfo) { + recruiterTrigger.addEventListener('click', function() { + recruiterInfo.classList.toggle('show'); + }); + } + + // Improved smooth scroll navigation + document.querySelectorAll('.nav-link').forEach(link => { + link.addEventListener('click', function(e) { + e.preventDefault(); + const targetId = this.getAttribute('href'); + const targetElement = document.querySelector(targetId); + if (targetElement) { + scrollToTarget(targetElement); + // Highlight effect + targetElement.style.boxShadow = '0 0 0 3px rgba(13, 110, 253, 0.5)'; + targetElement.style.transition = 'box-shadow 0.3s ease'; + setTimeout(() => { + targetElement.style.boxShadow = 'none'; + }, 2000); + } + }); + }); +} + +// Initialize the page when DOM is ready +document.addEventListener('DOMContentLoaded', initApplicantsPage); \ No newline at end of file diff --git a/addons_extensions/hr_recruitment_web_app/static/src/js/ats.js b/addons_extensions/hr_recruitment_web_app/static/src/js/ats.js new file mode 100644 index 000000000..caf3dd0bc --- /dev/null +++ b/addons_extensions/hr_recruitment_web_app/static/src/js/ats.js @@ -0,0 +1,46 @@ +/** @odoo-module */ + +document.addEventListener("DOMContentLoaded", function () { + console.log("ATS JS Loaded"); + const contentArea = document.getElementById("main-content"); + const jobDetailArea = document.getElementById("job-detail"); + + const toggleBtn = document.getElementById("sidebar-toggle-btn"); + const sidebar = document.getElementById("sidebar"); + + // ✅ Sidebar Toggle + if (toggleBtn && sidebar) { + toggleBtn.addEventListener("click", function () { + sidebar.classList.toggle("collapsed"); + sidebar.classList.toggle("expanded"); + }); + } + + document.querySelectorAll('.menu-list a[data-page]').forEach(link => { + link.addEventListener('click', async function (e) { + e.preventDefault(); + const page = this.dataset.page; + + try { + const res = await fetch(`/myATS/page/${page}`, { + headers: { "X-Requested-With": "XMLHttpRequest" } + }); + const html = await res.text(); + contentArea.innerHTML = html; + if (jobDetailArea) jobDetailArea.innerHTML = ""; + + // 🔽 Dynamically import JS module for the page + if (page === "job_requests") { + initJobListPage(); + } else if (page === "applicants") { + initApplicantsPage(); + } else if (page === "candidates") { + initCandidatesPage(); + } + + } catch (err) { + contentArea.innerHTML = `

Error loading page: ${err}

`; + } + }); + }); +}); diff --git a/addons_extensions/hr_recruitment_web_app/static/src/js/candidates.js b/addons_extensions/hr_recruitment_web_app/static/src/js/candidates.js new file mode 100644 index 000000000..51b89010f --- /dev/null +++ b/addons_extensions/hr_recruitment_web_app/static/src/js/candidates.js @@ -0,0 +1,608 @@ +/** @odoo-module **/ +function initCandidatesPage() { + console.log("candidates Page Loaded"); + const candidateDetailArea = document.getElementById("candidates-detail"); + const container = document.querySelector('.ats-list-container'); // Added this line + const toggleBtn = document.getElementById("candidates-list-sidebar-toggle-btn"); // Added this line + const sidebar = document.getElementById("candidates-list-panel"); // Added this line + + document.querySelectorAll(".ats-item.candidates-item").forEach(item => { + item.addEventListener("click", function() { + document.querySelectorAll(".candidates-item.selected").forEach(el => el.classList.remove("selected")); + this.classList.add("selected"); + + // Show the detail panel and add necessary classes + candidateDetailArea.style.display = 'block'; // Added this line + container.classList.add('ats-selected'); // Added this line + sidebar.classList.remove('collapsed'); // Added this line + toggleBtn.style.display = 'flex'; // Added this line + + const candidateId = this.dataset.id; + console.log("Candidate ID:", candidateId); // Added for debugging + + // Show loading state + if (candidateDetailArea) { + candidateDetailArea.innerHTML = '

Loading candidate details...

'; + } + + fetch(`/myATS/candidate/detail/${candidateId}`, { + headers: { "X-Requested-With": "XMLHttpRequest" } + }) + .then(res => { + if (!res.ok) throw new Error('Network response was not ok'); + return res.text(); + }) + .then(html => { + console.log("Response received"); // Added for debugging + if (candidateDetailArea) { + candidateDetailArea.innerHTML = html; + + // Add close button functionality + const closeBtn = candidateDetailArea.querySelector('.close-detail'); + if (closeBtn) { + closeBtn.addEventListener('click', function() { + candidateDetailArea.style.display = 'none'; + container.classList.remove('ats-selected'); + document.querySelectorAll(".ats-item.candidates-item.selected").forEach(el => el.classList.remove("selected")); + }); + } + } + }) + .catch(error => { + console.error('Error loading candidate details:', error); + if (candidateDetailArea) { + candidateDetailArea.innerHTML = '
Error loading candidate details. Please try again.
'; + } + }); + }); + }); + + // Search functionality + const search = document.getElementById("candidates-search"); + if (search) { + search.addEventListener("input", function() { + const query = this.value.toLowerCase(); + let visibleCount = 0; + document.querySelectorAll(".ats-item.candidates-item").forEach(item => { + const match = item.textContent.toLowerCase().includes(query); + item.style.display = match ? "" : "none"; + if (match) visibleCount++; + }); + const countElement = document.getElementById("active-records-count"); + if (countElement) { + countElement.textContent = visibleCount; + } + }); + } + + // Sidebar Toggle + if (toggleBtn && sidebar) { // Added this check + toggleBtn.addEventListener("click", function(e) { + e.stopPropagation(); + sidebar.classList.toggle("collapsed"); + }); + } + + // Rest of your code remains the same... + const createCandidate = document.getElementById('add-candidate-create-btn'); + const candidateModal = document.getElementById('candidate-form-modal'); + const form = document.getElementById('candidate-form'); + const closeModal = document.querySelectorAll('.candidate-form-close, .btn-cancel'); + const avatarUpload = document.getElementById('avatar-upload'); + const candidateImage = document.getElementById('candidate-image'); + const avatarUploadIcon = document.querySelector('.avatar-upload i'); + + if (avatarUpload && candidateImage) { + // Make the entire avatar clickable + candidateImage.parentElement.style.cursor = 'pointer'; + // Handle click on avatar + candidateImage.parentElement.addEventListener('click', function(e) { + if (e.target !== avatarUpload && e.target !== avatarUploadIcon) { + avatarUpload.click(); + } + }); + // Handle file selection + avatarUpload.addEventListener('change', function(e) { + const file = e.target.files[0]; + if (file && file.type.match('image.*')) { + const reader = new FileReader(); + reader.onload = function(e) { + candidateImage.src = e.target.result; + }; + reader.readAsDataURL(file); + } + }); + } + + // Resume Upload Elements + const resumeUpload = document.getElementById('resume-upload'); + const resumeDropzone = document.getElementById('resume-dropzone'); + const resumePreview = document.getElementById('resume-preview'); + const resumePlaceholder = resumePreview.querySelector('.resume-preview-placeholder'); + const resumeIframe = document.getElementById('resume-iframe'); + const resumeImage = document.getElementById('resume-image'); + const unsupportedFormat = document.getElementById('unsupported-format'); + const downloadResume = document.getElementById('download-resume'); + const uploadResumeBtn = document.getElementById('upload-applicant-resume'); + let currentResumeFile = null; + + const saveBtn = document.getElementById('save-candidate'); + if (saveBtn) { + saveBtn.addEventListener('click', function (e) { + e.preventDefault(); + createNewCandidate(form, candidateModal); + }); + } + + if (createCandidate) { + createCandidate.addEventListener('click', function(e) { + e.preventDefault(); + candidateModal.style.display = 'flex'; + setTimeout(() => { + candidateModal.classList.add('show'); + }, 10); + document.body.style.overflow = 'hidden'; + setTimeout(() => { + initSelect2(); + initResumeUploadHandlers(); + }, 100); + }); + } + + if (closeModal) { + closeModal.forEach(btn => { + btn.addEventListener('click', function() { + candidateModal.classList.remove('show'); + setTimeout(() => { + candidateModal.style.display = 'none'; + }, 300); + document.body.style.overflow = ''; + }); + }); + } + + candidateModal.addEventListener('click', function(e) { + if (e.target === candidateModal) { + candidateModal.classList.remove('show'); + setTimeout(() => { + candidateModal.style.display = 'none'; + }, 300); + document.body.style.overflow = ''; + } + }); + + function formatUserOption(user) { + if (!user.id) return user.text; + var imageUrl = $(user.element).data('image') || '/web/static/img/placeholder.png'; + var $container = $( + '' + + '' + + '' + user.text + '' + + '' + ); + return $container; + } + + function formatUserSelection(user) { + if (!user.id) return user.text; + var imageUrl = $(user.element).data('image') || '/web/static/img/placeholder.png'; + var $container = $( + '' + + '' + + '' + user.text + '' + + '' + ); + return $container; + } + + function initSelect2() { + const candidateSkills = document.getElementById('candidate-skills'); + if (candidateSkills) { + $(candidateSkills).select2({ + placeholder: 'Select skills', + allowClear: true, + dropdownParent: $('.candidate-form-modal'), + width: '100%', + escapeMarkup: function(m) { return m; } + }); + } + + const managerSelect = document.getElementById('manager'); + if (managerSelect) { + $(managerSelect).select2({ + placeholder: 'Select Manager', + allowClear: true, + templateResult: formatUserOption, + templateSelection: formatUserSelection, + escapeMarkup: function(m) { return m; } + }); + } + } + + function initResumeUploadHandlers() { + // Create remove button + const removeResumeBtn = document.createElement('button'); + removeResumeBtn.innerHTML = ' Remove Resume'; + removeResumeBtn.className = 'btn btn-danger btn-sm mt-2'; + removeResumeBtn.style.display = 'none'; + resumePreview.appendChild(removeResumeBtn); + + // Handle remove resume + removeResumeBtn.addEventListener('click', function() { + resetResumePreview(); + }); + + function resetResumePreview() { + // Clear file input + resumeUpload.value = ''; + currentResumeFile = null; + // Reset preview + resumePlaceholder.style.display = 'flex'; + resumeIframe.style.display = 'none'; + resumeImage.style.display = 'none'; + unsupportedFormat.style.display = 'none'; + removeResumeBtn.style.display = 'none'; + // Reset iframe/src to prevent memory leaks + if (resumeIframe.src) { + URL.revokeObjectURL(resumeIframe.src); + resumeIframe.src = ''; + } + if (resumeImage.src) { + URL.revokeObjectURL(resumeImage.src); + resumeImage.src = ''; + } + if (downloadResume.href) { + URL.revokeObjectURL(downloadResume.href); + downloadResume.href = '#'; + } + } + + // Unified upload handler for both preview and parsing + uploadResumeBtn.addEventListener('click', function(e) { + e.preventDefault(); + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.accept = '.pdf,.doc,.docx,.txt,.jpg,.jpeg,.png'; + fileInput.onchange = async (e) => { + const file = e.target.files[0]; + if (!file) return; + + // First handle the preview + handleResumeFile(file); + + // Then try to parse the resume if it's a parseable type + if (file.type.match(/pdf|msword|openxmlformats|text/)) { + // Show loading state + const button = uploadResumeBtn; + const originalText = button.innerHTML; + button.innerHTML = ' Processing Resume...'; + button.disabled = true; + + try { + const formData = new FormData(); + formData.append('file', file); + formData.append('type', 'candidate'); + + const response = await fetch('/resume/upload', { + method: 'POST', + body: formData, + credentials: 'same-origin' + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + populateCandidateForm(result); + } catch (error) { + console.error('Error parsing Resume:', error); + showNotification('Failed to parse Resume. Please try again or enter manually.', 'danger'); + } finally { + button.innerHTML = ' Upload Resume'; + button.disabled = false; + } + } + }; + fileInput.click(); + }); + + // Handle click on dropzone + resumeDropzone.addEventListener('click', function(e) { + if (e.target === this || e.target.classList.contains('upload-icon') || + e.target.tagName === 'H5' || e.target.tagName === 'P') { + resumeUpload.click(); + } + }); + + // Handle drag and drop + resumeDropzone.addEventListener('dragover', function(e) { + e.preventDefault(); + e.stopPropagation(); + this.classList.add('dragover'); + this.style.borderColor = '#3498db'; + this.style.backgroundColor = 'rgba(52, 152, 219, 0.1)'; + }); + + resumeDropzone.addEventListener('dragleave', function(e) { + e.preventDefault(); + e.stopPropagation(); + this.classList.remove('dragover'); + this.style.borderColor = ''; + this.style.backgroundColor = ''; + }); + + resumeDropzone.addEventListener('drop', function(e) { + e.preventDefault(); + e.stopPropagation(); + this.classList.remove('dragover'); + this.style.borderColor = ''; + this.style.backgroundColor = ''; + + if (e.dataTransfer.files.length) { + const file = e.dataTransfer.files[0]; + handleResumeFile(file); + } + }); + + // Handle file selection from the regular input + resumeUpload.addEventListener('change', function(e) { + if (this.files.length) { + handleResumeFile(this.files[0]); + } + }); + + function handleResumeFile(file) { + const validTypes = [ + 'application/pdf', + 'application/msword', + 'application/wps-office.docx', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'image/jpeg', + 'image/png', + 'text/plain' + ]; + + if (!validTypes.includes(file.type)) { + alert('Please upload a valid file type (PDF, Word, Image, or Text)'); + return; + } + + currentResumeFile = file; + + // Hide placeholder + resumePlaceholder.style.display = 'none'; + + // Set up download link + const fileURL = URL.createObjectURL(file); + downloadResume.href = fileURL; + downloadResume.download = file.name; + removeResumeBtn.style.display = 'block'; + + // Check file type and show appropriate preview + if (file.type === 'application/pdf') { + // PDF preview + resumeIframe.src = fileURL; + resumeIframe.style.display = 'block'; + resumeImage.style.display = 'none'; + unsupportedFormat.style.display = 'none'; + } else if (file.type.match('image.*')) { + // Image preview + resumeImage.src = fileURL; + resumeImage.style.display = 'block'; + resumeIframe.style.display = 'none'; + unsupportedFormat.style.display = 'none'; + } else { + // Unsupported format for preview + unsupportedFormat.style.display = 'flex'; + resumeIframe.style.display = 'none'; + resumeImage.style.display = 'none'; + } + + // Update the actual resume-upload input + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file); + resumeUpload.files = dataTransfer.files; + } + } + + function populateCandidateForm(resumeData) { + // Add CSS for visual feedback + const style = document.createElement('style'); + style.textContent = ` + .populated-field { + background-color: #f5f5f5 !important; + transition: background-color 0.3s ease; + } + .select2-populated .select2-selection--multiple { + background-color: #f5f5f5 !important; + } + .select2-populated .select2-selection__choice { + background-color: #e8f5e9 !important; + border-color: #c8e6c9 !important; + } + `; + document.head.appendChild(style); + + // Helper function to set value with visual feedback + function setValueWithFeedback(elementId, value) { + const element = document.getElementById(elementId); + if (element && value) { + element.value = value; + element.classList.add('populated-field'); + + // Special handling for Select2 if this element uses it + if ($(element).hasClass('select2-hidden-accessible')) { + $(element).next('.select2-container') + .find('.select2-selection') + .addClass('populated-field'); + } + return true; + } + return false; + } + + // Section 1: Basic Information + if (resumeData.personal_info) { + const personal = resumeData.personal_info; + + // Set values with visual feedback + setValueWithFeedback('partner-name', personal.name); + setValueWithFeedback('email', personal.email); + setValueWithFeedback('phone', personal.phone); + setValueWithFeedback('linkedin', personal.linkedin); + + // If any of these fields are Select2 elements, ensure they get styled + ['partner-name', 'email', 'phone', 'linkedin'].forEach(id => { + const el = document.getElementById(id); + if (el && el.value && $(el).hasClass('select2-hidden-accessible')) { + $(el).next('.select2-container') + .find('.select2-selection') + .addClass('populated-field'); + } + }); + } + + // Skills - Handle Select2 with background color change + if (resumeData.skills?.length) { + const skillValues = resumeData.skills.map(skill => skill.id); + $('#candidate-skills') + .val(skillValues) + .trigger('change') + .addClass('populated-field'); + + // Add class to Select2 container for styling + $('#candidate-skills').next('.select2-container') + .find('.select2-selection--multiple') + .addClass('select2-populated'); + } + + // Show notification + showNotification('Resume uploaded and fields populated successfully!', 'success'); + } + + function showNotification(message, type) { + // Check if notification container exists, create if not + let notificationContainer = document.getElementById('notification-container'); + if (!notificationContainer) { + notificationContainer = document.createElement('div'); + notificationContainer.id = 'notification-container'; + notificationContainer.style.position = 'fixed'; + notificationContainer.style.top = '20px'; + notificationContainer.style.right = '20px'; + notificationContainer.style.zIndex = '9999'; + document.body.appendChild(notificationContainer); + } + + // Create notification element + const notification = document.createElement('div'); + notification.className = `alert alert-${type} alert-dismissible fade show`; + notification.innerHTML = ` + ${message} + + `; + + // Add to container + notificationContainer.appendChild(notification); + + // Auto remove after 5 seconds + setTimeout(() => { + notification.classList.remove('show'); + setTimeout(() => { + notification.remove(); + }, 300); + }, 5000); + } +} + +function createNewCandidate(form, modal) { + if (!form.checkValidity()) { + form.reportValidity(); + return; + } + + const formData = new FormData(); + + // Basic Information + formData.append('sequence', document.getElementById('candidate-sequence').value); + formData.append('partner_name', document.getElementById('partner-name').value); + + // Add image file if selected + const avatarUpload = document.getElementById('avatar-upload'); + if (avatarUpload.files.length > 0) { + formData.append('image_1920', avatarUpload.files[0]); + } + + // Contact Information + formData.append('email', document.getElementById('email').value); + formData.append('phone', document.getElementById('phone').value); + formData.append('mobile', document.getElementById('alternate-phone').value); + formData.append('linkedin_profile', document.getElementById('linkedin').value); + formData.append('type_id', document.getElementById('type').value); + formData.append('user_id', document.getElementById('manager').value); + formData.append('availability', document.getElementById('availability').value); + + // Skills + const skillOptions = document.getElementById('candidate-skills').selectedOptions; + for (let i = 0; i < skillOptions.length; i++) { + formData.append('skill_ids', skillOptions[i].value); + } + + // Add resume file if selected + const resumeUpload = document.getElementById('resume-upload'); + if (resumeUpload.files.length > 0) { + formData.append('resume_file', resumeUpload.files[0]); + } + + // Additional fields + formData.append('active', 'true'); + formData.append('color', '0'); + + fetch('/myATS/candidate/create', { + method: 'POST', + body: formData, + headers: { + 'X-Requested-With': 'XMLHttpRequest', + } + }) + .then(async (response) => { + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || response.statusText); + } + return response.json(); + }) + .then(data => { + if (data.success) { + modal.classList.remove('show'); + document.body.style.overflow = ''; + form.reset(); + alert('Candidate created successfully!'); + + // Refresh the candidates list + fetch('/myATS/page/candidates', { + headers: { "X-Requested-With": "XMLHttpRequest" } + }) + .then(res => res.text()) + .then(html => { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const newCandidate = doc.querySelector('.candidates-list-left'); + const existingCandidatesList = document.querySelector('.candidates-list-left'); + if (newCandidate && existingCandidatesList) { + existingCandidatesList.innerHTML = newCandidate.innerHTML; + initCandidatesPage(); + } + }); + } else { + throw new Error(data.error || 'Failed to save Candidate'); + } + }) + .catch(error => { + console.error('Error:', error); + alert("Error saving changes: " + error.message); + }); +} + +// Initialize the page when DOM is ready +document.addEventListener('DOMContentLoaded', initCandidatesPage); \ No newline at end of file diff --git a/addons_extensions/hr_recruitment_web_app/static/src/js/job_requests.js b/addons_extensions/hr_recruitment_web_app/static/src/js/job_requests.js new file mode 100644 index 000000000..0be23f0a6 --- /dev/null +++ b/addons_extensions/hr_recruitment_web_app/static/src/js/job_requests.js @@ -0,0 +1,1631 @@ +/** @odoo-module */ + +// Define all your page initialization functions first +function initJobListPage() { + console.log("Job List Page JS Loaded"); + const jobDetailArea = document.getElementById("job-detail"); + const headerBadges = document.querySelectorAll(".stat-toggle"); + const container = document.querySelector('.ats-list-container'); + const sidebar = document.getElementById("job-list-panel"); + const toggleBtn = document.getElementById("job-list-sidebar-toggle-btn"); + + // Initially hide the job detail panel + jobDetailArea.style.display = 'none'; + + // Badge click handler (unchanged) + headerBadges.forEach(badge => { + badge.addEventListener("click", function() { + this.classList.toggle("active"); + this.classList.toggle("crossed"); + const type = this.dataset.type; + document.querySelectorAll(`.job-badge[data-type="${type}"]`).forEach(jobBadge => { + jobBadge.style.display = this.classList.contains("active") ? "inline-block" : "none"; + }); + const activeJob = document.querySelector(".job-item.selected"); + if (!this.classList.contains("active")) { + // Handle inactive state + } else if (activeJob) { + // Handle active state + } + }); + }); + + // Job item click logic + document.querySelectorAll(".job-item").forEach(item => { + item.addEventListener("click", function() { + document.querySelectorAll(".job-item.selected").forEach(el => el.classList.remove("selected")); + this.classList.add("selected"); + + // Show job detail panel and adjust layout + jobDetailArea.style.display = 'block'; + container.classList.add('ats-selected'); + sidebar.classList.remove('collapsed'); + toggleBtn.style.display = 'flex'; + + const jobId = this.dataset.id; + fetch(`/myATS/job/detail/${jobId}`, { + headers: { "X-Requested-With": "XMLHttpRequest" } + }) + .then(res => res.text()) + .then(html => { + if (jobDetailArea) { + jobDetailArea.innerHTML = html; + initJobDetailEdit(); // Initialize edit functionality + + // Add close button functionality + const closeBtn = jobDetailArea.querySelector('.close-detail'); + if (closeBtn) { + closeBtn.addEventListener('click', function() { + jobDetailArea.style.display = 'none'; + container.classList.remove('ats-selected'); + document.querySelectorAll(".job-item.selected").forEach(el => el.classList.remove("selected")); + }); + } + } + }); + }); + }); + + // Search functionality (unchanged) + const search = document.getElementById("ats-search"); + if (search) { + search.addEventListener("input", function () { + const query = this.value.toLowerCase(); + let visibleCount = 0; + document.querySelectorAll(".ats-item").forEach(item => { + const match = item.textContent.toLowerCase().includes(query); + item.style.display = match ? "" : "none"; + if (match) visibleCount++; + }); + const countElement = document.getElementById("active-records-count"); + if (countElement) { + countElement.textContent = visibleCount; + } + }); + } + + // Sidebar Toggle + if (toggleBtn && sidebar) { + toggleBtn.addEventListener("click", function () { + sidebar.classList.toggle("collapsed"); + // Change the button icon based on state + if (sidebar.classList.contains("collapsed")) { + this.textContent = "☰"; + this.style.right = "-12px"; + this.style.transform = "translateY(-50%)"; + } else { + this.textContent = "☰"; + this.style.right = "-12px"; + } + }); + } + + initJDModal(); +} + +let editor = null; + +function initJobDetailEdit() { + const editBtn = document.getElementById('edit-job-btn'); + const cancelBtn = document.getElementById('cancel-edit'); + const editForm = document.getElementById('job-edit-form'); + const viewSection = document.getElementById('job-detail-view'); + const editSection = document.getElementById('job-detail-edit'); + + + if (editBtn) { + editBtn.addEventListener('click', function() { + viewSection.style.display = 'none'; + editSection.style.display = 'block'; + + // Initialize CKEditor + if (typeof CKEDITOR !== 'undefined') { + if (editor) { + editor.destroy(); + } + editor = CKEDITOR.replace('job-description-edit', { + toolbar: [ + { name: 'basicstyles', items: ['Bold', 'Italic', 'Underline', 'Strike', '-', 'RemoveFormat'] }, + { name: 'paragraph', items: ['NumberedList', 'BulletedList', '-', 'Outdent', 'Indent'] }, + { name: 'links', items: ['Link', 'Unlink'] }, + { name: 'document', items: ['Source'] } + ], + height: 300 + }); + } + }); + } + + if (cancelBtn) { + cancelBtn.addEventListener('click', function () { + const jobId = document.getElementById('job-edit-form').dataset.id; + + fetch(`/myATS/job/detail/${jobId}`, { + headers: { "X-Requested-With": "XMLHttpRequest" } + }) + .then(res => res.text()) + .then(html => { + const jobDetailArea = document.getElementById('job-detail'); + if (jobDetailArea) { + jobDetailArea.innerHTML = html; + initJobDetailEdit(); // re-init the edit handlers + } + }); + + if (editor) { + editor.destroy(); + editor = null; + } + }); + } + + + if (editForm) { + editForm.addEventListener('submit', function(e) { + e.preventDefault(); + saveJobDetails(this); + }); + } + + initSmartButtons(); +} + +function saveJobDetails(form) { + // Get CKEditor data properly + let description = ''; + if (editor && CKEDITOR.instances['job-description-edit']) { + description = CKEDITOR.instances['job-description-edit'].getData(); + } else { + description = document.getElementById('job-description-edit')?.innerHTML || ''; + } + + const jobId = form.dataset.id; + const formData = { + 'id': jobId, + 'job_id': form.querySelector('#job-id-edit').value, + 'job_sequence': form.querySelector('#job-sequence-edit').value, + 'category': form.querySelector('#job-category-edit').value, + 'description': description, + }; + + fetch('/myATS/job/save', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-Requested-With': 'XMLHttpRequest' + }, + body: JSON.stringify(formData) + }) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.json(); + }) + .then(data => { + if (data.success) { + // Update view with new data + document.getElementById('job-name-view').textContent = formData.name; + document.getElementById('job-description-view').innerHTML = formData.description; + + const selectedOption = document.querySelector('#job-category-edit option:checked'); + if (selectedOption) { + document.getElementById('job-category-view').textContent = selectedOption.textContent; + } + + document.getElementById('job-detail-view').style.display = 'block'; + document.getElementById('job-detail-edit').style.display = 'none'; + + if (editor) { + editor.destroy(); + editor = null; + } + + alert("Changes saved successfully"); + fetch('/myATS/page/job_requests', { + headers: { "X-Requested-With": "XMLHttpRequest" } + }) + .then(res => res.text()) + .then(html => { + // Replace only the left panel to avoid wiping out the whole page + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const newJobList = doc.querySelector('.job-list-left'); + const existingJobList = document.querySelector('.job-list-left'); + + if (newJobList && existingJobList) { + existingJobList.innerHTML = newJobList.innerHTML; + initJobListPage(); // re-bind event listeners + } + }); + } else { + throw new Error(data.error || 'Unknown error occurred'); + } + }) + .catch(error => { + console.error('Error:', error); + alert("Error saving changes: " + error.message); + }); +} + +let wysiwyg = null; + + +function createJobDetails(form, modal) { + + if (!form.checkValidity()) { + form.reportValidity(); + return; + } + + // Get editor content + let description = ''; + if (wysiwyg) { + description = wysiwyg.getData(); + } else { + description = null +// description = document.getElementById('job-description').value || ''; + } + + // Collect form data + const formData = { + // Basic Information + sequence: document.getElementById('job-sequence').value, + position_id: document.getElementById('job-position').value, + category: document.getElementById('job-category').value, + priority: document.getElementById('job-priority').value, + work_type: document.querySelector('input[name="work-type"]:checked').value, + client_company_id: document.getElementById('client-company').value, + client_id: document.getElementById('client-id').value, + + // Employment Details + employment_type_id: document.getElementById('employment-type').value, + budget: document.getElementById('job-budget').value, + no_of_positions: document.getElementById('job-no-of-positions').value, + eligible_submissions: document.getElementById('job-eligible-submissions').value, + target_from: document.getElementById('target-from').value, + target_to: document.getElementById('target-to').value, + + // Skills & Requirements + primary_skill_ids: Array.from(document.getElementById('job-primary-skills').selectedOptions) + .map(option => option.value), + secondary_skill_ids: Array.from(document.getElementById('job-secondary-skills').selectedOptions) + .map(option => option.value), + experience_id: document.getElementById('job-experience').value, + + // Recruitment Team + primary_recruiter_id: document.getElementById('primary-recruiter').value, + secondary_recruiter_ids: Array.from(document.getElementById('secondary-recruiter').selectedOptions) + .map(option => option.value), + + // Additional Information + location_ids: Array.from(document.getElementById('job-locations').selectedOptions) + .map(option => option.value), + stage_ids: Array.from(document.getElementById('recruitment-stages').selectedOptions) + .map(option => option.value), + + // Description + description: description, + + // Attachments (if any) + attachment_ids: [] + }; + + fetch('/myATS/job/create', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'Accept': 'application/json' + }, + body: JSON.stringify(formData) + }).then(async (response) => { + const contentType = response.headers.get("content-type"); + const responseData = contentType && contentType.includes("application/json") + ? await response.json() + : await response.text(); + + if (!response.ok) { + // For 500 errors, check if we have a JSON response with error details + if (response.status === 500 && typeof responseData === 'object') { + throw new Error(responseData.error || responseData.message || "Internal server error"); + } else if (typeof responseData === 'string') { + throw new Error(responseData); + } else { + throw new Error(response.statusText); + } + } + + return responseData; + }) + .then(data => { + if (data.success) { + modal.classList.remove('show'); + document.body.style.overflow = ''; + form.reset(); + + if (wysiwyg) { + wysiwyg.destroy() + .then(() => { + wysiwyg = null; + }) + .catch(error => { + console.error('Error destroying editor:', error); + }); + } + + alert('Job created successfully!'); + fetch('/myATS/page/job_requests', { + headers: { "X-Requested-With": "XMLHttpRequest" } + }) + .then(res => res.text()) + .then(html => { + // Replace only the left panel to avoid wiping out the whole page + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const newJobList = doc.querySelector('.job-list-left'); + const existingJobList = document.querySelector('.job-list-left'); + + if (newJobList && existingJobList) { + existingJobList.innerHTML = newJobList.innerHTML; + initJobListPage(); // re-bind event listeners + } + }); + } else { + throw new Error(data.error || 'Failed to save job'); + } + }) + .catch(error => { + console.error('Error:', error); + alert("Error saving changes: " + error.message); + }); +} + + +// Add this function to your existing JS +async function initJDModal() { + console.log('Initializing JD modal'); + const modal = document.getElementById('add-jd-modal'); + const addJdBtn = document.getElementById('add-jd-create-btn'); + const closeBtns = document.querySelectorAll('.jd-modal-close, .btn-cancel'); + const saveBtn = document.getElementById('save-jd'); + const form = document.getElementById('jd-form'); + const uploadBtn = document.getElementById('upload-jd') + + if (!modal) return; + + if (addJdBtn) { + addJdBtn.addEventListener('click', async function(e) { + e.preventDefault(); + modal.classList.add('show'); + document.body.style.overflow = 'hidden'; + + // Initialize WYSIWYG editor + const editorElement = document.getElementById('job-description-editor'); + +// editorElement.innerHTML = document.getElementById('job-description').value || ''; + + // Replace your current CKEditor initialization with this: + // Replace your CKEditor initialization with this: + if (editorElement && !wysiwyg) { + try { + await loadAssets([ + '/hr_recruitment_web_app/static/src/libs/ckeditor/build/ckeditor.js' + ]); + + ClassicEditor + .create(editorElement, { + toolbar: { + items: [ + 'heading', '|', + 'bold', 'italic', 'link', 'bulletedList', 'numberedList', '|', + 'blockQuote', 'uploadImage', '|', + 'undo', 'redo' + ] + }, + image: { + toolbar: [ + 'imageTextAlternative', + 'toggleImageCaption', + 'imageStyle:inline', + 'imageStyle:block', + 'imageStyle:side' + ], + upload: { + types: ['jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff'] + } + }, + simpleUpload: { + uploadUrl: '/web_editor/attachment/add', + withCredentials: true, + headers: { + 'X-CSRF-TOKEN': getCookie('csrftoken') || '', + 'X-Requested-With': 'XMLHttpRequest' + }, + additionalRequestData: { + 'res_model': 'hr.job', + 'res_id': 0, + 'callback': 'window.top' + } + }, + typing: { + undo: true + }, + keystrokes: [ + [ 32, 'insertText', ' ' ] // Handle spacebar + ] + }) + .then(editor => { + wysiwyg = editor; + const textarea = document.getElementById('job-description'); + + editor.model.document.on('change:data', () => { + document.getElementById('job-description').value = editor.getData(); + }); + + if (textarea) { + textarea.addEventListener('input', () => { + if (wysiwyg) { + wysiwyg.setData(textarea.value); + } + }); + } + }) + .catch(error => { + console.error('Error initializing CKEditor:', error); + }); + } catch (error) { + console.error('Error loading CKEditor:', error); + } + } + + setTimeout(initSelect2, 100); + }); + } + + // Close modal handlers + closeBtns.forEach(btn => { + btn.addEventListener('click', function(e) { + e.preventDefault(); + modal.classList.remove('show'); + document.body.style.overflow = ''; + form.reset(); + + if (wysiwyg) { + wysiwyg.destroy() + .then(() => { + wysiwyg = null; + }) + .catch(error => { + console.error('Error destroying editor:', error); + }); + } + }); + }); + + modal.addEventListener('click', function(e) { + if (e.target === modal) { + modal.classList.remove('show'); + document.body.style.overflow = ''; + form.reset(); + if (wysiwyg) { + wysiwyg.destroy() + .then(() => { + wysiwyg = null; + }) + .catch(error => { + console.error('Error destroying editor:', error); + }); + } + } + }); + + // Save JD + if (saveBtn) { + saveBtn.addEventListener('click', function (e) { + e.preventDefault(); + createJobDetails(form, modal); + }); + } + + if (uploadBtn) { + uploadBtn.addEventListener('click', function (e) { + e.preventDefault(); + // Create file input element + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.accept = '.pdf,.doc,.docx,.txt'; + + fileInput.onchange = async (e) => { + const file = e.target.files[0]; + if (!file) return; + + // Show loading state + const button = document.getElementById('upload-jd'); + const originalText = button.innerHTML; + button.innerHTML = ' Processing JD...'; + button.disabled = true; + + try { + const formData = new FormData(); + formData.append('file', file); + + // Call your Odoo endpoint + const response = await fetch('/jd/upload', { + method: 'POST', + body: formData, + credentials: 'same-origin' + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + + // Map all fields from the parsed result to the form + populateJDForm(result); + + // Show notification + showNotification('JD uploaded and fields populated successfully!', 'success'); + + } catch (error) { + console.error('Error parsing JD:', error); + showNotification('Failed to parse JD. Please try again or enter manually.', 'danger'); + } finally { + button.innerHTML = originalText; + button.disabled = false; + } + }; + + fileInput.click(); + }); + } + + function populateJDForm(jdData) { + // Section 1: Basic Information + if (jdData.sequence) { + document.getElementById('job-sequence').value = jdData.sequence; + } + + // Job Position - try to match with existing options + if (jdData.job_title) { + const jobSelect = document.getElementById('job-position'); + const options = jobSelect.options; + let found = false; + + // First try exact match + for (let i = 0; i < options.length; i++) { + if (options[i].text.toLowerCase() === jdData.job_title.toLowerCase()) { + jobSelect.value = options[i].value; + $(jobSelect).trigger('change'); + found = true; + break; + } + } + + // If not found, try partial match + if (!found) { + for (let i = 0; i < options.length; i++) { + if (options[i].text.toLowerCase().includes(jdData.job_title.toLowerCase()) || + jdData.job_title.toLowerCase().includes(options[i].text.toLowerCase())) { + jobSelect.value = options[i].value; + $(jobSelect).trigger('change'); + break; + } + } + } + } + + // Set work type based on employment type + if (jdData.employment_type) { + const employmentType = jdData.employment_type.toLowerCase(); + if (employmentType.includes('client') || employmentType.includes('external')) { + document.getElementById('work-type-external').checked = true; + } else { + document.getElementById('work-type-internal').checked = true; + } + } + + // Section 2: Employment Details + if (jdData.target_start_date) { + document.getElementById('target-from').value = jdData.target_start_date; + } + if (jdData.target_end_date) { + document.getElementById('target-to').value = jdData.target_end_date; + } + + // Set employment type dropdown + if (jdData.employment_type) { + const empTypeSelect = document.getElementById('employment-type'); + const options = empTypeSelect.options; + for (let i = 0; i < options.length; i++) { + if (options[i].text.toLowerCase().includes(jdData.employment_type.toLowerCase())) { + empTypeSelect.value = options[i].value; + break; + } + } + } + + // Set experience dropdown + if (jdData.experience) { + document.getElementById('job-experience').value = jdData.experience.id; + $(document.getElementById('job-experience')).trigger('change'); + } + + // Section 3: Skills & Requirements + if (jdData.primary_skills && jdData.primary_skills.length) { + const skillValues = jdData.primary_skills.map(skill => skill.id); + $('#job-primary-skills').val(skillValues).trigger('change'); + } + + if (jdData.secondary_skills && jdData.secondary_skills.length) { + const skillValues = jdData.secondary_skills.map(skill => skill.id); + $('#job-secondary-skills').val(skillValues).trigger('change'); + } + + // Section 5: Additional Information + if (jdData.locations && jdData.locations.length) { + const locationValues = jdData.locations.map(loc => loc.id); + $('#job-locations').val(locationValues).trigger('change'); + } + + if (jdData.description) { + setTimeout(() => { + const editor = document.getElementById('job-description-editor'); + const textarea = document.getElementById('job-description'); + + if (!editor || !textarea) { + console.error('Editor or textarea element not found!'); + return; + } + + const lines = jdData.description.split('\n').map(l => l.trim()).filter(l => l); + let formatted = ''; + let inBulletList = false; + + lines.forEach(line => { + const numberedMatch = line.match(/^(\d+\.\s+)(.*)/); + const bulletMatch = line.match(/^([•\-○]\s*)(.*)/); + const titleMatch = line.match(/^(.+?):\s*(.*)/); + + if (numberedMatch) { + // Close bullet list if previously opened + if (inBulletList) { + formatted += ''; + inBulletList = false; + } + formatted += `

${numberedMatch[1]}${numberedMatch[2]}

`; + } else if (bulletMatch) { + if (!inBulletList) { + formatted += '
    '; + inBulletList = true; + } + formatted += `
  • ${bulletMatch[2]}
  • `; + } else if (titleMatch) { + if (inBulletList) { + formatted += '
'; + inBulletList = false; + } + formatted += `

${titleMatch[1]}: ${titleMatch[2]}

`; + } else { + // Treat as bullet if short or point-like + if (line.length < 150 && /^[A-Za-z0-9].*/.test(line)) { + if (!inBulletList) { + formatted += '
    '; + inBulletList = true; + } + formatted += `
  • ${line}
  • `; + } else { + if (inBulletList) { + formatted += '
'; + inBulletList = false; + } + formatted += `

${line}

`; + } + } + }); + + // Close any open
    + if (inBulletList) { + formatted += '
'; + } + + editor.innerHTML = formatted; + textarea.value = formatted; + textarea.dispatchEvent(new Event('input', { bubbles: true })); + + console.log('Formatted:', formatted); + }, 300); + } + + } + + function showNotification(message, type) { + // Using Odoo's notification system + if (typeof Notification !== 'undefined') { + Notification.create({ + title: type === 'success' ? 'Success' : 'Error', + message: message, + type: type, + sticky: false + }); + } else { + // Fallback to alert + alert(`${type.toUpperCase()}: ${message}`); + } + } + + function getCookie(name) { + let cookieValue = null; + if (document.cookie && document.cookie !== '') { + const cookies = document.cookie.split(';'); + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].trim(); + if (cookie.substring(0, name.length + 1) === (name + '=')) { + cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); + break; + } + } + } + return cookieValue; + } + + // Helper function to load WYSIWYG assets + function loadAssets(assets) { + return Promise.all(assets.map(asset => { + return new Promise((resolve, reject) => { + const isCss = asset.endsWith('.css'); + const element = isCss + ? document.createElement('link') + : document.createElement('script'); + + if (isCss) { + element.rel = 'stylesheet'; + element.href = asset; + element.type = 'text/css'; + } else { + element.src = asset; + element.type = 'text/javascript'; + } + + element.onload = resolve; + element.onerror = () => reject(new Error(`Failed to load ${asset}`)); + document.head.appendChild(element); + }); + })); + } + + + // Populate categories (example) +// const categorySelect = document.getElementById('job-category'); +// if (categorySelect) { +// // You might want to fetch these from your backend +// const categories = ['IT', 'Marketing', 'Finance', 'HR', 'Operations']; +// categories.forEach(cat => { +// const option = document.createElement('option'); +// option.value = cat.toLowerCase(); +// option.textContent = cat; +// categorySelect.appendChild(option); +// }); +// } + + const workTypeRadios = document.querySelectorAll("input[name='work-type']"); + const companySelect = document.getElementById("client-company"); + const clientSelect = document.getElementById("client-id"); + + let clientToCompanyMap = {}; // New map + + function initSelect2() { + // Check if Select2 is already initialized + const primarySkillsSelect = document.getElementById('job-primary-skills'); + if (primarySkillsSelect) { + $(primarySkillsSelect).select2({ + placeholder: 'Select skills', + allowClear: true, + dropdownParent: $('.new-jd-container'), + width: '100%', + escapeMarkup: function(m) { return m; } + }); + } + const jobPosition = document.getElementById('job-position'); + if (jobPosition) { + $(jobPosition).select2({ + placeholder: 'Select Job Position', + dropdownParent: $('.new-jd-container'), + width: '100%', + escapeMarkup: function(m) { return m; } + }); + } + + const clientCompany = document.getElementById('client-company'); + if (clientCompany) { + $(clientCompany).select2({ + placeholder: 'Select Client Company', + dropdownParent: $('.new-jd-container'), + width: '100%', + escapeMarkup: function(m) { return m; } + }); + } + + const clientId = document.getElementById('client-id'); + if (clientId) { + $(clientId).select2({ + placeholder: 'Select client', + dropdownParent: $('.new-jd-container'), + width: '100%', + escapeMarkup: function(m) { return m; } + }); + } + + const secondarySkillSelect = document.getElementById('job-secondary-skills'); + if (secondarySkillSelect) { + $(secondarySkillSelect).select2({ + placeholder: 'Select skills', + allowClear: true, + dropdownParent: $('.new-jd-container'), + width: '100%', + escapeMarkup: function(m) { return m; } + }); + } + + const primaryRecruiterSelect = document.getElementById('primary-recruiter'); + if (primaryRecruiterSelect) { + $(primaryRecruiterSelect).select2({ + placeholder: 'Select Primary Recruiters', + allowClear: true, + templateResult: formatUserOption, + templateSelection: formatUserSelection, + escapeMarkup: function(m) { return m; } + }); + } + + const secondaryRecruiterSelect = document.getElementById('secondary-recruiter'); + if (secondaryRecruiterSelect) { + $(secondaryRecruiterSelect).select2({ + placeholder: 'Select Secondary Recruiters', + allowClear: true, + templateResult: formatUserOption, + templateSelection: formatUserSelection, + dropdownParent: $('.new-jd-container'), + width: '100%', + escapeMarkup: function(m) { return m; } + }); + } + + const jobLocationsSelect = document.getElementById('job-locations'); + if (jobLocationsSelect) { + $(jobLocationsSelect).select2({ + placeholder: 'Select Job Locations', + allowClear: true, + dropdownParent: $('.new-jd-container'), + width: '100%', + escapeMarkup: function(m) { return m; } + }); + } + + const RecruitmentStagesSelect = document.getElementById('recruitment-stages'); + if (RecruitmentStagesSelect) { + $(RecruitmentStagesSelect).select2({ + placeholder: 'Select Recruitment Stages', + allowClear: true, + dropdownParent: $('.new-jd-container'), + width: '100%', + escapeMarkup: function(m) { return m; } + }); + } + + initSkillsDragAndDrop(); + } + + function setPrimarySkills(skills, remove = false) { + const $select = $('#job-primary-skills'); + if ($select.hasClass("select2-hidden-accessible")) { + let current = $select.val() || []; + if (remove) { + // Remove the specified skills + current = current.filter(id => !skills.includes(id)); + } else { + // Add the specified skills (avoid duplicates) + current = Array.from(new Set(current.concat(skills))); + } + $select.val(current).trigger('change'); + } else { + setTimeout(() => setPrimarySkills(skills, remove), 100); + } + } + + function setSecondarySkills(skills, remove = false) { + const $select = $('#job-secondary-skills'); + if ($select.hasClass("select2-hidden-accessible")) { + let current = $select.val() || []; + if (remove) { + // Remove the specified skills + current = current.filter(id => !skills.includes(id)); + } else { + // Add the specified skills (avoid duplicates) + current = Array.from(new Set(current.concat(skills))); + } + $select.val(current).trigger('change'); + } else { + setTimeout(() => setSecondarySkills(skills, remove), 100); + } + } + + function isSkillInOptions(skillId, selectId) { + return $(`#${selectId} option[value="${skillId}"]`).length > 0; + } + function isSkillSelected(skillId, selectId) { + const selector = `#${selectId}`; + const selectedValues = $(selector).val() || []; + return selectedValues.includes(skillId.toString()); + } + // Add this new function + function initSkillsDragAndDrop() { + const skillRequirementsDiv = document.getElementById('skill_requirements'); + const primarySkills = document.getElementById('job-primary-skills'); + const secondarySkills = document.getElementById('job-secondary-skills'); + + if (!skillRequirementsDiv || !primarySkills || !secondarySkills) return; + + // Make options draggable + [primarySkills, secondarySkills].forEach(select => { + // Set draggable attribute on all options + Array.from(select.options).forEach(option => { + option.setAttribute('draggable', 'true'); + }); + + select.addEventListener('dragstart', function(e) { + if (e.target.tagName === 'OPTION') { + console.log('Drag started for option:', e.target.value); + e.dataTransfer.setData('text/plain', e.target.value); + e.dataTransfer.effectAllowed = 'move'; + e.target.classList.add('dragging'); + } + }); + + select.addEventListener('dragend', function(e) { + if (e.target.tagName === 'OPTION') { + console.log('Drag ended for option:', e.target.value); + e.target.classList.remove('dragging'); + } + }); + }); + + // Set up drop zones for both selects + [primarySkills, secondarySkills].forEach(select => { + const select2Container = $(`#${select.id}`).next('.select2-container'); + const select2Selection = select2Container.find('.select2-selection--multiple'); + console.log(select2Container); + console.log(select2Selection); + // Handle dragover for the Select2 container + select2Selection.on('dragover', function(e) { + e.preventDefault(); + e.originalEvent.dataTransfer.dropEffect = 'move'; + $(this).addClass('drag-over'); + console.log('Dragging over select:', select.id); + return false; + }); + + // Handle dragleave for the Select2 container + select2Selection.on('dragleave', function(e) { + console.log("drag leave feature"); + $(this).removeClass('drag-over'); + return false; + }); + + // Handle drop for the Select2 container + select2Selection.on('drop', function(e) { + console.log("dropping the data"); + e.preventDefault(); + e.stopPropagation(); + $(this).removeClass('drag-over'); + + const skillId = e.originalEvent.dataTransfer.getData('text/plain'); + const option = document.querySelector(`option[value="${skillId}"]`); + console.log('Moving option from', option.parentElement.id, 'to', select.id); + + console.log('Dropped skill ID:', skillId); + + if (!isSkillInOptions(skillId, select.id)) { + console.log('Skill does not exists in the selected dropdown'); + return; + } + + + // If the option is already in this select, do nothing + if (isSkillSelected(skillId, select.id)) { + console.log('Skill is already selected.'); + return; + } + + + // Remove from current select + const oldSelect = option.parentElement; + if (select.id == 'job-primary-skills'){ + setSecondarySkills(skillId, remove=true) + setPrimarySkills(skillId) + } else { + setPrimarySkills(skillId, remove=true) + setSecondarySkills(skillId) + } + + + + // Trigger Select2 updates + $(oldSelect).trigger('change'); + $(select).trigger('change'); + + console.log('Option moved successfully'); + return false; + }); + }); + + // Prevent default behavior for the parent container + skillRequirementsDiv.addEventListener('dragover', function(e) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'none'; + return false; + }); + + skillRequirementsDiv.addEventListener('drop', function(e) { + console.log("drop feature"); + e.preventDefault(); + return false; + }); + + // Make Select2 choices draggable + $(document).on('mouseenter', '.select2-selection__choice', function() { + this.setAttribute('draggable', 'true'); + this.addEventListener('dragstart', function(e) { + console.log("dragstarted"); + const value = $(this).attr('title') || $(this).text().trim(); + console.log('Dragging choice:', value); + const option = $(primarySkills).find(`option:contains('${value}')`)[0] || + $(secondarySkills).find(`option:contains('${value}')`)[0]; + if (option) { + console.log('Found matching option:', option.value); + e.dataTransfer.setData('text/plain', option.value); + e.dataTransfer.effectAllowed = 'move'; + } else { + console.log('No matching option found for:', value); + } + }); + }); + } + + function formatUserOption(user) { + if (!user.id) return user.text; + var imageUrl = $(user.element).data('image') || '/web/static/img/placeholder.png'; + var $container = $( + '' + + '' + + '' + user.text + '' + + '' + ); + return $container; + } + + function formatUserSelection(user) { + if (!user.id) return user.text; + var imageUrl = $(user.element).data('image') || '/web/static/img/placeholder.png'; + var $container = $( + '' + + '' + + '' + user.text + '' + + '' + ); + return $container; + } + + function fetchCompanies(workType) { + fetch('/get_client_companies', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: workType }) + }) + .then(res => res.json()) + .then(data => { + const companies = data.result; + if (!Array.isArray(companies)) { + console.error("Expected an array, got:", companies); + return; + } + + companySelect.innerHTML = ''; + companies.forEach(company => { + companySelect.innerHTML += ``; + }); + + clientSelect.innerHTML = ''; + clientToCompanyMap = {}; // Reset map + }) + .catch(err => { + console.error("Error fetching companies:", err); + }); + } + + function fetchClients(companyId, workType) { + fetch('/get_clients_by_company', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ company_id: companyId, type: workType }) + }) + .then(res => res.json()) + .then(data => { + const clients = data.result; + if (!Array.isArray(clients)) { + console.error("Expected an array, got:", clients); + return; + } + + clientSelect.innerHTML = ''; + clientToCompanyMap = {}; // Reset map + + clients.forEach(client => { + clientSelect.innerHTML += ``; + clientToCompanyMap[client.id] = client.company_id; // Assuming `company_id` comes from backend + }); + }); + } + + + // Initial fetch on page load + const initialType = document.querySelector("input[name='work-type']:checked").value; + fetchCompanies(initialType); + fetchClients(null, initialType); + + + // Change on radio click + workTypeRadios.forEach(radio => { + console.log("radio clicked"); + radio.addEventListener("change", () => { + fetchCompanies(radio.value); + fetchClients(companyId = null, workType = radio.value); + + }); + }); + + companySelect.addEventListener("change", () => { + let selectedCompanyId = null; + if (companySelect.value){ + selectedCompanyId = companySelect.value; + } + clientSelect.innerHTML = ''; + const selectedType = document.querySelector("input[name='work-type']:checked").value; + fetchClients(selectedCompanyId, selectedType); + }); + + // On client selection + clientSelect.addEventListener("change", () => { + const selectedClientId = clientSelect.value; + + if (selectedClientId && clientToCompanyMap[selectedClientId]) { + const companyId = clientToCompanyMap[selectedClientId]; + companySelect.value = companyId; + } + }); +} + +// Track open popups +const openPopups = []; + +function initSmartButtons() { + // Matching Candidates button + document.getElementById('matching-jd-candidates')?.addEventListener('click', function() { + togglePopup('matchingCandidates', this.dataset.popupTitle || 'Matching Candidates'); + }); + + // Applicants button + document.getElementById('jd-applicants')?.addEventListener('click', function() { + togglePopup('applicants', this.dataset.popupTitle || 'Applicants'); + }); + + // Close buttons (using event delegation) + document.addEventListener('click', function(e) { + if (e.target.classList.contains('popup-close')) { + const popup = e.target.closest('.popup-container'); + closePopup(popup.id.replace('Popup', '')); + } + }); +} + +function togglePopup(type, title) { + debugger; + const popupId = `${type}Popup`; + let popup = document.getElementById(popupId); + + if (!popup) { + // Create popup if it doesn't exist + popup = document.createElement('div'); + popup.id = popupId; + popup.className = 'popup-container bottom-right'; + popup.innerHTML = document.getElementById(`${type}_popup`).innerHTML; + document.body.appendChild(popup); + } + + if (popup.classList.contains('visible')) { + closePopup(type); + } else { + openPopup(type, title); + } +} + +function openPopup(type, title) { + debugger; + const popupId = `${type}Popup`; + const popup = document.getElementById(popupId); + + if (!popup) return; + + // Update title + const titleEl = popup.querySelector('h5'); + if (titleEl) titleEl.textContent = title; + + // Show loading state + const loadingEl = popup.querySelector('.loading-spinner'); + const contentEl = popup.querySelector('.popup-content'); + + if (loadingEl) { + loadingEl.style.display = 'flex'; + loadingEl.innerHTML = '
'; + } + if (contentEl) { + contentEl.style.display = 'none'; + contentEl.innerHTML = ''; + } + + // Show popup + popup.classList.add('visible'); + + // Add to open popups array if not already there + if (!openPopups.includes(popupId)) { + openPopups.push(popupId); + } + + // Position popups + positionPopups(); + debugger; + // Fetch data + const jobId = document.querySelector('.job-detail-container')?.dataset?.jobId; + if (!jobId) { + console.error('No job ID found'); + return; + } + + const endpoint = type === 'matchingCandidates' + ? `/myATS/job/matching_candidates/${jobId}` + : `/myATS/job/applicants/${jobId}`; + + fetch(endpoint, { + headers: { "X-Requested-With": "XMLHttpRequest" } + }) + .then(response => { + if (!response.ok) throw new Error('Network response was not ok'); + return response.text(); + }) + .then(html => { + if (loadingEl) loadingEl.style.display = 'none'; + if (contentEl) { + contentEl.innerHTML = html; + contentEl.style.display = 'block'; + initMatchingCandidates(); + } + }) + .catch(error => { + console.error('Error loading popup content:', error); + if (loadingEl) { + loadingEl.innerHTML = ` +
+ Failed to load data. Please try again. +
+ `; + } + }); +} + +function closePopup(type) { + const popupId = `${type}Popup`; + const popup = document.getElementById(popupId); + + if (popup) { + popup.classList.remove('visible'); + + // Remove from open popups array + const index = openPopups.indexOf(popupId); + if (index > -1) { + openPopups.splice(index, 1); + } + + // Reposition remaining popups + positionPopups(); + } +} + +function positionPopups() { + // Reset all popup positions first + document.querySelectorAll('.popup-container').forEach(popup => { + popup.style.right = ''; + popup.style.bottom = ''; + }); + + // Position each visible popup + openPopups.forEach((popupId, index) => { + const popup = document.getElementById(popupId); + if (popup) { + popup.style.right = `${20 + (index * 420)}px`; // 400px width + 20px gap + popup.style.bottom = '0'; + } + }); +} + +function initMatchingCandidates() { + // Handle candidate card clicks + document.addEventListener('click', function(e) { + const card = e.target.closest('.mc-card'); + if (card) { + debugger; + const candidateId = parseInt(card.dataset.id); + showCandidateDetails(card); + } + + // Close modal + if (e.target.classList.contains('mc-close-modal')) { + closeCandidateModal(); + } + }); + + // Close modal when clicking outside + window.addEventListener('click', function(e) { + const modal = document.getElementById('candidateDetailModal'); + if (e.target === modal) { + closeCandidateModal(); + } + }); +} + +function showCandidateDetails(card) { + const modalContent = document.getElementById('candidateDetailContent'); + + // Parse the applications data + let applications = []; + try { + applications = JSON.parse(card.dataset.applications.replace(/'/g, '"')); + } catch (e) { + console.error('Error parsing applications:', e); + } + + // Parse skills data + let primarySkills = []; + let secondarySkills = []; + try { + primarySkills = card.dataset.matchPrimarySkills ? + JSON.parse(card.dataset.matchPrimarySkills.replace(/'/g, '"')) : []; + secondarySkills = card.dataset.matchSecondarySkills ? + JSON.parse(card.dataset.matchSecondarySkills.replace(/'/g, '"')) : []; + } catch (e) { + console.error('Error parsing skills:', e); + } + + // Create the HTML structure + modalContent.innerHTML = ` +
+
+
+ ${card.dataset.image ? + `Candidate` : + `
+ ${card.dataset.candidate ? card.dataset.candidate.charAt(0).toUpperCase() : '?'} +
` + } +
+

+ ${card.dataset.candidate || 'Unnamed Candidate'} +

+
+ +
+

Contact Information

+
+ Email: + + ${card.dataset.email ? + `${card.dataset.email}` : + 'Not provided'} + +
+
+ Phone: + + ${card.dataset.phone ? + `${card.dataset.phone}` : + 'Not provided'} + +
+
+ Recruiter: + + ${card.dataset.manager || 'Unassigned'} + +
+
+ +
+

Skills Match

+
+
+
Primary Skills (${parseInt(card.dataset.primaryPercent) || 0}% Match)
+ ${primarySkills.length > 0 ? + `
    + ${primarySkills.map(skill => `
  • ${skill}
  • `).join('')} +
` : + '

No primary skills matched

'} +
+
+
Secondary Skills (${parseInt(card.dataset.secondaryPercent) || 0}% Match)
+ ${secondarySkills.length > 0 ? + `
    + ${secondarySkills.map(skill => `
  • ${skill}
  • `).join('')} +
` : + '

No secondary skills matched

'} +
+
+
+ +
+

Applications

+ ${applications.length > 0 ? + `
+ ${applications.map(app => ` +
+ ${app[0] || 'Untitled Application'} + ${app[1]} +
+ `).join('')} +
` : + '

No applications found

'} +
+
+ `; + + // Show modal + document.getElementById('candidateDetailModal').style.display = 'block'; +} + +function renderCandidateDetail(data) { + const detailContainer = document.getElementById('candidateDetailContent'); + // Here you would create the HTML structure based on your template + // and populate it with the data received from the server + detailContainer.innerHTML = ` +
+
+ +

${data.candidate.display_name || 'Unnamed Candidate'}

+
+ +
+ `; + // You would continue with all the other fields from your template +} + +function closeCandidateModal() { + document.getElementById('candidateDetailModal').style.display = 'none'; +} + +// +//// Add this function to initialize the popup functionality +//function initSmartButtons() { +// document.querySelectorAll('.smart-button').forEach(button => { +// console.log("event-exist"); +// button.addEventListener('click', function() { +// console.log("event-clicked"); +// const jobId = document.querySelector('.job-detail-container').getAttribute('data-job-id'); +// const popupType = this.dataset.popupType; +// const popupTitle = this.dataset.popupTitle; +// console.log(jobId); +// console.log(popupType); +// console.log(popupTitle); +// showSmartPopup(jobId, popupType, popupTitle); +// }); +// }); +//} +// +//function showSmartPopup(jobId, popupType, popupTitle) { +// // Create or get the canvas element +// debugger; +// let canvasId = popupType + 'Canvas'; +// let canvas = document.getElementById(canvasId); +// +// if (!canvas) { +// // Create the canvas from template if it doesn't exist +// const template = document.getElementById(popupType + '_popup'); +// if (template) { +// const clone = template.content.cloneNode(true); +// document.body.appendChild(clone); +// canvas = document.getElementById(canvasId); +// } +// } +// +// if (canvas) { +// // Initialize the offcanvas if not already initialized +// if (!canvas._offcanvas) { +// canvas._offcanvas = new bootstrap.Offcanvas(canvas); +// } +// +// // Show the offcanvas +// canvas._offcanvas.show(); +// +// // Load content +// loadPopupContent(jobId, popupType); +// } +//} +// +//function loadPopupContent(jobId, popupType) { +// console.log("loadPopupContent"); +// const canvas = document.getElementById(popupType + 'Canvas'); +// if (!canvas) return; +// +// const loadingSpinner = canvas.querySelector('.loading-spinner'); +// const contentArea = canvas.querySelector(`.${popupType.replace('_', '-')}-list`); +// +// // Show loading spinner +// if (loadingSpinner) loadingSpinner.style.display = 'block'; +// if (contentArea) contentArea.innerHTML = ''; +// +// // Determine the endpoint based on popup type +// let endpoint = ''; +// if (popupType === 'matchingCandidates') { +// endpoint = `/myATS/job/${jobId}/matching_candidates`; +// } else if (popupType === 'applicants') { +// endpoint = `/myATS/job/${jobId}/applicants`; +// } +// +// if (endpoint) { +// fetch(endpoint, { +// headers: { "X-Requested-With": "XMLHttpRequest" } +// }) +// .then(response => response.text()) +// .then(html => { +// if (contentArea) { +// contentArea.innerHTML = html; +// // Initialize any interactive elements in the popup if needed +// initPopupInteractiveElements(popupType); +// } +// }) +// .catch(error => { +// console.error(`Error loading ${popupType}:`, error); +// if (contentArea) { +// contentArea.innerHTML = ` +//
+// Failed to load ${popupType.replace('_', ' ')}. Please try again. +//
+// `; +// } +// }) +// .finally(() => { +// if (loadingSpinner) loadingSpinner.style.display = 'none'; +// }); +// } +//} +// +//function initPopupInteractiveElements(popupType) { +// // Initialize any interactive elements specific to each popup type +// if (popupType === 'matching_candidates') { +// // Add event listeners for matching candidates popup +// document.querySelectorAll('.match-candidate-action').forEach(button => { +// button.addEventListener('click', function() { +// const candidateId = this.dataset.candidateId; +// // Handle candidate action +// }); +// }); +// } else if (popupType === 'applicants') { +// // Add event listeners for applicants popup +// document.querySelectorAll('.applicant-action').forEach(button => { +// button.addEventListener('click', function() { +// const applicantId = this.dataset.applicantId; +// // Handle applicant action +// }); +// }); +// } +//} diff --git a/addons_extensions/hr_recruitment_web_app/static/src/js/jquery.js b/addons_extensions/hr_recruitment_web_app/static/src/js/jquery.js new file mode 100644 index 000000000..e24ae4561 --- /dev/null +++ b/addons_extensions/hr_recruitment_web_app/static/src/js/jquery.js @@ -0,0 +1,10993 @@ +/*! + * jQuery JavaScript Library v3.6.3 + * https://jquery.com/ + * + * Includes Sizzle.js + * https://sizzlejs.com/ + * + * Copyright OpenJS Foundation and other contributors + * Released under the MIT license + * https://jquery.org/license + * + * Date: 2022-12-20T21:28Z + */ +( function( global, factory ) { + + "use strict"; + + if ( typeof module === "object" && typeof module.exports === "object" ) { + + // For CommonJS and CommonJS-like environments where a proper `window` + // is present, execute the factory and get jQuery. + // For environments that do not have a `window` with a `document` + // (such as Node.js), expose a factory as module.exports. + // This accentuates the need for the creation of a real `window`. + // e.g. var jQuery = require("jquery")(window); + // See ticket trac-14549 for more info. + module.exports = global.document ? + factory( global, true ) : + function( w ) { + if ( !w.document ) { + throw new Error( "jQuery requires a window with a document" ); + } + return factory( w ); + }; + } else { + factory( global ); + } + +// Pass this if window is not defined yet +} )( typeof window !== "undefined" ? window : this, function( window, noGlobal ) { + +// Edge <= 12 - 13+, Firefox <=18 - 45+, IE 10 - 11, Safari 5.1 - 9+, iOS 6 - 9.1 +// throw exceptions when non-strict code (e.g., ASP.NET 4.5) accesses strict mode +// arguments.callee.caller (trac-13335). But as of jQuery 3.0 (2016), strict mode should be common +// enough that all such attempts are guarded in a try block. +"use strict"; + +var arr = []; + +var getProto = Object.getPrototypeOf; + +var slice = arr.slice; + +var flat = arr.flat ? function( array ) { + return arr.flat.call( array ); +} : function( array ) { + return arr.concat.apply( [], array ); +}; + + +var push = arr.push; + +var indexOf = arr.indexOf; + +var class2type = {}; + +var toString = class2type.toString; + +var hasOwn = class2type.hasOwnProperty; + +var fnToString = hasOwn.toString; + +var ObjectFunctionString = fnToString.call( Object ); + +var support = {}; + +var isFunction = function isFunction( obj ) { + + // Support: Chrome <=57, Firefox <=52 + // In some browsers, typeof returns "function" for HTML elements + // (i.e., `typeof document.createElement( "object" ) === "function"`). + // We don't want to classify *any* DOM node as a function. + // Support: QtWeb <=3.8.5, WebKit <=534.34, wkhtmltopdf tool <=0.12.5 + // Plus for old WebKit, typeof returns "function" for HTML collections + // (e.g., `typeof document.getElementsByTagName("div") === "function"`). (gh-4756) + return typeof obj === "function" && typeof obj.nodeType !== "number" && + typeof obj.item !== "function"; + }; + + +var isWindow = function isWindow( obj ) { + return obj != null && obj === obj.window; + }; + + +var document = window.document; + + + + var preservedScriptAttributes = { + type: true, + src: true, + nonce: true, + noModule: true + }; + + function DOMEval( code, node, doc ) { + doc = doc || document; + + var i, val, + script = doc.createElement( "script" ); + + script.text = code; + if ( node ) { + for ( i in preservedScriptAttributes ) { + + // Support: Firefox 64+, Edge 18+ + // Some browsers don't support the "nonce" property on scripts. + // On the other hand, just using `getAttribute` is not enough as + // the `nonce` attribute is reset to an empty string whenever it + // becomes browsing-context connected. + // See https://github.com/whatwg/html/issues/2369 + // See https://html.spec.whatwg.org/#nonce-attributes + // The `node.getAttribute` check was added for the sake of + // `jQuery.globalEval` so that it can fake a nonce-containing node + // via an object. + val = node[ i ] || node.getAttribute && node.getAttribute( i ); + if ( val ) { + script.setAttribute( i, val ); + } + } + } + doc.head.appendChild( script ).parentNode.removeChild( script ); + } + + +function toType( obj ) { + if ( obj == null ) { + return obj + ""; + } + + // Support: Android <=2.3 only (functionish RegExp) + return typeof obj === "object" || typeof obj === "function" ? + class2type[ toString.call( obj ) ] || "object" : + typeof obj; +} +/* global Symbol */ +// Defining this global in .eslintrc.json would create a danger of using the global +// unguarded in another place, it seems safer to define global only for this module + + + +var + version = "3.6.3", + + // Define a local copy of jQuery + jQuery = function( selector, context ) { + + // The jQuery object is actually just the init constructor 'enhanced' + // Need init if jQuery is called (just allow error to be thrown if not included) + return new jQuery.fn.init( selector, context ); + }; + +jQuery.fn = jQuery.prototype = { + + // The current version of jQuery being used + jquery: version, + + constructor: jQuery, + + // The default length of a jQuery object is 0 + length: 0, + + toArray: function() { + return slice.call( this ); + }, + + // Get the Nth element in the matched element set OR + // Get the whole matched element set as a clean array + get: function( num ) { + + // Return all the elements in a clean array + if ( num == null ) { + return slice.call( this ); + } + + // Return just the one element from the set + return num < 0 ? this[ num + this.length ] : this[ num ]; + }, + + // Take an array of elements and push it onto the stack + // (returning the new matched element set) + pushStack: function( elems ) { + + // Build a new jQuery matched element set + var ret = jQuery.merge( this.constructor(), elems ); + + // Add the old object onto the stack (as a reference) + ret.prevObject = this; + + // Return the newly-formed element set + return ret; + }, + + // Execute a callback for every element in the matched set. + each: function( callback ) { + return jQuery.each( this, callback ); + }, + + map: function( callback ) { + return this.pushStack( jQuery.map( this, function( elem, i ) { + return callback.call( elem, i, elem ); + } ) ); + }, + + slice: function() { + return this.pushStack( slice.apply( this, arguments ) ); + }, + + first: function() { + return this.eq( 0 ); + }, + + last: function() { + return this.eq( -1 ); + }, + + even: function() { + return this.pushStack( jQuery.grep( this, function( _elem, i ) { + return ( i + 1 ) % 2; + } ) ); + }, + + odd: function() { + return this.pushStack( jQuery.grep( this, function( _elem, i ) { + return i % 2; + } ) ); + }, + + eq: function( i ) { + var len = this.length, + j = +i + ( i < 0 ? len : 0 ); + return this.pushStack( j >= 0 && j < len ? [ this[ j ] ] : [] ); + }, + + end: function() { + return this.prevObject || this.constructor(); + }, + + // For internal use only. + // Behaves like an Array's method, not like a jQuery method. + push: push, + sort: arr.sort, + splice: arr.splice +}; + +jQuery.extend = jQuery.fn.extend = function() { + var options, name, src, copy, copyIsArray, clone, + target = arguments[ 0 ] || {}, + i = 1, + length = arguments.length, + deep = false; + + // Handle a deep copy situation + if ( typeof target === "boolean" ) { + deep = target; + + // Skip the boolean and the target + target = arguments[ i ] || {}; + i++; + } + + // Handle case when target is a string or something (possible in deep copy) + if ( typeof target !== "object" && !isFunction( target ) ) { + target = {}; + } + + // Extend jQuery itself if only one argument is passed + if ( i === length ) { + target = this; + i--; + } + + for ( ; i < length; i++ ) { + + // Only deal with non-null/undefined values + if ( ( options = arguments[ i ] ) != null ) { + + // Extend the base object + for ( name in options ) { + copy = options[ name ]; + + // Prevent Object.prototype pollution + // Prevent never-ending loop + if ( name === "__proto__" || target === copy ) { + continue; + } + + // Recurse if we're merging plain objects or arrays + if ( deep && copy && ( jQuery.isPlainObject( copy ) || + ( copyIsArray = Array.isArray( copy ) ) ) ) { + src = target[ name ]; + + // Ensure proper type for the source value + if ( copyIsArray && !Array.isArray( src ) ) { + clone = []; + } else if ( !copyIsArray && !jQuery.isPlainObject( src ) ) { + clone = {}; + } else { + clone = src; + } + copyIsArray = false; + + // Never move original objects, clone them + target[ name ] = jQuery.extend( deep, clone, copy ); + + // Don't bring in undefined values + } else if ( copy !== undefined ) { + target[ name ] = copy; + } + } + } + } + + // Return the modified object + return target; +}; + +jQuery.extend( { + + // Unique for each copy of jQuery on the page + expando: "jQuery" + ( version + Math.random() ).replace( /\D/g, "" ), + + // Assume jQuery is ready without the ready module + isReady: true, + + error: function( msg ) { + throw new Error( msg ); + }, + + noop: function() {}, + + isPlainObject: function( obj ) { + var proto, Ctor; + + // Detect obvious negatives + // Use toString instead of jQuery.type to catch host objects + if ( !obj || toString.call( obj ) !== "[object Object]" ) { + return false; + } + + proto = getProto( obj ); + + // Objects with no prototype (e.g., `Object.create( null )`) are plain + if ( !proto ) { + return true; + } + + // Objects with prototype are plain iff they were constructed by a global Object function + Ctor = hasOwn.call( proto, "constructor" ) && proto.constructor; + return typeof Ctor === "function" && fnToString.call( Ctor ) === ObjectFunctionString; + }, + + isEmptyObject: function( obj ) { + var name; + + for ( name in obj ) { + return false; + } + return true; + }, + + // Evaluates a script in a provided context; falls back to the global one + // if not specified. + globalEval: function( code, options, doc ) { + DOMEval( code, { nonce: options && options.nonce }, doc ); + }, + + each: function( obj, callback ) { + var length, i = 0; + + if ( isArrayLike( obj ) ) { + length = obj.length; + for ( ; i < length; i++ ) { + if ( callback.call( obj[ i ], i, obj[ i ] ) === false ) { + break; + } + } + } else { + for ( i in obj ) { + if ( callback.call( obj[ i ], i, obj[ i ] ) === false ) { + break; + } + } + } + + return obj; + }, + + // results is for internal usage only + makeArray: function( arr, results ) { + var ret = results || []; + + if ( arr != null ) { + if ( isArrayLike( Object( arr ) ) ) { + jQuery.merge( ret, + typeof arr === "string" ? + [ arr ] : arr + ); + } else { + push.call( ret, arr ); + } + } + + return ret; + }, + + inArray: function( elem, arr, i ) { + return arr == null ? -1 : indexOf.call( arr, elem, i ); + }, + + // Support: Android <=4.0 only, PhantomJS 1 only + // push.apply(_, arraylike) throws on ancient WebKit + merge: function( first, second ) { + var len = +second.length, + j = 0, + i = first.length; + + for ( ; j < len; j++ ) { + first[ i++ ] = second[ j ]; + } + + first.length = i; + + return first; + }, + + grep: function( elems, callback, invert ) { + var callbackInverse, + matches = [], + i = 0, + length = elems.length, + callbackExpect = !invert; + + // Go through the array, only saving the items + // that pass the validator function + for ( ; i < length; i++ ) { + callbackInverse = !callback( elems[ i ], i ); + if ( callbackInverse !== callbackExpect ) { + matches.push( elems[ i ] ); + } + } + + return matches; + }, + + // arg is for internal usage only + map: function( elems, callback, arg ) { + var length, value, + i = 0, + ret = []; + + // Go through the array, translating each of the items to their new values + if ( isArrayLike( elems ) ) { + length = elems.length; + for ( ; i < length; i++ ) { + value = callback( elems[ i ], i, arg ); + + if ( value != null ) { + ret.push( value ); + } + } + + // Go through every key on the object, + } else { + for ( i in elems ) { + value = callback( elems[ i ], i, arg ); + + if ( value != null ) { + ret.push( value ); + } + } + } + + // Flatten any nested arrays + return flat( ret ); + }, + + // A global GUID counter for objects + guid: 1, + + // jQuery.support is not used in Core but other projects attach their + // properties to it so it needs to exist. + support: support +} ); + +if ( typeof Symbol === "function" ) { + jQuery.fn[ Symbol.iterator ] = arr[ Symbol.iterator ]; +} + +// Populate the class2type map +jQuery.each( "Boolean Number String Function Array Date RegExp Object Error Symbol".split( " " ), + function( _i, name ) { + class2type[ "[object " + name + "]" ] = name.toLowerCase(); + } ); + +function isArrayLike( obj ) { + + // Support: real iOS 8.2 only (not reproducible in simulator) + // `in` check used to prevent JIT error (gh-2145) + // hasOwn isn't used here due to false negatives + // regarding Nodelist length in IE + var length = !!obj && "length" in obj && obj.length, + type = toType( obj ); + + if ( isFunction( obj ) || isWindow( obj ) ) { + return false; + } + + return type === "array" || length === 0 || + typeof length === "number" && length > 0 && ( length - 1 ) in obj; +} +var Sizzle = +/*! + * Sizzle CSS Selector Engine v2.3.9 + * https://sizzlejs.com/ + * + * Copyright JS Foundation and other contributors + * Released under the MIT license + * https://js.foundation/ + * + * Date: 2022-12-19 + */ +( function( window ) { +var i, + support, + Expr, + getText, + isXML, + tokenize, + compile, + select, + outermostContext, + sortInput, + hasDuplicate, + + // Local document vars + setDocument, + document, + docElem, + documentIsHTML, + rbuggyQSA, + rbuggyMatches, + matches, + contains, + + // Instance-specific data + expando = "sizzle" + 1 * new Date(), + preferredDoc = window.document, + dirruns = 0, + done = 0, + classCache = createCache(), + tokenCache = createCache(), + compilerCache = createCache(), + nonnativeSelectorCache = createCache(), + sortOrder = function( a, b ) { + if ( a === b ) { + hasDuplicate = true; + } + return 0; + }, + + // Instance methods + hasOwn = ( {} ).hasOwnProperty, + arr = [], + pop = arr.pop, + pushNative = arr.push, + push = arr.push, + slice = arr.slice, + + // Use a stripped-down indexOf as it's faster than native + // https://jsperf.com/thor-indexof-vs-for/5 + indexOf = function( list, elem ) { + var i = 0, + len = list.length; + for ( ; i < len; i++ ) { + if ( list[ i ] === elem ) { + return i; + } + } + return -1; + }, + + booleans = "checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|" + + "ismap|loop|multiple|open|readonly|required|scoped", + + // Regular expressions + + // http://www.w3.org/TR/css3-selectors/#whitespace + whitespace = "[\\x20\\t\\r\\n\\f]", + + // https://www.w3.org/TR/css-syntax-3/#ident-token-diagram + identifier = "(?:\\\\[\\da-fA-F]{1,6}" + whitespace + + "?|\\\\[^\\r\\n\\f]|[\\w-]|[^\0-\\x7f])+", + + // Attribute selectors: http://www.w3.org/TR/selectors/#attribute-selectors + attributes = "\\[" + whitespace + "*(" + identifier + ")(?:" + whitespace + + + // Operator (capture 2) + "*([*^$|!~]?=)" + whitespace + + + // "Attribute values must be CSS identifiers [capture 5] + // or strings [capture 3 or capture 4]" + "*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|(" + identifier + "))|)" + + whitespace + "*\\]", + + pseudos = ":(" + identifier + ")(?:\\((" + + + // To reduce the number of selectors needing tokenize in the preFilter, prefer arguments: + // 1. quoted (capture 3; capture 4 or capture 5) + "('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|" + + + // 2. simple (capture 6) + "((?:\\\\.|[^\\\\()[\\]]|" + attributes + ")*)|" + + + // 3. anything else (capture 2) + ".*" + + ")\\)|)", + + // Leading and non-escaped trailing whitespace, capturing some non-whitespace characters preceding the latter + rwhitespace = new RegExp( whitespace + "+", "g" ), + rtrim = new RegExp( "^" + whitespace + "+|((?:^|[^\\\\])(?:\\\\.)*)" + + whitespace + "+$", "g" ), + + rcomma = new RegExp( "^" + whitespace + "*," + whitespace + "*" ), + rcombinators = new RegExp( "^" + whitespace + "*([>+~]|" + whitespace + ")" + whitespace + + "*" ), + rdescend = new RegExp( whitespace + "|>" ), + + rpseudo = new RegExp( pseudos ), + ridentifier = new RegExp( "^" + identifier + "$" ), + + matchExpr = { + "ID": new RegExp( "^#(" + identifier + ")" ), + "CLASS": new RegExp( "^\\.(" + identifier + ")" ), + "TAG": new RegExp( "^(" + identifier + "|[*])" ), + "ATTR": new RegExp( "^" + attributes ), + "PSEUDO": new RegExp( "^" + pseudos ), + "CHILD": new RegExp( "^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\(" + + whitespace + "*(even|odd|(([+-]|)(\\d*)n|)" + whitespace + "*(?:([+-]|)" + + whitespace + "*(\\d+)|))" + whitespace + "*\\)|)", "i" ), + "bool": new RegExp( "^(?:" + booleans + ")$", "i" ), + + // For use in libraries implementing .is() + // We use this for POS matching in `select` + "needsContext": new RegExp( "^" + whitespace + + "*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\(" + whitespace + + "*((?:-\\d)?\\d*)" + whitespace + "*\\)|)(?=[^-]|$)", "i" ) + }, + + rhtml = /HTML$/i, + rinputs = /^(?:input|select|textarea|button)$/i, + rheader = /^h\d$/i, + + rnative = /^[^{]+\{\s*\[native \w/, + + // Easily-parseable/retrievable ID or TAG or CLASS selectors + rquickExpr = /^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/, + + rsibling = /[+~]/, + + // CSS escapes + // http://www.w3.org/TR/CSS21/syndata.html#escaped-characters + runescape = new RegExp( "\\\\[\\da-fA-F]{1,6}" + whitespace + "?|\\\\([^\\r\\n\\f])", "g" ), + funescape = function( escape, nonHex ) { + var high = "0x" + escape.slice( 1 ) - 0x10000; + + return nonHex ? + + // Strip the backslash prefix from a non-hex escape sequence + nonHex : + + // Replace a hexadecimal escape sequence with the encoded Unicode code point + // Support: IE <=11+ + // For values outside the Basic Multilingual Plane (BMP), manually construct a + // surrogate pair + high < 0 ? + String.fromCharCode( high + 0x10000 ) : + String.fromCharCode( high >> 10 | 0xD800, high & 0x3FF | 0xDC00 ); + }, + + // CSS string/identifier serialization + // https://drafts.csswg.org/cssom/#common-serializing-idioms + rcssescape = /([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g, + fcssescape = function( ch, asCodePoint ) { + if ( asCodePoint ) { + + // U+0000 NULL becomes U+FFFD REPLACEMENT CHARACTER + if ( ch === "\0" ) { + return "\uFFFD"; + } + + // Control characters and (dependent upon position) numbers get escaped as code points + return ch.slice( 0, -1 ) + "\\" + + ch.charCodeAt( ch.length - 1 ).toString( 16 ) + " "; + } + + // Other potentially-special ASCII characters get backslash-escaped + return "\\" + ch; + }, + + // Used for iframes + // See setDocument() + // Removing the function wrapper causes a "Permission Denied" + // error in IE + unloadHandler = function() { + setDocument(); + }, + + inDisabledFieldset = addCombinator( + function( elem ) { + return elem.disabled === true && elem.nodeName.toLowerCase() === "fieldset"; + }, + { dir: "parentNode", next: "legend" } + ); + +// Optimize for push.apply( _, NodeList ) +try { + push.apply( + ( arr = slice.call( preferredDoc.childNodes ) ), + preferredDoc.childNodes + ); + + // Support: Android<4.0 + // Detect silently failing push.apply + // eslint-disable-next-line no-unused-expressions + arr[ preferredDoc.childNodes.length ].nodeType; +} catch ( e ) { + push = { apply: arr.length ? + + // Leverage slice if possible + function( target, els ) { + pushNative.apply( target, slice.call( els ) ); + } : + + // Support: IE<9 + // Otherwise append directly + function( target, els ) { + var j = target.length, + i = 0; + + // Can't trust NodeList.length + while ( ( target[ j++ ] = els[ i++ ] ) ) {} + target.length = j - 1; + } + }; +} + +function Sizzle( selector, context, results, seed ) { + var m, i, elem, nid, match, groups, newSelector, + newContext = context && context.ownerDocument, + + // nodeType defaults to 9, since context defaults to document + nodeType = context ? context.nodeType : 9; + + results = results || []; + + // Return early from calls with invalid selector or context + if ( typeof selector !== "string" || !selector || + nodeType !== 1 && nodeType !== 9 && nodeType !== 11 ) { + + return results; + } + + // Try to shortcut find operations (as opposed to filters) in HTML documents + if ( !seed ) { + setDocument( context ); + context = context || document; + + if ( documentIsHTML ) { + + // If the selector is sufficiently simple, try using a "get*By*" DOM method + // (excepting DocumentFragment context, where the methods don't exist) + if ( nodeType !== 11 && ( match = rquickExpr.exec( selector ) ) ) { + + // ID selector + if ( ( m = match[ 1 ] ) ) { + + // Document context + if ( nodeType === 9 ) { + if ( ( elem = context.getElementById( m ) ) ) { + + // Support: IE, Opera, Webkit + // TODO: identify versions + // getElementById can match elements by name instead of ID + if ( elem.id === m ) { + results.push( elem ); + return results; + } + } else { + return results; + } + + // Element context + } else { + + // Support: IE, Opera, Webkit + // TODO: identify versions + // getElementById can match elements by name instead of ID + if ( newContext && ( elem = newContext.getElementById( m ) ) && + contains( context, elem ) && + elem.id === m ) { + + results.push( elem ); + return results; + } + } + + // Type selector + } else if ( match[ 2 ] ) { + push.apply( results, context.getElementsByTagName( selector ) ); + return results; + + // Class selector + } else if ( ( m = match[ 3 ] ) && support.getElementsByClassName && + context.getElementsByClassName ) { + + push.apply( results, context.getElementsByClassName( m ) ); + return results; + } + } + + // Take advantage of querySelectorAll + if ( support.qsa && + !nonnativeSelectorCache[ selector + " " ] && + ( !rbuggyQSA || !rbuggyQSA.test( selector ) ) && + + // Support: IE 8 only + // Exclude object elements + ( nodeType !== 1 || context.nodeName.toLowerCase() !== "object" ) ) { + + newSelector = selector; + newContext = context; + + // qSA considers elements outside a scoping root when evaluating child or + // descendant combinators, which is not what we want. + // In such cases, we work around the behavior by prefixing every selector in the + // list with an ID selector referencing the scope context. + // The technique has to be used as well when a leading combinator is used + // as such selectors are not recognized by querySelectorAll. + // Thanks to Andrew Dupont for this technique. + if ( nodeType === 1 && + ( rdescend.test( selector ) || rcombinators.test( selector ) ) ) { + + // Expand context for sibling selectors + newContext = rsibling.test( selector ) && testContext( context.parentNode ) || + context; + + // We can use :scope instead of the ID hack if the browser + // supports it & if we're not changing the context. + if ( newContext !== context || !support.scope ) { + + // Capture the context ID, setting it first if necessary + if ( ( nid = context.getAttribute( "id" ) ) ) { + nid = nid.replace( rcssescape, fcssescape ); + } else { + context.setAttribute( "id", ( nid = expando ) ); + } + } + + // Prefix every selector in the list + groups = tokenize( selector ); + i = groups.length; + while ( i-- ) { + groups[ i ] = ( nid ? "#" + nid : ":scope" ) + " " + + toSelector( groups[ i ] ); + } + newSelector = groups.join( "," ); + } + + try { + + // `qSA` may not throw for unrecognized parts using forgiving parsing: + // https://drafts.csswg.org/selectors/#forgiving-selector + // like the `:has()` pseudo-class: + // https://drafts.csswg.org/selectors/#relational + // `CSS.supports` is still expected to return `false` then: + // https://drafts.csswg.org/css-conditional-4/#typedef-supports-selector-fn + // https://drafts.csswg.org/css-conditional-4/#dfn-support-selector + if ( support.cssSupportsSelector && + + // eslint-disable-next-line no-undef + !CSS.supports( "selector(:is(" + newSelector + "))" ) ) { + + // Support: IE 11+ + // Throw to get to the same code path as an error directly in qSA. + // Note: once we only support browser supporting + // `CSS.supports('selector(...)')`, we can most likely drop + // the `try-catch`. IE doesn't implement the API. + throw new Error(); + } + + push.apply( results, + newContext.querySelectorAll( newSelector ) + ); + return results; + } catch ( qsaError ) { + nonnativeSelectorCache( selector, true ); + } finally { + if ( nid === expando ) { + context.removeAttribute( "id" ); + } + } + } + } + } + + // All others + return select( selector.replace( rtrim, "$1" ), context, results, seed ); +} + +/** + * Create key-value caches of limited size + * @returns {function(string, object)} Returns the Object data after storing it on itself with + * property name the (space-suffixed) string and (if the cache is larger than Expr.cacheLength) + * deleting the oldest entry + */ +function createCache() { + var keys = []; + + function cache( key, value ) { + + // Use (key + " ") to avoid collision with native prototype properties (see Issue #157) + if ( keys.push( key + " " ) > Expr.cacheLength ) { + + // Only keep the most recent entries + delete cache[ keys.shift() ]; + } + return ( cache[ key + " " ] = value ); + } + return cache; +} + +/** + * Mark a function for special use by Sizzle + * @param {Function} fn The function to mark + */ +function markFunction( fn ) { + fn[ expando ] = true; + return fn; +} + +/** + * Support testing using an element + * @param {Function} fn Passed the created element and returns a boolean result + */ +function assert( fn ) { + var el = document.createElement( "fieldset" ); + + try { + return !!fn( el ); + } catch ( e ) { + return false; + } finally { + + // Remove from its parent by default + if ( el.parentNode ) { + el.parentNode.removeChild( el ); + } + + // release memory in IE + el = null; + } +} + +/** + * Adds the same handler for all of the specified attrs + * @param {String} attrs Pipe-separated list of attributes + * @param {Function} handler The method that will be applied + */ +function addHandle( attrs, handler ) { + var arr = attrs.split( "|" ), + i = arr.length; + + while ( i-- ) { + Expr.attrHandle[ arr[ i ] ] = handler; + } +} + +/** + * Checks document order of two siblings + * @param {Element} a + * @param {Element} b + * @returns {Number} Returns less than 0 if a precedes b, greater than 0 if a follows b + */ +function siblingCheck( a, b ) { + var cur = b && a, + diff = cur && a.nodeType === 1 && b.nodeType === 1 && + a.sourceIndex - b.sourceIndex; + + // Use IE sourceIndex if available on both nodes + if ( diff ) { + return diff; + } + + // Check if b follows a + if ( cur ) { + while ( ( cur = cur.nextSibling ) ) { + if ( cur === b ) { + return -1; + } + } + } + + return a ? 1 : -1; +} + +/** + * Returns a function to use in pseudos for input types + * @param {String} type + */ +function createInputPseudo( type ) { + return function( elem ) { + var name = elem.nodeName.toLowerCase(); + return name === "input" && elem.type === type; + }; +} + +/** + * Returns a function to use in pseudos for buttons + * @param {String} type + */ +function createButtonPseudo( type ) { + return function( elem ) { + var name = elem.nodeName.toLowerCase(); + return ( name === "input" || name === "button" ) && elem.type === type; + }; +} + +/** + * Returns a function to use in pseudos for :enabled/:disabled + * @param {Boolean} disabled true for :disabled; false for :enabled + */ +function createDisabledPseudo( disabled ) { + + // Known :disabled false positives: fieldset[disabled] > legend:nth-of-type(n+2) :can-disable + return function( elem ) { + + // Only certain elements can match :enabled or :disabled + // https://html.spec.whatwg.org/multipage/scripting.html#selector-enabled + // https://html.spec.whatwg.org/multipage/scripting.html#selector-disabled + if ( "form" in elem ) { + + // Check for inherited disabledness on relevant non-disabled elements: + // * listed form-associated elements in a disabled fieldset + // https://html.spec.whatwg.org/multipage/forms.html#category-listed + // https://html.spec.whatwg.org/multipage/forms.html#concept-fe-disabled + // * option elements in a disabled optgroup + // https://html.spec.whatwg.org/multipage/forms.html#concept-option-disabled + // All such elements have a "form" property. + if ( elem.parentNode && elem.disabled === false ) { + + // Option elements defer to a parent optgroup if present + if ( "label" in elem ) { + if ( "label" in elem.parentNode ) { + return elem.parentNode.disabled === disabled; + } else { + return elem.disabled === disabled; + } + } + + // Support: IE 6 - 11 + // Use the isDisabled shortcut property to check for disabled fieldset ancestors + return elem.isDisabled === disabled || + + // Where there is no isDisabled, check manually + /* jshint -W018 */ + elem.isDisabled !== !disabled && + inDisabledFieldset( elem ) === disabled; + } + + return elem.disabled === disabled; + + // Try to winnow out elements that can't be disabled before trusting the disabled property. + // Some victims get caught in our net (label, legend, menu, track), but it shouldn't + // even exist on them, let alone have a boolean value. + } else if ( "label" in elem ) { + return elem.disabled === disabled; + } + + // Remaining elements are neither :enabled nor :disabled + return false; + }; +} + +/** + * Returns a function to use in pseudos for positionals + * @param {Function} fn + */ +function createPositionalPseudo( fn ) { + return markFunction( function( argument ) { + argument = +argument; + return markFunction( function( seed, matches ) { + var j, + matchIndexes = fn( [], seed.length, argument ), + i = matchIndexes.length; + + // Match elements found at the specified indexes + while ( i-- ) { + if ( seed[ ( j = matchIndexes[ i ] ) ] ) { + seed[ j ] = !( matches[ j ] = seed[ j ] ); + } + } + } ); + } ); +} + +/** + * Checks a node for validity as a Sizzle context + * @param {Element|Object=} context + * @returns {Element|Object|Boolean} The input node if acceptable, otherwise a falsy value + */ +function testContext( context ) { + return context && typeof context.getElementsByTagName !== "undefined" && context; +} + +// Expose support vars for convenience +support = Sizzle.support = {}; + +/** + * Detects XML nodes + * @param {Element|Object} elem An element or a document + * @returns {Boolean} True iff elem is a non-HTML XML node + */ +isXML = Sizzle.isXML = function( elem ) { + var namespace = elem && elem.namespaceURI, + docElem = elem && ( elem.ownerDocument || elem ).documentElement; + + // Support: IE <=8 + // Assume HTML when documentElement doesn't yet exist, such as inside loading iframes + // https://bugs.jquery.com/ticket/4833 + return !rhtml.test( namespace || docElem && docElem.nodeName || "HTML" ); +}; + +/** + * Sets document-related variables once based on the current document + * @param {Element|Object} [doc] An element or document object to use to set the document + * @returns {Object} Returns the current document + */ +setDocument = Sizzle.setDocument = function( node ) { + var hasCompare, subWindow, + doc = node ? node.ownerDocument || node : preferredDoc; + + // Return early if doc is invalid or already selected + // Support: IE 11+, Edge 17 - 18+ + // IE/Edge sometimes throw a "Permission denied" error when strict-comparing + // two documents; shallow comparisons work. + // eslint-disable-next-line eqeqeq + if ( doc == document || doc.nodeType !== 9 || !doc.documentElement ) { + return document; + } + + // Update global variables + document = doc; + docElem = document.documentElement; + documentIsHTML = !isXML( document ); + + // Support: IE 9 - 11+, Edge 12 - 18+ + // Accessing iframe documents after unload throws "permission denied" errors (jQuery #13936) + // Support: IE 11+, Edge 17 - 18+ + // IE/Edge sometimes throw a "Permission denied" error when strict-comparing + // two documents; shallow comparisons work. + // eslint-disable-next-line eqeqeq + if ( preferredDoc != document && + ( subWindow = document.defaultView ) && subWindow.top !== subWindow ) { + + // Support: IE 11, Edge + if ( subWindow.addEventListener ) { + subWindow.addEventListener( "unload", unloadHandler, false ); + + // Support: IE 9 - 10 only + } else if ( subWindow.attachEvent ) { + subWindow.attachEvent( "onunload", unloadHandler ); + } + } + + // Support: IE 8 - 11+, Edge 12 - 18+, Chrome <=16 - 25 only, Firefox <=3.6 - 31 only, + // Safari 4 - 5 only, Opera <=11.6 - 12.x only + // IE/Edge & older browsers don't support the :scope pseudo-class. + // Support: Safari 6.0 only + // Safari 6.0 supports :scope but it's an alias of :root there. + support.scope = assert( function( el ) { + docElem.appendChild( el ).appendChild( document.createElement( "div" ) ); + return typeof el.querySelectorAll !== "undefined" && + !el.querySelectorAll( ":scope fieldset div" ).length; + } ); + + // Support: Chrome 105+, Firefox 104+, Safari 15.4+ + // Make sure forgiving mode is not used in `CSS.supports( "selector(...)" )`. + // + // `:is()` uses a forgiving selector list as an argument and is widely + // implemented, so it's a good one to test against. + support.cssSupportsSelector = assert( function() { + /* eslint-disable no-undef */ + + return CSS.supports( "selector(*)" ) && + + // Support: Firefox 78-81 only + // In old Firefox, `:is()` didn't use forgiving parsing. In that case, + // fail this test as there's no selector to test against that. + // `CSS.supports` uses unforgiving parsing + document.querySelectorAll( ":is(:jqfake)" ) && + + // `*` is needed as Safari & newer Chrome implemented something in between + // for `:has()` - it throws in `qSA` if it only contains an unsupported + // argument but multiple ones, one of which is supported, are fine. + // We want to play safe in case `:is()` gets the same treatment. + !CSS.supports( "selector(:is(*,:jqfake))" ); + + /* eslint-enable */ + } ); + + /* Attributes + ---------------------------------------------------------------------- */ + + // Support: IE<8 + // Verify that getAttribute really returns attributes and not properties + // (excepting IE8 booleans) + support.attributes = assert( function( el ) { + el.className = "i"; + return !el.getAttribute( "className" ); + } ); + + /* getElement(s)By* + ---------------------------------------------------------------------- */ + + // Check if getElementsByTagName("*") returns only elements + support.getElementsByTagName = assert( function( el ) { + el.appendChild( document.createComment( "" ) ); + return !el.getElementsByTagName( "*" ).length; + } ); + + // Support: IE<9 + support.getElementsByClassName = rnative.test( document.getElementsByClassName ); + + // Support: IE<10 + // Check if getElementById returns elements by name + // The broken getElementById methods don't pick up programmatically-set names, + // so use a roundabout getElementsByName test + support.getById = assert( function( el ) { + docElem.appendChild( el ).id = expando; + return !document.getElementsByName || !document.getElementsByName( expando ).length; + } ); + + // ID filter and find + if ( support.getById ) { + Expr.filter[ "ID" ] = function( id ) { + var attrId = id.replace( runescape, funescape ); + return function( elem ) { + return elem.getAttribute( "id" ) === attrId; + }; + }; + Expr.find[ "ID" ] = function( id, context ) { + if ( typeof context.getElementById !== "undefined" && documentIsHTML ) { + var elem = context.getElementById( id ); + return elem ? [ elem ] : []; + } + }; + } else { + Expr.filter[ "ID" ] = function( id ) { + var attrId = id.replace( runescape, funescape ); + return function( elem ) { + var node = typeof elem.getAttributeNode !== "undefined" && + elem.getAttributeNode( "id" ); + return node && node.value === attrId; + }; + }; + + // Support: IE 6 - 7 only + // getElementById is not reliable as a find shortcut + Expr.find[ "ID" ] = function( id, context ) { + if ( typeof context.getElementById !== "undefined" && documentIsHTML ) { + var node, i, elems, + elem = context.getElementById( id ); + + if ( elem ) { + + // Verify the id attribute + node = elem.getAttributeNode( "id" ); + if ( node && node.value === id ) { + return [ elem ]; + } + + // Fall back on getElementsByName + elems = context.getElementsByName( id ); + i = 0; + while ( ( elem = elems[ i++ ] ) ) { + node = elem.getAttributeNode( "id" ); + if ( node && node.value === id ) { + return [ elem ]; + } + } + } + + return []; + } + }; + } + + // Tag + Expr.find[ "TAG" ] = support.getElementsByTagName ? + function( tag, context ) { + if ( typeof context.getElementsByTagName !== "undefined" ) { + return context.getElementsByTagName( tag ); + + // DocumentFragment nodes don't have gEBTN + } else if ( support.qsa ) { + return context.querySelectorAll( tag ); + } + } : + + function( tag, context ) { + var elem, + tmp = [], + i = 0, + + // By happy coincidence, a (broken) gEBTN appears on DocumentFragment nodes too + results = context.getElementsByTagName( tag ); + + // Filter out possible comments + if ( tag === "*" ) { + while ( ( elem = results[ i++ ] ) ) { + if ( elem.nodeType === 1 ) { + tmp.push( elem ); + } + } + + return tmp; + } + return results; + }; + + // Class + Expr.find[ "CLASS" ] = support.getElementsByClassName && function( className, context ) { + if ( typeof context.getElementsByClassName !== "undefined" && documentIsHTML ) { + return context.getElementsByClassName( className ); + } + }; + + /* QSA/matchesSelector + ---------------------------------------------------------------------- */ + + // QSA and matchesSelector support + + // matchesSelector(:active) reports false when true (IE9/Opera 11.5) + rbuggyMatches = []; + + // qSa(:focus) reports false when true (Chrome 21) + // We allow this because of a bug in IE8/9 that throws an error + // whenever `document.activeElement` is accessed on an iframe + // So, we allow :focus to pass through QSA all the time to avoid the IE error + // See https://bugs.jquery.com/ticket/13378 + rbuggyQSA = []; + + if ( ( support.qsa = rnative.test( document.querySelectorAll ) ) ) { + + // Build QSA regex + // Regex strategy adopted from Diego Perini + assert( function( el ) { + + var input; + + // Select is set to empty string on purpose + // This is to test IE's treatment of not explicitly + // setting a boolean content attribute, + // since its presence should be enough + // https://bugs.jquery.com/ticket/12359 + docElem.appendChild( el ).innerHTML = "" + + ""; + + // Support: IE8, Opera 11-12.16 + // Nothing should be selected when empty strings follow ^= or $= or *= + // The test attribute must be unknown in Opera but "safe" for WinRT + // https://msdn.microsoft.com/en-us/library/ie/hh465388.aspx#attribute_section + if ( el.querySelectorAll( "[msallowcapture^='']" ).length ) { + rbuggyQSA.push( "[*^$]=" + whitespace + "*(?:''|\"\")" ); + } + + // Support: IE8 + // Boolean attributes and "value" are not treated correctly + if ( !el.querySelectorAll( "[selected]" ).length ) { + rbuggyQSA.push( "\\[" + whitespace + "*(?:value|" + booleans + ")" ); + } + + // Support: Chrome<29, Android<4.4, Safari<7.0+, iOS<7.0+, PhantomJS<1.9.8+ + if ( !el.querySelectorAll( "[id~=" + expando + "-]" ).length ) { + rbuggyQSA.push( "~=" ); + } + + // Support: IE 11+, Edge 15 - 18+ + // IE 11/Edge don't find elements on a `[name='']` query in some cases. + // Adding a temporary attribute to the document before the selection works + // around the issue. + // Interestingly, IE 10 & older don't seem to have the issue. + input = document.createElement( "input" ); + input.setAttribute( "name", "" ); + el.appendChild( input ); + if ( !el.querySelectorAll( "[name='']" ).length ) { + rbuggyQSA.push( "\\[" + whitespace + "*name" + whitespace + "*=" + + whitespace + "*(?:''|\"\")" ); + } + + // Webkit/Opera - :checked should return selected option elements + // http://www.w3.org/TR/2011/REC-css3-selectors-20110929/#checked + // IE8 throws error here and will not see later tests + if ( !el.querySelectorAll( ":checked" ).length ) { + rbuggyQSA.push( ":checked" ); + } + + // Support: Safari 8+, iOS 8+ + // https://bugs.webkit.org/show_bug.cgi?id=136851 + // In-page `selector#id sibling-combinator selector` fails + if ( !el.querySelectorAll( "a#" + expando + "+*" ).length ) { + rbuggyQSA.push( ".#.+[+~]" ); + } + + // Support: Firefox <=3.6 - 5 only + // Old Firefox doesn't throw on a badly-escaped identifier. + // el.querySelectorAll( "\\\f" ); + // rbuggyQSA.push( "[\\r\\n\\f]" ); + } ); + + assert( function( el ) { + el.innerHTML = "" + + ""; + + // Support: Windows 8 Native Apps + // The type and name attributes are restricted during .innerHTML assignment + var input = document.createElement( "input" ); + input.setAttribute( "type", "hidden" ); + el.appendChild( input ).setAttribute( "name", "D" ); + + // Support: IE8 + // Enforce case-sensitivity of name attribute + if ( el.querySelectorAll( "[name=d]" ).length ) { + rbuggyQSA.push( "name" + whitespace + "*[*^$|!~]?=" ); + } + + // FF 3.5 - :enabled/:disabled and hidden elements (hidden elements are still enabled) + // IE8 throws error here and will not see later tests + if ( el.querySelectorAll( ":enabled" ).length !== 2 ) { + rbuggyQSA.push( ":enabled", ":disabled" ); + } + + // Support: IE9-11+ + // IE's :disabled selector does not pick up the children of disabled fieldsets + docElem.appendChild( el ).disabled = true; + if ( el.querySelectorAll( ":disabled" ).length !== 2 ) { + rbuggyQSA.push( ":enabled", ":disabled" ); + } + + // Support: Opera 10 - 11 only + // Opera 10-11 does not throw on post-comma invalid pseudos + // el.querySelectorAll( "*,:x" ); + // rbuggyQSA.push( ",.*:" ); + } ); + } + + if ( ( support.matchesSelector = rnative.test( ( matches = docElem.matches || + docElem.webkitMatchesSelector || + docElem.mozMatchesSelector || + docElem.oMatchesSelector || + docElem.msMatchesSelector ) ) ) ) { + + assert( function( el ) { + + // Check to see if it's possible to do matchesSelector + // on a disconnected node (IE 9) + support.disconnectedMatch = matches.call( el, "*" ); + + // This should fail with an exception + // Gecko does not error, returns false instead + // matches.call( el, "[s!='']:x" ); + // rbuggyMatches.push( "!=", pseudos ); + } ); + } + + if ( !support.cssSupportsSelector ) { + + // Support: Chrome 105+, Safari 15.4+ + // `:has()` uses a forgiving selector list as an argument so our regular + // `try-catch` mechanism fails to catch `:has()` with arguments not supported + // natively like `:has(:contains("Foo"))`. Where supported & spec-compliant, + // we now use `CSS.supports("selector(:is(SELECTOR_TO_BE_TESTED))")`, but + // outside that we mark `:has` as buggy. + rbuggyQSA.push( ":has" ); + } + + rbuggyQSA = rbuggyQSA.length && new RegExp( rbuggyQSA.join( "|" ) ); + rbuggyMatches = rbuggyMatches.length && new RegExp( rbuggyMatches.join( "|" ) ); + + /* Contains + ---------------------------------------------------------------------- */ + hasCompare = rnative.test( docElem.compareDocumentPosition ); + + // Element contains another + // Purposefully self-exclusive + // As in, an element does not contain itself + contains = hasCompare || rnative.test( docElem.contains ) ? + function( a, b ) { + + // Support: IE <9 only + // IE doesn't have `contains` on `document` so we need to check for + // `documentElement` presence. + // We need to fall back to `a` when `documentElement` is missing + // as `ownerDocument` of elements within ` + diff --git a/addons_extensions/hr_recruitment_web_app/views/recruitment.xml b/addons_extensions/hr_recruitment_web_app/views/recruitment.xml new file mode 100644 index 000000000..d89d9f291 --- /dev/null +++ b/addons_extensions/hr_recruitment_web_app/views/recruitment.xml @@ -0,0 +1,19 @@ + + + + My WebApp + ir.actions.act_url + /myATS + new + + + + + + + \ No newline at end of file diff --git a/addons_extensions/hr_recruitment_web_app/views/recruitmnet_doc_upload_wizard.xml b/addons_extensions/hr_recruitment_web_app/views/recruitmnet_doc_upload_wizard.xml new file mode 100644 index 000000000..55aca1669 --- /dev/null +++ b/addons_extensions/hr_recruitment_web_app/views/recruitmnet_doc_upload_wizard.xml @@ -0,0 +1,50 @@ + + + + recruitment.doc.upload.wizard.form + recruitment.doc.upload.wizard + +
+ + + + + + + + +