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>
This commit is contained in:
Marc
2026-03-31 23:09:12 +02:00
parent 0ed29fe2fd
commit 8741282322
2933 changed files with 280644 additions and 264 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@@ -0,0 +1,5 @@
declare module "models" {
export interface Store {
employees: {[key: number]: {id: number, user_id: number, hasCheckedUser: boolean}};
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View 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();

View 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();

View 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();

View File

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

View 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]
);
},
});
},
});

View 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,
});

View File

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View 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

View 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

View 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

View 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

View 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

View 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

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

View File

@@ -0,0 +1,3 @@
input#hr_presence_control_email_amount {
max-width: 5rem;
}

View File

@@ -0,0 +1,6 @@
.o_form_view.o_res_users_form_view_full {
.o_contact_image_large img {
width: 155px;
height: 155px;
}
}

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

View 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();
},
}
);
};
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",
};
},
});

View File

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

View 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,
});

View 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,
});

View 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,
});

View 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);
}
});

View 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,
});

View 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,
};

View File

@@ -0,0 +1,3 @@
import { unpatchAvatarCardPopover } from "@hr/components/avatar_card/avatar_card_popover_patch";
unpatchAvatarCardPopover();

View 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);
});

View File

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

View File

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

View File

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

View File

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

View 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>`,
};
}

View File

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

Some files were not shown because too many files have changed in this diff Show More