Recruitement Dashboards
This commit is contained in:
parent
eb17d717dd
commit
945aedc0b4
|
|
@ -0,0 +1 @@
|
|||
from . import models
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"name": "HR Recruitment Dashboards",
|
||||
"version": "18.0.1.0.0",
|
||||
"category": "Human Resources/Recruitment",
|
||||
"summary": "Interactive recruitment dashboards with ApexCharts",
|
||||
"description": """
|
||||
High-end recruitment dashboards with filters, KPIs, ApexCharts, and chart drilldowns.
|
||||
""",
|
||||
"author": "Pranay",
|
||||
"website": "https://www.ftprotech.com",
|
||||
"depends": [
|
||||
"web",
|
||||
"hr_recruitment_extended",
|
||||
],
|
||||
"data": [
|
||||
"views/dashboard_menu.xml",
|
||||
],
|
||||
"assets": {
|
||||
"web.assets_backend": [
|
||||
"hr_recruitment_dashboards/static/src/lib/apexcharts.min.js",
|
||||
"hr_recruitment_dashboards/static/src/js/recruitment_dashboard.js",
|
||||
"hr_recruitment_dashboards/static/src/xml/recruitment_dashboard.xml",
|
||||
"hr_recruitment_dashboards/static/src/scss/recruitment_dashboard.scss",
|
||||
],
|
||||
},
|
||||
"installable": True,
|
||||
"application": False,
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
from . import recruitment_dashboard
|
||||
|
|
@ -0,0 +1,383 @@
|
|||
from collections import defaultdict
|
||||
from datetime import datetime, time
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class HrRecruitmentDashboard(models.AbstractModel):
|
||||
_name = 'hr.recruitment.dashboard'
|
||||
_description = 'HR Recruitment Dashboard'
|
||||
|
||||
@api.model
|
||||
def get_dashboard_data(self, filters=None):
|
||||
filters = filters or {}
|
||||
applicant_domain = self._get_applicant_domain(filters)
|
||||
job_domain = self._get_job_domain(filters)
|
||||
|
||||
Applicant = self.env['hr.applicant'].with_context(active_test=False)
|
||||
JobRecruitment = self.env['hr.job.recruitment'].with_context(active_test=False)
|
||||
applicants = Applicant.search(applicant_domain)
|
||||
jobs = JobRecruitment.search(job_domain)
|
||||
active_applicants = applicants.filtered('active')
|
||||
submitted_applicants = applicants.filtered(lambda item: item.submitted_to_client)
|
||||
hired_applicants = applicants.filtered(lambda item: item.recruitment_stage_id.hired_stage)
|
||||
refused_applicants = applicants.filtered(lambda item: not item.active or item.refuse_reason_id)
|
||||
on_hold_applicants = applicants.filtered(lambda item: item.is_on_hold)
|
||||
|
||||
return {
|
||||
'filters': self._get_filter_options(),
|
||||
'summary': self._get_summary_cards(jobs, applicants, active_applicants, submitted_applicants, hired_applicants, refused_applicants, on_hold_applicants),
|
||||
'charts': {
|
||||
'pipeline': self._get_pipeline_chart(applicants, applicant_domain),
|
||||
'status': self._get_status_chart(jobs, job_domain),
|
||||
'recruiters': self._get_recruiter_chart(applicants, applicant_domain),
|
||||
'funnel': self._get_funnel_chart(applicants, submitted_applicants, hired_applicants, refused_applicants, applicant_domain),
|
||||
'priority': self._get_priority_chart(jobs, job_domain),
|
||||
'trend': self._get_trend_chart(applicant_domain, filters),
|
||||
'skill_match': self._get_skill_match_chart(applicants, applicant_domain),
|
||||
},
|
||||
'tables': {
|
||||
'top_jobs': self._get_top_jobs(jobs),
|
||||
'attention': self._get_attention_items(jobs, on_hold_applicants),
|
||||
},
|
||||
}
|
||||
|
||||
@api.model
|
||||
def get_drilldown_action(self, model_name, domain, title=False, view_mode=False):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': title or _('Recruitment Records'),
|
||||
'res_model': model_name,
|
||||
'domain': domain or [],
|
||||
'view_mode': view_mode or ('kanban,list,form' if model_name == 'hr.applicant' else 'list,form'),
|
||||
'views': self._get_action_views(model_name),
|
||||
'target': 'current',
|
||||
'context': {'active_test': False},
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _get_action_views(self, model_name):
|
||||
if model_name == 'hr.applicant':
|
||||
return [(False, 'kanban'), (False, 'list'), (False, 'form')]
|
||||
if model_name == 'hr.job.recruitment':
|
||||
return [(False, 'kanban'), (False, 'list'), (False, 'form')]
|
||||
return [(False, 'list'), (False, 'form')]
|
||||
|
||||
@api.model
|
||||
def _get_filter_options(self):
|
||||
recruiters = self.env['res.users'].search([
|
||||
('share', '=', False),
|
||||
('active', '=', True),
|
||||
], order='name')
|
||||
jobs = self.env['hr.job.recruitment'].with_context(active_test=False).search([], order='recruitment_sequence desc, id desc', limit=200)
|
||||
departments = self.env['hr.department'].search([], order='name')
|
||||
return {
|
||||
'recruiters': [{'id': item.id, 'name': item.name} for item in recruiters],
|
||||
'jobs': [{'id': item.id, 'name': item.display_name} for item in jobs],
|
||||
'departments': [{'id': item.id, 'name': item.display_name} for item in departments],
|
||||
'recruitment_types': [
|
||||
{'id': 'internal', 'name': _('In-House')},
|
||||
{'id': 'external', 'name': _('Client-Side')},
|
||||
],
|
||||
'statuses': self._selection_options('hr.job.recruitment', 'recruitment_status'),
|
||||
'priorities': self._selection_options('hr.job.recruitment', 'job_priority'),
|
||||
'ranges': [
|
||||
{'id': '30', 'name': _('Last 30 Days')},
|
||||
{'id': '90', 'name': _('Last 90 Days')},
|
||||
{'id': '180', 'name': _('Last 180 Days')},
|
||||
{'id': '365', 'name': _('Last 12 Months')},
|
||||
{'id': 'custom', 'name': _('Custom')},
|
||||
],
|
||||
'published_statuses': [
|
||||
{'id': 'all', 'name': _('All')},
|
||||
{'id': 'published', 'name': _('Published')},
|
||||
{'id': 'unpublished', 'name': _('Unpublished')},
|
||||
],
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _selection_options(self, model_name, field_name):
|
||||
field = self.env[model_name]._fields[field_name]
|
||||
return [{'id': value, 'name': label} for value, label in field.selection]
|
||||
|
||||
@api.model
|
||||
def _get_date_values(self, filters):
|
||||
today = fields.Date.context_today(self)
|
||||
range_key = filters.get('range') or '90'
|
||||
if filters.get('date_from') or filters.get('date_to'):
|
||||
range_key = 'custom'
|
||||
|
||||
if range_key != 'custom':
|
||||
date_to = today
|
||||
date_from = today - relativedelta(days=int(range_key))
|
||||
else:
|
||||
date_from = fields.Date.to_date(filters.get('date_from')) if filters.get('date_from') else today - relativedelta(days=90)
|
||||
date_to = fields.Date.to_date(filters.get('date_to')) if filters.get('date_to') else today
|
||||
|
||||
date_from_dt = datetime.combine(date_from, time.min)
|
||||
date_to_dt = datetime.combine(date_to, time.max)
|
||||
return date_from, date_to, fields.Datetime.to_string(date_from_dt), fields.Datetime.to_string(date_to_dt)
|
||||
|
||||
@api.model
|
||||
def _get_applicant_domain(self, filters):
|
||||
date_from, date_to, date_from_dt, date_to_dt = self._get_date_values(filters)
|
||||
domain = [
|
||||
('create_date', '>=', date_from_dt),
|
||||
('create_date', '<=', date_to_dt),
|
||||
'|',
|
||||
('company_id', '=', False),
|
||||
('company_id', 'in', self.env.companies.ids),
|
||||
]
|
||||
domain += self._job_related_domain(filters, applicant=True)
|
||||
return domain
|
||||
|
||||
@api.model
|
||||
def _get_job_domain(self, filters):
|
||||
date_from, date_to, date_from_dt, date_to_dt = self._get_date_values(filters)
|
||||
domain = [
|
||||
'|',
|
||||
('company_id', '=', False),
|
||||
('company_id', 'in', self.env.companies.ids),
|
||||
'|',
|
||||
('target_from', '=', False),
|
||||
('target_from', '<=', date_to),
|
||||
'|',
|
||||
('target_to', '=', False),
|
||||
('target_to', '>=', date_from),
|
||||
]
|
||||
domain += self._job_related_domain(filters, applicant=False)
|
||||
return domain
|
||||
|
||||
@api.model
|
||||
def _job_related_domain(self, filters, applicant=False):
|
||||
prefix = 'hr_job_recruitment.' if applicant else ''
|
||||
domain = []
|
||||
if filters.get('recruiter_ids'):
|
||||
domain.append((prefix + 'user_id', 'in', filters['recruiter_ids']))
|
||||
if filters.get('job_ids'):
|
||||
domain.append(('hr_job_recruitment' if applicant else 'id', 'in', filters['job_ids']))
|
||||
if filters.get('department_ids'):
|
||||
domain.append((prefix + 'department_id', 'in', filters['department_ids']))
|
||||
if filters.get('recruitment_types'):
|
||||
domain.append((prefix + 'recruitment_type', 'in', filters['recruitment_types']))
|
||||
if filters.get('statuses'):
|
||||
domain.append((prefix + 'recruitment_status', 'in', filters['statuses']))
|
||||
if filters.get('priorities'):
|
||||
domain.append((prefix + 'job_priority', 'in', filters['priorities']))
|
||||
if filters.get('published_status') == 'published':
|
||||
domain.append((prefix + 'website_published', '=', True))
|
||||
elif filters.get('published_status') == 'unpublished':
|
||||
domain.append((prefix + 'website_published', '=', False))
|
||||
return domain
|
||||
|
||||
@api.model
|
||||
def _get_summary_cards(self, jobs, applicants, active_applicants, submitted_applicants, hired_applicants, refused_applicants, on_hold_applicants):
|
||||
open_jobs = jobs.filtered(lambda item: item.recruitment_status == 'open')
|
||||
total_positions = sum(jobs.mapped('no_of_recruitment'))
|
||||
avg_skill = round(sum(applicants.mapped('overall_skill_match_percentage')) / len(applicants), 1) if applicants else 0
|
||||
conversion = round((len(hired_applicants) / len(applicants)) * 100, 1) if applicants else 0
|
||||
return [
|
||||
self._card(_('Open Recruitments'), len(open_jobs), 'hr.job.recruitment', [('id', 'in', open_jobs.ids)], 'fa-briefcase', '#2563eb'),
|
||||
self._card(_('Open Positions'), total_positions, 'hr.job.recruitment', [('id', 'in', jobs.ids)], 'fa-users', '#059669'),
|
||||
self._card(_('Applications'), len(active_applicants), 'hr.applicant', [('id', 'in', active_applicants.ids)], 'fa-id-card-o', '#7c3aed'),
|
||||
self._card(_('Submitted to Client'), len(submitted_applicants), 'hr.applicant', [('id', 'in', submitted_applicants.ids)], 'fa-paper-plane', '#ea580c'),
|
||||
self._card(_('Hired'), len(hired_applicants), 'hr.applicant', [('id', 'in', hired_applicants.ids)], 'fa-check-circle', '#16a34a'),
|
||||
self._card(_('On Hold'), len(on_hold_applicants), 'hr.applicant', [('id', 'in', on_hold_applicants.ids)], 'fa-pause-circle', '#ca8a04'),
|
||||
self._card(_('Avg Skill Match'), '%s%%' % avg_skill, 'hr.applicant', [('id', 'in', applicants.ids)], 'fa-bullseye', '#0891b2'),
|
||||
self._card(_('Hire Conversion'), '%s%%' % conversion, 'hr.applicant', [('id', 'in', hired_applicants.ids)], 'fa-line-chart', '#db2777'),
|
||||
]
|
||||
|
||||
@api.model
|
||||
def _card(self, title, value, model_name, domain, icon, color):
|
||||
return {
|
||||
'title': title,
|
||||
'value': value,
|
||||
'model': model_name,
|
||||
'domain': domain,
|
||||
'icon': icon,
|
||||
'color': color,
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _get_pipeline_chart(self, applicants, base_domain):
|
||||
grouped = defaultdict(lambda: {'count': 0, 'ids': []})
|
||||
for applicant in applicants:
|
||||
stage = applicant.recruitment_stage_id
|
||||
key = stage.display_name if stage else _('No Stage')
|
||||
grouped[key]['count'] += 1
|
||||
grouped[key]['ids'].append(applicant.id)
|
||||
labels = list(grouped)
|
||||
return {
|
||||
'labels': labels,
|
||||
'series': [grouped[label]['count'] for label in labels],
|
||||
'points': [{
|
||||
'model': 'hr.applicant',
|
||||
'title': _('Applications: %s') % label,
|
||||
'domain': [('id', 'in', grouped[label]['ids'])],
|
||||
} for label in labels],
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _get_status_chart(self, jobs, base_domain):
|
||||
labels_by_value = dict(self.env['hr.job.recruitment']._fields['recruitment_status'].selection)
|
||||
grouped = defaultdict(lambda: {'count': 0, 'ids': []})
|
||||
for job in jobs:
|
||||
key = job.recruitment_status or 'open'
|
||||
grouped[key]['count'] += 1
|
||||
grouped[key]['ids'].append(job.id)
|
||||
labels = list(grouped)
|
||||
return {
|
||||
'labels': [labels_by_value.get(label, label) for label in labels],
|
||||
'series': [grouped[label]['count'] for label in labels],
|
||||
'points': [{
|
||||
'model': 'hr.job.recruitment',
|
||||
'title': _('Recruitments: %s') % labels_by_value.get(label, label),
|
||||
'domain': [('id', 'in', grouped[label]['ids'])],
|
||||
} for label in labels],
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _get_recruiter_chart(self, applicants, base_domain):
|
||||
grouped = defaultdict(lambda: {'count': 0, 'ids': []})
|
||||
for applicant in applicants:
|
||||
recruiter = applicant.hr_job_recruitment.user_id or applicant.user_id
|
||||
key = recruiter.name if recruiter else _('Unassigned')
|
||||
grouped[key]['count'] += 1
|
||||
grouped[key]['ids'].append(applicant.id)
|
||||
sorted_items = sorted(grouped.items(), key=lambda item: item[1]['count'], reverse=True)[:10]
|
||||
return {
|
||||
'labels': [item[0] for item in sorted_items],
|
||||
'series': [item[1]['count'] for item in sorted_items],
|
||||
'points': [{
|
||||
'model': 'hr.applicant',
|
||||
'title': _('Applications: %s') % item[0],
|
||||
'domain': [('id', 'in', item[1]['ids'])],
|
||||
} for item in sorted_items],
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _get_funnel_chart(self, applicants, submitted_applicants, hired_applicants, refused_applicants, base_domain):
|
||||
stages = [
|
||||
(_('Applications'), applicants.ids),
|
||||
(_('Client Submitted'), submitted_applicants.ids),
|
||||
(_('Hired'), hired_applicants.ids),
|
||||
(_('Refused / Archived'), refused_applicants.ids),
|
||||
]
|
||||
return {
|
||||
'labels': [stage[0] for stage in stages],
|
||||
'series': [len(stage[1]) for stage in stages],
|
||||
'points': [{
|
||||
'model': 'hr.applicant',
|
||||
'title': stage[0],
|
||||
'domain': [('id', 'in', stage[1])],
|
||||
} for stage in stages],
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _get_priority_chart(self, jobs, base_domain):
|
||||
labels_by_value = dict(self.env['hr.job.recruitment']._fields['job_priority'].selection)
|
||||
grouped = defaultdict(lambda: {'count': 0, 'ids': []})
|
||||
for job in jobs:
|
||||
key = job.job_priority or 'medium'
|
||||
grouped[key]['count'] += 1
|
||||
grouped[key]['ids'].append(job.id)
|
||||
order = ['high', 'medium', 'low']
|
||||
labels = [item for item in order if item in grouped] + [item for item in grouped if item not in order]
|
||||
return {
|
||||
'labels': [labels_by_value.get(label, label.title()) for label in labels],
|
||||
'series': [grouped[label]['count'] for label in labels],
|
||||
'points': [{
|
||||
'model': 'hr.job.recruitment',
|
||||
'title': _('Priority: %s') % labels_by_value.get(label, label.title()),
|
||||
'domain': [('id', 'in', grouped[label]['ids'])],
|
||||
} for label in labels],
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _get_skill_match_chart(self, applicants, base_domain):
|
||||
buckets = [
|
||||
('80-100%', lambda value: value >= 80),
|
||||
('60-79%', lambda value: 60 <= value < 80),
|
||||
('40-59%', lambda value: 40 <= value < 60),
|
||||
('0-39%', lambda value: value < 40),
|
||||
]
|
||||
values = []
|
||||
points = []
|
||||
for label, predicate in buckets:
|
||||
records = applicants.filtered(lambda item: predicate(item.overall_skill_match_percentage or 0))
|
||||
values.append(len(records))
|
||||
points.append({
|
||||
'model': 'hr.applicant',
|
||||
'title': _('Skill Match %s') % label,
|
||||
'domain': [('id', 'in', records.ids)],
|
||||
})
|
||||
return {'labels': [item[0] for item in buckets], 'series': values, 'points': points}
|
||||
|
||||
@api.model
|
||||
def _get_trend_chart(self, base_domain, filters):
|
||||
Applicant = self.env['hr.applicant'].with_context(active_test=False)
|
||||
date_from, date_to, date_from_dt, date_to_dt = self._get_date_values(filters)
|
||||
current = date_from.replace(day=1)
|
||||
labels = []
|
||||
applications = []
|
||||
submissions = []
|
||||
hires = []
|
||||
points = []
|
||||
while current <= date_to:
|
||||
month_start = datetime.combine(current, time.min)
|
||||
month_end_date = current + relativedelta(months=1, days=-1)
|
||||
month_end = datetime.combine(month_end_date, time.max)
|
||||
month_domain = [
|
||||
('create_date', '>=', fields.Datetime.to_string(month_start)),
|
||||
('create_date', '<=', fields.Datetime.to_string(month_end)),
|
||||
] + [item for item in base_domain if item[0] not in {'create_date'}]
|
||||
month_records = Applicant.search(month_domain)
|
||||
submitted = month_records.filtered(lambda item: item.submitted_to_client)
|
||||
hired = month_records.filtered(lambda item: item.recruitment_stage_id.hired_stage)
|
||||
labels.append(current.strftime('%b %Y'))
|
||||
applications.append(len(month_records))
|
||||
submissions.append(len(submitted))
|
||||
hires.append(len(hired))
|
||||
points.append({
|
||||
'model': 'hr.applicant',
|
||||
'title': _('Applications: %s') % current.strftime('%b %Y'),
|
||||
'domain': [('id', 'in', month_records.ids)],
|
||||
})
|
||||
current += relativedelta(months=1)
|
||||
return {
|
||||
'labels': labels,
|
||||
'series': [
|
||||
{'name': _('Applications'), 'data': applications},
|
||||
{'name': _('Submitted'), 'data': submissions},
|
||||
{'name': _('Hired'), 'data': hires},
|
||||
],
|
||||
'points': points,
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _get_top_jobs(self, jobs):
|
||||
ranked = jobs.sorted(lambda item: item.application_count, reverse=True)[:10]
|
||||
return [{
|
||||
'id': job.id,
|
||||
'name': job.display_name,
|
||||
'recruiter': job.user_id.name or '',
|
||||
'status': dict(job._fields['recruitment_status'].selection).get(job.recruitment_status, ''),
|
||||
'priority': dict(job._fields['job_priority'].selection).get(job.job_priority, ''),
|
||||
'positions': job.no_of_recruitment,
|
||||
'applications': job.application_count,
|
||||
'submissions': job.no_of_submissions,
|
||||
'hired': job.no_of_hired_employee,
|
||||
'domain': [('id', '=', job.id)],
|
||||
} for job in ranked]
|
||||
|
||||
@api.model
|
||||
def _get_attention_items(self, jobs, on_hold_applicants):
|
||||
overdue_jobs = jobs.filtered(lambda item: item.target_to and item.target_to < fields.Date.context_today(self) and item.recruitment_status == 'open')
|
||||
high_priority_jobs = jobs.filtered(lambda item: item.job_priority == 'high' and item.recruitment_status == 'open')
|
||||
return [
|
||||
self._card(_('Overdue Recruitments'), len(overdue_jobs), 'hr.job.recruitment', [('id', 'in', overdue_jobs.ids)], 'fa-calendar-times-o', '#dc2626'),
|
||||
self._card(_('High Priority Openings'), len(high_priority_jobs), 'hr.job.recruitment', [('id', 'in', high_priority_jobs.ids)], 'fa-fire', '#ea580c'),
|
||||
self._card(_('Candidates On Hold'), len(on_hold_applicants), 'hr.applicant', [('id', 'in', on_hold_applicants.ids)], 'fa-pause', '#ca8a04'),
|
||||
]
|
||||
|
|
@ -0,0 +1,402 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { Component, onMounted, onWillDestroy, useRef, useState } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
export class HrRecruitmentDashboard extends Component {
|
||||
static template = "hr_recruitment_dashboards.HrRecruitmentDashboard";
|
||||
|
||||
setup() {
|
||||
this.orm = useService("orm");
|
||||
this.action = useService("action");
|
||||
this.notification = useService("notification");
|
||||
this.root = useRef("root");
|
||||
this.charts = {};
|
||||
|
||||
this.state = useState({
|
||||
loading: true,
|
||||
data: this.emptyDashboardData(),
|
||||
filters: this.defaultFilters(),
|
||||
});
|
||||
|
||||
onMounted(() => this.loadDashboard());
|
||||
onWillDestroy(() => this.destroyCharts());
|
||||
}
|
||||
|
||||
defaultFilters() {
|
||||
return {
|
||||
range: "90",
|
||||
date_from: "",
|
||||
date_to: "",
|
||||
recruiter_ids: [],
|
||||
job_ids: [],
|
||||
department_ids: [],
|
||||
recruitment_types: [],
|
||||
statuses: [],
|
||||
priorities: [],
|
||||
published_status: "all",
|
||||
};
|
||||
}
|
||||
|
||||
emptyChartData(series = []) {
|
||||
return {
|
||||
labels: [],
|
||||
series,
|
||||
points: [],
|
||||
};
|
||||
}
|
||||
|
||||
emptyDashboardData() {
|
||||
return {
|
||||
filters: {
|
||||
recruiters: [],
|
||||
jobs: [],
|
||||
departments: [],
|
||||
recruitment_types: [],
|
||||
statuses: [],
|
||||
priorities: [],
|
||||
ranges: [
|
||||
{ id: "30", name: "Last 30 Days" },
|
||||
{ id: "90", name: "Last 90 Days" },
|
||||
{ id: "180", name: "Last 180 Days" },
|
||||
{ id: "365", name: "Last 12 Months" },
|
||||
{ id: "custom", name: "Custom" },
|
||||
],
|
||||
published_statuses: [
|
||||
{ id: "all", name: "All" },
|
||||
{ id: "published", name: "Published" },
|
||||
{ id: "unpublished", name: "Unpublished" },
|
||||
],
|
||||
},
|
||||
summary: [],
|
||||
charts: {
|
||||
pipeline: this.emptyChartData(),
|
||||
status: this.emptyChartData(),
|
||||
recruiters: this.emptyChartData(),
|
||||
funnel: this.emptyChartData(),
|
||||
priority: this.emptyChartData(),
|
||||
trend: this.emptyChartData([]),
|
||||
skill_match: this.emptyChartData(),
|
||||
},
|
||||
tables: {
|
||||
top_jobs: [],
|
||||
attention: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
normalizeDashboardData(data) {
|
||||
const defaults = this.emptyDashboardData();
|
||||
const charts = data?.charts || {};
|
||||
return {
|
||||
...defaults,
|
||||
...(data || {}),
|
||||
filters: {
|
||||
...defaults.filters,
|
||||
...(data?.filters || {}),
|
||||
},
|
||||
charts: {
|
||||
...defaults.charts,
|
||||
...charts,
|
||||
},
|
||||
tables: {
|
||||
...defaults.tables,
|
||||
...(data?.tables || {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async loadDashboard() {
|
||||
this.state.loading = true;
|
||||
try {
|
||||
const data = await this.orm.call("hr.recruitment.dashboard", "get_dashboard_data", [this.state.filters]);
|
||||
this.state.data = this.normalizeDashboardData(data);
|
||||
setTimeout(() => this.renderCharts(), 80);
|
||||
} catch (error) {
|
||||
console.error("Recruitment dashboard load failed", error);
|
||||
this.notification.add("Could not load the recruitment dashboard.", { type: "danger" });
|
||||
} finally {
|
||||
this.state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
destroyCharts() {
|
||||
Object.values(this.charts).forEach((chart) => chart && chart.destroy());
|
||||
this.charts = {};
|
||||
}
|
||||
|
||||
renderCharts() {
|
||||
if (!this.state.data || !window.ApexCharts || !this.root.el) {
|
||||
return;
|
||||
}
|
||||
this.destroyCharts();
|
||||
const charts = this.state.data.charts;
|
||||
this.renderDonut("pipelineChart", charts.pipeline, ["#2563eb", "#06b6d4", "#7c3aed", "#f59e0b", "#10b981", "#ef4444"]);
|
||||
this.renderRadial("statusChart", charts.status, ["#16a34a", "#ca8a04", "#64748b", "#7c3aed", "#dc2626"]);
|
||||
this.renderBar("recruiterChart", charts.recruiters, "#2563eb", true);
|
||||
this.renderFunnel("funnelChart", charts.funnel);
|
||||
this.renderDonut("priorityChart", charts.priority, ["#dc2626", "#f59e0b", "#16a34a"]);
|
||||
this.renderLine("trendChart", charts.trend);
|
||||
this.renderBar("skillChart", charts.skill_match, "#0891b2", false);
|
||||
}
|
||||
|
||||
chartElement(id) {
|
||||
return this.root.el.querySelector(`#${id}`);
|
||||
}
|
||||
|
||||
baseChartOptions(chartData, extra = {}) {
|
||||
return {
|
||||
chart: {
|
||||
toolbar: { show: false },
|
||||
animations: { enabled: true, speed: 550 },
|
||||
events: {
|
||||
dataPointSelection: (event, chartContext, config) => {
|
||||
const point = chartData.points?.[config.dataPointIndex];
|
||||
if (point) {
|
||||
this.openDrilldown(point);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
theme: { mode: "light" },
|
||||
dataLabels: { enabled: false },
|
||||
legend: {
|
||||
position: "bottom",
|
||||
fontSize: "12px",
|
||||
markers: { size: 5 },
|
||||
},
|
||||
tooltip: {
|
||||
y: { formatter: (value) => this.formatNumber(value) },
|
||||
},
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
renderDonut(elementId, chartData, colors) {
|
||||
const element = this.chartElement(elementId);
|
||||
if (!element) return;
|
||||
this.charts[elementId] = new ApexCharts(element, this.baseChartOptions(chartData, {
|
||||
series: chartData.series,
|
||||
labels: chartData.labels,
|
||||
colors,
|
||||
chart: {
|
||||
...this.baseChartOptions(chartData).chart,
|
||||
type: "donut",
|
||||
height: 300,
|
||||
},
|
||||
plotOptions: {
|
||||
pie: {
|
||||
donut: {
|
||||
size: "68%",
|
||||
labels: {
|
||||
show: true,
|
||||
total: {
|
||||
show: true,
|
||||
label: "Total",
|
||||
formatter: () => this.formatNumber(chartData.series.reduce((sum, value) => sum + value, 0)),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
this.charts[elementId].render();
|
||||
}
|
||||
|
||||
renderRadial(elementId, chartData, colors) {
|
||||
const element = this.chartElement(elementId);
|
||||
if (!element) return;
|
||||
const total = chartData.series.reduce((sum, value) => sum + value, 0) || 1;
|
||||
const series = chartData.series.map((value) => Math.round((value / total) * 100));
|
||||
this.charts[elementId] = new ApexCharts(element, this.baseChartOptions(chartData, {
|
||||
series,
|
||||
labels: chartData.labels,
|
||||
colors,
|
||||
chart: {
|
||||
...this.baseChartOptions(chartData).chart,
|
||||
type: "radialBar",
|
||||
height: 300,
|
||||
},
|
||||
plotOptions: {
|
||||
radialBar: {
|
||||
hollow: { size: "34%" },
|
||||
dataLabels: {
|
||||
total: {
|
||||
show: true,
|
||||
label: "Recruitments",
|
||||
formatter: () => this.formatNumber(total),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
y: {
|
||||
formatter: (value, opts) => this.formatNumber(chartData.series[opts.seriesIndex]),
|
||||
},
|
||||
},
|
||||
}));
|
||||
this.charts[elementId].render();
|
||||
}
|
||||
|
||||
renderBar(elementId, chartData, color, horizontal) {
|
||||
const element = this.chartElement(elementId);
|
||||
if (!element) return;
|
||||
this.charts[elementId] = new ApexCharts(element, this.baseChartOptions(chartData, {
|
||||
series: [{ name: "Count", data: chartData.series }],
|
||||
colors: [color],
|
||||
chart: {
|
||||
...this.baseChartOptions(chartData).chart,
|
||||
type: "bar",
|
||||
height: 320,
|
||||
},
|
||||
plotOptions: {
|
||||
bar: {
|
||||
horizontal,
|
||||
borderRadius: 6,
|
||||
columnWidth: "48%",
|
||||
barHeight: "52%",
|
||||
},
|
||||
},
|
||||
xaxis: { categories: chartData.labels },
|
||||
yaxis: { labels: { style: { fontSize: "12px" } } },
|
||||
}));
|
||||
this.charts[elementId].render();
|
||||
}
|
||||
|
||||
renderFunnel(elementId, chartData) {
|
||||
const element = this.chartElement(elementId);
|
||||
if (!element) return;
|
||||
this.charts[elementId] = new ApexCharts(element, this.baseChartOptions(chartData, {
|
||||
series: [{ name: "Candidates", data: chartData.series }],
|
||||
colors: ["#7c3aed"],
|
||||
chart: {
|
||||
...this.baseChartOptions(chartData).chart,
|
||||
type: "bar",
|
||||
height: 300,
|
||||
},
|
||||
plotOptions: {
|
||||
bar: {
|
||||
horizontal: true,
|
||||
isFunnel: true,
|
||||
borderRadius: 4,
|
||||
},
|
||||
},
|
||||
xaxis: { categories: chartData.labels },
|
||||
}));
|
||||
this.charts[elementId].render();
|
||||
}
|
||||
|
||||
renderLine(elementId, chartData) {
|
||||
const element = this.chartElement(elementId);
|
||||
if (!element) return;
|
||||
this.charts[elementId] = new ApexCharts(element, this.baseChartOptions(chartData, {
|
||||
series: chartData.series,
|
||||
colors: ["#2563eb", "#ea580c", "#16a34a"],
|
||||
chart: {
|
||||
...this.baseChartOptions(chartData).chart,
|
||||
type: "area",
|
||||
height: 320,
|
||||
},
|
||||
stroke: { curve: "smooth", width: 3 },
|
||||
fill: {
|
||||
type: "gradient",
|
||||
gradient: { opacityFrom: 0.28, opacityTo: 0.04 },
|
||||
},
|
||||
xaxis: { categories: chartData.labels },
|
||||
}));
|
||||
this.charts[elementId].render();
|
||||
}
|
||||
|
||||
async openDrilldown(point) {
|
||||
if (!point.domain || !point.model) {
|
||||
return;
|
||||
}
|
||||
const action = await this.orm.call("hr.recruitment.dashboard", "get_drilldown_action", [
|
||||
point.model,
|
||||
point.domain,
|
||||
point.title,
|
||||
]);
|
||||
this.action.doAction(action);
|
||||
}
|
||||
|
||||
async openCard(card) {
|
||||
await this.openDrilldown({
|
||||
model: card.model,
|
||||
domain: card.domain,
|
||||
title: card.title,
|
||||
});
|
||||
}
|
||||
|
||||
async openJob(row) {
|
||||
await this.openDrilldown({
|
||||
model: "hr.job.recruitment",
|
||||
domain: row.domain,
|
||||
title: row.name,
|
||||
});
|
||||
}
|
||||
|
||||
onRangeChange(event) {
|
||||
this.state.filters.range = event.target.value;
|
||||
if (event.target.value !== "custom") {
|
||||
this.state.filters.date_from = "";
|
||||
this.state.filters.date_to = "";
|
||||
}
|
||||
}
|
||||
|
||||
onInputChange(key, event) {
|
||||
this.state.filters[key] = event.target.value;
|
||||
if (key === "date_from" || key === "date_to") {
|
||||
this.state.filters.range = "custom";
|
||||
}
|
||||
}
|
||||
|
||||
onMultiChange(key, event) {
|
||||
this.state.filters[key] = [...event.target.selectedOptions].map((option) => {
|
||||
const value = option.value;
|
||||
return value && !Number.isNaN(Number(value)) ? Number(value) : value;
|
||||
});
|
||||
}
|
||||
|
||||
toggleMultiFilter(key, value, checked) {
|
||||
const values = this.state.filters[key] || [];
|
||||
if (checked && !values.includes(value)) {
|
||||
this.state.filters[key] = [...values, value];
|
||||
} else if (!checked) {
|
||||
this.state.filters[key] = values.filter((item) => item !== value);
|
||||
}
|
||||
}
|
||||
|
||||
selectedFilterItems(key, options) {
|
||||
const values = this.state.filters[key] || [];
|
||||
return (options || []).filter((item) => values.includes(item.id));
|
||||
}
|
||||
|
||||
multiFilterLabel(key, options, placeholder) {
|
||||
const selectedItems = this.selectedFilterItems(key, options);
|
||||
if (!selectedItems.length) {
|
||||
return placeholder;
|
||||
}
|
||||
if (selectedItems.length === 1) {
|
||||
return selectedItems[0].name;
|
||||
}
|
||||
return `${selectedItems.length} selected`;
|
||||
}
|
||||
|
||||
clearFilters() {
|
||||
this.state.filters = this.defaultFilters();
|
||||
this.loadDashboard();
|
||||
}
|
||||
|
||||
formatNumber(value) {
|
||||
if (value === false || value === null || value === undefined) {
|
||||
return "0";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
return Intl.NumberFormat("en-IN", { maximumFractionDigits: 1 }).format(value);
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("hr_recruitment_dashboard", HrRecruitmentDashboard);
|
||||
14
addons_extensions/hr_recruitment_dashboards/static/src/lib/apexcharts.min.js
vendored
Normal file
14
addons_extensions/hr_recruitment_dashboards/static/src/lib/apexcharts.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,344 @@
|
|||
.o_hr_recruitment_dashboard {
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
padding: 22px;
|
||||
background: #f5f7fb;
|
||||
color: #172033;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
.o_hr_recruitment_dashboard__header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 20px 22px;
|
||||
border: 1px solid #dfe6f1;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #eef7ff 100%);
|
||||
|
||||
h1 {
|
||||
margin: 2px 0 4px;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
max-width: 780px;
|
||||
}
|
||||
}
|
||||
|
||||
.o_hr_recruitment_dashboard__eyebrow {
|
||||
color: #2563eb;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.o_hr_recruitment_dashboard__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.o_hr_recruitment_dashboard__filters {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
margin: 14px 0;
|
||||
padding: 14px;
|
||||
border: 1px solid #dfe6f1;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.o_filter_dropdown {
|
||||
position: relative;
|
||||
|
||||
summary {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 38px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
color: #212529;
|
||||
font-size: 14px;
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
|
||||
&::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
&[open] {
|
||||
z-index: 10;
|
||||
|
||||
summary {
|
||||
border-color: #86b7fe;
|
||||
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.16);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_filter_dropdown_menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
left: 0;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 4px;
|
||||
max-height: 230px;
|
||||
min-width: 220px;
|
||||
padding: 8px;
|
||||
border: 1px solid #dfe6f1;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 18px 34px rgba(15, 23, 42, 0.15);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.o_filter_check {
|
||||
display: grid;
|
||||
grid-template-columns: 16px 1fr;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin: 0;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_hr_recruitment_dashboard__loading {
|
||||
margin-top: 24px;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
border: 1px dashed #cbd5e1;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.o_hr_recruitment_dashboard__kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, minmax(120px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.o_recruitment_kpi,
|
||||
.o_attention_item {
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #ffffff;
|
||||
cursor: pointer;
|
||||
transition: transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--kpi-color, #2563eb);
|
||||
box-shadow: 0 14px 26px rgba(15, 23, 42, 0.09);
|
||||
}
|
||||
}
|
||||
|
||||
.o_recruitment_kpi {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 78px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
text-align: left;
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
small {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.o_recruitment_kpi__icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--kpi-color, #2563eb) 12%, #fff);
|
||||
color: var(--kpi-color, #2563eb);
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.o_hr_recruitment_dashboard__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(280px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.o_panel_wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.o_recruitment_panel {
|
||||
min-width: 0;
|
||||
padding: 16px;
|
||||
border: 1px solid #dfe6f1;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.o_panel_title {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.o_chart {
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.o_hr_recruitment_dashboard__bottom {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 14px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.o_recruitment_table {
|
||||
margin-bottom: 0;
|
||||
|
||||
tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
.o_attention_stack {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.o_attention_item {
|
||||
display: grid;
|
||||
grid-template-columns: 32px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
text-align: left;
|
||||
|
||||
i {
|
||||
color: var(--attention-color, #2563eb);
|
||||
}
|
||||
|
||||
span {
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_action_manager .o_action .o_hr_recruitment_dashboard,
|
||||
.o_action_manager .o_content .o_hr_recruitment_dashboard {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.o_hr_recruitment_dashboard {
|
||||
.o_hr_recruitment_dashboard__filters,
|
||||
.o_hr_recruitment_dashboard__kpis {
|
||||
grid-template-columns: repeat(4, minmax(150px, 1fr));
|
||||
}
|
||||
|
||||
.o_hr_recruitment_dashboard__grid,
|
||||
.o_hr_recruitment_dashboard__bottom {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.o_panel_wide {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.o_hr_recruitment_dashboard {
|
||||
padding: 12px;
|
||||
|
||||
.o_hr_recruitment_dashboard__header {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.o_hr_recruitment_dashboard__actions {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.o_hr_recruitment_dashboard__filters,
|
||||
.o_hr_recruitment_dashboard__kpis {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,237 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="hr_recruitment_dashboards.HrRecruitmentDashboard">
|
||||
<div class="o_hr_recruitment_dashboard" t-ref="root">
|
||||
<div class="o_hr_recruitment_dashboard__header">
|
||||
<div>
|
||||
<div class="o_hr_recruitment_dashboard__eyebrow">Recruitment Intelligence</div>
|
||||
<h1>Recruitment Dashboard</h1>
|
||||
<p>Pipeline health, recruiter performance, client submissions, hiring conversion, and urgent openings in one place.</p>
|
||||
</div>
|
||||
<div class="o_hr_recruitment_dashboard__actions">
|
||||
<button class="btn btn-light" t-on-click="clearFilters">Reset</button>
|
||||
<button class="btn btn-primary" t-on-click="loadDashboard">
|
||||
<i class="fa fa-refresh me-1"/> Apply Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_hr_recruitment_dashboard__filters">
|
||||
<div class="o_filter_item">
|
||||
<label>Range</label>
|
||||
<select class="form-select" t-att-value="state.filters.range" t-on-change="onRangeChange">
|
||||
<t t-if="state.data" t-foreach="state.data.filters.ranges" t-as="item" t-key="item.id">
|
||||
<option t-att-value="item.id" t-att-selected="state.filters.range === item.id"><t t-esc="item.name"/></option>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
<div class="o_filter_item">
|
||||
<label>From</label>
|
||||
<input type="date" class="form-control" t-att-value="state.filters.date_from" t-on-change="(ev) => this.onInputChange('date_from', ev)"/>
|
||||
</div>
|
||||
<div class="o_filter_item">
|
||||
<label>To</label>
|
||||
<input type="date" class="form-control" t-att-value="state.filters.date_to" t-on-change="(ev) => this.onInputChange('date_to', ev)"/>
|
||||
</div>
|
||||
<div class="o_filter_item">
|
||||
<label>Recruiters</label>
|
||||
<details class="o_filter_dropdown">
|
||||
<summary>
|
||||
<span><t t-esc="multiFilterLabel('recruiter_ids', state.data.filters.recruiters, 'All Recruiters')"/></span>
|
||||
<i class="fa fa-angle-down"/>
|
||||
</summary>
|
||||
<div class="o_filter_dropdown_menu">
|
||||
<t t-if="state.data" t-foreach="state.data.filters.recruiters" t-as="item" t-key="item.id">
|
||||
<label class="o_filter_check">
|
||||
<input type="checkbox" t-att-checked="state.filters.recruiter_ids.includes(item.id)" t-on-change="(ev) => this.toggleMultiFilter('recruiter_ids', item.id, ev.target.checked)"/>
|
||||
<span><t t-esc="item.name"/></span>
|
||||
</label>
|
||||
</t>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="o_filter_item">
|
||||
<label>Recruitment Type</label>
|
||||
<details class="o_filter_dropdown">
|
||||
<summary>
|
||||
<span><t t-esc="multiFilterLabel('recruitment_types', state.data.filters.recruitment_types, 'All Types')"/></span>
|
||||
<i class="fa fa-angle-down"/>
|
||||
</summary>
|
||||
<div class="o_filter_dropdown_menu">
|
||||
<t t-if="state.data" t-foreach="state.data.filters.recruitment_types" t-as="item" t-key="item.id">
|
||||
<label class="o_filter_check">
|
||||
<input type="checkbox" t-att-checked="state.filters.recruitment_types.includes(item.id)" t-on-change="(ev) => this.toggleMultiFilter('recruitment_types', item.id, ev.target.checked)"/>
|
||||
<span><t t-esc="item.name"/></span>
|
||||
</label>
|
||||
</t>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="o_filter_item">
|
||||
<label>Status</label>
|
||||
<details class="o_filter_dropdown">
|
||||
<summary>
|
||||
<span><t t-esc="multiFilterLabel('statuses', state.data.filters.statuses, 'All Statuses')"/></span>
|
||||
<i class="fa fa-angle-down"/>
|
||||
</summary>
|
||||
<div class="o_filter_dropdown_menu">
|
||||
<t t-if="state.data" t-foreach="state.data.filters.statuses" t-as="item" t-key="item.id">
|
||||
<label class="o_filter_check">
|
||||
<input type="checkbox" t-att-checked="state.filters.statuses.includes(item.id)" t-on-change="(ev) => this.toggleMultiFilter('statuses', item.id, ev.target.checked)"/>
|
||||
<span><t t-esc="item.name"/></span>
|
||||
</label>
|
||||
</t>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="o_filter_item">
|
||||
<label>Priority</label>
|
||||
<details class="o_filter_dropdown">
|
||||
<summary>
|
||||
<span><t t-esc="multiFilterLabel('priorities', state.data.filters.priorities, 'All Priorities')"/></span>
|
||||
<i class="fa fa-angle-down"/>
|
||||
</summary>
|
||||
<div class="o_filter_dropdown_menu">
|
||||
<t t-if="state.data" t-foreach="state.data.filters.priorities" t-as="item" t-key="item.id">
|
||||
<label class="o_filter_check">
|
||||
<input type="checkbox" t-att-checked="state.filters.priorities.includes(item.id)" t-on-change="(ev) => this.toggleMultiFilter('priorities', item.id, ev.target.checked)"/>
|
||||
<span><t t-esc="item.name"/></span>
|
||||
</label>
|
||||
</t>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="o_filter_item">
|
||||
<label>Published</label>
|
||||
<select class="form-select" t-att-value="state.filters.published_status" t-on-change="(ev) => this.onInputChange('published_status', ev)">
|
||||
<t t-if="state.data" t-foreach="state.data.filters.published_statuses" t-as="item" t-key="item.id">
|
||||
<option t-att-value="item.id" t-att-selected="state.filters.published_status === item.id"><t t-esc="item.name"/></option>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<t t-if="state.loading">
|
||||
<div class="o_hr_recruitment_dashboard__loading">
|
||||
<i class="fa fa-spinner fa-spin"/> Loading recruitment intelligence...
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-if="!state.loading and state.data">
|
||||
<div class="o_hr_recruitment_dashboard__kpis">
|
||||
<t t-foreach="state.data.summary" t-as="card" t-key="card.title">
|
||||
<button type="button" class="o_recruitment_kpi" t-on-click="() => this.openCard(card)" t-att-style="'--kpi-color:' + card.color">
|
||||
<span class="o_recruitment_kpi__icon"><i t-att-class="'fa ' + card.icon"/></span>
|
||||
<span>
|
||||
<strong><t t-esc="formatNumber(card.value)"/></strong>
|
||||
<small><t t-esc="card.title"/></small>
|
||||
</span>
|
||||
</button>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<div class="o_hr_recruitment_dashboard__grid">
|
||||
<section class="o_recruitment_panel o_panel_wide">
|
||||
<div class="o_panel_title">
|
||||
<h3>Application Trend</h3>
|
||||
<span>Applications, client submissions, and hires by month</span>
|
||||
</div>
|
||||
<div id="trendChart" class="o_chart"/>
|
||||
</section>
|
||||
<section class="o_recruitment_panel">
|
||||
<div class="o_panel_title">
|
||||
<h3>Pipeline Distribution</h3>
|
||||
<span>Click a stage to open matching applications</span>
|
||||
</div>
|
||||
<div id="pipelineChart" class="o_chart"/>
|
||||
</section>
|
||||
<section class="o_recruitment_panel">
|
||||
<div class="o_panel_title">
|
||||
<h3>Recruitment Status</h3>
|
||||
<span>Open, hold, closed, modified, cancelled</span>
|
||||
</div>
|
||||
<div id="statusChart" class="o_chart"/>
|
||||
</section>
|
||||
<section class="o_recruitment_panel">
|
||||
<div class="o_panel_title">
|
||||
<h3>Recruiter Analysis</h3>
|
||||
<span>Top recruiters by application volume</span>
|
||||
</div>
|
||||
<div id="recruiterChart" class="o_chart"/>
|
||||
</section>
|
||||
<section class="o_recruitment_panel">
|
||||
<div class="o_panel_title">
|
||||
<h3>Hiring Funnel</h3>
|
||||
<span>Application to submission to hire</span>
|
||||
</div>
|
||||
<div id="funnelChart" class="o_chart"/>
|
||||
</section>
|
||||
<section class="o_recruitment_panel">
|
||||
<div class="o_panel_title">
|
||||
<h3>Priority Mix</h3>
|
||||
<span>Openings by business urgency</span>
|
||||
</div>
|
||||
<div id="priorityChart" class="o_chart"/>
|
||||
</section>
|
||||
<section class="o_recruitment_panel">
|
||||
<div class="o_panel_title">
|
||||
<h3>Skill Match Quality</h3>
|
||||
<span>Applications grouped by overall match percentage</span>
|
||||
</div>
|
||||
<div id="skillChart" class="o_chart"/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="o_hr_recruitment_dashboard__bottom">
|
||||
<section class="o_recruitment_panel">
|
||||
<div class="o_panel_title">
|
||||
<h3>Top Recruitment Requests</h3>
|
||||
<span>Highest application volume</span>
|
||||
</div>
|
||||
<table class="table table-sm o_recruitment_table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Recruitment</th>
|
||||
<th>Recruiter</th>
|
||||
<th>Status</th>
|
||||
<th>Priority</th>
|
||||
<th class="text-end">Positions</th>
|
||||
<th class="text-end">Applications</th>
|
||||
<th class="text-end">Submitted</th>
|
||||
<th class="text-end">Hired</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="state.data.tables.top_jobs" t-as="row" t-key="row.id" t-on-click="() => this.openJob(row)">
|
||||
<td><t t-esc="row.name"/></td>
|
||||
<td><t t-esc="row.recruiter"/></td>
|
||||
<td><span class="badge text-bg-light"><t t-esc="row.status"/></span></td>
|
||||
<td><t t-esc="row.priority"/></td>
|
||||
<td class="text-end"><t t-esc="row.positions"/></td>
|
||||
<td class="text-end"><t t-esc="row.applications"/></td>
|
||||
<td class="text-end"><t t-esc="row.submissions"/></td>
|
||||
<td class="text-end"><t t-esc="row.hired"/></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section class="o_recruitment_panel">
|
||||
<div class="o_panel_title">
|
||||
<h3>Needs Attention</h3>
|
||||
<span>Operational risks and blocked movement</span>
|
||||
</div>
|
||||
<div class="o_attention_stack">
|
||||
<t t-foreach="state.data.tables.attention" t-as="item" t-key="item.title">
|
||||
<button type="button" class="o_attention_item" t-on-click="() => this.openCard(item)" t-att-style="'--attention-color:' + item.color">
|
||||
<i t-att-class="'fa ' + item.icon"/>
|
||||
<span><t t-esc="item.title"/></span>
|
||||
<strong><t t-esc="formatNumber(item.value)"/></strong>
|
||||
</button>
|
||||
</t>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<record id="action_hr_recruitment_dashboard" model="ir.actions.client">
|
||||
<field name="name">Recruitment Dashboard</field>
|
||||
<field name="tag">hr_recruitment_dashboard</field>
|
||||
<field name="target">current</field>
|
||||
</record>
|
||||
|
||||
<menuitem
|
||||
id="menu_hr_recruitment_dashboard"
|
||||
name="Dashboard"
|
||||
parent="hr_recruitment.menu_hr_recruitment_root"
|
||||
action="action_hr_recruitment_dashboard"
|
||||
groups="hr_recruitment.group_hr_recruitment_user"
|
||||
sequence="1"/>
|
||||
</odoo>
|
||||
Loading…
Reference in New Issue