ALL updates regrading the hrms, ats and pmt
This commit is contained in:
parent
29af1ebf29
commit
064bd90c58
|
|
@ -14,7 +14,6 @@ class EmployeePayslipDownloadWizard(models.TransientModel):
|
|||
'hr.employee',
|
||||
required=True,
|
||||
default=lambda self: self.env.user.employee_id.id,
|
||||
readonly=True,
|
||||
)
|
||||
download_type = fields.Selection(
|
||||
selection=[
|
||||
|
|
@ -39,6 +38,13 @@ class EmployeePayslipDownloadWizard(models.TransientModel):
|
|||
string='Available Payslips',
|
||||
compute='_compute_payslip_count',
|
||||
)
|
||||
is_hr_manager = fields.Boolean(compute="_compute_is_hr_manager")
|
||||
|
||||
def _compute_is_hr_manager(self):
|
||||
for rec in self:
|
||||
import pdb
|
||||
pdb.set_trace()
|
||||
rec.is_hr_manager = self.env.user.has_group('hr.group_hr_manager')
|
||||
|
||||
@api.onchange('download_type', 'period_id')
|
||||
def _onchange_download_type_period_id(self):
|
||||
|
|
|
|||
|
|
@ -18,9 +18,9 @@ class ITTaxStatementWizard(models.TransientModel):
|
|||
contract_id = fields.Many2one('hr.contract', related='employee_id.contract_id', required=True)
|
||||
currency_id = fields.Many2one('res.currency', related='employee_id.company_id.currency_id')
|
||||
|
||||
period_id = fields.Many2one('payroll.period', required=True)
|
||||
period_line = fields.Many2one('payroll.period.line',
|
||||
domain="[('period_id', '=', period_id), ('to_date', '<', fields.Date.today())]")
|
||||
period_id = fields.Many2one('payroll.period', required=True)
|
||||
period_line = fields.Many2one('payroll.period.line',
|
||||
domain="[('period_id', '=', period_id), ('to_date', '<', fields.Date.today())]")
|
||||
|
||||
# Taxpayer profile
|
||||
taxpayer_name = fields.Char(related='employee_id.name')
|
||||
|
|
@ -95,33 +95,38 @@ class ITTaxStatementWizard(models.TransientModel):
|
|||
('old', 'Old Regime'),
|
||||
('new', 'New Regime')
|
||||
], string="Beneficial Regime", readonly=True)
|
||||
is_hr_manager = fields.Boolean(compute="_compute_is_hr_manager")
|
||||
|
||||
def _get_age_category(self, age):
|
||||
if age < 60:
|
||||
return 'below_60'
|
||||
elif age < 80:
|
||||
return '60_to_80'
|
||||
return 'above_80'
|
||||
|
||||
def _get_effective_period_start(self):
|
||||
self.ensure_one()
|
||||
period_start = self.period_id.from_date if self.period_id else False
|
||||
if not period_start:
|
||||
return False
|
||||
if self.emp_doj and self.period_id.to_date and self.period_id.from_date <= self.emp_doj <= self.period_id.to_date:
|
||||
return max(period_start, self.emp_doj.replace(day=1))
|
||||
return period_start
|
||||
|
||||
def _get_effective_period_lines(self):
|
||||
self.ensure_one()
|
||||
if not self.period_id:
|
||||
return self.env['payroll.period.line']
|
||||
|
||||
period_lines = self.period_id.period_line_ids.sorted('from_date')
|
||||
effective_start = self._get_effective_period_start()
|
||||
if not effective_start:
|
||||
return period_lines
|
||||
return period_lines.filtered(lambda line: line.to_date and line.to_date >= effective_start)
|
||||
def _compute_is_hr_manager(self):
|
||||
for rec in self:
|
||||
rec.is_hr_manager = self.env.user.has_group('hr.group_hr_manager')
|
||||
|
||||
def _get_age_category(self, age):
|
||||
if age < 60:
|
||||
return 'below_60'
|
||||
elif age < 80:
|
||||
return '60_to_80'
|
||||
return 'above_80'
|
||||
|
||||
def _get_effective_period_start(self):
|
||||
self.ensure_one()
|
||||
period_start = self.period_id.from_date if self.period_id else False
|
||||
if not period_start:
|
||||
return False
|
||||
if self.emp_doj and self.period_id.to_date and self.period_id.from_date <= self.emp_doj <= self.period_id.to_date:
|
||||
return max(period_start, self.emp_doj.replace(day=1))
|
||||
return period_start
|
||||
|
||||
def _get_effective_period_lines(self):
|
||||
self.ensure_one()
|
||||
if not self.period_id:
|
||||
return self.env['payroll.period.line']
|
||||
|
||||
period_lines = self.period_id.period_line_ids.sorted('from_date')
|
||||
effective_start = self._get_effective_period_start()
|
||||
if not effective_start:
|
||||
return period_lines
|
||||
return period_lines.filtered(lambda line: line.to_date and line.to_date >= effective_start)
|
||||
|
||||
def _find_applicable_slab(self, regime, period_id, age, residence_type):
|
||||
"""Find the applicable tax slab without forcing both regimes to exist."""
|
||||
|
|
@ -136,7 +141,7 @@ class ITTaxStatementWizard(models.TransientModel):
|
|||
('residence_type', '=', 'both')
|
||||
], limit=1)
|
||||
|
||||
def _get_applicable_slab(self, regime, period_id, age, residence_type):
|
||||
def _get_applicable_slab(self, regime, period_id, age, residence_type):
|
||||
"""Get the applicable tax slab based on regime, age, and residence type"""
|
||||
age_category = self._get_age_category(age)
|
||||
slab_master = self._find_applicable_slab(regime, period_id, age, residence_type)
|
||||
|
|
@ -145,21 +150,21 @@ class ITTaxStatementWizard(models.TransientModel):
|
|||
"No tax slab found for %s Regime with Age Category: %s and Residence Type: %s"
|
||||
) % (regime.capitalize(), age_category.replace('_', ' ').title(), residence_type))
|
||||
|
||||
return slab_master
|
||||
|
||||
@api.onchange('employee_id', 'period_id')
|
||||
def _onchange_employee_id_period_id(self):
|
||||
domain_by_record = {}
|
||||
for rec in self:
|
||||
domain = [('period_id', '=', rec.period_id.id), ('to_date', '<', fields.Date.today())] if rec.period_id else []
|
||||
if rec.emp_doj:
|
||||
domain.append(('to_date', '>=', rec.emp_doj.replace(day=1)))
|
||||
|
||||
if rec.period_line and rec.period_line not in rec._get_effective_period_lines():
|
||||
rec.period_line = False
|
||||
domain_by_record[rec.id] = domain
|
||||
if len(self) == 1:
|
||||
return {'domain': {'period_line': domain_by_record.get(self.id, [])}}
|
||||
return slab_master
|
||||
|
||||
@api.onchange('employee_id', 'period_id')
|
||||
def _onchange_employee_id_period_id(self):
|
||||
domain_by_record = {}
|
||||
for rec in self:
|
||||
domain = [('period_id', '=', rec.period_id.id), ('to_date', '<', fields.Date.today())] if rec.period_id else []
|
||||
if rec.emp_doj:
|
||||
domain.append(('to_date', '>=', rec.emp_doj.replace(day=1)))
|
||||
|
||||
if rec.period_line and rec.period_line not in rec._get_effective_period_lines():
|
||||
rec.period_line = False
|
||||
domain_by_record[rec.id] = domain
|
||||
if len(self) == 1:
|
||||
return {'domain': {'period_line': domain_by_record.get(self.id, [])}}
|
||||
|
||||
def _get_standard_deduction(self, regime, slab_master=False):
|
||||
if slab_master:
|
||||
|
|
@ -329,7 +334,7 @@ class ITTaxStatementWizard(models.TransientModel):
|
|||
|
||||
return list(grouped.values())
|
||||
|
||||
def fetch_salary_components(self):
|
||||
def fetch_salary_components(self):
|
||||
"""fetch salary components from payroll data"""
|
||||
for rec in self:
|
||||
data = {
|
||||
|
|
@ -345,10 +350,10 @@ class ITTaxStatementWizard(models.TransientModel):
|
|||
}
|
||||
if not rec.employee_id or not rec.contract_id or not rec.period_id or not rec.period_line:
|
||||
return data
|
||||
period_lines = rec._get_effective_period_lines()
|
||||
|
||||
for line in period_lines:
|
||||
components = rec._get_salary_components_for_period_line(line)
|
||||
period_lines = rec._get_effective_period_lines()
|
||||
|
||||
for line in period_lines:
|
||||
components = rec._get_salary_components_for_period_line(line)
|
||||
if line.from_date and rec.period_line.from_date and line.from_date <= rec.period_line.from_date:
|
||||
data['basic_salary']['actual'].append(components['basic_salary'])
|
||||
data['hra_salary']['actual'].append(components['hra_salary'])
|
||||
|
|
@ -412,7 +417,7 @@ class ITTaxStatementWizard(models.TransientModel):
|
|||
)
|
||||
rec.standard_deduction = rec._get_standard_deduction(rec.tax_regime, slab_master)
|
||||
|
||||
def fetch_deduction_components(self):
|
||||
def fetch_deduction_components(self):
|
||||
for rec in self:
|
||||
data = {
|
||||
'professional_tax': {'actual': [], 'projected': []},
|
||||
|
|
@ -421,12 +426,12 @@ class ITTaxStatementWizard(models.TransientModel):
|
|||
if not rec.employee_id or not rec.contract_id or not rec.period_id or not rec.period_line:
|
||||
return data
|
||||
|
||||
for line in rec._get_effective_period_lines():
|
||||
rule_amounts = rec._get_rule_amounts_for_period_line(line, ['PT', 'PFE'])
|
||||
bucket = 'actual' if line.from_date <= rec.period_line.from_date else 'projected'
|
||||
data['professional_tax'][bucket].append(rule_amounts['PT'])
|
||||
data['nps_employer_contribution'][bucket].append(rule_amounts['PFE'])
|
||||
return data
|
||||
for line in rec._get_effective_period_lines():
|
||||
rule_amounts = rec._get_rule_amounts_for_period_line(line, ['PT', 'PFE'])
|
||||
bucket = 'actual' if line.from_date <= rec.period_line.from_date else 'projected'
|
||||
data['professional_tax'][bucket].append(rule_amounts['PT'])
|
||||
data['nps_employer_contribution'][bucket].append(rule_amounts['PFE'])
|
||||
return data
|
||||
|
||||
|
||||
@api.onchange('employee_id')
|
||||
|
|
@ -521,6 +526,14 @@ class ITTaxStatementWizard(models.TransientModel):
|
|||
tax_with_surcharge = total_before_mr - mr
|
||||
return surcharge, mr, tax_with_surcharge
|
||||
|
||||
def fetch_current_employer_deducted_tax(self):
|
||||
for rec in self:
|
||||
payslip_ids = self.env['hr.payslip'].sudo().search([('employee_id','=',rec.employee_id.id),('state','in',['done','paid']),('date_from','>=',rec.period_id.from_date),('date_to','<=',rec.period_id.to_date)])
|
||||
amount_deducted = 0.0
|
||||
for payslip in payslip_ids:
|
||||
amount_deducted += sum(payslip.line_ids.filtered(lambda l:l.salary_rule_id.code == 'TDS').mapped('amount'))
|
||||
|
||||
return amount_deducted
|
||||
def _compute_tax_old_regime(self, taxable, slab_master=False):
|
||||
# Get applicable slab
|
||||
slab_master = slab_master or self._get_applicable_slab(
|
||||
|
|
@ -549,6 +562,8 @@ class ITTaxStatementWizard(models.TransientModel):
|
|||
|
||||
total_tax = tax_with_surcharge + cess
|
||||
|
||||
current_employer_deducted_tax = self.fetch_current_employer_deducted_tax()
|
||||
|
||||
return {
|
||||
'taxable_income': taxable,
|
||||
'slab_tax': slab_tax,
|
||||
|
|
@ -558,7 +573,9 @@ class ITTaxStatementWizard(models.TransientModel):
|
|||
'marginal_relief': marginal_relief,
|
||||
'tax_with_surcharge': tax_with_surcharge,
|
||||
'cess_4pct': cess,
|
||||
'total_tax': total_tax
|
||||
'total_tax': total_tax,
|
||||
'current_employer_deducted_tax': current_employer_deducted_tax,
|
||||
'balance_tax': total_tax - (-current_employer_deducted_tax)
|
||||
}
|
||||
|
||||
def _compute_tax_new_regime(self, taxable, slab_master=False):
|
||||
|
|
@ -587,6 +604,7 @@ class ITTaxStatementWizard(models.TransientModel):
|
|||
cess = tax_with_surcharge * cess_rate[0] / 100
|
||||
total_tax = tax_with_surcharge + cess
|
||||
|
||||
current_employer_deducted_tax = self.fetch_current_employer_deducted_tax()
|
||||
return {
|
||||
'taxable_income': taxable,
|
||||
'slab_tax': slab_tax,
|
||||
|
|
@ -596,7 +614,9 @@ class ITTaxStatementWizard(models.TransientModel):
|
|||
'marginal_relief': marginal_relief,
|
||||
'tax_with_surcharge': tax_with_surcharge,
|
||||
'cess_4pct': cess,
|
||||
'total_tax': total_tax
|
||||
'total_tax': total_tax,
|
||||
'current_employer_deducted_tax': current_employer_deducted_tax,
|
||||
'balance_tax': total_tax - (-current_employer_deducted_tax)
|
||||
}
|
||||
|
||||
def _compute_house_property_income(self):
|
||||
|
|
@ -759,19 +779,19 @@ class ITTaxStatementWizard(models.TransientModel):
|
|||
'target': 'current',
|
||||
}
|
||||
|
||||
def _prepare_income_tax_data(self, include_comparison=False):
|
||||
"""Prepare data for the tax statement report"""
|
||||
today = date.today()
|
||||
display_fy_start = self.period_id.from_date
|
||||
fy_end = self.period_id.to_date
|
||||
effective_fy_start = self._get_effective_period_start() or display_fy_start
|
||||
total_months = ((fy_end.year - effective_fy_start.year) * 12 +
|
||||
(fy_end.month - effective_fy_start.month) + 1)
|
||||
|
||||
line_start = self.period_line.from_date
|
||||
current_month_index = ((line_start.year - effective_fy_start.year) * 12 +
|
||||
(line_start.month - effective_fy_start.month) + 1)
|
||||
values = self._get_tax_base_values(include_comparison=include_comparison)
|
||||
def _prepare_income_tax_data(self, include_comparison=False):
|
||||
"""Prepare data for the tax statement report"""
|
||||
today = date.today()
|
||||
display_fy_start = self.period_id.from_date
|
||||
fy_end = self.period_id.to_date
|
||||
effective_fy_start = self._get_effective_period_start() or display_fy_start
|
||||
total_months = ((fy_end.year - effective_fy_start.year) * 12 +
|
||||
(fy_end.month - effective_fy_start.month) + 1)
|
||||
|
||||
line_start = self.period_line.from_date
|
||||
current_month_index = ((line_start.year - effective_fy_start.year) * 12 +
|
||||
(line_start.month - effective_fy_start.month) + 1)
|
||||
values = self._get_tax_base_values(include_comparison=include_comparison)
|
||||
salary_components_data = values['salary_components_data']
|
||||
annual_gross_salary = values['annual_gross_salary']
|
||||
gross_salary_actual = values['gross_salary_actual']
|
||||
|
|
@ -806,16 +826,16 @@ class ITTaxStatementWizard(models.TransientModel):
|
|||
|
||||
# Prepare data structure matching screenshot format
|
||||
# Financial year (period_id)
|
||||
display_fy_start = self.period_id.from_date
|
||||
fy_end = self.period_id.to_date
|
||||
effective_fy_start = self._get_effective_period_start() or display_fy_start
|
||||
total_months = ((fy_end.year - effective_fy_start.year) * 12 +
|
||||
(fy_end.month - effective_fy_start.month) + 1)
|
||||
|
||||
# Current month (period_line)
|
||||
line_start = self.period_line.from_date
|
||||
current_month_index = ((line_start.year - effective_fy_start.year) * 12 +
|
||||
(line_start.month - effective_fy_start.month) + 1)
|
||||
display_fy_start = self.period_id.from_date
|
||||
fy_end = self.period_id.to_date
|
||||
effective_fy_start = self._get_effective_period_start() or display_fy_start
|
||||
total_months = ((fy_end.year - effective_fy_start.year) * 12 +
|
||||
(fy_end.month - effective_fy_start.month) + 1)
|
||||
|
||||
# Current month (period_line)
|
||||
line_start = self.period_line.from_date
|
||||
current_month_index = ((line_start.year - effective_fy_start.year) * 12 +
|
||||
(line_start.month - effective_fy_start.month) + 1)
|
||||
tax_result['roundoff_taxable_income'] = float(round(tax_result["taxable_income"] / 10) * 10)
|
||||
birthday = self.employee_id.birthday
|
||||
if birthday:
|
||||
|
|
@ -835,8 +855,8 @@ class ITTaxStatementWizard(models.TransientModel):
|
|||
'total': total,
|
||||
})
|
||||
data = {
|
||||
'financial_year': f"{display_fy_start.year}-{fy_end.year}",
|
||||
'assessment_year': fy_end.year + 1,
|
||||
'financial_year': f"{display_fy_start.year}-{fy_end.year}",
|
||||
'assessment_year': fy_end.year,
|
||||
'report_time': today.strftime('%d-%m-%Y %H:%M'),
|
||||
'user': 'ESS',
|
||||
'emp_code': self.employee_id.employee_id,
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@
|
|||
<t t-set="rebate_87a" t-value="'{:,.0f}'.format(tax_computation.get('rebate_87a', 0))"/>
|
||||
<t t-set="cess" t-value="'{:,.0f}'.format(tax_computation.get('cess_4pct', 0))"/>
|
||||
<t t-set="total_tax" t-value="'{:,.0f}'.format(tax_computation.get('total_tax', 0))"/>
|
||||
<t t-set="current_employer_deducted_tax" t-value="'{:,.0f}'.format(tax_computation.get('current_employer_deducted_tax', 0))"/>
|
||||
<t t-set="balance_tax" t-value="'{:,.0f}'.format(tax_computation.get('balance_tax', 0))"/>
|
||||
<t t-set="report_time" t-value="data.get('report_time', '')"/>
|
||||
|
||||
<!-- Header -->
|
||||
|
|
@ -378,7 +380,7 @@
|
|||
<tr>
|
||||
<td>Less: Tax deducted current employer (up to previous month)</td>
|
||||
<td style="text-align: right;">0</td>
|
||||
<td style="text-align: right;">0</td>
|
||||
<td style="text-align: right;" t-esc="current_employer_deducted_tax"/>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Less: Tax deducted from previous Employer / Self Tax Paid</td>
|
||||
|
|
@ -389,7 +391,7 @@
|
|||
<tr style="font-weight: bold;">
|
||||
<td>Balance Tax for the year</td>
|
||||
<td style="text-align: right;">0</td>
|
||||
<td style="text-align: right;" t-esc="total_tax"/>
|
||||
<td style="text-align: right;" t-esc="balance_tax"/>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Less: Adhoc tax deducted in Off-Cycle in current month</td>
|
||||
|
|
@ -399,7 +401,7 @@
|
|||
<tr style="font-weight: bold;">
|
||||
<td><strong>Balance Tax</strong></td>
|
||||
<td style="text-align: right;">0</td>
|
||||
<td style="text-align: right;" t-esc="total_tax"/>
|
||||
<td style="text-align: right;" t-esc="balance_tax"/>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Tax deducted from current month salary</strong></td>
|
||||
|
|
|
|||
|
|
@ -54,14 +54,14 @@ access_nsc_interest_entry_user,nsc.interest.entry,model_nsc_interest_entry,base.
|
|||
|
||||
access_house_rent_declaration_user,access.house.rent.declaration.user,model_house_rent_declaration,base.group_user,1,1,1,1
|
||||
|
||||
access_it_tax_statement,it.tax.statement,model_it_tax_statement,base.group_user,1,0,0,0
|
||||
access_it_tax_statement_wizard,it.tax.statement.wizard,model_it_tax_statement_wizard,base.group_user,1,0,0,0
|
||||
access_employee_payslip_download_wizard,employee.payslip.download.wizard,model_employee_payslip_download_wizard,base.group_user,1,1,1,0
|
||||
|
||||
access_it_tax_statement_manager,it.tax.statement,model_it_tax_statement,hr.group_hr_manager,1,1,1,1
|
||||
access_it_tax_statement,it.tax.statement,model_it_tax_statement,base.group_user,1,0,0,0
|
||||
access_it_tax_statement_wizard,it.tax.statement.wizard,model_it_tax_statement_wizard,base.group_user,1,1,1,0
|
||||
access_employee_payslip_download_wizard,employee.payslip.download.wizard,model_employee_payslip_download_wizard,base.group_user,1,1,1,0
|
||||
|
||||
access_it_tax_statement_manager,it.tax.statement,model_it_tax_statement,hr.group_hr_manager,1,1,1,1
|
||||
access_it_tax_statement_wizard_manager,it.tax.statement.wizard,model_it_tax_statement_wizard,hr.group_hr_manager,1,1,1,1
|
||||
|
||||
|
||||
access_it_slab_master,it.slab.master,model_it_slab_master,base.group_user,1,1,1,1
|
||||
access_it_slab_master_rules,it.slab.master.rules,model_it_slab_master_rules,base.group_user,1,1,1,1
|
||||
access_it_sur_charge_rules,it.sur.charge.rules.user,model_it_sur_charge_rules,base.group_user,1,1,1,1
|
||||
access_it_sur_charge_rules,it.sur.charge.rules.user,model_it_sur_charge_rules,base.group_user,1,1,1,1
|
||||
|
|
|
|||
|
|
|
@ -21,7 +21,8 @@
|
|||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="employee_id" options="{'no_edit': True, 'no_create': True}"/>
|
||||
<field name="is_hr_manager" invisible="1"/>
|
||||
<field name="employee_id" readonly="not is_hr_manager" options="{'no_edit': True, 'no_create': True}"/>
|
||||
<field name="download_type" widget="radio" options="{'horizontal': true}"/>
|
||||
</group>
|
||||
<group>
|
||||
|
|
|
|||
|
|
@ -43,7 +43,8 @@
|
|||
<field name="currency_id" invisible="1"/>
|
||||
<field name="is_general_tax_statement" invisible="1"/>
|
||||
<group>
|
||||
<field name="employee_id" options="{'no_edit': True, 'no_create': True}"/>
|
||||
<field name="is_hr_manager" invisible="0" force_save="1"/>
|
||||
<field name="employee_id" readonly="not is_hr_manager" options="{'no_edit': True, 'no_create': True}"/>
|
||||
<field name="contract_id" readonly="1" force_save="1" invisible="0"/>
|
||||
</group>
|
||||
<group>
|
||||
|
|
@ -88,11 +89,13 @@
|
|||
<field name="res_model">it.tax.statement.wizard</field>
|
||||
<field name="path">tax-statement</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="domain">[("activity_ids.active", "in", [True, False])]</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create a new employment type
|
||||
</p>
|
||||
</field>
|
||||
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_it_tax_statement_root" name="IT Tax Statement"
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
'resource',
|
||||
'portal',
|
||||
'digest',
|
||||
'website',
|
||||
],
|
||||
'description': """
|
||||
Helpdesk - Ticket Management App
|
||||
|
|
@ -60,6 +61,7 @@ Features:
|
|||
'views/mail_activity_views.xml',
|
||||
'views/helpdesk_templates.xml',
|
||||
'views/helpdesk_menus.xml',
|
||||
'views/website_form.xml',
|
||||
'wizard/helpdesk_stage_delete_views.xml',
|
||||
],
|
||||
'demo': ['data/helpdesk_demo.xml'],
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from odoo import http
|
|||
from odoo.exceptions import AccessError, MissingError, UserError
|
||||
from odoo.http import request
|
||||
from odoo.tools.translate import _
|
||||
from odoo.tools import groupby as groupbyelem
|
||||
from odoo.tools import groupby as groupbyelem, plaintext2html
|
||||
from odoo.addons.portal.controllers import portal
|
||||
from odoo.addons.portal.controllers.portal import pager as portal_pager
|
||||
from odoo.osv.expression import AND, FALSE_DOMAIN
|
||||
|
|
@ -32,7 +32,22 @@ class CustomerPortal(portal.CustomerPortal):
|
|||
return values
|
||||
|
||||
def _prepare_helpdesk_tickets_domain(self):
|
||||
return []
|
||||
partner = request.env.user.partner_id
|
||||
return [('message_partner_ids', 'in', [partner.id])]
|
||||
|
||||
def _get_portal_ticket_teams(self):
|
||||
return request.env['helpdesk.team'].sudo().search([
|
||||
('privacy_visibility', '=', 'portal'),
|
||||
('active', '=', True),
|
||||
], order='sequence, name')
|
||||
|
||||
def _get_team_for_ticket_type(self, ticket_type):
|
||||
teams = self._get_portal_ticket_teams()
|
||||
return (
|
||||
teams.filtered(lambda team: team.portal_ticket_type == ticket_type)[:1]
|
||||
or teams.filtered(lambda team: team.portal_ticket_type == 'general')[:1]
|
||||
or teams[:1]
|
||||
)
|
||||
|
||||
def _ticket_get_page_view_values(self, ticket, access_token, **kwargs):
|
||||
values = {
|
||||
|
|
@ -157,6 +172,66 @@ class CustomerPortal(portal.CustomerPortal):
|
|||
values = self._prepare_my_tickets_values(page, date_begin, date_end, sortby, filterby, search, groupby, search_in)
|
||||
return request.render("helpdesk.portal_helpdesk_ticket", values)
|
||||
|
||||
@http.route(['/helpdesk/new'], type='http', auth="user", website=True, methods=['GET'])
|
||||
def helpdesk_ticket_new(self, **kw):
|
||||
teams = self._get_portal_ticket_teams()
|
||||
ticket_teams = request.env['helpdesk.team'].sudo().search([
|
||||
('use_website_helpdesk_form', '=', True),
|
||||
'|',
|
||||
('company_id', '=', False),
|
||||
('company_id', 'in', [request.env.company.id])
|
||||
])
|
||||
|
||||
selection_dict = dict(
|
||||
request.env['helpdesk.team']._fields['portal_ticket_type'].selection
|
||||
)
|
||||
|
||||
used_types = list(set(ticket_teams.mapped('portal_ticket_type')))
|
||||
|
||||
ticket_types = [
|
||||
(key, selection_dict[key])
|
||||
for key in used_types
|
||||
if key in selection_dict
|
||||
]
|
||||
return request.render("helpdesk.portal_helpdesk_ticket_new", {
|
||||
'page_name': 'ticket',
|
||||
'teams': teams,
|
||||
'default_category': ticket_types[0][0],
|
||||
'default_company': request.env.company,
|
||||
'ticket_types': ticket_types,
|
||||
'ticket_teams': ticket_teams,
|
||||
'default_partner': request.env.user.partner_id,
|
||||
'error': kw.get('error'),
|
||||
})
|
||||
|
||||
@http.route(['/helpdesk/new'], type='http', auth="user", website=True, methods=['POST'])
|
||||
def helpdesk_ticket_create(self, **post):
|
||||
ticket_type = post.get('portal_ticket_type') or 'general'
|
||||
team = request.env['helpdesk.team'].sudo().browse(int(post.get('helpdesk_team'))) if post.get('helpdesk_team').isnumeric() else self._get_team_for_ticket_type(ticket_type)
|
||||
partner = request.env.user.partner_id
|
||||
|
||||
if not team:
|
||||
return request.redirect('/helpdesk/new?error=no_team')
|
||||
|
||||
subject = (post.get('name') or '').strip()
|
||||
description = (post.get('description') or '').strip()
|
||||
if not subject or not description:
|
||||
return request.redirect('/helpdesk/new?error=missing')
|
||||
|
||||
ticket = request.env['helpdesk.ticket'].sudo().create({
|
||||
'name': subject,
|
||||
'description': plaintext2html(description),
|
||||
'portal_ticket_type': ticket_type,
|
||||
'team_id': team.id,
|
||||
'partner_id': partner.id,
|
||||
'partner_name': partner.name,
|
||||
'partner_email': post.get('partner_email') or partner.email,
|
||||
'partner_phone': post.get('partner_phone') or partner.phone,
|
||||
# 'company_id': team.company_id,
|
||||
})
|
||||
ticket.message_subscribe(partner_ids=partner.ids)
|
||||
return request.redirect('/my/ticket/%s/%s?created=1' % (ticket.id, ticket.access_token))
|
||||
|
||||
@http.route([
|
||||
"/helpdesk/ticket/<int:ticket_id>",
|
||||
"/helpdesk/ticket/<int:ticket_id>/<access_token>",
|
||||
|
|
@ -170,6 +245,8 @@ class CustomerPortal(portal.CustomerPortal):
|
|||
return request.redirect('/my')
|
||||
|
||||
values = self._ticket_get_page_view_values(ticket_sudo, access_token, **kw)
|
||||
values['ticket_created'] = kw.get('created')
|
||||
values['ticket_reopened'] = kw.get('reopened')
|
||||
return request.render("helpdesk.tickets_followup", values)
|
||||
|
||||
@http.route([
|
||||
|
|
@ -195,3 +272,18 @@ class CustomerPortal(portal.CustomerPortal):
|
|||
ticket_sudo.with_context(mail_create_nosubscribe=True).message_post(body=body, message_type='comment', subtype_xmlid='mail.mt_note')
|
||||
|
||||
return request.redirect('/my/ticket/%s/%s?ticket_closed=1' % (ticket_id, access_token or ''))
|
||||
|
||||
@http.route([
|
||||
'/my/ticket/rerequest/<int:ticket_id>',
|
||||
'/my/ticket/rerequest/<int:ticket_id>/<access_token>',
|
||||
], type='http', auth="public", website=True)
|
||||
def ticket_rerequest(self, ticket_id=None, access_token=None, **kw):
|
||||
try:
|
||||
ticket_sudo = self._document_check_access('helpdesk.ticket', ticket_id, access_token)
|
||||
except (AccessError, MissingError):
|
||||
return request.redirect('/my')
|
||||
|
||||
if ticket_sudo.stage_id.fold:
|
||||
ticket_sudo.action_portal_rerequest()
|
||||
|
||||
return request.redirect('/my/ticket/%s/%s?reopened=1' % (ticket_id, access_token or ticket_sudo.access_token or ''))
|
||||
|
|
|
|||
|
|
@ -1,42 +1,88 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
|
||||
<record id="helpdesk_team1" model="helpdesk.team">
|
||||
<field name="name">Customer Care</field>
|
||||
<field name="alias_name">customer-care</field>
|
||||
<field name="stage_ids" eval="False"/> <!-- eval=False to don't get the default stage. New stages are setted below-->
|
||||
<field name="use_sla" eval="True"/>
|
||||
<field name="member_ids" eval="[Command.link(ref('base.user_admin'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- stage "New" gets created by default with sequence 0-->
|
||||
<record id="stage_new" model="helpdesk.stage">
|
||||
<field name="name">New</field>
|
||||
<field name="sequence">0</field>
|
||||
<field name="team_ids" eval="[(4, ref('helpdesk_team1'))]"/>
|
||||
<field name="template_id" ref="helpdesk.new_ticket_request_email_template"/>
|
||||
</record>
|
||||
<record id="stage_in_progress" model="helpdesk.stage">
|
||||
<field name="name">In Progress</field>
|
||||
<field name="sequence">1</field>
|
||||
<field name="team_ids" eval="[(4, ref('helpdesk_team1'))]"/>
|
||||
</record>
|
||||
<record id="stage_on_hold" model="helpdesk.stage">
|
||||
<field name="name">On Hold</field>
|
||||
<field name="sequence">2</field>
|
||||
<field name="team_ids" eval="[(4, ref('helpdesk_team1'))]"/>
|
||||
</record>
|
||||
<record id="stage_solved" model="helpdesk.stage">
|
||||
<field name="name">Solved</field>
|
||||
<field name="team_ids" eval="[(4, ref('helpdesk_team1'))]"/>
|
||||
<field name="sequence">3</field>
|
||||
<field name="fold" eval="True"/>
|
||||
</record>
|
||||
<record id="stage_cancelled" model="helpdesk.stage">
|
||||
<field name="name">Cancelled</field>
|
||||
<field name="sequence">4</field>
|
||||
<field name="team_ids" eval="[(4, ref('helpdesk_team1'))]"/>
|
||||
<field name="fold" eval="True"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
|
||||
<record id="helpdesk_team1" model="helpdesk.team">
|
||||
<field name="name">Customer Care</field>
|
||||
<field name="alias_name">customer-care</field>
|
||||
<field name="portal_ticket_type">general</field>
|
||||
<field name="allow_portal_ticket_closing" eval="True"/>
|
||||
<field name="stage_ids" eval="False"/> <!-- eval=False to don't get the default stage. New stages are setted below-->
|
||||
<field name="use_sla" eval="True"/>
|
||||
<field name="member_ids" eval="[Command.link(ref('base.user_admin'))]"/>
|
||||
<field name="responsible_manager_id" ref="base.user_admin"/>
|
||||
<!-- <field name="approver_id" ref="base.user_admin"/>-->
|
||||
</record>
|
||||
|
||||
<record id="helpdesk_team_technical" model="helpdesk.team">
|
||||
<field name="name">Technical Support</field>
|
||||
<field name="alias_name">technical-support</field>
|
||||
<field name="portal_ticket_type">technical</field>
|
||||
<field name="stage_ids" eval="False"/>
|
||||
<field name="privacy_visibility">portal</field>
|
||||
<field name="use_sla" eval="True"/>
|
||||
<field name="use_rating" eval="True"/>
|
||||
<field name="allow_portal_ticket_closing" eval="True"/>
|
||||
<field name="member_ids" eval="[Command.link(ref('base.user_admin'))]"/>
|
||||
<field name="responsible_manager_id" ref="base.user_admin"/>
|
||||
<!-- <field name="approver_id" ref="base.user_admin"/>-->
|
||||
</record>
|
||||
|
||||
<record id="helpdesk_team_personal" model="helpdesk.team">
|
||||
<field name="name">Personal Concern Support</field>
|
||||
<field name="alias_name">personal-concern-support</field>
|
||||
<field name="portal_ticket_type">personal</field>
|
||||
<field name="stage_ids" eval="False"/>
|
||||
<field name="privacy_visibility">portal</field>
|
||||
<field name="use_sla" eval="True"/>
|
||||
<field name="use_rating" eval="True"/>
|
||||
<field name="allow_portal_ticket_closing" eval="True"/>
|
||||
<field name="member_ids" eval="[Command.link(ref('base.user_admin'))]"/>
|
||||
<field name="responsible_manager_id" ref="base.user_admin"/>
|
||||
<!-- <field name="approver_id" ref="base.user_admin"/>-->
|
||||
</record>
|
||||
|
||||
<record id="helpdesk_team_damage" model="helpdesk.team">
|
||||
<field name="name">Damage / Asset Support</field>
|
||||
<field name="alias_name">damage-asset-support</field>
|
||||
<field name="portal_ticket_type">damage</field>
|
||||
<field name="stage_ids" eval="False"/>
|
||||
<field name="privacy_visibility">portal</field>
|
||||
<field name="use_sla" eval="True"/>
|
||||
<field name="use_rating" eval="True"/>
|
||||
<field name="allow_portal_ticket_closing" eval="True"/>
|
||||
<field name="member_ids" eval="[Command.link(ref('base.user_admin'))]"/>
|
||||
<field name="responsible_manager_id" ref="base.user_admin"/>
|
||||
<!-- <field name="approver_id" ref="base.user_admin"/>-->
|
||||
</record>
|
||||
|
||||
<!-- stage "New" gets created by default with sequence 0-->
|
||||
<record id="stage_new" model="helpdesk.stage">
|
||||
<field name="name">New</field>
|
||||
<field name="sequence">0</field>
|
||||
<field name="team_ids" eval="[(4, ref('helpdesk_team1')), (4, ref('helpdesk_team_technical')), (4, ref('helpdesk_team_personal')), (4, ref('helpdesk_team_damage'))]"/>
|
||||
<field name="template_id" ref="helpdesk.new_ticket_request_email_template"/>
|
||||
</record>
|
||||
<record id="stage_in_progress" model="helpdesk.stage">
|
||||
<field name="name">In Progress</field>
|
||||
<field name="sequence">1</field>
|
||||
<field name="team_ids" eval="[(4, ref('helpdesk_team1')), (4, ref('helpdesk_team_technical')), (4, ref('helpdesk_team_personal')), (4, ref('helpdesk_team_damage'))]"/>
|
||||
</record>
|
||||
<record id="stage_on_hold" model="helpdesk.stage">
|
||||
<field name="name">On Hold</field>
|
||||
<field name="sequence">2</field>
|
||||
<field name="team_ids" eval="[(4, ref('helpdesk_team1')), (4, ref('helpdesk_team_technical')), (4, ref('helpdesk_team_personal')), (4, ref('helpdesk_team_damage'))]"/>
|
||||
</record>
|
||||
<record id="stage_solved" model="helpdesk.stage">
|
||||
<field name="name">Solved</field>
|
||||
<field name="team_ids" eval="[(4, ref('helpdesk_team1')), (4, ref('helpdesk_team_technical')), (4, ref('helpdesk_team_personal')), (4, ref('helpdesk_team_damage'))]"/>
|
||||
<field name="sequence">3</field>
|
||||
<field name="fold" eval="True"/>
|
||||
</record>
|
||||
<record id="stage_cancelled" model="helpdesk.stage">
|
||||
<field name="name">Cancelled</field>
|
||||
<field name="sequence">4</field>
|
||||
<field name="team_ids" eval="[(4, ref('helpdesk_team1')), (4, ref('helpdesk_team_technical')), (4, ref('helpdesk_team_personal')), (4, ref('helpdesk_team_damage'))]"/>
|
||||
<field name="fold" eval="True"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo><data noupdate="1">
|
||||
<record id="new_ticket_request_email_template" model="mail.template">
|
||||
<field name="name">Helpdesk: Ticket Received</field>
|
||||
<field name="model_id" ref="helpdesk.model_helpdesk_ticket"/>
|
||||
<field name="subject">{{ object.name }}</field>
|
||||
<record id="new_ticket_request_email_template" model="mail.template">
|
||||
<field name="name">Helpdesk: Ticket Received</field>
|
||||
<field name="model_id" ref="helpdesk.model_helpdesk_ticket"/>
|
||||
<field name="subject">Ticket Received - {{ object.ticket_ref or object.id }}</field>
|
||||
<field name="email_from">{{ (object.team_id.alias_email_from or object.company_id.email_formatted or object.user_id.email_formatted or user.email_formatted) }}</field>
|
||||
<field name="email_to">{{ (object.partner_email if not object.sudo().partner_id.email or object.sudo().partner_id.email != object.partner_email else '') }}</field>
|
||||
<field name="partner_to">{{ object.partner_id.id if object.sudo().partner_id.email and object.sudo().partner_id.email == object.partner_email else '' }}</field>
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
<field name="body_html" type="html">
|
||||
<div>
|
||||
Dear <t t-out="object.sudo().partner_id.name or object.sudo().partner_name or 'Madam/Sir'">Madam/Sir</t>,<br /><br />
|
||||
Your request
|
||||
Your ticket has been received. Please wait patiently while our team reviews your request:
|
||||
<t t-if="hasattr(object.team_id, 'website_id') and object.get_portal_url()">
|
||||
<a t-attf-href="{{ object.team_id.website_id.domain }}/my/ticket/{{ object.id }}/{{ object.access_token }}" t-out="object.name or ''">Table legs are unbalanced</a>
|
||||
</t>
|
||||
|
|
@ -51,12 +51,33 @@
|
|||
</field>
|
||||
<field name="lang">{{ object.partner_id.lang or object.user_id.lang or user.lang }}</field>
|
||||
<field name="auto_delete" eval="True"/>
|
||||
</record>
|
||||
</record>
|
||||
|
||||
<record id="ticket_assigned_user_email_template" model="mail.template">
|
||||
<field name="name">Helpdesk: New Ticket Assigned User</field>
|
||||
<field name="model_id" ref="helpdesk.model_helpdesk_ticket"/>
|
||||
<field name="subject">New Ticket Assigned - {{ object.ticket_ref or object.id }}</field>
|
||||
<field name="email_from">{{ (object.company_id.email_formatted or user.email_formatted) }}</field>
|
||||
<field name="email_to">{{ object.user_id.email_formatted if object.user_id.email else '' }}</field>
|
||||
<field name="description">Notify the assigned helpdesk user when a new ticket is created.</field>
|
||||
<field name="body_html" type="html">
|
||||
<div>
|
||||
Hello <t t-out="object.user_id.name or 'there'">there</t>,<br/><br/>
|
||||
You have a new ticket assigned to you.<br/><br/>
|
||||
<strong>Reference:</strong> <t t-out="object.ticket_ref or object.id">15</t><br/>
|
||||
<strong>Type:</strong> <t t-out="dict(object._fields['portal_ticket_type'].selection).get(object.portal_ticket_type)">Technical Issue</t><br/>
|
||||
<strong>Customer:</strong> <t t-out="object.partner_name or object.partner_id.name or ''">Customer</t><br/>
|
||||
<strong>Subject:</strong> <t t-out="object.name or ''">Ticket Subject</t><br/><br/>
|
||||
<a style="background-color: #875A7B; padding: 8px 16px; text-decoration: none; color: #fff; border-radius: 5px; font-size: 13px;" t-att-href="'/odoo/action-helpdesk.helpdesk_ticket_action_main_tree/%s' % object.id" target="_blank">Open Ticket</a>
|
||||
</div>
|
||||
</field>
|
||||
<field name="auto_delete" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="solved_ticket_request_email_template" model="mail.template">
|
||||
<field name="name">Helpdesk: Ticket Closed</field>
|
||||
<field name="model_id" ref="helpdesk.model_helpdesk_ticket"/>
|
||||
<field name="subject">Ticket Closed - Reference {{ object.id if object.id else 15 }}</field>
|
||||
<field name="subject">Ticket Closed - Reference {{ object.ticket_ref or object.id }}</field>
|
||||
<field name="email_from">{{ (object.team_id.alias_email_from or object.company_id.email_formatted or object.user_id.email_formatted or user.email_formatted) }}</field>
|
||||
<field name="email_to">{{ (object.partner_email if not object.sudo().partner_id.email or object.sudo().partner_id.email != object.partner_email else '') }}</field>
|
||||
<field name="partner_to">{{ object.partner_id.id if object.sudo().partner_id.email and object.sudo().partner_id.email == object.partner_email else '' }}</field>
|
||||
|
|
@ -64,10 +85,14 @@
|
|||
<field name="body_html" type="html">
|
||||
<div>
|
||||
Dear <t t-out="object.sudo().partner_id.name or 'Madam/Sir'">Madam/Sir</t>,<br /><br />
|
||||
We would like to inform you that we have closed your ticket (reference <t t-out="object.id or ''">15</t>).
|
||||
We trust that the services provided have met your expectations and that you have found a satisfactory resolution to your issue.<br /><br />
|
||||
However, if you have any further questions or comments, please do not hesitate to reply to this email to re-open your ticket.
|
||||
Our team is always here to help you and we will be happy to assist you with any further concerns you may have.<br /><br />
|
||||
Your ticket (reference <t t-out="object.ticket_ref or object.id or ''">15</t>) is now closed.
|
||||
We trust that the services provided have met your expectations and that you have found a satisfactory resolution to your issue.<br /><br />
|
||||
Please give feedback or leave a review if you would like to. This is optional, but it helps us improve.<br /><br />
|
||||
However, if you have any further questions or comments, please reply to this email or use the re-request option in your portal to re-open your ticket.
|
||||
Our team is always here to help you and we will be happy to assist you with any further concerns you may have.<br /><br />
|
||||
<div style="text-align: center; padding: 16px 0px;">
|
||||
<a style="background-color: #875A7B; padding: 8px 16px; text-decoration: none; color: #fff; border-radius: 5px; font-size: 13px;" t-att-href="object.get_portal_url()" target="_blank">View Ticket</a>
|
||||
</div>
|
||||
Thank you for choosing our services and for your cooperation throughout this process. We truly value your business and appreciate the opportunity to serve you.<br /><br />
|
||||
Kind regards,<br /><br />
|
||||
<t t-out="object.team_id.name or 'Helpdesk'">Helpdesk</t> Team.
|
||||
|
|
@ -75,7 +100,25 @@
|
|||
</field>
|
||||
<field name="lang">{{ object.partner_id.lang or object.user_id.lang or user.lang }}</field>
|
||||
<field name="auto_delete" eval="True"/>
|
||||
</record>
|
||||
</record>
|
||||
|
||||
<record id="ticket_reopened_user_email_template" model="mail.template">
|
||||
<field name="name">Helpdesk: Ticket Re-requested</field>
|
||||
<field name="model_id" ref="helpdesk.model_helpdesk_ticket"/>
|
||||
<field name="subject">Ticket Re-requested - {{ object.ticket_ref or object.id }}</field>
|
||||
<field name="email_from">{{ (object.company_id.email_formatted or user.email_formatted) }}</field>
|
||||
<field name="email_to">{{ object.user_id.email_formatted if object.user_id.email else '' }}</field>
|
||||
<field name="description">Notify the assigned helpdesk user when a customer re-requests help on a closed ticket.</field>
|
||||
<field name="body_html" type="html">
|
||||
<div>
|
||||
Hello <t t-out="object.user_id.name or 'there'">there</t>,<br/><br/>
|
||||
The customer has re-requested help on ticket <strong><t t-out="object.ticket_ref or object.id">15</t></strong>.<br/><br/>
|
||||
<strong>Subject:</strong> <t t-out="object.name or ''">Ticket Subject</t><br/><br/>
|
||||
<a style="background-color: #875A7B; padding: 8px 16px; text-decoration: none; color: #fff; border-radius: 5px; font-size: 13px;" t-att-href="'/odoo/action-helpdesk.helpdesk_ticket_action_main_tree/%s' % object.id" target="_blank">Open Ticket</a>
|
||||
</div>
|
||||
</field>
|
||||
<field name="auto_delete" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="rating_ticket_request_email_template" model="mail.template">
|
||||
<field name="name">Helpdesk: Ticket Rating Request</field>
|
||||
|
|
|
|||
|
|
@ -72,6 +72,20 @@ class HelpdeskTeam(models.Model):
|
|||
privacy_visibility_warning = fields.Char(compute='_compute_privacy_visibility_warning', export_string_translation=False)
|
||||
access_instruction_message = fields.Char(compute='_compute_access_instruction_message', export_string_translation=False)
|
||||
ticket_ids = fields.One2many('helpdesk.ticket', 'team_id', string='Tickets')
|
||||
portal_ticket_type = fields.Selection([
|
||||
('technical', 'Technical Issue'),
|
||||
('personal', 'Personal / Harassment'),
|
||||
('damage', 'Damage / Asset Issue'),
|
||||
('general', 'General Request'),
|
||||
('others', 'Others')
|
||||
], string='Portal Ticket Type', default='general',
|
||||
help="Used by the website ticket form to route requests to the correct team.")
|
||||
responsible_manager_id = fields.Many2one(
|
||||
'res.users', string='Responsible Manager',
|
||||
domain=lambda self: [('groups_id', 'in', self.env.ref('helpdesk.group_helpdesk_user').id)])
|
||||
# approver_id = fields.Many2one(
|
||||
# 'res.users', string='Approver',
|
||||
# domain=lambda self: [('groups_id', 'in', self.env.ref('base.group_user').id)])
|
||||
|
||||
use_alias = fields.Boolean('Use Alias', default=True)
|
||||
has_external_mail_server = fields.Boolean(compute='_compute_has_external_mail_server', export_string_translation=False)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,14 @@ TICKET_PRIORITY = [
|
|||
('3', 'Urgent'),
|
||||
]
|
||||
|
||||
HELPDESK_TICKET_TYPES = [
|
||||
('technical', 'Technical Issue'),
|
||||
('personal', 'Personal / Harassment'),
|
||||
('damage', 'Damage / Asset Issue'),
|
||||
('general', 'General Request'),
|
||||
('others', 'Others')
|
||||
]
|
||||
|
||||
class HelpdeskTicket(models.Model):
|
||||
_name = 'helpdesk.ticket'
|
||||
_description = 'Helpdesk Ticket'
|
||||
|
|
@ -61,6 +69,13 @@ class HelpdeskTicket(models.Model):
|
|||
|
||||
name = fields.Char(string='Subject', required=True, index=True, tracking=True)
|
||||
team_id = fields.Many2one('helpdesk.team', string='Helpdesk Team', default=_default_team_id, index=True, tracking=True)
|
||||
portal_ticket_type = fields.Selection(HELPDESK_TICKET_TYPES, string='Ticket Type',store=True, tracking=True, relatated='team_id.portal_ticket_type')
|
||||
responsible_manager_id = fields.Many2one(
|
||||
'res.users', string='Responsible Manager', related='team_id.responsible_manager_id',
|
||||
store=True, readonly=True)
|
||||
# approver_id = fields.Many2one(
|
||||
# 'res.users', string='Approver', related='team_id.approver_id',
|
||||
# store=True, readonly=True)
|
||||
use_sla = fields.Boolean(related='team_id.use_sla')
|
||||
team_privacy_visibility = fields.Selection(related='team_id.privacy_visibility', export_string_translation=False)
|
||||
description = fields.Html(sanitize_attributes=False)
|
||||
|
|
@ -156,6 +171,8 @@ class HelpdeskTicket(models.Model):
|
|||
if ticket_sudo.team_id and ticket_sudo.team_id.privacy_visibility == 'invited_internal':
|
||||
ticket_user_ids = ticket_sudo.team_id.message_partner_ids.user_ids.ids
|
||||
ticket.domain_user_ids = [Command.set(user_ids + ticket_user_ids)]
|
||||
if ticket_sudo.team_id.auto_assignment:
|
||||
ticket.domain_user_ids = [Command.set(ticket_sudo.team_id.member_ids.ids)]
|
||||
|
||||
def _compute_access_url(self):
|
||||
super(HelpdeskTicket, self)._compute_access_url()
|
||||
|
|
@ -259,6 +276,12 @@ class HelpdeskTicket(models.Model):
|
|||
if not ticket.stage_id or ticket.stage_id not in ticket.team_id.stage_ids:
|
||||
ticket.stage_id = ticket.team_id._determine_stage()[ticket.team_id.id]
|
||||
|
||||
|
||||
@api.onchange('team_id')
|
||||
def onchange_user_and_stage_ids(self):
|
||||
for ticket in self.filtered(lambda ticket: ticket.team_id):
|
||||
ticket.user_id = ticket.team_id._determine_user_to_assign()[ticket.team_id.id]
|
||||
|
||||
@api.depends('partner_id')
|
||||
def _compute_partner_name(self):
|
||||
for ticket in self:
|
||||
|
|
@ -522,6 +545,7 @@ class HelpdeskTicket(models.Model):
|
|||
|
||||
# apply SLA
|
||||
tickets.sudo()._sla_apply()
|
||||
tickets._send_ticket_created_notifications()
|
||||
|
||||
return tickets
|
||||
|
||||
|
|
@ -579,8 +603,56 @@ class HelpdeskTicket(models.Model):
|
|||
message = _("This ticket was successfully closed %s hours before its SLA deadline.", round(abs(min_hours))) if min_hours < 0 \
|
||||
else _("This ticket was closed %s hours after its SLA deadline.", round(min_hours))
|
||||
ticket.message_post(body=message, subtype_xmlid="mail.mt_note", author_id=odoobot_partner_id)
|
||||
closed_tickets._send_ticket_closed_notifications()
|
||||
return res
|
||||
|
||||
def _send_template_once(self, template_xmlid, marker):
|
||||
template = self.env.ref(template_xmlid, raise_if_not_found=False)
|
||||
if not template:
|
||||
return
|
||||
for ticket in self:
|
||||
if ticket.message_ids.filtered(lambda message: message.body and marker in message.body):
|
||||
continue
|
||||
template.sudo().send_mail(ticket.id, force_send=True, email_layout_xmlid='mail.mail_notification_light')
|
||||
ticket.message_post(body=marker, subtype_xmlid='mail.mt_note')
|
||||
|
||||
def _send_ticket_created_notifications(self):
|
||||
if self.env.context.get('helpdesk_skip_create_notifications') or self.env.context.get('install_mode'):
|
||||
return
|
||||
self.filtered(lambda ticket: ticket.partner_email)._send_template_once(
|
||||
'helpdesk.new_ticket_request_email_template',
|
||||
'<span data-helpdesk-notification="customer-created"></span>',
|
||||
)
|
||||
self.filtered(lambda ticket: ticket.user_id and ticket.user_id.email)._send_template_once(
|
||||
'helpdesk.ticket_assigned_user_email_template',
|
||||
'<span data-helpdesk-notification="assignee-created"></span>',
|
||||
)
|
||||
|
||||
def _send_ticket_closed_notifications(self):
|
||||
if self.env.context.get('helpdesk_skip_close_notifications') or self.env.context.get('install_mode'):
|
||||
return
|
||||
self.filtered(lambda ticket: ticket.partner_email)._send_template_once(
|
||||
'helpdesk.solved_ticket_request_email_template',
|
||||
'<span data-helpdesk-notification="customer-closed"></span>',
|
||||
)
|
||||
|
||||
def action_portal_rerequest(self):
|
||||
for ticket in self:
|
||||
opening_stage = ticket.team_id._determine_stage()[ticket.team_id.id]
|
||||
vals = {'closed_by_partner': False}
|
||||
if opening_stage:
|
||||
vals['stage_id'] = opening_stage.id
|
||||
ticket.with_context(helpdesk_skip_create_notifications=True).write(vals)
|
||||
ticket.message_post(
|
||||
body=_('The customer has re-requested help on this ticket.'),
|
||||
message_type='comment',
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
self.filtered(lambda ticket: ticket.user_id and ticket.user_id.email)._send_template_once(
|
||||
'helpdesk.ticket_reopened_user_email_template',
|
||||
'<span data-helpdesk-notification="assignee-reopened"></span>',
|
||||
)
|
||||
|
||||
def copy_data(self, default=None):
|
||||
vals_list = super().copy_data(default=default)
|
||||
return [dict(vals, name=self.env._("%s (copy)", ticket.name)) for ticket, vals in zip(self, vals_list)]
|
||||
|
|
@ -787,6 +859,11 @@ class HelpdeskTicket(models.Model):
|
|||
partner_ids = [x.id for x in self.env['mail.thread']._mail_find_partner_from_emails(self._ticket_email_split(msg), records=self) if x]
|
||||
if partner_ids:
|
||||
self.message_subscribe(partner_ids)
|
||||
incoming_email = tools.email_normalize(msg.get('from') or '')
|
||||
for ticket in self.filtered(lambda t: t.stage_id.fold):
|
||||
partner_email = tools.email_normalize(ticket.partner_email or ticket.partner_id.email or '')
|
||||
if incoming_email and incoming_email == partner_email:
|
||||
ticket.action_portal_rerequest()
|
||||
return super(HelpdeskTicket, self).message_update(msg, update_vals=update_vals)
|
||||
|
||||
def _message_compute_subject(self):
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models, tools
|
||||
from odoo.addons.helpdesk.models.helpdesk_ticket import TICKET_PRIORITY
|
||||
from odoo.addons.helpdesk.models.helpdesk_ticket import HELPDESK_TICKET_TYPES, TICKET_PRIORITY
|
||||
from odoo.addons.rating.models.rating_data import RATING_LIMIT_MIN
|
||||
|
||||
|
||||
|
|
@ -21,6 +21,7 @@ class HelpdeskSLAReport(models.Model):
|
|||
name = fields.Char(string='Subject', readonly=True)
|
||||
create_date = fields.Datetime("Ticket Creation Date", readonly=True)
|
||||
priority = fields.Selection(TICKET_PRIORITY, string='Minimum Priority', readonly=True)
|
||||
portal_ticket_type = fields.Selection(HELPDESK_TICKET_TYPES, string='Ticket Type', readonly=True, related='team_id.portal_ticket_type')
|
||||
user_id = fields.Many2one('res.users', string="Assigned To", readonly=True)
|
||||
partner_id = fields.Many2one('res.partner', string="Customer", readonly=True)
|
||||
partner_name = fields.Char(string='Customer Name', readonly=True)
|
||||
|
|
@ -76,6 +77,7 @@ class HelpdeskSLAReport(models.Model):
|
|||
NULLIF(T.rating_last_value, 0) AS rating_last_value,
|
||||
AVG(rt.rating) as rating_avg,
|
||||
T.priority AS priority,
|
||||
T.portal_ticket_type AS portal_ticket_type,
|
||||
NULLIF(T.close_hours, 0) AS ticket_close_hours,
|
||||
CASE
|
||||
WHEN EXTRACT(EPOCH FROM (COALESCE(T.assign_date, NOW() AT TIME ZONE 'UTC') - T.create_date)) / 3600 < 1 THEN NULL
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models, tools
|
||||
from odoo.addons.helpdesk.models.helpdesk_ticket import TICKET_PRIORITY
|
||||
from odoo.addons.helpdesk.models.helpdesk_ticket import HELPDESK_TICKET_TYPES, TICKET_PRIORITY
|
||||
from odoo.addons.rating.models.rating_data import RATING_LIMIT_MIN
|
||||
|
||||
|
||||
|
|
@ -25,6 +25,7 @@ class HelpdeskTicketReport(models.Model):
|
|||
sla_status_ids = fields.One2many('helpdesk.sla.status', 'ticket_id', string="SLA Status")
|
||||
create_date = fields.Datetime("Ticket Creation Date", readonly=True)
|
||||
priority = fields.Selection(TICKET_PRIORITY, string='Minimum Priority', readonly=True)
|
||||
portal_ticket_type = fields.Selection(HELPDESK_TICKET_TYPES, string='Ticket Type', readonly=True, related='team_id.portal_ticket_type')
|
||||
user_id = fields.Many2one('res.users', string="Assigned To", readonly=True)
|
||||
partner_id = fields.Many2one('res.partner', string="Customer", readonly=True)
|
||||
partner_name = fields.Char(string='Customer Name', readonly=True)
|
||||
|
|
@ -60,6 +61,7 @@ class HelpdeskTicketReport(models.Model):
|
|||
T.name AS name,
|
||||
T.create_date AS create_date,
|
||||
T.priority AS priority,
|
||||
T.portal_ticket_type AS portal_ticket_type,
|
||||
T.user_id AS user_id,
|
||||
T.partner_id AS partner_id,
|
||||
T.partner_name AS partner_name,
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@
|
|||
<t t-call="portal.portal_searchbar">
|
||||
<t t-set="title">Tickets</t>
|
||||
</t>
|
||||
<div class="d-flex justify-content-end mb-3">
|
||||
<a class="btn btn-primary" href="/helpdesk/new">New Ticket</a>
|
||||
</div>
|
||||
<div t-if="not grouped_tickets" class="alert alert-info">
|
||||
There are currently no Ticket for your account.
|
||||
</div>
|
||||
|
|
@ -47,6 +50,7 @@
|
|||
<thead>
|
||||
<tr>
|
||||
<th>Ticket</th>
|
||||
<th>Type</th>
|
||||
<th class="text-end" t-if="groupby != 'create_date'">Reported on</th>
|
||||
<th id="ticket_user_header" t-if="groupby != 'user_id'" class="ps-5">Assigned to</th>
|
||||
<th t-if="groupby != 'stage_id'" colspan="5" class="text-end">Stage</th>
|
||||
|
|
@ -82,6 +86,7 @@
|
|||
<t t-foreach="tickets" t-as="ticket">
|
||||
<tr>
|
||||
<td class="text-start"><a t-attf-href="/helpdesk/ticket/#{ticket.id}"><small>#</small><t t-out="ticket.ticket_ref"/><span class="ms-2" t-att-title="ticket.name" t-field="ticket.name"/></a></td>
|
||||
<td><span t-field="ticket.portal_ticket_type"/></td>
|
||||
<td class="text-end" t-if="groupby != 'create_date'">
|
||||
<span t-field="ticket.create_date" t-options='{"widget": "datetime", "hide_seconds": True}'/>
|
||||
</td>
|
||||
|
|
@ -100,6 +105,161 @@
|
|||
</t>
|
||||
</template>
|
||||
|
||||
<template id="portal_helpdesk_ticket_new" name="Raise Helpdesk Ticket">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="breadcrumbs_searchbar" t-value="True"/>
|
||||
<t t-call="portal.portal_searchbar">
|
||||
<t t-set="title">New Ticket</t>
|
||||
</t>
|
||||
<div t-if="error == 'no_team'" class="alert alert-warning">
|
||||
No portal helpdesk team is configured yet. Please contact support.
|
||||
</div>
|
||||
<div t-if="error == 'missing'" class="alert alert-danger">
|
||||
Please fill in the ticket type, subject, and details.
|
||||
</div>
|
||||
<form action="/helpdesk/new" method="post" class="card border-0 shadow-sm">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<div class="card bg-light border mb-3">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fa fa-user me-2"/>
|
||||
<t t-out="default_company.name or default_partner.company_name"/>
|
||||
</h6>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label text-muted small">
|
||||
Full Name
|
||||
</label>
|
||||
<input class="form-control"
|
||||
readonly="readonly"
|
||||
t-att-value="default_partner.display_name or ''"/>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label text-muted small">
|
||||
Phone
|
||||
</label>
|
||||
<input class="form-control"
|
||||
id="partner_phone"
|
||||
name="partner_phone"
|
||||
t-att-value="default_partner.phone or ''"/>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label text-muted small">
|
||||
Email
|
||||
</label>
|
||||
<input class="form-control"
|
||||
id="partner_email"
|
||||
name="partner_email"
|
||||
t-att-value="default_partner.email or ''"/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="portal_ticket_type">Category</label>
|
||||
<select class="form-select" id="portal_ticket_type" name="portal_ticket_type" required="required">
|
||||
<t t-foreach="ticket_types" t-as="ticket_type">
|
||||
<option t-att-value="ticket_type[0]"
|
||||
t-att-selected="'selected' if ticket_type[0] == default_category else None"><t t-out="ticket_type[1]"/></option>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="helpdesk_team">Team</label>
|
||||
<select class="form-select" id="helpdesk_team" name="helpdesk_team" required="required">
|
||||
<t t-foreach="ticket_teams" t-as="ticket_team">
|
||||
<option
|
||||
t-att-value="ticket_team.id"
|
||||
t-att-data-category="ticket_team.portal_ticket_type">
|
||||
<t t-out="ticket_team.display_name"/>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="name">Subject</label>
|
||||
<input class="form-control" id="name" name="name" required="required" placeholder="Short summary of the issue"/>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="description">Details</label>
|
||||
<textarea class="form-control" id="description" name="description" rows="8" required="required" placeholder="Describe what happened and what help you need"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent d-flex justify-content-between">
|
||||
<a class="btn btn-light" href="/my/tickets">Cancel</a>
|
||||
<button class="btn btn-primary" type="submit">Submit Ticket</button>
|
||||
</div>
|
||||
</form>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<template id="portal_helpdesk_ticket_new_js" inherit_id="portal_helpdesk_ticket_new">
|
||||
<xpath expr="//form" position="after">
|
||||
<script type="text/javascript">
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
|
||||
const categorySelect = document.getElementById('portal_ticket_type');
|
||||
const teamSelect = document.getElementById('helpdesk_team');
|
||||
|
||||
if (!categorySelect || !teamSelect) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allTeams = Array.from(teamSelect.options).map(opt =>
|
||||
opt.cloneNode(true)
|
||||
);
|
||||
|
||||
function filterTeamsByCategory() {
|
||||
const category = categorySelect.value;
|
||||
const currentTeam = teamSelect.value;
|
||||
|
||||
teamSelect.innerHTML = '';
|
||||
|
||||
allTeams.forEach(option => {
|
||||
if (option.dataset.category === category) {
|
||||
teamSelect.appendChild(option.cloneNode(true));
|
||||
}
|
||||
});
|
||||
|
||||
const found = [...teamSelect.options].find(
|
||||
opt => opt.value === currentTeam
|
||||
);
|
||||
|
||||
if (found) {
|
||||
teamSelect.value = currentTeam;
|
||||
}
|
||||
}
|
||||
|
||||
function setCategoryFromTeam() {
|
||||
const selected = teamSelect.options[teamSelect.selectedIndex];
|
||||
|
||||
if (selected && selected.dataset.category) {
|
||||
categorySelect.value = selected.dataset.category;
|
||||
}
|
||||
}
|
||||
|
||||
categorySelect.addEventListener('change', filterTeamsByCategory);
|
||||
teamSelect.addEventListener('change', setCategoryFromTeam);
|
||||
|
||||
filterTeamsByCategory();
|
||||
});
|
||||
</script>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<template id="tickets_followup" name="Helpdesk Tickets">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="title" t-value="ticket.name"/>
|
||||
|
|
@ -116,7 +276,14 @@
|
|||
<t t-set="classes" t-value="'col-lg-4 col-xxl-3 d-print-none'"/>
|
||||
|
||||
<t t-set="entries">
|
||||
<div class="d-flex flex-column">
|
||||
<div class="d-flex flex-column">
|
||||
<div t-if="ticket.stage_id.fold" class="flex-grow-1 mb-3">
|
||||
<div class="d-grid flex-sm-nowrap">
|
||||
<a class="btn btn-light pt-1" t-att-href="'/my/ticket/rerequest/%s/%s' % (ticket.id, ticket.access_token)">
|
||||
Re-request Help
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div t-if="ticket.team_id.allow_portal_ticket_closing and not ticket.stage_id.fold and not ticket.closed_by_partner" class="flex-grow-1">
|
||||
<div class="d-grid flex-sm-nowrap">
|
||||
<button class="btn btn-light pt-1" data-bs-target="#helpdesk_ticket_close_modal" data-bs-toggle="modal">
|
||||
|
|
@ -198,6 +365,14 @@
|
|||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
<span>Your ticket has successfully been closed. Thank you for your collaboration.</span>
|
||||
</div>
|
||||
<div t-if="ticket_created" class="alert alert-success alert-dismissible d-print-none" role="status">
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
<span>Your ticket has been received. Please wait patiently while the assigned team reviews it.</span>
|
||||
</div>
|
||||
<div t-if="ticket_reopened" class="alert alert-info alert-dismissible d-print-none" role="status">
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
<span>Your ticket has been re-requested and moved back to the support queue.</span>
|
||||
</div>
|
||||
<div id="card">
|
||||
<div id="card_header" class="container" data-anchor="true">
|
||||
<div class="row gs-0">
|
||||
|
|
@ -220,6 +395,10 @@
|
|||
<strong class="col-lg-3">Reported on</strong>
|
||||
<span class="col-lg-9" t-field="ticket.create_date" t-options='{"widget": "datetime", "hide_seconds": True}'/>
|
||||
</div>
|
||||
<div class="row mb-4">
|
||||
<strong class="col-lg-3">Type</strong>
|
||||
<span class="col-lg-9" t-field="ticket.portal_ticket_type"/>
|
||||
</div>
|
||||
<div t-if="not is_html_empty(ticket.description)" class="row mt-3" name="description">
|
||||
<div class="col-lg-12" t-field="ticket.description"/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -119,74 +119,86 @@
|
|||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
<setting class="col-lg-12" help="Route website tickets of this type to this team">
|
||||
<label for="portal_ticket_type"/>
|
||||
<field name="portal_ticket_type" class="mt16"/>
|
||||
<div class="mt16">
|
||||
<label for="responsible_manager_id"/>
|
||||
<field name="responsible_manager_id" widget="many2one_avatar_user" options="{'no_quick_create': True}"/>
|
||||
</div>
|
||||
<!-- <div class="mt16">-->
|
||||
<!-- <label for="approver_id"/>-->
|
||||
<!-- <field name="approver_id" widget="many2one_avatar_user" options="{'no_quick_create': True}"/>-->
|
||||
<!-- </div>-->
|
||||
</setting>
|
||||
</div>
|
||||
</div>
|
||||
<h2>Channels</h2>
|
||||
<div class="row mt16 o_settings_container" id="channels">
|
||||
<setting id="alias_channels" help="Create tickets by sending an email to an alias"
|
||||
documentation="/applications/services/helpdesk/overview/receiving_tickets.html#email-alias">
|
||||
<field name="use_alias" string="Email Alias"/>
|
||||
<div invisible="not use_alias" class="mt16">
|
||||
<div class="oe_edit_only" dir="ltr">
|
||||
<strong>Alias </strong>
|
||||
<field name="alias_name" placeholder="alias" class="w-25"/>@
|
||||
<field name="alias_domain_id" class="oe_inline" placeholder="e.g. mycompany.com"
|
||||
options="{'no_create': True, 'no_open': True}"/>
|
||||
<br/>
|
||||
<label for="alias_contact"/>
|
||||
<field name="alias_contact" string="Accept Emails From"/>
|
||||
</div>
|
||||
<p class="oe_read_only">
|
||||
<strong>Alias </strong>
|
||||
<field name="alias_id" class="oe_read_only oe_inline" required="False"/>
|
||||
</p>
|
||||
<field name="has_external_mail_server" invisible="1"/>
|
||||
<p class="text-muted o_row ps-1" invisible="alias_domain_id or has_external_mail_server">
|
||||
<i class="fa fa-lightbulb-o" role='img'/><span class="ms-2">To use an email alias, the first step is to configure an Alias Domain. You can achieve this by navigating to the General Settings and configuring the corresponding field.</span>
|
||||
</p>
|
||||
<p invisible="alias_domain_id">
|
||||
<a href="/odoo/settings#email-alias-setting" class="btn-link mt-2" role="button"><i class="oi oi-arrow-right"/> Set an Alias Domain</a>
|
||||
</p>
|
||||
</div>
|
||||
</setting>
|
||||
<setting help="Get in touch with your website visitors, and engage them with scripted chatbot conversations. Create and search tickets from your conversations.">
|
||||
<field name="use_website_helpdesk_livechat"/>
|
||||
<div class="text-muted o_row ps-1 mt16" invisible="not use_website_helpdesk_livechat">
|
||||
<i class="fa fa-lightbulb-o"/>
|
||||
<span class="ms-2">
|
||||
Type <b>/ticket</b> to create tickets<br/>
|
||||
Type <b>/search_tickets</b> to find tickets<br/>
|
||||
</span>
|
||||
</div>
|
||||
</setting>
|
||||
</div>
|
||||
<!-- <h2>Channels</h2>-->
|
||||
<!-- <div class="row mt16 o_settings_container" id="channels">-->
|
||||
<!-- <setting id="alias_channels" help="Create tickets by sending an email to an alias"-->
|
||||
<!-- documentation="/applications/services/helpdesk/overview/receiving_tickets.html#email-alias">-->
|
||||
<!-- <field name="use_alias" string="Email Alias"/>-->
|
||||
<!-- <div invisible="not use_alias" class="mt16">-->
|
||||
<!-- <div class="oe_edit_only" dir="ltr">-->
|
||||
<!-- <strong>Alias </strong>-->
|
||||
<!-- <field name="alias_name" placeholder="alias" class="w-25"/>@-->
|
||||
<!-- <field name="alias_domain_id" class="oe_inline" placeholder="e.g. mycompany.com"-->
|
||||
<!-- options="{'no_create': True, 'no_open': True}"/>-->
|
||||
<!-- <br/>-->
|
||||
<!-- <label for="alias_contact"/>-->
|
||||
<!-- <field name="alias_contact" string="Accept Emails From"/>-->
|
||||
<!-- </div>-->
|
||||
<!-- <p class="oe_read_only">-->
|
||||
<!-- <strong>Alias </strong>-->
|
||||
<!-- <field name="alias_id" class="oe_read_only oe_inline" required="False"/>-->
|
||||
<!-- </p>-->
|
||||
<!-- <field name="has_external_mail_server" invisible="1"/>-->
|
||||
<!-- <p class="text-muted o_row ps-1" invisible="alias_domain_id or has_external_mail_server">-->
|
||||
<!-- <i class="fa fa-lightbulb-o" role='img'/><span class="ms-2">To use an email alias, the first step is to configure an Alias Domain. You can achieve this by navigating to the General Settings and configuring the corresponding field.</span>-->
|
||||
<!-- </p>-->
|
||||
<!-- <p invisible="alias_domain_id">-->
|
||||
<!-- <a href="/odoo/settings#email-alias-setting" class="btn-link mt-2" role="button"><i class="oi oi-arrow-right"/> Set an Alias Domain</a>-->
|
||||
<!-- </p>-->
|
||||
<!-- </div>-->
|
||||
<!-- </setting>-->
|
||||
<!-- <setting help="Get in touch with your website visitors, and engage them with scripted chatbot conversations. Create and search tickets from your conversations.">-->
|
||||
<!-- <field name="use_website_helpdesk_livechat"/>-->
|
||||
<!-- <div class="text-muted o_row ps-1 mt16" invisible="not use_website_helpdesk_livechat">-->
|
||||
<!-- <i class="fa fa-lightbulb-o"/>-->
|
||||
<!-- <span class="ms-2">-->
|
||||
<!-- Type <b>/ticket</b> to create tickets<br/>-->
|
||||
<!-- Type <b>/search_tickets</b> to find tickets<br/>-->
|
||||
<!-- </span>-->
|
||||
<!-- </div>-->
|
||||
<!-- </setting>-->
|
||||
<!-- </div>-->
|
||||
<h2>Help Center</h2>
|
||||
<div class="row mt16 o_settings_container" id="website_form_channel">
|
||||
<setting help="Get tickets through an online form">
|
||||
<field name="use_website_helpdesk_form"/>
|
||||
</setting>
|
||||
<setting help="Centralize, manage, share and grow your knowledge library. Allow customers to search your articles in the help center for answers to their questions." documentation="/applications/services/helpdesk/overview/help_center.html#knowledge">
|
||||
<field name="use_website_helpdesk_knowledge"/>
|
||||
</setting>
|
||||
<setting help="Allow customers to help each other on a forum. Share answers from your tickets directly." documentation="/applications/services/helpdesk/overview/help_center.html#community-forum">
|
||||
<field name="use_website_helpdesk_forum"/>
|
||||
</setting>
|
||||
<setting help="Share presentations and videos, and organize them into courses. Allow customers to search your eLearning courses in the help center for answers to their questions." documentation="/applications/services/helpdesk/overview/help_center.html#elearning">
|
||||
<field name="use_website_helpdesk_slides"/>
|
||||
</setting>
|
||||
<!-- <setting help="Centralize, manage, share and grow your knowledge library. Allow customers to search your articles in the help center for answers to their questions." documentation="/applications/services/helpdesk/overview/help_center.html#knowledge">-->
|
||||
<!-- <field name="use_website_helpdesk_knowledge"/>-->
|
||||
<!-- </setting>-->
|
||||
<!-- <setting help="Allow customers to help each other on a forum. Share answers from your tickets directly." documentation="/applications/services/helpdesk/overview/help_center.html#community-forum">-->
|
||||
<!-- <field name="use_website_helpdesk_forum"/>-->
|
||||
<!-- </setting>-->
|
||||
<!-- <setting help="Share presentations and videos, and organize them into courses. Allow customers to search your eLearning courses in the help center for answers to their questions." documentation="/applications/services/helpdesk/overview/help_center.html#elearning">-->
|
||||
<!-- <field name="use_website_helpdesk_slides"/>-->
|
||||
<!-- </setting>-->
|
||||
</div>
|
||||
<h2 class="mt32">Track & Bill Time</h2>
|
||||
<h2 class="mt32">Timesheets</h2>
|
||||
<div class="row mt16 o_settings_container">
|
||||
<setting id="timesheet"
|
||||
help="Track the time spent on tickets"
|
||||
documentation="/applications/services/helpdesk/advanced/track_and_bill.html">
|
||||
<field name="use_helpdesk_timesheet"/>
|
||||
</setting>
|
||||
<setting id="sale_timesheet"
|
||||
help="Bill the time spent on your tickets to your customers"
|
||||
documentation="/applications/services/helpdesk/advanced/track_and_bill.html">
|
||||
<field name="use_helpdesk_sale_timesheet"/>
|
||||
</setting>
|
||||
<!-- <setting id="sale_timesheet"-->
|
||||
<!-- help="Bill the time spent on your tickets to your customers"-->
|
||||
<!-- documentation="/applications/services/helpdesk/advanced/track_and_bill.html">-->
|
||||
<!-- <field name="use_helpdesk_sale_timesheet"/>-->
|
||||
<!-- </setting>-->
|
||||
</div>
|
||||
<h2>Performance</h2>
|
||||
<div class="row mt16 o_settings_container">
|
||||
|
|
@ -218,42 +230,42 @@
|
|||
<setting help="Allow your customers to close their own tickets" documentation="/applications/services/helpdesk/advanced/close_tickets.html">
|
||||
<field name="allow_portal_ticket_closing"/>
|
||||
</setting>
|
||||
<setting help="Close inactive tickets automatically">
|
||||
<field name="auto_close_ticket"/>
|
||||
<div class="content-group" invisible="not auto_close_ticket">
|
||||
<field name="stage_ids" invisible="1"/>
|
||||
<div class="mt16">
|
||||
<label for="to_stage_id"/>
|
||||
<field name="to_stage_id" class="ms-2 oe_inline" required="auto_close_ticket" context="{'default_team_id': id}"/>
|
||||
</div>
|
||||
<div class="mt8">
|
||||
<strong>After</strong><field name="auto_close_day" class="mx-2 oe_inline text-center" required="1"/><span>days of inactivity</span>
|
||||
</div>
|
||||
<div class="mt8">
|
||||
<label for="from_stage_ids"/>
|
||||
<field name="from_stage_ids" widget="many2many_tags" class="ms-2" context="{'default_team_id': id}"/>
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
</div>
|
||||
<h2>After-Sales <widget name="documentation_link" path="/applications/services/helpdesk/advanced/after_sales.html" icon="fa-question-circle"/></h2>
|
||||
<div class="row mt32 o_settings_container" id="after-sales">
|
||||
<setting help="Issue credits notes">
|
||||
<field name="use_credit_notes"/>
|
||||
</setting>
|
||||
<setting help="Grant discounts, free products or free shipping">
|
||||
<field name="use_coupons"/>
|
||||
</setting>
|
||||
<setting help="Return faulty products">
|
||||
<field name="use_product_returns"/>
|
||||
</setting>
|
||||
<setting help="Send broken products for repair">
|
||||
<field name="use_product_repairs"/>
|
||||
</setting>
|
||||
<setting help="Plan onsite interventions">
|
||||
<field name="use_fsm"/>
|
||||
</setting>
|
||||
<!-- <setting help="Close inactive tickets automatically">-->
|
||||
<!-- <field name="auto_close_ticket"/>-->
|
||||
<!-- <div class="content-group" invisible="not auto_close_ticket">-->
|
||||
<!-- <field name="stage_ids" invisible="1"/>-->
|
||||
<!-- <div class="mt16">-->
|
||||
<!-- <label for="to_stage_id"/>-->
|
||||
<!-- <field name="to_stage_id" class="ms-2 oe_inline" required="auto_close_ticket" context="{'default_team_id': id}"/>-->
|
||||
<!-- </div>-->
|
||||
<!-- <div class="mt8">-->
|
||||
<!-- <strong>After</strong><field name="auto_close_day" class="mx-2 oe_inline text-center" required="1"/><span>days of inactivity</span>-->
|
||||
<!-- </div>-->
|
||||
<!-- <div class="mt8">-->
|
||||
<!-- <label for="from_stage_ids"/>-->
|
||||
<!-- <field name="from_stage_ids" widget="many2many_tags" class="ms-2" context="{'default_team_id': id}"/>-->
|
||||
<!-- </div>-->
|
||||
<!-- </div>-->
|
||||
<!-- </setting>-->
|
||||
</div>
|
||||
<!-- <h2>After-Sales <widget name="documentation_link" path="/applications/services/helpdesk/advanced/after_sales.html" icon="fa-question-circle"/></h2>-->
|
||||
<!-- <div class="row mt32 o_settings_container" id="after-sales">-->
|
||||
<!-- <setting help="Issue credits notes">-->
|
||||
<!-- <field name="use_credit_notes"/>-->
|
||||
<!-- </setting>-->
|
||||
<!-- <setting help="Grant discounts, free products or free shipping">-->
|
||||
<!-- <field name="use_coupons"/>-->
|
||||
<!-- </setting>-->
|
||||
<!-- <setting help="Return faulty products">-->
|
||||
<!-- <field name="use_product_returns"/>-->
|
||||
<!-- </setting>-->
|
||||
<!-- <setting help="Send broken products for repair">-->
|
||||
<!-- <field name="use_product_repairs"/>-->
|
||||
<!-- </setting>-->
|
||||
<!-- <setting help="Plan onsite interventions">-->
|
||||
<!-- <field name="use_fsm"/>-->
|
||||
<!-- </setting>-->
|
||||
<!-- </div>-->
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@
|
|||
<field name="user_id"/>
|
||||
<field name="partner_id" filter_domain="['|', '|', '|', ('partner_id', 'ilike', self), ('partner_email', 'ilike', self), ('partner_phone', 'ilike', self), ('partner_name', 'ilike', self)]"/>
|
||||
<field name="team_id" invisible="context.get('default_team_id', False)"/>
|
||||
<field name="portal_ticket_type"/>
|
||||
<field name="stage_id"/>
|
||||
<field name="sla_ids" groups="helpdesk.group_use_sla"/>
|
||||
<field name="priority" invisible="1"/>
|
||||
|
|
@ -119,6 +120,7 @@
|
|||
<group expand="0" string="Group By">
|
||||
<filter string="Assigned to" name="assignee" context="{'group_by':'user_id'}"/>
|
||||
<filter string="Helpdesk Team" name="team" context="{'group_by':'team_id'}" invisible="context.get('default_team_id', False)"/>
|
||||
<filter string="Ticket Type" name="portal_ticket_type" context="{'group_by':'portal_ticket_type'}"/>
|
||||
<filter string="Stage" name="stage" context="{'group_by':'stage_id'}"/>
|
||||
<filter string="Status" name="state" context="{'group_by': 'kanban_state'}"/>
|
||||
<filter string="SLA" name="sla_ids" context="{'group_by': 'sla_ids'}" groups="helpdesk.group_use_sla"/>
|
||||
|
|
@ -213,6 +215,7 @@
|
|||
<field name="name" string="Name"/>
|
||||
<field name="team_id" optional="show" readonly="1" column_invisible="context.get('default_team_id', False)"/>
|
||||
<field name="team_id" optional="hide" readonly="1" column_invisible="not context.get('default_team_id', False)"/>
|
||||
<field name="portal_ticket_type" optional="show"/>
|
||||
<field name="user_id" optional="show" widget="many2one_avatar_user" options="{'no_quick_create': True}"/>
|
||||
<field name="partner_id" domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]" optional="show" options="{'no_open': True}"/>
|
||||
<field name="company_id" groups="base.group_multi_company" optional="show" readonly="1" column_invisible="context.get('default_team_id', False)"/>
|
||||
|
|
@ -399,13 +402,16 @@
|
|||
<group class="mb-0 mt-4">
|
||||
<group>
|
||||
<field name="active" invisible="1"/>
|
||||
<field name="team_id" required="1" context="{'kanban_view_ref': 'helpdesk.helpdesk_team_view_kanban_mobile', 'default_use_sla': True}"/>
|
||||
<field name="team_id" required="1" force_save="1" context="{'kanban_view_ref': 'helpdesk.helpdesk_team_view_kanban_mobile', 'default_use_sla': True}"/>
|
||||
<field name="portal_ticket_type"/>
|
||||
<field name="user_id" class="field_user_id" domain="['&', ('id', 'in', domain_user_ids), ('share', '=', False)]" widget="many2one_avatar_user"/>
|
||||
<field name="domain_user_ids" invisible="1"/>
|
||||
<field name="priority" widget="priority"/>
|
||||
<field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color', 'no_create_edit': True}"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="responsible_manager_id" widget="many2one_avatar_user"/>
|
||||
<!-- <field name="approver_id" widget="many2one_avatar_user"/>-->
|
||||
<field name="partner_id" class="field_partner_id" domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]" widget="res_partner_many2one" context="{'default_phone': partner_phone}"/>
|
||||
<field name="is_partner_phone_update" invisible="1"/>
|
||||
<label for="partner_phone" string="Phone"/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -231,7 +231,7 @@ class HrRecruitmentAutoDocWizard(models.TransientModel):
|
|||
"relevant_experience_years": {"type": "float", "description": "Relevant years of experience as a number"},
|
||||
"notice_period": {"type": "string", "description": "Notice period text"},
|
||||
"degree": {"type": "string", "description": "Highest degree or main qualification"},
|
||||
"skills": {"type": "list", "description": "All explicit technical and functional skills"},
|
||||
"skills": {"type": "list", "description": "All explicit technical and functional skills that are mentioned in skills session and do not fetch the skills seperatly from the education and employeer history data"},
|
||||
"summary": {"type": "string", "description": "Short professional summary from the resume"},
|
||||
"education_history": {
|
||||
"type": "list",
|
||||
|
|
@ -360,12 +360,24 @@ class HrRecruitmentAutoDocWizard(models.TransientModel):
|
|||
phone_values = [self._normalize_phone(parsed_data.get("phone")), self._normalize_phone(parsed_data.get("alternate_phone"))]
|
||||
phone_values = [phone for phone in phone_values if phone]
|
||||
|
||||
expanded_phone_values = []
|
||||
|
||||
for phone in phone_values:
|
||||
expanded_phone_values.append(phone)
|
||||
|
||||
if phone.startswith('+91'):
|
||||
expanded_phone_values.append(phone[3:]) # remove +91
|
||||
elif len(phone) == 10 and phone.isdigit():
|
||||
expanded_phone_values.append('+91' + phone) # add +91
|
||||
|
||||
phone_values = list(set(expanded_phone_values))
|
||||
|
||||
candidate = False
|
||||
if email_value:
|
||||
candidate = search_model.search([("email_normalized", "=", email_value)], limit=1)
|
||||
candidate = search_model.search(['|',('email_from','=', email_value),("email_normalized", "=", email_value)], limit=1)
|
||||
if not candidate and phone_values:
|
||||
candidate = search_model.search([
|
||||
"|",
|
||||
"|","|",('partner_phone',"in",phone_values),
|
||||
("partner_phone_sanitized", "in", phone_values),
|
||||
("alternate_phone", "in", phone_values),
|
||||
], limit=1)
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ High-end recruitment dashboards with filters, KPIs, ApexCharts, and chart drilld
|
|||
"hr_recruitment_dashboards/static/src/scss/recruitment_dashboard.scss",
|
||||
],
|
||||
},
|
||||
'images': ['static/description/banner.png'],
|
||||
"installable": True,
|
||||
"application": False,
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 189 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 189 KiB |
|
|
@ -15,11 +15,12 @@
|
|||
sequence="1"
|
||||
active="0"/>
|
||||
<menuitem
|
||||
name="Dashboard"
|
||||
id="hr_recruitment.report_hr_recruitment"
|
||||
parent="hr_recruitment.menu_hr_recruitment_root"
|
||||
name="Recruitment Analysis"
|
||||
id="report_hr_recruitment_root"
|
||||
groups="hr_recruitment.group_hr_recruitment_user"
|
||||
action="action_hr_recruitment_dashboard"
|
||||
web_icon="hr_recruitment_dashboards,static/description/icon.png"
|
||||
|
||||
sequence="99"/>
|
||||
<menuitem
|
||||
name="Recruitment Analysis"
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
'version': '0.1',
|
||||
|
||||
# any module necessary for this one to work correctly
|
||||
'depends': ['base','hr_recruitment','hr','hr_recruitment_skills','website_hr_recruitment','requisitions'],
|
||||
'depends': ['base','hr_recruitment','hr','hr_recruitment_skills','website_hr_recruitment','requisitions','search_view_extension'],
|
||||
|
||||
# always loaded
|
||||
'data': [
|
||||
|
|
|
|||
|
|
@ -9,8 +9,18 @@ class HrApplicantStageComment(models.Model):
|
|||
applicant_id = fields.Many2one('hr.applicant', required=True, ondelete='cascade', index=True)
|
||||
stage_id = fields.Many2one('hr.recruitment.stage', required=True, index=True)
|
||||
comment = fields.Text(required=True)
|
||||
user_id = fields.Many2one('res.users', default=lambda self: self.env.user, required=True)
|
||||
comment_date = fields.Datetime(default=fields.Datetime.now, required=True)
|
||||
user_id = fields.Many2one('res.users', default=lambda self: self.env.user, required=True, readonly=1)
|
||||
comment_date = fields.Datetime(default=fields.Datetime.now, required=True, readonly=True)
|
||||
|
||||
def edit_cmt(self):
|
||||
for rec in self:
|
||||
return {
|
||||
"type": "ir.actions.act_window",
|
||||
"res_model": rec._name,
|
||||
"res_id": rec.id,
|
||||
"view_mode": "form",
|
||||
"target": "new",
|
||||
}
|
||||
|
||||
@api.depends('applicant_id', 'stage_id')
|
||||
def _compute_display_name(self):
|
||||
|
|
|
|||
|
|
@ -4,13 +4,6 @@ from datetime import date
|
|||
from datetime import timedelta
|
||||
import datetime
|
||||
|
||||
#
|
||||
# class Job(models.Model):
|
||||
# _inherit = 'hr.job'
|
||||
#
|
||||
# hiring_history = fields.One2many('recruitment.status.history', 'job_id', string='History')
|
||||
|
||||
|
||||
class HrCandidate(models.Model):
|
||||
_inherit = "hr.candidate"
|
||||
|
||||
|
|
@ -32,7 +25,6 @@ class HrCandidate(models.Model):
|
|||
resume_name = fields.Char()
|
||||
|
||||
applications_stages_stat = fields.Many2many('application.stage.status',string="Applications History", compute="_compute_applications_stages_stat")
|
||||
# availability_status = fields.Selection([('available','Available'),('not_available','Not Available'),('hired','Hired'),('abscond','Abscond')])
|
||||
|
||||
def action_toggle_chatter_visibility(self):
|
||||
for record in self:
|
||||
|
|
@ -103,6 +95,21 @@ class HrCandidate(models.Model):
|
|||
for rec in self:
|
||||
rec.display_name = rec.partner_name if not rec.candidate_sequence else f"{rec.partner_name} ({rec.candidate_sequence})"
|
||||
|
||||
def _get_phone_variants(self, phone):
|
||||
if not phone:
|
||||
return []
|
||||
|
||||
phone = phone.replace(" ", "").strip()
|
||||
|
||||
variants = [phone]
|
||||
|
||||
if phone.startswith("+91"):
|
||||
variants.append(phone[3:])
|
||||
elif len(phone) == 10 and phone.isdigit():
|
||||
variants.append("+91" + phone)
|
||||
|
||||
return list(set(variants))
|
||||
|
||||
@api.constrains('email_from', 'partner_phone', 'alternate_phone')
|
||||
def _candidate_unique_constraints(self):
|
||||
for rec in self:
|
||||
|
|
@ -116,16 +123,23 @@ class HrCandidate(models.Model):
|
|||
|
||||
# Check for unique phone number (partner_phone or alternate_phone)
|
||||
if rec.partner_phone:
|
||||
existing_phone = self.sudo().search(
|
||||
[('id', '!=', rec.id), '|', ('partner_phone', '=', rec.partner_phone),
|
||||
('alternate_phone', '=', rec.partner_phone)], limit=1)
|
||||
phone_variants = self._get_phone_variants(rec.partner_phone)
|
||||
|
||||
existing_phone = self.sudo().search([
|
||||
('id', '!=', rec.id),
|
||||
'|',
|
||||
('partner_phone_sanitized', 'in', phone_variants),
|
||||
('alternate_phone', 'in', phone_variants),
|
||||
], limit=1)
|
||||
if existing_phone:
|
||||
raise ValidationError(_("A candidate with the phone number '%s' already exists, sourced by %s %s.") % (
|
||||
existing_phone.partner_phone, existing_phone.user_id.name, existing_phone.candidate_sequence if existing_phone.candidate_sequence else ''))
|
||||
|
||||
if rec.alternate_phone:
|
||||
phone_variants = self._get_phone_variants(rec.alternate_phone)
|
||||
|
||||
existing_al_phone = self.sudo().search(
|
||||
[('id', '!=', rec.id), '|', ('partner_phone', '=', rec.alternate_phone),
|
||||
[('id', '!=', rec.id), '|', ('partner_phone_sanitized', 'in', phone_variants),
|
||||
('alternate_phone', '=', rec.alternate_phone)], limit=1)
|
||||
if existing_al_phone:
|
||||
raise ValidationError(
|
||||
|
|
@ -161,12 +175,6 @@ class HrCandidate(models.Model):
|
|||
employee.write({
|
||||
'image_1920': self.candidate_image})
|
||||
return action
|
||||
# #authotentication Details
|
||||
# pan_no = fields.Char(string='PAN No',tracking=True)
|
||||
# identification_id = fields.Char(string='Aadhar No',tracking=True)
|
||||
# previous_company_pf_no = fields.Char(string='Previous Company PF No',tracking=True)
|
||||
# previous_company_uan_no = fields.Char(string='Previous Company UAN No',tracking=True)
|
||||
#
|
||||
|
||||
@api.constrains('partner_name')
|
||||
def partner_name_constrain(self):
|
||||
|
|
|
|||
|
|
@ -4,4 +4,4 @@ from odoo import models, fields, api, _
|
|||
class ResPartner(models.Model):
|
||||
_inherit = 'res.partner'
|
||||
|
||||
contact_type = fields.Selection([('internal','In-House'),('external','Client-Side')], required=True, default='external')
|
||||
contact_type = fields.Selection([('internal','In-House'),('external','Client-Side')], required=True, default='internal')
|
||||
|
|
@ -25,13 +25,14 @@ hr_recruitment.access_hr_applicant_interviewer,hr.applicant.interviewer,hr_recru
|
|||
hr_recruitment.access_hr_recruitment_stage_user,hr.recruitment.stage.user,hr_recruitment.model_hr_recruitment_stage,hr_recruitment.group_hr_recruitment_user,1,1,1,0
|
||||
access_hr_recruitment_stage_hr,hr.recruitment.stage.hr,hr_recruitment.model_hr_recruitment_stage,hr.group_hr_manager,1,0,0,0
|
||||
|
||||
access_application_stage_status,application.stage.status,model_application_stage_status,base.group_user,1,1,1,1
|
||||
access_hr_applicant_stage_comment_user,hr.applicant.stage.comment.user,model_hr_applicant_stage_comment,base.group_user,1,1,1,0
|
||||
|
||||
|
||||
access_ats_invite_mail_template_wizard,ats.invite.mail.template.wizard.user,hr_recruitment_extended.model_ats_invite_mail_template_wizard,,1,1,1,1
|
||||
access_client_submission_mails_template_wizard,client.submission.mails.template.wizard.user,hr_recruitment_extended.model_client_submission_mails_template_wizard,,1,1,1,1
|
||||
access_applicant_stage_comment_wizard,applicant.stage.comment.wizard.user,model_applicant_stage_comment_wizard,base.group_user,1,1,1,1
|
||||
access_hr_application_public,hr.applicant.public.access,hr_recruitment.model_hr_applicant,base.group_public,1,0,0,0
|
||||
access_application_stage_status,application.stage.status,model_application_stage_status,base.group_user,1,1,1,1
|
||||
access_hr_applicant_stage_comment_user,hr.applicant.stage.comment.user,model_hr_applicant_stage_comment,base.group_user,1,1,1,0
|
||||
|
||||
|
||||
access_ats_invite_mail_template_wizard,ats.invite.mail.template.wizard.user,hr_recruitment_extended.model_ats_invite_mail_template_wizard,,1,1,1,1
|
||||
access_client_submission_mails_template_wizard,client.submission.mails.template.wizard.user,hr_recruitment_extended.model_client_submission_mails_template_wizard,,1,1,1,1
|
||||
access_applicant_stage_comment_wizard,applicant.stage.comment.wizard.user,model_applicant_stage_comment_wizard,base.group_user,1,1,1,1
|
||||
access_hr_application_public,hr.applicant.public.access,hr_recruitment.model_hr_applicant,base.group_public,1,0,0,0
|
||||
access_hr_application_group_hr,hr.applicant.hr.access,hr_recruitment.model_hr_applicant,hr.group_hr_manager,1,1,0,0
|
||||
access_applicant_request_forms_hr_user,access.applicant.request.forms.hr.user,model_applicant_request_forms,hr.group_hr_user,1,1,1,1
|
||||
access_applicant_request_forms_hr_user,access.applicant.request.forms.hr.user,model_applicant_request_forms,hr.group_hr_user,1,1,1,1
|
||||
access_hr_skill,access.hr.skill.user,hr_skills.model_hr_skill,base.group_public,1,0,0,0
|
||||
|
|
|
@ -224,6 +224,14 @@
|
|||
<field name="submission_status"/>
|
||||
<field name="requested_by"/>
|
||||
<separator/>
|
||||
<searchtab name="published_records_tab" string="Published">
|
||||
<filter string="Published" name="published_records_tab_filter"
|
||||
domain="[('website_published','=',True)]"/>
|
||||
</searchtab>
|
||||
<searchtab name="unpublished_records_tab" string="Unpublished">
|
||||
<filter string="Unpublished" name="unpublished_records_tab_filter"
|
||||
domain="[('website_published','=',False)]"/>
|
||||
</searchtab>
|
||||
<filter string="Published Records" name="published_records" domain="[('website_published','=',True)]"/>
|
||||
<filter string="UnPublished Records" name="unpublished_records" domain="[('website_published','=',False)]"/>
|
||||
<separator/>
|
||||
|
|
@ -434,7 +442,7 @@
|
|||
|
||||
|
||||
<record id="action_hr_job_recruitment_awaiting_published" model="ir.actions.act_window">
|
||||
<field name="name">JD</field>
|
||||
<field name="name">Job Position</field>
|
||||
<field name="res_model">hr.job.recruitment</field>
|
||||
<field name="view_mode">kanban,list,form,search</field>
|
||||
<field name="search_view_id" ref="view_job_recruitment_filter"/>
|
||||
|
|
@ -474,7 +482,7 @@
|
|||
active="0"
|
||||
sequence="2"/>
|
||||
|
||||
<menuitem name="JD"
|
||||
<menuitem name="JP"
|
||||
id="menu_hr_job_descriptions"
|
||||
parent="hr_recruitment.menu_hr_recruitment_root"
|
||||
action="action_hr_job_recruitment_awaiting_published"
|
||||
|
|
|
|||
|
|
@ -14,8 +14,10 @@
|
|||
<filter string="Client Type" name="contact_type" context="{'group_by': 'contact_type'}"/>
|
||||
</filter>
|
||||
<xpath expr="//search" position="inside">
|
||||
<filter name="internal_contact" string="In-House Contact" domain="[('contact_type', '=', 'internal')]"/>
|
||||
<filter name="external_contact" string="Client-Side Contact" domain="[('contact_type', '=', 'external')]"/>
|
||||
<filter name="internal_contact" string="In-House Contact"
|
||||
domain="[('contact_type', '=', 'internal')]"/>
|
||||
<filter name="external_contact" string="Client-Side Contact"
|
||||
domain="[('contact_type', '=', 'external')]"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
|
@ -27,12 +29,234 @@
|
|||
<field name="inherit_id" ref="base.view_partner_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='category_id']" position="after">
|
||||
<field name="contact_type"/>
|
||||
<field name="contact_type"/>
|
||||
</xpath>
|
||||
|
||||
</field>
|
||||
</record>
|
||||
<record id="view_vendor_partner_form" model="ir.ui.view">
|
||||
<field name="name">vendor.partner.form</field>
|
||||
<field name="model">res.partner</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Vendor">
|
||||
<sheet>
|
||||
|
||||
<field name="image_1920"
|
||||
widget="image"
|
||||
class="oe_avatar"
|
||||
options="{'preview_image': 'avatar_128'}"/>
|
||||
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="name" placeholder="Vendor Name"/>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<group>
|
||||
<group>
|
||||
|
||||
<field name="company_type" widget="radio"
|
||||
options="{'horizontal': true}"/>
|
||||
|
||||
<field name="email"/>
|
||||
|
||||
<field name="phone"/>
|
||||
|
||||
<field name="website" widget="url"/>
|
||||
|
||||
<field name="category_id"
|
||||
widget="many2many_tags"
|
||||
options="{'color_field':'color'}"/>
|
||||
|
||||
<field name="contact_type"
|
||||
readonly="1"/>
|
||||
|
||||
</group>
|
||||
|
||||
<group>
|
||||
|
||||
<field name="street"/>
|
||||
|
||||
<field name="street2"/>
|
||||
|
||||
<field name="city"/>
|
||||
|
||||
<field name="state_id"/>
|
||||
|
||||
<field name="zip"/>
|
||||
|
||||
<field name="country_id"/>
|
||||
|
||||
<field name="vat"
|
||||
string="GSTIN"/>
|
||||
|
||||
<field name="l10n_in_pan"
|
||||
string="PAN"/>
|
||||
|
||||
</group>
|
||||
|
||||
</group>
|
||||
|
||||
<notebook>
|
||||
|
||||
<page string="Contacts & Addresses">
|
||||
|
||||
<field name="child_ids"
|
||||
context="{
|
||||
'default_parent_id': id,
|
||||
'default_type': 'contact'
|
||||
}">
|
||||
|
||||
<kanban>
|
||||
<field name="name"/>
|
||||
<field name="email"/>
|
||||
<field name="phone"/>
|
||||
|
||||
<templates>
|
||||
<t t-name="card">
|
||||
<div class="oe_kanban_global_click">
|
||||
<strong>
|
||||
<field name="name"/>
|
||||
</strong>
|
||||
|
||||
<div>
|
||||
<field name="email"/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<field name="phone"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
||||
</kanban>
|
||||
|
||||
</field>
|
||||
|
||||
</page>
|
||||
|
||||
</notebook>
|
||||
|
||||
</sheet>
|
||||
|
||||
<chatter/>
|
||||
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_vendor_partner_list" model="ir.ui.view">
|
||||
<field name="name">vendor.partner.list</field>
|
||||
<field name="model">res.partner</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Vendors">
|
||||
|
||||
<field name="name"/>
|
||||
|
||||
<field name="company_type"/>
|
||||
|
||||
<field name="phone"/>
|
||||
|
||||
<field name="email"/>
|
||||
|
||||
<field name="vat"/>
|
||||
|
||||
<field name="l10n_in_pan"/>
|
||||
|
||||
<field name="website"/>
|
||||
|
||||
<field name="contact_type"/>
|
||||
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_vendor_partner_kanban" model="ir.ui.view">
|
||||
<field name="name">vendor.partner.kanban</field>
|
||||
<field name="model">res.partner</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="o_kanban_mobile">
|
||||
<field name="image_128"/>
|
||||
<field name="name"/>
|
||||
<field name="phone"/>
|
||||
<field name="email"/>
|
||||
|
||||
<templates>
|
||||
<t t-name="card">
|
||||
|
||||
<div class="oe_kanban_global_click d-flex p-2">
|
||||
|
||||
<!-- Avatar -->
|
||||
<div class="me-2">
|
||||
<field name="image_128"
|
||||
widget="image"
|
||||
class="rounded"
|
||||
options="{'size': [48,48]}"/>
|
||||
</div>
|
||||
|
||||
<!-- Details -->
|
||||
<div class="flex-grow-1 overflow-hidden">
|
||||
|
||||
<div class="fw-bold text-truncate">
|
||||
<field name="name"/>
|
||||
</div>
|
||||
|
||||
<div t-if="record.phone.raw_value"
|
||||
class="text-muted small">
|
||||
<i class="fa fa-phone me-1"/>
|
||||
<field name="phone"/>
|
||||
</div>
|
||||
|
||||
<div t-if="record.email.raw_value"
|
||||
class="text-muted small text-truncate">
|
||||
<i class="fa fa-envelope me-1"/>
|
||||
<field name="email"/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_vendor_partner" model="ir.actions.act_window">
|
||||
<field name="name">Vendors</field>
|
||||
<field name="res_model">res.partner</field>
|
||||
<field name="view_mode">kanban,list,form</field>
|
||||
<field name="context">
|
||||
{
|
||||
'default_contact_type':'external',
|
||||
'search_default_external':1
|
||||
}
|
||||
</field>
|
||||
<field name="domain">[('contact_type','=','external')]</field>
|
||||
</record>
|
||||
|
||||
<record id="action_vendor_partner_kanban" model="ir.actions.act_window.view">
|
||||
<field name="sequence" eval="1"/>
|
||||
<field name="view_mode">kanban</field>
|
||||
<field name="view_id" ref="view_vendor_partner_kanban"/>
|
||||
<field name="act_window_id" ref="action_vendor_partner"/>
|
||||
</record>
|
||||
|
||||
<record id="action_vendor_partner_list" model="ir.actions.act_window.view">
|
||||
<field name="sequence" eval="2"/>
|
||||
<field name="view_mode">list</field>
|
||||
<field name="view_id" ref="view_vendor_partner_list"/>
|
||||
<field name="act_window_id" ref="action_vendor_partner"/>
|
||||
</record>
|
||||
|
||||
<record id="action_vendor_partner_form" model="ir.actions.act_window.view">
|
||||
<field name="sequence" eval="3"/>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="view_id" ref="view_vendor_partner_form"/>
|
||||
<field name="act_window_id" ref="action_vendor_partner"/>
|
||||
</record>
|
||||
|
||||
<record id="action_contacts_recruitments" model="ir.actions.act_window">
|
||||
<field name="name">Contacts</field>
|
||||
|
|
@ -56,15 +280,16 @@
|
|||
id="menu_hr_recruitment_config_contacts"
|
||||
name="Contacts"
|
||||
parent="hr_recruitment.menu_hr_recruitment_configuration"
|
||||
active="0"
|
||||
sequence="10"/>
|
||||
|
||||
<menuitem
|
||||
id="menu_hr_recruitment_stage"
|
||||
name="Clients"
|
||||
parent="menu_hr_recruitment_config_contacts"
|
||||
action="action_contacts_recruitments"
|
||||
name="Vendors"
|
||||
parent="hr_recruitment.menu_hr_recruitment_root"
|
||||
action="action_vendor_partner"
|
||||
groups="base.group_user"
|
||||
sequence="1"/>
|
||||
sequence="98"/>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
<field name="stage_id"/>
|
||||
<field name="user_id"/>
|
||||
<field name="comment"/>
|
||||
<button string="Edit" name="edit_cmt" type="object" class="btn-primary"/>
|
||||
</list>
|
||||
</field>
|
||||
</group>
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ class ClientSubmissionsMailTemplateWizard(models.TransientModel):
|
|||
raise UserError("Email template not found.")
|
||||
|
||||
self.email_from = record.user_id.partner_id.email
|
||||
self.email_to = record.hr_job_recruitment.requested_by.email
|
||||
self.email_to = record.requested_by.email
|
||||
self.email_body = email_template.body_html # Assign the rendered email bodyc
|
||||
self.email_subject = email_template.subject
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
from . import controllers
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
from . import hrms_emp_dashboard
|
||||
|
|
@ -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 |
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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 && !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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -19,11 +19,15 @@
|
|||
'views/login.xml',
|
||||
'views/menu_access_control_views.xml',
|
||||
],
|
||||
# 'assets': {
|
||||
# 'web.assets_backend': [
|
||||
# 'menu_control_center/static/src/js/login.js',
|
||||
# ],
|
||||
# },
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'menu_control_center/static/src/js/master_background.js',
|
||||
'menu_control_center/static/src/scss/home_menu_background.scss',
|
||||
],
|
||||
'web.assets_frontend': [
|
||||
'menu_control_center/static/src/scss/home_menu_background.scss', # used by login page
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'application': True,
|
||||
'auto_install': False,
|
||||
|
|
|
|||
|
|
@ -8,9 +8,21 @@ class IrHttp(models.AbstractModel):
|
|||
|
||||
def session_info(self):
|
||||
info = super().session_info()
|
||||
info["master_background_url"] = False
|
||||
info["master_glass_effect"] = False
|
||||
active_master = http.request.session.get("active_master")
|
||||
if active_master:
|
||||
info['user_context']['active_master'] = active_master
|
||||
master = request.env['master.control'].sudo().search(
|
||||
[('code', '=', active_master)],
|
||||
limit=1
|
||||
)
|
||||
if master.use_custom_bg and master.background_image:
|
||||
info["master_background_url"] = (
|
||||
f"/web/image/master.control/{master.id}/background_image"
|
||||
)
|
||||
if master.enable_glass_effect:
|
||||
info["master_glass_effect"] = master.enable_glass_effect
|
||||
else:
|
||||
info['user_context']['active_master'] = ''
|
||||
return info
|
||||
|
|
@ -9,6 +9,9 @@ class MasterControl(models.Model):
|
|||
sequence = fields.Integer()
|
||||
name = fields.Char(string='Master Name', required=True)
|
||||
code = fields.Char(string='Code', required=True)
|
||||
custom_menu_order = fields.Boolean(string='Custom Menu Order', default=False)
|
||||
custom_menu_icons = fields.Boolean(string='Custom Menu Icons', default=False)
|
||||
enable_rename_option = fields.Boolean(string='Custom Menu Naming', default=False)
|
||||
user_ids = fields.Many2many(
|
||||
'res.users',
|
||||
'master_control_res_users_rel',
|
||||
|
|
@ -28,12 +31,45 @@ class MasterControl(models.Model):
|
|||
string='Sub Menus',
|
||||
domain=[('menu_id.parent_id', '!=', False)],
|
||||
)
|
||||
order_menu_line_ids = fields.One2many(
|
||||
'master.control.menu.line',
|
||||
'master_control_id',
|
||||
string='Menu Order',
|
||||
domain=[('menu_id.parent_id', '=', False), ('show_menu', '=', True)],
|
||||
)
|
||||
rename_menu_line_ids = fields.One2many(
|
||||
'master.control.menu.line',
|
||||
'master_control_id',
|
||||
string='Menu Order',
|
||||
domain=[('menu_id.parent_id', '=', False), ('show_menu', '=', True)],
|
||||
)
|
||||
rename_submenu_line_ids = fields.One2many(
|
||||
'master.control.menu.line',
|
||||
'master_control_id',
|
||||
string='Sub Menus',
|
||||
domain=[('menu_id.parent_id', '!=', False), ('show_menu', '=', True)],
|
||||
)
|
||||
|
||||
allowed_menu_ids = fields.Many2many(
|
||||
'ir.ui.menu',
|
||||
compute='_compute_allowed_menu_ids',
|
||||
string='Allowed Menus',
|
||||
)
|
||||
|
||||
use_custom_bg = fields.Boolean("Custom Background")
|
||||
|
||||
background_image = fields.Binary(
|
||||
"Background Image",
|
||||
attachment=True,
|
||||
)
|
||||
|
||||
background_image_filename = fields.Char()
|
||||
|
||||
enable_glass_effect = fields.Boolean(
|
||||
string="Enable Glass Effect",
|
||||
default=False
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
('master_control_code_unique', 'unique(code)', 'Master code must be unique.'),
|
||||
]
|
||||
|
|
@ -74,21 +110,21 @@ class MasterControl(models.Model):
|
|||
def _get_all_menus_sql(self):
|
||||
self.env.cr.execute("""
|
||||
WITH RECURSIVE menu_tree AS (
|
||||
SELECT id, parent_id
|
||||
SELECT id, parent_id, sequence
|
||||
FROM ir_ui_menu
|
||||
WHERE parent_id IS NULL
|
||||
AND active = true
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT menu.id, menu.parent_id
|
||||
SELECT menu.id, menu.parent_id, menu.sequence
|
||||
FROM ir_ui_menu menu
|
||||
JOIN menu_tree tree ON tree.id = menu.parent_id
|
||||
WHERE menu.active = true
|
||||
)
|
||||
SELECT id, parent_id
|
||||
SELECT id, parent_id, sequence
|
||||
FROM menu_tree
|
||||
ORDER BY parent_id NULLS FIRST, id
|
||||
ORDER BY parent_id NULLS FIRST, sequence, id
|
||||
""")
|
||||
return self.env.cr.dictfetchall()
|
||||
|
||||
|
|
@ -110,13 +146,14 @@ class MasterControl(models.Model):
|
|||
(record.menu_line_ids | record.submenu_line_ids).unlink()
|
||||
existing_lines = {}
|
||||
|
||||
for main_menu in children_map.get(None, []):
|
||||
for index, main_menu in enumerate(children_map.get(None, []), start=1):
|
||||
parent_line = existing_lines.get(main_menu['id'])
|
||||
if not parent_line:
|
||||
parent_line = line_model.create({
|
||||
'master_control_id': record.id,
|
||||
'menu_id': main_menu['id'],
|
||||
'show_menu': True,
|
||||
'custom_order_sequence': index * 10,
|
||||
})
|
||||
existing_lines[main_menu['id']] = parent_line
|
||||
notification_count += 1
|
||||
|
|
@ -130,6 +167,7 @@ class MasterControl(models.Model):
|
|||
'menu_id': submenu['id'],
|
||||
'show_menu': True,
|
||||
'parent_line_id': parent_line.id,
|
||||
'custom_order_sequence': submenu.get('sequence') or 10,
|
||||
})
|
||||
existing_lines[submenu['id']] = True
|
||||
notification_count += 1
|
||||
|
|
@ -178,13 +216,18 @@ class MasterControlMenuLine(models.Model):
|
|||
_name = 'master.control.menu.line'
|
||||
_description = 'Master Control Menu Line'
|
||||
_rec_name = 'menu_id'
|
||||
_order = 'menu_id'
|
||||
_order = 'custom_order_sequence, menu_sequence, menu_id, id'
|
||||
|
||||
master_control_id = fields.Many2one('master.control', required=True, ondelete='cascade')
|
||||
menu_id = fields.Many2one('ir.ui.menu', string='Menu', required=True)
|
||||
menu_sequence = fields.Integer(related='menu_id.sequence', string='Menu Sequence', store=True)
|
||||
custom_order_sequence = fields.Integer(string='Order', default=10)
|
||||
show_menu = fields.Boolean(string='Show Menu', default=True)
|
||||
parent_menu_id = fields.Many2one('ir.ui.menu', related='menu_id.parent_id', string='Parent Menu', store=True)
|
||||
parent_line_id = fields.Many2one('master.control.menu.line', string='Parent Line')
|
||||
menu_name = fields.Char(string='Original Name', related='menu_id.name')
|
||||
menu_custom_name = fields.Char(string='Custom Name')
|
||||
menu_icon = fields.Binary(string='Icon')
|
||||
|
||||
_sql_constraints = [
|
||||
('master_control_menu_unique', 'unique(master_control_id, menu_id)', 'Menu already exists for this master control.'),
|
||||
|
|
@ -204,6 +247,19 @@ class MasterControlMenuLine(models.Model):
|
|||
'domain': [('parent_line_id', '=', self.id)],
|
||||
}
|
||||
|
||||
def open_rename_submenus_popup_view(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'name': _('Sub Menus'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'master.control.menu.line',
|
||||
'view_mode': 'list',
|
||||
'views': [
|
||||
(self.env.ref('menu_control_center.view_master_rename_submenu_line_list').id, 'list'),
|
||||
],
|
||||
'target': 'new',
|
||||
'domain': [('parent_line_id', '=', self.id),('show_menu','=',True)],
|
||||
}
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
records = super().create(vals_list)
|
||||
|
|
|
|||
|
|
@ -10,14 +10,17 @@ class IrUiMenu(models.Model):
|
|||
def _get_active_master_code(self):
|
||||
return (request.session.get('active_master') if request else False) or self.env.context.get('active_master')
|
||||
|
||||
def _get_active_master_control(self):
|
||||
active_master_code = self._get_active_master_code()
|
||||
return self.env['master.control'].sudo().search([('code', '=', active_master_code)], limit=1) if active_master_code else False
|
||||
|
||||
@api.model
|
||||
@tools.ormcache('frozenset(self.env.user.groups_id.ids)', 'debug', 'self.env.uid', 'self._get_active_master_code() or ""')
|
||||
def _visible_menu_ids(self, debug=False):
|
||||
context = {'ir.ui.menu.full_list': True}
|
||||
menus = self.with_context(context).search_fetch([], ['action', 'parent_id']).sudo()
|
||||
|
||||
active_master_code = self._get_active_master_code()
|
||||
master_control = self.env['master.control'].sudo().search([('code', '=', active_master_code)], limit=1) if active_master_code else False
|
||||
master_control = self._get_active_master_control()
|
||||
|
||||
group_ids = set(self.env.user._get_group_ids())
|
||||
if not debug:
|
||||
|
|
@ -40,14 +43,104 @@ class IrUiMenu(models.Model):
|
|||
visible = self._process_action_menus(menus)
|
||||
return set(visible.ids)
|
||||
|
||||
def _get_master_menu_order_map(self, master_control):
|
||||
if not master_control or not master_control.custom_menu_order:
|
||||
return {}
|
||||
|
||||
visible_lines = master_control.order_menu_line_ids.filtered('show_menu').sorted(
|
||||
key=lambda line: (
|
||||
line.custom_order_sequence,
|
||||
line.menu_id.sequence,
|
||||
line.menu_id.name or '',
|
||||
line.menu_id.id,
|
||||
)
|
||||
)
|
||||
return {line.menu_id.id: index for index, line in enumerate(visible_lines)}
|
||||
|
||||
def _get_master_menu_icon_map(self, master_control):
|
||||
if not master_control or not master_control.custom_menu_icons:
|
||||
return {}
|
||||
|
||||
visible_lines = (
|
||||
master_control.order_menu_line_ids
|
||||
).filtered(
|
||||
lambda l: l.show_menu and l.menu_icon
|
||||
)
|
||||
|
||||
return {
|
||||
line.menu_id.id: (
|
||||
line.menu_icon.decode('utf-8')
|
||||
if isinstance(line.menu_icon, bytes)
|
||||
else line.menu_icon
|
||||
)
|
||||
for line in visible_lines
|
||||
}
|
||||
|
||||
def _get_master_menu_rename_map(self, master_control):
|
||||
if not master_control or not master_control.enable_rename_option:
|
||||
return {}
|
||||
|
||||
visible_lines = (
|
||||
master_control.rename_menu_line_ids +
|
||||
master_control.rename_submenu_line_ids
|
||||
).filtered(
|
||||
lambda l: l.show_menu and l.menu_custom_name
|
||||
)
|
||||
|
||||
return {
|
||||
line.menu_id.id: line.menu_custom_name
|
||||
for line in visible_lines
|
||||
}
|
||||
|
||||
def _sort_root_menu_ids(self, menu_ids, master_control):
|
||||
order_map = self._get_master_menu_order_map(master_control)
|
||||
if not order_map:
|
||||
return menu_ids
|
||||
default_positions = {menu_id: index for index, menu_id in enumerate(menu_ids)}
|
||||
return sorted(
|
||||
menu_ids,
|
||||
key=lambda menu_id: (
|
||||
order_map.get(menu_id, len(order_map)),
|
||||
default_positions[menu_id],
|
||||
)
|
||||
)
|
||||
|
||||
def _sort_root_menu_dicts(self, menus, master_control):
|
||||
order_map = self._get_master_menu_order_map(master_control)
|
||||
if not order_map:
|
||||
return menus
|
||||
default_positions = {menu['id']: index for index, menu in enumerate(menus)}
|
||||
return sorted(
|
||||
menus,
|
||||
key=lambda menu: (
|
||||
order_map.get(menu['id'], len(order_map)),
|
||||
default_positions[menu['id']],
|
||||
)
|
||||
)
|
||||
|
||||
@api.model
|
||||
def load_menus_root(self):
|
||||
root = super().load_menus_root()
|
||||
visible_ids = self._visible_menu_ids(request.session.debug if request else False)
|
||||
master_control = self._get_active_master_control()
|
||||
|
||||
rename_map = self._get_master_menu_rename_map(master_control)
|
||||
icon_map = self._get_master_menu_icon_map(master_control)
|
||||
|
||||
root['children'] = [
|
||||
child for child in root.get('children', [])
|
||||
if child['id'] in visible_ids
|
||||
]
|
||||
|
||||
for child in root['children']:
|
||||
if child['id'] in rename_map:
|
||||
child['name'] = rename_map[child['id']]
|
||||
|
||||
|
||||
if child['id'] in icon_map:
|
||||
child['web_icon_data'] = icon_map[child['id']]
|
||||
|
||||
root['children'] = self._sort_root_menu_dicts(root['children'], master_control)
|
||||
root['all_menu_ids'] = [
|
||||
menu_id for menu_id in root.get('all_menu_ids', [])
|
||||
if menu_id in visible_ids
|
||||
|
|
@ -58,6 +151,9 @@ class IrUiMenu(models.Model):
|
|||
def load_menus(self, debug):
|
||||
all_menus = super().load_menus(debug)
|
||||
visible_ids = self._visible_menu_ids(debug)
|
||||
master_control = self._get_active_master_control()
|
||||
rename_map = self._get_master_menu_rename_map(master_control)
|
||||
icon_map = self._get_master_menu_icon_map(master_control)
|
||||
|
||||
filtered_menus = {'root': dict(all_menus['root'])}
|
||||
for menu_id, menu_data in all_menus.items():
|
||||
|
|
@ -70,6 +166,10 @@ class IrUiMenu(models.Model):
|
|||
child_id for child_id in filtered_menus['root'].get('children', [])
|
||||
if child_id in filtered_menus
|
||||
]
|
||||
filtered_menus['root']['children'] = self._sort_root_menu_ids(
|
||||
filtered_menus['root']['children'],
|
||||
master_control,
|
||||
)
|
||||
|
||||
for menu_id, menu_data in list(filtered_menus.items()):
|
||||
if menu_id == 'root':
|
||||
|
|
@ -79,6 +179,14 @@ class IrUiMenu(models.Model):
|
|||
if child_id in filtered_menus
|
||||
]
|
||||
|
||||
for menu_id, menu_data in filtered_menus.items():
|
||||
if menu_id == 'root':
|
||||
continue
|
||||
if menu_id in rename_map:
|
||||
menu_data['name'] = rename_map[menu_id]
|
||||
|
||||
if menu_id in icon_map:
|
||||
menu_data['web_icon_data'] = icon_map[menu_id]
|
||||
return filtered_menus
|
||||
|
||||
def _get_hidden_menu_ids(self, master_control):
|
||||
|
|
@ -170,4 +278,4 @@ class IrUiMenu(models.Model):
|
|||
# Clear caches
|
||||
self.clear_caches()
|
||||
|
||||
return super().unlink()
|
||||
return super().unlink()
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,20 @@
|
|||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_master_rename_submenu_line_list" model="ir.ui.view">
|
||||
<field name="name">master.control.menu.line.submenu.list</field>
|
||||
<field name="model">master.control.menu.line</field>
|
||||
<field name="arch" type="xml">
|
||||
<list editable="bottom" create="0">
|
||||
<field name="menu_id" readonly="1" force_save="1"/>
|
||||
<field name="parent_menu_id" readonly="1"/>
|
||||
<field name="menu_name"/>
|
||||
<field name="menu_custom_name"/>
|
||||
<field name="show_menu"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_master_control_list" model="ir.ui.view">
|
||||
<field name="name">master.control.list</field>
|
||||
<field name="model">master.control</field>
|
||||
|
|
@ -36,6 +50,29 @@
|
|||
<field name="name"/>
|
||||
<field name="code"/>
|
||||
</group>
|
||||
<group>
|
||||
<group>
|
||||
<group>
|
||||
<field name="custom_menu_order" widget="boolean_toggle"/>
|
||||
<field name="custom_menu_icons" widget="boolean_toggle"/>
|
||||
<field string="Custom Naming" name="enable_rename_option" widget="boolean_toggle"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="enable_glass_effect"/>
|
||||
<field name="use_custom_bg"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<field name="background_image_filename" invisible="1" nolabel="1"/>
|
||||
<field name="background_image" invisible="not use_custom_bg or background_image" nolabel="1"
|
||||
filename="background_image_filename"
|
||||
readonly="not use_custom_bg" options="{'accepted_file_extensions': 'image/png,image/jpg,image/jpeg,image/svg,.png,.jpg,.jpeg,.svg'}"/>
|
||||
|
||||
<field name="background_image" invisible="not use_custom_bg or not background_image" nolabel="1"
|
||||
filename="background_image_filename" widget="image"
|
||||
readonly="not use_custom_bg" options="{'accepted_file_extensions': 'image/png,image/jpg,image/jpeg,image/svg,.png,.jpg,.jpeg,.svg', 'size': [180, 180]}"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<notebook>
|
||||
<page string="Users">
|
||||
|
|
@ -60,6 +97,17 @@
|
|||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Order/Icons" invisible="not custom_menu_order and not custom_menu_icons">
|
||||
<field name="order_menu_line_ids" widget="one2many_search">
|
||||
<list editable="bottom" create="0" delete="0" default_order="custom_order_sequence, menu_sequence, menu_id">
|
||||
<field name="custom_order_sequence" widget="handle" column_invisible="not parent.custom_menu_order"/>
|
||||
<field name="menu_sequence" column_invisible="1"/>
|
||||
<field name="menu_id" readonly="1" force_save="1"/>
|
||||
<field name="menu_icon" widget="image" options="{'accepted_file_extensions': 'image/png,image/jpg,image/jpeg,image/svg,.png,.jpg,.jpeg,.svg', 'size': [32, 32]}" column_invisible="not parent.custom_menu_icons"/>
|
||||
<field name="show_menu" readonly="1"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Sub Menus">
|
||||
<field name="submenu_line_ids" widget="one2many_search">
|
||||
<list editable="bottom" create="0" default_group_by="parent_menu_id">
|
||||
|
|
@ -69,6 +117,19 @@
|
|||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Renaming" invisible="not enable_rename_option">
|
||||
<field name="rename_menu_line_ids" widget="one2many_search">
|
||||
<list editable="bottom" create="0" delete="0" default_order="custom_order_sequence, menu_sequence, menu_id">
|
||||
<field name="menu_sequence" column_invisible="1"/>
|
||||
<field name="menu_id" readonly="1" force_save="1"/>
|
||||
<field name="menu_name"/>
|
||||
<field name="menu_custom_name"/>
|
||||
<field name="show_menu" readonly="1"/>
|
||||
<button name="open_rename_submenus_popup_view" string="Sub Menus" type="object" class="btn-primary"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -10,8 +10,6 @@ class OfferLetterResponseController(http.Controller):
|
|||
raise request.not_found()
|
||||
if not token or offer_letter.response_token != token:
|
||||
raise request.not_found()
|
||||
if not offer_letter._is_latest_offer():
|
||||
raise request.not_found()
|
||||
return offer_letter
|
||||
|
||||
@http.route('/offer_letters/respond/<int:offer_id>/accept', type='http', auth='public')
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from datetime import timedelta, datetime
|
|||
from odoo.tools.safe_eval import safe_eval
|
||||
import json
|
||||
import calendar
|
||||
import secrets
|
||||
|
||||
class DefaultDictroll(defaultdict):
|
||||
def get(self, key, default=None):
|
||||
|
|
@ -84,6 +85,13 @@ class OfferLetter(models.Model):
|
|||
pay_struct_id = fields.Many2one('hr.payroll.structure', string="Salary Structure", required=True)
|
||||
manager_id = fields.Many2one('hr.employee', string='Manager')
|
||||
rejection_reason = fields.Char()
|
||||
response_token = fields.Char(copy=False, index=True)
|
||||
|
||||
def _issue_response_token(self):
|
||||
while True:
|
||||
token = secrets.token_urlsafe(32)
|
||||
if not self.search_count([('response_token', '=', token)]):
|
||||
return token
|
||||
|
||||
@api.model
|
||||
def _default_terms(self):
|
||||
|
|
|
|||
|
|
@ -140,6 +140,7 @@ class ApplicantOfferMailWizard(models.TransientModel):
|
|||
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
|
||||
accept_url = f"{base_url}/offer_letters/respond/{offer_letter.id}/accept?token={response_token}"
|
||||
reject_url = f"{base_url}/offer_letters/respond/{offer_letter.id}/reject?token={response_token}"
|
||||
offer_letter.response_token = response_token
|
||||
render_results = template.with_context(
|
||||
offer_accept_url=accept_url,
|
||||
offer_reject_url=reject_url,
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ Enterprise-grade project dashboards with:
|
|||
'html2canvas': 'https://cdn.jsdelivr.net/npm/html2canvas'
|
||||
},
|
||||
},
|
||||
'images': ['static/description/banner.png'],
|
||||
"installable": True,
|
||||
"application": False,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
from . import project_dashboard_controller
|
||||
from . import portfolio_dashboard_controller
|
||||
from . import project_dashboard_controller
|
||||
from . import portfolio_dashboard_controller
|
||||
from . import user_dashboard_controller
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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 && 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 && !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>
|
||||
|
|
@ -9,6 +9,12 @@
|
|||
<field name="params" eval="{'model': 'project.project', 'res_id': False}"/>
|
||||
</record>
|
||||
|
||||
<record id="action_project_user_dashboard" model="ir.actions.client">
|
||||
<field name="name">My Work Dashboard</field>
|
||||
<field name="tag">project_user_dashboard</field>
|
||||
<field name="target">current</field>
|
||||
</record>
|
||||
|
||||
<!-- Menu Item for Dashboard -->
|
||||
<menuitem id="menu_project_dashboard"
|
||||
name="Project Dashboard"
|
||||
|
|
@ -17,5 +23,12 @@
|
|||
groups="project.group_project_manager"
|
||||
active="0"
|
||||
sequence="10"/>
|
||||
|
||||
<menuitem id="menu_project_user_dashboard_root"
|
||||
name="Work Analysis"
|
||||
action="action_project_user_dashboard"
|
||||
groups="project.group_project_user"
|
||||
web_icon="project_dashboards_management,static/description/icon.png"
|
||||
sequence="11"/>
|
||||
</data>
|
||||
</odoo>
|
||||
</odoo>
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
from . import models
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
from . import ir_ui_view
|
||||
|
|
@ -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)
|
||||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -17,4 +17,29 @@
|
|||
.o_form_view .o_inner_group {
|
||||
margin-top: 0px !important;
|
||||
padding-top: 0px !important;
|
||||
}
|
||||
|
||||
/* Increase dropdown z-index */
|
||||
.ui-autocomplete,
|
||||
.o_m2o_dropdown,
|
||||
.o_field_many2one .o_m2o_dropdown {
|
||||
z-index: 1100 !important;
|
||||
}
|
||||
|
||||
/* Ensure container has proper overflow */
|
||||
.container-fluid.mt-2.mb-2 {
|
||||
overflow: visible !important;
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
/* Fix notebook stacking context */
|
||||
.o_notebook {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Ensure dropdown appears above everything */
|
||||
.modal-open .o_field_many2one .o_m2o_dropdown {
|
||||
z-index: 1061 !important;
|
||||
}
|
||||
|
|
@ -1,4 +1,75 @@
|
|||
<odoo>
|
||||
<record id="hr_applicant_view_form_css_fix" model="ir.ui.view">
|
||||
<field name="name">hr.applicant.view.form.css.fix</field>
|
||||
<field name="model">hr.applicant</field>
|
||||
<field name="inherit_id" ref="hr_recruitment.hr_applicant_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//sheet" position="inside">
|
||||
<div class="o_form_custom_css_fix" style="display: none;">
|
||||
<style>
|
||||
/* Fix for notebook overlapping dropdowns */
|
||||
.o_notebook {
|
||||
overflow: visible !important;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.o_notebook .nav-tabs {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.o_notebook .tab-content {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.o_notebook .tab-pane {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
/* Critical fix - make sure the dropdown appears above notebook */
|
||||
.ui-autocomplete,
|
||||
.ui-menu,
|
||||
.o_m2o_dropdown,
|
||||
.o_field_many2one .o_m2o_dropdown,
|
||||
.dropdown-menu {
|
||||
z-index: 9999 !important;
|
||||
position: absolute !important;
|
||||
}
|
||||
|
||||
/* Fix for the specific many2one field container */
|
||||
.o_field_many2one {
|
||||
position: relative !important;
|
||||
z-index: 100 !important;
|
||||
}
|
||||
|
||||
/* When dropdown is open, ensure it's on top */
|
||||
.o_field_many2one.o_focused .o_m2o_dropdown {
|
||||
z-index: 10000 !important;
|
||||
position: fixed !important;
|
||||
max-height: 300px !important;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
/* Override any overflow hidden on parent containers */
|
||||
.container-fluid,
|
||||
.card,
|
||||
.card-body,
|
||||
.row,
|
||||
.col-lg-4,
|
||||
.col-lg-8,
|
||||
div[class*="col-"] {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
/* Specifically target the modal/dialog context */
|
||||
.modal .o_field_many2one .o_m2o_dropdown {
|
||||
z-index: 10001 !important;
|
||||
}
|
||||
</style>
|
||||
</div>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
<record id="hr_applicant_view_form_ui_customize"
|
||||
model="ir.ui.view">
|
||||
|
||||
|
|
|
|||
|
|
@ -23,11 +23,11 @@
|
|||
</div>
|
||||
<div>
|
||||
<h2 class="fw-bold text-dark mb-1">
|
||||
<field name="partner_name" nolabel="0"/>
|
||||
<field name="partner_name" nolabel="0" placeholder="Candidate Name"/>
|
||||
</h2>
|
||||
<div class="d-flex align-items-center g-1">
|
||||
<i class="fa fa-id-badge me-3 text-primary"/>
|
||||
<field name="candidate_sequence" nolabel="1"/>
|
||||
<field name="candidate_sequence" nolabel="1" placeholder="Sequence Id"/>
|
||||
</div>
|
||||
<div class="text-muted fs-5 mb-2">
|
||||
<field name="type_id" nolabel="1" readonly="1"/>
|
||||
|
|
@ -42,11 +42,11 @@
|
|||
<div class="d-flex flex-column gap-2">
|
||||
<div class="d-flex align-items-center g-1 mb-2">
|
||||
<i class="fa fa-envelope me-3 text-primary"/>
|
||||
<field name="email_from" nolabel="1"/>
|
||||
<field name="email_from" nolabel="1" placeholder="eg: abc@gmail.com"/>
|
||||
</div>
|
||||
<div class="d-flex align-items-center g-1">
|
||||
<i class="fa fa-mobile me-3 text-primary"/>
|
||||
<field name="partner_phone" nolabel="1"/>
|
||||
<field name="partner_phone" nolabel="1" placeholder="eg: 63xxx27xx0"/>
|
||||
</div>
|
||||
<div class="d-flex align-items-center g-1" invisible="not middle_name">
|
||||
<i class="fa fa-user me-3 text-primary"/>
|
||||
|
|
@ -58,7 +58,7 @@
|
|||
</div>
|
||||
<div class="d-flex align-items-center g-1">
|
||||
<i class="fa fa-linkedin me-3 text-primary"/>
|
||||
<field name="linkedin_profile" nolabel="1"/>
|
||||
<field name="linkedin_profile" nolabel="1" placeholder="Linkedin"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -24,8 +24,6 @@ class SurveyInvite(models.TransientModel):
|
|||
survey_line = self.applicant_id.survey_line_ids.filtered(
|
||||
lambda line: line.survey_id == self.survey_id
|
||||
)[:1]
|
||||
import pdb
|
||||
pdb.set_trace()
|
||||
latest_answer = survey_answers.sorted('create_date')[-1]
|
||||
survey_url = werkzeug.urls.url_join(
|
||||
self.survey_id.get_base_url(),
|
||||
|
|
|
|||
|
|
@ -1,3 +1,535 @@
|
|||
# # Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
#
|
||||
# import warnings
|
||||
# from datetime import datetime
|
||||
# from dateutil.relativedelta import relativedelta
|
||||
# from operator import itemgetter
|
||||
# from werkzeug.urls import url_encode
|
||||
#
|
||||
# from odoo import http, _
|
||||
# from odoo.addons.website_hr_recruitment.controllers.main import WebsiteHrRecruitment
|
||||
# from odoo.osv.expression import AND
|
||||
# from odoo.http import request
|
||||
# from odoo.tools import email_normalize
|
||||
# from odoo.tools.misc import groupby
|
||||
# import ast
|
||||
# import base64
|
||||
#
|
||||
#
|
||||
#
|
||||
# class WebsiteJobHrRecruitment(WebsiteHrRecruitment):
|
||||
# _jobs_per_page = 12
|
||||
#
|
||||
# def sitemap_jobs(env, rule, qs):
|
||||
# if not qs or qs.lower() in '/jobs':
|
||||
# yield {'loc': '/jobs'}
|
||||
#
|
||||
# @http.route([
|
||||
# '/jobs',
|
||||
# '/jobs/page/<int:page>',
|
||||
# ], type='http', auth="public", website=True, sitemap=sitemap_jobs)
|
||||
# def jobs(self, country_id=None, department_id=None, office_id=None, contract_type_id=None,
|
||||
# is_remote=False, is_other_department=False, is_untyped=None, page=1, search=None, **kwargs):
|
||||
# env = request.env(context=dict(request.env.context, show_address=True, no_tag_br=True))
|
||||
#
|
||||
# Country = env['res.country']
|
||||
# Jobs = env['hr.job.recruitment']
|
||||
# Department = env['hr.department']
|
||||
#
|
||||
# country = Country.browse(int(country_id)) if country_id else None
|
||||
# department = Department.browse(int(department_id)) if department_id else None
|
||||
# office_id = int(office_id) if office_id else None
|
||||
# contract_type_id = int(contract_type_id) if contract_type_id else None
|
||||
#
|
||||
# # Default search by user country
|
||||
# if not (country or department or office_id or contract_type_id or kwargs.get('all_countries')):
|
||||
# if request.geoip.country_code:
|
||||
# countries_ = Country.search([('code', '=', request.geoip.country_code)])
|
||||
# country = countries_[0] if countries_ else None
|
||||
# if country:
|
||||
# country_count = Jobs.search_count(AND([
|
||||
# request.website.website_domain(),
|
||||
# [('address_id.country_id', '=', country.id)]
|
||||
# ]))
|
||||
# if not country_count:
|
||||
# country = False
|
||||
#
|
||||
# options = {
|
||||
# 'displayDescription': True,
|
||||
# 'allowFuzzy': not request.params.get('noFuzzy'),
|
||||
# 'country_id': country.id if country else None,
|
||||
# 'department_id': department.id if department else None,
|
||||
# 'office_id': office_id,
|
||||
# 'contract_type_id': contract_type_id,
|
||||
# 'is_remote': is_remote,
|
||||
# 'is_other_department': is_other_department,
|
||||
# 'is_untyped': is_untyped,
|
||||
# }
|
||||
# total, details, fuzzy_search_term = request.website._search_with_fuzzy("job_requests", search,
|
||||
# limit=1000, order="is_published desc, sequence, no_of_recruitment desc", options=options)
|
||||
# # Browse jobs as superuser, because address is restricted
|
||||
# jobs = details[0].get('results', Jobs).sudo()
|
||||
#
|
||||
# def sort(records_list, field_name):
|
||||
# """ Sort records in the given collection according to the given
|
||||
# field name, alphabetically. None values instead of records are
|
||||
# placed at the end.
|
||||
#
|
||||
# :param list records_list: collection of records or None values
|
||||
# :param str field_name: field on which to sort
|
||||
# :return: sorted list
|
||||
# """
|
||||
# return sorted(
|
||||
# records_list,
|
||||
# key=lambda item: (item is None, item.sudo()[field_name] if item and item.sudo()[field_name] else ''),
|
||||
# )
|
||||
#
|
||||
# # Countries
|
||||
# if country or is_remote:
|
||||
# cross_country_options = options.copy()
|
||||
# cross_country_options.update({
|
||||
# 'allowFuzzy': False,
|
||||
# 'country_id': None,
|
||||
# 'is_remote': False,
|
||||
# })
|
||||
# cross_country_total, cross_country_details, _ = request.website._search_with_fuzzy("jobs",
|
||||
# fuzzy_search_term or search, limit=1000, order="is_published desc, sequence, no_of_recruitment desc",
|
||||
# options=cross_country_options)
|
||||
# # Browse jobs as superuser, because address is restricted
|
||||
# cross_country_jobs = cross_country_details[1].get('results', Jobs).sudo()
|
||||
# else:
|
||||
# cross_country_total = total
|
||||
# cross_country_jobs = jobs
|
||||
# country_offices = set(j.address_id or None for j in cross_country_jobs)
|
||||
# countries = sort(set(o and o.country_id or None for o in country_offices), 'name')
|
||||
# count_per_country = {'all': cross_country_total}
|
||||
# for c, jobs_list in groupby(cross_country_jobs, lambda job: job.address_id.country_id):
|
||||
# count_per_country[c] = len(jobs_list)
|
||||
# count_remote = len(cross_country_jobs.filtered(lambda job: not job.address_id))
|
||||
# if count_remote:
|
||||
# count_per_country[None] = count_remote
|
||||
#
|
||||
# # Departments
|
||||
# if department or is_other_department:
|
||||
# cross_department_options = options.copy()
|
||||
# cross_department_options.update({
|
||||
# 'allowFuzzy': False,
|
||||
# 'department_id': None,
|
||||
# 'is_other_department': False,
|
||||
# })
|
||||
# cross_department_total, cross_department_details, _ = request.website._search_with_fuzzy("jobs",
|
||||
# fuzzy_search_term or search, limit=1000, order="is_published desc, sequence, no_of_recruitment desc",
|
||||
# options=cross_department_options)
|
||||
# cross_department_jobs = cross_department_details[1].get('results', Jobs)
|
||||
# else:
|
||||
# cross_department_total = total
|
||||
# cross_department_jobs = jobs
|
||||
# departments = sort(set(j.department_id or None for j in cross_department_jobs), 'name')
|
||||
# count_per_department = {'all': cross_department_total}
|
||||
# for d, jobs_list in groupby(cross_department_jobs, lambda job: job.department_id):
|
||||
# count_per_department[d] = len(jobs_list)
|
||||
# count_other_department = len(cross_department_jobs.filtered(lambda job: not job.department_id))
|
||||
# if count_other_department:
|
||||
# count_per_department[None] = count_other_department
|
||||
#
|
||||
# # Offices
|
||||
# if office_id or is_remote:
|
||||
# cross_office_options = options.copy()
|
||||
# cross_office_options.update({
|
||||
# 'allowFuzzy': False,
|
||||
# 'office_id': None,
|
||||
# 'is_remote': False,
|
||||
# })
|
||||
# cross_office_total, cross_office_details, _ = request.website._search_with_fuzzy("jobs",
|
||||
# fuzzy_search_term or search, limit=1000, order="is_published desc, sequence, no_of_recruitment desc",
|
||||
# options=cross_office_options)
|
||||
# # Browse jobs as superuser, because address is restricted
|
||||
# cross_office_jobs = cross_office_details[1].get('results', Jobs).sudo()
|
||||
# else:
|
||||
# cross_office_total = total
|
||||
# cross_office_jobs = jobs
|
||||
# offices = sort(set(j.address_id or None for j in cross_office_jobs), 'city')
|
||||
# count_per_office = {'all': cross_office_total}
|
||||
# for o, jobs_list in groupby(cross_office_jobs, lambda job: job.address_id):
|
||||
# count_per_office[o] = len(jobs_list)
|
||||
# count_remote = len(cross_office_jobs.filtered(lambda job: not job.address_id))
|
||||
# if count_remote:
|
||||
# count_per_office[None] = count_remote
|
||||
#
|
||||
# # Employment types
|
||||
# if contract_type_id or is_untyped:
|
||||
# cross_type_options = options.copy()
|
||||
# cross_type_options.update({
|
||||
# 'allowFuzzy': False,
|
||||
# 'contract_type_id': None,
|
||||
# 'is_untyped': False,
|
||||
# })
|
||||
# cross_type_total, cross_type_details, _ = request.website._search_with_fuzzy("jobs",
|
||||
# fuzzy_search_term or search, limit=1000, order="is_published desc, sequence, no_of_recruitment desc",
|
||||
# options=cross_type_options)
|
||||
# cross_type_jobs = cross_type_details[1].get('results', Jobs)
|
||||
# else:
|
||||
# cross_type_total = total
|
||||
# cross_type_jobs = jobs
|
||||
# employment_types = sort(set(j.contract_type_id for j in jobs if j.contract_type_id), 'name')
|
||||
# count_per_employment_type = {'all': cross_type_total}
|
||||
# for t, jobs_list in groupby(cross_type_jobs, lambda job: job.contract_type_id):
|
||||
# count_per_employment_type[t] = len(jobs_list)
|
||||
# count_untyped = len(cross_type_jobs.filtered(lambda job: not job.contract_type_id))
|
||||
# if count_untyped:
|
||||
# count_per_employment_type[None] = count_untyped
|
||||
#
|
||||
# pager = request.website.pager(
|
||||
# url=request.httprequest.path.partition('/page/')[0],
|
||||
# url_args=request.httprequest.args,
|
||||
# total=total,
|
||||
# page=page,
|
||||
# step=self._jobs_per_page,
|
||||
# )
|
||||
# offset = pager['offset']
|
||||
# jobs = jobs[offset:offset + self._jobs_per_page]
|
||||
#
|
||||
# office = env['res.partner'].browse(int(office_id)) if office_id else None
|
||||
# contract_type = env['hr.contract.type'].browse(int(contract_type_id)) if contract_type_id else None
|
||||
# # Render page
|
||||
# return request.render("website_hr_recruitment_extended.recruitment_index", {
|
||||
# 'jobs': jobs,
|
||||
# 'countries': countries,
|
||||
# 'departments': departments,
|
||||
# 'offices': offices,
|
||||
# 'employment_types': employment_types,
|
||||
# 'country_id': country,
|
||||
# 'department_id': department,
|
||||
# 'office_id': office,
|
||||
# 'contract_type_id': contract_type,
|
||||
# 'is_remote': is_remote,
|
||||
# 'is_other_department': is_other_department,
|
||||
# 'is_untyped': is_untyped,
|
||||
# 'pager': pager,
|
||||
# 'search': fuzzy_search_term or search,
|
||||
# 'search_count': total,
|
||||
# 'original_search': fuzzy_search_term and search,
|
||||
# 'count_per_country': count_per_country,
|
||||
# 'count_per_department': count_per_department,
|
||||
# 'count_per_office': count_per_office,
|
||||
# 'count_per_employment_type': count_per_employment_type,
|
||||
# })
|
||||
#
|
||||
# @http.route('/jobs/add', type='json', auth="user", website=True)
|
||||
# def jobs_add(self, **kwargs):
|
||||
# # avoid branding of website_description by setting rendering_bundle in context
|
||||
# job = request.env['hr.job.recruitment'].with_context(rendering_bundle=True).create({
|
||||
# 'name': _('Job Title'),
|
||||
# })
|
||||
# return f"/jobs/{request.env['ir.http']._slug(job)}"
|
||||
#
|
||||
# @http.route('''/jobs/detail/<model("hr.job.recruitment"):job>''', type='http', auth="public", website=True, sitemap=True)
|
||||
# def jobs_detail(self, job, **kwargs):
|
||||
# redirect_url = f"/jobs/{request.env['ir.http']._slug(job)}"
|
||||
# return request.redirect(redirect_url, code=301)
|
||||
#
|
||||
# @http.route('''/jobs/<model("hr.job.recruitment"):job>''', type='http', auth="public", website=True, sitemap=True)
|
||||
# def job(self, job, **kwargs):
|
||||
# return request.render("website_hr_recruitment_extended.recruitment_detail", {
|
||||
# 'job': job,
|
||||
# 'main_object': job,
|
||||
# })
|
||||
#
|
||||
# @http.route('''/jobs/apply/<model("hr.job.recruitment"):job>''', type='http', auth="public", website=True, sitemap=True)
|
||||
# def jobs_apply(self, job, **kwargs):
|
||||
# error = {}
|
||||
# default = {}
|
||||
# if 'website_hr_recruitment_error' in request.session:
|
||||
# error = request.session.pop('website_hr_recruitment_error')
|
||||
# default = request.session.pop('website_hr_recruitment_default')
|
||||
# return request.render("website_hr_recruitment_extended.recruitment_apply", {
|
||||
# 'job': job,
|
||||
# 'error': error,
|
||||
# 'default': default,
|
||||
# })
|
||||
#
|
||||
# # Compatibility routes
|
||||
#
|
||||
# @http.route([
|
||||
# '/jobs/country/<model("res.country"):country>',
|
||||
# '/jobs/department/<model("hr.department"):department>',
|
||||
# '/jobs/country/<model("res.country"):country>/department/<model("hr.department"):department>',
|
||||
# '/jobs/office/<int:office_id>',
|
||||
# '/jobs/country/<model("res.country"):country>/office/<int:office_id>',
|
||||
# '/jobs/department/<model("hr.department"):department>/office/<int:office_id>',
|
||||
# '/jobs/country/<model("res.country"):country>/department/<model("hr.department"):department>/office/<int:office_id>',
|
||||
# '/jobs/employment_type/<int:contract_type_id>',
|
||||
# '/jobs/country/<model("res.country"):country>/employment_type/<int:contract_type_id>',
|
||||
# '/jobs/department/<model("hr.department"):department>/employment_type/<int:contract_type_id>',
|
||||
# '/jobs/office/<int:office_id>/employment_type/<int:contract_type_id>',
|
||||
# '/jobs/country/<model("res.country"):country>/department/<model("hr.department"):department>/employment_type/<int:contract_type_id>',
|
||||
# '/jobs/country/<model("res.country"):country>/office/<int:office_id>/employment_type/<int:contract_type_id>',
|
||||
# '/jobs/department/<model("hr.department"):department>/office/<int:office_id>/employment_type/<int:contract_type_id>',
|
||||
# '/jobs/country/<model("res.country"):country>/department/<model("hr.department"):department>/office/<int:office_id>/employment_type/<int:contract_type_id>',
|
||||
# ], type='http', auth="public", website=True, sitemap=False)
|
||||
# def jobs_compatibility(self, country=None, department=None, office_id=None, contract_type_id=None, **kwargs):
|
||||
# """
|
||||
# Deprecated since Odoo 16.3: those routes are kept by compatibility.
|
||||
# They should not be used in Odoo code anymore.
|
||||
# """
|
||||
# warnings.warn(
|
||||
# "This route is deprecated since Odoo 16.3: the jobs list is now available at /jobs or /jobs/page/XXX",
|
||||
# DeprecationWarning
|
||||
# )
|
||||
# url_params = {
|
||||
# 'country_id': country and country.id,
|
||||
# 'department_id': department and department.id,
|
||||
# 'office_id': office_id,
|
||||
# 'contract_type_id': contract_type_id,
|
||||
# **kwargs,
|
||||
# }
|
||||
# return request.redirect(
|
||||
# '/jobs?%s' % url_encode(url_params),
|
||||
# code=301,
|
||||
# )
|
||||
#
|
||||
#
|
||||
# @http.route('/hr_recruitment_extended/fetch_hr_recruitment_degree', type='json', auth="public", website=True)
|
||||
# def fetch_recruitment_degrees(self):
|
||||
# degrees = {}
|
||||
# all_degrees = http.request.env['hr.recruitment.degree'].sudo().search([])
|
||||
# if all_degrees:
|
||||
# for degree in all_degrees:
|
||||
# degrees[degree.id] = degree.name
|
||||
# return degrees
|
||||
#
|
||||
# @http.route('/hr_recruitment_extended/fetch_preferred_locations', type='json', auth="public", website=True)
|
||||
# def fetch_preferred_locations(self, loc_ids):
|
||||
# locations = {}
|
||||
# for id in loc_ids:
|
||||
# location = http.request.env['hr.location'].sudo().browse(id)
|
||||
# if location:
|
||||
# locations[location.id] = location.location_name
|
||||
# return locations
|
||||
#
|
||||
# @http.route('/hr_recruitment_extended/fetch_preferred_skills', type='json', auth="public", website=True)
|
||||
# def fetch_preferred_skills(self, skill_ids,fetch_others=False):
|
||||
# skills = {}
|
||||
# Skill = http.request.env['hr.skill'].sudo()
|
||||
# SkillLevel = http.request.env['hr.skill.level'].sudo()
|
||||
#
|
||||
# if fetch_others:
|
||||
# for skill in Skill.search([('id','not in',skill_ids)]):
|
||||
# levels = SkillLevel.search([('skill_type_id', '=', skill.skill_type_id.id)],order='sequence')
|
||||
# skills[skill.id] = {
|
||||
# 'id': skill.id,
|
||||
# 'name': skill.name,
|
||||
# 'levels': [{'id': lvl.id, 'name': lvl.name, 'percentage': lvl.level_progress, 'sequence': lvl.sequence} for lvl in levels]
|
||||
# }
|
||||
# else:
|
||||
# for skill in Skill.browse(skill_ids):
|
||||
# levels = SkillLevel.search([('skill_type_id', '=', skill.skill_type_id.id)],order='sequence')
|
||||
# skills[skill.id] = {
|
||||
# 'id': skill.id,
|
||||
# 'name': skill.name,
|
||||
# 'levels': [{'id': lvl.id, 'name': lvl.name, 'percentage': lvl.level_progress, 'sequence': lvl.sequence} for lvl in levels]
|
||||
# }
|
||||
#
|
||||
# return skills
|
||||
#
|
||||
# @http.route('/website_hr_recruitment_extended/check_recent_application', type='json', auth="public", website=True)
|
||||
# def check_recent_application(self, value, job_id):
|
||||
# # Function to check if the applicant has an existing record based on email, phone, or linkedin
|
||||
# if value:
|
||||
# def refused_applicants_condition(applicant):
|
||||
# return not applicant.active \
|
||||
# and applicant.hr_job_recruitment.id == int(job_id) \
|
||||
# and applicant.create_date >= (datetime.now() - relativedelta(months=6))
|
||||
# # Search for applicants with the same email, phone, or linkedin (only if the value is not False/None)
|
||||
# applicants_with_similar_info = http.request.env['hr.applicant'].sudo().search([
|
||||
# ('hr_job_recruitment','=',int(job_id)),
|
||||
# '|',
|
||||
# ('email_normalized', '=', email_normalize(value)),
|
||||
# '|',
|
||||
# ('partner_phone', '=', value),
|
||||
# ('linkedin_profile', '=ilike', value),
|
||||
# ], order='create_date DESC')
|
||||
#
|
||||
# if not applicants_with_similar_info:
|
||||
# return {'message':None}
|
||||
# # Group applications by their status
|
||||
# applications_by_status = applicants_with_similar_info.grouped('application_status')
|
||||
#
|
||||
# # Check for refused applicants with the same value within the last 6 months
|
||||
# refused_applicants = applications_by_status.get('refused', http.request.env['hr.applicant'])
|
||||
# if any(applicant for applicant in refused_applicants if refused_applicants_condition(applicant)):
|
||||
# return {
|
||||
# 'message': _(
|
||||
# 'We\'ve found a previous closed application in our system within the last 6 months.'
|
||||
# ' Please consider before applying in order not to duplicate efforts.'
|
||||
# )
|
||||
# }
|
||||
#
|
||||
# # Check for ongoing applications with the same value
|
||||
# ongoing_applications = applications_by_status.get('ongoing', [])
|
||||
# if ongoing_applications:
|
||||
# ongoing_application = ongoing_applications[0]
|
||||
# if ongoing_application.hr_job_recruitment.id == int(job_id):
|
||||
# recruiter_contact = "" if not ongoing_application.user_id else _(
|
||||
# ' In case of issue, contact %(contact_infos)s',
|
||||
# contact_infos=", ".join(
|
||||
# [value for value in itemgetter('name', 'email', 'phone')(ongoing_application.user_id) if value]
|
||||
# ))
|
||||
#
|
||||
# error_message = 'An application already exists for %s Duplicates might be rejected. %s '%(value,recruiter_contact)
|
||||
# print(error_message)
|
||||
# return {
|
||||
# 'message': _(error_message)
|
||||
# }
|
||||
#
|
||||
# # If no existing application found, show the following message
|
||||
# return {
|
||||
# 'message': _(
|
||||
# 'We found a recent application with a similar name, email, phone number.'
|
||||
# ' You can continue if it\'s not a mistake.'
|
||||
# )
|
||||
# }
|
||||
# else:
|
||||
# return {'message': None}
|
||||
#
|
||||
# def _should_log_authenticate_message(self, record):
|
||||
# if record._name == "hr.applicant" and not request.session.uid:
|
||||
# return False
|
||||
# return super()._should_log_authenticate_message(record)
|
||||
#
|
||||
# def extract_data(self, model, values):
|
||||
# candidate = False
|
||||
# extracted_resume = values.pop('resume_base64', None)
|
||||
# current_ctc = values.pop('current_ctc', None)
|
||||
# expected_ctc = values.pop('expected_ctc', None)
|
||||
# available_joining_date = values.pop('available_joining_date', None)
|
||||
# exp_type = values.pop('exp_type', None)
|
||||
# current_location = values.pop('current_location', None)
|
||||
# preferred_locations_str = values.pop('preferred_locations', '')
|
||||
# department_id = values.pop('department_id',None)
|
||||
# hr_job_recruitment = values.pop('job_id', None)
|
||||
# preferred_locations = [int(x) for x in preferred_locations_str.split(',')] if len(
|
||||
# preferred_locations_str) > 0 else []
|
||||
# current_organization = values.pop('current_organization', None)
|
||||
# notice_period = values.pop('notice_period', 0)
|
||||
# notice_period_type = values.pop('notice_period_type', 'day')
|
||||
# experience_years = values.pop('experience_years',0)
|
||||
# experience_months = values.pop('experience_months',0)
|
||||
#
|
||||
# # If there are months, convert everything to months
|
||||
# if int(experience_months) > 0:
|
||||
# total_experience = (int(experience_years) * 12) + int(experience_months)
|
||||
# total_experience_type = 'month'
|
||||
# else:
|
||||
# total_experience = int(experience_years)
|
||||
# total_experience_type = 'year'
|
||||
#
|
||||
# if extracted_resume:
|
||||
# attachment = request.env.ref("hr_recruitment_extended.employee_recruitment_attachments_preview")
|
||||
# file = attachment.sudo().write({
|
||||
# 'datas': extracted_resume,
|
||||
# })
|
||||
# if file:
|
||||
# resume_type = attachment.mimetype
|
||||
# resume_name = attachment.name
|
||||
# else:
|
||||
# resume_type = ''
|
||||
# resume_name = ''
|
||||
# else:
|
||||
# resume_type = ''
|
||||
# resume_name = ''
|
||||
#
|
||||
# skill_dict = {key: ast.literal_eval(value) for key,value in values.items() if "skill" in key and value != '0'}
|
||||
#
|
||||
# if model.model == 'hr.applicant':
|
||||
# partner_name = values.pop('full_name', None)
|
||||
# partner_phone = values.pop('partner_phone', None)
|
||||
# alternate_phone = values.pop('alternate_phone', None)
|
||||
# partner_email = values.pop('email_from', None)
|
||||
# degree = values.pop('degree', None)
|
||||
# if partner_phone and partner_email:
|
||||
# candidate = request.env['hr.candidate'].sudo().search([
|
||||
# '|', ('email_from', '=', partner_email),
|
||||
# ('partner_phone', '=', partner_phone),
|
||||
# ], limit=1)
|
||||
#
|
||||
# if candidate:
|
||||
# candidate.sudo().write({
|
||||
# 'partner_name': partner_name,
|
||||
# 'alternate_phone': alternate_phone,
|
||||
# 'email_from': partner_email,
|
||||
# 'partner_phone': partner_phone,
|
||||
# 'type_id': int(degree) if degree.isdigit() else False,
|
||||
# 'resume': extracted_resume,
|
||||
# 'resume_type': resume_type,
|
||||
# 'resume_name': resume_name,
|
||||
# })
|
||||
# if not candidate:
|
||||
# candidate = request.env['hr.candidate'].sudo().create({
|
||||
# 'partner_name': partner_name,
|
||||
# 'email_from': partner_email,
|
||||
# 'partner_phone': partner_phone,
|
||||
# 'alternate_phone': alternate_phone,
|
||||
# 'type_id': int(degree) if degree.isdigit() else False,
|
||||
# 'resume': extracted_resume,
|
||||
# 'resume_type': resume_type,
|
||||
# 'resume_name': resume_name,
|
||||
# })
|
||||
#
|
||||
# if len(skill_dict) > 0:
|
||||
# # candidate_skills_list = []
|
||||
# for key, value in skill_dict.items():
|
||||
# candidate_skills = dict()
|
||||
# skill_type_id = request.env['hr.skill'].sudo().browse(int(key.split("_")[1])).skill_type_id.id
|
||||
# candidate_skills['candidate_id'] = candidate.id
|
||||
# candidate_skills['skill_id'] = int(key.split("_")[1])
|
||||
# candidate_skills['skill_level_id'] = value[0]
|
||||
# candidate_skills['level_progress'] = value[1]
|
||||
# candidate_skills['skill_type_id'] = skill_type_id
|
||||
# # skill = request.env['hr.candidate.skill'].sudo().create(candidate_skills)
|
||||
# # candidate_skills_list.append(skill.id)
|
||||
# if candidate.candidate_skill_ids:
|
||||
# if candidate_skills['skill_id'] not in candidate.candidate_skill_ids.skill_id.ids:
|
||||
# candidate.write({'candidate_skill_ids':[(0,4,candidate_skills)]})
|
||||
# else:
|
||||
# candidate.write({'candidate_skill_ids':[(0,4,candidate_skills)]})
|
||||
#
|
||||
#
|
||||
# else:
|
||||
# skills = None
|
||||
#
|
||||
# values['partner_name'] = partner_name
|
||||
# if partner_phone:
|
||||
# values['partner_phone'] = partner_phone
|
||||
# if partner_email:
|
||||
# values['email_from'] = partner_email
|
||||
# notice_period_str = 'N/A'
|
||||
# if notice_period and notice_period_type:
|
||||
# notice_period_str = str(notice_period) + ' ' + str(notice_period_type)
|
||||
# data = super().extract_data(model, values)
|
||||
# data['record']['current_ctc'] = float(current_ctc if current_ctc else 0)
|
||||
# data['record']['salary_expected'] = float(expected_ctc if expected_ctc else 0)
|
||||
# data['record']['exp_type'] = exp_type if exp_type else 'fresher'
|
||||
# data['record']['current_location'] = current_location if current_location else ''
|
||||
# data['record']['current_organization'] = current_organization if current_organization else ''
|
||||
# data['record']['notice_period'] = notice_period_str if notice_period_str else 'N/A'
|
||||
# data['record']['notice_period_type'] = notice_period_type if notice_period_type else 'day'
|
||||
# data['record']['hr_job_recruitment'] = int(hr_job_recruitment) if str(hr_job_recruitment).isdigit() else ''
|
||||
# data['record']['department_id'] = int(department_id) if str(department_id).isdigit() else ''
|
||||
# data['record']['availability'] = datetime.strptime(available_joining_date, '%Y-%m-%d').date() if available_joining_date else ''
|
||||
#
|
||||
# data['record']['total_exp'] = total_experience if total_experience else 0
|
||||
# data['record']['total_exp_type'] = total_experience_type if total_experience_type else 'year'
|
||||
# # data['record']['resume'] = resume if resume else None
|
||||
# if len(preferred_locations_str) > 0:
|
||||
# data['record']['preferred_location'] = preferred_locations
|
||||
# if candidate:
|
||||
# data['record']['candidate_id'] = candidate.id
|
||||
# data['record']['type_id'] = candidate.type_id.id
|
||||
#
|
||||
# return data
|
||||
#
|
||||
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import warnings
|
||||
|
|
@ -70,6 +602,32 @@ class WebsiteJobHrRecruitment(WebsiteHrRecruitment):
|
|||
# Browse jobs as superuser, because address is restricted
|
||||
jobs = details[0].get('results', Jobs).sudo()
|
||||
|
||||
if search:
|
||||
|
||||
search = search.strip()
|
||||
|
||||
custom_jobs = Jobs.sudo().search([
|
||||
'|', '|', '|',
|
||||
('name', 'ilike', search),
|
||||
('address_id.city', 'ilike', search),
|
||||
('skill_ids.name', 'ilike', search),
|
||||
('secondary_skill_ids.name', 'ilike', search),
|
||||
])
|
||||
|
||||
# Merge fuzzy jobs + custom jobs
|
||||
jobs = (jobs | custom_jobs).sorted(
|
||||
key=lambda j: (
|
||||
not j.is_published,
|
||||
j.sequence,
|
||||
-j.no_of_recruitment
|
||||
)
|
||||
)
|
||||
|
||||
total = len(jobs)
|
||||
|
||||
else:
|
||||
jobs = jobs
|
||||
|
||||
def sort(records_list, field_name):
|
||||
""" Sort records in the given collection according to the given
|
||||
field name, alphabetically. None values instead of records are
|
||||
|
|
@ -307,28 +865,49 @@ class WebsiteJobHrRecruitment(WebsiteHrRecruitment):
|
|||
locations[location.id] = location.location_name
|
||||
return locations
|
||||
|
||||
@http.route('/hr_recruitment_extended/fetch_preferred_skills', type='json', auth="public", website=True)
|
||||
def fetch_preferred_skills(self, skill_ids,fetch_others=False):
|
||||
# @http.route('/hr_recruitment_extended/fetch_preferred_skills', type='json', auth="public", website=True)
|
||||
# def fetch_preferred_skills(self, skill_ids,fetch_others=False):
|
||||
# skills = {}
|
||||
# Skill = http.request.env['hr.skill'].sudo()
|
||||
# SkillLevel = http.request.env['hr.skill.level'].sudo()
|
||||
#
|
||||
# if fetch_others:
|
||||
# for skill in Skill.search([('id','not in',skill_ids)]):
|
||||
# levels = SkillLevel.search([('skill_type_id', '=', skill.skill_type_id.id)],order='sequence')
|
||||
# skills[skill.id] = {
|
||||
# 'id': skill.id,
|
||||
# 'name': skill.name,
|
||||
# 'levels': [{'id': lvl.id, 'name': lvl.name, 'percentage': lvl.level_progress, 'sequence': lvl.sequence} for lvl in levels]
|
||||
# }
|
||||
# else:
|
||||
# for skill in Skill.browse(skill_ids):
|
||||
# levels = SkillLevel.search([('skill_type_id', '=', skill.skill_type_id.id)],order='sequence')
|
||||
# skills[skill.id] = {
|
||||
# 'id': skill.id,
|
||||
# 'name': skill.name,
|
||||
# 'levels': [{'id': lvl.id, 'name': lvl.name, 'percentage': lvl.level_progress, 'sequence': lvl.sequence} for lvl in levels]
|
||||
# }
|
||||
#
|
||||
# return skills
|
||||
|
||||
@http.route('/hr_recruitment_extended/fetch_preferred_skills',type='json',auth="public",website=True)
|
||||
def fetch_preferred_skills(self, skill_ids, fetch_others=False):
|
||||
|
||||
skills = {}
|
||||
Skill = http.request.env['hr.skill'].sudo()
|
||||
SkillLevel = http.request.env['hr.skill.level'].sudo()
|
||||
|
||||
domain = []
|
||||
|
||||
if fetch_others:
|
||||
for skill in Skill.search([('id','not in',skill_ids)]):
|
||||
levels = SkillLevel.search([('skill_type_id', '=', skill.skill_type_id.id)],order='sequence')
|
||||
skills[skill.id] = {
|
||||
'id': skill.id,
|
||||
'name': skill.name,
|
||||
'levels': [{'id': lvl.id, 'name': lvl.name, 'percentage': lvl.level_progress, 'sequence': lvl.sequence} for lvl in levels]
|
||||
}
|
||||
domain = [('id', 'not in', skill_ids)]
|
||||
else:
|
||||
for skill in Skill.browse(skill_ids):
|
||||
levels = SkillLevel.search([('skill_type_id', '=', skill.skill_type_id.id)],order='sequence')
|
||||
skills[skill.id] = {
|
||||
'id': skill.id,
|
||||
'name': skill.name,
|
||||
'levels': [{'id': lvl.id, 'name': lvl.name, 'percentage': lvl.level_progress, 'sequence': lvl.sequence} for lvl in levels]
|
||||
}
|
||||
domain = [('id', 'in', skill_ids)]
|
||||
|
||||
for skill in Skill.search(domain):
|
||||
skills[skill.id] = {
|
||||
'id': skill.id,
|
||||
'name': skill.name,
|
||||
}
|
||||
|
||||
return skills
|
||||
|
||||
|
|
@ -476,27 +1055,93 @@ class WebsiteJobHrRecruitment(WebsiteHrRecruitment):
|
|||
'resume_name': resume_name,
|
||||
})
|
||||
|
||||
if len(skill_dict) > 0:
|
||||
# candidate_skills_list = []
|
||||
for key, value in skill_dict.items():
|
||||
candidate_skills = dict()
|
||||
skill_type_id = request.env['hr.skill'].sudo().browse(int(key.split("_")[1])).skill_type_id.id
|
||||
candidate_skills['candidate_id'] = candidate.id
|
||||
candidate_skills['skill_id'] = int(key.split("_")[1])
|
||||
candidate_skills['skill_level_id'] = value[0]
|
||||
candidate_skills['level_progress'] = value[1]
|
||||
candidate_skills['skill_type_id'] = skill_type_id
|
||||
# skill = request.env['hr.candidate.skill'].sudo().create(candidate_skills)
|
||||
# candidate_skills_list.append(skill.id)
|
||||
if candidate.candidate_skill_ids:
|
||||
if candidate_skills['skill_id'] not in candidate.candidate_skill_ids.skill_id.ids:
|
||||
candidate.write({'candidate_skill_ids':[(0,4,candidate_skills)]})
|
||||
else:
|
||||
candidate.write({'candidate_skill_ids':[(0,4,candidate_skills)]})
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Skills From Portal
|
||||
# ---------------------------------------------------------
|
||||
|
||||
else:
|
||||
skills = None
|
||||
primary_skill_ids = request.httprequest.form.getlist(
|
||||
'primary_skill_ids'
|
||||
)
|
||||
|
||||
secondary_skill_ids = request.httprequest.form.getlist(
|
||||
'secondary_skill_ids'
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Merge & Remove Duplicates
|
||||
# ---------------------------------------------------------
|
||||
|
||||
all_skill_ids = []
|
||||
|
||||
for skill_group in (primary_skill_ids + secondary_skill_ids):
|
||||
|
||||
if skill_group:
|
||||
|
||||
for skill_id in skill_group.split(','):
|
||||
|
||||
if skill_id.strip().isdigit():
|
||||
all_skill_ids.append(int(skill_id.strip()))
|
||||
|
||||
# Remove duplicates
|
||||
all_skill_ids = list(set(all_skill_ids))
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Create Candidate Skill Lines
|
||||
# ---------------------------------------------------------
|
||||
|
||||
existing_skill_ids = candidate.candidate_skill_ids.mapped(
|
||||
'skill_id'
|
||||
).ids
|
||||
|
||||
for skill_id in all_skill_ids:
|
||||
|
||||
if skill_id not in existing_skill_ids:
|
||||
skill = request.env['hr.skill'].sudo().browse(skill_id)
|
||||
|
||||
default_level = skill.skill_type_id.skill_level_ids.filtered(
|
||||
lambda l: l.default_level
|
||||
)[:1]
|
||||
|
||||
candidate.write({
|
||||
'candidate_skill_ids': [(0, 0, {
|
||||
'candidate_id': candidate.id,
|
||||
'skill_id': skill.id,
|
||||
'skill_type_id': skill.skill_type_id.id,
|
||||
'skill_level_id': default_level.id if default_level else False,
|
||||
})]
|
||||
})
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Store Quick Skills
|
||||
# ---------------------------------------------------------
|
||||
|
||||
if all_skill_ids:
|
||||
candidate.write({
|
||||
'quick_skill_ids': [(6, 0, all_skill_ids)]
|
||||
})
|
||||
|
||||
# if len(skill_dict) > 0:
|
||||
# # candidate_skills_list = []
|
||||
# for key, value in skill_dict.items():
|
||||
# candidate_skills = dict()
|
||||
# skill_type_id = request.env['hr.skill'].sudo().browse(int(key.split("_")[1])).skill_type_id.id
|
||||
# candidate_skills['candidate_id'] = candidate.id
|
||||
# candidate_skills['skill_id'] = int(key.split("_")[1])
|
||||
# candidate_skills['skill_level_id'] = value[0]
|
||||
# candidate_skills['level_progress'] = value[1]
|
||||
# candidate_skills['skill_type_id'] = skill_type_id
|
||||
# # skill = request.env['hr.candidate.skill'].sudo().create(candidate_skills)
|
||||
# # candidate_skills_list.append(skill.id)
|
||||
# if candidate.candidate_skill_ids:
|
||||
# if candidate_skills['skill_id'] not in candidate.candidate_skill_ids.skill_id.ids:
|
||||
# candidate.write({'candidate_skill_ids':[(0,4,candidate_skills)]})
|
||||
# else:
|
||||
# candidate.write({'candidate_skill_ids':[(0,4,candidate_skills)]})
|
||||
#
|
||||
#
|
||||
# else:
|
||||
# skills = None
|
||||
|
||||
values['partner_name'] = partner_name
|
||||
if partner_phone:
|
||||
|
|
@ -527,5 +1172,4 @@ class WebsiteJobHrRecruitment(WebsiteHrRecruitment):
|
|||
data['record']['candidate_id'] = candidate.id
|
||||
data['record']['type_id'] = candidate.type_id.id
|
||||
|
||||
return data
|
||||
|
||||
return data
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
<field name="inherit_id" ref="hr_recruitment_extended.view_job_recruitment_kanban"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//kanban" position="attributes">
|
||||
<attribute name="default_order">is_published desc, is_favorite desc, create_date DESC</attribute>
|
||||
<attribute name="default_order">is_favorite desc, create_date DESC</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//t[@t-name='card']/div" position="before">
|
||||
<field name="website_published" invisible="1"/>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue