ALL updates regrading the hrms, ats and pmt

This commit is contained in:
pranaysaidurga 2026-06-12 12:17:49 +05:30
parent 29af1ebf29
commit 064bd90c58
75 changed files with 5699 additions and 694 deletions

View File

@ -14,7 +14,6 @@ class EmployeePayslipDownloadWizard(models.TransientModel):
'hr.employee',
required=True,
default=lambda self: self.env.user.employee_id.id,
readonly=True,
)
download_type = fields.Selection(
selection=[
@ -39,6 +38,13 @@ class EmployeePayslipDownloadWizard(models.TransientModel):
string='Available Payslips',
compute='_compute_payslip_count',
)
is_hr_manager = fields.Boolean(compute="_compute_is_hr_manager")
def _compute_is_hr_manager(self):
for rec in self:
import pdb
pdb.set_trace()
rec.is_hr_manager = self.env.user.has_group('hr.group_hr_manager')
@api.onchange('download_type', 'period_id')
def _onchange_download_type_period_id(self):

View File

@ -18,9 +18,9 @@ class ITTaxStatementWizard(models.TransientModel):
contract_id = fields.Many2one('hr.contract', related='employee_id.contract_id', required=True)
currency_id = fields.Many2one('res.currency', related='employee_id.company_id.currency_id')
period_id = fields.Many2one('payroll.period', required=True)
period_line = fields.Many2one('payroll.period.line',
domain="[('period_id', '=', period_id), ('to_date', '<', fields.Date.today())]")
period_id = fields.Many2one('payroll.period', required=True)
period_line = fields.Many2one('payroll.period.line',
domain="[('period_id', '=', period_id), ('to_date', '<', fields.Date.today())]")
# Taxpayer profile
taxpayer_name = fields.Char(related='employee_id.name')
@ -95,33 +95,38 @@ class ITTaxStatementWizard(models.TransientModel):
('old', 'Old Regime'),
('new', 'New Regime')
], string="Beneficial Regime", readonly=True)
is_hr_manager = fields.Boolean(compute="_compute_is_hr_manager")
def _get_age_category(self, age):
if age < 60:
return 'below_60'
elif age < 80:
return '60_to_80'
return 'above_80'
def _get_effective_period_start(self):
self.ensure_one()
period_start = self.period_id.from_date if self.period_id else False
if not period_start:
return False
if self.emp_doj and self.period_id.to_date and self.period_id.from_date <= self.emp_doj <= self.period_id.to_date:
return max(period_start, self.emp_doj.replace(day=1))
return period_start
def _get_effective_period_lines(self):
self.ensure_one()
if not self.period_id:
return self.env['payroll.period.line']
period_lines = self.period_id.period_line_ids.sorted('from_date')
effective_start = self._get_effective_period_start()
if not effective_start:
return period_lines
return period_lines.filtered(lambda line: line.to_date and line.to_date >= effective_start)
def _compute_is_hr_manager(self):
for rec in self:
rec.is_hr_manager = self.env.user.has_group('hr.group_hr_manager')
def _get_age_category(self, age):
if age < 60:
return 'below_60'
elif age < 80:
return '60_to_80'
return 'above_80'
def _get_effective_period_start(self):
self.ensure_one()
period_start = self.period_id.from_date if self.period_id else False
if not period_start:
return False
if self.emp_doj and self.period_id.to_date and self.period_id.from_date <= self.emp_doj <= self.period_id.to_date:
return max(period_start, self.emp_doj.replace(day=1))
return period_start
def _get_effective_period_lines(self):
self.ensure_one()
if not self.period_id:
return self.env['payroll.period.line']
period_lines = self.period_id.period_line_ids.sorted('from_date')
effective_start = self._get_effective_period_start()
if not effective_start:
return period_lines
return period_lines.filtered(lambda line: line.to_date and line.to_date >= effective_start)
def _find_applicable_slab(self, regime, period_id, age, residence_type):
"""Find the applicable tax slab without forcing both regimes to exist."""
@ -136,7 +141,7 @@ class ITTaxStatementWizard(models.TransientModel):
('residence_type', '=', 'both')
], limit=1)
def _get_applicable_slab(self, regime, period_id, age, residence_type):
def _get_applicable_slab(self, regime, period_id, age, residence_type):
"""Get the applicable tax slab based on regime, age, and residence type"""
age_category = self._get_age_category(age)
slab_master = self._find_applicable_slab(regime, period_id, age, residence_type)
@ -145,21 +150,21 @@ class ITTaxStatementWizard(models.TransientModel):
"No tax slab found for %s Regime with Age Category: %s and Residence Type: %s"
) % (regime.capitalize(), age_category.replace('_', ' ').title(), residence_type))
return slab_master
@api.onchange('employee_id', 'period_id')
def _onchange_employee_id_period_id(self):
domain_by_record = {}
for rec in self:
domain = [('period_id', '=', rec.period_id.id), ('to_date', '<', fields.Date.today())] if rec.period_id else []
if rec.emp_doj:
domain.append(('to_date', '>=', rec.emp_doj.replace(day=1)))
if rec.period_line and rec.period_line not in rec._get_effective_period_lines():
rec.period_line = False
domain_by_record[rec.id] = domain
if len(self) == 1:
return {'domain': {'period_line': domain_by_record.get(self.id, [])}}
return slab_master
@api.onchange('employee_id', 'period_id')
def _onchange_employee_id_period_id(self):
domain_by_record = {}
for rec in self:
domain = [('period_id', '=', rec.period_id.id), ('to_date', '<', fields.Date.today())] if rec.period_id else []
if rec.emp_doj:
domain.append(('to_date', '>=', rec.emp_doj.replace(day=1)))
if rec.period_line and rec.period_line not in rec._get_effective_period_lines():
rec.period_line = False
domain_by_record[rec.id] = domain
if len(self) == 1:
return {'domain': {'period_line': domain_by_record.get(self.id, [])}}
def _get_standard_deduction(self, regime, slab_master=False):
if slab_master:
@ -329,7 +334,7 @@ class ITTaxStatementWizard(models.TransientModel):
return list(grouped.values())
def fetch_salary_components(self):
def fetch_salary_components(self):
"""fetch salary components from payroll data"""
for rec in self:
data = {
@ -345,10 +350,10 @@ class ITTaxStatementWizard(models.TransientModel):
}
if not rec.employee_id or not rec.contract_id or not rec.period_id or not rec.period_line:
return data
period_lines = rec._get_effective_period_lines()
for line in period_lines:
components = rec._get_salary_components_for_period_line(line)
period_lines = rec._get_effective_period_lines()
for line in period_lines:
components = rec._get_salary_components_for_period_line(line)
if line.from_date and rec.period_line.from_date and line.from_date <= rec.period_line.from_date:
data['basic_salary']['actual'].append(components['basic_salary'])
data['hra_salary']['actual'].append(components['hra_salary'])
@ -412,7 +417,7 @@ class ITTaxStatementWizard(models.TransientModel):
)
rec.standard_deduction = rec._get_standard_deduction(rec.tax_regime, slab_master)
def fetch_deduction_components(self):
def fetch_deduction_components(self):
for rec in self:
data = {
'professional_tax': {'actual': [], 'projected': []},
@ -421,12 +426,12 @@ class ITTaxStatementWizard(models.TransientModel):
if not rec.employee_id or not rec.contract_id or not rec.period_id or not rec.period_line:
return data
for line in rec._get_effective_period_lines():
rule_amounts = rec._get_rule_amounts_for_period_line(line, ['PT', 'PFE'])
bucket = 'actual' if line.from_date <= rec.period_line.from_date else 'projected'
data['professional_tax'][bucket].append(rule_amounts['PT'])
data['nps_employer_contribution'][bucket].append(rule_amounts['PFE'])
return data
for line in rec._get_effective_period_lines():
rule_amounts = rec._get_rule_amounts_for_period_line(line, ['PT', 'PFE'])
bucket = 'actual' if line.from_date <= rec.period_line.from_date else 'projected'
data['professional_tax'][bucket].append(rule_amounts['PT'])
data['nps_employer_contribution'][bucket].append(rule_amounts['PFE'])
return data
@api.onchange('employee_id')
@ -521,6 +526,14 @@ class ITTaxStatementWizard(models.TransientModel):
tax_with_surcharge = total_before_mr - mr
return surcharge, mr, tax_with_surcharge
def fetch_current_employer_deducted_tax(self):
for rec in self:
payslip_ids = self.env['hr.payslip'].sudo().search([('employee_id','=',rec.employee_id.id),('state','in',['done','paid']),('date_from','>=',rec.period_id.from_date),('date_to','<=',rec.period_id.to_date)])
amount_deducted = 0.0
for payslip in payslip_ids:
amount_deducted += sum(payslip.line_ids.filtered(lambda l:l.salary_rule_id.code == 'TDS').mapped('amount'))
return amount_deducted
def _compute_tax_old_regime(self, taxable, slab_master=False):
# Get applicable slab
slab_master = slab_master or self._get_applicable_slab(
@ -549,6 +562,8 @@ class ITTaxStatementWizard(models.TransientModel):
total_tax = tax_with_surcharge + cess
current_employer_deducted_tax = self.fetch_current_employer_deducted_tax()
return {
'taxable_income': taxable,
'slab_tax': slab_tax,
@ -558,7 +573,9 @@ class ITTaxStatementWizard(models.TransientModel):
'marginal_relief': marginal_relief,
'tax_with_surcharge': tax_with_surcharge,
'cess_4pct': cess,
'total_tax': total_tax
'total_tax': total_tax,
'current_employer_deducted_tax': current_employer_deducted_tax,
'balance_tax': total_tax - (-current_employer_deducted_tax)
}
def _compute_tax_new_regime(self, taxable, slab_master=False):
@ -587,6 +604,7 @@ class ITTaxStatementWizard(models.TransientModel):
cess = tax_with_surcharge * cess_rate[0] / 100
total_tax = tax_with_surcharge + cess
current_employer_deducted_tax = self.fetch_current_employer_deducted_tax()
return {
'taxable_income': taxable,
'slab_tax': slab_tax,
@ -596,7 +614,9 @@ class ITTaxStatementWizard(models.TransientModel):
'marginal_relief': marginal_relief,
'tax_with_surcharge': tax_with_surcharge,
'cess_4pct': cess,
'total_tax': total_tax
'total_tax': total_tax,
'current_employer_deducted_tax': current_employer_deducted_tax,
'balance_tax': total_tax - (-current_employer_deducted_tax)
}
def _compute_house_property_income(self):
@ -759,19 +779,19 @@ class ITTaxStatementWizard(models.TransientModel):
'target': 'current',
}
def _prepare_income_tax_data(self, include_comparison=False):
"""Prepare data for the tax statement report"""
today = date.today()
display_fy_start = self.period_id.from_date
fy_end = self.period_id.to_date
effective_fy_start = self._get_effective_period_start() or display_fy_start
total_months = ((fy_end.year - effective_fy_start.year) * 12 +
(fy_end.month - effective_fy_start.month) + 1)
line_start = self.period_line.from_date
current_month_index = ((line_start.year - effective_fy_start.year) * 12 +
(line_start.month - effective_fy_start.month) + 1)
values = self._get_tax_base_values(include_comparison=include_comparison)
def _prepare_income_tax_data(self, include_comparison=False):
"""Prepare data for the tax statement report"""
today = date.today()
display_fy_start = self.period_id.from_date
fy_end = self.period_id.to_date
effective_fy_start = self._get_effective_period_start() or display_fy_start
total_months = ((fy_end.year - effective_fy_start.year) * 12 +
(fy_end.month - effective_fy_start.month) + 1)
line_start = self.period_line.from_date
current_month_index = ((line_start.year - effective_fy_start.year) * 12 +
(line_start.month - effective_fy_start.month) + 1)
values = self._get_tax_base_values(include_comparison=include_comparison)
salary_components_data = values['salary_components_data']
annual_gross_salary = values['annual_gross_salary']
gross_salary_actual = values['gross_salary_actual']
@ -806,16 +826,16 @@ class ITTaxStatementWizard(models.TransientModel):
# Prepare data structure matching screenshot format
# Financial year (period_id)
display_fy_start = self.period_id.from_date
fy_end = self.period_id.to_date
effective_fy_start = self._get_effective_period_start() or display_fy_start
total_months = ((fy_end.year - effective_fy_start.year) * 12 +
(fy_end.month - effective_fy_start.month) + 1)
# Current month (period_line)
line_start = self.period_line.from_date
current_month_index = ((line_start.year - effective_fy_start.year) * 12 +
(line_start.month - effective_fy_start.month) + 1)
display_fy_start = self.period_id.from_date
fy_end = self.period_id.to_date
effective_fy_start = self._get_effective_period_start() or display_fy_start
total_months = ((fy_end.year - effective_fy_start.year) * 12 +
(fy_end.month - effective_fy_start.month) + 1)
# Current month (period_line)
line_start = self.period_line.from_date
current_month_index = ((line_start.year - effective_fy_start.year) * 12 +
(line_start.month - effective_fy_start.month) + 1)
tax_result['roundoff_taxable_income'] = float(round(tax_result["taxable_income"] / 10) * 10)
birthday = self.employee_id.birthday
if birthday:
@ -835,8 +855,8 @@ class ITTaxStatementWizard(models.TransientModel):
'total': total,
})
data = {
'financial_year': f"{display_fy_start.year}-{fy_end.year}",
'assessment_year': fy_end.year + 1,
'financial_year': f"{display_fy_start.year}-{fy_end.year}",
'assessment_year': fy_end.year,
'report_time': today.strftime('%d-%m-%Y %H:%M'),
'user': 'ESS',
'emp_code': self.employee_id.employee_id,

View File

@ -38,6 +38,8 @@
<t t-set="rebate_87a" t-value="'{:,.0f}'.format(tax_computation.get('rebate_87a', 0))"/>
<t t-set="cess" t-value="'{:,.0f}'.format(tax_computation.get('cess_4pct', 0))"/>
<t t-set="total_tax" t-value="'{:,.0f}'.format(tax_computation.get('total_tax', 0))"/>
<t t-set="current_employer_deducted_tax" t-value="'{:,.0f}'.format(tax_computation.get('current_employer_deducted_tax', 0))"/>
<t t-set="balance_tax" t-value="'{:,.0f}'.format(tax_computation.get('balance_tax', 0))"/>
<t t-set="report_time" t-value="data.get('report_time', '')"/>
<!-- Header -->
@ -378,7 +380,7 @@
<tr>
<td>Less: Tax deducted current employer (up to previous month)</td>
<td style="text-align: right;">0</td>
<td style="text-align: right;">0</td>
<td style="text-align: right;" t-esc="current_employer_deducted_tax"/>
</tr>
<tr>
<td>Less: Tax deducted from previous Employer / Self Tax Paid</td>
@ -389,7 +391,7 @@
<tr style="font-weight: bold;">
<td>Balance Tax for the year</td>
<td style="text-align: right;">0</td>
<td style="text-align: right;" t-esc="total_tax"/>
<td style="text-align: right;" t-esc="balance_tax"/>
</tr>
<tr>
<td>Less: Adhoc tax deducted in Off-Cycle in current month</td>
@ -399,7 +401,7 @@
<tr style="font-weight: bold;">
<td><strong>Balance Tax</strong></td>
<td style="text-align: right;">0</td>
<td style="text-align: right;" t-esc="total_tax"/>
<td style="text-align: right;" t-esc="balance_tax"/>
</tr>
<tr>
<td><strong>Tax deducted from current month salary</strong></td>

View File

@ -54,14 +54,14 @@ access_nsc_interest_entry_user,nsc.interest.entry,model_nsc_interest_entry,base.
access_house_rent_declaration_user,access.house.rent.declaration.user,model_house_rent_declaration,base.group_user,1,1,1,1
access_it_tax_statement,it.tax.statement,model_it_tax_statement,base.group_user,1,0,0,0
access_it_tax_statement_wizard,it.tax.statement.wizard,model_it_tax_statement_wizard,base.group_user,1,0,0,0
access_employee_payslip_download_wizard,employee.payslip.download.wizard,model_employee_payslip_download_wizard,base.group_user,1,1,1,0
access_it_tax_statement_manager,it.tax.statement,model_it_tax_statement,hr.group_hr_manager,1,1,1,1
access_it_tax_statement,it.tax.statement,model_it_tax_statement,base.group_user,1,0,0,0
access_it_tax_statement_wizard,it.tax.statement.wizard,model_it_tax_statement_wizard,base.group_user,1,1,1,0
access_employee_payslip_download_wizard,employee.payslip.download.wizard,model_employee_payslip_download_wizard,base.group_user,1,1,1,0
access_it_tax_statement_manager,it.tax.statement,model_it_tax_statement,hr.group_hr_manager,1,1,1,1
access_it_tax_statement_wizard_manager,it.tax.statement.wizard,model_it_tax_statement_wizard,hr.group_hr_manager,1,1,1,1
access_it_slab_master,it.slab.master,model_it_slab_master,base.group_user,1,1,1,1
access_it_slab_master_rules,it.slab.master.rules,model_it_slab_master_rules,base.group_user,1,1,1,1
access_it_sur_charge_rules,it.sur.charge.rules.user,model_it_sur_charge_rules,base.group_user,1,1,1,1
access_it_sur_charge_rules,it.sur.charge.rules.user,model_it_sur_charge_rules,base.group_user,1,1,1,1

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
54
55
56
57
58
59
60
61
62
63
64
65
66
67

View File

@ -21,7 +21,8 @@
</header>
<sheet>
<group>
<field name="employee_id" options="{'no_edit': True, 'no_create': True}"/>
<field name="is_hr_manager" invisible="1"/>
<field name="employee_id" readonly="not is_hr_manager" options="{'no_edit': True, 'no_create': True}"/>
<field name="download_type" widget="radio" options="{'horizontal': true}"/>
</group>
<group>

View File

@ -43,7 +43,8 @@
<field name="currency_id" invisible="1"/>
<field name="is_general_tax_statement" invisible="1"/>
<group>
<field name="employee_id" options="{'no_edit': True, 'no_create': True}"/>
<field name="is_hr_manager" invisible="0" force_save="1"/>
<field name="employee_id" readonly="not is_hr_manager" options="{'no_edit': True, 'no_create': True}"/>
<field name="contract_id" readonly="1" force_save="1" invisible="0"/>
</group>
<group>
@ -88,11 +89,13 @@
<field name="res_model">it.tax.statement.wizard</field>
<field name="path">tax-statement</field>
<field name="view_mode">form</field>
<field name="domain">[("activity_ids.active", "in", [True, False])]</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a new employment type
</p>
</field>
</record>
<menuitem id="menu_it_tax_statement_root" name="IT Tax Statement"

View File

@ -17,6 +17,7 @@
'resource',
'portal',
'digest',
'website',
],
'description': """
Helpdesk - Ticket Management App
@ -60,6 +61,7 @@ Features:
'views/mail_activity_views.xml',
'views/helpdesk_templates.xml',
'views/helpdesk_menus.xml',
'views/website_form.xml',
'wizard/helpdesk_stage_delete_views.xml',
],
'demo': ['data/helpdesk_demo.xml'],

View File

@ -9,7 +9,7 @@ from odoo import http
from odoo.exceptions import AccessError, MissingError, UserError
from odoo.http import request
from odoo.tools.translate import _
from odoo.tools import groupby as groupbyelem
from odoo.tools import groupby as groupbyelem, plaintext2html
from odoo.addons.portal.controllers import portal
from odoo.addons.portal.controllers.portal import pager as portal_pager
from odoo.osv.expression import AND, FALSE_DOMAIN
@ -32,7 +32,22 @@ class CustomerPortal(portal.CustomerPortal):
return values
def _prepare_helpdesk_tickets_domain(self):
return []
partner = request.env.user.partner_id
return [('message_partner_ids', 'in', [partner.id])]
def _get_portal_ticket_teams(self):
return request.env['helpdesk.team'].sudo().search([
('privacy_visibility', '=', 'portal'),
('active', '=', True),
], order='sequence, name')
def _get_team_for_ticket_type(self, ticket_type):
teams = self._get_portal_ticket_teams()
return (
teams.filtered(lambda team: team.portal_ticket_type == ticket_type)[:1]
or teams.filtered(lambda team: team.portal_ticket_type == 'general')[:1]
or teams[:1]
)
def _ticket_get_page_view_values(self, ticket, access_token, **kwargs):
values = {
@ -157,6 +172,66 @@ class CustomerPortal(portal.CustomerPortal):
values = self._prepare_my_tickets_values(page, date_begin, date_end, sortby, filterby, search, groupby, search_in)
return request.render("helpdesk.portal_helpdesk_ticket", values)
@http.route(['/helpdesk/new'], type='http', auth="user", website=True, methods=['GET'])
def helpdesk_ticket_new(self, **kw):
teams = self._get_portal_ticket_teams()
ticket_teams = request.env['helpdesk.team'].sudo().search([
('use_website_helpdesk_form', '=', True),
'|',
('company_id', '=', False),
('company_id', 'in', [request.env.company.id])
])
selection_dict = dict(
request.env['helpdesk.team']._fields['portal_ticket_type'].selection
)
used_types = list(set(ticket_teams.mapped('portal_ticket_type')))
ticket_types = [
(key, selection_dict[key])
for key in used_types
if key in selection_dict
]
return request.render("helpdesk.portal_helpdesk_ticket_new", {
'page_name': 'ticket',
'teams': teams,
'default_category': ticket_types[0][0],
'default_company': request.env.company,
'ticket_types': ticket_types,
'ticket_teams': ticket_teams,
'default_partner': request.env.user.partner_id,
'error': kw.get('error'),
})
@http.route(['/helpdesk/new'], type='http', auth="user", website=True, methods=['POST'])
def helpdesk_ticket_create(self, **post):
ticket_type = post.get('portal_ticket_type') or 'general'
team = request.env['helpdesk.team'].sudo().browse(int(post.get('helpdesk_team'))) if post.get('helpdesk_team').isnumeric() else self._get_team_for_ticket_type(ticket_type)
partner = request.env.user.partner_id
if not team:
return request.redirect('/helpdesk/new?error=no_team')
subject = (post.get('name') or '').strip()
description = (post.get('description') or '').strip()
if not subject or not description:
return request.redirect('/helpdesk/new?error=missing')
ticket = request.env['helpdesk.ticket'].sudo().create({
'name': subject,
'description': plaintext2html(description),
'portal_ticket_type': ticket_type,
'team_id': team.id,
'partner_id': partner.id,
'partner_name': partner.name,
'partner_email': post.get('partner_email') or partner.email,
'partner_phone': post.get('partner_phone') or partner.phone,
# 'company_id': team.company_id,
})
ticket.message_subscribe(partner_ids=partner.ids)
return request.redirect('/my/ticket/%s/%s?created=1' % (ticket.id, ticket.access_token))
@http.route([
"/helpdesk/ticket/<int:ticket_id>",
"/helpdesk/ticket/<int:ticket_id>/<access_token>",
@ -170,6 +245,8 @@ class CustomerPortal(portal.CustomerPortal):
return request.redirect('/my')
values = self._ticket_get_page_view_values(ticket_sudo, access_token, **kw)
values['ticket_created'] = kw.get('created')
values['ticket_reopened'] = kw.get('reopened')
return request.render("helpdesk.tickets_followup", values)
@http.route([
@ -195,3 +272,18 @@ class CustomerPortal(portal.CustomerPortal):
ticket_sudo.with_context(mail_create_nosubscribe=True).message_post(body=body, message_type='comment', subtype_xmlid='mail.mt_note')
return request.redirect('/my/ticket/%s/%s?ticket_closed=1' % (ticket_id, access_token or ''))
@http.route([
'/my/ticket/rerequest/<int:ticket_id>',
'/my/ticket/rerequest/<int:ticket_id>/<access_token>',
], type='http', auth="public", website=True)
def ticket_rerequest(self, ticket_id=None, access_token=None, **kw):
try:
ticket_sudo = self._document_check_access('helpdesk.ticket', ticket_id, access_token)
except (AccessError, MissingError):
return request.redirect('/my')
if ticket_sudo.stage_id.fold:
ticket_sudo.action_portal_rerequest()
return request.redirect('/my/ticket/%s/%s?reopened=1' % (ticket_id, access_token or ticket_sudo.access_token or ''))

View File

@ -1,42 +1,88 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="helpdesk_team1" model="helpdesk.team">
<field name="name">Customer Care</field>
<field name="alias_name">customer-care</field>
<field name="stage_ids" eval="False"/> <!-- eval=False to don't get the default stage. New stages are setted below-->
<field name="use_sla" eval="True"/>
<field name="member_ids" eval="[Command.link(ref('base.user_admin'))]"/>
</record>
<!-- stage "New" gets created by default with sequence 0-->
<record id="stage_new" model="helpdesk.stage">
<field name="name">New</field>
<field name="sequence">0</field>
<field name="team_ids" eval="[(4, ref('helpdesk_team1'))]"/>
<field name="template_id" ref="helpdesk.new_ticket_request_email_template"/>
</record>
<record id="stage_in_progress" model="helpdesk.stage">
<field name="name">In Progress</field>
<field name="sequence">1</field>
<field name="team_ids" eval="[(4, ref('helpdesk_team1'))]"/>
</record>
<record id="stage_on_hold" model="helpdesk.stage">
<field name="name">On Hold</field>
<field name="sequence">2</field>
<field name="team_ids" eval="[(4, ref('helpdesk_team1'))]"/>
</record>
<record id="stage_solved" model="helpdesk.stage">
<field name="name">Solved</field>
<field name="team_ids" eval="[(4, ref('helpdesk_team1'))]"/>
<field name="sequence">3</field>
<field name="fold" eval="True"/>
</record>
<record id="stage_cancelled" model="helpdesk.stage">
<field name="name">Cancelled</field>
<field name="sequence">4</field>
<field name="team_ids" eval="[(4, ref('helpdesk_team1'))]"/>
<field name="fold" eval="True"/>
</record>
</odoo>
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="helpdesk_team1" model="helpdesk.team">
<field name="name">Customer Care</field>
<field name="alias_name">customer-care</field>
<field name="portal_ticket_type">general</field>
<field name="allow_portal_ticket_closing" eval="True"/>
<field name="stage_ids" eval="False"/> <!-- eval=False to don't get the default stage. New stages are setted below-->
<field name="use_sla" eval="True"/>
<field name="member_ids" eval="[Command.link(ref('base.user_admin'))]"/>
<field name="responsible_manager_id" ref="base.user_admin"/>
<!-- <field name="approver_id" ref="base.user_admin"/>-->
</record>
<record id="helpdesk_team_technical" model="helpdesk.team">
<field name="name">Technical Support</field>
<field name="alias_name">technical-support</field>
<field name="portal_ticket_type">technical</field>
<field name="stage_ids" eval="False"/>
<field name="privacy_visibility">portal</field>
<field name="use_sla" eval="True"/>
<field name="use_rating" eval="True"/>
<field name="allow_portal_ticket_closing" eval="True"/>
<field name="member_ids" eval="[Command.link(ref('base.user_admin'))]"/>
<field name="responsible_manager_id" ref="base.user_admin"/>
<!-- <field name="approver_id" ref="base.user_admin"/>-->
</record>
<record id="helpdesk_team_personal" model="helpdesk.team">
<field name="name">Personal Concern Support</field>
<field name="alias_name">personal-concern-support</field>
<field name="portal_ticket_type">personal</field>
<field name="stage_ids" eval="False"/>
<field name="privacy_visibility">portal</field>
<field name="use_sla" eval="True"/>
<field name="use_rating" eval="True"/>
<field name="allow_portal_ticket_closing" eval="True"/>
<field name="member_ids" eval="[Command.link(ref('base.user_admin'))]"/>
<field name="responsible_manager_id" ref="base.user_admin"/>
<!-- <field name="approver_id" ref="base.user_admin"/>-->
</record>
<record id="helpdesk_team_damage" model="helpdesk.team">
<field name="name">Damage / Asset Support</field>
<field name="alias_name">damage-asset-support</field>
<field name="portal_ticket_type">damage</field>
<field name="stage_ids" eval="False"/>
<field name="privacy_visibility">portal</field>
<field name="use_sla" eval="True"/>
<field name="use_rating" eval="True"/>
<field name="allow_portal_ticket_closing" eval="True"/>
<field name="member_ids" eval="[Command.link(ref('base.user_admin'))]"/>
<field name="responsible_manager_id" ref="base.user_admin"/>
<!-- <field name="approver_id" ref="base.user_admin"/>-->
</record>
<!-- stage "New" gets created by default with sequence 0-->
<record id="stage_new" model="helpdesk.stage">
<field name="name">New</field>
<field name="sequence">0</field>
<field name="team_ids" eval="[(4, ref('helpdesk_team1')), (4, ref('helpdesk_team_technical')), (4, ref('helpdesk_team_personal')), (4, ref('helpdesk_team_damage'))]"/>
<field name="template_id" ref="helpdesk.new_ticket_request_email_template"/>
</record>
<record id="stage_in_progress" model="helpdesk.stage">
<field name="name">In Progress</field>
<field name="sequence">1</field>
<field name="team_ids" eval="[(4, ref('helpdesk_team1')), (4, ref('helpdesk_team_technical')), (4, ref('helpdesk_team_personal')), (4, ref('helpdesk_team_damage'))]"/>
</record>
<record id="stage_on_hold" model="helpdesk.stage">
<field name="name">On Hold</field>
<field name="sequence">2</field>
<field name="team_ids" eval="[(4, ref('helpdesk_team1')), (4, ref('helpdesk_team_technical')), (4, ref('helpdesk_team_personal')), (4, ref('helpdesk_team_damage'))]"/>
</record>
<record id="stage_solved" model="helpdesk.stage">
<field name="name">Solved</field>
<field name="team_ids" eval="[(4, ref('helpdesk_team1')), (4, ref('helpdesk_team_technical')), (4, ref('helpdesk_team_personal')), (4, ref('helpdesk_team_damage'))]"/>
<field name="sequence">3</field>
<field name="fold" eval="True"/>
</record>
<record id="stage_cancelled" model="helpdesk.stage">
<field name="name">Cancelled</field>
<field name="sequence">4</field>
<field name="team_ids" eval="[(4, ref('helpdesk_team1')), (4, ref('helpdesk_team_technical')), (4, ref('helpdesk_team_personal')), (4, ref('helpdesk_team_damage'))]"/>
<field name="fold" eval="True"/>
</record>
</odoo>

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data noupdate="1">
<record id="new_ticket_request_email_template" model="mail.template">
<field name="name">Helpdesk: Ticket Received</field>
<field name="model_id" ref="helpdesk.model_helpdesk_ticket"/>
<field name="subject">{{ object.name }}</field>
<record id="new_ticket_request_email_template" model="mail.template">
<field name="name">Helpdesk: Ticket Received</field>
<field name="model_id" ref="helpdesk.model_helpdesk_ticket"/>
<field name="subject">Ticket Received - {{ object.ticket_ref or object.id }}</field>
<field name="email_from">{{ (object.team_id.alias_email_from or object.company_id.email_formatted or object.user_id.email_formatted or user.email_formatted) }}</field>
<field name="email_to">{{ (object.partner_email if not object.sudo().partner_id.email or object.sudo().partner_id.email != object.partner_email else '') }}</field>
<field name="partner_to">{{ object.partner_id.id if object.sudo().partner_id.email and object.sudo().partner_id.email == object.partner_email else '' }}</field>
@ -11,7 +11,7 @@
<field name="body_html" type="html">
<div>
Dear <t t-out="object.sudo().partner_id.name or object.sudo().partner_name or 'Madam/Sir'">Madam/Sir</t>,<br /><br />
Your request
Your ticket has been received. Please wait patiently while our team reviews your request:
<t t-if="hasattr(object.team_id, 'website_id') and object.get_portal_url()">
<a t-attf-href="{{ object.team_id.website_id.domain }}/my/ticket/{{ object.id }}/{{ object.access_token }}" t-out="object.name or ''">Table legs are unbalanced</a>
</t>
@ -51,12 +51,33 @@
</field>
<field name="lang">{{ object.partner_id.lang or object.user_id.lang or user.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
</record>
<record id="ticket_assigned_user_email_template" model="mail.template">
<field name="name">Helpdesk: New Ticket Assigned User</field>
<field name="model_id" ref="helpdesk.model_helpdesk_ticket"/>
<field name="subject">New Ticket Assigned - {{ object.ticket_ref or object.id }}</field>
<field name="email_from">{{ (object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="email_to">{{ object.user_id.email_formatted if object.user_id.email else '' }}</field>
<field name="description">Notify the assigned helpdesk user when a new ticket is created.</field>
<field name="body_html" type="html">
<div>
Hello <t t-out="object.user_id.name or 'there'">there</t>,<br/><br/>
You have a new ticket assigned to you.<br/><br/>
<strong>Reference:</strong> <t t-out="object.ticket_ref or object.id">15</t><br/>
<strong>Type:</strong> <t t-out="dict(object._fields['portal_ticket_type'].selection).get(object.portal_ticket_type)">Technical Issue</t><br/>
<strong>Customer:</strong> <t t-out="object.partner_name or object.partner_id.name or ''">Customer</t><br/>
<strong>Subject:</strong> <t t-out="object.name or ''">Ticket Subject</t><br/><br/>
<a style="background-color: #875A7B; padding: 8px 16px; text-decoration: none; color: #fff; border-radius: 5px; font-size: 13px;" t-att-href="'/odoo/action-helpdesk.helpdesk_ticket_action_main_tree/%s' % object.id" target="_blank">Open Ticket</a>
</div>
</field>
<field name="auto_delete" eval="True"/>
</record>
<record id="solved_ticket_request_email_template" model="mail.template">
<field name="name">Helpdesk: Ticket Closed</field>
<field name="model_id" ref="helpdesk.model_helpdesk_ticket"/>
<field name="subject">Ticket Closed - Reference {{ object.id if object.id else 15 }}</field>
<field name="subject">Ticket Closed - Reference {{ object.ticket_ref or object.id }}</field>
<field name="email_from">{{ (object.team_id.alias_email_from or object.company_id.email_formatted or object.user_id.email_formatted or user.email_formatted) }}</field>
<field name="email_to">{{ (object.partner_email if not object.sudo().partner_id.email or object.sudo().partner_id.email != object.partner_email else '') }}</field>
<field name="partner_to">{{ object.partner_id.id if object.sudo().partner_id.email and object.sudo().partner_id.email == object.partner_email else '' }}</field>
@ -64,10 +85,14 @@
<field name="body_html" type="html">
<div>
Dear <t t-out="object.sudo().partner_id.name or 'Madam/Sir'">Madam/Sir</t>,<br /><br />
We would like to inform you that we have closed your ticket (reference <t t-out="object.id or ''">15</t>).
We trust that the services provided have met your expectations and that you have found a satisfactory resolution to your issue.<br /><br />
However, if you have any further questions or comments, please do not hesitate to reply to this email to re-open your ticket.
Our team is always here to help you and we will be happy to assist you with any further concerns you may have.<br /><br />
Your ticket (reference <t t-out="object.ticket_ref or object.id or ''">15</t>) is now closed.
We trust that the services provided have met your expectations and that you have found a satisfactory resolution to your issue.<br /><br />
Please give feedback or leave a review if you would like to. This is optional, but it helps us improve.<br /><br />
However, if you have any further questions or comments, please reply to this email or use the re-request option in your portal to re-open your ticket.
Our team is always here to help you and we will be happy to assist you with any further concerns you may have.<br /><br />
<div style="text-align: center; padding: 16px 0px;">
<a style="background-color: #875A7B; padding: 8px 16px; text-decoration: none; color: #fff; border-radius: 5px; font-size: 13px;" t-att-href="object.get_portal_url()" target="_blank">View Ticket</a>
</div>
Thank you for choosing our services and for your cooperation throughout this process. We truly value your business and appreciate the opportunity to serve you.<br /><br />
Kind regards,<br /><br />
<t t-out="object.team_id.name or 'Helpdesk'">Helpdesk</t> Team.
@ -75,7 +100,25 @@
</field>
<field name="lang">{{ object.partner_id.lang or object.user_id.lang or user.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
</record>
<record id="ticket_reopened_user_email_template" model="mail.template">
<field name="name">Helpdesk: Ticket Re-requested</field>
<field name="model_id" ref="helpdesk.model_helpdesk_ticket"/>
<field name="subject">Ticket Re-requested - {{ object.ticket_ref or object.id }}</field>
<field name="email_from">{{ (object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="email_to">{{ object.user_id.email_formatted if object.user_id.email else '' }}</field>
<field name="description">Notify the assigned helpdesk user when a customer re-requests help on a closed ticket.</field>
<field name="body_html" type="html">
<div>
Hello <t t-out="object.user_id.name or 'there'">there</t>,<br/><br/>
The customer has re-requested help on ticket <strong><t t-out="object.ticket_ref or object.id">15</t></strong>.<br/><br/>
<strong>Subject:</strong> <t t-out="object.name or ''">Ticket Subject</t><br/><br/>
<a style="background-color: #875A7B; padding: 8px 16px; text-decoration: none; color: #fff; border-radius: 5px; font-size: 13px;" t-att-href="'/odoo/action-helpdesk.helpdesk_ticket_action_main_tree/%s' % object.id" target="_blank">Open Ticket</a>
</div>
</field>
<field name="auto_delete" eval="True"/>
</record>
<record id="rating_ticket_request_email_template" model="mail.template">
<field name="name">Helpdesk: Ticket Rating Request</field>

View File

@ -72,6 +72,20 @@ class HelpdeskTeam(models.Model):
privacy_visibility_warning = fields.Char(compute='_compute_privacy_visibility_warning', export_string_translation=False)
access_instruction_message = fields.Char(compute='_compute_access_instruction_message', export_string_translation=False)
ticket_ids = fields.One2many('helpdesk.ticket', 'team_id', string='Tickets')
portal_ticket_type = fields.Selection([
('technical', 'Technical Issue'),
('personal', 'Personal / Harassment'),
('damage', 'Damage / Asset Issue'),
('general', 'General Request'),
('others', 'Others')
], string='Portal Ticket Type', default='general',
help="Used by the website ticket form to route requests to the correct team.")
responsible_manager_id = fields.Many2one(
'res.users', string='Responsible Manager',
domain=lambda self: [('groups_id', 'in', self.env.ref('helpdesk.group_helpdesk_user').id)])
# approver_id = fields.Many2one(
# 'res.users', string='Approver',
# domain=lambda self: [('groups_id', 'in', self.env.ref('base.group_user').id)])
use_alias = fields.Boolean('Use Alias', default=True)
has_external_mail_server = fields.Boolean(compute='_compute_has_external_mail_server', export_string_translation=False)

View File

@ -16,6 +16,14 @@ TICKET_PRIORITY = [
('3', 'Urgent'),
]
HELPDESK_TICKET_TYPES = [
('technical', 'Technical Issue'),
('personal', 'Personal / Harassment'),
('damage', 'Damage / Asset Issue'),
('general', 'General Request'),
('others', 'Others')
]
class HelpdeskTicket(models.Model):
_name = 'helpdesk.ticket'
_description = 'Helpdesk Ticket'
@ -61,6 +69,13 @@ class HelpdeskTicket(models.Model):
name = fields.Char(string='Subject', required=True, index=True, tracking=True)
team_id = fields.Many2one('helpdesk.team', string='Helpdesk Team', default=_default_team_id, index=True, tracking=True)
portal_ticket_type = fields.Selection(HELPDESK_TICKET_TYPES, string='Ticket Type',store=True, tracking=True, relatated='team_id.portal_ticket_type')
responsible_manager_id = fields.Many2one(
'res.users', string='Responsible Manager', related='team_id.responsible_manager_id',
store=True, readonly=True)
# approver_id = fields.Many2one(
# 'res.users', string='Approver', related='team_id.approver_id',
# store=True, readonly=True)
use_sla = fields.Boolean(related='team_id.use_sla')
team_privacy_visibility = fields.Selection(related='team_id.privacy_visibility', export_string_translation=False)
description = fields.Html(sanitize_attributes=False)
@ -156,6 +171,8 @@ class HelpdeskTicket(models.Model):
if ticket_sudo.team_id and ticket_sudo.team_id.privacy_visibility == 'invited_internal':
ticket_user_ids = ticket_sudo.team_id.message_partner_ids.user_ids.ids
ticket.domain_user_ids = [Command.set(user_ids + ticket_user_ids)]
if ticket_sudo.team_id.auto_assignment:
ticket.domain_user_ids = [Command.set(ticket_sudo.team_id.member_ids.ids)]
def _compute_access_url(self):
super(HelpdeskTicket, self)._compute_access_url()
@ -259,6 +276,12 @@ class HelpdeskTicket(models.Model):
if not ticket.stage_id or ticket.stage_id not in ticket.team_id.stage_ids:
ticket.stage_id = ticket.team_id._determine_stage()[ticket.team_id.id]
@api.onchange('team_id')
def onchange_user_and_stage_ids(self):
for ticket in self.filtered(lambda ticket: ticket.team_id):
ticket.user_id = ticket.team_id._determine_user_to_assign()[ticket.team_id.id]
@api.depends('partner_id')
def _compute_partner_name(self):
for ticket in self:
@ -522,6 +545,7 @@ class HelpdeskTicket(models.Model):
# apply SLA
tickets.sudo()._sla_apply()
tickets._send_ticket_created_notifications()
return tickets
@ -579,8 +603,56 @@ class HelpdeskTicket(models.Model):
message = _("This ticket was successfully closed %s hours before its SLA deadline.", round(abs(min_hours))) if min_hours < 0 \
else _("This ticket was closed %s hours after its SLA deadline.", round(min_hours))
ticket.message_post(body=message, subtype_xmlid="mail.mt_note", author_id=odoobot_partner_id)
closed_tickets._send_ticket_closed_notifications()
return res
def _send_template_once(self, template_xmlid, marker):
template = self.env.ref(template_xmlid, raise_if_not_found=False)
if not template:
return
for ticket in self:
if ticket.message_ids.filtered(lambda message: message.body and marker in message.body):
continue
template.sudo().send_mail(ticket.id, force_send=True, email_layout_xmlid='mail.mail_notification_light')
ticket.message_post(body=marker, subtype_xmlid='mail.mt_note')
def _send_ticket_created_notifications(self):
if self.env.context.get('helpdesk_skip_create_notifications') or self.env.context.get('install_mode'):
return
self.filtered(lambda ticket: ticket.partner_email)._send_template_once(
'helpdesk.new_ticket_request_email_template',
'<span data-helpdesk-notification="customer-created"></span>',
)
self.filtered(lambda ticket: ticket.user_id and ticket.user_id.email)._send_template_once(
'helpdesk.ticket_assigned_user_email_template',
'<span data-helpdesk-notification="assignee-created"></span>',
)
def _send_ticket_closed_notifications(self):
if self.env.context.get('helpdesk_skip_close_notifications') or self.env.context.get('install_mode'):
return
self.filtered(lambda ticket: ticket.partner_email)._send_template_once(
'helpdesk.solved_ticket_request_email_template',
'<span data-helpdesk-notification="customer-closed"></span>',
)
def action_portal_rerequest(self):
for ticket in self:
opening_stage = ticket.team_id._determine_stage()[ticket.team_id.id]
vals = {'closed_by_partner': False}
if opening_stage:
vals['stage_id'] = opening_stage.id
ticket.with_context(helpdesk_skip_create_notifications=True).write(vals)
ticket.message_post(
body=_('The customer has re-requested help on this ticket.'),
message_type='comment',
subtype_xmlid='mail.mt_note',
)
self.filtered(lambda ticket: ticket.user_id and ticket.user_id.email)._send_template_once(
'helpdesk.ticket_reopened_user_email_template',
'<span data-helpdesk-notification="assignee-reopened"></span>',
)
def copy_data(self, default=None):
vals_list = super().copy_data(default=default)
return [dict(vals, name=self.env._("%s (copy)", ticket.name)) for ticket, vals in zip(self, vals_list)]
@ -787,6 +859,11 @@ class HelpdeskTicket(models.Model):
partner_ids = [x.id for x in self.env['mail.thread']._mail_find_partner_from_emails(self._ticket_email_split(msg), records=self) if x]
if partner_ids:
self.message_subscribe(partner_ids)
incoming_email = tools.email_normalize(msg.get('from') or '')
for ticket in self.filtered(lambda t: t.stage_id.fold):
partner_email = tools.email_normalize(ticket.partner_email or ticket.partner_id.email or '')
if incoming_email and incoming_email == partner_email:
ticket.action_portal_rerequest()
return super(HelpdeskTicket, self).message_update(msg, update_vals=update_vals)
def _message_compute_subject(self):

View File

@ -2,7 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, tools
from odoo.addons.helpdesk.models.helpdesk_ticket import TICKET_PRIORITY
from odoo.addons.helpdesk.models.helpdesk_ticket import HELPDESK_TICKET_TYPES, TICKET_PRIORITY
from odoo.addons.rating.models.rating_data import RATING_LIMIT_MIN
@ -21,6 +21,7 @@ class HelpdeskSLAReport(models.Model):
name = fields.Char(string='Subject', readonly=True)
create_date = fields.Datetime("Ticket Creation Date", readonly=True)
priority = fields.Selection(TICKET_PRIORITY, string='Minimum Priority', readonly=True)
portal_ticket_type = fields.Selection(HELPDESK_TICKET_TYPES, string='Ticket Type', readonly=True, related='team_id.portal_ticket_type')
user_id = fields.Many2one('res.users', string="Assigned To", readonly=True)
partner_id = fields.Many2one('res.partner', string="Customer", readonly=True)
partner_name = fields.Char(string='Customer Name', readonly=True)
@ -76,6 +77,7 @@ class HelpdeskSLAReport(models.Model):
NULLIF(T.rating_last_value, 0) AS rating_last_value,
AVG(rt.rating) as rating_avg,
T.priority AS priority,
T.portal_ticket_type AS portal_ticket_type,
NULLIF(T.close_hours, 0) AS ticket_close_hours,
CASE
WHEN EXTRACT(EPOCH FROM (COALESCE(T.assign_date, NOW() AT TIME ZONE 'UTC') - T.create_date)) / 3600 < 1 THEN NULL

View File

@ -2,7 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, tools
from odoo.addons.helpdesk.models.helpdesk_ticket import TICKET_PRIORITY
from odoo.addons.helpdesk.models.helpdesk_ticket import HELPDESK_TICKET_TYPES, TICKET_PRIORITY
from odoo.addons.rating.models.rating_data import RATING_LIMIT_MIN
@ -25,6 +25,7 @@ class HelpdeskTicketReport(models.Model):
sla_status_ids = fields.One2many('helpdesk.sla.status', 'ticket_id', string="SLA Status")
create_date = fields.Datetime("Ticket Creation Date", readonly=True)
priority = fields.Selection(TICKET_PRIORITY, string='Minimum Priority', readonly=True)
portal_ticket_type = fields.Selection(HELPDESK_TICKET_TYPES, string='Ticket Type', readonly=True, related='team_id.portal_ticket_type')
user_id = fields.Many2one('res.users', string="Assigned To", readonly=True)
partner_id = fields.Many2one('res.partner', string="Customer", readonly=True)
partner_name = fields.Char(string='Customer Name', readonly=True)
@ -60,6 +61,7 @@ class HelpdeskTicketReport(models.Model):
T.name AS name,
T.create_date AS create_date,
T.priority AS priority,
T.portal_ticket_type AS portal_ticket_type,
T.user_id AS user_id,
T.partner_id AS partner_id,
T.partner_name AS partner_name,

View File

@ -39,6 +39,9 @@
<t t-call="portal.portal_searchbar">
<t t-set="title">Tickets</t>
</t>
<div class="d-flex justify-content-end mb-3">
<a class="btn btn-primary" href="/helpdesk/new">New Ticket</a>
</div>
<div t-if="not grouped_tickets" class="alert alert-info">
There are currently no Ticket for your account.
</div>
@ -47,6 +50,7 @@
<thead>
<tr>
<th>Ticket</th>
<th>Type</th>
<th class="text-end" t-if="groupby != 'create_date'">Reported on</th>
<th id="ticket_user_header" t-if="groupby != 'user_id'" class="ps-5">Assigned to</th>
<th t-if="groupby != 'stage_id'" colspan="5" class="text-end">Stage</th>
@ -82,6 +86,7 @@
<t t-foreach="tickets" t-as="ticket">
<tr>
<td class="text-start"><a t-attf-href="/helpdesk/ticket/#{ticket.id}"><small>#</small><t t-out="ticket.ticket_ref"/><span class="ms-2" t-att-title="ticket.name" t-field="ticket.name"/></a></td>
<td><span t-field="ticket.portal_ticket_type"/></td>
<td class="text-end" t-if="groupby != 'create_date'">
<span t-field="ticket.create_date" t-options='{"widget": "datetime", "hide_seconds": True}'/>
</td>
@ -100,6 +105,161 @@
</t>
</template>
<template id="portal_helpdesk_ticket_new" name="Raise Helpdesk Ticket">
<t t-call="portal.portal_layout">
<t t-set="breadcrumbs_searchbar" t-value="True"/>
<t t-call="portal.portal_searchbar">
<t t-set="title">New Ticket</t>
</t>
<div t-if="error == 'no_team'" class="alert alert-warning">
No portal helpdesk team is configured yet. Please contact support.
</div>
<div t-if="error == 'missing'" class="alert alert-danger">
Please fill in the ticket type, subject, and details.
</div>
<form action="/helpdesk/new" method="post" class="card border-0 shadow-sm">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<div class="card-body">
<div class="row g-3">
<div class="col-12">
<div class="card bg-light border mb-3">
<div class="card-header">
<h6 class="mb-0">
<i class="fa fa-user me-2"/>
<t t-out="default_company.name or default_partner.company_name"/>
</h6>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label text-muted small">
Full Name
</label>
<input class="form-control"
readonly="readonly"
t-att-value="default_partner.display_name or ''"/>
</div>
<div class="col-md-4">
<label class="form-label text-muted small">
Phone
</label>
<input class="form-control"
id="partner_phone"
name="partner_phone"
t-att-value="default_partner.phone or ''"/>
</div>
<div class="col-md-4">
<label class="form-label text-muted small">
Email
</label>
<input class="form-control"
id="partner_email"
name="partner_email"
t-att-value="default_partner.email or ''"/>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<label class="form-label" for="portal_ticket_type">Category</label>
<select class="form-select" id="portal_ticket_type" name="portal_ticket_type" required="required">
<t t-foreach="ticket_types" t-as="ticket_type">
<option t-att-value="ticket_type[0]"
t-att-selected="'selected' if ticket_type[0] == default_category else None"><t t-out="ticket_type[1]"/></option>
</t>
</select>
</div>
<div class="col-md-6">
<label class="form-label" for="helpdesk_team">Team</label>
<select class="form-select" id="helpdesk_team" name="helpdesk_team" required="required">
<t t-foreach="ticket_teams" t-as="ticket_team">
<option
t-att-value="ticket_team.id"
t-att-data-category="ticket_team.portal_ticket_type">
<t t-out="ticket_team.display_name"/>
</option>
</t>
</select>
</div>
<div class="col-12">
<label class="form-label" for="name">Subject</label>
<input class="form-control" id="name" name="name" required="required" placeholder="Short summary of the issue"/>
</div>
<div class="col-12">
<label class="form-label" for="description">Details</label>
<textarea class="form-control" id="description" name="description" rows="8" required="required" placeholder="Describe what happened and what help you need"></textarea>
</div>
</div>
</div>
<div class="card-footer bg-transparent d-flex justify-content-between">
<a class="btn btn-light" href="/my/tickets">Cancel</a>
<button class="btn btn-primary" type="submit">Submit Ticket</button>
</div>
</form>
</t>
</template>
<template id="portal_helpdesk_ticket_new_js" inherit_id="portal_helpdesk_ticket_new">
<xpath expr="//form" position="after">
<script type="text/javascript">
document.addEventListener('DOMContentLoaded', function () {
const categorySelect = document.getElementById('portal_ticket_type');
const teamSelect = document.getElementById('helpdesk_team');
if (!categorySelect || !teamSelect) {
return;
}
const allTeams = Array.from(teamSelect.options).map(opt =>
opt.cloneNode(true)
);
function filterTeamsByCategory() {
const category = categorySelect.value;
const currentTeam = teamSelect.value;
teamSelect.innerHTML = '';
allTeams.forEach(option => {
if (option.dataset.category === category) {
teamSelect.appendChild(option.cloneNode(true));
}
});
const found = [...teamSelect.options].find(
opt => opt.value === currentTeam
);
if (found) {
teamSelect.value = currentTeam;
}
}
function setCategoryFromTeam() {
const selected = teamSelect.options[teamSelect.selectedIndex];
if (selected &amp;&amp; selected.dataset.category) {
categorySelect.value = selected.dataset.category;
}
}
categorySelect.addEventListener('change', filterTeamsByCategory);
teamSelect.addEventListener('change', setCategoryFromTeam);
filterTeamsByCategory();
});
</script>
</xpath>
</template>
<template id="tickets_followup" name="Helpdesk Tickets">
<t t-call="portal.portal_layout">
<t t-set="title" t-value="ticket.name"/>
@ -116,7 +276,14 @@
<t t-set="classes" t-value="'col-lg-4 col-xxl-3 d-print-none'"/>
<t t-set="entries">
<div class="d-flex flex-column">
<div class="d-flex flex-column">
<div t-if="ticket.stage_id.fold" class="flex-grow-1 mb-3">
<div class="d-grid flex-sm-nowrap">
<a class="btn btn-light pt-1" t-att-href="'/my/ticket/rerequest/%s/%s' % (ticket.id, ticket.access_token)">
Re-request Help
</a>
</div>
</div>
<div t-if="ticket.team_id.allow_portal_ticket_closing and not ticket.stage_id.fold and not ticket.closed_by_partner" class="flex-grow-1">
<div class="d-grid flex-sm-nowrap">
<button class="btn btn-light pt-1" data-bs-target="#helpdesk_ticket_close_modal" data-bs-toggle="modal">
@ -198,6 +365,14 @@
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
<span>Your ticket has successfully been closed. Thank you for your collaboration.</span>
</div>
<div t-if="ticket_created" class="alert alert-success alert-dismissible d-print-none" role="status">
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
<span>Your ticket has been received. Please wait patiently while the assigned team reviews it.</span>
</div>
<div t-if="ticket_reopened" class="alert alert-info alert-dismissible d-print-none" role="status">
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
<span>Your ticket has been re-requested and moved back to the support queue.</span>
</div>
<div id="card">
<div id="card_header" class="container" data-anchor="true">
<div class="row gs-0">
@ -220,6 +395,10 @@
<strong class="col-lg-3">Reported on</strong>
<span class="col-lg-9" t-field="ticket.create_date" t-options='{"widget": "datetime", "hide_seconds": True}'/>
</div>
<div class="row mb-4">
<strong class="col-lg-3">Type</strong>
<span class="col-lg-9" t-field="ticket.portal_ticket_type"/>
</div>
<div t-if="not is_html_empty(ticket.description)" class="row mt-3" name="description">
<div class="col-lg-12" t-field="ticket.description"/>
</div>

View File

@ -119,74 +119,86 @@
</div>
</div>
</setting>
<setting class="col-lg-12" help="Route website tickets of this type to this team">
<label for="portal_ticket_type"/>
<field name="portal_ticket_type" class="mt16"/>
<div class="mt16">
<label for="responsible_manager_id"/>
<field name="responsible_manager_id" widget="many2one_avatar_user" options="{'no_quick_create': True}"/>
</div>
<!-- <div class="mt16">-->
<!-- <label for="approver_id"/>-->
<!-- <field name="approver_id" widget="many2one_avatar_user" options="{'no_quick_create': True}"/>-->
<!-- </div>-->
</setting>
</div>
</div>
<h2>Channels</h2>
<div class="row mt16 o_settings_container" id="channels">
<setting id="alias_channels" help="Create tickets by sending an email to an alias"
documentation="/applications/services/helpdesk/overview/receiving_tickets.html#email-alias">
<field name="use_alias" string="Email Alias"/>
<div invisible="not use_alias" class="mt16">
<div class="oe_edit_only" dir="ltr">
<strong>Alias </strong>
<field name="alias_name" placeholder="alias" class="w-25"/>@
<field name="alias_domain_id" class="oe_inline" placeholder="e.g. mycompany.com"
options="{'no_create': True, 'no_open': True}"/>
<br/>
<label for="alias_contact"/>
<field name="alias_contact" string="Accept Emails From"/>
</div>
<p class="oe_read_only">
<strong>Alias </strong>
<field name="alias_id" class="oe_read_only oe_inline" required="False"/>
</p>
<field name="has_external_mail_server" invisible="1"/>
<p class="text-muted o_row ps-1" invisible="alias_domain_id or has_external_mail_server">
<i class="fa fa-lightbulb-o" role='img'/><span class="ms-2">To use an email alias, the first step is to configure an Alias Domain. You can achieve this by navigating to the General Settings and configuring the corresponding field.</span>
</p>
<p invisible="alias_domain_id">
<a href="/odoo/settings#email-alias-setting" class="btn-link mt-2" role="button"><i class="oi oi-arrow-right"/> Set an Alias Domain</a>
</p>
</div>
</setting>
<setting help="Get in touch with your website visitors, and engage them with scripted chatbot conversations. Create and search tickets from your conversations.">
<field name="use_website_helpdesk_livechat"/>
<div class="text-muted o_row ps-1 mt16" invisible="not use_website_helpdesk_livechat">
<i class="fa fa-lightbulb-o"/>
<span class="ms-2">
Type <b>/ticket</b> to create tickets<br/>
Type <b>/search_tickets</b> to find tickets<br/>
</span>
</div>
</setting>
</div>
<!-- <h2>Channels</h2>-->
<!-- <div class="row mt16 o_settings_container" id="channels">-->
<!-- <setting id="alias_channels" help="Create tickets by sending an email to an alias"-->
<!-- documentation="/applications/services/helpdesk/overview/receiving_tickets.html#email-alias">-->
<!-- <field name="use_alias" string="Email Alias"/>-->
<!-- <div invisible="not use_alias" class="mt16">-->
<!-- <div class="oe_edit_only" dir="ltr">-->
<!-- <strong>Alias </strong>-->
<!-- <field name="alias_name" placeholder="alias" class="w-25"/>@-->
<!-- <field name="alias_domain_id" class="oe_inline" placeholder="e.g. mycompany.com"-->
<!-- options="{'no_create': True, 'no_open': True}"/>-->
<!-- <br/>-->
<!-- <label for="alias_contact"/>-->
<!-- <field name="alias_contact" string="Accept Emails From"/>-->
<!-- </div>-->
<!-- <p class="oe_read_only">-->
<!-- <strong>Alias </strong>-->
<!-- <field name="alias_id" class="oe_read_only oe_inline" required="False"/>-->
<!-- </p>-->
<!-- <field name="has_external_mail_server" invisible="1"/>-->
<!-- <p class="text-muted o_row ps-1" invisible="alias_domain_id or has_external_mail_server">-->
<!-- <i class="fa fa-lightbulb-o" role='img'/><span class="ms-2">To use an email alias, the first step is to configure an Alias Domain. You can achieve this by navigating to the General Settings and configuring the corresponding field.</span>-->
<!-- </p>-->
<!-- <p invisible="alias_domain_id">-->
<!-- <a href="/odoo/settings#email-alias-setting" class="btn-link mt-2" role="button"><i class="oi oi-arrow-right"/> Set an Alias Domain</a>-->
<!-- </p>-->
<!-- </div>-->
<!-- </setting>-->
<!-- <setting help="Get in touch with your website visitors, and engage them with scripted chatbot conversations. Create and search tickets from your conversations.">-->
<!-- <field name="use_website_helpdesk_livechat"/>-->
<!-- <div class="text-muted o_row ps-1 mt16" invisible="not use_website_helpdesk_livechat">-->
<!-- <i class="fa fa-lightbulb-o"/>-->
<!-- <span class="ms-2">-->
<!-- Type <b>/ticket</b> to create tickets<br/>-->
<!-- Type <b>/search_tickets</b> to find tickets<br/>-->
<!-- </span>-->
<!-- </div>-->
<!-- </setting>-->
<!-- </div>-->
<h2>Help Center</h2>
<div class="row mt16 o_settings_container" id="website_form_channel">
<setting help="Get tickets through an online form">
<field name="use_website_helpdesk_form"/>
</setting>
<setting help="Centralize, manage, share and grow your knowledge library. Allow customers to search your articles in the help center for answers to their questions." documentation="/applications/services/helpdesk/overview/help_center.html#knowledge">
<field name="use_website_helpdesk_knowledge"/>
</setting>
<setting help="Allow customers to help each other on a forum. Share answers from your tickets directly." documentation="/applications/services/helpdesk/overview/help_center.html#community-forum">
<field name="use_website_helpdesk_forum"/>
</setting>
<setting help="Share presentations and videos, and organize them into courses. Allow customers to search your eLearning courses in the help center for answers to their questions." documentation="/applications/services/helpdesk/overview/help_center.html#elearning">
<field name="use_website_helpdesk_slides"/>
</setting>
<!-- <setting help="Centralize, manage, share and grow your knowledge library. Allow customers to search your articles in the help center for answers to their questions." documentation="/applications/services/helpdesk/overview/help_center.html#knowledge">-->
<!-- <field name="use_website_helpdesk_knowledge"/>-->
<!-- </setting>-->
<!-- <setting help="Allow customers to help each other on a forum. Share answers from your tickets directly." documentation="/applications/services/helpdesk/overview/help_center.html#community-forum">-->
<!-- <field name="use_website_helpdesk_forum"/>-->
<!-- </setting>-->
<!-- <setting help="Share presentations and videos, and organize them into courses. Allow customers to search your eLearning courses in the help center for answers to their questions." documentation="/applications/services/helpdesk/overview/help_center.html#elearning">-->
<!-- <field name="use_website_helpdesk_slides"/>-->
<!-- </setting>-->
</div>
<h2 class="mt32">Track &amp; Bill Time</h2>
<h2 class="mt32">Timesheets</h2>
<div class="row mt16 o_settings_container">
<setting id="timesheet"
help="Track the time spent on tickets"
documentation="/applications/services/helpdesk/advanced/track_and_bill.html">
<field name="use_helpdesk_timesheet"/>
</setting>
<setting id="sale_timesheet"
help="Bill the time spent on your tickets to your customers"
documentation="/applications/services/helpdesk/advanced/track_and_bill.html">
<field name="use_helpdesk_sale_timesheet"/>
</setting>
<!-- <setting id="sale_timesheet"-->
<!-- help="Bill the time spent on your tickets to your customers"-->
<!-- documentation="/applications/services/helpdesk/advanced/track_and_bill.html">-->
<!-- <field name="use_helpdesk_sale_timesheet"/>-->
<!-- </setting>-->
</div>
<h2>Performance</h2>
<div class="row mt16 o_settings_container">
@ -218,42 +230,42 @@
<setting help="Allow your customers to close their own tickets" documentation="/applications/services/helpdesk/advanced/close_tickets.html">
<field name="allow_portal_ticket_closing"/>
</setting>
<setting help="Close inactive tickets automatically">
<field name="auto_close_ticket"/>
<div class="content-group" invisible="not auto_close_ticket">
<field name="stage_ids" invisible="1"/>
<div class="mt16">
<label for="to_stage_id"/>
<field name="to_stage_id" class="ms-2 oe_inline" required="auto_close_ticket" context="{'default_team_id': id}"/>
</div>
<div class="mt8">
<strong>After</strong><field name="auto_close_day" class="mx-2 oe_inline text-center" required="1"/><span>days of inactivity</span>
</div>
<div class="mt8">
<label for="from_stage_ids"/>
<field name="from_stage_ids" widget="many2many_tags" class="ms-2" context="{'default_team_id': id}"/>
</div>
</div>
</setting>
</div>
<h2>After-Sales <widget name="documentation_link" path="/applications/services/helpdesk/advanced/after_sales.html" icon="fa-question-circle"/></h2>
<div class="row mt32 o_settings_container" id="after-sales">
<setting help="Issue credits notes">
<field name="use_credit_notes"/>
</setting>
<setting help="Grant discounts, free products or free shipping">
<field name="use_coupons"/>
</setting>
<setting help="Return faulty products">
<field name="use_product_returns"/>
</setting>
<setting help="Send broken products for repair">
<field name="use_product_repairs"/>
</setting>
<setting help="Plan onsite interventions">
<field name="use_fsm"/>
</setting>
<!-- <setting help="Close inactive tickets automatically">-->
<!-- <field name="auto_close_ticket"/>-->
<!-- <div class="content-group" invisible="not auto_close_ticket">-->
<!-- <field name="stage_ids" invisible="1"/>-->
<!-- <div class="mt16">-->
<!-- <label for="to_stage_id"/>-->
<!-- <field name="to_stage_id" class="ms-2 oe_inline" required="auto_close_ticket" context="{'default_team_id': id}"/>-->
<!-- </div>-->
<!-- <div class="mt8">-->
<!-- <strong>After</strong><field name="auto_close_day" class="mx-2 oe_inline text-center" required="1"/><span>days of inactivity</span>-->
<!-- </div>-->
<!-- <div class="mt8">-->
<!-- <label for="from_stage_ids"/>-->
<!-- <field name="from_stage_ids" widget="many2many_tags" class="ms-2" context="{'default_team_id': id}"/>-->
<!-- </div>-->
<!-- </div>-->
<!-- </setting>-->
</div>
<!-- <h2>After-Sales <widget name="documentation_link" path="/applications/services/helpdesk/advanced/after_sales.html" icon="fa-question-circle"/></h2>-->
<!-- <div class="row mt32 o_settings_container" id="after-sales">-->
<!-- <setting help="Issue credits notes">-->
<!-- <field name="use_credit_notes"/>-->
<!-- </setting>-->
<!-- <setting help="Grant discounts, free products or free shipping">-->
<!-- <field name="use_coupons"/>-->
<!-- </setting>-->
<!-- <setting help="Return faulty products">-->
<!-- <field name="use_product_returns"/>-->
<!-- </setting>-->
<!-- <setting help="Send broken products for repair">-->
<!-- <field name="use_product_repairs"/>-->
<!-- </setting>-->
<!-- <setting help="Plan onsite interventions">-->
<!-- <field name="use_fsm"/>-->
<!-- </setting>-->
<!-- </div>-->
</sheet>
</form>
</field>

View File

@ -77,6 +77,7 @@
<field name="user_id"/>
<field name="partner_id" filter_domain="['|', '|', '|', ('partner_id', 'ilike', self), ('partner_email', 'ilike', self), ('partner_phone', 'ilike', self), ('partner_name', 'ilike', self)]"/>
<field name="team_id" invisible="context.get('default_team_id', False)"/>
<field name="portal_ticket_type"/>
<field name="stage_id"/>
<field name="sla_ids" groups="helpdesk.group_use_sla"/>
<field name="priority" invisible="1"/>
@ -119,6 +120,7 @@
<group expand="0" string="Group By">
<filter string="Assigned to" name="assignee" context="{'group_by':'user_id'}"/>
<filter string="Helpdesk Team" name="team" context="{'group_by':'team_id'}" invisible="context.get('default_team_id', False)"/>
<filter string="Ticket Type" name="portal_ticket_type" context="{'group_by':'portal_ticket_type'}"/>
<filter string="Stage" name="stage" context="{'group_by':'stage_id'}"/>
<filter string="Status" name="state" context="{'group_by': 'kanban_state'}"/>
<filter string="SLA" name="sla_ids" context="{'group_by': 'sla_ids'}" groups="helpdesk.group_use_sla"/>
@ -213,6 +215,7 @@
<field name="name" string="Name"/>
<field name="team_id" optional="show" readonly="1" column_invisible="context.get('default_team_id', False)"/>
<field name="team_id" optional="hide" readonly="1" column_invisible="not context.get('default_team_id', False)"/>
<field name="portal_ticket_type" optional="show"/>
<field name="user_id" optional="show" widget="many2one_avatar_user" options="{'no_quick_create': True}"/>
<field name="partner_id" domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]" optional="show" options="{'no_open': True}"/>
<field name="company_id" groups="base.group_multi_company" optional="show" readonly="1" column_invisible="context.get('default_team_id', False)"/>
@ -399,13 +402,16 @@
<group class="mb-0 mt-4">
<group>
<field name="active" invisible="1"/>
<field name="team_id" required="1" context="{'kanban_view_ref': 'helpdesk.helpdesk_team_view_kanban_mobile', 'default_use_sla': True}"/>
<field name="team_id" required="1" force_save="1" context="{'kanban_view_ref': 'helpdesk.helpdesk_team_view_kanban_mobile', 'default_use_sla': True}"/>
<field name="portal_ticket_type"/>
<field name="user_id" class="field_user_id" domain="['&amp;', ('id', 'in', domain_user_ids), ('share', '=', False)]" widget="many2one_avatar_user"/>
<field name="domain_user_ids" invisible="1"/>
<field name="priority" widget="priority"/>
<field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color', 'no_create_edit': True}"/>
</group>
<group>
<field name="responsible_manager_id" widget="many2one_avatar_user"/>
<!-- <field name="approver_id" widget="many2one_avatar_user"/>-->
<field name="partner_id" class="field_partner_id" domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]" widget="res_partner_many2one" context="{'default_phone': partner_phone}"/>
<field name="is_partner_phone_update" invisible="1"/>
<label for="partner_phone" string="Phone"/>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="website_helpdesk_support" model="website.menu">
<field name="name">Helpdesk</field>
<field name="url" eval="'/my/tickets'"/>
<field name="parent_id" ref="website.main_menu"/>
<field name="sequence" type="int">55</field>
</record>
</odoo>

View File

@ -231,7 +231,7 @@ class HrRecruitmentAutoDocWizard(models.TransientModel):
"relevant_experience_years": {"type": "float", "description": "Relevant years of experience as a number"},
"notice_period": {"type": "string", "description": "Notice period text"},
"degree": {"type": "string", "description": "Highest degree or main qualification"},
"skills": {"type": "list", "description": "All explicit technical and functional skills"},
"skills": {"type": "list", "description": "All explicit technical and functional skills that are mentioned in skills session and do not fetch the skills seperatly from the education and employeer history data"},
"summary": {"type": "string", "description": "Short professional summary from the resume"},
"education_history": {
"type": "list",
@ -360,12 +360,24 @@ class HrRecruitmentAutoDocWizard(models.TransientModel):
phone_values = [self._normalize_phone(parsed_data.get("phone")), self._normalize_phone(parsed_data.get("alternate_phone"))]
phone_values = [phone for phone in phone_values if phone]
expanded_phone_values = []
for phone in phone_values:
expanded_phone_values.append(phone)
if phone.startswith('+91'):
expanded_phone_values.append(phone[3:]) # remove +91
elif len(phone) == 10 and phone.isdigit():
expanded_phone_values.append('+91' + phone) # add +91
phone_values = list(set(expanded_phone_values))
candidate = False
if email_value:
candidate = search_model.search([("email_normalized", "=", email_value)], limit=1)
candidate = search_model.search(['|',('email_from','=', email_value),("email_normalized", "=", email_value)], limit=1)
if not candidate and phone_values:
candidate = search_model.search([
"|",
"|","|",('partner_phone',"in",phone_values),
("partner_phone_sanitized", "in", phone_values),
("alternate_phone", "in", phone_values),
], limit=1)

View File

@ -23,6 +23,7 @@ High-end recruitment dashboards with filters, KPIs, ApexCharts, and chart drilld
"hr_recruitment_dashboards/static/src/scss/recruitment_dashboard.scss",
],
},
'images': ['static/description/banner.png'],
"installable": True,
"application": False,
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

View File

@ -15,11 +15,12 @@
sequence="1"
active="0"/>
<menuitem
name="Dashboard"
id="hr_recruitment.report_hr_recruitment"
parent="hr_recruitment.menu_hr_recruitment_root"
name="Recruitment Analysis"
id="report_hr_recruitment_root"
groups="hr_recruitment.group_hr_recruitment_user"
action="action_hr_recruitment_dashboard"
web_icon="hr_recruitment_dashboards,static/description/icon.png"
sequence="99"/>
<menuitem
name="Recruitment Analysis"

View File

@ -18,7 +18,7 @@
'version': '0.1',
# any module necessary for this one to work correctly
'depends': ['base','hr_recruitment','hr','hr_recruitment_skills','website_hr_recruitment','requisitions'],
'depends': ['base','hr_recruitment','hr','hr_recruitment_skills','website_hr_recruitment','requisitions','search_view_extension'],
# always loaded
'data': [

View File

@ -9,8 +9,18 @@ class HrApplicantStageComment(models.Model):
applicant_id = fields.Many2one('hr.applicant', required=True, ondelete='cascade', index=True)
stage_id = fields.Many2one('hr.recruitment.stage', required=True, index=True)
comment = fields.Text(required=True)
user_id = fields.Many2one('res.users', default=lambda self: self.env.user, required=True)
comment_date = fields.Datetime(default=fields.Datetime.now, required=True)
user_id = fields.Many2one('res.users', default=lambda self: self.env.user, required=True, readonly=1)
comment_date = fields.Datetime(default=fields.Datetime.now, required=True, readonly=True)
def edit_cmt(self):
for rec in self:
return {
"type": "ir.actions.act_window",
"res_model": rec._name,
"res_id": rec.id,
"view_mode": "form",
"target": "new",
}
@api.depends('applicant_id', 'stage_id')
def _compute_display_name(self):

View File

@ -4,13 +4,6 @@ from datetime import date
from datetime import timedelta
import datetime
#
# class Job(models.Model):
# _inherit = 'hr.job'
#
# hiring_history = fields.One2many('recruitment.status.history', 'job_id', string='History')
class HrCandidate(models.Model):
_inherit = "hr.candidate"
@ -32,7 +25,6 @@ class HrCandidate(models.Model):
resume_name = fields.Char()
applications_stages_stat = fields.Many2many('application.stage.status',string="Applications History", compute="_compute_applications_stages_stat")
# availability_status = fields.Selection([('available','Available'),('not_available','Not Available'),('hired','Hired'),('abscond','Abscond')])
def action_toggle_chatter_visibility(self):
for record in self:
@ -103,6 +95,21 @@ class HrCandidate(models.Model):
for rec in self:
rec.display_name = rec.partner_name if not rec.candidate_sequence else f"{rec.partner_name} ({rec.candidate_sequence})"
def _get_phone_variants(self, phone):
if not phone:
return []
phone = phone.replace(" ", "").strip()
variants = [phone]
if phone.startswith("+91"):
variants.append(phone[3:])
elif len(phone) == 10 and phone.isdigit():
variants.append("+91" + phone)
return list(set(variants))
@api.constrains('email_from', 'partner_phone', 'alternate_phone')
def _candidate_unique_constraints(self):
for rec in self:
@ -116,16 +123,23 @@ class HrCandidate(models.Model):
# Check for unique phone number (partner_phone or alternate_phone)
if rec.partner_phone:
existing_phone = self.sudo().search(
[('id', '!=', rec.id), '|', ('partner_phone', '=', rec.partner_phone),
('alternate_phone', '=', rec.partner_phone)], limit=1)
phone_variants = self._get_phone_variants(rec.partner_phone)
existing_phone = self.sudo().search([
('id', '!=', rec.id),
'|',
('partner_phone_sanitized', 'in', phone_variants),
('alternate_phone', 'in', phone_variants),
], limit=1)
if existing_phone:
raise ValidationError(_("A candidate with the phone number '%s' already exists, sourced by %s %s.") % (
existing_phone.partner_phone, existing_phone.user_id.name, existing_phone.candidate_sequence if existing_phone.candidate_sequence else ''))
if rec.alternate_phone:
phone_variants = self._get_phone_variants(rec.alternate_phone)
existing_al_phone = self.sudo().search(
[('id', '!=', rec.id), '|', ('partner_phone', '=', rec.alternate_phone),
[('id', '!=', rec.id), '|', ('partner_phone_sanitized', 'in', phone_variants),
('alternate_phone', '=', rec.alternate_phone)], limit=1)
if existing_al_phone:
raise ValidationError(
@ -161,12 +175,6 @@ class HrCandidate(models.Model):
employee.write({
'image_1920': self.candidate_image})
return action
# #authotentication Details
# pan_no = fields.Char(string='PAN No',tracking=True)
# identification_id = fields.Char(string='Aadhar No',tracking=True)
# previous_company_pf_no = fields.Char(string='Previous Company PF No',tracking=True)
# previous_company_uan_no = fields.Char(string='Previous Company UAN No',tracking=True)
#
@api.constrains('partner_name')
def partner_name_constrain(self):

View File

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

View File

@ -25,13 +25,14 @@ hr_recruitment.access_hr_applicant_interviewer,hr.applicant.interviewer,hr_recru
hr_recruitment.access_hr_recruitment_stage_user,hr.recruitment.stage.user,hr_recruitment.model_hr_recruitment_stage,hr_recruitment.group_hr_recruitment_user,1,1,1,0
access_hr_recruitment_stage_hr,hr.recruitment.stage.hr,hr_recruitment.model_hr_recruitment_stage,hr.group_hr_manager,1,0,0,0
access_application_stage_status,application.stage.status,model_application_stage_status,base.group_user,1,1,1,1
access_hr_applicant_stage_comment_user,hr.applicant.stage.comment.user,model_hr_applicant_stage_comment,base.group_user,1,1,1,0
access_ats_invite_mail_template_wizard,ats.invite.mail.template.wizard.user,hr_recruitment_extended.model_ats_invite_mail_template_wizard,,1,1,1,1
access_client_submission_mails_template_wizard,client.submission.mails.template.wizard.user,hr_recruitment_extended.model_client_submission_mails_template_wizard,,1,1,1,1
access_applicant_stage_comment_wizard,applicant.stage.comment.wizard.user,model_applicant_stage_comment_wizard,base.group_user,1,1,1,1
access_hr_application_public,hr.applicant.public.access,hr_recruitment.model_hr_applicant,base.group_public,1,0,0,0
access_application_stage_status,application.stage.status,model_application_stage_status,base.group_user,1,1,1,1
access_hr_applicant_stage_comment_user,hr.applicant.stage.comment.user,model_hr_applicant_stage_comment,base.group_user,1,1,1,0
access_ats_invite_mail_template_wizard,ats.invite.mail.template.wizard.user,hr_recruitment_extended.model_ats_invite_mail_template_wizard,,1,1,1,1
access_client_submission_mails_template_wizard,client.submission.mails.template.wizard.user,hr_recruitment_extended.model_client_submission_mails_template_wizard,,1,1,1,1
access_applicant_stage_comment_wizard,applicant.stage.comment.wizard.user,model_applicant_stage_comment_wizard,base.group_user,1,1,1,1
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_hr_user,access.applicant.request.forms.hr.user,model_applicant_request_forms,hr.group_hr_user,1,1,1,1
access_hr_skill,access.hr.skill.user,hr_skills.model_hr_skill,base.group_public,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
25 access_hr_skill access.hr.skill.user hr_skills.model_hr_skill base.group_public 1 0 0 0
26
27
28
29
30
31
32
33
34
35
36
37
38

View File

@ -224,6 +224,14 @@
<field name="submission_status"/>
<field name="requested_by"/>
<separator/>
<searchtab name="published_records_tab" string="Published">
<filter string="Published" name="published_records_tab_filter"
domain="[('website_published','=',True)]"/>
</searchtab>
<searchtab name="unpublished_records_tab" string="Unpublished">
<filter string="Unpublished" name="unpublished_records_tab_filter"
domain="[('website_published','=',False)]"/>
</searchtab>
<filter string="Published Records" name="published_records" domain="[('website_published','=',True)]"/>
<filter string="UnPublished Records" name="unpublished_records" domain="[('website_published','=',False)]"/>
<separator/>
@ -434,7 +442,7 @@
<record id="action_hr_job_recruitment_awaiting_published" model="ir.actions.act_window">
<field name="name">JD</field>
<field name="name">Job Position</field>
<field name="res_model">hr.job.recruitment</field>
<field name="view_mode">kanban,list,form,search</field>
<field name="search_view_id" ref="view_job_recruitment_filter"/>
@ -474,7 +482,7 @@
active="0"
sequence="2"/>
<menuitem name="JD"
<menuitem name="JP"
id="menu_hr_job_descriptions"
parent="hr_recruitment.menu_hr_recruitment_root"
action="action_hr_job_recruitment_awaiting_published"

View File

@ -14,8 +14,10 @@
<filter string="Client Type" name="contact_type" context="{'group_by': 'contact_type'}"/>
</filter>
<xpath expr="//search" position="inside">
<filter name="internal_contact" string="In-House Contact" domain="[('contact_type', '=', 'internal')]"/>
<filter name="external_contact" string="Client-Side Contact" domain="[('contact_type', '=', 'external')]"/>
<filter name="internal_contact" string="In-House Contact"
domain="[('contact_type', '=', 'internal')]"/>
<filter name="external_contact" string="Client-Side Contact"
domain="[('contact_type', '=', 'external')]"/>
</xpath>
</field>
</record>
@ -27,12 +29,234 @@
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='category_id']" position="after">
<field name="contact_type"/>
<field name="contact_type"/>
</xpath>
</field>
</record>
<record id="view_vendor_partner_form" model="ir.ui.view">
<field name="name">vendor.partner.form</field>
<field name="model">res.partner</field>
<field name="arch" type="xml">
<form string="Vendor">
<sheet>
<field name="image_1920"
widget="image"
class="oe_avatar"
options="{'preview_image': 'avatar_128'}"/>
<div class="oe_title">
<h1>
<field name="name" placeholder="Vendor Name"/>
</h1>
</div>
<group>
<group>
<field name="company_type" widget="radio"
options="{'horizontal': true}"/>
<field name="email"/>
<field name="phone"/>
<field name="website" widget="url"/>
<field name="category_id"
widget="many2many_tags"
options="{'color_field':'color'}"/>
<field name="contact_type"
readonly="1"/>
</group>
<group>
<field name="street"/>
<field name="street2"/>
<field name="city"/>
<field name="state_id"/>
<field name="zip"/>
<field name="country_id"/>
<field name="vat"
string="GSTIN"/>
<field name="l10n_in_pan"
string="PAN"/>
</group>
</group>
<notebook>
<page string="Contacts &amp; Addresses">
<field name="child_ids"
context="{
'default_parent_id': id,
'default_type': 'contact'
}">
<kanban>
<field name="name"/>
<field name="email"/>
<field name="phone"/>
<templates>
<t t-name="card">
<div class="oe_kanban_global_click">
<strong>
<field name="name"/>
</strong>
<div>
<field name="email"/>
</div>
<div>
<field name="phone"/>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="view_vendor_partner_list" model="ir.ui.view">
<field name="name">vendor.partner.list</field>
<field name="model">res.partner</field>
<field name="arch" type="xml">
<list string="Vendors">
<field name="name"/>
<field name="company_type"/>
<field name="phone"/>
<field name="email"/>
<field name="vat"/>
<field name="l10n_in_pan"/>
<field name="website"/>
<field name="contact_type"/>
</list>
</field>
</record>
<record id="view_vendor_partner_kanban" model="ir.ui.view">
<field name="name">vendor.partner.kanban</field>
<field name="model">res.partner</field>
<field name="arch" type="xml">
<kanban class="o_kanban_mobile">
<field name="image_128"/>
<field name="name"/>
<field name="phone"/>
<field name="email"/>
<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>
<!-- Details -->
<div class="flex-grow-1 overflow-hidden">
<div class="fw-bold text-truncate">
<field name="name"/>
</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>
</t>
</templates>
</kanban>
</field>
</record>
<record id="action_vendor_partner" model="ir.actions.act_window">
<field name="name">Vendors</field>
<field name="res_model">res.partner</field>
<field name="view_mode">kanban,list,form</field>
<field name="context">
{
'default_contact_type':'external',
'search_default_external':1
}
</field>
<field name="domain">[('contact_type','=','external')]</field>
</record>
<record id="action_vendor_partner_kanban" model="ir.actions.act_window.view">
<field name="sequence" eval="1"/>
<field name="view_mode">kanban</field>
<field name="view_id" ref="view_vendor_partner_kanban"/>
<field name="act_window_id" ref="action_vendor_partner"/>
</record>
<record id="action_vendor_partner_list" model="ir.actions.act_window.view">
<field name="sequence" eval="2"/>
<field name="view_mode">list</field>
<field name="view_id" ref="view_vendor_partner_list"/>
<field name="act_window_id" ref="action_vendor_partner"/>
</record>
<record id="action_vendor_partner_form" model="ir.actions.act_window.view">
<field name="sequence" eval="3"/>
<field name="view_mode">form</field>
<field name="view_id" ref="view_vendor_partner_form"/>
<field name="act_window_id" ref="action_vendor_partner"/>
</record>
<record id="action_contacts_recruitments" model="ir.actions.act_window">
<field name="name">Contacts</field>
@ -56,15 +280,16 @@
id="menu_hr_recruitment_config_contacts"
name="Contacts"
parent="hr_recruitment.menu_hr_recruitment_configuration"
active="0"
sequence="10"/>
<menuitem
id="menu_hr_recruitment_stage"
name="Clients"
parent="menu_hr_recruitment_config_contacts"
action="action_contacts_recruitments"
name="Vendors"
parent="hr_recruitment.menu_hr_recruitment_root"
action="action_vendor_partner"
groups="base.group_user"
sequence="1"/>
sequence="98"/>
</data>
</odoo>

View File

@ -17,6 +17,7 @@
<field name="stage_id"/>
<field name="user_id"/>
<field name="comment"/>
<button string="Edit" name="edit_cmt" type="object" class="btn-primary"/>
</list>
</field>
</group>

View File

@ -35,7 +35,7 @@ class ClientSubmissionsMailTemplateWizard(models.TransientModel):
raise UserError("Email template not found.")
self.email_from = record.user_id.partner_id.email
self.email_to = record.hr_job_recruitment.requested_by.email
self.email_to = record.requested_by.email
self.email_body = email_template.body_html # Assign the rendered email bodyc
self.email_subject = email_template.subject

View File

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

View File

@ -0,0 +1,33 @@
{
"name": "HRMS Employee Dashboard",
"version": "18.0.1.0.0",
"category": "Human Resources",
"summary": "Employee self-service dashboard with attendance, leaves, expenses, equipment, and payslips",
"author": "Pranay",
"license": "LGPL-3",
"depends": [
"base",
"web",
"hr",
"hr_attendance",
"hr_holidays",
"maintenance",
"employee_it_declaration",
"business_travel_expense_management",
"helpdesk",
],
"data": [
"views/hrms_emp_dashboard_views.xml",
],
"assets": {
"web.assets_backend": [
"https://cdn.jsdelivr.net/npm/apexcharts@3.35.0/dist/apexcharts.min.js",
"hrms_emp_dashboard/static/src/js/hrms_emp_dashboard.js",
"hrms_emp_dashboard/static/src/xml/hrms_emp_dashboard.xml",
"hrms_emp_dashboard/static/src/css/hrms_emp_dashboard.css",
],
},
'images': ['static/description/banner.png'],
"installable": True,
"application": False,
}

View File

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

View File

@ -0,0 +1,385 @@
from collections import defaultdict
from datetime import date, datetime, time, timedelta
from dateutil.relativedelta import relativedelta
from odoo import fields, http, _
from odoo.http import request
class HrmsEmployeeDashboard(http.Controller):
@http.route("/hrms_emp_dashboard/data", type="json", auth="user")
def dashboard_data(
self,
month=False,
date_from=False,
date_to=False,
calendar_date_from=False,
calendar_date_to=False,
calendar_view="monthly",
**kwargs
):
employee = request.env.user.employee_id
if not employee:
return {"success": False, "error": _("No employee is linked to your user.")}
range_start, range_end = self._date_range(month, date_from, date_to)
if range_start > range_end:
return {"success": False, "error": _("From date must be before To date.")}
calendar_start, calendar_end = self._date_range(False, calendar_date_from, calendar_date_to)
if calendar_start > calendar_end:
return {"success": False, "error": _("Calendar From date must be before Calendar To date.")}
employee = employee.sudo()
attendances = self._get_attendances(employee, range_start, range_end)
leaves = self._get_leaves(employee, range_start, range_end)
public_holidays = self._get_public_holidays(employee, range_start, range_end)
calendar_attendances = self._get_attendances(employee, calendar_start, calendar_end)
calendar_leaves = self._get_leaves(employee, calendar_start, calendar_end)
calendar_public_holidays = self._get_public_holidays(employee, calendar_start, calendar_end)
print(self._expense_data(employee, range_start, range_end))
return {
"success": True,
"employee": self._employee_card(employee),
"attendance_state": employee.attendance_state,
"date_from": range_start.strftime("%Y-%m-%d"),
"date_to": range_end.strftime("%Y-%m-%d"),
"calendar_date_from": calendar_start.strftime("%Y-%m-%d"),
"calendar_date_to": calendar_end.strftime("%Y-%m-%d"),
"calendar_view": calendar_view,
"leave_balances": self._leave_balances(employee),
"public_holidays": self._holiday_list(public_holidays),
"attendance_calendar": self._attendance_calendar(
calendar_start,
calendar_end,
calendar_attendances,
calendar_leaves,
calendar_public_holidays,
calendar_view,
),
"attendance_summary": self._attendance_summary(range_start, range_end, attendances, leaves, public_holidays),
"expenses": self._expense_data(employee, range_start, range_end),
"equipment": self._equipment_data(employee),
"latest_payslip": self._latest_payslip(employee),
}
def _date_range(self, month=False, date_from=False, date_to=False):
today = fields.Date.context_today(request.env.user)
if date_from and date_to:
return fields.Date.to_date(date_from), fields.Date.to_date(date_to)
month_date = fields.Date.to_date(month) if month else today
month_start = month_date.replace(day=1)
month_end = month_start + relativedelta(months=1, days=-1)
return month_start, month_end
@http.route("/hrms_emp_dashboard/toggle_attendance", type="json", auth="user")
def toggle_attendance(self, **kwargs):
employee = request.env.user.employee_id
if not employee:
return {"success": False, "error": _("No employee is linked to your user.")}
employee.sudo()._attendance_action_change()
employee.invalidate_recordset(["attendance_state", "last_attendance_id"])
return {
"success": True,
"attendance_state": employee.sudo().attendance_state,
"message": _("Checked in") if employee.sudo().attendance_state == "checked_in" else _("Checked out"),
}
def _employee_card(self, employee):
return {
"id": employee.id,
"user_id": employee.user_id.id,
"partner_id": employee.user_id.partner_id.id if employee.user_id.partner_id else False,
"name": employee.name or "",
"image_url": "/web/image/hr.employee/%s/image_1920" % employee.id,
"job": employee.job_id.name if employee.job_id else "",
"department": employee.department_id.name if employee.department_id else "",
"joining_date": self._first_existing_date(employee, ["doj", "joining_date", "first_contract_date"]),
"birthday": self._date_string(employee.birthday),
"phone": employee.mobile_phone or employee.work_phone or "",
"email": employee.work_email or employee.user_id.email or "",
"address": self._address(employee),
"manager": employee.parent_id.name if employee.parent_id else "",
"company": employee.company_id.name if employee.company_id else "",
}
def _first_existing_date(self, record, field_names):
for field_name in field_names:
if field_name in record._fields and record[field_name]:
return self._date_string(record[field_name])
return ""
def _date_string(self, value):
return fields.Date.to_string(value) if value else ""
def _address(self, employee):
parts = []
for field_name in ["private_street", "private_street2", "private_city", "private_zip"]:
if field_name in employee._fields and employee[field_name]:
parts.append(employee[field_name])
if "private_state_id" in employee._fields and employee.private_state_id:
parts.append(employee.private_state_id.name)
if "private_country_id" in employee._fields and employee.private_country_id:
parts.append(employee.private_country_id.name)
return ", ".join(parts)
def _leave_balances(self, employee):
LeaveType = request.env["hr.leave.type"].sudo()
Allocation = request.env["hr.leave.allocation"].sudo()
Leave = request.env["hr.leave"].sudo()
balances = []
for leave_type in LeaveType.search([("active", "=", True)], order="sequence, name"):
allocations = Allocation.search([
("employee_id", "=", employee.id),
("holiday_status_id", "=", leave_type.id),
("state", "=", "validate"),
])
leaves = Leave.search([
("employee_id", "=", employee.id),
("holiday_status_id", "=", leave_type.id),
("state", "in", ["confirm", "validate1", "validate"]),
])
allocated = sum(allocations.mapped("number_of_days"))
taken = sum(leaves.filtered(lambda leave: leave.state == "validate").mapped("number_of_days"))
planned = sum(leaves.filtered(lambda leave: leave.state in ("confirm", "validate1")).mapped("number_of_days"))
if allocated or taken or planned or leave_type.requires_allocation == "no":
balances.append({
"id": leave_type.id,
"name": leave_type.name,
"allocated": round(allocated, 2),
"taken": round(taken, 2),
"planned": round(planned, 2),
"remaining": round(allocated - taken - planned, 2) if leave_type.requires_allocation != "no" else 0,
"requires_allocation": leave_type.requires_allocation,
})
return balances
def _get_attendances(self, employee, month_start, month_end):
start_dt = datetime.combine(month_start, time.min)
end_dt = datetime.combine(month_end, time.max)
return request.env["hr.attendance"].sudo().search([
("employee_id", "=", employee.id),
("check_in", ">=", start_dt),
("check_in", "<=", end_dt),
], order="check_in asc")
def _get_leaves(self, employee, month_start, month_end):
return request.env["hr.leave"].sudo().search([
("employee_id", "=", employee.id),
("state", "in", ["confirm", "validate1", "validate"]),
("request_date_from", "<=", month_end),
("request_date_to", ">=", month_start),
], order="request_date_from asc")
def _get_public_holidays(self, employee, month_start, month_end):
start_dt = datetime.combine(month_start, time.min)
end_dt = datetime.combine(month_end, time.max)
calendar = employee.resource_calendar_id or employee.company_id.resource_calendar_id
holidays = request.env["resource.calendar.leaves"].sudo().search([
("date_from", "<=", end_dt),
("date_to", ">=", start_dt),
("company_id", "in", [False, employee.company_id.id]),
], order="date_from asc")
return holidays.filtered(lambda holiday: (
(not holiday.resource_id or holiday.resource_id == employee.resource_id)
and (not holiday.calendar_id or holiday.calendar_id == calendar)
))
def _attendance_calendar(self, month_start, month_end, attendances, leaves, public_holidays, calendar_view="monthly"):
if calendar_view == "yearly":
return self._yearly_attendance_calendar(month_start, month_end, attendances, leaves, public_holidays)
today = fields.Date.context_today(request.env.user)
attendance_by_day = defaultdict(float)
for attendance in attendances:
day = fields.Datetime.context_timestamp(request.env.user, attendance.check_in).date()
attendance_by_day[day] += attendance.worked_hours or 0.0
leave_by_day = self._days_from_date_range_records(leaves, "request_date_from", "request_date_to", "holiday_status_id")
holiday_by_day = self._days_from_datetime_range_records(public_holidays, "date_from", "date_to")
days = []
cursor = month_start
if calendar_view == "monthly":
cursor = month_start - timedelta(days=(month_start.weekday() + 1) % 7)
end_cursor = month_end + timedelta(days=(5 - month_end.weekday()) % 7)
else:
end_cursor = month_end
while cursor <= end_cursor:
outside_period = cursor < month_start or cursor > month_end
status = "future" if cursor > today else "absent"
label = ""
hours = round(attendance_by_day.get(cursor, 0.0), 2)
if outside_period:
status = "empty"
elif hours:
status = "present"
elif cursor in holiday_by_day:
status = "holiday"
label = holiday_by_day[cursor]
elif cursor in leave_by_day:
status = "leave"
label = leave_by_day[cursor]
elif cursor.weekday() in (5, 6):
status = "weekend"
label = _("Weekend")
days.append({
"type": "day",
"date": cursor.strftime("%Y-%m-%d"),
"day": cursor.day,
"weekday": cursor.strftime("%a"),
"is_weekend": cursor.weekday() in (5, 6),
"outside_period": outside_period,
"status": status,
"label": label,
"hours": hours,
})
cursor += timedelta(days=1)
return days
def _yearly_attendance_calendar(self, range_start, range_end, attendances, leaves, public_holidays):
days = self._attendance_calendar(range_start, range_end, attendances, leaves, public_holidays, "monthly")
months = []
cursor = range_start.replace(day=1)
while cursor <= range_end:
month_key = cursor.strftime("%Y-%m")
month_days = [day for day in days if day["date"].startswith(month_key)]
counts = defaultdict(int)
for day in month_days:
counts[day["status"]] += 1
months.append({
"type": "month",
"date": month_key,
"weekday": cursor.strftime("%Y"),
"day": cursor.strftime("%b"),
"status": "present" if counts["present"] else "future",
"label": cursor.strftime("%B %Y"),
"present": counts["present"],
"absent": counts["absent"],
"leave": counts["leave"],
"holiday": counts["holiday"],
"hours": round(sum(day["hours"] for day in month_days), 2),
})
cursor += relativedelta(months=1)
return months
def _days_from_date_range_records(self, records, start_field, end_field, label_field=False):
result = {}
for record in records:
start = fields.Date.to_date(record[start_field])
end = fields.Date.to_date(record[end_field])
label = record[label_field].name if label_field and record[label_field] else record.display_name
while start and end and start <= end:
result[start] = label
start += timedelta(days=1)
return result
def _days_from_datetime_range_records(self, records, start_field, end_field):
result = {}
for record in records:
start = fields.Datetime.context_timestamp(request.env.user, record[start_field]).date()
end = fields.Datetime.context_timestamp(request.env.user, record[end_field]).date()
while start and end and start <= end:
result[start] = record.name or _("Public Holiday")
start += timedelta(days=1)
return result
def _attendance_summary(self, month_start, month_end, attendances, leaves, public_holidays):
days = self._attendance_calendar(month_start, month_end, attendances, leaves, public_holidays)
counts = defaultdict(int)
for day in days:
counts[day["status"]] += 1
return {
"present": counts["present"],
"absent": counts["absent"],
"leave": counts["leave"],
"holiday": counts["holiday"],
"hours": round(sum(attendances.mapped("worked_hours")), 2),
}
def _holiday_list(self, holidays):
return [{
"id": holiday.id,
"name": holiday.name or _("Public Holiday"),
"date_from": fields.Datetime.to_string(holiday.date_from),
"date_to": fields.Datetime.to_string(holiday.date_to),
} for holiday in holidays[:10]]
def _expense_data(self, employee, date_from, date_to):
labels = []
totals = defaultdict(float)
state_totals = defaultdict(float)
first_month = date_from.replace(day=1)
last_month = date_to.replace(day=1)
month = first_month
while month <= last_month:
labels.append(month.strftime("%b %Y"))
totals[month.strftime("%Y-%m")] += 0.0
month += relativedelta(months=1)
HrExpense = request.env["hr.expense"].sudo()
emp_expenses = HrExpense.search([
("employee_id", "=", employee.id),
("date", ">=", date_from),
("date", "<=", date_to),
])
for expense in emp_expenses:
print(
"Expense: %s | Date: %s | Amount: %s",
expense.name,
expense.date,
expense.total_amount
)
totals[expense.date.strftime("%Y-%m")] += expense.total_amount or 0.0
state_totals[expense.state or "draft"] += expense.total_amount or 0.0
if request.env.registry.get("hr.expense"):
hr_expenses = request.env["hr.expense"].sudo().search([
("employee_id", "=", employee.id),
("date", ">=", date_from),
("date", "<=", date_to),
])
for expense in hr_expenses:
totals[expense.date.strftime("%Y-%m")] += expense.total_amount_currency or expense.total_amount or 0.0
state_totals[expense.state or "draft"] += expense.total_amount_currency or expense.total_amount or 0.0
return {
"labels": labels,
"series": [round(totals[label_date], 2) for label_date in self._month_keys(first_month, last_month)],
"state_labels": [key.title() for key in state_totals.keys()],
"state_series": [round(value, 2) for value in state_totals.values()],
"currency": employee.company_id.currency_id.symbol or "",
}
def _month_keys(self, first_month, last_month):
keys = []
month = first_month
while month <= last_month:
keys.append(month.strftime("%Y-%m"))
month += relativedelta(months=1)
return keys
def _equipment_data(self, employee):
equipment = request.env["maintenance.equipment"].sudo().search([
("owner_user_id", "=", employee.user_id.id),
], order="assign_date desc, id desc", limit=12)
return [{
"id": item.id,
"name": item.name,
"category": item.category_id.name if item.category_id else "",
"serial": item.serial_no or getattr(item, "comp_serial_no", "") or "",
"assign_date": self._date_string(item.assign_date),
} for item in equipment]
def _latest_payslip(self, employee):
payslip = request.env["hr.payslip"].sudo().search([
("employee_id", "=", employee.id),
("state", "in", ["done", "paid"]),
], order="date_to desc, id desc", limit=1)
return {
"id": payslip.id if payslip else False,
"name": payslip.name if payslip else "",
"date_from": self._date_string(payslip.date_from) if payslip else "",
"date_to": self._date_string(payslip.date_to) if payslip else "",
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,414 @@
.hrms-emp-dashboard {
height: calc(100vh - 84px);
overflow-y: auto;
overflow-x: hidden;
padding: 20px;
background: #f4f7fb;
color: #111827;
}
.hrms-loading {
padding: 24px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.hrms-employee-card,
.hrms-panel,
.hrms-kpi {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.05);
}
.hrms-employee-card {
display: flex;
justify-content: space-between;
gap: 20px;
padding: 20px;
margin-bottom: 14px;
background: linear-gradient(135deg, #172554 0%, #0f766e 100%);
color: #fff;
}
.hrms-employee-main {
display: flex;
gap: 18px;
min-width: 0;
}
.hrms-avatar {
width: 112px;
height: 112px;
object-fit: cover;
border-radius: 8px;
background: #e2e8f0;
border: 3px solid rgba(255, 255, 255, 0.7);
}
.hrms-employee-card h1 {
margin: 0;
color: #fff;
font-size: 28px;
font-weight: 800;
}
.hrms-role {
color: #ccfbf1;
margin-top: 4px;
}
.hrms-employee-grid {
display: grid;
grid-template-columns: repeat(2, minmax(220px, 1fr));
gap: 8px 18px;
margin-top: 14px;
color: #e0f2fe;
}
.hrms-employee-grid i {
width: 18px;
color: #ffffff;
}
.hrms-employee-grid b {
color: #ffffff;
font-weight: 700;
}
.hrms-employee-actions {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
min-width: 300px;
}
.hrms-action-buttons,
.hrms-panel-actions {
display: flex;
align-items: center;
gap: 10px;
}
.hrms-icon-button {
min-width: 92px;
padding: 10px 12px;
border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: 8px;
background: rgba(255, 255, 255, 0.16);
color: #fff;
font-weight: 700;
}
.hrms-icon-button i {
display: block;
font-size: 22px;
margin-bottom: 4px;
}
.hrms-icon-button.primary {
background: #16a34a;
border-color: #22c55e;
}
.hrms-filter-box {
width: 100%;
padding: 12px;
border: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 8px;
background: rgba(255, 255, 255, 0.12);
}
.hrms-filter-box label {
display: block;
margin-bottom: 4px;
color: #e0f2fe;
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.hrms-custom-dates {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-top: 8px;
}
.hrms-kpi-row {
display: grid;
grid-template-columns: repeat(5, minmax(140px, 1fr));
gap: 12px;
margin-bottom: 14px;
}
.hrms-kpi {
padding: 14px;
}
.hrms-kpi span {
display: block;
color: #64748b;
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
.hrms-kpi strong {
display: block;
margin-top: 8px;
font-size: 26px;
color: #0f172a;
}
.hrms-kpi.present strong { color: #16a34a; }
.hrms-kpi.absent strong { color: #dc2626; }
.hrms-kpi.leave strong,
.hrms-kpi.holiday strong { color: #f97316; }
.hrms-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.hrms-panel {
padding: 16px;
min-width: 0;
}
.hrms-panel.wide {
grid-column: span 2;
}
.hrms-panel h2 {
margin: 0 0 12px;
font-size: 16px;
font-weight: 800;
color: #0f172a;
}
.hrms-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.hrms-month-controls {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.hrms-calendar-select {
width: 112px;
}
.hrms-calendar {
display: grid;
grid-template-columns: repeat(7, minmax(82px, 1fr));
gap: 10px;
}
.hrms-calendar-weekdays {
display: grid;
grid-template-columns: repeat(7, minmax(82px, 1fr));
gap: 10px;
margin-bottom: 8px;
}
.hrms-calendar-weekdays span {
color: #64748b;
font-size: 11px;
font-weight: 800;
text-align: center;
text-transform: uppercase;
}
.hrms-calendar.yearly {
grid-template-columns: repeat(4, minmax(160px, 1fr));
}
.hrms-day {
min-height: 82px;
padding: 8px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
}
.hrms-day span,
.hrms-day small {
display: block;
color: #64748b;
font-size: 11px;
}
.hrms-day strong {
display: block;
width: 34px;
height: 34px;
line-height: 31px;
margin-top: 6px;
border-radius: 999px;
text-align: center;
border: 2px solid #cbd5e1;
color: #0f172a;
}
.hrms-day.present strong { border-color: #16a34a; background: #dcfce7; }
.hrms-day.absent strong { border-color: #dc2626; color: #dc2626; background: #fff; }
.hrms-day.leave strong,
.hrms-day.holiday strong { border-color: #f97316; background: #ffedd5; color: #9a3412; }
.hrms-day.weekend strong { border-color: #94a3b8; background: #f1f5f9; color: #475569; }
.hrms-day.future { opacity: 0.55; }
.hrms-day.empty {
background: #f8fafc;
border-style: dashed;
opacity: 0.45;
}
.hrms-leave-summary {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 12px;
}
.hrms-leave-tile {
min-width: 180px;
flex: 1 1 180px; /* grow, shrink, basis */
max-width: 250px;
padding: 10px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #f8fafc;
}
.hrms-leave-tile strong {
display: block;
color: #0f172a;
font-size: 13px;
margin-bottom: 8px;
}
.hrms-leave-tile span {
color: #64748b;
font-size: 11px;
}
.hrms-leave-tile b {
display: block;
color: #0f172a;
font-size: 18px;
}
.hrms-two-charts {
display: grid;
grid-template-columns: 1.3fr 0.7fr;
gap: 12px;
}
.hrms-list {
display: grid;
gap: 10px;
}
.hrms-list-row {
display: flex;
gap: 10px;
padding: 10px;
background: #f8fafc;
border-radius: 8px;
}
.hrms-list-row i {
color: #f97316;
margin-top: 3px;
}
.hrms-list-row span,
.hrms-muted {
display: block;
color: #64748b;
font-size: 12px;
}
.hrms-equipment-grid {
display: grid;
grid-template-columns: repeat(4, minmax(160px, 1fr));
gap: 10px;
}
.hrms-equipment {
text-align: left;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #f8fafc;
padding: 12px;
color: #0f172a;
}
.hrms-equipment i {
color: #2563eb;
font-size: 20px;
}
.hrms-equipment strong,
.hrms-equipment span,
.hrms-equipment small {
display: block;
margin-top: 4px;
}
.hrms-equipment span,
.hrms-equipment small {
color: #64748b;
}
@media (max-width: 1200px) {
.hrms-employee-card,
.hrms-employee-main {
flex-direction: column;
}
.hrms-employee-grid,
.hrms-grid,
.hrms-two-charts {
grid-template-columns: 1fr;
}
.hrms-panel.wide {
grid-column: span 1;
}
.hrms-kpi-row,
.hrms-equipment-grid,
.hrms-calendar.yearly {
grid-template-columns: repeat(2, minmax(140px, 1fr));
}
}
@media (max-width: 700px) {
.hrms-kpi-row,
.hrms-equipment-grid,
.hrms-calendar,
.hrms-calendar.yearly,
.hrms-leave-summary,
.hrms-custom-dates {
grid-template-columns: 1fr;
}
.hrms-panel-header,
.hrms-panel-actions,
.hrms-action-buttons {
align-items: stretch;
flex-direction: column;
}
}

View File

@ -0,0 +1,395 @@
/** @odoo-module **/
import { Component, onMounted, onWillDestroy, useState, xml } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
class HrmsEmployeeDashboard extends Component {
static template = "hrms_emp_dashboard.HrmsEmployeeDashboard";
setup() {
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());
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekStart.getDate() + 6);
this.state = useState({
loading: true,
error: null,
data: null,
period: "this_month",
calendarView: "weekly",
dateFrom: this.formatDate(monthStart),
dateTo: this.formatDate(monthEnd),
calendarDateFrom: this.formatDate(weekStart),
calendarDateTo: this.formatDate(weekEnd),
});
this.rpc = rpc;
this.action = useService("action");
this.dialog = useService("dialog");
this.notification = useService("notification");
this.charts = [];
onMounted(() => {
this.loadData();
this.onCalendarViewChange({
target: { value: this.state.calendarView }
});
});
onWillDestroy(() => this.destroyCharts());
}
formatDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
async loadData() {
this.state.loading = true;
this.state.error = null;
try {
const response = await this.rpc("/hrms_emp_dashboard/data", {
date_from: this.state.dateFrom,
date_to: this.state.dateTo,
calendar_date_from: this.state.calendarDateFrom,
calendar_date_to: this.state.calendarDateTo,
calendar_view: this.state.calendarView,
});
if (!response.success) {
this.state.error = response.error || "Unable to load employee dashboard.";
return;
}
this.state.data = response;
setTimeout(() => this.initCharts(), 100);
} catch (error) {
console.error(error);
this.state.error = "Unable to load employee dashboard.";
this.notification.add("Unable to load employee dashboard", { type: "danger" });
} finally {
this.state.loading = false;
}
}
destroyCharts() {
for (const chart of this.charts) {
try {
chart.destroy();
} catch (error) {
console.warn("Unable to destroy chart", error);
}
}
this.charts = [];
}
initCharts() {
if (!window.ApexCharts || !this.state.data) {
return;
}
this.destroyCharts();
const leaves = this.state.data.leave_balances || [];
debugger;
this.renderLeaveChart(leaves);
this.renderExpenseChart();
this.renderExpenseStateChart();
}
chartBase(element, config) {
const node = document.querySelector(element);
if (!node) {
return;
}
const chart = new ApexCharts(node, {
chart: {
height: config.height || 300,
toolbar: { show: false },
animations: { enabled: true },
fontFamily: "Inter, system-ui, sans-serif",
...config.chart,
},
noData: { text: "No data" },
grid: { borderColor: "#e5e7eb" },
...config,
});
chart.render();
this.charts.push(chart);
}
renderLeaveChart(leaves) {
this.chartBase("#hrmsLeaveChart", {
chart: { type: "bar" },
series: [
{ name: "Remaining", data: leaves.map((leave) => leave.remaining) },
{ name: "Taken", data: leaves.map((leave) => leave.taken) },
{ name: "Planned", data: leaves.map((leave) => leave.planned) },
],
colors: ["#16a34a", "#2563eb", "#f59e0b"],
plotOptions: { bar: { borderRadius: 4, columnWidth: "50%" } },
dataLabels: { enabled: false },
xaxis: { categories: leaves.map((leave) => leave.name), labels: { rotate: -25, trim: true } },
yaxis: { min: 0, forceNiceScale: true },
});
}
renderExpenseChart() {
const expenses = this.state.data.expenses;
debugger;
this.chartBase("#hrmsExpenseChart", {
chart: {
type: "area",
events: {
dataPointSelection: () => this.openExpenses(),
},
},
series: [{ name: "Expenses", data: expenses.series }],
colors: ["#7c3aed"],
stroke: { curve: "smooth", width: 3 },
fill: { type: "gradient", gradient: { opacityFrom: 0.35, opacityTo: 0.04 } },
dataLabels: { enabled: false },
xaxis: { categories: expenses.labels },
tooltip: { y: { formatter: (value) => `${expenses.currency} ${Number(value || 0).toFixed(2)}` } },
});
}
renderExpenseStateChart() {
const expenses = this.state.data.expenses;
this.chartBase("#hrmsExpenseStateChart", {
chart: {
type: "donut",
events: {
dataPointSelection: (event, chartContext, config) => {
debugger;
const stateIndex = chartContext.dataPointIndex;
const state = expenses.state_labels[config.dataPointIndex];
this.openExpenses(state);
},
},
},
labels: expenses.state_labels,
series: expenses.state_series,
colors: ["#64748b", "#f59e0b", "#16a34a", "#dc2626", "#2563eb"],
dataLabels: { enabled: false },
legend: { position: "bottom" },
plotOptions: { pie: { donut: { size: "68%", labels: { show: true, total: { show: true } } } } },
});
}
onPeriodChange(event) {
const period = event.target.value;
this.state.period = period;
if (period === "custom") {
return;
}
this.applyPeriod(period);
}
applyPeriod(period) {
const today = new Date();
let dateFrom;
let dateTo;
if (period === "this_year") {
dateFrom = new Date(today.getFullYear(), 0, 1);
dateTo = new Date(today.getFullYear(), 11, 31);
} else if (period === "last_3_months") {
dateFrom = new Date(today.getFullYear(), today.getMonth() - 2, 1);
dateTo = new Date(today.getFullYear(), today.getMonth() + 1, 0);
} else {
dateFrom = new Date(today.getFullYear(), today.getMonth(), 1);
dateTo = new Date(today.getFullYear(), today.getMonth() + 1, 0);
}
this.state.dateFrom = this.formatDate(dateFrom);
this.state.dateTo = this.formatDate(dateTo);
this.loadData();
}
onCustomDateChange(field, event) {
this.state[field] = event.target.value;
if (this.state.dateFrom && this.state.dateTo) {
this.loadData();
}
}
onCalendarViewChange(event) {
this.state.calendarView = event.target.value;
const today = new Date();
if (this.state.calendarView === "yearly") {
this.state.calendarDateFrom = this.formatDate(new Date(today.getFullYear(), 0, 1));
this.state.calendarDateTo = this.formatDate(new Date(today.getFullYear(), 11, 31));
} else if (this.state.calendarView === "weekly") {
const weekStart = new Date(today);
weekStart.setDate(weekStart.getDate() - weekStart.getDay());
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekStart.getDate() + 6);
this.state.calendarDateFrom = this.formatDate(weekStart);
this.state.calendarDateTo = this.formatDate(weekEnd);
} else {
this.state.calendarDateFrom = this.formatDate(new Date(today.getFullYear(), today.getMonth(), 1));
this.state.calendarDateTo = this.formatDate(new Date(today.getFullYear(), today.getMonth() + 1, 0));
}
this.loadData();
}
previousMonth() {
this.shiftCalendar(-1);
}
nextMonth() {
this.shiftCalendar(1);
}
shiftCalendar(direction) {
const from = new Date(`${this.state.calendarDateFrom}T00:00:00`);
const to = new Date(`${this.state.calendarDateTo}T00:00:00`);
if (this.state.calendarView === "yearly") {
from.setFullYear(from.getFullYear() + direction, 0, 1);
to.setFullYear(to.getFullYear() + direction, 11, 31);
} else if (this.state.calendarView === "weekly") {
from.setDate(from.getDate() + (direction * 7));
to.setDate(to.getDate() + (direction * 7));
} else {
const shiftedMonth = new Date(from.getFullYear(), from.getMonth() + direction, 1);
from.setFullYear(shiftedMonth.getFullYear(), shiftedMonth.getMonth(), 1);
to.setFullYear(shiftedMonth.getFullYear(), shiftedMonth.getMonth() + 1, 0);
}
this.state.calendarDateFrom = this.formatDate(from);
this.state.calendarDateTo = this.formatDate(to);
this.loadData();
}
addExpense() {
this.action.doAction({
type: "ir.actions.act_window",
name: "Add Expense",
res_model: "hr.expense",
views: [[false, "form"]],
target: "new",
context: {
default_expense_date: this.formatDate(new Date()),
},
});
}
addBusinessTravel() {
this.action.doAction({
type: "ir.actions.act_window",
name: "Add Business Travel",
res_model: "travel.trip",
views: [[false, "form"]],
target: "new",
context: {
default_employee_id: this.state.data.employee.id,
default_start_date: this.state.dateFrom,
default_end_date: this.state.dateTo,
},
});
}
raiseHelpdeskTicket() {
window.open(
'/helpdesk/new',
'helpdesk',
'width=1200,height=800,resizable=yes,scrollbars=yes'
);
}
async toggleAttendance() {
try {
const response = await this.rpc("/hrms_emp_dashboard/toggle_attendance", {});
if (!response.success) {
this.notification.add(response.error || "Unable to update attendance", { type: "danger" });
return;
}
this.notification.add(response.message, { type: "success" });
await this.loadData();
} catch (error) {
console.error(error);
this.notification.add("Unable to update attendance", { type: "danger" });
}
}
applyLeave() {
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 },
});
}
async downloadPayslip() {
const action = await this.action.loadAction(
"employee_it_declaration.action_employee_payslip_download_wizard"
);
await this.action.doAction({
...action,
target: "new",
});
}
openAttendances() {
this.action.doAction({
type: "ir.actions.act_window",
name: "My Attendances",
res_model: "hr.attendance",
views: [[false, "list"], [false, "form"]],
domain: [["employee_id", "=", this.state.data.employee.id]],
target: "current",
});
}
openExpenses(state = false) {
debugger;
const domain = [["employee_id", "=", this.state.data.employee.id]];
if (state) {
domain.push(["state", "=", state.toLowerCase()]);
}
this.action.doAction({
type: "ir.actions.act_window",
name: state ? `My Travel Expenses - ${state}` : "My Travel Expenses",
res_model: "hr.expense",
views: [[false, "list"], [false, "form"]],
domain: domain,
target: "current",
});
}
openEquipment(equipmentId = false) {
this.action.doAction({
type: "ir.actions.act_window",
name: "My Equipment",
res_model: "maintenance.equipment",
views: equipmentId ? [[false, "form"]] : [[false, "list"], [false, "form"]],
res_id: equipmentId || undefined,
domain: [["owner_user_id", "=", this.state.data.employee.user_id]],
target: "current",
});
}
get statusText() {
return this.state.data?.attendance_state === "checked_in" ? "Check Out" : "Check In";
}
get monthLabel() {
const from = new Date(`${this.state.calendarDateFrom}T00:00:00`);
const to = new Date(`${this.state.calendarDateTo}T00:00:00`);
if (this.state.calendarView === "yearly") {
return from.getFullYear() === to.getFullYear() ? `${from.getFullYear()}` : `${from.getFullYear()} - ${to.getFullYear()}`;
}
if (this.state.calendarView === "weekly") {
return `${from.toLocaleDateString()} - ${to.toLocaleDateString()}`;
}
return from.toLocaleDateString(undefined, { month: "long", year: "numeric" });
}
}
registry.category("actions").add("hrms_emp_dashboard", HrmsEmployeeDashboard);

View File

@ -0,0 +1,181 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="hrms_emp_dashboard.HrmsEmployeeDashboard">
<div class="hrms-emp-dashboard">
<div t-if="state.error" class="alert alert-danger" t-esc="state.error"/>
<div t-if="state.loading" class="hrms-loading">Loading employee dashboard...</div>
<t t-if="state.data &amp;&amp; !state.loading">
<section class="hrms-employee-card">
<div class="hrms-employee-main">
<img class="hrms-avatar" t-att-src="state.data.employee.image_url" alt="Employee"/>
<div>
<h1 t-esc="state.data.employee.name"/>
<div class="hrms-role">
<span t-esc="state.data.employee.job || 'Employee'"/>
<span t-if="state.data.employee.department"> · <t t-esc="state.data.employee.department"/></span>
</div>
<div class="hrms-employee-grid">
<span><i class="fa fa-calendar-check-o"/> DOJ: <b t-esc="state.data.employee.joining_date || '-'"/></span>
<span><i class="fa fa-birthday-cake"/> DOB: <b t-esc="state.data.employee.birthday || '-'"/></span>
<span><i class="fa fa-phone"/> <b t-esc="state.data.employee.phone || '-'"/></span>
<span><i class="fa fa-envelope"/> <b t-esc="state.data.employee.email || '-'"/></span>
<span><i class="fa fa-user"/> Manager: <b t-esc="state.data.employee.manager || '-'"/></span>
<span><i class="fa fa-map-marker"/> <b t-esc="state.data.employee.address || '-'"/></span>
</div>
</div>
</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 class="hrms-icon-button" t-on-click="downloadPayslip" title="Download Payslip">
<i class="fa fa-download"/>
<span>Payslip</span>
</button>
<button class="hrms-icon-button" t-on-click="raiseHelpdeskTicket" title="Raise Helpdesk Ticket">
<i class="fa fa-life-ring"/>
<span>Helpdesk</span>
</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">
<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>
<option value="custom" t-att-selected="state.period === 'custom'">Custom</option>
</select>
<div t-if="state.period === 'custom'" class="hrms-custom-dates">
<div>
<label>From</label>
<input class="form-control form-control-sm" type="date" t-att-value="state.dateFrom" t-on-change="(ev) => this.onCustomDateChange('dateFrom', ev)"/>
</div>
<div>
<label>To</label>
<input class="form-control form-control-sm" type="date" t-att-value="state.dateTo" t-on-change="(ev) => this.onCustomDateChange('dateTo', ev)"/>
</div>
</div>
</div>
</div>
</section>
<section class="hrms-kpi-row">
<div class="hrms-kpi present"><span>Present</span><strong t-esc="state.data.attendance_summary.present"/></div>
<div class="hrms-kpi absent"><span>No Check-In</span><strong t-esc="state.data.attendance_summary.absent"/></div>
<div class="hrms-kpi leave"><span>Leaves</span><strong t-esc="state.data.attendance_summary.leave"/></div>
<div class="hrms-kpi holiday"><span>Holidays</span><strong t-esc="state.data.attendance_summary.holiday"/></div>
<div class="hrms-kpi"><span>Hours</span><strong><t t-esc="state.data.attendance_summary.hours"/>h</strong></div>
</section>
<div class="hrms-grid">
<section class="hrms-panel wide">
<div class="hrms-panel-header">
<h2>Attendance 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>
<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>
<option value="yearly" t-att-selected="state.calendarView === 'yearly'">Yearly</option>
</select>
</div>
</div>
<div t-if="state.calendarView !== 'yearly'" class="hrms-calendar-weekdays">
<span>Sun</span>
<span>Mon</span>
<span>Tue</span>
<span>Wed</span>
<span>Thu</span>
<span>Fri</span>
<span>Sat</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.hours + 'h'">
<t t-if="day.status === 'empty'">
<span/>
</t>
<t t-elif="day.type === 'month'">
<span t-esc="day.weekday"/>
<strong t-esc="day.day"/>
<small><t t-esc="day.present"/> present · <t t-esc="day.hours"/>h</small>
<small><t t-esc="day.leave"/> leave · <t t-esc="day.holiday"/> holidays</small>
</t>
<t t-else="">
<span t-esc="day.weekday"/>
<strong t-esc="day.day"/>
<small t-if="day.hours"><t t-esc="day.hours"/>h</small>
<small t-elif="day.label" t-esc="day.label"/>
</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>
</div>
</div>
</div>
</div>
<div id="hrmsLeaveChart"/>
</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>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>
</div>
</t>
</div>
</t>
</templates>

View File

@ -0,0 +1,15 @@
<?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="tag">hrms_emp_dashboard</field>
<field name="target">current</field>
</record>
<menuitem id="menu_hrms_emp_dashboard_root"
name="Employee Dashboard"
action="action_hrms_emp_dashboard"
web_icon="hrms_emp_dashboard,static/description/icon.png"
sequence="-90"
groups="base.group_user"/>
</odoo>

View File

@ -19,11 +19,15 @@
'views/login.xml',
'views/menu_access_control_views.xml',
],
# 'assets': {
# 'web.assets_backend': [
# 'menu_control_center/static/src/js/login.js',
# ],
# },
'assets': {
'web.assets_backend': [
'menu_control_center/static/src/js/master_background.js',
'menu_control_center/static/src/scss/home_menu_background.scss',
],
'web.assets_frontend': [
'menu_control_center/static/src/scss/home_menu_background.scss', # used by login page
],
},
'installable': True,
'application': True,
'auto_install': False,

View File

@ -8,9 +8,21 @@ class IrHttp(models.AbstractModel):
def session_info(self):
info = super().session_info()
info["master_background_url"] = False
info["master_glass_effect"] = False
active_master = http.request.session.get("active_master")
if active_master:
info['user_context']['active_master'] = active_master
master = request.env['master.control'].sudo().search(
[('code', '=', active_master)],
limit=1
)
if master.use_custom_bg and master.background_image:
info["master_background_url"] = (
f"/web/image/master.control/{master.id}/background_image"
)
if master.enable_glass_effect:
info["master_glass_effect"] = master.enable_glass_effect
else:
info['user_context']['active_master'] = ''
return info

View File

@ -9,6 +9,9 @@ class MasterControl(models.Model):
sequence = fields.Integer()
name = fields.Char(string='Master Name', required=True)
code = fields.Char(string='Code', required=True)
custom_menu_order = fields.Boolean(string='Custom Menu Order', default=False)
custom_menu_icons = fields.Boolean(string='Custom Menu Icons', default=False)
enable_rename_option = fields.Boolean(string='Custom Menu Naming', default=False)
user_ids = fields.Many2many(
'res.users',
'master_control_res_users_rel',
@ -28,12 +31,45 @@ class MasterControl(models.Model):
string='Sub Menus',
domain=[('menu_id.parent_id', '!=', False)],
)
order_menu_line_ids = fields.One2many(
'master.control.menu.line',
'master_control_id',
string='Menu Order',
domain=[('menu_id.parent_id', '=', False), ('show_menu', '=', True)],
)
rename_menu_line_ids = fields.One2many(
'master.control.menu.line',
'master_control_id',
string='Menu Order',
domain=[('menu_id.parent_id', '=', False), ('show_menu', '=', True)],
)
rename_submenu_line_ids = fields.One2many(
'master.control.menu.line',
'master_control_id',
string='Sub Menus',
domain=[('menu_id.parent_id', '!=', False), ('show_menu', '=', True)],
)
allowed_menu_ids = fields.Many2many(
'ir.ui.menu',
compute='_compute_allowed_menu_ids',
string='Allowed Menus',
)
use_custom_bg = fields.Boolean("Custom Background")
background_image = fields.Binary(
"Background Image",
attachment=True,
)
background_image_filename = fields.Char()
enable_glass_effect = fields.Boolean(
string="Enable Glass Effect",
default=False
)
_sql_constraints = [
('master_control_code_unique', 'unique(code)', 'Master code must be unique.'),
]
@ -74,21 +110,21 @@ class MasterControl(models.Model):
def _get_all_menus_sql(self):
self.env.cr.execute("""
WITH RECURSIVE menu_tree AS (
SELECT id, parent_id
SELECT id, parent_id, sequence
FROM ir_ui_menu
WHERE parent_id IS NULL
AND active = true
UNION ALL
SELECT menu.id, menu.parent_id
SELECT menu.id, menu.parent_id, menu.sequence
FROM ir_ui_menu menu
JOIN menu_tree tree ON tree.id = menu.parent_id
WHERE menu.active = true
)
SELECT id, parent_id
SELECT id, parent_id, sequence
FROM menu_tree
ORDER BY parent_id NULLS FIRST, id
ORDER BY parent_id NULLS FIRST, sequence, id
""")
return self.env.cr.dictfetchall()
@ -110,13 +146,14 @@ class MasterControl(models.Model):
(record.menu_line_ids | record.submenu_line_ids).unlink()
existing_lines = {}
for main_menu in children_map.get(None, []):
for index, main_menu in enumerate(children_map.get(None, []), start=1):
parent_line = existing_lines.get(main_menu['id'])
if not parent_line:
parent_line = line_model.create({
'master_control_id': record.id,
'menu_id': main_menu['id'],
'show_menu': True,
'custom_order_sequence': index * 10,
})
existing_lines[main_menu['id']] = parent_line
notification_count += 1
@ -130,6 +167,7 @@ class MasterControl(models.Model):
'menu_id': submenu['id'],
'show_menu': True,
'parent_line_id': parent_line.id,
'custom_order_sequence': submenu.get('sequence') or 10,
})
existing_lines[submenu['id']] = True
notification_count += 1
@ -178,13 +216,18 @@ class MasterControlMenuLine(models.Model):
_name = 'master.control.menu.line'
_description = 'Master Control Menu Line'
_rec_name = 'menu_id'
_order = 'menu_id'
_order = 'custom_order_sequence, menu_sequence, menu_id, id'
master_control_id = fields.Many2one('master.control', required=True, ondelete='cascade')
menu_id = fields.Many2one('ir.ui.menu', string='Menu', required=True)
menu_sequence = fields.Integer(related='menu_id.sequence', string='Menu Sequence', store=True)
custom_order_sequence = fields.Integer(string='Order', default=10)
show_menu = fields.Boolean(string='Show Menu', default=True)
parent_menu_id = fields.Many2one('ir.ui.menu', related='menu_id.parent_id', string='Parent Menu', store=True)
parent_line_id = fields.Many2one('master.control.menu.line', string='Parent Line')
menu_name = fields.Char(string='Original Name', related='menu_id.name')
menu_custom_name = fields.Char(string='Custom Name')
menu_icon = fields.Binary(string='Icon')
_sql_constraints = [
('master_control_menu_unique', 'unique(master_control_id, menu_id)', 'Menu already exists for this master control.'),
@ -204,6 +247,19 @@ class MasterControlMenuLine(models.Model):
'domain': [('parent_line_id', '=', self.id)],
}
def open_rename_submenus_popup_view(self):
self.ensure_one()
return {
'name': _('Sub Menus'),
'type': 'ir.actions.act_window',
'res_model': 'master.control.menu.line',
'view_mode': 'list',
'views': [
(self.env.ref('menu_control_center.view_master_rename_submenu_line_list').id, 'list'),
],
'target': 'new',
'domain': [('parent_line_id', '=', self.id),('show_menu','=',True)],
}
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)

View File

@ -10,14 +10,17 @@ class IrUiMenu(models.Model):
def _get_active_master_code(self):
return (request.session.get('active_master') if request else False) or self.env.context.get('active_master')
def _get_active_master_control(self):
active_master_code = self._get_active_master_code()
return self.env['master.control'].sudo().search([('code', '=', active_master_code)], limit=1) if active_master_code else False
@api.model
@tools.ormcache('frozenset(self.env.user.groups_id.ids)', 'debug', 'self.env.uid', 'self._get_active_master_code() or ""')
def _visible_menu_ids(self, debug=False):
context = {'ir.ui.menu.full_list': True}
menus = self.with_context(context).search_fetch([], ['action', 'parent_id']).sudo()
active_master_code = self._get_active_master_code()
master_control = self.env['master.control'].sudo().search([('code', '=', active_master_code)], limit=1) if active_master_code else False
master_control = self._get_active_master_control()
group_ids = set(self.env.user._get_group_ids())
if not debug:
@ -40,14 +43,104 @@ class IrUiMenu(models.Model):
visible = self._process_action_menus(menus)
return set(visible.ids)
def _get_master_menu_order_map(self, master_control):
if not master_control or not master_control.custom_menu_order:
return {}
visible_lines = master_control.order_menu_line_ids.filtered('show_menu').sorted(
key=lambda line: (
line.custom_order_sequence,
line.menu_id.sequence,
line.menu_id.name or '',
line.menu_id.id,
)
)
return {line.menu_id.id: index for index, line in enumerate(visible_lines)}
def _get_master_menu_icon_map(self, master_control):
if not master_control or not master_control.custom_menu_icons:
return {}
visible_lines = (
master_control.order_menu_line_ids
).filtered(
lambda l: l.show_menu and l.menu_icon
)
return {
line.menu_id.id: (
line.menu_icon.decode('utf-8')
if isinstance(line.menu_icon, bytes)
else line.menu_icon
)
for line in visible_lines
}
def _get_master_menu_rename_map(self, master_control):
if not master_control or not master_control.enable_rename_option:
return {}
visible_lines = (
master_control.rename_menu_line_ids +
master_control.rename_submenu_line_ids
).filtered(
lambda l: l.show_menu and l.menu_custom_name
)
return {
line.menu_id.id: line.menu_custom_name
for line in visible_lines
}
def _sort_root_menu_ids(self, menu_ids, master_control):
order_map = self._get_master_menu_order_map(master_control)
if not order_map:
return menu_ids
default_positions = {menu_id: index for index, menu_id in enumerate(menu_ids)}
return sorted(
menu_ids,
key=lambda menu_id: (
order_map.get(menu_id, len(order_map)),
default_positions[menu_id],
)
)
def _sort_root_menu_dicts(self, menus, master_control):
order_map = self._get_master_menu_order_map(master_control)
if not order_map:
return menus
default_positions = {menu['id']: index for index, menu in enumerate(menus)}
return sorted(
menus,
key=lambda menu: (
order_map.get(menu['id'], len(order_map)),
default_positions[menu['id']],
)
)
@api.model
def load_menus_root(self):
root = super().load_menus_root()
visible_ids = self._visible_menu_ids(request.session.debug if request else False)
master_control = self._get_active_master_control()
rename_map = self._get_master_menu_rename_map(master_control)
icon_map = self._get_master_menu_icon_map(master_control)
root['children'] = [
child for child in root.get('children', [])
if child['id'] in visible_ids
]
for child in root['children']:
if child['id'] in rename_map:
child['name'] = rename_map[child['id']]
if child['id'] in icon_map:
child['web_icon_data'] = icon_map[child['id']]
root['children'] = self._sort_root_menu_dicts(root['children'], master_control)
root['all_menu_ids'] = [
menu_id for menu_id in root.get('all_menu_ids', [])
if menu_id in visible_ids
@ -58,6 +151,9 @@ class IrUiMenu(models.Model):
def load_menus(self, debug):
all_menus = super().load_menus(debug)
visible_ids = self._visible_menu_ids(debug)
master_control = self._get_active_master_control()
rename_map = self._get_master_menu_rename_map(master_control)
icon_map = self._get_master_menu_icon_map(master_control)
filtered_menus = {'root': dict(all_menus['root'])}
for menu_id, menu_data in all_menus.items():
@ -70,6 +166,10 @@ class IrUiMenu(models.Model):
child_id for child_id in filtered_menus['root'].get('children', [])
if child_id in filtered_menus
]
filtered_menus['root']['children'] = self._sort_root_menu_ids(
filtered_menus['root']['children'],
master_control,
)
for menu_id, menu_data in list(filtered_menus.items()):
if menu_id == 'root':
@ -79,6 +179,14 @@ class IrUiMenu(models.Model):
if child_id in filtered_menus
]
for menu_id, menu_data in filtered_menus.items():
if menu_id == 'root':
continue
if menu_id in rename_map:
menu_data['name'] = rename_map[menu_id]
if menu_id in icon_map:
menu_data['web_icon_data'] = icon_map[menu_id]
return filtered_menus
def _get_hidden_menu_ids(self, master_control):
@ -170,4 +278,4 @@ class IrUiMenu(models.Model):
# Clear caches
self.clear_caches()
return super().unlink()
return super().unlink()

View File

@ -0,0 +1,22 @@
/** @odoo-module **/
import { session } from "@web/session";
const bgUrl = session.master_background_url;
if (bgUrl) {
document.documentElement.style.setProperty(
"--homeMenu-bg-image",
`url("${bgUrl}")`
);
} else {
document.documentElement.style.removeProperty(
"--homeMenu-bg-image"
);
}
if (session.master_glass_effect) {
document.documentElement.classList.add("o_master_glass_effect");
} else {
document.documentElement.classList.remove("o_master_glass_effect");
}

View File

@ -0,0 +1,13 @@
.o_master_glass_effect {
.o_home_menu_background {
background-size: cover;
}
.o_apps {
background: rgba(0, 0, 0, 0.15);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 16px;
padding: 16px;
}
}

View File

@ -13,6 +13,20 @@
</field>
</record>
<record id="view_master_rename_submenu_line_list" model="ir.ui.view">
<field name="name">master.control.menu.line.submenu.list</field>
<field name="model">master.control.menu.line</field>
<field name="arch" type="xml">
<list editable="bottom" create="0">
<field name="menu_id" readonly="1" force_save="1"/>
<field name="parent_menu_id" readonly="1"/>
<field name="menu_name"/>
<field name="menu_custom_name"/>
<field name="show_menu"/>
</list>
</field>
</record>
<record id="view_master_control_list" model="ir.ui.view">
<field name="name">master.control.list</field>
<field name="model">master.control</field>
@ -36,6 +50,29 @@
<field name="name"/>
<field name="code"/>
</group>
<group>
<group>
<group>
<field name="custom_menu_order" widget="boolean_toggle"/>
<field name="custom_menu_icons" widget="boolean_toggle"/>
<field string="Custom Naming" name="enable_rename_option" widget="boolean_toggle"/>
</group>
<group>
<field name="enable_glass_effect"/>
<field name="use_custom_bg"/>
</group>
</group>
<group>
<field name="background_image_filename" invisible="1" nolabel="1"/>
<field name="background_image" invisible="not use_custom_bg or background_image" nolabel="1"
filename="background_image_filename"
readonly="not use_custom_bg" options="{'accepted_file_extensions': 'image/png,image/jpg,image/jpeg,image/svg,.png,.jpg,.jpeg,.svg'}"/>
<field name="background_image" invisible="not use_custom_bg or not background_image" nolabel="1"
filename="background_image_filename" widget="image"
readonly="not use_custom_bg" options="{'accepted_file_extensions': 'image/png,image/jpg,image/jpeg,image/svg,.png,.jpg,.jpeg,.svg', 'size': [180, 180]}"/>
</group>
</group>
<notebook>
<page string="Users">
@ -60,6 +97,17 @@
</list>
</field>
</page>
<page string="Order/Icons" invisible="not custom_menu_order and not custom_menu_icons">
<field name="order_menu_line_ids" widget="one2many_search">
<list editable="bottom" create="0" delete="0" default_order="custom_order_sequence, menu_sequence, menu_id">
<field name="custom_order_sequence" widget="handle" column_invisible="not parent.custom_menu_order"/>
<field name="menu_sequence" column_invisible="1"/>
<field name="menu_id" readonly="1" force_save="1"/>
<field name="menu_icon" widget="image" options="{'accepted_file_extensions': 'image/png,image/jpg,image/jpeg,image/svg,.png,.jpg,.jpeg,.svg', 'size': [32, 32]}" column_invisible="not parent.custom_menu_icons"/>
<field name="show_menu" readonly="1"/>
</list>
</field>
</page>
<page string="Sub Menus">
<field name="submenu_line_ids" widget="one2many_search">
<list editable="bottom" create="0" default_group_by="parent_menu_id">
@ -69,6 +117,19 @@
</list>
</field>
</page>
<page string="Renaming" invisible="not enable_rename_option">
<field name="rename_menu_line_ids" widget="one2many_search">
<list editable="bottom" create="0" delete="0" default_order="custom_order_sequence, menu_sequence, menu_id">
<field name="menu_sequence" column_invisible="1"/>
<field name="menu_id" readonly="1" force_save="1"/>
<field name="menu_name"/>
<field name="menu_custom_name"/>
<field name="show_menu" readonly="1"/>
<button name="open_rename_submenus_popup_view" string="Sub Menus" type="object" class="btn-primary"/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>

View File

@ -10,8 +10,6 @@ class OfferLetterResponseController(http.Controller):
raise request.not_found()
if not token or offer_letter.response_token != token:
raise request.not_found()
if not offer_letter._is_latest_offer():
raise request.not_found()
return offer_letter
@http.route('/offer_letters/respond/<int:offer_id>/accept', type='http', auth='public')

View File

@ -6,6 +6,7 @@ from datetime import timedelta, datetime
from odoo.tools.safe_eval import safe_eval
import json
import calendar
import secrets
class DefaultDictroll(defaultdict):
def get(self, key, default=None):
@ -84,6 +85,13 @@ class OfferLetter(models.Model):
pay_struct_id = fields.Many2one('hr.payroll.structure', string="Salary Structure", required=True)
manager_id = fields.Many2one('hr.employee', string='Manager')
rejection_reason = fields.Char()
response_token = fields.Char(copy=False, index=True)
def _issue_response_token(self):
while True:
token = secrets.token_urlsafe(32)
if not self.search_count([('response_token', '=', token)]):
return token
@api.model
def _default_terms(self):

View File

@ -140,6 +140,7 @@ class ApplicantOfferMailWizard(models.TransientModel):
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
accept_url = f"{base_url}/offer_letters/respond/{offer_letter.id}/accept?token={response_token}"
reject_url = f"{base_url}/offer_letters/respond/{offer_letter.id}/reject?token={response_token}"
offer_letter.response_token = response_token
render_results = template.with_context(
offer_accept_url=accept_url,
offer_reject_url=reject_url,

View File

@ -40,6 +40,7 @@ Enterprise-grade project dashboards with:
'html2canvas': 'https://cdn.jsdelivr.net/npm/html2canvas'
},
},
'images': ['static/description/banner.png'],
"installable": True,
"application": False,
}

View File

@ -1,2 +1,3 @@
from . import project_dashboard_controller
from . import portfolio_dashboard_controller
from . import project_dashboard_controller
from . import portfolio_dashboard_controller
from . import user_dashboard_controller

View File

@ -0,0 +1,268 @@
from collections import defaultdict
from datetime import timedelta
from odoo import fields, http
from odoo.http import request
class ProjectUserDashboardController(http.Controller):
@http.route('/project_user_dashboard/get_dashboard_data', type='json', auth='user')
def get_dashboard_data(self, **kwargs):
user = request.env.user
selected_user_id = int(kwargs.get('user_id') or user.id)
if selected_user_id != user.id and not user.has_group('project.group_project_manager'):
selected_user_id = user.id
target_user = request.env['res.users'].sudo().browse(selected_user_id)
if not target_user.exists():
return {'success': False, 'error': 'User not found.'}
filters = self._parse_filters(kwargs)
tasks = self._get_user_tasks(target_user, filters)
timelines = self._get_user_timelines(target_user, tasks, filters)
timesheets = self._get_user_timesheets(target_user, tasks, filters)
task_cards = self._build_task_cards(tasks, target_user, timelines, timesheets)
weekly_hours = self._build_weekly_hours(timesheets, filters)
stage_summary = self._count_by_label(task_cards, 'stage_name', 'No Stage')
project_summary = self._count_by_label(task_cards, 'project_name', 'No Project')
assignment_summary = self._assignment_summary(task_cards)
timeline_stage_hours = self._timeline_stage_hours(timelines)
workload = self._build_workload(timelines)
return {
'success': True,
'filters': self._filter_options(target_user, tasks),
'user': {
'id': target_user.id,
'name': target_user.name,
'employee_id': target_user.employee_id.id if target_user.employee_id else False,
'is_manager': user.has_group('project.group_project_manager'),
},
'kpis': self._build_kpis(task_cards, timesheets, timelines),
'tasks': task_cards,
'overdue_tasks': [task for task in task_cards if task['is_overdue']][:20],
'near_deadline_tasks': [task for task in task_cards if task['deadline_status'] == 'near'][:20],
'weekly_hours': weekly_hours,
'stage_summary': stage_summary,
'project_summary': project_summary[:12],
'assignment_summary': assignment_summary,
'timeline_stage_hours': timeline_stage_hours,
'workload': workload,
}
def _parse_filters(self, kwargs):
today = fields.Date.context_today(request.env.user)
date_to = fields.Date.to_date(kwargs.get('date_to')) if kwargs.get('date_to') else today
date_from = fields.Date.to_date(kwargs.get('date_from')) if kwargs.get('date_from') else date_to - timedelta(days=55)
return {
'date_from': date_from,
'date_to': date_to,
'project_id': int(kwargs.get('project_id') or 0),
'stage_id': self._parse_int(kwargs.get('stage_id')),
'stage_name': '' if self._parse_int(kwargs.get('stage_id')) else (kwargs.get('stage_id') or '').strip(),
'task_type': kwargs.get('task_type') or 'all',
'assignment_source': kwargs.get('assignment_source') or 'all',
'search': (kwargs.get('search') or '').strip(),
}
def _parse_int(self, value):
try:
return int(value or 0)
except (TypeError, ValueError):
return 0
def _get_user_tasks(self, user, filters):
Task = request.env['project.task'].sudo()
domain = ['|', '|',
('user_ids', 'in', user.id),
('involved_user_ids', 'in', user.id),
('assignees_timelines.assigned_to', '=', user.id)]
if filters['project_id']:
domain.append(('project_id', '=', filters['project_id']))
if filters['stage_id']:
domain.append(('stage_id', '=', filters['stage_id']))
elif filters['stage_name']:
domain.append(('stage_id.name', '=', filters['stage_name']))
if filters['task_type'] == 'generic':
domain.append(('is_generic', '=', True))
elif filters['task_type'] == 'non_generic':
domain.append(('is_generic', '=', False))
if filters['search']:
term = filters['search']
domain += ['|', '|', ('name', 'ilike', term), ('sequence_name', 'ilike', term), ('project_id.name', 'ilike', term)]
tasks = Task.search(domain, order='date_deadline asc, priority desc, id desc', limit=500)
if filters['assignment_source'] == 'direct':
tasks = tasks.filtered(lambda t: user in t.user_ids)
elif filters['assignment_source'] == 'involved':
tasks = tasks.filtered(lambda t: user in t.involved_user_ids)
elif filters['assignment_source'] == 'timeline':
tasks = tasks.filtered(lambda t: any(line.assigned_to == user for line in t.assignees_timelines))
return tasks
def _get_user_timelines(self, user, tasks, filters):
if not tasks:
return request.env['project.task.time.lines'].sudo().browse()
domain = [('assigned_to', '=', user.id)]
domain.append(('task_id', 'in', tasks.ids))
if filters['project_id']:
domain.append(('project_id', '=', filters['project_id']))
if filters['stage_id']:
domain.append(('stage_id', '=', filters['stage_id']))
elif filters['stage_name']:
domain.append(('stage_id.name', '=', filters['stage_name']))
return request.env['project.task.time.lines'].sudo().search(domain, order='estimated_start_datetime asc')
def _get_user_timesheets(self, user, tasks, filters):
employee = user.employee_id
if not employee or not tasks:
return request.env['account.analytic.line'].sudo().browse()
domain = [
('employee_id', '=', employee.id),
('date', '>=', filters['date_from']),
('date', '<=', filters['date_to']),
]
domain.append(('task_id', 'in', tasks.ids))
if filters['project_id']:
domain.append(('project_id', '=', filters['project_id']))
if filters['stage_id']:
domain.append(('stage_id', '=', filters['stage_id']))
elif filters['stage_name']:
domain.append(('stage_id.name', '=', filters['stage_name']))
return request.env['account.analytic.line'].sudo().search(domain, order='date asc')
def _build_task_cards(self, tasks, user, timelines, timesheets):
today = fields.Date.context_today(request.env.user)
timesheet_hours = defaultdict(float)
for line in timesheets:
if line.task_id:
timesheet_hours[line.task_id.id] += line.unit_amount
timeline_by_task = defaultdict(list)
for line in timelines:
timeline_by_task[line.task_id.id].append(line)
cards = []
for task in tasks:
task_timelines = timeline_by_task.get(task.id, [])
deadline_date = fields.Date.to_date(task.date_deadline) if task.date_deadline else False
is_overdue = bool(deadline_date and deadline_date < today and task.state not in ('1_done', '1_canceled'))
deadline_status = 'normal'
if is_overdue:
deadline_status = 'overdue'
elif deadline_date and deadline_date <= today + timedelta(days=2):
deadline_status = 'near'
assignment_sources = []
if user in task.user_ids:
assignment_sources.append('Direct')
if user in task.involved_user_ids:
assignment_sources.append('Involved')
if task_timelines:
assignment_sources.append('Timeline')
estimated = sum(line.estimated_time or 0.0 for line in task_timelines) if task_timelines else (task.estimated_hours or 0.0)
actual = timesheet_hours.get(task.id, 0.0)
cards.append({
'id': task.id,
'name': task.name or '',
'sequence_name': task.sequence_name or '',
'project_id': task.project_id.id if task.project_id else False,
'project_name': task.project_id.name if task.project_id else '',
'stage_id': task.stage_id.id if task.stage_id else False,
'stage_name': task.stage_id.name if task.stage_id else '',
'priority': task.priority or '0',
'state': task.state or '',
'is_generic': bool(task.is_generic),
'is_overdue': is_overdue,
'deadline_status': deadline_status,
'date_deadline': fields.Datetime.to_string(task.date_deadline) if task.date_deadline else '',
'estimated_hours': round(estimated, 2),
'actual_hours': round(actual, 2),
'remaining_hours': round(max(estimated - actual, 0.0), 2),
'progress': round(min((actual / estimated) * 100, 100), 1) if estimated else 0.0,
'assignment_source': ', '.join(assignment_sources) or 'Assigned',
'timeline_count': len(task_timelines),
'timeline_stages': ', '.join(line.stage_id.name for line in task_timelines if line.stage_id),
})
return cards
def _build_kpis(self, tasks, timesheets, timelines):
total = len(tasks)
done = len([task for task in tasks if task['state'] == '1_done'])
overdue = len([task for task in tasks if task['is_overdue']])
non_generic = len([task for task in tasks if not task['is_generic']])
actual_hours = sum(timesheets.mapped('unit_amount')) if timesheets else 0.0
estimated_hours = sum(task['estimated_hours'] for task in tasks)
return {
'total_tasks': total,
'done_tasks': done,
'open_tasks': len([task for task in tasks if task['state'] not in ('1_done', '1_canceled')]),
'overdue_tasks': overdue,
'near_deadline_tasks': len([task for task in tasks if task['deadline_status'] == 'near']),
'non_generic_tasks': non_generic,
'generic_tasks': total - non_generic,
'completion_rate': round((done / total) * 100, 1) if total else 0.0,
'actual_hours': round(actual_hours, 2),
'estimated_hours': round(estimated_hours, 2),
'timeline_items': len(timelines),
}
def _build_weekly_hours(self, timesheets, filters):
week_map = defaultdict(float)
current = filters['date_from']
while current <= filters['date_to']:
monday = current - timedelta(days=current.weekday())
week_map[monday] += 0.0
current += timedelta(days=7)
for line in timesheets:
monday = line.date - timedelta(days=line.date.weekday())
week_map[monday] += line.unit_amount
return [
{'week': week.strftime('%Y-%m-%d'), 'hours': round(hours, 2)}
for week, hours in sorted(week_map.items())
]
def _count_by_label(self, rows, key, fallback):
counts = defaultdict(int)
for row in rows:
counts[row.get(key) or fallback] += 1
return [{'label': label, 'count': count} for label, count in sorted(counts.items(), key=lambda item: item[1], reverse=True)]
def _assignment_summary(self, rows):
summary = defaultdict(int)
for row in rows:
for source in row['assignment_source'].split(', '):
summary[source] += 1
return [{'label': label, 'count': count} for label, count in summary.items()]
def _timeline_stage_hours(self, timelines):
data = defaultdict(float)
for line in timelines:
data[line.stage_id.name or 'No Stage'] += line.estimated_time or 0.0
return [{'label': label, 'hours': round(hours, 2)} for label, hours in sorted(data.items(), key=lambda item: item[1], reverse=True)]
def _build_workload(self, timelines):
today = fields.Date.context_today(request.env.user)
labels = [(today + timedelta(days=offset)) for offset in range(14)]
data = {day: 0.0 for day in labels}
for line in timelines:
start = fields.Datetime.context_timestamp(request.env.user, line.estimated_start_datetime).date() if line.estimated_start_datetime else False
end = fields.Datetime.context_timestamp(request.env.user, line.estimated_end_datetime).date() if line.estimated_end_datetime else False
if start and end:
for day in labels:
if start <= day <= end:
data[day] += line.estimated_time or 0.0
return [{'date': day.strftime('%Y-%m-%d'), 'hours': round(hours, 2)} for day, hours in data.items()]
def _filter_options(self, user, tasks):
user_domain = [('share', '=', False), ('active', '=', True)]
users = request.env['res.users'].sudo().search(user_domain, order='name', limit=200) if request.env.user.has_group('project.group_project_manager') else user
projects = tasks.mapped('project_id').sorted('name')
stage_names = sorted(set(stage.name for stage in tasks.mapped('stage_id') if stage.name))
return {
'users': [{'id': rec.id, 'name': rec.name} for rec in users],
'projects': [{'id': rec.id, 'name': rec.name} for rec in projects],
'stages': [{'id': name, 'name': name} for name in stage_names],
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@ -0,0 +1,191 @@
.project-user-dashboard {
height: calc(100vh - 84px);
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 20px;
background: #f6f8fb;
color: #111827;
}
.pud-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 20px;
margin-bottom: 16px;
background: #0f172a;
color: #fff;
border-radius: 8px;
}
.pud-header h2 {
margin: 0;
font-size: 24px;
font-weight: 700;
color: #ffffff;
}
.pud-header p {
margin: 4px 0 0;
color: #cbd5e1;
}
.pud-header strong {
color: #ffffff;
}
.pud-filters {
display: grid;
grid-template-columns: minmax(220px, 1.5fr) repeat(7, minmax(130px, 1fr)) auto auto;
gap: 10px;
align-items: center;
margin-bottom: 16px;
}
.pud-alert,
.pud-loading {
margin: 14px 0;
}
.pud-kpis {
display: grid;
grid-template-columns: repeat(8, minmax(120px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.pud-kpi {
padding: 14px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
min-height: 86px;
}
.pud-kpi span {
display: block;
color: #64748b;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.pud-kpi strong {
display: block;
margin-top: 10px;
font-size: 26px;
color: #111827;
}
.pud-kpi.danger strong {
color: #dc2626;
}
.pud-kpi.warning strong {
color: #d97706;
}
.pud-kpi.success strong {
color: #15803d;
}
.pud-chart-grid,
.pud-table-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
margin-bottom: 14px;
}
.pud-panel {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 16px;
min-width: 0;
}
.pud-panel.wide {
grid-column: span 2;
}
.pud-panel h3 {
margin: 0 0 12px;
font-size: 15px;
font-weight: 700;
color: #0f172a;
}
.pud-panel .apexcharts-canvas {
cursor: pointer;
}
.pud-table {
margin-bottom: 0;
}
.pud-table tbody tr {
cursor: pointer;
}
.pud-table tbody tr:hover {
background: #f8fafc;
}
.pud-badge {
display: inline-flex;
align-items: center;
padding: 3px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
background: #e2e8f0;
color: #334155;
}
.pud-badge.primary {
background: #dbeafe;
color: #1d4ed8;
}
.pud-badge.muted {
background: #f1f5f9;
color: #64748b;
}
@media (max-width: 1400px) {
.pud-filters {
grid-template-columns: repeat(4, minmax(160px, 1fr));
}
.pud-kpis {
grid-template-columns: repeat(4, minmax(120px, 1fr));
}
}
@media (max-width: 900px) {
.pud-header,
.pud-chart-grid,
.pud-table-grid {
grid-template-columns: 1fr;
}
.pud-header {
display: block;
}
.pud-header .btn {
margin-top: 12px;
}
.pud-filters,
.pud-kpis {
grid-template-columns: 1fr;
}
.pud-panel.wide {
grid-column: span 1;
}
}

View File

@ -0,0 +1,339 @@
/** @odoo-module **/
import { Component, onMounted, onWillDestroy, useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
class ProjectUserDashboard extends Component {
static template = "project_dashboards_management.ProjectUserDashboard";
setup() {
const today = new Date();
const start = new Date();
start.setDate(today.getDate() - 55);
this.state = useState({
loading: true,
error: null,
data: null,
chartsReady: false,
filters: {
user_id: "",
project_id: "",
stage_id: "",
task_type: "all",
assignment_source: "all",
search: "",
date_from: this.formatDate(start),
date_to: this.formatDate(today),
},
});
this.rpc = rpc;
this.action = useService("action");
this.notification = useService("notification");
this.charts = [];
onMounted(() => this.loadData());
onWillDestroy(() => this.destroyCharts());
}
formatDate(date) {
return date.toISOString().slice(0, 10);
}
async loadData() {
this.state.loading = true;
this.state.error = null;
try {
const response = await this.rpc("/project_user_dashboard/get_dashboard_data", this.state.filters);
if (!response.success) {
this.state.error = response.error || "Unable to load dashboard.";
return;
}
this.state.data = response;
if (!this.state.filters.user_id) {
this.state.filters.user_id = String(response.user.id);
}
setTimeout(() => this.initCharts(), 100);
} catch (error) {
console.error(error);
this.state.error = "Unable to load user dashboard.";
this.notification.add("Unable to load user dashboard", { type: "danger" });
} finally {
this.state.loading = false;
}
}
onFilterChange(ev) {
const { name, value } = ev.target;
this.state.filters[name] = value;
}
onSearchInput(ev) {
this.state.filters.search = ev.target.value;
}
applyFilters() {
this.loadData();
}
resetFilters() {
const today = new Date();
const start = new Date();
start.setDate(today.getDate() - 55);
this.state.filters.project_id = "";
this.state.filters.stage_id = "";
this.state.filters.task_type = "all";
this.state.filters.assignment_source = "all";
this.state.filters.search = "";
this.state.filters.date_from = this.formatDate(start);
this.state.filters.date_to = this.formatDate(today);
this.loadData();
}
destroyCharts() {
for (const chart of this.charts) {
try {
chart.destroy();
} catch (error) {
console.warn("Chart destroy failed", error);
}
}
this.charts = [];
}
initCharts() {
if (!window.ApexCharts || !this.state.data) {
return;
}
this.destroyCharts();
this.renderDonut("#userTaskTypeChart", ["Non Generic", "Generic"], [
this.state.data.kpis.non_generic_tasks,
this.state.data.kpis.generic_tasks,
], ["#2563eb", "#94a3b8"], (label) => this.openTasksBy("type", label));
this.renderBar("#userStageChart", this.state.data.stage_summary.map((row) => row.label), this.state.data.stage_summary.map((row) => row.count), "Tasks", "#0f766e", (label) => this.openTasksBy("stage", label));
this.renderBar("#userProjectChart", this.state.data.project_summary.map((row) => row.label), this.state.data.project_summary.map((row) => row.count), "Tasks", "#7c3aed", (label) => this.openTasksBy("project", label));
this.renderDonut("#userAssignmentChart", this.state.data.assignment_summary.map((row) => row.label), this.state.data.assignment_summary.map((row) => row.count), ["#0891b2", "#f97316", "#16a34a", "#64748b"], (label) => this.openTasksBy("assignment", label));
this.renderArea("#userWeeklyTimesheetChart", this.state.data.weekly_hours.map((row) => row.week), this.state.data.weekly_hours.map((row) => row.hours), "Hours", "#2563eb", (label) => this.openTimesheetsForWeek(label));
this.renderBar("#userTimelineStageChart", this.state.data.timeline_stage_hours.map((row) => row.label), this.state.data.timeline_stage_hours.map((row) => row.hours), "Estimated Hours", "#db2777", (label) => this.openTimelinesByStage(label));
this.renderArea("#userWorkloadChart", this.state.data.workload.map((row) => row.date), this.state.data.workload.map((row) => row.hours), "Planned Hours", "#ea580c", (label) => this.openTimelinesByDate(label));
}
chartBase(type, element, options) {
const node = document.querySelector(element);
if (!node) {
return;
}
const chart = new ApexCharts(node, {
chart: {
type,
height: options.height || 300,
toolbar: { show: false },
animations: { enabled: true },
fontFamily: "Inter, system-ui, sans-serif",
events: options.events || {},
},
noData: { text: "No data" },
grid: { borderColor: "#e5e7eb" },
...options,
});
chart.render();
this.charts.push(chart);
}
renderDonut(element, labels, series, colors, onSelect) {
this.chartBase("donut", element, {
labels,
series,
colors,
events: {
dataPointSelection: (_event, _chartContext, config) => {
if (onSelect && config.dataPointIndex >= 0) {
onSelect(labels[config.dataPointIndex]);
}
},
},
legend: { position: "bottom" },
dataLabels: { enabled: false },
plotOptions: {
pie: {
donut: {
size: "68%",
labels: {
show: true,
total: { show: true, label: "Total" },
},
},
},
},
});
}
renderBar(element, categories, data, name, color, onSelect) {
this.chartBase("bar", element, {
series: [{ name, data }],
colors: [color],
events: {
dataPointSelection: (_event, _chartContext, config) => {
if (onSelect && config.dataPointIndex >= 0) {
onSelect(categories[config.dataPointIndex]);
}
},
},
plotOptions: { bar: { borderRadius: 5, columnWidth: "48%" } },
dataLabels: { enabled: false },
xaxis: { categories, labels: { rotate: -25, trim: true } },
yaxis: { min: 0, forceNiceScale: true },
});
}
renderArea(element, categories, data, name, color, onSelect) {
this.chartBase("area", element, {
series: [{ name, data }],
colors: [color],
events: {
dataPointSelection: (_event, _chartContext, config) => {
if (onSelect && config.dataPointIndex >= 0) {
onSelect(categories[config.dataPointIndex]);
}
},
},
stroke: { curve: "smooth", width: 3 },
fill: { type: "gradient", gradient: { opacityFrom: 0.35, opacityTo: 0.02 } },
dataLabels: { enabled: false },
xaxis: { categories, labels: { rotate: -25 } },
yaxis: { min: 0, forceNiceScale: true },
tooltip: { y: { formatter: (value) => `${Number(value || 0).toFixed(2)} h` } },
});
}
openTask(taskId) {
this.action.doAction({
type: "ir.actions.act_window",
res_model: "project.task",
res_id: taskId,
views: [[false, "form"]],
target: "current",
});
}
openTasksBy(kind, label) {
const taskIds = this.tasks
.filter((task) => {
if (kind === "type") {
return label === "Generic" ? task.is_generic : !task.is_generic;
}
if (kind === "stage") {
return task.stage_name === label;
}
if (kind === "project") {
return task.project_name === label;
}
if (kind === "assignment") {
return task.assignment_source.split(", ").includes(label);
}
return false;
})
.map((task) => task.id);
this.openTaskList(taskIds, `${label} Tasks`);
}
openTaskList(taskIds, title) {
if (!taskIds.length) {
this.notification.add("No related tasks found for this chart point.", { type: "warning" });
return;
}
this.action.doAction({
type: "ir.actions.act_window",
name: title,
res_model: "project.task",
views: [[false, "list"], [false, "kanban"], [false, "form"]],
domain: [["id", "in", taskIds]],
target: "current",
});
}
baseAnalyticDomain() {
const domain = [];
const employeeId = this.state.data?.user?.employee_id;
if (employeeId) {
domain.push(["employee_id", "=", employeeId]);
}
if (this.state.filters.project_id) {
domain.push(["project_id", "=", Number(this.state.filters.project_id)]);
}
if (this.state.filters.stage_id) {
domain.push(["stage_id.name", "=", this.state.filters.stage_id]);
}
return domain;
}
baseTimelineDomain() {
const domain = [["assigned_to", "=", this.state.data.user.id]];
if (this.state.filters.project_id) {
domain.push(["project_id", "=", Number(this.state.filters.project_id)]);
}
return domain;
}
openTimesheetsForWeek(weekStart) {
const start = new Date(`${weekStart}T00:00:00`);
const end = new Date(start);
end.setDate(start.getDate() + 6);
const domain = [
...this.baseAnalyticDomain(),
["date", ">=", this.formatDate(start)],
["date", "<=", this.formatDate(end)],
];
this.action.doAction({
type: "ir.actions.act_window",
name: `Timesheets: ${weekStart}`,
res_model: "account.analytic.line",
views: [[false, "list"], [false, "form"]],
domain,
target: "current",
});
}
openTimelinesByStage(stageName) {
this.action.doAction({
type: "ir.actions.act_window",
name: `Timeline: ${stageName}`,
res_model: "project.task.time.lines",
views: [[false, "list"], [false, "form"]],
domain: [...this.baseTimelineDomain(), ["stage_id.name", "=", stageName]],
target: "current",
});
}
openTimelinesByDate(date) {
this.action.doAction({
type: "ir.actions.act_window",
name: `Timeline: ${date}`,
res_model: "project.task.time.lines",
views: [[false, "list"], [false, "form"]],
domain: [
...this.baseTimelineDomain(),
["estimated_start_datetime", "<=", `${date} 23:59:59`],
["estimated_end_datetime", ">=", `${date} 00:00:00`],
],
target: "current",
});
}
get kpis() {
return this.state.data ? this.state.data.kpis : {};
}
get tasks() {
return this.state.data ? this.state.data.tasks : [];
}
get overdueTasks() {
return this.state.data ? this.state.data.overdue_tasks : [];
}
}
registry.category("actions").add("project_user_dashboard", ProjectUserDashboard);

View File

@ -0,0 +1,123 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="project_dashboards_management.ProjectUserDashboard">
<div class="project-user-dashboard">
<div class="pud-header">
<div>
<h2>My Work Dashboard</h2>
<p t-if="state.data">Task, timeline, overdue, and timesheet view for <strong t-esc="state.data.user.name"/></p>
<p t-else="">Task, timeline, overdue, and timesheet view</p>
</div>
<button class="btn btn-light" t-on-click="() => this.loadData()" t-att-disabled="state.loading">
<i class="fa fa-refresh me-1"/> Refresh
</button>
</div>
<div class="pud-filters">
<input name="search" class="form-control" placeholder="Search task, task id, or project" t-att-value="state.filters.search" t-on-input="onSearchInput"/>
<select name="user_id" class="form-select" t-att-value="state.filters.user_id" t-on-change="onFilterChange" t-if="state.data &amp;&amp; state.data.user.is_manager">
<t t-foreach="state.data.filters.users" t-as="user" t-key="user.id">
<option t-att-value="user.id" t-esc="user.name"/>
</t>
</select>
<select name="project_id" class="form-select" t-att-value="state.filters.project_id" t-on-change="onFilterChange">
<option value="">All Projects</option>
<t t-if="state.data">
<t t-foreach="state.data.filters.projects" t-as="project" t-key="project.id">
<option t-att-value="project.id" t-esc="project.name"/>
</t>
</t>
</select>
<select name="stage_id" class="form-select" t-att-value="state.filters.stage_id" t-on-change="onFilterChange">
<option value="">All Stages</option>
<t t-if="state.data">
<t t-foreach="state.data.filters.stages" t-as="stage" t-key="stage.id">
<option t-att-value="stage.id" t-esc="stage.name"/>
</t>
</t>
</select>
<select name="task_type" class="form-select" t-att-value="state.filters.task_type" t-on-change="onFilterChange">
<option value="all">All Tasks</option>
<option value="non_generic">Non Generic</option>
<option value="generic">Generic</option>
</select>
<select name="assignment_source" class="form-select" t-att-value="state.filters.assignment_source" t-on-change="onFilterChange">
<option value="all">All Assignments</option>
<option value="direct">Direct Assignee</option>
<option value="timeline">Timeline Assigned To</option>
<option value="involved">Involved Assignee</option>
</select>
<input name="date_from" class="form-control" type="date" t-att-value="state.filters.date_from" t-on-change="onFilterChange"/>
<input name="date_to" class="form-control" type="date" t-att-value="state.filters.date_to" t-on-change="onFilterChange"/>
<button class="btn btn-primary" t-on-click="applyFilters">Apply</button>
<button class="btn btn-outline-secondary" t-on-click="resetFilters">Reset</button>
</div>
<div t-if="state.error" class="alert alert-danger pud-alert" t-esc="state.error"/>
<div t-if="state.loading" class="pud-loading">Loading dashboard...</div>
<t t-if="state.data &amp;&amp; !state.loading">
<div class="pud-kpis">
<div class="pud-kpi"><span>Total Tasks</span><strong t-esc="kpis.total_tasks"/></div>
<div class="pud-kpi danger"><span>Overdue</span><strong t-esc="kpis.overdue_tasks"/></div>
<div class="pud-kpi warning"><span>Near Deadline</span><strong t-esc="kpis.near_deadline_tasks"/></div>
<div class="pud-kpi"><span>Non Generic</span><strong t-esc="kpis.non_generic_tasks"/></div>
<div class="pud-kpi success"><span>Completion</span><strong><t t-esc="kpis.completion_rate"/>%</strong></div>
<div class="pud-kpi"><span>Timesheet Hours</span><strong><t t-esc="kpis.actual_hours"/>h</strong></div>
<div class="pud-kpi"><span>Estimated Hours</span><strong><t t-esc="kpis.estimated_hours"/>h</strong></div>
<div class="pud-kpi"><span>Timeline Items</span><strong t-esc="kpis.timeline_items"/></div>
</div>
<div class="pud-chart-grid">
<section class="pud-panel"><h3>Generic vs Non Generic</h3><div id="userTaskTypeChart"/></section>
<section class="pud-panel"><h3>Tasks by Stage</h3><div id="userStageChart"/></section>
<section class="pud-panel"><h3>Tasks by Project</h3><div id="userProjectChart"/></section>
<section class="pud-panel"><h3>Assignment Source</h3><div id="userAssignmentChart"/></section>
<section class="pud-panel wide"><h3>Week Wise Timesheet Hours</h3><div id="userWeeklyTimesheetChart"/></section>
<section class="pud-panel"><h3>Timeline Stage Estimate</h3><div id="userTimelineStageChart"/></section>
<section class="pud-panel"><h3>Next 14 Days Workload</h3><div id="userWorkloadChart"/></section>
</div>
<div class="pud-table-grid">
<section class="pud-panel">
<h3>Overdue Tasks</h3>
<div class="table-responsive">
<table class="table table-sm pud-table">
<thead><tr><th>Task</th><th>Project</th><th>Stage</th><th>Due</th><th>Hours</th></tr></thead>
<tbody>
<tr t-if="!overdueTasks.length"><td colspan="5" class="text-muted">No overdue tasks.</td></tr>
<tr t-foreach="overdueTasks" t-as="task" t-key="task.id" t-on-click="() => this.openTask(task.id)">
<td><strong t-esc="task.sequence_name || task.name"/><div class="text-muted" t-esc="task.name"/></td>
<td t-esc="task.project_name"/>
<td t-esc="task.stage_name"/>
<td t-esc="task.date_deadline"/>
<td><t t-esc="task.actual_hours"/> / <t t-esc="task.estimated_hours"/></td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="pud-panel">
<h3>Current Work</h3>
<div class="table-responsive">
<table class="table table-sm pud-table">
<thead><tr><th>Task</th><th>Type</th><th>Assignment</th><th>Progress</th><th>Timeline Stages</th></tr></thead>
<tbody>
<tr t-if="!tasks.length"><td colspan="5" class="text-muted">No tasks match the filters.</td></tr>
<tr t-foreach="tasks.slice(0, 25)" t-as="task" t-key="task.id" t-on-click="() => this.openTask(task.id)">
<td><strong t-esc="task.sequence_name || task.name"/><div class="text-muted" t-esc="task.project_name"/></td>
<td><span class="pud-badge" t-att-class="task.is_generic ? 'muted' : 'primary'" t-esc="task.is_generic ? 'Generic' : 'Non Generic'"/></td>
<td t-esc="task.assignment_source"/>
<td><t t-esc="task.progress"/>%</td>
<td t-esc="task.timeline_stages"/>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</t>
</div>
</t>
</templates>

View File

@ -9,6 +9,12 @@
<field name="params" eval="{'model': 'project.project', 'res_id': False}"/>
</record>
<record id="action_project_user_dashboard" model="ir.actions.client">
<field name="name">My Work Dashboard</field>
<field name="tag">project_user_dashboard</field>
<field name="target">current</field>
</record>
<!-- Menu Item for Dashboard -->
<menuitem id="menu_project_dashboard"
name="Project Dashboard"
@ -17,5 +23,12 @@
groups="project.group_project_manager"
active="0"
sequence="10"/>
<menuitem id="menu_project_user_dashboard_root"
name="Work Analysis"
action="action_project_user_dashboard"
groups="project.group_project_user"
web_icon="project_dashboards_management,static/description/icon.png"
sequence="11"/>
</data>
</odoo>
</odoo>

View File

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

View File

@ -0,0 +1,21 @@
{
"name": "Search View Extension",
"version": "18.0.1.0.0",
"category": "Extra Tools",
"summary": "Add tab-style filters below the Odoo search box",
"author": "Pranay",
"company": "FTPROTECH",
"website": "https://www.ftprotech.in",
"depends": ["web"],
"assets": {
"web.assets_backend": [
"search_view_extension/static/src/search_tabs/search_tabs.js",
"search_view_extension/static/src/search_tabs/search_tabs.xml",
"search_view_extension/static/src/search_tabs/search_tabs.scss",
],
},
"license": "LGPL-3",
"installable": True,
"auto_install": False,
"application": False,
}

View File

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

View File

@ -0,0 +1,17 @@
from odoo.tools import view_validation
def _strip_search_tabs_for_rng(arch, **kwargs):
"""Allow custom <searchtab> nodes to pass Odoo's stock search RNG schema."""
if arch.tag != "search":
return True
for searchtab in list(arch.iter("searchtab")):
parent = searchtab.getparent()
if parent is not None:
parent.remove(searchtab)
return True
validators = view_validation._validators["search"]
if _strip_search_tabs_for_rng not in validators:
validators.insert(0, _strip_search_tabs_for_rng)

View File

@ -0,0 +1,164 @@
/** @odoo-module **/
import { useBus } from "@web/core/utils/hooks";
import { patch } from "@web/core/utils/patch";
import { SearchArchParser } from "@web/search/search_arch_parser";
import { SearchModel } from "@web/search/search_model";
import { ControlPanel } from "@web/search/control_panel/control_panel";
function boolAttr(node, attrName, defaultValue = false) {
if (!node.hasAttribute(attrName)) {
return defaultValue;
}
return /^(1|true|yes)$/i.test(node.getAttribute(attrName));
}
function isActive(model, itemId) {
return model.query.some((queryElem) => queryElem.searchItemId === itemId);
}
patch(SearchArchParser.prototype, {
visitSearch(node, visitChildren) {
this.searchTabs = [];
this.pushGroup();
for (const searchTabNode of [...node.children].filter((child) => child.tagName === "searchtab")) {
const tab = {
id: this.searchTabs.length + 1,
name: searchTabNode.getAttribute("name") || `searchtab_${this.searchTabs.length + 1}`,
description:
searchTabNode.getAttribute("string") ||
searchTabNode.getAttribute("name") ||
`Tab ${this.searchTabs.length + 1}`,
isDefault: boolAttr(searchTabNode, "default"),
itemNames: [],
};
this.currentTag = "filter";
this.currentGroup = [];
this.groupNumber++;
for (const filterNode of [...searchTabNode.children].filter((child) => child.tagName === "filter")) {
const beforeLength = this.currentGroup.length;
this.visitFilter(filterNode, () => {});
for (const item of this.currentGroup.slice(beforeLength)) {
item.searchTabName = tab.name;
item.invisible = "True";
if (item.name) {
tab.itemNames.push(item.name);
}
}
}
this.pushGroup();
this.searchTabs.push(tab);
searchTabNode.remove();
}
super.visitSearch(node, visitChildren);
},
parse() {
const result = super.parse(...arguments);
result.searchPanelInfo.searchTabs = this.searchTabs || [];
return result;
},
});
patch(SearchModel.prototype, {
async load(config) {
await super.load(...arguments);
if (config.state?.searchTabs) {
this.searchTabs = config.state.searchTabs;
} else {
this.searchTabs = this.searchPanelInfo?.searchTabs || [];
}
this._prepareSearchTabs();
},
exportState() {
const state = super.exportState(...arguments);
state.searchTabs = this.searchTabs || [];
return state;
},
getSearchTabs() {
const tabs = this.searchTabs || [];
if (!tabs.length) {
return [];
}
return [
{
id: false,
name: "__all__",
description: "All",
isActive: !tabs.some((tab) => tab.itemIds.some((itemId) => isActive(this, itemId))),
},
...tabs.map((tab) => ({
...tab,
isActive: tab.itemIds.some((itemId) => isActive(this, itemId)),
})),
];
},
toggleSearchTab(tabId) {
const tabs = this.searchTabs || [];
const tab = tabs.find((candidate) => candidate.id === tabId);
const tabItemIds = new Set(tabs.flatMap((candidate) => candidate.itemIds));
this.query = this.query.filter((queryElem) => !tabItemIds.has(queryElem.searchItemId));
if (tab) {
for (const itemId of tab.itemIds) {
this.query.push({ searchItemId: itemId });
}
}
this._notify();
},
_prepareSearchTabs() {
const parserTabs = this.searchTabs || [];
this.searchTabs = parserTabs
.map((tab) => ({
...tab,
itemIds: tab.itemNames
.map((name) =>
Object.values(this.searchItems || {}).find(
(item) => item.name === name && item.searchTabName === tab.name
)
)
.filter(Boolean)
.map((item) => item.id),
}))
.filter((tab) => tab.itemIds.length);
const hasActiveTab = this.searchTabs.some((tab) =>
tab.itemIds.some((itemId) => isActive(this, itemId))
);
const defaultTab = this.searchTabs.find((tab) => tab.isDefault);
if (!hasActiveTab && defaultTab) {
for (const itemId of defaultTab.itemIds) {
this.query.push({ searchItemId: itemId });
}
this._reset();
}
},
});
patch(ControlPanel.prototype, {
setup() {
super.setup(...arguments);
if (this.env.searchModel) {
useBus(this.env.searchModel, "update", this.render);
}
},
getSearchTabs() {
if (this.env.config?.viewType === "form") {
return [];
}
return this.env.searchModel?.getSearchTabs?.() || [];
},
onSearchTabClick(tab) {
this.env.searchModel.toggleSearchTab(tab.id);
},
});

View File

@ -0,0 +1,10 @@
.o_control_panel .o_search_tabs {
gap: 0.25rem;
min-height: 2rem;
.nav-link {
border: 0;
color: var(--body-color, #374151);
padding: 0.35rem 0.75rem;
}
}

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-inherit="web.ControlPanel" t-inherit-mode="extension">
<xpath expr="//div[hasclass('o_control_panel_main')]" position="after">
<nav t-if="getSearchTabs().length" class="o_search_tabs nav nav-pills flex-nowrap overflow-auto d-print-none">
<button
t-foreach="getSearchTabs()"
t-as="tab"
t-key="tab.id || tab.name"
type="button"
class="nav-link text-nowrap"
t-att-class="{ active: tab.isActive }"
t-on-click="() => this.onSearchTabClick(tab)"
>
<t t-esc="tab.description"/>
</button>
</nav>
</xpath>
</t>
</templates>

View File

@ -17,4 +17,29 @@
.o_form_view .o_inner_group {
margin-top: 0px !important;
padding-top: 0px !important;
}
/* Increase dropdown z-index */
.ui-autocomplete,
.o_m2o_dropdown,
.o_field_many2one .o_m2o_dropdown {
z-index: 1100 !important;
}
/* Ensure container has proper overflow */
.container-fluid.mt-2.mb-2 {
overflow: visible !important;
position: relative;
z-index: 100;
}
/* Fix notebook stacking context */
.o_notebook {
position: relative;
z-index: 1;
}
/* Ensure dropdown appears above everything */
.modal-open .o_field_many2one .o_m2o_dropdown {
z-index: 1061 !important;
}

View File

@ -1,4 +1,75 @@
<odoo>
<record id="hr_applicant_view_form_css_fix" model="ir.ui.view">
<field name="name">hr.applicant.view.form.css.fix</field>
<field name="model">hr.applicant</field>
<field name="inherit_id" ref="hr_recruitment.hr_applicant_view_form"/>
<field name="arch" type="xml">
<xpath expr="//sheet" position="inside">
<div class="o_form_custom_css_fix" style="display: none;">
<style>
/* Fix for notebook overlapping dropdowns */
.o_notebook {
overflow: visible !important;
position: relative;
z-index: 1;
}
.o_notebook .nav-tabs {
overflow: visible !important;
}
.o_notebook .tab-content {
overflow: visible !important;
}
.o_notebook .tab-pane {
overflow: visible !important;
}
/* Critical fix - make sure the dropdown appears above notebook */
.ui-autocomplete,
.ui-menu,
.o_m2o_dropdown,
.o_field_many2one .o_m2o_dropdown,
.dropdown-menu {
z-index: 9999 !important;
position: absolute !important;
}
/* Fix for the specific many2one field container */
.o_field_many2one {
position: relative !important;
z-index: 100 !important;
}
/* When dropdown is open, ensure it's on top */
.o_field_many2one.o_focused .o_m2o_dropdown {
z-index: 10000 !important;
position: fixed !important;
max-height: 300px !important;
overflow-y: auto !important;
}
/* Override any overflow hidden on parent containers */
.container-fluid,
.card,
.card-body,
.row,
.col-lg-4,
.col-lg-8,
div[class*="col-"] {
overflow: visible !important;
}
/* Specifically target the modal/dialog context */
.modal .o_field_many2one .o_m2o_dropdown {
z-index: 10001 !important;
}
</style>
</div>
</xpath>
</field>
</record>
<record id="hr_applicant_view_form_ui_customize"
model="ir.ui.view">

View File

@ -23,11 +23,11 @@
</div>
<div>
<h2 class="fw-bold text-dark mb-1">
<field name="partner_name" nolabel="0"/>
<field name="partner_name" nolabel="0" placeholder="Candidate Name"/>
</h2>
<div class="d-flex align-items-center g-1">
<i class="fa fa-id-badge me-3 text-primary"/>
<field name="candidate_sequence" nolabel="1"/>
<field name="candidate_sequence" nolabel="1" placeholder="Sequence Id"/>
</div>
<div class="text-muted fs-5 mb-2">
<field name="type_id" nolabel="1" readonly="1"/>
@ -42,11 +42,11 @@
<div class="d-flex flex-column gap-2">
<div class="d-flex align-items-center g-1 mb-2">
<i class="fa fa-envelope me-3 text-primary"/>
<field name="email_from" nolabel="1"/>
<field name="email_from" nolabel="1" placeholder="eg: abc@gmail.com"/>
</div>
<div class="d-flex align-items-center g-1">
<i class="fa fa-mobile me-3 text-primary"/>
<field name="partner_phone" nolabel="1"/>
<field name="partner_phone" nolabel="1" placeholder="eg: 63xxx27xx0"/>
</div>
<div class="d-flex align-items-center g-1" invisible="not middle_name">
<i class="fa fa-user me-3 text-primary"/>
@ -58,7 +58,7 @@
</div>
<div class="d-flex align-items-center g-1">
<i class="fa fa-linkedin me-3 text-primary"/>
<field name="linkedin_profile" nolabel="1"/>
<field name="linkedin_profile" nolabel="1" placeholder="Linkedin"/>
</div>
</div>
</div>

View File

@ -24,8 +24,6 @@ class SurveyInvite(models.TransientModel):
survey_line = self.applicant_id.survey_line_ids.filtered(
lambda line: line.survey_id == self.survey_id
)[:1]
import pdb
pdb.set_trace()
latest_answer = survey_answers.sorted('create_date')[-1]
survey_url = werkzeug.urls.url_join(
self.survey_id.get_base_url(),

View File

@ -1,3 +1,535 @@
# # Part of Odoo. See LICENSE file for full copyright and licensing details.
#
# import warnings
# from datetime import datetime
# from dateutil.relativedelta import relativedelta
# from operator import itemgetter
# from werkzeug.urls import url_encode
#
# from odoo import http, _
# from odoo.addons.website_hr_recruitment.controllers.main import WebsiteHrRecruitment
# from odoo.osv.expression import AND
# from odoo.http import request
# from odoo.tools import email_normalize
# from odoo.tools.misc import groupby
# import ast
# import base64
#
#
#
# class WebsiteJobHrRecruitment(WebsiteHrRecruitment):
# _jobs_per_page = 12
#
# def sitemap_jobs(env, rule, qs):
# if not qs or qs.lower() in '/jobs':
# yield {'loc': '/jobs'}
#
# @http.route([
# '/jobs',
# '/jobs/page/<int:page>',
# ], type='http', auth="public", website=True, sitemap=sitemap_jobs)
# def jobs(self, country_id=None, department_id=None, office_id=None, contract_type_id=None,
# is_remote=False, is_other_department=False, is_untyped=None, page=1, search=None, **kwargs):
# env = request.env(context=dict(request.env.context, show_address=True, no_tag_br=True))
#
# Country = env['res.country']
# Jobs = env['hr.job.recruitment']
# Department = env['hr.department']
#
# country = Country.browse(int(country_id)) if country_id else None
# department = Department.browse(int(department_id)) if department_id else None
# office_id = int(office_id) if office_id else None
# contract_type_id = int(contract_type_id) if contract_type_id else None
#
# # Default search by user country
# if not (country or department or office_id or contract_type_id or kwargs.get('all_countries')):
# if request.geoip.country_code:
# countries_ = Country.search([('code', '=', request.geoip.country_code)])
# country = countries_[0] if countries_ else None
# if country:
# country_count = Jobs.search_count(AND([
# request.website.website_domain(),
# [('address_id.country_id', '=', country.id)]
# ]))
# if not country_count:
# country = False
#
# options = {
# 'displayDescription': True,
# 'allowFuzzy': not request.params.get('noFuzzy'),
# 'country_id': country.id if country else None,
# 'department_id': department.id if department else None,
# 'office_id': office_id,
# 'contract_type_id': contract_type_id,
# 'is_remote': is_remote,
# 'is_other_department': is_other_department,
# 'is_untyped': is_untyped,
# }
# total, details, fuzzy_search_term = request.website._search_with_fuzzy("job_requests", search,
# limit=1000, order="is_published desc, sequence, no_of_recruitment desc", options=options)
# # Browse jobs as superuser, because address is restricted
# jobs = details[0].get('results', Jobs).sudo()
#
# def sort(records_list, field_name):
# """ Sort records in the given collection according to the given
# field name, alphabetically. None values instead of records are
# placed at the end.
#
# :param list records_list: collection of records or None values
# :param str field_name: field on which to sort
# :return: sorted list
# """
# return sorted(
# records_list,
# key=lambda item: (item is None, item.sudo()[field_name] if item and item.sudo()[field_name] else ''),
# )
#
# # Countries
# if country or is_remote:
# cross_country_options = options.copy()
# cross_country_options.update({
# 'allowFuzzy': False,
# 'country_id': None,
# 'is_remote': False,
# })
# cross_country_total, cross_country_details, _ = request.website._search_with_fuzzy("jobs",
# fuzzy_search_term or search, limit=1000, order="is_published desc, sequence, no_of_recruitment desc",
# options=cross_country_options)
# # Browse jobs as superuser, because address is restricted
# cross_country_jobs = cross_country_details[1].get('results', Jobs).sudo()
# else:
# cross_country_total = total
# cross_country_jobs = jobs
# country_offices = set(j.address_id or None for j in cross_country_jobs)
# countries = sort(set(o and o.country_id or None for o in country_offices), 'name')
# count_per_country = {'all': cross_country_total}
# for c, jobs_list in groupby(cross_country_jobs, lambda job: job.address_id.country_id):
# count_per_country[c] = len(jobs_list)
# count_remote = len(cross_country_jobs.filtered(lambda job: not job.address_id))
# if count_remote:
# count_per_country[None] = count_remote
#
# # Departments
# if department or is_other_department:
# cross_department_options = options.copy()
# cross_department_options.update({
# 'allowFuzzy': False,
# 'department_id': None,
# 'is_other_department': False,
# })
# cross_department_total, cross_department_details, _ = request.website._search_with_fuzzy("jobs",
# fuzzy_search_term or search, limit=1000, order="is_published desc, sequence, no_of_recruitment desc",
# options=cross_department_options)
# cross_department_jobs = cross_department_details[1].get('results', Jobs)
# else:
# cross_department_total = total
# cross_department_jobs = jobs
# departments = sort(set(j.department_id or None for j in cross_department_jobs), 'name')
# count_per_department = {'all': cross_department_total}
# for d, jobs_list in groupby(cross_department_jobs, lambda job: job.department_id):
# count_per_department[d] = len(jobs_list)
# count_other_department = len(cross_department_jobs.filtered(lambda job: not job.department_id))
# if count_other_department:
# count_per_department[None] = count_other_department
#
# # Offices
# if office_id or is_remote:
# cross_office_options = options.copy()
# cross_office_options.update({
# 'allowFuzzy': False,
# 'office_id': None,
# 'is_remote': False,
# })
# cross_office_total, cross_office_details, _ = request.website._search_with_fuzzy("jobs",
# fuzzy_search_term or search, limit=1000, order="is_published desc, sequence, no_of_recruitment desc",
# options=cross_office_options)
# # Browse jobs as superuser, because address is restricted
# cross_office_jobs = cross_office_details[1].get('results', Jobs).sudo()
# else:
# cross_office_total = total
# cross_office_jobs = jobs
# offices = sort(set(j.address_id or None for j in cross_office_jobs), 'city')
# count_per_office = {'all': cross_office_total}
# for o, jobs_list in groupby(cross_office_jobs, lambda job: job.address_id):
# count_per_office[o] = len(jobs_list)
# count_remote = len(cross_office_jobs.filtered(lambda job: not job.address_id))
# if count_remote:
# count_per_office[None] = count_remote
#
# # Employment types
# if contract_type_id or is_untyped:
# cross_type_options = options.copy()
# cross_type_options.update({
# 'allowFuzzy': False,
# 'contract_type_id': None,
# 'is_untyped': False,
# })
# cross_type_total, cross_type_details, _ = request.website._search_with_fuzzy("jobs",
# fuzzy_search_term or search, limit=1000, order="is_published desc, sequence, no_of_recruitment desc",
# options=cross_type_options)
# cross_type_jobs = cross_type_details[1].get('results', Jobs)
# else:
# cross_type_total = total
# cross_type_jobs = jobs
# employment_types = sort(set(j.contract_type_id for j in jobs if j.contract_type_id), 'name')
# count_per_employment_type = {'all': cross_type_total}
# for t, jobs_list in groupby(cross_type_jobs, lambda job: job.contract_type_id):
# count_per_employment_type[t] = len(jobs_list)
# count_untyped = len(cross_type_jobs.filtered(lambda job: not job.contract_type_id))
# if count_untyped:
# count_per_employment_type[None] = count_untyped
#
# pager = request.website.pager(
# url=request.httprequest.path.partition('/page/')[0],
# url_args=request.httprequest.args,
# total=total,
# page=page,
# step=self._jobs_per_page,
# )
# offset = pager['offset']
# jobs = jobs[offset:offset + self._jobs_per_page]
#
# office = env['res.partner'].browse(int(office_id)) if office_id else None
# contract_type = env['hr.contract.type'].browse(int(contract_type_id)) if contract_type_id else None
# # Render page
# return request.render("website_hr_recruitment_extended.recruitment_index", {
# 'jobs': jobs,
# 'countries': countries,
# 'departments': departments,
# 'offices': offices,
# 'employment_types': employment_types,
# 'country_id': country,
# 'department_id': department,
# 'office_id': office,
# 'contract_type_id': contract_type,
# 'is_remote': is_remote,
# 'is_other_department': is_other_department,
# 'is_untyped': is_untyped,
# 'pager': pager,
# 'search': fuzzy_search_term or search,
# 'search_count': total,
# 'original_search': fuzzy_search_term and search,
# 'count_per_country': count_per_country,
# 'count_per_department': count_per_department,
# 'count_per_office': count_per_office,
# 'count_per_employment_type': count_per_employment_type,
# })
#
# @http.route('/jobs/add', type='json', auth="user", website=True)
# def jobs_add(self, **kwargs):
# # avoid branding of website_description by setting rendering_bundle in context
# job = request.env['hr.job.recruitment'].with_context(rendering_bundle=True).create({
# 'name': _('Job Title'),
# })
# return f"/jobs/{request.env['ir.http']._slug(job)}"
#
# @http.route('''/jobs/detail/<model("hr.job.recruitment"):job>''', type='http', auth="public", website=True, sitemap=True)
# def jobs_detail(self, job, **kwargs):
# redirect_url = f"/jobs/{request.env['ir.http']._slug(job)}"
# return request.redirect(redirect_url, code=301)
#
# @http.route('''/jobs/<model("hr.job.recruitment"):job>''', type='http', auth="public", website=True, sitemap=True)
# def job(self, job, **kwargs):
# return request.render("website_hr_recruitment_extended.recruitment_detail", {
# 'job': job,
# 'main_object': job,
# })
#
# @http.route('''/jobs/apply/<model("hr.job.recruitment"):job>''', type='http', auth="public", website=True, sitemap=True)
# def jobs_apply(self, job, **kwargs):
# error = {}
# default = {}
# if 'website_hr_recruitment_error' in request.session:
# error = request.session.pop('website_hr_recruitment_error')
# default = request.session.pop('website_hr_recruitment_default')
# return request.render("website_hr_recruitment_extended.recruitment_apply", {
# 'job': job,
# 'error': error,
# 'default': default,
# })
#
# # Compatibility routes
#
# @http.route([
# '/jobs/country/<model("res.country"):country>',
# '/jobs/department/<model("hr.department"):department>',
# '/jobs/country/<model("res.country"):country>/department/<model("hr.department"):department>',
# '/jobs/office/<int:office_id>',
# '/jobs/country/<model("res.country"):country>/office/<int:office_id>',
# '/jobs/department/<model("hr.department"):department>/office/<int:office_id>',
# '/jobs/country/<model("res.country"):country>/department/<model("hr.department"):department>/office/<int:office_id>',
# '/jobs/employment_type/<int:contract_type_id>',
# '/jobs/country/<model("res.country"):country>/employment_type/<int:contract_type_id>',
# '/jobs/department/<model("hr.department"):department>/employment_type/<int:contract_type_id>',
# '/jobs/office/<int:office_id>/employment_type/<int:contract_type_id>',
# '/jobs/country/<model("res.country"):country>/department/<model("hr.department"):department>/employment_type/<int:contract_type_id>',
# '/jobs/country/<model("res.country"):country>/office/<int:office_id>/employment_type/<int:contract_type_id>',
# '/jobs/department/<model("hr.department"):department>/office/<int:office_id>/employment_type/<int:contract_type_id>',
# '/jobs/country/<model("res.country"):country>/department/<model("hr.department"):department>/office/<int:office_id>/employment_type/<int:contract_type_id>',
# ], type='http', auth="public", website=True, sitemap=False)
# def jobs_compatibility(self, country=None, department=None, office_id=None, contract_type_id=None, **kwargs):
# """
# Deprecated since Odoo 16.3: those routes are kept by compatibility.
# They should not be used in Odoo code anymore.
# """
# warnings.warn(
# "This route is deprecated since Odoo 16.3: the jobs list is now available at /jobs or /jobs/page/XXX",
# DeprecationWarning
# )
# url_params = {
# 'country_id': country and country.id,
# 'department_id': department and department.id,
# 'office_id': office_id,
# 'contract_type_id': contract_type_id,
# **kwargs,
# }
# return request.redirect(
# '/jobs?%s' % url_encode(url_params),
# code=301,
# )
#
#
# @http.route('/hr_recruitment_extended/fetch_hr_recruitment_degree', type='json', auth="public", website=True)
# def fetch_recruitment_degrees(self):
# degrees = {}
# all_degrees = http.request.env['hr.recruitment.degree'].sudo().search([])
# if all_degrees:
# for degree in all_degrees:
# degrees[degree.id] = degree.name
# return degrees
#
# @http.route('/hr_recruitment_extended/fetch_preferred_locations', type='json', auth="public", website=True)
# def fetch_preferred_locations(self, loc_ids):
# locations = {}
# for id in loc_ids:
# location = http.request.env['hr.location'].sudo().browse(id)
# if location:
# locations[location.id] = location.location_name
# return locations
#
# @http.route('/hr_recruitment_extended/fetch_preferred_skills', type='json', auth="public", website=True)
# def fetch_preferred_skills(self, skill_ids,fetch_others=False):
# skills = {}
# Skill = http.request.env['hr.skill'].sudo()
# SkillLevel = http.request.env['hr.skill.level'].sudo()
#
# if fetch_others:
# for skill in Skill.search([('id','not in',skill_ids)]):
# levels = SkillLevel.search([('skill_type_id', '=', skill.skill_type_id.id)],order='sequence')
# skills[skill.id] = {
# 'id': skill.id,
# 'name': skill.name,
# 'levels': [{'id': lvl.id, 'name': lvl.name, 'percentage': lvl.level_progress, 'sequence': lvl.sequence} for lvl in levels]
# }
# else:
# for skill in Skill.browse(skill_ids):
# levels = SkillLevel.search([('skill_type_id', '=', skill.skill_type_id.id)],order='sequence')
# skills[skill.id] = {
# 'id': skill.id,
# 'name': skill.name,
# 'levels': [{'id': lvl.id, 'name': lvl.name, 'percentage': lvl.level_progress, 'sequence': lvl.sequence} for lvl in levels]
# }
#
# return skills
#
# @http.route('/website_hr_recruitment_extended/check_recent_application', type='json', auth="public", website=True)
# def check_recent_application(self, value, job_id):
# # Function to check if the applicant has an existing record based on email, phone, or linkedin
# if value:
# def refused_applicants_condition(applicant):
# return not applicant.active \
# and applicant.hr_job_recruitment.id == int(job_id) \
# and applicant.create_date >= (datetime.now() - relativedelta(months=6))
# # Search for applicants with the same email, phone, or linkedin (only if the value is not False/None)
# applicants_with_similar_info = http.request.env['hr.applicant'].sudo().search([
# ('hr_job_recruitment','=',int(job_id)),
# '|',
# ('email_normalized', '=', email_normalize(value)),
# '|',
# ('partner_phone', '=', value),
# ('linkedin_profile', '=ilike', value),
# ], order='create_date DESC')
#
# if not applicants_with_similar_info:
# return {'message':None}
# # Group applications by their status
# applications_by_status = applicants_with_similar_info.grouped('application_status')
#
# # Check for refused applicants with the same value within the last 6 months
# refused_applicants = applications_by_status.get('refused', http.request.env['hr.applicant'])
# if any(applicant for applicant in refused_applicants if refused_applicants_condition(applicant)):
# return {
# 'message': _(
# 'We\'ve found a previous closed application in our system within the last 6 months.'
# ' Please consider before applying in order not to duplicate efforts.'
# )
# }
#
# # Check for ongoing applications with the same value
# ongoing_applications = applications_by_status.get('ongoing', [])
# if ongoing_applications:
# ongoing_application = ongoing_applications[0]
# if ongoing_application.hr_job_recruitment.id == int(job_id):
# recruiter_contact = "" if not ongoing_application.user_id else _(
# ' In case of issue, contact %(contact_infos)s',
# contact_infos=", ".join(
# [value for value in itemgetter('name', 'email', 'phone')(ongoing_application.user_id) if value]
# ))
#
# error_message = 'An application already exists for %s Duplicates might be rejected. %s '%(value,recruiter_contact)
# print(error_message)
# return {
# 'message': _(error_message)
# }
#
# # If no existing application found, show the following message
# return {
# 'message': _(
# 'We found a recent application with a similar name, email, phone number.'
# ' You can continue if it\'s not a mistake.'
# )
# }
# else:
# return {'message': None}
#
# def _should_log_authenticate_message(self, record):
# if record._name == "hr.applicant" and not request.session.uid:
# return False
# return super()._should_log_authenticate_message(record)
#
# def extract_data(self, model, values):
# candidate = False
# extracted_resume = values.pop('resume_base64', None)
# current_ctc = values.pop('current_ctc', None)
# expected_ctc = values.pop('expected_ctc', None)
# available_joining_date = values.pop('available_joining_date', None)
# exp_type = values.pop('exp_type', None)
# current_location = values.pop('current_location', None)
# preferred_locations_str = values.pop('preferred_locations', '')
# department_id = values.pop('department_id',None)
# hr_job_recruitment = values.pop('job_id', None)
# preferred_locations = [int(x) for x in preferred_locations_str.split(',')] if len(
# preferred_locations_str) > 0 else []
# current_organization = values.pop('current_organization', None)
# notice_period = values.pop('notice_period', 0)
# notice_period_type = values.pop('notice_period_type', 'day')
# experience_years = values.pop('experience_years',0)
# experience_months = values.pop('experience_months',0)
#
# # If there are months, convert everything to months
# if int(experience_months) > 0:
# total_experience = (int(experience_years) * 12) + int(experience_months)
# total_experience_type = 'month'
# else:
# total_experience = int(experience_years)
# total_experience_type = 'year'
#
# if extracted_resume:
# attachment = request.env.ref("hr_recruitment_extended.employee_recruitment_attachments_preview")
# file = attachment.sudo().write({
# 'datas': extracted_resume,
# })
# if file:
# resume_type = attachment.mimetype
# resume_name = attachment.name
# else:
# resume_type = ''
# resume_name = ''
# else:
# resume_type = ''
# resume_name = ''
#
# skill_dict = {key: ast.literal_eval(value) for key,value in values.items() if "skill" in key and value != '0'}
#
# if model.model == 'hr.applicant':
# partner_name = values.pop('full_name', None)
# partner_phone = values.pop('partner_phone', None)
# alternate_phone = values.pop('alternate_phone', None)
# partner_email = values.pop('email_from', None)
# degree = values.pop('degree', None)
# if partner_phone and partner_email:
# candidate = request.env['hr.candidate'].sudo().search([
# '|', ('email_from', '=', partner_email),
# ('partner_phone', '=', partner_phone),
# ], limit=1)
#
# if candidate:
# candidate.sudo().write({
# 'partner_name': partner_name,
# 'alternate_phone': alternate_phone,
# 'email_from': partner_email,
# 'partner_phone': partner_phone,
# 'type_id': int(degree) if degree.isdigit() else False,
# 'resume': extracted_resume,
# 'resume_type': resume_type,
# 'resume_name': resume_name,
# })
# if not candidate:
# candidate = request.env['hr.candidate'].sudo().create({
# 'partner_name': partner_name,
# 'email_from': partner_email,
# 'partner_phone': partner_phone,
# 'alternate_phone': alternate_phone,
# 'type_id': int(degree) if degree.isdigit() else False,
# 'resume': extracted_resume,
# 'resume_type': resume_type,
# 'resume_name': resume_name,
# })
#
# if len(skill_dict) > 0:
# # candidate_skills_list = []
# for key, value in skill_dict.items():
# candidate_skills = dict()
# skill_type_id = request.env['hr.skill'].sudo().browse(int(key.split("_")[1])).skill_type_id.id
# candidate_skills['candidate_id'] = candidate.id
# candidate_skills['skill_id'] = int(key.split("_")[1])
# candidate_skills['skill_level_id'] = value[0]
# candidate_skills['level_progress'] = value[1]
# candidate_skills['skill_type_id'] = skill_type_id
# # skill = request.env['hr.candidate.skill'].sudo().create(candidate_skills)
# # candidate_skills_list.append(skill.id)
# if candidate.candidate_skill_ids:
# if candidate_skills['skill_id'] not in candidate.candidate_skill_ids.skill_id.ids:
# candidate.write({'candidate_skill_ids':[(0,4,candidate_skills)]})
# else:
# candidate.write({'candidate_skill_ids':[(0,4,candidate_skills)]})
#
#
# else:
# skills = None
#
# values['partner_name'] = partner_name
# if partner_phone:
# values['partner_phone'] = partner_phone
# if partner_email:
# values['email_from'] = partner_email
# notice_period_str = 'N/A'
# if notice_period and notice_period_type:
# notice_period_str = str(notice_period) + ' ' + str(notice_period_type)
# data = super().extract_data(model, values)
# data['record']['current_ctc'] = float(current_ctc if current_ctc else 0)
# data['record']['salary_expected'] = float(expected_ctc if expected_ctc else 0)
# data['record']['exp_type'] = exp_type if exp_type else 'fresher'
# data['record']['current_location'] = current_location if current_location else ''
# data['record']['current_organization'] = current_organization if current_organization else ''
# data['record']['notice_period'] = notice_period_str if notice_period_str else 'N/A'
# data['record']['notice_period_type'] = notice_period_type if notice_period_type else 'day'
# data['record']['hr_job_recruitment'] = int(hr_job_recruitment) if str(hr_job_recruitment).isdigit() else ''
# data['record']['department_id'] = int(department_id) if str(department_id).isdigit() else ''
# data['record']['availability'] = datetime.strptime(available_joining_date, '%Y-%m-%d').date() if available_joining_date else ''
#
# data['record']['total_exp'] = total_experience if total_experience else 0
# data['record']['total_exp_type'] = total_experience_type if total_experience_type else 'year'
# # data['record']['resume'] = resume if resume else None
# if len(preferred_locations_str) > 0:
# data['record']['preferred_location'] = preferred_locations
# if candidate:
# data['record']['candidate_id'] = candidate.id
# data['record']['type_id'] = candidate.type_id.id
#
# return data
#
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import warnings
@ -70,6 +602,32 @@ class WebsiteJobHrRecruitment(WebsiteHrRecruitment):
# Browse jobs as superuser, because address is restricted
jobs = details[0].get('results', Jobs).sudo()
if search:
search = search.strip()
custom_jobs = Jobs.sudo().search([
'|', '|', '|',
('name', 'ilike', search),
('address_id.city', 'ilike', search),
('skill_ids.name', 'ilike', search),
('secondary_skill_ids.name', 'ilike', search),
])
# Merge fuzzy jobs + custom jobs
jobs = (jobs | custom_jobs).sorted(
key=lambda j: (
not j.is_published,
j.sequence,
-j.no_of_recruitment
)
)
total = len(jobs)
else:
jobs = jobs
def sort(records_list, field_name):
""" Sort records in the given collection according to the given
field name, alphabetically. None values instead of records are
@ -307,28 +865,49 @@ class WebsiteJobHrRecruitment(WebsiteHrRecruitment):
locations[location.id] = location.location_name
return locations
@http.route('/hr_recruitment_extended/fetch_preferred_skills', type='json', auth="public", website=True)
def fetch_preferred_skills(self, skill_ids,fetch_others=False):
# @http.route('/hr_recruitment_extended/fetch_preferred_skills', type='json', auth="public", website=True)
# def fetch_preferred_skills(self, skill_ids,fetch_others=False):
# skills = {}
# Skill = http.request.env['hr.skill'].sudo()
# SkillLevel = http.request.env['hr.skill.level'].sudo()
#
# if fetch_others:
# for skill in Skill.search([('id','not in',skill_ids)]):
# levels = SkillLevel.search([('skill_type_id', '=', skill.skill_type_id.id)],order='sequence')
# skills[skill.id] = {
# 'id': skill.id,
# 'name': skill.name,
# 'levels': [{'id': lvl.id, 'name': lvl.name, 'percentage': lvl.level_progress, 'sequence': lvl.sequence} for lvl in levels]
# }
# else:
# for skill in Skill.browse(skill_ids):
# levels = SkillLevel.search([('skill_type_id', '=', skill.skill_type_id.id)],order='sequence')
# skills[skill.id] = {
# 'id': skill.id,
# 'name': skill.name,
# 'levels': [{'id': lvl.id, 'name': lvl.name, 'percentage': lvl.level_progress, 'sequence': lvl.sequence} for lvl in levels]
# }
#
# return skills
@http.route('/hr_recruitment_extended/fetch_preferred_skills',type='json',auth="public",website=True)
def fetch_preferred_skills(self, skill_ids, fetch_others=False):
skills = {}
Skill = http.request.env['hr.skill'].sudo()
SkillLevel = http.request.env['hr.skill.level'].sudo()
domain = []
if fetch_others:
for skill in Skill.search([('id','not in',skill_ids)]):
levels = SkillLevel.search([('skill_type_id', '=', skill.skill_type_id.id)],order='sequence')
skills[skill.id] = {
'id': skill.id,
'name': skill.name,
'levels': [{'id': lvl.id, 'name': lvl.name, 'percentage': lvl.level_progress, 'sequence': lvl.sequence} for lvl in levels]
}
domain = [('id', 'not in', skill_ids)]
else:
for skill in Skill.browse(skill_ids):
levels = SkillLevel.search([('skill_type_id', '=', skill.skill_type_id.id)],order='sequence')
skills[skill.id] = {
'id': skill.id,
'name': skill.name,
'levels': [{'id': lvl.id, 'name': lvl.name, 'percentage': lvl.level_progress, 'sequence': lvl.sequence} for lvl in levels]
}
domain = [('id', 'in', skill_ids)]
for skill in Skill.search(domain):
skills[skill.id] = {
'id': skill.id,
'name': skill.name,
}
return skills
@ -476,27 +1055,93 @@ class WebsiteJobHrRecruitment(WebsiteHrRecruitment):
'resume_name': resume_name,
})
if len(skill_dict) > 0:
# candidate_skills_list = []
for key, value in skill_dict.items():
candidate_skills = dict()
skill_type_id = request.env['hr.skill'].sudo().browse(int(key.split("_")[1])).skill_type_id.id
candidate_skills['candidate_id'] = candidate.id
candidate_skills['skill_id'] = int(key.split("_")[1])
candidate_skills['skill_level_id'] = value[0]
candidate_skills['level_progress'] = value[1]
candidate_skills['skill_type_id'] = skill_type_id
# skill = request.env['hr.candidate.skill'].sudo().create(candidate_skills)
# candidate_skills_list.append(skill.id)
if candidate.candidate_skill_ids:
if candidate_skills['skill_id'] not in candidate.candidate_skill_ids.skill_id.ids:
candidate.write({'candidate_skill_ids':[(0,4,candidate_skills)]})
else:
candidate.write({'candidate_skill_ids':[(0,4,candidate_skills)]})
# ---------------------------------------------------------
# Skills From Portal
# ---------------------------------------------------------
else:
skills = None
primary_skill_ids = request.httprequest.form.getlist(
'primary_skill_ids'
)
secondary_skill_ids = request.httprequest.form.getlist(
'secondary_skill_ids'
)
# ---------------------------------------------------------
# Merge & Remove Duplicates
# ---------------------------------------------------------
all_skill_ids = []
for skill_group in (primary_skill_ids + secondary_skill_ids):
if skill_group:
for skill_id in skill_group.split(','):
if skill_id.strip().isdigit():
all_skill_ids.append(int(skill_id.strip()))
# Remove duplicates
all_skill_ids = list(set(all_skill_ids))
# ---------------------------------------------------------
# Create Candidate Skill Lines
# ---------------------------------------------------------
existing_skill_ids = candidate.candidate_skill_ids.mapped(
'skill_id'
).ids
for skill_id in all_skill_ids:
if skill_id not in existing_skill_ids:
skill = request.env['hr.skill'].sudo().browse(skill_id)
default_level = skill.skill_type_id.skill_level_ids.filtered(
lambda l: l.default_level
)[:1]
candidate.write({
'candidate_skill_ids': [(0, 0, {
'candidate_id': candidate.id,
'skill_id': skill.id,
'skill_type_id': skill.skill_type_id.id,
'skill_level_id': default_level.id if default_level else False,
})]
})
# ---------------------------------------------------------
# Store Quick Skills
# ---------------------------------------------------------
if all_skill_ids:
candidate.write({
'quick_skill_ids': [(6, 0, all_skill_ids)]
})
# if len(skill_dict) > 0:
# # candidate_skills_list = []
# for key, value in skill_dict.items():
# candidate_skills = dict()
# skill_type_id = request.env['hr.skill'].sudo().browse(int(key.split("_")[1])).skill_type_id.id
# candidate_skills['candidate_id'] = candidate.id
# candidate_skills['skill_id'] = int(key.split("_")[1])
# candidate_skills['skill_level_id'] = value[0]
# candidate_skills['level_progress'] = value[1]
# candidate_skills['skill_type_id'] = skill_type_id
# # skill = request.env['hr.candidate.skill'].sudo().create(candidate_skills)
# # candidate_skills_list.append(skill.id)
# if candidate.candidate_skill_ids:
# if candidate_skills['skill_id'] not in candidate.candidate_skill_ids.skill_id.ids:
# candidate.write({'candidate_skill_ids':[(0,4,candidate_skills)]})
# else:
# candidate.write({'candidate_skill_ids':[(0,4,candidate_skills)]})
#
#
# else:
# skills = None
values['partner_name'] = partner_name
if partner_phone:
@ -527,5 +1172,4 @@ class WebsiteJobHrRecruitment(WebsiteHrRecruitment):
data['record']['candidate_id'] = candidate.id
data['record']['type_id'] = candidate.type_id.id
return data
return data

View File

@ -6,7 +6,7 @@
<field name="inherit_id" ref="hr_recruitment_extended.view_job_recruitment_kanban"/>
<field name="arch" type="xml">
<xpath expr="//kanban" position="attributes">
<attribute name="default_order">is_published desc, is_favorite desc, create_date DESC</attribute>
<attribute name="default_order">is_favorite desc, create_date DESC</attribute>
</xpath>
<xpath expr="//t[@t-name='card']/div" position="before">
<field name="website_published" invisible="1"/>