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.
- Extract ONLY what exists in text.
- FOR ANY DATES CHANGE FORMAT TO %Y-%m-%d
- For list fields, always return an array. Return [] when no explicit entries exist.
- For list fields that describe objects, return one object per real document entry, not a sentence blob.
- Do not skip education, employer, project, certification, or family sections if they appear later in the document.
FIELD RULES:
- If "skills" exists, extract only explicit technical skills written in the document.
@ -286,9 +283,6 @@ FIELD RULES:
- If "name" exists, prefer the full name at the top and exclude titles, companies, and addresses.
- If "phone" exists, return the most complete phone number found.
- If "experience" exists, return only clearly supported numeric values.
- If "education_history" exists, extract every education row/section with education_type, specialization, university, start_year, end_year, and marks_or_grade.
- If "employer_history" exists, extract every employer/project/work-experience entry with company_name, designation, date_of_joining, last_working_day, ctc, and work_description.
- If "family_details" exists, extract only explicitly written family members with relation_type, name, contact_no, dob, and location.
Schema:
{schema_text}
@ -307,7 +301,7 @@ Document:
"model": model,
"messages": [{"role": "user", "content": prompt}],
"temperature": 0,
"max_tokens": 4000,
"max_tokens": 1500,
}
try:
response = requests.post(endpoint, headers=headers, json=payload, timeout=90)

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',
'website': 'https://ftprotech.in',
'depends': ['hr', 'mail','hr_employee_extended','hr_recruitment_extended'],
'data': [
'data/data.xml',
'data/actions.xml',
'data/template.xml',
'views/emp_jod.xml',
],
'depends': ['hr', 'mail', 'hr_employee_extended', 'employee_bridge'],
'data': [
'data/actions.xml',
'data/template.xml',
],
'license': 'LGPL-3',
}

View File

@ -1,65 +1,22 @@
from odoo import http, _
from odoo.http import request
from odoo.addons.hr_recruitment_extended.controllers.controllers import website_hr_recruitment_applications
from odoo.http import content_disposition
import logging
from odoo.tools import misc
_logger = logging.getLogger(__name__)
class website_hr_recruitment_applications_extended(website_hr_recruitment_applications):
@http.route(['/SRIVYNPLATFORMS/JoiningForm/<int:applicant_id>'], type='http', auth="public",
website=True)
def post_onboarding_form(self, applicant_id, **kwargs):
"""Renders the website form for applicants to submit additional details."""
applicant = request.env['hr.applicant'].sudo().browse(applicant_id)
if not applicant.exists():
return request.not_found()
if applicant:
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)}"
from odoo import http
from odoo.http import request
from odoo.tools import misc
class EmployeeJodController(http.Controller):
@http.route("/download/employee_jod/<int:employee_id>", type="http", auth="user")
def download_employee_jod_form(self, employee_id, **kwargs):
employee = request.env["hr.employee"].sudo().browse(employee_id)
if not employee.exists():
return request.not_found()
template = request.env.ref("employee_jod.emp_joining_form_template", raise_if_not_found=False)
if not template:
return "Error: Template not found"
return request.env["ir.qweb"]._render(template.id, {
"docs": employee,
"doc": employee,
"time": misc.datetime,
"user": request.env.user,
})

View File

@ -19,9 +19,8 @@
<field name="report_file">employee_jod.emp_joining_form_template</field>
<field name="binding_model_id" ref="hr.model_hr_employee"/>
<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>
</record>
<field name="binding_type">report</field>
</record>
</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>
</template>
<!-- <template id="thank_you_template_inherit" inherit_id="hr_recruitment_exteded.thank_you_template" name="Thank You Template Extended">-->
<!-- <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>
</odoo>

View File

@ -1,165 +1,8 @@
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class HRApplicant(models.Model):
_inherit = 'hr.applicant'
joining_form_link = fields.Char()
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')
from odoo import models
class HrEmployee(models.Model):
_inherit = "hr.employee"
def send_jod_form_to_employee(self):
return super().send_jod_form_to_employee()

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/sequence.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/job_category.xml',
'views/hr_location.xml',
@ -64,7 +67,7 @@
'web.assets_frontend': [
'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/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",
website=True)
@http.route([
'/SRIVYNPLATFORMS/JoiningForm/<int:applicant_id>',
'/FTPROTECH/JoiningForm/<int:applicant_id>',
], type='http', auth="public",
website=True)
def post_onboarding_form(self, applicant_id, **kwargs):
"""Renders the website form for applicants to submit additional details."""
applicant = request.env['hr.applicant'].sudo().browse(applicant_id)
@ -174,8 +177,11 @@ class website_hr_recruitment_applications(http.Controller):
return request.not_found()
@http.route(['/SRIVYNPLATFORMS/submit/<int:applicant_id>/JoinForm'], type='http', auth="public",
methods=['POST'], website=True, csrf=False)
@http.route([
'/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):
applicant = request.env['hr.applicant'].sudo().browse(applicant_id)
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)))
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_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', ''),
'doj': datetime.strptime(post.get('doj'), '%Y-%m-%d').date() if post.get('doj', None) else '',
'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.loads(attachments_data_json) if attachments_data_json else []
applicant.write(applicant_data)
applicant.replace_joining_attachments(attachments_data)
template = request.env.ref('hr_recruitment_extended.email_template_post_onboarding_form_user_submit',
raise_if_not_found=False)
applicant.write(applicant_data)
applicant.replace_joining_attachments(attachments_data)
if 'recruitment.employee.bridge' in request.env.registry:
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')
users = request.env['res.users'].sudo().search([
('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 hr_recruitment
from . import hr_job_recruitment

View File

@ -13,6 +13,7 @@ class CandidateExperience(models.Model):
experience_code = fields.Char('Experience Code')
experience_from = fields.Integer(string="Experience From (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")
# active = fields.Boolean()

View File

@ -462,6 +462,7 @@ class RecruitmentCategory(models.Model):
category_name = fields.Char(string="Category Name")
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):

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):
_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'))]"/>
</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="search" model="ir.model.data">
<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_from" required="1" placeholder="0" />
<field name="experience_to" required="1" placeholder="2" />
<field name="company_id"/>
</list>
</field>
</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">
<field name="category_name" required="1"/>
<field name="default_user"/>
<field name="company_id" required="1"/>
</list>
</field>
</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>
<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"/>

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 models

View File

@ -1,8 +1,8 @@
{
"name": "HRMS Employee Dashboard",
"name": "Dashboard",
"version": "18.0.1.0.0",
"category": "Human Resources",
"summary": "Employee self-service dashboard with attendance, leaves, expenses, equipment, and payslips",
"summary": "Employee, manager, and HR self-service dashboard",
"author": "Pranay",
"license": "LGPL-3",
"depends": [
@ -11,6 +11,12 @@
"hr",
"hr_attendance",
"hr_holidays",
"hr_expense",
"calendar",
"project_todo",
"hr_resignation",
"knowledge",
"website",
"maintenance",
"employee_it_declaration",
"business_travel_expense_management",
@ -18,6 +24,7 @@
],
"data": [
"views/hrms_emp_dashboard_views.xml",
"views/res_config_settings_views.xml",
],
"assets": {
"web.assets_backend": [

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 monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
const monthEnd = new Date(today.getFullYear(), today.getMonth() + 1, 0);
const weekStart = new Date(today);
weekStart.setDate(today.getDate() - ((today.getDay() + 6) % 7));
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekStart.getDate() + 6);
this.state = useState({
loading: true,
error: null,
data: null,
period: "this_month",
calendarView: "weekly",
calendarView: "monthly",
dateFrom: this.formatDate(monthStart),
dateTo: this.formatDate(monthEnd),
calendarDateFrom: this.formatDate(weekStart),
calendarDateTo: this.formatDate(weekEnd),
calendarDateFrom: this.formatDate(monthStart),
calendarDateTo: this.formatDate(monthEnd),
selectedDay: null,
activeTab: this.getStoredActiveTab(),
holidayTab: 'current',
leaveRequests: [],
leaveRequestsTotal: 0,
leaveRequestsLimit: 5,
});
this.rpc = rpc;
this.action = useService("action");
@ -42,7 +44,6 @@ class HrmsEmployeeDashboard extends Component {
});
});
onWillDestroy(() => this.destroyCharts());
}
formatDate(date) {
@ -52,6 +53,22 @@ class HrmsEmployeeDashboard extends Component {
return `${year}-${month}-${day}`;
}
getStoredActiveTab() {
try {
return window.localStorage.getItem("hrms_emp_dashboard.activeTab") || "employee";
} catch {
return "employee";
}
}
storeActiveTab(tab = this.state.activeTab) {
try {
window.localStorage.setItem("hrms_emp_dashboard.activeTab", tab || "employee");
} catch {
// Local storage can be unavailable in restricted browser contexts.
}
}
async loadData() {
this.state.loading = true;
this.state.error = null;
@ -63,17 +80,45 @@ class HrmsEmployeeDashboard extends Component {
calendar_date_to: this.state.calendarDateTo,
calendar_view: this.state.calendarView,
});
if (!response.success) {
if (!response.success) {
this.state.error = response.error || "Unable to load employee dashboard.";
return;
}
this.state.data = response;
if (!["employee", "manager", "hr"].includes(this.state.activeTab)) {
this.state.activeTab = "employee";
}
if (this.state.activeTab === "manager" && !response.access?.manager) {
this.state.activeTab = "employee";
}
if (this.state.activeTab === "hr" && !response.access?.hr) {
this.state.activeTab = response.access?.manager ? "manager" : "employee";
}
this.storeActiveTab();
// --- FILTER HOLIDAYS FOR TABS (Frontend Logic) ---
// If your backend doesn't separate them yet, we do it here based on 'date_from'
const today = new Date();
const currentMonth = today.getMonth();
const currentYear = today.getFullYear();
// If backend returns public_holidays[]
const allHolidays = response.public_holidays || [];
const yearlyHolidays = response.all_public_holidays || [];
// "This Month" tab = holidays within the selected period filter (backend already filters)
this.state.data.current_holidays = allHolidays;
// "Upcoming" tab = all yearly holidays strictly after today
this.state.data.upcoming_holidays = yearlyHolidays.filter(h => {
const hDate = new Date(h.date_from);
return hDate > today;
});
// -------------------------------------------------
this.loadLeaveRequests(true);
const expenses = response.expenses || {};
const total =
(expenses.series || []).reduce(
(sum, value) => sum + Number(value || 0),
0
);
const total = (expenses.series || []).reduce((sum, value) => sum + Number(value || 0), 0);
this.state.expenseCount = total;
setTimeout(() => this.initCharts(), 100);
} catch (error) {
@ -85,6 +130,30 @@ class HrmsEmployeeDashboard extends Component {
}
}
async loadLeaveRequests(reset = false) {
if (reset) {
this.state.leaveRequestsLimit = 5;
this.state.leaveRequests = [];
}
try {
const response = await this.rpc("/hrms_emp_dashboard/leave_requests", {
limit: this.state.leaveRequestsLimit,
offset: 0,
});
if (response.success) {
this.state.leaveRequests = response.leaves;
this.state.leaveRequestsTotal = response.total;
}
} catch (e) {
console.warn("Could not load leave requests:", e);
}
}
loadMoreLeaves() {
this.state.leaveRequestsLimit += 5;
this.loadLeaveRequests();
}
destroyCharts() {
for (const chart of this.charts) {
try {
@ -301,6 +370,170 @@ class HrmsEmployeeDashboard extends Component {
});
}
openDashboardMenu(menu) {
if (!menu || !menu.action) {
return;
}
this.storeActiveTab();
if (menu.action.xml_id) {
this.action.doAction(menu.action.xml_id);
return;
}
this.action.doAction(menu.action);
}
switchTab(tab) {
this.state.activeTab = tab;
this.storeActiveTab(tab);
setTimeout(() => this.initCharts(), 80);
}
openWorkAction(action) {
if (action) {
this.storeActiveTab();
this.action.doAction(action);
}
}
async runApproval(record, operation) {
try {
const response = await this.rpc("/hrms_emp_dashboard/approval_action", {
model: record.model,
record_id: record.id,
operation,
});
if (!response.success) {
this.notification.add(response.error || "Unable to update request", { type: "danger" });
return;
}
this.notification.add(response.message || "Request updated", { type: "success" });
await this.loadData();
} catch (error) {
console.error(error);
this.notification.add("Unable to update request", { type: "danger" });
}
}
createFromDashboardMenu(menu) {
if (!menu || !menu.create_action) {
return;
}
this.storeActiveTab();
this.action.doAction(menu.create_action);
}
runDayAction(action) {
const day = this.state.selectedDay;
if (!day || !action) {
return;
}
const handlers = {
apply_leave: () => this.applyLeaveForDate(day.date),
add_todo: () => this.addTodoForDate(day.date),
open_attendance: () => this.openAttendancesForDate(day.date),
open_calendar: () => this.openCalendarForDate(day.date, day.type),
create_meeting: () => this.createMeetingForDate(day.date),
};
const handler = handlers[action.key];
if (handler) {
handler();
this.closeDayDetails();
}
}
applyLeaveForDate(date) {
this.action.doAction({
type: "ir.actions.act_window",
name: "Apply Leave",
res_model: "hr.leave",
views: [[false, "form"]],
target: "new",
context: {
default_employee_id: this.state.data.employee.id,
default_request_date_from: date,
default_request_date_to: date,
},
});
}
addTodoForDate(date) {
this.action.doAction({
type: "ir.actions.act_window",
name: "Add To-do",
res_model: "project.task",
views: [[false, "form"]],
target: "new",
context: {
default_name: "To-do for " + date,
default_date_deadline: date + " 18:00:00",
default_user_ids: [[4, this.state.data.employee.user_id]],
},
});
}
openAttendancesForDate(date) {
this.action.doAction({
type: "ir.actions.act_window",
name: "Attendance - " + date,
res_model: "hr.attendance",
views: [[false, "list"], [false, "form"]],
domain: [
["employee_id", "=", this.state.data.employee.id],
["check_in", ">=", date + " 00:00:00"],
["check_in", "<=", date + " 23:59:59"],
],
target: "current",
});
}
openCalendarForDate(date, type = "day") {
const domain = [];
if (type !== "month") {
domain.push(["start", "<=", date + " 23:59:59"]);
domain.push(["stop", ">=", date + " 00:00:00"]);
}
this.action.doAction({
type: "ir.actions.act_window",
name: "Calendar",
res_model: "calendar.event",
views: [[false, "calendar"], [false, "list"], [false, "form"]],
domain,
target: "current",
context: {
default_start: date + " 09:00:00",
default_stop: date + " 10:00:00",
},
});
}
createMeetingFromCalendar() {
this.createMeetingForDate(this.state.calendarDateFrom || this.formatDate(new Date()));
}
createMeetingForDate(date) {
if (date && date.length === 7) {
date = date + "-01";
}
const start = date + " 09:00:00";
const stop = date + " 10:00:00";
const context = {
default_name: "Meeting",
default_start: start,
default_stop: stop,
};
if (this.state.data.employee.partner_id) {
context.default_partner_ids = [[4, this.state.data.employee.partner_id]];
}
this.action.doAction({
type: "ir.actions.act_window",
name: "New Meeting",
res_model: "calendar.event",
views: [[false, "form"]],
target: "new",
context,
});
}
// addExpense() {
// this.action.doAction({
// type: "ir.actions.act_window",
@ -418,6 +651,71 @@ class HrmsEmployeeDashboard extends Component {
});
}
buildDayDetails(day) {
if (!day || day.status === 'empty') return [];
const details = [];
if (day.worked_display) details.push({ label: "Worked", value: day.worked_display });
if (day.break_display) details.push({ label: "Break", value: day.break_display });
if (day.expected_display) details.push({ label: "Expected", value: day.expected_display });
if (day.balance_display) details.push({ label: "Balance", value: day.balance_display });
if (day.leave && parseFloat(day.leave) > 0) details.push({ label: "Leave", value: day.leave + " hrs" });
if (day.holiday && parseFloat(day.holiday) > 0) details.push({ label: "Holiday", value: day.holiday + " day(s)" });
if (day.event_count) details.push({ label: "Events", value: day.event_count });
if (day.date) details.push({ label: "Date", value: day.date });
return details;
}
async openDayDetails(day) {
if (!day || day.status === 'empty') return;
let fullDay = day;
let gotBackendSignals = false;
// Try backend first (for tasks, extra leaves, holidays)
if (day.date && day.type !== 'month') {
try {
const response = await this.rpc("/hrms_emp_dashboard/day_details", {
date: day.date,
});
if (response.success && response.day && response.day.signals && response.day.signals.length > 0) {
fullDay = { ...day, ...response.day };
gotBackendSignals = true;
}
} catch (e) {
console.warn("Backend details unavailable, using calendar data:", e);
}
}
let finalSignals;
if (gotBackendSignals) {
// Backend returned full signals — just strip any leftover "more"
finalSignals = (fullDay.signals || []).filter(s => s.type !== 'more');
} else {
// Rebuild ALL signals from day.events (contains every event, no truncation)
const existingSignals = fullDay.signals || [];
const nonEventSignals = existingSignals.filter(s => s.type !== 'more' && s.type !== 'event');
const eventSignals = (fullDay.events || []).map(event => ({
type: 'event',
label: event.display_time
? event.display_time + ' ' + event.name
: event.name,
icon: 'fa fa-users',
}));
finalSignals = [...nonEventSignals, ...eventSignals];
}
this.state.selectedDay = {
...fullDay,
signals: finalSignals,
details: this.buildDayDetails(fullDay),
};
}
closeDayDetails() {
this.state.selectedDay = null;
}
get statusText() {
return this.state.data?.attendance_state === "checked_in" ? "Check Out" : "Check In";
}

View File

@ -6,6 +6,27 @@
<div t-if="state.loading" class="hrms-loading">Loading employee dashboard...</div>
<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">
<div class="hrms-employee-main">
<img class="hrms-avatar" t-att-src="state.data.employee.image_url" alt="Employee"/>
@ -27,21 +48,15 @@
</div>
<div class="hrms-employee-actions">
<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
t-att-class="'hrms-icon-button ' + (state.data.attendance_state === 'checked_in' ? 'checkout' : 'checkin')"
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'"/>
<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 class="hrms-icon-button" t-on-click="downloadPayslip" title="Download Payslip">
<i class="fa fa-download"/>
<span>Payslip</span>
@ -53,7 +68,7 @@
</div>
<div class="hrms-filter-box">
<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_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>
@ -73,6 +88,34 @@
</div>
</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">
<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>
@ -84,14 +127,18 @@
</div>
</section>
<div class="hrms-grid">
<section class="hrms-panel wide">
<div class="hrms-employee-columns">
<div class="hrms-employee-main-column">
<section class="hrms-panel hrms-calendar-panel">
<div class="hrms-panel-header">
<h2>Attendance Calendar</h2>
<h2>Work Calendar</h2>
<div class="hrms-month-controls">
<button class="btn btn-light btn-sm" t-on-click="previousMonth"><i class="fa fa-chevron-left"/></button>
<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-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">
<option value="weekly" t-att-selected="state.calendarView === 'weekly'">Weekly</option>
<option value="monthly" t-att-selected="state.calendarView === 'monthly'">Monthly</option>
@ -109,10 +156,14 @@
<span>Sun</span>
</div>
<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">
<t t-if="day.status === 'empty'">
<span/>
</t>
<button t-foreach="state.data.attendance_calendar" t-as="day" t-key="day.date"
type="button"
class="hrms-day"
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'">
<div class="hrms-day-top">
<span t-esc="day.weekday"/>
@ -123,222 +174,418 @@
<small><t t-esc="day.worked_display"/> worked</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-if="day.event_count"><i class="fa fa-users me-1"/> <t t-esc="day.event_count"/> events</small>
</div>
</t>
<t t-else="">
<div class="hrms-day-top">
<strong>
<t t-esc="day.day"/>
<span t-esc="day.weekday"/>
</strong>
<span t-if="day.label and day.show_metrics" class="hrms-day-badge" t-esc="day.label"/>
<strong t-esc="day.day"/>
<span t-esc="day.weekday"/>
</div>
<div t-if="day.worked_display"
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 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-elif="day.status === 'holiday'" class="hrms-day-message-icon"><i class="fa fa-calendar"/></span>
<strong t-esc="day.label || '-'"/>
</div>
<div t-if="day.show_metrics" t-att-class="'hrms-day-balance ' + (day.balance_hours &gt;= 0 ? 'positive' : 'negative')">
<t t-esc="day.balance_display"/>
</div>
<div t-if="day.show_metrics" class="hrms-day-metrics">
<div>
<span>Wrk.</span>
<strong t-esc="day.worked_display"/>
</div>
<div>
<span>Break</span>
<strong t-esc="day.break_display"/>
</div>
<div>
<span>Exp.</span>
<strong t-esc="day.expected_display"/>
</div>
<div t-if="day.signals &amp;&amp; day.signals.length" class="hrms-day-signals">
<t t-foreach="day.signals" t-as="signal" t-key="signal.type + signal.label">
<button t-if="signal.type === 'more'"
type="button"
class="hrms-day-signal more hrms-more-btn"
t-on-click.stop="() => this.openDayDetails(day)"
t-att-title="'Click to view all'">
<i class="fa fa-ellipsis-h"/>
<t t-esc="signal.label"/>
</button>
<span t-else="" t-att-class="'hrms-day-signal ' + signal.type">
<i t-att-class="signal.icon"/>
<t t-esc="signal.label"/>
</span>
</t>
</div>
</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>
</div>
</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>
</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>
</t>
</templates>

