19.0 vanilla

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -0,0 +1 @@
<svg width="50" height="50" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg"><path d="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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
.o_form_view {
.o_work_entry_source_warning {
cursor: help;
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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