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