Eliminate Python dependency: embed frontend assets in odoo-go
- Copy all OWL frontend assets (JS/CSS/XML/fonts/images) into frontend/ directory (2925 files, 43MB) — no more runtime reads from Python Odoo - Replace OdooAddonsPath config with FrontendDir pointing to local frontend/ - Rewire bundle.go, static.go, templates.go, webclient.go to read from frontend/ instead of external Python Odoo addons directory - Auto-detect frontend/ and build/ dirs relative to binary in main.go - Delete obsolete Python helper scripts (tools/*.py) The Go server is now fully self-contained: single binary + frontend/ folder. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
BIN
frontend/hr/static/description/icon.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
1
frontend/hr/static/description/icon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="50" height="50" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg"><path d="M34 17a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" fill="#985184"/><path d="M12 24a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z" fill="#FBB945"/><path d="M46 24a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z" fill="#1AD3BB"/><path d="M25 30H4a4 4 0 0 0-4 4v4a4 4 0 0 0 4 4h21V30Z" fill="#FBB945"/><path d="M46 30H25v12h21a4 4 0 0 0 4-4v-4a4 4 0 0 0-4-4Z" fill="#1AD3BB"/><path d="M12 30h14c6.627 0 12 5.373 12 12H24c-6.627 0-12-5.373-12-12Z" fill="#985184"/></svg>
|
||||
|
After Width: | Height: | Size: 511 B |
BIN
frontend/hr/static/description/icon_hi.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/hr/static/img/employee-image.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
frontend/hr/static/img/employee_al-image.jpg
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
frontend/hr/static/img/employee_awa-image.jpg
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
frontend/hr/static/img/employee_chs-image.jpg
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
frontend/hr/static/img/employee_fme-image.jpg
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
frontend/hr/static/img/employee_fpi-image.jpg
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
frontend/hr/static/img/employee_han-image.jpg
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
frontend/hr/static/img/employee_hne-image.jpg
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
frontend/hr/static/img/employee_jep-image.jpg
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
frontend/hr/static/img/employee_jgo-image.jpg
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
frontend/hr/static/img/employee_jod-image.jpg
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
frontend/hr/static/img/employee_jog-image.jpg
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
frontend/hr/static/img/employee_jth-image.jpg
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
frontend/hr/static/img/employee_jve-image.jpg
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
frontend/hr/static/img/employee_lur-image.jpg
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
frontend/hr/static/img/employee_mit-image.jpg
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
frontend/hr/static/img/employee_ngh-image.jpg
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
frontend/hr/static/img/employee_niv-image.jpg
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
frontend/hr/static/img/employee_qdp-image.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
frontend/hr/static/img/employee_stw-image.jpg
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
frontend/hr/static/img/employee_vad-image.jpg
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
frontend/hr/static/img/partner_root-image.jpg
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
5
frontend/hr/static/src/@types/models.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
declare module "models" {
|
||||
export interface Store {
|
||||
employees: {[key: number]: {id: number, user_id: number, hasCheckedUser: boolean}};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { AvatarCardPopover } from "@mail/discuss/web/avatar_card/avatar_card_popover";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
export const patchAvatarCardPopover = {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.orm = useService("orm");
|
||||
this.userInfoTemplate = "hr.avatarCardUserInfos";
|
||||
},
|
||||
get email() {
|
||||
return this.employeeId?.work_email || super.email;
|
||||
},
|
||||
get phone() {
|
||||
return this.employeeId?.work_phone || super.phone;
|
||||
},
|
||||
get employeeId() {
|
||||
return this.partner?.employee_id;
|
||||
},
|
||||
async getProfileAction() {
|
||||
if (!this.employeeId) {
|
||||
return super.getProfileAction(...arguments);
|
||||
}
|
||||
return this.orm.call("hr.employee", "get_formview_action", [this.employeeId.id]);
|
||||
},
|
||||
};
|
||||
|
||||
export const unpatchAvatarCardPopover = patch(AvatarCardPopover.prototype, patchAvatarCardPopover);
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-inherit="mail.AvatarCardPopover" t-inherit-mode="extension">
|
||||
<xpath expr="//a[hasclass('o-mail-avatar-card-tel')]" position="after">
|
||||
<span t-if="employeeId?.work_location_id" class="text-muted" data-tooltip="Work Location">
|
||||
<i t-if="employeeId.work_location_id.location_type === 'office'" class="me-1 fa fa-fw fa-building-o"/>
|
||||
<i t-elif="employeeId.work_location_id.location_type === 'home'" class="me-1 fa fa-fw fa-home"/>
|
||||
<i t-else="" class="me-1 fa fa-fw fa-map-marker"/>
|
||||
<t t-esc="employeeId.work_location_id.name"/>
|
||||
</span>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
<t t-name="hr.avatarCardUserInfos">
|
||||
<small class="text-muted text-truncate" t-if="employeeId?.job_title" t-att-title="employeeId.job_title" t-esc="employeeId.job_title"/>
|
||||
<span class="text-muted text-truncate" t-if="employeeId?.department_id" data-tooltip="Department" t-att-title="employeeId.department_id.name" t-esc="employeeId.department_id.name"/>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,17 @@
|
||||
import { AvatarCardResourcePopover } from "@resource_mail/components/avatar_card_resource/avatar_card_resource_popover";
|
||||
|
||||
export class AvatarCardEmployeePopover extends AvatarCardResourcePopover {
|
||||
static defaultProps = {
|
||||
...AvatarCardResourcePopover.defaultProps,
|
||||
recordModel: "hr.employee",
|
||||
};
|
||||
async onWillStart() {
|
||||
await super.onWillStart();
|
||||
this.record.employee_id = [this.props.id];
|
||||
}
|
||||
|
||||
get fieldNames() {
|
||||
const excludedFields = ["employee_id", "resource_type"];
|
||||
return super.fieldNames.filter((field) => !excludedFields.includes(field));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-inherit="resource_mail.AvatarCardResourcePopover" t-inherit-mode="extension">
|
||||
<xpath expr="//span[hasclass('o_avatar')]/img" position="attributes">
|
||||
<attribute name="t-if">this.record.employee_id?.length</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//span[hasclass('o_user_im_status')]" position="after">
|
||||
<span t-elif="record.show_hr_icon_display" name="icon" class="o_card_avatar_im_status position-absolute d-flex align-items-center justify-content-center o_employee_presence_status bg-inherit">
|
||||
<!-- Employee is present/connected and it is normal according to his work schedule -->
|
||||
<i t-if="record.hr_icon_display == 'presence_present'" class="fa fa-fw fa-circle text-success" title="Present" role="img" aria-label="Present"/>
|
||||
<!-- Employee is not present/connected and it is normal according to his work schedule -->
|
||||
<i t-if="record.hr_icon_display == 'presence_absent'" class="fa fa-fw fa-circle text-warning" title="Absent" role="img" aria-label="Absent"/>
|
||||
<!-- Employee is connected but according to his work schedule, he should not work for now -->
|
||||
<i t-if="record.hr_icon_display == 'presence_out_of_working_hour'" class="fa fa-fw fa-circle text-muted" title="Off-Hours" role="img" aria-label="Off-Hours"/>
|
||||
</span>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
<t t-name="hr.avatarCardResourceInfos">
|
||||
<small class="text-muted" t-if="record.job_id" t-esc="record.job_id[1]"/>
|
||||
<span class="text-muted" t-if="record.department_id" data-tooltip="Department" t-esc="record.department_id[1]"/>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,49 @@
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { AvatarCardResourcePopover } from "@resource_mail/components/avatar_card_resource/avatar_card_resource_popover";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { TagsList } from "@web/core/tags_list/tags_list";
|
||||
|
||||
const patchAvatarCardResourcePopover = {
|
||||
setup() {
|
||||
super.setup();
|
||||
(this.userInfoTemplate = "hr.avatarCardResourceInfos"),
|
||||
(this.actionService = useService("action"));
|
||||
},
|
||||
get fieldNames() {
|
||||
return [
|
||||
...super.fieldNames,
|
||||
"department_id",
|
||||
this.props.recordModel ? "employee_id" : "employee_ids",
|
||||
"hr_icon_display",
|
||||
"job_title",
|
||||
"show_hr_icon_display",
|
||||
"work_email",
|
||||
"work_location_id",
|
||||
"work_phone",
|
||||
];
|
||||
},
|
||||
get email() {
|
||||
return this.record.work_email || this.record.email;
|
||||
},
|
||||
get phone() {
|
||||
return this.record.work_phone || this.record.phone;
|
||||
},
|
||||
get showViewProfileBtn() {
|
||||
return this.record.employee_id?.length > 0;
|
||||
},
|
||||
async getProfileAction() {
|
||||
return await this.orm.call("hr.employee", "get_formview_action", [
|
||||
this.record.employee_id[0],
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
patch(AvatarCardResourcePopover.prototype, patchAvatarCardResourcePopover);
|
||||
// Adding TagsList component allows display tag lists on the resource/employee avatar card
|
||||
// This is used by multiple modules depending on hr (planning for roles and hr_skills for skills)
|
||||
patch(AvatarCardResourcePopover, {
|
||||
components: {
|
||||
...AvatarCardResourcePopover.components,
|
||||
TagsList,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Avatar } from "@mail/views/web/fields/avatar/avatar";
|
||||
import { AvatarCardEmployeePopover } from "../avatar_card_employee/avatar_card_employee_popover";
|
||||
|
||||
export class AvatarEmployee extends Avatar {
|
||||
static components = { ...super.components, Popover: AvatarCardEmployeePopover };
|
||||
|
||||
get popoverProps() {
|
||||
return {
|
||||
...super.popoverProps,
|
||||
recordModel: this.props.resModel,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { registry } from '@web/core/registry';
|
||||
|
||||
import { ImageField, imageField } from '@web/views/fields/image/image_field';
|
||||
|
||||
export class BackgroundImageField extends ImageField {
|
||||
static template = "hr.BackgroundImage";
|
||||
}
|
||||
|
||||
export const backgroundImageField = {
|
||||
...imageField,
|
||||
component: BackgroundImageField,
|
||||
};
|
||||
|
||||
registry.category("fields").add("background_image", backgroundImageField);
|
||||
@@ -0,0 +1,21 @@
|
||||
div.o_field_widget.o_field_background_image {
|
||||
display: inline-block;
|
||||
|
||||
@include media-breakpoint-down(md) {
|
||||
height: calc(100% + var(--KanbanRecord-padding-v)* 2);
|
||||
margin-top: calc(var(--KanbanRecord-padding-v)* -1);
|
||||
margin-bottom: calc(var(--KanbanRecord-padding-v)* -1);
|
||||
margin-left: calc(var(--KanbanRecord-padding-h)* -1);
|
||||
}
|
||||
|
||||
> img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="hr.BackgroundImage">
|
||||
<img
|
||||
loading="lazy"
|
||||
t-att-data-tooltip-template="hasTooltip and tooltipAttributes.template"
|
||||
t-att-data-tooltip-info="hasTooltip and tooltipAttributes.info"
|
||||
t-att-data-tooltip-delay="hasTooltip and props.zoomDelay"
|
||||
t-attf-src="#{getUrl(props.previewImage or props.name)}"
|
||||
alt="Binary file"
|
||||
/>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useDateTimePicker } from "@web/core/datetime/datetime_picker_hook";
|
||||
import { serializeDate } from "@web/core/l10n/dates";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { standardWidgetProps } from "@web/views/widgets/standard_widget_props";
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
export class ButtonNewContractWidget extends Component {
|
||||
static template = "hr.ButtonNewContract";
|
||||
static props = {
|
||||
...standardWidgetProps,
|
||||
};
|
||||
|
||||
/** @override **/
|
||||
setup() {
|
||||
super.setup();
|
||||
this.orm = useService("orm");
|
||||
|
||||
this.dateTimePicker = useDateTimePicker({
|
||||
target: `datetime-picker-target-new-contract`,
|
||||
onApply: (date) => {
|
||||
if (date) {
|
||||
this.tryAndCreateContract(serializeDate(date));
|
||||
}
|
||||
},
|
||||
get pickerProps() {
|
||||
return { type: "date" };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async onClickNewContractBtn() {
|
||||
await this.props.record.save();
|
||||
await this.orm.call("hr.version", "check_contract_finished", [
|
||||
[this.props.record.data.version_id.id],
|
||||
]);
|
||||
this.dateTimePicker.open();
|
||||
}
|
||||
|
||||
async tryAndCreateContract(date) {
|
||||
await this.orm.call("hr.employee", "check_no_existing_contract", [
|
||||
[this.props.record.resId],
|
||||
date,
|
||||
]);
|
||||
const contract = await this.orm.call("hr.employee", "create_contract", [
|
||||
[this.props.record.resId],
|
||||
date,
|
||||
]);
|
||||
await this.loadVersion(contract);
|
||||
}
|
||||
|
||||
async loadVersion(version_id) {
|
||||
const { record } = this.props;
|
||||
await record.save();
|
||||
await this.props.record.model.load({
|
||||
context: {
|
||||
...this.props.record.model.env.searchModel.context,
|
||||
version_id: version_id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const buttonNewContractWidget = {
|
||||
component: ButtonNewContractWidget,
|
||||
};
|
||||
|
||||
registry.category("view_widgets").add("button_new_contract", buttonNewContractWidget);
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0"?>
|
||||
<template>
|
||||
<t t-name="hr.ButtonNewContract">
|
||||
<span class="w-100 d-flex justify-content-end">
|
||||
<button class="btn btn-link p-0 o_field_widget text-end w-auto" t-on-click="onClickNewContractBtn"
|
||||
t-ref="datetime-picker-target-new-contract" t-if="props.record.resId and props.record.data.contract_date_start">New Contract</button>
|
||||
</span>
|
||||
</t>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
import { standardWidgetProps } from "@web/views/widgets/standard_widget_props";
|
||||
import { onWillStart, useState, onWillUpdateProps, Component } from "@odoo/owl";
|
||||
|
||||
export class DepartmentChart extends Component {
|
||||
static template = "hr.DepartmentChart";
|
||||
static props = {
|
||||
...standardWidgetProps,
|
||||
};
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
|
||||
this.action = useService("action");
|
||||
this.orm = useService("orm");
|
||||
this.state = useState({
|
||||
hierarchy: {},
|
||||
});
|
||||
onWillStart(async () => await this.fetchHierarchy(this.props.record.resId));
|
||||
|
||||
onWillUpdateProps(async (nextProps) => {
|
||||
await this.fetchHierarchy(nextProps.record.resId);
|
||||
});
|
||||
}
|
||||
|
||||
async fetchHierarchy(departmentId) {
|
||||
this.state.hierarchy = await this.orm.call("hr.department", "get_department_hierarchy", [
|
||||
departmentId,
|
||||
]);
|
||||
}
|
||||
|
||||
async openDepartmentEmployees(departmentId) {
|
||||
const dialogAction = await this.orm.call(
|
||||
this.props.record.resModel,
|
||||
"action_employee_from_department",
|
||||
[departmentId],
|
||||
{}
|
||||
);
|
||||
this.action.doAction(dialogAction);
|
||||
}
|
||||
}
|
||||
|
||||
export const departmentChart = {
|
||||
component: DepartmentChart,
|
||||
};
|
||||
registry.category("view_widgets").add("hr_department_chart", departmentChart);
|
||||
@@ -0,0 +1,16 @@
|
||||
.o_widget_hr_department_chart {
|
||||
.o_hr_department_chart {
|
||||
.o_hr_department_chart_self {
|
||||
.department_name {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-up(sm) {
|
||||
width: 50%;
|
||||
}
|
||||
@include media-breakpoint-down(md) {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="hr.DepartmentChart">
|
||||
<div class="o_hr_department_chart w-100">
|
||||
<div class="o_horizontal_separator mb-3 text-uppercase fw-bolder small">Department Organization</div>
|
||||
<t t-if="state.hierarchy.self">
|
||||
<div class="o_hr_department_chart_parent">
|
||||
<t t-set="dept" t-value="state.hierarchy.parent"/>
|
||||
<t t-set="hideTree" t-value="true"/>
|
||||
<t t-call="hr.DepartmentChart.Department"/>
|
||||
</div>
|
||||
|
||||
<div t-att-class="state.hierarchy.parent?'ms-4':''">
|
||||
<div class="o_hr_department_chart_self">
|
||||
<t t-set="dept" t-value="state.hierarchy.self"/>
|
||||
<t t-set="hideTree" t-value="!state.hierarchy.parent"/>
|
||||
<t t-call="hr.DepartmentChart.Department"/>
|
||||
</div>
|
||||
|
||||
<t t-set="hideTree" t-value="false"/>
|
||||
<div class="o_hr_department_chart_children ms-4">
|
||||
<t t-foreach="state.hierarchy.children" t-as="dept" t-key="dept.name">
|
||||
<t t-call="hr.DepartmentChart.Department"/>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="hr.DepartmentChart.Department">
|
||||
<t t-if="dept">
|
||||
<div t-attf-class="#{hideTree?'':'o_treeEntry'}">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span class="department_name ms-1 mb-1">
|
||||
<t t-esc="dept.name"/>
|
||||
</span>
|
||||
<button class="btn btn-secondary btn-sm rounded-pill ms-2 my-1"
|
||||
t-on-click.prevent="() => this.openDepartmentEmployees(dept.id)">
|
||||
<span class="badge top-0 px-0">
|
||||
<t t-esc="dept.employees"/>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,22 @@
|
||||
import { registry } from "@web/core/registry";
|
||||
import { standardWidgetProps } from "@web/views/widgets/standard_widget_props";
|
||||
|
||||
import { useOpenChat } from "@mail/core/web/open_chat_hook";
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
export class HrEmployeeChat extends Component {
|
||||
static props = {
|
||||
...standardWidgetProps,
|
||||
};
|
||||
static template = "hr.OpenChat";
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
this.openChat = useOpenChat(this.props.record.resModel);
|
||||
}
|
||||
}
|
||||
|
||||
export const hrEmployeeChat = {
|
||||
component: HrEmployeeChat,
|
||||
};
|
||||
registry.category("view_widgets").add("hr_employee_chat", hrEmployeeChat);
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="hr.OpenChat">
|
||||
<a t-if="props.record.data.user_id and props.record.resId"
|
||||
title="Chat"
|
||||
icon="fa-comments"
|
||||
t-on-click.prevent="() => openChat(props.record.resId)"
|
||||
href="#"
|
||||
class="ml8 o_employee_chat_btn"
|
||||
role="button">
|
||||
<i class="fa fa-comments align-middle fs-6"/>
|
||||
</a>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,14 @@
|
||||
import { registry } from "@web/core/registry";
|
||||
import { floatField, FloatField } from "@web/views/fields/float/float_field";
|
||||
|
||||
const fieldRegistry = registry.category("fields");
|
||||
|
||||
class FloatWithoutTrailingZeros extends FloatField {
|
||||
get formattedValue() {
|
||||
return super.formattedValue.replace(/(\.\d*?[1-9])0+$/ , "$1").replace(/\.0+$/, "");
|
||||
}
|
||||
}
|
||||
|
||||
const floatWithoutTrailingZeros = { ...floatField, component: FloatWithoutTrailingZeros };
|
||||
|
||||
fieldRegistry.add("float_without_trailing_zeros", floatWithoutTrailingZeros);
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { standardFieldProps } from "@web/views/fields/standard_field_props";
|
||||
|
||||
export class HrPresenceStatus extends Component {
|
||||
static template = "hr.HrPresenceStatus";
|
||||
static props = {
|
||||
...standardFieldProps,
|
||||
tag: { type: String, optional: true },
|
||||
};
|
||||
static defaultProps = {
|
||||
tag: "small",
|
||||
};
|
||||
|
||||
get classNames() {
|
||||
const classNames = ["fa"];
|
||||
classNames.push(
|
||||
this.icon,
|
||||
"fa-fw",
|
||||
"o_button_icon",
|
||||
"hr_presence",
|
||||
"align-middle",
|
||||
this.color,
|
||||
)
|
||||
return classNames.join(" ");
|
||||
}
|
||||
|
||||
get color() {
|
||||
switch (this.value) {
|
||||
case "presence_present":
|
||||
return "text-success";
|
||||
case "presence_absent":
|
||||
return "o_icon_employee_absent";
|
||||
case "presence_out_of_working_hour":
|
||||
case "presence_archive":
|
||||
return "text-muted";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
get icon() {
|
||||
return `fa-circle${this.value.startsWith("presence_archive") ? "-o" : ""}`;
|
||||
}
|
||||
|
||||
get label() {
|
||||
return this.value !== false
|
||||
? this.options.find(([value, label]) => value === this.value)[1]
|
||||
: "";
|
||||
}
|
||||
|
||||
get options() {
|
||||
return this.props.record.fields[this.props.name].selection.filter(
|
||||
(option) => option[0] !== false && option[1] !== ""
|
||||
);
|
||||
}
|
||||
|
||||
get value() {
|
||||
return this.props.record.data[this.props.name];
|
||||
}
|
||||
}
|
||||
|
||||
export const hrPresenceStatus = {
|
||||
component: HrPresenceStatus,
|
||||
fieldDependencies: [],
|
||||
displayName: _t("HR Presence Status"),
|
||||
extractProps({ viewType }, dynamicInfo) {
|
||||
return {
|
||||
tag: viewType === "kanban" ? "span" : "small",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
registry.category("fields").add("hr_presence_status", hrPresenceStatus)
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="hr.HrPresenceStatus">
|
||||
<div class="o_employee_availability">
|
||||
<t t-tag="props.tag" role="img" t-att-class="classNames" t-att-aria-label="label" t-att-title="label"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,35 @@
|
||||
import { registry } from "@web/core/registry";
|
||||
import { HrPresenceStatus, hrPresenceStatus } from "../hr_presence_status/hr_presence_status";
|
||||
|
||||
export class HrPresenceStatusPill extends HrPresenceStatus {
|
||||
static template = "hr.HrPresenceStatusPill";
|
||||
|
||||
/** @override */
|
||||
get classNames() {
|
||||
const classNames = ["fw-bold", "text-center", "btn", "rounded-pill", "cursor-default"];
|
||||
classNames.push(this.color);
|
||||
return classNames.join(" ");
|
||||
}
|
||||
|
||||
/** @override */
|
||||
get color() {
|
||||
switch (this.value) {
|
||||
case "presence_present":
|
||||
return "btn-outline-success";
|
||||
case "presence_absent":
|
||||
return "btn-outline-warning";
|
||||
case "presence_out_of_working_hour":
|
||||
case "presence_archive":
|
||||
return "btn-outline-secondary text-muted";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const hrPresenceStatusPill = {
|
||||
...hrPresenceStatus,
|
||||
component: HrPresenceStatusPill,
|
||||
};
|
||||
|
||||
registry.category("fields").add("form.hr_presence_status", hrPresenceStatusPill);
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="hr.HrPresenceStatusPill">
|
||||
<div t-att-class="classNames" t-att-aria-label="label" t-att-title="label">
|
||||
<t t-tag="props.tag" role="img" class="me-1 fa" t-att-class="icon"/>
|
||||
<t t-esc="label"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,11 @@
|
||||
import { registry } from "@web/core/registry";
|
||||
import { HrPresenceStatus, hrPresenceStatus } from "../hr_presence_status/hr_presence_status";
|
||||
|
||||
export class HrPresenceStatusPrivate extends HrPresenceStatus { }
|
||||
|
||||
export const hrPresenceStatusPrivate = {
|
||||
...hrPresenceStatus,
|
||||
component: HrPresenceStatusPrivate,
|
||||
};
|
||||
|
||||
registry.category("fields").add("hr_presence_status_private", hrPresenceStatusPrivate);
|
||||
@@ -0,0 +1,14 @@
|
||||
import { registry } from "@web/core/registry";
|
||||
import {
|
||||
HrPresenceStatusPill,
|
||||
hrPresenceStatusPill,
|
||||
} from "../hr_presence_status_pill/hr_presence_status_pill";
|
||||
|
||||
export class HrPresenceStatusPrivatePill extends HrPresenceStatusPill {}
|
||||
|
||||
export const hrPresenceStatusPrivatePill = {
|
||||
...hrPresenceStatusPill,
|
||||
component: HrPresenceStatusPrivatePill,
|
||||
};
|
||||
|
||||
registry.category("fields").add("form.hr_presence_status_private", hrPresenceStatusPrivatePill);
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
many2ManyTagsFieldColorEditable,
|
||||
Many2ManyTagsFieldColorEditable,
|
||||
} from "@web/views/fields/many2many_tags/many2many_tags_field";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { TagsList } from "@web/core/tags_list/tags_list";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
export class FieldMany2ManyTagsSalaryBankTagsList extends TagsList {
|
||||
static template = "web.TagsList";
|
||||
}
|
||||
|
||||
export class FieldMany2ManyTagsSalaryBank extends Many2ManyTagsFieldColorEditable {
|
||||
static template = "web.Many2ManyTagsField";
|
||||
static components = {
|
||||
...Many2ManyTagsFieldColorEditable.components,
|
||||
TagsList: FieldMany2ManyTagsSalaryBankTagsList,
|
||||
};
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
this.actionService = useService("action");
|
||||
const parentOpenMany2xRecord = this.openMany2xRecord;
|
||||
this.openMany2xRecord = async (...args) => {
|
||||
const result = await parentOpenMany2xRecord(...args);
|
||||
const isDirty = await this.props.record.model.root.isDirty();
|
||||
if (isDirty) {
|
||||
await this.props.record.model.root.save();
|
||||
}
|
||||
await this.props.record.load();
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
getTagProps(record) {
|
||||
var text = record.data?.display_name;
|
||||
const amount = record.data?.employee_salary_amount;
|
||||
const has_multiple_bank_accounts = this.props.record.data["has_multiple_bank_accounts"];
|
||||
if (has_multiple_bank_accounts && amount) {
|
||||
const symbol = record.data?.currency_symbol;
|
||||
if (record.data?.employee_salary_amount_is_percentage) {
|
||||
text =
|
||||
(amount && amount <= 100 ? `(${amount.toFixed(0)}%) ` : "") +
|
||||
record.data?.display_name;
|
||||
} else if (amount) {
|
||||
text = `(${amount.toFixed(2)}${symbol ? symbol : ""}) ` + record.data?.display_name;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...super.getTagProps(record),
|
||||
text,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const fieldMany2ManyTagsSalaryBank = {
|
||||
...many2ManyTagsFieldColorEditable,
|
||||
component: FieldMany2ManyTagsSalaryBank,
|
||||
relatedFields: () => [
|
||||
{ name: "employee_salary_amount" },
|
||||
{ name: "employee_salary_amount_is_percentage" },
|
||||
{ name: "display_name" },
|
||||
{ name: "currency_symbol" },
|
||||
],
|
||||
additionalClasses: [
|
||||
...(many2ManyTagsFieldColorEditable.additionalClasses || []),
|
||||
"o_field_many2many_tags",
|
||||
],
|
||||
extractProps({ options, attrs, string, placeholder }, dynamicInfo) {
|
||||
const props = many2ManyTagsFieldColorEditable.extractProps(
|
||||
{ options, attrs, string, placeholder },
|
||||
dynamicInfo
|
||||
);
|
||||
props.nameCreateField = "acc_number";
|
||||
return props;
|
||||
},
|
||||
};
|
||||
|
||||
registry.category("fields").add("many2many_tags_salary_bank", fieldMany2ManyTagsSalaryBank);
|
||||
@@ -0,0 +1,11 @@
|
||||
import { registry } from "@web/core/registry";
|
||||
import { RadioField, radioField } from "@web/views/fields/radio/radio_field";
|
||||
|
||||
class RadioImageField extends RadioField {
|
||||
static template = "hr_homeworking.RadioImageField";
|
||||
}
|
||||
|
||||
registry.category("fields").add("hr_homeworking_radio_image", {
|
||||
...radioField,
|
||||
component: RadioImageField,
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-name="hr_homeworking.RadioImageField">
|
||||
<div role="radiogroup" class="d-flex flex-wrap" t-att-aria-label="string">
|
||||
<t t-foreach="items" t-as="item" t-key="item[0]">
|
||||
<t t-if="['office', 'home', 'other'].includes(item[0])">
|
||||
<div class="form-check o_radio_item me-1" aria-atomic="true">
|
||||
<input
|
||||
type="radio"
|
||||
class="form-check-input o_radio_input"
|
||||
t-att-checked="item[0] === value"
|
||||
t-att-disabled="props.readonly"
|
||||
t-att-name="id"
|
||||
t-att-data-value="item[0]"
|
||||
t-att-data-index="item_index"
|
||||
t-att-id="`${id}_${item[0]}`"
|
||||
t-on-change="() => this.onChange(item)"
|
||||
/>
|
||||
<t t-if="item[0] === 'office'">
|
||||
<i class="fa fa-building-o fa-2x" role="img"/>
|
||||
</t>
|
||||
<t t-elif="item[0] === 'home'">
|
||||
<i class="fa fa-home fa-2x" role="img"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<i class="fa fa-map-marker fa-2x" role="img"/>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useDateTimePicker } from "@web/core/datetime/datetime_picker_hook";
|
||||
import { Domain } from "@web/core/domain";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { statusBarField, StatusBarField } from "@web/views/fields/statusbar/statusbar_field";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
|
||||
export class VersionsTimeline extends StatusBarField {
|
||||
static template = "hr.VersionsTimeline";
|
||||
|
||||
/** @override **/
|
||||
setup() {
|
||||
super.setup();
|
||||
this.actionService = useService("action");
|
||||
this.orm = useService("orm");
|
||||
|
||||
this.dateTimePicker = useDateTimePicker({
|
||||
target: `datetime-picker-target-version`,
|
||||
onApply: (date) => {
|
||||
if (date) {
|
||||
this.createVersion(date);
|
||||
}
|
||||
},
|
||||
get pickerProps() {
|
||||
return { type: "date" };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** @override **/
|
||||
getDomain() {
|
||||
return Domain.and([super.getDomain(),
|
||||
[["employee_id", "=", this.props.record.evalContext.id]]]
|
||||
).toList()
|
||||
}
|
||||
|
||||
/** @override **/
|
||||
getFieldNames() {
|
||||
const fieldNames = super.getFieldNames();
|
||||
fieldNames.push([
|
||||
"contract_type_id",
|
||||
"contract_date_start",
|
||||
"contract_date_end",
|
||||
]);
|
||||
return fieldNames.filter((fName) => fName in this.props.record.fields);
|
||||
}
|
||||
|
||||
displayContractLines() {
|
||||
return ["contract_type_id", "contract_date_start", "contract_date_end"].every(
|
||||
(fieldName) => fieldName in this.props.record.fields
|
||||
);
|
||||
}
|
||||
|
||||
async createVersion(date) {
|
||||
await this.props.record.save();
|
||||
const version_id = await this.orm.call("hr.employee", "create_version", [
|
||||
this.props.record.evalContext.id,
|
||||
{ date_version: date },
|
||||
]);
|
||||
|
||||
const { specialDataCaches } = this.props.record.model;
|
||||
// Invalidate cache after creating new version.
|
||||
Object.keys(specialDataCaches).forEach(key => delete specialDataCaches[key]);
|
||||
|
||||
await this.props.record.model.load({
|
||||
context: {
|
||||
...this.props.record.model.env.searchModel.context,
|
||||
version_id: version_id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onClickDateTimePickerBtn() {
|
||||
this.dateTimePicker.open();
|
||||
}
|
||||
|
||||
/** @override **/
|
||||
async selectItem(item) {
|
||||
const { record } = this.props;
|
||||
await record.save();
|
||||
await this.props.record.model.load({
|
||||
context: {
|
||||
...this.props.record.model.env.searchModel.context,
|
||||
version_id: item.value,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** @override **/
|
||||
getAllItems() {
|
||||
function format(dateString) {
|
||||
return luxon.DateTime.fromISO(dateString).toFormat("MMM dd, yyyy");
|
||||
}
|
||||
const items = super.getAllItems();
|
||||
if (!this.displayContractLines) {
|
||||
return items;
|
||||
}
|
||||
const dataById = new Map(this.specialData.data.map((d) => [d.id, d]));
|
||||
|
||||
const selectedVersion = items.find((item) => item.isSelected)?.value;
|
||||
const selectedContractDate = dataById.get(selectedVersion)?.contract_date_start;
|
||||
|
||||
return items.map((item, index) => {
|
||||
const itemSpecialData = dataById.get(item.value) || {};
|
||||
const contractDateStart = itemSpecialData.contract_date_start;
|
||||
let contractDateEnd = itemSpecialData.contract_date_end;
|
||||
contractDateEnd = contractDateEnd ? format(contractDateEnd) : _t("Indefinite");
|
||||
const contractType = itemSpecialData.contract_type_id?.[1] ?? _t("Contract");
|
||||
const toolTip = contractDateStart
|
||||
? `${contractType}: ${format(contractDateStart)} - ${contractDateEnd}`
|
||||
: _t("No contract");
|
||||
|
||||
return {
|
||||
...item,
|
||||
isCurrentContract: contractDateStart === selectedContractDate,
|
||||
isInContract: Boolean(contractDateStart),
|
||||
toolTip,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const versionsTimeline = {
|
||||
...statusBarField,
|
||||
component: VersionsTimeline,
|
||||
additionalClasses: ["o_field_statusbar", "d-flex", "gap-1"],
|
||||
};
|
||||
|
||||
registry.category("fields").add("versions_timeline", versionsTimeline);
|
||||
@@ -0,0 +1,204 @@
|
||||
.o_field_statusbar {
|
||||
> .o_statusbar_status {
|
||||
width: auto;
|
||||
height: 100%;
|
||||
.o_arrow_button_wrapper {
|
||||
.o_purple_line {
|
||||
background-color: var(--o-cc1-btn-primary);
|
||||
}
|
||||
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
div {
|
||||
position: absolute;
|
||||
bottom: -5px;
|
||||
left: -1em;
|
||||
right: 1em;
|
||||
height: 2px;
|
||||
z-index: 10;
|
||||
|
||||
&.o_first {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
&.o_last {
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_arrow_button:not(.d-none) {
|
||||
position: relative;
|
||||
padding: var(--o-statusbar-padding-y) calc(var(--o-statusbar-padding-x) * 1.375);
|
||||
border: 0;
|
||||
clip-path: polygon(
|
||||
var(--o-statusbar-point-top-left),
|
||||
var(--o-statusbar-point-top-right),
|
||||
var(--o-statusbar-point-middle-right),
|
||||
var(--o-statusbar-point-bottom-right),
|
||||
var(--o-statusbar-point-bottom-left),
|
||||
var(--o-statusbar-point-middle-left)
|
||||
);
|
||||
margin-left: calc(-1 * var(--o-statusbar-caret-width) - var(--o-statusbar-border-width) * sqrt(3));
|
||||
|
||||
&.o_last {
|
||||
--o-statusbar-point-middle-left: 0 50%;
|
||||
padding-left: var(--o-statusbar-padding-x);
|
||||
margin-left: 0;
|
||||
border-top-left-radius: var(--o-statusbar-radius);
|
||||
border-bottom-left-radius: var(--o-statusbar-radius);
|
||||
}
|
||||
|
||||
&.o_first {
|
||||
--o-statusbar-point-top-right: 100% 0;
|
||||
--o-statusbar-point-bottom-right: 100% 100%;
|
||||
padding-right: var(--o-statusbar-padding-x);
|
||||
border-top-right-radius: var(--o-statusbar-radius);
|
||||
border-bottom-right-radius: var(--o-statusbar-radius);
|
||||
}
|
||||
|
||||
&.dropdown-toggle::after {
|
||||
content: normal;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: var(--o-statusbar-border);
|
||||
clip-path: polygon(
|
||||
var(--o-statusbar-point-top-left),
|
||||
var(--o-statusbar-point-top-right),
|
||||
var(--o-statusbar-point-middle-right),
|
||||
var(--o-statusbar-point-bottom-right),
|
||||
var(--o-statusbar-point-bottom-left),
|
||||
var(--o-statusbar-point-middle-left),
|
||||
var(--o-statusbar-point-top-left),
|
||||
var(--o-statusbar-point-inner-top-left),
|
||||
var(--o-statusbar-point-inner-middle-left),
|
||||
var(--o-statusbar-point-inner-bottom-left),
|
||||
var(--o-statusbar-point-inner-bottom-right),
|
||||
var(--o-statusbar-point-inner-middle-right),
|
||||
var(--o-statusbar-point-inner-top-right),
|
||||
var(--o-statusbar-point-inner-top-left)
|
||||
);
|
||||
}
|
||||
|
||||
&.o_last::before {
|
||||
--o-statusbar-point-inner-top-left: var(--o-statusbar-point-top-left);
|
||||
--o-statusbar-point-inner-middle-left: var(--o-statusbar-point-middle-left);
|
||||
--o-statusbar-point-inner-bottom-left: var(--o-statusbar-point-bottom-left);
|
||||
border-top-left-radius: var(--o-statusbar-radius);
|
||||
border-bottom-left-radius: var(--o-statusbar-radius);
|
||||
}
|
||||
|
||||
&.o_first::before {
|
||||
--o-statusbar-point-inner-top-right: var(--o-statusbar-point-top-right);
|
||||
--o-statusbar-point-inner-middle-right: var(--o-statusbar-point-middle-right);
|
||||
--o-statusbar-point-inner-bottom-right: var(--o-statusbar-point-bottom-right);
|
||||
border-top-right-radius: var(--o-statusbar-radius);
|
||||
border-bottom-right-radius: var(--o-statusbar-radius);
|
||||
}
|
||||
|
||||
&:hover, &:focus {
|
||||
background-color: var(--o-statusbar-background-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 1;
|
||||
cursor: default;
|
||||
|
||||
&:not(.o_arrow_button_current) {
|
||||
&, &:hover, &:focus {
|
||||
color: $text-muted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.o_arrow_button_current:disabled, &:active:not(.o_last) {
|
||||
z-index: 1;
|
||||
background-color: var(--o-statusbar-background-active);
|
||||
|
||||
&::before {
|
||||
--o-statusbar-point-inner-top-left: calc(var(--o-statusbar-border-width) * sqrt(3)) var(--o-statusbar-border-width);
|
||||
--o-statusbar-point-inner-top-right: calc(100% - var(--o-statusbar-caret-width) - var(--o-statusbar-border-width) / sqrt(2)) var(--o-statusbar-border-width);
|
||||
--o-statusbar-point-inner-bottom-left: calc(var(--o-statusbar-border-width) * sqrt(3)) calc(100% - var(--o-statusbar-border-width));
|
||||
--o-statusbar-point-inner-bottom-right: calc(100% - var(--o-statusbar-caret-width) - var(--o-statusbar-border-width) / sqrt(2)) calc(100% - var(--o-statusbar-border-width));
|
||||
background-color: var(--o-statusbar-border-active);
|
||||
}
|
||||
|
||||
&.o_last::before {
|
||||
--o-statusbar-point-inner-top-left: calc(var(--o-statusbar-border-width) * sqrt(2)) var(--o-statusbar-border-width);
|
||||
--o-statusbar-point-inner-middle-left: calc(var(--o-statusbar-border-width) * sqrt(2)) 50%;
|
||||
--o-statusbar-point-inner-bottom-left: calc(var(--o-statusbar-border-width) * sqrt(2)) calc(100% - var(--o-statusbar-border-width));
|
||||
}
|
||||
|
||||
&.o_first::before {
|
||||
--o-statusbar-point-inner-top-right: calc(100% - var(--o-statusbar-border-width) * sqrt(2)) var(--o-statusbar-border-width);
|
||||
--o-statusbar-point-inner-middle-right: calc(100% - var(--o-statusbar-border-width) * sqrt(2)) 50%;
|
||||
--o-statusbar-point-inner-bottom-right: calc(100% - var(--o-statusbar-border-width) * sqrt(2)) calc(100% - var(--o-statusbar-border-width));
|
||||
}
|
||||
}
|
||||
|
||||
.o_rtl & {
|
||||
--o-statusbar-point-top-left: var(--o-statusbar-caret-width) 0;
|
||||
--o-statusbar-point-top-right: 100% 0;
|
||||
--o-statusbar-point-middle-left: 0 50%;
|
||||
--o-statusbar-point-middle-right: calc(100% - var(--o-statusbar-caret-width)) 50%;
|
||||
--o-statusbar-point-bottom-left: var(--o-statusbar-caret-width) 100%;
|
||||
--o-statusbar-point-bottom-right: 100% 100%;
|
||||
--o-statusbar-point-inner-top-left: calc(var(--o-statusbar-caret-width) + var(--o-statusbar-border-width) * sqrt(2)) 0;
|
||||
--o-statusbar-point-inner-top-right: calc(100% - var(--o-statusbar-border-width) * sqrt(2)) 0;
|
||||
--o-statusbar-point-inner-middle-left: calc(var(--o-statusbar-border-width) * sqrt(2)) 50%;
|
||||
--o-statusbar-point-inner-middle-right: calc(100% - var(--o-statusbar-caret-width) - var(--o-statusbar-border-width) * sqrt(2)) 50%;
|
||||
--o-statusbar-point-inner-bottom-left: calc(var(--o-statusbar-caret-width) + var(--o-statusbar-border-width) * sqrt(2)) 100%;
|
||||
--o-statusbar-point-inner-bottom-right: calc(100% - var(--o-statusbar-border-width) * sqrt(2)) 100%;
|
||||
|
||||
&.o_last {
|
||||
--o-statusbar-point-middle-right: 100% 50%;
|
||||
}
|
||||
|
||||
&.o_first {
|
||||
--o-statusbar-point-top-left: 0 0;
|
||||
--o-statusbar-point-bottom-left: 0 100%;
|
||||
}
|
||||
|
||||
&.o_last::before {
|
||||
--o-statusbar-point-inner-top-right: var(--o-statusbar-point-top-right);
|
||||
--o-statusbar-point-inner-middle-right: var(--o-statusbar-point-middle-right);
|
||||
--o-statusbar-point-inner-bottom-right: var(--o-statusbar-point-bottom-right);
|
||||
}
|
||||
|
||||
&.o_first::before {
|
||||
--o-statusbar-point-inner-top-left: var(--o-statusbar-point-top-left);
|
||||
--o-statusbar-point-inner-middle-left: var(--o-statusbar-point-middle-left);
|
||||
--o-statusbar-point-inner-bottom-left: var(--o-statusbar-point-bottom-left);
|
||||
}
|
||||
|
||||
&.o_arrow_button_current:disabled, &:active:not(.o_last) {
|
||||
&::before {
|
||||
--o-statusbar-point-inner-top-left: calc(var(--o-statusbar-caret-width) + var(--o-statusbar-border-width) / sqrt(2)) var(--o-statusbar-border-width);
|
||||
--o-statusbar-point-inner-top-right: calc(100% - var(--o-statusbar-border-width) * sqrt(3)) var(--o-statusbar-border-width);
|
||||
--o-statusbar-point-inner-bottom-left: calc(var(--o-statusbar-caret-width) + var(--o-statusbar-border-width) / sqrt(2)) calc(100% - var(--o-statusbar-border-width));
|
||||
--o-statusbar-point-inner-bottom-right: calc(100% - var(--o-statusbar-border-width) * sqrt(3)) calc(100% - var(--o-statusbar-border-width));
|
||||
}
|
||||
|
||||
&.o_last::before {
|
||||
--o-statusbar-point-inner-top-right: calc(100% - var(--o-statusbar-border-width) * sqrt(2)) var(--o-statusbar-border-width);
|
||||
--o-statusbar-point-inner-middle-right: calc(100% - var(--o-statusbar-border-width) * sqrt(2)) 50%;
|
||||
--o-statusbar-point-inner-bottom-right: calc(100% - var(--o-statusbar-border-width) * sqrt(2)) calc(100% - var(--o-statusbar-border-width));
|
||||
}
|
||||
|
||||
&.o_first::before {
|
||||
--o-statusbar-point-inner-top-left: calc(var(--o-statusbar-border-width) * sqrt(2)) var(--o-statusbar-border-width);
|
||||
--o-statusbar-point-inner-middle-left: calc(var(--o-statusbar-border-width) * sqrt(2)) 50%;
|
||||
--o-statusbar-point-inner-bottom-left: calc(var(--o-statusbar-border-width) * sqrt(2)) calc(100% - var(--o-statusbar-border-width));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?xml version="1.0"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="hr.VersionsTimeline" t-inherit="web.StatusBarField" t-inherit-mode="primary">
|
||||
<div t-ref="root" position="after">
|
||||
<div class="o_arrow_button_wrapper">
|
||||
<button
|
||||
t-ref="datetime-picker-target-version"
|
||||
title="New Employee Record"
|
||||
t-on-click="onClickDateTimePickerBtn"
|
||||
class="btn btn-primary">
|
||||
<i class="fa fa-plus"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button t-ref="after" position="replace">
|
||||
<div class="o_arrow_button_wrapper">
|
||||
<button
|
||||
t-ref="after"
|
||||
type="button"
|
||||
class="btn btn-secondary dropdown-toggle o_arrow_button o_first"
|
||||
t-att-disabled="props.isDisabled"
|
||||
aria-label="More..."
|
||||
>
|
||||
...
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button t-ref="before" position="replace">
|
||||
<div class="o_arrow_button_wrapper">
|
||||
<button
|
||||
t-ref="before"
|
||||
type="button"
|
||||
class="btn btn-secondary dropdown-toggle o_arrow_button o_last"
|
||||
t-att-disabled="props.isDisabled"
|
||||
aria-label="More..."
|
||||
>
|
||||
...
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<t t-foreach="items.inline" position="replace">
|
||||
<t t-foreach="items.inline" t-as="item" t-key="item.value">
|
||||
<div class="o_arrow_button_wrapper"
|
||||
t-att-data-tooltip="item.toolTip">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary o_arrow_button"
|
||||
t-att-class="{
|
||||
o_first: item_first,
|
||||
o_arrow_button_current: item.isSelected,
|
||||
o_last: item_last,
|
||||
}"
|
||||
t-att-disabled="props.isDisabled || item.isSelected"
|
||||
role="radio"
|
||||
t-att-aria-checked="item.isSelected.toString()"
|
||||
t-att-aria-current="item.isSelected and 'step'"
|
||||
t-att-data-value="item.value"
|
||||
t-esc="item.label"
|
||||
t-on-click="() => this.selectItem(item)"/>
|
||||
<div
|
||||
t-if="displayContractLines"
|
||||
t-att-class="{
|
||||
o_first: item_first,
|
||||
o_last: item_last,
|
||||
o_purple_line: item.isInContract and item.isCurrentContract,
|
||||
}"/>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,13 @@
|
||||
import { registry } from "@web/core/registry";
|
||||
import { BinaryField, binaryField } from "@web/views/fields/binary/binary_field";
|
||||
|
||||
export class WorkPermitUploadField extends BinaryField {
|
||||
static template = "hr.WorkPermitUploadField";
|
||||
}
|
||||
|
||||
export const workPermitUploadField = {
|
||||
...binaryField,
|
||||
component: WorkPermitUploadField,
|
||||
};
|
||||
|
||||
registry.category("fields").add("work_permit_upload", workPermitUploadField);
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-name="hr.WorkPermitUploadField" t-inherit="web.BinaryField" t-inherit-mode="primary">
|
||||
<xpath expr="//label[hasclass('o_select_file_button')]" position="attributes">
|
||||
<attribute name="class" remove="btn-primary" add="btn-secondary" separator=" " />
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
30
frontend/hr/static/src/core/common/@types/models.d.ts
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
declare module "models" {
|
||||
import { HrDepartment as HrDepartmentClass } from "@hr/core/common/hr_department_model";
|
||||
import { HrEmployee as HrEmployeeClass } from "@hr/core/common/hr_employee_model";
|
||||
import { HrWorkLocation as HrWorkLocationClass } from "@hr/core/common/hr_work_location_model";
|
||||
|
||||
export interface HrDepartment extends HrDepartmentClass {}
|
||||
export interface HrEmployee extends HrEmployeeClass {}
|
||||
export interface HrWorkLocation extends HrWorkLocationClass {}
|
||||
|
||||
export interface ResPartner {
|
||||
employee_id: HrEmployee;
|
||||
employee_ids: HrEmployee[];
|
||||
employeeId: number|undefined;
|
||||
}
|
||||
export interface ResUsers {
|
||||
employee_id: HrEmployee;
|
||||
employee_ids: HrEmployee[];
|
||||
}
|
||||
export interface Store {
|
||||
"hr.department": StaticMailRecord<HrDepartment, typeof HrDepartmentClass>;
|
||||
"hr.employee": StaticMailRecord<HrEmployee, typeof HrEmployeeClass>;
|
||||
"hr.work.location": StaticMailRecord<HrWorkLocation, typeof HrWorkLocationClass>;
|
||||
}
|
||||
|
||||
export interface Models {
|
||||
"hr.department": HrDepartment;
|
||||
"hr.employee": HrEmployee;
|
||||
"hr.work.location": HrWorkLocation;
|
||||
}
|
||||
}
|
||||
13
frontend/hr/static/src/core/common/hr_department_model.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Record } from "@mail/core/common/record";
|
||||
|
||||
export class HrDepartment extends Record {
|
||||
static _name = "hr.department";
|
||||
static id = "id";
|
||||
|
||||
/** @type {number} */
|
||||
id;
|
||||
/** @type {string} */
|
||||
name;
|
||||
}
|
||||
|
||||
HrDepartment.register();
|
||||
23
frontend/hr/static/src/core/common/hr_employee_model.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Record, fields } from "@mail/core/common/record";
|
||||
|
||||
export class HrEmployee extends Record {
|
||||
static _name = "hr.employee";
|
||||
static id = "id";
|
||||
|
||||
/** @type {number} */
|
||||
id;
|
||||
/** @type {number} */
|
||||
company_id = fields.One("res.company");
|
||||
department_id = fields.One("hr.department");
|
||||
/** @type {string} */
|
||||
job_title;
|
||||
work_contact_id = fields.One("res.partner");
|
||||
user_id = fields.One("res.users");
|
||||
/** @type {string} */
|
||||
work_email;
|
||||
work_location_id = fields.One("hr.work.location");
|
||||
/** @type {string} */
|
||||
work_phone;
|
||||
}
|
||||
|
||||
HrEmployee.register();
|
||||
15
frontend/hr/static/src/core/common/hr_work_location_model.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Record } from "@mail/core/common/record";
|
||||
|
||||
export class HrWorkLocation extends Record {
|
||||
static _name = "hr.work.location";
|
||||
static id = "id";
|
||||
|
||||
/** @type {number} */
|
||||
id;
|
||||
/** @type {string} */
|
||||
location_type;
|
||||
/** @type {string} */
|
||||
name;
|
||||
}
|
||||
|
||||
HrWorkLocation.register();
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ResPartner } from "@mail/core/common/res_partner_model";
|
||||
import { fields } from "@mail/model/misc";
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { user } from "@web/core/user";
|
||||
|
||||
patch(ResPartner.prototype, {
|
||||
/** @type {number|undefined} */
|
||||
employeeId: undefined,
|
||||
setup() {
|
||||
super.setup();
|
||||
this.employee_ids = fields.Many("hr.employee", {
|
||||
inverse: "work_contact_id",
|
||||
});
|
||||
this.employee_id = fields.One("hr.employee", {
|
||||
compute() {
|
||||
return (
|
||||
this.employee_ids.find(
|
||||
(employee) => employee.company_id?.id === user.activeCompany.id
|
||||
) || this.employee_ids[0]
|
||||
);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
22
frontend/hr/static/src/core/common/res_users_model_patch.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { fields } from "@mail/model/misc";
|
||||
import { ResUsers } from "@mail/core/common/res_users_model";
|
||||
import { user } from "@web/core/user";
|
||||
|
||||
patch(ResUsers.prototype, {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.employee_ids = fields.Many("hr.employee", {
|
||||
inverse: "user_id",
|
||||
});
|
||||
this.employee_id = fields.One("hr.employee", {
|
||||
compute() {
|
||||
return (
|
||||
this.employee_ids.find(
|
||||
(employee) => employee.company_id?.id === user.activeCompany.id
|
||||
) || this.employee_ids[0]
|
||||
);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
34
frontend/hr/static/src/core/web/thread_actions.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { registerThreadAction } from "@mail/core/common/thread_actions";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
|
||||
registerThreadAction("open-hr-profile", {
|
||||
condition: ({ owner, thread }) =>
|
||||
thread?.channel_type === "chat" &&
|
||||
owner.props.chatWindow?.isOpen &&
|
||||
thread.correspondent?.partner_id?.employeeId &&
|
||||
!owner.isDiscussSidebarChannelActions,
|
||||
icon: "fa fa-fw fa-id-card",
|
||||
name: _t("Open Profile"),
|
||||
open: async ({ store, thread }) =>
|
||||
store.env.services.action.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
res_id: thread.correspondent.partner_id?.employeeId,
|
||||
res_model: "hr.employee.public",
|
||||
views: [[false, "form"]],
|
||||
}),
|
||||
async setup({ thread }) {
|
||||
let employeeId;
|
||||
if (thread?.correspondent?.partner_id && !thread.correspondent.partner_id.employeeId) {
|
||||
const employees = await this.store.env.services.orm.silent.searchRead(
|
||||
"hr.employee",
|
||||
[["user_partner_id", "=", thread.correspondent.partner_id.id]],
|
||||
["id"]
|
||||
);
|
||||
employeeId = employees[0]?.id;
|
||||
if (employeeId) {
|
||||
thread.correspondent.partner_id.employeeId = employeeId;
|
||||
}
|
||||
}
|
||||
},
|
||||
sequence: 16,
|
||||
});
|
||||
0
frontend/hr/static/src/default_image.png
Normal file
72
frontend/hr/static/src/fields/boolean_radio.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { RadioField, radioField } from "@web/views/fields/radio/radio_field";
|
||||
import { onMounted } from "@odoo/owl";
|
||||
|
||||
export class BooleanRadio extends RadioField {
|
||||
static props = {
|
||||
...RadioField.props,
|
||||
yes_label_element_id: { type: String },
|
||||
no_label_element_id: { type: String },
|
||||
};
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
onMounted(this.moveElement);
|
||||
}
|
||||
|
||||
moveElement() {
|
||||
document.querySelectorAll("[data-value='true']")[0]
|
||||
.labels[0].textContent = document.getElementById(this.props.yes_label_element_id).innerText;
|
||||
document.querySelectorAll("[data-value='false']")[0]
|
||||
.labels[0].textContent = document.getElementById(this.props.no_label_element_id).innerText;
|
||||
}
|
||||
|
||||
get items() {
|
||||
if (this.type === "boolean") return [["true", ""], ["false", ""]];
|
||||
return super.items;
|
||||
}
|
||||
|
||||
get value() {
|
||||
if (this.type === "boolean") return this.props.record.data[this.props.name].toString();
|
||||
return super.items;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} value
|
||||
*/
|
||||
onChange(value) {
|
||||
if (this.type === "boolean") this.props.record.update({ [this.props.name]: value[0] === "true" });
|
||||
super.onChange();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const booleanRadio = {
|
||||
...radioField,
|
||||
component: BooleanRadio,
|
||||
displayName: _t("Boolean display as radio field with translatable labels"),
|
||||
supportedOptions: [
|
||||
{
|
||||
label: _t("True association"),
|
||||
name: "yes_label_element_id",
|
||||
type: "string",
|
||||
help: _t("Link an element with the boolean True value."),
|
||||
},
|
||||
{
|
||||
label: _t("False association"),
|
||||
name: "no_label_element_id",
|
||||
type: "string",
|
||||
help: _t("Link an element with the boolean False value."),
|
||||
},
|
||||
],
|
||||
supportedTypes: ["boolean"],
|
||||
extractProps({ options }, dynamicInfo) {
|
||||
return {
|
||||
readonly: dynamicInfo.readonly,
|
||||
yes_label_element_id: options.yes_label_element_id,
|
||||
no_label_element_id: options.no_label_element_id,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
registry.category("fields").add("boolean_radio", booleanRadio);
|
||||
13
frontend/hr/static/src/fields/boolean_radio.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="hr_holidays.BooleanRadio" t-inherit="web.BooleanField" t-inherit-mode="primary">
|
||||
<xpath expr="//CheckBox" position="replace">
|
||||
<select>
|
||||
<option>YES</option>
|
||||
<option>NO</option>
|
||||
</select>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
75
frontend/hr/static/src/fields/radio_followed_by_element.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { RadioField, radioField } from "@web/views/fields/radio/radio_field";
|
||||
import {onMounted,onWillUnmount} from "@odoo/owl";
|
||||
|
||||
export class RadioFollowedByElement extends RadioField {
|
||||
static props = {
|
||||
...RadioField.props,
|
||||
links: { type: Object },
|
||||
observe: { type: String },
|
||||
};
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
|
||||
onMounted(() => {
|
||||
this.moveElement();
|
||||
this.observer = new MutationObserver((mutations) => {
|
||||
if ([...mutations].map(mutation =>
|
||||
[...mutation.addedNodes].map(node => node.id))
|
||||
.flat()
|
||||
.filter(id => Object.values(this.props.links).includes(id))) this.moveElement();
|
||||
});
|
||||
|
||||
this.observer.observe(document.getElementsByName(this.props.observe).item(0), {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: false,
|
||||
characterData: false,
|
||||
});
|
||||
});
|
||||
|
||||
onWillUnmount(() => {
|
||||
this.observer.disconnect();
|
||||
});
|
||||
}
|
||||
|
||||
moveElement() {
|
||||
for (const [key, value] of Object.entries(this.props.links)) {
|
||||
const option = document.querySelectorAll("[data-value="+key+"]")[0];
|
||||
const elementToAppend = document.getElementById(value);
|
||||
if (option === null || elementToAppend === null || elementToAppend.parentElement === option.parentElement)
|
||||
continue;
|
||||
option.parentElement.appendChild(elementToAppend);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const radioFollowedByElement = {
|
||||
...radioField,
|
||||
component: RadioFollowedByElement,
|
||||
displayName: _t("Radio followed by element"),
|
||||
supportedOptions: [
|
||||
{
|
||||
label: _t("Element association"),
|
||||
name: "links",
|
||||
type: "Object",
|
||||
help: _t("An object to link select options and element id to move"),
|
||||
},
|
||||
{
|
||||
label: _t("Element to observe"),
|
||||
name: "observe",
|
||||
type: "String",
|
||||
help: _t("An element name parent of the radio to observe updates"),
|
||||
}
|
||||
],
|
||||
extractProps({ options }, dynamicInfo) {
|
||||
return {
|
||||
readonly: dynamicInfo.readonly,
|
||||
links: options.links,
|
||||
observe: options.observe,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
registry.category("fields").add("radio_followed_by_element", radioFollowedByElement);
|
||||
BIN
frontend/hr/static/src/img/default_image.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
1
frontend/hr/static/src/img/icons/hatched.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path d="M83.13,35.69a7.9,7.9,0,0,0-4.94-7.33L34.33,72.22H47.06L83.13,36.15Z" style="fill:#875b7b"/><path d="M53.39,27.78,16.87,64.3h0a7.9,7.9,0,0,0,5.21,7.43l44-44Z" style="fill:#875b7b"/><path d="M24.78,27.78a7.91,7.91,0,0,0-7.91,7.91V51.57L40.66,27.78Z" style="fill:#875b7b"/><path d="M59.78,72.22H75.22a7.91,7.91,0,0,0,7.91-7.91V48.87Z" style="fill:#875b7b"/></svg>
|
||||
|
After Width: | Height: | Size: 431 B |
1
frontend/hr/static/src/img/icons/hatched_dark.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path d="M83.13,35.69a7.9,7.9,0,0,0-4.94-7.33L34.33,72.22H47.06L83.13,36.15Z" style="fill:#bb86fc"/><path d="M53.39,27.78,16.87,64.3h0a7.9,7.9,0,0,0,5.21,7.43l44-44Z" style="fill:#bb86fc"/><path d="M24.78,27.78a7.91,7.91,0,0,0-7.91,7.91V51.57L40.66,27.78Z" style="fill:#bb86fc"/><path d="M59.78,72.22H75.22a7.91,7.91,0,0,0,7.91-7.91V48.87Z" style="fill:#bb86fc"/></svg>
|
||||
|
After Width: | Height: | Size: 432 B |
1
frontend/hr/static/src/img/icons/line.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path d="M21.93,55H78.07a5,5,0,0,0,0-10H21.93a5,5,0,0,0,0,10Z" style="fill:#875b7b"/></svg>
|
||||
|
After Width: | Height: | Size: 153 B |
1
frontend/hr/static/src/img/icons/line_dark.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path d="M21.93,55H78.07a5,5,0,0,0,0-10H21.93a5,5,0,0,0,0,10Z" style="fill:#bb86fc"/></svg>
|
||||
|
After Width: | Height: | Size: 154 B |
1
frontend/hr/static/src/img/icons/plain.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect x="16.87" y="27.78" width="66.26" height="44.44" rx="7.91" style="fill:#875b7b"/></svg>
|
||||
|
After Width: | Height: | Size: 155 B |
1
frontend/hr/static/src/img/icons/plain_dark.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect x="16.87" y="27.78" width="66.26" height="44.44" rx="7.91" style="fill:#bb86fc"/></svg>
|
||||
|
After Width: | Height: | Size: 156 B |
109
frontend/hr/static/src/scss/hr.scss
Normal file
@@ -0,0 +1,109 @@
|
||||
.o_hr_department_kanban .o_kanban_renderer {
|
||||
--KanbanRecord-width: 450px;
|
||||
--KanbanRecord-width-small: 350px;
|
||||
}
|
||||
|
||||
.o_icon_employee_absent {
|
||||
color: $warning;
|
||||
}
|
||||
|
||||
.o_hr_employee_form_view .o_form_renderer {
|
||||
.o_form_sheet_bg {
|
||||
max-width: unset;
|
||||
}
|
||||
.o_employee_chat_btn {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-bottom: 5px;
|
||||
@include media-breakpoint-down(md) {
|
||||
margin-left: 0!important;
|
||||
}
|
||||
}
|
||||
.o_employee_avatar {
|
||||
position: relative;
|
||||
width: fit-content;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
.o_employee_availability {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 0px;
|
||||
right: -5px;
|
||||
padding-bottom: 1px;
|
||||
border-radius: 50%;
|
||||
background-color: $o-view-background-color;
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
* {
|
||||
margin-bottom: 4px;
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
.oe_title {
|
||||
flex: 1;
|
||||
}
|
||||
.o_employee_form_header_info{
|
||||
flex: 5;
|
||||
}
|
||||
.o_presence_status_pill_wrapper{
|
||||
flex: 3;
|
||||
}
|
||||
}
|
||||
|
||||
.o_hr_employee_kanban .o_kanban_renderer {
|
||||
.o_employee_availability {
|
||||
margin: unset !important;
|
||||
}
|
||||
}
|
||||
|
||||
.hr_tags {
|
||||
margin-right: 20%;
|
||||
}
|
||||
|
||||
.o_hr_narrow_field {
|
||||
width: 8rem!important;
|
||||
max-width: 8rem!important;
|
||||
* {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.o_hr_percentage_narrow_field input {
|
||||
max-width: 8rem !important;
|
||||
width: 8rem !important;
|
||||
* {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@for $size from 1 through 15 {
|
||||
.o_hr_narrow_field-#{$size} {
|
||||
width: #{$size}rem!important;
|
||||
max-width: #{$size}rem!important;
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-up(lg) {
|
||||
.o_hr_column {
|
||||
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_hr_version_list_view th[data-name="date_version"] {
|
||||
width: 8rem !important;
|
||||
}
|
||||
|
||||
.button-on {
|
||||
color: $o-success;
|
||||
}
|
||||
3
frontend/hr/static/src/scss/res_config_settings.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
input#hr_presence_control_email_amount {
|
||||
max-width: 5rem;
|
||||
}
|
||||
6
frontend/hr/static/src/scss/res_users.scss
Normal file
@@ -0,0 +1,6 @@
|
||||
.o_form_view.o_res_users_form_view_full {
|
||||
.o_contact_image_large img {
|
||||
width: 155px;
|
||||
height: 155px;
|
||||
}
|
||||
}
|
||||
55
frontend/hr/static/src/store_service_patch.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { Store } from "@mail/core/common/store_service";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
/** @type {import("models").Store} */
|
||||
const storeServicePatch = {
|
||||
setup() {
|
||||
super.setup();
|
||||
/** @type {{[key: number]: {id: number, user_id: number, hasCheckedUser: boolean}}} */
|
||||
this.employees = {};
|
||||
},
|
||||
async getChat(person) {
|
||||
const { employeeId } = person;
|
||||
if (!employeeId) {
|
||||
return super.getChat(person);
|
||||
}
|
||||
let employee = this.employees[employeeId];
|
||||
if (!employee) {
|
||||
this.employees[employeeId] = { id: employeeId };
|
||||
employee = this.employees[employeeId];
|
||||
}
|
||||
if (!employee.user_id && !employee.hasCheckedUser) {
|
||||
employee.hasCheckedUser = true;
|
||||
const [employeeData] = await this.env.services.orm.silent.read(
|
||||
"hr.employee.public",
|
||||
[employee.id],
|
||||
["user_id", "user_partner_id"],
|
||||
{ context: { active_test: false } }
|
||||
);
|
||||
if (employeeData) {
|
||||
employee.user_id = employeeData.user_id[0];
|
||||
let user = this.users[employee.user_id];
|
||||
if (!user) {
|
||||
this.users[employee.user_id] = { id: employee.user_id };
|
||||
user = this.users[employee.user_id];
|
||||
}
|
||||
user.partner_id = employeeData.user_partner_id[0];
|
||||
this["res.partner"].insert({
|
||||
display_name: employeeData.user_partner_id[1],
|
||||
id: employeeData.user_partner_id[0],
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!employee.user_id) {
|
||||
this.env.services.notification.add(
|
||||
_t("You can only chat with employees that have a dedicated user."),
|
||||
{ type: "info" }
|
||||
);
|
||||
return;
|
||||
}
|
||||
return super.getChat({ userId: employee.user_id });
|
||||
},
|
||||
};
|
||||
|
||||
patch(Store.prototype, storeServicePatch);
|
||||
29
frontend/hr/static/src/views/archive_employee_hook.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { useComponent } from "@odoo/owl";
|
||||
|
||||
export function useArchiveEmployee() {
|
||||
const component = useComponent();
|
||||
const action = useService("action");
|
||||
return (ids) => {
|
||||
action.doAction(
|
||||
{
|
||||
type: "ir.actions.act_window",
|
||||
name: _t("Employee Termination"),
|
||||
res_model: "hr.departure.wizard",
|
||||
views: [[false, "form"]],
|
||||
view_mode: "form",
|
||||
target: "new",
|
||||
context: {
|
||||
active_ids: ids,
|
||||
employee_termination: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
onClose: async () => {
|
||||
await component.model.load();
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { AvatarCardEmployeePopover } from "@hr/components/avatar_card_employee/avatar_card_employee_popover";
|
||||
import { onWillStart } from "@odoo/owl";
|
||||
import { usePopover } from "@web/core/popover/popover_hook";
|
||||
import { user } from "@web/core/user";
|
||||
|
||||
/**
|
||||
* Mixin that handles public/private access of employee records in many2X fields
|
||||
* @param { Class } fieldClass
|
||||
* @returns Class
|
||||
*/
|
||||
export function EmployeeFieldRelationMixin(fieldClass) {
|
||||
return class extends fieldClass {
|
||||
static props = {
|
||||
...fieldClass.props,
|
||||
relation: { type: String, optional: true },
|
||||
};
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
onWillStart(async () => {
|
||||
this.isHrUser = await user.hasGroup("hr.group_hr_user");
|
||||
});
|
||||
this.avatarCard = usePopover(AvatarCardEmployeePopover, { closeOnClickAway: true });
|
||||
}
|
||||
|
||||
get relation() {
|
||||
if (this.props.relation) {
|
||||
return this.props.relation;
|
||||
}
|
||||
return this.isHrUser ? "hr.employee" : "hr.employee.public";
|
||||
}
|
||||
|
||||
getAvatarCardProps(record) {
|
||||
const originalProps = super.getAvatarCardProps(record);
|
||||
if (["hr.employee", "hr.employee.public"].includes(this.relation)) {
|
||||
return {
|
||||
...originalProps,
|
||||
recordModel: this.relation,
|
||||
};
|
||||
}
|
||||
return originalProps;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { registry } from "@web/core/registry";
|
||||
import {
|
||||
Many2ManyTagsAvatarUserField,
|
||||
KanbanMany2ManyTagsAvatarUserField,
|
||||
ListMany2ManyTagsAvatarUserField,
|
||||
many2ManyTagsAvatarUserField,
|
||||
kanbanMany2ManyTagsAvatarUserField,
|
||||
listMany2ManyTagsAvatarUserField,
|
||||
} from "@mail/views/web/fields/many2many_avatar_user_field/many2many_avatar_user_field";
|
||||
import { EmployeeFieldRelationMixin } from "@hr/views/fields/employee_field_relation_mixin";
|
||||
|
||||
export class Many2ManyTagsAvatarEmployeeField extends EmployeeFieldRelationMixin(
|
||||
Many2ManyTagsAvatarUserField
|
||||
) {
|
||||
displayAvatarCard(record) {
|
||||
return (
|
||||
(!this.env.isSmall && ["hr.employee", "hr.employee.public"].includes(this.relation)) ||
|
||||
super.displayAvatarCard(record)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const many2ManyTagsAvatarEmployeeField = {
|
||||
...many2ManyTagsAvatarUserField,
|
||||
component: Many2ManyTagsAvatarEmployeeField,
|
||||
additionalClasses: [
|
||||
...many2ManyTagsAvatarUserField.additionalClasses,
|
||||
"o_field_many2many_avatar_user",
|
||||
],
|
||||
extractProps: (fieldInfo, dynamicInfo) => ({
|
||||
...many2ManyTagsAvatarUserField.extractProps(fieldInfo, dynamicInfo),
|
||||
canQuickCreate: false,
|
||||
relation: fieldInfo.options?.relation,
|
||||
}),
|
||||
};
|
||||
|
||||
registry.category("fields").add("many2many_avatar_employee", many2ManyTagsAvatarEmployeeField);
|
||||
|
||||
export class KanbanMany2ManyTagsAvatarEmployeeField extends EmployeeFieldRelationMixin(
|
||||
KanbanMany2ManyTagsAvatarUserField
|
||||
) {
|
||||
displayAvatarCard(record) {
|
||||
return (
|
||||
(!this.env.isSmall && ["hr.employee", "hr.employee.public"].includes(this.relation)) ||
|
||||
super.displayAvatarCard(record)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const kanbanMany2ManyTagsAvatarEmployeeField = {
|
||||
...kanbanMany2ManyTagsAvatarUserField,
|
||||
component: KanbanMany2ManyTagsAvatarEmployeeField,
|
||||
additionalClasses: [
|
||||
...kanbanMany2ManyTagsAvatarUserField.additionalClasses,
|
||||
"o_field_many2many_avatar_user",
|
||||
],
|
||||
extractProps: (fieldInfo, dynamicInfo) => ({
|
||||
...kanbanMany2ManyTagsAvatarUserField.extractProps(fieldInfo, dynamicInfo),
|
||||
relation: fieldInfo.options?.relation,
|
||||
}),
|
||||
};
|
||||
|
||||
registry
|
||||
.category("fields")
|
||||
.add("kanban.many2many_avatar_employee", kanbanMany2ManyTagsAvatarEmployeeField)
|
||||
.add("activity.many2many_avatar_employee", kanbanMany2ManyTagsAvatarEmployeeField);
|
||||
|
||||
export class ListMany2ManyTagsAvatarEmployeeField extends EmployeeFieldRelationMixin(
|
||||
ListMany2ManyTagsAvatarUserField
|
||||
) {
|
||||
displayAvatarCard(record) {
|
||||
return (
|
||||
(!this.env.isSmall && ["hr.employee", "hr.employee.public"].includes(this.relation)) ||
|
||||
super.displayAvatarCard(record)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const listMany2ManyTagsAvatarEmployeeField = {
|
||||
...listMany2ManyTagsAvatarUserField,
|
||||
component: ListMany2ManyTagsAvatarEmployeeField,
|
||||
additionalClasses: [
|
||||
...listMany2ManyTagsAvatarUserField.additionalClasses,
|
||||
"o_field_many2many_avatar_user",
|
||||
],
|
||||
};
|
||||
registry
|
||||
.category("fields")
|
||||
.add("list.many2many_avatar_employee", listMany2ManyTagsAvatarEmployeeField);
|
||||
@@ -0,0 +1,73 @@
|
||||
import { AvatarEmployee } from "@hr/components/avatar_employee/avatar_employee";
|
||||
import { Component, onWillStart } from "@odoo/owl";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { user } from "@web/core/user";
|
||||
import { computeM2OProps, KanbanMany2One } from "@web/views/fields/many2one/many2one";
|
||||
import {
|
||||
buildM2OFieldDescription,
|
||||
extractM2OFieldProps,
|
||||
m2oSupportedOptions,
|
||||
Many2OneField,
|
||||
} from "@web/views/fields/many2one/many2one_field";
|
||||
|
||||
export class KanbanMany2OneAvatarEmployeeField extends Component {
|
||||
static template = "hr.KanbanMany2OneAvatarEmployeeField";
|
||||
static components = { AvatarEmployee, KanbanMany2One };
|
||||
static props = {
|
||||
...Many2OneField.props,
|
||||
displayAvatarName: { type: Boolean, optional: true },
|
||||
relation: { type: String, optional: true },
|
||||
};
|
||||
|
||||
setup() {
|
||||
onWillStart(async () => {
|
||||
this.isHrUser = await user.hasGroup("hr.group_hr_user");
|
||||
});
|
||||
}
|
||||
|
||||
get displayName() {
|
||||
return this.props.displayAvatarName && this.value ? this.value.display_name : "";
|
||||
}
|
||||
|
||||
get m2oProps() {
|
||||
return {
|
||||
...computeM2OProps(this.props),
|
||||
canQuickCreate: false,
|
||||
relation: this.relation,
|
||||
};
|
||||
}
|
||||
|
||||
get relation() {
|
||||
return this.props.relation ?? (this.isHrUser ? "hr.employee" : "hr.employee.public");
|
||||
}
|
||||
|
||||
get value() {
|
||||
return this.props.record.data[this.props.name];
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {import("registries").FieldsRegistryItemShape} */
|
||||
const fieldDescr = {
|
||||
...buildM2OFieldDescription(KanbanMany2OneAvatarEmployeeField),
|
||||
additionalClasses: ["o_field_many2one_avatar_kanban", "o_field_many2one_avatar_user"],
|
||||
extractProps(staticInfo, dynamicInfo) {
|
||||
return {
|
||||
...extractM2OFieldProps(staticInfo, dynamicInfo),
|
||||
displayAvatarName: staticInfo.options.display_avatar_name || false,
|
||||
readonly: dynamicInfo.readonly,
|
||||
relation: staticInfo.options.relation,
|
||||
};
|
||||
},
|
||||
supportedOptions: [
|
||||
...m2oSupportedOptions,
|
||||
{
|
||||
label: _t("Display avatar name"),
|
||||
name: "display_avatar_name",
|
||||
type: "boolean",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
registry.category("fields").add("activity.many2one_avatar_employee", fieldDescr);
|
||||
registry.category("fields").add("kanban.many2one_avatar_employee", fieldDescr);
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="hr.KanbanMany2OneAvatarEmployeeField">
|
||||
<KanbanMany2One t-props="m2oProps">
|
||||
<t t-set-slot="avatar">
|
||||
<AvatarEmployee cssClass="'o_m2o_avatar'" resModel="relation" resId="value.id" noSpacing="false" displayName="displayName"/>
|
||||
</t>
|
||||
<t t-set-slot="autoCompleteItem" t-slot-scope="autoCompleteItemScope">
|
||||
<span class="o_avatar_many2x_autocomplete o_avatar d-flex align-items-center">
|
||||
<img class="rounded me-1" t-attf-src="/web/image/{{relation}}/{{autoCompleteItemScope.record.id}}/avatar_128?unique={{autoCompleteItemScope.record.data.write_date.ts}}"/>
|
||||
<span t-out="autoCompleteItemScope.label"/>
|
||||
</span>
|
||||
</t>
|
||||
</KanbanMany2One>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,55 @@
|
||||
import { AvatarEmployee } from "@hr/components/avatar_employee/avatar_employee";
|
||||
import { Component, onWillStart } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { user } from "@web/core/user";
|
||||
import { computeM2OProps, Many2One } from "@web/views/fields/many2one/many2one";
|
||||
import {
|
||||
buildM2OFieldDescription,
|
||||
extractM2OFieldProps,
|
||||
Many2OneField,
|
||||
} from "@web/views/fields/many2one/many2one_field";
|
||||
|
||||
export class Many2OneAvatarEmployeeField extends Component {
|
||||
static template = "hr.Many2OneAvatarEmployeeField";
|
||||
static components = { AvatarEmployee, Many2One };
|
||||
static props = {
|
||||
...Many2OneField.props,
|
||||
relation: { type: String, optional: true },
|
||||
};
|
||||
|
||||
setup() {
|
||||
onWillStart(async () => {
|
||||
this.isHrUser = await user.hasGroup("hr.group_hr_user");
|
||||
});
|
||||
}
|
||||
|
||||
get m2oProps() {
|
||||
return {
|
||||
...computeM2OProps(this.props),
|
||||
canQuickCreate: false,
|
||||
relation: this.relation,
|
||||
};
|
||||
}
|
||||
|
||||
get relation() {
|
||||
return this.props.relation ?? (this.isHrUser ? "hr.employee" : "hr.employee.public");
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("fields").add("many2one_avatar_employee", {
|
||||
...buildM2OFieldDescription(Many2OneAvatarEmployeeField),
|
||||
additionalClasses: [
|
||||
"o_field_many2one_avatar",
|
||||
"o_field_many2one_avatar_kanban",
|
||||
"o_field_many2one_avatar_user",
|
||||
],
|
||||
extractProps(staticInfo, dynamicInfo) {
|
||||
return {
|
||||
...extractM2OFieldProps(staticInfo, dynamicInfo),
|
||||
relation: staticInfo.options.relation,
|
||||
canOpen: "no_open" in staticInfo.options
|
||||
? !staticInfo.options.no_open
|
||||
: staticInfo.viewType === "form",
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="hr.Many2OneAvatarEmployeeField">
|
||||
<t t-set="value" t-value="props.record.data[props.name]"/>
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<t t-if="value !== false">
|
||||
<AvatarEmployee cssClass="'o_m2o_avatar'" resModel="relation" resId="value.id" noSpacing="true"/>
|
||||
</t>
|
||||
<Many2One t-props="m2oProps" cssClass="'w-100'">
|
||||
<t t-set-slot="autoCompleteItem" t-slot-scope="autoCompleteItemScope">
|
||||
<div class="o_avatar_many2x_autocomplete d-flex align-items-center">
|
||||
<AvatarEmployee resModel="relation" resId="autoCompleteItemScope.record.id" canOpenPopover="false"/>
|
||||
<span t-out="autoCompleteItemScope.label"/>
|
||||
</div>
|
||||
</t>
|
||||
</Many2One>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
24
frontend/hr/static/src/views/form_view.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
import { formView } from "@web/views/form/form_view";
|
||||
import { FormController } from "@web/views/form/form_controller";
|
||||
|
||||
import { useArchiveEmployee } from "@hr/views/archive_employee_hook";
|
||||
|
||||
export class EmployeeFormController extends FormController {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.archiveEmployee = useArchiveEmployee();
|
||||
}
|
||||
|
||||
getStaticActionMenuItems() {
|
||||
const menuItems = super.getStaticActionMenuItems();
|
||||
menuItems.archive.callback = this.archiveEmployee.bind(this, [this.model.root.resId]);
|
||||
return menuItems;
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("views").add("hr_employee_form", {
|
||||
...formView,
|
||||
Controller: EmployeeFormController,
|
||||
});
|
||||
29
frontend/hr/static/src/views/kanban_view.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
import { kanbanView } from "@web/views/kanban/kanban_view";
|
||||
import { KanbanController } from "@web/views/kanban/kanban_controller";
|
||||
|
||||
import { useArchiveEmployee } from "@hr/views/archive_employee_hook";
|
||||
|
||||
export class EmployeeKanbanController extends KanbanController {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.archiveEmployee = useArchiveEmployee();
|
||||
}
|
||||
|
||||
getStaticActionMenuItems() {
|
||||
const menuItems = super.getStaticActionMenuItems();
|
||||
const selectedRecords = this.model.root.selection;
|
||||
|
||||
menuItems.archive.callback = this.archiveEmployee.bind(
|
||||
this,
|
||||
selectedRecords.map(({ resId }) => resId)
|
||||
);
|
||||
return menuItems;
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("views").add("hr_employee_kanban", {
|
||||
...kanbanView,
|
||||
Controller: EmployeeKanbanController,
|
||||
});
|
||||
33
frontend/hr/static/src/views/list_view.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { registry } from '@web/core/registry';
|
||||
|
||||
import { listView } from '@web/views/list/list_view';
|
||||
import { ListController } from '@web/views/list/list_controller';
|
||||
|
||||
import { useArchiveEmployee } from '@hr/views/archive_employee_hook';
|
||||
|
||||
export class EmployeeListController extends ListController {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.archiveEmployee = useArchiveEmployee();
|
||||
}
|
||||
|
||||
getStaticActionMenuItems() {
|
||||
const menuItems = super.getStaticActionMenuItems();
|
||||
const selectedRecords = this.model.root.selection;
|
||||
|
||||
menuItems.archive.callback = this.archiveEmployee.bind(
|
||||
this,
|
||||
selectedRecords.map(({resId}) => resId),
|
||||
)
|
||||
return menuItems;
|
||||
}
|
||||
|
||||
async createRecord() {
|
||||
await this.props.createRecord();
|
||||
}
|
||||
}
|
||||
|
||||
registry.category('views').add('hr_employee_list', {
|
||||
...listView,
|
||||
Controller: EmployeeListController,
|
||||
});
|
||||
16
frontend/hr/static/src/views/open_chat_hook.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { helpers } from "@mail/core/web/open_chat_hook";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
patch(helpers, {
|
||||
SUPPORTED_M2X_AVATAR_MODELS: [
|
||||
...helpers.SUPPORTED_M2X_AVATAR_MODELS,
|
||||
"hr.employee",
|
||||
"hr.employee.public",
|
||||
],
|
||||
buildOpenChatParams(resModel, id) {
|
||||
if (["hr.employee", "hr.employee.public"].includes(resModel)) {
|
||||
return { employeeId: id };
|
||||
}
|
||||
return super.buildOpenChatParams(...arguments);
|
||||
}
|
||||
});
|
||||
28
frontend/hr/static/src/views/preferences_form_view.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { formView } from "@web/views/form/form_view";
|
||||
|
||||
export class HrUserPreferencesController extends formView.Controller {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.action = useService("action");
|
||||
this.mustReload = false;
|
||||
}
|
||||
|
||||
onWillSaveRecord(record, changes) {
|
||||
this.mustReload = "lang" in changes;
|
||||
}
|
||||
|
||||
async onRecordSaved(record) {
|
||||
await super.onRecordSaved(...arguments);
|
||||
if (this.mustReload) {
|
||||
this.mustReload = false;
|
||||
return this.action.doAction("reload_context");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("views").add("hr_user_preferences_form", {
|
||||
...formView,
|
||||
Controller: HrUserPreferencesController,
|
||||
});
|
||||
32
frontend/hr/static/tests/hr_test_helpers.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { HrDepartment } from "@hr/../tests/mock_server/mock_models/hr_department";
|
||||
import { HrEmployee } from "@hr/../tests/mock_server/mock_models/hr_employee";
|
||||
import { HrEmployeePublic } from "@hr/../tests/mock_server/mock_models/hr_employee_public";
|
||||
import { M2xAvatarEmployee } from "@hr/../tests/mock_server/mock_models/m2x_avatar_employee";
|
||||
import { mailModels } from "@mail/../tests/mail_test_helpers";
|
||||
import { defineModels } from "@web/../tests/web_test_helpers";
|
||||
import { FakeUser } from "@hr/../tests/mock_server/mock_models/fake_user";
|
||||
import { HrVersion } from "./mock_server/mock_models/hr_version";
|
||||
import { HrJob } from "./mock_server/mock_models/hr_job";
|
||||
import { HrWorkLocation } from "./mock_server/mock_models/hr_work_location";
|
||||
import { ResourceResource } from "@resource/../tests/mock_server/mock_models/resource_resource";
|
||||
import { ResUsers } from "./mock_server/mock_models/res_users";
|
||||
import { ResPartner } from "./mock_server/mock_models/res_partner";
|
||||
|
||||
export function defineHrModels() {
|
||||
return defineModels(hrModels);
|
||||
}
|
||||
|
||||
export const hrModels = {
|
||||
...mailModels,
|
||||
M2xAvatarEmployee,
|
||||
HrDepartment,
|
||||
HrEmployee,
|
||||
HrVersion,
|
||||
HrEmployeePublic,
|
||||
FakeUser,
|
||||
HrJob,
|
||||
HrWorkLocation,
|
||||
ResourceResource,
|
||||
ResUsers,
|
||||
ResPartner,
|
||||
};
|
||||
3
frontend/hr/static/tests/legacy/disable_patch.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import { unpatchAvatarCardPopover } from "@hr/components/avatar_card/avatar_card_popover_patch";
|
||||
|
||||
unpatchAvatarCardPopover();
|
||||
501
frontend/hr/static/tests/m2x_avatar_employee.test.js
Normal file
@@ -0,0 +1,501 @@
|
||||
import { defineHrModels } from "@hr/../tests/hr_test_helpers";
|
||||
import { start } from "@mail/../tests/mail_test_helpers";
|
||||
import { describe, expect, test } from "@odoo/hoot";
|
||||
import { waitFor } from "@odoo/hoot-dom";
|
||||
import { contains, makeMockServer, mountView, onRpc } from "@web/../tests/web_test_helpers";
|
||||
import { getOrigin } from "@web/core/utils/urls";
|
||||
|
||||
describe.current.tags("desktop");
|
||||
defineHrModels();
|
||||
|
||||
test("many2one in list view", async () => {
|
||||
const { env } = await makeMockServer();
|
||||
const [partnerId_1, partnerId_2] = env["res.partner"].create([
|
||||
{ name: "Mario" },
|
||||
{ name: "Luigi" },
|
||||
]);
|
||||
const [userId_1, userId_2] = env["res.users"].create([
|
||||
{ partner_id: partnerId_1 },
|
||||
{ partner_id: partnerId_2 },
|
||||
]);
|
||||
const [employeeId_1, employeeId_2] = env["hr.employee.public"].create([
|
||||
{
|
||||
name: "Mario",
|
||||
user_id: userId_1,
|
||||
user_partner_id: partnerId_1,
|
||||
work_email: "Mario@partner.com",
|
||||
},
|
||||
{
|
||||
name: "Luigi",
|
||||
user_id: userId_2,
|
||||
user_partner_id: partnerId_2,
|
||||
},
|
||||
]);
|
||||
env["m2x.avatar.employee"].create([
|
||||
{
|
||||
employee_id: employeeId_1,
|
||||
employee_ids: [employeeId_1, employeeId_2],
|
||||
},
|
||||
{ employee_id: employeeId_2 },
|
||||
{ employee_id: employeeId_1 },
|
||||
]);
|
||||
await start();
|
||||
onRpc("has_group", () => false);
|
||||
await mountView({
|
||||
type: "list",
|
||||
resModel: "m2x.avatar.employee",
|
||||
arch: `<list><field name="employee_id" widget="many2one_avatar_employee"/></list>`,
|
||||
});
|
||||
expect(".o_data_cell div[name='employee_id']:eq(0)").toHaveText("Mario");
|
||||
expect(".o_data_cell div[name='employee_id']:eq(1)").toHaveText("Luigi");
|
||||
expect(".o_data_cell div[name='employee_id']:eq(2)").toHaveText("Mario");
|
||||
expect("div[name='employee_id'] a").toHaveCount(0);
|
||||
|
||||
// click on first employee avatar
|
||||
await contains(".o_data_cell .o_m2o_avatar > img:eq(0)").click();
|
||||
await waitFor(".o_avatar_card");
|
||||
expect(".o_card_user_infos > span").toHaveText("Mario");
|
||||
expect(".o_card_user_infos > a").toHaveText("Mario@partner.com");
|
||||
expect(".o_avatar_card_buttons button:eq(0)").toHaveText("Send message");
|
||||
await contains(".o_avatar_card_buttons button:eq(0)").click();
|
||||
await waitFor(".o-mail-ChatWindow");
|
||||
await waitFor(".o-mail-ChatWindow-header:contains('Mario')");
|
||||
|
||||
// click on second employee
|
||||
await contains(".o_data_cell .o_m2o_avatar > img:eq(1)").click();
|
||||
expect(".o_card_user_infos span").toHaveText("Luigi");
|
||||
expect(".o_avatar_card").toHaveCount(1);
|
||||
expect(".o_avatar_card_buttons button:eq(0)").toHaveText("Send message");
|
||||
await contains(".o_avatar_card_buttons button:eq(0)").click();
|
||||
await waitFor(".o-mail-ChatWindow-header:contains('Luigi')");
|
||||
expect(".o-mail-ChatWindow").toHaveCount(2);
|
||||
|
||||
// click on third employee (same as first)
|
||||
await contains(".o_data_cell .o_m2o_avatar > img:eq(2)").click();
|
||||
expect(".o_card_user_infos span").toHaveText("Mario");
|
||||
expect(".o_avatar_card").toHaveCount(1);
|
||||
expect(".o_card_user_infos span:eq(0)").toHaveText("Mario");
|
||||
expect(".o_card_user_infos > a").toHaveText("Mario@partner.com");
|
||||
expect(".o_avatar_card_buttons button:eq(0)").toHaveText("Send message");
|
||||
await contains(".o_avatar_card_buttons button:eq(0)").click();
|
||||
await waitFor(".o-mail-ChatWindow-header:contains('Mario')");
|
||||
expect(".o-mail-ChatWindow").toHaveCount(2);
|
||||
});
|
||||
|
||||
test("many2one in kanban view", async () => {
|
||||
const { env } = await makeMockServer();
|
||||
const partnerId = env["res.partner"].create({});
|
||||
const userId = env["res.users"].create({ partner_id: partnerId });
|
||||
const employeeId = env["hr.employee.public"].create({
|
||||
user_id: userId,
|
||||
user_partner_id: partnerId,
|
||||
});
|
||||
env["m2x.avatar.employee"].create({
|
||||
employee_id: employeeId,
|
||||
employee_ids: [employeeId],
|
||||
});
|
||||
onRpc("has_group", () => false);
|
||||
await mountView({
|
||||
type: "kanban",
|
||||
resModel: "m2x.avatar.employee",
|
||||
arch: `<kanban>
|
||||
<templates>
|
||||
<t t-name="card">
|
||||
<field name="employee_id" widget="many2one_avatar_employee"/>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>`,
|
||||
});
|
||||
expect(".o_kanban_record:eq(0)").toHaveText("");
|
||||
await waitFor(".o_m2o_avatar");
|
||||
expect(".o_m2o_avatar > img:eq(0)").toHaveAttribute(
|
||||
"data-src",
|
||||
`/web/image/hr.employee.public/${employeeId}/avatar_128`
|
||||
);
|
||||
});
|
||||
|
||||
test("many2one: click on an employee not associated with a user", async () => {
|
||||
const { env } = await makeMockServer();
|
||||
const employeeId = env["hr.employee.public"].create({ name: "Mario" });
|
||||
const avatarId = env["m2x.avatar.employee"].create({ employee_id: employeeId });
|
||||
onRpc("has_group", () => false);
|
||||
await mountView({
|
||||
type: "form",
|
||||
resModel: "m2x.avatar.employee",
|
||||
resId: avatarId,
|
||||
arch: `<form><field name="employee_id" widget="many2one_avatar_employee"/></form>`,
|
||||
});
|
||||
await waitFor(".o_field_widget[name=employee_id] input:value(Mario)");
|
||||
await contains(".o_m2o_avatar > img").click();
|
||||
});
|
||||
|
||||
test("many2one with hr group widget in kanban view", async () => {
|
||||
const { env } = await makeMockServer();
|
||||
const partnerId = env["res.partner"].create({});
|
||||
const userId = env["res.users"].create({ partner_id: partnerId });
|
||||
const employeeId = env["hr.employee.public"].create({
|
||||
user_id: userId,
|
||||
user_partner_id: partnerId,
|
||||
});
|
||||
env["m2x.avatar.employee"].create({
|
||||
employee_id: employeeId,
|
||||
employee_ids: [employeeId],
|
||||
});
|
||||
await mountView({
|
||||
type: "kanban",
|
||||
resModel: "m2x.avatar.employee",
|
||||
arch: `<kanban>
|
||||
<templates>
|
||||
<t t-name="card">
|
||||
<field name="employee_id" widget="many2one_avatar_employee"/>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>`,
|
||||
});
|
||||
expect(".o_kanban_record:eq(0)").toHaveText("");
|
||||
await waitFor(".o_m2o_avatar");
|
||||
expect(".o_m2o_avatar > img:eq(0)").toHaveAttribute(
|
||||
"data-src",
|
||||
`/web/image/hr.employee/${employeeId}/avatar_128`
|
||||
);
|
||||
});
|
||||
|
||||
test("many2one with relation set in options", async () => {
|
||||
const { env } = await makeMockServer();
|
||||
const partnerId = env["res.partner"].create({});
|
||||
const userId = env["res.users"].create({ partner_id: partnerId });
|
||||
const employeeId = env["hr.employee.public"].create({
|
||||
user_id: userId,
|
||||
user_partner_id: partnerId,
|
||||
});
|
||||
env["m2x.avatar.employee"].create({
|
||||
employee_id: employeeId,
|
||||
employee_ids: [employeeId],
|
||||
});
|
||||
await mountView({
|
||||
type: "kanban",
|
||||
resModel: "m2x.avatar.employee",
|
||||
arch: `<kanban>
|
||||
<templates>
|
||||
<t t-name="card">
|
||||
<field name="employee_id" widget="many2one_avatar_employee" options="{'relation': 'hr.employee.public'}"/>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>`,
|
||||
});
|
||||
expect(".o_kanban_record:eq(0)").toHaveText("");
|
||||
await waitFor(".o_m2o_avatar");
|
||||
expect(".o_m2o_avatar > img:eq(0)").toHaveAttribute(
|
||||
"data-src",
|
||||
`/web/image/hr.employee.public/${employeeId}/avatar_128`
|
||||
);
|
||||
});
|
||||
|
||||
test("many2one without hr.group_hr_user", async () => {
|
||||
const { env } = await makeMockServer();
|
||||
env["m2x.avatar.employee"].create({});
|
||||
env["hr.employee"].create({ name: "babar" });
|
||||
env["hr.employee.public"].create({ name: "babar" });
|
||||
onRpc("web_name_search", (args) => {
|
||||
expect.step("web_name_search");
|
||||
expect(args.model).toBe("hr.employee.public");
|
||||
});
|
||||
onRpc("web_search_read", (args) => {
|
||||
expect.step("web_search_read");
|
||||
expect(args.model).toBe("hr.employee.public");
|
||||
});
|
||||
onRpc("has_group", () => false);
|
||||
await mountView({
|
||||
type: "form",
|
||||
resModel: "m2x.avatar.employee",
|
||||
arch: `<form><field name="employee_id" widget="many2one_avatar_employee"/></form>`,
|
||||
});
|
||||
|
||||
await waitFor(".o-autocomplete--input.o_input");
|
||||
await contains(".o-autocomplete--input.o_input").click();
|
||||
expect.verifySteps(["web_name_search"]);
|
||||
|
||||
await waitFor(".o_m2o_dropdown_option_search_more");
|
||||
await contains(".o_m2o_dropdown_option_search_more").click();
|
||||
expect.verifySteps(["web_search_read"]);
|
||||
});
|
||||
|
||||
test("many2one in form view", async () => {
|
||||
const { env } = await makeMockServer();
|
||||
const [partnerId_1, partnerId_2] = env["res.partner"].create([
|
||||
{ name: "Mario" },
|
||||
{ name: "Luigi" },
|
||||
]);
|
||||
const [userId_1, userId_2] = env["res.users"].create([
|
||||
{ partner_id: partnerId_1 },
|
||||
{ partner_id: partnerId_2 },
|
||||
]);
|
||||
const [employeeId_1, employeeId_2] = env["hr.employee.public"].create([
|
||||
{
|
||||
user_id: userId_1,
|
||||
user_partner_id: partnerId_1,
|
||||
name: "Mario",
|
||||
work_email: "Mario@partner.com",
|
||||
},
|
||||
{
|
||||
name: "Luigi",
|
||||
user_id: userId_2,
|
||||
user_partner_id: partnerId_2,
|
||||
},
|
||||
]);
|
||||
const avatarId_1 = env["m2x.avatar.employee"].create({
|
||||
employee_ids: [employeeId_1, employeeId_2],
|
||||
});
|
||||
await start();
|
||||
onRpc("has_group", () => false);
|
||||
await mountView({
|
||||
type: "form",
|
||||
resId: avatarId_1,
|
||||
resModel: "m2x.avatar.employee",
|
||||
arch: `<form><field name="employee_ids" widget="many2many_avatar_employee"/></form>`,
|
||||
});
|
||||
expect(".o_field_many2many_avatar_employee .o_tag").toHaveCount(2);
|
||||
expect(".o_field_many2many_avatar_employee .o_tag img:eq(0)").toHaveAttribute(
|
||||
"data-src",
|
||||
`${getOrigin()}/web/image/hr.employee.public/${employeeId_1}/avatar_128`
|
||||
);
|
||||
|
||||
// Clicking on first employee's avatar
|
||||
await contains(".o_field_many2many_avatar_employee .o_tag .o_m2m_avatar:eq(0)").click();
|
||||
await waitFor(".o_avatar_card");
|
||||
expect(".o_card_user_infos > span").toHaveText("Mario");
|
||||
expect(".o_card_user_infos > a").toHaveText("Mario@partner.com");
|
||||
expect(".o_avatar_card_buttons button:eq(0)").toHaveText("Send message");
|
||||
await contains(".o_avatar_card_buttons button:eq(0)").click();
|
||||
await waitFor(".o-mail-ChatWindow");
|
||||
await waitFor(".o-mail-ChatWindow-header:contains('Mario')");
|
||||
|
||||
// Clicking on second employee's avatar
|
||||
await contains(".o_field_many2many_avatar_employee .o_tag .o_m2m_avatar:eq(1)").click();
|
||||
expect(".o_card_user_infos span").toHaveText("Luigi");
|
||||
expect(".o_avatar_card").toHaveCount(1);
|
||||
expect(".o_avatar_card_buttons button:eq(0)").toHaveText("Send message");
|
||||
await contains(".o_avatar_card_buttons button:eq(0)").click();
|
||||
await waitFor(".o-mail-ChatWindow-header:contains('Luigi')");
|
||||
expect(".o-mail-ChatWindow").toHaveCount(2);
|
||||
});
|
||||
|
||||
test("many2one with hr group widget in form view", async () => {
|
||||
const { env } = await makeMockServer();
|
||||
const [partnerId_1, partnerId_2] = env["res.partner"].create([{}, {}]);
|
||||
const [userId_1, userId_2] = env["res.users"].create([
|
||||
{ partner_id: partnerId_1 },
|
||||
{ partner_id: partnerId_2 },
|
||||
]);
|
||||
const [employeeData_1, employeeData_2] = [
|
||||
{ user_id: userId_1, user_partner_id: partnerId_1 },
|
||||
{ user_id: userId_2, user_partner_id: partnerId_2 },
|
||||
];
|
||||
env["hr.employee"].create([{ ...employeeData_1 }, { ...employeeData_2 }]);
|
||||
const [employeeId_1, employeeId_2] = env["hr.employee.public"].create([
|
||||
{ ...employeeData_1 },
|
||||
{ ...employeeData_2 },
|
||||
]);
|
||||
const avatarId_1 = env["m2x.avatar.employee"].create({
|
||||
employee_ids: [employeeId_1, employeeId_2],
|
||||
});
|
||||
expect.step(`read hr.employee ${employeeId_1}`);
|
||||
expect.step(`read hr.employee ${employeeId_2}`);
|
||||
await mountView({
|
||||
type: "form",
|
||||
resId: avatarId_1,
|
||||
resModel: "m2x.avatar.employee",
|
||||
arch: `<form><field name="employee_ids" widget="many2many_avatar_employee"/></form>`,
|
||||
});
|
||||
expect(".o_field_many2many_avatar_employee .o_tag").toHaveCount(2);
|
||||
expect(".o_field_many2many_avatar_employee .o_tag img:eq(0)").toHaveAttribute(
|
||||
"data-src",
|
||||
`${getOrigin()}/web/image/hr.employee/${employeeId_1}/avatar_128`
|
||||
);
|
||||
await contains(".o_field_many2many_avatar_employee .o_tag .o_m2m_avatar:eq(0)").click();
|
||||
await contains(".o_field_many2many_avatar_employee .o_tag .o_m2m_avatar:eq(1)").click();
|
||||
expect.verifySteps([
|
||||
`read hr.employee ${employeeId_1}`,
|
||||
`read hr.employee ${employeeId_2}`,
|
||||
]);
|
||||
});
|
||||
|
||||
test("many2one widget in list view", async () => {
|
||||
const { env } = await makeMockServer();
|
||||
const [partnerId_1, partnerId_2] = env["res.partner"].create([
|
||||
{ name: "Mario" },
|
||||
{ name: "Yoshi" },
|
||||
]);
|
||||
const [userId_1, userId_2] = env["res.users"].create([
|
||||
{ partner_id: partnerId_1 },
|
||||
{ partner_id: partnerId_2 },
|
||||
]);
|
||||
const [employeeId_1, employeeId_2] = env["hr.employee.public"].create([
|
||||
{
|
||||
name: "Mario",
|
||||
user_id: userId_1,
|
||||
user_partner_id: partnerId_1,
|
||||
work_email: "Mario@partner.com",
|
||||
},
|
||||
{
|
||||
name: "Yoshi",
|
||||
user_id: userId_2,
|
||||
user_partner_id: partnerId_2,
|
||||
},
|
||||
]);
|
||||
env["m2x.avatar.employee"].create({
|
||||
employee_ids: [employeeId_1, employeeId_2],
|
||||
});
|
||||
onRpc("has_group", () => false);
|
||||
await start();
|
||||
await mountView({
|
||||
type: "list",
|
||||
resModel: "m2x.avatar.employee",
|
||||
arch: `<list><field name="employee_ids" widget="many2many_avatar_employee"/></list>`,
|
||||
});
|
||||
expect(".o_data_cell:first .o_field_many2many_avatar_employee > div > span").toHaveCount(2);
|
||||
|
||||
// Clicking on first employee's avatar
|
||||
await contains(".o_data_cell .o_m2m_avatar:eq(0)").click();
|
||||
await waitFor(".o_avatar_card");
|
||||
expect(".o_card_user_infos > span").toHaveText("Mario");
|
||||
expect(".o_card_user_infos > a").toHaveText("Mario@partner.com");
|
||||
expect(".o_avatar_card_buttons button:eq(0)").toHaveText("Send message");
|
||||
await contains(".o_avatar_card_buttons button:eq(0)").click();
|
||||
await waitFor(".o-mail-ChatWindow");
|
||||
await waitFor(".o-mail-ChatWindow-header:contains('Mario')");
|
||||
|
||||
// Clicking on second employee's avatar
|
||||
await contains(".o_data_cell .o_m2m_avatar:eq(1)").click();
|
||||
expect(".o_card_user_infos span").toHaveText("Yoshi");
|
||||
expect(".o_avatar_card").toHaveCount(1);
|
||||
expect(".o_avatar_card_buttons button:eq(0)").toHaveText("Send message");
|
||||
await contains(".o_avatar_card_buttons button:eq(0)").click();
|
||||
await waitFor(".o-mail-ChatWindow-header:contains('Yoshi')");
|
||||
});
|
||||
|
||||
test("many2many in kanban view", async () => {
|
||||
const { env } = await makeMockServer();
|
||||
const [partnerId_1, partnerId_2] = env["res.partner"].create([
|
||||
{ name: "Mario" },
|
||||
{ name: "Luigi" },
|
||||
]);
|
||||
const [userId_1, userId_2] = env["res.users"].create([
|
||||
{ partner_id: partnerId_1 },
|
||||
{ partner_id: partnerId_2 },
|
||||
]);
|
||||
const [employeeId_1, employeeId_2] = env["hr.employee.public"].create([
|
||||
{
|
||||
user_id: userId_1,
|
||||
user_partner_id: partnerId_1,
|
||||
name: "Mario",
|
||||
work_email: "Mario@partner.com",
|
||||
},
|
||||
{
|
||||
name: "Luigi",
|
||||
user_id: userId_2,
|
||||
user_partner_id: partnerId_2,
|
||||
},
|
||||
]);
|
||||
env["m2x.avatar.employee"].create({
|
||||
employee_ids: [employeeId_1, employeeId_2],
|
||||
});
|
||||
onRpc("has_group", () => false);
|
||||
await start();
|
||||
await mountView({
|
||||
type: "kanban",
|
||||
resModel: "m2x.avatar.employee",
|
||||
arch: `<kanban>
|
||||
<templates>
|
||||
<t t-name="card">
|
||||
<footer>
|
||||
<field name="employee_ids" widget="many2many_avatar_employee"/>
|
||||
</footer>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>`,
|
||||
});
|
||||
expect(
|
||||
".o_kanban_record:first .o_field_many2many_avatar_employee img.o_m2m_avatar"
|
||||
).toHaveCount(2);
|
||||
expect(
|
||||
".o_kanban_record .o_field_many2many_avatar_employee img.o_m2m_avatar:eq(0)"
|
||||
).toHaveAttribute(
|
||||
"data-src",
|
||||
`${getOrigin()}/web/image/hr.employee.public/${employeeId_2}/avatar_128`
|
||||
);
|
||||
expect(
|
||||
".o_kanban_record .o_field_many2many_avatar_employee img.o_m2m_avatar:eq(1)"
|
||||
).toHaveAttribute(
|
||||
"data-src",
|
||||
`${getOrigin()}/web/image/hr.employee.public/${employeeId_1}/avatar_128`
|
||||
);
|
||||
|
||||
// Clicking on first employee's avatar
|
||||
await contains(".o_kanban_record img.o_m2m_avatar:eq(1)").click();
|
||||
await waitFor(".o_avatar_card");
|
||||
expect(".o_card_user_infos > span").toHaveText("Mario");
|
||||
expect(".o_card_user_infos > a").toHaveText("Mario@partner.com");
|
||||
expect(".o_avatar_card_buttons button:eq(0)").toHaveText("Send message");
|
||||
await contains(".o_avatar_card_buttons button:eq(0)").click();
|
||||
await waitFor(".o-mail-ChatWindow");
|
||||
await waitFor(".o-mail-ChatWindow-header:contains('Mario')");
|
||||
|
||||
// Clicking on second employee's avatar
|
||||
await contains(".o_kanban_record img.o_m2m_avatar:eq(0)").click();
|
||||
expect(".o_card_user_infos span").toHaveText("Luigi");
|
||||
expect(".o_avatar_card").toHaveCount(1);
|
||||
expect(".o_avatar_card_buttons button:eq(0)").toHaveText("Send message");
|
||||
await contains(".o_avatar_card_buttons button:eq(0)").click();
|
||||
await waitFor(".o-mail-ChatWindow-header:contains('Luigi')");
|
||||
expect(".o-mail-ChatWindow").toHaveCount(2);
|
||||
});
|
||||
|
||||
test("many2many: click on an employee not associated with a user", async () => {
|
||||
const { env } = await makeMockServer();
|
||||
const partnerId = env["res.partner"].create({ name: "Luigi" });
|
||||
const userId = env["res.users"].create({ partner_id: partnerId });
|
||||
const [employeeId_1, employeeId_2] = env["hr.employee.public"].create([
|
||||
{
|
||||
name: "Mario",
|
||||
work_email: "Mario@partner.com",
|
||||
},
|
||||
{
|
||||
name: "Luigi",
|
||||
user_id: userId,
|
||||
user_partner_id: partnerId,
|
||||
},
|
||||
]);
|
||||
const avatarId = env["m2x.avatar.employee"].create({
|
||||
employee_ids: [employeeId_1, employeeId_2],
|
||||
});
|
||||
onRpc("has_group", () => false);
|
||||
await start();
|
||||
await mountView({
|
||||
type: "form",
|
||||
resModel: "m2x.avatar.employee",
|
||||
resId: avatarId,
|
||||
arch: `<form><field name="employee_ids" widget="many2many_avatar_employee"/></form>`,
|
||||
});
|
||||
expect(".o_field_many2many_avatar_employee .o_tag").toHaveCount(2);
|
||||
expect(".o_field_many2many_avatar_employee .o_tag img:eq(0)").toHaveAttribute(
|
||||
"data-src",
|
||||
`${getOrigin()}/web/image/hr.employee.public/${employeeId_1}/avatar_128`
|
||||
);
|
||||
|
||||
// Clicking on first employee's avatar (employee with no user)
|
||||
await contains(".o_field_many2many_avatar_employee .o_tag .o_m2m_avatar:eq(0)").click();
|
||||
await waitFor(".o_avatar_card");
|
||||
expect(".o_card_user_infos > span").toHaveText("Mario");
|
||||
expect(".o_card_user_infos > a").toHaveText("Mario@partner.com");
|
||||
expect(".o_avatar_card_buttons button:eq(0)").toHaveText("View Profile");
|
||||
|
||||
// Clicking on second employee's avatar (employee with user)
|
||||
await contains(".o_field_many2many_avatar_employee .o_tag .o_m2m_avatar:eq(1)").click();
|
||||
expect(".o_card_user_infos span").toHaveText("Luigi");
|
||||
expect(".o_avatar_card").toHaveCount(1);
|
||||
expect(".o_avatar_card_buttons button:eq(0)").toHaveText("Send message");
|
||||
await contains(".o_avatar_card_buttons button:eq(0)").click();
|
||||
await waitFor(".o-mail-ChatWindow-header:contains('Luigi')");
|
||||
expect(".o-mail-ChatWindow").toHaveCount(1);
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { fields, models } from "@web/../tests/web_test_helpers";
|
||||
|
||||
export class FakeUser extends models.Model {
|
||||
_name = "fake.user";
|
||||
|
||||
name = fields.Char({ string: "Name" });
|
||||
lang = fields.Char({ string: "Lang" });
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { models, fields } from "@web/../tests/web_test_helpers";
|
||||
|
||||
export class HrDepartment extends models.ServerModel {
|
||||
_name = "hr.department";
|
||||
_rec_name = "complete_name";
|
||||
|
||||
name = fields.Char();
|
||||
complete_name = fields.Char({
|
||||
compute: "_compute_complete_name",
|
||||
});
|
||||
display_name = fields.Char({
|
||||
compute: "_compute_display_name",
|
||||
});
|
||||
|
||||
_compute_complete_name() {
|
||||
for (const department of this) {
|
||||
department.complete_name = department.name;
|
||||
}
|
||||
}
|
||||
|
||||
_compute_display_name() {
|
||||
this._compute_complete_name();
|
||||
for (const department of this) {
|
||||
department.display_name = department.complete_name;
|
||||
}
|
||||
}
|
||||
|
||||
get _to_store_defaults() {
|
||||
return ["name"];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { fields, models } from "@web/../tests/web_test_helpers";
|
||||
import { mailDataHelpers } from "@mail/../tests/mock_server/mail_mock_server";
|
||||
|
||||
export class HrEmployee extends models.ServerModel {
|
||||
_name = "hr.employee";
|
||||
|
||||
department_id = fields.Many2one({ relation: "hr.department" });
|
||||
work_email = fields.Char();
|
||||
work_phone = fields.Char();
|
||||
work_location_type = fields.Char();
|
||||
work_location_id = fields.Many2one({ relation: "hr.work.location" });
|
||||
job_title = fields.Char();
|
||||
|
||||
_get_store_avatar_card_fields() {
|
||||
return [
|
||||
"company_id",
|
||||
mailDataHelpers.Store.one("department_id", ["name"]),
|
||||
"work_email",
|
||||
mailDataHelpers.Store.one("work_location_id", ["location_type", "name"]),
|
||||
"work_phone",
|
||||
"job_title",
|
||||
];
|
||||
}
|
||||
|
||||
_views = {
|
||||
search: `<search><field name="display_name" string="Name" /></search>`,
|
||||
list: `<list><field name="display_name"/></list>`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { models } from "@web/../tests/web_test_helpers";
|
||||
|
||||
export class HrEmployeePublic extends models.ServerModel {
|
||||
_name = "hr.employee.public";
|
||||
|
||||
_views = {
|
||||
search: `<search><field name="display_name" string="Name" /></search>`,
|
||||
list: `<list><field name="display_name"/></list>`,
|
||||
};
|
||||
}
|
||||
10
frontend/hr/static/tests/mock_server/mock_models/hr_job.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { models } from "@web/../tests/web_test_helpers";
|
||||
|
||||
export class HrJob extends models.ServerModel {
|
||||
_name = "hr.job";
|
||||
|
||||
_views = {
|
||||
search: `<search><field name="display_name" string="Name" /></search>`,
|
||||
list: `<list><field name="display_name"/></list>`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { models } from "@web/../tests/web_test_helpers";
|
||||
|
||||
export class HrVersion extends models.ServerModel {
|
||||
_name = "hr.version";
|
||||
|
||||
_views = {
|
||||
search: `<search><field name="display_name" string="Name" /></search>`,
|
||||
list: `<list><field name="display_name"/></list>`,
|
||||
};
|
||||
}
|
||||