Recruitement Dashboards

This commit is contained in:
pranaysaidurga 2026-06-03 14:23:00 +05:30
parent eb17d717dd
commit 945aedc0b4
9 changed files with 1426 additions and 0 deletions

View File

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

View File

@ -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,
}

View File

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

View File

@ -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'),
]

View File

@ -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);

File diff suppressed because one or more lines are too long

View File

@ -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;
}
}
}

View File

@ -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>

View File

@ -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>