srivyn_test #32
|
|
@ -107,7 +107,8 @@ RUN if [ -f requirements.txt ]; then \
|
|||
pypdf \
|
||||
phonenumbers \
|
||||
python-docx \
|
||||
pyzk
|
||||
pyzk \
|
||||
firebase-admin
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Create Required Directories
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
from . import models
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
{
|
||||
'name': 'Base Changes',
|
||||
'category': 'Base',
|
||||
'version': '1.1',
|
||||
'author': 'Seshi Kanth',
|
||||
'summary': 'Changes of the form',
|
||||
'description': 'This module contains form changes.',
|
||||
'depends': [
|
||||
'base',
|
||||
'web',
|
||||
],
|
||||
|
||||
'data': [
|
||||
# 'views/user_menu.xml',
|
||||
],
|
||||
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'base_custom/static/src/css/backend.css',
|
||||
'base_custom/static/src/js/user_menu_patch.js',
|
||||
'base_custom/static/src/js/hide_user_menu.js',
|
||||
'base_custom/static/src/xml/user_menu.xml',
|
||||
],
|
||||
},
|
||||
|
||||
'installable': True,
|
||||
'application': True,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
from . import ir_http
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
from odoo import models
|
||||
|
||||
|
||||
class IrHttp(models.AbstractModel):
|
||||
_inherit = "ir.http"
|
||||
|
||||
def session_info(self):
|
||||
session_info = super().session_info()
|
||||
|
||||
employee = self.env.user.employee_id
|
||||
|
||||
session_info.update({
|
||||
"employee_name": employee.name or self.env.user.name,
|
||||
"employee_designation": employee.job_id.name if employee.job_id else "",
|
||||
})
|
||||
|
||||
return session_info
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
.o_switch_company_menu .oe_topbar_name{
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.oe_topbar_name{
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
line-height:1.2;
|
||||
}
|
||||
|
||||
.oe_topbar_name .fw-bold{
|
||||
font-size:13px;
|
||||
font-weight:600;
|
||||
}
|
||||
|
||||
.oe_topbar_name .designation{
|
||||
font-size:11px;
|
||||
color:#7b7b7b;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #000000 !important;
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
const userMenuRegistry = registry.category("user_menuitems");
|
||||
|
||||
const itemsToHide = [
|
||||
"documentation",
|
||||
"support",
|
||||
"shortcuts",
|
||||
"odoo_account",
|
||||
];
|
||||
|
||||
for (const item of itemsToHide) {
|
||||
if (userMenuRegistry.contains(item)) {
|
||||
userMenuRegistry.remove(item);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { UserMenu } from "@web/webclient/user_menu/user_menu";
|
||||
import { session } from "@web/session";
|
||||
|
||||
patch(UserMenu.prototype, {
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
|
||||
this.employeeName = session.employee_name;
|
||||
this.employeeDesignation = session.employee_designation;
|
||||
},
|
||||
|
||||
get employeeInfo() {
|
||||
return {
|
||||
employeeName: this.employeeName,
|
||||
employeeDesignation: this.employeeDesignation,
|
||||
};
|
||||
},
|
||||
|
||||
});
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-inherit="web.UserMenu"
|
||||
t-inherit-mode="extension">
|
||||
|
||||
<xpath expr="//small[contains(@class,'oe_topbar_name')]"
|
||||
position="replace">
|
||||
|
||||
<small class="oe_topbar_name d-none d-lg-inline-block ms-2 text-start"
|
||||
style="max-width:220px">
|
||||
|
||||
<div class="fw-bold text-truncate">
|
||||
<t t-esc="employeeInfo.employeeName"/>
|
||||
</div>
|
||||
|
||||
<div class="text-muted text-truncate designation">
|
||||
<t t-esc="employeeInfo.employeeDesignation"/>
|
||||
</div>
|
||||
|
||||
</small>
|
||||
|
||||
</xpath>
|
||||
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
from . import models
|
||||
from . import controllers
|
||||
from . import wizards
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
from . import main
|
||||
|
|
@ -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)])
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
|||
from . import recruitment_employee_bridge
|
||||
from . import hr_employee
|
||||
from . import onboarding_documents
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
@ -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;">
|
||||
×
|
||||
</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();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1 @@
|
|||
from . import employee_bridge_attachment_wizard
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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>
|
||||
|
|
@ -10,12 +10,10 @@
|
|||
""",
|
||||
'author': 'FTPROTECH',
|
||||
'website': 'https://ftprotech.in',
|
||||
'depends': ['hr', 'mail','hr_employee_extended','hr_recruitment_extended'],
|
||||
'depends': ['hr', 'mail', 'hr_employee_extended', 'employee_bridge'],
|
||||
'data': [
|
||||
'data/data.xml',
|
||||
'data/actions.xml',
|
||||
'data/template.xml',
|
||||
'views/emp_jod.xml',
|
||||
],
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,65 +1,22 @@
|
|||
from odoo import http, _
|
||||
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:
|
||||
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()
|
||||
|
||||
@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')
|
||||
template = request.env.ref("employee_jod.emp_joining_form_template", raise_if_not_found=False)
|
||||
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)}"
|
||||
return request.env["ir.qweb"]._render(template.id, {
|
||||
"docs": employee,
|
||||
"doc": employee,
|
||||
"time": misc.datetime,
|
||||
"user": request.env.user,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@
|
|||
<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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -1,165 +1,8 @@
|
|||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
from odoo import models
|
||||
|
||||
|
||||
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")
|
||||
class HrEmployee(models.Model):
|
||||
_inherit = "hr.employee"
|
||||
|
||||
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')
|
||||
return super().send_jod_form_to_employee()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
from odoo import api, fields, models, tools, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class AttendanceAnalytics(models.Model):
|
||||
|
|
@ -64,6 +65,8 @@ class AttendanceAnalytics(models.Model):
|
|||
|
||||
('absent', 'Absent'),
|
||||
|
||||
('invalid_attendance', 'Invalid Attendance'),
|
||||
|
||||
('half_day', 'Half Day'),
|
||||
|
||||
('late_in', 'Late In'),
|
||||
|
|
@ -192,7 +195,7 @@ class AttendanceAnalytics(models.Model):
|
|||
for rec in self:
|
||||
if rec.status == 'present':
|
||||
rec.color = 10
|
||||
elif rec.status == 'absent':
|
||||
elif rec.status in ('absent', 'invalid_attendance'):
|
||||
rec.color = 1
|
||||
elif rec.status == 'half_day':
|
||||
rec.color = 2
|
||||
|
|
@ -280,6 +283,11 @@ class AttendanceAnalytics(models.Model):
|
|||
def action_create_shiftswap_request(self):
|
||||
self.ensure_one()
|
||||
|
||||
if self.date and self.date < fields.Date.context_today(self):
|
||||
raise UserError(
|
||||
_("Shift Swap cannot be requested for past dates.")
|
||||
)
|
||||
|
||||
request = self.env['shift.swap.request'].search([
|
||||
('employee_id', '=', self.employee_id.id),
|
||||
('roster_date', '=', self.date),
|
||||
|
|
@ -324,6 +332,7 @@ class AttendanceAnalytics(models.Model):
|
|||
emp.department_id,
|
||||
emp.resource_calendar_id,
|
||||
emp.attendance_mode,
|
||||
emp.work_mode,
|
||||
generate_series(
|
||||
DATE(emp.create_date),
|
||||
CURRENT_DATE,
|
||||
|
|
@ -340,7 +349,11 @@ class AttendanceAnalytics(models.Model):
|
|||
|
||||
att.employee_id,
|
||||
|
||||
DATE(att.check_in)
|
||||
DATE(
|
||||
att.check_in
|
||||
AT TIME ZONE 'UTC'
|
||||
AT TIME ZONE 'Asia/Kolkata'
|
||||
)
|
||||
AS attendance_date,
|
||||
|
||||
MIN(att.check_in)
|
||||
|
|
@ -349,6 +362,18 @@ class AttendanceAnalytics(models.Model):
|
|||
MAX(att.check_out)
|
||||
AS max_check_out,
|
||||
|
||||
MIN(
|
||||
att.check_in
|
||||
AT TIME ZONE 'UTC'
|
||||
AT TIME ZONE 'Asia/Kolkata'
|
||||
) AS min_check_in_local,
|
||||
|
||||
MAX(
|
||||
att.check_out
|
||||
AT TIME ZONE 'UTC'
|
||||
AT TIME ZONE 'Asia/Kolkata'
|
||||
) AS max_check_out_local,
|
||||
|
||||
SUM(att.worked_hours)
|
||||
AS worked_hours
|
||||
|
||||
|
|
@ -358,7 +383,11 @@ class AttendanceAnalytics(models.Model):
|
|||
|
||||
att.employee_id,
|
||||
|
||||
DATE(att.check_in)
|
||||
DATE(
|
||||
att.check_in
|
||||
AT TIME ZONE 'UTC'
|
||||
AT TIME ZONE 'Asia/Kolkata'
|
||||
)
|
||||
|
||||
),
|
||||
|
||||
|
|
@ -431,6 +460,32 @@ class AttendanceAnalytics(models.Model):
|
|||
WHERE rl.date_from IS NOT NULL
|
||||
AND rl.date_to IS NOT NULL
|
||||
AND rl.resource_id IS NULL
|
||||
),
|
||||
|
||||
calendar_day_schedule AS (
|
||||
|
||||
SELECT
|
||||
|
||||
rca.calendar_id,
|
||||
|
||||
rca.dayofweek,
|
||||
|
||||
MIN(rca.hour_from) * 60
|
||||
AS shift_start_minutes,
|
||||
|
||||
MAX(rca.hour_to) * 60
|
||||
AS shift_end_minutes,
|
||||
|
||||
SUM(rca.hour_to - rca.hour_from)
|
||||
AS hours_per_day
|
||||
|
||||
FROM resource_calendar_attendance rca
|
||||
|
||||
GROUP BY
|
||||
|
||||
rca.calendar_id,
|
||||
|
||||
rca.dayofweek
|
||||
)
|
||||
|
||||
SELECT
|
||||
|
|
@ -438,6 +493,7 @@ class AttendanceAnalytics(models.Model):
|
|||
ed.employee_id,
|
||||
ed.department_id,
|
||||
ed.attendance_mode AS attendance_mode,
|
||||
ed.work_mode,
|
||||
rc.id AS shift_id,
|
||||
rc.name AS shift_name,
|
||||
ed.date,
|
||||
|
|
@ -448,20 +504,20 @@ class AttendanceAnalytics(models.Model):
|
|||
ats.worked_hours,
|
||||
0
|
||||
) AS worked_hours,
|
||||
rc.hours_per_day AS hours_per_day,
|
||||
es.hours_per_day AS hours_per_day,
|
||||
|
||||
(
|
||||
rc.hours_per_day
|
||||
es.hours_per_day
|
||||
+
|
||||
COALESCE(rc.over_time_hrs, 0)
|
||||
) AS allowed_ot_limit,
|
||||
|
||||
CASE
|
||||
|
||||
WHEN ats.worked_hours > rc.hours_per_day
|
||||
WHEN ats.worked_hours > es.hours_per_day
|
||||
|
||||
THEN
|
||||
ats.worked_hours - rc.hours_per_day
|
||||
ats.worked_hours - es.hours_per_day
|
||||
|
||||
ELSE 0
|
||||
|
||||
|
|
@ -472,7 +528,7 @@ class AttendanceAnalytics(models.Model):
|
|||
WHEN ats.worked_hours >
|
||||
|
||||
(
|
||||
rc.hours_per_day
|
||||
es.hours_per_day
|
||||
+
|
||||
COALESCE(rc.over_time_hrs, 0)
|
||||
)
|
||||
|
|
@ -525,19 +581,11 @@ class AttendanceAnalytics(models.Model):
|
|||
AS department_grace_period,
|
||||
|
||||
(
|
||||
(
|
||||
rc.shift_start_time * 60
|
||||
)
|
||||
+
|
||||
COALESCE(
|
||||
dg.grace_period,
|
||||
rc.late_grace_period,
|
||||
0
|
||||
)
|
||||
es.shift_start_minutes
|
||||
) AS expected_check_in,
|
||||
|
||||
(
|
||||
rc.shift_end_time * 60
|
||||
es.shift_end_minutes
|
||||
) AS expected_check_out,
|
||||
|
||||
CASE
|
||||
|
|
@ -549,28 +597,20 @@ class AttendanceAnalytics(models.Model):
|
|||
(
|
||||
(
|
||||
EXTRACT(
|
||||
HOUR FROM ats.min_check_in
|
||||
HOUR FROM ats.min_check_in_local
|
||||
) * 60
|
||||
)
|
||||
+
|
||||
EXTRACT(
|
||||
MINUTE
|
||||
FROM ats.min_check_in
|
||||
FROM ats.min_check_in_local
|
||||
)
|
||||
)
|
||||
|
||||
-
|
||||
|
||||
(
|
||||
(
|
||||
rc.shift_start_time * 60
|
||||
)
|
||||
+
|
||||
COALESCE(
|
||||
dg.grace_period,
|
||||
rc.late_grace_period,
|
||||
0
|
||||
)
|
||||
es.shift_start_minutes
|
||||
),
|
||||
|
||||
0
|
||||
|
|
@ -589,28 +629,20 @@ class AttendanceAnalytics(models.Model):
|
|||
(
|
||||
(
|
||||
EXTRACT(
|
||||
HOUR FROM ats.min_check_in
|
||||
HOUR FROM ats.min_check_in_local
|
||||
) * 60
|
||||
)
|
||||
+
|
||||
EXTRACT(
|
||||
MINUTE
|
||||
FROM ats.min_check_in
|
||||
FROM ats.min_check_in_local
|
||||
)
|
||||
)
|
||||
|
||||
>
|
||||
|
||||
(
|
||||
(
|
||||
rc.shift_start_time * 60
|
||||
)
|
||||
+
|
||||
COALESCE(
|
||||
dg.grace_period,
|
||||
rc.late_grace_period,
|
||||
0
|
||||
)
|
||||
es.shift_start_minutes
|
||||
)
|
||||
|
||||
THEN TRUE
|
||||
|
|
@ -622,7 +654,7 @@ class AttendanceAnalytics(models.Model):
|
|||
(
|
||||
|
||||
(
|
||||
rc.shift_end_time * 60
|
||||
es.shift_end_minutes
|
||||
)
|
||||
|
||||
+
|
||||
|
|
@ -638,28 +670,20 @@ class AttendanceAnalytics(models.Model):
|
|||
(
|
||||
EXTRACT(
|
||||
HOUR
|
||||
FROM ats.min_check_in
|
||||
FROM ats.min_check_in_local
|
||||
) * 60
|
||||
)
|
||||
+
|
||||
EXTRACT(
|
||||
MINUTE
|
||||
FROM ats.min_check_in
|
||||
FROM ats.min_check_in_local
|
||||
)
|
||||
)
|
||||
|
||||
-
|
||||
|
||||
(
|
||||
(
|
||||
rc.shift_start_time * 60
|
||||
)
|
||||
+
|
||||
COALESCE(
|
||||
dg.grace_period,
|
||||
rc.late_grace_period,
|
||||
0
|
||||
)
|
||||
es.shift_start_minutes
|
||||
),
|
||||
|
||||
0
|
||||
|
|
@ -669,7 +693,7 @@ class AttendanceAnalytics(models.Model):
|
|||
|
||||
END
|
||||
|
||||
) AS required_checkout_time,
|
||||
) / 60.0 AS required_checkout_time,
|
||||
|
||||
CASE
|
||||
|
||||
|
|
@ -681,7 +705,7 @@ class AttendanceAnalytics(models.Model):
|
|||
(
|
||||
|
||||
(
|
||||
rc.shift_end_time * 60
|
||||
es.shift_end_minutes
|
||||
)
|
||||
|
||||
+
|
||||
|
|
@ -697,29 +721,20 @@ class AttendanceAnalytics(models.Model):
|
|||
(
|
||||
EXTRACT(
|
||||
HOUR
|
||||
FROM ats.min_check_in
|
||||
FROM ats.min_check_in_local
|
||||
) * 60
|
||||
)
|
||||
+
|
||||
EXTRACT(
|
||||
MINUTE
|
||||
FROM ats.min_check_in
|
||||
FROM ats.min_check_in_local
|
||||
)
|
||||
)
|
||||
|
||||
-
|
||||
|
||||
(
|
||||
(
|
||||
rc.shift_start_time
|
||||
* 60
|
||||
)
|
||||
+
|
||||
COALESCE(
|
||||
dg.grace_period,
|
||||
rc.late_grace_period,
|
||||
0
|
||||
)
|
||||
es.shift_start_minutes
|
||||
),
|
||||
|
||||
0
|
||||
|
|
@ -737,13 +752,13 @@ class AttendanceAnalytics(models.Model):
|
|||
(
|
||||
EXTRACT(
|
||||
HOUR
|
||||
FROM ats.max_check_out
|
||||
FROM ats.max_check_out_local
|
||||
) * 60
|
||||
)
|
||||
+
|
||||
EXTRACT(
|
||||
MINUTE
|
||||
FROM ats.max_check_out
|
||||
FROM ats.max_check_out_local
|
||||
)
|
||||
),
|
||||
|
||||
|
|
@ -765,13 +780,13 @@ class AttendanceAnalytics(models.Model):
|
|||
(
|
||||
EXTRACT(
|
||||
HOUR
|
||||
FROM ats.max_check_out
|
||||
FROM ats.max_check_out_local
|
||||
) * 60
|
||||
)
|
||||
+
|
||||
EXTRACT(
|
||||
MINUTE
|
||||
FROM ats.max_check_out
|
||||
FROM ats.max_check_out_local
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -780,7 +795,7 @@ class AttendanceAnalytics(models.Model):
|
|||
(
|
||||
|
||||
(
|
||||
rc.shift_end_time * 60
|
||||
es.shift_end_minutes
|
||||
)
|
||||
|
||||
+
|
||||
|
|
@ -796,29 +811,20 @@ class AttendanceAnalytics(models.Model):
|
|||
(
|
||||
EXTRACT(
|
||||
HOUR
|
||||
FROM ats.min_check_in
|
||||
FROM ats.min_check_in_local
|
||||
) * 60
|
||||
)
|
||||
+
|
||||
EXTRACT(
|
||||
MINUTE
|
||||
FROM ats.min_check_in
|
||||
FROM ats.min_check_in_local
|
||||
)
|
||||
)
|
||||
|
||||
-
|
||||
|
||||
(
|
||||
(
|
||||
rc.shift_start_time
|
||||
* 60
|
||||
)
|
||||
+
|
||||
COALESCE(
|
||||
dg.grace_period,
|
||||
rc.late_grace_period,
|
||||
0
|
||||
)
|
||||
es.shift_start_minutes
|
||||
),
|
||||
|
||||
0
|
||||
|
|
@ -840,7 +846,7 @@ class AttendanceAnalytics(models.Model):
|
|||
WHEN
|
||||
(
|
||||
(
|
||||
rc.shift_end_time * 60
|
||||
es.shift_end_minutes
|
||||
)
|
||||
|
||||
+
|
||||
|
|
@ -856,29 +862,20 @@ class AttendanceAnalytics(models.Model):
|
|||
(
|
||||
EXTRACT(
|
||||
HOUR
|
||||
FROM ats.min_check_in
|
||||
FROM ats.min_check_in_local
|
||||
) * 60
|
||||
)
|
||||
+
|
||||
EXTRACT(
|
||||
MINUTE
|
||||
FROM ats.min_check_in
|
||||
FROM ats.min_check_in_local
|
||||
)
|
||||
)
|
||||
|
||||
-
|
||||
|
||||
(
|
||||
(
|
||||
rc.shift_start_time
|
||||
* 60
|
||||
)
|
||||
+
|
||||
COALESCE(
|
||||
dg.grace_period,
|
||||
rc.late_grace_period,
|
||||
0
|
||||
)
|
||||
es.shift_start_minutes
|
||||
),
|
||||
|
||||
0
|
||||
|
|
@ -895,13 +892,13 @@ class AttendanceAnalytics(models.Model):
|
|||
(
|
||||
EXTRACT(
|
||||
HOUR
|
||||
FROM ats.max_check_out
|
||||
FROM ats.max_check_out_local
|
||||
) * 60
|
||||
)
|
||||
+
|
||||
EXTRACT(
|
||||
MINUTE
|
||||
FROM ats.max_check_out
|
||||
FROM ats.max_check_out_local
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -930,35 +927,30 @@ class AttendanceAnalytics(models.Model):
|
|||
WHEN ats.min_check_in IS NULL
|
||||
THEN 'absent'
|
||||
|
||||
WHEN ats.worked_hours < (rc.hours_per_day / 2.0)
|
||||
WHEN ats.worked_hours < 1.0
|
||||
THEN 'invalid_attendance'
|
||||
|
||||
WHEN ats.worked_hours < (es.hours_per_day / 2.0)
|
||||
THEN 'half_day'
|
||||
|
||||
WHEN
|
||||
(
|
||||
(
|
||||
EXTRACT(
|
||||
HOUR FROM ats.min_check_in
|
||||
HOUR FROM ats.min_check_in_local
|
||||
) * 60
|
||||
)
|
||||
+
|
||||
EXTRACT(
|
||||
MINUTE
|
||||
FROM ats.min_check_in
|
||||
FROM ats.min_check_in_local
|
||||
)
|
||||
)
|
||||
|
||||
>
|
||||
|
||||
(
|
||||
(
|
||||
rc.shift_start_time * 60
|
||||
)
|
||||
+
|
||||
COALESCE(
|
||||
dg.grace_period,
|
||||
rc.late_grace_period,
|
||||
0
|
||||
)
|
||||
es.shift_start_minutes
|
||||
)
|
||||
|
||||
THEN 'late_in'
|
||||
|
|
@ -968,20 +960,20 @@ class AttendanceAnalytics(models.Model):
|
|||
(
|
||||
EXTRACT(
|
||||
HOUR
|
||||
FROM ats.max_check_out
|
||||
FROM ats.max_check_out_local
|
||||
) * 60
|
||||
)
|
||||
+
|
||||
EXTRACT(
|
||||
MINUTE
|
||||
FROM ats.max_check_out
|
||||
FROM ats.max_check_out_local
|
||||
)
|
||||
)
|
||||
|
||||
<
|
||||
|
||||
(
|
||||
rc.shift_end_time * 60
|
||||
es.shift_end_minutes
|
||||
)
|
||||
|
||||
THEN 'early_out'
|
||||
|
|
@ -1024,6 +1016,36 @@ class AttendanceAnalytics(models.Model):
|
|||
LEFT JOIN resource_calendar rc
|
||||
ON rc.id = ed.resource_calendar_id
|
||||
|
||||
LEFT JOIN calendar_day_schedule cds
|
||||
ON cds.calendar_id = ed.resource_calendar_id
|
||||
AND cds.dayofweek = (
|
||||
(
|
||||
(
|
||||
EXTRACT(DOW FROM ed.date)::integer
|
||||
+ 6
|
||||
) % 7
|
||||
)::text
|
||||
)
|
||||
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
COALESCE(
|
||||
NULLIF(rc.shift_start_time * 60, 0),
|
||||
cds.shift_start_minutes,
|
||||
0
|
||||
) AS shift_start_minutes,
|
||||
COALESCE(
|
||||
NULLIF(rc.shift_end_time * 60, 0),
|
||||
cds.shift_end_minutes,
|
||||
0
|
||||
) AS shift_end_minutes,
|
||||
COALESCE(
|
||||
NULLIF(rc.hours_per_day, 0),
|
||||
cds.hours_per_day,
|
||||
0
|
||||
) AS hours_per_day
|
||||
) es ON TRUE
|
||||
|
||||
LEFT JOIN department_grace dg
|
||||
ON dg.calendar_id
|
||||
= ed.resource_calendar_id
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@
|
|||
<field name="name">attendance.analytics.list</field>
|
||||
<field name="model">attendance.analytics</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Attendance Analytics">
|
||||
<list string="Attendance Analytics"
|
||||
decoration-danger="status == 'absent' or status == 'invalid_attendance'">
|
||||
<field name="employee_id"/>
|
||||
<field name="department_id"/>
|
||||
<field name="date"/>
|
||||
|
|
@ -28,7 +29,7 @@
|
|||
<field name="status"
|
||||
widget="badge"
|
||||
decoration-success="status == 'present'"
|
||||
decoration-danger="status == 'absent'"
|
||||
decoration-danger="status == 'absent' or status == 'invalid_attendance'"
|
||||
decoration-warning="status == 'late_in'"
|
||||
decoration-info="status == 'half_day'"
|
||||
decoration-primary="status == 'holiday'"/>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,32 @@
|
|||
<?xml version="1.0"?>
|
||||
<odoo>
|
||||
<template id="report_payslip">
|
||||
<t t-call="web.external_layout">
|
||||
<div class="page">
|
||||
<h2 id="payslip_name"><span t-field="o.name">August 2023 Payslip</span></h2>
|
||||
<t t-set="is_invalid" t-value="o._is_invalid()"/>
|
||||
<div t-if="is_invalid">
|
||||
<strong id="invalid_warning"><span t-out="is_invalid">This payslip is not validated. This is not a legal document.</span></strong>
|
||||
</div>
|
||||
<div t-else="">
|
||||
<div class="oe_structure"></div>
|
||||
</div>
|
||||
<div id="infos_table">
|
||||
<table class="table table-sm table-borderless">
|
||||
<thead class="o_black_border">
|
||||
<tr>
|
||||
<template id="report_payslip">
|
||||
<t t-call="web.external_layout">
|
||||
<div class="page">
|
||||
<div class="row mb-3">
|
||||
<div class="col-6">
|
||||
<img t-if="o.company_id.logo"
|
||||
t-att-src="image_data_uri(o.company_id.logo)"
|
||||
style="max-height:80px; max-width:220px;"
|
||||
alt="Company Logo"/>
|
||||
</div>
|
||||
</div>
|
||||
<h2 id="payslip_name">
|
||||
<span t-field="o.name">August 2023 Payslip</span>
|
||||
</h2>
|
||||
<t t-set="is_invalid" t-value="o._is_invalid()"/>
|
||||
<div t-if="is_invalid">
|
||||
<strong id="invalid_warning">
|
||||
<span t-out="is_invalid">This payslip is not validated. This is not a legal document.</span>
|
||||
</strong>
|
||||
</div>
|
||||
<div t-else="">
|
||||
<div class="oe_structure"></div>
|
||||
</div>
|
||||
<div id="infos_table">
|
||||
<table class="table table-sm table-borderless">
|
||||
<thead class="o_black_border">
|
||||
<tr>
|
||||
<th>Employee Information</th>
|
||||
<th>Other Information</th>
|
||||
</tr>
|
||||
|
|
@ -28,7 +40,7 @@
|
|||
</div>
|
||||
<div id="employee_id">
|
||||
<strong class="me-2">ID:</strong>
|
||||
<span t-if="o.employee_id.identification_id" t-field="o.employee_id.identification_id"/>
|
||||
<span t-if="o.employee_id.employee_id" t-field="o.employee_id.identification_id"/>
|
||||
<span t-else="" style="color:#875A7B" class="fw-bold">No ID number on the employee !!!</span>
|
||||
</div>
|
||||
<div id="employee_email" t-if="o.employee_id.work_email">
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
<separator string="Employees Selection"/>
|
||||
<div class="o_row ms-2">
|
||||
<group>
|
||||
<field name="department_id" class="w-75"
|
||||
<field name="department_id" class="w-75" placeholder="All"
|
||||
help="Set a specific department if you wish to select all the employees from this department (and subdepartments) at once."/>
|
||||
<field name="job_id" class="w-75" invisible="not department_id" domain="[('department_id', 'child_of', department_id)]"
|
||||
help="Set a specific job if you wish to select all the employees from this job at once."/>
|
||||
|
|
@ -95,4 +95,27 @@
|
|||
action = env['hr.payslip.employees'].create({}).compute_sheet()
|
||||
</field>
|
||||
</record>
|
||||
<record id="hr_hr_employee_view_form3_inherit_salary_contract" model="ir.ui.view">
|
||||
<field name="name">hr.hr.employee.view.form3.inherit.salary.contract</field>
|
||||
<field name="model">hr.employee</field>
|
||||
<field name="inherit_id" ref="hr_contract.hr_hr_employee_view_form3"/>
|
||||
<field name="arch" type="xml">
|
||||
|
||||
<xpath expr="//button[@name='action_open_contract']" position="replace">
|
||||
<button name="action_open_contract"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-book"
|
||||
string="Employee Salary"
|
||||
groups="hr_contract.group_hr_contract_manager"
|
||||
context="{
|
||||
'default_employee_id': id,
|
||||
'default_resource_calendar_id': resource_calendar_id.id or False,
|
||||
'from_action_open_contract': True
|
||||
}"
|
||||
invisible="employee_type not in ['employee', 'student', 'trainee']"/>
|
||||
</xpath>
|
||||
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<odoo>
|
||||
<menuitem
|
||||
id="hr_payroll.menu_hr_payroll_employees_root"
|
||||
name="Contracts"
|
||||
name="Employee Salary Contract"
|
||||
parent="hr_payroll.menu_hr_payroll_root"
|
||||
sequence="1"
|
||||
action="hr_contract.action_hr_contract"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_hr_recruitment_auto_doc_wizard,hr.recruitment.auto.doc.wizard,model_hr_recruitment_auto_doc_wizard,base.group_user,1,1,1,1
|
||||
access_hr_recruitment_auto_doc_wizard_line,hr.recruitment.auto.doc.wizard.line,model_hr_recruitment_auto_doc_wizard_line,base.group_user,1,1,1,1
|
||||
access_hr_recruitment_auto_doc_education_line,hr.recruitment.auto.doc.education.line,model_hr_recruitment_auto_doc_education_line,base.group_user,1,1,1,1
|
||||
access_hr_recruitment_auto_doc_employer_line,hr.recruitment.auto.doc.employer.line,model_hr_recruitment_auto_doc_employer_line,base.group_user,1,1,1,1
|
||||
access_hr_recruitment_auto_doc_family_line,hr.recruitment.auto.doc.family.line,model_hr_recruitment_auto_doc_family_line,base.group_user,1,1,1,1
|
||||
access_hr_recruitment_auto_doc_certification_line,hr.recruitment.auto.doc.certification.line,model_hr_recruitment_auto_doc_certification_line,base.group_user,1,1,1,1
|
||||
access_hr_recruitment_auto_doc_project_line,hr.recruitment.auto.doc.project.line,model_hr_recruitment_auto_doc_project_line,base.group_user,1,1,1,1
|
||||
access_hr_recruitment_auto_doc_other_line,hr.recruitment.auto.doc.other.line,model_hr_recruitment_auto_doc_other_line,base.group_user,1,1,1,1
|
||||
|
|
|
|||
|
|
|
@ -152,7 +152,10 @@ class HrRecruitmentAutoDocWizard(models.TransientModel):
|
|||
parsed_data = self._post_process_jd_data(parsed_data, parsed_payload["text"])
|
||||
else:
|
||||
parsed_data = self._post_process_resume_data(parsed_data, parsed_payload["text"], line.file_name)
|
||||
line.extracted_payload = json.dumps(parsed_data, indent=2, ensure_ascii=False)
|
||||
line.write(dict(
|
||||
line._prepare_editable_vals(parsed_data),
|
||||
extracted_payload=json.dumps(parsed_data, indent=2, ensure_ascii=False),
|
||||
))
|
||||
|
||||
try:
|
||||
processed += 1
|
||||
|
|
@ -255,6 +258,7 @@ class HrRecruitmentAutoDocWizard(models.TransientModel):
|
|||
|
||||
try:
|
||||
parsed_data = json.loads(line.extracted_payload)
|
||||
parsed_data = line._get_edited_parsed_data(parsed_data)
|
||||
|
||||
with self.env.cr.savepoint():
|
||||
|
||||
|
|
@ -480,6 +484,17 @@ class HrRecruitmentAutoDocWizard(models.TransientModel):
|
|||
"relation_type (father/mother/spouse/kid1/kid2), name, contact_no, dob, and location."
|
||||
),
|
||||
},
|
||||
"certifications": {
|
||||
"type": "list",
|
||||
"description": "List of explicit certifications, courses, training, or licenses if present.",
|
||||
},
|
||||
"projects": {
|
||||
"type": "list",
|
||||
"description": (
|
||||
"List of explicit resume projects if present. Each item should include project_name, role, "
|
||||
"technologies, duration, and description when available."
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
def _get_jd_required_fields(self):
|
||||
|
|
@ -510,6 +525,10 @@ class HrRecruitmentAutoDocWizard(models.TransientModel):
|
|||
"Normalize skills into clean individual names. "
|
||||
"For experience values, return numeric years when clearly inferable. "
|
||||
"If the resume contains education details, previous employer details, or family details, return them as structured arrays of objects using the requested field names. "
|
||||
"Read the complete resume including tables, side columns, and later pages before returning JSON. "
|
||||
"education_history must include every school, intermediate, diploma, graduation, post graduation, or additional qualification row that is explicitly present. "
|
||||
"employer_history must include internships, current employer, previous employers, project/company experience, and work periods when explicitly present. "
|
||||
"family_details must include every explicitly mentioned family member and must be [] if the resume has no family section. "
|
||||
"For each employer entry, extract the role-specific work description into work_description. "
|
||||
"Only include entries that are explicitly present in the document. "
|
||||
"Do not consider certifications, responsibilities, Non Technical Stuff as skills"
|
||||
|
|
@ -642,10 +661,13 @@ class HrRecruitmentAutoDocWizard(models.TransientModel):
|
|||
data["email"] = email_match.group(1)
|
||||
|
||||
phone_matches = re.findall(r"(\+?\d[\d\-\s()]{7,}\d)", extracted_text)
|
||||
if phone_matches and not data.get("phone"):
|
||||
data["phone"] = phone_matches[0].strip()
|
||||
if len(phone_matches) > 1 and not data.get("alternate_phone"):
|
||||
data["alternate_phone"] = phone_matches[1].strip()
|
||||
phone_values = []
|
||||
for phone_candidate in [data.get("phone"), data.get("alternate_phone")] + phone_matches:
|
||||
phone_value = self._clean_resume_phone_value(phone_candidate)
|
||||
if phone_value and phone_value not in phone_values:
|
||||
phone_values.append(phone_value)
|
||||
data["phone"] = phone_values[0] if phone_values else False
|
||||
data["alternate_phone"] = phone_values[1] if len(phone_values) > 1 else False
|
||||
|
||||
linkedin_match = re.search(r"(https?://(?:www\.)?linkedin\.com/[^\s]+)", extracted_text, re.I)
|
||||
if linkedin_match:
|
||||
|
|
@ -654,11 +676,12 @@ class HrRecruitmentAutoDocWizard(models.TransientModel):
|
|||
if not data.get("total_experience_years"):
|
||||
data["total_experience_years"] = self._guess_total_experience(extracted_text)
|
||||
|
||||
resume_skills = self._normalize_resume_skill_values(data.get("skills") or [], extracted_text)
|
||||
data["skills"] = self.env[
|
||||
"document.parser.service"
|
||||
].validate_explicit_skills(
|
||||
extracted_text,
|
||||
data.get("skills") or []
|
||||
resume_skills
|
||||
)
|
||||
data["education_history"] = self._normalize_resume_list(data.get("education_history"))
|
||||
data["employer_history"] = self._normalize_resume_list(data.get("employer_history"))
|
||||
|
|
@ -1431,8 +1454,57 @@ class HrRecruitmentAutoDocWizard(models.TransientModel):
|
|||
normalized = re.sub(r"[^\d+]", "", value)
|
||||
return normalized or False
|
||||
|
||||
def _clean_resume_phone_value(self, value):
|
||||
if not value:
|
||||
return False
|
||||
raw_value = str(value).strip()
|
||||
if re.fullmatch(r"(?:19|20)\d{2}\s*[-/]\s*(?:19|20)\d{2}", raw_value):
|
||||
return False
|
||||
normalized = self._normalize_phone(raw_value)
|
||||
digits = re.sub(r"\D", "", normalized or "")
|
||||
if len(digits) < 10 or len(digits) > 15:
|
||||
return False
|
||||
return normalized
|
||||
|
||||
def _normalize_resume_skill_values(self, skills, extracted_text):
|
||||
skill_parts = []
|
||||
for skill in skills:
|
||||
for skill_part in self._split_resume_skill_value(skill):
|
||||
normalized = self._normalize_skill_name(skill_part)
|
||||
if normalized and self._is_valid_resume_skill(normalized, extracted_text):
|
||||
skill_parts.append(normalized)
|
||||
normalized_skills = []
|
||||
index = 0
|
||||
while index < len(skill_parts):
|
||||
current_skill = skill_parts[index]
|
||||
next_skill = skill_parts[index + 1] if index + 1 < len(skill_parts) else False
|
||||
combined_skill = "%s %s" % (current_skill, next_skill) if next_skill else False
|
||||
if combined_skill and self._is_valid_resume_skill(combined_skill, extracted_text):
|
||||
normalized_skills.append(combined_skill)
|
||||
index += 2
|
||||
continue
|
||||
normalized_skills.append(current_skill)
|
||||
index += 1
|
||||
return self._deduplicate_skill_names(normalized_skills)
|
||||
|
||||
def _split_resume_skill_value(self, value):
|
||||
value = re.sub(r"^[\s\-*\u2022•]+", "", str(value or "")).strip()
|
||||
if not value:
|
||||
return []
|
||||
if ":" in value:
|
||||
label, value = [item.strip() for item in value.split(":", 1)]
|
||||
if not re.search(r"(programming|frameworks?|databases?|analytics?|cloud|devops|tools?|technologies|skills?)", label, re.I):
|
||||
value = "%s %s" % (label, value)
|
||||
value = re.sub(r"\((?:advanced|intermediate|beginner|expert)\)", "", value, flags=re.I)
|
||||
return [item for item in re.split(r"[,;|/]", value) if item.strip()]
|
||||
|
||||
def _normalize_skill_name(self, value):
|
||||
value = re.sub(r"\s+", " ", (value or "")).strip(" -,:;")
|
||||
value = re.sub(r"^[\s\-*\u2022•]+", "", value or "")
|
||||
value = re.sub(r"\s+", " ", value).strip(" -,:;()")
|
||||
if ":" in value:
|
||||
label, skill_value = [item.strip() for item in value.split(":", 1)]
|
||||
if re.search(r"(programming|frameworks?|databases?|analytics?|cloud|devops|tools?|technologies|skills?)", label, re.I):
|
||||
value = skill_value
|
||||
value = re.sub(r"^[0-9.)\-(\s]+", "", value).strip()
|
||||
if not value:
|
||||
return False
|
||||
|
|
@ -1834,8 +1906,9 @@ class HrRecruitmentAutoDocWizard(models.TransientModel):
|
|||
"target_to": self._parse_date_value(parsed_data.get("end_date")),
|
||||
"job_category": int(job_category_id) if job_category_id and job_category_id.isdigit() else False,
|
||||
"address_id":False,
|
||||
"recruitment_type": 'external'
|
||||
|
||||
"recruitment_type": 'external',
|
||||
"requested_by": parsed_data.get("requested_by") or False,
|
||||
"no_of_recruitment": parsed_data.get("no_of_positions") or 1,
|
||||
}
|
||||
if request_id:
|
||||
create_vals["recruitment_sequence"] = request_id
|
||||
|
|
@ -2034,13 +2107,12 @@ class HrRecruitmentAutoDocWizard(models.TransientModel):
|
|||
"<ul>",
|
||||
]
|
||||
for row in rows:
|
||||
html_parts.append(
|
||||
"<li><strong>%s</strong>: <span class='text-%s'>%s</span></li>" % (
|
||||
escape(row["filename"]),
|
||||
row["level"],
|
||||
escape(row["message"]),
|
||||
)
|
||||
)
|
||||
html_parts.append("<li><strong>%s</strong>: <span class='text-%s'>%s</span>" % (
|
||||
escape(row["filename"]),
|
||||
row["level"],
|
||||
escape(row["message"]),
|
||||
))
|
||||
html_parts.append("</li>")
|
||||
html_parts.extend(["</ul>", "</div>"])
|
||||
return "".join(html_parts)
|
||||
|
||||
|
|
@ -2072,3 +2144,543 @@ class HrRecruitmentAutoDocWizardLine(models.TransientModel):
|
|||
extracted_payload = fields.Text(readonly=True)
|
||||
candidate_id = fields.Many2one("hr.candidate", readonly=True)
|
||||
applicant_id = fields.Many2one("hr.applicant", readonly=True)
|
||||
target_model = fields.Selection(related="wizard_id.target_model", readonly=True)
|
||||
|
||||
full_name = fields.Char()
|
||||
first_name = fields.Char()
|
||||
last_name = fields.Char()
|
||||
email = fields.Char()
|
||||
phone = fields.Char()
|
||||
alternate_phone = fields.Char()
|
||||
linkedin_profile = fields.Char()
|
||||
current_location = fields.Char()
|
||||
current_organization = fields.Char()
|
||||
total_experience_years = fields.Float()
|
||||
relevant_experience_years = fields.Float()
|
||||
notice_period = fields.Char()
|
||||
degree = fields.Char()
|
||||
skills_text = fields.Text(string="Skills")
|
||||
summary = fields.Text()
|
||||
education_history_json = fields.Text(string="Education History")
|
||||
employer_history_json = fields.Text(string="Employer History")
|
||||
family_details_json = fields.Text(string="Family Details")
|
||||
other_payload_json = fields.Text(string="Other Parsed Data")
|
||||
education_line_ids = fields.One2many(
|
||||
"hr.recruitment.auto.doc.education.line",
|
||||
"wizard_line_id",
|
||||
string="Education History",
|
||||
)
|
||||
employer_line_ids = fields.One2many(
|
||||
"hr.recruitment.auto.doc.employer.line",
|
||||
"wizard_line_id",
|
||||
string="Employer History",
|
||||
)
|
||||
family_line_ids = fields.One2many(
|
||||
"hr.recruitment.auto.doc.family.line",
|
||||
"wizard_line_id",
|
||||
string="Family Details",
|
||||
)
|
||||
certification_line_ids = fields.One2many(
|
||||
"hr.recruitment.auto.doc.certification.line",
|
||||
"wizard_line_id",
|
||||
string="Certifications",
|
||||
)
|
||||
project_line_ids = fields.One2many(
|
||||
"hr.recruitment.auto.doc.project.line",
|
||||
"wizard_line_id",
|
||||
string="Projects",
|
||||
)
|
||||
other_line_ids = fields.One2many(
|
||||
"hr.recruitment.auto.doc.other.line",
|
||||
"wizard_line_id",
|
||||
string="Other Parsed Data",
|
||||
)
|
||||
|
||||
request_id = fields.Char(string="Request ID")
|
||||
start_date = fields.Char()
|
||||
end_date = fields.Char()
|
||||
site_location = fields.Char()
|
||||
job_title = fields.Char()
|
||||
job_summary = fields.Text()
|
||||
requirements = fields.Text()
|
||||
primary_skills_text = fields.Text(string="Primary Skills")
|
||||
secondary_skills_text = fields.Text(string="Secondary Skills")
|
||||
budget = fields.Char()
|
||||
experience_years = fields.Float()
|
||||
job_category = fields.Char()
|
||||
requested_by = fields.Many2one('res.partner',domain="[('contact_type', '=', 'external')]")
|
||||
no_of_positions = fields.Integer('Number of Positions')
|
||||
|
||||
def action_save_line_changes(self):
|
||||
self.ensure_one()
|
||||
parsed_data = self._get_edited_parsed_data()
|
||||
self.extracted_payload = json.dumps(parsed_data, indent=2, ensure_ascii=False)
|
||||
return self._action_open_parent_wizard()
|
||||
|
||||
def action_close_line(self):
|
||||
self.ensure_one()
|
||||
return self._action_open_parent_wizard()
|
||||
|
||||
def _action_open_parent_wizard(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
"type": "ir.actions.act_window",
|
||||
"res_model": self.wizard_id._name,
|
||||
"res_id": self.wizard_id.id,
|
||||
"view_mode": "form",
|
||||
"target": "new",
|
||||
}
|
||||
|
||||
def _prepare_editable_vals(self, parsed_data):
|
||||
parsed_data = parsed_data or {}
|
||||
return {
|
||||
"full_name": parsed_data.get("full_name"),
|
||||
"first_name": parsed_data.get("first_name"),
|
||||
"last_name": parsed_data.get("last_name"),
|
||||
"email": parsed_data.get("email"),
|
||||
"phone": parsed_data.get("phone"),
|
||||
"alternate_phone": parsed_data.get("alternate_phone"),
|
||||
"linkedin_profile": parsed_data.get("linkedin_profile"),
|
||||
"current_location": parsed_data.get("current_location"),
|
||||
"current_organization": parsed_data.get("current_organization"),
|
||||
"total_experience_years": self._float_or_zero(parsed_data.get("total_experience_years")),
|
||||
"relevant_experience_years": self._float_or_zero(parsed_data.get("relevant_experience_years")),
|
||||
"notice_period": parsed_data.get("notice_period"),
|
||||
"degree": parsed_data.get("degree"),
|
||||
"skills_text": self._list_to_text(parsed_data.get("skills")),
|
||||
"summary": parsed_data.get("summary"),
|
||||
"education_history_json": self._json_to_text(parsed_data.get("education_history")),
|
||||
"employer_history_json": self._json_to_text(parsed_data.get("employer_history")),
|
||||
"family_details_json": self._json_to_text(parsed_data.get("family_details")),
|
||||
"other_payload_json": self._json_to_text(self._get_other_parsed_data(parsed_data)),
|
||||
"education_line_ids": self._prepare_education_line_commands(parsed_data.get("education_history")),
|
||||
"employer_line_ids": self._prepare_employer_line_commands(parsed_data.get("employer_history"), parsed_data.get("projects")),
|
||||
"family_line_ids": self._prepare_family_line_commands(parsed_data.get("family_details")),
|
||||
"certification_line_ids": self._prepare_certification_line_commands(parsed_data.get("certifications")),
|
||||
"project_line_ids": self._prepare_project_line_commands(parsed_data.get("projects")),
|
||||
"other_line_ids": self._prepare_other_line_commands(self._get_other_parsed_data(parsed_data)),
|
||||
"request_id": parsed_data.get("request_id"),
|
||||
"start_date": parsed_data.get("start_date"),
|
||||
"end_date": parsed_data.get("end_date"),
|
||||
"site_location": parsed_data.get("site_location"),
|
||||
"job_title": parsed_data.get("job_title"),
|
||||
"job_summary": parsed_data.get("job_summary"),
|
||||
"requirements": parsed_data.get("requirements"),
|
||||
"primary_skills_text": self._list_to_text(parsed_data.get("primary_skills")),
|
||||
"secondary_skills_text": self._list_to_text(parsed_data.get("secondary_skills")),
|
||||
"budget": parsed_data.get("budget"),
|
||||
"experience_years": self._float_or_zero(parsed_data.get("experience_years")),
|
||||
"job_category": parsed_data.get("job_category"),
|
||||
}
|
||||
|
||||
def _get_edited_parsed_data(self, base_data=None):
|
||||
data = dict(base_data or {})
|
||||
if self.target_model == "job_recruitment":
|
||||
data.update({
|
||||
"request_id": self.request_id,
|
||||
"start_date": self.start_date,
|
||||
"end_date": self.end_date,
|
||||
"site_location": self.site_location,
|
||||
"job_title": self.job_title,
|
||||
"job_summary": self.job_summary,
|
||||
"requirements": self.requirements,
|
||||
"primary_skills": self._text_to_list(self.primary_skills_text),
|
||||
"secondary_skills": self._text_to_list(self.secondary_skills_text),
|
||||
"budget": self.budget,
|
||||
"experience_years": self.experience_years,
|
||||
"job_category": self.job_category,
|
||||
"requested_by": self.requested_by.id if self.requested_by else False,
|
||||
"no_of_positions": self.no_of_positions if self.no_of_positions else 1,
|
||||
})
|
||||
else:
|
||||
data.update({
|
||||
"full_name": self.full_name,
|
||||
"first_name": self.first_name,
|
||||
"last_name": self.last_name,
|
||||
"email": self.email,
|
||||
"phone": self.phone,
|
||||
"alternate_phone": self.alternate_phone,
|
||||
"linkedin_profile": self.linkedin_profile,
|
||||
"current_location": self.current_location,
|
||||
"current_organization": self.current_organization,
|
||||
"total_experience_years": self.total_experience_years,
|
||||
"relevant_experience_years": self.relevant_experience_years,
|
||||
"notice_period": self.notice_period,
|
||||
"degree": self.degree,
|
||||
"skills": self._text_to_list(self.skills_text),
|
||||
"summary": self.summary,
|
||||
"education_history": self._get_education_history_from_lines(),
|
||||
"employer_history": self._get_employer_history_from_lines(),
|
||||
"family_details": self._get_family_details_from_lines(),
|
||||
"certifications": self._get_certifications_from_lines(),
|
||||
"projects": self._get_projects_from_lines(),
|
||||
})
|
||||
data.update(self._get_other_payload_from_lines())
|
||||
return data
|
||||
|
||||
def _prepare_education_line_commands(self, education_history):
|
||||
commands = [(5, 0, 0)]
|
||||
for education in self._normalize_dict_list(education_history):
|
||||
commands.append((0, 0, {
|
||||
"education_type": education.get("education_type"),
|
||||
"specialization": education.get("specialization") or education.get("name") or education.get("degree"),
|
||||
"university": education.get("university") or education.get("institution") or education.get("college"),
|
||||
"start_year": education.get("start_year"),
|
||||
"end_year": education.get("end_year"),
|
||||
"marks_or_grade": education.get("marks_or_grade") or education.get("marks") or education.get("grade") or education.get("cgpa") or education.get("percentage"),
|
||||
}))
|
||||
return commands
|
||||
|
||||
def _prepare_employer_line_commands(self, employer_history, projects=None):
|
||||
commands = [(5, 0, 0)]
|
||||
employers = self._normalize_shared_employer_descriptions(
|
||||
self._normalize_dict_list(employer_history),
|
||||
projects,
|
||||
)
|
||||
for employer in employers:
|
||||
commands.append((0, 0, {
|
||||
"company_name": employer.get("company_name") or employer.get("employer"),
|
||||
"designation": employer.get("designation") or employer.get("role") or employer.get("job_title"),
|
||||
"date_of_joining": employer.get("date_of_joining") or employer.get("start_date"),
|
||||
"last_working_day": employer.get("last_working_day") or employer.get("end_date"),
|
||||
"ctc": employer.get("ctc") or employer.get("salary"),
|
||||
"work_description": employer.get("work_description") or employer.get("summary") or employer.get("description") or employer.get("responsibilities"),
|
||||
}))
|
||||
return commands
|
||||
|
||||
def _prepare_family_line_commands(self, family_details):
|
||||
commands = [(5, 0, 0)]
|
||||
for member in self._normalize_dict_list(family_details):
|
||||
commands.append((0, 0, {
|
||||
"relation_type": member.get("relation_type") or member.get("relation"),
|
||||
"name": member.get("name"),
|
||||
"contact_no": member.get("contact_no") or member.get("contact") or member.get("phone"),
|
||||
"dob": member.get("dob"),
|
||||
"location": member.get("location") or member.get("address"),
|
||||
}))
|
||||
return commands
|
||||
|
||||
def _prepare_certification_line_commands(self, certifications):
|
||||
commands = [(5, 0, 0)]
|
||||
for certification in self._normalize_value_list(certifications):
|
||||
if isinstance(certification, dict):
|
||||
commands.append((0, 0, {
|
||||
"certification_name": certification.get("certification_name") or certification.get("name") or certification.get("title"),
|
||||
"provider": certification.get("provider") or certification.get("issuer") or certification.get("organization"),
|
||||
"completion_date": certification.get("completion_date") or certification.get("date") or certification.get("year"),
|
||||
}))
|
||||
else:
|
||||
commands.append((0, 0, {"certification_name": str(certification)}))
|
||||
return commands
|
||||
|
||||
def _prepare_project_line_commands(self, projects):
|
||||
commands = [(5, 0, 0)]
|
||||
for project in self._normalize_dict_list(projects):
|
||||
technologies = project.get("technologies")
|
||||
commands.append((0, 0, {
|
||||
"project_name": project.get("project_name") or project.get("name") or project.get("title"),
|
||||
"role": project.get("role"),
|
||||
"technologies": ", ".join(technologies) if isinstance(technologies, list) else technologies,
|
||||
"duration": project.get("duration"),
|
||||
"description": project.get("description") or project.get("summary") or project.get("work_description"),
|
||||
}))
|
||||
return commands
|
||||
|
||||
def _prepare_other_line_commands(self, other_payload):
|
||||
commands = [(5, 0, 0)]
|
||||
for key, value in (other_payload or {}).items():
|
||||
commands.append((0, 0, {
|
||||
"key": key,
|
||||
"value": self._json_to_text(value) or str(value),
|
||||
}))
|
||||
return commands
|
||||
|
||||
def _get_education_history_from_lines(self):
|
||||
return [line._to_payload() for line in self.education_line_ids if line._to_payload()]
|
||||
|
||||
def _get_employer_history_from_lines(self):
|
||||
return [line._to_payload() for line in self.employer_line_ids if line._to_payload()]
|
||||
|
||||
def _get_family_details_from_lines(self):
|
||||
return [line._to_payload() for line in self.family_line_ids if line._to_payload()]
|
||||
|
||||
def _get_certifications_from_lines(self):
|
||||
return [line._to_payload() for line in self.certification_line_ids if line._to_payload()]
|
||||
|
||||
def _get_projects_from_lines(self):
|
||||
return [line._to_payload() for line in self.project_line_ids if line._to_payload()]
|
||||
|
||||
def _get_other_payload_from_lines(self):
|
||||
return {
|
||||
line.key: line._json_value()
|
||||
for line in self.other_line_ids
|
||||
if line.key and line.value not in (False, None, "")
|
||||
}
|
||||
|
||||
def _normalize_dict_list(self, value):
|
||||
if isinstance(value, list):
|
||||
return [item for item in value if isinstance(item, dict)]
|
||||
if isinstance(value, dict):
|
||||
return [value]
|
||||
return []
|
||||
|
||||
def _normalize_value_list(self, value):
|
||||
if isinstance(value, list):
|
||||
return [item for item in value if item not in (False, None, "", {})]
|
||||
if value in (False, None, "", {}):
|
||||
return []
|
||||
return [value]
|
||||
|
||||
def _normalize_shared_employer_descriptions(self, employers, projects=None):
|
||||
if len(employers) < 2:
|
||||
return employers
|
||||
|
||||
descriptions = [
|
||||
employer.get("work_description") or employer.get("summary") or employer.get("description") or employer.get("responsibilities")
|
||||
for employer in employers
|
||||
]
|
||||
descriptions = [description for description in descriptions if description]
|
||||
if descriptions:
|
||||
shared_description = max(descriptions, key=lambda item: len(str(item)))
|
||||
else:
|
||||
project_descriptions = []
|
||||
for project in self._normalize_dict_list(projects):
|
||||
description = project.get("description") or project.get("summary") or project.get("work_description")
|
||||
if description:
|
||||
project_descriptions.append("%s: %s" % (
|
||||
project.get("project_name") or project.get("name") or project.get("title") or "Project",
|
||||
description,
|
||||
))
|
||||
shared_description = "\n".join(project_descriptions) if project_descriptions else False
|
||||
|
||||
if not shared_description:
|
||||
return employers
|
||||
|
||||
for employer in employers:
|
||||
if not (employer.get("work_description") or employer.get("summary") or employer.get("description") or employer.get("responsibilities")):
|
||||
employer["work_description"] = shared_description
|
||||
return employers
|
||||
|
||||
def _list_to_text(self, value):
|
||||
if not value:
|
||||
return False
|
||||
if isinstance(value, list):
|
||||
return "\n".join(str(item) for item in value if item not in (False, None, ""))
|
||||
return str(value)
|
||||
|
||||
def _text_to_list(self, value):
|
||||
if not value:
|
||||
return []
|
||||
return [item.strip(" -,\t") for item in str(value).splitlines() if item.strip(" -,\t")]
|
||||
|
||||
def _json_to_text(self, value):
|
||||
if value in (False, None, "", [], {}):
|
||||
return False
|
||||
return json.dumps(value, indent=2, ensure_ascii=False)
|
||||
|
||||
def _text_to_json_list(self, value):
|
||||
parsed = self._text_to_json_value(value, [])
|
||||
if isinstance(parsed, list):
|
||||
return [item for item in parsed if item not in (False, None, "", {})]
|
||||
if isinstance(parsed, dict):
|
||||
return [parsed]
|
||||
return []
|
||||
|
||||
def _text_to_json_dict(self, value):
|
||||
parsed = self._text_to_json_value(value, {})
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
|
||||
def _text_to_json_value(self, value, default):
|
||||
if not value:
|
||||
return default
|
||||
try:
|
||||
return json.loads(value)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
def _get_other_parsed_data(self, parsed_data):
|
||||
known_keys = {
|
||||
"full_name", "first_name", "last_name", "email", "phone", "alternate_phone",
|
||||
"linkedin_profile", "current_location", "current_organization",
|
||||
"total_experience_years", "relevant_experience_years", "notice_period",
|
||||
"degree", "skills", "summary", "education_history", "employer_history",
|
||||
"family_details", "certifications", "projects", "request_id", "start_date", "end_date", "site_location",
|
||||
"job_title", "job_summary", "requirements", "primary_skills",
|
||||
"secondary_skills", "budget", "experience_years", "job_category",
|
||||
}
|
||||
return {
|
||||
key: value
|
||||
for key, value in (parsed_data or {}).items()
|
||||
if key not in known_keys and value not in (False, None, "", [], {})
|
||||
}
|
||||
|
||||
def _float_or_zero(self, value):
|
||||
if value in (False, None, ""):
|
||||
return 0.0
|
||||
try:
|
||||
return float(value)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
class HrRecruitmentAutoDocEducationLine(models.TransientModel):
|
||||
_name = "hr.recruitment.auto.doc.education.line"
|
||||
_description = "HR Recruitment Auto Document Education Line"
|
||||
_order = "id"
|
||||
|
||||
wizard_line_id = fields.Many2one(
|
||||
"hr.recruitment.auto.doc.wizard.line",
|
||||
required=True,
|
||||
ondelete="cascade",
|
||||
)
|
||||
education_type = fields.Char()
|
||||
specialization = fields.Char()
|
||||
university = fields.Char()
|
||||
start_year = fields.Char()
|
||||
end_year = fields.Char()
|
||||
marks_or_grade = fields.Char()
|
||||
|
||||
def _to_payload(self):
|
||||
self.ensure_one()
|
||||
payload = {
|
||||
"education_type": self.education_type,
|
||||
"specialization": self.specialization,
|
||||
"university": self.university,
|
||||
"start_year": self.start_year,
|
||||
"end_year": self.end_year,
|
||||
"marks_or_grade": self.marks_or_grade,
|
||||
}
|
||||
return {key: value for key, value in payload.items() if value not in (False, None, "")}
|
||||
|
||||
|
||||
class HrRecruitmentAutoDocEmployerLine(models.TransientModel):
|
||||
_name = "hr.recruitment.auto.doc.employer.line"
|
||||
_description = "HR Recruitment Auto Document Employer Line"
|
||||
_order = "id"
|
||||
|
||||
wizard_line_id = fields.Many2one(
|
||||
"hr.recruitment.auto.doc.wizard.line",
|
||||
required=True,
|
||||
ondelete="cascade",
|
||||
)
|
||||
company_name = fields.Char()
|
||||
designation = fields.Char()
|
||||
date_of_joining = fields.Char()
|
||||
last_working_day = fields.Char()
|
||||
ctc = fields.Char()
|
||||
work_description = fields.Text()
|
||||
|
||||
def _to_payload(self):
|
||||
self.ensure_one()
|
||||
payload = {
|
||||
"company_name": self.company_name,
|
||||
"designation": self.designation,
|
||||
"date_of_joining": self.date_of_joining,
|
||||
"last_working_day": self.last_working_day,
|
||||
"ctc": self.ctc,
|
||||
"work_description": self.work_description,
|
||||
}
|
||||
return {key: value for key, value in payload.items() if value not in (False, None, "")}
|
||||
|
||||
|
||||
class HrRecruitmentAutoDocFamilyLine(models.TransientModel):
|
||||
_name = "hr.recruitment.auto.doc.family.line"
|
||||
_description = "HR Recruitment Auto Document Family Line"
|
||||
_order = "id"
|
||||
|
||||
wizard_line_id = fields.Many2one(
|
||||
"hr.recruitment.auto.doc.wizard.line",
|
||||
required=True,
|
||||
ondelete="cascade",
|
||||
)
|
||||
relation_type = fields.Char()
|
||||
name = fields.Char()
|
||||
contact_no = fields.Char()
|
||||
dob = fields.Char()
|
||||
location = fields.Char()
|
||||
|
||||
def _to_payload(self):
|
||||
self.ensure_one()
|
||||
payload = {
|
||||
"relation_type": self.relation_type,
|
||||
"name": self.name,
|
||||
"contact_no": self.contact_no,
|
||||
"dob": self.dob,
|
||||
"location": self.location,
|
||||
}
|
||||
return {key: value for key, value in payload.items() if value not in (False, None, "")}
|
||||
|
||||
|
||||
class HrRecruitmentAutoDocCertificationLine(models.TransientModel):
|
||||
_name = "hr.recruitment.auto.doc.certification.line"
|
||||
_description = "HR Recruitment Auto Document Certification Line"
|
||||
_order = "id"
|
||||
|
||||
wizard_line_id = fields.Many2one(
|
||||
"hr.recruitment.auto.doc.wizard.line",
|
||||
required=True,
|
||||
ondelete="cascade",
|
||||
)
|
||||
certification_name = fields.Char()
|
||||
provider = fields.Char()
|
||||
completion_date = fields.Char()
|
||||
|
||||
def _to_payload(self):
|
||||
self.ensure_one()
|
||||
payload = {
|
||||
"certification_name": self.certification_name,
|
||||
"provider": self.provider,
|
||||
"completion_date": self.completion_date,
|
||||
}
|
||||
return {key: value for key, value in payload.items() if value not in (False, None, "")}
|
||||
|
||||
|
||||
class HrRecruitmentAutoDocProjectLine(models.TransientModel):
|
||||
_name = "hr.recruitment.auto.doc.project.line"
|
||||
_description = "HR Recruitment Auto Document Project Line"
|
||||
_order = "id"
|
||||
|
||||
wizard_line_id = fields.Many2one(
|
||||
"hr.recruitment.auto.doc.wizard.line",
|
||||
required=True,
|
||||
ondelete="cascade",
|
||||
)
|
||||
project_name = fields.Char()
|
||||
role = fields.Char()
|
||||
technologies = fields.Char()
|
||||
duration = fields.Char()
|
||||
description = fields.Text()
|
||||
|
||||
def _to_payload(self):
|
||||
self.ensure_one()
|
||||
payload = {
|
||||
"project_name": self.project_name,
|
||||
"role": self.role,
|
||||
"technologies": [item.strip() for item in (self.technologies or "").split(",") if item.strip()],
|
||||
"duration": self.duration,
|
||||
"description": self.description,
|
||||
}
|
||||
return {key: value for key, value in payload.items() if value not in (False, None, "", [])}
|
||||
|
||||
|
||||
class HrRecruitmentAutoDocOtherLine(models.TransientModel):
|
||||
_name = "hr.recruitment.auto.doc.other.line"
|
||||
_description = "HR Recruitment Auto Document Other Parsed Data Line"
|
||||
_order = "id"
|
||||
|
||||
wizard_line_id = fields.Many2one(
|
||||
"hr.recruitment.auto.doc.wizard.line",
|
||||
required=True,
|
||||
ondelete="cascade",
|
||||
)
|
||||
key = fields.Char(required=True)
|
||||
value = fields.Text()
|
||||
|
||||
def _json_value(self):
|
||||
self.ensure_one()
|
||||
if not self.value:
|
||||
return False
|
||||
try:
|
||||
return json.loads(self.value)
|
||||
except Exception:
|
||||
return self.value
|
||||
|
|
|
|||
|
|
@ -48,7 +48,147 @@
|
|||
</div>
|
||||
|
||||
<group string="Uploaded Files" invisible="not line_ids">
|
||||
<field name="line_ids" nolabel="1" readonly="1">
|
||||
<field name="line_ids" nolabel="1" create="0" delete="0">
|
||||
<form string="Parsed Document" create="0" delete="0">
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="file_name" readonly="1"/>
|
||||
<field name="state" readonly="1"/>
|
||||
<field name="message" readonly="1"/>
|
||||
<field name="target_model" invisible="1"/>
|
||||
<field name="extracted_payload" invisible="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="candidate_id" readonly="1" invisible="target_model == 'job_recruitment'"/>
|
||||
<field name="applicant_id" readonly="1" invisible="target_model != 'applicant'"/>
|
||||
<field name="attachment_id" readonly="1"/>
|
||||
<field name="file" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<notebook invisible="target_model == 'job_recruitment'">
|
||||
<page string="Candidate Details">
|
||||
<group>
|
||||
<group>
|
||||
<field name="full_name"/>
|
||||
<field name="first_name"/>
|
||||
<field name="last_name"/>
|
||||
<field name="email"/>
|
||||
<field name="phone"/>
|
||||
<field name="alternate_phone"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="linkedin_profile"/>
|
||||
<field name="current_location"/>
|
||||
<field name="current_organization"/>
|
||||
<field name="total_experience_years"/>
|
||||
<field name="relevant_experience_years"/>
|
||||
<field name="notice_period"/>
|
||||
<field name="degree"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<field name="summary" nolabel="1" placeholder="Summary"/>
|
||||
</group>
|
||||
</page>
|
||||
<page string="Skills">
|
||||
<field name="skills_text" nolabel="1" placeholder="Enter one skill per line"/>
|
||||
</page>
|
||||
<page string="Education">
|
||||
<field name="education_line_ids" nolabel="1">
|
||||
<list editable="bottom">
|
||||
<field name="education_type"/>
|
||||
<field name="specialization"/>
|
||||
<field name="university"/>
|
||||
<field name="start_year"/>
|
||||
<field name="end_year"/>
|
||||
<field name="marks_or_grade"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Experience">
|
||||
<field name="employer_line_ids" 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"/>
|
||||
<field name="work_description"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Family">
|
||||
<field name="family_line_ids" 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="Certifications">
|
||||
<field name="certification_line_ids" nolabel="1">
|
||||
<list editable="bottom">
|
||||
<field name="certification_name"/>
|
||||
<field name="provider"/>
|
||||
<field name="completion_date"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Other">
|
||||
<field name="other_line_ids" nolabel="1">
|
||||
<list editable="bottom">
|
||||
<field name="key"/>
|
||||
<field name="value"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
|
||||
<notebook invisible="target_model != 'job_recruitment'">
|
||||
<page string="Job Details">
|
||||
<group>
|
||||
<group>
|
||||
<field name="request_id"/>
|
||||
<field name="job_title"/>
|
||||
<field name="site_location"/>
|
||||
<field name="start_date"/>
|
||||
<field name="end_date"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="requested_by"/>
|
||||
<field name="no_of_positions"/>
|
||||
<field name="budget"/>
|
||||
<field name="experience_years"/>
|
||||
<field name="job_category"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<group>
|
||||
<field name="job_summary" nolabel="1" placeholder="Job Summary"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="requirements" nolabel="1" placeholder="Requirements"/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
<page string="Skills">
|
||||
<group>
|
||||
<field name="primary_skills_text" nolabel="1" placeholder="Enter one primary skill per line"/>
|
||||
<field name="secondary_skills_text" nolabel="1" placeholder="Enter one secondary skill per line"/>
|
||||
</group>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button name="action_save_line_changes" string="Save" type="object" class="btn-primary"/>
|
||||
<button name="action_close_line" string="Close" type="object" class="btn-secondary"/>
|
||||
</footer>
|
||||
</form>
|
||||
<kanban class="o_kanban_small_column">
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -156,7 +156,10 @@ class website_hr_recruitment_applications(http.Controller):
|
|||
|
||||
|
||||
|
||||
@http.route(['/SRIVYNPLATFORMS/JoiningForm/<int:applicant_id>'], type='http', auth="public",
|
||||
@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."""
|
||||
|
|
@ -174,7 +177,10 @@ class website_hr_recruitment_applications(http.Controller):
|
|||
return request.not_found()
|
||||
|
||||
|
||||
@http.route(['/SRIVYNPLATFORMS/submit/<int:applicant_id>/JoinForm'], type='http', auth="public",
|
||||
@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)
|
||||
|
|
@ -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', ''),
|
||||
|
|
@ -278,6 +284,13 @@ class website_hr_recruitment_applications(http.Controller):
|
|||
|
||||
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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -285,6 +285,10 @@ class HRApplicant(models.Model):
|
|||
string='Request Forms'
|
||||
)
|
||||
post_onboarding_form_status = fields.Selection([('draft','Draft'),('email_sent_to_candidate','Email Sent to Candidate'),('done','Done')], default='draft')
|
||||
doc_requests_form_status = fields.Selection(
|
||||
[('draft', 'Draft'), ('email_sent_to_candidate', 'Email Sent to Candidate'), ('done', 'Done')],
|
||||
default='draft',
|
||||
)
|
||||
legend_blocked = fields.Char(related='recruitment_stage_id.legend_blocked', string='Kanban Blocked')
|
||||
legend_done = fields.Char(related='recruitment_stage_id.legend_done', string='Kanban Valid')
|
||||
legend_normal = fields.Char(related='recruitment_stage_id.legend_normal', string='Kanban Ongoing')
|
||||
|
|
@ -301,6 +305,12 @@ class HRApplicant(models.Model):
|
|||
|
||||
approval_required = fields.Boolean(related='recruitment_stage_id.require_approval')
|
||||
application_submitted = fields.Boolean(string="Application Submitted")
|
||||
send_second_application_form = fields.Boolean(string="Send Second Application Form")
|
||||
send_post_onboarding_form = fields.Boolean(string="Send Post Onboarding Form")
|
||||
second_application_form_status = fields.Selection(
|
||||
[('draft', 'Draft'), ('email_sent_to_candidate', 'Email Sent to Candidate'), ('done', 'Done')],
|
||||
default='draft',
|
||||
)
|
||||
resume = fields.Binary(related='candidate_id.resume', readonly=False, compute_sudo=True)
|
||||
resume_type = fields.Char(related='candidate_id.resume_type', readonly=False, compute_sudo=True)
|
||||
resume_name = fields.Char(related='candidate_id.resume_name', readonly=False, compute_sudo=True)
|
||||
|
|
@ -366,6 +376,9 @@ class HRApplicant(models.Model):
|
|||
},
|
||||
}
|
||||
|
||||
def submit_to_client(self):
|
||||
return self.action_share_applicant()
|
||||
|
||||
def submit_for_approval(self):
|
||||
for rec in self:
|
||||
manager_id = self.env['ir.config_parameter'].sudo().get_param('requisitions.requisition_manager')
|
||||
|
|
@ -376,8 +389,7 @@ class HRApplicant(models.Model):
|
|||
render_ctx = dict(recruitment_manager=manager_id)
|
||||
mail_template.with_context(render_ctx).send_mail(
|
||||
self.id,
|
||||
force_send=True,
|
||||
email_layout_xmlid='mail.mail_notification_light')
|
||||
force_send=True)
|
||||
rec.application_submitted = True
|
||||
|
||||
def approve_applicant(self):
|
||||
|
|
@ -391,8 +403,7 @@ class HRApplicant(models.Model):
|
|||
render_ctx = dict(recruitment_manager=manager_id)
|
||||
mail_template.with_context(render_ctx).send_mail(
|
||||
self.id,
|
||||
force_send=True,
|
||||
email_layout_xmlid='mail.mail_notification_light')
|
||||
force_send=True,)
|
||||
rec.application_submitted = False
|
||||
recruitment_stage_ids = rec.hr_job_recruitment.recruitment_stage_ids.ids
|
||||
current_stage = self.env['hr.recruitment.stage'].browse(rec.recruitment_stage_id.id)
|
||||
|
|
@ -436,6 +447,9 @@ class HRApplicant(models.Model):
|
|||
'context': {'default_req_attachment_ids': []}
|
||||
}
|
||||
|
||||
def send_post_onboarding_form_to_candidate(self):
|
||||
return self.send_jod_form_to_employee()
|
||||
|
||||
|
||||
def send_pre_onboarding_doc_request_form_to_candidate(self):
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -161,11 +161,11 @@ class HRJobRecruitment(models.Model):
|
|||
company_id = fields.Many2one('res.company', string='Company', default=lambda self: self.env.company, tracking=True, exportable=False)
|
||||
contract_type_id = fields.Many2one('hr.contract.type', string='Employment Type', tracking=True)
|
||||
user_id = fields.Many2one('res.users', "Recruiter",
|
||||
domain="[('share', '=', False), ('company_ids', 'in', company_id)]",
|
||||
domain=lambda self:[('share', '=', False), ('company_ids', 'in', self.env.company.id),('groups_id', 'in', self.env.ref('hr_recruitment.group_hr_recruitment_user').id)],
|
||||
default=lambda self: self.env.user,
|
||||
tracking=True, help="The Recruiter will be the default value for all Applicants in this job \
|
||||
position. The Recruiter is automatically added to all meetings with the Applicant.")
|
||||
interviewer_ids = fields.Many2many('res.users', string='Interviewers', domain="[('share', '=', False), ('company_ids', 'in', company_id)]", tracking=True, help="The Interviewers set on the job position can see all Applicants in it. They have access to the information, the attachments, the meeting management and they can refuse him. You don't need to have Recruitment rights to be set as an interviewer.")
|
||||
interviewer_ids = fields.Many2many('res.users', string='Interviewers', domain=lambda self:[('share', '=', False), ('company_ids', 'in', self.env.company.id),('groups_id', 'in', self.env.ref('hr_recruitment.group_hr_recruitment_user').id)], tracking=True, help="The Interviewers set on the job position can see all Applicants in it. They have access to the information, the attachments, the meeting management and they can refuse him. You don't need to have Recruitment rights to be set as an interviewer.")
|
||||
skill_ids = fields.Many2many('hr.skill','hr_job_recruitment_hr_primary_skill_rel','job_id', 'user_id', string="Primary Skills", tracking=True)
|
||||
address_id = fields.Many2one(
|
||||
'res.partner', "Job Location", default=_default_address_id,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -5,3 +5,46 @@ class ResPartner(models.Model):
|
|||
_inherit = 'res.partner'
|
||||
|
||||
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)
|
||||
|
||||
# Vendor Statistics Fields
|
||||
total_positions_requested = fields.Integer(compute='_compute_vendor_statistics', string='Positions Requested')
|
||||
total_submitted = fields.Integer(compute='_compute_vendor_statistics', string='Total Submitted')
|
||||
total_hired = fields.Integer(compute='_compute_vendor_statistics', string='Total Hired')
|
||||
total_rejected = fields.Integer(compute='_compute_vendor_statistics', string='Total Rejected')
|
||||
job_recruitment_ids = fields.One2many('hr.job.recruitment', 'requested_by', string='Job Requests')
|
||||
|
||||
@api.depends('contact_type')
|
||||
def _compute_vendor_statistics(self):
|
||||
for partner in self:
|
||||
if partner.contact_type == 'external':
|
||||
# Get all job recruitments requested by this vendor
|
||||
job_recruitments = self.env['hr.job.recruitment'].search([
|
||||
('requested_by', '=', partner.id)
|
||||
])
|
||||
|
||||
partner.total_positions_requested = sum(job_recruitments.mapped('no_of_recruitment'))
|
||||
partner.total_submitted = sum(job_recruitments.mapped('no_of_submissions'))
|
||||
partner.total_hired = sum(job_recruitments.mapped('no_of_hired_employee'))
|
||||
partner.total_rejected = sum(job_recruitments.mapped('no_of_refused_submissions'))
|
||||
else:
|
||||
partner.total_positions_requested = 0
|
||||
partner.total_submitted = 0
|
||||
partner.total_hired = 0
|
||||
partner.total_rejected = 0
|
||||
|
||||
def action_create_job_request(self):
|
||||
"""Action to create a new job request from vendor"""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Create Job Request'),
|
||||
'res_model': 'hr.job.recruitment',
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
'context': {
|
||||
'default_requested_by': self.id,
|
||||
'default_recruitment_type': 'external',
|
||||
'default_address_id': self.id if self.is_company else (self.parent_id.id if self.parent_id else False),
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -36,6 +36,6 @@ access_applicant_stage_comment_wizard,applicant.stage.comment.wizard.user,model_
|
|||
access_hr_application_public,hr.applicant.public.access,hr_recruitment.model_hr_applicant,base.group_public,1,0,0,0
|
||||
access_hr_application_group_hr,hr.applicant.hr.access,hr_recruitment.model_hr_applicant,hr.group_hr_manager,1,1,0,0
|
||||
access_applicant_request_forms_hr_user,access.applicant.request.forms.hr.user,model_applicant_request_forms,hr.group_hr_user,1,1,1,1
|
||||
access_applicant_request_forms_user,access.applicant.request.forms.user,model_applicant_request_forms,base.group_user,1,1,0,0
|
||||
access_applicant_request_forms_user,access.applicant.request.forms.user,model_applicant_request_forms,base.group_user,1,1,1,0
|
||||
access_hr_skill,access.hr.skill.user,hr_skills.model_hr_skill,base.group_public,1,0,0,0
|
||||
|
||||
|
|
|
|||
|
|
|
@ -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')] "/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -218,6 +218,17 @@
|
|||
<xpath expr="//filter[@name='refused']" position="attributes">
|
||||
<attribute name="string">Rejected</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//group" position="before">
|
||||
<searchtab name="inprogress_records_tab" string="In Progress">
|
||||
<filter string="In Progress" name="ongoing" domain="[('date_closed', '=', False), ('active', '=', True), ('refuse_reason_id', '=', False)]"/>
|
||||
</searchtab>
|
||||
<searchtab name="hired_records_tab" string="Hired">
|
||||
<filter string="Hired" name="hired" domain="[('date_closed', '!=', False)]"/>
|
||||
</searchtab>
|
||||
<searchtab name="refused_records_tab" string="Refused">
|
||||
<filter string="Refused" name="refused" domain="[('active', '=', False), ('refuse_reason_id', '!=', False)]"/>
|
||||
</searchtab>
|
||||
</xpath>
|
||||
<xpath expr="//filter[@name='refuse_reason_id']" position="attributes">
|
||||
<attribute name="string">Reject Reason</attribute>
|
||||
</xpath>
|
||||
|
|
|
|||
|
|
@ -246,6 +246,20 @@
|
|||
<filter string="Unpublished" name="unpublished_records_tab_filter"
|
||||
domain="[('website_published','=',False)]"/>
|
||||
</searchtab>
|
||||
<searchtab name="open_records_tab" string="Open">
|
||||
<filter name="open_status" string="Open"
|
||||
domain="[('recruitment_status','=', 'open')]"/>
|
||||
</searchtab>
|
||||
<searchtab name="closed_records_tab" string="Closed">
|
||||
<filter name="closed_status" string="Closed"
|
||||
domain="[('recruitment_status','=', 'closed')]"/>
|
||||
</searchtab>
|
||||
<searchtab name="hold_records_tab" string="Hold">
|
||||
<filter name="hold_status" string="Hold"
|
||||
domain="[('recruitment_status','=', 'hold')]"/>
|
||||
</searchtab>
|
||||
|
||||
|
||||
<filter string="Published Records" name="published_records" domain="[('website_published','=',True)]"/>
|
||||
<filter string="UnPublished Records" name="unpublished_records" domain="[('website_published','=',False)]"/>
|
||||
<separator/>
|
||||
|
|
@ -462,7 +476,7 @@
|
|||
<field name="view_mode">kanban,list,form,search</field>
|
||||
<field name="search_view_id" ref="view_job_recruitment_filter"/>
|
||||
<field name="domain"></field>
|
||||
<field name="context">{"search_default_open_status":1,"search_default_my_assignments":1}</field>
|
||||
<field name="context">{"search_default_my_assignments":1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Ready to recruit more efficiently?
|
||||
|
|
|
|||
|
|
@ -268,6 +268,19 @@
|
|||
<field name="model">hr.candidate</field>
|
||||
<field name="inherit_id" ref="hr_recruitment.hr_candidate_view_search"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//group" position="before">
|
||||
<searchtab name="inprogress_records_tab" string="Application In Progress">
|
||||
<filter string="Application in Progress" name="application_in_progress" domain="[('applicant_ids.application_status', '=', 'ongoing')]"/>
|
||||
</searchtab>
|
||||
<searchtab name="hired_records_tab" string="Hired">
|
||||
<filter string="Hired" name="hired" domain="[('applicant_ids.application_status', '=', 'hired')]"/>
|
||||
</searchtab>
|
||||
|
||||
<searchtab name="refused_records_tab" string="Refused">
|
||||
<filter string="Refused" name="refused" context="{'active_test': False}" domain="[('applicant_ids.application_status', '=', 'refused')]"/>
|
||||
</searchtab>
|
||||
</xpath>
|
||||
|
||||
<xpath expr="//field[@name='partner_name']" position="after">
|
||||
<field name="candidate_sequence"/>
|
||||
</xpath>
|
||||
|
|
|
|||
|
|
@ -195,18 +195,18 @@
|
|||
</template>
|
||||
|
||||
|
||||
<template id="post_onboarding_form_template" name="SRIVYN PLATFORMS Joining Form">
|
||||
<template id="post_onboarding_form_template" name="FTPROTECH Joining Form">
|
||||
<t t-call="website.layout">
|
||||
<section class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-10">
|
||||
<div class="card shadow-lg p-4">
|
||||
<h1 class="form-header">Welcome to SRIVYN PLATFORMS</h1>
|
||||
<h1 class="form-header">Welcome to FTPROTECH</h1>
|
||||
<h4 class="form-subHeader">Joining Form</h4>
|
||||
<hr/>
|
||||
|
||||
<form id="post_onboarding_form"
|
||||
t-att-action="'/SRIVYNPLATFORMS/submit/%s/JoinForm'%(applicant.id)" method="post" enctype="multipart/form-data">
|
||||
t-att-action="'/FTPROTECH/submit/%s/JoinForm'%(applicant.id)" method="post" enctype="multipart/form-data">
|
||||
<div>
|
||||
<!-- Upload or Capture Photo -->
|
||||
<input type="hidden" name="applicant_id" t-att-value="applicant.id"/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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"/>
|
||||
|
||||
|
|
@ -97,6 +98,58 @@
|
|||
|
||||
</group>
|
||||
|
||||
<div class="row" invisible="contact_type != 'external'">
|
||||
<div class="col-12">
|
||||
<div class="card bg-secondary">
|
||||
<div class="card-header bg-primary text-white" style="color: white;">
|
||||
<h5 class="mb-0" style="color: white;"><i class="fa fa-chart-bar me-2"/>Recruitment Statistics</h5>
|
||||
</div>
|
||||
<div class="card-body" style="background-color: #f8f9fa;">
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<div class="text-center p-3">
|
||||
<div class="text-muted small">Positions Requested</div>
|
||||
<div class="display-4 fw-bold text-primary">
|
||||
<field name="total_positions_requested"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-center p-3">
|
||||
<div class="text-muted small">Total Submitted</div>
|
||||
<div class="display-4 fw-bold text-success">
|
||||
<field name="total_submitted"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-center p-3">
|
||||
<div class="text-muted small">Total Hired</div>
|
||||
<div class="display-4 fw-bold text-info">
|
||||
<field name="total_hired"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-center p-3">
|
||||
<div class="text-muted small">Total Rejected</div>
|
||||
<div class="display-4 fw-bold text-danger">
|
||||
<field name="total_rejected"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="oe_button_box" invisible="contact_type != 'external'">
|
||||
<button name="action_create_job_request" type="object" class="btn btn-primary">
|
||||
<i class="fa fa-plus me-1"/>Create Job Request
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<notebook>
|
||||
|
||||
<page string="Contacts & Addresses">
|
||||
|
|
@ -136,6 +189,22 @@
|
|||
|
||||
</page>
|
||||
|
||||
<page string="Job Requests" invisible="contact_type != 'external'">
|
||||
<field name="id" invisible="1"/>
|
||||
<field name="contact_type" invisible="1"/>
|
||||
<field name="job_recruitment_ids" widget="many2many">
|
||||
<list>
|
||||
<field name="recruitment_sequence"/>
|
||||
<field name="name"/>
|
||||
<field name="no_of_recruitment"/>
|
||||
<field name="no_of_submissions"/>
|
||||
<field name="no_of_hired_employee"/>
|
||||
<field name="no_of_refused_submissions"/>
|
||||
<field name="recruitment_status"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
|
||||
</notebook>
|
||||
|
||||
</sheet>
|
||||
|
|
@ -181,43 +250,88 @@
|
|||
<field name="name"/>
|
||||
<field name="phone"/>
|
||||
<field name="email"/>
|
||||
<field name="total_positions_requested"/>
|
||||
<field name="total_submitted"/>
|
||||
<field name="total_hired"/>
|
||||
<field name="total_rejected"/>
|
||||
|
||||
<templates>
|
||||
<t t-name="card">
|
||||
|
||||
<div class="oe_kanban_global_click d-flex p-2">
|
||||
|
||||
<!-- Avatar -->
|
||||
<div class="me-2">
|
||||
<field name="image_128"
|
||||
widget="image"
|
||||
class="rounded"
|
||||
options="{'size': [48,48]}"/>
|
||||
<div class="oe_kanban_global_click">
|
||||
<div class="o_kanban_card_header">
|
||||
<div class="o_kanban_card_header_title">
|
||||
<div class="me-2">
|
||||
<field name="image_128"
|
||||
widget="image"
|
||||
class="rounded-circle oe_avatar"
|
||||
style="width:64px; height:64px; min-width:64px; min-height:64px; object-fit:cover;"
|
||||
options="{'size': [64, 64]}"/>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<strong class="o_kanban_record_title">
|
||||
<field name="name"/>
|
||||
</strong>
|
||||
<div t-if="record.email.raw_value" class="text-muted small">
|
||||
<i class="fa fa-envelope me-1"/>
|
||||
<field name="email"/>
|
||||
</div>
|
||||
<div t-if="record.phone.raw_value" class="text-muted small">
|
||||
<i class="fa fa-phone me-1"/>
|
||||
<field name="phone"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Details -->
|
||||
<div class="flex-grow-1 overflow-hidden">
|
||||
|
||||
<div class="fw-bold text-truncate">
|
||||
<field name="name"/>
|
||||
<div class="o_kanban_card_content">
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-6">
|
||||
<div class="card bg-light border-0" style="background-color: #f8f9fa;">
|
||||
<div class="card-body p-2 text-center">
|
||||
<div class="text-muted small mb-1" style="color: black;">Requested</div>
|
||||
<div class="fw-bold text-primary fs-5">
|
||||
<field name="total_positions_requested"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="card bg-light border-0" style="background-color: #f8f9fa;">
|
||||
<div class="card-body p-2 text-center">
|
||||
<div class="text-muted small mb-1" style="color: black;">Submitted</div>
|
||||
<div class="fw-bold text-success fs-5">
|
||||
<field name="total_submitted"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="card bg-light border-0" style="background-color: #f8f9fa;">
|
||||
<div class="card-body p-2 text-center">
|
||||
<div class="text-muted small mb-1" style="color: black;">Hired</div>
|
||||
<div class="fw-bold text-info fs-5">
|
||||
<field name="total_hired"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="card bg-light border-0" style="background-color: #f8f9fa;">
|
||||
<div class="card-body p-2 text-center">
|
||||
<div class="text-muted small mb-1" style="color: black;">Rejected</div>
|
||||
<div class="fw-bold text-danger fs-5">
|
||||
<field name="total_rejected"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div t-if="record.phone.raw_value"
|
||||
class="text-muted small">
|
||||
<i class="fa fa-phone me-1"/>
|
||||
<field name="phone"/>
|
||||
</div>
|
||||
|
||||
<div t-if="record.email.raw_value"
|
||||
class="text-muted small text-truncate">
|
||||
<i class="fa fa-envelope me-1"/>
|
||||
<field name="email"/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="o_kanban_card_manage">
|
||||
<button name="action_create_job_request" type="object" class="btn btn-primary btn-sm">
|
||||
<i class="fa fa-plus me-1"/>Create Job Request
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>
|
||||
|
|
@ -283,13 +397,22 @@
|
|||
active="0"
|
||||
sequence="10"/>
|
||||
|
||||
<record id="hr_recruitment.menu_hr_recruitment_configuration" model="ir.ui.menu">
|
||||
<field name="groups_id" eval="[(6, 0, [ref('hr_recruitment.group_hr_recruitment_manager')])]"/>
|
||||
</record>
|
||||
|
||||
|
||||
<menuitem
|
||||
id="menu_hr_recruitment_stage"
|
||||
name="Vendors"
|
||||
name="Vendor"
|
||||
parent="hr_recruitment.menu_hr_recruitment_root"
|
||||
action="action_vendor_partner"
|
||||
groups="base.group_user"
|
||||
groups="hr_recruitment.group_hr_recruitment_manager"
|
||||
sequence="98"/>
|
||||
|
||||
<record id="hr_recruitment_extended.menu_hr_recruitment_stage" model="ir.ui.menu">
|
||||
<field name="groups_id" eval="[(6, 0, [ref('hr_recruitment.group_hr_recruitment_manager')])]"/>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -1 +1,2 @@
|
|||
from . import controllers
|
||||
from . import models
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1 @@
|
|||
from . import res_config_settings
|
||||
|
|
@ -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,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,27 @@
|
|||
<div t-if="state.loading" class="hrms-loading">Loading employee dashboard...</div>
|
||||
|
||||
<t t-if="state.data && !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>
|
||||
|
|
@ -52,8 +67,8 @@
|
|||
</button>
|
||||
</div>
|
||||
<div class="hrms-filter-box">
|
||||
<label>Period</label>
|
||||
<select class="form-select form-select-sm" t-on-change="onPeriodChange" t-att-value="state.period">
|
||||
<span>Period</span>
|
||||
<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' && !'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 >= 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 && 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>-->
|
||||
<!-- <!– 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">-->
|
||||
<!-- <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] && 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 && state.selectedDay.signals.length" class="hrms-modal-events">
|
||||
<span class="hrms-modal-events-label">Events & 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 && 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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,2 @@
|
|||
from . import stages
|
||||
from . import offer_letter
|
||||
from . import hr_applicant
|
||||
from . import hr_candidate
|
||||
from . import recruitment_employee_bridge
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -27,24 +27,46 @@ class OfferLetter(models.Model):
|
|||
default=lambda self: _('New'),
|
||||
copy=False
|
||||
)
|
||||
candidate_id = fields.Many2one( 'hr.applicant', string='Applicant', required=False,
|
||||
bridge_id = fields.Many2one(
|
||||
'recruitment.employee.bridge',
|
||||
string='Onboarding',
|
||||
ondelete='restrict',
|
||||
tracking=True,
|
||||
index=True,
|
||||
)
|
||||
main_candidate_id = fields.Many2one('hr.candidate',string='Candidate', readonly=False, required=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.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):
|
||||
@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:
|
||||
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 = 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)
|
||||
|
||||
|
|
@ -122,17 +144,17 @@ class OfferLetter(models.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)
|
||||
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('candidate_id'):
|
||||
record.candidate_id.finalized_ctc = record.salary
|
||||
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):
|
||||
|
|
@ -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},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
|
@ -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"/>
|
||||
|
|
@ -51,6 +50,7 @@
|
|||
<field name="pay_struct_id"/>
|
||||
<field name="sent_date" readonly="1"/>
|
||||
<field name="response_date" readonly="1"/>
|
||||
<field name="recipient_email" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -1,3 +1,2 @@
|
|||
from . import applicant_offer_mail_wizard
|
||||
from . import offer_release_request_wizard
|
||||
from . import offer_letter_reject_wizard
|
||||
|
|
|
|||
|
|
@ -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', ''),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
@ -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>
|
||||
|
|
@ -117,7 +117,7 @@ def post_init_hook(env):
|
|||
('project_id.privacy_visibility', '=', 'followers'),
|
||||
'|',
|
||||
'|',
|
||||
('project_id.project_lead', '=', user.id),
|
||||
('project_id.project_lead', 'in', [user.id]),
|
||||
('project_id.user_id', '=', user.id),
|
||||
'|',
|
||||
'&',
|
||||
|
|
|
|||
|
|
@ -7,6 +7,37 @@ import pytz
|
|||
class ProjectProject(models.Model):
|
||||
_inherit = 'project.project'
|
||||
|
||||
def init(self):
|
||||
super().init()
|
||||
self.env.cr.execute("""
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'project_project'
|
||||
AND column_name = 'project_lead'
|
||||
""")
|
||||
if not self.env.cr.fetchone():
|
||||
return
|
||||
|
||||
self.env.cr.execute("""
|
||||
SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_name = 'project_project_lead_rel'
|
||||
""")
|
||||
if not self.env.cr.fetchone():
|
||||
return
|
||||
|
||||
self.env.cr.execute("""
|
||||
INSERT INTO project_project_lead_rel (project_id, user_id)
|
||||
SELECT project.id, project.project_lead
|
||||
FROM project_project project
|
||||
WHERE project.project_lead IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM project_project_lead_rel rel
|
||||
WHERE rel.project_id = project.id
|
||||
AND rel.user_id = project.project_lead
|
||||
)
|
||||
""")
|
||||
|
||||
@api.constrains('name')
|
||||
def _check_duplicate_project_name(self):
|
||||
|
|
@ -49,6 +80,21 @@ class ProjectProject(models.Model):
|
|||
project_stages = fields.One2many('project.stages.approval.flow', 'project_id')
|
||||
assign_approval_flow = fields.Boolean(default=False)
|
||||
project_sponsor = fields.Many2one('res.users')
|
||||
allowed_project_manager_user_ids = fields.Many2many(
|
||||
'res.users',
|
||||
compute='_compute_portfolio_allowed_users',
|
||||
string='Allowed Project Managers',
|
||||
)
|
||||
allowed_project_lead_user_ids = fields.Many2many(
|
||||
'res.users',
|
||||
compute='_compute_portfolio_allowed_users',
|
||||
string='Allowed Project Leads',
|
||||
)
|
||||
allowed_internal_team_ids = fields.Many2many(
|
||||
'internal.teams',
|
||||
compute='_compute_portfolio_allowed_teams',
|
||||
string='Allowed Internal Teams',
|
||||
)
|
||||
show_project_chatter = fields.Boolean(default=False)
|
||||
project_vision = fields.Text(
|
||||
string="Project Vision",
|
||||
|
|
@ -207,6 +253,27 @@ class ProjectProject(models.Model):
|
|||
"project_id",
|
||||
string="Project Sprints"
|
||||
)
|
||||
active_sprint_id = fields.Many2one(
|
||||
"project.sprint",
|
||||
compute="_compute_sprint_planning_metrics",
|
||||
string="Active Sprint",
|
||||
)
|
||||
sprint_count = fields.Integer(
|
||||
compute="_compute_sprint_planning_metrics",
|
||||
string="Sprints",
|
||||
)
|
||||
sprint_backlog_task_count = fields.Integer(
|
||||
compute="_compute_sprint_planning_metrics",
|
||||
string="Backlog Tasks",
|
||||
)
|
||||
sprint_planned_hours = fields.Float(
|
||||
compute="_compute_sprint_planning_metrics",
|
||||
string="Sprint Planned Hours",
|
||||
)
|
||||
sprint_actual_hours = fields.Float(
|
||||
compute="_compute_sprint_planning_metrics",
|
||||
string="Sprint Actual Hours",
|
||||
)
|
||||
|
||||
commit_step_ids = fields.One2many(
|
||||
'project.commit.step',
|
||||
|
|
@ -270,6 +337,45 @@ class ProjectProject(models.Model):
|
|||
hold_reason = fields.Text(string="Hold Reason", tracking=True)
|
||||
privacy_visibility = fields.Selection(default="followers")
|
||||
|
||||
@api.depends(
|
||||
"sprint_ids",
|
||||
"sprint_ids.status",
|
||||
"sprint_ids.task_ids",
|
||||
"sprint_ids.task_ids.sprint_estimated_hours",
|
||||
"sprint_ids.task_ids.estimated_hours",
|
||||
"sprint_ids.task_ids.actual_hours",
|
||||
"task_ids.sprint_id",
|
||||
)
|
||||
def _compute_sprint_planning_metrics(self):
|
||||
for project in self:
|
||||
active_sprint = project.sprint_ids.filtered(lambda sprint: sprint.status == "in_progress")[:1]
|
||||
sprint_tasks = project.task_ids.filtered(lambda task: task.sprint_id)
|
||||
project.active_sprint_id = active_sprint
|
||||
project.sprint_count = len(project.sprint_ids)
|
||||
project.sprint_backlog_task_count = len(project.task_ids.filtered(lambda task: not task.sprint_id))
|
||||
project.sprint_planned_hours = sum(task.sprint_estimated_hours or task.estimated_hours for task in sprint_tasks)
|
||||
project.sprint_actual_hours = sum(sprint_tasks.mapped("actual_hours"))
|
||||
|
||||
def action_open_sprint_backlog(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
"type": "ir.actions.act_window",
|
||||
"name": _("Sprint Backlog"),
|
||||
"res_model": "project.task",
|
||||
"view_mode": "kanban,list,form,calendar,pivot,graph,activity",
|
||||
"domain": [("project_id", "=", self.id), ("sprint_id", "=", False)],
|
||||
"context": {
|
||||
"default_project_id": self.id,
|
||||
"search_default_project_id": self.id,
|
||||
},
|
||||
}
|
||||
|
||||
def action_open_active_sprint_tasks(self):
|
||||
self.ensure_one()
|
||||
if not self.active_sprint_id:
|
||||
raise UserError(_("There is no active sprint for this project."))
|
||||
return self.active_sprint_id.action_open_tasks()
|
||||
|
||||
def action_hold_unhold(self):
|
||||
for project in self:
|
||||
if project.project_state == 'hold':
|
||||
|
|
@ -340,7 +446,7 @@ class ProjectProject(models.Model):
|
|||
if project.user_id:
|
||||
users_list.append(project.user_id.id)
|
||||
if project.project_lead:
|
||||
users_list.append(project.project_lead.id)
|
||||
users_list.extend(project.project_lead.ids)
|
||||
if project.type_ids:
|
||||
for task_stage in project.type_ids:
|
||||
if task_stage.team_id:
|
||||
|
|
@ -1082,15 +1188,132 @@ class ProjectProject(models.Model):
|
|||
def _default_type_ids(self):
|
||||
return self._get_default_task_stage_templates()
|
||||
|
||||
def _get_role_users_for_portfolio(self, role_xmlid):
|
||||
self.ensure_one()
|
||||
role = self.env.ref(role_xmlid, raise_if_not_found=False)
|
||||
if not role:
|
||||
return self.env['res.users']
|
||||
if self.portfolio_id:
|
||||
assignment = self.portfolio_id.role_assignment_ids.filtered(
|
||||
lambda line: line.role_id == role
|
||||
)[:1]
|
||||
if assignment:
|
||||
return assignment.user_ids
|
||||
return role.user_ids
|
||||
|
||||
project_lead = fields.Many2one("res.users", string="Project Lead",
|
||||
domain=lambda self: [('id','in',self.env.ref('project_task_timesheet_extended.role_project_lead').user_ids.ids)])
|
||||
def _get_portfolio_role_assignment(self, role_xmlid):
|
||||
self.ensure_one()
|
||||
role = self.env.ref(role_xmlid, raise_if_not_found=False)
|
||||
if not role or not self.portfolio_id:
|
||||
return self.env['project.portfolio.role.assignment']
|
||||
return self.portfolio_id.role_assignment_ids.filtered(
|
||||
lambda line: line.role_id == role
|
||||
)[:1]
|
||||
|
||||
@api.depends(
|
||||
'portfolio_id',
|
||||
'portfolio_id.role_assignment_ids.role_id',
|
||||
'portfolio_id.role_assignment_ids.user_ids',
|
||||
)
|
||||
def _compute_portfolio_allowed_users(self):
|
||||
manager_groups = (
|
||||
self.env.ref('project.group_project_manager')
|
||||
| self.env.ref('project_task_timesheet_extended.group_project_supervisor')
|
||||
)
|
||||
for project in self:
|
||||
manager_users = project._get_role_users_for_portfolio(
|
||||
'project_task_timesheet_extended.role_project_manager'
|
||||
)
|
||||
project.allowed_project_manager_user_ids = manager_users.filtered(
|
||||
lambda user: not user.share and bool(user.groups_id & manager_groups)
|
||||
)
|
||||
project.allowed_project_lead_user_ids = project._get_role_users_for_portfolio(
|
||||
'project_task_timesheet_extended.role_project_lead'
|
||||
)
|
||||
|
||||
@api.depends('portfolio_id', 'portfolio_id.internal_team_ids')
|
||||
def _compute_portfolio_allowed_teams(self):
|
||||
Team = self.env['internal.teams']
|
||||
fallback_teams = Team.search([('active', '=', True)])
|
||||
for project in self:
|
||||
project.allowed_internal_team_ids = (
|
||||
project.portfolio_id.internal_team_ids
|
||||
if project.portfolio_id and project.portfolio_id.internal_team_ids
|
||||
else fallback_teams
|
||||
)
|
||||
|
||||
@api.onchange('portfolio_id')
|
||||
def _onchange_portfolio_id_scope_assignments(self):
|
||||
for project in self:
|
||||
manager_assignment = project._get_portfolio_role_assignment(
|
||||
'project_task_timesheet_extended.role_project_manager'
|
||||
)
|
||||
lead_assignment = project._get_portfolio_role_assignment(
|
||||
'project_task_timesheet_extended.role_project_lead'
|
||||
)
|
||||
if (
|
||||
manager_assignment
|
||||
and project.user_id
|
||||
and project.user_id not in project.allowed_project_manager_user_ids
|
||||
):
|
||||
project.user_id = False
|
||||
if lead_assignment and project.project_lead:
|
||||
project.project_lead &= project.allowed_project_lead_user_ids
|
||||
if project.portfolio_id.internal_team_ids:
|
||||
for task_stage in project.type_ids:
|
||||
if task_stage.team_id and task_stage.team_id not in project.allowed_internal_team_ids:
|
||||
task_stage.team_id = False
|
||||
task_stage.involved_user_ids = [Command.clear()]
|
||||
|
||||
@api.constrains('portfolio_id', 'user_id', 'project_lead')
|
||||
def _check_portfolio_role_users(self):
|
||||
for project in self:
|
||||
manager_assignment = project._get_portfolio_role_assignment(
|
||||
'project_task_timesheet_extended.role_project_manager'
|
||||
)
|
||||
lead_assignment = project._get_portfolio_role_assignment(
|
||||
'project_task_timesheet_extended.role_project_lead'
|
||||
)
|
||||
if (
|
||||
manager_assignment
|
||||
and project.user_id
|
||||
and project.user_id not in project.allowed_project_manager_user_ids
|
||||
):
|
||||
raise ValidationError(_(
|
||||
'The project manager must be one of the users selected for the Project Manager role on this portfolio.'
|
||||
))
|
||||
if lead_assignment and project.project_lead - project.allowed_project_lead_user_ids:
|
||||
raise ValidationError(_(
|
||||
'Project leads must be selected from the Project Lead users configured on this portfolio.'
|
||||
))
|
||||
|
||||
@api.constrains('portfolio_id', 'type_ids', 'type_ids.team_id')
|
||||
def _check_portfolio_internal_teams(self):
|
||||
for project in self:
|
||||
if not project.portfolio_id.internal_team_ids:
|
||||
continue
|
||||
invalid_stages = project.type_ids.filtered(
|
||||
lambda stage: stage.team_id and stage.team_id not in project.allowed_internal_team_ids
|
||||
)
|
||||
if invalid_stages:
|
||||
raise ValidationError(_(
|
||||
'Task stage teams must be selected from the internal teams configured on this portfolio.'
|
||||
))
|
||||
|
||||
project_lead = fields.Many2many(
|
||||
"res.users",
|
||||
"project_project_lead_rel",
|
||||
"project_id",
|
||||
"user_id",
|
||||
string="Project Lead",
|
||||
domain=lambda self: [('id', 'in', self.env.ref('project_task_timesheet_extended.role_project_lead').user_ids.ids)],
|
||||
)
|
||||
members_ids = fields.Many2many('res.users', 'project_user_rel', 'project_id',
|
||||
'user_id', 'Project Members', help="""Project's
|
||||
members are users who can have an access to
|
||||
the tasks related to this project."""
|
||||
)
|
||||
user_id = fields.Many2one('res.users', string='Project Manager', default=False, tracking=True, required = True,
|
||||
user_id = fields.Many2one('res.users', string='Project Manager', default=False, tracking=True,
|
||||
domain=lambda self: [('id','in',self.env.ref('project_task_timesheet_extended.role_project_manager').user_ids.ids),('groups_id', 'in', [self.env.ref('project.group_project_manager').id,self.env.ref('project_task_timesheet_extended.group_project_supervisor').id]),('share','=',False)],)
|
||||
|
||||
# @api.constrains('user_id')
|
||||
|
|
@ -1142,8 +1365,8 @@ class ProjectTask(models.Model):
|
|||
|
||||
def _default_sprint_id(self):
|
||||
"""Return the current active (in-progress) sprint of the project."""
|
||||
if 'project_id' in self._context:
|
||||
project_id = self._context.get('project_id')
|
||||
project_id = self._context.get('default_project_id') or self._context.get('project_id')
|
||||
if project_id:
|
||||
sprint = self.env['project.sprint'].search([
|
||||
('project_id', '=', project_id),
|
||||
('status', '=', 'in_progress')
|
||||
|
|
@ -1154,7 +1377,9 @@ class ProjectTask(models.Model):
|
|||
sprint_id = fields.Many2one(
|
||||
"project.sprint",
|
||||
string="Sprint",
|
||||
default=_default_sprint_id
|
||||
default=_default_sprint_id,
|
||||
tracking=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
require_sprint = fields.Boolean(
|
||||
|
|
@ -1187,6 +1412,8 @@ class ProjectTask(models.Model):
|
|||
for task in self:
|
||||
if task.project_id and not task.project_id.require_sprint:
|
||||
task.sprint_id = False
|
||||
elif task.sprint_id and task.sprint_id.project_id != task.project_id:
|
||||
task.sprint_id = False
|
||||
else:
|
||||
if task.project_id and task.project_id.require_sprint:
|
||||
sprint = self.env['project.sprint'].search([
|
||||
|
|
@ -1195,10 +1422,35 @@ class ProjectTask(models.Model):
|
|||
], limit=1)
|
||||
task.sprint_id = sprint.id
|
||||
|
||||
@api.onchange("sprint_id")
|
||||
def _onchange_sprint_id_project(self):
|
||||
for task in self:
|
||||
if task.sprint_id and not task.project_id:
|
||||
task.project_id = task.sprint_id.project_id
|
||||
|
||||
@api.constrains("project_id", "sprint_id")
|
||||
def _check_sprint_project(self):
|
||||
for task in self:
|
||||
if task.sprint_id and task.project_id and task.sprint_id.project_id != task.project_id:
|
||||
raise ValidationError(_("Task sprint must belong to the same project as the task."))
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
project_id = vals.get("project_id") or self._context.get("default_project_id")
|
||||
if project_id and "sprint_id" not in vals:
|
||||
project = self.env["project.project"].browse(project_id)
|
||||
if project.require_sprint:
|
||||
sprint = self.env["project.sprint"].search([
|
||||
("project_id", "=", project.id),
|
||||
("status", "=", "in_progress"),
|
||||
], limit=1)
|
||||
if sprint:
|
||||
vals["sprint_id"] = sprint.id
|
||||
return super().create(vals_list)
|
||||
|
||||
|
||||
def action_show_project_task_chatter(self):
|
||||
"""Toggle visibility of project chatter"""
|
||||
for project in self:
|
||||
project.show_task_chatter = not project.show_task_chatter
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import ValidationError
|
||||
from datetime import date, timedelta
|
||||
import json
|
||||
|
||||
|
|
@ -28,6 +29,20 @@ class ProjectPortfolio(models.Model):
|
|||
'portfolio_id',
|
||||
string='Projects'
|
||||
)
|
||||
internal_team_ids = fields.Many2many(
|
||||
'internal.teams',
|
||||
'project_portfolio_internal_team_rel',
|
||||
'portfolio_id',
|
||||
'team_id',
|
||||
string='Internal Teams',
|
||||
help='Internal teams available for projects linked to this portfolio.'
|
||||
)
|
||||
role_assignment_ids = fields.One2many(
|
||||
'project.portfolio.role.assignment',
|
||||
'portfolio_id',
|
||||
string='Role Assignments',
|
||||
help='Portfolio-specific users available for each project role.'
|
||||
)
|
||||
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
|
|
@ -542,6 +557,60 @@ class Project(models.Model):
|
|||
)
|
||||
|
||||
|
||||
class ProjectPortfolioRoleAssignment(models.Model):
|
||||
_name = 'project.portfolio.role.assignment'
|
||||
_description = 'Project Portfolio Role Assignment'
|
||||
_order = 'role_id'
|
||||
|
||||
portfolio_id = fields.Many2one(
|
||||
'project.portfolio',
|
||||
string='Portfolio',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
role_id = fields.Many2one(
|
||||
'project.role',
|
||||
string='Role',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
master_user_ids = fields.Many2many(
|
||||
'res.users',
|
||||
related='role_id.user_ids',
|
||||
string='Master Assigned Users',
|
||||
readonly=True,
|
||||
)
|
||||
user_ids = fields.Many2many(
|
||||
'res.users',
|
||||
'project_portfolio_role_user_rel',
|
||||
'assignment_id',
|
||||
'user_id',
|
||||
string='Portfolio Users',
|
||||
domain="[('id', 'in', master_user_ids)]",
|
||||
help='Users selected for this role in this portfolio.'
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
(
|
||||
'portfolio_role_unique',
|
||||
'unique(portfolio_id, role_id)',
|
||||
'Each role can only be configured once per portfolio.',
|
||||
),
|
||||
]
|
||||
|
||||
@api.onchange('role_id')
|
||||
def _onchange_role_id(self):
|
||||
for assignment in self:
|
||||
assignment.user_ids &= assignment.role_id.user_ids
|
||||
|
||||
@api.constrains('role_id', 'user_ids')
|
||||
def _check_user_ids_in_role_master(self):
|
||||
for assignment in self:
|
||||
if assignment.user_ids - assignment.role_id.user_ids:
|
||||
raise ValidationError(_(
|
||||
'Portfolio users must be selected from the users assigned on the role master.'
|
||||
))
|
||||
|
||||
|
||||
class ProjectPortfolioEmployeePerformance(models.Model):
|
||||
_name = 'project.portfolio.employee.performance'
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from odoo import fields, models
|
||||
from odoo import _, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class ProjectRole(models.Model):
|
||||
|
|
@ -29,6 +30,14 @@ class ProjectRole(models.Model):
|
|||
string='Assigned Users',
|
||||
help="Users assigned to this role"
|
||||
)
|
||||
required_groups = fields.Many2many(
|
||||
'res.groups',
|
||||
'project_role_required_group_rel',
|
||||
'role_id',
|
||||
'group_id',
|
||||
string='Required Groups',
|
||||
help="Groups that should be granted to the assigned users for this role"
|
||||
)
|
||||
active = fields.Boolean(
|
||||
string='Active',
|
||||
default=True,
|
||||
|
|
@ -58,6 +67,32 @@ class ProjectRole(models.Model):
|
|||
'context': {'default_members_ids': [(6, 0, self.user_ids.ids)],
|
||||
},
|
||||
}
|
||||
|
||||
def action_grant_access(self):
|
||||
"""Grant the selected required groups to all assigned users."""
|
||||
for role in self:
|
||||
role.check_access('write')
|
||||
if not role.user_ids:
|
||||
raise UserError(_("Please assign at least one user before granting access."))
|
||||
if not role.required_groups:
|
||||
raise UserError(_("Please select at least one required group before granting access."))
|
||||
|
||||
users = role.user_ids.sudo()
|
||||
groups = role.required_groups.sudo()
|
||||
for user in users:
|
||||
user.write({'groups_id': [(4, group.id) for group in groups]})
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Access Granted'),
|
||||
'message': _('Required groups were added to the assigned users.'),
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
}
|
||||
}
|
||||
|
||||
def action_view_users(self):
|
||||
"""Open users assigned to this role"""
|
||||
self.ensure_one()
|
||||
|
|
|
|||
|
|
@ -1,21 +1,31 @@
|
|||
from odoo import models, fields, api, _
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import ValidationError, UserError
|
||||
|
||||
class ProjectSprint(models.Model):
|
||||
_name = "project.sprint"
|
||||
_description = "Project Sprint"
|
||||
_order = "date_start desc"
|
||||
_rec_name = "sprint_name"
|
||||
_order = "date_start desc, id desc"
|
||||
|
||||
project_id = fields.Many2one(
|
||||
"project.project",
|
||||
required=True,
|
||||
ondelete="cascade"
|
||||
ondelete="cascade",
|
||||
index=True,
|
||||
)
|
||||
|
||||
sprint_name = fields.Char(string="Sprint Name", required=True)
|
||||
date_start = fields.Date(string="Start Date", required=True)
|
||||
date_end = fields.Date(string="End Date", required=True)
|
||||
|
||||
allocated_hours = fields.Float(string="Allocated Hours")
|
||||
allocated_hours = fields.Float(string="Capacity Hours")
|
||||
task_ids = fields.One2many("project.task", "sprint_id", string="Sprint Tasks")
|
||||
task_count = fields.Integer(compute="_compute_sprint_metrics", string="Tasks")
|
||||
completed_task_count = fields.Integer(compute="_compute_sprint_metrics", string="Completed Tasks")
|
||||
planned_hours = fields.Float(compute="_compute_sprint_metrics", string="Planned Hours")
|
||||
actual_hours = fields.Float(compute="_compute_sprint_metrics", string="Actual Hours")
|
||||
remaining_hours = fields.Float(compute="_compute_sprint_metrics", string="Remaining Hours")
|
||||
progress = fields.Float(compute="_compute_sprint_metrics", string="Progress")
|
||||
|
||||
status = fields.Selection(
|
||||
[
|
||||
|
|
@ -28,3 +38,75 @@ class ProjectSprint(models.Model):
|
|||
)
|
||||
done_date = fields.Date(string="Done Date")
|
||||
note = fields.Text(string="Note")
|
||||
|
||||
@api.depends(
|
||||
"allocated_hours",
|
||||
"task_ids",
|
||||
"task_ids.state",
|
||||
"task_ids.sprint_estimated_hours",
|
||||
"task_ids.estimated_hours",
|
||||
"task_ids.actual_hours",
|
||||
)
|
||||
def _compute_sprint_metrics(self):
|
||||
done_states = ("1_done", "1_canceled")
|
||||
for sprint in self:
|
||||
tasks = sprint.task_ids
|
||||
sprint.task_count = len(tasks)
|
||||
sprint.completed_task_count = len(tasks.filtered(lambda task: task.state in done_states))
|
||||
sprint.planned_hours = sum(task.sprint_estimated_hours or task.estimated_hours for task in tasks)
|
||||
sprint.actual_hours = sum(tasks.mapped("actual_hours"))
|
||||
sprint.remaining_hours = sprint.allocated_hours - sprint.planned_hours
|
||||
sprint.progress = (sprint.completed_task_count / sprint.task_count * 100.0) if sprint.task_count else 0.0
|
||||
|
||||
@api.constrains("date_start", "date_end")
|
||||
def _check_sprint_dates(self):
|
||||
for sprint in self:
|
||||
if sprint.date_start and sprint.date_end and sprint.date_start > sprint.date_end:
|
||||
raise ValidationError(_("Sprint start date must be before the end date."))
|
||||
|
||||
@api.constrains("project_id", "status")
|
||||
def _check_single_active_sprint(self):
|
||||
for sprint in self.filtered(lambda rec: rec.status == "in_progress"):
|
||||
active_sprint = self.search([
|
||||
("project_id", "=", sprint.project_id.id),
|
||||
("status", "=", "in_progress"),
|
||||
("id", "!=", sprint.id),
|
||||
], limit=1)
|
||||
if active_sprint:
|
||||
raise ValidationError(_(
|
||||
"Only one sprint can be in progress per project. "
|
||||
"Please complete '%s' before starting another sprint."
|
||||
) % active_sprint.display_name)
|
||||
|
||||
def action_start_sprint(self):
|
||||
for sprint in self:
|
||||
if not sprint.task_ids:
|
||||
raise UserError(_("Add at least one task before starting the sprint."))
|
||||
sprint.status = "in_progress"
|
||||
return True
|
||||
|
||||
def action_complete_sprint(self):
|
||||
open_tasks = self.mapped("task_ids").filtered(lambda task: task.state not in ("1_done", "1_canceled"))
|
||||
if open_tasks:
|
||||
raise UserError(_("Complete or cancel all sprint tasks before closing the sprint."))
|
||||
self.write({"status": "done", "done_date": fields.Date.context_today(self)})
|
||||
return True
|
||||
|
||||
def action_reset_to_draft(self):
|
||||
self.write({"status": "draft", "done_date": False})
|
||||
return True
|
||||
|
||||
def action_open_tasks(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
"type": "ir.actions.act_window",
|
||||
"name": _("Sprint Tasks"),
|
||||
"res_model": "project.task",
|
||||
"view_mode": "kanban,list,form,calendar,pivot,graph,activity",
|
||||
"domain": [("sprint_id", "=", self.id)],
|
||||
"context": {
|
||||
"default_project_id": self.project_id.id,
|
||||
"default_sprint_id": self.id,
|
||||
"search_default_project_id": self.project_id.id,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue