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', 'hr.employee',
required=True, required=True,
default=lambda self: self.env.user.employee_id.id, default=lambda self: self.env.user.employee_id.id,
readonly=True,
) )
download_type = fields.Selection( download_type = fields.Selection(
selection=[ selection=[
@ -39,6 +38,13 @@ class EmployeePayslipDownloadWizard(models.TransientModel):
string='Available Payslips', string='Available Payslips',
compute='_compute_payslip_count', 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') @api.onchange('download_type', 'period_id')
def _onchange_download_type_period_id(self): 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) 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') currency_id = fields.Many2one('res.currency', related='employee_id.company_id.currency_id')
period_id = fields.Many2one('payroll.period', required=True) period_id = fields.Many2one('payroll.period', required=True)
period_line = fields.Many2one('payroll.period.line', period_line = fields.Many2one('payroll.period.line',
domain="[('period_id', '=', period_id), ('to_date', '<', fields.Date.today())]") domain="[('period_id', '=', period_id), ('to_date', '<', fields.Date.today())]")
# Taxpayer profile # Taxpayer profile
taxpayer_name = fields.Char(related='employee_id.name') taxpayer_name = fields.Char(related='employee_id.name')
@ -95,33 +95,38 @@ class ITTaxStatementWizard(models.TransientModel):
('old', 'Old Regime'), ('old', 'Old Regime'),
('new', 'New Regime') ('new', 'New Regime')
], string="Beneficial Regime", readonly=True) ], string="Beneficial Regime", readonly=True)
is_hr_manager = fields.Boolean(compute="_compute_is_hr_manager")
def _get_age_category(self, age): def _compute_is_hr_manager(self):
if age < 60: for rec in self:
return 'below_60' rec.is_hr_manager = self.env.user.has_group('hr.group_hr_manager')
elif age < 80:
return '60_to_80' def _get_age_category(self, age):
return 'above_80' if age < 60:
return 'below_60'
def _get_effective_period_start(self): elif age < 80:
self.ensure_one() return '60_to_80'
period_start = self.period_id.from_date if self.period_id else False return 'above_80'
if not period_start:
return False def _get_effective_period_start(self):
if self.emp_doj and self.period_id.to_date and self.period_id.from_date <= self.emp_doj <= self.period_id.to_date: self.ensure_one()
return max(period_start, self.emp_doj.replace(day=1)) period_start = self.period_id.from_date if self.period_id else False
return period_start if not period_start:
return False
def _get_effective_period_lines(self): if self.emp_doj and self.period_id.to_date and self.period_id.from_date <= self.emp_doj <= self.period_id.to_date:
self.ensure_one() return max(period_start, self.emp_doj.replace(day=1))
if not self.period_id: return period_start
return self.env['payroll.period.line']
def _get_effective_period_lines(self):
period_lines = self.period_id.period_line_ids.sorted('from_date') self.ensure_one()
effective_start = self._get_effective_period_start() if not self.period_id:
if not effective_start: return self.env['payroll.period.line']
return period_lines
return period_lines.filtered(lambda line: line.to_date and line.to_date >= effective_start) 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): def _find_applicable_slab(self, regime, period_id, age, residence_type):
"""Find the applicable tax slab without forcing both regimes to exist.""" """Find the applicable tax slab without forcing both regimes to exist."""
@ -136,7 +141,7 @@ class ITTaxStatementWizard(models.TransientModel):
('residence_type', '=', 'both') ('residence_type', '=', 'both')
], limit=1) ], 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""" """Get the applicable tax slab based on regime, age, and residence type"""
age_category = self._get_age_category(age) age_category = self._get_age_category(age)
slab_master = self._find_applicable_slab(regime, period_id, age, residence_type) 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" "No tax slab found for %s Regime with Age Category: %s and Residence Type: %s"
) % (regime.capitalize(), age_category.replace('_', ' ').title(), residence_type)) ) % (regime.capitalize(), age_category.replace('_', ' ').title(), residence_type))
return slab_master return slab_master
@api.onchange('employee_id', 'period_id') @api.onchange('employee_id', 'period_id')
def _onchange_employee_id_period_id(self): def _onchange_employee_id_period_id(self):
domain_by_record = {} domain_by_record = {}
for rec in self: for rec in self:
domain = [('period_id', '=', rec.period_id.id), ('to_date', '<', fields.Date.today())] if rec.period_id else [] domain = [('period_id', '=', rec.period_id.id), ('to_date', '<', fields.Date.today())] if rec.period_id else []
if rec.emp_doj: if rec.emp_doj:
domain.append(('to_date', '>=', rec.emp_doj.replace(day=1))) 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(): if rec.period_line and rec.period_line not in rec._get_effective_period_lines():
rec.period_line = False rec.period_line = False
domain_by_record[rec.id] = domain domain_by_record[rec.id] = domain
if len(self) == 1: if len(self) == 1:
return {'domain': {'period_line': domain_by_record.get(self.id, [])}} return {'domain': {'period_line': domain_by_record.get(self.id, [])}}
def _get_standard_deduction(self, regime, slab_master=False): def _get_standard_deduction(self, regime, slab_master=False):
if slab_master: if slab_master:
@ -329,7 +334,7 @@ class ITTaxStatementWizard(models.TransientModel):
return list(grouped.values()) return list(grouped.values())
def fetch_salary_components(self): def fetch_salary_components(self):
"""fetch salary components from payroll data""" """fetch salary components from payroll data"""
for rec in self: for rec in self:
data = { 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: if not rec.employee_id or not rec.contract_id or not rec.period_id or not rec.period_line:
return data return data
period_lines = rec._get_effective_period_lines() period_lines = rec._get_effective_period_lines()
for line in period_lines: for line in period_lines:
components = rec._get_salary_components_for_period_line(line) 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: 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['basic_salary']['actual'].append(components['basic_salary'])
data['hra_salary']['actual'].append(components['hra_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) 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: for rec in self:
data = { data = {
'professional_tax': {'actual': [], 'projected': []}, '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: if not rec.employee_id or not rec.contract_id or not rec.period_id or not rec.period_line:
return data return data
for line in rec._get_effective_period_lines(): for line in rec._get_effective_period_lines():
rule_amounts = rec._get_rule_amounts_for_period_line(line, ['PT', 'PFE']) 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' bucket = 'actual' if line.from_date <= rec.period_line.from_date else 'projected'
data['professional_tax'][bucket].append(rule_amounts['PT']) data['professional_tax'][bucket].append(rule_amounts['PT'])
data['nps_employer_contribution'][bucket].append(rule_amounts['PFE']) data['nps_employer_contribution'][bucket].append(rule_amounts['PFE'])
return data return data
@api.onchange('employee_id') @api.onchange('employee_id')
@ -521,6 +526,14 @@ class ITTaxStatementWizard(models.TransientModel):
tax_with_surcharge = total_before_mr - mr tax_with_surcharge = total_before_mr - mr
return surcharge, mr, tax_with_surcharge 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): def _compute_tax_old_regime(self, taxable, slab_master=False):
# Get applicable slab # Get applicable slab
slab_master = slab_master or self._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 total_tax = tax_with_surcharge + cess
current_employer_deducted_tax = self.fetch_current_employer_deducted_tax()
return { return {
'taxable_income': taxable, 'taxable_income': taxable,
'slab_tax': slab_tax, 'slab_tax': slab_tax,
@ -558,7 +573,9 @@ class ITTaxStatementWizard(models.TransientModel):
'marginal_relief': marginal_relief, 'marginal_relief': marginal_relief,
'tax_with_surcharge': tax_with_surcharge, 'tax_with_surcharge': tax_with_surcharge,
'cess_4pct': cess, '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): 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 cess = tax_with_surcharge * cess_rate[0] / 100
total_tax = tax_with_surcharge + cess total_tax = tax_with_surcharge + cess
current_employer_deducted_tax = self.fetch_current_employer_deducted_tax()
return { return {
'taxable_income': taxable, 'taxable_income': taxable,
'slab_tax': slab_tax, 'slab_tax': slab_tax,
@ -596,7 +614,9 @@ class ITTaxStatementWizard(models.TransientModel):
'marginal_relief': marginal_relief, 'marginal_relief': marginal_relief,
'tax_with_surcharge': tax_with_surcharge, 'tax_with_surcharge': tax_with_surcharge,
'cess_4pct': cess, '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): def _compute_house_property_income(self):
@ -759,19 +779,19 @@ class ITTaxStatementWizard(models.TransientModel):
'target': 'current', 'target': 'current',
} }
def _prepare_income_tax_data(self, include_comparison=False): def _prepare_income_tax_data(self, include_comparison=False):
"""Prepare data for the tax statement report""" """Prepare data for the tax statement report"""
today = date.today() today = date.today()
display_fy_start = self.period_id.from_date display_fy_start = self.period_id.from_date
fy_end = self.period_id.to_date fy_end = self.period_id.to_date
effective_fy_start = self._get_effective_period_start() or display_fy_start effective_fy_start = self._get_effective_period_start() or display_fy_start
total_months = ((fy_end.year - effective_fy_start.year) * 12 + total_months = ((fy_end.year - effective_fy_start.year) * 12 +
(fy_end.month - effective_fy_start.month) + 1) (fy_end.month - effective_fy_start.month) + 1)
line_start = self.period_line.from_date line_start = self.period_line.from_date
current_month_index = ((line_start.year - effective_fy_start.year) * 12 + current_month_index = ((line_start.year - effective_fy_start.year) * 12 +
(line_start.month - effective_fy_start.month) + 1) (line_start.month - effective_fy_start.month) + 1)
values = self._get_tax_base_values(include_comparison=include_comparison) values = self._get_tax_base_values(include_comparison=include_comparison)
salary_components_data = values['salary_components_data'] salary_components_data = values['salary_components_data']
annual_gross_salary = values['annual_gross_salary'] annual_gross_salary = values['annual_gross_salary']
gross_salary_actual = values['gross_salary_actual'] gross_salary_actual = values['gross_salary_actual']
@ -806,16 +826,16 @@ class ITTaxStatementWizard(models.TransientModel):
# Prepare data structure matching screenshot format # Prepare data structure matching screenshot format
# Financial year (period_id) # Financial year (period_id)
display_fy_start = self.period_id.from_date display_fy_start = self.period_id.from_date
fy_end = self.period_id.to_date fy_end = self.period_id.to_date
effective_fy_start = self._get_effective_period_start() or display_fy_start effective_fy_start = self._get_effective_period_start() or display_fy_start
total_months = ((fy_end.year - effective_fy_start.year) * 12 + total_months = ((fy_end.year - effective_fy_start.year) * 12 +
(fy_end.month - effective_fy_start.month) + 1) (fy_end.month - effective_fy_start.month) + 1)
# Current month (period_line) # Current month (period_line)
line_start = self.period_line.from_date line_start = self.period_line.from_date
current_month_index = ((line_start.year - effective_fy_start.year) * 12 + current_month_index = ((line_start.year - effective_fy_start.year) * 12 +
(line_start.month - effective_fy_start.month) + 1) (line_start.month - effective_fy_start.month) + 1)
tax_result['roundoff_taxable_income'] = float(round(tax_result["taxable_income"] / 10) * 10) tax_result['roundoff_taxable_income'] = float(round(tax_result["taxable_income"] / 10) * 10)
birthday = self.employee_id.birthday birthday = self.employee_id.birthday
if birthday: if birthday:
@ -835,8 +855,8 @@ class ITTaxStatementWizard(models.TransientModel):
'total': total, 'total': total,
}) })
data = { data = {
'financial_year': f"{display_fy_start.year}-{fy_end.year}", 'financial_year': f"{display_fy_start.year}-{fy_end.year}",
'assessment_year': fy_end.year + 1, 'assessment_year': fy_end.year,
'report_time': today.strftime('%d-%m-%Y %H:%M'), 'report_time': today.strftime('%d-%m-%Y %H:%M'),
'user': 'ESS', 'user': 'ESS',
'emp_code': self.employee_id.employee_id, '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="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="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="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', '')"/> <t t-set="report_time" t-value="data.get('report_time', '')"/>
<!-- Header --> <!-- Header -->
@ -378,7 +380,7 @@
<tr> <tr>
<td>Less: Tax deducted current employer (up to previous month)</td> <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;">0</td> <td style="text-align: right;" t-esc="current_employer_deducted_tax"/>
</tr> </tr>
<tr> <tr>
<td>Less: Tax deducted from previous Employer / Self Tax Paid</td> <td>Less: Tax deducted from previous Employer / Self Tax Paid</td>
@ -389,7 +391,7 @@
<tr style="font-weight: bold;"> <tr style="font-weight: bold;">
<td>Balance Tax for the year</td> <td>Balance Tax for the year</td>
<td style="text-align: right;">0</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>
<tr> <tr>
<td>Less: Adhoc tax deducted in Off-Cycle in current month</td> <td>Less: Adhoc tax deducted in Off-Cycle in current month</td>
@ -399,7 +401,7 @@
<tr style="font-weight: bold;"> <tr style="font-weight: bold;">
<td><strong>Balance Tax</strong></td> <td><strong>Balance Tax</strong></td>
<td style="text-align: right;">0</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>
<tr> <tr>
<td><strong>Tax deducted from current month salary</strong></td> <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_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,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_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_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_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_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,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_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> </header>
<sheet> <sheet>
<group> <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}"/> <field name="download_type" widget="radio" options="{'horizontal': true}"/>
</group> </group>
<group> <group>

View File

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

View File

@ -17,6 +17,7 @@
'resource', 'resource',
'portal', 'portal',
'digest', 'digest',
'website',
], ],
'description': """ 'description': """
Helpdesk - Ticket Management App Helpdesk - Ticket Management App
@ -60,6 +61,7 @@ Features:
'views/mail_activity_views.xml', 'views/mail_activity_views.xml',
'views/helpdesk_templates.xml', 'views/helpdesk_templates.xml',
'views/helpdesk_menus.xml', 'views/helpdesk_menus.xml',
'views/website_form.xml',
'wizard/helpdesk_stage_delete_views.xml', 'wizard/helpdesk_stage_delete_views.xml',
], ],
'demo': ['data/helpdesk_demo.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.exceptions import AccessError, MissingError, UserError
from odoo.http import request from odoo.http import request
from odoo.tools.translate import _ 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 import portal
from odoo.addons.portal.controllers.portal import pager as portal_pager from odoo.addons.portal.controllers.portal import pager as portal_pager
from odoo.osv.expression import AND, FALSE_DOMAIN from odoo.osv.expression import AND, FALSE_DOMAIN
@ -32,7 +32,22 @@ class CustomerPortal(portal.CustomerPortal):
return values return values
def _prepare_helpdesk_tickets_domain(self): 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): def _ticket_get_page_view_values(self, ticket, access_token, **kwargs):
values = { 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) 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) 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([ @http.route([
"/helpdesk/ticket/<int:ticket_id>", "/helpdesk/ticket/<int:ticket_id>",
"/helpdesk/ticket/<int:ticket_id>/<access_token>", "/helpdesk/ticket/<int:ticket_id>/<access_token>",
@ -170,6 +245,8 @@ class CustomerPortal(portal.CustomerPortal):
return request.redirect('/my') return request.redirect('/my')
values = self._ticket_get_page_view_values(ticket_sudo, access_token, **kw) 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) return request.render("helpdesk.tickets_followup", values)
@http.route([ @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') 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 '')) 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"?> <?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1"> <odoo noupdate="1">
<record id="helpdesk_team1" model="helpdesk.team"> <record id="helpdesk_team1" model="helpdesk.team">
<field name="name">Customer Care</field> <field name="name">Customer Care</field>
<field name="alias_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="portal_ticket_type">general</field>
<field name="use_sla" eval="True"/> <field name="allow_portal_ticket_closing" eval="True"/>
<field name="member_ids" eval="[Command.link(ref('base.user_admin'))]"/> <field name="stage_ids" eval="False"/> <!-- eval=False to don't get the default stage. New stages are setted below-->
</record> <field name="use_sla" eval="True"/>
<field name="member_ids" eval="[Command.link(ref('base.user_admin'))]"/>
<!-- stage "New" gets created by default with sequence 0--> <field name="responsible_manager_id" ref="base.user_admin"/>
<record id="stage_new" model="helpdesk.stage"> <!-- <field name="approver_id" ref="base.user_admin"/>-->
<field name="name">New</field> </record>
<field name="sequence">0</field>
<field name="team_ids" eval="[(4, ref('helpdesk_team1'))]"/> <record id="helpdesk_team_technical" model="helpdesk.team">
<field name="template_id" ref="helpdesk.new_ticket_request_email_template"/> <field name="name">Technical Support</field>
</record> <field name="alias_name">technical-support</field>
<record id="stage_in_progress" model="helpdesk.stage"> <field name="portal_ticket_type">technical</field>
<field name="name">In Progress</field> <field name="stage_ids" eval="False"/>
<field name="sequence">1</field> <field name="privacy_visibility">portal</field>
<field name="team_ids" eval="[(4, ref('helpdesk_team1'))]"/> <field name="use_sla" eval="True"/>
</record> <field name="use_rating" eval="True"/>
<record id="stage_on_hold" model="helpdesk.stage"> <field name="allow_portal_ticket_closing" eval="True"/>
<field name="name">On Hold</field> <field name="member_ids" eval="[Command.link(ref('base.user_admin'))]"/>
<field name="sequence">2</field> <field name="responsible_manager_id" ref="base.user_admin"/>
<field name="team_ids" eval="[(4, ref('helpdesk_team1'))]"/> <!-- <field name="approver_id" ref="base.user_admin"/>-->
</record> </record>
<record id="stage_solved" model="helpdesk.stage">
<field name="name">Solved</field> <record id="helpdesk_team_personal" model="helpdesk.team">
<field name="team_ids" eval="[(4, ref('helpdesk_team1'))]"/> <field name="name">Personal Concern Support</field>
<field name="sequence">3</field> <field name="alias_name">personal-concern-support</field>
<field name="fold" eval="True"/> <field name="portal_ticket_type">personal</field>
</record> <field name="stage_ids" eval="False"/>
<record id="stage_cancelled" model="helpdesk.stage"> <field name="privacy_visibility">portal</field>
<field name="name">Cancelled</field> <field name="use_sla" eval="True"/>
<field name="sequence">4</field> <field name="use_rating" eval="True"/>
<field name="team_ids" eval="[(4, ref('helpdesk_team1'))]"/> <field name="allow_portal_ticket_closing" eval="True"/>
<field name="fold" eval="True"/> <field name="member_ids" eval="[Command.link(ref('base.user_admin'))]"/>
</record> <field name="responsible_manager_id" ref="base.user_admin"/>
<!-- <field name="approver_id" ref="base.user_admin"/>-->
</odoo> </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"?> <?xml version="1.0" encoding="utf-8"?>
<odoo><data noupdate="1"> <odoo><data noupdate="1">
<record id="new_ticket_request_email_template" model="mail.template"> <record id="new_ticket_request_email_template" model="mail.template">
<field name="name">Helpdesk: Ticket Received</field> <field name="name">Helpdesk: Ticket Received</field>
<field name="model_id" ref="helpdesk.model_helpdesk_ticket"/> <field name="model_id" ref="helpdesk.model_helpdesk_ticket"/>
<field name="subject">{{ object.name }}</field> <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_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="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> <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"> <field name="body_html" type="html">
<div> <div>
Dear <t t-out="object.sudo().partner_id.name or object.sudo().partner_name or 'Madam/Sir'">Madam/Sir</t>,<br /><br /> 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()"> <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> <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> </t>
@ -51,12 +51,33 @@
</field> </field>
<field name="lang">{{ object.partner_id.lang or object.user_id.lang or user.lang }}</field> <field name="lang">{{ object.partner_id.lang or object.user_id.lang or user.lang }}</field>
<field name="auto_delete" eval="True"/> <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"> <record id="solved_ticket_request_email_template" model="mail.template">
<field name="name">Helpdesk: Ticket Closed</field> <field name="name">Helpdesk: Ticket Closed</field>
<field name="model_id" ref="helpdesk.model_helpdesk_ticket"/> <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_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="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> <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"> <field name="body_html" type="html">
<div> <div>
Dear <t t-out="object.sudo().partner_id.name or 'Madam/Sir'">Madam/Sir</t>,<br /><br /> 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>). 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 /> 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. Please give feedback or leave a review if you would like to. This is optional, but it helps us improve.<br /><br />
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 /> 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 /> 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 /> Kind regards,<br /><br />
<t t-out="object.team_id.name or 'Helpdesk'">Helpdesk</t> Team. <t t-out="object.team_id.name or 'Helpdesk'">Helpdesk</t> Team.
@ -75,7 +100,25 @@
</field> </field>
<field name="lang">{{ object.partner_id.lang or object.user_id.lang or user.lang }}</field> <field name="lang">{{ object.partner_id.lang or object.user_id.lang or user.lang }}</field>
<field name="auto_delete" eval="True"/> <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"> <record id="rating_ticket_request_email_template" model="mail.template">
<field name="name">Helpdesk: Ticket Rating Request</field> <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) 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) access_instruction_message = fields.Char(compute='_compute_access_instruction_message', export_string_translation=False)
ticket_ids = fields.One2many('helpdesk.ticket', 'team_id', string='Tickets') 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) use_alias = fields.Boolean('Use Alias', default=True)
has_external_mail_server = fields.Boolean(compute='_compute_has_external_mail_server', export_string_translation=False) 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'), ('3', 'Urgent'),
] ]
HELPDESK_TICKET_TYPES = [
('technical', 'Technical Issue'),
('personal', 'Personal / Harassment'),
('damage', 'Damage / Asset Issue'),
('general', 'General Request'),
('others', 'Others')
]
class HelpdeskTicket(models.Model): class HelpdeskTicket(models.Model):
_name = 'helpdesk.ticket' _name = 'helpdesk.ticket'
_description = 'Helpdesk Ticket' _description = 'Helpdesk Ticket'
@ -61,6 +69,13 @@ class HelpdeskTicket(models.Model):
name = fields.Char(string='Subject', required=True, index=True, tracking=True) 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) 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') use_sla = fields.Boolean(related='team_id.use_sla')
team_privacy_visibility = fields.Selection(related='team_id.privacy_visibility', export_string_translation=False) team_privacy_visibility = fields.Selection(related='team_id.privacy_visibility', export_string_translation=False)
description = fields.Html(sanitize_attributes=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': 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_user_ids = ticket_sudo.team_id.message_partner_ids.user_ids.ids
ticket.domain_user_ids = [Command.set(user_ids + ticket_user_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): def _compute_access_url(self):
super(HelpdeskTicket, self)._compute_access_url() 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: 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] 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') @api.depends('partner_id')
def _compute_partner_name(self): def _compute_partner_name(self):
for ticket in self: for ticket in self:
@ -522,6 +545,7 @@ class HelpdeskTicket(models.Model):
# apply SLA # apply SLA
tickets.sudo()._sla_apply() tickets.sudo()._sla_apply()
tickets._send_ticket_created_notifications()
return tickets 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 \ 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)) 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) ticket.message_post(body=message, subtype_xmlid="mail.mt_note", author_id=odoobot_partner_id)
closed_tickets._send_ticket_closed_notifications()
return res 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): def copy_data(self, default=None):
vals_list = super().copy_data(default=default) 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)] 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] 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: if partner_ids:
self.message_subscribe(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) return super(HelpdeskTicket, self).message_update(msg, update_vals=update_vals)
def _message_compute_subject(self): def _message_compute_subject(self):

View File

@ -2,7 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details. # Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, tools 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 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) name = fields.Char(string='Subject', readonly=True)
create_date = fields.Datetime("Ticket Creation Date", readonly=True) create_date = fields.Datetime("Ticket Creation Date", readonly=True)
priority = fields.Selection(TICKET_PRIORITY, string='Minimum Priority', 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) user_id = fields.Many2one('res.users', string="Assigned To", readonly=True)
partner_id = fields.Many2one('res.partner', string="Customer", readonly=True) partner_id = fields.Many2one('res.partner', string="Customer", readonly=True)
partner_name = fields.Char(string='Customer Name', 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, NULLIF(T.rating_last_value, 0) AS rating_last_value,
AVG(rt.rating) as rating_avg, AVG(rt.rating) as rating_avg,
T.priority AS priority, T.priority AS priority,
T.portal_ticket_type AS portal_ticket_type,
NULLIF(T.close_hours, 0) AS ticket_close_hours, NULLIF(T.close_hours, 0) AS ticket_close_hours,
CASE CASE
WHEN EXTRACT(EPOCH FROM (COALESCE(T.assign_date, NOW() AT TIME ZONE 'UTC') - T.create_date)) / 3600 < 1 THEN NULL 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. # Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, tools 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 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") sla_status_ids = fields.One2many('helpdesk.sla.status', 'ticket_id', string="SLA Status")
create_date = fields.Datetime("Ticket Creation Date", readonly=True) create_date = fields.Datetime("Ticket Creation Date", readonly=True)
priority = fields.Selection(TICKET_PRIORITY, string='Minimum Priority', 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) user_id = fields.Many2one('res.users', string="Assigned To", readonly=True)
partner_id = fields.Many2one('res.partner', string="Customer", readonly=True) partner_id = fields.Many2one('res.partner', string="Customer", readonly=True)
partner_name = fields.Char(string='Customer Name', readonly=True) partner_name = fields.Char(string='Customer Name', readonly=True)
@ -60,6 +61,7 @@ class HelpdeskTicketReport(models.Model):
T.name AS name, T.name AS name,
T.create_date AS create_date, T.create_date AS create_date,
T.priority AS priority, T.priority AS priority,
T.portal_ticket_type AS portal_ticket_type,
T.user_id AS user_id, T.user_id AS user_id,
T.partner_id AS partner_id, T.partner_id AS partner_id,
T.partner_name AS partner_name, T.partner_name AS partner_name,

View File

@ -39,6 +39,9 @@
<t t-call="portal.portal_searchbar"> <t t-call="portal.portal_searchbar">
<t t-set="title">Tickets</t> <t t-set="title">Tickets</t>
</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"> <div t-if="not grouped_tickets" class="alert alert-info">
There are currently no Ticket for your account. There are currently no Ticket for your account.
</div> </div>
@ -47,6 +50,7 @@
<thead> <thead>
<tr> <tr>
<th>Ticket</th> <th>Ticket</th>
<th>Type</th>
<th class="text-end" t-if="groupby != 'create_date'">Reported on</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 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> <th t-if="groupby != 'stage_id'" colspan="5" class="text-end">Stage</th>
@ -82,6 +86,7 @@
<t t-foreach="tickets" t-as="ticket"> <t t-foreach="tickets" t-as="ticket">
<tr> <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 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'"> <td class="text-end" t-if="groupby != 'create_date'">
<span t-field="ticket.create_date" t-options='{"widget": "datetime", "hide_seconds": True}'/> <span t-field="ticket.create_date" t-options='{"widget": "datetime", "hide_seconds": True}'/>
</td> </td>
@ -100,6 +105,161 @@
</t> </t>
</template> </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"> <template id="tickets_followup" name="Helpdesk Tickets">
<t t-call="portal.portal_layout"> <t t-call="portal.portal_layout">
<t t-set="title" t-value="ticket.name"/> <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="classes" t-value="'col-lg-4 col-xxl-3 d-print-none'"/>
<t t-set="entries"> <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 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"> <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"> <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> <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> <span>Your ticket has successfully been closed. Thank you for your collaboration.</span>
</div> </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">
<div id="card_header" class="container" data-anchor="true"> <div id="card_header" class="container" data-anchor="true">
<div class="row gs-0"> <div class="row gs-0">
@ -220,6 +395,10 @@
<strong class="col-lg-3">Reported on</strong> <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}'/> <span class="col-lg-9" t-field="ticket.create_date" t-options='{"widget": "datetime", "hide_seconds": True}'/>
</div> </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 t-if="not is_html_empty(ticket.description)" class="row mt-3" name="description">
<div class="col-lg-12" t-field="ticket.description"/> <div class="col-lg-12" t-field="ticket.description"/>
</div> </div>

View File

@ -119,74 +119,86 @@
</div> </div>
</div> </div>
</setting> </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>
</div> </div>
<h2>Channels</h2> <!-- <h2>Channels</h2>-->
<div class="row mt16 o_settings_container" id="channels"> <!-- <div class="row mt16 o_settings_container" id="channels">-->
<setting id="alias_channels" help="Create tickets by sending an email to an alias" <!-- <setting id="alias_channels" help="Create tickets by sending an email to an alias"-->
documentation="/applications/services/helpdesk/overview/receiving_tickets.html#email-alias"> <!-- documentation="/applications/services/helpdesk/overview/receiving_tickets.html#email-alias">-->
<field name="use_alias" string="Email Alias"/> <!-- <field name="use_alias" string="Email Alias"/>-->
<div invisible="not use_alias" class="mt16"> <!-- <div invisible="not use_alias" class="mt16">-->
<div class="oe_edit_only" dir="ltr"> <!-- <div class="oe_edit_only" dir="ltr">-->
<strong>Alias </strong> <!-- <strong>Alias </strong>-->
<field name="alias_name" placeholder="alias" class="w-25"/>@ <!-- <field name="alias_name" placeholder="alias" class="w-25"/>@-->
<field name="alias_domain_id" class="oe_inline" placeholder="e.g. mycompany.com" <!-- <field name="alias_domain_id" class="oe_inline" placeholder="e.g. mycompany.com"-->
options="{'no_create': True, 'no_open': True}"/> <!-- options="{'no_create': True, 'no_open': True}"/>-->
<br/> <!-- <br/>-->
<label for="alias_contact"/> <!-- <label for="alias_contact"/>-->
<field name="alias_contact" string="Accept Emails From"/> <!-- <field name="alias_contact" string="Accept Emails From"/>-->
</div> <!-- </div>-->
<p class="oe_read_only"> <!-- <p class="oe_read_only">-->
<strong>Alias </strong> <!-- <strong>Alias </strong>-->
<field name="alias_id" class="oe_read_only oe_inline" required="False"/> <!-- <field name="alias_id" class="oe_read_only oe_inline" required="False"/>-->
</p> <!-- </p>-->
<field name="has_external_mail_server" invisible="1"/> <!-- <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"> <!-- <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> <!-- <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>-->
<p invisible="alias_domain_id"> <!-- <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> <!-- <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> <!-- </p>-->
</div> <!-- </div>-->
</setting> <!-- </setting>-->
<setting help="Get in touch with your website visitors, and engage them with scripted chatbot conversations. Create and search tickets from your conversations."> <!-- <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"/> <!-- <field name="use_website_helpdesk_livechat"/>-->
<div class="text-muted o_row ps-1 mt16" invisible="not 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"/> <!-- <i class="fa fa-lightbulb-o"/>-->
<span class="ms-2"> <!-- <span class="ms-2">-->
Type <b>/ticket</b> to create tickets<br/> <!-- Type <b>/ticket</b> to create tickets<br/>-->
Type <b>/search_tickets</b> to find tickets<br/> <!-- Type <b>/search_tickets</b> to find tickets<br/>-->
</span> <!-- </span>-->
</div> <!-- </div>-->
</setting> <!-- </setting>-->
</div> <!-- </div>-->
<h2>Help Center</h2> <h2>Help Center</h2>
<div class="row mt16 o_settings_container" id="website_form_channel"> <div class="row mt16 o_settings_container" id="website_form_channel">
<setting help="Get tickets through an online form"> <setting help="Get tickets through an online form">
<field name="use_website_helpdesk_form"/> <field name="use_website_helpdesk_form"/>
</setting> </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"> <!-- <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"/> <!-- <field name="use_website_helpdesk_knowledge"/>-->
</setting> <!-- </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"> <!-- <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"/> <!-- <field name="use_website_helpdesk_forum"/>-->
</setting> <!-- </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"> <!-- <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"/> <!-- <field name="use_website_helpdesk_slides"/>-->
</setting> <!-- </setting>-->
</div> </div>
<h2 class="mt32">Track &amp; Bill Time</h2> <h2 class="mt32">Timesheets</h2>
<div class="row mt16 o_settings_container"> <div class="row mt16 o_settings_container">
<setting id="timesheet" <setting id="timesheet"
help="Track the time spent on tickets" help="Track the time spent on tickets"
documentation="/applications/services/helpdesk/advanced/track_and_bill.html"> documentation="/applications/services/helpdesk/advanced/track_and_bill.html">
<field name="use_helpdesk_timesheet"/> <field name="use_helpdesk_timesheet"/>
</setting> </setting>
<setting id="sale_timesheet" <!-- <setting id="sale_timesheet"-->
help="Bill the time spent on your tickets to your customers" <!-- help="Bill the time spent on your tickets to your customers"-->
documentation="/applications/services/helpdesk/advanced/track_and_bill.html"> <!-- documentation="/applications/services/helpdesk/advanced/track_and_bill.html">-->
<field name="use_helpdesk_sale_timesheet"/> <!-- <field name="use_helpdesk_sale_timesheet"/>-->
</setting> <!-- </setting>-->
</div> </div>
<h2>Performance</h2> <h2>Performance</h2>
<div class="row mt16 o_settings_container"> <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"> <setting help="Allow your customers to close their own tickets" documentation="/applications/services/helpdesk/advanced/close_tickets.html">
<field name="allow_portal_ticket_closing"/> <field name="allow_portal_ticket_closing"/>
</setting> </setting>
<setting help="Close inactive tickets automatically"> <!-- <setting help="Close inactive tickets automatically">-->
<field name="auto_close_ticket"/> <!-- <field name="auto_close_ticket"/>-->
<div class="content-group" invisible="not auto_close_ticket"> <!-- <div class="content-group" invisible="not auto_close_ticket">-->
<field name="stage_ids" invisible="1"/> <!-- <field name="stage_ids" invisible="1"/>-->
<div class="mt16"> <!-- <div class="mt16">-->
<label for="to_stage_id"/> <!-- <label for="to_stage_id"/>-->
<field name="to_stage_id" class="ms-2 oe_inline" required="auto_close_ticket" context="{'default_team_id': id}"/> <!-- <field name="to_stage_id" class="ms-2 oe_inline" required="auto_close_ticket" context="{'default_team_id': id}"/>-->
</div> <!-- </div>-->
<div class="mt8"> <!-- <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> <!-- <strong>After</strong><field name="auto_close_day" class="mx-2 oe_inline text-center" required="1"/><span>days of inactivity</span>-->
</div> <!-- </div>-->
<div class="mt8"> <!-- <div class="mt8">-->
<label for="from_stage_ids"/> <!-- <label for="from_stage_ids"/>-->
<field name="from_stage_ids" widget="many2many_tags" class="ms-2" context="{'default_team_id': id}"/> <!-- <field name="from_stage_ids" widget="many2many_tags" class="ms-2" context="{'default_team_id': id}"/>-->
</div> <!-- </div>-->
</div> <!-- </div>-->
</setting> <!-- </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> </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> </sheet>
</form> </form>
</field> </field>

View File

@ -77,6 +77,7 @@
<field name="user_id"/> <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="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="team_id" invisible="context.get('default_team_id', False)"/>
<field name="portal_ticket_type"/>
<field name="stage_id"/> <field name="stage_id"/>
<field name="sla_ids" groups="helpdesk.group_use_sla"/> <field name="sla_ids" groups="helpdesk.group_use_sla"/>
<field name="priority" invisible="1"/> <field name="priority" invisible="1"/>
@ -119,6 +120,7 @@
<group expand="0" string="Group By"> <group expand="0" string="Group By">
<filter string="Assigned to" name="assignee" context="{'group_by':'user_id'}"/> <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="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="Stage" name="stage" context="{'group_by':'stage_id'}"/>
<filter string="Status" name="state" context="{'group_by': 'kanban_state'}"/> <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"/> <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="name" string="Name"/>
<field name="team_id" optional="show" readonly="1" column_invisible="context.get('default_team_id', False)"/> <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="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="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="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)"/> <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 class="mb-0 mt-4">
<group> <group>
<field name="active" invisible="1"/> <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="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="domain_user_ids" invisible="1"/>
<field name="priority" widget="priority"/> <field name="priority" widget="priority"/>
<field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color', 'no_create_edit': True}"/> <field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color', 'no_create_edit': True}"/>
</group> </group>
<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="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"/> <field name="is_partner_phone_update" invisible="1"/>
<label for="partner_phone" string="Phone"/> <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"}, "relevant_experience_years": {"type": "float", "description": "Relevant years of experience as a number"},
"notice_period": {"type": "string", "description": "Notice period text"}, "notice_period": {"type": "string", "description": "Notice period text"},
"degree": {"type": "string", "description": "Highest degree or main qualification"}, "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"}, "summary": {"type": "string", "description": "Short professional summary from the resume"},
"education_history": { "education_history": {
"type": "list", "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 = [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] 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 candidate = False
if email_value: 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: if not candidate and phone_values:
candidate = search_model.search([ candidate = search_model.search([
"|", "|","|",('partner_phone',"in",phone_values),
("partner_phone_sanitized", "in", phone_values), ("partner_phone_sanitized", "in", phone_values),
("alternate_phone", "in", phone_values), ("alternate_phone", "in", phone_values),
], limit=1) ], 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", "hr_recruitment_dashboards/static/src/scss/recruitment_dashboard.scss",
], ],
}, },
'images': ['static/description/banner.png'],
"installable": True, "installable": True,
"application": False, "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" sequence="1"
active="0"/> active="0"/>
<menuitem <menuitem
name="Dashboard" name="Recruitment Analysis"
id="hr_recruitment.report_hr_recruitment" id="report_hr_recruitment_root"
parent="hr_recruitment.menu_hr_recruitment_root"
groups="hr_recruitment.group_hr_recruitment_user" groups="hr_recruitment.group_hr_recruitment_user"
action="action_hr_recruitment_dashboard" action="action_hr_recruitment_dashboard"
web_icon="hr_recruitment_dashboards,static/description/icon.png"
sequence="99"/> sequence="99"/>
<menuitem <menuitem
name="Recruitment Analysis" name="Recruitment Analysis"

View File

@ -18,7 +18,7 @@
'version': '0.1', 'version': '0.1',
# any module necessary for this one to work correctly # 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 # always loaded
'data': [ 'data': [

View File

@ -9,8 +9,18 @@ class HrApplicantStageComment(models.Model):
applicant_id = fields.Many2one('hr.applicant', required=True, ondelete='cascade', index=True) applicant_id = fields.Many2one('hr.applicant', required=True, ondelete='cascade', index=True)
stage_id = fields.Many2one('hr.recruitment.stage', required=True, index=True) stage_id = fields.Many2one('hr.recruitment.stage', required=True, index=True)
comment = fields.Text(required=True) comment = fields.Text(required=True)
user_id = fields.Many2one('res.users', default=lambda self: self.env.user, 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) 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') @api.depends('applicant_id', 'stage_id')
def _compute_display_name(self): def _compute_display_name(self):

View File

@ -4,13 +4,6 @@ from datetime import date
from datetime import timedelta from datetime import timedelta
import datetime import datetime
#
# class Job(models.Model):
# _inherit = 'hr.job'
#
# hiring_history = fields.One2many('recruitment.status.history', 'job_id', string='History')
class HrCandidate(models.Model): class HrCandidate(models.Model):
_inherit = "hr.candidate" _inherit = "hr.candidate"
@ -32,7 +25,6 @@ class HrCandidate(models.Model):
resume_name = fields.Char() resume_name = fields.Char()
applications_stages_stat = fields.Many2many('application.stage.status',string="Applications History", compute="_compute_applications_stages_stat") 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): def action_toggle_chatter_visibility(self):
for record in self: for record in self:
@ -103,6 +95,21 @@ class HrCandidate(models.Model):
for rec in self: for rec in self:
rec.display_name = rec.partner_name if not rec.candidate_sequence else f"{rec.partner_name} ({rec.candidate_sequence})" 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') @api.constrains('email_from', 'partner_phone', 'alternate_phone')
def _candidate_unique_constraints(self): def _candidate_unique_constraints(self):
for rec in self: for rec in self:
@ -116,16 +123,23 @@ class HrCandidate(models.Model):
# Check for unique phone number (partner_phone or alternate_phone) # Check for unique phone number (partner_phone or alternate_phone)
if rec.partner_phone: if rec.partner_phone:
existing_phone = self.sudo().search( phone_variants = self._get_phone_variants(rec.partner_phone)
[('id', '!=', rec.id), '|', ('partner_phone', '=', rec.partner_phone),
('alternate_phone', '=', rec.partner_phone)], limit=1) existing_phone = self.sudo().search([
('id', '!=', rec.id),
'|',
('partner_phone_sanitized', 'in', phone_variants),
('alternate_phone', 'in', phone_variants),
], limit=1)
if existing_phone: if existing_phone:
raise ValidationError(_("A candidate with the phone number '%s' already exists, sourced by %s %s.") % ( 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 '')) existing_phone.partner_phone, existing_phone.user_id.name, existing_phone.candidate_sequence if existing_phone.candidate_sequence else ''))
if rec.alternate_phone: if rec.alternate_phone:
phone_variants = self._get_phone_variants(rec.alternate_phone)
existing_al_phone = self.sudo().search( 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) ('alternate_phone', '=', rec.alternate_phone)], limit=1)
if existing_al_phone: if existing_al_phone:
raise ValidationError( raise ValidationError(
@ -161,12 +175,6 @@ class HrCandidate(models.Model):
employee.write({ employee.write({
'image_1920': self.candidate_image}) 'image_1920': self.candidate_image})
return action 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') @api.constrains('partner_name')
def partner_name_constrain(self): def partner_name_constrain(self):

View File

@ -4,4 +4,4 @@ from odoo import models, fields, api, _
class ResPartner(models.Model): class ResPartner(models.Model):
_inherit = 'res.partner' _inherit = 'res.partner'
contact_type = fields.Selection([('internal','In-House'),('external','Client-Side')], required=True, default='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 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_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_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_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_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_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_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_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_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="submission_status"/>
<field name="requested_by"/> <field name="requested_by"/>
<separator/> <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="Published Records" name="published_records" domain="[('website_published','=',True)]"/>
<filter string="UnPublished Records" name="unpublished_records" domain="[('website_published','=',False)]"/> <filter string="UnPublished Records" name="unpublished_records" domain="[('website_published','=',False)]"/>
<separator/> <separator/>
@ -434,7 +442,7 @@
<record id="action_hr_job_recruitment_awaiting_published" model="ir.actions.act_window"> <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="res_model">hr.job.recruitment</field>
<field name="view_mode">kanban,list,form,search</field> <field name="view_mode">kanban,list,form,search</field>
<field name="search_view_id" ref="view_job_recruitment_filter"/> <field name="search_view_id" ref="view_job_recruitment_filter"/>
@ -474,7 +482,7 @@
active="0" active="0"
sequence="2"/> sequence="2"/>
<menuitem name="JD" <menuitem name="JP"
id="menu_hr_job_descriptions" id="menu_hr_job_descriptions"
parent="hr_recruitment.menu_hr_recruitment_root" parent="hr_recruitment.menu_hr_recruitment_root"
action="action_hr_job_recruitment_awaiting_published" 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 string="Client Type" name="contact_type" context="{'group_by': 'contact_type'}"/>
</filter> </filter>
<xpath expr="//search" position="inside"> <xpath expr="//search" position="inside">
<filter name="internal_contact" string="In-House Contact" domain="[('contact_type', '=', 'internal')]"/> <filter name="internal_contact" string="In-House Contact"
<filter name="external_contact" string="Client-Side Contact" domain="[('contact_type', '=', 'external')]"/> domain="[('contact_type', '=', 'internal')]"/>
<filter name="external_contact" string="Client-Side Contact"
domain="[('contact_type', '=', 'external')]"/>
</xpath> </xpath>
</field> </field>
</record> </record>
@ -27,12 +29,234 @@
<field name="inherit_id" ref="base.view_partner_form"/> <field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml"> <field name="arch" type="xml">
<xpath expr="//field[@name='category_id']" position="after"> <xpath expr="//field[@name='category_id']" position="after">
<field name="contact_type"/> <field name="contact_type"/>
</xpath> </xpath>
</field> </field>
</record> </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"> <record id="action_contacts_recruitments" model="ir.actions.act_window">
<field name="name">Contacts</field> <field name="name">Contacts</field>
@ -56,15 +280,16 @@
id="menu_hr_recruitment_config_contacts" id="menu_hr_recruitment_config_contacts"
name="Contacts" name="Contacts"
parent="hr_recruitment.menu_hr_recruitment_configuration" parent="hr_recruitment.menu_hr_recruitment_configuration"
active="0"
sequence="10"/> sequence="10"/>
<menuitem <menuitem
id="menu_hr_recruitment_stage" id="menu_hr_recruitment_stage"
name="Clients" name="Vendors"
parent="menu_hr_recruitment_config_contacts" parent="hr_recruitment.menu_hr_recruitment_root"
action="action_contacts_recruitments" action="action_vendor_partner"
groups="base.group_user" groups="base.group_user"
sequence="1"/> sequence="98"/>
</data> </data>
</odoo> </odoo>

View File

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

View File

@ -35,7 +35,7 @@ class ClientSubmissionsMailTemplateWizard(models.TransientModel):
raise UserError("Email template not found.") raise UserError("Email template not found.")
self.email_from = record.user_id.partner_id.email 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_body = email_template.body_html # Assign the rendered email bodyc
self.email_subject = email_template.subject 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/login.xml',
'views/menu_access_control_views.xml', 'views/menu_access_control_views.xml',
], ],
# 'assets': { 'assets': {
# 'web.assets_backend': [ 'web.assets_backend': [
# 'menu_control_center/static/src/js/login.js', '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, 'installable': True,
'application': True, 'application': True,
'auto_install': False, 'auto_install': False,

View File

@ -8,9 +8,21 @@ class IrHttp(models.AbstractModel):
def session_info(self): def session_info(self):
info = super().session_info() info = super().session_info()
info["master_background_url"] = False
info["master_glass_effect"] = False
active_master = http.request.session.get("active_master") active_master = http.request.session.get("active_master")
if active_master: if active_master:
info['user_context']['active_master'] = 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: else:
info['user_context']['active_master'] = '' info['user_context']['active_master'] = ''
return info return info

View File

@ -9,6 +9,9 @@ class MasterControl(models.Model):
sequence = fields.Integer() sequence = fields.Integer()
name = fields.Char(string='Master Name', required=True) name = fields.Char(string='Master Name', required=True)
code = fields.Char(string='Code', 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( user_ids = fields.Many2many(
'res.users', 'res.users',
'master_control_res_users_rel', 'master_control_res_users_rel',
@ -28,12 +31,45 @@ class MasterControl(models.Model):
string='Sub Menus', string='Sub Menus',
domain=[('menu_id.parent_id', '!=', False)], 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( allowed_menu_ids = fields.Many2many(
'ir.ui.menu', 'ir.ui.menu',
compute='_compute_allowed_menu_ids', compute='_compute_allowed_menu_ids',
string='Allowed Menus', 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 = [ _sql_constraints = [
('master_control_code_unique', 'unique(code)', 'Master code must be unique.'), ('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): def _get_all_menus_sql(self):
self.env.cr.execute(""" self.env.cr.execute("""
WITH RECURSIVE menu_tree AS ( WITH RECURSIVE menu_tree AS (
SELECT id, parent_id SELECT id, parent_id, sequence
FROM ir_ui_menu FROM ir_ui_menu
WHERE parent_id IS NULL WHERE parent_id IS NULL
AND active = true AND active = true
UNION ALL UNION ALL
SELECT menu.id, menu.parent_id SELECT menu.id, menu.parent_id, menu.sequence
FROM ir_ui_menu menu FROM ir_ui_menu menu
JOIN menu_tree tree ON tree.id = menu.parent_id JOIN menu_tree tree ON tree.id = menu.parent_id
WHERE menu.active = true WHERE menu.active = true
) )
SELECT id, parent_id SELECT id, parent_id, sequence
FROM menu_tree FROM menu_tree
ORDER BY parent_id NULLS FIRST, id ORDER BY parent_id NULLS FIRST, sequence, id
""") """)
return self.env.cr.dictfetchall() return self.env.cr.dictfetchall()
@ -110,13 +146,14 @@ class MasterControl(models.Model):
(record.menu_line_ids | record.submenu_line_ids).unlink() (record.menu_line_ids | record.submenu_line_ids).unlink()
existing_lines = {} 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']) parent_line = existing_lines.get(main_menu['id'])
if not parent_line: if not parent_line:
parent_line = line_model.create({ parent_line = line_model.create({
'master_control_id': record.id, 'master_control_id': record.id,
'menu_id': main_menu['id'], 'menu_id': main_menu['id'],
'show_menu': True, 'show_menu': True,
'custom_order_sequence': index * 10,
}) })
existing_lines[main_menu['id']] = parent_line existing_lines[main_menu['id']] = parent_line
notification_count += 1 notification_count += 1
@ -130,6 +167,7 @@ class MasterControl(models.Model):
'menu_id': submenu['id'], 'menu_id': submenu['id'],
'show_menu': True, 'show_menu': True,
'parent_line_id': parent_line.id, 'parent_line_id': parent_line.id,
'custom_order_sequence': submenu.get('sequence') or 10,
}) })
existing_lines[submenu['id']] = True existing_lines[submenu['id']] = True
notification_count += 1 notification_count += 1
@ -178,13 +216,18 @@ class MasterControlMenuLine(models.Model):
_name = 'master.control.menu.line' _name = 'master.control.menu.line'
_description = 'Master Control Menu Line' _description = 'Master Control Menu Line'
_rec_name = 'menu_id' _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') master_control_id = fields.Many2one('master.control', required=True, ondelete='cascade')
menu_id = fields.Many2one('ir.ui.menu', string='Menu', required=True) 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) 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_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') 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 = [ _sql_constraints = [
('master_control_menu_unique', 'unique(master_control_id, menu_id)', 'Menu already exists for this master control.'), ('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)], '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 @api.model_create_multi
def create(self, vals_list): def create(self, vals_list):
records = super().create(vals_list) records = super().create(vals_list)

View File

@ -10,14 +10,17 @@ class IrUiMenu(models.Model):
def _get_active_master_code(self): def _get_active_master_code(self):
return (request.session.get('active_master') if request else False) or self.env.context.get('active_master') 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 @api.model
@tools.ormcache('frozenset(self.env.user.groups_id.ids)', 'debug', 'self.env.uid', 'self._get_active_master_code() or ""') @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): def _visible_menu_ids(self, debug=False):
context = {'ir.ui.menu.full_list': True} context = {'ir.ui.menu.full_list': True}
menus = self.with_context(context).search_fetch([], ['action', 'parent_id']).sudo() menus = self.with_context(context).search_fetch([], ['action', 'parent_id']).sudo()
active_master_code = self._get_active_master_code() master_control = self._get_active_master_control()
master_control = self.env['master.control'].sudo().search([('code', '=', active_master_code)], limit=1) if active_master_code else False
group_ids = set(self.env.user._get_group_ids()) group_ids = set(self.env.user._get_group_ids())
if not debug: if not debug:
@ -40,14 +43,104 @@ class IrUiMenu(models.Model):
visible = self._process_action_menus(menus) visible = self._process_action_menus(menus)
return set(visible.ids) 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 @api.model
def load_menus_root(self): def load_menus_root(self):
root = super().load_menus_root() root = super().load_menus_root()
visible_ids = self._visible_menu_ids(request.session.debug if request else False) 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'] = [ root['children'] = [
child for child in root.get('children', []) child for child in root.get('children', [])
if child['id'] in visible_ids 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'] = [ root['all_menu_ids'] = [
menu_id for menu_id in root.get('all_menu_ids', []) menu_id for menu_id in root.get('all_menu_ids', [])
if menu_id in visible_ids if menu_id in visible_ids
@ -58,6 +151,9 @@ class IrUiMenu(models.Model):
def load_menus(self, debug): def load_menus(self, debug):
all_menus = super().load_menus(debug) all_menus = super().load_menus(debug)
visible_ids = self._visible_menu_ids(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'])} filtered_menus = {'root': dict(all_menus['root'])}
for menu_id, menu_data in all_menus.items(): 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', []) child_id for child_id in filtered_menus['root'].get('children', [])
if child_id in filtered_menus 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()): for menu_id, menu_data in list(filtered_menus.items()):
if menu_id == 'root': if menu_id == 'root':
@ -79,6 +179,14 @@ class IrUiMenu(models.Model):
if child_id in filtered_menus 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 return filtered_menus
def _get_hidden_menu_ids(self, master_control): def _get_hidden_menu_ids(self, master_control):
@ -170,4 +278,4 @@ class IrUiMenu(models.Model):
# Clear caches # Clear caches
self.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> </field>
</record> </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"> <record id="view_master_control_list" model="ir.ui.view">
<field name="name">master.control.list</field> <field name="name">master.control.list</field>
<field name="model">master.control</field> <field name="model">master.control</field>
@ -36,6 +50,29 @@
<field name="name"/> <field name="name"/>
<field name="code"/> <field name="code"/>
</group> </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> <notebook>
<page string="Users"> <page string="Users">
@ -60,6 +97,17 @@
</list> </list>
</field> </field>
</page> </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"> <page string="Sub Menus">
<field name="submenu_line_ids" widget="one2many_search"> <field name="submenu_line_ids" widget="one2many_search">
<list editable="bottom" create="0" default_group_by="parent_menu_id"> <list editable="bottom" create="0" default_group_by="parent_menu_id">
@ -69,6 +117,19 @@
</list> </list>
</field> </field>
</page> </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> </notebook>
</sheet> </sheet>
</form> </form>

View File

@ -10,8 +10,6 @@ class OfferLetterResponseController(http.Controller):
raise request.not_found() raise request.not_found()
if not token or offer_letter.response_token != token: if not token or offer_letter.response_token != token:
raise request.not_found() raise request.not_found()
if not offer_letter._is_latest_offer():
raise request.not_found()
return offer_letter return offer_letter
@http.route('/offer_letters/respond/<int:offer_id>/accept', type='http', auth='public') @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 from odoo.tools.safe_eval import safe_eval
import json import json
import calendar import calendar
import secrets
class DefaultDictroll(defaultdict): class DefaultDictroll(defaultdict):
def get(self, key, default=None): 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) pay_struct_id = fields.Many2one('hr.payroll.structure', string="Salary Structure", required=True)
manager_id = fields.Many2one('hr.employee', string='Manager') manager_id = fields.Many2one('hr.employee', string='Manager')
rejection_reason = fields.Char() 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 @api.model
def _default_terms(self): 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') 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}" 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}" 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( render_results = template.with_context(
offer_accept_url=accept_url, offer_accept_url=accept_url,
offer_reject_url=reject_url, offer_reject_url=reject_url,

View File

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

View File

@ -1,2 +1,3 @@
from . import project_dashboard_controller from . import project_dashboard_controller
from . import portfolio_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}"/> <field name="params" eval="{'model': 'project.project', 'res_id': False}"/>
</record> </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 --> <!-- Menu Item for Dashboard -->
<menuitem id="menu_project_dashboard" <menuitem id="menu_project_dashboard"
name="Project Dashboard" name="Project Dashboard"
@ -17,5 +23,12 @@
groups="project.group_project_manager" groups="project.group_project_manager"
active="0" active="0"
sequence="10"/> 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> </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 { .o_form_view .o_inner_group {
margin-top: 0px !important; margin-top: 0px !important;
padding-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> <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" <record id="hr_applicant_view_form_ui_customize"
model="ir.ui.view"> model="ir.ui.view">

View File

@ -23,11 +23,11 @@
</div> </div>
<div> <div>
<h2 class="fw-bold text-dark mb-1"> <h2 class="fw-bold text-dark mb-1">
<field name="partner_name" nolabel="0"/> <field name="partner_name" nolabel="0" placeholder="Candidate Name"/>
</h2> </h2>
<div class="d-flex align-items-center g-1"> <div class="d-flex align-items-center g-1">
<i class="fa fa-id-badge me-3 text-primary"/> <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>
<div class="text-muted fs-5 mb-2"> <div class="text-muted fs-5 mb-2">
<field name="type_id" nolabel="1" readonly="1"/> <field name="type_id" nolabel="1" readonly="1"/>
@ -42,11 +42,11 @@
<div class="d-flex flex-column gap-2"> <div class="d-flex flex-column gap-2">
<div class="d-flex align-items-center g-1 mb-2"> <div class="d-flex align-items-center g-1 mb-2">
<i class="fa fa-envelope me-3 text-primary"/> <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>
<div class="d-flex align-items-center g-1"> <div class="d-flex align-items-center g-1">
<i class="fa fa-mobile me-3 text-primary"/> <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>
<div class="d-flex align-items-center g-1" invisible="not middle_name"> <div class="d-flex align-items-center g-1" invisible="not middle_name">
<i class="fa fa-user me-3 text-primary"/> <i class="fa fa-user me-3 text-primary"/>
@ -58,7 +58,7 @@
</div> </div>
<div class="d-flex align-items-center g-1"> <div class="d-flex align-items-center g-1">
<i class="fa fa-linkedin me-3 text-primary"/> <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> </div>
</div> </div>

View File

@ -24,8 +24,6 @@ class SurveyInvite(models.TransientModel):
survey_line = self.applicant_id.survey_line_ids.filtered( survey_line = self.applicant_id.survey_line_ids.filtered(
lambda line: line.survey_id == self.survey_id lambda line: line.survey_id == self.survey_id
)[:1] )[:1]
import pdb
pdb.set_trace()
latest_answer = survey_answers.sorted('create_date')[-1] latest_answer = survey_answers.sorted('create_date')[-1]
survey_url = werkzeug.urls.url_join( survey_url = werkzeug.urls.url_join(
self.survey_id.get_base_url(), 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. # Part of Odoo. See LICENSE file for full copyright and licensing details.
import warnings import warnings
@ -70,6 +602,32 @@ class WebsiteJobHrRecruitment(WebsiteHrRecruitment):
# Browse jobs as superuser, because address is restricted # Browse jobs as superuser, because address is restricted
jobs = details[0].get('results', Jobs).sudo() 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): def sort(records_list, field_name):
""" Sort records in the given collection according to the given """ Sort records in the given collection according to the given
field name, alphabetically. None values instead of records are field name, alphabetically. None values instead of records are
@ -307,28 +865,49 @@ class WebsiteJobHrRecruitment(WebsiteHrRecruitment):
locations[location.id] = location.location_name locations[location.id] = location.location_name
return locations return locations
@http.route('/hr_recruitment_extended/fetch_preferred_skills', type='json', auth="public", website=True) # @http.route('/hr_recruitment_extended/fetch_preferred_skills', type='json', auth="public", website=True)
def fetch_preferred_skills(self, skill_ids,fetch_others=False): # 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 = {} skills = {}
Skill = http.request.env['hr.skill'].sudo() Skill = http.request.env['hr.skill'].sudo()
SkillLevel = http.request.env['hr.skill.level'].sudo()
domain = []
if fetch_others: if fetch_others:
for skill in Skill.search([('id','not in',skill_ids)]): domain = [('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: else:
for skill in Skill.browse(skill_ids): domain = [('id', 'in', skill_ids)]
levels = SkillLevel.search([('skill_type_id', '=', skill.skill_type_id.id)],order='sequence')
skills[skill.id] = { for skill in Skill.search(domain):
'id': skill.id, skills[skill.id] = {
'name': skill.name, 'id': skill.id,
'levels': [{'id': lvl.id, 'name': lvl.name, 'percentage': lvl.level_progress, 'sequence': lvl.sequence} for lvl in levels] 'name': skill.name,
} }
return skills return skills
@ -476,27 +1055,93 @@ class WebsiteJobHrRecruitment(WebsiteHrRecruitment):
'resume_name': resume_name, '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: primary_skill_ids = request.httprequest.form.getlist(
skills = None '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 values['partner_name'] = partner_name
if partner_phone: if partner_phone:
@ -527,5 +1172,4 @@ class WebsiteJobHrRecruitment(WebsiteHrRecruitment):
data['record']['candidate_id'] = candidate.id data['record']['candidate_id'] = candidate.id
data['record']['type_id'] = candidate.type_id.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="inherit_id" ref="hr_recruitment_extended.view_job_recruitment_kanban"/>
<field name="arch" type="xml"> <field name="arch" type="xml">
<xpath expr="//kanban" position="attributes"> <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>
<xpath expr="//t[@t-name='card']/div" position="before"> <xpath expr="//t[@t-name='card']/div" position="before">
<field name="website_published" invisible="1"/> <field name="website_published" invisible="1"/>