diff --git a/addons_extensions/document_parser/models/document_parser_service.py b/addons_extensions/document_parser/models/document_parser_service.py index f59727146..ff5d8da16 100644 --- a/addons_extensions/document_parser/models/document_parser_service.py +++ b/addons_extensions/document_parser/models/document_parser_service.py @@ -269,9 +269,6 @@ RULES: - Scan the entire document carefully before answering. - Extract ONLY what exists in text. - FOR ANY DATES CHANGE FORMAT TO %Y-%m-%d -- For list fields, always return an array. Return [] when no explicit entries exist. -- For list fields that describe objects, return one object per real document entry, not a sentence blob. -- Do not skip education, employer, project, certification, or family sections if they appear later in the document. FIELD RULES: - If "skills" exists, extract only explicit technical skills written in the document. @@ -286,9 +283,6 @@ FIELD RULES: - If "name" exists, prefer the full name at the top and exclude titles, companies, and addresses. - If "phone" exists, return the most complete phone number found. - If "experience" exists, return only clearly supported numeric values. -- If "education_history" exists, extract every education row/section with education_type, specialization, university, start_year, end_year, and marks_or_grade. -- If "employer_history" exists, extract every employer/project/work-experience entry with company_name, designation, date_of_joining, last_working_day, ctc, and work_description. -- If "family_details" exists, extract only explicitly written family members with relation_type, name, contact_no, dob, and location. Schema: {schema_text} @@ -307,7 +301,7 @@ Document: "model": model, "messages": [{"role": "user", "content": prompt}], "temperature": 0, - "max_tokens": 4000, + "max_tokens": 1500, } try: response = requests.post(endpoint, headers=headers, json=payload, timeout=90) diff --git a/addons_extensions/employee_bridge/__init__.py b/addons_extensions/employee_bridge/__init__.py new file mode 100644 index 000000000..b6de22769 --- /dev/null +++ b/addons_extensions/employee_bridge/__init__.py @@ -0,0 +1,3 @@ +from . import models +from . import controllers +from . import wizards diff --git a/addons_extensions/employee_bridge/__manifest__.py b/addons_extensions/employee_bridge/__manifest__.py new file mode 100644 index 000000000..2bf145920 --- /dev/null +++ b/addons_extensions/employee_bridge/__manifest__.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +{ + "name": "Onboarding", + "summary": "Employee-side onboarding bridge for JOD and onboarding links", + "description": """ + Provides an employee-owned bridge record that can be used without + installing recruitment or ATS modules. + """, + "author": "FTPROTECH", + "website": "https://www.ftprotech.com", + "category": "Human Resources", + "version": "1.0", + "depends": [ + "hr_employee_extended", + "mail", + "website", + ], + "data": [ + "data/ir_sequence_data.xml", + "data/onboarding_attachment_data.xml", + "data/mail_template.xml", + "data/template.xml", + "security/ir.model.access.csv", + "wizards/employee_bridge_attachment_wizard.xml", + "views/recruitment_employee_bridge_views.xml", + "views/onboarding_attachment_views.xml", + "views/hr_employee_views.xml", + ], + "license": "LGPL-3", + "assets": { + "web.assets_frontend": [ + "employee_bridge/static/src/js/post_onboarding_form.js", + ], + }, + "application": False, + "installable": True, +} diff --git a/addons_extensions/employee_bridge/controllers/__init__.py b/addons_extensions/employee_bridge/controllers/__init__.py new file mode 100644 index 000000000..12a7e529b --- /dev/null +++ b/addons_extensions/employee_bridge/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/addons_extensions/employee_bridge/controllers/main.py b/addons_extensions/employee_bridge/controllers/main.py new file mode 100644 index 000000000..8d2dafa5b --- /dev/null +++ b/addons_extensions/employee_bridge/controllers/main.py @@ -0,0 +1,43 @@ +from odoo import http +from odoo.http import request +from odoo.addons.website.controllers.main import Website # <-- ADD THIS IMPORT + + +# CHANGE INHERITANCE: http.Controller -> Website +class EmployeeBridgeController(Website): + + def _get_bridge(self, token): + return request.env["recruitment.employee.bridge"].sudo().search([ + ("access_token", "=", token), + ("active", "=", True), + ], limit=1) + + def _render_form(self, bridge): + # Removed the 'message' variable as we will use a separate template for success + return request.render("employee_bridge.employee_bridge_jod_form_template", { + "bridge": bridge, + }) + + @http.route("/employee_bridge/jod/", type="http", auth="public", csrf=False, website=True) + def employee_bridge_jod(self, token, **post): + bridge = self._get_bridge(token) + if not bridge: + return request.not_found() + + if request.httprequest.method == "POST": + bridge.write_from_public_form(post) + + # RENDER A SEPARATE THANK YOU TEMPLATE INSTEAD OF THE FORM + return request.render("employee_bridge.employee_bridge_thank_you_template", { + "bridge": bridge, + }) + + return self._render_form(bridge) + + @http.route("/employee_bridge/fetch_related_state_ids", type="json", auth="public", website=True) + def fetch_related_state_ids(self, country_id=None): + states = request.env["res.country.state"].sudo() + return { + state.id: state.name + for state in states.search([("country_id", "=?", country_id)]) + } \ No newline at end of file diff --git a/addons_extensions/employee_bridge/data/ir_sequence_data.xml b/addons_extensions/employee_bridge/data/ir_sequence_data.xml new file mode 100644 index 000000000..148b091b7 --- /dev/null +++ b/addons_extensions/employee_bridge/data/ir_sequence_data.xml @@ -0,0 +1,10 @@ + + + + Onboarding + recruitment.employee.bridge + EB/ + 5 + + + diff --git a/addons_extensions/employee_bridge/data/mail_template.xml b/addons_extensions/employee_bridge/data/mail_template.xml new file mode 100644 index 000000000..50ec5f84e --- /dev/null +++ b/addons_extensions/employee_bridge/data/mail_template.xml @@ -0,0 +1,41 @@ + + + + JOD Form Email + + {{ user.email_formatted or object.company_id.email or '' }} + {{ object.work_email or object.private_email or object.employee_id.work_email or '' }} + Joining Onboarding Form - {{ object.employee_name or object.employee_id.name or '' }} + +
+

Dear ,

+

Welcome to .

+

+ Please submit your employee joining details using the link below. +

+

+ + Open Employee Joining Form + +

+ +

Attachments to Request

+ +

Personal Documents:

+
+ +

Education Documents:

+
+ +

Previous Employer Documents:

+
+ +

Other Documents:

+
+
+

Regards,
HR Team

+
+
+
+
diff --git a/addons_extensions/employee_bridge/data/onboarding_attachment_data.xml b/addons_extensions/employee_bridge/data/onboarding_attachment_data.xml new file mode 100644 index 000000000..403dd963a --- /dev/null +++ b/addons_extensions/employee_bridge/data/onboarding_attachment_data.xml @@ -0,0 +1,44 @@ + + + + Aadhaar Card + personal + 1 + + + + PAN Card + personal + 1 + + + + Passport Size Photo + personal + 1 + + + + Education Certificates + education + 1 + + + + Experience / Relieving Letter + previous_employer + 1 + + + + Last Three Months Payslips + previous_employer + 1 + + + + Cancelled Cheque / Bank Proof + others + 1 + + diff --git a/addons_extensions/employee_bridge/data/template.xml b/addons_extensions/employee_bridge/data/template.xml new file mode 100644 index 000000000..08f4e8544 --- /dev/null +++ b/addons_extensions/employee_bridge/data/template.xml @@ -0,0 +1,1690 @@ + + + + + + + diff --git a/addons_extensions/employee_bridge/models/__init__.py b/addons_extensions/employee_bridge/models/__init__.py new file mode 100644 index 000000000..8825e9eac --- /dev/null +++ b/addons_extensions/employee_bridge/models/__init__.py @@ -0,0 +1,3 @@ +from . import recruitment_employee_bridge +from . import hr_employee +from . import onboarding_documents diff --git a/addons_extensions/employee_bridge/models/hr_employee.py b/addons_extensions/employee_bridge/models/hr_employee.py new file mode 100644 index 000000000..e14a61b64 --- /dev/null +++ b/addons_extensions/employee_bridge/models/hr_employee.py @@ -0,0 +1,64 @@ +from odoo import _, fields, models +from odoo.exceptions import ValidationError + + +class HrEmployee(models.Model): + _inherit = "hr.employee" + + recruitment_bridge_ids = fields.One2many( + "recruitment.employee.bridge", + "employee_id", + string="Onboarding Records", + ) + recruitment_bridge_count = fields.Integer(compute="_compute_recruitment_bridge_count") + + def _compute_recruitment_bridge_count(self): + data = self.env["recruitment.employee.bridge"].read_group( + [("employee_id", "in", self.ids), ("active", "=", True)], + ["employee_id"], + ["employee_id"], + ) + counts = {item["employee_id"][0]: item["employee_id_count"] for item in data} + for employee in self: + employee.recruitment_bridge_count = counts.get(employee.id, 0) + + def _get_or_create_employee_bridge(self): + self.ensure_one() + bridge = self.env["recruitment.employee.bridge"].search( + [("employee_id", "=", self.id), ("active", "=", True)], + limit=1, + order="id desc", + ) + if not bridge: + bridge = self.env["recruitment.employee.bridge"].create({ + "employee_id": self.id, + "company_id": self.company_id.id or self.env.company.id, + "source": "jod", + "state": "draft", + }) + return bridge + + def send_jod_form_to_employee(self): + self.ensure_one() + bridge = self._get_or_create_employee_bridge() + bridge.action_send_jod_form() + return { + "name": _("Onboarding"), + "type": "ir.actions.act_window", + "res_model": "recruitment.employee.bridge", + "view_mode": "form", + "res_id": bridge.id, + } + + def action_open_recruitment_bridge(self): + self.ensure_one() + bridge = self._get_or_create_employee_bridge() + if not bridge: + raise ValidationError(_("No onboarding record found.")) + return { + "name": _("Onboarding"), + "type": "ir.actions.act_window", + "res_model": "recruitment.employee.bridge", + "view_mode": "form", + "res_id": bridge.id, + } diff --git a/addons_extensions/employee_bridge/models/onboarding_documents.py b/addons_extensions/employee_bridge/models/onboarding_documents.py new file mode 100644 index 000000000..2c9c72ce3 --- /dev/null +++ b/addons_extensions/employee_bridge/models/onboarding_documents.py @@ -0,0 +1,69 @@ +from odoo import fields, models + + +class EmployeeBridgeRequestedAttachment(models.Model): + _name = "employee.bridge.requested.attachment" + _description = "Onboarding Requested Attachment" + + name = fields.Char(required=True) + attachment_type = fields.Selection([ + ("personal", "Personal Documents"), + ("education", "Education Documents"), + ("previous_employer", "Previous Employer"), + ("others", "Others"), + ], default="others", required=True) + is_default = fields.Boolean(string="Is Default") + + +class EmployeeBridgeAttachment(models.Model): + _name = "employee.bridge.attachment" + _description = "Onboarding Attachment" + _rec_name = "name" + + name = fields.Char(required=True) + bridge_id = fields.Many2one("recruitment.employee.bridge", required=True, ondelete="cascade") + employee_id = fields.Many2one("hr.employee") + requested_attachment_id = fields.Many2one("employee.bridge.requested.attachment") + attachment_type = fields.Selection(related="requested_attachment_id.attachment_type") + file = fields.Binary(required=True) + file_name = fields.Char() + review_status = fields.Selection([ + ("draft", "Under Review"), + ("pass", "PASS"), + ("fail", "FAIL"), + ], default="draft") + review_comments = fields.Char() + + def action_preview_file(self): + self.ensure_one() + attachment = self.env["ir.attachment"].sudo().create({ + "name": self.file_name or self.name, + "datas": self.file, + "res_model": self._name, + "res_id": self.id, + "type": "binary", + }) + return { + "name": "File Preview", + "type": "ir.actions.act_url", + "url": f"/web/content/{attachment.id}?download=false", + "target": "current", + } + + +class FamilyDetails(models.Model): + _inherit = "family.details" + + bridge_id = fields.Many2one("recruitment.employee.bridge", ondelete="cascade") + + +class EducationHistory(models.Model): + _inherit = "education.history" + + bridge_id = fields.Many2one("recruitment.employee.bridge", ondelete="cascade") + + +class EmployerHistory(models.Model): + _inherit = "employer.history" + + bridge_id = fields.Many2one("recruitment.employee.bridge", ondelete="cascade") diff --git a/addons_extensions/employee_bridge/models/recruitment_employee_bridge.py b/addons_extensions/employee_bridge/models/recruitment_employee_bridge.py new file mode 100644 index 000000000..e6ed3edb8 --- /dev/null +++ b/addons_extensions/employee_bridge/models/recruitment_employee_bridge.py @@ -0,0 +1,559 @@ +import json +from datetime import datetime +from secrets import token_urlsafe + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class RecruitmentEmployeeBridge(models.Model): + _name = "recruitment.employee.bridge" + _description = "Onboarding" + _inherit = ["mail.thread", "mail.activity.mixin"] + _rec_name = "name" + _order = "id desc" + + @api.depends('name', 'employee_id', 'employee_name') + def _compute_display_name(self): + for record in self: + employee_name = record.employee_name or record.employee_id.name or '' + + if employee_name: + record.display_name = f"{record.name} - {employee_name}" + else: + record.display_name = record.name + + name = fields.Char(default="/", copy=False, readonly=True, tracking=True) + active = fields.Boolean(default=True) + employee_id = fields.Many2one( + "hr.employee", + string="Employee", + ondelete="restrict", + tracking=True, + index=True, + ) + company_id = fields.Many2one( + "res.company", + string="Company", + default=lambda self: self.env.company, + required=True, + index=True, + ) + source = fields.Selection( + [ + ("jod", "JOD"), + ("manual", "Manual"), + ("recruitment", "Recruitment"), + ], + default="jod", + required=True, + tracking=True, + ) + state = fields.Selection( + [ + ("draft", "Draft"), + ("jod_sent", "JOD Sent"), + ("jod_received", "JOD Received"), + ("validated", "Validated"), + ], + default="draft", + required=True, + tracking=True, + ) + employee_name = fields.Char(string="Employee Name", tracking=True) + employee_code = fields.Char(string="Employee Code", tracking=True) + candidate_image = fields.Image(string="Photo") + work_email = fields.Char(string="Work Email", tracking=True) + private_email = fields.Char(string="Private Email") + alternate_phone = fields.Char(string="Alternate Mobile") + work_phone = fields.Char(string="Work Phone") + mobile_phone = fields.Char(string="Mobile Phone") + department_id = fields.Many2one("hr.department", string="Department") + job_id = fields.Many2one("hr.job", string="Job Position") + emp_type = fields.Many2one("hr.contract.type", string="Employment Type") + doj = fields.Date(string="Date of Joining") + gender = fields.Selection([ + ("male", "Male"), + ("female", "Female"), + ("other", "Other"), + ]) + birthday = fields.Date() + blood_group = fields.Selection([ + ("A+", "A+"), + ("A-", "A-"), + ("B+", "B+"), + ("B-", "B-"), + ("O+", "O+"), + ("O-", "O-"), + ("AB+", "AB+"), + ("AB-", "AB-"), + ], string="Blood Group") + marital = fields.Selection( + selection=[ + ("single", "Single"), + ("married", "Married"), + ("cohabitant", "Legal Cohabitant"), + ("widower", "Widower"), + ("divorced", "Divorced"), + ], + default="single", + string="Marital Status", + ) + marriage_anniversary_date = fields.Date(string="Anniversary Date") + private_street = fields.Char(string="Private Street") + private_street2 = fields.Char(string="Private Street2") + private_city = fields.Char(string="Private City") + private_state_id = fields.Many2one("res.country.state", string="Private State") + private_zip = fields.Char(string="Private Zip") + private_country_id = fields.Many2one("res.country", string="Private Country") + permanent_street = fields.Char(string="Permanent Street") + permanent_street2 = fields.Char(string="Permanent Street2") + permanent_city = fields.Char(string="Permanent City") + permanent_state_id = fields.Many2one("res.country.state", string="Permanent State") + permanent_zip = fields.Char(string="Permanent Zip") + permanent_country_id = fields.Many2one("res.country", string="Permanent Country") + pan_no = fields.Char(string="PAN No") + identification_id = fields.Char(string="Aadhar No") + previous_company_pf_no = fields.Char(string="Previous Company PF No") + previous_company_uan_no = fields.Char(string="Previous Company UAN No") + passport_no = fields.Char(string="Passport No") + passport_start_date = fields.Date(string="Passport Issued Date") + passport_end_date = fields.Date(string="Passport End Date") + passport_issued_location = fields.Char(string="Passport Issued Location") + full_name_as_in_bank = fields.Char(string="Name as per Bank") + bank_name = fields.Char() + bank_branch = fields.Char() + bank_account_no = fields.Char() + bank_ifsc_code = fields.Char() + requested_attachment_ids = fields.Many2many( + "employee.bridge.requested.attachment", + "employee_bridge_requested_attachment_rel", + "bridge_id", + "attachment_id", + string="Attachments to Request", + ) + joining_attachment_ids = fields.One2many( + "employee.bridge.attachment", + "bridge_id", + string="Attachments", + ) + education_history = fields.One2many("education.history", "bridge_id", string="Education Details") + employer_history = fields.One2many("employer.history", "bridge_id", string="Employer History") + family_details = fields.One2many("family.details", "bridge_id", string="Family Details") + joining_form_link = fields.Char(string="Joining Form Link", copy=False, readonly=True) + access_token = fields.Char(copy=False, readonly=True) + total_exp = fields.Float(string="Total Exp") + + + _sql_constraints = [ + ( + "unique_bridge_employee", + "unique(employee_id)", + "This employee is already connected to an employee bridge.", + ), + ] + + def _get_base_url(self): + return self.env["ir.config_parameter"].sudo().get_param("web.base.url") + + def _ensure_access_token(self): + for bridge in self: + if not bridge.access_token: + bridge.access_token = token_urlsafe(32) + + def _get_joining_form_link(self): + self.ensure_one() + self._ensure_access_token() + return "%s/employee_bridge/jod/%s" % (self._get_base_url(), self.access_token) + + def _group_requested_attachments(self, attachments): + return { + "personal_docs": attachments.filtered(lambda a: a.attachment_type == "personal").mapped("name"), + "education_docs": attachments.filtered(lambda a: a.attachment_type == "education").mapped("name"), + "previous_employer_docs": attachments.filtered(lambda a: a.attachment_type == "previous_employer").mapped("name"), + "other_docs": attachments.filtered(lambda a: a.attachment_type == "others").mapped("name"), + } + + def _send_jod_mail(self, req_attachment_ids=False, email_values=None, email_body=False): + for bridge in self: + email_to = bridge.work_email or bridge.private_email or bridge.employee_id.work_email + if not email_to: + raise ValidationError(_("Add an email on the bridge before sending the JOD form.")) + link = bridge._get_joining_form_link() + bridge.write({ + "joining_form_link": link, + "state": "jod_sent", + }) + attachments = req_attachment_ids or self.env["employee.bridge.requested.attachment"].browse() + template = self.env.ref("employee_bridge.email_template_jod_form", raise_if_not_found=False) + context = { + "joining_form_link": link, + **bridge._group_requested_attachments(attachments), + } + values = dict(email_values or {}) + values.setdefault("email_from", self.env.company.email or self.env.user.email) + values.setdefault("email_to", email_to) + if email_body: + values["body_html"] = email_body + if template: + rendered_subject = template.with_context(**context)._render_field("subject", [bridge.id])[bridge.id] + rendered_body = template.with_context(**context)._render_field( + "body_html", [bridge.id], compute_lang=True + )[bridge.id] + values.setdefault("subject", rendered_subject) + values.setdefault("body_html", rendered_body) + template.sudo().with_context(**context).send_mail(bridge.id, email_values=values, force_send=True) + else: + values.setdefault("subject", _("Joining Onboarding Details")) + values.setdefault("body_html", _( + "

Hello %(name)s,

" + "

Please submit your joining onboarding details using this link:

" + "

Open Joining Form

" + ) % { + "name": bridge.employee_name or bridge.employee_id.name or _("Employee"), + "link": link, + }) + self.env["mail.mail"].sudo().create(values).send() + return True + + def action_send_jod_form(self): + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": _("Select Attachments"), + "res_model": "employee.bridge.attachment.wizard", + "view_mode": "form", + "target": "new", + "context": { + "default_bridge_id": self.id, + }, + } + + def action_open_employee(self): + self.ensure_one() + if not self.employee_id: + raise ValidationError(_("No employee is linked to this bridge.")) + return { + "name": _("Employee"), + "type": "ir.actions.act_window", + "res_model": "hr.employee", + "view_mode": "form", + "res_id": self.employee_id.id, + } + + def action_validate_employee_details(self): + for bridge in self: + if not bridge.employee_id: + raise ValidationError(_("Link or create an employee before validating the JOD details.")) + bridge.employee_id.write(bridge._get_employee_update_vals()) + bridge.education_history.write({"employee_id": bridge.employee_id.id}) + bridge.employer_history.write({"employee_id": bridge.employee_id.id}) + bridge.family_details.write({"employee_id": bridge.employee_id.id}) + bridge.joining_attachment_ids.write({"employee_id": bridge.employee_id.id}) + bridge.state = "validated" + + def _get_employee_create_vals(self): + self.ensure_one() + vals = { + "name": self.employee_name, + "employee_id": self.employee_code, + "work_email": self.work_email, + "private_email": self.private_email, + "work_phone": self.work_phone, + "mobile_phone": self.mobile_phone, + "image_1920": self.candidate_image, + "department_id": self.department_id.id, + "job_id": self.job_id.id, + "job_title": self.job_id.name, + "emp_type": self.emp_type.id, + "company_id": self.company_id.id, + } + vals = {key: value for key, value in vals.items() if value not in (False, "", 0)} + if not vals.get("name"): + raise ValidationError(_("Employee Name is required to create an employee.")) + return vals + + def _get_employee_update_vals(self): + self.ensure_one() + vals = { + "name": self.employee_name, + "employee_id": self.employee_code, + "work_email": self.work_email, + "private_email": self.private_email, + "work_phone": self.work_phone, + "mobile_phone": self.mobile_phone, + "image_1920": self.candidate_image, + "department_id": self.department_id.id, + "job_id": self.job_id.id, + "job_title": self.job_id.name, + "emp_type": self.emp_type.id, + "doj": self.doj, + "gender": self.gender, + "birthday": self.birthday, + "blood_group": self.blood_group, + "marital": self.marital, + "marriage_anniversary_date": self.marriage_anniversary_date, + "private_street": self.private_street, + "private_street2": self.private_street2, + "private_city": self.private_city, + "private_state_id": self.private_state_id.id, + "private_zip": self.private_zip, + "private_country_id": self.private_country_id.id, + "permanent_street": self.permanent_street, + "permanent_street2": self.permanent_street2, + "permanent_city": self.permanent_city, + "permanent_state_id": self.permanent_state_id.id, + "permanent_zip": self.permanent_zip, + "permanent_country_id": self.permanent_country_id.id, + "pan_no": self.pan_no, + "identification_id": self.identification_id, + "previous_company_pf_no": self.previous_company_pf_no, + "previous_company_uan_no": self.previous_company_uan_no, + "passport_id": self.passport_no, + "passport_start_date": self.passport_start_date, + "passport_end_date": self.passport_end_date, + "passport_issued_location": self.passport_issued_location, + } + return { + key: value + for key, value in vals.items() + if key in self.employee_id._fields and value not in (False, "", 0) + } + + def action_create_employee(self): + for bridge in self: + if bridge.employee_id: + continue + bridge.employee_id = self.env["hr.employee"].sudo().create(bridge._get_employee_create_vals()) + return self.action_open_employee() + + def _sync_from_employee(self): + for bridge in self.filtered("employee_id"): + employee = bridge.employee_id + bridge.write({ + "employee_name": bridge.employee_name or employee.name, + "employee_code": bridge.employee_code or employee.employee_id, + "work_email": bridge.work_email or employee.work_email, + "private_email": bridge.private_email or employee.private_email, + "work_phone": bridge.work_phone or employee.work_phone, + "mobile_phone": bridge.mobile_phone or employee.mobile_phone, + "department_id": bridge.department_id.id or employee.department_id.id, + "job_id": bridge.job_id.id or employee.job_id.id, + "emp_type": bridge.emp_type.id or employee.emp_type.id, + "company_id": employee.company_id.id or bridge.company_id.id, + "doj": bridge.doj or employee.doj, + "gender": bridge.gender or employee.gender, + "birthday": bridge.birthday or employee.birthday, + "blood_group": bridge.blood_group or employee.blood_group, + "marital": bridge.marital or employee.marital, + "marriage_anniversary_date": bridge.marriage_anniversary_date or employee.marriage_anniversary_date, + "private_street": bridge.private_street or employee.private_street, + "private_street2": bridge.private_street2 or employee.private_street2, + "private_city": bridge.private_city or employee.private_city, + "private_state_id": bridge.private_state_id.id or employee.private_state_id.id, + "private_zip": bridge.private_zip or employee.private_zip, + "private_country_id": bridge.private_country_id.id or employee.private_country_id.id, + "permanent_street": bridge.permanent_street or employee.permanent_street, + "permanent_street2": bridge.permanent_street2 or employee.permanent_street2, + "permanent_city": bridge.permanent_city or employee.permanent_city, + "permanent_state_id": bridge.permanent_state_id.id or employee.permanent_state_id.id, + "permanent_zip": bridge.permanent_zip or employee.permanent_zip, + "permanent_country_id": bridge.permanent_country_id.id or employee.permanent_country_id.id, + "pan_no": bridge.pan_no or employee.pan_no, + "identification_id": bridge.identification_id or employee.identification_id, + "previous_company_pf_no": bridge.previous_company_pf_no or employee.previous_company_pf_no, + "previous_company_uan_no": bridge.previous_company_uan_no or employee.previous_company_uan_no, + "passport_no": bridge.passport_no or employee.passport_id, + "passport_start_date": bridge.passport_start_date or employee.passport_start_date, + "passport_end_date": bridge.passport_end_date or employee.passport_end_date, + "passport_issued_location": bridge.passport_issued_location or employee.passport_issued_location, + }) + + @api.onchange("employee_id") + def _onchange_employee_id(self): + for bridge in self: + employee = bridge.employee_id + if not employee: + continue + bridge.employee_name = bridge.employee_name or employee.name + bridge.employee_code = bridge.employee_code or employee.employee_id + bridge.work_email = bridge.work_email or employee.work_email + bridge.private_email = bridge.private_email or employee.private_email + bridge.work_phone = bridge.work_phone or employee.work_phone + bridge.mobile_phone = bridge.mobile_phone or employee.mobile_phone + bridge.department_id = bridge.department_id or employee.department_id + bridge.job_id = bridge.job_id or employee.job_id + bridge.emp_type = bridge.emp_type or employee.emp_type + + def write_from_public_form(self, values): + self.ensure_one() + private_state = self.env["res.country.state"].sudo().browse(int(values.get("present_state") or 0)) + permanent_state = self.env["res.country.state"].sudo().browse(int(values.get("permanent_state") or 0)) + mapped_values = dict(values) + mapped_values.update({ + "candidate_image": values.get("candidate_image_base64"), + "private_email": values.get("email_from"), + "mobile_phone": values.get("partner_phone"), + "alternate_phone": values.get("alternate_phone"), + "private_street": values.get("present_street"), + "private_street2": values.get("present_street2"), + "private_city": values.get("present_city"), + "private_zip": values.get("present_zip"), + "private_state_id": private_state.id if private_state else False, + "private_country_id": private_state.country_id.id if private_state else False, + "permanent_state_id": permanent_state.id if permanent_state else False, + "permanent_country_id": permanent_state.country_id.id if permanent_state else False, + }) + allowed_fields = { + "employee_name", + "candidate_image", + "private_email", + "mobile_phone", + "alternate_phone", + "doj", + "gender", + "birthday", + "blood_group", + "marital", + "marriage_anniversary_date", + "private_street", + "private_street2", + "private_city", + "private_state_id", + "private_country_id", + "private_zip", + "permanent_street", + "permanent_street2", + "permanent_city", + "permanent_state_id", + "permanent_country_id", + "permanent_zip", + "full_name_as_in_bank", + "bank_name", + "bank_branch", + "bank_account_no", + "bank_ifsc_code", + "pan_no", + "identification_id", + "previous_company_pf_no", + "previous_company_uan_no", + "passport_no", + "passport_start_date", + "passport_end_date", + "passport_issued_location", + } + bridge_vals = { + field_name: value or False + for field_name, value in mapped_values.items() + if field_name in allowed_fields and field_name in self._fields + } + if bridge_vals: + self.sudo().write(bridge_vals) + self._write_history_from_public_form(values) + self._replace_joining_attachments_from_public_form(values) + self.state = "jod_received" + + def _safe_date(self, value): + try: + return datetime.strptime(value, "%Y-%m-%d").date() if value else False + except (TypeError, ValueError): + return False + + def _safe_int(self, value): + try: + return int(value) if value else False + except (TypeError, ValueError): + return False + + def _json_list(self, values, key): + raw = values.get(key, "[]") + try: + data = json.loads(raw) if raw else [] + except (TypeError, ValueError): + data = [] + return data if isinstance(data, list) else [] + + def _write_history_from_public_form(self, values): + self.ensure_one() + family_values = [] + for member in self._json_list(values, "family_data_json"): + if member.get("name") and member.get("relation"): + family_values.append((0, 0, { + "relation_type": member.get("relation"), + "name": member.get("name"), + "contact_no": member.get("contact"), + "dob": self._safe_date(member.get("dob")), + "location": member.get("location"), + })) + + education_values = [] + for education in self._json_list(values, "education_data_json"): + if education.get("specialization"): + education_values.append((0, 0, { + "education_type": education.get("education_type") or "additional", + "name": education.get("specialization"), + "university": education.get("university"), + "start_year": self._safe_int(education.get("start_year")), + "end_year": self._safe_int(education.get("end_year")), + "marks_or_grade": education.get("marks_or_grade"), + })) + + employer_values = [] + for employer in self._json_list(values, "employer_history_data_json"): + if employer.get("company_name"): + employer_values.append((0, 0, { + "company_name": employer.get("company_name"), + "designation": employer.get("designation"), + "date_of_joining": self._safe_date(employer.get("date_of_joining")), + "last_working_day": self._safe_date(employer.get("last_working_day")), + "ctc": employer.get("ctc"), + })) + + write_vals = {} + if family_values: + write_vals["family_details"] = [(5, 0, 0)] + family_values + if education_values: + write_vals["education_history"] = [(5, 0, 0)] + education_values + if employer_values: + write_vals["employer_history"] = [(5, 0, 0)] + employer_values + if write_vals: + self.sudo().write(write_vals) + + def _replace_joining_attachments_from_public_form(self, values): + self.ensure_one() + attachment_values = [] + seen_files = set() + for item in self._json_list(values, "attachments_data_json"): + attachment = self.env["employee.bridge.requested.attachment"].sudo().browse( + int(item.get("attachment_rec_id") or 0) + ) + file_content = item.get("file_content") + file_name = item.get("file_name") + file_key = (attachment.id, file_name, file_content) + if attachment.exists() and file_content and file_key not in seen_files: + seen_files.add(file_key) + attachment_values.append((0, 0, { + "name": attachment.name, + "requested_attachment_id": attachment.id, + "file": file_content, + "file_name": file_name, + })) + self.sudo().write({"joining_attachment_ids": [(5, 0, 0)] + attachment_values}) + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if vals.get("name", "/") == "/": + vals["name"] = self.env["ir.sequence"].next_by_code("recruitment.employee.bridge") or "/" + if vals.get("employee_id") and not vals.get("company_id"): + employee = self.env["hr.employee"].browse(vals["employee_id"]) + vals["company_id"] = employee.company_id.id or self.env.company.id + bridges = super().create(vals_list) + bridges._sync_from_employee() + return bridges + + def write(self, vals): + res = super().write(vals) + if "employee_id" in vals: + self._sync_from_employee() + return res diff --git a/addons_extensions/employee_bridge/security/ir.model.access.csv b/addons_extensions/employee_bridge/security/ir.model.access.csv new file mode 100644 index 000000000..010db3660 --- /dev/null +++ b/addons_extensions/employee_bridge/security/ir.model.access.csv @@ -0,0 +1,8 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_employee_bridge_user,employee.bridge.user,model_recruitment_employee_bridge,base.group_user,1,1,1,0 +access_employee_bridge_manager,employee.bridge.manager,model_recruitment_employee_bridge,hr.group_hr_manager,1,1,1,1 +access_employee_bridge_requested_attachment_user,employee.bridge.requested.attachment.user,model_employee_bridge_requested_attachment,base.group_user,1,1,1,0 +access_employee_bridge_requested_attachment_manager,employee.bridge.requested.attachment.manager,model_employee_bridge_requested_attachment,hr.group_hr_manager,1,1,1,1 +access_employee_bridge_attachment_user,employee.bridge.attachment.user,model_employee_bridge_attachment,base.group_user,1,1,1,0 +access_employee_bridge_attachment_manager,employee.bridge.attachment.manager,model_employee_bridge_attachment,hr.group_hr_manager,1,1,1,1 +access_employee_bridge_attachment_wizard,employee.bridge.attachment.wizard.user,model_employee_bridge_attachment_wizard,base.group_user,1,1,1,1 diff --git a/addons_extensions/employee_bridge/static/src/js/post_onboarding_form.js b/addons_extensions/employee_bridge/static/src/js/post_onboarding_form.js new file mode 100644 index 000000000..7111428ca --- /dev/null +++ b/addons_extensions/employee_bridge/static/src/js/post_onboarding_form.js @@ -0,0 +1,754 @@ +import publicWidget from "@web/legacy/js/public/public_widget"; +import { _t } from "@web/core/l10n/translation"; +import { rpc } from "@web/core/network/rpc"; +import { assets, loadCSS, loadJS } from "@web/core/assets"; + +publicWidget.registry.employeeBridgeJod = publicWidget.Widget.extend({ + selector: "#post_onboarding_form", + events: { + "change [name='candidate_image']": "previewApplicantPhoto", + "click #delete-photo-btn": "deleteCandidatePhoto", + "click #preview-photo-btn": "previewFullImage", + "click #add-education-row": "addEducationRow", // Ensure button click event is correctly bound + + + + 'change .attachment-input': 'handleAttachmentUpload', + 'click .upload-new-btn': 'handleUploadNewFile', + 'click .delete-file-btn': 'handleDeleteUploadedFile', + 'input .file-name-input': 'handleFileNameChange', + "click .remove-file": "removeFile", + "click .preview-file": "previewFile", + "click .view-attachments-btn": "openAttachmentModal", // Opens modal with files + "click .close-modal-btn": "closeAttachmentModal", // Close modal + "submit": "handleFormSubmit", + "change [name='experience']": "onChangeExperience", + "change [name='marital']": "onChangeMarital", + }, + + + init() { + this._super(...arguments); + this.uploadedFiles = {}; // Store files per attachment ID for this form only. + this.isSubmitting = false; + }, + + _getFileSignature(file) { + return [file.name, file.size, file.lastModified, file.type].join("|"); + }, + + addUploadedFileRow(attachmentId, file, base64String) { + console.log("addUploadedFileRow", attachmentId, file.name); + const tableBody = this.$(`#preview_body_${attachmentId}`); + + if (!this.uploadedFiles[attachmentId]) { + this.uploadedFiles[attachmentId] = []; + } + + const fileSignature = this._getFileSignature(file); + const isDuplicate = this.uploadedFiles[attachmentId].some( + uploadedFile => uploadedFile.signature === fileSignature + ); + if (isDuplicate) { + return; + } + + // Generate a unique file ID using attachmentId and a timestamp + const fileId = `${attachmentId}-${Date.now()}-${this.uploadedFiles[attachmentId].length}`; + + const fileRecord = { + attachment_rec_id : attachmentId, + id: fileId, // Unique file ID + name: file.name, + base64: base64String, + type: file.type, + signature: fileSignature, + }; + + this.uploadedFiles[attachmentId].push(fileRecord); + + const fileIndex = this.uploadedFiles[attachmentId].length - 1; + const previewImageId = `preview_image_${fileId}`; + const fileNameInputId = `file_name_input_${fileId}`; + let previewContent = ''; + let previewClickHandler = ''; + + // Check if the file is an image or PDF and set preview content accordingly + if (file.type.startsWith('image/')) { + previewContent = `
+ +
`; + previewClickHandler = () => { + this.$('#modal_attachment_photo_preview').attr('src', `data:image/png;base64,${base64String}`); + this.$('#modal_attachment_photo_preview').show(); + this.$('#modal_attachment_pdf_preview').hide(); // Hide PDF preview + this.$('#attachmentPreviewModal').modal('show'); + }; + } else if (file.type === 'application/pdf') { + previewContent = `
+ +
`; + previewClickHandler = () => { + this.$('#modal_attachment_pdf_preview').attr('src', `data:application/pdf;base64,${base64String}`); + this.$('#modal_attachment_pdf_preview').show(); + this.$('#modal_attachment_photo_preview').hide(); // Hide image preview + this.$('#attachmentPreviewModal').modal('show'); + }; + } + + // Append new row to the table with a preview and buttons + tableBody.append(` + + + + + +
+ ${previewContent} +
+ + +
+
+ + + `); + + this.$(`#preview_table_container_${attachmentId}`).removeClass('d-none'); + + // Attach click handler for preview (image or PDF) + this.$(`#preview_wrapper_${fileId}`).on('click', previewClickHandler); + + // Attach click handler for the preview button (to trigger modal) + this.$(`.preview-btn[data-attachment-id="${attachmentId}"][data-file-id="${fileId}"]`).on('click', previewClickHandler); + }, + + handleDeleteUploadedFile(ev) { + const button = ev.currentTarget; + const attachmentId = $(button).data('attachment-id'); + const fileId = $(button).data('file-id'); + + // Find the index of the file to delete based on unique file ID + const fileIndex = this.uploadedFiles[attachmentId].findIndex(f => f.id === fileId); + + if (fileIndex !== -1) { + this.uploadedFiles[attachmentId].splice(fileIndex, 1); // Remove from array + } + + // Remove the row from DOM + this.$(`tr[data-file-id="${fileId}"]`).remove(); + + // Hide table if no files left + if (this.uploadedFiles[attachmentId].length === 0) { + this.$(`#preview_table_container_${attachmentId}`).addClass('d-none'); + } + }, + + + handleAttachmentUpload(ev) { + console.log("handleAttachmentUpload"); + const input = ev.target; + const attachmentId = $(input).data('attachment-id'); + + if (input.files.length > 0) { + Array.from(input.files).forEach((file) => { + const reader = new FileReader(); + reader.onload = (e) => { + const base64String = e.target.result.split(',')[1]; + this.addUploadedFileRow(attachmentId, file, base64String); + }; + reader.readAsDataURL(file); + }); + } + $(input).val(''); + }, + + handleUploadNewFile(ev) { + const button = $(ev.currentTarget); + const attachmentId = button.data('attachment-id'); + const index = button.data('index'); + const hiddenInput = this.$(`.upload-new-file-input[data-attachment-id='${attachmentId}']`); + + hiddenInput.off('change').on('change', (e) => { + const file = e.target.files[0]; + if (!file) { + return; + } + + const reader = new FileReader(); + reader.onload = (event) => { + const base64String = event.target.result.split(',')[1]; + this.uploadedFiles[attachmentId][index] = { + name: file.name, + base64: base64String, + }; + + const imageId = `preview_image_${attachmentId}_${index}`; + const filePreviewWrapperId = `preview_wrapper_${attachmentId}_${index}`; + const fileType = file.type; + + if (fileType.startsWith('image/')) { + this.$(`#${filePreviewWrapperId}`).html(` + + `); + } else if (fileType === 'application/pdf') { + this.$(`#${filePreviewWrapperId}`).html(` + + `); + } + + const fileNameInputId = `file_name_input_${attachmentId}_${index}`; + this.$(`#${fileNameInputId}`).val(file.name); + + // CRITICAL FIX: Clear input after reading + $(e.target).val(''); + }; + + reader.readAsDataURL(file); + }); + + hiddenInput.trigger('click'); + }, + + + + handleFileNameChange(event) { + const attachmentId = $(event.target).closest('tr').data('attachment-id'); + const fileId = $(event.target).closest('tr').data('file-id'); + const newFileName = event.target.value; + + if (!attachmentId || !fileId) { + console.error('Missing attachmentId or fileId'); + return; + } + + const fileList = this.uploadedFiles[attachmentId]; + + if (!fileList) { + console.error(`No files found for attachmentId: ${attachmentId}`); + return; + } + + const fileRecord = fileList.find(file => file.id === fileId); + + if (!fileRecord) { + console.error(`File with ID ${fileId} not found under attachment ${attachmentId}`); + return; + } + + fileRecord.name = newFileName; + }, + + + +renderFilePreview(attachmentId) { + const container = this.$(`#preview_container_${attachmentId}`); + container.empty(); + + if (this.uploadedFiles[attachmentId].length === 0) { + container.addClass("d-none"); + return; + } + + container.removeClass("d-none"); + + this.uploadedFiles[attachmentId].forEach((file, index) => { + const fileHtml = $(` +
+
+ + +
+ ${file.name} +
+ `); + + fileHtml.find("img").on("click", this.previewAttachmentImage.bind(this)); + fileHtml.find(".remove-file").on("click", this.removeFile.bind(this)); + + container.append(fileHtml); + }); +}, + +previewAttachmentImage(ev) { + const attachmentId = $(ev.currentTarget).data("attachment-id"); + const index = $(ev.currentTarget).data("index"); + const fileData = this.uploadedFiles[attachmentId][index]; + + this.$("#attachment_modal_preview").attr("src", fileData.base64); + this.$("#attachmentPreviewModal").modal("show"); +}, + +removeFile(ev) { + const attachmentId = $(ev.currentTarget).data("attachment-id"); + const index = $(ev.currentTarget).data("index"); + + this.uploadedFiles[attachmentId].splice(index, 1); + this.renderFilePreview(attachmentId); +}, + + + previewFile(ev) { + const fileUrl = ev.currentTarget.dataset.fileUrl; + const modal = this.$("#photoPreviewModal"); + + this.$("#modal_photo_preview").attr("src", fileUrl); + modal.modal("show"); + }, + + onChangeExperience(event) { + const selectedValue = $(event.currentTarget).val(); + const employerHistorySection = $("#employer_history_data"); + + if (selectedValue === "experienced") { + employerHistorySection.show(); + } else { + employerHistorySection.hide(); + } + }, + + onChangeMarital(event) { + const selectedValue = $(event.currentTarget).val(); + const marriageAnniversarySection = this.$('#marriage_anniversary_date_div') + const family_details_data_spouse = this.$('#family_details_data_spouse') + const family_details_data_kid1 = this.$('#family_details_data_kid1') + const family_details_data_kid2 = this.$('#family_details_data_kid2') + + + if (selectedValue === "married") { + marriageAnniversarySection.show(); + // Show rows for spouse, kid1, and kid2 + family_details_data_spouse.show(); + family_details_data_kid1.show(); + family_details_data_kid2.show(); + } else { + marriageAnniversarySection.hide(); + // Hide rows for spouse, kid1, and kid2 + family_details_data_spouse.hide(); + family_details_data_kid1.hide(); + family_details_data_kid2.hide(); + } + }, + + /** + * Open modal and display uploaded files + */ + openAttachmentModal(event) { + console.log("openAttachmentModal"); + const rowId = $(event.currentTarget).closest("tr").index(); // Get the row index + this.currentRowId = rowId; // Store rowId for reference + this.renderAttachmentModal(rowId); // Render the modal for the row + }, + + renderAttachmentModal(rowId) { + const fileList = this.uploadedFiles && this.uploadedFiles[rowId] ? this.uploadedFiles[rowId] : []; + + let modalHtml = ` + `; + + // Remove old modal and append new one + $("#attachmentModal").remove(); + $("body").append(modalHtml); + + // Attach remove event to new modal content + $("#attachmentModal").on("click", ".remove-file", this.removeFile.bind(this)); + $("#attachmentModal").on("click", ".close-modal-btn", this.closeAttachmentModal.bind(this)); + }, + + /** + * Close attachment modal + */ + closeAttachmentModal() { + console.log("Closing modal"); + $("#attachmentModal").remove(); // Remove the modal from the DOM + }, + + + // Function to preview the uploaded image + previewApplicantPhoto(ev) { + const input = ev.currentTarget; + const preview = this.$("#photo_preview"); + + if (input.files && input.files[0]) { + const reader = new FileReader(); + reader.onload = (e) => { + const base64String = e.target.result.split(",")[1]; // Get only Base64 part + preview.attr("src", e.target.result); + + // Store the base64 in a hidden input field + this.$("input[name='candidate_image_base64']").val(base64String); + }; + reader.readAsDataURL(input.files[0]); + } + }, + + // Function to delete the uploaded image + deleteCandidatePhoto() { + const preview = this.$("#photo_preview"); + const inputFile = this.$("input[name='candidate_image']"); + + preview.attr("src", "data:image/png;base64,"); // Reset preview + inputFile.val(""); // Reset file input + }, + + // Function to preview full image inside a modal + previewFullImage() { + const previewSrc = this.$("#photo_preview").attr("src"); + if (previewSrc) { + this.$("#modal_photo_preview").attr("src", previewSrc); + this.$("#photoPreviewModal").modal("show"); // Use jQuery to show the modal + } + }, + + // Function to add a new education row dynamically + addEducationRow(ev) { + ev.preventDefault(); // Prevent default behavior + + let newRow = ` + + + + + + + + + + + + `; + + this.$("#education_details_data tbody").append(newRow); // Append new row inside the table + console.log("New education row added!"); + }, + + + validateFamilyDetails() { + let familyDetailsFilled = false; + let educationDetailsFilled = false; + const family_rows = document.querySelectorAll('#family_details_data tbody tr'); + const education_rows = document.querySelectorAll('#education_details_data tbody tr'); + + + family_rows.forEach(row => { + const inputs = row.querySelectorAll('input[type="text"], input[type="date"]'); + const isRowFilled = Array.from(inputs).some(input => input.value.trim() !== ""); + if (isRowFilled) { + familyDetailsFilled = true; + } + }); + + education_rows.forEach(row => { + const inputs = row.querySelectorAll('input[type="text"]'); + const isRowFilled = Array.from(inputs).some(input => input.value.trim() !== ""); + if (isRowFilled) { + educationDetailsFilled = true; + } + }); + + if (!familyDetailsFilled) { + alert('Please fill at least one family member detail.'); + return false; + } + + if (!educationDetailsFilled) { + alert('Please fill at least one Education details') + return false; + } + + return true; + }, + +handleFormSubmit(ev) { + ev.preventDefault(); + + if (this.isSubmitting) { + return; + } + + if (!this.validateFamilyDetails()) { + return; + } + + this.isSubmitting = true; + this.$("#submit-btn").prop("disabled", true); + + let employerHistoryData = []; + this.$("#employer_history_data tbody tr").each((index, row) => { + let rowData = { + company_name: this.$(row).find("[name$='company_name']").val()?.trim() || "", + designation: this.$(row).find("[name$='designation']").val()?.trim() || "", + date_of_joining: this.$(row).find("[name$='doj']").val()?.trim() || "", + last_working_day: this.$(row).find("[name$='lwd']").val()?.trim() || "", + ctc: this.$(row).find("[name$='ctc']").val()?.trim() || "", + }; + if (Object.values(rowData).some(value => value)) { + employerHistoryData.push(rowData); + } + }); + + let familyData = []; + this.$("#family_details_data tbody tr").each((index, row) => { + let rowData = { + relation: this.$(row).find(".relation-col").attr("value")?.trim(), + name: this.$(row).find("[name$='_name']").val()?.trim() || "", + contact: this.$(row).find("[name$='_contact']").val()?.trim() || "", + dob: this.$(row).find("[name$='_dob']").val()?.trim() || "", + location: this.$(row).find("[name$='_location']").val()?.trim() || "", + }; + if (Object.entries(rowData).some(([key, value]) => key !== 'relation' && value)) { + familyData.push(rowData); + } + }); + + let educationData = []; + this.$("#education_details_data tbody tr").each((index, row) => { + let educationCell = this.$(row).find(".education-relation-col"); + let educationType = educationCell.find("select").val()?.trim() || educationCell.attr("value")?.trim(); + + let rowData = { + education_type: educationType, + specialization: this.$(row).find("[name='specialization']").val()?.trim() || "", + university: this.$(row).find("[name='university']").val()?.trim() || "", + start_year: this.$(row).find("[name='start_year']").val()?.trim() || "", + end_year: this.$(row).find("[name='end_year']").val()?.trim() || "", + marks_or_grade: this.$(row).find("[name='marks_grade']").val()?.trim() || "", + }; + if (Object.entries(rowData).some(([key, value]) => key !== 'education_type' && value)) { + educationData.push(rowData); + } + + }); + + let attachments = []; + let fileReadPromises = []; + let attachmentInputs = this.uploadedFiles; // your object {1: Array(1), 2: Array(2)} + + Object.keys(attachmentInputs).forEach(key => { + let filesArray = attachmentInputs[key]; + if (filesArray && Array.isArray(filesArray)) { + filesArray.forEach(file => { + // Only push if base64 actually exists (prevents ghost/empty data) + if (file && file.base64) { + attachments.push({ + attachment_rec_id: file.attachment_rec_id, + file_name: file.name, + file_content: file.base64, + }); + } + }); + } + }); + + + Promise.all(fileReadPromises).then(() => { + this.$("#family_data_json").val(JSON.stringify(familyData)); + this.$("#education_data_json").val(JSON.stringify(educationData)); + this.$("#employer_history_data_json").val(JSON.stringify(employerHistoryData)); + this.$("#attachments_data_json").val(JSON.stringify(attachments)); + + let formElement = this.$el.is("form") ? this.$el[0] : this.$el.find("form")[0]; + if (formElement) { + formElement.submit(); + } else { + console.error("Form element not found."); + } + }).catch((error) => { + console.error("Error reading files:", error); + this.isSubmitting = false; + this.$("#submit-btn").prop("disabled", false); + }); +}, + + + + + + // Enforce required fields when any field in a row is filled + enforceRowValidation() { + // Education Details Validation - Make fields required when typing + this.$("#education_details_data").on("input", "tbody tr input", function () { + let row = $(this).closest("tr"); + let inputs = row.find("input"); + + // Check if at least one field has a value + let isFilled = inputs.toArray().some(input => $(input).val().trim() !== ""); + + if (isFilled) { + inputs.attr("required", true); + } else { + inputs.removeAttr("required"); // Remove required if all fields are empty + } + }); + + // Remove empty rows only when the user leaves the last input field + this.$("#education_details_data").on("blur", "tbody tr input", function () { + let row = $(this).closest("tr"); + let inputs = row.find("input"); + + if (row.hasClass("predefined-row")) { + let isEmpty = inputs.toArray().every(input => $(input).val().trim() === ""); + if (isEmpty) { + row.remove(); + } + } + }); + + // Family Details Validation +// this.$("#family_details_data").on("input", "tbody tr input", function () { +// let row = $(this).closest("tr"); +// let inputs = row.find("input"); +// +// let isFilled = inputs.toArray().some(input => $(input).val().trim() !== ""); +// +// if (isFilled) { +// inputs.attr("required", true); +// } else { +// inputs.removeAttr("required"); +// } +// }); + + // Remove row functionality (Manual delete using button) + this.$("#education_details_data").on("click", ".remove-edu-row", function () { + $(this).closest("tr").remove(); + }); + + this.$("#family_details_data").on("click", ".remove-family-row", function () { + $(this).closest("tr").remove(); + }); + }, + + async _renderStateIds() { + console.log("Fetching States..."); + + const country_id = $('#present_state_ids_container').data('country_id'); + + const state_ids = await rpc("/employee_bridge/fetch_related_state_ids", { + country_id: country_id, + }); + + const present_state_ids_container = $("#present_state_ids_container"); + const permanent_state_ids_container = $("#permanent_state_ids_container"); + + present_state_ids_container.empty(); + permanent_state_ids_container.empty(); + + console.log(state_ids); + + if (typeof state_ids === 'object' && !Array.isArray(state_ids)) { + const stateOptions = Object.entries(state_ids).map(([id, name]) => ` + + `).join(''); + + const stateHtml = ` + + `; + + const presentStateHtml = ` + + `; + + permanent_state_ids_container.append(stateHtml); + present_state_ids_container.append(presentStateHtml); + } else { + console.error("Expected an object like {id: name}, but got:", state_ids); + } + + console.log("Hello World"); + }, + + + + async start() { + this._super(...arguments); + this._renderStateIds(); + + // Ensure form submit event is properly bound + // this.$el.on("submit", this.handleFormSubmit.bind(this)); + + // Bind validation enforcement + this.enforceRowValidation(); + const selectedExperience = this.$("[name='experience']:checked").val(); + const selectedMarital = this.$("[name='marital']:checked").val(); + + const employerHistorySection = this.$("#employer_history_data"); + const marriageAnniversarySection = this.$('#marriage_anniversary_date_div') + + const family_details_data_spouse = this.$('#family_details_data_spouse') + const family_details_data_kid1 = this.$('#family_details_data_kid1') + const family_details_data_kid2 = this.$('#family_details_data_kid2') + + + if (selectedExperience === "experienced") { + employerHistorySection.show(); + } else { + employerHistorySection.hide(); + } + + if (selectedMarital === 'married') { + marriageAnniversarySection.show(); + // Show rows for spouse, kid1, and kid2 if married + family_details_data_spouse.show(); + family_details_data_kid1.show(); + family_details_data_kid2.show(); + } else { + marriageAnniversarySection.hide(); + // Show rows for spouse, kid1, and kid2 if married + family_details_data_spouse.hide(); + family_details_data_kid1.hide(); + family_details_data_kid2.hide(); + } + }, +}); diff --git a/addons_extensions/employee_bridge/views/hr_employee_views.xml b/addons_extensions/employee_bridge/views/hr_employee_views.xml new file mode 100644 index 000000000..f9611c436 --- /dev/null +++ b/addons_extensions/employee_bridge/views/hr_employee_views.xml @@ -0,0 +1,28 @@ + + + + hr.employee.view.form.bridge + hr.employee + + + + + + + + + + + + + + + + + + + + + + - - - - - - `); - - this.$(`#preview_table_container_${attachmentId}`).removeClass('d-none'); - - // Attach click handler for preview (image or PDF) - this.$(`#preview_wrapper_${fileId}`).on('click', previewClickHandler); - - // Attach click handler for the preview button (to trigger modal) - this.$(`.preview-btn[data-attachment-id="${attachmentId}"][data-file-id="${fileId}"]`).on('click', previewClickHandler); - }, - - handleDeleteUploadedFile(ev) { - const button = ev.currentTarget; - const attachmentId = $(button).data('attachment-id'); - const fileId = $(button).data('file-id'); - - // Find the index of the file to delete based on unique file ID - const fileIndex = this.uploadedFiles[attachmentId].findIndex(f => f.id === fileId); - - if (fileIndex !== -1) { - this.uploadedFiles[attachmentId].splice(fileIndex, 1); // Remove from array - } - - // Remove the row from DOM - this.$(`tr[data-file-id="${fileId}"]`).remove(); - - // Hide table if no files left - if (this.uploadedFiles[attachmentId].length === 0) { - this.$(`#preview_table_container_${attachmentId}`).addClass('d-none'); - } - }, - - - handleAttachmentUpload(ev) { - const input = ev.target; - const attachmentId = $(input).data('attachment-id'); - - if (input.files.length > 0) { - Array.from(input.files).forEach((file) => { - const reader = new FileReader(); - reader.onload = (e) => { - const base64String = e.target.result.split(',')[1]; - this.addUploadedFileRow(attachmentId, file, base64String); - }; - reader.readAsDataURL(file); - }); - } - }, - - handleUploadNewFile(ev) { - console.log("hello upload file"); - const button = $(ev.currentTarget); - const attachmentId = button.data('attachment-id'); - const index = button.data('index'); - - // Find the hidden file input specific to this attachmentId - const hiddenInput = this.$(`.upload-new-file-input[data-attachment-id='${attachmentId}']`); - - // When file is selected, update preview and replace old file - hiddenInput.off('change').on('change', (e) => { - const file = e.target.files[0]; - - if (!file) { - return; // Do nothing if no file is selected - } - - const reader = new FileReader(); - - reader.onload = (event) => { - const base64String = event.target.result.split(',')[1]; - - // Replace the existing file in uploadedFiles - this.uploadedFiles[attachmentId][index] = { - name: file.name, - base64: base64String, - }; - - // Update the preview image in the table - const imageId = `preview_image_${attachmentId}_${index}`; - const filePreviewWrapperId = `preview_wrapper_${attachmentId}_${index}`; - - // Check if the file is an image or PDF and update accordingly - const fileType = file.type; - - if (fileType.startsWith('image/')) { - this.$(`#${filePreviewWrapperId}`).html(` - - `); - } else if (fileType === 'application/pdf') { - this.$(`#${filePreviewWrapperId}`).html(` - - `); - } - - // Optional: Update the file name in the input field - const fileNameInputId = `file_name_input_${attachmentId}_${index}`; - this.$(`#${fileNameInputId}`).val(file.name); - }; - - reader.readAsDataURL(file); - }); - - // Trigger the hidden file input to open the file selection dialog - console.log("Triggering file input..."); - hiddenInput.trigger('click'); // Ensure this is working properly - }, - - - - handleFileNameChange(event) { - const attachmentId = $(event.target).closest('tr').data('attachment-id'); - const fileId = $(event.target).closest('tr').data('file-id'); - const newFileName = event.target.value; - - if (!attachmentId || !fileId) { - console.error('Missing attachmentId or fileId'); - return; - } - - const fileList = this.uploadedFiles[attachmentId]; - - if (!fileList) { - console.error(`No files found for attachmentId: ${attachmentId}`); - return; - } - - const fileRecord = fileList.find(file => file.id === fileId); - - if (!fileRecord) { - console.error(`File with ID ${fileId} not found under attachment ${attachmentId}`); - return; - } - - fileRecord.name = newFileName; - }, - - - -renderFilePreview(attachmentId) { - const container = this.$(`#preview_container_${attachmentId}`); - container.empty(); - - if (this.uploadedFiles[attachmentId].length === 0) { - container.addClass("d-none"); - return; - } - - container.removeClass("d-none"); - - this.uploadedFiles[attachmentId].forEach((file, index) => { - const fileHtml = $(` -
-
- - -
- ${file.name} -
- `); - - fileHtml.find("img").on("click", this.previewAttachmentImage.bind(this)); - fileHtml.find(".remove-file").on("click", this.removeFile.bind(this)); - - container.append(fileHtml); - }); -}, - -previewAttachmentImage(ev) { - const attachmentId = $(ev.currentTarget).data("attachment-id"); - const index = $(ev.currentTarget).data("index"); - const fileData = this.uploadedFiles[attachmentId][index]; - - this.$("#attachment_modal_preview").attr("src", fileData.base64); - this.$("#attachmentPreviewModal").modal("show"); -}, - -removeFile(ev) { - const attachmentId = $(ev.currentTarget).data("attachment-id"); - const index = $(ev.currentTarget).data("index"); - - this.uploadedFiles[attachmentId].splice(index, 1); - this.renderFilePreview(attachmentId); -}, - - - previewFile(ev) { - const fileUrl = ev.currentTarget.dataset.fileUrl; - const modal = this.$("#photoPreviewModal"); - - this.$("#modal_photo_preview").attr("src", fileUrl); - modal.modal("show"); - }, - - onChangeExperience(event) { - const selectedValue = $(event.currentTarget).val(); - const employerHistorySection = $("#employer_history_data"); - - if (selectedValue === "experienced") { - employerHistorySection.show(); - } else { - employerHistorySection.hide(); - } - }, - - onChangeMarital(event) { - const selectedValue = $(event.currentTarget).val(); - const marriageAnniversarySection = this.$('#marriage_anniversary_date_div') - const family_details_data_spouse = this.$('#family_details_data_spouse') - const family_details_data_kid1 = this.$('#family_details_data_kid1') - const family_details_data_kid2 = this.$('#family_details_data_kid2') - - - if (selectedValue === "married") { - marriageAnniversarySection.show(); - // Show rows for spouse, kid1, and kid2 - family_details_data_spouse.show(); - family_details_data_kid1.show(); - family_details_data_kid2.show(); - } else { - marriageAnniversarySection.hide(); - // Hide rows for spouse, kid1, and kid2 - family_details_data_spouse.hide(); - family_details_data_kid1.hide(); - family_details_data_kid2.hide(); - } - }, - - /** - * Open modal and display uploaded files - */ - openAttachmentModal(event) { - console.log("openAttachmentModal"); - const rowId = $(event.currentTarget).closest("tr").index(); // Get the row index - this.currentRowId = rowId; // Store rowId for reference - this.renderAttachmentModal(rowId); // Render the modal for the row - }, - - renderAttachmentModal(rowId) { - const fileList = this.uploadedFiles && this.uploadedFiles[rowId] ? this.uploadedFiles[rowId] : []; - - let modalHtml = ` - `; - - // Remove old modal and append new one - $("#attachmentModal").remove(); - $("body").append(modalHtml); - - // Attach remove event to new modal content - $("#attachmentModal").on("click", ".remove-file", this.removeFile.bind(this)); - $("#attachmentModal").on("click", ".close-modal-btn", this.closeAttachmentModal.bind(this)); - }, - - /** - * Close attachment modal - */ - closeAttachmentModal() { - console.log("Closing modal"); - $("#attachmentModal").remove(); // Remove the modal from the DOM - }, - - - // Function to preview the uploaded image - previewApplicantPhoto(ev) { - const input = ev.currentTarget; - const preview = this.$("#photo_preview"); - - if (input.files && input.files[0]) { - const reader = new FileReader(); - reader.onload = (e) => { - const base64String = e.target.result.split(",")[1]; // Get only Base64 part - preview.attr("src", e.target.result); - - // Store the base64 in a hidden input field - this.$("input[name='candidate_image_base64']").val(base64String); - }; - reader.readAsDataURL(input.files[0]); - } - }, - - // Function to delete the uploaded image - deleteCandidatePhoto() { - const preview = this.$("#photo_preview"); - const inputFile = this.$("input[name='candidate_image']"); - - preview.attr("src", "data:image/png;base64,"); // Reset preview - inputFile.val(""); // Reset file input - }, - - // Function to preview full image inside a modal - previewFullImage() { - const previewSrc = this.$("#photo_preview").attr("src"); - if (previewSrc) { - this.$("#modal_photo_preview").attr("src", previewSrc); - this.$("#photoPreviewModal").modal("show"); // Use jQuery to show the modal - } - }, - - // Function to add a new education row dynamically - addEducationRow(ev) { - ev.preventDefault(); // Prevent default behavior - - let newRow = ` - - - - - - - - - - - - `; - - this.$("#education_details_data tbody").append(newRow); // Append new row inside the table - console.log("New education row added!"); - }, - - - validateFamilyDetails() { - let familyDetailsFilled = false; - let educationDetailsFilled = false; - const family_rows = document.querySelectorAll('#family_details_data tbody tr'); - const education_rows = document.querySelectorAll('#education_details_data tbody tr'); - - - family_rows.forEach(row => { - const inputs = row.querySelectorAll('input[type="text"], input[type="date"]'); - const isRowFilled = Array.from(inputs).some(input => input.value.trim() !== ""); - if (isRowFilled) { - familyDetailsFilled = true; - } - }); - - education_rows.forEach(row => { - const inputs = row.querySelectorAll('input[type="text"]'); - const isRowFilled = Array.from(inputs).some(input => input.value.trim() !== ""); - if (isRowFilled) { - educationDetailsFilled = true; - } - }); - - if (!familyDetailsFilled) { - alert('Please fill at least one family member detail.'); - return false; - } - - if (!educationDetailsFilled) { - alert('Please fill at least one Education details') - return false; - } - - return true; - }, - -handleFormSubmit(ev) { - ev.preventDefault(); - - if (!this.validateFamilyDetails()) { - return; - } - - let employerHistoryData = []; - this.$("#employer_history_data tbody tr").each((index, row) => { - let rowData = { - company_name: this.$(row).find("[name$='company_name']").val()?.trim() || "", - designation: this.$(row).find("[name$='designation']").val()?.trim() || "", - date_of_joining: this.$(row).find("[name$='doj']").val()?.trim() || "", - last_working_day: this.$(row).find("[name$='lwd']").val()?.trim() || "", - ctc: this.$(row).find("[name$='ctc']").val()?.trim() || "", - }; - if (Object.values(rowData).some(value => value)) { - employerHistoryData.push(rowData); - } - }); - - let familyData = []; - this.$("#family_details_data tbody tr").each((index, row) => { - let rowData = { - relation: this.$(row).find(".relation-col").attr("value")?.trim(), - name: this.$(row).find("[name$='_name']").val()?.trim() || "", - contact: this.$(row).find("[name$='_contact']").val()?.trim() || "", - dob: this.$(row).find("[name$='_dob']").val()?.trim() || "", - location: this.$(row).find("[name$='_location']").val()?.trim() || "", - }; - if (Object.entries(rowData).some(([key, value]) => key !== 'relation' && value)) { - familyData.push(rowData); - } - }); - - let educationData = []; - this.$("#education_details_data tbody tr").each((index, row) => { - let educationCell = this.$(row).find(".education-relation-col"); - let educationType = educationCell.find("select").val()?.trim() || educationCell.attr("value")?.trim(); - - let rowData = { - education_type: educationType, - specialization: this.$(row).find("[name='specialization']").val()?.trim() || "", - university: this.$(row).find("[name='university']").val()?.trim() || "", - start_year: this.$(row).find("[name='start_year']").val()?.trim() || "", - end_year: this.$(row).find("[name='end_year']").val()?.trim() || "", - marks_or_grade: this.$(row).find("[name='marks_grade']").val()?.trim() || "", - }; - if (Object.entries(rowData).some(([key, value]) => key !== 'education_type' && value)) { - educationData.push(rowData); - } - - }); - - let attachments = []; - let fileReadPromises = []; - let attachmentInputs = this.uploadedFiles; // your object {1: Array(1), 2: Array(2)} - - Object.keys(attachmentInputs).forEach(key => { - let filesArray = attachmentInputs[key]; // This is an array - filesArray.forEach(file => { - attachments.push({ - attachment_rec_id: file.attachment_rec_id, - file_name: file.name, - file_content: file.base64, // Assuming base64 is already present - }); - }); - }); - - - Promise.all(fileReadPromises).then(() => { - this.$("#family_data_json").val(JSON.stringify(familyData)); - this.$("#education_data_json").val(JSON.stringify(educationData)); - this.$("#employer_history_data_json").val(JSON.stringify(employerHistoryData)); - this.$("#attachments_data_json").val(JSON.stringify(attachments)); - - let formElement = this.$el.is("form") ? this.$el[0] : this.$el.find("form")[0]; - if (formElement) { - formElement.submit(); - } else { - console.error("Form element not found."); - } - }).catch((error) => { - console.error("Error reading files:", error); - }); -}, - - - - - - // Enforce required fields when any field in a row is filled - enforceRowValidation() { - // Education Details Validation - Make fields required when typing - this.$("#education_details_data").on("input", "tbody tr input", function () { - let row = $(this).closest("tr"); - let inputs = row.find("input"); - - // Check if at least one field has a value - let isFilled = inputs.toArray().some(input => $(input).val().trim() !== ""); - - if (isFilled) { - inputs.attr("required", true); - } else { - inputs.removeAttr("required"); // Remove required if all fields are empty - } - }); - - // Remove empty rows only when the user leaves the last input field - this.$("#education_details_data").on("blur", "tbody tr input", function () { - let row = $(this).closest("tr"); - let inputs = row.find("input"); - - if (row.hasClass("predefined-row")) { - let isEmpty = inputs.toArray().every(input => $(input).val().trim() === ""); - if (isEmpty) { - row.remove(); - } - } - }); - - // Family Details Validation -// this.$("#family_details_data").on("input", "tbody tr input", function () { -// let row = $(this).closest("tr"); -// let inputs = row.find("input"); -// -// let isFilled = inputs.toArray().some(input => $(input).val().trim() !== ""); -// -// if (isFilled) { -// inputs.attr("required", true); -// } else { -// inputs.removeAttr("required"); -// } -// }); - - // Remove row functionality (Manual delete using button) - this.$("#education_details_data").on("click", ".remove-edu-row", function () { - $(this).closest("tr").remove(); - }); - - this.$("#family_details_data").on("click", ".remove-family-row", function () { - $(this).closest("tr").remove(); - }); - }, - - async _renderStateIds() { - console.log("Fetching States..."); - - const country_id = $('#present_state_ids_container').data('country_id'); - - const state_ids = await rpc("/hr_recruitment_extended/fetch_related_state_ids", { - country_id: country_id, - }); - - const present_state_ids_container = $("#present_state_ids_container"); - const permanent_state_ids_container = $("#permanent_state_ids_container"); - - present_state_ids_container.empty(); - permanent_state_ids_container.empty(); - - console.log(state_ids); - - if (typeof state_ids === 'object' && !Array.isArray(state_ids)) { - const stateOptions = Object.entries(state_ids).map(([id, name]) => ` - - `).join(''); - - const stateHtml = ` - - `; - - const presentStateHtml = ` - - `; - - permanent_state_ids_container.append(stateHtml); - present_state_ids_container.append(presentStateHtml); - } else { - console.error("Expected an object like {id: name}, but got:", state_ids); - } - - console.log("Hello World"); - }, - - - - async start() { - this._super(...arguments); - this._renderStateIds(); - - // Ensure form submit event is properly bound - this.$el.on("submit", this.handleFormSubmit.bind(this)); - - // Bind validation enforcement - this.enforceRowValidation(); - const selectedExperience = this.$("[name='experience']:checked").val(); - const selectedMarital = this.$("[name='marital']:checked").val(); - - const employerHistorySection = this.$("#employer_history_data"); - const marriageAnniversarySection = this.$('#marriage_anniversary_date_div') - - const family_details_data_spouse = this.$('#family_details_data_spouse') - const family_details_data_kid1 = this.$('#family_details_data_kid1') - const family_details_data_kid2 = this.$('#family_details_data_kid2') - - - if (selectedExperience === "experienced") { - employerHistorySection.show(); - } else { - employerHistorySection.hide(); - } - - if (selectedMarital === 'married') { - marriageAnniversarySection.show(); - // Show rows for spouse, kid1, and kid2 if married - family_details_data_spouse.show(); - family_details_data_kid1.show(); - family_details_data_kid2.show(); - } else { - marriageAnniversarySection.hide(); - // Show rows for spouse, kid1, and kid2 if married - family_details_data_spouse.hide(); - family_details_data_kid1.hide(); - family_details_data_kid2.hide(); - } - }, -}); +import publicWidget from "@web/legacy/js/public/public_widget"; +import { _t } from "@web/core/l10n/translation"; +import { rpc } from "@web/core/network/rpc"; +import { assets, loadCSS, loadJS } from "@web/core/assets"; + +publicWidget.registry.hrRecruitment = publicWidget.Widget.extend({ + selector: "#post_onboarding_form", + events: { + "change [name='candidate_image']": "previewApplicantPhoto", + "click #delete-photo-btn": "deleteCandidatePhoto", + "click #preview-photo-btn": "previewFullImage", + "click #add-education-row": "addEducationRow", // Ensure button click event is correctly bound + + + + 'change .attachment-input': 'handleAttachmentUpload', + 'click .upload-new-btn': 'handleUploadNewFile', + 'click .delete-file-btn': 'handleDeleteUploadedFile', + 'input .file-name-input': 'handleFileNameChange', + "click .remove-file": "removeFile", + "click .preview-file": "previewFile", + "click .view-attachments-btn": "openAttachmentModal", // Opens modal with files + "click .close-modal-btn": "closeAttachmentModal", // Close modal + "submit": "handleFormSubmit", + "change [name='experience']": "onChangeExperience", + "change [name='marital']": "onChangeMarital", + }, + + + uploadedFiles: {}, // Store files per attachment ID + addUploadedFileRow(attachmentId, file, base64String) { + const tableBody = this.$(`#preview_body_${attachmentId}`); + + if (!this.uploadedFiles[attachmentId]) { + this.uploadedFiles[attachmentId] = []; + } + + // Generate a unique file ID using attachmentId and a timestamp + const fileId = `${attachmentId}-${Date.now()}`; + + const fileRecord = { + attachment_rec_id : attachmentId, + id: fileId, // Unique file ID + name: file.name, + base64: base64String, + type: file.type, + }; + + this.uploadedFiles[attachmentId].push(fileRecord); + + const fileIndex = this.uploadedFiles[attachmentId].length - 1; + const previewImageId = `preview_image_${fileId}`; + const fileNameInputId = `file_name_input_${fileId}`; + let previewContent = ''; + let previewClickHandler = ''; + + // Check if the file is an image or PDF and set preview content accordingly + if (file.type.startsWith('image/')) { + previewContent = `
+ +
`; + previewClickHandler = () => { + this.$('#modal_attachment_photo_preview').attr('src', `data:image/png;base64,${base64String}`); + this.$('#modal_attachment_photo_preview').show(); + this.$('#modal_attachment_pdf_preview').hide(); // Hide PDF preview + this.$('#attachmentPreviewModal').modal('show'); + }; + } else if (file.type === 'application/pdf') { + previewContent = `
+ +
`; + previewClickHandler = () => { + this.$('#modal_attachment_pdf_preview').attr('src', `data:application/pdf;base64,${base64String}`); + this.$('#modal_attachment_pdf_preview').show(); + this.$('#modal_attachment_photo_preview').hide(); // Hide image preview + this.$('#attachmentPreviewModal').modal('show'); + }; + } + + // Append new row to the table with a preview and buttons + tableBody.append(` + + + + + +
+ ${previewContent} +
+ + +
+
+ + + `); + + this.$(`#preview_table_container_${attachmentId}`).removeClass('d-none'); + + // Attach click handler for preview (image or PDF) + this.$(`#preview_wrapper_${fileId}`).on('click', previewClickHandler); + + // Attach click handler for the preview button (to trigger modal) + this.$(`.preview-btn[data-attachment-id="${attachmentId}"][data-file-id="${fileId}"]`).on('click', previewClickHandler); + }, + + handleDeleteUploadedFile(ev) { + const button = ev.currentTarget; + const attachmentId = $(button).data('attachment-id'); + const fileId = $(button).data('file-id'); + + // Find the index of the file to delete based on unique file ID + const fileIndex = this.uploadedFiles[attachmentId].findIndex(f => f.id === fileId); + + if (fileIndex !== -1) { + this.uploadedFiles[attachmentId].splice(fileIndex, 1); // Remove from array + } + + // Remove the row from DOM + this.$(`tr[data-file-id="${fileId}"]`).remove(); + + // Hide table if no files left + if (this.uploadedFiles[attachmentId].length === 0) { + this.$(`#preview_table_container_${attachmentId}`).addClass('d-none'); + } + }, + + + handleAttachmentUpload(ev) { + const input = ev.target; + const attachmentId = $(input).data('attachment-id'); + + if (input.files.length > 0) { + Array.from(input.files).forEach((file) => { + const reader = new FileReader(); + reader.onload = (e) => { + const base64String = e.target.result.split(',')[1]; + this.addUploadedFileRow(attachmentId, file, base64String); + }; + reader.readAsDataURL(file); + }); + } + }, + + handleUploadNewFile(ev) { + console.log("hello upload file"); + const button = $(ev.currentTarget); + const attachmentId = button.data('attachment-id'); + const index = button.data('index'); + + // Find the hidden file input specific to this attachmentId + const hiddenInput = this.$(`.upload-new-file-input[data-attachment-id='${attachmentId}']`); + + // When file is selected, update preview and replace old file + hiddenInput.off('change').on('change', (e) => { + const file = e.target.files[0]; + + if (!file) { + return; // Do nothing if no file is selected + } + + const reader = new FileReader(); + + reader.onload = (event) => { + const base64String = event.target.result.split(',')[1]; + + // Replace the existing file in uploadedFiles + this.uploadedFiles[attachmentId][index] = { + name: file.name, + base64: base64String, + }; + + // Update the preview image in the table + const imageId = `preview_image_${attachmentId}_${index}`; + const filePreviewWrapperId = `preview_wrapper_${attachmentId}_${index}`; + + // Check if the file is an image or PDF and update accordingly + const fileType = file.type; + + if (fileType.startsWith('image/')) { + this.$(`#${filePreviewWrapperId}`).html(` + + `); + } else if (fileType === 'application/pdf') { + this.$(`#${filePreviewWrapperId}`).html(` + + `); + } + + // Optional: Update the file name in the input field + const fileNameInputId = `file_name_input_${attachmentId}_${index}`; + this.$(`#${fileNameInputId}`).val(file.name); + }; + + reader.readAsDataURL(file); + }); + + // Trigger the hidden file input to open the file selection dialog + console.log("Triggering file input..."); + hiddenInput.trigger('click'); // Ensure this is working properly + }, + + + + handleFileNameChange(event) { + const attachmentId = $(event.target).closest('tr').data('attachment-id'); + const fileId = $(event.target).closest('tr').data('file-id'); + const newFileName = event.target.value; + + if (!attachmentId || !fileId) { + console.error('Missing attachmentId or fileId'); + return; + } + + const fileList = this.uploadedFiles[attachmentId]; + + if (!fileList) { + console.error(`No files found for attachmentId: ${attachmentId}`); + return; + } + + const fileRecord = fileList.find(file => file.id === fileId); + + if (!fileRecord) { + console.error(`File with ID ${fileId} not found under attachment ${attachmentId}`); + return; + } + + fileRecord.name = newFileName; + }, + + + +renderFilePreview(attachmentId) { + const container = this.$(`#preview_container_${attachmentId}`); + container.empty(); + + if (this.uploadedFiles[attachmentId].length === 0) { + container.addClass("d-none"); + return; + } + + container.removeClass("d-none"); + + this.uploadedFiles[attachmentId].forEach((file, index) => { + const fileHtml = $(` +
+
+ + +
+ ${file.name} +
+ `); + + fileHtml.find("img").on("click", this.previewAttachmentImage.bind(this)); + fileHtml.find(".remove-file").on("click", this.removeFile.bind(this)); + + container.append(fileHtml); + }); +}, + +previewAttachmentImage(ev) { + const attachmentId = $(ev.currentTarget).data("attachment-id"); + const index = $(ev.currentTarget).data("index"); + const fileData = this.uploadedFiles[attachmentId][index]; + + this.$("#attachment_modal_preview").attr("src", fileData.base64); + this.$("#attachmentPreviewModal").modal("show"); +}, + +removeFile(ev) { + const attachmentId = $(ev.currentTarget).data("attachment-id"); + const index = $(ev.currentTarget).data("index"); + + this.uploadedFiles[attachmentId].splice(index, 1); + this.renderFilePreview(attachmentId); +}, + + + previewFile(ev) { + const fileUrl = ev.currentTarget.dataset.fileUrl; + const modal = this.$("#photoPreviewModal"); + + this.$("#modal_photo_preview").attr("src", fileUrl); + modal.modal("show"); + }, + + onChangeExperience(event) { + const selectedValue = $(event.currentTarget).val(); + const employerHistorySection = $("#employer_history_data"); + + if (selectedValue === "experienced") { + employerHistorySection.show(); + } else { + employerHistorySection.hide(); + } + }, + + onChangeMarital(event) { + const selectedValue = $(event.currentTarget).val(); + const marriageAnniversarySection = this.$('#marriage_anniversary_date_div') + const family_details_data_spouse = this.$('#family_details_data_spouse') + const family_details_data_kid1 = this.$('#family_details_data_kid1') + const family_details_data_kid2 = this.$('#family_details_data_kid2') + + + if (selectedValue === "married") { + marriageAnniversarySection.show(); + // Show rows for spouse, kid1, and kid2 + family_details_data_spouse.show(); + family_details_data_kid1.show(); + family_details_data_kid2.show(); + } else { + marriageAnniversarySection.hide(); + // Hide rows for spouse, kid1, and kid2 + family_details_data_spouse.hide(); + family_details_data_kid1.hide(); + family_details_data_kid2.hide(); + } + }, + + /** + * Open modal and display uploaded files + */ + openAttachmentModal(event) { + console.log("openAttachmentModal"); + const rowId = $(event.currentTarget).closest("tr").index(); // Get the row index + this.currentRowId = rowId; // Store rowId for reference + this.renderAttachmentModal(rowId); // Render the modal for the row + }, + + renderAttachmentModal(rowId) { + const fileList = this.uploadedFiles && this.uploadedFiles[rowId] ? this.uploadedFiles[rowId] : []; + + let modalHtml = ` + `; + + // Remove old modal and append new one + $("#attachmentModal").remove(); + $("body").append(modalHtml); + + // Attach remove event to new modal content + $("#attachmentModal").on("click", ".remove-file", this.removeFile.bind(this)); + $("#attachmentModal").on("click", ".close-modal-btn", this.closeAttachmentModal.bind(this)); + }, + + /** + * Close attachment modal + */ + closeAttachmentModal() { + console.log("Closing modal"); + $("#attachmentModal").remove(); // Remove the modal from the DOM + }, + + + // Function to preview the uploaded image + previewApplicantPhoto(ev) { + const input = ev.currentTarget; + const preview = this.$("#photo_preview"); + + if (input.files && input.files[0]) { + const reader = new FileReader(); + reader.onload = (e) => { + const base64String = e.target.result.split(",")[1]; // Get only Base64 part + preview.attr("src", e.target.result); + + // Store the base64 in a hidden input field + this.$("input[name='candidate_image_base64']").val(base64String); + }; + reader.readAsDataURL(input.files[0]); + } + }, + + // Function to delete the uploaded image + deleteCandidatePhoto() { + const preview = this.$("#photo_preview"); + const inputFile = this.$("input[name='candidate_image']"); + + preview.attr("src", "data:image/png;base64,"); // Reset preview + inputFile.val(""); // Reset file input + }, + + // Function to preview full image inside a modal + previewFullImage() { + const previewSrc = this.$("#photo_preview").attr("src"); + if (previewSrc) { + this.$("#modal_photo_preview").attr("src", previewSrc); + this.$("#photoPreviewModal").modal("show"); // Use jQuery to show the modal + } + }, + + // Function to add a new education row dynamically + addEducationRow(ev) { + ev.preventDefault(); // Prevent default behavior + + let newRow = ` + + + + + + + + + + + + `; + + this.$("#education_details_data tbody").append(newRow); // Append new row inside the table + console.log("New education row added!"); + }, + + + validateFamilyDetails() { + let familyDetailsFilled = false; + let educationDetailsFilled = false; + const family_rows = document.querySelectorAll('#family_details_data tbody tr'); + const education_rows = document.querySelectorAll('#education_details_data tbody tr'); + + + family_rows.forEach(row => { + const inputs = row.querySelectorAll('input[type="text"], input[type="date"]'); + const isRowFilled = Array.from(inputs).some(input => input.value.trim() !== ""); + if (isRowFilled) { + familyDetailsFilled = true; + } + }); + + education_rows.forEach(row => { + const inputs = row.querySelectorAll('input[type="text"]'); + const isRowFilled = Array.from(inputs).some(input => input.value.trim() !== ""); + if (isRowFilled) { + educationDetailsFilled = true; + } + }); + + if (!familyDetailsFilled) { + alert('Please fill at least one family member detail.'); + return false; + } + + if (!educationDetailsFilled) { + alert('Please fill at least one Education details') + return false; + } + + return true; + }, + +handleFormSubmit(ev) { + ev.preventDefault(); + + if (!this.validateFamilyDetails()) { + return; + } + + let employerHistoryData = []; + this.$("#employer_history_data tbody tr").each((index, row) => { + let rowData = { + company_name: this.$(row).find("[name$='company_name']").val()?.trim() || "", + designation: this.$(row).find("[name$='designation']").val()?.trim() || "", + date_of_joining: this.$(row).find("[name$='doj']").val()?.trim() || "", + last_working_day: this.$(row).find("[name$='lwd']").val()?.trim() || "", + ctc: this.$(row).find("[name$='ctc']").val()?.trim() || "", + }; + if (Object.values(rowData).some(value => value)) { + employerHistoryData.push(rowData); + } + }); + + let familyData = []; + this.$("#family_details_data tbody tr").each((index, row) => { + let rowData = { + relation: this.$(row).find(".relation-col").attr("value")?.trim(), + name: this.$(row).find("[name$='_name']").val()?.trim() || "", + contact: this.$(row).find("[name$='_contact']").val()?.trim() || "", + dob: this.$(row).find("[name$='_dob']").val()?.trim() || "", + location: this.$(row).find("[name$='_location']").val()?.trim() || "", + }; + if (Object.entries(rowData).some(([key, value]) => key !== 'relation' && value)) { + familyData.push(rowData); + } + }); + + let educationData = []; + this.$("#education_details_data tbody tr").each((index, row) => { + let educationCell = this.$(row).find(".education-relation-col"); + let educationType = educationCell.find("select").val()?.trim() || educationCell.attr("value")?.trim(); + + let rowData = { + education_type: educationType, + specialization: this.$(row).find("[name='specialization']").val()?.trim() || "", + university: this.$(row).find("[name='university']").val()?.trim() || "", + start_year: this.$(row).find("[name='start_year']").val()?.trim() || "", + end_year: this.$(row).find("[name='end_year']").val()?.trim() || "", + marks_or_grade: this.$(row).find("[name='marks_grade']").val()?.trim() || "", + }; + if (Object.entries(rowData).some(([key, value]) => key !== 'education_type' && value)) { + educationData.push(rowData); + } + + }); + + let attachments = []; + let fileReadPromises = []; + let attachmentInputs = this.uploadedFiles; // your object {1: Array(1), 2: Array(2)} + + Object.keys(attachmentInputs).forEach(key => { + let filesArray = attachmentInputs[key]; // This is an array + filesArray.forEach(file => { + attachments.push({ + attachment_rec_id: file.attachment_rec_id, + file_name: file.name, + file_content: file.base64, // Assuming base64 is already present + }); + }); + }); + + + Promise.all(fileReadPromises).then(() => { + this.$("#family_data_json").val(JSON.stringify(familyData)); + this.$("#education_data_json").val(JSON.stringify(educationData)); + this.$("#employer_history_data_json").val(JSON.stringify(employerHistoryData)); + this.$("#attachments_data_json").val(JSON.stringify(attachments)); + + let formElement = this.$el.is("form") ? this.$el[0] : this.$el.find("form")[0]; + if (formElement) { + formElement.submit(); + } else { + console.error("Form element not found."); + } + }).catch((error) => { + console.error("Error reading files:", error); + }); +}, + + + + + + // Enforce required fields when any field in a row is filled + enforceRowValidation() { + // Education Details Validation - Make fields required when typing + this.$("#education_details_data").on("input", "tbody tr input", function () { + let row = $(this).closest("tr"); + let inputs = row.find("input"); + + // Check if at least one field has a value + let isFilled = inputs.toArray().some(input => $(input).val().trim() !== ""); + + if (isFilled) { + inputs.attr("required", true); + } else { + inputs.removeAttr("required"); // Remove required if all fields are empty + } + }); + + // Remove empty rows only when the user leaves the last input field + this.$("#education_details_data").on("blur", "tbody tr input", function () { + let row = $(this).closest("tr"); + let inputs = row.find("input"); + + if (row.hasClass("predefined-row")) { + let isEmpty = inputs.toArray().every(input => $(input).val().trim() === ""); + if (isEmpty) { + row.remove(); + } + } + }); + + // Family Details Validation +// this.$("#family_details_data").on("input", "tbody tr input", function () { +// let row = $(this).closest("tr"); +// let inputs = row.find("input"); +// +// let isFilled = inputs.toArray().some(input => $(input).val().trim() !== ""); +// +// if (isFilled) { +// inputs.attr("required", true); +// } else { +// inputs.removeAttr("required"); +// } +// }); + + // Remove row functionality (Manual delete using button) + this.$("#education_details_data").on("click", ".remove-edu-row", function () { + $(this).closest("tr").remove(); + }); + + this.$("#family_details_data").on("click", ".remove-family-row", function () { + $(this).closest("tr").remove(); + }); + }, + + async _renderStateIds() { + console.log("Fetching States..."); + + const country_id = $('#present_state_ids_container').data('country_id'); + + const state_ids = await rpc("/hr_recruitment_extended/fetch_related_state_ids", { + country_id: country_id, + }); + + const present_state_ids_container = $("#present_state_ids_container"); + const permanent_state_ids_container = $("#permanent_state_ids_container"); + + present_state_ids_container.empty(); + permanent_state_ids_container.empty(); + + console.log(state_ids); + + if (typeof state_ids === 'object' && !Array.isArray(state_ids)) { + const stateOptions = Object.entries(state_ids).map(([id, name]) => ` + + `).join(''); + + const stateHtml = ` + + `; + + const presentStateHtml = ` + + `; + + permanent_state_ids_container.append(stateHtml); + present_state_ids_container.append(presentStateHtml); + } else { + console.error("Expected an object like {id: name}, but got:", state_ids); + } + + console.log("Hello World"); + }, + + + + async start() { + this._super(...arguments); + this._renderStateIds(); + + // Ensure form submit event is properly bound + this.$el.on("submit", this.handleFormSubmit.bind(this)); + + // Bind validation enforcement + this.enforceRowValidation(); + const selectedExperience = this.$("[name='experience']:checked").val(); + const selectedMarital = this.$("[name='marital']:checked").val(); + + const employerHistorySection = this.$("#employer_history_data"); + const marriageAnniversarySection = this.$('#marriage_anniversary_date_div') + + const family_details_data_spouse = this.$('#family_details_data_spouse') + const family_details_data_kid1 = this.$('#family_details_data_kid1') + const family_details_data_kid2 = this.$('#family_details_data_kid2') + + + if (selectedExperience === "experienced") { + employerHistorySection.show(); + } else { + employerHistorySection.hide(); + } + + if (selectedMarital === 'married') { + marriageAnniversarySection.show(); + // Show rows for spouse, kid1, and kid2 if married + family_details_data_spouse.show(); + family_details_data_kid1.show(); + family_details_data_kid2.show(); + } else { + marriageAnniversarySection.hide(); + // Show rows for spouse, kid1, and kid2 if married + family_details_data_spouse.hide(); + family_details_data_kid1.hide(); + family_details_data_kid2.hide(); + } + }, +}); diff --git a/addons_extensions/hr_recruitment_extended/views/candidate_experience.xml b/addons_extensions/hr_recruitment_extended/views/candidate_experience.xml index 4f765f972..7994eb0ab 100644 --- a/addons_extensions/hr_recruitment_extended/views/candidate_experience.xml +++ b/addons_extensions/hr_recruitment_extended/views/candidate_experience.xml @@ -10,6 +10,7 @@ +
diff --git a/addons_extensions/hr_recruitment_extended/views/hr_recruitment_application_templates.xml b/addons_extensions/hr_recruitment_extended/views/hr_recruitment_application_templates.xml index 667be3a67..dd596ba83 100644 --- a/addons_extensions/hr_recruitment_extended/views/hr_recruitment_application_templates.xml +++ b/addons_extensions/hr_recruitment_extended/views/hr_recruitment_application_templates.xml @@ -1,2229 +1,2229 @@ - - - - - - - - - - - - - - + + + + + + + + + + + + + + diff --git a/addons_extensions/hr_recruitment_extended/views/hr_recruitment_category.xml b/addons_extensions/hr_recruitment_extended/views/hr_recruitment_category.xml new file mode 100644 index 000000000..a98ddab9a --- /dev/null +++ b/addons_extensions/hr_recruitment_extended/views/hr_recruitment_category.xml @@ -0,0 +1,14 @@ + + + + + hr.applicant.category.tree.inherit + hr.applicant.category + + + + + + + + \ No newline at end of file diff --git a/addons_extensions/hr_recruitment_extended/views/job_category.xml b/addons_extensions/hr_recruitment_extended/views/job_category.xml index a9f36680e..a2d25ec2c 100644 --- a/addons_extensions/hr_recruitment_extended/views/job_category.xml +++ b/addons_extensions/hr_recruitment_extended/views/job_category.xml @@ -9,6 +9,7 @@ + diff --git a/addons_extensions/hr_recruitment_extended/views/res_config_settings.xml b/addons_extensions/hr_recruitment_extended/views/res_config_settings.xml new file mode 100644 index 000000000..cb18dc63b --- /dev/null +++ b/addons_extensions/hr_recruitment_extended/views/res_config_settings.xml @@ -0,0 +1,18 @@ + + + + res.config.settings.view.form.requisitions.access + res.config.settings + + + + + + + + + + diff --git a/addons_extensions/hr_recruitment_extended/views/res_partner.xml b/addons_extensions/hr_recruitment_extended/views/res_partner.xml index 766ea02a1..d8cc9e9a5 100644 --- a/addons_extensions/hr_recruitment_extended/views/res_partner.xml +++ b/addons_extensions/hr_recruitment_extended/views/res_partner.xml @@ -56,7 +56,8 @@ + options="{'horizontal': true}" force_save="1"/> + diff --git a/addons_extensions/hr_recruitment_extended/views/survey_survey.xml b/addons_extensions/hr_recruitment_extended/views/survey_survey.xml new file mode 100644 index 000000000..32561ed4e --- /dev/null +++ b/addons_extensions/hr_recruitment_extended/views/survey_survey.xml @@ -0,0 +1,14 @@ + + + + hr.survey.survey.form.inherit + survey.survey + + + + + + + + + \ No newline at end of file diff --git a/addons_extensions/hrms_emp_dashboard/__init__.py b/addons_extensions/hrms_emp_dashboard/__init__.py index e046e49fb..91c5580fe 100644 --- a/addons_extensions/hrms_emp_dashboard/__init__.py +++ b/addons_extensions/hrms_emp_dashboard/__init__.py @@ -1 +1,2 @@ from . import controllers +from . import models diff --git a/addons_extensions/hrms_emp_dashboard/__manifest__.py b/addons_extensions/hrms_emp_dashboard/__manifest__.py index 0eee6443a..099561d7f 100644 --- a/addons_extensions/hrms_emp_dashboard/__manifest__.py +++ b/addons_extensions/hrms_emp_dashboard/__manifest__.py @@ -1,8 +1,8 @@ { - "name": "HRMS Employee Dashboard", + "name": "Dashboard", "version": "18.0.1.0.0", "category": "Human Resources", - "summary": "Employee self-service dashboard with attendance, leaves, expenses, equipment, and payslips", + "summary": "Employee, manager, and HR self-service dashboard", "author": "Pranay", "license": "LGPL-3", "depends": [ @@ -11,6 +11,12 @@ "hr", "hr_attendance", "hr_holidays", + "hr_expense", + "calendar", + "project_todo", + "hr_resignation", + "knowledge", + "website", "maintenance", "employee_it_declaration", "business_travel_expense_management", @@ -18,6 +24,7 @@ ], "data": [ "views/hrms_emp_dashboard_views.xml", + "views/res_config_settings_views.xml", ], "assets": { "web.assets_backend": [ diff --git a/addons_extensions/hrms_emp_dashboard/controllers/hrms_emp_dashboard.py b/addons_extensions/hrms_emp_dashboard/controllers/hrms_emp_dashboard.py index 9ae0b4e09..79a108c84 100644 --- a/addons_extensions/hrms_emp_dashboard/controllers/hrms_emp_dashboard.py +++ b/addons_extensions/hrms_emp_dashboard/controllers/hrms_emp_dashboard.py @@ -34,11 +34,17 @@ class HrmsEmployeeDashboard(http.Controller): attendances = self._get_attendances(employee, range_start, range_end) leaves = self._get_leaves(employee, range_start, range_end) public_holidays = self._get_public_holidays(employee, range_start, range_end) + all_public_holidays = self._get_all_public_holidays(employee) calendar_attendances = self._get_attendances(employee, calendar_start, calendar_end) calendar_leaves = self._get_leaves(employee, calendar_start, calendar_end) calendar_public_holidays = self._get_public_holidays(employee, calendar_start, calendar_end) + calendar_events = self._get_calendar_events(employee, calendar_start, calendar_end) return { "success": True, + "access": { + "manager": request.env.user.has_group("hr.group_hr_user"), + "hr": request.env.user.has_group("hr.group_hr_manager"), + }, "employee": self._employee_card(employee), "attendance_state": employee.attendance_state, "date_from": range_start.strftime("%Y-%m-%d"), @@ -48,20 +54,117 @@ class HrmsEmployeeDashboard(http.Controller): "calendar_view": calendar_view, "leave_balances": self._leave_balances(employee), "public_holidays": self._holiday_list(public_holidays), + "all_public_holidays": self._holiday_list(all_public_holidays), "attendance_calendar": self._attendance_calendar( calendar_start, calendar_end, calendar_attendances, calendar_leaves, calendar_public_holidays, + calendar_events, calendar_view, ), "attendance_summary": self._attendance_summary(range_start, range_end, attendances, leaves, public_holidays), + "dashboard_menus": self._dashboard_menus(employee, range_start, range_end), "expenses": self._expense_data(employee, range_start, range_end), "equipment": self._equipment_data(employee), "latest_payslip": self._latest_payslip(employee), + "manager_dashboard": self._manager_dashboard(employee, range_start, range_end), + "hr_dashboard": self._hr_dashboard(range_start, range_end), } + @http.route('/hrms_emp_dashboard/day_details', type='json', auth='user') + def day_details(self, date, **kwargs): + try: + employee = request.env.user.employee_id + if not employee: + return {'success': False} + + signals = [] + day_start = date + ' 00:00:00' + day_end = date + ' 23:59:59' + + # 1. Calendar events — use same search as _get_calendar_events + try: + partner = employee.user_id.partner_id if employee.user_id else False + domain = [ + ('start', '<=', day_end), + ('stop', '>=', day_start), + ] + if partner: + domain = ['|', ('partner_ids', 'in', [partner.id]), ('user_id', '=', employee.user_id.id)] + domain + else: + domain.append(('user_id', '=', employee.user_id.id)) + + events = request.env['calendar.event'].sudo().search(domain, order='start asc') + + for event in events: + start_dt = fields.Datetime.context_timestamp(request.env.user, event.start) + start_time = start_dt.strftime('%H:%M') + signals.append({ + 'type': 'event', + 'label': f"{start_time} {event.name}", + 'icon': 'fa-calendar', + }) + except Exception: + pass + + # 2. Public holidays + try: + day_date = fields.Date.from_string(date) + holidays = request.env['hr.holidays.public.line'].sudo().search([ + ('date', '=', day_date), + ]) + for holiday in holidays: + signals.append({ + 'type': 'holiday', + 'label': holiday.name, + 'icon': 'fa-umbrella-beach', + }) + except Exception: + pass + + # 3. Leaves + try: + leaves = request.env['hr.leave'].sudo().search([ + ('employee_id', '=', employee.id), + ('request_date_from', '<=', day_end), + ('request_date_to', '>=', day_start), + ('state', 'in', ['confirm', 'validate1', 'validate']), + ]) + for leave in leaves: + signals.append({ + 'type': 'leave', + 'label': leave.holiday_status_id.name, + 'icon': 'fa-calendar-times-o', + }) + except Exception: + pass + + # 4. To-dos / tasks + try: + tasks = request.env['project.task'].sudo().search([ + ('user_ids', 'in', [request.env.uid]), + ('date_deadline', '=', date), + ], limit=10) + for task in tasks: + signals.append({ + 'type': 'event', + 'label': f"To-do: {task.name}", + 'icon': 'fa-check-square', + }) + except Exception: + pass + + return { + 'success': True, + 'day': { + 'signals': signals, + }, + } + except Exception as e: + return {'success': False, 'error': str(e)} + def _date_range(self, month=False, date_from=False, date_to=False): today = fields.Date.context_today(request.env.user) if date_from and date_to: @@ -84,6 +187,59 @@ class HrmsEmployeeDashboard(http.Controller): "message": _("Checked in") if employee.sudo().attendance_state == "checked_in" else _("Checked out"), } + @http.route("/hrms_emp_dashboard/approval_action", type="json", auth="user") + def approval_action(self, model=False, record_id=False, operation=False, **kwargs): + allowed = { + "hr.leave": { + "approve": ["action_approve", "action_validate"], + "reject": ["action_refuse"], + }, + "on.duty.form": {"approve": ["action_approve"], "reject": ["action_reject"]}, + "late.coming.request": {"approve": ["action_approve"], "reject": ["action_reject"]}, + "overtime.request": {"approve": ["action_approve"], "reject": ["action_reject"]}, + "shift.swap.request": {"approve": ["action_approve"], "reject": ["action_reject"]}, + "travel.trip": {"approve": ["action_approve"]}, + "travel.expense": {"approve": ["action_approve"]}, + } + if model not in allowed or operation not in allowed[model] or model not in request.env: + return {"success": False, "error": _("This approval action is not available.")} + + record = request.env[model].browse(int(record_id or 0)).exists() + if not record: + return {"success": False, "error": _("Record not found.")} + if not self._can_use_record_action(record): + return {"success": False, "error": _("You are not allowed to update this record.")} + + last_error = False + for method_name in allowed[model][operation]: + if not hasattr(record, method_name): + continue + try: + getattr(record, method_name)() + return {"success": True, "message": _("Request updated.")} + except Exception as error: + last_error = error + return {"success": False, "error": str(last_error) if last_error else _("No matching action was found.")} + + def _can_use_record_action(self, record): + user = request.env.user + if user.has_group("hr.group_hr_manager"): + return True + if not user.has_group("hr.group_hr_user"): + return False + employee = user.employee_id + if not employee: + return False + if "manager_id" in record._fields and record.manager_id: + manager = record.manager_id.sudo() + return manager == employee or manager.user_id == user + if "employee_id" in record._fields and record.employee_id: + return record.employee_id.sudo().parent_id == employee + if record._name == "travel.expense" and "activity_id" in record._fields: + trip = record.activity_id.trip_id + return trip and trip.manager_id.sudo().user_id == user + return False + def _employee_card(self, employee): return { "id": employee.id, @@ -200,15 +356,54 @@ class HrmsEmployeeDashboard(http.Controller): ) return holidays - def _attendance_calendar(self, month_start, month_end, attendances, leaves, public_holidays, calendar_view="monthly"): + def _get_all_public_holidays(self, employee): + today = date.today() + + year_start = date(today.year, 1, 1) + year_end = date(today.year, 12, 31) + + start_dt = datetime.combine(year_start, time.min) + end_dt = datetime.combine(year_end, time.max) + + calendar = employee.resource_calendar_id or employee.company_id.resource_calendar_id + holidays = request.env["resource.calendar.leaves"].sudo().search([ + ("date_from", "<=", end_dt), + ("date_to", ">=", start_dt), + ("company_id", "in", [False, employee.company_id.id]), + ("resource_id", "=", False), # Only company/public holidays + ], order="date_from asc") + if calendar: + holidays = holidays.filtered( + lambda h: not h.calendar_id or h.calendar_id == calendar + ) + return holidays + + def _get_calendar_events(self, employee, range_start, range_end): + if "calendar.event" not in request.env: + return request.env["ir.model"].browse() + start_dt = datetime.combine(range_start, time.min) + end_dt = datetime.combine(range_end, time.max) + domain = [ + ("start", "<=", end_dt), + ("stop", ">=", start_dt), + ] + partner = employee.user_id.partner_id + if partner: + domain = ["|", ("partner_ids", "in", [partner.id]), ("user_id", "=", employee.user_id.id)] + domain + else: + domain.append(("user_id", "=", employee.user_id.id)) + return request.env["calendar.event"].sudo().search(domain, order="start asc", limit=500) + + def _attendance_calendar(self, month_start, month_end, attendances, leaves, public_holidays, calendar_events=False, calendar_view="monthly"): if calendar_view == "yearly": - return self._yearly_attendance_calendar(month_start, month_end, attendances, leaves, public_holidays) + return self._yearly_attendance_calendar(month_start, month_end, attendances, leaves, public_holidays, calendar_events) today = fields.Date.context_today(request.env.user) attendance_by_day = self._attendance_hours_by_day(attendances) leave_by_day = self._leave_days(leaves) holiday_by_day = self._days_from_datetime_range_records(public_holidays, "date_from", "date_to") + events_by_day = self._calendar_events_by_day(calendar_events) days = [] cursor = month_start @@ -227,6 +422,7 @@ class HrmsEmployeeDashboard(http.Controller): break_hours = round(attendance_hours.get("break_hours", 0.0), 2) has_worked_hours = hours > 0.004 is_holiday = cursor in holiday_by_day + day_events = events_by_day.get(cursor, []) is_leave = cursor in leave_by_day is_half_day_leave = bool(leave_by_day.get(cursor, {}).get("is_half_day")) is_weekend = cursor.weekday() in (5, 6) @@ -267,6 +463,10 @@ class HrmsEmployeeDashboard(http.Controller): "outside_period": outside_period, "status": status, "label": label, + "events": day_events, + "event_count": len(day_events), + "has_events": bool(day_events), + "signals": self._calendar_day_signals(holiday_by_day.get(cursor), leave_by_day.get(cursor), day_events), "show_metrics": show_metrics, "is_half_day_leave": is_half_day_leave, "hours": hours, @@ -280,6 +480,18 @@ class HrmsEmployeeDashboard(http.Controller): "expected_display": self._format_hours(expected_hours), "leave_display": self._format_hours(leave_hours), "balance_display": self._format_signed_hours(hours - expected_hours), + "details": self._calendar_day_details( + cursor, + attendance_hours, + leave_by_day.get(cursor), + holiday_by_day.get(cursor), + day_events, + expected_hours, + hours, + break_hours, + status, + ), + "actions": self._calendar_day_actions(cursor, status), }) cursor += timedelta(days=1) return days @@ -298,6 +510,39 @@ class HrmsEmployeeDashboard(http.Controller): start += timedelta(days=1) return result + def _calendar_events_by_day(self, events): + result = defaultdict(list) + for event in events or []: + start_dt = fields.Datetime.context_timestamp(request.env.user, event.start) + stop_dt = fields.Datetime.context_timestamp(request.env.user, event.stop) + cursor = start_dt.date() + end = stop_dt.date() + event_info = { + "id": event.id, + "name": event.name or _("Meeting"), + "start": fields.Datetime.to_string(start_dt), + "stop": fields.Datetime.to_string(stop_dt), + "display_time": event.display_time or "", + "allday": bool(event.allday), + "attendee_count": len(event.partner_ids), + } + while cursor and end and cursor <= end: + result[cursor].append(event_info) + cursor += timedelta(days=1) + return result + + def _calendar_day_signals(self, holiday_label, leave_data, events): + signals = [] + if holiday_label: + signals.append({"type": "holiday", "label": holiday_label, "icon": "fa fa-star"}) + if leave_data: + signals.append({"type": "leave", "label": _("Time Off"), "icon": "fa fa-plane"}) + for event in (events or [])[:2]: + signals.append({"type": "event", "label": event["name"], "icon": "fa fa-users"}) + if len(events or []) > 2: + signals.append({"type": "more", "label": _("+%s more") % (len(events) - 2), "icon": "fa fa-ellipsis-h"}) + return signals + def _is_half_day_leave(self, leave): if "request_unit_half" in leave._fields and leave.request_unit_half: return True @@ -307,8 +552,8 @@ class HrmsEmployeeDashboard(http.Controller): return True return bool(leave.number_of_days and leave.number_of_days < 1) - def _yearly_attendance_calendar(self, range_start, range_end, attendances, leaves, public_holidays): - days = self._attendance_calendar(range_start, range_end, attendances, leaves, public_holidays, "monthly") + def _yearly_attendance_calendar(self, range_start, range_end, attendances, leaves, public_holidays, calendar_events=False): + days = self._attendance_calendar(range_start, range_end, attendances, leaves, public_holidays, calendar_events, "monthly") months = [] cursor = range_start.replace(day=1) while cursor <= range_end: @@ -317,6 +562,7 @@ class HrmsEmployeeDashboard(http.Controller): counts = defaultdict(int) for day in month_days: counts[day["status"]] += 1 + event_count = sum(day.get("event_count", 0) for day in month_days) months.append({ "type": "month", "date": month_key, @@ -328,6 +574,11 @@ class HrmsEmployeeDashboard(http.Controller): "absent": counts["absent"], "leave": counts["leave"], "holiday": counts["holiday"], + "event_count": event_count, + "has_events": bool(event_count), + "signals": [ + {"type": "event", "label": _("%s meetings/events") % event_count, "icon": "fa fa-users"} + ] if event_count else [], "hours": round(sum(day["hours"] for day in month_days), 2), "worked_hours": round(sum(day["worked_hours"] for day in month_days), 2), "break_hours": round(sum(day["break_hours"] for day in month_days), 2), @@ -335,6 +586,17 @@ class HrmsEmployeeDashboard(http.Controller): "worked_display": self._format_hours(sum(day["worked_hours"] for day in month_days)), "break_display": self._format_hours(sum(day["break_hours"] for day in month_days)), "expected_display": self._format_hours(sum(day["expected_hours"] for day in month_days)), + "details": [ + {"label": _("Present Days"), "value": counts["present"]}, + {"label": _("Leave Days"), "value": counts["leave"]}, + {"label": _("Public Holidays"), "value": counts["holiday"]}, + {"label": _("Meetings/Events"), "value": event_count}, + {"label": _("Worked Hours"), "value": self._format_hours(sum(day["worked_hours"] for day in month_days))}, + ], + "actions": [ + {"key": "create_meeting", "label": _("Create Meeting"), "icon": "fa fa-users", "primary": True}, + {"key": "open_calendar", "label": _("Open Calendar"), "icon": "fa fa-calendar"}, + ], }) cursor += relativedelta(months=1) return months @@ -359,9 +621,61 @@ class HrmsEmployeeDashboard(http.Controller): result[day] = { "worked_hours": worked_hours, "break_hours": break_hours, + "entries": [{ + "check_in": fields.Datetime.to_string(fields.Datetime.context_timestamp(request.env.user, attendance.check_in)) if attendance.check_in else "", + "check_out": fields.Datetime.to_string(fields.Datetime.context_timestamp(request.env.user, attendance.check_out)) if attendance.check_out else _("Open"), + "worked_display": self._format_hours(attendance.worked_hours or 0.0), + } for attendance in sorted_attendances], } return result + def _calendar_day_details(self, day, attendance_data, leave_data, holiday_label, events, expected_hours, worked_hours, break_hours, status): + details = [ + {"label": _("Date"), "value": fields.Date.to_string(day)}, + {"label": _("Status"), "value": self._calendar_status_label(status)}, + {"label": _("Expected"), "value": self._format_hours(expected_hours)}, + {"label": _("Worked"), "value": self._format_hours(worked_hours)}, + {"label": _("Break"), "value": self._format_hours(break_hours)}, + ] + if holiday_label: + details.append({"label": _("Holiday"), "value": holiday_label}) + if leave_data: + details.append({"label": _("Time Off"), "value": _("Half Day") if leave_data.get("is_half_day") else _("Full Day")}) + for event in events or []: + details.append({ + "label": _("Meeting/Event"), + "value": "%s%s" % (event["name"], (" - %s" % event["display_time"]) if event["display_time"] else ""), + }) + for index, entry in enumerate(attendance_data.get("entries", []), start=1): + details.append({ + "label": _("Attendance %s") % index, + "value": "%s - %s (%s)" % (entry["check_in"], entry["check_out"], entry["worked_display"]), + }) + return details + + def _calendar_status_label(self, status): + labels = { + "present": _("Present"), + "absent": _("Absent"), + "leave": _("Time Off"), + "holiday": _("Public Holiday"), + "weekend": _("Weekend"), + "future": _("Upcoming"), + "empty": _("Outside Period"), + } + return labels.get(status, status.title() if status else "") + + def _calendar_day_actions(self, day, status): + actions = [ + {"key": "create_meeting", "label": _("Create Meeting"), "icon": "fa fa-users", "primary": True}, + {"key": "apply_leave", "label": _("Apply Leave"), "icon": "fa fa-calendar-plus-o", "primary": False}, + {"key": "add_todo", "label": _("Add To-do"), "icon": "fa fa-check-square-o", "primary": False}, + {"key": "open_calendar", "label": _("Open Calendar"), "icon": "fa fa-calendar", "primary": False}, + ] + if status in ("present", "absent"): + actions.insert(2, {"key": "open_attendance", "label": _("Attendance"), "icon": "fa fa-clock-o", "primary": False}) + return actions + def _expected_hours_for_day(self, employee, day): calendar = employee.resource_calendar_id or employee.company_id.resource_calendar_id if not calendar: @@ -410,6 +724,7 @@ class HrmsEmployeeDashboard(http.Controller): attendances, leaves, public_holidays, + False, ) counts = defaultdict(int) @@ -453,13 +768,344 @@ class HrmsEmployeeDashboard(http.Controller): "remaining_display": self._format_signed_hours(remaining_hours), } + def _dashboard_menus(self, employee, date_from, date_to): + user = request.env.user + settings = self._dashboard_settings() + tiles = [ + self._menu_tile( + key="leave", + title=_("Time Off"), + subtitle=_("Requests and balances"), + icon="fa fa-calendar-plus-o", + color="green", + count=self._safe_search_count("hr.leave", [ + ("employee_id", "=", employee.id), + ("state", "in", ["confirm", "validate1", "validate"]), + ("request_date_from", "<=", date_to), + ("request_date_to", ">=", date_from), + ]), + count_label=_("requests"), + model="hr.leave", + domain=[["employee_id", "=", employee.id]], + views=[[False, "list"], [False, "form"], [False, "calendar"]], + context={"default_employee_id": employee.id}, + create_action={ + "type": "ir.actions.act_window", + "name": _("Apply Time Off"), + "res_model": "hr.leave", + "views": [[False, "form"]], + "target": "new", + "context": {"default_employee_id": employee.id}, + }, + ), + self._employee_optional_tile( + settings["show_on_duty"], + key="on_duty", + title=_("On-Duty"), + subtitle=_("Duty outside office"), + icon="fa fa-map-marker", + color="cyan", + model="on.duty.form", + domain=[["employee_id", "=", employee.id]], + count_domain=[("employee_id", "=", employee.id)], + create_action={ + "type": "ir.actions.act_window", + "name": _("New On-Duty Request"), + "res_model": "on.duty.form", + "views": [[False, "form"]], + "target": "new", + "context": {"default_employee_id": employee.id}, + }, + ), + self._employee_optional_tile( + settings["show_late_coming"], + key="late_coming", + title=_("Late Coming"), + subtitle=_("Late and early-out approvals"), + icon="fa fa-clock-o", + color="amber", + model="late.coming.request", + domain=[["employee_id", "=", employee.id]], + count_domain=[("employee_id", "=", employee.id)], + create_action={ + "type": "ir.actions.act_window", + "name": _("New Late Coming Request"), + "res_model": "late.coming.request", + "views": [[False, "form"]], + "target": "new", + "context": {"default_employee_id": employee.id}, + }, + ), + self._employee_optional_tile( + settings["show_overtime"], + key="overtime", + title=_("Overtime"), + subtitle=_("Extra hours approval"), + icon="fa fa-hourglass-half", + color="violet", + model="overtime.request", + domain=[["employee_id", "=", employee.id]], + count_domain=[("employee_id", "=", employee.id)], + create_action={ + "type": "ir.actions.act_window", + "name": _("New Overtime Request"), + "res_model": "overtime.request", + "views": [[False, "form"]], + "target": "new", + "context": {"default_employee_id": employee.id}, + }, + ), + self._employee_optional_tile( + settings["show_shift_swap"], + key="shift_swap", + title=_("Shift Swap"), + subtitle=_("Swap rostered shifts"), + icon="fa fa-exchange", + color="rose", + model="shift.swap.request", + domain=[["employee_id", "=", employee.id]], + count_domain=[("employee_id", "=", employee.id)], + create_action={ + "type": "ir.actions.act_window", + "name": _("New Shift Swap Request"), + "res_model": "shift.swap.request", + "views": [[False, "form"]], + "target": "new", + "context": {"default_employee_id": employee.id}, + }, + ), + self._menu_tile( + key="attendance", + title=_("Attendance"), + subtitle=_("Entries and working time"), + icon="fa fa-clock-o", + color="blue", + count=self._safe_search_count("hr.attendance", [("employee_id", "=", employee.id)]), + count_label=_("entries"), + model="hr.attendance", + domain=[["employee_id", "=", employee.id]], + views=[[False, "list"], [False, "form"]], + create_action={ + "type": "ir.actions.act_window", + "name": _("Add Attendance"), + "res_model": "hr.attendance", + "views": [[False, "form"]], + "target": "new", + "context": {"default_employee_id": employee.id}, + }, + ), + self._menu_tile( + key="todo", + title=_("To-do"), + subtitle=_("Personal tasks"), + icon="fa fa-check-square-o", + color="amber", + count=self._safe_search_count("project.task", [("project_id","=",False),("user_ids", "in", [user.id]), ("active", "=", True)]), + count_label=_("open"), + xml_id="project_todo.project_task_preload_action_todo", + fallback_model="project.task", + fallback_domain=[["user_ids", "in", [user.id]], ["active", "=", True]], + fallback_views=[[False, "kanban"], [False, "list"], [False, "form"]], + context={"default_user_ids": [(4, user.id)]}, + create_action={ + "type": "ir.actions.act_window", + "name": _("New To-do"), + "res_model": "project.task", + "views": [[False, "form"]], + "target": "new", + "context": {"default_user_ids": [(4, user.id)]}, + }, + ), + self._menu_tile( + key="employees", + title=_("Employees"), + subtitle=_("Company directory"), + icon="fa fa-users", + color="cyan", + count=self._safe_search_count("hr.employee", [("active", "=", True)]), + count_label=_("people"), + xml_id="hr.open_view_employee_list_my", + fallback_model="hr.employee", + fallback_domain=[["active", "=", True]], + fallback_views=[[False, "kanban"], [False, "list"], [False, "form"]], + create_action={ + "type": "ir.actions.act_window", + "name": _("New Employee"), + "res_model": "hr.employee", + "views": [[False, "form"]], + "target": "new", + }, + ), + self._resignation_tile(employee), + self._menu_tile( + key="knowledge", + title=_("Knowledge"), + subtitle=_("Policies and articles"), + icon="fa fa-book", + color="violet", + show_count=False, + xml_id="knowledge.ir_actions_server_knowledge_home_page", + fallback_model="knowledge.article", + fallback_views=[[False, "kanban"], [False, "list"], [False, "form"]], + ), + self._menu_tile( + key="calendar", + title=_("Calendar"), + subtitle=_("Meetings and events"), + icon="fa fa-calendar", + color="slate", + count=self._safe_search_count("calendar.event", [ + ("start", "<=", datetime.combine(date_to, time.max)), + ("stop", ">=", datetime.combine(date_from, time.min)), + ("partner_ids","in",[user.partner_id.id]), + ]), + count_label=_("events"), + xml_id="calendar.action_calendar_event", + fallback_model="calendar.event", + fallback_views=[[False, "calendar"], [False, "list"], [False, "form"]], + create_action={ + "type": "ir.actions.act_window", + "name": _("New Calendar Event"), + "res_model": "calendar.event", + "views": [[False, "form"]], + "target": "new", + "context": { + "default_partner_ids": [(4, user.partner_id.id)] if user.partner_id else [], + }, + }, + ), + self._menu_tile( + key="website", + title=_("Website"), + subtitle=_("Open company site"), + icon="fa fa-globe", + color="rose", + show_count=False, + url="/", + ), + ] + return [tile for tile in tiles if tile] + + def _dashboard_settings(self): + params = request.env["ir.config_parameter"].sudo() + return { + "show_on_duty": params.get_param("hrms_emp_dashboard.show_on_duty", "True") == "True", + "show_late_coming": params.get_param("hrms_emp_dashboard.show_late_coming", "True") == "True", + "show_overtime": params.get_param("hrms_emp_dashboard.show_overtime", "True") == "True", + "show_shift_swap": params.get_param("hrms_emp_dashboard.show_shift_swap", "True") == "True", + } + + def _employee_optional_tile(self, enabled, key, title, subtitle, icon, color, model, domain, count_domain, create_action=False): + if not enabled or model not in request.env: + return False + return self._menu_tile( + key=key, + title=title, + subtitle=subtitle, + icon=icon, + color=color, + count=self._safe_search_count(model, count_domain), + count_label=_("requests"), + model=model, + domain=domain, + views=[[False, "list"], [False, "form"]], + create_action=create_action, + ) + + def _resignation_tile(self, employee): + if "hr.resignation" not in request.env: + return False + domain = [("employee_id", "=", employee.id)] + latest = request.env["hr.resignation"].sudo().search(domain, order="id desc", limit=1) + stage = "" + if latest: + stage = ( + dict(latest._fields["state"].selection).get(latest.state) + or dict(latest._fields["normal_resignation_status"].selection).get(latest.normal_resignation_status) + or latest.state + or "" + ) + return self._menu_tile( + key="resignation", + title=_("Resignation"), + subtitle=stage or _("Exit request workflow"), + icon="fa fa-sign-out", + color="red", + count=self._safe_search_count("hr.resignation", domain), + count_label=_("requests"), + xml_id="hr_resignation.hr_resignation_action", + fallback_model="hr.resignation", + fallback_domain=[["employee_id", "=", employee.id]], + fallback_views=[[False, "list"], [False, "form"]], + context={"default_employee_id": employee.id}, + badge=stage, + create_action={ + "type": "ir.actions.act_window", + "name": _("New Resignation"), + "res_model": "hr.resignation", + "views": [[False, "form"]], + "target": "new", + "context": {"default_employee_id": employee.id}, + }, + ) + + def _menu_tile(self, key, title, subtitle, icon, color, count=0, count_label="", model=False, domain=False, views=False, context=False, xml_id=False, fallback_model=False, fallback_domain=False, fallback_views=False, show_count=True, url=False, badge=False, create_action=False): + action = False + if url: + action = {"type": "ir.actions.act_url", "url": url, "target": "self"} + elif xml_id and request.env.ref(xml_id, raise_if_not_found=False): + action = {"xml_id": xml_id} + elif model and model in request.env: + action = { + "type": "ir.actions.act_window", + "name": title, + "res_model": model, + "views": views or [[False, "list"], [False, "form"]], + "domain": domain or [], + "context": context or {}, + "target": "current", + } + elif fallback_model and fallback_model in request.env: + action = { + "type": "ir.actions.act_window", + "name": title, + "res_model": fallback_model, + "views": fallback_views or [[False, "list"], [False, "form"]], + "domain": fallback_domain or [], + "context": context or {}, + "target": "current", + } + if not action: + return False + return { + "key": key, + "title": title, + "subtitle": subtitle, + "icon": icon, + "color": color, + "count": count, + "count_label": count_label, + "show_count": show_count, + "badge": badge or "", + "action": action, + "create_action": create_action or False, + } + + def _safe_search_count(self, model_name, domain): + if model_name not in request.env: + return 0 + try: + return request.env[model_name].sudo().search_count(domain) + except Exception: + return 0 + def _holiday_list(self, holidays): return [{ "id": holiday.id, "name": holiday.name or _("Public Holiday"), "date_from": fields.Datetime.to_string(holiday.date_from), "date_to": fields.Datetime.to_string(holiday.date_to), - } for holiday in holidays[:10]] + } for holiday in holidays[:50]] def _expense_data(self, employee, date_from, date_to): labels = [] @@ -514,6 +1160,7 @@ class HrmsEmployeeDashboard(http.Controller): "category": item.category_id.name if item.category_id else "", "serial": item.serial_no or getattr(item, "comp_serial_no", "") or "", "assign_date": self._date_string(item.assign_date), + "service_open_count": item.maintenance_open_count or 0, } for item in equipment] def _latest_payslip(self, employee): @@ -527,3 +1174,277 @@ class HrmsEmployeeDashboard(http.Controller): "date_from": self._date_string(payslip.date_from) if payslip else "", "date_to": self._date_string(payslip.date_to) if payslip else "", } + + def _manager_dashboard(self, employee, date_from, date_to): + if not request.env.user.has_group("hr.group_hr_user"): + return {} + user = request.env.user + team = request.env["hr.employee"].sudo().search([ + ("parent_id", "=", employee.id), + ("active", "=", True), + ], order="name") + team_ids = team.ids + today = fields.Date.context_today(user) + today_start = datetime.combine(today, time.min) + today_end = datetime.combine(today, time.max) + present_today = self._safe_search_count("hr.attendance", [ + ("employee_id", "in", team_ids), + ("check_in", ">=", today_start), + ("check_in", "<=", today_end), + ]) if team_ids else 0 + + approval_tiles = [ + self._approval_tile("manager_leave", _("Time Off Approvals"), "hr.leave", [ + ("employee_id", "in", team_ids), + ("state", "in", ["confirm", "validate1"]), + ], "fa fa-calendar-check-o", "green"), + self._approval_tile("manager_on_duty", _("On-Duty Requests"), "on.duty.form", [ + ("employee_id", "in", team_ids), + ("state", "=", "submitted"), + ], "fa fa-map-marker", "blue"), + self._approval_tile("manager_late", _("Late Coming"), "late.coming.request", [ + ("manager_id", "=", employee.id), + ("state", "=", "submitted"), + ], "fa fa-clock-o", "amber"), + self._approval_tile("manager_ot", _("Overtime"), "overtime.request", [ + ("manager_id", "=", employee.id), + ("state", "=", "submitted"), + ], "fa fa-hourglass-half", "cyan"), + self._approval_tile("manager_shift", _("Shift Swaps"), "shift.swap.request", [ + ("manager_id", "=", employee.id), + ("state", "=", "submitted"), + ], "fa fa-exchange", "violet"), + self._approval_tile("manager_travel", _("Travel Approvals"), "travel.trip", [ + ("manager_id", "=", employee.id), + ("state", "=", "submitted"), + ], "fa fa-plane", "rose"), + ] + approval_tiles = [tile for tile in approval_tiles if tile] + pending_count = sum(tile["count"] for tile in approval_tiles) + return { + "hero": { + "title": _("Manager Self Service"), + "subtitle": _("Team approvals, availability, travel and daily exceptions in one place."), + }, + "kpis": [ + {"label": _("Team Members"), "value": len(team), "tone": "blue"}, + {"label": _("Present Today"), "value": present_today, "tone": "green"}, + {"label": _("Pending Approvals"), "value": pending_count, "tone": "amber"}, + {"label": _("Open Team Tasks"), "value": self._safe_search_count("project.task", [("user_ids", "in", team.mapped("user_id").ids), ("active", "=", True)]) if team else 0, "tone": "violet"}, + ], + "menus": [ + self._menu_tile(_("Team Directory").lower().replace(" ", "_"), _("Team Directory"), _("Employee records reporting to you"), "fa fa-users", "blue", count=len(team), count_label=_("people"), model="hr.employee", domain=[["id", "in", team_ids]], views=[[False, "kanban"], [False, "list"], [False, "form"]]), + self._menu_tile("team_attendance", _("Team Attendance"), _("Today and historical attendance"), "fa fa-id-card-o", "green", count=present_today, count_label=_("today"), model="hr.attendance", domain=[["employee_id", "in", team_ids]], views=[[False, "list"], [False, "form"]]), + self._menu_tile("team_tasks", _("Team Tasks"), _("Assigned work and deadlines"), "fa fa-check-square-o", "amber", count=self._safe_search_count("project.task", [("user_ids", "in", team.mapped("user_id").ids), ("active", "=", True)]) if team else 0, count_label=_("open"), model="project.task", domain=[["user_ids", "in", team.mapped("user_id").ids], ["active", "=", True]], views=[[False, "kanban"], [False, "list"], [False, "form"]]), + self._menu_tile("team_calendar", _("Team Calendar"), _("Meetings and schedules"), "fa fa-calendar", "slate", show_count=False, xml_id="calendar.action_calendar_event", fallback_model="calendar.event", fallback_views=[[False, "calendar"], [False, "list"], [False, "form"]]), + ], + "approval_tiles": approval_tiles, + "approval_queue": self._manager_approval_queue(employee, team_ids), + "team": self._team_snapshot(team, today_start, today_end), + } + + def _hr_dashboard(self, date_from, date_to): + if not request.env.user.has_group("hr.group_hr_manager"): + return {} + employees_count = self._safe_search_count("hr.employee", [("active", "=", True)]) + pending_tiles = [ + self._approval_tile("hr_leave", _("Time Off"), "hr.leave", [("state", "in", ["confirm", "validate1"])], "fa fa-calendar-check-o", "green"), + self._approval_tile("hr_on_duty", _("On-Duty"), "on.duty.form", [("state", "=", "submitted")], "fa fa-map-marker", "blue"), + self._approval_tile("hr_late", _("Late Coming"), "late.coming.request", [("state", "=", "submitted")], "fa fa-clock-o", "amber"), + self._approval_tile("hr_ot", _("Overtime"), "overtime.request", [("state", "=", "submitted")], "fa fa-hourglass-half", "cyan"), + self._approval_tile("hr_shift", _("Shift Swaps"), "shift.swap.request", [("state", "=", "submitted")], "fa fa-exchange", "violet"), + self._approval_tile("hr_travel", _("Travel"), "travel.trip", [("state", "=", "submitted")], "fa fa-plane", "rose"), + self._approval_tile("hr_resignation", _("Resignations"), "hr.resignation", [("state", "not in", ["cancel", "cancelled", "done", "approved", "refuse", "rejected"])], "fa fa-sign-out", "red"), + ] + pending_tiles = [tile for tile in pending_tiles if tile] + return { + "hero": { + "title": _("HR Self Service"), + "subtitle": _("Workforce administration, pending approvals, payroll and HR operations."), + }, + "kpis": [ + {"label": _("Active Employees"), "value": employees_count, "tone": "blue"}, + {"label": _("Departments"), "value": self._safe_search_count("hr.department", []), "tone": "green"}, + {"label": _("Pending HR Items"), "value": sum(tile["count"] for tile in pending_tiles), "tone": "amber"}, + {"label": _("Open Positions"), "value": self._safe_search_count("hr.job", [("active", "=", True)]), "tone": "violet"}, + ], + "menus": [ + self._menu_tile("hr_employees", _("Employees"), _("Manage employee master data"), "fa fa-users", "blue", count=employees_count, count_label=_("active"), xml_id="hr.open_view_employee_list_my", fallback_model="hr.employee", fallback_domain=[["active", "=", True]], fallback_views=[[False, "kanban"], [False, "list"], [False, "form"]]), + self._menu_tile("hr_contracts", _("Contracts"), _("Employment contracts"), "fa fa-file-text-o", "green", count=self._safe_search_count("hr.contract", []), count_label=_("records"), model="hr.contract", views=[[False, "list"], [False, "form"]]), + self._menu_tile("hr_payroll", _("Payroll"), _("Payslips and batches"), "fa fa-money", "amber", count=self._safe_search_count("hr.payslip", [("state", "in", ["draft", "verify"])]), count_label=_("to process"), xml_id="hr_payroll.action_view_hr_payslip_month_form", fallback_model="hr.payslip", fallback_views=[[False, "list"], [False, "form"]]), + self._menu_tile("hr_recruitment", _("Recruitment"), _("Jobs and applicants"), "fa fa-briefcase", "cyan", count=self._safe_search_count("hr.applicant", [("active", "=", True)]), count_label=_("applicants"), xml_id="hr_recruitment.crm_case_categ0_act_job", fallback_model="hr.applicant", fallback_views=[[False, "kanban"], [False, "list"], [False, "form"]]), + self._menu_tile("hr_appraisal", _("Appraisals"), _("Cycles and reviews"), "fa fa-star", "violet", count=self._safe_search_count("hr.notice.appraisal", [("state", "in", ["draft", "sent", "postponed"])]), count_label=_("cycles"), model="hr.notice.appraisal", views=[[False, "list"], [False, "form"]]), + self._menu_tile("hr_salary_advance", _("Salary Advances"), _("Advances and deductions"), "fa fa-credit-card", "rose", count=self._safe_search_count("hr.salary.advance", [("state", "=", "open")]), count_label=_("running"), model="hr.salary.advance", views=[[False, "list"], [False, "form"]]), + ], + "approval_tiles": pending_tiles, + "approval_queue": self._hr_approval_queue(), + "workforce": self._workforce_snapshot(date_from, date_to), + } + + def _approval_tile(self, key, title, model, domain, icon, color): + if model not in request.env: + return False + return self._menu_tile( + key=key, + title=title, + subtitle=_("Review and approve pending work"), + icon=icon, + color=color, + count=self._safe_search_count(model, domain), + count_label=_("pending"), + model=model, + domain=[list(item) for item in domain], + views=[[False, "list"], [False, "form"]], + ) + + def _manager_approval_queue(self, manager, team_ids): + specs = [ + ("hr.leave", [("employee_id", "in", team_ids), ("state", "in", ["confirm", "validate1"])], _("Time Off"), "request_date_from"), + ("on.duty.form", [("employee_id", "in", team_ids), ("state", "=", "submitted")], _("On-Duty"), "start_date"), + ("late.coming.request", [("manager_id", "=", manager.id), ("state", "=", "submitted")], _("Late Coming"), "attendance_date"), + ("overtime.request", [("manager_id", "=", manager.id), ("state", "=", "submitted")], _("Overtime"), "attendance_date"), + ("shift.swap.request", [("manager_id", "=", manager.id), ("state", "=", "submitted")], _("Shift Swap"), "roster_date"), + ("travel.trip", [("manager_id", "=", manager.id), ("state", "=", "submitted")], _("Travel"), "start_date"), + ] + return self._approval_records(specs) + + def _hr_approval_queue(self): + specs = [ + ("hr.leave", [("state", "in", ["confirm", "validate1"])], _("Time Off"), "request_date_from"), + ("on.duty.form", [("state", "=", "submitted")], _("On-Duty"), "start_date"), + ("late.coming.request", [("state", "=", "submitted")], _("Late Coming"), "attendance_date"), + ("overtime.request", [("state", "=", "submitted")], _("Overtime"), "attendance_date"), + ("shift.swap.request", [("state", "=", "submitted")], _("Shift Swap"), "roster_date"), + ("travel.trip", [("state", "=", "submitted")], _("Travel"), "start_date"), + ] + return self._approval_records(specs) + + def _approval_records(self, specs, limit=18): + rows = [] + for model, domain, category, date_field in specs: + if model not in request.env: + continue + try: + records = request.env[model].sudo().search(domain, order="id desc", limit=6) + except Exception: + continue + for record in records: + rows.append(self._approval_record(record, category, date_field)) + return sorted(rows, key=lambda item: item["sort_date"] or "", reverse=True)[:limit] + + def _approval_record(self, record, category, date_field): + employee = record.employee_id if "employee_id" in record._fields else False + date_value = record[date_field] if date_field in record._fields else False + title = getattr(record, "display_name", "") or getattr(record, "name", "") or category + return { + "id": record.id, + "model": record._name, + "category": category, + "title": title, + "employee": employee.name if employee else "", + "department": employee.department_id.name if employee and employee.department_id else "", + "date": self._date_string(date_value), + "sort_date": self._date_string(date_value), + "state": record.state if "state" in record._fields else "", + "state_label": self._selection_label(record, "state"), + "can_approve": hasattr(record, "action_approve") or hasattr(record, "action_validate"), + "can_reject": hasattr(record, "action_reject") or hasattr(record, "action_refuse"), + "action": { + "type": "ir.actions.act_window", + "name": category, + "res_model": record._name, + "views": [[False, "form"]], + "res_id": record.id, + "target": "current", + }, + } + + def _selection_label(self, record, field_name): + if field_name not in record._fields: + return "" + selection = dict(record._fields[field_name].selection) + return selection.get(record[field_name], record[field_name] or "") + + def _team_snapshot(self, team, today_start, today_end): + rows = [] + attendance_model = request.env["hr.attendance"].sudo() if "hr.attendance" in request.env else False + leave_model = request.env["hr.leave"].sudo() if "hr.leave" in request.env else False + for employee in team[:12]: + present = False + on_leave = False + if attendance_model: + present = bool(attendance_model.search_count([ + ("employee_id", "=", employee.id), + ("check_in", ">=", today_start), + ("check_in", "<=", today_end), + ])) + if leave_model: + today = today_start.date() + on_leave = bool(leave_model.search_count([ + ("employee_id", "=", employee.id), + ("request_date_from", "<=", today), + ("request_date_to", ">=", today), + ("state", "=", "validate"), + ])) + rows.append({ + "id": employee.id, + "name": employee.name, + "job": employee.job_id.name if employee.job_id else "", + "department": employee.department_id.name if employee.department_id else "", + "image_url": "/web/image/hr.employee/%s/image_1920" % employee.id, + "status": _("On Leave") if on_leave else (_("Present") if present else _("Not Checked In")), + "tone": "leave" if on_leave else ("present" if present else "absent"), + "action": { + "type": "ir.actions.act_window", + "name": _("Employee"), + "res_model": "hr.employee", + "views": [[False, "form"]], + "res_id": employee.id, + "target": "current", + }, + }) + return rows + + def _workforce_snapshot(self, date_from, date_to): + return [ + {"label": _("New Joiners"), "value": self._safe_search_count("hr.employee", [("create_date", ">=", datetime.combine(date_from, time.min)), ("create_date", "<=", datetime.combine(date_to, time.max))]), "icon": "fa fa-user-plus"}, + {"label": _("Open Leave Requests"), "value": self._safe_search_count("hr.leave", [("state", "in", ["confirm", "validate1"])]), "icon": "fa fa-calendar-check-o"}, + {"label": _("Running Salary Advances"), "value": self._safe_search_count("hr.salary.advance", [("state", "=", "open")]), "icon": "fa fa-credit-card"}, + {"label": _("Active Appraisal Cycles"), "value": self._safe_search_count("hr.notice.appraisal", [("state", "in", ["sent", "postponed"])]), "icon": "fa fa-star"}, + ] + + + @http.route('/hrms_emp_dashboard/leave_requests', type='json', auth='user') + def leave_requests(self, limit=5, offset=0, **kwargs): + try: + employee = request.env.user.employee_id + if not employee: + return {'success': False} + + domain = [('employee_id', '=', employee.id)] + total = request.env['hr.leave'].sudo().search_count(domain) + + leaves = request.env['hr.leave'].sudo().search( + domain, + order='request_date_from desc', + limit=limit, + offset=offset, + ) + + state_selection = dict(leaves._fields['state'].selection) if leaves else {} + + return { + 'success': True, + 'leaves': [{ + 'id': leave.id, + 'name': leave.holiday_status_id.name or '', + 'date_from': fields.Datetime.to_string(leave.request_date_from), + 'date_to': fields.Datetime.to_string(leave.request_date_to), + 'duration': round(leave.number_of_days or 0, 1), + 'state': leave.state or 'draft', + 'state_label': state_selection.get(leave.state, leave.state or 'Draft'), + } for leave in leaves], + 'total': total, + } + except Exception as e: + return {'success': False, 'error': str(e)} diff --git a/addons_extensions/hrms_emp_dashboard/models/__init__.py b/addons_extensions/hrms_emp_dashboard/models/__init__.py new file mode 100644 index 000000000..0deb68c46 --- /dev/null +++ b/addons_extensions/hrms_emp_dashboard/models/__init__.py @@ -0,0 +1 @@ +from . import res_config_settings diff --git a/addons_extensions/hrms_emp_dashboard/models/res_config_settings.py b/addons_extensions/hrms_emp_dashboard/models/res_config_settings.py new file mode 100644 index 000000000..d8f14dcb3 --- /dev/null +++ b/addons_extensions/hrms_emp_dashboard/models/res_config_settings.py @@ -0,0 +1,26 @@ +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + hrms_dashboard_show_on_duty = fields.Boolean( + string="On-Duty Requests", + config_parameter="hrms_emp_dashboard.show_on_duty", + default=True, + ) + hrms_dashboard_show_late_coming = fields.Boolean( + string="Late Coming Requests", + config_parameter="hrms_emp_dashboard.show_late_coming", + default=True, + ) + hrms_dashboard_show_overtime = fields.Boolean( + string="Overtime Requests", + config_parameter="hrms_emp_dashboard.show_overtime", + default=True, + ) + hrms_dashboard_show_shift_swap = fields.Boolean( + string="Shift Swap Requests", + config_parameter="hrms_emp_dashboard.show_shift_swap", + default=True, + ) diff --git a/addons_extensions/hrms_emp_dashboard/static/src/css/hrms_emp_dashboard.css b/addons_extensions/hrms_emp_dashboard/static/src/css/hrms_emp_dashboard.css index b6e9b2d32..b28c2ac4f 100644 --- a/addons_extensions/hrms_emp_dashboard/static/src/css/hrms_emp_dashboard.css +++ b/addons_extensions/hrms_emp_dashboard/static/src/css/hrms_emp_dashboard.css @@ -2,9 +2,9 @@ height: calc(100vh - 84px); overflow-y: auto; overflow-x: hidden; - padding: 20px; - background: #f4f7fb; - color: #111827; + padding: 18px; + background: #f5f8fd; + color: #071b4f; } .hrms-loading { @@ -14,21 +14,264 @@ border-radius: 8px; } -.hrms-employee-card, -.hrms-panel, -.hrms-kpi { - background: #fff; - border: 1px solid #e5e7eb; +.hrms-dashboard-tabs { + display: flex; + gap: 8px; + margin-bottom: 14px; + padding: 6px; + border: 1px solid #dfe8f6; border-radius: 8px; - box-shadow: 0 8px 24px rgba(15, 23, 42, 0.05); + background: #fff; + box-shadow: 0 10px 24px rgba(26, 58, 118, 0.05); + overflow-x: auto; } +.hrms-dashboard-tab { + min-height: 42px; + padding: 9px 14px; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: #50658f; + font-weight: 800; + white-space: nowrap; +} + +.hrms-dashboard-tab i { margin-right: 6px; } + +.hrms-dashboard-tab.active { + background: #0f766e; + border-color: #0f766e; + color: #fff; +} + +.hrms-panel, +.hrms-menu, +.hrms-kpi { + background: #fff; + border: 1px solid #dfe8f6; + border-radius: 8px; + box-shadow: 0 14px 34px rgba(26, 58, 118, 0.07); +} + +.hrms-service-dashboard { + display: grid; + gap: 16px; +} + +.hrms-service-hero { + min-height: 156px; + padding: 22px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + color: #fff; + box-shadow: 0 14px 34px rgba(26, 58, 118, 0.1); +} + +.hrms-service-hero.manager { background: linear-gradient(135deg, #0f766e 0%, #1d4ed8 100%); } +.hrms-service-hero.hr { background: linear-gradient(135deg, #1e3a8a 0%, #be123c 100%); } + +.hrms-service-hero span { + display: block; + color: rgba(255, 255, 255, 0.78); + font-size: 12px; + font-weight: 800; + text-transform: uppercase; +} + +.hrms-service-hero h1 { + margin: 6px 0 4px; + color: #fff; + font-size: 28px; + font-weight: 800; +} + +.hrms-service-hero p { + max-width: 720px; + margin: 0; + color: rgba(255, 255, 255, 0.88); +} + +.hrms-service-kpis { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.hrms-service-kpi { + padding: 14px; + border: 1px solid #dfe8f6; + border-radius: 8px; + background: #fff; + box-shadow: 0 10px 24px rgba(26, 58, 118, 0.05); +} + +.hrms-service-kpi span { + display: block; + color: #64748b; + font-size: 12px; + font-weight: 800; + text-transform: uppercase; +} + +.hrms-service-kpi strong { + display: block; + margin-top: 8px; + color: #0f172a; + font-size: 28px; + line-height: 1; +} + +.hrms-service-kpi.blue strong { color: #2563eb; } +.hrms-service-kpi.green strong { color: #16a34a; } +.hrms-service-kpi.amber strong { color: #d97706; } +.hrms-service-kpi.violet strong { color: #7c3aed; } + +.hrms-service-grid { + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(340px, 0.65fr); + gap: 16px; + align-items: start; +} + +.hrms-service-menu-row { + grid-template-columns: none; + margin-bottom: 0; +} + +.hrms-approval-list, +.hrms-team-list, +.hrms-workforce-list { + display: grid; + gap: 8px; + max-height: 520px; + overflow-y: auto; + scrollbar-width: thin; +} + +.hrms-approval-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + padding: 10px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #f8fafc; +} + +.hrms-approval-main { + min-width: 0; + border: 0; + background: transparent; + text-align: left; + color: #0f172a; +} + +.hrms-approval-main strong, +.hrms-team-row strong { + display: block; + color: #0f172a; + font-size: 13px; + font-weight: 800; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.hrms-approval-main small, +.hrms-team-row small { + display: block; + margin-top: 2px; + color: #64748b; + font-size: 11px; +} + +.hrms-approval-category { + display: block; + color: #2563eb; + font-size: 10px; + font-weight: 800; + text-transform: uppercase; +} + +.hrms-approval-actions { + display: flex; + align-items: center; + gap: 6px; +} + +.hrms-team-row { + display: grid; + grid-template-columns: 38px minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + padding: 9px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #fff; + text-align: left; +} + +.hrms-team-row img { + width: 38px; + height: 38px; + border-radius: 8px; + object-fit: cover; + background: #e2e8f0; +} + +.hrms-team-row em { + padding: 3px 8px; + border-radius: 999px; + font-size: 10px; + font-style: normal; + font-weight: 800; + white-space: nowrap; +} + +.hrms-team-row em.present { background: #dcfce7; color: #15803d; } +.hrms-team-row em.absent { background: #f1f5f9; color: #64748b; } +.hrms-team-row em.leave { background: #ffedd5; color: #c2410c; } + +.hrms-workforce-row { + display: grid; + grid-template-columns: 36px minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + padding: 12px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #f8fafc; +} + +.hrms-workforce-row i { + width: 36px; + height: 36px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + background: #eff6ff; + color: #2563eb; +} + +.hrms-workforce-row span { color: #334155; font-weight: 800; } +.hrms-workforce-row strong { color: #0f172a; font-size: 22px; } + +/* ── Employee Card ── */ .hrms-employee-card { display: flex; justify-content: space-between; gap: 20px; padding: 20px; margin-bottom: 14px; + border: 1px solid #e5e7eb; + border-radius: 8px; + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.05); background: linear-gradient(135deg, #172554 0%, #0f766e 100%); color: #fff; } @@ -68,15 +311,8 @@ color: #e0f2fe; } -.hrms-employee-grid i { - width: 18px; - color: #ffffff; -} - -.hrms-employee-grid b { - color: #ffffff; - font-weight: 700; -} +.hrms-employee-grid i { width: 18px; color: #fff; } +.hrms-employee-grid b { color: #fff; font-weight: 700; } .hrms-employee-actions { display: flex; @@ -103,34 +339,11 @@ font-weight: 700; } -.hrms-icon-button i { - display: block; - font-size: 22px; - margin-bottom: 4px; -} - -/*.hrms-icon-button.primary {*/ -/* background: #16a34a;*/ -/* border-color: #22c55e;*/ -/*}*/ - -.hrms-icon-button.checkin { - background: #16a34a; - color: #fff; -} - -.hrms-icon-button.checkout { - background: #dc2626; - color: #fff; -} - -.hrms-icon-button.checkout:hover { - background: #b91c1c; -} - -.hrms-icon-button.checkin:hover { - background: #15803d; -} +.hrms-icon-button i { display: block; font-size: 22px; margin-bottom: 4px; } +.hrms-icon-button.checkin { background: #16a34a; color: #fff; } +.hrms-icon-button.checkout { background: #dc2626; color: #fff; } +.hrms-icon-button.checkout:hover { background: #b91c1c; } +.hrms-icon-button.checkin:hover { background: #15803d; } .hrms-filter-box { width: 100%; @@ -149,6 +362,14 @@ text-transform: uppercase; } +.hrms-filter-box .form-emp-card-select { + color: white; +} + +.hrms-filter-box .form-emp-card-select option { + color: black; /* Keeps dropdown items readable on most browsers */ +} + .hrms-custom-dates { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -156,17 +377,164 @@ margin-top: 8px; } -.hrms-kpi-row { +/* ── Menu Row ── */ +.hrms-menus-row { display: grid; - grid-template-columns: repeat(5, minmax(140px, 1fr)); + grid-auto-flow: column; + grid-auto-columns: minmax(174px, 1fr); + grid-template-columns: repeat(8, minmax(174px, 1fr)); gap: 12px; - margin-bottom: 14px; + margin-bottom: 16px; + overflow-x: auto; + padding-bottom: 4px; + scrollbar-width: thin; } -.hrms-kpi { - padding: 14px; +.hrms-menu { + min-height: 112px; + padding: 12px; + border: 1px solid #dfe8f6; + display: grid; + grid-template-columns: 34px minmax(0, 1fr) auto; + grid-template-rows: auto 1fr; + gap: 10px; + align-items: start; + text-align: left; + color: #071b4f; + cursor: pointer; + position: relative; + overflow: hidden; + transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease; } +.hrms-menu:hover { + border-color: #b9cdf0; + box-shadow: 0 18px 36px rgba(26, 58, 118, 0.12); + transform: translateY(-2px); +} + +.hrms-menu.green { background: linear-gradient(135deg, #ffffff 0%, #ecfdf5 100%); } +.hrms-menu.blue { background: linear-gradient(135deg, #ffffff 0%, #eff6ff 100%); } +.hrms-menu.amber { background: linear-gradient(135deg, #ffffff 0%, #fff7ed 100%); } +.hrms-menu.cyan { background: linear-gradient(135deg, #ffffff 0%, #ecfeff 100%); } +.hrms-menu.red { background: linear-gradient(135deg, #ffffff 0%, #fff1f2 100%); } +.hrms-menu.violet { background: linear-gradient(135deg, #ffffff 0%, #f5f3ff 100%); } +.hrms-menu.slate { background: linear-gradient(135deg, #ffffff 0%, #f1f5f9 100%); } +.hrms-menu.rose { background: linear-gradient(135deg, #ffffff 0%, #fdf2f8 100%); } + +.hrms-menu-glow { + position: absolute; + right: -28px; + top: -34px; + width: 94px; + height: 94px; + border-radius: 50%; + background: rgba(37, 99, 235, 0.08); + pointer-events: none; +} + +.hrms-menu-icon { + width: 34px; + height: 34px; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 16px; + background: #2563eb; + color: #fff; +} + +.hrms-menu.green .hrms-menu-icon { background: #10b981; } +.hrms-menu.blue .hrms-menu-icon { background: #3b82f6; } +.hrms-menu.amber .hrms-menu-icon { background: #f59e0b; } +.hrms-menu.cyan .hrms-menu-icon { background: #0891b2; } +.hrms-menu.red .hrms-menu-icon { background: #e11d48; } +.hrms-menu.violet .hrms-menu-icon { background: #8b5cf6; } +.hrms-menu.slate .hrms-menu-icon { background: #1e3a8a; } +.hrms-menu.rose .hrms-menu-icon { background: #db2777; } + +.hrms-menu-content { + grid-column: 1 / 4; + min-width: 0; + position: relative; +} + +.hrms-menu-title { + display: block; + color: #071b4f; + font-size: 14px; + font-weight: 800; +} + +.hrms-menu-content small, +.hrms-menu-count small { + display: block; + margin-top: 3px; + color: #50658f; + font-size: 11px; + font-weight: 700; + line-height: 1.25; +} + +.hrms-menu-count { + min-width: 54px; + text-align: right; + position: relative; + z-index: 1; +} + +.hrms-menu-count strong { + display: block; + color: #071b4f; + font-size: 22px; + line-height: 1; +} + +.hrms-menu-badge { + max-width: 96px; + padding: 5px 8px; + border-radius: 999px; + background: rgba(37, 99, 235, 0.08); + color: #1d4ed8; + font-size: 11px; + font-weight: 800; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.hrms-menu-arrow { color: #2563eb; } + +.hrms-menu-plus { + border: 1px solid #cbd5e1; + background: #fff; + border-radius: 6px; + color: #64748b; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + display: none !important; +} + +.hrms-menu-create:hover { + background: #eff6ff; + transform: scale(1.04); +} + +/* ── KPI Row: 5 columns full width ── */ +.hrms-kpi-row { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 16px; +} + +.hrms-kpi { padding: 12px; } + .hrms-kpi span { display: block; color: #64748b; @@ -183,33 +551,56 @@ } .hrms-kpi.present strong { color: #16a34a; } -.hrms-kpi.absent strong { color: #dc2626; } -.hrms-kpi.break strong { color: #2563eb; } +.hrms-kpi.absent strong { color: #dc2626; } +.hrms-kpi.break strong { color: #2563eb; } .hrms-kpi.leave strong, .hrms-kpi.holiday strong { color: #f97316; } .hrms-kpi.remaining.positive strong { color: #16a34a; } .hrms-kpi.remaining.negative strong { color: #dc2626; } +/* ── Middle Grid: Calendar + Right Stack ── */ .hrms-grid { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 14px; + grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); + gap: 16px; + align-items: start; + margin-bottom: 16px; } -.hrms-panel { - padding: 16px; +.hrms-employee-columns { + display: grid; + grid-template-columns: minmax(0, 1.6fr) minmax(340px, 1fr); + gap: 16px; + align-items: start; +} + +.hrms-employee-main-column, +.hrms-employee-side-column { + display: grid; + gap: 16px; + align-content: start; min-width: 0; } -.hrms-panel.wide { - grid-column: span 2; +/* Right stack: Leave Balance on top, Public Holidays below */ +.hrms-right-stack { + display: grid; + grid-template-rows: auto auto; + gap: 16px; + align-content: start; +} + +/* ── Panels ── */ +.hrms-panel { + padding: 14px; + min-width: 0; } .hrms-panel h2 { - margin: 0 0 12px; + margin: 0; font-size: 16px; font-weight: 800; - color: #0f172a; + color: #071b4f; } .hrms-panel-header { @@ -220,6 +611,16 @@ margin-bottom: 10px; } +.hrms-panel-count { + padding: 4px 8px; + border-radius: 999px; + background: #f1f5f9; + color: #475569; + font-size: 11px; + font-weight: 800; +} + +/* ── Calendar ── */ .hrms-month-controls { display: flex; align-items: center; @@ -227,18 +628,16 @@ flex-wrap: wrap; } -.hrms-calendar-select { - width: 112px; -} +.hrms-calendar-select { width: 112px; } .hrms-calendar { display: grid; grid-template-columns: repeat(7, minmax(130px, 1fr)); gap: 0; overflow-x: auto; - border: 1px solid #dbe3ed; + border: 1px solid #dbe8f7; border-radius: 8px; - background: #dbe3ed; + background: #dbe8f7; } .hrms-calendar-weekdays { @@ -246,24 +645,22 @@ grid-template-columns: repeat(7, minmax(130px, 1fr)); gap: 0; overflow-x: auto; - border: 1px solid #dbe3ed; + border: 1px solid #dbe8f7; border-bottom: 0; border-radius: 8px 8px 0 0; - background: #f8fafc; + background: #f7faff; } .hrms-calendar-weekdays span { padding: 10px 8px; - color: #0f172a; + color: #071b4f; font-size: 13px; font-weight: 800; text-align: center; border-right: 1px solid #dbe3ed; } -.hrms-calendar-weekdays span:last-child { - border-right: 0; -} +.hrms-calendar-weekdays span:last-child { border-right: 0; } .hrms-calendar.yearly { grid-template-columns: repeat(4, minmax(160px, 1fr)); @@ -276,14 +673,24 @@ min-height: 132px; padding: 10px; border: 0; - border-right: 1px solid #dbe3ed; - border-bottom: 1px solid #dbe3ed; + border-right: 1px solid #dbe8f7; + border-bottom: 1px solid #dbe8f7; border-radius: 0; - background: #fff; + background: #fbfdff; position: relative; overflow: hidden; + text-align: left; + cursor: pointer; } +.hrms-day:hover:not(:disabled) { + z-index: 1; + outline: 2px solid #3b82f6; + outline-offset: -2px; +} + +.hrms-day:disabled { cursor: default; } + .hrms-calendar.yearly .hrms-day { min-height: 126px; border: 1px solid #e5e7eb; @@ -291,10 +698,7 @@ } .hrms-day span, -.hrms-day small { - color: #64748b; - font-size: 12px; -} +.hrms-day small { color: #64748b; font-size: 12px; } .hrms-day-top { display: flex; @@ -304,25 +708,32 @@ min-height: 24px; } -.hrms-day-top strong { - color: #0f172a; - font-size: 13px; - line-height: 1.2; -} +.hrms-day-top strong { color: #071b4f; font-size: 13px; line-height: 1.2; } +.hrms-day-top strong span { display: inline; margin-left: 4px; font-size: 11px; font-weight: 700; } -.hrms-day-top strong span { - display: inline; - margin-left: 4px; - font-size: 11px; - font-weight: 700; -} +/*.hrms-day-badge {*/ +/* max-width: 76px;*/ +/* padding: 2px 6px;*/ +/* border-radius: 6px;*/ +/* background: #eef2ff;*/ +/* color: #4338ca;*/ +/* font-size: 10px;*/ +/* font-weight: 800;*/ +/* overflow: hidden;*/ +/* text-overflow: ellipsis;*/ +/* white-space: nowrap;*/ +/*}*/ -.hrms-day-badge { - max-width: 76px; - padding: 2px 6px; - border-radius: 6px; - background: #eef2ff; - color: #4338ca; +.hrms-day-signals { display: grid; gap: 4px; margin-top: 8px; } + +.hrms-day-signal { + display: flex; + align-items: center; + gap: 5px; + min-height: 20px; + max-width: 100%; + padding: 3px 6px; + border-radius: 7px; font-size: 10px; font-weight: 800; overflow: hidden; @@ -330,25 +741,24 @@ white-space: nowrap; } -.hrms-day-balance { - height: 22px; - line-height: 22px; - margin: 10px 0 8px; - border-radius: 999px; - text-align: center; - font-size: 12px; - font-weight: 800; -} +.hrms-day-signal i { flex: 0 0 auto; font-size: 10px; } +.hrms-day-signal.event { background: #dbeafe; color: #1d4ed8; } +.hrms-day-signal.holiday { background: #fef3c7; color: #b45309; } +.hrms-day-signal.leave { background: #ffedd5; color: #c2410c; } +.hrms-day-signal.more { background: #eef2ff; color: #4338ca; } -.hrms-day-balance.positive { - background: #dcfce7; - color: #16a34a; -} +/*.hrms-day-balance {*/ +/* height: 22px;*/ +/* line-height: 22px;*/ +/* margin: 10px 0 8px;*/ +/* border-radius: 999px;*/ +/* text-align: center;*/ +/* font-size: 12px;*/ +/* font-weight: 800;*/ +/*}*/ -.hrms-day-balance.negative { - background: #fee2e2; - color: #dc2626; -} +/*.hrms-day-balance.positive { background: #dcfce7; color: #16a34a; }*/ +/*.hrms-day-balance.negative { background: #fee2e2; color: #dc2626; }*/ .hrms-day-message { min-height: 76px; @@ -362,249 +772,325 @@ text-align: center; } -.hrms-day-message-icon { - color: inherit; - font-size: 16px; -} +.hrms-day-message-icon { color: inherit; font-size: 16px; } +.hrms-day-message strong { color: inherit; font-size: 13px; line-height: 1.25; overflow-wrap: anywhere; } -.hrms-day-message strong { - color: inherit; - font-size: 13px; - line-height: 1.25; - overflow-wrap: anywhere; -} +/*.hrms-day-metrics {*/ +/* display: grid;*/ +/* grid-template-columns: repeat(3, minmax(0, 1fr));*/ +/* gap: 6px;*/ +/*}*/ -.hrms-day-metrics { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 6px; -} +/*.hrms-day-metrics div { min-width: 0; }*/ +/*.hrms-day-metrics span { display: block; color: #64748b; font-size: 10px; font-weight: 800; }*/ +/*.hrms-day-metrics strong { display: block; margin-top: 3px; color: #0f172a; font-size: 13px; line-height: 1.15; word-break: keep-all; }*/ -.hrms-day-metrics div { - min-width: 0; -} - -.hrms-day-metrics span { - display: block; - color: #64748b; - font-size: 10px; - font-weight: 800; -} - -.hrms-day-metrics strong { - display: block; - margin-top: 3px; - color: #0f172a; - font-size: 13px; - line-height: 1.15; - word-break: keep-all; -} - -.hrms-calendar-month-stats { - display: grid; - gap: 5px; - margin-top: 10px; -} - -.hrms-calendar-month-stats small { - display: block; - font-size: 12px; -} +.hrms-calendar-month-stats { display: grid; gap: 5px; margin-top: 10px; } +.hrms-calendar-month-stats small { display: block; font-size: 12px; } .hrms-day.present { box-shadow: inset 0 3px 0 #22c55e; } -.hrms-day.absent { box-shadow: inset 0 3px 0 #ef4444; } -.hrms-day.leave { - background: #fff5f5; - box-shadow: inset 0 3px 0 #ef4444; -} -.hrms-day.leave .hrms-day-message { - background: #fee2e2; - color: #b91c1c; - border: 1px solid #fecaca; -} -.hrms-day.holiday { - background: #f8fafc; - box-shadow: inset 0 3px 0 #94a3b8; -} -.hrms-day.holiday .hrms-day-message { - background: #e5e7eb; - color: #334155; - border: 1px solid #cbd5e1; -} -.hrms-day.weekend { - background: #f8fafc; - box-shadow: none; -} -.hrms-day.weekend .hrms-day-message { - min-height: 58px; - background: transparent; - color: #94a3b8; - border: 1px dashed #e2e8f0; -} +.hrms-day.absent { box-shadow: inset 0 3px 0 #fecaca; } +.hrms-day.leave { background: #fff5f5; box-shadow: inset 0 3px 0 #ef4444; } +.hrms-day.leave .hrms-day-message { background: #fee2e2; color: #b91c1c; border: 1px solid #fecaca; } +.hrms-day.holiday { background: #fffaf0; box-shadow: inset 0 3px 0 #f59e0b; } +.hrms-day.holiday .hrms-day-message { background: #e5e7eb; color: #334155; border: 1px solid #cbd5e1; } +.hrms-day.weekend { background: #f4f8ff; box-shadow: none; } +.hrms-day.weekend .hrms-day-message { min-height: 58px; background: transparent; color: #94a3b8; border: 1px dashed #e2e8f0; } .hrms-day.future { opacity: 0.55; } -.hrms-day.empty { - background: #f8fafc; - opacity: 0.45; +.hrms-day.empty { background: #f8fafc; opacity: 0.45; } + +/* ── Leave Balance: Simple bars, fixed height, scroll ── */ +.hrms-leave-scroll { + max-height: 300px; + overflow-y: auto; + scrollbar-width: thin; } -.hrms-leave-summary { +.hrms-leave-scroll::-webkit-scrollbar { width: 5px; } +.hrms-leave-scroll::-webkit-scrollbar-track { background: transparent; } +.hrms-leave-scroll::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; } + + +.hrms-leave-item:last-child { margin-bottom: 0; } + +/* ── Leave Balance: 2-column grid cards ── */ +.hrms-balance-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 8px; +} + +.hrms-balance-card { display: flex; - flex-wrap: wrap; + align-items: center; gap: 10px; - margin-bottom: 12px; -} - -.hrms-leave-tile { - min-width: 180px; - flex: 1 1 180px; /* grow, shrink, basis */ - max-width: 250px; - - padding: 10px; + padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 8px; background: #f8fafc; + transition: border-color 0.15s ease, box-shadow 0.15s ease; } -.hrms-leave-tile strong { - display: block; - color: #0f172a; - font-size: 13px; - margin-bottom: 8px; +.hrms-balance-card:hover { + border-color: #cbd5e1; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); } -.hrms-leave-tile span { - display: inline-block; - min-width: 64px; - margin-right: 10px; - color: #64748b; - font-size: 11px; -} - -.hrms-leave-tile b { - display: block; - color: #0f172a; - font-size: 18px; -} - -.hrms-leave-graph { - display: grid; - gap: 12px; -} - -.hrms-leave-graph-row { - display: grid; - grid-template-columns: minmax(140px, 190px) minmax(180px, 1fr); - gap: 10px 14px; - align-items: center; -} - -.hrms-leave-graph-label strong { - display: block; - color: #0f172a; - font-size: 13px; -} - -.hrms-leave-graph-label span { - color: #64748b; - font-size: 11px; -} - -.hrms-leave-bar { - display: flex; - width: 100%; - height: 18px; - overflow: hidden; +.hrms-balance-card-icon { + width: 34px; + height: 34px; border-radius: 8px; - background: #e5e7eb; + background: #eff6ff; + color: #2563eb; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + flex-shrink: 0; } -.hrms-leave-bar span { +.hrms-balance-card-info { min-width: 0; } -.hrms-leave-bar-remaining { - background: #16a34a; +.hrms-balance-card-info strong { + display: block; + color: #0f172a; + font-size: 12px; + font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.hrms-leave-bar-taken { - background: #2563eb; +.hrms-balance-card-days { + display: block; + color: #16a34a; + font-size: 16px; + font-weight: 800; + margin-top: 1px; } -.hrms-leave-bar-planned { - background: #f59e0b; -} - -.hrms-leave-legend { - grid-column: 2; +/* ── Leave Divider ── */ +.hrms-leave-divider { display: flex; - flex-wrap: wrap; - gap: 8px 14px; + align-items: center; + gap: 10px; + margin: 14px 0 10px; color: #64748b; font-size: 11px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.5px; } -.hrms-leave-legend span { - display: inline-flex; +.hrms-leave-divider::before, +.hrms-leave-divider::after { + content: ''; + flex: 1; + height: 1px; + background: #e5e7eb; +} + +/* ── Leave Requests ── */ +.hrms-leave-empty-req { + text-align: center; + padding: 12px 0; +} + +.hrms-leave-req-row { + display: flex; align-items: center; - gap: 5px; + justify-content: space-between; + gap: 8px; + padding: 8px 10px; + border: 1px solid #f1f5f9; + border-radius: 8px; + background: #fafbfc; + margin-bottom: 6px; + transition: border-color 0.15s ease; } -.hrms-leave-legend i { - width: 9px; - height: 9px; - border-radius: 2px; +.hrms-leave-req-row:hover { + border-color: #e2e8f0; } -.hrms-leave-legend i.remaining { - background: #16a34a; +.hrms-leave-req-info { + min-width: 0; + flex: 1; } -.hrms-leave-legend i.taken { - background: #2563eb; +.hrms-leave-req-info strong { + display: block; + color: #0f172a; + font-size: 12px; + font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.hrms-leave-legend i.planned { - background: #f59e0b; +.hrms-leave-req-dates { + display: block; + color: #94a3b8; + font-size: 11px; + margin-top: 2px; } -.hrms-leave-legend b { +.hrms-leave-req-meta { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.hrms-leave-req-duration { + color: #64748b; + font-size: 12px; + font-weight: 700; +} + +.hrms-leave-req-state { + padding: 2px 8px; + border-radius: 999px; + font-size: 10px; + font-weight: 800; + text-transform: capitalize; + white-space: nowrap; +} + +.hrms-leave-req-state.draft { background: #f1f5f9; color: #64748b; } +.hrms-leave-req-state.confirm { background: #fff7ed; color: #c2410c; } +.hrms-leave-req-state.validate1 { background: #eff6ff; color: #2563eb; } +.hrms-leave-req-state.validate { background: #dcfce7; color: #15803d; } +.hrms-leave-req-state.refuse { background: #fef2f2; color: #dc2626; } +.hrms-leave-req-state.cancel { background: #f1f5f9; color: #94a3b8; } + +/* ── Load More Button ── */ +.hrms-load-more-btn { + display: block; + width: 100%; + padding: 8px; + margin-top: 8px; + border: 1px dashed #cbd5e1; + border-radius: 8px; + background: #fff; + color: #475569; + font-size: 12px; + font-weight: 700; + cursor: pointer; + transition: all 0.15s ease; +} + +.hrms-load-more-btn:hover { + background: #f8fafc; + border-color: #94a3b8; color: #0f172a; } -.hrms-two-charts { - display: grid; - grid-template-columns: 1.3fr 0.7fr; - gap: 12px; + +/* ── Public Holidays: Fixed height, scroll ── */ +.hrms-holidays-scroll { + max-height: 300px; + overflow-y: auto; + scrollbar-width: thin; } -.hrms-list { - display: grid; - gap: 10px; -} +.hrms-holidays-scroll::-webkit-scrollbar { width: 5px; } +.hrms-holidays-scroll::-webkit-scrollbar-track { background: transparent; } +.hrms-holidays-scroll::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; } + +.btn-group .btn { border-radius: 0 !important; } +.btn-group .btn:first-child { border-radius: 4px 0 0 4px !important; } +.btn-group .btn:last-child { border-radius: 0 4px 4px 0 !important; } + +/* ── List rows ── */ +.hrms-list { display: grid; gap: 10px; } .hrms-list-row { display: flex; gap: 10px; padding: 10px; background: #f8fafc; + border: 1px solid #e5e7eb; border-radius: 8px; } -.hrms-list-row i { - color: #f97316; - margin-top: 3px; +.hrms-list-row i { color: #f97316; margin-top: 3px; } +.hrms-list-row span, +.hrms-muted { display: block; color: #64748b; font-size: 12px; } + +/* ── Bottom Row: Expenses + Equipment ── */ +.hrms-bottom-row { + display: grid; + grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); + gap: 16px; + align-items: start; } -.hrms-list-row span, -.hrms-muted { - display: block; - color: #64748b; - font-size: 12px; +/* ── Expenses ── */ +.hrms-expense-body { + max-height: 380px; + overflow-y: auto; + scrollbar-width: thin; } +.hrms-expense-body::-webkit-scrollbar { width: 5px; } +.hrms-expense-body::-webkit-scrollbar-track { background: transparent; } +.hrms-expense-body::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; } + +.hrms-two-charts { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} + +#hrmsExpenseChart, +#hrmsExpenseStateChart { min-height: 340px; } + +.expense-empty-state { + min-height: 240px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; + border: 1px dashed #d9d9d9; + border-radius: 12px; + background: #fafafa; + padding: 28px; +} + +.expense-empty-state .empty-icon { + width: 90px; + height: 90px; + border-radius: 50%; + background: #f3f4f6; + display: flex; + justify-content: center; + align-items: center; + font-size: 42px; + color: #9ca3af; + margin-bottom: 20px; +} + +.expense-empty-state h3 { font-size: 24px; font-weight: 600; color: #374151; margin-bottom: 12px; } +.expense-empty-state p { width: 500px; max-width: 95%; color: #6b7280; line-height: 1.7; margin-bottom: 25px; } + +/* ── Equipment: Fixed height, scroll ── */ +.hrms-equipment-scroll { + max-height: 340px; + overflow-y: auto; + scrollbar-width: thin; +} + +.hrms-equipment-scroll::-webkit-scrollbar { width: 5px; } +.hrms-equipment-scroll::-webkit-scrollbar-track { background: transparent; } +.hrms-equipment-scroll::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; } + .hrms-equipment-grid { display: grid; - grid-template-columns: repeat(4, minmax(160px, 1fr)); - gap: 10px; + grid-template-columns: repeat(2, 1fr); + gap: 8px; } .hrms-equipment { @@ -616,128 +1102,872 @@ color: #0f172a; } -.hrms-equipment i { - color: #2563eb; - font-size: 20px; -} - +.hrms-equipment i { color: #2563eb; font-size: 20px; } .hrms-equipment strong, .hrms-equipment span, -.hrms-equipment small { - display: block; - margin-top: 4px; -} - +.hrms-equipment small { display: block; margin-top: 4px; } .hrms-equipment span, -.hrms-equipment small { - color: #64748b; +.hrms-equipment small { color: #64748b; } + +/* ── Day Modal ── */ +.hrms-day-modal-backdrop { + position: fixed; + inset: 0; + z-index: 1050; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + background: rgba(15, 23, 42, 0.4); + backdrop-filter: blur(2px); } +.hrms-day-modal { + width: min(520px, 100%); + border-radius: 12px; + background: #fff; + box-shadow: 0 20px 60px rgba(15, 23, 42, 0.2); + overflow: hidden; +} + +.hrms-modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 16px 18px 12px; + background: #f8fafc; + border-bottom: 1px solid #e5e7eb; +} + +.hrms-modal-title-group { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.hrms-modal-title-group h2 { + margin: 0; + color: #0f172a; + font-size: 18px; + font-weight: 800; +} + +.hrms-day-modal header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 20px 20px 12px; + border-bottom: 1px solid #e5e7eb; +} + +.hrms-day-modal h2 { margin: 2px 0 0; color: #0f172a; font-size: 20px; font-weight: 800; } +.hrms-modal-kicker { color: #64748b; font-size: 11px; font-weight: 800; text-transform: uppercase; } + +.hrms-modal-close { + width: 30px; + height: 30px; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #fff; + color: #64748b; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + flex-shrink: 0; + transition: all 0.15s ease; +} + +.hrms-modal-close:hover { + background: #f1f5f9; + color: #0f172a; +} + +.hrms-day-modal-status { + display: flex; + align-items: center; + gap: 8px; + padding: 14px 20px 0; + color: #334155; +} + + +/* Modal body */ +.hrms-modal-body { + padding: 14px 18px; +} + +/* Detail chips - compact flex layout */ +.hrms-detail-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.hrms-detail-chip { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + background: #f8fafc; + border: 1px solid #f1f5f9; + border-radius: 8px; + min-width: 0; + transition: border-color 0.15s ease; +} + +.hrms-detail-chip:hover { + border-color: #e2e8f0; +} + +.hrms-detail-chip span { + color: #94a3b8; + font-size: 10px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.4px; + white-space: nowrap; +} + +.hrms-detail-chip strong { + color: #0f172a; + font-size: 14px; + font-weight: 700; + white-space: nowrap; +} + +.hrms-modal-event { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid #f1f5f9; +} + +.hrms-modal-events-label { + display: block; + font-size: 10px; + font-weight: 800; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 8px; +} + +.hrms-modal-events-list { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.hrms-event-tag { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 5px 10px; + border-radius: 6px; + font-size: 12px; + font-weight: 600; +} + + +.hrms-event-tag i { + font-size: 11px; +} + +.hrms-event-tag.event { background: #dbeafe; color: #1d4ed8; } +.hrms-event-tag.holiday { background: #fef3c7; color: #92400e; } +.hrms-event-tag.leave { background: #ffedd5; color: #c2410c; } +.hrms-event-tag.more { background: #eef2ff; color: #4338ca; } + +/* Modal footer */ +.hrms-day-modal footer { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; + padding: 10px 18px 14px; + border-top: 1px solid #f1f5f9; + background: #fafbfc; +} + + +.hrms-status-pill { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + text-transform: capitalize; + background: #f1f5f9; + color: #475569; +} + +.hrms-status-pill::before { + content: ''; + width: 7px; + height: 7px; + border-radius: 50%; + background: #94a3b8; +} + +.hrms-status-pill.present { background: #dcfce7; color: #15803d; } +.hrms-status-pill.present::before { background: #22c55e; } +.hrms-status-pill.absent { background: #fee2e2; color: #dc2626; } +.hrms-status-pill.absent::before { background: #ef4444; } +.hrms-status-pill.leave { background: #ffedd5; color: #c2410c; } +.hrms-status-pill.leave::before { background: #f97316; } +.hrms-status-pill.holiday { background: #fef3c7; color: #92400e; } +.hrms-status-pill.holiday::before { background: #f59e0b; } +.hrms-status-pill.weekend { background: #f1f5f9; color: #64748b; } +.hrms-status-pill.weekend::before { background: #94a3b8; } +.hrms-status-pill.future { background: #e0f2fe; color: #0369a1; } +.hrms-status-pill.future::before { background: #38bdf8; } + + +.hrms-status-dot { width: 10px; height: 10px; border-radius: 50%; background: #94a3b8; } +.hrms-status-dot.present { background: #22c55e; } +.hrms-status-dot.absent { background: #ef4444; } +.hrms-status-dot.leave { background: #f97316; } +.hrms-status-dot.holiday { background: #64748b; } +.hrms-status-dot.weekend { background: #94a3b8; } +.hrms-status-dot.future { background: #38bdf8; } + +.hrms-day-detail-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + padding: 16px 20px 20px; +} + +.hrms-day-detail { + min-height: 72px; + padding: 12px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #f8fafc; +} + +.hrms-day-detail span { display: block; color: #64748b; font-size: 11px; font-weight: 800; text-transform: uppercase; } +.hrms-day-detail strong { display: block; margin-top: 5px; color: #0f172a; font-size: 13px; line-height: 1.35; overflow-wrap: anywhere; } + +.hrms-day-modal footer { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; + padding: 14px 20px 20px; + border-top: 1px solid #e5e7eb; +} + +/* ── Responsive ── */ @media (max-width: 1200px) { .hrms-employee-card, - .hrms-employee-main { - flex-direction: column; - } + .hrms-employee-main { flex-direction: column; } - .hrms-employee-grid, - .hrms-grid, - .hrms-leave-graph-row, - .hrms-two-charts { - grid-template-columns: 1fr; - } + .hrms-employee-grid { grid-template-columns: 1fr; } - .hrms-leave-legend { - grid-column: 1; - } + .hrms-kpi-row { grid-template-columns: repeat(3, minmax(0, 1fr)); } - .hrms-panel.wide { - grid-column: span 1; - } + .hrms-grid { grid-template-columns: 1fr; } - .hrms-kpi-row, - .hrms-equipment-grid, - .hrms-calendar.yearly { - grid-template-columns: repeat(2, minmax(140px, 1fr)); - } + .hrms-employee-columns { grid-template-columns: 1fr; } + + .hrms-bottom-row { grid-template-columns: 1fr; } + + .hrms-equipment-grid { grid-template-columns: repeat(2, minmax(140px, 1fr)); } } @media (max-width: 700px) { - .hrms-kpi-row, + .hrms-kpi-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .hrms-equipment-grid, .hrms-calendar.yearly, - .hrms-leave-summary, - .hrms-custom-dates { - grid-template-columns: 1fr; - } + .hrms-custom-dates { grid-template-columns: 1fr; } .hrms-calendar, - .hrms-calendar-weekdays { - grid-template-columns: repeat(7, minmax(118px, 1fr)); - } + .hrms-calendar-weekdays { grid-template-columns: repeat(7, minmax(118px, 1fr)); } .hrms-panel-header, .hrms-panel-actions, - .hrms-action-buttons { - align-items: stretch; - flex-direction: column; - } + .hrms-action-buttons { align-items: stretch; flex-direction: column; } + + .hrms-day-detail-grid { grid-template-columns: 1fr; } } -.expense-empty-state{ - min-height:420px; +/* ── Menu Plus Button: Top Right ── */ +.hrms-menu-plus-btn { + position: absolute; + top: 8px; + right: 8px; + width: 26px; + height: 26px; + border: 1px solid #e2e8f0; + border-radius: 6px; + background: #fff; + color: #64748b; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + z-index: 2; + transition: all 0.15s ease; + box-shadow: 0 1px 3px rgba(0,0,0,0.06); +} + +.hrms-menu-plus-btn:hover { + background: #2563eb; + color: #fff; + border-color: #2563eb; + transform: scale(1.08); +} + +/* Remove old plus style if exists */ +.hrms-menu-plus { + display: none !important; +} + +/* ── Calendar Day: Clean professional look ── */ +/* ── Calendar Day ── */ +.hrms-day { + min-height: 110px; + padding: 10px; + border: 0; + border-right: 1px solid #dbe8f7; + border-bottom: 1px solid #dbe8f7; + border-radius: 0; + background: #fbfdff; + position: relative; + overflow: hidden; + text-align: left; + cursor: pointer; + display: flex; + flex-direction: column; + gap: 4px; +} + +.hrms-day:hover:not(:disabled) { + z-index: 1; + outline: 2px solid #3b82f6; + outline-offset: -2px; +} + +.hrms-day:disabled { cursor: default; } + +.hrms-calendar.yearly .hrms-day { + min-height: 126px; + border: 1px solid #e5e7eb; + border-radius: 8px; +} + +.hrms-day-top { + display: flex; + align-items: baseline; + gap: 6px; +} + +.hrms-day-top strong { + color: #071b4f; + font-size: 15px; + font-weight: 800; + line-height: 1; +} + +.hrms-day-top span { + color: #94a3b8; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +/* Worked hours pill */ +.hrms-day-worked { + display: inline-flex; + align-items: center; + padding: 3px 8px; + border-radius: 6px; + background: #f0fdf4; + color: #16a34a; + font-size: 13px; + font-weight: 800; + width: fit-content; + margin-top: 2px; +} + +/* Zero hours - red */ +.hrms-day-worked.zero { + background: #fef2f2; + color: #dc2626; +} + +/* Day signals */ +.hrms-day-signals { + display: flex; + flex-direction: column; + gap: 3px; + margin-top: auto; +} + +.hrms-day-signal { + display: flex; + align-items: center; + gap: 5px; + padding: 2px 6px; + border-radius: 5px; + font-size: 10px; + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.4; + border: none; + background: none; + cursor: default; + text-align: left; + width: 100%; +} + +.hrms-day-signal i { + flex: 0 0 auto; + font-size: 9px; + width: 14px; + height: 14px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 3px; + background: rgba(255,255,255,0.7); +} + +.hrms-day-signal.event { background: #dbeafe; color: #1d4ed8; } +.hrms-day-signal.holiday { background: #fef3c7; color: #b45309; } +.hrms-day-signal.leave { background: #ffedd5; color: #c2410c; } +.hrms-day-signal.more { background: #eef2ff; color: #4338ca; } + +/* Clickable "+X more" button */ +.hrms-more-btn { + cursor: pointer !important; + border: 1px dashed #a5b4fc !important; + background: #eef2ff !important; + justify-content: center; + transition: all 0.15s ease; +} + +.hrms-more-btn:hover { + background: #c7d2fe !important; + color: #3730a3 !important; + border-color: #818cf8 !important; +} + +.hrms-more-btn i { + background: rgba(99, 102, 241, 0.15); + color: #6366f1; +} + +/* Day message */ +.hrms-day-message { + flex: 1; + margin-top: 6px; + padding: 8px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + text-align: center; +} + +.hrms-day-message strong { + font-size: 11px; + line-height: 1.3; + color: inherit; + overflow-wrap: anywhere; +} + +.hrms-day-message-icon { font-size: 14px; color: inherit; } + +/* Day status */ +.hrms-day.present { box-shadow: inset 0 3px 0 #22c55e; } +.hrms-day.absent { box-shadow: inset 0 3px 0 #fecaca; } +.hrms-day.leave { background: #fff7ed; box-shadow: inset 0 3px 0 #fb923c; } +.hrms-day.leave .hrms-day-message { background: #ffedd5; color: #c2410c; border: 1px solid #fed7aa; } +.hrms-day.holiday { background: #fffbeb; box-shadow: inset 0 3px 0 #fbbf24; } +.hrms-day.holiday .hrms-day-message { background: #fef3c7; color: #92400e; border: 1px solid #fde68a; } +.hrms-day.weekend { background: #f8fafc; box-shadow: none; } +.hrms-day.weekend .hrms-day-message { background: transparent; color: #94a3b8; border: 1px dashed #e2e8f0; } +.hrms-day.future { opacity: 0.5; } +.hrms-day.empty { background: #f8fafc; opacity: 0.35; } + +/* Month stats for yearly view */ +.hrms-calendar-month-stats { display: grid; gap: 5px; margin-top: 10px; } +.hrms-calendar-month-stats small { display: block; font-size: 12px; } + +/* Day message for leave/holiday/weekend */ +.hrms-day-message { + flex: 1; + margin-top: 6px; + padding: 8px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + text-align: center; +} + +.hrms-day-message strong { + font-size: 11px; + line-height: 1.3; +} + +.hrms-day-message-icon { font-size: 14px; } + +/* Day status colors - cleaner top border */ +.hrms-day.present { box-shadow: inset 0 3px 0 #22c55e; } +.hrms-day.absent { box-shadow: inset 0 3px 0 #fecaca; } +.hrms-day.leave { background: #fff7ed; box-shadow: inset 0 3px 0 #fb923c; } +.hrms-day.leave .hrms-day-message { background: #ffedd5; color: #c2410c; border: 1px solid #fed7aa; } +.hrms-day.holiday { background: #fffbeb; box-shadow: inset 0 3px 0 #fbbf24; } +.hrms-day.holiday .hrms-day-message { background: #fef3c7; color: #92400e; border: 1px solid #fde68a; } +.hrms-day.weekend { background: #f8fafc; box-shadow: none; } +.hrms-day.weekend .hrms-day-message { background: transparent; color: #94a3b8; border: 1px dashed #e2e8f0; } +.hrms-day.future { opacity: 0.5; } +.hrms-day.empty { background: #f8fafc; opacity: 0.35; } + +/* ── Modal: Professional popup ── */ +.hrms-modal-status-row { + display: flex; + align-items: center; + gap: 10px; + padding: 0 20px 16px; + border-bottom: 1px solid #f1f5f9; +} + +.hrms-modal-status-row strong { + font-size: 15px; + color: #334155; + font-weight: 700; +} + +.hrms-modal-signals { + padding: 16px 20px; + border-top: 1px solid #f1f5f9; +} + +.hrms-modal-signals > strong { + display: block; + font-size: 12px; + font-weight: 800; + color: #64748b; + text-transform: uppercase; + margin-bottom: 10px; + letter-spacing: 0.5px; +} + +.hrms-modal-signals > div { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.hrms-modal-signals .hrms-day-signal { + padding: 6px 12px; + font-size: 12px; + border-radius: 8px; +} + +.hrms-modal-signals .hrms-day-signal i { + width: 18px; + height: 18px; + font-size: 11px; +} + +.hrms-day-detail { + min-height: 64px; + padding: 14px; + border: 1px solid #f1f5f9; + border-radius: 10px; + background: #fafbfc; + transition: border-color 0.15s ease; +} + +.hrms-day-detail:hover { + border-color: #e2e8f0; +} + +.hrms-day-detail span { + display: block; + color: #94a3b8; + font-size: 10px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.hrms-day-detail strong { + display: block; + margin-top: 6px; + color: #0f172a; + font-size: 15px; + font-weight: 700; + line-height: 1.3; +} + +/* ── Leave empty state ── */ +.hrms-empty-message { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 32px 16px; + color: #94a3b8; + font-size: 13px; + font-weight: 600; + text-align: center; +} + +.hrms-empty-message i { + font-size: 28px; + color: #cbd5e1; +} + +/* ── Leave Requests Section ── */ +.hrms-leave-divider { + display: flex; + align-items: center; + gap: 10px; + margin: 14px 0 10px; + color: #64748b; + font-size: 11px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.hrms-leave-divider::before, +.hrms-leave-divider::after { + content: ''; + flex: 1; + height: 1px; + background: #e5e7eb; +} + +.hrms-leave-empty-req { + text-align: center; + padding: 12px 0; +} + +.hrms-leave-req-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 10px; + border: 1px solid #f1f5f9; + border-radius: 8px; + background: #fafbfc; + margin-bottom: 6px; + transition: border-color 0.15s ease; +} + +.hrms-leave-req-row:hover { + border-color: #e2e8f0; +} + +.hrms-leave-req-info { + min-width: 0; + flex: 1; +} + +.hrms-leave-req-info strong { + display: block; + color: #0f172a; + font-size: 12px; + font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.hrms-leave-req-dates { + display: block; + color: #94a3b8; + font-size: 11px; + margin-top: 2px; +} + +.hrms-leave-req-meta { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.hrms-leave-req-duration { + color: #64748b; + font-size: 12px; + font-weight: 700; +} + +.hrms-leave-req-state { + padding: 2px 8px; + border-radius: 999px; + font-size: 10px; + font-weight: 800; + text-transform: capitalize; + white-space: nowrap; +} + +.hrms-leave-req-state.draft { background: #f1f5f9; color: #64748b; } +.hrms-leave-req-state.confirm { background: #fff7ed; color: #c2410c; } +.hrms-leave-req-state.validate1 { background: #eff6ff; color: #2563eb; } +.hrms-leave-req-state.validate { background: #dcfce7; color: #15803d; } +.hrms-leave-req-state.refuse { background: #fef2f2; color: #dc2626; } +.hrms-leave-req-state.cancel { background: #f1f5f9; color: #94a3b8; } + +.hrms-load-more-btn { + display: block; + width: 100%; + padding: 8px; + margin-top: 8px; + border: 1px dashed #cbd5e1; + border-radius: 8px; + background: #fff; + color: #475569; + font-size: 12px; + font-weight: 700; + cursor: pointer; + transition: all 0.15s ease; +} + +.hrms-load-more-btn:hover { + background: #f8fafc; + border-color: #94a3b8; + color: #0f172a; +} + +.hrms-equipment-timeline{ display:flex; flex-direction:column; - justify-content:center; - align-items:center; - text-align:center; - - border:1px dashed #d9d9d9; - border-radius:12px; - - background:#fafafa; - padding:40px; + gap:18px; + padding-top:8px; } -.expense-empty-state .empty-icon{ - width:90px; - height:90px; +.hrms-equipment-row{ + display:grid; + grid-template-columns:110px 40px 1fr; + gap:18px; + align-items:flex-start; +} - border-radius:50%; - background:#f3f4f6; +.hrms-equipment-date{ + text-align:right; + padding-top:12px; + font-size:13px; + color:#64748b; + font-weight:600; +} +.hrms-equipment-line{ + position:relative; display:flex; justify-content:center; - align-items:center; - - font-size:42px; - color:#9ca3af; - - margin-bottom:20px; + min-height:130px; } -.expense-empty-state h3{ - font-size:24px; - font-weight:600; - color:#374151; - margin-bottom:12px; +.timeline-dot{ + width:16px; + height:16px; + border-radius:50%; + background:#14b8a6; + border:4px solid white; + box-shadow:0 0 0 2px #14b8a6; + z-index:2; } -.expense-empty-state p{ - width:500px; - max-width:95%; - color:#6b7280; - line-height:1.7; - margin-bottom:25px; +.timeline-line{ + position:absolute; + top:18px; + width:2px; + bottom:-18px; + background:#d6dee8; } -.hrms-two-charts{ - display:grid; - grid-template-columns:1fr 1fr; - gap:20px; +.hrms-equipment-row:last-child .timeline-line{ + display:none; } -#hrmsExpenseChart, -#hrmsExpenseStateChart{ - min-height:340px; +.hrms-equipment-card { + position: relative; + width: 100%; + border: none; + text-align: left; + background: white; + border-radius: 14px; + padding: 22px; + border: 1px solid #e5e7eb; + box-shadow: 0 8px 22px rgba(0, 0, 0, .05); + transition: .25s; } + +.hrms-equipment-card:hover { + transform: translateY(-3px); + box-shadow: 0 14px 30px rgba(0, 0, 0, .12); + border-color: #14b8a6; +} + +.equipment-service-count { + position: absolute; + right: 18px; + top: 18px; + padding: 6px 12px; + border-radius: 30px; + background: #ecfeff; + color: #0f766e; + font-weight: 700; + font-size: 12px; + display: flex; + align-items: center; + gap: 6px; +} + +.equipment-title { + font-size: 18px; + font-weight: 700; + color: #0f172a; + margin-bottom: 6px; +} + +.equipment-category { + color: #64748b; + font-size: 14px; + margin-bottom: 8px; +} + +.equipment-serial { + color: #475569; + font-size: 13px; +} + +@media (max-width: 768px) { + + .hrms-equipment-row { + grid-template-columns:1fr; + } + + .hrms-equipment-date { + text-align: left; + padding-left: 48px; + } + + .hrms-equipment-line { + position: absolute; + left: 20px; + } +} \ No newline at end of file diff --git a/addons_extensions/hrms_emp_dashboard/static/src/js/hrms_emp_dashboard.js b/addons_extensions/hrms_emp_dashboard/static/src/js/hrms_emp_dashboard.js index 6cfeb97a6..9eaf4a9de 100644 --- a/addons_extensions/hrms_emp_dashboard/static/src/js/hrms_emp_dashboard.js +++ b/addons_extensions/hrms_emp_dashboard/static/src/js/hrms_emp_dashboard.js @@ -13,21 +13,23 @@ class HrmsEmployeeDashboard extends Component { const today = new Date(); const monthStart = new Date(today.getFullYear(), today.getMonth(), 1); const monthEnd = new Date(today.getFullYear(), today.getMonth() + 1, 0); - const weekStart = new Date(today); - weekStart.setDate(today.getDate() - ((today.getDay() + 6) % 7)); - const weekEnd = new Date(weekStart); - weekEnd.setDate(weekStart.getDate() + 6); this.state = useState({ loading: true, error: null, data: null, period: "this_month", - calendarView: "weekly", + calendarView: "monthly", dateFrom: this.formatDate(monthStart), dateTo: this.formatDate(monthEnd), - calendarDateFrom: this.formatDate(weekStart), - calendarDateTo: this.formatDate(weekEnd), + calendarDateFrom: this.formatDate(monthStart), + calendarDateTo: this.formatDate(monthEnd), + selectedDay: null, + activeTab: this.getStoredActiveTab(), + holidayTab: 'current', + leaveRequests: [], + leaveRequestsTotal: 0, + leaveRequestsLimit: 5, }); this.rpc = rpc; this.action = useService("action"); @@ -42,7 +44,6 @@ class HrmsEmployeeDashboard extends Component { }); }); onWillDestroy(() => this.destroyCharts()); - } formatDate(date) { @@ -52,6 +53,22 @@ class HrmsEmployeeDashboard extends Component { return `${year}-${month}-${day}`; } + getStoredActiveTab() { + try { + return window.localStorage.getItem("hrms_emp_dashboard.activeTab") || "employee"; + } catch { + return "employee"; + } + } + + storeActiveTab(tab = this.state.activeTab) { + try { + window.localStorage.setItem("hrms_emp_dashboard.activeTab", tab || "employee"); + } catch { + // Local storage can be unavailable in restricted browser contexts. + } + } + async loadData() { this.state.loading = true; this.state.error = null; @@ -63,17 +80,45 @@ class HrmsEmployeeDashboard extends Component { calendar_date_to: this.state.calendarDateTo, calendar_view: this.state.calendarView, }); - if (!response.success) { + if (!response.success) { this.state.error = response.error || "Unable to load employee dashboard."; return; } this.state.data = response; + if (!["employee", "manager", "hr"].includes(this.state.activeTab)) { + this.state.activeTab = "employee"; + } + if (this.state.activeTab === "manager" && !response.access?.manager) { + this.state.activeTab = "employee"; + } + if (this.state.activeTab === "hr" && !response.access?.hr) { + this.state.activeTab = response.access?.manager ? "manager" : "employee"; + } + this.storeActiveTab(); + + // --- FILTER HOLIDAYS FOR TABS (Frontend Logic) --- + // If your backend doesn't separate them yet, we do it here based on 'date_from' + const today = new Date(); + const currentMonth = today.getMonth(); + const currentYear = today.getFullYear(); + + // If backend returns public_holidays[] + const allHolidays = response.public_holidays || []; + const yearlyHolidays = response.all_public_holidays || []; + + // "This Month" tab = holidays within the selected period filter (backend already filters) + this.state.data.current_holidays = allHolidays; + + // "Upcoming" tab = all yearly holidays strictly after today + this.state.data.upcoming_holidays = yearlyHolidays.filter(h => { + const hDate = new Date(h.date_from); + return hDate > today; + }); + // ------------------------------------------------- + this.loadLeaveRequests(true); + const expenses = response.expenses || {}; - const total = - (expenses.series || []).reduce( - (sum, value) => sum + Number(value || 0), - 0 - ); + const total = (expenses.series || []).reduce((sum, value) => sum + Number(value || 0), 0); this.state.expenseCount = total; setTimeout(() => this.initCharts(), 100); } catch (error) { @@ -85,6 +130,30 @@ class HrmsEmployeeDashboard extends Component { } } + async loadLeaveRequests(reset = false) { + if (reset) { + this.state.leaveRequestsLimit = 5; + this.state.leaveRequests = []; + } + try { + const response = await this.rpc("/hrms_emp_dashboard/leave_requests", { + limit: this.state.leaveRequestsLimit, + offset: 0, + }); + if (response.success) { + this.state.leaveRequests = response.leaves; + this.state.leaveRequestsTotal = response.total; + } + } catch (e) { + console.warn("Could not load leave requests:", e); + } + } + + loadMoreLeaves() { + this.state.leaveRequestsLimit += 5; + this.loadLeaveRequests(); + } + destroyCharts() { for (const chart of this.charts) { try { @@ -301,6 +370,170 @@ class HrmsEmployeeDashboard extends Component { }); } + openDashboardMenu(menu) { + if (!menu || !menu.action) { + return; + } + this.storeActiveTab(); + if (menu.action.xml_id) { + this.action.doAction(menu.action.xml_id); + return; + } + this.action.doAction(menu.action); + } + + switchTab(tab) { + this.state.activeTab = tab; + this.storeActiveTab(tab); + setTimeout(() => this.initCharts(), 80); + } + + openWorkAction(action) { + if (action) { + this.storeActiveTab(); + this.action.doAction(action); + } + } + + async runApproval(record, operation) { + try { + const response = await this.rpc("/hrms_emp_dashboard/approval_action", { + model: record.model, + record_id: record.id, + operation, + }); + if (!response.success) { + this.notification.add(response.error || "Unable to update request", { type: "danger" }); + return; + } + this.notification.add(response.message || "Request updated", { type: "success" }); + await this.loadData(); + } catch (error) { + console.error(error); + this.notification.add("Unable to update request", { type: "danger" }); + } + } + + createFromDashboardMenu(menu) { + if (!menu || !menu.create_action) { + return; + } + this.storeActiveTab(); + this.action.doAction(menu.create_action); + } + + runDayAction(action) { + const day = this.state.selectedDay; + if (!day || !action) { + return; + } + const handlers = { + apply_leave: () => this.applyLeaveForDate(day.date), + add_todo: () => this.addTodoForDate(day.date), + open_attendance: () => this.openAttendancesForDate(day.date), + open_calendar: () => this.openCalendarForDate(day.date, day.type), + create_meeting: () => this.createMeetingForDate(day.date), + }; + const handler = handlers[action.key]; + if (handler) { + handler(); + this.closeDayDetails(); + } + } + + applyLeaveForDate(date) { + this.action.doAction({ + type: "ir.actions.act_window", + name: "Apply Leave", + res_model: "hr.leave", + views: [[false, "form"]], + target: "new", + context: { + default_employee_id: this.state.data.employee.id, + default_request_date_from: date, + default_request_date_to: date, + }, + }); + } + + addTodoForDate(date) { + this.action.doAction({ + type: "ir.actions.act_window", + name: "Add To-do", + res_model: "project.task", + views: [[false, "form"]], + target: "new", + context: { + default_name: "To-do for " + date, + default_date_deadline: date + " 18:00:00", + default_user_ids: [[4, this.state.data.employee.user_id]], + }, + }); + } + + openAttendancesForDate(date) { + this.action.doAction({ + type: "ir.actions.act_window", + name: "Attendance - " + date, + res_model: "hr.attendance", + views: [[false, "list"], [false, "form"]], + domain: [ + ["employee_id", "=", this.state.data.employee.id], + ["check_in", ">=", date + " 00:00:00"], + ["check_in", "<=", date + " 23:59:59"], + ], + target: "current", + }); + } + + openCalendarForDate(date, type = "day") { + const domain = []; + if (type !== "month") { + domain.push(["start", "<=", date + " 23:59:59"]); + domain.push(["stop", ">=", date + " 00:00:00"]); + } + this.action.doAction({ + type: "ir.actions.act_window", + name: "Calendar", + res_model: "calendar.event", + views: [[false, "calendar"], [false, "list"], [false, "form"]], + domain, + target: "current", + context: { + default_start: date + " 09:00:00", + default_stop: date + " 10:00:00", + }, + }); + } + + createMeetingFromCalendar() { + this.createMeetingForDate(this.state.calendarDateFrom || this.formatDate(new Date())); + } + + createMeetingForDate(date) { + if (date && date.length === 7) { + date = date + "-01"; + } + const start = date + " 09:00:00"; + const stop = date + " 10:00:00"; + const context = { + default_name: "Meeting", + default_start: start, + default_stop: stop, + }; + if (this.state.data.employee.partner_id) { + context.default_partner_ids = [[4, this.state.data.employee.partner_id]]; + } + this.action.doAction({ + type: "ir.actions.act_window", + name: "New Meeting", + res_model: "calendar.event", + views: [[false, "form"]], + target: "new", + context, + }); + } + // addExpense() { // this.action.doAction({ // type: "ir.actions.act_window", @@ -418,6 +651,71 @@ class HrmsEmployeeDashboard extends Component { }); } + buildDayDetails(day) { + if (!day || day.status === 'empty') return []; + const details = []; + if (day.worked_display) details.push({ label: "Worked", value: day.worked_display }); + if (day.break_display) details.push({ label: "Break", value: day.break_display }); + if (day.expected_display) details.push({ label: "Expected", value: day.expected_display }); + if (day.balance_display) details.push({ label: "Balance", value: day.balance_display }); + if (day.leave && parseFloat(day.leave) > 0) details.push({ label: "Leave", value: day.leave + " hrs" }); + if (day.holiday && parseFloat(day.holiday) > 0) details.push({ label: "Holiday", value: day.holiday + " day(s)" }); + if (day.event_count) details.push({ label: "Events", value: day.event_count }); + if (day.date) details.push({ label: "Date", value: day.date }); + return details; + } + + async openDayDetails(day) { + if (!day || day.status === 'empty') return; + + let fullDay = day; + let gotBackendSignals = false; + + // Try backend first (for tasks, extra leaves, holidays) + if (day.date && day.type !== 'month') { + try { + const response = await this.rpc("/hrms_emp_dashboard/day_details", { + date: day.date, + }); + if (response.success && response.day && response.day.signals && response.day.signals.length > 0) { + fullDay = { ...day, ...response.day }; + gotBackendSignals = true; + } + } catch (e) { + console.warn("Backend details unavailable, using calendar data:", e); + } + } + + let finalSignals; + + if (gotBackendSignals) { + // Backend returned full signals — just strip any leftover "more" + finalSignals = (fullDay.signals || []).filter(s => s.type !== 'more'); + } else { + // Rebuild ALL signals from day.events (contains every event, no truncation) + const existingSignals = fullDay.signals || []; + const nonEventSignals = existingSignals.filter(s => s.type !== 'more' && s.type !== 'event'); + const eventSignals = (fullDay.events || []).map(event => ({ + type: 'event', + label: event.display_time + ? event.display_time + ' ' + event.name + : event.name, + icon: 'fa fa-users', + })); + finalSignals = [...nonEventSignals, ...eventSignals]; + } + + this.state.selectedDay = { + ...fullDay, + signals: finalSignals, + details: this.buildDayDetails(fullDay), + }; + } + + closeDayDetails() { + this.state.selectedDay = null; + } + get statusText() { return this.state.data?.attendance_state === "checked_in" ? "Check Out" : "Check In"; } diff --git a/addons_extensions/hrms_emp_dashboard/static/src/xml/hrms_emp_dashboard.xml b/addons_extensions/hrms_emp_dashboard/static/src/xml/hrms_emp_dashboard.xml index 3b361e71a..969071671 100644 --- a/addons_extensions/hrms_emp_dashboard/static/src/xml/hrms_emp_dashboard.xml +++ b/addons_extensions/hrms_emp_dashboard/static/src/xml/hrms_emp_dashboard.xml @@ -6,6 +6,27 @@
Loading employee dashboard...
+
+ + + +
+ +
Employee @@ -27,21 +48,15 @@
- - - - +
Period - @@ -73,6 +88,34 @@
+
+
+ + + + + + + + + + + + + +
+
+ +
Expected Hours
Worked Hours
@@ -84,14 +127,18 @@
-
-
+
+
+
-

Attendance Calendar

+

Work Calendar

+ + + + +
+
+
+
+
No public holidays in the selected month.
+
+ +
-
+
+
+
+
No upcoming public holidays.
+
+ +
-
+
+
+
+
+ +
+
+

Allocated Equipment

+ +
+
+
+ No equipment allocated. +
+
+ +
+ +
+ +
+ + +
+ + +
+
+
+
+
+
+ +
+
+
+ Manager Workspace +

+

+

+ +
+
+
+ + +
+
+
+
+ + + + + + + + + +
+
+
+
+
+

Approval Queue

+ items +
+
+
No pending manager approvals.
+
+ +
+ + + +
+
+
+
+
+
+

Team Today

+ people +
+
+
No team members reporting to you.
+ +
+
+
+
+
+ + + + + + +
+
+
+ +
+
+
+ HR Workspace +

+

+

+ +
+
+
+ + +
+
+
+
+ + + + + + +
+
+
+
+
+

HR Approval Queue

+ items +
+
+
No pending HR approvals.
+
+ +
+ + + +
+
+
+
+
+
+

Workforce Signals

+
+
+
+ + + +
+
+
+
+
+
+ + + + + + +
+
+
+
+
+
+

+ + +

+ +
+ +
+
+
+
+ + +
+
+
+ Events & Activities +
+ + + + +
+
+
+
+ +
+
+
diff --git a/addons_extensions/hrms_emp_dashboard/views/hrms_emp_dashboard_views.xml b/addons_extensions/hrms_emp_dashboard/views/hrms_emp_dashboard_views.xml index a86e71d83..ee1308bbc 100644 --- a/addons_extensions/hrms_emp_dashboard/views/hrms_emp_dashboard_views.xml +++ b/addons_extensions/hrms_emp_dashboard/views/hrms_emp_dashboard_views.xml @@ -1,13 +1,13 @@ - Employee Dashboard + Dashboard hrms_emp_dashboard current + + + res.config.settings.view.form.dashboard + res.config.settings + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons_extensions/hrms_employee_appraisal/static/description/icon.png b/addons_extensions/hrms_employee_appraisal/static/description/icon.png deleted file mode 100644 index 92fba0e58..000000000 Binary files a/addons_extensions/hrms_employee_appraisal/static/description/icon.png and /dev/null differ diff --git a/addons_extensions/offer_letters/__manifest__.py b/addons_extensions/offer_letters/__manifest__.py index 3104f490b..4c3c9c3d5 100644 --- a/addons_extensions/offer_letters/__manifest__.py +++ b/addons_extensions/offer_letters/__manifest__.py @@ -8,17 +8,15 @@ """, 'author': 'Raman Marikanti', 'category': 'Human Resources', - 'depends': ['base', 'hr_recruitment', 'hr_payroll', 'hr_recruitment_extended'], + 'depends': ['base', 'employee_bridge', 'hr_payroll'], 'data': [ 'security/ir.model.access.csv', 'data/mail_template.xml', - 'views/stages.xml', 'views/offer_letter_views.xml', - 'views/hr_applicant_offer_views.xml', 'views/offer_response_templates.xml', + 'views/recruitment_employee_bridge.xml', # 'views/templates.xml', 'views/menu_views.xml', - 'wizards/offer_release_request_wizard.xml', 'wizards/applicant_offer_mail_wizard.xml', 'wizards/offer_letter_reject_wizard.xml', 'report/offer_letter_report.xml', diff --git a/addons_extensions/offer_letters/data/mail_template.xml b/addons_extensions/offer_letters/data/mail_template.xml index 958432a3e..e7bcd359e 100644 --- a/addons_extensions/offer_letters/data/mail_template.xml +++ b/addons_extensions/offer_letters/data/mail_template.xml @@ -5,14 +5,14 @@ Applicant Offer Email Template {{ user.email_formatted }} - {{ object.main_candidate_id.email_from or '' }} - Offer Letter - {{ object.position or object.main_candidate_id.job_id.name or '' }} + {{ object.recipient_email or '' }} + Offer Letter - {{ object.position or '' }} Send applicant offer mail with offer letter attachment.
-

Dear ,

+

Dear ,

With reference to the interview and subsequent discussions you had with us, we are pleased to select you for the position of "" in our organization with the following diff --git a/addons_extensions/offer_letters/models/__init__.py b/addons_extensions/offer_letters/models/__init__.py index 2e82655fc..b434b278f 100644 --- a/addons_extensions/offer_letters/models/__init__.py +++ b/addons_extensions/offer_letters/models/__init__.py @@ -1,4 +1,2 @@ -from . import stages from . import offer_letter -from . import hr_applicant -from . import hr_candidate \ No newline at end of file +from . import recruitment_employee_bridge diff --git a/addons_extensions/offer_letters/models/hr_applicant.py b/addons_extensions/offer_letters/models/hr_applicant.py deleted file mode 100644 index 5f768a093..000000000 --- a/addons_extensions/offer_letters/models/hr_applicant.py +++ /dev/null @@ -1,54 +0,0 @@ -from odoo import api, fields, models, _ - - -class HRApplicant(models.Model): - _inherit = 'hr.applicant' - - finalized_ctc = fields.Float(string='Finalized CTC', tracking=True) - offer_letter_ids = fields.One2many('offer.letter', 'candidate_id', string='Offer Letters') - current_offer_letter_id = fields.Many2one( - 'offer.letter', - string='Current Offer Letter', - compute='_compute_current_offer_letter', - store=True, - ) - offer_release_status = fields.Selection( - selection=[ - ('requested', 'Requested'), - ('sent', 'Sent to Applicant'), - ('accepted', 'Accepted'), - ('rejected', 'Rejected'), - ('expired', 'Expired'), - ], - string='Offer Status', - related='current_offer_letter_id.state', - readonly=True, - store=False, - ) - - request_offer_release = fields.Boolean(related='recruitment_stage_id.request_offer_release') - - @api.depends('offer_letter_ids', 'offer_letter_ids.create_date', 'offer_letter_ids.state') - def _compute_current_offer_letter(self): - for applicant in self: - offer_letters = applicant.offer_letter_ids.sorted( - key=lambda offer: (offer.create_date or fields.Datetime.from_string('1970-01-01 00:00:00'), offer.id) - ) - applicant.current_offer_letter_id = offer_letters[-1] if offer_letters else False - - def action_request_offer_release(self): - self.ensure_one() - return { - 'type': 'ir.actions.act_window', - 'name': _('Request Offer Release'), - 'res_model': 'offer.release.request.wizard', - 'view_mode': 'form', - 'view_id': self.env.ref('offer_letters.view_offer_release_request_wizard_form').id, - 'target': 'new', - 'context': { - 'default_applicant_id': self.id, - }, - } - - def action_send_offer(self): - return self.action_request_offer_release() diff --git a/addons_extensions/offer_letters/models/hr_candidate.py b/addons_extensions/offer_letters/models/hr_candidate.py deleted file mode 100644 index e64faae1e..000000000 --- a/addons_extensions/offer_letters/models/hr_candidate.py +++ /dev/null @@ -1,33 +0,0 @@ -from odoo import api, fields, models - - -class HRCandidate(models.Model): - _inherit = 'hr.candidate' - - current_offer_letter_id = fields.Many2one( - 'offer.letter', - string='Current Offer Letter', - compute='_compute_current_offer_letter', - store=False, - ) - offer_release_status = fields.Selection( - selection=[ - ('requested', 'Requested'), - ('sent', 'Sent to Applicant'), - ('accepted', 'Accepted'), - ('rejected', 'Rejected'), - ('expired', 'Expired'), - ], - string='Offer Status', - related='current_offer_letter_id.state', - readonly=True, - store=False, - ) - - @api.depends('applicant_ids.current_offer_letter_id', 'applicant_ids.current_offer_letter_id.create_date') - def _compute_current_offer_letter(self): - for candidate in self: - offer_letters = candidate.applicant_ids.mapped('current_offer_letter_id').sorted( - key=lambda offer: (offer.create_date or fields.Datetime.from_string('1970-01-01 00:00:00'), offer.id) - ) - candidate.current_offer_letter_id = offer_letters[-1] if offer_letters else False diff --git a/addons_extensions/offer_letters/models/offer_letter.py b/addons_extensions/offer_letters/models/offer_letter.py index 7749a3520..583b5798c 100644 --- a/addons_extensions/offer_letters/models/offer_letter.py +++ b/addons_extensions/offer_letters/models/offer_letter.py @@ -27,24 +27,46 @@ class OfferLetter(models.Model): default=lambda self: _('New'), copy=False ) - candidate_id = fields.Many2one( 'hr.applicant', string='Applicant', required=False, - ) - main_candidate_id = fields.Many2one('hr.candidate',string='Candidate', readonly=False, required=True) - - @api.onchange('candidate_id') - def _onchange_candidate_id(self): - if self.candidate_id: - self.main_candidate_id = self.candidate_id.candidate_id - - main_candidate_name = fields.Char(compute="_compute_main_candidate_name", readonly=False) - - @api.depends('candidate_id','main_candidate_id') - def _compute_main_candidate_name(self): - for rec in self: - if rec.candidate_id: - rec.main_candidate_name = rec.candidate_id.partner_name - elif rec.main_candidate_id: - rec.main_candidate_name = rec.main_candidate_id.partner_name + bridge_id = fields.Many2one( + 'recruitment.employee.bridge', + string='Onboarding', + ondelete='restrict', + tracking=True, + index=True, + ) + main_candidate_name = fields.Char(string="Candidate Name", compute="_compute_bridge_contact", store=True) + recipient_email = fields.Char(compute="_compute_bridge_contact", store=True) + recipient_street = fields.Char(compute="_compute_bridge_contact", store=True) + recipient_street2 = fields.Char(compute="_compute_bridge_contact", store=True) + recipient_city = fields.Char(compute="_compute_bridge_contact", store=True) + recipient_zip = fields.Char(compute="_compute_bridge_contact", store=True) + recipient_country_id = fields.Many2one("res.country", compute="_compute_bridge_contact", store=True) + + @api.depends( + 'bridge_id', + 'bridge_id.employee_name', + 'bridge_id.employee_id.name', + 'bridge_id.work_email', + 'bridge_id.private_email', + 'bridge_id.employee_id.work_email', + 'bridge_id.employee_id.private_email', + 'bridge_id.private_street', + 'bridge_id.private_street2', + 'bridge_id.private_city', + 'bridge_id.private_zip', + 'bridge_id.private_country_id', + ) + def _compute_bridge_contact(self): + for rec in self: + bridge = rec.bridge_id + employee = bridge.employee_id + rec.main_candidate_name = bridge.employee_name or employee.name or '' + rec.recipient_email = bridge.work_email or bridge.private_email or employee.work_email or employee.private_email or '' + rec.recipient_street = bridge.private_street + rec.recipient_street2 = bridge.private_street2 + rec.recipient_city = bridge.private_city + rec.recipient_zip = bridge.private_zip + rec.recipient_country_id = bridge.private_country_id requested_by_id = fields.Many2one('res.users', string='Requested By', readonly=True, tracking=True) request_date = fields.Datetime(string='Requested On', readonly=True, tracking=True) @@ -120,20 +142,20 @@ class OfferLetter(models.Model): @api.model def create(self, vals): - if vals.get('name', _('New')) == _('New'): - vals['name'] = self.env['ir.sequence'].next_by_code('offer.letter') or _('New') - if vals.get('candidate_id') and 'salary' in vals: - self.env['hr.applicant'].browse(vals['candidate_id']).write({ - 'finalized_ctc': vals['salary'], - }) - return super(OfferLetter, self).create(vals) - - def write(self, vals): - result = super(OfferLetter, self).write(vals) - if 'salary' in vals: - for record in self.filtered('candidate_id'): - record.candidate_id.finalized_ctc = record.salary - return result + if vals.get('name', _('New')) == _('New'): + vals['name'] = self.env['ir.sequence'].next_by_code('offer.letter') or _('New') + offer_letter = super(OfferLetter, self).create(vals) + if vals.get('bridge_id') and 'salary' in vals and 'finalized_ctc' in offer_letter.bridge_id._fields: + offer_letter.bridge_id.finalized_ctc = offer_letter.salary + return offer_letter + + def write(self, vals): + result = super(OfferLetter, self).write(vals) + if 'salary' in vals: + for record in self.filtered('bridge_id'): + if 'finalized_ctc' in record.bridge_id._fields: + record.bridge_id.finalized_ctc = record.salary + return result def action_open_send_offer_wizard(self): self.ensure_one() @@ -162,7 +184,7 @@ class OfferLetter(models.Model): def action_accept_offer(self): self.ensure_one() # employee = self.env['hr.employee'].create({ - # 'name': self.candidate_id.partner_name, + # 'name': self.main_candidate_name, # 'job_title': self.position, # 'department_id': self.department_id.id, # 'currency_id': self.currency_id.id, @@ -174,17 +196,18 @@ class OfferLetter(models.Model): }) return True - def action_reject_offer(self): + def action_reject_offer(self, reason=False): self.ensure_one() self.write({ 'state': 'rejected', - 'response_date': fields.Datetime.now() + 'response_date': fields.Datetime.now(), + 'rejection_reason': reason }) return True - @api.onchange('candidate_id') - def _onchange_candidate_id(self): - self.position = self.candidate_id.job_id.name + @api.onchange('bridge_id') + def _onchange_bridge_id(self): + self.position = self.bridge_id.job_id.name def get_paydetailed_lines(self): today = fields.Date.today() @@ -278,3 +301,15 @@ class OfferLetter(models.Model): def generate_pdf_report(self): return self.env.ref('offer_letters.hr_offer_letters_employee_print').report_action(self) + + + def action_open_reject_wizard(self): + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": _("Reject Offer"), + "res_model": "offer.letter.reject.wizard", + "view_mode": "form", + "target": "new", + "context": {"default_offer_letter_id": self.id}, + } diff --git a/addons_extensions/offer_letters/models/recruitment_employee_bridge.py b/addons_extensions/offer_letters/models/recruitment_employee_bridge.py new file mode 100644 index 000000000..3935bda01 --- /dev/null +++ b/addons_extensions/offer_letters/models/recruitment_employee_bridge.py @@ -0,0 +1,45 @@ +from odoo import _, api, fields, models + + +class RecruitmentEmployeeBridge(models.Model): + _inherit = "recruitment.employee.bridge" + + finalized_ctc = fields.Float(string="Finalized CTC", tracking=True) + offer_letter_ids = fields.One2many("offer.letter", "bridge_id", string="Offer Letters") + current_offer_letter_id = fields.Many2one( + "offer.letter", + string="Current Offer Letter", + compute="_compute_current_offer_letter", + store=True, + ) + offer_release_status = fields.Selection( + selection=[ + ("requested", "Requested"), + ("sent", "Sent to Candidate"), + ("accepted", "Accepted"), + ("rejected", "Rejected"), + ("expired", "Expired"), + ], + string="Offer Status", + related="current_offer_letter_id.state", + readonly=True, + store=False, + ) + + @api.depends("offer_letter_ids", "offer_letter_ids.create_date", "offer_letter_ids.state") + def _compute_current_offer_letter(self): + for bridge in self: + offer_letters = bridge.offer_letter_ids.sorted( + key=lambda offer: (offer.create_date or fields.Datetime.from_string("1970-01-01 00:00:00"), offer.id) + ) + bridge.current_offer_letter_id = offer_letters[-1] if offer_letters else False + + def action_open_current_offer(self): + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": _("Offer Letter"), + "res_model": "offer.letter", + "view_mode": "form", + "res_id": self.current_offer_letter_id.id, + } diff --git a/addons_extensions/offer_letters/report/offer_letter_template.xml b/addons_extensions/offer_letters/report/offer_letter_template.xml index a1b714787..1cd5c9c0d 100644 --- a/addons_extensions/offer_letters/report/offer_letter_template.xml +++ b/addons_extensions/offer_letters/report/offer_letter_template.xml @@ -62,26 +62,26 @@

To,
- +
-
- +
+
- - + +
- - + +
- +
Dear - , + ,
@@ -368,7 +368,7 @@


- +

@@ -486,7 +486,7 @@

Employee Name : - +

@@ -595,7 +595,7 @@


- +

@@ -621,7 +621,7 @@ Company incorporated under Indian Companies Act 1956, having registered office in Hyderabad, India ("Company") - + (Recipient)

Whereas "Company" wishes to explore the possibility of entering into an employment @@ -875,7 +875,7 @@ - +

diff --git a/addons_extensions/offer_letters/security/ir.model.access.csv b/addons_extensions/offer_letters/security/ir.model.access.csv index 12a196f82..209dcffc5 100644 --- a/addons_extensions/offer_letters/security/ir.model.access.csv +++ b/addons_extensions/offer_letters/security/ir.model.access.csv @@ -2,4 +2,4 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_offer_letter_user,offer.letter.user,model_offer_letter,base.group_user,1,1,1,0 access_offer_letter_manager,offer.letter.manager,model_offer_letter,hr.group_hr_manager,1,1,1,1 access_applicant_offer_mail_wizard,applicant.offer.mail.wizard.user,offer_letters.model_applicant_offer_mail_wizard,base.group_user,1,1,1,1 -access_offer_release_request_wizard,offer.release.request.wizard.user,model_offer_release_request_wizard,base.group_user,1,1,1,1 \ No newline at end of file +access_offer_letter_reject_wizard,offer.letter.reject.wizard.user,model_offer_letter_reject_wizard,base.group_user,1,1,1,1 diff --git a/addons_extensions/offer_letters/views/offer_letter_views.xml b/addons_extensions/offer_letters/views/offer_letter_views.xml index e708d6a05..a6b23c69a 100644 --- a/addons_extensions/offer_letters/views/offer_letter_views.xml +++ b/addons_extensions/offer_letters/views/offer_letter_views.xml @@ -23,7 +23,7 @@ + + + + + + + + + + + + + + + + + + + + + + not active and not bridge_employee_id + + + 1 + + + + + + hr.applicant.additional.info.bridge + hr.applicant + + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + not bridge_employee_id + + + + diff --git a/addons_extensions/recruitment_employee_bridge/views/hr_candidate_views.xml b/addons_extensions/recruitment_employee_bridge/views/hr_candidate_views.xml new file mode 100644 index 000000000..cbb60b62c --- /dev/null +++ b/addons_extensions/recruitment_employee_bridge/views/hr_candidate_views.xml @@ -0,0 +1,31 @@ + + + + hr.candidate.view.form.bridge + hr.candidate + + + + + + + + bridge_count or not active + Create Onboarding + + + 1 + + + + + + + diff --git a/addons_extensions/recruitment_employee_bridge/views/recruitment_employee_bridge_views.xml b/addons_extensions/recruitment_employee_bridge/views/recruitment_employee_bridge_views.xml new file mode 100644 index 000000000..dbcd94a84 --- /dev/null +++ b/addons_extensions/recruitment_employee_bridge/views/recruitment_employee_bridge_views.xml @@ -0,0 +1,267 @@ + + + + recruitment.employee.bridge.list + recruitment.employee.bridge + + + + + + + + + + + + + + + + + + recruitment.employee.bridge.form + recruitment.employee.bridge + +

+
+
+ +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +