mirror of
https://github.com/bringout/oca-ocb-hr.git
synced 2026-04-23 16:32:01 +02:00
19.0 vanilla
This commit is contained in:
parent
a1137a1456
commit
e1d89e11e3
2789 changed files with 1093187 additions and 605897 deletions
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
|
|
@ -0,0 +1 @@
|
|||
<svg width="50" height="50" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg"><path d="M0 12.5h50V38a4 4 0 0 1-4 4H0V12.5Z" fill="#985184"/><path d="M4 20.5a4 4 0 0 1-4-4V12a4 4 0 0 1 4-4h46v8.5a4 4 0 0 1-4 4H4Z" fill="#2EBCFA"/><path d="M0 16h50v4a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4v-4Z" fill="#144496"/><path d="M25 28a9 9 0 0 0-9 9h10a9 9 0 0 0 9-9H25Z" fill="#fff"/><circle cx="25" cy="19" r="6" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 420 B |
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
|
|
@ -0,0 +1,46 @@
|
|||
import { registry } from "@web/core/registry";
|
||||
import {
|
||||
Many2ManyTagsAvatarEmployeeField,
|
||||
many2ManyTagsAvatarEmployeeField,
|
||||
} from "@hr/views/fields/many2many_avatar_employee_field/many2many_avatar_employee_field";
|
||||
import { Many2ManyAvatarUserTagsList } from "@mail/views/web/fields/many2many_avatar_user_field/many2many_avatar_user_field";
|
||||
|
||||
export class Many2ManyAvatarUserTagsListError extends Many2ManyAvatarUserTagsList {
|
||||
static template = "hr_work_entry.Many2ManyAvatarUserTagsListError";
|
||||
}
|
||||
|
||||
export class Many2ManyTagsAvatarEmployeeErrorField extends Many2ManyTagsAvatarEmployeeField {
|
||||
static props = {
|
||||
...Many2ManyTagsAvatarEmployeeField.props,
|
||||
inErrorField: { type: String, optional: true },
|
||||
};
|
||||
static components = {
|
||||
...Many2ManyTagsAvatarEmployeeField.components,
|
||||
TagsList: Many2ManyAvatarUserTagsListError,
|
||||
};
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
getTagProps(record) {
|
||||
return {
|
||||
...super.getTagProps(record),
|
||||
inError: this.props.record.data[this.props.inErrorField].currentIds.includes(
|
||||
record.resId
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const many2ManyTagsAvatarEmployeeErrorField = {
|
||||
...many2ManyTagsAvatarEmployeeField,
|
||||
component: Many2ManyTagsAvatarEmployeeErrorField,
|
||||
extractProps: (fieldInfo, dynamicInfo) => ({
|
||||
...many2ManyTagsAvatarEmployeeField.extractProps(fieldInfo, dynamicInfo),
|
||||
inErrorField: fieldInfo.attrs.in_error,
|
||||
}),
|
||||
};
|
||||
|
||||
registry
|
||||
.category("fields")
|
||||
.add("many2many_avatar_employee_class", many2ManyTagsAvatarEmployeeErrorField);
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<template>
|
||||
<t t-name="hr_work_entry.Many2ManyAvatarUserTagsListError" t-inherit="mail.Many2ManyAvatarUserTagsList" t-inherit-mode="primary">
|
||||
<xpath expr="//span[hasclass('o_tag')]" position="attributes">
|
||||
<attribute name="t-attf-class" separator=" " add="#{tag.inError ? 'text-danger' : ''}"/>
|
||||
</xpath>
|
||||
<div t-if="props.displayText" position="after">
|
||||
<i t-if="tag.inError" class="ms-1 fa fa-lock z-1"/>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { registry } from "@web/core/registry";
|
||||
import { RadioField, radioField } from "@web/views/fields/radio/radio_field";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
|
||||
export class WorkEntrySourceField extends RadioField {
|
||||
static template = "hr_work_entry.WorkEntrySourceField";
|
||||
|
||||
get isFullyFlexible() {
|
||||
return !this.props.record.data.resource_calendar_id;
|
||||
}
|
||||
|
||||
get tooltipWarning() {
|
||||
return JSON.stringify({
|
||||
"text" : _t("Invalid option: For fully flexible calendars, the work entry source cannot be 'Working Hours'."),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const workEntrySourceField = {
|
||||
...radioField,
|
||||
component: WorkEntrySourceField,
|
||||
fieldDependencies: [
|
||||
{ name: "resource_calendar_id", type: "many2one", relation: "resource.calendar" },
|
||||
],
|
||||
};
|
||||
|
||||
registry.category("fields").add("work_entry_source_field", workEntrySourceField);
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="hr_work_entry.WorkEntrySourceField" t-inherit="web.RadioField">
|
||||
<xpath expr="//input[hasclass('o_radio_input')]" position="attributes">
|
||||
<attribute name="t-att-disabled">props.readonly or (item[0] === 'calendar' and isFullyFlexible)</attribute>
|
||||
</xpath>
|
||||
|
||||
<xpath expr="//div[hasclass('o_radio_item')]" position="inside">
|
||||
<span t-if="props.readonly or (item[0] === 'calendar' and item[0] === value and isFullyFlexible)"
|
||||
class="fa fa-exclamation-triangle text-danger o_work_entry_source_warning ms-3"
|
||||
data-tooltip-template="hr_work_entry.WorkEntrySourceWarning"
|
||||
t-att-data-tooltip-info='tooltipWarning'/>
|
||||
</xpath>
|
||||
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import { registry } from "@web/core/registry";
|
||||
import { buildM2OFieldDescription, Many2OneField } from "@web/views/fields/many2one/many2one_field";
|
||||
import { Component, onWillRender, onWillUpdateProps, useState } from "@odoo/owl";
|
||||
import { Many2One } from "@web/views/fields/many2one/many2one";
|
||||
|
||||
export class WorkEntryType extends Component {
|
||||
static template = "hr_work_entry.WorkEntryType";
|
||||
static props = {
|
||||
data: Object,
|
||||
className: { type: String, optional: true },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.state = useState({ data: this.props.data });
|
||||
onWillUpdateProps((nextProps) => {
|
||||
this.state.data = nextProps.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function extractData(record) {
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
let name;
|
||||
if ("display_name" in record) {
|
||||
name = record.display_name;
|
||||
} else if ("name" in record) {
|
||||
name = record.name.id ? record.name.display_name : record.name;
|
||||
}
|
||||
return {
|
||||
id: record.id,
|
||||
display_name: name,
|
||||
display_code: record.display_code,
|
||||
color: record.color,
|
||||
};
|
||||
}
|
||||
|
||||
export class WorkEntryTypeMany2One extends Many2One {
|
||||
get many2XAutocompleteProps() {
|
||||
const props = super.many2XAutocompleteProps;
|
||||
return {
|
||||
...props,
|
||||
update: (records) => {
|
||||
const idNamePair = records && extractData(records[0]) ? records[0] : false;
|
||||
this.update(idNamePair);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Many2OneWorkEntryTypeField extends Many2OneField {
|
||||
static template = "hr_work_entry.Many2OneWorkEntryTypeField";
|
||||
static components = {
|
||||
...Many2OneField.components,
|
||||
Many2One: WorkEntryTypeMany2One,
|
||||
WorkEntryType,
|
||||
};
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
this.state = useState({ data: this.props.record.data });
|
||||
onWillRender(() => {
|
||||
if (this.props.record.data?.work_entry_type_id.color) {
|
||||
this.state.data = this.props.record.data.work_entry_type_id;
|
||||
} else {
|
||||
this.state.data = this.props.record.data;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get m2oProps() {
|
||||
const props = super.m2oProps;
|
||||
return {
|
||||
...props,
|
||||
specification: { display_code: 1, color: 1 },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const many2OneWorkEntryTypeField = {
|
||||
...buildM2OFieldDescription(Many2OneWorkEntryTypeField),
|
||||
fieldDependencies: [
|
||||
{ name: "display_code", type: "char" },
|
||||
{ name: "color", type: "char" },
|
||||
],
|
||||
};
|
||||
|
||||
registry.category("fields").add("many2one_work_entry_type", many2OneWorkEntryTypeField);
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="hr_work_entry.Many2OneWorkEntryTypeField">
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<t t-if="m2oProps.value !== false">
|
||||
<WorkEntryType data="state.data"/>
|
||||
</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">
|
||||
<WorkEntryType data="autoCompleteItemScope.record" className="'me-1'"/>
|
||||
<t t-out="autoCompleteItemScope.label"/>
|
||||
</div>
|
||||
</t>
|
||||
</Many2One>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="hr_work_entry.WorkEntryType">
|
||||
<div class="o_calendar_renderer o_radio_item position-relative cursor-pointer d-flex gap-1 align-items-center"
|
||||
t-att-class="props.className"
|
||||
style="background-color: inherit;">
|
||||
<span class="small rounded-3 d-flex justify-content-center" t-esc="state.data?.display_code || ' '"
|
||||
t-attf-class="o_calendar_color_#{state.data?.color || 0}"
|
||||
style="background-color: rgb(var(--o-event-bg--subtle-rgb)); width: 5ch; height:23px; padding: 2px;"/>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
.o_form_view {
|
||||
.o_work_entry_source_warning {
|
||||
cursor: help;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import { CalendarCommonPopover } from "@web/views/calendar/calendar_common/calendar_common_popover";
|
||||
|
||||
export class WorkEntryCalendarCommonPopover extends CalendarCommonPopover {
|
||||
static subTemplates = {
|
||||
...CalendarCommonPopover.subTemplates,
|
||||
footer: "hr_work_entry.WorkEntryCalendarCommonPopover.footer",
|
||||
};
|
||||
static props = {
|
||||
...CalendarCommonPopover.props,
|
||||
splitRecord: Function,
|
||||
};
|
||||
|
||||
get isWorkEntryValidated() {
|
||||
return this.props.record.rawRecord.state === "validated";
|
||||
}
|
||||
|
||||
get isSplittable() {
|
||||
return this.props.record.rawRecord.duration >= 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get isEventEditable() {
|
||||
return !this.isWorkEntryValidated && super.isEventEditable;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get isEventDeletable() {
|
||||
return !this.isWorkEntryValidated && super.isEventDeletable;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get isEventViewable() {
|
||||
return !this.isWorkEntryValidated && super.isEventViewable;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get hasFooter() {
|
||||
return this.isWorkEntryValidated || super.hasFooter;
|
||||
}
|
||||
|
||||
onSplitEvent() {
|
||||
this.props.splitRecord(this.props.record);
|
||||
this.props.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<template>
|
||||
<t t-name="hr_work_entry.WorkEntryCalendarCommonPopover.footer" t-inherit="web.CalendarCommonPopover.footer" t-inherit-mode="primary">
|
||||
<xpath expr="." position="inside">
|
||||
<span t-if="isWorkEntryValidated"><i class="fa fa-lock me-1"/> You cannot edit a validated work entry</span>
|
||||
</xpath>
|
||||
<a t-on-click="onEditEvent" position="after">
|
||||
<a href="#" class="btn btn-secondary" t-if="isSplittable" t-on-click="onSplitEvent">
|
||||
Split
|
||||
</a>
|
||||
</a>
|
||||
<a t-on-click="onDeleteEvent" position="attributes">
|
||||
<attribute name="class" add="ms-auto btn-danger" remove="btn-secondary" separator=" "/>
|
||||
</a>
|
||||
</t>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import { convertRecordToEvent } from "@web/views/calendar/utils";
|
||||
import { CalendarCommonRenderer } from "@web/views/calendar/calendar_common/calendar_common_renderer";
|
||||
import { WorkEntryCalendarCommonPopover } from "@hr_work_entry/views/work_entry_calendar/calendar_common/work_entry_calendar_common_popover";
|
||||
|
||||
export class WorkEntryCalendarCommonRenderer extends CalendarCommonRenderer {
|
||||
static eventTemplate = "hr_work_entry.WorkEntryCalendarCommonRenderer.event";
|
||||
static components = {
|
||||
...CalendarCommonRenderer,
|
||||
Popover: WorkEntryCalendarCommonPopover,
|
||||
};
|
||||
static props = {
|
||||
...CalendarCommonRenderer.props,
|
||||
splitRecord: Function,
|
||||
};
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
convertRecordToEvent(record) {
|
||||
const event = convertRecordToEvent(record);
|
||||
const editable = record.rawRecord.state !== "validated" && (event.editable ?? null);
|
||||
return {
|
||||
...event,
|
||||
...(editable ? { editable: editable } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
getPopoverProps(record) {
|
||||
return {
|
||||
...super.getPopoverProps(record),
|
||||
splitRecord: this.props.splitRecord,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
|
||||
<templates>
|
||||
<t t-name="hr_work_entry.WorkEntryCalendarCommonRenderer.event" t-inherit="web.CalendarCommonRenderer.event" t-inherit-mode="primary">
|
||||
<div t-out="title" position="after">
|
||||
<i t-if="rawRecord.state === 'validated'" class="fa fa-lock position-absolute top-0 end-0" style="font-size: x-small"/>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
.o_calendar_header {
|
||||
@for $index from 1 through length($o-colors-complete) {
|
||||
$color: nth($o-colors-complete, $index);
|
||||
$color-subtle: mix($o-white, $color, 55%);
|
||||
$color-hover: mix($o-white, $color, 45%);
|
||||
|
||||
.o_gantt_color_#{$index - 1} {
|
||||
color: color-contrast($color-subtle);
|
||||
background-color: $color-subtle;
|
||||
border-color: $color-subtle;
|
||||
}
|
||||
|
||||
.btn.o_gantt_color_#{$index - 1}:hover {
|
||||
color: color-contrast($color-hover);
|
||||
background-color: $color-hover;
|
||||
border-color: $color-hover;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<templates>
|
||||
<t t-name="hr_work_entry.calendar.controlButtons" t-inherit="web.CalendarController.controlButtons" t-inherit-mode="primary">
|
||||
<xpath expr="." position="inside">
|
||||
<button class="btn btn-secondary" t-on-click="onRegenerateWorkEntries">
|
||||
Reset
|
||||
</button>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
import { WorkEntryCalendarMultiSelectionButtons } from "@hr_work_entry/views/work_entry_calendar/work_entry_multi_selection_buttons";
|
||||
import { useWorkEntry } from "@hr_work_entry/views/work_entry_hook";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { CalendarController } from "@web/views/calendar/calendar_controller";
|
||||
import { FormViewDialog } from "@web/views/view_dialogs/form_view_dialog";
|
||||
|
||||
|
||||
export class WorkEntryCalendarController extends CalendarController {
|
||||
static components = {
|
||||
...CalendarController.components,
|
||||
MultiSelectionButtons: WorkEntryCalendarMultiSelectionButtons,
|
||||
};
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
const { onRegenerateWorkEntries } = useWorkEntry({
|
||||
getEmployeeIds: this.getEmployeeIds.bind(this),
|
||||
getRange: this.model.computeRange.bind(this.model),
|
||||
onClose: this.model.load.bind(this.model),
|
||||
});
|
||||
this.onRegenerateWorkEntries = onRegenerateWorkEntries;
|
||||
this.dialogService = useService("dialog");
|
||||
}
|
||||
|
||||
async splitRecord(record) {
|
||||
this.dialogService.add(
|
||||
FormViewDialog,
|
||||
{
|
||||
title: _t("Split Work Entry"),
|
||||
resModel: "hr.work.entry",
|
||||
onRecordSave: async (_record) => {
|
||||
await this.orm.call("hr.work.entry", "action_split", [
|
||||
record.id,
|
||||
{
|
||||
duration: _record.data.duration,
|
||||
work_entry_type_id: _record.data.work_entry_type_id.id,
|
||||
name: _record.data.name,
|
||||
},
|
||||
]);
|
||||
return true;
|
||||
},
|
||||
context: {
|
||||
form_view_ref: "hr_work_entry.hr_work_entry_calendar_gantt_view_form",
|
||||
default_duration: record.rawRecord.duration / 2,
|
||||
default_name: record.rawRecord.name,
|
||||
default_work_entry_type_id: record.rawRecord.work_entry_type_id?.[0],
|
||||
default_employee_id: record.rawRecord.employee_id?.[0],
|
||||
default_date: record.rawRecord.date,
|
||||
},
|
||||
canExpand: false,
|
||||
},
|
||||
{
|
||||
onClose: () => {
|
||||
this.model.load();
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get rendererProps() {
|
||||
return {
|
||||
...super.rendererProps,
|
||||
splitRecord: this.splitRecord.bind(this),
|
||||
};
|
||||
}
|
||||
|
||||
getEmployeeIds() {
|
||||
return [
|
||||
...new Set(
|
||||
Object.values(this.model.records).map((rec) => rec.rawRecord.employee_id[0])
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
getSelectedRecords(selectedCells) {
|
||||
const ids = this.getSelectedRecordIds(selectedCells);
|
||||
return Object.values(this.model.records)
|
||||
.filter((r) => ids.includes(r.id))
|
||||
.map((r) => r.rawRecord);
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
prepareMultiSelectionButtonsReactive() {
|
||||
const result = super.prepareMultiSelectionButtonsReactive();
|
||||
result.userFavoritesWorkEntries = this.model.userFavoritesWorkEntries || [];
|
||||
result.onQuickReplace = (values) => this.onMultiReplace(values, this.selectedCells);
|
||||
result.onQuickReset = () => this.onResetWorkEntries(this.selectedCells);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
updateMultiSelection() {
|
||||
super.updateMultiSelection(...arguments);
|
||||
this.multiSelectionButtonsReactive.userFavoritesWorkEntries = this.model.userFavoritesWorkEntries || [];
|
||||
}
|
||||
|
||||
getDatesWithoutValidatedWorkEntry(selectedCells, records) {
|
||||
return this.getDates(selectedCells).filter(
|
||||
(d) =>
|
||||
!records
|
||||
.filter((r) => r.state === "validated")
|
||||
.map((r) => r.date)
|
||||
.includes(d.toISODate())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
onMultiDelete(selectedCells) {
|
||||
const records = this.getSelectedRecords(selectedCells);
|
||||
return this.model.unlinkRecords(
|
||||
records.filter((r) => r.state !== "validated").map((r) => r.id)
|
||||
);
|
||||
}
|
||||
|
||||
onMultiReplace(values, selectedCells) {
|
||||
const records = this.getSelectedRecords(selectedCells);
|
||||
const dates = this.getDatesWithoutValidatedWorkEntry(selectedCells, records);
|
||||
return this.model.multiReplaceRecords(
|
||||
values,
|
||||
dates,
|
||||
records.filter((r) => r.state !== "validated")
|
||||
);
|
||||
}
|
||||
|
||||
onResetWorkEntries(selectedCells) {
|
||||
const records = this.getSelectedRecords(selectedCells);
|
||||
const dates = this.getDatesWithoutValidatedWorkEntry(selectedCells, records);
|
||||
this.model.resetWorkEntries(
|
||||
dates,
|
||||
records.filter((r) => r.state !== "validated").map((r) => r.id)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
import { serializeDate } from "@web/core/l10n/dates";
|
||||
import { user } from "@web/core/user";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { CalendarModel } from "@web/views/calendar/calendar_model";
|
||||
|
||||
const { DateTime } = luxon;
|
||||
|
||||
export class WorkEntryCalendarModel extends CalendarModel {
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
this.orm = useService("orm");
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
async updateData(data) {
|
||||
const { start, end } = this.computeRange();
|
||||
await this.orm.call("hr.employee", "generate_work_entries", [
|
||||
[this.meta.context.default_employee_id],
|
||||
serializeDate(start),
|
||||
serializeDate(end),
|
||||
]);
|
||||
await Promise.all([
|
||||
super.updateData(...arguments),
|
||||
this._fetchUserFavoritesWorkEntries(),
|
||||
]);
|
||||
}
|
||||
|
||||
async _fetchUserFavoritesWorkEntries() {
|
||||
const userFavoritesWorkEntriesIds = await this.orm.formattedReadGroup(
|
||||
"hr.work.entry",
|
||||
[
|
||||
["create_uid", "=", user.userId],
|
||||
["create_date", ">", serializeDate(DateTime.local().minus({ months: 3 }))],
|
||||
],
|
||||
["work_entry_type_id", "create_date:day"],
|
||||
[],
|
||||
{
|
||||
order: "create_date:day desc",
|
||||
limit: 6,
|
||||
}
|
||||
);
|
||||
if (userFavoritesWorkEntriesIds.length) {
|
||||
this.userFavoritesWorkEntries = await this.orm.read(
|
||||
"hr.work.entry.type",
|
||||
userFavoritesWorkEntriesIds.map((r) => r.work_entry_type_id?.[0]).filter(Boolean),
|
||||
["display_name", "display_code", "color"]
|
||||
);
|
||||
this.userFavoritesWorkEntries = this.userFavoritesWorkEntries.sort((a, b) =>
|
||||
a.display_code
|
||||
? a.display_code.localeCompare(b.display_code)
|
||||
: a.display_name.localeCompare(b.display_name)
|
||||
);
|
||||
} else {
|
||||
this.userFavoritesWorkEntries = [];
|
||||
}
|
||||
}
|
||||
|
||||
async multiReplaceRecords(values, dates, records) {
|
||||
if (!dates.length) {
|
||||
return;
|
||||
}
|
||||
const new_records = [];
|
||||
const quickreplace = (values.duration < 0);
|
||||
const newly_generated_entries = [];
|
||||
for (const date of dates) {
|
||||
const rawRecord = this.buildRawRecord({ start: date });
|
||||
if (quickreplace) {
|
||||
const selected_date_records = records.filter((r) => r.date === rawRecord.date);
|
||||
const existing_duration = selected_date_records.reduce((acc, r) => acc + r.duration, 0);
|
||||
if (existing_duration > 0)
|
||||
values.duration = existing_duration;
|
||||
else {
|
||||
const generated_work_entry = await this.orm.call(
|
||||
"hr.employee",
|
||||
"generate_work_entries",
|
||||
[values.employee_id, date, date, true]
|
||||
);
|
||||
if (generated_work_entry.length > 0)
|
||||
newly_generated_entries.push(generated_work_entry[0]);
|
||||
continue
|
||||
}
|
||||
}
|
||||
new_records.push({
|
||||
...rawRecord,
|
||||
...values,
|
||||
});
|
||||
}
|
||||
await this.orm.write("hr.work.entry", newly_generated_entries, {
|
||||
work_entry_type_id: values.work_entry_type_id
|
||||
});
|
||||
const created = await this.orm.create(this.meta.resModel, new_records, {
|
||||
context: this.meta.context,
|
||||
});
|
||||
if (records.length && created) {
|
||||
await this.orm.unlink(this.meta.resModel, records.map((r) => r.id));
|
||||
}
|
||||
return this.load();
|
||||
}
|
||||
|
||||
async resetWorkEntries(dates, recordIds) {
|
||||
const cellsFormattedData = dates.map((date) => ({
|
||||
date,
|
||||
employee_id: this.meta.context.default_employee_id,
|
||||
}));
|
||||
await this.orm.call("hr.work.entry.regeneration.wizard", "regenerate_work_entries", [
|
||||
[],
|
||||
cellsFormattedData,
|
||||
recordIds,
|
||||
]);
|
||||
return this.load();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { CalendarRenderer } from "@web/views/calendar/calendar_renderer";
|
||||
import { WorkEntryCalendarCommonRenderer } from "@hr_work_entry/views/work_entry_calendar/calendar_common/work_entry_calendar_common_renderer";
|
||||
|
||||
export class WorkEntryCalendarRenderer extends CalendarRenderer {
|
||||
static components = {
|
||||
...CalendarRenderer.components,
|
||||
month: WorkEntryCalendarCommonRenderer,
|
||||
};
|
||||
static props = {
|
||||
...CalendarRenderer.props,
|
||||
splitRecord: Function,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { calendarView } from "@web/views/calendar/calendar_view";
|
||||
import { WorkEntryCalendarRenderer } from "@hr_work_entry/views/work_entry_calendar/work_entry_calendar_renderer";
|
||||
import { WorkEntryCalendarModel } from "@hr_work_entry/views/work_entry_calendar/work_entry_calendar_model";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { WorkEntryCalendarController } from "@hr_work_entry/views/work_entry_calendar/work_entry_calendar_controller";
|
||||
|
||||
export const WorkEntryCalendarView = {
|
||||
...calendarView,
|
||||
Controller: WorkEntryCalendarController,
|
||||
Renderer: WorkEntryCalendarRenderer,
|
||||
Model: WorkEntryCalendarModel,
|
||||
buttonTemplate: "hr_work_entry.calendar.controlButtons",
|
||||
};
|
||||
|
||||
registry.category("views").add("work_entries_calendar", WorkEntryCalendarView);
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { MultiCreatePopover } from "@web/views/view_components/multi_create_popover";
|
||||
|
||||
export class WorkEntryMultiCreatePopover extends MultiCreatePopover {
|
||||
static template = "hr_work_entry.WorkEntryMultiCreatePopover";
|
||||
static props = {
|
||||
...MultiCreatePopover.props,
|
||||
onQuickReplace: Function,
|
||||
};
|
||||
|
||||
async onReplace() {
|
||||
const isValid = await this.isValidMultiCreateData();
|
||||
if (isValid) {
|
||||
const values = await this.multiCreateData.record.getChanges();
|
||||
this.props.onQuickReplace(values);
|
||||
this.props.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="hr_work_entry.WorkEntryMultiCreatePopover" t-inherit="web.MultiCreatePopover" t-inherit-mode="primary">
|
||||
<button t-on-click="() => this.onAdd()" position="after">
|
||||
<button class="btn btn-sm btn-secondary" t-on-click="() => this.onReplace()">Replace</button>
|
||||
</button>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { WorkEntryMultiCreatePopover } from "@hr_work_entry/views/work_entry_calendar/work_entry_multi_create_popover";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { addFieldDependencies } from "@web/model/relational_model/utils";
|
||||
import { MultiSelectionButtons } from "@web/views/view_components/multi_selection_buttons";
|
||||
|
||||
export class WorkEntryCalendarMultiSelectionButtons extends MultiSelectionButtons {
|
||||
static template = "hr_work_entry.WorkEntryCalendarMultiSelectionButtons";
|
||||
static props = {
|
||||
reactive: {
|
||||
type: Object,
|
||||
shape: {
|
||||
...MultiSelectionButtons.props.reactive.shape,
|
||||
userFavoritesWorkEntries: Array,
|
||||
onQuickReplace: Function,
|
||||
onQuickReset: Function,
|
||||
},
|
||||
}
|
||||
};
|
||||
static components = {
|
||||
...MultiSelectionButtons.components,
|
||||
Popover: WorkEntryMultiCreatePopover,
|
||||
};
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
this.actionService = useService("action");
|
||||
}
|
||||
|
||||
get favoritesWorkEntries() {
|
||||
return this.props.reactive.userFavoritesWorkEntries;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
getMultiCreatePopoverProps() {
|
||||
const props = super.getMultiCreatePopoverProps();
|
||||
props.onQuickReplace = (values) => {
|
||||
this.props.reactive.onQuickReplace(values);
|
||||
}
|
||||
return props;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
async loadMultiCreateView() {
|
||||
await super.loadMultiCreateView();
|
||||
addFieldDependencies(
|
||||
this.multiCreateRecordProps.activeFields,
|
||||
this.multiCreateRecordProps.fields,
|
||||
[
|
||||
{ name: "display_code", type: "char" },
|
||||
{ name: "color", type: "integer" },
|
||||
{ name: "employee_id", type: "many2one" },
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
makeValues(workEntryTypeId) {
|
||||
return {
|
||||
employee_id: this.props.reactive.context.default_employee_id,
|
||||
duration: -1,
|
||||
work_entry_type_id: workEntryTypeId,
|
||||
};
|
||||
}
|
||||
|
||||
async onQuickReplace(workEntryTypeId) {
|
||||
const values = this.makeValues(workEntryTypeId);
|
||||
this.props.reactive.onQuickReplace(values);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
<t t-name="hr_work_entry.WorkEntryCalendarMultiSelectionButtons" t-inherit="web.MultiSelectionButtons" t-inherit-mode="primary">
|
||||
<button data-tooltip="Delete" position="before">
|
||||
<button class="btn btn-secondary" data-tooltip="Reset Selected Work Entries" t-on-click="() => this.props.reactive.onQuickReset()">
|
||||
<i class="fa fa-undo"/>
|
||||
</button>
|
||||
</button>
|
||||
|
||||
<button t-ref="addButton" position="replace">
|
||||
<button class="btn btn-secondary" data-tooltip="Add" t-on-click="() => this.onAdd()" t-ref="addButton">
|
||||
Set
|
||||
</button>
|
||||
</button>
|
||||
|
||||
<button t-ref="addButton" position="after">
|
||||
<t t-foreach="favoritesWorkEntries" t-as="workEntry" t-key="workEntry.id">
|
||||
<button class="small btn btn-secondary rounded-1"
|
||||
t-attf-class="o_gantt_color_#{workEntry.color}"
|
||||
t-attf-data-tooltip="Replace by #{workEntry.display_name}"
|
||||
t-on-click="() => this.onQuickReplace(workEntry.id)">
|
||||
<t t-if="workEntry.display_code" t-out="workEntry.display_code"/>
|
||||
<t t-else="" t-out="workEntry.display_name"/>
|
||||
</button>
|
||||
</t>
|
||||
</button>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { serializeDate } from "@web/core/l10n/dates";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
export function useWorkEntry({ getEmployeeIds, getRange, onClose}) {
|
||||
const orm = useService("orm");
|
||||
const action = useService("action");
|
||||
|
||||
return {
|
||||
onRegenerateWorkEntries: () => {
|
||||
const { start, end } = getRange();
|
||||
action.doAction('hr_work_entry.hr_work_entry_regeneration_wizard_action', {
|
||||
additionalContext: {
|
||||
default_employee_ids: getEmployeeIds(),
|
||||
date_start: serializeDate(start),
|
||||
date_end: serializeDate(end),
|
||||
}, onClose: onClose
|
||||
});
|
||||
},
|
||||
generateWorkEntries: () => {
|
||||
const { start, end } = getRange();
|
||||
return orm.call(
|
||||
'hr.employee',
|
||||
'generate_work_entries',
|
||||
[[], serializeDate(start), serializeDate(end)]
|
||||
);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="hr_work_entry.WorkEntrySourceWarning">
|
||||
<div class="o-tooltip px-1 py-2">
|
||||
<p style="font-size: 12px;" class="text-danger"
|
||||
t-esc="text"/>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { defineModels } from "@web/../tests/web_test_helpers";
|
||||
import { hrModels } from "@hr/../tests/hr_test_helpers";
|
||||
import { HrEmployee } from "@hr_work_entry/../tests/mock_server/mock_models/hr_employee";
|
||||
import { HrWorkEntryType } from "@hr_work_entry/../tests/mock_server/mock_models/hr_work_entry_type";
|
||||
import { HrWorkEntry } from "@hr_work_entry/../tests/mock_server/mock_models/hr_work_entry";
|
||||
|
||||
export function defineHrWorkEntryModels() {
|
||||
return defineModels(hrWorkEntryModels);
|
||||
}
|
||||
|
||||
export const hrWorkEntryModels = {
|
||||
...hrModels,
|
||||
HrEmployee,
|
||||
HrWorkEntry,
|
||||
HrWorkEntryType,
|
||||
};
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { models } from "@web/../tests/web_test_helpers";
|
||||
|
||||
export class HrEmployee extends models.ServerModel {
|
||||
_name = "hr.employee";
|
||||
|
||||
_records = [
|
||||
{ id: 100, name: "Richard" },
|
||||
{ id: 200, name: "Alice" },
|
||||
];
|
||||
|
||||
async generate_work_entries(employeeIds, startDate, endDate) {}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { models } from "@web/../tests/web_test_helpers";
|
||||
|
||||
export class HrWorkEntry extends models.ServerModel {
|
||||
_name = "hr.work.entry";
|
||||
|
||||
_views = {
|
||||
"form,multi_create_form": `
|
||||
<form>
|
||||
<field name="employee_id" invisible="1"/>
|
||||
</form>
|
||||
`,
|
||||
calendar: `
|
||||
<calendar
|
||||
date_start="date"
|
||||
date_stop="date"
|
||||
mode="month"
|
||||
scales="month"
|
||||
month_overflow="0"
|
||||
quick_create="0"
|
||||
color="color"
|
||||
event_limit="9"
|
||||
show_date_picker="0"
|
||||
multi_create_view="multi_create_form"
|
||||
js_class="work_entries_calendar">
|
||||
</calendar>
|
||||
`,
|
||||
};
|
||||
|
||||
action_split(workEntryIds, vals) {}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { models } from "@web/../tests/web_test_helpers";
|
||||
|
||||
export class HrWorkEntryType extends models.ServerModel {
|
||||
_name = "hr.work.entry.type";
|
||||
|
||||
_records = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Test Work Entry Type",
|
||||
color: 1,
|
||||
display_code: "WT1",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "WET no color",
|
||||
color: false,
|
||||
display_code: "WT2",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "WET no display code",
|
||||
color: 1,
|
||||
display_code: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import { beforeEach, describe, expect, test } from "@odoo/hoot";
|
||||
import { animationFrame, mockDate } from "@odoo/hoot-mock";
|
||||
import { findComponent, makeMockServer, mountView } from "@web/../tests/web_test_helpers";
|
||||
import { defineHrWorkEntryModels } from "@hr_work_entry/../tests/hr_work_entry_test_helpers";
|
||||
import { WorkEntryCalendarController } from "@hr_work_entry/views/work_entry_calendar/work_entry_calendar_controller";
|
||||
import { WorkEntryCalendarMultiSelectionButtons } from "@hr_work_entry/views/work_entry_calendar/work_entry_multi_selection_buttons";
|
||||
const { DateTime } = luxon;
|
||||
|
||||
describe.current.tags("desktop");
|
||||
defineHrWorkEntryModels();
|
||||
|
||||
beforeEach(() => {
|
||||
mockDate("2025-01-01 12:00:00", +0);
|
||||
});
|
||||
|
||||
function getCalendarController(view) {
|
||||
return findComponent(view, (c) => c instanceof WorkEntryCalendarController);
|
||||
}
|
||||
|
||||
test("Test work entry calendar without work entry type", async () => {
|
||||
const { env } = await makeMockServer();
|
||||
env["hr.work.entry"].create([
|
||||
{
|
||||
name: "Test Work Entry 0",
|
||||
employee_id: 100,
|
||||
work_entry_type_id: false,
|
||||
date: "2025-01-01",
|
||||
duration: 120,
|
||||
},
|
||||
]);
|
||||
const calendar = await mountView({
|
||||
type: "calendar",
|
||||
resModel: "hr.work.entry",
|
||||
});
|
||||
expect(".o_calendar_renderer").toBeDisplayed({
|
||||
message:
|
||||
"Calendar view should be displayed even with work entries with false work entry type",
|
||||
});
|
||||
const controller = getCalendarController(calendar);
|
||||
const data = {
|
||||
name: "Test New Work Entry",
|
||||
employee_id: 100,
|
||||
work_entry_type_id: false,
|
||||
};
|
||||
await controller.model.multiCreateRecords(
|
||||
{
|
||||
record: {
|
||||
getChanges: () => data,
|
||||
},
|
||||
},
|
||||
[DateTime.fromISO("2025-01-02")]
|
||||
);
|
||||
await animationFrame();
|
||||
expect(".fc-event").toHaveCount(2, {
|
||||
message: "2 work entries should be displayed in the calendar view",
|
||||
});
|
||||
});
|
||||
|
||||
test("should use default_employee_id from context in work entry", async () => {
|
||||
const defaultEmployeeId = 100;
|
||||
const view = await mountView({
|
||||
type: "calendar",
|
||||
resModel: "hr.work.entry",
|
||||
context: { default_employee_id: defaultEmployeeId },
|
||||
});
|
||||
|
||||
const controller = findComponent(
|
||||
view,
|
||||
(component) => component instanceof WorkEntryCalendarMultiSelectionButtons
|
||||
);
|
||||
const workEntryTypeId = 1;
|
||||
const values = controller.makeValues(workEntryTypeId);
|
||||
|
||||
expect(values).toEqual({
|
||||
employee_id: defaultEmployeeId,
|
||||
duration: -1,
|
||||
work_entry_type_id: workEntryTypeId,
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue