19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:31:00 +01:00
parent a1137a1456
commit e1d89e11e3
2789 changed files with 1093187 additions and 605897 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 2 KiB

Before After
Before After

View file

@ -1,49 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 70 70">
<defs>
<mask id="mask" x="0" y="0" width="70" height="70" maskUnits="userSpaceOnUse">
<g id="mask-2" data-name="mask">
<g id="b">
<path id="a" d="M4,0H65c4,0,5,1,5,5V65c0,4-1,5-5,5H4c-3,0-4-1-4-5V5C0,1,1,0,4,0Z" fill="#fff" fill-rule="evenodd"/>
</g>
</g>
</mask>
<linearGradient id="linear-gradient" x1="-909.8" y1="-216.96" x2="-910.8" y2="-217.96" gradientTransform="matrix(70, 0, 0, -70, 63756, -15187.42)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#269396"/>
<stop offset="1" stop-color="#218689"/>
</linearGradient>
</defs>
<g mask="url(#mask)">
<g>
<path d="M0,0H70V70H0Z" fill-rule="evenodd" fill="url(#linear-gradient)"/>
<path d="M4,1H65c2.67,0,4.33.67,5,2V0H0V3C.67,1.67,2,1,4,1Z" fill="#fff" fill-opacity="0.38" fill-rule="evenodd"/>
<path d="M43,69H4c-2,0-4-.14-4-4V36.23L10.93,25.3l6-5,4.15,2.53L30.9,13l5.49-1.51,3.87,2.33L40,21.49,16.5,45.05l.72,1.51L30.16,33.63,40.85,33,52.13,21.68,54.77,21l5,9.6L41.22,49.17l7.69,1.15,5.64-5.64,1.24,11.49Z" fill="#393939" fill-rule="evenodd" opacity="0.32" style="isolation: isolate"/>
<path d="M4,69H65c2.67,0,4.33-1,5-3v4H0V66A3.92,3.92,0,0,0,4,69Z" fill-opacity="0.38" fill-rule="evenodd"/>
<g>
<g opacity="0.4">
<g>
<g>
<path d="M33.94,32.4h0a9.12,9.12,0,0,0,0,18.23h.19a9.12,9.12,0,0,0-.19-18.23Z"/>
<path d="M55.66,45a2.5,2.5,0,0,0-3.1,1.7L50.71,53,40.34,50.41a11,11,0,0,1-12.77,0L17.18,53.13,15.3,46.67a2.5,2.5,0,0,0-4.81,1.4L13,56.69a2.49,2.49,0,0,0,2.39,1.8,2.38,2.38,0,0,0,.46,0l8.94-1.64v4.76H43.08V56.38l8.81,2a2.74,2.74,0,0,0,.56.06,2.49,2.49,0,0,0,2.4-1.8l2.51-8.62A2.49,2.49,0,0,0,55.66,45Z"/>
</g>
<g>
<path d="M14.91,36a1.77,1.77,0,0,1-1.26-.52L8.08,29.94a1.77,1.77,0,0,1-.52-1.26,1.74,1.74,0,0,1,.52-1.26l5.57-5.58a1.8,1.8,0,0,1,2.53,0l5.57,5.57a1.8,1.8,0,0,1,0,2.53l-5.57,5.57A1.78,1.78,0,0,1,14.91,36ZM9.65,28.67l5.27,5.27,5.26-5.26-5.27-5.27Z"/>
<path d="M56.24,33.48H48a1.79,1.79,0,0,1-1.59-2.58h0l4.14-8.28a1.76,1.76,0,0,1,1.59-1h0a1.79,1.79,0,0,1,1.6,1l4.14,8.28a1.75,1.75,0,0,1-.08,1.73A1.77,1.77,0,0,1,56.24,33.48Zm-7.93-2h7.58L52.1,23.9Z"/>
<path d="M33.58,25.22a6.38,6.38,0,0,1-4.53-1.87h0a6.42,6.42,0,1,1,4.53,1.87Zm-3.12-3.28a4.41,4.41,0,1,0,0-6.24,4.43,4.43,0,0,0,0,6.24Z"/>
</g>
</g>
</g>
<g>
<g>
<path d="M35.93,30.42h0a9.12,9.12,0,0,0,0,18.23h.19a9.12,9.12,0,0,0-.19-18.23Z" fill="#fff"/>
<path d="M57.65,43a2.5,2.5,0,0,0-3.1,1.7L52.7,51l-10.37-2.6a11.06,11.06,0,0,1-12.77,0l-10.4,2.71-1.88-6.46a2.5,2.5,0,1,0-4.8,1.4L15,54.7a2.45,2.45,0,0,0,2.86,1.76l8.94-1.64v4.76H45.07V54.39l8.8,2a2.78,2.78,0,0,0,.57.07,2.5,2.5,0,0,0,2.4-1.81l2.51-8.62A2.49,2.49,0,0,0,57.65,43Z" fill="#fff"/>
</g>
<g>
<path d="M16.9,34a1.77,1.77,0,0,1-1.26-.52L10.07,28a1.77,1.77,0,0,1-.52-1.26,1.74,1.74,0,0,1,.52-1.26l5.57-5.58a1.79,1.79,0,0,1,2.52,0l5.58,5.58a1.74,1.74,0,0,1,.52,1.26A1.81,1.81,0,0,1,23.74,28l-5.57,5.57A1.78,1.78,0,0,1,16.9,34Zm-5.26-7.35L16.9,32l5.27-5.27L16.9,21.42Z" fill="#fff"/>
<path d="M58.23,31.49H50a1.75,1.75,0,0,1-1.51-.84,1.77,1.77,0,0,1-.08-1.74h0l4.14-8.28a1.75,1.75,0,0,1,1.59-1h0a1.78,1.78,0,0,1,1.6,1l4.14,8.28a1.79,1.79,0,0,1-1.6,2.58Zm-7.93-2h7.58l-3.79-7.58Z" fill="#fff"/>
<path d="M35.57,23.24A6.39,6.39,0,0,1,31,21.36h0a6.4,6.4,0,1,1,4.53,1.88ZM32.45,20a4.41,4.41,0,1,0,0-6.23,4.41,4.41,0,0,0,0,6.23Z" fill="#fff"/>
</g>
</g>
</g>
</g>
</g>
</svg>
<svg width="50" height="50" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg"><path d="M30.576 14.907a4.029 4.029 0 0 1 5.697 0l8.547 8.546a4.029 4.029 0 0 1 0 5.698l-15.669 15.67a4.029 4.029 0 0 1-5.698 0l-8.546-8.547a4.029 4.029 0 0 1 0-5.698l15.669-15.67Z" fill="#FBB945"/><path d="M18.721 9.047a4.029 4.029 0 0 1 5.264-2.18l11.167 4.625a4.029 4.029 0 0 1 2.18 5.264l-8.48 20.472a4.029 4.029 0 0 1-5.263 2.181l-11.167-4.626a4.029 4.029 0 0 1-2.18-5.264l8.48-20.472Z" fill="#985184"/><path d="M37.527 16.16c-.048.2-.113.4-.194.596l-8.48 20.472a4.029 4.029 0 0 1-5.265 2.18l-9.236-3.825a4.03 4.03 0 0 1 .555-5.007l15.669-15.67a4.029 4.029 0 0 1 5.697 0l1.254 1.254Z" fill="#712258"/><path d="M4 8.029A4.029 4.029 0 0 1 8.029 4h12.087a4.029 4.029 0 0 1 4.029 4.029v22.16a4.029 4.029 0 0 1-4.03 4.028H8.03A4.029 4.029 0 0 1 4 30.188V8.03Z" fill="#1AD3BB"/><path d="M23.973 6.861c.112.37.172.762.172 1.168v22.16a4.029 4.029 0 0 1-4.029 4.029h-8.658a4.03 4.03 0 0 1-1.217-4.699l8.48-20.472a4.029 4.029 0 0 1 5.252-2.186Z" fill="#005E7A"/></svg>

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 1 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-inherit="resource_mail.AvatarCardResourcePopover" t-inherit-mode="extension">
<xpath expr="//div[hasclass('o_avatar_card_details')]" position="inside">
<div t-if="skills?.length">
<span class="fw-bold">Skills</span>
<div class="d-flex flex-wrap gap-1 o_employee_skills_tags mt-1 overflow-hidden">
<TagsList tags="skillTags" visibleItemsLimit="5"/>
</div>
</div>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,38 @@
import { patch } from "@web/core/utils/patch";
import { AvatarCardResourcePopover } from "@resource_mail/components/avatar_card_resource/avatar_card_resource_popover";
export const patchAvatarCardResourcePopover = {
loadAdditionalData() {
const promises = super.loadAdditionalData();
this.skills = false;
if (this.record.employee_skill_ids?.length) {
promises.push(
this.orm
.read("hr.employee.skill", this.record.employee_skill_ids, ["display_name", "color"])
.then((skills) => {
this.skills = skills;
})
);
}
return promises;
},
get fieldNames() {
return [
...super.fieldNames,
"employee_skill_ids",
];
},
get hasFooter() {
return this.skills?.length > 0 || super.hasFooter;
},
get skillTags() {
return this.skills.map(({ id, display_name, color }) => ({
id,
text: display_name,
colorIndex: color,
}));
},
};
export const unpatchAvatarCardResourcePopover = patch(AvatarCardResourcePopover.prototype, patchAvatarCardResourcePopover);

View file

@ -0,0 +1,59 @@
import { Component, onWillStart, onWillUpdateProps } from "@odoo/owl";
import { standardWidgetProps } from "@web/views/widgets/standard_widget_props";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { formatDate } from "@web/core/l10n/dates";
export class InternalResumeLineComponent extends Component {
static template = "hr_skills.InternalResumeLineComponent";
static props = { ...standardWidgetProps };
setup(){
super.setup();
this.orm = useService("orm");
onWillStart(async () => {
this.internalResumeLines = await this.getInternalResumeLines(
this.props.record.resId,
this.props.record.resModel
)
})
onWillUpdateProps(async (nextProps) => {
this.internalResumeLines = await this.getInternalResumeLines(
nextProps.record.resId,
nextProps.record.resModel
)
})
}
async getInternalResumeLines(resId, resModel){
const internalResumeLines = await this.orm.call(
"hr.employee",
"get_internal_resume_lines",
[resId, resModel]
);
return internalResumeLines;
}
get companyId(){
return this.props.record.data.company_id.display_name;
}
get haveResumeLines(){
return this.props.record.data.resume_line_ids.records.length || this.internalResumeLines.length;
}
formatDate(date) {
const formattedDate = luxon.DateTime.fromISO(date);
return formatDate(formattedDate);
}
}
export const internalResumeLinesComponent = {
component: InternalResumeLineComponent,
fieldDependencies: [
{ name: "company_id", type: "many2one" },
{ name: "resume_line_ids", type: "one2many"}
],
};
registry.category("view_widgets").add("internal_resume_lines", internalResumeLinesComponent);

View file

@ -1,4 +1,4 @@
.o_field_resume_one2many {
.o_widget_internal_resume_lines {
$o-hrs-timeline-entry-padding: .5rem;
$o-hrs-timeline-dot-size: .6rem;
@ -16,12 +16,19 @@
}
&:before {
@include o-position-absolute(0, $left: ($o-hrs-timeline-dot-size * .5 + $o-hrs-timeline-entry-padding));
width: 1px;
@include o-position-absolute(0, $left: ($o-hrs-timeline-dot-size * .5 + o-to-rem($o-horizontal-padding) - o-to-rem($border-width)));
width: $border-width;
height: 100%;
margin-left: 2.1rem;
background-color: $border-color;
content: "";
@include media-breakpoint-up(lg) {
left: ($o-hrs-timeline-dot-size * .5 + o-to-rem(map-get($spacers, 4)) - o-to-rem($border-width))
}
@include media-breakpoint-up(xxl) {
left: ($o-hrs-timeline-dot-size * .5 + o-to-rem($o-horizontal-padding) - o-to-rem($border-width))
}
}
}
}

View file

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<div t-name="hr_skills.InternalResumeLineComponent" class="internalResume o_list_view o_field_x2many o_field_x2many_list">
<div class="o_list_renderer o_renderer table-responsive" tabindex="-1">
<table class="o_list_table table table-sm table-hover position-relative mb-0 o_list_table_ungrouped table-striped mb-3 overflow-hidden o_skill_table table-borderless">
<thead style="visibility: collapse;">
<tr>
<th style="width: 32px; min-width: 32px;"></th>
<th class="w-100"></th>
<th style="width: 32px; min-width: 32px;"></th>
</tr>
</thead>
<tbody class="ui-sortable">
<t t-if="internalResumeLines.length">
<tr class="o_group_has_content o_group_header o_resume_group_header">
<th tabindex="-1" class="o_group_name" colspan="2">
<div class="d-flex align-items-center">
Experience - <t t-out="companyId"/>
</div>
</th>
</tr>
</t>
<t t-foreach="internalResumeLines" t-as="record" t-key="record.id">
<tr class="o_data_row">
<td class="o_resume_timeline_cell position-relative pe-lg-2">
<div class="rounded-circle bg-info position-relative"/>
</td>
<td class="o_data_cell pt-0">
<div t-attf-class="o_resume_line" t-att-data-id="id">
<small class="o_resume_line_dates fw-bold">
<t t-out="formatDate(record.date_start)"/>
-
<t t-if="record.date_end" t-out="formatDate(record.date_end)"/>
<t t-else="">
Current
</t>
</small>
<h4 class="o_resume_line_title mt-2" t-esc="record.job_title"/>
</div>
</td>
</tr>
</t>
</tbody>
</table>
<t t-if="!haveResumeLines">
<div name="no_resume_line" class="ms-4 mt-3 text-muted fst-italic oe_inline">
<p>
There are no resume lines on this employee.
</p>
</div>
</t>
</div>
</div>
</odoo>

View file

@ -0,0 +1,18 @@
import { registry } from '@web/core/registry';
import { ListBooleanToggleField, listBooleanToggleField } from "@web/views/fields/boolean_toggle/list_boolean_toggle_field";
export class ListBooleanToggleLoadField extends ListBooleanToggleField {
async onChange(newValue) {
this.state.value = newValue;
// technical_is_new_default ensure to the backend which level trigger the onchange
const changes = { [this.props.name]: newValue, technical_is_new_default: newValue };
await this.props.record.update(changes, { save: this.props.autosave });
}
}
export const listBooleanToggleLoadField = {
...listBooleanToggleField,
component: ListBooleanToggleLoadField,
};
registry.category("fields").add("boolean_toggle_load", listBooleanToggleLoadField);

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="hr_skills.X2ManyFieldDialog" t-inherit="web.X2ManyFieldDialog" t-inherit-mode="primary">
<xpath expr="//button[hasclass('o_form_button_save')]" position="replace">
<button class="btn btn-primary o_form_button_save" t-on-click="() => this.save()" data-hotkey="c">Select &amp; Close</button>
</xpath>
<xpath expr="//button[hasclass('o_form_button_save_new')]" position="replace">
<button class="btn btn-primary o_form_button_save_new" t-on-click="saveAndNew" data-hotkey="n">Select &amp; New</button>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,78 @@
import { Component } from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { evaluateBooleanExpr } from "@web/core/py_js/py";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
export class FormattedDate extends Component {
static template = "hr_skills.FormattedDate";
static props = {
...standardFieldProps,
dayFormat: String,
monthFormat: String,
yearFormat: String,
color: Object,
};
get value() {
return this.props.record.data[this.props.name];
}
get placeholder() {
return _t("Indefinite");
}
get colorClass() {
const colors = Object.keys(this.props.color);
if (colors) {
for (const colorName of colors) {
if (evaluateBooleanExpr(`${this.props.color[colorName]}`, this.props.record.evalContextWithVirtualIds)) {
return "text-" + colorName;
}
}
}
return ""
}
}
export const formattedDate = {
component: FormattedDate,
supportedOptions: [
{
label: _t("Day Format"),
name: "day_format",
type: "string",
default: "numeric",
},
{
label: _t("Month Format"),
name: "month_format",
type: "string",
default: "numeric",
},
{
label: _t("Year Format"),
name: "year_format",
type: "string",
default: "numeric",
},
{
label: _t("Color"),
name: "color",
type: "string",
default: {},
},
],
supportedTypes: ["date"],
extractProps({ options }) {
return {
dayFormat: options.day_format || "numeric",
monthFormat: options.month_format || "numeric",
yearFormat: options.year_format || "numeric",
color: options.color || {},
};
},
};
registry.category("fields").add("formatted_date", formattedDate);

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="hr_skills.FormattedDate">
<span t-out="this.value.toLocaleString({day: this.props.dayFormat, month: this.props.monthFormat, year: this.props.yearFormat})" t-if="this.value" t-att-class="this.colorClass"/>
<span t-else="" t-out="this.placeholder" class='text-muted opacity-50'/>
</t>
</templates>

View file

@ -0,0 +1,10 @@
import { TagsList } from "@web/core/tags_list/tags_list";
export class SkillsTagList extends TagsList {
static template = "hr_skills.SkillsTagsList";
getTextStyle(tag) {
return tag.defaultLevel
}
}

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="hr_skills.SkillsTagsList" t-inherit="web.TagsList">
<xpath expr='//span[hasclass("o_tag")]' position="attributes">
<attribute name="t-attf-class" add="{{ getTextStyle(tag) ? 'border border-2' : '' }}" separator=" "/>
<attribute name="t-attf-style" add="{{ getTextStyle(tag) ? 'border-color: rgb(140, 140, 140) !important ;' : ''}}" separator=" "/>
</xpath>
<xpath expr='//div[hasclass("o_tag_badge_text")]' position="attributes">
<attribute name="t-attf-class" add="{{ getTextStyle(tag) ? 'fw-bold' : '' }}" separator=" "/>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,28 @@
import { registry } from "@web/core/registry";
import {
Many2ManyTagsField,
many2ManyTagsField,
} from "@web/views/fields/many2many_tags/many2many_tags_field";
import { SkillsTagList } from "../hr_skills_tags_list/hr_skills_tags_list";
class SkillsMany2ManyTags extends Many2ManyTagsField {
static components = { ...Many2ManyTagsField.components, TagsList: SkillsTagList };
getTagProps(record) {
return { ...super.getTagProps(record), defaultLevel: record.data.default_level };
}
}
export const skillsMany2ManyTags = {
...many2ManyTagsField,
component: SkillsMany2ManyTags,
relatedFields: (fieldInfo) => {
return [
...many2ManyTagsField.relatedFields(fieldInfo),
{ name: "default_level", type: "boolean"},
];
},
};
registry.category("fields").add("many2many_tags_skills", skillsMany2ManyTags);

View file

@ -0,0 +1,63 @@
import { _t } from "@web/core/l10n/translation";
import { X2ManyField, x2ManyField } from "@web/views/fields/x2many/x2many_field";
import { useX2ManyCrud, useOpenX2ManyRecord } from "@web/views/fields/relational_utils";
import { registry } from "@web/core/registry";
import { TagsList } from "@web/core/tags_list/tags_list";
export class One2ManyTagsSkillsField extends X2ManyField {
static components = {
...X2ManyField.components,
TagsList,
};
static template = "hr_recruitment_skills.One2ManyTagsSkillsField";
setup() {
super.setup();
const { saveRecord, updateRecord } = useX2ManyCrud(() => this.list, this.isMany2Many);
const openRecord = useOpenX2ManyRecord({
resModel: this.list.resModel,
activeField: this.activeField,
activeActions: this.activeActions,
getList: () => this.list,
saveRecord: saveRecord,
updateRecord: updateRecord,
withParentId: this.props.widget !== "many2many",
});
this._openRecord = (params) => {
params.title = _t("Select Skills");
openRecord({ ...params });
};
}
getTagProps(record) {
const tagProps = {
id: record.id,
resId: record.resId,
text: record.data.display_name,
colorIndex: record.data.color,
canEdit: true,
onClick: (ev) => this.onTagClick(ev, record),
onDelete: !this.props.readonly ? () => this.activeActions.onDelete(record) : undefined,
};
return tagProps;
}
get tags() {
return this.props.record.data[this.props.name].records.map((record) =>
this.getTagProps(record)
);
}
onTagClick(ev, record) {
this.openRecord(record);
}
}
export const one2ManyTagsSkillsField = {
...x2ManyField,
component: One2ManyTagsSkillsField,
};
registry.category("fields").add("many2one_tags_skills", one2ManyTagsSkillsField);

View file

@ -0,0 +1,3 @@
.o_field_many2one_tags_skills a.o_delete {
color: inherit;
}

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="hr_recruitment_skills.One2ManyTagsSkillsField">
<div class="d-flex align-items-baseline"
t-attf-class="{{ tags.length &amp;&amp; 'gap-1'}}">
<div class=" d-inline-flex flex-wrap gap-1 mw-100">
<TagsList tags="tags" />
</div>
<button t-on-click="onAdd"
class="btn btn-link text-action"
role="button"
style="padding: 0; line-height: 0; height: min-content;">
<i class="fa fa-plus text-info" />
</button>
</div>
</t>
</templates>

View file

@ -1,40 +0,0 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import { formatDate } from "@web/core/l10n/dates";
import { SkillsX2ManyField } from "./skills_one2many";
import { CommonSkillsListRenderer } from "../views/skills_list_renderer";
export class ResumeListRenderer extends CommonSkillsListRenderer {
get groupBy() {
return 'line_type_id';
}
get colspan() {
if (this.props.activeActions) {
return 3;
}
return 2;
}
formatDate(date) {
return formatDate(date);
}
setDefaultColumnWidths() {}
}
ResumeListRenderer.template = 'hr_skills.ResumeListRenderer';
ResumeListRenderer.rowsTemplate = "hr_skills.ResumeListRenderer.Rows";
ResumeListRenderer.recordRowTemplate = "hr_skills.ResumeListRenderer.RecordRow";
export class ResumeX2ManyField extends SkillsX2ManyField {}
ResumeX2ManyField.components = {
...SkillsX2ManyField.components,
ListRenderer: ResumeListRenderer,
};
registry.category("fields")
.add("resume_one2many", ResumeX2ManyField);

View file

@ -1,46 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<t t-name="hr_skills.ResumeListRenderer" owl="1" t-inherit-mode="primary" t-inherit="hr_skills.SkillsListRenderer">
<xpath expr="//table" position="attributes">
<attribute name="t-attf-class" add="table-borderless {{ !showTable ? 'd-none' : ''}}" remove="table-striped" separator=" "/>
</xpath>
<xpath expr="//thead/tr" position="replace">
<tr>
<th style="width: 32px; min-width: 32px;"></th>
<th class="w-100"></th>
<th t-if="isEditable" class="o_list_actions_header" style="width: 32px; min-width: 32px"></th>
</tr>
</xpath>
</t>
<t t-name="hr_skills.ResumeListRenderer.Rows" owl="1" t-inherit-mode="primary" t-inherit="hr_skills.SkillsListRenderer.Rows">
<xpath expr="//tr" position="attributes">
<attribute name="class" add="o_resume_group_header" separator=" "/>
</xpath>
<xpath expr="//th[hasclass('o_group_name')]" position="after">
<th></th>
</xpath>
</t>
<t t-name="hr_skills.ResumeListRenderer.RecordRow" owl="1" t-inherit-mode="primary" t-inherit="web.ListRenderer.RecordRow">
<xpath expr="//t[@t-foreach='getColumns(record)']" position="replace">
<t t-set="data" t-value="record.data"/>
<t t-if="data.display_type === 'classic'" id='row'>
<td class="o_resume_timeline_cell position-relative pe-lg-2" id='hiii'>
<div class="rounded-circle bg-info position-relative"/>
</td>
<td class="o_data_cell pt-0" t-on-click="(ev) => this.onCellClicked(record, null, ev)">
<div t-attf-class="o_resume_line {{data.display_type == 'certification' ? 'o_resume_line_display_certification' : ''}}" t-att-data-id="id">
<small class="o_resume_line_dates fw-bold">
<t t-out="formatDate(data.date_start)"/> -
<t t-if="data.date_end" t-out="formatDate(data.date_end)"/>
<t t-else="">Current</t>
</small>
<h4 class="o_resume_line_title mt-2" t-esc="data.name"/>
<p t-if="data.description" class="o_resume_line_desc" t-out="data.description" t-ref="link-target-blank"/>
</div>
</td>
</t>
</xpath>
</t>
</odoo>

View file

@ -0,0 +1,66 @@
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { onMounted, onPatched, useRef } from "@odoo/owl";
import { formatDate } from "@web/core/l10n/dates";
import { SkillsX2ManyField, skillsX2ManyField } from "../skills_one2many/skills_one2many";
import { CommonSkillsListRenderer } from "../../views/skills_list_renderer";
export class ResumeListRenderer extends CommonSkillsListRenderer {
static template = "hr_skills.ResumeListRenderer";
static rowsTemplate = "hr_skills.ResumeListRenderer.Rows";
static recordRowTemplate = "hr_skills.ResumeListRenderer.RecordRow";
static useMagicColumnWidths = false;
setup() {
super.setup();
this.linkRef = useRef("link-target-blank");
onMounted(this._setLinksToOpenInNewTab);
onPatched(this._setLinksToOpenInNewTab);
}
get groupBy() {
return "line_type_id";
}
get colspan() {
if (this.props.activeActions) {
return 3;
}
return 2;
}
formatDate(date) {
return formatDate(date);
}
_setLinksToOpenInNewTab() {
const resumeLines = this.linkRef.el;
// Find all links within the resume description and set target to "_blank"
if (resumeLines){
const links = resumeLines.querySelectorAll('a');
links.forEach(link => {
link.setAttribute('target', '_blank'); // Set target="_blank" to open links in new tab
});
}
}
}
export class ResumeX2ManyField extends SkillsX2ManyField {
static components = {
...SkillsX2ManyField.components,
ListRenderer: ResumeListRenderer,
};
getWizardTitleName() {
return _t("New Resume Line");
}
}
export const resumeX2ManyField = {
...skillsX2ManyField,
component: ResumeX2ManyField,
};
registry.category("fields").add("resume_one2many", resumeX2ManyField);

View file

@ -0,0 +1,62 @@
.o_field_resume_one2many {
$o-hrs-timeline-entry-padding: .5rem;
$o-hrs-timeline-dot-size: .6rem;
.o_data_row {
border-bottom: none;
}
.o_data_row td {
padding: $o-hrs-timeline-entry-padding;
&.o_resume_timeline_cell {
overflow: hidden !important;
div {
width: $o-hrs-timeline-dot-size;
height: $o-hrs-timeline-dot-size;
}
&:before {
@include o-position-absolute(0, $left: ($o-hrs-timeline-dot-size * .5 + o-to-rem($o-horizontal-padding) - o-to-rem($border-width)));
width: $border-width;
height: 100%;
background-color: $border-color;
content: "";
@include media-breakpoint-up(lg) {
left: ($o-hrs-timeline-dot-size * .5 + o-to-rem(map-get($spacers, 4)) - o-to-rem($border-width))
}
@include media-breakpoint-up(xxl) {
left: ($o-hrs-timeline-dot-size * .5 + o-to-rem($o-horizontal-padding) - o-to-rem($border-width))
}
}
}
}
.o_resume_line_desc {
overflow: auto;
}
.o_resume_line_title, .o_resume_line_desc {
white-space: normal;
}
.o_resume_line_title, .o_resume_line_dates {
line-height: 1;
}
.o_resume_group_header + .o_data_row .o_resume_timeline_cell:before {
top: $o-hrs-timeline-entry-padding;
}
.o_data_row.o_data_row_last {
.o_resume_line_desc {
margin-bottom: $headings-margin-bottom;
}
.o_resume_timeline_cell:before {
height: $o-hrs-timeline-entry-padding;
}
}
}

View file

@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<t t-name="hr_skills.ResumeListRenderer" t-inherit-mode="primary" t-inherit="hr_skills.SkillsListRenderer">
<xpath expr="//table" position="attributes">
<attribute name="t-attf-class" add="table-borderless {{ !showTable ? 'd-none' : ''}}" remove="table-striped" separator=" "/>
</xpath>
<xpath expr="//table" position="after">
<t t-if="!showTable">
<button t-on-click="props.onAdd" class="btn btn-secondary ms-4 mt-3" role="button" t-if="isEditable">
Create Resume Lines
</button>
</t>
</xpath>
<xpath expr="//div[@name='skills_header']" position="replace"/>
<xpath expr="//thead/tr" position="replace">
<tr>
<th style="width: 32px; min-width: 32px;"></th>
<th class="w-100"></th>
<th t-if="isEditable" class="o_list_actions_header" style="width: 32px; min-width: 32px"></th>
</tr>
</xpath>
<xpath expr="//div[@name='no_skills']" position="replace"/>
</t>
<t t-name="hr_skills.ResumeListRenderer.Rows" t-inherit-mode="primary" t-inherit="hr_skills.SkillsListRenderer.Rows">
<xpath expr="//tr" position="attributes">
<attribute name="class" add="o_resume_group_header" separator=" "/>
</xpath>
<xpath expr="//th[hasclass('o_group_name')]" position="after">
<th></th>
</xpath>
<xpath expr="//button[@id='add_button']" position="attributes">
<attribute name="t-on-click">() => props.onAdd({ context: { default_line_type_id: skill_group[1].id }})</attribute>
</xpath>
</t>
<t t-name="hr_skills.ResumeListRenderer.RecordRow" t-inherit-mode="primary" t-inherit="web.ListRenderer.RecordRow">
<xpath expr="//t[@t-foreach='getColumns(record)']" position="replace">
<t t-set="data" t-value="record.data"/>
<td class="o_resume_timeline_cell position-relative pe-lg-2">
<div class="rounded-circle bg-info position-relative"/>
</td>
<td class="o_data_cell pt-0" t-on-click="(ev) => this.onCellClicked(record, null, ev)">
<div t-attf-class="o_resume_line" t-att-data-id="id">
<small class="o_resume_line_dates fw-bold">
<t t-out="formatDate(data.date_start)"/>
<t t-if="!data.is_course">
-
<t t-if="data.date_end" t-out="formatDate(data.date_end)"/>
<t t-else="">
Current
</t>
</t>
<t t-if="data.is_course &amp;&amp; data.duration > 0">
(<t t-out="data.duration"/> hours)
</t>
</small>
<div class="d-flex align-items-center">
<h4 class="o_resume_line_title mt-2 me-2" t-esc="data.name"/>
<t t-if="data.is_course">
<a id="external_link" t-if="data.external_url" t-attf-href="#{data.external_url}" target="_blank" class="ms-2 fa fa-external-link btn-secondary" style="font-size: 1rem;"/>
</t>
</div>
<p t-if="data.description" class="o_resume_line_desc" t-out="data.description" t-ref="link-target-blank"/>
</div>
</td>
</xpath>
</t>
</odoo>

View file

@ -1,44 +0,0 @@
/** @odoo-module */
import { X2ManyField } from "@web/views/fields/x2many/x2many_field";
import { registry } from "@web/core/registry";
import { CommonSkillsListRenderer } from "../views/skills_list_renderer";
export class SkillsListRenderer extends CommonSkillsListRenderer {
get groupBy() {
return 'skill_type_id';
}
calculateColumnWidth(column) {
if (column.name != 'skill_level_id') {
return {
type: 'absolute',
value: '90px',
}
}
return super.calculateColumnWidth(column);
}
}
SkillsListRenderer.template = 'hr_skills.SkillsListRenderer';
export class SkillsX2ManyField extends X2ManyField {
async onAdd({ context, editable } = {}) {
const employeeId = this.props.record.resId;
return super.onAdd({
editable,
context: {
...context,
default_employee_id: employeeId,
}
});
}
}
SkillsX2ManyField.components = {
...X2ManyField.components,
ListRenderer: SkillsListRenderer,
};
registry.category("fields").add("skills_one2many", SkillsX2ManyField);

View file

@ -1,23 +0,0 @@
.o_field_skills_one2many, .o_field_resume_one2many {
.o_progress {
background: $gray-300;
border: 0;
height: 5px;
}
.o_progressbar_value {
font-size: $font-size-sm;
font-weight: bold;
}
}
table.o_skill_table, .o_hr_skills_dialog_form {
.o_progressbar {
display: flex;
align-items: center;
.o_progressbar_value input {
width: auto;
}
}
}

View file

@ -1,38 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<t t-name="hr_skills.SkillsListRenderer" owl="1" t-inherit-mode="primary" t-inherit="web.ListRenderer">
<xpath expr="//table" position="attributes">
<attribute name="t-attf-class" add="mb-1 {{ !isEditable ? 'cursor-default' : '' }} {{ !showTable ? 'd-none' : ''}} o_skill_table" separator=" "/>
</xpath>
<xpath expr="//thead" position="attributes">
<attribute name="style">visibility: collapse;</attribute>
</xpath>
<xpath expr="//table" position="after">
<t t-if="!showTable">
<button t-on-click="props.onAdd" class="btn btn-secondary ms-4 mt-3" role="button" t-if="isEditable">
Create a new entry
</button>
</t>
</xpath>
</t>
<t t-name="hr_skills.SkillsListRenderer.Rows" owl="1">
<t t-foreach="Object.entries(groupedList)" t-as="skill_group" t-key="skill_group[0]">
<tr class="o_group_has_content o_group_header">
<th tabindex="-1" class="o_group_name" t-att-colspan="colspan">
<div class="d-flex justify-content-between align-items-center">
<span t-esc="skill_group[1].name"/>
<button class="btn btn-secondary btn-sm"
t-if="isEditable"
t-on-click="() => props.onAdd({ context: { default_skill_type_id: skill_group[1].id }})"
role="button">ADD</button>
</div>
</th>
</tr>
<t t-foreach="skill_group[1].list.records" t-as="record" t-key="record.id">
<t t-set="group" t-value="skill_group[1]"/>
<t t-call="{{ constructor.recordRowTemplate }}"/>
</t>
</t>
</t>
</odoo>

View file

@ -0,0 +1,121 @@
import { X2ManyField, x2ManyField } from "@web/views/fields/x2many/x2many_field";
import { useX2ManyCrud, useOpenX2ManyRecord } from "@web/views/fields/relational_utils";
import { registry } from "@web/core/registry";
import { _t } from "@web/core/l10n/translation";
import { user } from "@web/core/user";
import { CommonSkillsListRenderer } from "../../views/skills_list_renderer";
import { useService } from '@web/core/utils/hooks';
import { onWillStart } from "@odoo/owl";
export class SkillsListRenderer extends CommonSkillsListRenderer {
static template = "hr_skills.SkillsListRenderer";
setup() {
super.setup();
this.orm = useService('orm');
this.actionService = useService("action");
onWillStart(async () => {
const res = await this.orm.searchCount('hr.skill.type', []);
this.anySkills = res > 0;
[this.user] = await this.orm.read("res.users", [user.userId], ["employee_ids"]);
this.IsHrUser = await user.hasGroup("hr.group_hr_user");
this.userSubordinates = (await this.orm.searchRead(
"hr.employee",
[["id", "child_of", this.user.employee_ids]],
["id"]
)).map((record) => record["id"]);
});
}
get groupBy() {
return 'skill_type_id';
}
async skillTypesAction() {
return this.actionService.doAction("hr_skills.hr_skill_type_action");
}
async openSkillsReport() {
// fetch id through employee or public.employee
const id = this.env.model.root.data.id || this.env.model.root.data.employee_id.id;
this.actionService.doAction({
type: "ir.actions.act_window",
name: _t("Skills Report"),
res_model: "hr.employee.skill.history.report",
view_mode: "graph,list",
views: [[false, "graph"]],
context: {
'fill_temporal': false,
},
target: "current",
domain: [['employee_id', '=', id]],
});
}
get showTimeline() {
return this.SkillsRight && !this.props.list.context.no_timeline;
}
get SkillsRight() {
let isSubordinate = false;
if (this.env.model.root.data.employee_id) {
isSubordinate = this.userSubordinates.includes(this.env.model.root.data.employee_id.id);
}
return this.IsHrUser || isSubordinate;
}
}
export class SkillsX2ManyField extends X2ManyField {
static components = {
...X2ManyField.components,
ListRenderer: SkillsListRenderer,
};
setup() {
super.setup();
this.orm = useService('orm');
this.actionService = useService('action');
const { saveRecord, updateRecord } = useX2ManyCrud(
() => this.list,
this.isMany2Many
);
const openRecord = useOpenX2ManyRecord({
resModel: this.list.resModel,
activeField: this.activeField,
activeActions: this.activeActions,
getList: () => this.list,
saveRecord: saveRecord,
updateRecord: updateRecord,
withParentId: this.props.widget !== "many2many",
});
this._openRecord = (params) => {
params.title = this.getWizardTitleName();
openRecord({...params});
};
}
getWizardTitleName() {
return _t("Update Skills")
}
async onAdd({ context, editable } = {}) {
const employeeId = this.props.record.resModel === "res.users" ? this.props.record.data.employee_id.id : this.props.record.resId;
return super.onAdd({
editable,
context: {
...context,
default_employee_id: employeeId,
}
});
}
}
export const skillsX2ManyField = {
...x2ManyField,
component: SkillsX2ManyField,
};
registry.category("fields").add("skills_one2many", skillsX2ManyField);

View file

@ -0,0 +1,58 @@
.o_field_skills_one2many, .o_field_resume_one2many {
.o_progress {
background: $gray-300;
border: 0;
height: 5px;
}
.o_progressbar_value {
font-size: $font-size-sm;
font-weight: bold;
}
}
table.o_skill_table {
.o_hr_skills_dialog_form {
.o_progressbar {
display: flex;
align-items: center;
.o_progressbar_value input {
width: auto;
}
}
}
table-layout: fixed;
}
.skills_header {
padding-inline: var(--ListRenderer-table-padding-x);
}
.o_skill_table thead {
/* This is to remove the top row with the column names from most displays of the skill table */
visibility: collapse;
}
.o_skill_table:not(.o_group_resume .o_skill_table) > thead {
/* This is needed because the border is rendered on chrome by default but not firefox but we also */
/* don't want to render any extra borders for the resume display */
border-width: 1px;
}
.o_employee_skills .skills_header {
/* Margin and padding classes are added to .skills_header to work in both HR and Recruitment contexts. */
/* However, the combined spacing is too much for either scenario, so one is removed depending on context. */
padding-block: 0 !important;
}
.o_employee_skills .o_list_renderer {
margin-top: 0 !important;
}
.skills_helper {
display: flex;
flex-direction: column;
width: fit-content;
padding-inline: var(--ListRenderer-table-padding-x);
}

View file

@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<t t-name="hr_skills.SkillsListRenderer" t-inherit-mode="primary" t-inherit="web.ListRenderer">
<xpath expr="//table" position="attributes">
<attribute name="t-attf-class" add="mb-1 {{ !isEditable ? 'cursor-default' : '' }} {{ !showTable ? 'd-none' : ''}} o_skill_table overflow-hidden" separator=" "/>
</xpath>
<xpath expr="//table" position="before">
<div name="skills_header" class="skills_header text-uppercase small fw-bolder py-2 mt-4" t-att-class="{'o_horizontal_separator': !showTable }">
Skills &amp; Certifications
<a t-if="!isEmpty &amp;&amp; showTimeline" t-on-click="openSkillsReport" class="float-end cursor-pointer">
<span class="fa fa-line-chart me-1"/>
Timeline
</a>
</div>
</xpath>
<xpath expr="//table" position="after">
<t t-if="!showTable">
<t t-if="!anySkills">
<div name="no_skills" class="skills_helper">
<p class="mt-2">
There are no skills defined in the library.
</p>
<button t-on-click="skillTypesAction"
class="btn btn-secondary align-self-center"
role="button" t-if="isEditable">
Create Skills
</button>
</div>
</t>
<t t-else="">
<div name="skills_available" class="skills_helper text-muted fst-italic">
<p class="mt-2">You can add skills from our library to the employee profile.<br/>
If skills are missing, they can be created by an HR officer.
</p>
<button t-on-click="props.onAdd"
class="btn btn-secondary align-self-center"
role="button" t-if="isEditable">
Pick a skill from the list
</button>
</div>
</t>
</t>
</xpath>
</t>
<t t-name="hr_skills.SkillsListRenderer.Rows">
<t t-foreach="Object.entries(groupedList)" t-as="skill_group" t-key="skill_group[0]">
<tr class="o_group_has_content o_group_header">
<th tabindex="-1" class="o_group_name" t-att-colspan="colspan">
<div class="d-flex justify-content-between align-items-center">
<span t-esc="skill_group[1].name"/>
<button id="add_button" class="btn btn-secondary btn-sm"
t-if="isEditable"
t-on-click="() => props.onAdd({ context: { default_skill_type_id: skill_group[1].id }})"
role="button">ADD</button>
</div>
</th>
</tr>
<t t-foreach="skill_group[1].list.records" t-as="record" t-key="record.id">
<t t-set="group" t-value="skill_group[1]"/>
<t t-call="{{ constructor.recordRowTemplate }}"/>
</t>
</t>
</t>
</odoo>

View file

@ -1,12 +1,35 @@
.o_hr_skills_group {
padding: 0 calc(var(--gutter-x) * 1)!important;
&:first-child {
padding-left: calc(var(--gutter-x) * .5)!important;
}
&:last-child {
padding-right: calc(var(--gutter-x) * .5)!important;
}
.o_list_renderer {
overflow-x: hidden!important;
.o_hr_skill_type_form {
th[data-name="level_progress"] div span {
text-align: left !important;
}
}
.o_list_renderer {
.o_list_table {
.o_data_row:not(.o_selected_row) .o_data_cell {
&.o_boolean_toggle_load_cell {
> .o_field_widget:not(.o_readonly_modifier) .form-check {
pointer-events: auto;
}
}
}
}
}
// hide expand button when the form view is readonly
.o_hr_skills_dialog_form:has(main > div.o_form_readonly) > header > button.o_expand_button {
display: None !important;
}
.o_skill_level_tree{
.o_list_renderer .o_list_table thead .o_list_number_th{
text-align: start !important;
}
.o_progressbar_value .o_input {
width: 0 !important;
}
}
.o_resume_html .note-editable {
min-height: 10rem;
}

View file

@ -0,0 +1,246 @@
$text-color: #16171a;
$text-color-secondary: #16171a;
$text-muted: #3b4757;
$progess-bar-background: #f5f5f5;
div.o_employee_cv {
font-family: 'Roboto', sans-serif;
color: $text-color-secondary;
font-size: 14px;
padding: 0;
margin: 0;
position: relative;
h1, h2, h3, h4, h5, h6 {
font-weight: bolder;
}
a {
text-decoration: none;
}
p {
line-height: 1.5;
}
&:not(:last-child) .o_new_page {
page-break-after: always;
}
.o_sidebar {
color: #fff;
margin-top: -150px;
margin-left: 500px;
width: inherit;
display: inline-block;
max-width: 50%;
min-width: 50%;
box-sizing: border-box;
vertical-align: top;
a {
color: #fff;
}
.o_profile {
padding: 30px;
padding-top: 15px;
background: rgba(0, 0, 0, 0.2);
text-align: center;
color: #fff;
}
.o_profile_name {
font-size: 32px;
font-weight: bolder;
margin-bottom: 10px;
}
.o_profile_job {
color: rgba(256, 256, 256, 0.9);
font-size: 20px;
font-weight: bold;
margin-top: 0;
}
.o_profile_image {
margin-bottom: 15px;
width: 100px;
float: right;
}
.o_social {
list-style-type: none;
li {
margin-bottom: 15px;
&:last-child {
margin-bottom: 0;
}
}
}
.o_sidebar_section {
padding: 15px;
}
.o_sidebar_title {
text-transform: uppercase;
font-size: 16px;
font-weight: bold;
margin-top: 0;
margin-bottom: 15px;
}
.o_sidebar_education {
.o_sidebar_education_degree {
font-size: 14px;
margin-top: 0;
margin-bottom: 0;
}
.o_sidebar_education_line {
margin-bottom: 15px;
overflow-wrap: break-word;
&:last-child {
margin-bottom: 0;
}
}
.o_sidebar_education_description {
color: rgba(256, 256, 256, 0.6);
font-weight: bold;
margin-bottom: 0px;
margin-top: 0;
font-size: 14px;
}
.o_sidebar_education_description_year {
color: rgba(256, 256, 256, 0.6);
font-weight: bold;
margin-bottom: 0px;
}
}
.o_sidebar_language {
.o_sidebar_language_level {
color: rgba(256, 256, 256, 0.6);
}
}
.o_sidebar_list {
margin-bottom: 0;
li {
margin-bottom: 10px;
&:last-child {
margin-bottom: 0;
}
}
}
}
.o_company {
width: inherit;
display: inline-block;
max-width: 49%;
min-width: 49%;
box-sizing: border-box;
vertical-align: top;
padding-left: 30px;
}
.o_main_panel {
background: #fff;
max-width: 100%;
min-width: 100%;
display: inline-block;
padding: 5px 30px 0 30px;
box-sizing: border-box;
vertical-align: top;
.o_main_panel_title {
text-transform: uppercase;
font-size: 20px;
font-weight: bold;
position: relative;
margin-top: 0;
margin-bottom: 20px;
.o_main_panel_icon {
width: 30px;
height: 30px;
margin-right: 8px;
display: inline-block;
color: #fff;
border-radius: 50%;
background-clip: padding-box;
text-align: center;
font-size: 16px;
position: relative;
top: -2px;
padding-top: 2px;
fa {
font-size: 14px;
margin-top: 6px;
}
}
}
.o_main_panel_resume_title {
position: relative;
overflow: hidden;
margin-bottom: 0;
width: 100%;
.o_main_panel_resume_title_job {
display: inline-block;
}
.o_main_panel_resume_title_dates {
float: right;
white-space: nowrap;
}
}
.o_main_panel_resume_job {
color: $text-color;
font-size: 16px;
margin-top: 0;
margin-bottom: 0;
font-weight: bold;
}
.o_main_panel_resume_year {
right: 0;
top: 0;
color: $text-muted;
position: static;
display: block;
margin-top: 5px;
}
}
.o_main_panel_skills {
.o_main_panel_skill_line {
&:not(:last-of-type) {
margin-bottom: 15px;
}
}
.o_main_panel_skill_name {
font-size: 14px;
margin-top: 0;
margin-bottom: 12px;
}
.o_main_panel_skill_progress_bar {
height: 12px;
background: $progess-bar-background;
border-radius: 2px;
background-clip: padding-box;
}
}
}

View file

@ -1,5 +1,3 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { GraphRenderer } from "@web/views/graph/graph_renderer";
import { graphView } from "@web/views/graph/graph_view";
@ -8,8 +6,8 @@ export class SkillsGraphRenderer extends GraphRenderer {
getScaleOptions() {
const scaleOptions = super.getScaleOptions();
if ('yAxes' in scaleOptions) {
scaleOptions['yAxes'][0]['ticks']['suggestedMax'] = 100;
if ('y' in scaleOptions) {
scaleOptions.y.suggestedMax = 100;
}
return scaleOptions;

View file

@ -1,5 +1,4 @@
/** @odoo-module */
import { _t } from "@web/core/l10n/translation";
import { ListRenderer } from "@web/views/list/list_renderer";
export class CommonSkillsListRenderer extends ListRenderer {
@ -23,17 +22,17 @@ export class CommonSkillsListRenderer extends ListRenderer {
const data = record.data;
const group = data[this.groupBy];
if (grouped[group[1]] === undefined) {
grouped[group[1]] = {
id: parseInt(group[0]),
name: group[1] || this.env._t('Other'),
if (grouped[group.display_name] === undefined) {
grouped[group.display_name] = {
id: parseInt(group.id),
name: group.display_name || _t('Other'),
list: {
records: [],
},
};
}
grouped[group[1]].list.records.push(record);
grouped[group.display_name].list.records.push(record);
}
return grouped;
}

View file

@ -1,52 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="hr_resume_data_row">
<tr class="o_data_row cursor-default" t-attf-class="o_data_row #{is_last? 'o_data_row_last' : ''}" t-att-data-id="id">
<t t-if="data.display_type === 'classic'">
<td class="o_resume_timeline_cell position-relative pe-lg-2">
<div class="rounded-circle bg-info position-relative"/>
</td>
<td class="o_data_cell pt-0 w-100">
<div class="o_resume_line" t-att-data-id="id">
<small class="o_resume_line_dates">
<b t-esc="data.date_start"/> - <b t-esc="data.date_end"/>
</small>
<h4 class="o_resume_line_title mt-2" t-esc="data.name"/>
<p t-if="data.description" class="o_resume_line_desc" t-esc="data.description"/>
</div>
</td>
</t>
</tr>
</t>
<t t-name="hr_trash_button">
<td class="o_list_record_remove pe-3">
<button name="delete" arial-label="Delete row" class="btn btn-link text-danger">
<i class="fa fa-trash"/>
</button>
</td>
</t>
<t t-name="hr_resume_group_row">
<tr class="o_resume_group_header">
<td class="o_group_name" colspan="100%"><span class="o_horizontal_separator my-0" t-esc="display_name"/></td>
</tr>
</t>
<t t-name="group_add_item">
<t t-set="empty" t-value="Object.keys(context).length == 2"/>
<div t-attf-class="o_field_x2many_list_row_add #{empty? 'd-block w-100' : 'd-inline float-end'}">
<a href="#"
role="button"
t-attf-class="btn btn-secondary o-kanban-button-new #{empty? 'btn-primary mt-3' : 'btn-secondary btn-sm'}"
t-attf-data-context="{{ context }}">
<t t-if="empty">CREATE A NEW ENTRY</t>
<t t-else="">ADD</t>
</a>
</div>
</t>
</templates>

View file

@ -1,24 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="hr_skill_data_row">
<tr class="o_data_row cursor-default" t-att-data-id="id">
<td class="o_data_cell o_skill_cell w-100">
<t t-esc="data.skill_id.data.display_name"/>
</td>
<td class="o_data_cell o_skill_cell pe-3">
<t t-esc="data.skill_level_id.data.display_name"/>
</td>
</tr>
</t>
<t t-name="hr_default_group_row">
<tr class="o_group_header o_group_has_content">
<td class="o_group_name border-0 pe-2" colspan="99">
<b t-esc="display_name"/>
</td>
</tr>
</t>
</templates>

View file

@ -0,0 +1,16 @@
import { hrModels } from "@hr/../tests/hr_test_helpers";
import { defineModels } from "@web/../tests/web_test_helpers";
import { HrEmployeeSkill } from "./mock_server/mock_models/hr_employee_skill";
import { HrSkill } from "./mock_server/mock_models/hr_skill";
import { M2oAvatarEmployee } from "./mock_server/mock_models/m2o_avatar_employee";
export function defineHrSkillModels() {
return defineModels(hrSkillModels);
}
export const hrSkillModels = {
...hrModels,
HrEmployeeSkill,
HrSkill,
M2oAvatarEmployee,
};

View file

@ -0,0 +1,64 @@
import { click, contains, start, startServer } from "@mail/../tests/mail_test_helpers";
import { mountView, onRpc } from "@web/../tests/web_test_helpers";
import { describe, expect, test } from "@odoo/hoot";
import { queryAttribute } from "@odoo/hoot-dom";
import { defineHrSkillModels } from "@hr_skills/../tests/hr_skills_test_helpers";
describe.current.tags("desktop");
defineHrSkillModels();
test("many2one_avatar_employee widget in kanban view with skills on avatar card", async () => {
const pyEnv = await startServer();
const [java, tigrinya] = pyEnv["hr.skill"].create([{ name: "Java" }, { name: "Tigrinya" }]);
const pierrePid = pyEnv["res.partner"].create({ name: "Pierre" });
const pierreUid = pyEnv["res.users"].create({ name: "Pierre", partner_id: pierrePid });
const pierreEid = pyEnv["hr.employee"].create({
name: "Pierre",
user_id: pierreUid,
user_partner_id: pierrePid,
});
const [javaForPierre, tigrinyaForPierre] = pyEnv["hr.employee.skill"].create([
{ employee_id: pierreEid, skill_id: java },
{ employee_id: pierreEid, skill_id: tigrinya },
]);
pyEnv["hr.employee.public"].create({
name: "Pierre",
employee_skill_ids: [javaForPierre, tigrinyaForPierre],
});
pyEnv["m2o.avatar.employee"].create([{ employee_id: pierreEid }]);
await start();
onRpc("hr.employee", "get_avatar_card_data", (params) => {
const resourceIdArray = params.args[0];
const resourceId = resourceIdArray[0];
const resources = pyEnv['hr.employee.public'].read([resourceId]);
const result = resources.map(resource => ({
name: resource.name,
role_ids: resource.role_ids,
email:resource.email,
phone: resource.phone,
user_id: resource.user_id,
employee_skill_ids: resource.employee_skill_ids
}));
return result;
});
await mountView({
type: "kanban",
resModel: "m2o.avatar.employee",
arch: `<kanban>
<templates>
<t t-name="card">
<field name="employee_id" widget="many2one_avatar_employee"/>
</t>
</templates>
</kanban>`,
});
await contains(".o_m2o_avatar", { count: 1 });
await contains(".o_field_many2one_avatar_employee img", { count: 1 });
expect(
queryAttribute(".o_kanban_record .o_field_many2one_avatar_employee img", "data-src")
).toBe(`/web/image/hr.employee/${pierreEid}/avatar_128`);
await click(".o_kanban_record .o_m2o_avatar > img");
await contains(".o_avatar_card");
await contains(".o_avatar_card .o_employee_skills_tags > .o_tag", { count: 2 });
});

View file

@ -0,0 +1,5 @@
import { models } from "@web/../tests/web_test_helpers";
export class HrEmployeeSkill extends models.ServerModel {
_name = "hr.employee.skill";
}

View file

@ -0,0 +1,5 @@
import { models } from "@web/../tests/web_test_helpers";
export class HrSkill extends models.ServerModel {
_name = "hr.skill";
}

View file

@ -0,0 +1,7 @@
import { fields, models } from "@web/../tests/web_test_helpers";
export class M2oAvatarEmployee extends models.Model {
_name = "m2o.avatar.employee";
employee_id = fields.Many2one({ string: "Employee", relation: "hr.employee" });
}

View file

@ -1,147 +1,169 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { stepUtils } from "@web_tour/tour_utils";
import tour from 'web_tour.tour';
tour.register('hr_skills_tour', {
test: true,
url: '/web',
}, [
tour.stepUtils.showAppsMenuItem(),
{
content: "Open Employees app",
trigger: ".o_app[data-menu-xmlid='hr.menu_hr_root']",
},
{
content: "Create a new employee",
trigger: ".o-kanban-button-new",
},
{
content: "Pick a name",
trigger: ".o_field_widget[name='name'] input",
run: "text Jony McHallyFace",
},
{
content: "Save",
trigger: ".o_form_button_save",
},
{
content: "Add a new Resume experience",
trigger: ".o_field_resume_one2many tr.o_resume_group_header button.btn-secondary",
},
{
content: "Enter some company name",
trigger: ".modal-body .o_field_widget[name='name'] input",
run: "text Mamie Rock",
},
{
content: "Set start date",
trigger: ".o_field_widget[name='date_start'] input",
run: "text 12/05/2017",
},
{
content: "Give some description",
trigger: ".o_field_widget[name='description'] textarea",
run: "text Sang some songs and played some music",
},
{
content: "Save it",
trigger: ".o_form_button_save",
in_modal: true,
run: "click",
},
{
content: "Edit newly created experience",
trigger: ".o_resume_line_title:contains('Mamie Rock')",
run: "click",
},
{
content: "Change type",
trigger: ".o_field_widget[name='line_type_id'] input",
run: "text Experience",
},
{
content: "Choose experience",
trigger: '.ui-autocomplete .ui-menu-item a:contains("Experience")',
run: "click",
},
{
content: "Save experience change",
trigger: ".o_form_button_save",
in_modal: true,
run: "click",
},
{
content: "Add a new Skill",
trigger: ".o_field_skills_one2many button:contains('Create a new entry')",
},
{
content: "Select Music",
trigger: ".o_field_widget[name='skill_type_id'] label:contains('Best Music')",
run: "click",
},
{
content: "Select a song",
trigger: ".o_field_widget[name='skill_id'] input",
run: "text Fortun",
},
{
content: "Choose the song",
trigger: '.ui-autocomplete .ui-menu-item a:contains("Fortunate Son")',
run: "click",
},
{
content: "Select a level",
trigger: ".o_field_widget[name='skill_level_id'] input",
run: "text Level",
},
{
content: "Choose the level",
trigger: '.ui-autocomplete .ui-menu-item a:contains("Level 2")',
run: "click",
},
{
content: "Save new skill",
trigger: ".o_form_button_save",
in_modal: true,
run: "click",
},
{
content: "Add a new Skill",
trigger: ".o_field_skills_one2many button:contains('ADD')",
},
{
content: "Select a song", // "Music" should be already selected
trigger: ".o_field_widget[name='skill_id'] input",
run: "text Mary",
},
{
content: "Choose the song",
trigger: '.ui-autocomplete .ui-menu-item a:contains("Oh Mary")',
run: "click",
},
{
content: "Select a level",
trigger: ".o_field_widget[name='skill_level_id'] input",
run: "text Level 7",
},
{
content: "Choose the level",
trigger: '.ui-autocomplete .ui-menu-item a:contains("Level 7")',
run: "click",
},
{
content: "Save new skill",
trigger: ".o_form_button_save",
in_modal: true,
run: "click",
},
...tour.stepUtils.saveForm({
content: "save Form",
extra_trigger: 'td:containsExact("Oh Mary")',
}),
{
content: "Go back to employees",
trigger: 'a[data-menu-xmlid="hr.menu_hr_root"]',
run: "click",
}
]);
registry.category("web_tour.tours").add("hr_skills_tour", {
url: "/odoo",
steps: () => [
stepUtils.showAppsMenuItem(),
{
content: "Open Employees app",
trigger: ".o_app[data-menu-xmlid='hr.menu_hr_root']",
run: "click",
},
{
content: "Create a new employee",
trigger: ".o-kanban-button-new",
run: "click",
},
{
content: "Pick a name",
trigger: ".o_field_widget[name='name'] input",
run: "edit Jony McHallyFace",
},
{
content: "Save",
trigger: ".o_form_button_save",
run: "click",
},
{
content: "Add Experience",
trigger: ".nav-link:contains('Resume')",
run: "click",
},
{
content: "Add a new Resume experience",
trigger: ".o_field_resume_one2many button.btn-secondary",
run: "click",
},
{
content: "Enter some company name",
trigger:
".modal:contains(new resume line) .modal-body .o_field_widget[name='name'] input",
run: "edit Mamie Rock",
},
{
trigger: ".modal:contains(new resume line) .o_field_widget[name='date_start'] button",
content: "open date picker",
run: "click",
},
{
content: "Set start date",
trigger: ".modal:contains(new resume line) .o_field_widget[name='date_start'] input",
run: "edit 12/05/2017",
},
{
content: "Give some description",
trigger: `.modal:contains(new resume line) .o_field_html[name='description'] div.o-paragraph`,
run: "editor Sang some songs and played some music",
},
{
content: "Save it",
trigger: ".modal:contains(new resume line) .o_form_button_save:contains(save)",
run: "click",
},
{
trigger: "body:not(:has(.modal:contains(new resume line)))",
},
{
content: "Edit newly created experience",
trigger: ".o_resume_line_title:contains(Mamie Rock)",
run: "click",
},
{
content: "Change type",
trigger: ".modal:contains(new resume line) .o_field_widget[name='line_type_id'] .o_selection_badge:contains(Other Experience)",
run: "click",
},
{
content: "Save experience change",
trigger: ".modal:contains(new resume line) .o_form_button_save:contains(save)",
run: "click",
},
{
trigger: "body:not(:has(.modal:contains(new resume line)))",
},
{
content: "Add a new Skill",
trigger: ".o_field_skills_one2many button:contains('Pick a skill from the list')",
run: "click",
},
{
content: "Select Music",
trigger: ".o_field_widget[name='skill_type_id'] span:contains('Best Music')",
run: "click",
},
{
content: "Choose the song",
trigger: ".o_field_widget[name='skill_id'] span:contains('Fortunate Son')",
run: "click",
},
{
content: "Choose the level",
trigger: ".o_field_widget[name='skill_level_id'] span:contains('Level 2')",
run: "click",
},
{
content: "Save new skill",
trigger: ".modal:contains(update skills) .o_form_button_save:contains(save & close)",
run: "click",
},
{
content:
"Wait the new skill is completely saved. Ensure also the modal is closed before open a new one.",
trigger: "body:not(:has(.modal))",
},
{
content: "Check if item is added",
trigger: ".o_data_row td.o_data_cell:contains('Fortunate Son')",
},
{
content: "Add a new Skill",
trigger: ".o_field_skills_one2many button:contains('ADD')",
run: "click",
},
{
content: "Select Certification",
trigger: ".o_field_widget[name='skill_type_id'] span:contains('Music Certification')",
run: "click",
},
{
content: "Choose the instrument",
trigger: ".o_field_widget[name='skill_id'] span:contains('Piano')",
run: "click",
},
{
content: "Choose the level",
trigger: "div[name='valid_from'] button",
run: "click",
},
{
content: "Choose the level",
trigger: ".o_field_widget[name='valid_from'] input",
run: "edit 02/03/2025",
},
{
content: "Choose the level",
trigger: ".o_field_widget[name='valid_to']",
run: "click",
},
{
content: "Choose the level",
trigger: ".o_field_widget[name='valid_to'] input",
run: "edit 03/04/2025",
},
{
content: "Save new skill",
trigger: ".modal:contains(update skills) .o_form_button_save:contains(save & close)",
run: "click",
},
{
content: "Wait the new skill is completely saved",
trigger: "body:not(:has(.modal))",
},
{
content: "Check if item is added",
trigger: ".o_data_row td.o_data_cell:contains('Piano')",
},
...stepUtils.saveForm(),
],
});

View file

@ -0,0 +1,87 @@
import { registry } from "@web/core/registry";
import { stepUtils } from "@web_tour/tour_utils";
registry.category("web_tour.tours").add("hr_skills_type_tour", {
url: "/odoo",
steps: () => [
stepUtils.showAppsMenuItem(),
{
content: "Open Employees app",
trigger: ".o_app[data-menu-xmlid='hr.menu_hr_root']",
run: "click",
},
{
content: "Open skill type menu",
trigger: "[data-menu-xmlid='hr.menu_human_resources_configuration']",
run: "click",
},
{
content: "Open skill type menu",
trigger: "[data-menu-xmlid='hr_skills.hr_skill_type_menu']",
run: "click",
},
{
content: "Create a skill type",
trigger: ".o_list_button_add",
run: "click",
},
{
content: "Write skill type name",
trigger: ".o_field_widget[name='name'] input",
run: "edit Cooking Skill",
},
{
trigger: "div[name=skill_ids] .o_field_x2many_list_row_add a",
run: "click",
},
{
trigger: "div[name=skill_ids] div[name=name] input",
run: "edit Macaroon",
},
{
trigger: "div[name=skill_level_ids] .o_field_x2many_list_row_add a",
run: "click",
},
{
trigger: "div[name=skill_level_ids] div[name=name] input",
run: "edit Beginner",
},
{
trigger: "div[name=skill_level_ids] div[name=default_level] input[type='checkbox']",
run: "click",
},
{
trigger: "div[name=skill_level_ids] .o_field_x2many_list_row_add a",
run: "click",
},
{
trigger: "tr:nth-child(2).o_selected_row div[name=name] input",
run: "edit Intermediate",
},
{
trigger: "tr:nth-child(2).o_selected_row [name=default_level] input[type='checkbox']",
run: "click",
},
{
trigger: "div[name=skill_level_ids] .o_field_x2many_list_row_add a",
run: "click",
},
{
trigger: "tr:nth-child(3).o_selected_row div[name=name] input",
run: "edit Expert",
},
{
trigger: "tr:nth-child(3).o_selected_row [name=default_level] input[type='checkbox']",
run: "click",
},
{
trigger: "tr:nth-child(1) [name=default_level] input[type='checkbox']",
run: "click",
},
{
trigger: "tr:nth-child(2) [name=default_level] input[type='checkbox']",
run: "click",
},
...stepUtils.saveForm(),
],
});