View File

@ -1,13 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<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="target">current</field>
</record>
<menuitem id="menu_hrms_emp_dashboard_root"
name="Employee Dashboard"
name="Dashboard"
action="action_hrms_emp_dashboard"
web_icon="hrms_emp_dashboard,static/description/icon.png"
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',
'category': 'Human Resources',
'depends': ['base', 'hr_recruitment', 'hr_payroll', 'hr_recruitment_extended'],
'depends': ['base', 'employee_bridge', 'hr_payroll'],
'data': [
'security/ir.model.access.csv',
'data/mail_template.xml',
'views/stages.xml',
'views/offer_letter_views.xml',
'views/hr_applicant_offer_views.xml',
'views/offer_response_templates.xml',
'views/recruitment_employee_bridge.xml',
# 'views/templates.xml',
'views/menu_views.xml',
'wizards/offer_release_request_wizard.xml',
'wizards/applicant_offer_mail_wizard.xml',
'wizards/offer_letter_reject_wizard.xml',
'report/offer_letter_report.xml',

View File

@ -5,14 +5,14 @@
<field name="name">Applicant Offer Email Template</field>
<field name="model_id" ref="offer_letters.model_offer_letter"/>
<field name="email_from">{{ user.email_formatted }}</field>
<field name="email_to">{{ object.main_candidate_id.email_from or '' }}</field>
<field name="subject">Offer Letter - {{ object.position or object.main_candidate_id.job_id.name or '' }}</field>
<field name="email_to">{{ object.recipient_email or '' }}</field>
<field name="subject">Offer Letter - {{ object.position or '' }}</field>
<field name="description">
Send applicant offer mail with offer letter attachment.
</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.main_candidate_id.partner_name or ''"/>,</p>
<p>Dear <t t-esc="object.main_candidate_name or ''"/>,</p>
<p>
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

View File

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

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

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;">
<strong>To,</strong>
<br/>
<strong t-esc="o.main_candidate_id.partner_name"/>
<strong t-esc="o.main_candidate_name"/>
<br/>
<div t-if="o.candidate_id and o.candidate_id.private_street and o.candidate_id.private_city">
<t t-esc="o.candidate_id.private_street"/>
<div t-if="o.recipient_street and o.recipient_city">
<t t-esc="o.recipient_street"/>
<br/>
<t t-esc="o.candidate_id.private_street2" t-if="o.candidate_id.private_street2"/>
<t t-if="o.candidate_id.private_street2">
<t t-esc="o.recipient_street2" t-if="o.recipient_street2"/>
<t t-if="o.recipient_street2">
<br/>
</t>
<t t-esc="o.candidate_id.private_city"/>
<t t-esc="o.candidate_id.private_zip"/>
<t t-esc="o.recipient_city"/>
<t t-esc="o.recipient_zip"/>
<br/>
<t t-esc="o.candidate_id.private_country_id.name"/>
<t t-esc="o.recipient_country_id.name"/>
</div>
</div>
<!-- DEAR LINE -->
<div style="margin-bottom: 20px;">
<strong>Dear
<t t-esc="o.main_candidate_id.partner_name"/>,
<t t-esc="o.main_candidate_name"/>,
</strong>
</div>
@ -368,7 +368,7 @@
<br/>
<br/>
<br/>
<strong t-esc="o.main_candidate_id.partner_name"/>
<strong t-esc="o.main_candidate_name"/>
<br/>
<strong t-esc="o.position"/>
<br/>
@ -486,7 +486,7 @@
<br/>
<br/>
<strong>Employee Name :
<t t-esc="o.main_candidate_id.partner_name"/>
<t t-esc="o.main_candidate_name"/>
</strong>
<br/>
<br/>
@ -595,7 +595,7 @@
<br/>
<br/>
<br/>
<strong t-esc="o.main_candidate_id.partner_name"/>
<strong t-esc="o.main_candidate_name"/>
<br/>
<strong t-esc="o.position"/>
<br/>
@ -621,7 +621,7 @@
Company incorporated under Indian Companies Act 1956, having registered office in
Hyderabad,
India ("Company")
<strong t-esc="o.main_candidate_id.partner_name"/>
<strong t-esc="o.main_candidate_name"/>
(Recipient)
</p>
<p>Whereas "Company" wishes to explore the possibility of entering into an employment
@ -875,7 +875,7 @@
<strong t-esc="o.joining_date"/>
</td>
<td style="padding-top: 20px;">
<strong t-esc="o.main_candidate_id.partner_name"/>
<strong t-esc="o.main_candidate_name"/>
<br/>
<strong t-esc="o.position"/>
<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_manager,offer.letter.manager,model_offer_letter,hr.group_hr_manager,1,1,1,1
access_applicant_offer_mail_wizard,applicant.offer.mail.wizard.user,offer_letters.model_applicant_offer_mail_wizard,base.group_user,1,1,1,1
access_offer_release_request_wizard,offer.release.request.wizard.user,model_offer_release_request_wizard,base.group_user,1,1,1,1
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"
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_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="generate_pdf_report" type="object" string="Generate PDF" class="oe_highlight"/>
<field name="state" widget="statusbar" statusbar_visible="requested,sent,accepted,rejected,expired"/>
@ -32,9 +32,8 @@
<group>
<group>
<field name="name" readonly="state != 'requested'"/>
<field name="main_candidate_name" invisible="1"/>
<field name="candidate_id" invisible="not candidate_id" readonly="1" force_save="1"/>
<field name="main_candidate_id" invisible="candidate_id" force_save="1"/>
<field name="bridge_id"/>
<field name="main_candidate_name" readonly="1"/>
<field name="requested_by_id" readonly="1"/>
<field name="request_date" readonly="1"/>
<field name="manager_id"/>
@ -50,8 +49,9 @@
<field name="probation_period"/>
<field name="pay_struct_id"/>
<field name="sent_date" readonly="1"/>
<field name="response_date" readonly="1"/>
</group>
<field name="response_date" readonly="1"/>
<field name="recipient_email" readonly="1"/>
</group>
</group>
<notebook>
<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 offer_release_request_wizard
from . import offer_letter_reject_wizard

View File

@ -8,7 +8,7 @@ class ApplicantOfferMailWizard(models.TransientModel):
_name = '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)
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)
@ -44,12 +44,14 @@ class ApplicantOfferMailWizard(models.TransientModel):
def default_get(self, fields_list):
defaults = super().default_get(fields_list)
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)
offer_letter = offer_letter or self._create_offer_letter(applicant)
offer_letter = offer_letter or self._create_offer_letter(bridge)
defaults.update({
'applicant_id': applicant.id,
'bridge_id': bridge.id,
'offer_letter_id': offer_letter.id,
'position': offer_letter.position,
'salary': offer_letter.salary,
@ -64,11 +66,13 @@ class ApplicantOfferMailWizard(models.TransientModel):
defaults.update(self._prepare_mail_defaults(template, offer_letter))
return defaults
def _get_applicant(self):
applicant = self.env['hr.applicant'].browse(self.env.context.get('active_id'))
if not applicant.exists():
raise UserError(_("The applicant does not exist or is not accessible."))
return applicant
def _get_bridge(self):
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."))
return bridge
def _get_offer_letter_from_context(self):
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."))
return offer_letter
def _get_default_pay_structure(self, applicant):
company = applicant.company_id or self.env.company
def _get_default_pay_structure(self, bridge):
company = bridge.company_id or self.env.company
return self.env['hr.payroll.structure'].search([
'|',
('company_id', '=', company.id),
('company_id', '=', False),
], limit=1)
def _get_default_manager(self, applicant):
return applicant.user_id.employee_id or self.env.user.employee_id
def _get_default_manager(self, bridge):
return self.env.user.employee_id
def _create_offer_letter(self, applicant):
pay_structure = self._get_default_pay_structure(applicant)
def _create_offer_letter(self, bridge):
pay_structure = self._get_default_pay_structure(bridge)
if not pay_structure:
raise UserError(_("Please configure at least one salary structure before sending an offer."))
offer_letter = self.env['offer.letter'].create({
'candidate_id': applicant.id,
'position': applicant.job_id.name or applicant.hr_job_recruitment.job_id.name or applicant.partner_name or applicant.display_name,
'salary': applicant.finalized_ctc or applicant.salary_expected or applicant.current_ctc or 0.0,
'bridge_id': bridge.id,
'position': bridge.job_id.name or bridge.employee_name or bridge.display_name,
'salary': bridge.finalized_ctc if 'finalized_ctc' in bridge._fields else 0.0,
'joining_date': fields.Date.today(),
'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()
return offer_letter
@ -108,7 +112,7 @@ class ApplicantOfferMailWizard(models.TransientModel):
def _update_offer_letter(self):
self.ensure_one()
vals = {
'candidate_id': self.applicant_id.id,
'bridge_id': self.bridge_id.id,
'position': self.position,
'salary': self.salary,
'joining_date': self.joining_date,
@ -125,7 +129,7 @@ class ApplicantOfferMailWizard(models.TransientModel):
report = self.env.ref('offer_letters.hr_offer_letters_employee_print')
from odoo import _ as translate
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({
'name': attachment_name,
'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
return {
'email_from': generated_values.get('email_from') or offer_letter.candidate_id.user_id.email or self.env.user.email,
'email_to': generated_values.get('email_to') or offer_letter.candidate_id.email_from or '',
'email_from': generated_values.get('email_from') or self.env.user.email,
'email_to': generated_values.get('email_to') or offer_letter.recipient_email or '',
'email_cc': generated_values.get('email_cc', ''),
'email_subject': generated_values.get('subject', ''),
'email_body': generated_values.get('body_html', ''),

View File

@ -7,7 +7,7 @@
<field name="arch" type="xml">
<form string="Send Offer">
<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="generated_attachment_id" invisible="1"/>
</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):
_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>
</field>
</record>
<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="model">hr.applicant</field>
@ -35,8 +36,8 @@
<field name="current_offer_letter_id" readonly="1"/>
<field name="offer_release_status" widget="badge" readonly="1"/>
</group>
<field name="offer_letter_ids" nolabel="1">
<list>
<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"/>

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>