Recruitment and hrms dashboard changes

This commit is contained in:
pranaysaidurga 2026-07-29 16:14:45 +05:30
parent b12c76d315
commit e3ed288016
94 changed files with 11859 additions and 4267 deletions

View File

@ -269,9 +269,6 @@ RULES:
- Scan the entire document carefully before answering. - Scan the entire document carefully before answering.
- Extract ONLY what exists in text. - Extract ONLY what exists in text.
- FOR ANY DATES CHANGE FORMAT TO %Y-%m-%d - 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: FIELD RULES:
- If "skills" exists, extract only explicit technical skills written in the document. - 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 "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 "phone" exists, return the most complete phone number found.
- If "experience" exists, return only clearly supported numeric values. - 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:
{schema_text} {schema_text}
@ -307,7 +301,7 @@ Document:
"model": model, "model": model,
"messages": [{"role": "user", "content": prompt}], "messages": [{"role": "user", "content": prompt}],
"temperature": 0, "temperature": 0,
"max_tokens": 4000, "max_tokens": 1500,
} }
try: try:
response = requests.post(endpoint, headers=headers, json=payload, timeout=90) response = requests.post(endpoint, headers=headers, json=payload, timeout=90)

View File

@ -0,0 +1,3 @@
from . import models
from . import controllers
from . import wizards

View File

@ -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,
}

View File

@ -0,0 +1 @@
from . import main

View File

@ -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/<string:token>", 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)])
}

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo noupdate="1">
<record id="seq_recruitment_employee_bridge" model="ir.sequence">
<field name="name">Onboarding</field>
<field name="code">recruitment.employee.bridge</field>
<field name="prefix">EB/</field>
<field name="padding">5</field>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="email_template_jod_form" model="mail.template">
<field name="name">JOD Form Email</field>
<field name="model_id" ref="employee_bridge.model_recruitment_employee_bridge"/>
<field name="email_from">{{ user.email_formatted or object.company_id.email or '' }}</field>
<field name="email_to">{{ object.work_email or object.private_email or object.employee_id.work_email or '' }}</field>
<field name="subject">Joining Onboarding Form - {{ object.employee_name or object.employee_id.name or '' }}</field>
<field name="body_html" type="html">
<div style="margin:0; padding:0; font-size:13px; line-height:1.7;">
<p>Dear <t t-esc="object.employee_name or object.employee_id.name or 'Candidate'"/>,</p>
<p>Welcome to <t t-esc="object.employee_id.company_id.name"/>.</p>
<p>
Please submit your employee joining details using the link below.
</p>
<p>
<a t-att-href="ctx.get('joining_form_link')" target="_blank"
style="background-color:#005580; color:#ffffff; padding:10px 18px; text-decoration:none; border-radius:4px; display:inline-block;">
Open Employee Joining Form
</a>
</p>
<t t-if="ctx.get('personal_docs') or ctx.get('education_docs') or ctx.get('previous_employer_docs') or ctx.get('other_docs')">
<p><strong>Attachments to Request</strong></p>
<t t-if="ctx.get('personal_docs')">
<p><strong>Personal Documents:</strong> <t t-esc="', '.join(ctx.get('personal_docs'))"/></p>
</t>
<t t-if="ctx.get('education_docs')">
<p><strong>Education Documents:</strong> <t t-esc="', '.join(ctx.get('education_docs'))"/></p>
</t>
<t t-if="ctx.get('previous_employer_docs')">
<p><strong>Previous Employer Documents:</strong> <t t-esc="', '.join(ctx.get('previous_employer_docs'))"/></p>
</t>
<t t-if="ctx.get('other_docs')">
<p><strong>Other Documents:</strong> <t t-esc="', '.join(ctx.get('other_docs'))"/></p>
</t>
</t>
<p>Regards,<br/>HR Team</p>
</div>
</field>
</record>
</odoo>

View File

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo noupdate="1">
<record id="onboarding_attachment_aadhaar" model="employee.bridge.requested.attachment">
<field name="name">Aadhaar Card</field>
<field name="attachment_type">personal</field>
<field name="is_default">1</field>
</record>
<record id="onboarding_attachment_pan" model="employee.bridge.requested.attachment">
<field name="name">PAN Card</field>
<field name="attachment_type">personal</field>
<field name="is_default">1</field>
</record>
<record id="onboarding_attachment_photo" model="employee.bridge.requested.attachment">
<field name="name">Passport Size Photo</field>
<field name="attachment_type">personal</field>
<field name="is_default">1</field>
</record>
<record id="onboarding_attachment_education" model="employee.bridge.requested.attachment">
<field name="name">Education Certificates</field>
<field name="attachment_type">education</field>
<field name="is_default">1</field>
</record>
<record id="onboarding_attachment_experience" model="employee.bridge.requested.attachment">
<field name="name">Experience / Relieving Letter</field>
<field name="attachment_type">previous_employer</field>
<field name="is_default">1</field>
</record>
<record id="onboarding_attachment_payslip" model="employee.bridge.requested.attachment">
<field name="name">Last Three Months Payslips</field>
<field name="attachment_type">previous_employer</field>
<field name="is_default">1</field>
</record>
<record id="onboarding_attachment_bank" model="employee.bridge.requested.attachment">
<field name="name">Cancelled Cheque / Bank Proof</field>
<field name="attachment_type">others</field>
<field name="is_default">1</field>
</record>
</odoo>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,3 @@
from . import recruitment_employee_bridge
from . import hr_employee
from . import onboarding_documents

View File

@ -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,
}

View File

@ -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")

View File

@ -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", _(
"<p>Hello %(name)s,</p>"
"<p>Please submit your joining onboarding details using this link:</p>"
"<p><a href='%(link)s'>Open Joining Form</a></p>"
) % {
"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

View File

@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_employee_bridge_user employee.bridge.user model_recruitment_employee_bridge base.group_user 1 1 1 0
3 access_employee_bridge_manager employee.bridge.manager model_recruitment_employee_bridge hr.group_hr_manager 1 1 1 1
4 access_employee_bridge_requested_attachment_user employee.bridge.requested.attachment.user model_employee_bridge_requested_attachment base.group_user 1 1 1 0
5 access_employee_bridge_requested_attachment_manager employee.bridge.requested.attachment.manager model_employee_bridge_requested_attachment hr.group_hr_manager 1 1 1 1
6 access_employee_bridge_attachment_user employee.bridge.attachment.user model_employee_bridge_attachment base.group_user 1 1 1 0
7 access_employee_bridge_attachment_manager employee.bridge.attachment.manager model_employee_bridge_attachment hr.group_hr_manager 1 1 1 1
8 access_employee_bridge_attachment_wizard employee.bridge.attachment.wizard.user model_employee_bridge_attachment_wizard base.group_user 1 1 1 1

View File

@ -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 = `<div class="file-preview-wrapper" style="width: 80px; height: 80px; display: flex; align-items: center; justify-content: center; border: 1px solid #ccc; border-radius: 5px; overflow: hidden;">
<img src="data:image/png;base64,${base64String}" style="max-width: 100%; max-height: 100%; object-fit: contain; cursor: pointer;" />
</div>`;
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 = `<div class="file-preview-wrapper" style="width: 80px; height: 80px; display: flex; align-items: center; justify-content: center; border: 1px solid #ccc; border-radius: 5px; overflow: hidden;">
<iframe src="data:application/pdf;base64,${base64String}" style="width: 100%; height: 100%; border: none; cursor: pointer;"></iframe>
</div>`;
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(`
<tr data-attachment-id="${attachmentId}" data-file-id="${fileId}">
<td>
<input type="text" class="form-control file-name-input" id="${fileNameInputId}" value="${file.name}"/>
</td>
<td class="text-center">
<div class="d-flex flex-column align-items-center justify-content-center gap-2">
${previewContent}
<div class="d-flex gap-2">
<button type="button" class="btn btn-danger btn-sm delete-file-btn" data-attachment-id="${attachmentId}" data-file-id="${fileId}">
<i class="fa fa-trash"></i>
</button>
<button type="button" class="btn btn-info btn-sm preview-btn" data-attachment-id="${attachmentId}" data-file-id="${fileId}">
<i class="fa fa-eye"></i>
</button>
</div>
</div>
</td>
</tr>
`);
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(`
<img id="${imageId}" src="data:image/png;base64,${base64String}" class="img-thumbnail"
style="width: 80px; height: 80px; object-fit: cover; cursor: pointer;" />
`);
} else if (fileType === 'application/pdf') {
this.$(`#${filePreviewWrapperId}`).html(`
<iframe src="data:application/pdf;base64,${base64String}" width="80" height="80" class="img-thumbnail" style="border: none; cursor: pointer;"></iframe>
`);
}
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 = $(`
<div class="d-flex flex-column align-items-center">
<div class="position-relative">
<img src="${file.base64}" class="rounded-circle shadow-sm" style="width: 80px; height: 80px; object-fit: cover; border: 1px solid #ddd; cursor: pointer;" data-index="${index}" data-attachment-id="${attachmentId}" />
<button type="button" class="btn btn-sm btn-danger position-absolute top-0 end-0 remove-file" data-attachment-id="${attachmentId}" data-index="${index}" style="transform: translate(50%, -50%);"><i class="fa fa-trash"></i></button>
</div>
<small>${file.name}</small>
</div>
`);
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 = `
<div id="attachmentModal" class="modal fade show" tabindex="-1" style="display: block; background: rgba(0,0,0,0.5);" aria-modal="true">
<div class="modal-dialog modal-lg" style="max-width: 600px;">
<div class="modal-content" style="border-radius: 8px; box-shadow: 0px 5px 15px rgba(0, 0, 0, 0.2);">
<!-- Modal Header -->
<div class="modal-header" style="background: #143d5d; color: white; border-bottom: 2px solid #dee2e6; font-weight: bold; padding: 12px 15px;">
<h5 class="modal-title" style="margin: 0;">Uploaded Attachments</h5>
<button type="button" class="close close-modal-btn" data-dismiss="modal" aria-label="Close" style="background: none; border: none; font-size: 20px; color: white; cursor: pointer;">
&times;
</button>
</div>
<!-- Modal Body -->
<div class="modal-body" style="padding: 15px;">
<ul class="attachment-list" style="list-style: none; padding: 0; margin: 0; max-height: 300px; overflow-y: auto;">
${fileList.length ? fileList.map((file, index) => `
<li style="display: flex; justify-content: space-between; align-items: center; padding: 10px 15px; border: 1px solid #dee2e6; border-radius: 5px; margin-bottom: 8px; background: #f8f9fa;">
<span style="font-weight: 500; flex-grow: 1;">${file.name}</span>
<button type="button" class="remove-file" data-index="${index}"
style="background: #dc3545; color: white; border: none; padding: 6px 10px; border-radius: 4px; cursor: pointer; font-size: 14px;">
<i style="margin-right: 5px;">🗑</i>
</button>
</li>
`).join("") : `<p style="color: #6c757d; text-align: center; font-size: 16px; padding: 10px;">No files uploaded.</p>`}
</ul>
</div>
<!-- Modal Footer -->
<div class="modal-footer" style="border-top: 1px solid #dee2e6; padding: 12px;">
<button type="button" class="btn close-modal-btn" data-dismiss="modal"
style="background: #6c757d; color: white; border: none; padding: 8px 15px; border-radius: 5px; cursor: pointer; font-size: 14px;">
Close
</button>
</div>
</div>
</div>
</div>`;
// 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 = `
<tr class="predefined-row">
<td class="education-relation-col">
<select name="education_type" class="form-control">
<option value="10">10th</option>
<option value="inter">Inter</option>
<option value="graduation">Graduation</option>
<option value="post_graduation">Post Graduation</option>
<option value="additional">Additional Qualification</option>
</select>
</td>
<td><input type="text" name="specialization" class="form-control" placeholder="Enter Specialization"/></td>
<td><input type="text" name="university" class="form-control" placeholder="Enter University"/></td>
<td><input type="number" name="start_year" class="form-control" placeholder="Start Year"/></td>
<td><input type="number" name="end_year" class="form-control" placeholder="End Year"/></td>
<td><input type="text" name="marks_grade" class="form-control" placeholder="Marks/Grade"/></td>
</tr>
`;
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]) => `
<option value="${id}">${name}</option>
`).join('');
const stateHtml = `
<select name='permanent_state' id="permanent_state" class="form-control" required>
<option value="" disabled selected>Select State</option>
${stateOptions}
</select>
`;
const presentStateHtml = `
<select name='present_state' id="present_state" class="form-control" required>
<option value="" disabled selected>Select State</option>
${stateOptions}
</select>
`;
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();
}
},
});

View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="hr_employee_view_form_bridge" model="ir.ui.view">
<field name="name">hr.employee.view.form.bridge</field>
<field name="model">hr.employee</field>
<field name="inherit_id" ref="hr.view_employee_form"/>
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_open_recruitment_bridge"
type="object"
class="oe_stat_button"
icon="fa-link"
groups="hr.group_hr_user"
invisible="not recruitment_bridge_count">
<field name="recruitment_bridge_count" widget="statinfo" string="Onboarding"/>
</button>
</xpath>
<xpath expr="//label[@for='user_id']" position="before">
<button string="Send JOD Form"
name="send_jod_form_to_employee"
type="object"
class="btn-primary"
groups="hr.group_hr_user"
invisible="not work_email"/>
</xpath>
</field>
</record>
</odoo>

View File

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_employee_bridge_requested_attachment_list" model="ir.ui.view">
<field name="name">employee.bridge.requested.attachment.list</field>
<field name="model">employee.bridge.requested.attachment</field>
<field name="arch" type="xml">
<list editable="bottom">
<field name="name"/>
<field name="attachment_type"/>
<field name="is_default"/>
</list>
</field>
</record>
<record id="view_employee_bridge_requested_attachment_form" model="ir.ui.view">
<field name="name">employee.bridge.requested.attachment.form</field>
<field name="model">employee.bridge.requested.attachment</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="name"/>
<field name="attachment_type"/>
<field name="is_default"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="action_employee_bridge_requested_attachment" model="ir.actions.act_window">
<field name="name">Onboarding Documents</field>
<field name="res_model">employee.bridge.requested.attachment</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_employee_bridge_requested_attachment"
name="Onboarding Documents"
parent="menu_employee_bridge"
action="action_employee_bridge_requested_attachment"
sequence="20"
groups="hr.group_hr_user"/>
</odoo>

View File

@ -0,0 +1,224 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_employee_bridge_list" model="ir.ui.view">
<field name="name">employee.bridge.list</field>
<field name="model">recruitment.employee.bridge</field>
<field name="arch" type="xml">
<list>
<field name="name"/>
<field name="employee_name"/>
<field name="employee_id"/>
<field name="employee_code"/>
<field name="work_email"/>
<field name="joining_form_link" optional="show"/>
<field name="source"/>
<field name="state" widget="badge"/>
<field name="company_id" groups="base.group_multi_company"/>
</list>
</field>
</record>
<record id="view_employee_bridge_form" model="ir.ui.view">
<field name="name">employee.bridge.form</field>
<field name="model">recruitment.employee.bridge</field>
<field name="arch" type="xml">
<form>
<header>
<button string="Send JOD Form" name="action_send_jod_form" type="object" class="btn-primary" invisible="state == 'validated'"/>
<button string="Create Employee" name="action_create_employee" type="object" class="btn-secondary" invisible="employee_id"/>
<field name="state" widget="statusbar" statusbar_visible="draft,jod_sent,jod_received,validated"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_open_employee" type="object" class="oe_stat_button" icon="fa-id-card-o" invisible="not employee_id">
<div class="o_field_widget o_stat_info">
<span class="o_stat_value"><field name="employee_code" readonly="1"/></span>
<span class="o_stat_text">Employee</span>
</div>
</button>
</div>
<group string="Onboarding">
<group>
<field name="name"/>
<field name="source"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
<group>
<field name="employee_id"/>
<field name="employee_code"/>
<field name="joining_form_link" widget="url"/>
<field name="requested_attachment_ids" widget="many2many_tags"/>
</group>
</group>
<button string="Validate Into Employee" name="action_validate_employee_details" type="object" class="btn-primary" invisible="state == 'validated' or not employee_id"/>
<notebook>
<page string="Basic Details" name="basic_details">
<group>
<group>
<field name="employee_name"/>
<field name="candidate_image" widget="image" class="oe_avatar"/>
<field name="employee_code"/>
<field name="work_email" widget="email"/>
<field name="private_email" widget="email"/>
<field name="work_phone" widget="phone"/>
<field name="mobile_phone" widget="phone"/>
</group>
<group>
<field name="department_id"/>
<field name="job_id"/>
<field name="emp_type"/>
<field name="total_exp"/>
<field name="doj"/>
</group>
</group>
</page>
<page string="JOD Details" name="jod_details">
<group string="Personal Details">
<group>
<field name="gender"/>
<field name="birthday"/>
<field name="blood_group"/>
</group>
<group>
<field name="marital"/>
<field name="marriage_anniversary_date" invisible="marital == 'single'"/>
</group>
</group>
<group string="Current Address">
<group>
<field name="private_street"/>
<field name="private_street2"/>
<field name="private_city"/>
</group>
<group>
<field name="private_state_id"/>
<field name="private_zip"/>
<field name="private_country_id"/>
</group>
</group>
<group string="Permanent Address">
<group>
<field name="permanent_street"/>
<field name="permanent_street2"/>
<field name="permanent_city"/>
</group>
<group>
<field name="permanent_state_id"/>
<field name="permanent_zip"/>
<field name="permanent_country_id"/>
</group>
</group>
<group string="Authentication">
<group>
<field name="pan_no"/>
<field name="identification_id"/>
</group>
<group>
<field name="previous_company_pf_no"/>
<field name="previous_company_uan_no"/>
</group>
</group>
<group string="Passport">
<group>
<field name="passport_no"/>
<field name="passport_start_date"/>
<field name="passport_end_date"/>
</group>
<group>
<field name="passport_issued_location"/>
</group>
</group>
</page>
<page string="Family Details" name="family_details">
<field name="family_details" nolabel="1">
<list editable="bottom">
<field name="relation_type"/>
<field name="name"/>
<field name="contact_no"/>
<field name="dob"/>
<field name="location"/>
</list>
</field>
</page>
<page string="Employer History" name="employer_history">
<field name="employer_history" nolabel="1">
<list editable="bottom">
<field name="company_name"/>
<field name="designation"/>
<field name="date_of_joining"/>
<field name="last_working_day"/>
<field name="ctc"/>
</list>
</field>
</page>
<page string="Education Details" name="education_history">
<field name="education_history" nolabel="1">
<list editable="bottom">
<field name="education_type"/>
<field name="name"/>
<field name="university"/>
<field name="start_year"/>
<field name="end_year"/>
<field name="marks_or_grade"/>
</list>
</field>
</page>
<page string="Attachments" name="attachments">
<field name="joining_attachment_ids" nolabel="1">
<list editable="bottom" default_group_by="requested_attachment_id" decoration-success="review_status == 'pass'" decoration-danger="review_status == 'fail'">
<field name="requested_attachment_id"/>
<field name="name"/>
<field name="attachment_type"/>
<field name="file" widget="binary" filename="file_name"/>
<field name="file_name" optional="hide"/>
<button name="action_preview_file" type="object" string="Preview" class="oe_highlight" icon="fa-eye"/>
<field name="review_status"/>
<field name="review_comments" optional="hide"/>
</list>
</field>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="view_employee_bridge_search" model="ir.ui.view">
<field name="name">employee.bridge.search</field>
<field name="model">recruitment.employee.bridge</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="employee_id"/>
<filter name="jod_sent" string="JOD Sent" domain="[('state', '=', 'jod_sent')]"/>
<filter name="jod_received" string="JOD Received" domain="[('state', '=', 'jod_received')]"/>
<filter name="validated" string="Validated" domain="[('state', '=', 'validated')]"/>
<group expand="0" string="Group By">
<filter name="group_state" string="Status" context="{'group_by': 'state'}"/>
<filter name="group_source" string="Source" context="{'group_by': 'source'}"/>
</group>
</search>
</field>
</record>
<record id="action_employee_bridge" model="ir.actions.act_window">
<field name="name">Onboarding</field>
<field name="res_model">recruitment.employee.bridge</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_employee_bridge"
name="Onboarding"
parent="hr.menu_hr_root"
sequence="90"
groups="hr.group_hr_user"/>
<menuitem id="menu_employee_bridge_onboarding"
name="Onboarding"
parent="menu_employee_bridge"
action="action_employee_bridge"
sequence="10"
groups="hr.group_hr_user"/>
</odoo>

View File

@ -0,0 +1 @@
from . import employee_bridge_attachment_wizard

View File

@ -0,0 +1,92 @@
from odoo import api, fields, models
class EmployeeBridgeAttachmentWizard(models.TransientModel):
_name = "employee.bridge.attachment.wizard"
_description = "Onboarding Attachment Request Wizard"
bridge_id = fields.Many2one("recruitment.employee.bridge", required=True, readonly=True)
req_attachment_ids = fields.Many2many(
comodel_name="employee.bridge.requested.attachment",
relation="employee_bridge_req_attachment_rel", # relation table name
column1="request_id", # column referring to this model
column2="attachment_id", # column referring to the comodel
string="Attachments to Request",
)
attachment_ids = fields.Many2many("ir.attachment")
template_id = fields.Many2one(
"mail.template",
default=lambda self: self.env.ref("employee_bridge.email_template_jod_form", raise_if_not_found=False),
readonly=True,
)
email_from = fields.Char("Email From")
email_to = fields.Char("Email To")
email_cc = fields.Text("Email CC")
email_subject = fields.Char()
email_body = fields.Html(
"Body",
render_engine="qweb",
render_options={"post_process": True},
prefetch=True,
translate=True,
sanitize="email_outgoing",
)
@api.model
def default_get(self, fields_list):
defaults = super().default_get(fields_list)
bridge = self.env["recruitment.employee.bridge"].browse(
self.env.context.get("default_bridge_id") or self.env.context.get("bridge_id")
)
if bridge:
attachments = bridge.requested_attachment_ids or self.env["employee.bridge.requested.attachment"].search([
("is_default", "=", True),
])
defaults.update({
"bridge_id": bridge.id,
"req_attachment_ids": [(6, 0, attachments.ids)],
"email_from": self.env.company.email or self.env.user.email,
"email_to": bridge.work_email or bridge.private_email or bridge.employee_id.work_email,
})
template = self.env.ref("employee_bridge.email_template_jod_form", raise_if_not_found=False)
if template:
context = {
"joining_form_link": bridge._get_joining_form_link(),
**bridge._group_requested_attachments(attachments),
}
defaults.update({
"template_id": template.id,
"email_subject": template.with_context(**context)._render_field("subject", [bridge.id])[bridge.id],
"email_body": template.with_context(**context)._render_field(
"body_html", [bridge.id], compute_lang=True
)[bridge.id],
})
return defaults
@api.onchange("req_attachment_ids")
def _onchange_req_attachment_ids(self):
if not self.bridge_id or not self.template_id:
return
context = {
"joining_form_link": self.bridge_id._get_joining_form_link(),
**self.bridge_id._group_requested_attachments(self.req_attachment_ids),
}
self.email_body = self.template_id.with_context(**context)._render_field(
"body_html", [self.bridge_id.id], compute_lang=True
)[self.bridge_id.id]
def action_confirm(self):
self.ensure_one()
self.bridge_id.requested_attachment_ids = [(6, 0, self.req_attachment_ids.ids)]
self.bridge_id._send_jod_mail(
req_attachment_ids=self.req_attachment_ids,
email_values={
"email_from": self.email_from,
"email_to": self.email_to,
"email_cc": self.email_cc,
"subject": self.email_subject,
"attachment_ids": [(6, 0, self.attachment_ids.ids)],
},
email_body=self.email_body,
)
return {"type": "ir.actions.act_window_close"}

View File

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_employee_bridge_attachment_wizard_form" model="ir.ui.view">
<field name="name">employee.bridge.attachment.wizard.form</field>
<field name="model">employee.bridge.attachment.wizard</field>
<field name="arch" type="xml">
<form string="Select Attachments">
<group>
<field name="bridge_id" invisible="1"/>
<field name="req_attachment_ids" widget="many2many_tags" force_save="1"/>
</group>
<notebook>
<page name="attachment" string="Attachments (Binary)">
<field name="attachment_ids" widget="many2many_binary" domain="[('mimetype', 'not ilike', 'image')]"/>
</page>
<page name="email" string="Email">
<group>
<field name="template_id" options="{'no_create': True}" readonly="1" force_save="1"/>
<field name="email_from" force_save="1"/>
<field name="email_to" force_save="1"/>
<field name="email_cc"/>
<field name="email_subject" force_save="1"/>
<field name="email_body" widget="html_mail" class="oe-bordered-editor" options="{'codeview': true}" force_save="1"/>
<field name="attachment_ids" widget="many2many_tags" string="Attachments" force_save="1"/>
</group>
</page>
</notebook>
<footer>
<button name="action_confirm" type="object" string="Send JOD Form" class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@ -10,12 +10,10 @@
""", """,
'author': 'FTPROTECH', 'author': 'FTPROTECH',
'website': 'https://ftprotech.in', 'website': 'https://ftprotech.in',
'depends': ['hr', 'mail','hr_employee_extended','hr_recruitment_extended'], 'depends': ['hr', 'mail', 'hr_employee_extended', 'employee_bridge'],
'data': [ 'data': [
'data/data.xml', 'data/actions.xml',
'data/actions.xml', 'data/template.xml',
'data/template.xml', ],
'views/emp_jod.xml',
],
'license': 'LGPL-3', 'license': 'LGPL-3',
} }

View File

@ -1,65 +1,22 @@
from odoo import http, _ from odoo import http
from odoo.http import request from odoo.http import request
from odoo.addons.hr_recruitment_extended.controllers.controllers import website_hr_recruitment_applications from odoo.tools import misc
from odoo.http import content_disposition
import logging class EmployeeJodController(http.Controller):
@http.route("/download/employee_jod/<int:employee_id>", type="http", auth="user")
from odoo.tools import misc def download_employee_jod_form(self, employee_id, **kwargs):
employee = request.env["hr.employee"].sudo().browse(employee_id)
_logger = logging.getLogger(__name__) if not employee.exists():
return request.not_found()
class website_hr_recruitment_applications_extended(website_hr_recruitment_applications):
template = request.env.ref("employee_jod.emp_joining_form_template", raise_if_not_found=False)
@http.route(['/SRIVYNPLATFORMS/JoiningForm/<int:applicant_id>'], type='http', auth="public", if not template:
website=True) return "Error: Template not found"
def post_onboarding_form(self, applicant_id, **kwargs):
"""Renders the website form for applicants to submit additional details.""" return request.env["ir.qweb"]._render(template.id, {
applicant = request.env['hr.applicant'].sudo().browse(applicant_id) "docs": employee,
if not applicant.exists(): "doc": employee,
return request.not_found() "time": misc.datetime,
if applicant: "user": request.env.user,
if applicant.post_onboarding_form_status == 'done': })
return request.render("hr_recruitment_extended.thank_you_template", {
'applicant': applicant
})
else:
return request.render("hr_recruitment_extended.post_onboarding_form_template", {
'applicant': applicant
})
else:
return request.not_found()
@http.route(['/download/jod/<int:applicant_id>'], type='http', auth="public", cors='*', website=True)
def download_jod_form(self, applicant_id, **kwargs):
# Get the applicant record
applicant = request.env['hr.applicant'].sudo().browse(applicant_id)
if not applicant.exists():
return f"Error: Applicant with ID {applicant_id} not found"
# Business logic check
if applicant.post_onboarding_form_status != 'done':
return f"Error: Applicant {applicant_id} does not meet the criteria for download"
# Get the template
template = request.env.ref('hr_recruitment_extended.employee_joining_form_template')
if not template:
return "Error: Template not found"
try:
# Render the template to HTML for debugging
html = request.env['ir.qweb']._render(
template.id,
{
'docs': applicant,
'doc': applicant,
'time': misc.datetime,
'user': request.env.user,
}
)
# Return HTML for debugging
return html
except Exception as e:
return f"Error rendering template: {str(e)}"

View File

@ -19,9 +19,8 @@
<field name="report_file">employee_jod.emp_joining_form_template</field> <field name="report_file">employee_jod.emp_joining_form_template</field>
<field name="binding_model_id" ref="hr.model_hr_employee"/> <field name="binding_model_id" ref="hr.model_hr_employee"/>
<field name="print_report_name">'JOD - %s' % (object.display_name)</field> <field name="print_report_name">'JOD - %s' % (object.display_name)</field>
<field name="paperformat_id" ref="hr_recruitment_extended.custom_paper_format"/> <field name="binding_type">report</field>
<field name="binding_type">report</field> </record>
</record>
</data> </data>
</odoo> </odoo>

View File

@ -1,26 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<data noupdate="1">
<!-- Original job record -->
<record model="hr.job" id="employee_jod_internal_job_id">
<field name="name">Internal Job</field>
<field name="active" eval="False"/>
</record>
<!-- Recruitment stage -->
<record model="hr.recruitment.stage" id="hired_stage8">
<field name="name">IJ</field>
<field name="is_default_field" eval="False"/>
<field name="hired_stage" eval="True"/>
<!-- <field name="post_onboarding_form" eval="True"/>-->
</record>
<!-- Changed ID for the recruitment record -->
<record model="hr.job.recruitment" id="employee_jod_internal_job_recruitment_id">
<field name="recruitment_sequence">IJ001</field>
<field name="job_id" ref="employee_jod_internal_job_id"/>
<field name="recruitment_stage_ids" eval="[(4, ref('hired_stage8'))]"/>
<field name="active" eval="False"/>
</record>
</data>
</odoo>

View File

@ -313,13 +313,4 @@
</t> </t>
</template> </template>
<!-- <template id="thank_you_template_inherit" inherit_id="hr_recruitment_exteded.thank_you_template" name="Thank You Template Extended">--> </odoo>
<!-- <xpath expr="//div[@class='container mt-5 text-center']" position="inside">-->
<!-- <div t-if="applicant.post_onboarding_form_status == 'done'" style="margin-top: 20px;">-->
<!-- <a t-att-href="'/download/jod/%s' % applicant.id" class="btn btn-primary">Download JOD</a>-->
<!-- </div>-->
<!-- </xpath>-->
<!-- </template>-->
</odoo>

View File

@ -1,165 +1,8 @@
from odoo import api, fields, models, _ from odoo import models
from odoo.exceptions import UserError
class HrEmployee(models.Model):
class HRApplicant(models.Model): _inherit = "hr.employee"
_inherit = 'hr.applicant'
def send_jod_form_to_employee(self):
joining_form_link = fields.Char() return super().send_jod_form_to_employee()
class HREmployee(models.Model):
_inherit = 'hr.employee'
applicant_id = fields.Many2one("hr.applicant")
def send_jod_form_to_employee(self):
for rec in self:
if not rec.applicant_id:
application = self.env['hr.applicant'].sudo().search(['|','|',('partner_phone','=',rec.work_phone),('email_from','=',rec.work_email),('employee_id', '=', rec.id),'|',('company_id','=',False),('company_id','=',self.env.company.id)], limit=1)
if application and self.env['hr.employee'].sudo().search([('applicant_id','=', application.id)]):
application = False
if not application:
candidate = self.env['hr.candidate'].sudo().create({
'partner_name': rec.name,
'email_from': rec.work_email,
'partner_phone': rec.work_phone,
'employee_id': rec.id,
'company_id': self.env.company.id
})
application = self.env['hr.applicant'].sudo().create({
'candidate_id': candidate.id,
'hr_job_recruitment': self.env.ref('employee_jod.employee_jod_internal_job_recruitment_id').id,
'recruitment_stage_id': self.env.ref('employee_jod.hired_stage8').id,
'company_id': self.env.company.id
})
rec.applicant_id = application.id
return rec.sudo().applicant_id.send_jod_form_to_employee()
class PostOnboardingAttachmentWizard(models.TransientModel):
_inherit = 'post.onboarding.attachment.wizard'
send_mail = fields.Boolean(default=False)
@api.onchange('template_id')
def _onchange_template_id(self):
""" Update the email body and recipients based on the selected template. """
if self.template_id:
record_id = self.env.context.get('active_id')
model = self.env.context.get('active_model')
if model == 'applicant.request.forms':
applicant = self.env['hr.applicant'].browse(record_id)
elif model == 'hr.applicant':
applicant = self.env['hr.applicant'].browse(self.env.context.get('active_id'))
else:
if model == 'hr.employee':
applicant = self.env['hr.employee'].browse(record_id).applicant_id
if applicant:
record = self.env[self.template_id.model].sudo().browse(applicant.id)
if not record.exists():
raise UserError("The record does not exist or is not accessible.")
# Fetch email template
email_template = self.env['mail.template'].sudo().browse(self.template_id.id)
if not email_template:
raise UserError("Email template not found.")
self.email_from = self.env.company.email
self.email_to = applicant.email_from
self.email_body = email_template.body_html # Assign the rendered email bodyc
self.email_subject = email_template.subject
def action_confirm(self):
for rec in self:
self.ensure_one()
context = self.env.context
active_id = context.get('active_id')
model = context.get('active_model')
request_token = False
request_upload_url = False
if model == 'applicant.request.forms':
applicant = self.env['hr.applicant'].browse(active_id)
elif model == 'hr.applicant':
applicant = self.env['hr.applicant'].browse(context.get('active_id'))
elif model == 'hr.employee':
applicant = self.env['hr.employee'].browse(active_id).applicant_id
else:
applicant = self.env['hr.applicant'].browse(active_id)
if rec.is_pre_onboarding_attachment_request and not rec.request_form_id:
raise UserError("A document request form is required before sending this email.")
if rec.request_form_id:
request_token = rec.request_form_id._issue_new_access_token()
base_url = self.get_base_url()
request_upload_url = (
f"{base_url}/FTPROTECH/DocRequests/"
f"{applicant.id}/{rec.request_form_id.id}?token={request_token}"
)
applicant.recruitment_attachments = [(4, attachment.id) for attachment in rec.req_attachment_ids]
template = rec.template_id
personal_docs = rec.req_attachment_ids.filtered(lambda a: a.attachment_type == 'personal').mapped('name')
education_docs = rec.req_attachment_ids.filtered(lambda a: a.attachment_type == 'education').mapped('name')
previous_employer_docs = rec.req_attachment_ids.filtered(
lambda a: a.attachment_type == 'previous_employer').mapped('name')
other_docs = rec.req_attachment_ids.filtered(lambda a: a.attachment_type == 'others').mapped('name')
email_context = {
'applicant_request_form_id': rec.request_form_id.id,
'applicant_request_form_token': request_token,
'applicant_request_form_url': request_upload_url,
'personal_docs': personal_docs,
'education_docs': education_docs,
'previous_employer_docs': previous_employer_docs,
'other_docs': other_docs,
}
rendered_subject = template.with_context(**email_context)._render_field(
'subject', [applicant.id]
)[applicant.id]
rendered_body_html = template.with_context(**email_context)._render_field(
'body_html', [applicant.id], compute_lang=True
)[applicant.id]
email_values = {
'email_from': rec.email_from,
'email_to': rec.email_to,
'email_cc': rec.email_cc,
'subject': rendered_subject or rec.email_subject,
'body_html': rendered_body_html,
'attachment_ids': [(6, 0, rec.attachment_ids.ids)],
}
if rec.send_mail:
template.sudo().with_context(default_body_html=rec.email_body,
**email_context).send_mail(applicant.id, email_values=email_values,
force_send=True)
base_url = self.get_base_url()
if rec.is_pre_onboarding_attachment_request:
rec.request_form_id.status = 'email_sent_to_candidate'
else:
applicant.post_onboarding_form_status = 'email_sent_to_candidate'
applicant.joining_form_link = '%s/SRIVYNPLATFORMS/JoiningForm/%s'%(base_url,applicant.id)
return {'type': 'ir.actions.act_window_close'}
def get_base_url(self):
""" Return rooturl for a specific record.
By default, it returns the ir.config.parameter of base_url
but it can be overridden by model.
:return: the base url for this record
:rtype: str
"""
if len(self) > 1:
raise ValueError("Expected singleton or no record: %s" % self)
return self.env['ir.config_parameter'].sudo().get_param('web.base.url')

View File

@ -1,44 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="hr_employee_view_form_applicant_id" model="ir.ui.view">
<field name="name">hr.employee.view.form.applicant.id</field>
<field name="model">hr.employee</field>
<field name="inherit_id" ref="hr.view_employee_form"/>
<field name="arch" type="xml">
<xpath expr="//label[@for='user_id']" position="before">
<field name="applicant_id" string="Application ID" />
</xpath>
</field>
</record>
<record id="view_post_onboarding_attachment_wizard_form_inherit" model="ir.ui.view">
<field name="name">post.onboarding.attachment.wizard.form.inherit</field>
<field name="model">post.onboarding.attachment.wizard</field>
<field name="inherit_id" ref="hr_recruitment_extended.view_post_onboarding_attachment_wizard_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='req_attachment_ids']" position="after">
<field name="send_mail"/>
</xpath>
<xpath expr="//notebook" position="attributes">
<attribute name="invisible">not send_mail</attribute>
</xpath>
<xpath expr="//button[@name='action_confirm']" position="attributes">
<attribute name="string">Send</attribute>
</xpath>
</field>
</record>
<record id="hr_applicant_view_form_inherit_extend" model="ir.ui.view">
<field name="name">hr.applicant.view.form.extend</field>
<field name="model">hr.applicant</field>
<field name="inherit_id" ref="hr_recruitment_extended.hr_applicant_view_form_inherit"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='candidate_id']" position="after">
<field name="joining_form_link" force_save="1" readonly="1"/>
</xpath>
</field>
</record>
</odoo>

View File

@ -28,7 +28,10 @@
'data/data.xml', 'data/data.xml',
'data/sequence.xml', 'data/sequence.xml',
'data/mail_template.xml', 'data/mail_template.xml',
'data/templates.xml', # 'data/templates.xml',
'views/res_config_settings.xml',
'views/survey_survey.xml',
'views/hr_recruitment_category.xml',
'views/submission_share_history.xml', 'views/submission_share_history.xml',
'views/job_category.xml', 'views/job_category.xml',
'views/hr_location.xml', 'views/hr_location.xml',
@ -64,7 +67,7 @@
'web.assets_frontend': [ 'web.assets_frontend': [
'hr_recruitment_extended/static/src/js/website_hr_applicant_form.js', 'hr_recruitment_extended/static/src/js/website_hr_applicant_form.js',
'hr_recruitment_extended/static/src/js/pre_onboarding_attachment_requests.js', 'hr_recruitment_extended/static/src/js/pre_onboarding_attachment_requests.js',
'hr_recruitment_extended/static/src/js/post_onboarding_form.js', # 'hr_recruitment_extended/static/src/js/post_onboarding_form.js',
], ],
} }
} }

View File

@ -156,8 +156,11 @@ class website_hr_recruitment_applications(http.Controller):
@http.route(['/SRIVYNPLATFORMS/JoiningForm/<int:applicant_id>'], type='http', auth="public", @http.route([
website=True) '/SRIVYNPLATFORMS/JoiningForm/<int:applicant_id>',
'/FTPROTECH/JoiningForm/<int:applicant_id>',
], type='http', auth="public",
website=True)
def post_onboarding_form(self, applicant_id, **kwargs): def post_onboarding_form(self, applicant_id, **kwargs):
"""Renders the website form for applicants to submit additional details.""" """Renders the website form for applicants to submit additional details."""
applicant = request.env['hr.applicant'].sudo().browse(applicant_id) applicant = request.env['hr.applicant'].sudo().browse(applicant_id)
@ -174,8 +177,11 @@ class website_hr_recruitment_applications(http.Controller):
return request.not_found() return request.not_found()
@http.route(['/SRIVYNPLATFORMS/submit/<int:applicant_id>/JoinForm'], type='http', auth="public", @http.route([
methods=['POST'], website=True, csrf=False) '/SRIVYNPLATFORMS/submit/<int:applicant_id>/JoinForm',
'/FTPROTECH/submit/<int:applicant_id>/JoinForm',
], type='http', auth="public",
methods=['POST'], website=True, csrf=False)
def process_employee_joining_form(self,applicant_id,**post): def process_employee_joining_form(self,applicant_id,**post):
applicant = request.env['hr.applicant'].sudo().browse(applicant_id) applicant = request.env['hr.applicant'].sudo().browse(applicant_id)
if not applicant.exists(): if not applicant.exists():
@ -188,10 +194,10 @@ class website_hr_recruitment_applications(http.Controller):
private_state_id = request.env['res.country.state'].sudo().browse(int(post.get('present_state', 0))) private_state_id = request.env['res.country.state'].sudo().browse(int(post.get('present_state', 0)))
permanent_state_id = request.env['res.country.state'].sudo().browse(int(post.get('permanent_state', 0))) permanent_state_id = request.env['res.country.state'].sudo().browse(int(post.get('permanent_state', 0)))
employee_id = post.get('employee_id')
applicant_data = { applicant_data = {
'applicant_id': int(post.get('applicant_id', 0)), 'applicant_id': int(post.get('applicant_id', 0)),
'employee_id': int(post.get('employee_id', 0)), 'employee_id': int(employee_id) if employee_id else False,
'candidate_image': post.get('candidate_image_base64', ''), 'candidate_image': post.get('candidate_image_base64', ''),
'doj': datetime.strptime(post.get('doj'), '%Y-%m-%d').date() if post.get('doj', None) else '', 'doj': datetime.strptime(post.get('doj'), '%Y-%m-%d').date() if post.get('doj', None) else '',
'email_from': post.get('email_from', ''), 'email_from': post.get('email_from', ''),
@ -276,10 +282,17 @@ class website_hr_recruitment_applications(http.Controller):
attachments_data_json = post.get('attachments_data_json', '[]') attachments_data_json = post.get('attachments_data_json', '[]')
attachments_data = json.loads(attachments_data_json) if attachments_data_json else [] attachments_data = json.loads(attachments_data_json) if attachments_data_json else []
applicant.write(applicant_data) applicant.write(applicant_data)
applicant.replace_joining_attachments(attachments_data) applicant.replace_joining_attachments(attachments_data)
template = request.env.ref('hr_recruitment_extended.email_template_post_onboarding_form_user_submit', if 'recruitment.employee.bridge' in request.env.registry:
raise_if_not_found=False) bridge = request.env['recruitment.employee.bridge'].sudo().search([
('applicant_id', '=', applicant.id),
('active', '=', True),
], limit=1, order='id desc')
if bridge:
bridge.state = 'jod_received'
template = request.env.ref('hr_recruitment_extended.email_template_post_onboarding_form_user_submit',
raise_if_not_found=False)
group = request.env.ref('hr.group_hr_manager') group = request.env.ref('hr.group_hr_manager')
users = request.env['res.users'].sudo().search([ users = request.env['res.users'].sudo().search([
('groups_id', 'in', group.ids), ('groups_id', 'in', group.ids),

View File

@ -1,3 +1,6 @@
from . import res_config_settings
from . import survey_survey
from . import hr_recruitment_category
from . import submission_share_history from . import submission_share_history
from . import hr_recruitment from . import hr_recruitment
from . import hr_job_recruitment from . import hr_job_recruitment

View File

@ -13,6 +13,7 @@ class CandidateExperience(models.Model):
experience_code = fields.Char('Experience Code') experience_code = fields.Char('Experience Code')
experience_from = fields.Integer(string="Experience From (Years)") experience_from = fields.Integer(string="Experience From (Years)")
experience_to = fields.Integer(string="Experience To (Years)") experience_to = fields.Integer(string="Experience To (Years)")
company_id = fields.Many2one('res.company','Company', default=lambda self: self.env.company)
# display_name = fields.Char(string="Display Name") # display_name = fields.Char(string="Display Name")
# active = fields.Boolean() # active = fields.Boolean()

View File

@ -462,6 +462,7 @@ class RecruitmentCategory(models.Model):
category_name = fields.Char(string="Category Name") category_name = fields.Char(string="Category Name")
default_user = fields.Many2one('res.users') default_user = fields.Many2one('res.users')
company_id = fields.Many2one('res.company', default=lambda self: self.env.company ,string='Company', ondelete='cascade')
class ApplicationsStageStatus(models.Model): class ApplicationsStageStatus(models.Model):

View File

@ -0,0 +1,7 @@
from odoo import fields, models, api, _
class ApplicantCategory(models.Model):
_inherit = "hr.applicant.category"
company_id = fields.Many2one('res.company', default=lambda self: self.env.company)

View File

@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, api, _
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
allow_cross_company_candidates = fields.Boolean(
string="Allow Cross-Company Candidate Access",config_parameter='hr_recruitment_extended.allow_cross_company_candidates',
)
def set_values(self):
res = super().set_values()
rule = self.env.ref("hr_recruitment.hr_candidate_comp_rule")
rule.write({
"active": not self.allow_cross_company_candidates
})
self.env.registry.clear_cache()
return res

View File

@ -4,4 +4,5 @@ from odoo import models, fields, api, _
class ResPartner(models.Model): class ResPartner(models.Model):
_inherit = 'res.partner' _inherit = 'res.partner'
contact_type = fields.Selection([('internal','In-House'),('external','Client-Side')], required=True, default='internal') contact_type = fields.Selection([('internal','In-House'),('external','Client-Side')], required=True, default='internal')
company_id = fields.Many2one('res.company', default=lambda self: self.env.user.company_id)

View File

@ -0,0 +1,6 @@
from odoo import api, fields, models
class SurveySurvey(models.Model):
_inherit = "survey.survey"
company_id = fields.Many2one('res.company', 'Company', default=lambda self: self.env.company)

View File

@ -14,6 +14,43 @@
<field name="groups" eval="[(4, ref('hr_recruitment.group_hr_recruitment_interviewer'))]"/> <field name="groups" eval="[(4, ref('hr_recruitment.group_hr_recruitment_interviewer'))]"/>
</record> </record>
<record id="hr_job_recruitment_comp_rule" model="ir.rule">
<field name="name">Job Recruitment multi company rule</field>
<field name="model_id" ref="model_hr_job_recruitment"/>
<field eval="True" name="global"/>
<field name="domain_force">[('company_id', 'in', company_ids + [False])]</field>
</record>
<record id="hr_applicant_category_comp_rule" model="ir.rule">
<field name="name">hr Applicant category multi company rule</field>
<field name="model_id" ref="hr_recruitment.model_hr_applicant_category"/>
<field eval="True" name="global"/>
<field name="domain_force">[('company_id', 'in', company_ids + [False])]</field>
</record>
<record id="candidate_experience_comp_rule" model="ir.rule">
<field name="name">candidate experience multi company rule</field>
<field name="model_id" ref="model_candidate_experience"/>
<field eval="True" name="global"/>
<field name="domain_force">[('company_id', 'in', company_ids + [False])]</field>
</record>
<record id="survey_survey_comp_rule" model="ir.rule">
<field name="name">survey survey multi company rule</field>
<field name="model_id" ref="survey.model_survey_survey"/>
<field eval="True" name="global"/>
<field name="domain_force">[('company_id', 'in', company_ids + [False])]</field>
</record>
<record id="job_category_comp_rule" model="ir.rule">
<field name="name">Job Category multi company rule</field>
<field name="model_id" ref="model_job_category"/>
<field eval="True" name="global"/>
<field name="domain_force">[('company_id', 'in', company_ids + [False])]</field>
</record>
<function name="write" model="ir.model.data"> <function name="write" model="ir.model.data">
<function name="search" model="ir.model.data"> <function name="search" model="ir.model.data">
<value eval="[('module', '=', 'hr_recruitment_skills'), ('name','=','hr_applicant_skill_interviewer_rule')] "/> <value eval="[('module', '=', 'hr_recruitment_skills'), ('name','=','hr_applicant_skill_interviewer_rule')] "/>

View File

@ -10,6 +10,7 @@
<field name="experience_code" placeholder = "E1" required="1" width="30%"/> <field name="experience_code" placeholder = "E1" required="1" width="30%"/>
<field name="experience_from" required="1" placeholder="0" /> <field name="experience_from" required="1" placeholder="0" />
<field name="experience_to" required="1" placeholder="2" /> <field name="experience_to" required="1" placeholder="2" />
<field name="company_id"/>
</list> </list>
</field> </field>
</record> </record>

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="hr_applicant_category_view_tree_inherit" model="ir.ui.view">
<field name="name">hr.applicant.category.tree.inherit</field>
<field name="model">hr.applicant.category</field>
<field name="inherit_id" ref="hr_recruitment.hr_applicant_category_view_tree"/>
<field name="arch" type="xml">
<xpath expr="//list" position="inside">
<field name="company_id"/>
</xpath>
</field>
</record>
</odoo>

View File

@ -9,6 +9,7 @@
<list string="Category" editable="bottom"> <list string="Category" editable="bottom">
<field name="category_name" required="1"/> <field name="category_name" required="1"/>
<field name="default_user"/> <field name="default_user"/>
<field name="company_id" required="1"/>
</list> </list>
</field> </field>
</record> </record>

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form_inherit" model="ir.ui.view">
<field name="name">res.config.settings.view.form.requisitions.access</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="hr_recruitment.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//block[@name='recruitment_process_div']" position="inside">
<setting string="Allow Cross-Company Candidate Access"
help="Select it if you want to access the candidates across all the companies"
id="cross_company_candidates_access_control">
<field name="allow_cross_company_candidates"
options="{'no_quick_create': True, 'no_create_edit': True, 'no_open': True}"/>
</setting>
</xpath>
</field>
</record>
</odoo>

View File

@ -56,7 +56,8 @@
<group> <group>
<field name="company_type" widget="radio" <field name="company_type" widget="radio"
options="{'horizontal': true}"/> options="{'horizontal': true}" force_save="1"/>
<field name="company_id" force_save="1"/>
<field name="email"/> <field name="email"/>

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="survey_survey_view_form_inherit" model="ir.ui.view">
<field name="name">hr.survey.survey.form.inherit</field>
<field name="model">survey.survey</field>
<field name="inherit_id" ref="survey.survey_survey_view_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='restrict_user_ids']" position="after">
<field name="company_id"/>
</xpath>
</field>
</record>
</odoo>

View File

@ -1 +1,2 @@
from . import controllers from . import controllers
from . import models

View File

@ -1,8 +1,8 @@
{ {
"name": "HRMS Employee Dashboard", "name": "Dashboard",
"version": "18.0.1.0.0", "version": "18.0.1.0.0",
"category": "Human Resources", "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", "author": "Pranay",
"license": "LGPL-3", "license": "LGPL-3",
"depends": [ "depends": [
@ -11,6 +11,12 @@
"hr", "hr",
"hr_attendance", "hr_attendance",
"hr_holidays", "hr_holidays",
"hr_expense",
"calendar",
"project_todo",
"hr_resignation",
"knowledge",
"website",
"maintenance", "maintenance",
"employee_it_declaration", "employee_it_declaration",
"business_travel_expense_management", "business_travel_expense_management",
@ -18,6 +24,7 @@
], ],
"data": [ "data": [
"views/hrms_emp_dashboard_views.xml", "views/hrms_emp_dashboard_views.xml",
"views/res_config_settings_views.xml",
], ],
"assets": { "assets": {
"web.assets_backend": [ "web.assets_backend": [

View File

@ -0,0 +1 @@
from . import res_config_settings

View File

@ -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,
)

View File

@ -13,21 +13,23 @@ class HrmsEmployeeDashboard extends Component {
const today = new Date(); const today = new Date();
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1); const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
const monthEnd = new Date(today.getFullYear(), today.getMonth() + 1, 0); 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({ this.state = useState({
loading: true, loading: true,
error: null, error: null,
data: null, data: null,
period: "this_month", period: "this_month",
calendarView: "weekly", calendarView: "monthly",
dateFrom: this.formatDate(monthStart), dateFrom: this.formatDate(monthStart),
dateTo: this.formatDate(monthEnd), dateTo: this.formatDate(monthEnd),
calendarDateFrom: this.formatDate(weekStart), calendarDateFrom: this.formatDate(monthStart),
calendarDateTo: this.formatDate(weekEnd), calendarDateTo: this.formatDate(monthEnd),
selectedDay: null,
activeTab: this.getStoredActiveTab(),
holidayTab: 'current',
leaveRequests: [],
leaveRequestsTotal: 0,
leaveRequestsLimit: 5,
}); });
this.rpc = rpc; this.rpc = rpc;
this.action = useService("action"); this.action = useService("action");
@ -42,7 +44,6 @@ class HrmsEmployeeDashboard extends Component {
}); });
}); });
onWillDestroy(() => this.destroyCharts()); onWillDestroy(() => this.destroyCharts());
} }
formatDate(date) { formatDate(date) {
@ -52,6 +53,22 @@ class HrmsEmployeeDashboard extends Component {
return `${year}-${month}-${day}`; 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() { async loadData() {
this.state.loading = true; this.state.loading = true;
this.state.error = null; this.state.error = null;
@ -63,17 +80,45 @@ class HrmsEmployeeDashboard extends Component {
calendar_date_to: this.state.calendarDateTo, calendar_date_to: this.state.calendarDateTo,
calendar_view: this.state.calendarView, calendar_view: this.state.calendarView,
}); });
if (!response.success) { if (!response.success) {
this.state.error = response.error || "Unable to load employee dashboard."; this.state.error = response.error || "Unable to load employee dashboard.";
return; return;
} }
this.state.data = response; 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 expenses = response.expenses || {};
const total = const total = (expenses.series || []).reduce((sum, value) => sum + Number(value || 0), 0);
(expenses.series || []).reduce(
(sum, value) => sum + Number(value || 0),
0
);
this.state.expenseCount = total; this.state.expenseCount = total;
setTimeout(() => this.initCharts(), 100); setTimeout(() => this.initCharts(), 100);
} catch (error) { } 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() { destroyCharts() {
for (const chart of this.charts) { for (const chart of this.charts) {
try { 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() { // addExpense() {
// this.action.doAction({ // this.action.doAction({
// type: "ir.actions.act_window", // 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() { get statusText() {
return this.state.data?.attendance_state === "checked_in" ? "Check Out" : "Check In"; return this.state.data?.attendance_state === "checked_in" ? "Check Out" : "Check In";
} }

View File

@ -6,6 +6,27 @@
<div t-if="state.loading" class="hrms-loading">Loading employee dashboard...</div> <div t-if="state.loading" class="hrms-loading">Loading employee dashboard...</div>
<t t-if="state.data &amp;&amp; !state.loading"> <t t-if="state.data &amp;&amp; !state.loading">
<div class="hrms-dashboard-tabs">
<button type="button"
t-att-class="'hrms-dashboard-tab ' + (state.activeTab === 'employee' ? 'active' : '')"
t-on-click="() => this.switchTab('employee')">
<i class="fa fa-user"/> Employee Self Service
</button>
<button t-if="state.data.access.manager"
type="button"
t-att-class="'hrms-dashboard-tab ' + (state.activeTab === 'manager' ? 'active' : '')"
t-on-click="() => this.switchTab('manager')">
<i class="fa fa-users"/> Manager Self Service
</button>
<button t-if="state.data.access.hr"
type="button"
t-att-class="'hrms-dashboard-tab ' + (state.activeTab === 'hr' ? 'active' : '')"
t-on-click="() => this.switchTab('hr')">
<i class="fa fa-briefcase"/> HR Self Service
</button>
</div>
<div t-if="state.activeTab === 'employee'">
<section class="hrms-employee-card"> <section class="hrms-employee-card">
<div class="hrms-employee-main"> <div class="hrms-employee-main">
<img class="hrms-avatar" t-att-src="state.data.employee.image_url" alt="Employee"/> <img class="hrms-avatar" t-att-src="state.data.employee.image_url" alt="Employee"/>
@ -27,21 +48,15 @@
</div> </div>
<div class="hrms-employee-actions"> <div class="hrms-employee-actions">
<div class="hrms-action-buttons"> <div class="hrms-action-buttons">
<!-- <button class="hrms-icon-button primary" t-on-click="toggleAttendance" t-att-title="statusText">-->
<!-- <i t-att-class="state.data.attendance_state === 'checked_in' ? 'fa fa-sign-out' : 'fa fa-sign-in'"/>-->
<!-- <span t-esc="statusText"/>-->
<!-- </button>-->
<button <button
t-att-class="'hrms-icon-button ' + (state.data.attendance_state === 'checked_in' ? 'checkout' : 'checkin')" t-att-class="'hrms-icon-button ' + (state.data.attendance_state === 'checked_in' ? 'checkout' : 'checkin')"
t-on-click="toggleAttendance" t-on-click="toggleAttendance"
t-att-title="statusText"> t-att-title="statusText">
<i t-att-class="state.data.attendance_state === 'checked_in'
<i t-att-class="state.data.attendance_state === 'checked_in' ? 'fa fa-sign-out'
? 'fa fa-sign-out' : 'fa fa-sign-in'"/>
: 'fa fa-sign-in'"/>
<span t-esc="statusText"/> <span t-esc="statusText"/>
</button> </button>
<button class="hrms-icon-button" t-on-click="downloadPayslip" title="Download Payslip"> <button class="hrms-icon-button" t-on-click="downloadPayslip" title="Download Payslip">
<i class="fa fa-download"/> <i class="fa fa-download"/>
<span>Payslip</span> <span>Payslip</span>
@ -53,7 +68,7 @@
</div> </div>
<div class="hrms-filter-box"> <div class="hrms-filter-box">
<span>Period</span> <span>Period</span>
<select class="form-select form-select-sm" t-on-change="onPeriodChange" t-att-value="state.period"> <select class="form-select form-select-sm form-emp-card-select" t-on-change="onPeriodChange" t-att-value="state.period">
<option value="this_month" t-att-selected="state.period === 'this_month'">This Month</option> <option value="this_month" t-att-selected="state.period === 'this_month'">This Month</option>
<option value="this_year" t-att-selected="state.period === 'this_year'">This Year</option> <option value="this_year" t-att-selected="state.period === 'this_year'">This Year</option>
<option value="last_3_months" t-att-selected="state.period === 'last_3_months'">Last 3 Months</option> <option value="last_3_months" t-att-selected="state.period === 'last_3_months'">Last 3 Months</option>
@ -73,6 +88,34 @@
</div> </div>
</section> </section>
<section class="hrms-menus-row">
<article t-foreach="state.data.dashboard_menus" t-as="menu" t-key="menu.key"
t-att-class="'hrms-menu ' + menu.color"
t-on-click="() => this.openDashboardMenu(menu)"
t-att-title="menu.subtitle">
<span class="hrms-menu-glow"/>
<span class="hrms-menu-icon"><i t-att-class="menu.icon"/></span>
<span class="hrms-menu-content">
<span class="hrms-menu-title" t-esc="menu.title"/>
<small t-esc="menu.subtitle"/>
</span>
<span t-if="menu.badge" class="hrms-menu-badge" t-esc="menu.badge"/>
<span t-if="menu.show_count" class="hrms-menu-count">
<strong t-esc="menu.count"/>
<small t-esc="menu.count_label"/>
</span>
<button t-if="menu.create_action"
type="button"
class="hrms-menu-plus-btn"
t-on-click.stop="() => this.createFromDashboardMenu(menu)"
t-att-title="'Create ' + menu.title">
<i class="fa fa-plus"/>
</button>
<span t-if="!menu.show_count and !menu.create_action" class="hrms-menu-arrow"><i class="fa fa-arrow-right"/></span>
</article>
</section>
<!-- KPI ROW: Full width, 5 columns -->
<section class="hrms-kpi-row"> <section class="hrms-kpi-row">
<div class="hrms-kpi"><span>Expected Hours</span><strong t-esc="state.data.attendance_summary.expected_display"/></div> <div class="hrms-kpi"><span>Expected Hours</span><strong t-esc="state.data.attendance_summary.expected_display"/></div>
<div class="hrms-kpi present"><span>Worked Hours</span><strong t-esc="state.data.attendance_summary.worked_display"/></div> <div class="hrms-kpi present"><span>Worked Hours</span><strong t-esc="state.data.attendance_summary.worked_display"/></div>
@ -84,14 +127,18 @@
</div> </div>
</section> </section>
<div class="hrms-grid"> <div class="hrms-employee-columns">
<section class="hrms-panel wide"> <div class="hrms-employee-main-column">
<section class="hrms-panel hrms-calendar-panel">
<div class="hrms-panel-header"> <div class="hrms-panel-header">
<h2>Attendance Calendar</h2> <h2>Work Calendar</h2>
<div class="hrms-month-controls"> <div class="hrms-month-controls">
<button class="btn btn-light btn-sm" t-on-click="previousMonth"><i class="fa fa-chevron-left"/></button> <button class="btn btn-light btn-sm" t-on-click="previousMonth"><i class="fa fa-chevron-left"/></button>
<strong t-esc="monthLabel"/> <strong t-esc="monthLabel"/>
<button class="btn btn-light btn-sm" t-on-click="nextMonth"><i class="fa fa-chevron-right"/></button> <button class="btn btn-light btn-sm" t-on-click="nextMonth"><i class="fa fa-chevron-right"/></button>
<button class="btn btn-primary btn-sm" t-on-click="createMeetingFromCalendar">
<i class="fa fa-plus me-1"/>Meeting
</button>
<select class="form-select form-select-sm hrms-calendar-select" t-on-change="onCalendarViewChange" t-att-value="state.calendarView"> <select class="form-select form-select-sm hrms-calendar-select" t-on-change="onCalendarViewChange" t-att-value="state.calendarView">
<option value="weekly" t-att-selected="state.calendarView === 'weekly'">Weekly</option> <option value="weekly" t-att-selected="state.calendarView === 'weekly'">Weekly</option>
<option value="monthly" t-att-selected="state.calendarView === 'monthly'">Monthly</option> <option value="monthly" t-att-selected="state.calendarView === 'monthly'">Monthly</option>
@ -109,10 +156,14 @@
<span>Sun</span> <span>Sun</span>
</div> </div>
<div class="hrms-calendar" t-att-class="state.calendarView === 'yearly' ? 'yearly' : ''"> <div class="hrms-calendar" t-att-class="state.calendarView === 'yearly' ? 'yearly' : ''">
<div t-foreach="state.data.attendance_calendar" t-as="day" t-key="day.date" class="hrms-day" t-att-class="day.status" t-att-title="day.label || day.worked_display"> <button t-foreach="state.data.attendance_calendar" t-as="day" t-key="day.date"
<t t-if="day.status === 'empty'"> type="button"
<span/> class="hrms-day"
</t> t-att-class="day.status"
t-att-disabled="day.status === 'empty'"
t-att-title="day.label || day.worked_display"
t-on-click="() => this.openDayDetails(day)">
<t t-if="day.status === 'empty'"><span/></t>
<t t-elif="day.type === 'month'"> <t t-elif="day.type === 'month'">
<div class="hrms-day-top"> <div class="hrms-day-top">
<span t-esc="day.weekday"/> <span t-esc="day.weekday"/>
@ -123,222 +174,418 @@
<small><t t-esc="day.worked_display"/> worked</small> <small><t t-esc="day.worked_display"/> worked</small>
<small><t t-esc="day.break_display"/> break</small> <small><t t-esc="day.break_display"/> break</small>
<small><t t-esc="day.leave"/> leave · <t t-esc="day.holiday"/> holidays</small> <small><t t-esc="day.leave"/> leave · <t t-esc="day.holiday"/> holidays</small>
<small t-if="day.event_count"><i class="fa fa-users me-1"/> <t t-esc="day.event_count"/> events</small>
</div> </div>
</t> </t>
<t t-else=""> <t t-else="">
<div class="hrms-day-top"> <div class="hrms-day-top">
<strong> <strong t-esc="day.day"/>
<t t-esc="day.day"/> <span t-esc="day.weekday"/>
<span t-esc="day.weekday"/> </div>
</strong> <div t-if="day.worked_display"
<span t-if="day.label and day.show_metrics" class="hrms-day-badge" t-esc="day.label"/> t-att-class="'hrms-day-worked' + (day.worked_display.trim().charAt(0) === '0' &amp;&amp; !'123456789'.includes(day.worked_display.trim().charAt(1)) ? ' zero' : '')">
<t t-esc="day.worked_display"/>
</div> </div>
<div t-if="!day.show_metrics and day.label" class="hrms-day-message"> <div t-if="!day.show_metrics and day.label" class="hrms-day-message">
<span t-if="day.status === 'leave'" class="hrms-day-message-icon"><i class="fa fa-calendar-times-o"/></span> <span t-if="day.status === 'leave'" class="hrms-day-message-icon"><i class="fa fa-calendar-times-o"/></span>
<span t-elif="day.status === 'holiday'" class="hrms-day-message-icon"><i class="fa fa-calendar"/></span> <span t-elif="day.status === 'holiday'" class="hrms-day-message-icon"><i class="fa fa-calendar"/></span>
<strong t-esc="day.label || '-'"/> <strong t-esc="day.label || '-'"/>
</div> </div>
<div t-if="day.show_metrics" t-att-class="'hrms-day-balance ' + (day.balance_hours &gt;= 0 ? 'positive' : 'negative')"> <div t-if="day.signals &amp;&amp; day.signals.length" class="hrms-day-signals">
<t t-esc="day.balance_display"/> <t t-foreach="day.signals" t-as="signal" t-key="signal.type + signal.label">
</div> <button t-if="signal.type === 'more'"
<div t-if="day.show_metrics" class="hrms-day-metrics"> type="button"
<div> class="hrms-day-signal more hrms-more-btn"
<span>Wrk.</span> t-on-click.stop="() => this.openDayDetails(day)"
<strong t-esc="day.worked_display"/> t-att-title="'Click to view all'">
</div> <i class="fa fa-ellipsis-h"/>
<div> <t t-esc="signal.label"/>
<span>Break</span> </button>
<strong t-esc="day.break_display"/> <span t-else="" t-att-class="'hrms-day-signal ' + signal.type">
</div> <i t-att-class="signal.icon"/>
<div> <t t-esc="signal.label"/>
<span>Exp.</span> </span>
<strong t-esc="day.expected_display"/> </t>
</div>
</div> </div>
</t> </t>
</div>
</div>
</section>
<section class="hrms-panel">
<div class="hrms-panel-header">
<h2>Leave Balance</h2>
<button class="btn btn-primary btn-sm" t-on-click="applyLeave"><i class="fa fa-plus me-1"/>Apply Leave</button>
</div>
<div class="hrms-leave-summary">
<div t-if="!state.data.leave_balances.length" class="hrms-muted">No leave balances available.</div>
<div t-foreach="state.data.leave_balances" t-as="leave" t-key="leave.id">
<div t-if="leave.requires_allocation === 'yes'" class="hrms-leave-tile">
<strong t-esc="leave.name"/>
<div>
<span><b t-esc="leave.remaining"/> Balance</span>
<span><b t-esc="leave.taken"/> Taken</span>
<span><b t-esc="leave.planned"/> Confirmed</span>
</div>
</div>
</div>
</div>
<div class="hrms-leave-graph">
<div t-foreach="state.data.leave_balances" t-as="leave" t-key="leave.id + '-graph'">
<div t-if="leave.requires_allocation === 'yes'" class="hrms-leave-graph-row">
<div class="hrms-leave-graph-label">
<strong t-esc="leave.name"/>
<span><t t-esc="leave.allocated"/> allocated</span>
</div>
<div class="hrms-leave-bar" role="img" t-att-aria-label="leave.name + ': ' + leave.remaining + ' balance, ' + leave.taken + ' taken, ' + leave.planned + ' confirmed'">
<span class="hrms-leave-bar-remaining" t-att-style="'width: ' + leave.remaining_percent + '%'"/>
<span class="hrms-leave-bar-taken" t-att-style="'width: ' + leave.taken_percent + '%'"/>
<span class="hrms-leave-bar-planned" t-att-style="'width: ' + leave.planned_percent + '%'"/>
</div>
<div class="hrms-leave-legend">
<span><i class="remaining"/>Balance <b t-esc="leave.remaining"/></span>
<span><i class="taken"/>Taken <b t-esc="leave.taken"/></span>
<span><i class="planned"/>Confirmed <b t-esc="leave.planned"/></span>
</div>
</div>
</div>
</div>
</section>
<section class="hrms-panel">
<h2>Public Holidays</h2>
<div class="hrms-list">
<div t-if="!state.data.public_holidays.length" class="hrms-muted">No public holidays in the selected period.</div>
<div t-foreach="state.data.public_holidays" t-as="holiday" t-key="holiday.id" class="hrms-list-row">
<i class="fa fa-calendar"/>
<div><strong t-esc="holiday.name"/><span><t t-esc="holiday.date_from"/> - <t t-esc="holiday.date_to"/></span></div>
</div>
</div>
</section>
<!-- <section class="hrms-panel wide">-->
<!-- <div class="hrms-panel-header">-->
<!-- <h2>Expenses</h2>-->
<!-- <div class="hrms-panel-actions">-->
<!-- <button class="btn btn-primary btn-sm" t-on-click="addExpense"><i class="fa fa-plus me-1"/>Add Expense</button>-->
<!-- <button class="btn btn-light btn-sm" t-on-click="addBusinessTravel"><i class="fa fa-suitcase me-1"/>Add Business Travel</button>-->
<!-- </div>-->
<!-- </div>-->
<!-- -->
<!-- <div class="hrms-two-charts">-->
<!-- <div id="hrmsExpenseChart"/>-->
<!-- <div id="hrmsExpenseStateChart"/>-->
<!-- </div>-->
<!-- </section>-->
<!-- <section class="hrms-panel wide">-->
<!-- <div class="hrms-panel-header">-->
<!-- <h2>Expenses</h2>-->
<!-- <div class="hrms-panel-actions">-->
<!-- <button class="btn btn-primary btn-sm"-->
<!-- t-on-click="addExpense">-->
<!-- <i class="fa fa-plus me-1"/>-->
<!-- Add Expenses-->
<!-- </button>-->
<!-- <button class="btn btn-light btn-sm"-->
<!-- t-on-click="addBusinessTravel">-->
<!-- <i class="fa fa-suitcase me-1"/>-->
<!-- Add Business Travel-->
<!-- </button>-->
<!-- </div>-->
<!-- </div>-->
<!-- &lt;!&ndash; Charts &ndash;&gt;-->
<!-- <t t-if="state.expenseCount > 0">-->
<!-- <div class="hrms-two-charts">-->
<!-- <div id="hrmsExpenseChart"></div>-->
<!-- <div id="hrmsExpenseStateChart"></div>-->
<!-- </div>-->
<!-- </t>-->
<!-- &lt;!&ndash; Empty State &ndash;&gt;-->
<!-- <t t-else="">-->
<!-- <div class="expense-empty-state">-->
<!-- <i class="fa fa-receipt empty-icon"></i>-->
<!-- <h3>No Expense Records Found</h3>-->
<!-- <p>-->
<!-- No expenses were found for the selected period.-->
<!-- Click the button below to create your first expense.-->
<!-- </p>-->
<!-- <button class="btn btn-primary"-->
<!-- t-on-click="addExpense">-->
<!-- <i class="fa fa-plus me-1"></i>-->
<!-- Add Expense-->
<!-- </button>-->
<!-- </div>-->
<!-- </t>-->
<!-- </section>-->
<section class="hrms-panel wide">
<div class="hrms-panel-header">
<h2>Expenses</h2>
<div class="hrms-panel-actions">
<button class="btn btn-primary btn-sm"
t-on-click="addExpense">
<i class="fa fa-plus me-1"/>
Add Expense
</button>
<!-- <button class="btn btn-light btn-sm"-->
<!-- t-on-click="addBusinessTravel">-->
<!-- <i class="fa fa-suitcase me-1"/>-->
<!-- Add Business Travel-->
<!-- </button>-->
</div>
</div>
<!-- Expense Charts -->
<t t-if="state.expenseCount > 0">
<div class="hrms-two-charts">
<div id="hrmsExpenseChart"></div>
<div id="hrmsExpenseStateChart"></div>
</div>
</t>
<!-- Empty State -->
<t t-else="">
<div class="expense-empty-state">
<div class="empty-icon">
<i class="fa fa-file-invoice-dollar"/>
</div>
<h3>No Expense Records Found</h3>
<p>
There are no expense records available for the selected
period. Click <strong>Add Expense</strong> to create your
first expense claim.
</p>
<div class="mt-3">
<button class="btn btn-primary"
t-on-click="addExpense">
<i class="fa fa-plus me-1"/>
Add Expense
</button>
</div>
</div>
</t>
</section>
<section class="hrms-panel wide">
<div class="hrms-panel-header">
<h2>Allocated Equipment</h2>
<button class="btn btn-light btn-sm" t-on-click="() => this.openEquipment()">View All</button>
</div>
<div class="hrms-equipment-grid">
<div t-if="!state.data.equipment.length" class="hrms-muted">No equipment allocated.</div>
<button t-foreach="state.data.equipment" t-as="item" t-key="item.id" class="hrms-equipment" t-on-click="() => this.openEquipment(item.id)">
<i class="fa fa-laptop"/>
<strong t-esc="item.name"/>
<span t-esc="item.category"/>
<small><t t-esc="item.serial || '-'"/> · <t t-esc="item.assign_date || '-'"/></small>
</button> </button>
</div> </div>
</section> </section>
<section class="hrms-panel hrms-expense-panel">
<div class="hrms-panel-header">
<h2>Recent Expenses</h2>
<div class="hrms-panel-actions">
<button class="btn btn-primary btn-sm" t-on-click="addExpense">
<i class="fa fa-plus me-1"/> Add Expense
</button>
</div>
</div>
<div class="hrms-expense-body">
<t t-if="state.expenseCount > 0">
<div class="hrms-two-charts">
<div id="hrmsExpenseChart"></div>
<div id="hrmsExpenseStateChart"></div>
</div>
</t>
<t t-else="">
<div class="expense-empty-state">
<div class="empty-icon"><i class="fa fa-file-invoice-dollar"/></div>
<h3>No Expense Records Found</h3>
<p>There are no expense records available for the selected period. Click <strong>Add Expense</strong> to create your first expense claim.</p>
<div class="mt-3">
<button class="btn btn-primary" t-on-click="addExpense"><i class="fa fa-plus me-1"/> Add Expense</button>
</div>
</div>
</t>
</div>
</section>
</div>
<div class="hrms-employee-side-column">
<section class="hrms-panel hrms-leave-panel">
<div class="hrms-panel-header">
<h2>Leave Balance</h2>
<button class="btn btn-primary btn-sm" t-on-click="applyLeave"><i class="fa fa-plus me-1"/>Apply Leave</button>
</div>
<div class="hrms-leave-scroll">
<div t-if="!state.data.leave_balances.length" class="hrms-empty-message">
<i class="fa fa-info-circle"/>
<span>Nothing to show yet</span>
</div>
<div class="hrms-balance-grid">
<t t-foreach="state.data.leave_balances" t-as="leave" t-key="leave.id">
<div t-if="leave.requires_allocation === 'yes'" class="hrms-balance-card">
<div class="hrms-balance-card-icon">
<i class="fa fa-calendar-check-o"/>
</div>
<div class="hrms-balance-card-info">
<strong t-esc="leave.name"/>
<span class="hrms-balance-card-days">
<t t-esc="leave.remaining"/> Days
</span>
</div>
</div>
</t>
</div>
<div t-if="state.data.leave_balances.length" class="hrms-leave-divider">
<span>My Leave Requests</span>
</div>
<div class="hrms-leave-requests">
<div t-if="!state.leaveRequests.length" class="hrms-muted hrms-leave-empty-req">No leave requests found.</div>
<div t-foreach="state.leaveRequests" t-as="req" t-key="req.id" class="hrms-leave-req-row">
<div class="hrms-leave-req-info">
<strong t-esc="req.name"/>
<span class="hrms-leave-req-dates">
<t t-esc="req.date_from.split(' ')[0]"/>
<t t-if="req.date_from.split(' ')[0] !== req.date_to.split(' ')[0]">
<t t-esc="req.date_to.split(' ')[0]"/>
</t>
</span>
</div>
<div class="hrms-leave-req-meta">
<span class="hrms-leave-req-duration"><t t-esc="req.duration"/>d</span>
<span t-att-class="'hrms-leave-req-state ' + req.state" t-esc="req.state_label"/>
</div>
</div>
</div>
<t t-if="state.leaveRequestsTotal > state.leaveRequestsLimit">
<button class="hrms-load-more-btn" t-on-click="loadMoreLeaves">
Load More (<t t-esc="state.leaveRequestsTotal - state.leaveRequestsLimit"/> remaining)
</button>
</t>
</div>
</section>
<section class="hrms-panel hrms-holidays-panel">
<div class="hrms-panel-header">
<h2>Public Holidays</h2>
<div class="btn-group btn-group-sm" role="group">
<input type="radio" class="btn-check" name="holiday_tabs" id="holiday_tab_current" value="current" t-att-checked="state.holidayTab === 'current'" t-on-change="() => this.state.holidayTab = 'current'"/>
<label class="btn btn-outline-primary" for="holiday_tab_current">Period</label>
<input type="radio" class="btn-check" name="holiday_tabs" id="holiday_tab_upcoming" value="upcoming" t-att-checked="state.holidayTab === 'upcoming'" t-on-change="() => this.state.holidayTab = 'upcoming'"/>
<label class="btn btn-outline-primary" for="holiday_tab_upcoming">Upcoming</label>
</div>
</div>
<div class="hrms-holidays-scroll">
<div t-if="state.holidayTab === 'current'">
<div t-if="!state.data.current_holidays.length" class="hrms-muted">No public holidays in the selected month.</div>
<div t-foreach="state.data.current_holidays" t-as="holiday" t-key="holiday.id" class="hrms-list-row">
<i class="fa fa-calendar"/>
<div><strong t-esc="holiday.name"/><span><t t-esc="holiday.date_from"/> - <t t-esc="holiday.date_to"/></span></div>
</div>
</div>
<div t-if="state.holidayTab === 'upcoming'">
<div t-if="!state.data.upcoming_holidays.length" class="hrms-muted">No upcoming public holidays.</div>
<div t-foreach="state.data.upcoming_holidays" t-as="holiday" t-key="holiday.id" class="hrms-list-row">
<i class="fa fa-calendar"/>
<div><strong t-esc="holiday.name"/><span><t t-esc="holiday.date_from"/> - <t t-esc="holiday.date_to"/></span></div>
</div>
</div>
</div>
</section>
<section class="hrms-panel hrms-equipment-panel">
<div class="hrms-panel-header">
<h2>Allocated Equipment</h2>
<button class="btn btn-light btn-sm"
t-on-click="() => this.openEquipment()">
View All
</button>
</div>
<div class="hrms-equipment-timeline">
<div t-if="!state.data.equipment.length"
class="hrms-muted">
No equipment allocated.
</div>
<div t-foreach="state.data.equipment"
t-as="item"
t-key="item.id"
class="hrms-equipment-row">
<!-- LEFT DATE -->
<div class="hrms-equipment-date">
<strong t-esc="item.assign_date or '-'"/>
</div>
<!-- TIMELINE -->
<div class="hrms-equipment-line">
<span class="timeline-dot"/>
<span class="timeline-line"/>
</div>
<!-- CARD -->
<button class="hrms-equipment-card"
t-on-click="() => this.openEquipment(item.id)">
<div class="equipment-service-count">
<i class="fa fa-wrench"/>
<t t-esc="item.service_open_count"/>
</div>
<div class="equipment-title">
<t t-esc="item.name"/>
</div>
<div class="equipment-category">
<t t-esc="item.category"/>
</div>
<div t-if="item.serial" class="equipment-serial">
Asset :
<t t-esc="item.serial or '-'"/>
</div>
</button>
</div>
</div>
</section>
</div>
</div>
</div>
<div t-if="state.activeTab === 'manager'" class="hrms-service-dashboard">
<section class="hrms-service-hero manager">
<div>
<span>Manager Workspace</span>
<h1 t-esc="state.data.manager_dashboard.hero.title"/>
<p t-esc="state.data.manager_dashboard.hero.subtitle"/>
</div>
<button class="btn btn-light" t-on-click="() => this.openWorkAction(state.data.manager_dashboard.menus[0] &amp;&amp; state.data.manager_dashboard.menus[0].action)">
<i class="fa fa-users me-1"/> Open Team
</button>
</section>
<section class="hrms-service-kpis">
<article t-foreach="state.data.manager_dashboard.kpis" t-as="kpi" t-key="kpi.label" t-att-class="'hrms-service-kpi ' + kpi.tone">
<span t-esc="kpi.label"/>
<strong t-esc="kpi.value"/>
</article>
</section>
<section class="hrms-menus-row hrms-service-menu-row">
<article t-foreach="state.data.manager_dashboard.menus" t-as="menu" t-key="menu.key"
t-att-class="'hrms-menu ' + menu.color"
t-on-click="() => this.openDashboardMenu(menu)">
<span class="hrms-menu-icon"><i t-att-class="menu.icon"/></span>
<span class="hrms-menu-content">
<span class="hrms-menu-title" t-esc="menu.title"/>
<small t-esc="menu.subtitle"/>
</span>
<span t-if="menu.show_count" class="hrms-menu-count">
<strong t-esc="menu.count"/>
<small t-esc="menu.count_label"/>
</span>
</article>
</section>
<div class="hrms-service-grid">
<section class="hrms-panel">
<div class="hrms-panel-header">
<h2>Approval Queue</h2>
<span class="hrms-panel-count"><t t-esc="state.data.manager_dashboard.approval_queue.length"/> items</span>
</div>
<div class="hrms-approval-list">
<div t-if="!state.data.manager_dashboard.approval_queue.length" class="hrms-muted">No pending manager approvals.</div>
<article t-foreach="state.data.manager_dashboard.approval_queue" t-as="item" t-key="item.model + item.id" class="hrms-approval-row">
<button class="hrms-approval-main" t-on-click="() => this.openWorkAction(item.action)">
<span class="hrms-approval-category" t-esc="item.category"/>
<strong t-esc="item.employee || item.title"/>
<small><t t-esc="item.department || '-'"/> · <t t-esc="item.date || '-'"/></small>
</button>
<div class="hrms-approval-actions">
<span t-att-class="'hrms-leave-req-state ' + item.state" t-esc="item.state_label"/>
<button t-if="item.can_approve" class="btn btn-success btn-sm" t-on-click="() => this.runApproval(item, 'approve')"><i class="fa fa-check"/></button>
<button t-if="item.can_reject" class="btn btn-outline-danger btn-sm" t-on-click="() => this.runApproval(item, 'reject')"><i class="fa fa-times"/></button>
</div>
</article>
</div>
</section>
<section class="hrms-panel">
<div class="hrms-panel-header">
<h2>Team Today</h2>
<span class="hrms-panel-count"><t t-esc="state.data.manager_dashboard.team.length"/> people</span>
</div>
<div class="hrms-team-list">
<div t-if="!state.data.manager_dashboard.team.length" class="hrms-muted">No team members reporting to you.</div>
<button t-foreach="state.data.manager_dashboard.team" t-as="member" t-key="member.id" class="hrms-team-row" t-on-click="() => this.openWorkAction(member.action)">
<img t-att-src="member.image_url" alt=""/>
<span>
<strong t-esc="member.name"/>
<small><t t-esc="member.job || 'Employee'"/> · <t t-esc="member.department || '-'"/></small>
</span>
<em t-att-class="member.tone" t-esc="member.status"/>
</button>
</div>
</section>
</div>
<section class="hrms-menus-row hrms-service-menu-row">
<article t-foreach="state.data.manager_dashboard.approval_tiles" t-as="menu" t-key="menu.key"
t-att-class="'hrms-menu ' + menu.color"
t-on-click="() => this.openDashboardMenu(menu)">
<span class="hrms-menu-icon"><i t-att-class="menu.icon"/></span>
<span class="hrms-menu-content">
<span class="hrms-menu-title" t-esc="menu.title"/>
<small t-esc="menu.subtitle"/>
</span>
<span class="hrms-menu-count"><strong t-esc="menu.count"/><small t-esc="menu.count_label"/></span>
</article>
</section>
</div>
<div t-if="state.activeTab === 'hr'" class="hrms-service-dashboard">
<section class="hrms-service-hero hr">
<div>
<span>HR Workspace</span>
<h1 t-esc="state.data.hr_dashboard.hero.title"/>
<p t-esc="state.data.hr_dashboard.hero.subtitle"/>
</div>
<button class="btn btn-light" t-on-click="() => this.openDashboardMenu(state.data.hr_dashboard.menus[0])">
<i class="fa fa-users me-1"/> Open Employees
</button>
</section>
<section class="hrms-service-kpis">
<article t-foreach="state.data.hr_dashboard.kpis" t-as="kpi" t-key="kpi.label" t-att-class="'hrms-service-kpi ' + kpi.tone">
<span t-esc="kpi.label"/>
<strong t-esc="kpi.value"/>
</article>
</section>
<section class="hrms-menus-row hrms-service-menu-row">
<article t-foreach="state.data.hr_dashboard.menus" t-as="menu" t-key="menu.key"
t-att-class="'hrms-menu ' + menu.color"
t-on-click="() => this.openDashboardMenu(menu)">
<span class="hrms-menu-icon"><i t-att-class="menu.icon"/></span>
<span class="hrms-menu-content">
<span class="hrms-menu-title" t-esc="menu.title"/>
<small t-esc="menu.subtitle"/>
</span>
<span t-if="menu.show_count" class="hrms-menu-count"><strong t-esc="menu.count"/><small t-esc="menu.count_label"/></span>
</article>
</section>
<div class="hrms-service-grid">
<section class="hrms-panel">
<div class="hrms-panel-header">
<h2>HR Approval Queue</h2>
<span class="hrms-panel-count"><t t-esc="state.data.hr_dashboard.approval_queue.length"/> items</span>
</div>
<div class="hrms-approval-list">
<div t-if="!state.data.hr_dashboard.approval_queue.length" class="hrms-muted">No pending HR approvals.</div>
<article t-foreach="state.data.hr_dashboard.approval_queue" t-as="item" t-key="item.model + item.id" class="hrms-approval-row">
<button class="hrms-approval-main" t-on-click="() => this.openWorkAction(item.action)">
<span class="hrms-approval-category" t-esc="item.category"/>
<strong t-esc="item.employee || item.title"/>
<small><t t-esc="item.department || '-'"/> · <t t-esc="item.date || '-'"/></small>
</button>
<div class="hrms-approval-actions">
<span t-att-class="'hrms-leave-req-state ' + item.state" t-esc="item.state_label"/>
<button t-if="item.can_approve" class="btn btn-success btn-sm" t-on-click="() => this.runApproval(item, 'approve')"><i class="fa fa-check"/></button>
<button t-if="item.can_reject" class="btn btn-outline-danger btn-sm" t-on-click="() => this.runApproval(item, 'reject')"><i class="fa fa-times"/></button>
</div>
</article>
</div>
</section>
<section class="hrms-panel">
<div class="hrms-panel-header">
<h2>Workforce Signals</h2>
</div>
<div class="hrms-workforce-list">
<article t-foreach="state.data.hr_dashboard.workforce" t-as="signal" t-key="signal.label" class="hrms-workforce-row">
<i t-att-class="signal.icon"/>
<span t-esc="signal.label"/>
<strong t-esc="signal.value"/>
</article>
</div>
</section>
</div>
<section class="hrms-menus-row hrms-service-menu-row">
<article t-foreach="state.data.hr_dashboard.approval_tiles" t-as="menu" t-key="menu.key"
t-att-class="'hrms-menu ' + menu.color"
t-on-click="() => this.openDashboardMenu(menu)">
<span class="hrms-menu-icon"><i t-att-class="menu.icon"/></span>
<span class="hrms-menu-content">
<span class="hrms-menu-title" t-esc="menu.title"/>
<small t-esc="menu.subtitle"/>
</span>
<span class="hrms-menu-count"><strong t-esc="menu.count"/><small t-esc="menu.count_label"/></span>
</article>
</section>
</div> </div>
</t> </t>
<div t-if="state.selectedDay" class="hrms-day-modal-backdrop" t-on-click="closeDayDetails">
<section class="hrms-day-modal" t-on-click.stop="">
<header class="hrms-modal-header">
<div class="hrms-modal-title-group">
<h2>
<t t-if="state.selectedDay.type === 'month'" t-esc="state.selectedDay.label"/>
<t t-else=""><t t-esc="state.selectedDay.day"/> <t t-esc="state.selectedDay.weekday"/></t>
</h2>
<span t-att-class="'hrms-status-pill ' + state.selectedDay.status" t-esc="state.selectedDay.label || state.selectedDay.status"/>
</div>
<button type="button" class="hrms-modal-close" t-on-click="closeDayDetails" title="Close">
<i class="fa fa-times"/>
</button>
</header>
<div class="hrms-modal-body">
<div class="hrms-detail-chips">
<div t-foreach="state.selectedDay.details || []" t-as="detail" t-key="detail.label" class="hrms-detail-chip">
<span t-esc="detail.label"/>
<strong t-esc="detail.value"/>
</div>
</div>
<div t-if="state.selectedDay.signals &amp;&amp; state.selectedDay.signals.length" class="hrms-modal-events">
<span class="hrms-modal-events-label">Events &amp; Activities</span>
<div class="hrms-modal-events-list">
<span t-foreach="state.selectedDay.signals" t-as="signal" t-key="signal.type + signal.label"
t-if="signal.type !== 'more'"
t-att-class="'hrms-event-tag ' + signal.type">
<i t-att-class="signal.icon"/>
<t t-esc="signal.label"/>
</span>
</div>
</div>
</div>
<footer t-if="state.selectedDay.actions &amp;&amp; state.selectedDay.actions.length">
<button t-foreach="state.selectedDay.actions" t-as="action" t-key="action.key"
type="button"
t-att-class="'btn btn-sm ' + (action.primary ? 'btn-primary' : 'btn-light')"
t-on-click="() => this.runDayAction(action)">
<i t-att-class="action.icon + ' me-1'"/>
<t t-esc="action.label"/>
</button>
</footer>
</section>
</div>
</div> </div>
</t> </t>
</templates> </templates>

View File

@ -1,13 +1,13 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<odoo> <odoo>
<record id="action_hrms_emp_dashboard" model="ir.actions.client"> <record id="action_hrms_emp_dashboard" model="ir.actions.client">
<field name="name">Employee Dashboard</field> <field name="name">Dashboard</field>
<field name="tag">hrms_emp_dashboard</field> <field name="tag">hrms_emp_dashboard</field>
<field name="target">current</field> <field name="target">current</field>
</record> </record>
<menuitem id="menu_hrms_emp_dashboard_root" <menuitem id="menu_hrms_emp_dashboard_root"
name="Employee Dashboard" name="Dashboard"
action="action_hrms_emp_dashboard" action="action_hrms_emp_dashboard"
web_icon="hrms_emp_dashboard,static/description/icon.png" web_icon="hrms_emp_dashboard,static/description/icon.png"
sequence="-90" sequence="-90"

View File

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form_dashboard" model="ir.ui.view">
<field name="name">res.config.settings.view.form.dashboard</field>
<field name="model">res.config.settings</field>
<field name="priority" eval="95"/>
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//block[@name='employees_setting_container']" position="after">
<block title="Dashboard" name="hrms_dashboard_setting_container">
<setting string="On-Duty Requests"
help="Show the On-Duty request shortcut on Employee Self Service."
id="hrms_dashboard_show_on_duty_setting">
<field name="hrms_dashboard_show_on_duty"/>
</setting>
<setting string="Late Coming Requests"
help="Show the Late Coming request shortcut on Employee Self Service."
id="hrms_dashboard_show_late_coming_setting">
<field name="hrms_dashboard_show_late_coming"/>
</setting>
<setting string="Overtime Requests"
help="Show the Overtime request shortcut on Employee Self Service."
id="hrms_dashboard_show_overtime_setting">
<field name="hrms_dashboard_show_overtime"/>
</setting>
<setting string="Shift Swap Requests"
help="Show the Shift Swap request shortcut on Employee Self Service."
id="hrms_dashboard_show_shift_swap_setting">
<field name="hrms_dashboard_show_shift_swap"/>
</setting>
</block>
</xpath>
</field>
</record>
</odoo>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

View File

@ -8,17 +8,15 @@
""", """,
'author': 'Raman Marikanti', 'author': 'Raman Marikanti',
'category': 'Human Resources', 'category': 'Human Resources',
'depends': ['base', 'hr_recruitment', 'hr_payroll', 'hr_recruitment_extended'], 'depends': ['base', 'employee_bridge', 'hr_payroll'],
'data': [ 'data': [
'security/ir.model.access.csv', 'security/ir.model.access.csv',
'data/mail_template.xml', 'data/mail_template.xml',
'views/stages.xml',
'views/offer_letter_views.xml', 'views/offer_letter_views.xml',
'views/hr_applicant_offer_views.xml',
'views/offer_response_templates.xml', 'views/offer_response_templates.xml',
'views/recruitment_employee_bridge.xml',
# 'views/templates.xml', # 'views/templates.xml',
'views/menu_views.xml', 'views/menu_views.xml',
'wizards/offer_release_request_wizard.xml',
'wizards/applicant_offer_mail_wizard.xml', 'wizards/applicant_offer_mail_wizard.xml',
'wizards/offer_letter_reject_wizard.xml', 'wizards/offer_letter_reject_wizard.xml',
'report/offer_letter_report.xml', 'report/offer_letter_report.xml',

View File

@ -5,14 +5,14 @@
<field name="name">Applicant Offer Email Template</field> <field name="name">Applicant Offer Email Template</field>
<field name="model_id" ref="offer_letters.model_offer_letter"/> <field name="model_id" ref="offer_letters.model_offer_letter"/>
<field name="email_from">{{ user.email_formatted }}</field> <field name="email_from">{{ user.email_formatted }}</field>
<field name="email_to">{{ object.main_candidate_id.email_from or '' }}</field> <field name="email_to">{{ object.recipient_email or '' }}</field>
<field name="subject">Offer Letter - {{ object.position or object.main_candidate_id.job_id.name or '' }}</field> <field name="subject">Offer Letter - {{ object.position or '' }}</field>
<field name="description"> <field name="description">
Send applicant offer mail with offer letter attachment. Send applicant offer mail with offer letter attachment.
</field> </field>
<field name="body_html" type="html"> <field name="body_html" type="html">
<div style="margin: 0; padding: 0; font-size: 13px; line-height: 1.7;"> <div style="margin: 0; padding: 0; font-size: 13px; line-height: 1.7;">
<p>Dear <t t-esc="object.main_candidate_id.partner_name or ''"/>,</p> <p>Dear <t t-esc="object.main_candidate_name or ''"/>,</p>
<p> <p>
With reference to the interview and subsequent discussions you had with us, we are pleased to select With reference to the interview and subsequent discussions you had with us, we are pleased to select
you for the position of "<t t-esc="object.position or ''"/>" in our organization with the following you for the position of "<t t-esc="object.position or ''"/>" in our organization with the following

View File

@ -1,4 +1,2 @@
from . import stages
from . import offer_letter from . import offer_letter
from . import hr_applicant from . import recruitment_employee_bridge
from . import hr_candidate

View File

@ -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()

View File

@ -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

View File

@ -27,24 +27,46 @@ class OfferLetter(models.Model):
default=lambda self: _('New'), default=lambda self: _('New'),
copy=False copy=False
) )
candidate_id = fields.Many2one( 'hr.applicant', string='Applicant', required=False, bridge_id = fields.Many2one(
) 'recruitment.employee.bridge',
main_candidate_id = fields.Many2one('hr.candidate',string='Candidate', readonly=False, required=True) string='Onboarding',
ondelete='restrict',
@api.onchange('candidate_id') tracking=True,
def _onchange_candidate_id(self): index=True,
if self.candidate_id: )
self.main_candidate_id = self.candidate_id.candidate_id main_candidate_name = fields.Char(string="Candidate Name", compute="_compute_bridge_contact", store=True)
recipient_email = fields.Char(compute="_compute_bridge_contact", store=True)
main_candidate_name = fields.Char(compute="_compute_main_candidate_name", readonly=False) recipient_street = fields.Char(compute="_compute_bridge_contact", store=True)
recipient_street2 = fields.Char(compute="_compute_bridge_contact", store=True)
@api.depends('candidate_id','main_candidate_id') recipient_city = fields.Char(compute="_compute_bridge_contact", store=True)
def _compute_main_candidate_name(self): recipient_zip = fields.Char(compute="_compute_bridge_contact", store=True)
for rec in self: recipient_country_id = fields.Many2one("res.country", compute="_compute_bridge_contact", store=True)
if rec.candidate_id:
rec.main_candidate_name = rec.candidate_id.partner_name @api.depends(
elif rec.main_candidate_id: 'bridge_id',
rec.main_candidate_name = rec.main_candidate_id.partner_name '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) 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) request_date = fields.Datetime(string='Requested On', readonly=True, tracking=True)
@ -120,20 +142,20 @@ class OfferLetter(models.Model):
@api.model @api.model
def create(self, vals): def create(self, vals):
if vals.get('name', _('New')) == _('New'): if vals.get('name', _('New')) == _('New'):
vals['name'] = self.env['ir.sequence'].next_by_code('offer.letter') or _('New') vals['name'] = self.env['ir.sequence'].next_by_code('offer.letter') or _('New')
if vals.get('candidate_id') and 'salary' in vals: offer_letter = super(OfferLetter, self).create(vals)
self.env['hr.applicant'].browse(vals['candidate_id']).write({ if vals.get('bridge_id') and 'salary' in vals and 'finalized_ctc' in offer_letter.bridge_id._fields:
'finalized_ctc': vals['salary'], offer_letter.bridge_id.finalized_ctc = offer_letter.salary
}) return offer_letter
return super(OfferLetter, self).create(vals)
def write(self, vals):
def write(self, vals): result = super(OfferLetter, self).write(vals)
result = super(OfferLetter, self).write(vals) if 'salary' in vals:
if 'salary' in vals: for record in self.filtered('bridge_id'):
for record in self.filtered('candidate_id'): if 'finalized_ctc' in record.bridge_id._fields:
record.candidate_id.finalized_ctc = record.salary record.bridge_id.finalized_ctc = record.salary
return result return result
def action_open_send_offer_wizard(self): def action_open_send_offer_wizard(self):
self.ensure_one() self.ensure_one()
@ -162,7 +184,7 @@ class OfferLetter(models.Model):
def action_accept_offer(self): def action_accept_offer(self):
self.ensure_one() self.ensure_one()
# employee = self.env['hr.employee'].create({ # employee = self.env['hr.employee'].create({
# 'name': self.candidate_id.partner_name, # 'name': self.main_candidate_name,
# 'job_title': self.position, # 'job_title': self.position,
# 'department_id': self.department_id.id, # 'department_id': self.department_id.id,
# 'currency_id': self.currency_id.id, # 'currency_id': self.currency_id.id,
@ -174,17 +196,18 @@ class OfferLetter(models.Model):
}) })
return True return True
def action_reject_offer(self): def action_reject_offer(self, reason=False):
self.ensure_one() self.ensure_one()
self.write({ self.write({
'state': 'rejected', 'state': 'rejected',
'response_date': fields.Datetime.now() 'response_date': fields.Datetime.now(),
'rejection_reason': reason
}) })
return True return True
@api.onchange('candidate_id') @api.onchange('bridge_id')
def _onchange_candidate_id(self): def _onchange_bridge_id(self):
self.position = self.candidate_id.job_id.name self.position = self.bridge_id.job_id.name
def get_paydetailed_lines(self): def get_paydetailed_lines(self):
today = fields.Date.today() today = fields.Date.today()
@ -278,3 +301,15 @@ class OfferLetter(models.Model):
def generate_pdf_report(self): def generate_pdf_report(self):
return self.env.ref('offer_letters.hr_offer_letters_employee_print').report_action(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},
}

View File

@ -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,
}

View File

@ -62,26 +62,26 @@
<div style="margin-bottom: 25px;"> <div style="margin-bottom: 25px;">
<strong>To,</strong> <strong>To,</strong>
<br/> <br/>
<strong t-esc="o.main_candidate_id.partner_name"/> <strong t-esc="o.main_candidate_name"/>
<br/> <br/>
<div t-if="o.candidate_id and o.candidate_id.private_street and o.candidate_id.private_city"> <div t-if="o.recipient_street and o.recipient_city">
<t t-esc="o.candidate_id.private_street"/> <t t-esc="o.recipient_street"/>
<br/> <br/>
<t t-esc="o.candidate_id.private_street2" t-if="o.candidate_id.private_street2"/> <t t-esc="o.recipient_street2" t-if="o.recipient_street2"/>
<t t-if="o.candidate_id.private_street2"> <t t-if="o.recipient_street2">
<br/> <br/>
</t> </t>
<t t-esc="o.candidate_id.private_city"/> <t t-esc="o.recipient_city"/>
<t t-esc="o.candidate_id.private_zip"/> <t t-esc="o.recipient_zip"/>
<br/> <br/>
<t t-esc="o.candidate_id.private_country_id.name"/> <t t-esc="o.recipient_country_id.name"/>
</div> </div>
</div> </div>
<!-- DEAR LINE --> <!-- DEAR LINE -->
<div style="margin-bottom: 20px;"> <div style="margin-bottom: 20px;">
<strong>Dear <strong>Dear
<t t-esc="o.main_candidate_id.partner_name"/>, <t t-esc="o.main_candidate_name"/>,
</strong> </strong>
</div> </div>
@ -368,7 +368,7 @@
<br/> <br/>
<br/> <br/>
<br/> <br/>
<strong t-esc="o.main_candidate_id.partner_name"/> <strong t-esc="o.main_candidate_name"/>
<br/> <br/>
<strong t-esc="o.position"/> <strong t-esc="o.position"/>
<br/> <br/>
@ -486,7 +486,7 @@
<br/> <br/>
<br/> <br/>
<strong>Employee Name : <strong>Employee Name :
<t t-esc="o.main_candidate_id.partner_name"/> <t t-esc="o.main_candidate_name"/>
</strong> </strong>
<br/> <br/>
<br/> <br/>
@ -595,7 +595,7 @@
<br/> <br/>
<br/> <br/>
<br/> <br/>
<strong t-esc="o.main_candidate_id.partner_name"/> <strong t-esc="o.main_candidate_name"/>
<br/> <br/>
<strong t-esc="o.position"/> <strong t-esc="o.position"/>
<br/> <br/>
@ -621,7 +621,7 @@
Company incorporated under Indian Companies Act 1956, having registered office in Company incorporated under Indian Companies Act 1956, having registered office in
Hyderabad, Hyderabad,
India ("Company") India ("Company")
<strong t-esc="o.main_candidate_id.partner_name"/> <strong t-esc="o.main_candidate_name"/>
(Recipient) (Recipient)
</p> </p>
<p>Whereas "Company" wishes to explore the possibility of entering into an employment <p>Whereas "Company" wishes to explore the possibility of entering into an employment
@ -875,7 +875,7 @@
<strong t-esc="o.joining_date"/> <strong t-esc="o.joining_date"/>
</td> </td>
<td style="padding-top: 20px;"> <td style="padding-top: 20px;">
<strong t-esc="o.main_candidate_id.partner_name"/> <strong t-esc="o.main_candidate_name"/>
<br/> <br/>
<strong t-esc="o.position"/> <strong t-esc="o.position"/>
<br/> <br/>

View File

@ -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_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_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_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 access_offer_letter_reject_wizard,offer.letter.reject.wizard.user,model_offer_letter_reject_wizard,base.group_user,1,1,1,1

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_offer_letter_user offer.letter.user model_offer_letter base.group_user 1 1 1 0
3 access_offer_letter_manager offer.letter.manager model_offer_letter hr.group_hr_manager 1 1 1 1
4 access_applicant_offer_mail_wizard applicant.offer.mail.wizard.user offer_letters.model_applicant_offer_mail_wizard base.group_user 1 1 1 1
5 access_offer_release_request_wizard access_offer_letter_reject_wizard offer.release.request.wizard.user offer.letter.reject.wizard.user model_offer_release_request_wizard model_offer_letter_reject_wizard base.group_user 1 1 1 1

View File

@ -23,7 +23,7 @@
<button name="action_open_send_offer_wizard" type="object" string="Send Offer" class="oe_highlight" <button name="action_open_send_offer_wizard" type="object" string="Send Offer" class="oe_highlight"
invisible="state != 'requested'" groups="hr.group_hr_manager"/> invisible="state != 'requested'" groups="hr.group_hr_manager"/>
<button name="action_accept_offer" type="object" string="Accept Offer" invisible="state != 'sent'" class="oe_highlight"/> <button name="action_accept_offer" type="object" string="Accept Offer" invisible="state != 'sent'" class="oe_highlight"/>
<!-- <button name="action_open_reject_wizard" type="object" string="Reject Offer" invisible="state != 'sent'" class="oe_danger"/>--> <button name="action_open_reject_wizard" type="object" string="Reject Offer" invisible="state != 'sent'" class="oe_danger"/>
<button name="get_paydetailed_lines" type="object" string="Get Data" class="oe_danger"/> <button name="get_paydetailed_lines" type="object" string="Get Data" class="oe_danger"/>
<button name="generate_pdf_report" type="object" string="Generate PDF" class="oe_highlight"/> <button name="generate_pdf_report" type="object" string="Generate PDF" class="oe_highlight"/>
<field name="state" widget="statusbar" statusbar_visible="requested,sent,accepted,rejected,expired"/> <field name="state" widget="statusbar" statusbar_visible="requested,sent,accepted,rejected,expired"/>
@ -32,9 +32,8 @@
<group> <group>
<group> <group>
<field name="name" readonly="state != 'requested'"/> <field name="name" readonly="state != 'requested'"/>
<field name="main_candidate_name" invisible="1"/> <field name="bridge_id"/>
<field name="candidate_id" invisible="not candidate_id" readonly="1" force_save="1"/> <field name="main_candidate_name" readonly="1"/>
<field name="main_candidate_id" invisible="candidate_id" force_save="1"/>
<field name="requested_by_id" readonly="1"/> <field name="requested_by_id" readonly="1"/>
<field name="request_date" readonly="1"/> <field name="request_date" readonly="1"/>
<field name="manager_id"/> <field name="manager_id"/>
@ -50,8 +49,9 @@
<field name="probation_period"/> <field name="probation_period"/>
<field name="pay_struct_id"/> <field name="pay_struct_id"/>
<field name="sent_date" readonly="1"/> <field name="sent_date" readonly="1"/>
<field name="response_date" readonly="1"/> <field name="response_date" readonly="1"/>
</group> <field name="recipient_email" readonly="1"/>
</group>
</group> </group>
<notebook> <notebook>
<page string="Terms Conditions"> <page string="Terms Conditions">

View File

@ -0,0 +1,76 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_employee_bridge_form_inherit_offer_letter" model="ir.ui.view">
<field name="name">recruitment.employee.bridge.form.inherit.offer.letter</field>
<field name="model">recruitment.employee.bridge</field>
<field name="inherit_id" ref="employee_bridge.view_employee_bridge_form"/>
<field name="arch" type="xml">
<!-- Smart Button -->
<xpath expr="//div[@name='button_box']" position="inside">
<button
name="action_open_current_offer"
type="object"
class="oe_stat_button"
icon="fa-file-text-o"
invisible="not current_offer_letter_id">
<div class="o_field_widget o_stat_info">
<span class="o_stat_value">
<field name="offer_release_status"/>
</span>
<span class="o_stat_text">
Offer Letter
</span>
</div>
</button>
</xpath>
<!-- Offer Letter Page -->
<xpath expr="//notebook" position="inside">
<page string="Offer Letters" name="offer_letters">
<group>
<group>
<field name="finalized_ctc"/>
<field name="current_offer_letter_id" readonly="1"/>
<field name="offer_release_status" readonly="1"/>
</group>
<group>
<button
name="action_open_current_offer"
type="object"
string="Open Current Offer"
class="btn-primary"
invisible="not current_offer_letter_id"/>
</group>
</group>
<field name="offer_letter_ids" nolabel="1">
<list>
<field name="name"/>
<field name="main_candidate_name"/>
<field name="state" widget="badge"/>
<field name="salary"/>
<field name="request_date"/>
</list>
</field>
</page>
</xpath>
</field>
</record>
</odoo>

View File

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record model="ir.ui.view" id="hr_recruitment_stage_offer_request_form_extended">
<field name="name">hr.recruitment.stage.form.offer.request.extended</field>
<field name="model">hr.recruitment.stage</field>
<field name="inherit_id" ref="hr_recruitment.hr_recruitment_stage_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='fold']" position="after">
<field name="request_offer_release"/>
</xpath>
</field>
</record>
</odoo>

View File

@ -1,3 +1,2 @@
from . import applicant_offer_mail_wizard from . import applicant_offer_mail_wizard
from . import offer_release_request_wizard
from . import offer_letter_reject_wizard from . import offer_letter_reject_wizard

View File

@ -8,7 +8,7 @@ class ApplicantOfferMailWizard(models.TransientModel):
_name = 'applicant.offer.mail.wizard' _name = 'applicant.offer.mail.wizard'
_description = 'Applicant Offer Mail Wizard' _description = 'Applicant Offer Mail Wizard'
applicant_id = fields.Many2one('hr.applicant', string='Applicant', required=True, readonly=True) bridge_id = fields.Many2one('recruitment.employee.bridge', string='Onboarding', required=True, readonly=True)
offer_letter_id = fields.Many2one('offer.letter', string='Offer Letter', readonly=True) offer_letter_id = fields.Many2one('offer.letter', string='Offer Letter', readonly=True)
template_id = fields.Many2one('mail.template', string='Email Template', required=True, readonly=True) template_id = fields.Many2one('mail.template', string='Email Template', required=True, readonly=True)
generated_attachment_id = fields.Many2one('ir.attachment', string='Generated Offer Attachment', readonly=True) generated_attachment_id = fields.Many2one('ir.attachment', string='Generated Offer Attachment', readonly=True)
@ -44,12 +44,14 @@ class ApplicantOfferMailWizard(models.TransientModel):
def default_get(self, fields_list): def default_get(self, fields_list):
defaults = super().default_get(fields_list) defaults = super().default_get(fields_list)
offer_letter = self._get_offer_letter_from_context() offer_letter = self._get_offer_letter_from_context()
applicant = offer_letter.candidate_id if offer_letter else self._get_applicant() bridge = offer_letter.bridge_id if offer_letter else self._get_bridge()
if offer_letter and not bridge:
raise UserError(_("Link this offer letter to an onboarding record before sending it."))
template = self.env.ref('offer_letters.applicant_offer_email_template', raise_if_not_found=False) template = self.env.ref('offer_letters.applicant_offer_email_template', raise_if_not_found=False)
offer_letter = offer_letter or self._create_offer_letter(applicant) offer_letter = offer_letter or self._create_offer_letter(bridge)
defaults.update({ defaults.update({
'applicant_id': applicant.id, 'bridge_id': bridge.id,
'offer_letter_id': offer_letter.id, 'offer_letter_id': offer_letter.id,
'position': offer_letter.position, 'position': offer_letter.position,
'salary': offer_letter.salary, 'salary': offer_letter.salary,
@ -64,11 +66,13 @@ class ApplicantOfferMailWizard(models.TransientModel):
defaults.update(self._prepare_mail_defaults(template, offer_letter)) defaults.update(self._prepare_mail_defaults(template, offer_letter))
return defaults return defaults
def _get_applicant(self): def _get_bridge(self):
applicant = self.env['hr.applicant'].browse(self.env.context.get('active_id')) bridge = self.env['recruitment.employee.bridge'].browse(
if not applicant.exists(): self.env.context.get('default_bridge_id') or self.env.context.get('active_id')
raise UserError(_("The applicant does not exist or is not accessible.")) )
return applicant if not bridge.exists():
raise UserError(_("The onboarding record does not exist or is not accessible."))
return bridge
def _get_offer_letter_from_context(self): def _get_offer_letter_from_context(self):
if self.env.context.get('active_model') != 'offer.letter': if self.env.context.get('active_model') != 'offer.letter':
@ -78,29 +82,29 @@ class ApplicantOfferMailWizard(models.TransientModel):
raise UserError(_("The offer letter does not exist or is not accessible.")) raise UserError(_("The offer letter does not exist or is not accessible."))
return offer_letter return offer_letter
def _get_default_pay_structure(self, applicant): def _get_default_pay_structure(self, bridge):
company = applicant.company_id or self.env.company company = bridge.company_id or self.env.company
return self.env['hr.payroll.structure'].search([ return self.env['hr.payroll.structure'].search([
'|', '|',
('company_id', '=', company.id), ('company_id', '=', company.id),
('company_id', '=', False), ('company_id', '=', False),
], limit=1) ], limit=1)
def _get_default_manager(self, applicant): def _get_default_manager(self, bridge):
return applicant.user_id.employee_id or self.env.user.employee_id return self.env.user.employee_id
def _create_offer_letter(self, applicant): def _create_offer_letter(self, bridge):
pay_structure = self._get_default_pay_structure(applicant) pay_structure = self._get_default_pay_structure(bridge)
if not pay_structure: if not pay_structure:
raise UserError(_("Please configure at least one salary structure before sending an offer.")) raise UserError(_("Please configure at least one salary structure before sending an offer."))
offer_letter = self.env['offer.letter'].create({ offer_letter = self.env['offer.letter'].create({
'candidate_id': applicant.id, 'bridge_id': bridge.id,
'position': applicant.job_id.name or applicant.hr_job_recruitment.job_id.name or applicant.partner_name or applicant.display_name, 'position': bridge.job_id.name or bridge.employee_name or bridge.display_name,
'salary': applicant.finalized_ctc or applicant.salary_expected or applicant.current_ctc or 0.0, 'salary': bridge.finalized_ctc if 'finalized_ctc' in bridge._fields else 0.0,
'joining_date': fields.Date.today(), 'joining_date': fields.Date.today(),
'pay_struct_id': pay_structure.id, 'pay_struct_id': pay_structure.id,
'manager_id': self._get_default_manager(applicant).id, 'manager_id': self._get_default_manager(bridge).id,
}) })
offer_letter.get_paydetailed_lines() offer_letter.get_paydetailed_lines()
return offer_letter return offer_letter
@ -108,7 +112,7 @@ class ApplicantOfferMailWizard(models.TransientModel):
def _update_offer_letter(self): def _update_offer_letter(self):
self.ensure_one() self.ensure_one()
vals = { vals = {
'candidate_id': self.applicant_id.id, 'bridge_id': self.bridge_id.id,
'position': self.position, 'position': self.position,
'salary': self.salary, 'salary': self.salary,
'joining_date': self.joining_date, 'joining_date': self.joining_date,
@ -125,7 +129,7 @@ class ApplicantOfferMailWizard(models.TransientModel):
report = self.env.ref('offer_letters.hr_offer_letters_employee_print') report = self.env.ref('offer_letters.hr_offer_letters_employee_print')
from odoo import _ as translate from odoo import _ as translate
pdf_content, _ = report.sudo()._render_qweb_pdf(report, offer_letter.id) pdf_content, _ = report.sudo()._render_qweb_pdf(report, offer_letter.id)
attachment_name = translate('Offer Letter - %s.pdf') % (offer_letter.candidate_id.partner_name or offer_letter.name) attachment_name = translate('Offer Letter - %s.pdf') % (offer_letter.main_candidate_name or offer_letter.name)
return self.env['ir.attachment'].create({ return self.env['ir.attachment'].create({
'name': attachment_name, 'name': attachment_name,
'datas': base64.b64encode(pdf_content), 'datas': base64.b64encode(pdf_content),
@ -159,8 +163,8 @@ class ApplicantOfferMailWizard(models.TransientModel):
attachment_ids = (other_attachments | attachment).ids if attachment else other_attachments.ids attachment_ids = (other_attachments | attachment).ids if attachment else other_attachments.ids
return { return {
'email_from': generated_values.get('email_from') or offer_letter.candidate_id.user_id.email or self.env.user.email, 'email_from': generated_values.get('email_from') or self.env.user.email,
'email_to': generated_values.get('email_to') or offer_letter.candidate_id.email_from or '', 'email_to': generated_values.get('email_to') or offer_letter.recipient_email or '',
'email_cc': generated_values.get('email_cc', ''), 'email_cc': generated_values.get('email_cc', ''),
'email_subject': generated_values.get('subject', ''), 'email_subject': generated_values.get('subject', ''),
'email_body': generated_values.get('body_html', ''), 'email_body': generated_values.get('body_html', ''),

View File

@ -7,7 +7,7 @@
<field name="arch" type="xml"> <field name="arch" type="xml">
<form string="Send Offer"> <form string="Send Offer">
<group> <group>
<field name="applicant_id" readonly="1"/> <field name="bridge_id" readonly="1"/>
<field name="template_id" options="{'no_create': True}" readonly="1"/> <field name="template_id" options="{'no_create': True}" readonly="1"/>
<field name="generated_attachment_id" invisible="1"/> <field name="generated_attachment_id" invisible="1"/>
</group> </group>

View File

@ -1,118 +0,0 @@
from odoo import _, api, fields, models
from odoo.exceptions import UserError
class OfferReleaseRequestWizard(models.TransientModel):
_name = 'offer.release.request.wizard'
_description = 'Offer Release Request Wizard'
applicant_id = fields.Many2one('hr.applicant', string='Applicant', required=True, readonly=True)
position = fields.Char(string='Position', readonly=True)
finalized_ctc = fields.Float(string='Finalized CTC', required=True)
email_from = fields.Char(string='Email From', required=True)
email_to = fields.Char(string='Mail To', required=True)
email_cc = fields.Text(string='Mail CC')
email_subject = fields.Char(string='Subject', required=True)
email_body = fields.Html(
string='Mail Body',
render_engine='qweb',
render_options={'post_process': True},
prefetch=True,
translate=True,
sanitize='email_outgoing',
required=True,
)
@api.model
def default_get(self, fields_list):
defaults = super().default_get(fields_list)
applicant = self.env['hr.applicant'].browse(
self.env.context.get('default_applicant_id') or self.env.context.get('active_id')
)
if not applicant.exists():
raise UserError(_("The applicant does not exist or is not accessible."))
recruiter_name = applicant.user_id.name or self.env.user.name
candidate_name = applicant.partner_name or applicant.candidate_id.partner_name or applicant.display_name
position = applicant.job_id.name or applicant.hr_job_recruitment.job_id.name or applicant.display_name
finalized_ctc = applicant.finalized_ctc or applicant.salary_expected or applicant.current_ctc or 0.0
defaults.update({
'applicant_id': applicant.id,
'position': position,
'finalized_ctc': finalized_ctc,
'email_from': self.env.user.email or self.env.company.email or '',
'email_to': self._get_hr_email_to(),
'email_subject': _('Offer Release Request - %s') % candidate_name,
'email_body': (
f"<p>Dear HR Team,</p>"
f"<p>Please release the offer letter for <strong>{candidate_name}</strong>.</p>"
f"<p><strong>Position:</strong> {position}<br/>"
f"<strong>Finalized CTC:</strong> {finalized_ctc:.2f}<br/>"
f"<strong>Requested By:</strong> {recruiter_name}</p>"
f"<p>Please review the request and release the offer letter to the applicant.</p>"
),
})
return defaults
def _get_hr_email_to(self):
hr_id = self.env['ir.config_parameter'].sudo().get_param('requisitions.requisition_hr_id')
hr_manager_id = self.env['res.users'].sudo().browse(int(hr_id)) if hr_id else False
users = hr_manager_id
if not hr_id:
group = self.env.ref('hr.group_hr_manager')
users = self.env['res.users'].sudo().search([
('groups_id', 'in', group.ids),
('email', '!=', False),
])
emails = users.mapped('email')
return ','.join(emails) or self.env.company.email or ''
def _get_default_pay_structure(self, applicant):
company = applicant.company_id or self.env.company
return self.env['hr.payroll.structure'].sudo().search([
], limit=1)
def _get_default_manager(self, applicant):
return applicant.user_id.employee_id or self.env.user.employee_id
def action_submit_request(self):
self.ensure_one()
applicant = self.applicant_id
pay_structure = self._get_default_pay_structure(applicant)
if not pay_structure:
raise UserError(_("Please configure at least one salary structure before requesting an offer release."))
applicant.finalized_ctc = self.finalized_ctc
offer_letter = self.env['offer.letter'].create({
'candidate_id': applicant.id,
'position': self.position,
'salary': self.finalized_ctc,
'joining_date': fields.Date.today(),
'pay_struct_id': pay_structure.id,
'manager_id': self._get_default_manager(applicant).id,
'requested_by_id': self.env.user.id,
'request_date': fields.Datetime.now(),
'state': 'requested',
})
offer_letter.get_paydetailed_lines()
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
offer_url = f"{base_url}/web#id={offer_letter.id}&model=offer.letter&view_type=form"
body_html = (
f"{self.email_body}"
f"<p><a href=\"{offer_url}\">Review Offer Letter Request</a></p>"
)
mail = self.env['mail.mail'].sudo().create({
'email_from': self.email_from,
'email_to': self.email_to,
'email_cc': self.email_cc,
'subject': self.email_subject,
'body_html': body_html,
'auto_delete': False,
'model': 'offer.letter',
'res_id': offer_letter.id,
})
mail.sudo().send()
return {'type': 'ir.actions.act_window_close'}

View File

@ -1,30 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_offer_release_request_wizard_form" model="ir.ui.view">
<field name="name">offer.release.request.wizard.form</field>
<field name="model">offer.release.request.wizard</field>
<field name="arch" type="xml">
<form string="Request Offer Release">
<group>
<field name="applicant_id" readonly="1"/>
<field name="position" readonly="1"/>
<field name="finalized_ctc"/>
</group>
<group string="Email Details">
<field name="email_from" placeholder="Sender email"/>
<field name="email_to" placeholder="Recipient email(s)"/>
<field name="email_cc" placeholder="Comma-separated CC recipients"/>
<field name="email_subject" placeholder="Email subject"/>
</group>
<group string="Mail Body">
<field name="email_body" widget="html_mail" class="oe-bordered-editor"
options="{'codeview': true, 'dynamic_placeholder': true}"/>
</group>
<footer>
<button name="action_submit_request" type="object" string="Submit Request" class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@ -0,0 +1,3 @@
from . import models
from . import wizards
from .hooks import post_init_hook

View File

@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
{
"name": "Recruitment Onboarding",
"summary": "Bridge recruitment, JOD and employee onboarding without direct links",
"description": """
Keeps recruitment and employee records separated by routing onboarding,
JOD, validation and employee creation through a dedicated bridge master.
""",
"author": "FTPROTECH",
"website": "https://www.ftprotech.com",
"category": "Human Resources",
"version": "1.0",
"depends": [
"employee_bridge",
"offer_letters",
"hr_employee_extended",
"hr_recruitment_extended",
"hr_recruitment",
],
"data": [
"security/ir.model.access.csv",
"data.xml",
"views/stages.xml",
"views/recruitment_employee_bridge_views.xml",
"views/hr_applicant_views.xml",
"views/hr_candidate_views.xml",
"views/hr_applicant_offer_views.xml",
"wizards/offer_release_request_wizard.xml",
],
"license": "LGPL-3",
"application": False,
"installable": True,
"post_init_hook": "post_init_hook",
}

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo noupdate="1">
<record id="seq_recruitment_employee_bridge" model="ir.sequence">
<field name="name">Recruitment Onboarding</field>
<field name="code">recruitment.employee.bridge</field>
<field name="prefix">REB/</field>
<field name="padding">5</field>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@ -0,0 +1,103 @@
from odoo import api, SUPERUSER_ID
def post_init_hook(env):
"""Backfill bridge records for already hired recruitment records."""
env = api.Environment(env.cr, SUPERUSER_ID, {})
Bridge = env["recruitment.employee.bridge"].sudo().with_context(active_test=False)
Applicant = env["hr.applicant"].sudo().with_context(active_test=False)
Candidate = env["hr.candidate"].sudo().with_context(active_test=False)
def _bridge_vals(applicant=False, candidate=False, employee=False):
company = (
(employee and employee.company_id)
or (applicant and applicant.company_id)
or (candidate and candidate.company_id)
or env.company
)
return {
"applicant_id": applicant.id if applicant else False,
"candidate_id": candidate.id if candidate else False,
"employee_id": employee.id if employee else False,
"company_id": company.id,
"source": "recruitment",
"state": "draft",
}
def _sync_employee_applicant(employee, applicant):
if employee and applicant and "applicant_id" in employee._fields and not employee.applicant_id:
employee.write({"applicant_id": applicant.id})
def _get_or_create_bridge(applicant=False, candidate=False, employee=False):
bridge = Bridge
if employee:
bridge = Bridge.search([("employee_id", "=", employee.id)], limit=1, order="id desc")
if not bridge and applicant:
bridge = Bridge.search([("applicant_id", "=", applicant.id)], limit=1, order="id desc")
if not bridge and candidate:
bridge = Bridge.search([("candidate_id", "=", candidate.id)], limit=1, order="id desc")
vals = _bridge_vals(applicant, candidate, employee)
if bridge:
write_vals = {}
for field_name, value in vals.items():
if value and not bridge[field_name]:
write_vals[field_name] = value
if write_vals:
bridge.write(write_vals)
return bridge
return Bridge.create(vals)
applicants = Applicant.search([("employee_id", "!=", False)], order="id")
for applicant in applicants:
candidate = applicant.candidate_id
employee = applicant.employee_id
if not candidate or not employee:
continue
bridge = _get_or_create_bridge(applicant=applicant, candidate=candidate, employee=employee)
if candidate.employee_id != employee:
candidate.write({"employee_id": employee.id})
_sync_employee_applicant(employee, bridge.applicant_id)
candidates = Candidate.search([("employee_id", "!=", False)], order="id")
for candidate in candidates:
employee = candidate.employee_id
applicant = candidate.applicant_ids[:1]
bridge = _get_or_create_bridge(applicant=applicant, candidate=candidate, employee=employee)
_sync_employee_applicant(employee, bridge.applicant_id)
env.cr.execute("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'offer_letter'
AND column_name IN ('bridge_id', 'candidate_id', 'main_candidate_id', 'salary')
""")
offer_columns = {row[0] for row in env.cr.fetchall()}
if "bridge_id" in offer_columns and "candidate_id" in offer_columns:
env.cr.execute("""
UPDATE offer_letter offer
SET bridge_id = bridge.id
FROM recruitment_employee_bridge bridge
WHERE offer.bridge_id IS NULL
AND offer.candidate_id IS NOT NULL
AND bridge.applicant_id = offer.candidate_id
""")
if "bridge_id" in offer_columns and "main_candidate_id" in offer_columns:
env.cr.execute("""
UPDATE offer_letter offer
SET bridge_id = bridge.id
FROM recruitment_employee_bridge bridge
WHERE offer.bridge_id IS NULL
AND offer.main_candidate_id IS NOT NULL
AND bridge.candidate_id = offer.main_candidate_id
""")
if {"bridge_id", "salary"}.issubset(offer_columns):
env.cr.execute("""
UPDATE recruitment_employee_bridge bridge
SET finalized_ctc = offer.salary
FROM offer_letter offer
WHERE offer.bridge_id = bridge.id
AND COALESCE(bridge.finalized_ctc, 0) = 0
AND COALESCE(offer.salary, 0) != 0
""")

View File

@ -0,0 +1,6 @@
from . import recruitment_employee_bridge
from . import hr_applicant
from . import hr_candidate
from . import hr_employee
from . import offer_recruitment
from . import stages

View File

@ -0,0 +1,269 @@
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
class HrApplicant(models.Model):
_inherit = "hr.applicant"
bridge_ids = fields.One2many("recruitment.employee.bridge", "applicant_id", string="Onboarding Records")
bridge_count = fields.Integer(compute="_compute_bridge_count")
bridge_employee_id = fields.Many2one(
"hr.employee",
compute="_compute_bridge_employee",
string="Onboarding Employee",
)
bridge_employee_name = fields.Char(related="bridge_employee_id.name", string="Employee")
bridge_employee_code = fields.Char(related="bridge_employee_id.employee_id", string="Employee Code")
def _compute_bridge_count(self):
data = self.env["recruitment.employee.bridge"].read_group(
[("applicant_id", "in", self.ids), ("active", "=", True)],
["applicant_id"],
["applicant_id"],
)
counts = {item["applicant_id"][0]: item["applicant_id_count"] for item in data}
for applicant in self:
applicant.bridge_count = counts.get(applicant.id, 0)
def _compute_bridge_employee(self):
bridges = self.env["recruitment.employee.bridge"].search(
[("applicant_id", "in", self.ids), ("active", "=", True)],
order="id desc",
)
bridge_by_applicant = {}
for bridge in bridges:
bridge_by_applicant.setdefault(bridge.applicant_id.id, bridge)
for applicant in self:
bridge = bridge_by_applicant.get(applicant.id)
applicant.bridge_employee_id = bridge.employee_id if bridge else False
def _get_bridge(self, create=False, employee=False, source="recruitment"):
self.ensure_one()
vals = self._get_recruitment_bridge_vals(source=source)
bridge = self.env["recruitment.employee.bridge"].search(
['|','|',('work_email','=',vals['work_email']),('work_phone','=',vals['work_phone']),("applicant_id", "=", self.id), ("active", "=", True)],
limit=1,
order="id desc",
)
if not bridge and create:
vals.update({
"applicant_id": self.id,
"candidate_id": self.candidate_id.id,
})
if employee:
vals.update({
"employee_id": employee.id,
"company_id": employee.company_id.id or vals["company_id"],
"state": "draft",
})
bridge = self.env["recruitment.employee.bridge"].create(vals)
return bridge
def _get_recruitment_bridge_vals(self, source="recruitment"):
self.ensure_one()
return {
"employee_name": self.partner_name or self.candidate_id.partner_name,
"work_email": self.email_from or self.candidate_id.email_from,
"work_phone": self.partner_phone or self.candidate_id.partner_phone,
"job_id": self.job_id.id,
"company_id": self.company_id.id or self.candidate_id.company_id.id or self.env.company.id,
"source": source,
"state": "draft",
"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_no": self.passport_no,
"passport_start_date": self.passport_start_date,
"passport_end_date": self.passport_end_date,
"passport_issued_location": self.passport_issued_location,
}
def _get_bridge_employee_required(self):
self.ensure_one()
bridge = self._get_bridge()
if not bridge or not bridge.employee_id:
raise ValidationError(_("Create or select the bridge employee before validating this section."))
return bridge.employee_id
def create_employee_from_applicant(self):
self.ensure_one()
self.candidate_id._check_interviewer_access()
bridge = self._get_bridge(create=True, source="recruitment")
return {
"name": _("Onboarding"),
"type": "ir.actions.act_window",
"res_model": "recruitment.employee.bridge",
"view_mode": "form",
"res_id": bridge.id,
}
def action_open_bridge_employee(self):
self.ensure_one()
employee = self._get_bridge_employee_required()
return {
"name": _("Employee"),
"type": "ir.actions.act_window",
"res_model": "hr.employee",
"view_mode": "form",
"res_id": employee.id,
}
def action_open_bridge(self):
self.ensure_one()
bridge = self._get_bridge()
if not bridge:
raise ValidationError(_("No bridge found for this applicant."))
return {
"name": _("Onboarding"),
"type": "ir.actions.act_window",
"res_model": "recruitment.employee.bridge",
"view_mode": "form",
"res_id": bridge.id,
}
def action_validate_personal_details(self):
for rec in self:
employee = rec._get_bridge_employee_required()
vals = {
"doj": rec.doj,
"gender": rec.gender,
"birthday": rec.birthday,
"blood_group": rec.blood_group,
"marital": rec.marital,
"marriage_anniversary_date": rec.marriage_anniversary_date,
"image_1920": rec.candidate_image,
}
vals = {key: value for key, value in vals.items() if value not in ["", False, 0]}
if not vals:
raise ValidationError(_("No values to validate"))
employee.write(vals)
rec.personal_details_status = "validated"
def action_validate_contact_details(self):
for rec in self:
employee = rec._get_bridge_employee_required()
vals = {
"private_street": rec.private_street,
"private_street2": rec.private_street2,
"private_city": rec.private_city,
"private_state_id": rec.private_state_id.id,
"private_zip": rec.private_zip,
"private_country_id": rec.private_country_id.id,
"permanent_street": rec.permanent_street,
"permanent_street2": rec.permanent_street2,
"permanent_city": rec.permanent_city,
"permanent_state_id": rec.permanent_state_id.id,
"permanent_zip": rec.permanent_zip,
"permanent_country_id": rec.permanent_country_id.id,
}
vals = {key: value for key, value in vals.items() if value not in ["", False, 0]}
if not vals:
raise ValidationError(_("No values to validate"))
employee.write(vals)
rec.contact_details_status = "validated"
def action_validate_bank_details(self):
for rec in self:
employee = rec._get_bridge_employee_required()
if not all([rec.full_name_as_in_bank, rec.bank_name, rec.bank_branch, rec.bank_account_no, rec.bank_ifsc_code]):
raise ValidationError(_("Please Provide all the Bank Related Details"))
account_no = self.env["res.partner.bank"].sudo().search([("acc_number", "=", rec.bank_account_no)], limit=1)
if account_no:
employee.bank_account_id = account_no.id
else:
bank = self.env["res.bank"].sudo().search([("bic", "=", rec.bank_ifsc_code)], limit=1)
if not bank:
bank = self.env["res.bank"].sudo().create({
"name": rec.bank_name,
"bic": rec.bank_ifsc_code,
"branch": rec.bank_branch,
})
partner = employee.work_contact_id or employee.user_id.partner_id
partner_bank = rec.env["res.partner.bank"].sudo().create({
"acc_number": rec.bank_account_no,
"bank_id": bank.id,
"full_name": rec.full_name_as_in_bank,
"partner_id": partner.id,
})
employee.bank_account_id = partner_bank.id
rec.bank_details_status = "validated"
def action_validate_passport_details(self):
for rec in self:
employee = rec._get_bridge_employee_required()
vals = {
"passport_id": rec.passport_no,
"passport_start_date": rec.passport_start_date,
"passport_end_date": rec.passport_end_date,
"passport_issued_location": rec.passport_issued_location,
}
vals = {key: value for key, value in vals.items() if value not in ["", False, 0]}
if not vals:
raise ValidationError(_("No values to validate"))
employee.write(vals)
rec.passport_details_status = "validated"
def action_validate_authentication_details(self):
for rec in self:
employee = rec._get_bridge_employee_required()
vals = {
"pan_no": rec.pan_no,
"identification_id": rec.identification_id,
"previous_company_pf_no": rec.previous_company_pf_no,
"previous_company_uan_no": rec.previous_company_uan_no,
}
vals = {key: value for key, value in vals.items() if value not in ["", False, 0]}
if not vals:
raise ValidationError(_("No values to validate"))
employee.write(vals)
rec.authentication_details_status = "validated"
def action_validate_family_education_employer_details(self):
for rec in self:
employee = rec._get_bridge_employee_required()
if not (rec.education_history or rec.employer_history or rec.family_details):
raise ValidationError(_("No Data to Validate"))
rec.education_history.write({"employee_id": employee.id})
rec.employer_history.write({"employee_id": employee.id})
rec.family_details.write({"employee_id": employee.id})
rec.family_education_employer_details_status = "validated"
bridge = rec._get_bridge()
if bridge:
bridge.state = "validated"
def action_validate_attachments(self):
for rec in self:
employee = rec._get_bridge_employee_required()
if not rec.joining_attachment_ids:
raise ValidationError(_("No Data to Validate"))
rec.joining_attachment_ids.write({"employee_id": employee.id})
rec.attachments_validation_status = "validated"
def send_jod_form_to_employee(self):
for rec in self:
employee = rec._get_bridge_employee_required()
if not employee.employee_id:
raise ValidationError(_("Employee Code for the Employee (%s) is missing") % employee.name)
bridge = rec._get_bridge()
if bridge:
return bridge.action_send_jod_form()

View File

@ -0,0 +1,94 @@
from odoo import _, fields, models
from odoo.exceptions import UserError
class HrCandidate(models.Model):
_inherit = "hr.candidate"
bridge_ids = fields.One2many("recruitment.employee.bridge", "candidate_id", string="Onboarding Records")
bridge_count = fields.Integer(compute="_compute_bridge_count")
bridge_employee_id = fields.Many2one(
"hr.employee",
compute="_compute_bridge_employee",
string="Onboarding Employee",
)
bridge_employee_name = fields.Char(related="bridge_employee_id.name", string="Employee")
def _compute_bridge_count(self):
data = self.env["recruitment.employee.bridge"].read_group(
[("candidate_id", "in", self.ids), ("active", "=", True)],
["candidate_id"],
["candidate_id"],
)
counts = {item["candidate_id"][0]: item["candidate_id_count"] for item in data}
for candidate in self:
candidate.bridge_count = counts.get(candidate.id, 0)
def _compute_bridge_employee(self):
bridges = self.env["recruitment.employee.bridge"].search(
[("candidate_id", "in", self.ids), ("active", "=", True)],
order="id desc",
)
bridge_by_candidate = {}
for bridge in bridges:
bridge_by_candidate.setdefault(bridge.candidate_id.id, bridge)
for candidate in self:
candidate.bridge_employee_id = bridge_by_candidate.get(candidate.id).employee_id if bridge_by_candidate.get(candidate.id) else False
def _get_bridge_employee_create_vals(self):
self.ensure_one()
vals = dict(self._get_employee_create_vals())
vals.pop("candidate_id", None)
return vals
def create_employee_from_candidate(self):
self.ensure_one()
self._check_interviewer_access()
bridge = self.env["recruitment.employee.bridge"].search(
[("candidate_id", "=", self.id), ("active", "=", True)],
limit=1,
order="id desc",
)
if bridge:
return {
"name": _("Onboarding"),
"type": "ir.actions.act_window",
"res_model": "recruitment.employee.bridge",
"view_mode": "form",
"res_id": bridge.id,
}
bridge = self.env["recruitment.employee.bridge"].create({
"candidate_id": self.id,
"employee_name": self.partner_name,
"work_email": self.email_from,
"work_phone": self.partner_phone,
"company_id": self.company_id.id or self.env.company.id,
"source": "recruitment",
"state": "draft",
})
return {
"name": _("Onboarding"),
"type": "ir.actions.act_window",
"res_model": "recruitment.employee.bridge",
"view_mode": "form",
"res_id": bridge.id,
}
def action_open_bridge_employee(self):
self.ensure_one()
bridge = self.env["recruitment.employee.bridge"].search(
[("candidate_id", "=", self.id), ("active", "=", True)],
limit=1,
order="id desc",
)
if not bridge:
raise UserError(_("No bridge found."))
return {
"name": _("Onboarding"),
"type": "ir.actions.act_window",
"res_model": "recruitment.employee.bridge",
"view_mode": "form",
"res_id": bridge.id,
}

View File

@ -0,0 +1,47 @@
from odoo import models
class HrEmployee(models.Model):
_inherit = "hr.employee"
def _find_existing_recruitment_records_for_bridge(self):
self.ensure_one()
applicant = self.env["hr.applicant"].sudo()
candidate = self.env["hr.candidate"].sudo()
contact_domain = []
if self.work_phone:
contact_domain.append(("partner_phone", "=", self.work_phone))
if self.work_email:
contact_domain.append(("email_from", "=", self.work_email))
if not contact_domain:
return applicant, candidate
domain = contact_domain[0] if len(contact_domain) == 1 else ["|"] + contact_domain
applicant = applicant.search(domain, limit=1, order="id desc")
if applicant:
return applicant, applicant.candidate_id
candidate = candidate.search(domain, limit=1, order="id desc")
return candidate.applicant_ids[:1], candidate
def _get_or_create_employee_bridge(self):
bridge = super()._get_or_create_employee_bridge()
for employee in self:
if bridge.applicant_id or bridge.candidate_id:
continue
applicant, candidate = employee._find_existing_recruitment_records_for_bridge()
vals = {}
if applicant:
vals["applicant_id"] = applicant.id
vals["candidate_id"] = applicant.candidate_id.id
elif candidate:
vals["candidate_id"] = candidate.id
if vals:
bridge.write(vals)
return bridge
def send_jod_form_to_employee(self):
self.ensure_one()
bridge = self._get_or_create_employee_bridge()
return bridge.action_send_jod_form()

View File

@ -0,0 +1,82 @@
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.Many2many(
"offer.letter",
string="Offer Letters",
compute="_compute_offer_letters",
)
current_offer_letter_id = fields.Many2one(
"offer.letter",
string="Current Offer Letter",
compute="_compute_offer_letters",
)
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("bridge_ids.offer_letter_ids", "bridge_ids.offer_letter_ids.create_date", "bridge_ids.offer_letter_ids.state")
def _compute_offer_letters(self):
for applicant in self:
offers = applicant.bridge_ids.mapped("offer_letter_ids").sorted(
key=lambda offer: (offer.create_date or fields.Datetime.from_string("1970-01-01 00:00:00"), offer.id)
)
applicant.offer_letter_ids = offers
applicant.current_offer_letter_id = offers[-1] if offers else False
def action_request_offer_release(self):
self.ensure_one()
bridge = self._get_bridge(create=True, source="recruitment")
if self.finalized_ctc and "finalized_ctc" in bridge._fields:
bridge.finalized_ctc = self.finalized_ctc
return bridge.action_request_offer_release()
def action_send_offer(self):
return self.action_request_offer_release()
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("bridge_ids.offer_letter_ids", "bridge_ids.offer_letter_ids.create_date", "bridge_ids.offer_letter_ids.state")
def _compute_current_offer_letter(self):
for candidate in self:
offer_letters = candidate.bridge_ids.mapped("offer_letter_ids").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

View File

@ -0,0 +1,115 @@
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
class RecruitmentEmployeeBridge(models.Model):
_inherit = "recruitment.employee.bridge"
applicant_id = fields.Many2one(
"hr.applicant",
string="Applicant",
ondelete="restrict",
tracking=True,
index=True,
)
candidate_id = fields.Many2one(
"hr.candidate",
string="Candidate",
ondelete="restrict",
tracking=True,
index=True,
)
applicant_email = fields.Char(related="applicant_id.email_from", string="Applicant Email")
applicant_phone = fields.Char(related="applicant_id.partner_phone", string="Applicant Phone")
personal_details_status = fields.Selection(related="applicant_id.personal_details_status", readonly=True)
contact_details_status = fields.Selection(related="applicant_id.contact_details_status", readonly=True)
bank_details_status = fields.Selection(related="applicant_id.bank_details_status", readonly=True)
passport_details_status = fields.Selection(related="applicant_id.passport_details_status", readonly=True)
authentication_details_status = fields.Selection(related="applicant_id.authentication_details_status", readonly=True)
family_education_employer_details_status = fields.Selection(
related="applicant_id.family_education_employer_details_status",
readonly=True,
)
attachments_validation_status = fields.Selection(related="applicant_id.attachments_validation_status", readonly=True)
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get("applicant_id") and not vals.get("candidate_id"):
applicant = self.env["hr.applicant"].browse(vals["applicant_id"])
vals["candidate_id"] = applicant.candidate_id.id
if vals.get("candidate_id") and not vals.get("employee_id"):
candidate = self.env["hr.candidate"].browse(vals["candidate_id"])
vals["employee_id"] = candidate.employee_id.id
return super().create(vals_list)
def action_open_applicant(self):
self.ensure_one()
if not self.applicant_id:
raise ValidationError(_("No applicant is linked to this bridge."))
return {
"name": _("Applicant"),
"type": "ir.actions.act_window",
"res_model": "hr.applicant",
"view_mode": "form",
"res_id": self.applicant_id.id,
}
def action_open_candidate(self):
self.ensure_one()
if not self.candidate_id:
raise ValidationError(_("No candidate is linked to this bridge."))
return {
"name": _("Candidate"),
"type": "ir.actions.act_window",
"res_model": "hr.candidate",
"view_mode": "form",
"res_id": self.candidate_id.id,
}
def _get_applicant_required(self):
self.ensure_one()
if not self.applicant_id:
raise ValidationError(_("No applicant is linked to this bridge."))
return self.applicant_id
def action_validate_personal_details(self):
return self._get_applicant_required().action_validate_personal_details()
def action_validate_contact_details(self):
return self._get_applicant_required().action_validate_contact_details()
def action_validate_bank_details(self):
return self._get_applicant_required().action_validate_bank_details()
def action_validate_passport_details(self):
return self._get_applicant_required().action_validate_passport_details()
def action_validate_authentication_details(self):
return self._get_applicant_required().action_validate_authentication_details()
def action_validate_family_education_employer_details(self):
return self._get_applicant_required().action_validate_family_education_employer_details()
def action_validate_attachments(self):
return self._get_applicant_required().action_validate_attachments()
def action_validate_employee_details(self):
res = super().action_validate_employee_details()
for bridge in self.filtered(lambda item: item.employee_id and item.applicant_id and item.applicant_id.candidate_image):
bridge.employee_id.image_1920 = bridge.applicant_id.candidate_image
return res
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("recruitment_employee_bridge.view_offer_release_request_wizard_form").id,
"target": "new",
"context": {
"default_bridge_id": self.id,
},
}

View File

@ -1,6 +1,7 @@
from odoo import models, fields, api, _ from odoo import fields, models
class RecruitmentStage(models.Model): class RecruitmentStage(models.Model):
_inherit = 'hr.recruitment.stage' _inherit = "hr.recruitment.stage"
request_offer_release = fields.Boolean(string="Request Offer Release") request_offer_release = fields.Boolean(string="Request Offer Release")

View File

@ -0,0 +1,4 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_recruitment_employee_bridge_user,recruitment.employee.bridge.user,model_recruitment_employee_bridge,base.group_user,1,1,1,0
access_recruitment_employee_bridge_manager,recruitment.employee.bridge.manager,model_recruitment_employee_bridge,hr.group_hr_manager,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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_recruitment_employee_bridge_user recruitment.employee.bridge.user model_recruitment_employee_bridge base.group_user 1 1 1 0
3 access_recruitment_employee_bridge_manager recruitment.employee.bridge.manager model_recruitment_employee_bridge hr.group_hr_manager 1 1 1 1
4 access_offer_release_request_wizard offer.release.request.wizard.user model_offer_release_request_wizard base.group_user 1 1 1 1

View File

@ -18,6 +18,7 @@
</xpath> </xpath>
</field> </field>
</record> </record>
<record id="hr_applicant_view_form_offer_letters_inherit" model="ir.ui.view"> <record id="hr_applicant_view_form_offer_letters_inherit" model="ir.ui.view">
<field name="name">hr.applicant.view.form.offer.letters.inherit</field> <field name="name">hr.applicant.view.form.offer.letters.inherit</field>
<field name="model">hr.applicant</field> <field name="model">hr.applicant</field>
@ -35,8 +36,8 @@
<field name="current_offer_letter_id" readonly="1"/> <field name="current_offer_letter_id" readonly="1"/>
<field name="offer_release_status" widget="badge" readonly="1"/> <field name="offer_release_status" widget="badge" readonly="1"/>
</group> </group>
<field name="offer_letter_ids" nolabel="1"> <field name="offer_letter_ids" nolabel="1" options="{'no_create': True, 'no_open': True}">
<list> <list create="0" delete="0" no_open="True">
<field name="name"/> <field name="name"/>
<field name="requested_by_id"/> <field name="requested_by_id"/>
<field name="request_date"/> <field name="request_date"/>

View File

@ -0,0 +1,95 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="hr_applicant_view_form_bridge" model="ir.ui.view">
<field name="name">hr.applicant.view.form.bridge</field>
<field name="model">hr.applicant</field>
<field name="inherit_id" ref="hr_recruitment_extended.hr_applicant_view_form_inherit"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='employee_id']" position="after">
<field name="bridge_employee_id" invisible="1"/>
<field name="bridge_count" invisible="1"/>
</xpath>
<xpath expr="//button[@name='create_employee_from_applicant']" position="attributes">
<attribute name="invisible">bridge_count or not active or not date_closed or is_on_hold</attribute>
<attribute name="string">Create Onboarding</attribute>
</xpath>
<xpath expr="//button[@name='action_open_employee']" position="attributes">
<attribute name="invisible">1</attribute>
</xpath>
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_open_bridge_employee"
type="object"
class="oe_stat_button"
icon="fa-id-card-o"
groups="hr.group_hr_user"
invisible="not bridge_employee_id">
<div class="o_field_widget o_stat_info">
<span class="o_stat_value"><field name="bridge_employee_name" readonly="1"/></span>
<span class="o_stat_text">Onboarding Employee</span>
</div>
</button>
<button name="action_open_bridge"
type="object"
class="oe_stat_button"
icon="fa-link"
groups="hr.group_hr_user"
invisible="not bridge_count">
<field name="bridge_count" widget="statinfo" string="Onboarding"/>
</button>
</xpath>
<xpath expr="//field[@name='recruitment_stage_id']" position="attributes">
<attribute name="invisible">not active and not bridge_employee_id</attribute>
</xpath>
<xpath expr="//page[@name='Attachments']//button[@name='action_validate_attachments']" position="attributes">
<attribute name="invisible">1</attribute>
</xpath>
</field>
</record>
<record id="hr_applicant_additional_info_bridge" model="ir.ui.view">
<field name="name">hr.applicant.additional.info.bridge</field>
<field name="model">hr.applicant</field>
<field name="inherit_id" ref="hr_recruitment_extended.hr_applicant_view_form_additional_info_inherit"/>
<field name="arch" type="xml">
<xpath expr="//button[@name='action_validate_personal_details'][contains(@class, 'btn-success')]" position="attributes">
<attribute name="invisible">1</attribute>
</xpath>
<xpath expr="//button[@name='action_validate_personal_details'][contains(@class, 'btn-danger')]" position="attributes">
<attribute name="invisible">1</attribute>
</xpath>
<xpath expr="//button[@name='action_validate_contact_details'][contains(@class, 'btn-success')]" position="attributes">
<attribute name="invisible">1</attribute>
</xpath>
<xpath expr="//button[@name='action_validate_contact_details'][contains(@class, 'btn-danger')]" position="attributes">
<attribute name="invisible">1</attribute>
</xpath>
<xpath expr="//button[@name='action_validate_bank_details'][contains(@class, 'btn-success')]" position="attributes">
<attribute name="invisible">1</attribute>
</xpath>
<xpath expr="//button[@name='action_validate_bank_details'][contains(@class, 'btn-danger')]" position="attributes">
<attribute name="invisible">1</attribute>
</xpath>
<xpath expr="//button[@name='action_validate_passport_details'][contains(@class, 'btn-success')]" position="attributes">
<attribute name="invisible">1</attribute>
</xpath>
<xpath expr="//button[@name='action_validate_passport_details'][contains(@class, 'btn-danger')]" position="attributes">
<attribute name="invisible">1</attribute>
</xpath>
<xpath expr="//button[@name='action_validate_authentication_details'][contains(@class, 'btn-success')]" position="attributes">
<attribute name="invisible">1</attribute>
</xpath>
<xpath expr="//button[@name='action_validate_authentication_details'][contains(@class, 'btn-danger')]" position="attributes">
<attribute name="invisible">1</attribute>
</xpath>
<xpath expr="//button[@name='action_validate_family_education_employer_details'][contains(@class, 'btn-success')]" position="attributes">
<attribute name="invisible">1</attribute>
</xpath>
<xpath expr="//button[@name='action_validate_family_education_employer_details'][contains(@class, 'btn-danger')]" position="attributes">
<attribute name="invisible">1</attribute>
</xpath>
<xpath expr="//page[@name='additional_info']" position="attributes">
<attribute name="invisible">not bridge_employee_id</attribute>
</xpath>
</field>
</record>
</odoo>

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="hr_candidate_view_form_bridge" model="ir.ui.view">
<field name="name">hr.candidate.view.form.bridge</field>
<field name="model">hr.candidate</field>
<field name="inherit_id" ref="hr_recruitment.hr_candidate_view_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='employee_id']" position="after">
<field name="bridge_employee_id" invisible="1"/>
<field name="bridge_count" invisible="1"/>
</xpath>
<xpath expr="//button[@name='create_employee_from_candidate']" position="attributes">
<attribute name="invisible">bridge_count or not active</attribute>
<attribute name="string">Create Onboarding</attribute>
</xpath>
<xpath expr="//button[@name='action_open_employee']" position="attributes">
<attribute name="invisible">1</attribute>
</xpath>
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_open_bridge_employee"
type="object"
class="oe_stat_button"
icon="fa-link"
groups="hr.group_hr_user"
invisible="not bridge_count">
<field name="bridge_count" widget="statinfo" string="Onboarding"/>
</button>
</xpath>
</field>
</record>
</odoo>

View File

@ -0,0 +1,267 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_recruitment_employee_bridge_list" model="ir.ui.view">
<field name="name">recruitment.employee.bridge.list</field>
<field name="model">recruitment.employee.bridge</field>
<field name="arch" type="xml">
<list>
<field name="name"/>
<field name="employee_id"/>
<field name="employee_code"/>
<field name="joining_form_link" optional="show"/>
<field name="applicant_id"/>
<field name="candidate_id"/>
<field name="job_id"/>
<field name="source"/>
<field name="state" widget="badge"/>
<field name="company_id" groups="base.group_multi_company"/>
</list>
</field>
</record>
<record id="view_recruitment_employee_bridge_form" model="ir.ui.view">
<field name="name">recruitment.employee.bridge.form</field>
<field name="model">recruitment.employee.bridge</field>
<field name="arch" type="xml">
<form>
<header>
<button string="Send JOD Form" name="action_send_jod_form" type="object" class="btn-secondary" invisible="state == 'validated'"/>
<button string="Create Employee" name="action_create_employee" type="object" class="btn-secondary" invisible="employee_id"/>
<button string="Validate Into Employee" name="action_validate_employee_details" type="object" class="btn-primary" invisible="state == 'validated' or not employee_id"/>
<button string="Request Offer Release" name="action_request_offer_release" type="object" class="btn-primary" invisible="state != 'validated'"/>
<field name="state" widget="statusbar" statusbar_visible="draft,jod_sent,jod_received,validated"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_open_applicant" type="object" class="oe_stat_button" icon="fa-pencil" invisible="not applicant_id">
<div class="o_field_widget o_stat_info">
<span class="o_stat_text">Applicant</span>
</div>
</button>
<button name="action_open_candidate" type="object" class="oe_stat_button" icon="fa-user" invisible="not candidate_id">
<div class="o_field_widget o_stat_info">
<span class="o_stat_text">Candidate</span>
</div>
</button>
<button name="action_open_employee" type="object" class="oe_stat_button" icon="fa-id-card-o" invisible="not employee_id">
<div class="o_field_widget o_stat_info">
<span class="o_stat_value"><field name="employee_code" readonly="1"/></span>
<span class="o_stat_text">Employee</span>
</div>
</button>
<button name="action_open_current_offer" type="object" class="oe_stat_button" icon="fa-envelope-open-o" invisible="not current_offer_letter_id">
<field name="offer_release_status" widget="statinfo" string="Offer"/>
</button>
</div>
<group>
<group>
<field name="name"/>
<field name="source"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
<group>
<field name="employee_id"/>
<field name="employee_code"/>
<field name="joining_form_link" widget="url"/>
<field name="requested_attachment_ids" widget="many2many_tags"/>
<field name="finalized_ctc"/>
<field name="current_offer_letter_id" readonly="1"/>
<field name="offer_release_status" widget="badge" readonly="1"/>
<field name="applicant_id"/>
<field name="candidate_id"/>
<field name="job_id"/>
</group>
</group>
<notebook>
<page string="Validation" name="validation">
<group string="Personal Details">
<group>
<field name="personal_details_status" widget="badge"/>
<field name="doj"/>
<field name="gender"/>
<field name="birthday"/>
<field name="blood_group"/>
<field name="marital"/>
<field name="marriage_anniversary_date" invisible="marital == 'single'"/>
</group>
<group>
<button string="Validate Personal Details" name="action_validate_personal_details" type="object" class="btn-primary" invisible="not employee_id or not applicant_id"/>
</group>
</group>
<group string="Contact Details">
<group>
<field name="contact_details_status" widget="badge"/>
<label for="private_street" string="Current Address"/>
<div class="o_address_format">
<field name="private_street" placeholder="Street..." class="o_address_street"/>
<field name="private_street2" placeholder="Street 2..." class="o_address_street"/>
<field name="private_city" placeholder="City" class="o_address_city"/>
<field name="private_state_id" class="o_address_state" placeholder="State" options="{'no_open': True, 'no_quick_create': True}" context="{'default_country_id': private_country_id}"/>
<field name="private_zip" placeholder="ZIP" class="o_address_zip"/>
<field name="private_country_id" placeholder="Country" class="o_address_country" options="{'no_open': True, 'no_create': True}"/>
</div>
</group>
<group>
<button string="Validate Contact Details" name="action_validate_contact_details" type="object" class="btn-primary" invisible="not employee_id or not applicant_id"/>
<label for="permanent_street" string="Permanent Address"/>
<div class="o_address_format">
<field name="permanent_street" placeholder="Street..." class="o_address_street"/>
<field name="permanent_street2" placeholder="Street 2..." class="o_address_street"/>
<field name="permanent_city" placeholder="City" class="o_address_city"/>
<field name="permanent_state_id" class="o_address_state" placeholder="State" options="{'no_open': True, 'no_quick_create': True}" context="{'default_country_id': permanent_country_id}"/>
<field name="permanent_zip" placeholder="ZIP" class="o_address_zip"/>
<field name="permanent_country_id" placeholder="Country" class="o_address_country" options="{'no_open': True, 'no_create': True}"/>
</div>
</group>
</group>
<group string="Bank Details">
<group>
<field name="bank_details_status" widget="badge"/>
<field name="full_name_as_in_bank"/>
<field name="bank_name"/>
<field name="bank_branch"/>
<field name="bank_account_no"/>
<field name="bank_ifsc_code"/>
</group>
<group>
<button string="Validate Bank Details" name="action_validate_bank_details" type="object" class="btn-primary" invisible="not employee_id or not applicant_id"/>
</group>
</group>
<group string="Passport Details">
<group>
<field name="passport_details_status" widget="badge"/>
<field name="passport_no"/>
<field name="passport_start_date"/>
<field name="passport_end_date"/>
<field name="passport_issued_location"/>
</group>
<group>
<button string="Validate Passport Details" name="action_validate_passport_details" type="object" class="btn-primary" invisible="not employee_id or not applicant_id"/>
</group>
</group>
<group string="Authentication Details">
<group>
<field name="authentication_details_status" widget="badge"/>
<field name="pan_no"/>
<field name="identification_id"/>
<field name="previous_company_pf_no"/>
<field name="previous_company_uan_no"/>
</group>
<group>
<button string="Validate Authentication Details" name="action_validate_authentication_details" type="object" class="btn-primary" invisible="not employee_id or not applicant_id"/>
</group>
</group>
<group string="Onboarding Details">
<group>
<field name="family_education_employer_details_status" widget="badge"/>
<field name="attachments_validation_status" widget="badge"/>
</group>
<group>
<button string="Validate Education, Employer and Family" name="action_validate_family_education_employer_details" type="object" class="btn-primary" invisible="not employee_id or not applicant_id"/>
<button string="Validate Attachments" name="action_validate_attachments" type="object" class="btn-primary" invisible="not employee_id or not applicant_id"/>
</group>
</group>
</page>
<page string="Family Details" name="family_details">
<field name="family_details" nolabel="1">
<list editable="bottom">
<field name="relation_type"/>
<field name="name"/>
<field name="contact_no"/>
<field name="dob"/>
<field name="location"/>
</list>
</field>
</page>
<page string="Employer History" name="employer_history">
<field name="employer_history" nolabel="1">
<list>
<field name="company_name"/>
<field name="designation"/>
<field name="date_of_joining"/>
<field name="last_working_day"/>
<field name="ctc"/>
</list>
</field>
</page>
<page string="Education Details" name="education_history">
<field name="education_history" nolabel="1">
<list>
<field name="education_type"/>
<field name="name"/>
<field name="university"/>
<field name="start_year"/>
<field name="end_year"/>
<field name="marks_or_grade"/>
</list>
</field>
</page>
<page string="Attachments" name="attachments">
<field name="joining_attachment_ids" nolabel="1">
<list editable="bottom" default_group_by="requested_attachment_id" decoration-success="review_status == 'pass'" decoration-danger="review_status == 'fail'">
<field name="requested_attachment_id"/>
<field name="name"/>
<field name="attachment_type"/>
<field name="file" widget="binary" options="{'download': true}" filename="file_name"/>
<field name="file_name" optional="hide"/>
<button name="action_preview_file" type="object" string="Preview" class="oe_highlight" icon="fa-eye"/>
<field name="review_status"/>
<field name="review_comments" optional="hide"/>
</list>
</field>
</page>
<page string="Offer Letters" name="offer_letters">
<field name="offer_letter_ids" nolabel="1" options="{'no_create': True, 'no_open': True}">
<list create="0" delete="0" no_open="True">
<field name="name"/>
<field name="requested_by_id"/>
<field name="request_date"/>
<field name="salary"/>
<field name="state"/>
<field name="sent_date"/>
<field name="response_date"/>
</list>
</field>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="view_recruitment_employee_bridge_search" model="ir.ui.view">
<field name="name">recruitment.employee.bridge.search</field>
<field name="model">recruitment.employee.bridge</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="employee_id"/>
<field name="applicant_id"/>
<field name="candidate_id"/>
<field name="job_id"/>
<filter name="jod_sent" string="JOD Sent" domain="[('state', '=', 'jod_sent')]"/>
<filter name="jod_received" string="JOD Received" domain="[('state', '=', 'jod_received')]"/>
<filter name="validated" string="Validated" domain="[('state', '=', 'validated')]"/>
<group expand="0" string="Group By">
<filter name="group_state" string="Status" context="{'group_by': 'state'}"/>
<filter name="group_source" string="Source" context="{'group_by': 'source'}"/>
<filter name="group_job" string="Job" context="{'group_by': 'job_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_recruitment_employee_bridge" model="ir.actions.act_window">
<field name="name">Onboarding</field>
<field name="res_model">recruitment.employee.bridge</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_recruitment_employee_bridge"
name="Onboarding"
parent="hr_recruitment.menu_crm_case_categ0_act_job"
action="action_recruitment_employee_bridge"
sequence="4"
groups="hr_recruitment.group_hr_recruitment_user"/>
</odoo>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record model="ir.ui.view" id="hr_recruitment_stage_offer_request_form_extended">
<field name="name">hr.recruitment.stage.form.offer.request.extended</field>
<field name="model">hr.recruitment.stage</field>
<field name="inherit_id" ref="hr_recruitment.hr_recruitment_stage_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='fold']" position="after">
<field name="request_offer_release"/>
</xpath>
</field>
</record>
</odoo>

View File

@ -0,0 +1 @@
from . import offer_release_request_wizard

View File

@ -0,0 +1,114 @@
from odoo import _, api, fields, models
from odoo.exceptions import UserError
class OfferReleaseRequestWizard(models.TransientModel):
_name = "offer.release.request.wizard"
_description = "Offer Release Request Wizard"
bridge_id = fields.Many2one("recruitment.employee.bridge", string="Onboarding", required=True, readonly=True)
position = fields.Char(string="Position", readonly=True)
finalized_ctc = fields.Float(string="Finalized CTC", required=True)
email_from = fields.Char(string="Email From", required=True)
email_to = fields.Char(string="Mail To", required=True)
email_cc = fields.Text(string="Mail CC")
email_subject = fields.Char(string="Subject", required=True)
email_body = fields.Html(
string="Mail Body",
render_engine="qweb",
render_options={"post_process": True},
prefetch=True,
translate=True,
sanitize="email_outgoing",
required=True,
)
@api.model
def default_get(self, fields_list):
defaults = super().default_get(fields_list)
bridge = self.env["recruitment.employee.bridge"].browse(
self.env.context.get("default_bridge_id") or self.env.context.get("active_id")
)
if not bridge.exists():
raise UserError(_("The onboarding record does not exist or is not accessible."))
candidate_name = bridge.employee_name or bridge.employee_id.name or bridge.display_name
position = bridge.job_id.name or candidate_name
finalized_ctc = bridge.finalized_ctc or 0.0
defaults.update({
"bridge_id": bridge.id,
"position": position,
"finalized_ctc": finalized_ctc,
"email_from": self.env.user.email or self.env.company.email or "",
"email_to": self._get_hr_email_to(),
"email_subject": _("Offer Release Request - %s") % candidate_name,
"email_body": (
f"<p>Dear HR Team,</p>"
f"<p>Please release the offer letter for <strong>{candidate_name}</strong>.</p>"
f"<p><strong>Position:</strong> {position}<br/>"
f"<strong>Finalized CTC:</strong> {finalized_ctc:.2f}<br/>"
f"<strong>Requested By:</strong> {self.env.user.name}</p>"
f"<p>Please review the onboarding record and release the offer letter.</p>"
),
})
return defaults
def _get_hr_email_to(self):
hr_id = self.env["ir.config_parameter"].sudo().get_param("requisitions.requisition_hr_id")
users = self.env["res.users"].sudo().browse(int(hr_id)) if hr_id else self.env["res.users"]
if not users:
group = self.env.ref("hr.group_hr_manager")
users = self.env["res.users"].sudo().search([
("groups_id", "in", group.ids),
("email", "!=", False),
])
emails = users.mapped("email")
return ",".join(emails) or self.env.company.email or ""
def _get_default_pay_structure(self):
return self.env["hr.payroll.structure"].sudo().search([], limit=1)
def _get_default_manager(self):
return self.env.user.employee_id
def action_submit_request(self):
self.ensure_one()
bridge = self.bridge_id
pay_structure = self._get_default_pay_structure()
if not pay_structure:
raise UserError(_("Please configure at least one salary structure before requesting an offer release."))
bridge.finalized_ctc = self.finalized_ctc
offer_letter = self.env["offer.letter"].create({
"bridge_id": bridge.id,
"position": self.position,
"salary": self.finalized_ctc,
"joining_date": fields.Date.today(),
"pay_struct_id": pay_structure.id,
"manager_id": self._get_default_manager().id,
"requested_by_id": self.env.user.id,
"request_date": fields.Datetime.now(),
"state": "requested",
})
offer_letter.get_paydetailed_lines()
base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url")
offer_url = f"{base_url}/web#id={offer_letter.id}&model=offer.letter&view_type=form"
body_html = (
f"{self.email_body}"
f"<p><a href=\"{offer_url}\">Review Offer Letter Request</a></p>"
)
mail = self.env["mail.mail"].sudo().create({
"email_from": self.email_from,
"email_to": self.email_to,
"email_cc": self.email_cc,
"subject": self.email_subject,
"body_html": body_html,
"auto_delete": False,
"model": "offer.letter",
"res_id": offer_letter.id,
})
mail.sudo().send()
return {"type": "ir.actions.act_window_close"}

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_offer_release_request_wizard_form" model="ir.ui.view">
<field name="name">offer.release.request.wizard.form</field>
<field name="model">offer.release.request.wizard</field>
<field name="arch" type="xml">
<form string="Request Offer Release">
<group>
<field name="bridge_id" readonly="1"/>
<field name="position" readonly="1"/>
<field name="finalized_ctc"/>
</group>
<group string="Notification Email">
<field name="email_from" placeholder="Sender email"/>
<field name="email_to" placeholder="HR email"/>
<field name="email_cc" placeholder="CC emails"/>
<field name="email_subject"/>
<field name="email_body" widget="html"/>
</group>
<footer>
<button name="action_submit_request" string="Submit Request" type="object" class="btn-primary"/>
<button string="Cancel" special="cancel" class="btn-secondary"/>
</footer>
</form>
</field>
</record>
</odoo>