Initial commit: Hr packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:50 +02:00
commit 62531cd146
2820 changed files with 1432848 additions and 0 deletions

View file

@ -0,0 +1,46 @@
/** @odoo-module */
import session from 'web.session'
import { useService } from "@web/core/utils/hooks";
import { useEnv } from "@odoo/owl";
/**
* Redirect to the sub employee kanban view.
*
* @private
* @param {MouseEvent} event
* @returns {Promise} action loaded
*
*/
export function onEmployeeSubRedirect() {
const actionService = useService('action');
const orm = useService('orm');
const rpc = useService('rpc');
const env = useEnv();
return async (event) => {
const employeeId = parseInt(event.currentTarget.dataset.employeeId);
if (!employeeId) {
return {};
}
const type = event.currentTarget.dataset.type || 'direct';
// Get subordonates of an employee through a rpc call.
const subordinateIds = await rpc('/hr/get_subordinates', {
employee_id: employeeId,
subordinates_type: type,
context: session.user_context
});
let action = await orm.call('hr.employee', 'get_formview_action', [employeeId]);
action = {...action,
name: env._t('Team'),
view_mode: 'kanban,list,form',
views: [[false, 'kanban'], [false, 'list'], [false, 'form']],
domain: [['id', 'in', subordinateIds]],
res_id: false,
context: {
default_parent_id: employeeId,
}
};
actionService.doAction(action);
};
}

View file

@ -0,0 +1,168 @@
/** @odoo-module */
import {Field} from '@web/views/fields/field';
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { usePopover } from "@web/core/popover/popover_hook";
import { onEmployeeSubRedirect } from './hooks';
const { Component, onWillStart, onWillUpdateProps, useState } = owl;
function useUniquePopover() {
const popover = usePopover();
let remove = null;
return Object.assign(Object.create(popover), {
add(target, component, props, options) {
if (remove) {
remove();
}
remove = popover.add(target, component, props, options);
return () => {
remove();
remove = null;
};
},
});
}
class HrOrgChartPopover extends Component {
async setup() {
super.setup();
this.rpc = useService('rpc');
this.orm = useService('orm');
this.actionService = useService("action");
this._onEmployeeSubRedirect = onEmployeeSubRedirect();
}
/**
* Redirect to the employee form view.
*
* @private
* @param {MouseEvent} event
* @returns {Promise} action loaded
*/
async _onEmployeeRedirect(employeeId) {
const action = await this.orm.call('hr.employee', 'get_formview_action', [employeeId]);
this.actionService.doAction(action);
}
}
HrOrgChartPopover.template = 'hr_org_chart.hr_orgchart_emp_popover';
export class HrOrgChart extends Field {
async setup() {
super.setup();
this.rpc = useService('rpc');
this.orm = useService('orm');
this.actionService = useService("action");
this.popover = useUniquePopover();
this.jsonStringify = JSON.stringify;
this.state = useState({'employee_id': null});
this.lastParent = null;
this.max_level = null;
this._onEmployeeSubRedirect = onEmployeeSubRedirect();
onWillStart(async () => {
this.employee = this.props.record.data;
// the widget is either dispayed in the context of a hr.employee form or a res.users form
this.state.employee_id =
this.employee.employee_ids !== undefined
? this.employee.employee_ids.resIds[0]
: this.employee.id;
const parentId =
this.employee.parent_id && this.employee.parent_id[0]
? this.employee.parent_id[0]
: false;
const forceReload =
this.lastRecord !== this.props.record || this.lastParent != parentId;
this.lastParent = parentId;
this.lastRecord = this.props.record;
await this.fetchEmployeeData(this.state.employee_id, forceReload);
});
onWillUpdateProps(async (nextProps) => {
const newParentId =
nextProps.record.data.parent_id && nextProps.record.data.parent_id[0]
? nextProps.record.data.parent_id[0]
: false;
const newEmployeeId = nextProps.record.data.id || false;
if (this.lastParent !== newParentId || this.state.employee_id !== newEmployeeId) {
this.lastParent = newParentId;
this.max_level = null; // Reset max_level to default
await this.fetchEmployeeData(newEmployeeId, true);
}
this.state.employee_id = newEmployeeId;
});
}
async fetchEmployeeData(employeeId, force = false) {
if (!employeeId) {
this.managers = [];
this.children = [];
if (this.view_employee_id) {
this.render(true);
}
this.view_employee_id = null;
} else if (employeeId !== this.view_employee_id || force) {
this.view_employee_id = employeeId;
var orgData = await this.rpc(
'/hr/get_org_chart',
{
employee_id: employeeId,
context: {
...Component.env.session.user_context,
max_level: this.max_level,
new_parent_id: this.lastParent,
},
});
if (Object.keys(orgData).length === 0) {
orgData = {
managers: [],
children: [],
}
}
this.managers = orgData.managers;
this.children = orgData.children;
this.managers_more = orgData.managers_more;
this.self = orgData.self;
this.render(true);
}
}
_onOpenPopover(event, employee) {
this.popover.add(
event.currentTarget,
this.constructor.components.Popover,
{employee},
{closeOnClickAway: true}
);
}
/**
* Redirect to the employee form view.
*
* @private
* @param {MouseEvent} event
* @returns {Promise} action loaded
*/
async _onEmployeeRedirect(employeeId) {
const action = await this.orm.call('hr.employee', 'get_formview_action', [employeeId]);
this.actionService.doAction(action);
}
async _onEmployeeMoreManager(managerId) {
this.max_level = 100; // Set a high level to fetch all managers
await this.fetchEmployeeData(this.state.employee_id, true);
}
}
HrOrgChart.components = {
Popover: HrOrgChartPopover,
};
HrOrgChart.template = 'hr_org_chart.hr_org_chart';
registry.category("fields").add("hr_org_chart", HrOrgChart);

View file

@ -0,0 +1,56 @@
#o_employee_right {
@include media-breakpoint-up(lg) {
border-left: $border-width solid $o-form-separator-color;
}
.o_org_chart_entry {
.o_media_object {
width: $o-hr-org-chart-entry-pic-small-size;
height: $o-hr-org-chart-entry-pic-small-size;
background-size: cover;
background-position: center center;
}
&.o_org_chart_entry_self .o_media_object {
width: $o-hr-org-chart-entry-pic-size;
height: $o-hr-org-chart-entry-pic-size;
}
&:not(.o_org_chart_entry_self):hover .o_media_object {
box-shadow: 0 0 0 $border-width*2 $info;
}
}
.o_org_chart_entry_self_container.o_org_chart_has_managers {
margin-left: $o-hr-org-chart-entry-pic-small-size * .25;
}
.o_org_chart_group_down.o_org_chart_has_managers {
padding-left: $o-hr-org-chart-entry-pic-size * .6;
}
// ORGANIGRAM LINES
.o_org_chart_group_up .o_treeEntry {
--treeEntry-padding-h: 0px;
--treeEntry--before-top: 50%;
--treeEntry--after-display: none;
--treeEntry--beforeAfter-left: #{$o-hr-org-chart-entry-pic-small-size * .5};
}
.o_org_chart_entry_self_container .o_treeEntry {
--treeEntry-padding-h: #{$o-hr-org-chart-entry-pic-small-size * .5};
--treeEntry-padding-v: #{$o-hr-org-chart-entry-pic-size * .25};
}
.o_org_chart_group_down .o_treeEntry {
--treeEntry-padding-h: #{$o-hr-org-chart-entry-pic-size};
--treeEntry-padding-v: #{$o-hr-org-chart-entry-pic-small-size * .5};
}
}
// Right to Left specific style to flip the popover arrow
.o_rtl .o_org_chart_popup.popover .arrow {
left: 100%;
transform: matrix(-1, 0, 0, 1, 0, 0);
}

View file

@ -0,0 +1,176 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="hr_org_chart.hr_org_chart_employee" owl="1">
<t t-set="is_self" t-value="employee.id == view_employee_id"/>
<section t-if="employee_type == 'self'" t-attf-class="o_org_chart_entry_self_container #{managers.length &gt; 0 ? 'o_org_chart_has_managers' : ''}">
<div t-attf-class="o_org_chart_entry o_org_chart_entry_#{employee_type} d-flex position-relative py-2 overflow-visible #{managers.length &gt; 0 ? 'o_treeEntry' : ''}">
<t t-call="hr_org_chart.hr_org_chart_employee_content">
<t t-set="is_self" t-value="is_self"/>
</t>
</div>
</section>
<div t-else="" t-attf-class="o_org_chart_entry o_org_chart_entry_#{employee_type} o_treeEntry d-flex position-relative py-2 overflow-visible">
<t t-call="hr_org_chart.hr_org_chart_employee_content">
<t t-set="is_self" t-value="is_self"/>
</t>
</div>
</t>
<t t-name="hr_org_chart.hr_org_chart_employee_content" owl="1">
<div class="o_media_left position-relative">
<!-- NOTE: Since by the default on not squared images odoo add white borders,
use bg-images to get a clean and centred images -->
<a t-if="! is_self"
class="o_media_object d-block rounded-circle o_employee_redirect"
t-att-style="'background-image:url(\'/web/image/hr.employee.public/' + employee.id + '/avatar_1024/\')'"
t-att-alt="employee.name"
t-att-data-employee-id="employee.id"
t-att-href="employee.link"
t-on-click.prevent="() => this._onEmployeeRedirect(employee.id)"/>
<div t-if="is_self"
class="o_media_object d-block rounded-circle border border-info"
t-att-style="'background-image:url(\'/web/image/hr.employee.public/' + employee.id + '/avatar_1024/\')'"/>
</div>
<div class="d-flex flex-grow-1 align-items-center justify-content-between position-relative px-3">
<a t-if="!is_self" t-att-href="employee.link" class="o_employee_redirect d-flex flex-column" t-att-data-employee-id="employee.id" t-on-click.prevent="() => this._onEmployeeRedirect(employee.id)">
<b class="o_media_heading m-0 fs-6" t-esc="employee.name"/>
<small class="text-muted fw-bold" t-esc="employee.job_title"/>
</a>
<div t-if="is_self" class="d-flex flex-column">
<h5 class="o_media_heading m-0" t-esc="employee.name"/>
<small class="text-muted fw-bold" t-esc="employee.job_title"/>
</div>
<button t-if="employee.indirect_sub_count &gt; 0"
class="btn p-0 fs-3"
tabindex="0"
t-att-data-emp-name="employee.name"
t-att-data-emp-id="employee.id"
t-att-data-emp-dir-subs="employee.direct_sub_count"
t-att-data-emp-ind-subs="employee.indirect_sub_count"
data-bs-trigger="focus"
data-bs-toggle="popover"
t-on-click="(event) => this._onOpenPopover(event, employee)">
<a href="#"
t-attf-class="badge rounded-pill bg-white border {{employee.indirect_sub_count &lt; 10 ? 'px-2' : 'px-1' }}"
t-esc="employee.indirect_sub_count"
/>
</button>
</div>
</t>
<t t-name="hr_org_chart.hr_org_chart" owl="1">
<!-- NOTE: Desidered behaviour:
The maximun number of people is always 7 (including 'self'). Managers have priority over suburdinates
Eg. 1 Manager + 1 self = show just 5 subordinates (if availables)
Eg. 0 Manager + 1 self = show 6 subordinates (if available)
-->
<t t-set="emp_count" t-value="0"/>
<div t-if='managers.length &gt; 0' class="o_org_chart_group_up position-relative">
<div t-if='managers_more' class="o_org_chart_more pe-3" t-attf-class="{{max_level !== null ? 'invisible' : ''}}">
<a href="#" t-att-data-employee-id="managers[0].id" class="o_employee_more_managers d-block bg-100 px-3" t-on-click.prevent="() => this._onEmployeeMoreManager(managers[0].id)">
<i class="fa fa-angle-double-up" role="img" aria-label="More managers" title="More managers"/>
</a>
</div>
<t t-foreach="managers" t-as="employee" t-key="employee_index">
<t t-set="emp_count" t-value="emp_count + 1"/>
<t t-call="hr_org_chart.hr_org_chart_employee">
<t t-set="employee_type" t-value="'manager'"/>
</t>
</t>
</div>
<t t-if="children.length || managers.length" t-call="hr_org_chart.hr_org_chart_employee">
<t t-set="employee_type" t-value="'self'"/>
<t t-set="employee" t-value="self"/>
</t>
<t t-if="!children.length &amp;&amp; !managers.length">
<div class="alert alert-info" role="alert">
<p><b>No hierarchy position.</b></p>
<p>This employee has no manager or subordinate.</p>
<p>In order to get an organigram, set a manager and save the record.</p>
</div>
</t>
<div t-if="children.length" t-attf-class="o_org_chart_group_down position-relative #{managers.length &gt; 0 ? 'o_org_chart_has_managers' : ''}">
<t t-foreach="children" t-as="employee" t-key="employee_index">
<t t-set="emp_count" t-value="emp_count + 1"/>
<t t-if="emp_count &lt; 20">
<t t-call="hr_org_chart.hr_org_chart_employee">
<t t-set="employee_type" t-value="'sub'"/>
</t>
</t>
</t>
<t t-if="(children.length + managers.length) &gt; 19">
<div class="o_org_chart_entry o_org_chart_more d-flex overflow-visible">
<div class="o_media_left position-relative">
<a href="#"
t-att-data-employee-id="self.id"
t-att-data-employee-name="self.name"
class="o_org_chart_show_more o_employee_sub_redirect btn btn-link ps-2"
t-on-click.prevent="_onEmployeeSubRedirect">See All</a>
</div>
</div>
</t>
</div>
</t>
<t t-name="hr_org_chart.hr_orgchart_emp_popover" owl="1">
<div class="popover o_org_chart_popup" role="tooltip">
<div class="tooltip-arrow">
</div>
<h3 class="popover-header">
<div class="d-flex align-items-center">
<span class="flex-shrink-0" t-att-style='"background-image:url(\"/web/image/hr.employee.public/" + props.employee.id + "/avatar_1024/\")"'/>
<b class="flew-grow-1"><t t-esc="props.employee.name"/></b>
<a href="#" class="ms-auto o_employee_redirect" t-att-data-employee-id="props.employee.id" t-on-click.prevent="() => this._onEmployeeRedirect(props.employee.id)"><i class="fa fa-external-link" role="img" aria-label='Redirect' title="Redirect"></i></a>
</div>
</h3>
<div class="popover-body">
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr>
<td class="text-end"><b t-esc="props.employee.direct_sub_count"/></td>
<td>
<a href="#" class="o_employee_sub_redirect" data-type='direct'
t-att-data-employee-name="props.employee.name" t-att-data-employee-id="props.employee.id"
t-on-click.prevent="_onEmployeeSubRedirect">
<b>Direct subordinates</b></a>
</td>
</tr>
<tr>
<td class="text-end">
<b t-esc="props.employee.indirect_sub_count - props.employee.direct_sub_count"/>
</td>
<td>
<a href="#" class="o_employee_sub_redirect" data-type='indirect'
t-att-data-employee-name="props.employee.name" t-att-data-employee-id="props.employee.id"
t-on-click.prevent="_onEmployeeSubRedirect">
Indirect subordinates</a>
</td>
</tr>
<tr>
<td class="text-end"><b t-esc="props.employee.indirect_sub_count"/></td>
<td>
<a href="#" class="o_employee_sub_redirect" data-type='total'
t-att-data-employee-name="props.employee.name" t-att-data-employee-id="props.employee.id"
t-on-click.prevent="_onEmployeeSubRedirect">
Total</a>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</t>
</templates>

View file

@ -0,0 +1,6 @@
$o-hr-org-chart-entry-pic-size: 46px;
$o-hr-org-chart-entry-pic-small-size: $o-hr-org-chart-entry-pic-size * .8;

View file

@ -0,0 +1,191 @@
/** @odoo-module **/
import { getFixture } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
let target;
let serverData;
QUnit.module("hr_org_chart", {
async beforeEach() {
target = getFixture();
serverData = {
models: {
hr_employee: {
fields: {
child_ids: {string: "one2many Subordinates field", type: "one2many", relation: 'hr_employee'},
},
records: [{
id: 1,
child_ids: [],
}]
},
},
};
setupViewRegistries();
},
}, function () {
QUnit.test("hr org chart: empty render", async function (assert) {
assert.expect(2);
await makeView({
type: "form",
resModel: 'hr_employee',
serverData: serverData,
arch:
'<form>' +
'<field name="child_ids" widget="hr_org_chart"/>' +
'</form>',
resId: 1,
mockRPC: function (route, args) {
if (route === '/hr/get_org_chart') {
assert.ok('employee_id' in args, "it should have 'employee_id' as argument");
return Promise.resolve({
children: [],
managers: [],
managers_more: false,
});
} else if (route === '/hr/get_redirect_model') {
return Promise.resolve('hr.employee');
}
}
});
assert.strictEqual($(target.querySelector('[name="child_ids"]')).children().length, 1,
"the chart should have 1 child");
});
QUnit.test("hr org chart: render without data", async function (assert) {
assert.expect(2);
await makeView({
type: "form",
resModel: 'hr_employee',
serverData: serverData,
arch:
'<form>' +
'<field name="child_ids" widget="hr_org_chart"/>' +
'</form>',
resId: 1,
mockRPC: function (route, args) {
if (route === '/hr/get_org_chart') {
assert.ok('employee_id' in args, "it should have 'employee_id' as argument");
return Promise.resolve({}); // return no data
}
}
});
assert.strictEqual($(target.querySelector('[name="child_ids"]')).children().length, 1,
"the chart should have 1 child");
});
QUnit.test("hr org chart: basic render", async function (assert) {
assert.expect(3);
await makeView({
type: "form",
resModel: 'hr_employee',
serverData: serverData,
arch:
'<form>' +
'<sheet>' +
'<div id="o_employee_container"><div id="o_employee_main">' +
'<div id="o_employee_right">' +
'<field name="child_ids" widget="hr_org_chart"/>' +
'</div>' +
'</div></div>' +
'</sheet>' +
'</form>',
resId: 1,
mockRPC: function (route, args) {
if (route === '/hr/get_org_chart') {
assert.ok('employee_id' in args, "it should have 'employee_id' as argument");
return Promise.resolve({
children: [{
direct_sub_count: 0,
indirect_sub_count: 0,
job_id: 2,
job_name: 'Sub-Gooroo',
link: 'fake_link',
name: 'Michael Hawkins',
id: 2,
}],
managers: [],
managers_more: false,
self: {
direct_sub_count: 1,
id: 1,
indirect_sub_count: 1,
job_id: 1,
job_name: 'Gooroo',
link: 'fake_link',
name: 'Antoine Langlais',
}
});
} else if (route === '/hr/get_redirect_model') {
return Promise.resolve('hr.employee');
}
}
});
assert.containsOnce(target, '.o_org_chart_entry_sub',
"the chart should have 1 subordinate");
assert.containsOnce(target, '.o_org_chart_entry_self',
"the current employee should only be displayed once in the chart");
});
QUnit.test("hr org chart: basic manager render", async function (assert) {
assert.expect(4);
await makeView({
type: "form",
resModel: 'hr_employee',
serverData: serverData,
arch:
'<form>' +
'<sheet>' +
'<div id="o_employee_container"><div id="o_employee_main">' +
'<div id="o_employee_right">' +
'<field name="child_ids" widget="hr_org_chart"/>' +
'</div>' +
'</div></div>' +
'</sheet>' +
'</form>',
resId: 1,
mockRPC: function (route, args) {
if (route === '/hr/get_org_chart') {
assert.ok('employee_id' in args, "should have 'employee_id' as argument");
return Promise.resolve({
children: [{
direct_sub_count: 0,
indirect_sub_count: 0,
job_id: 2,
job_name: 'Sub-Gooroo',
link: 'fake_link',
name: 'Michael Hawkins',
id: 2,
}],
managers: [{
direct_sub_count: 1,
id: 1,
indirect_sub_count: 2,
job_id: 1,
job_name: 'Chief Gooroo',
link: 'fake_link',
name: 'Antoine Langlais',
}],
managers_more: false,
self: {
direct_sub_count: 1,
id: 1,
indirect_sub_count: 1,
job_id: 3,
job_name: 'Gooroo',
link: 'fake_link',
name: 'John Smith',
}
});
} else if (route === '/hr/get_redirect_model') {
return Promise.resolve('hr.employee');
}
}
});
assert.containsOnce(target, '.o_org_chart_group_up .o_org_chart_entry_manager', "the chart should have 1 manager");
assert.containsOnce(target, '.o_org_chart_group_down .o_org_chart_entry_sub', "the chart should have 1 subordinate");
assert.containsOnce(target, '.o_org_chart_entry_self', "the chart should have only once the current employee");
});
});