[IMP] hr_holidays: improve stress days display

Before this commit stress days were displayed as colored rectangles
on the calendar which made it ambiguous compared to leaves.
After this commit the stress day have the number colored and in bold
instead and the signification has been added to the legend on the side.
Moreover, clicking the event displays a popover giving a short
description of the stress day instead of opening a time off creation
modal.
This commit also adds `web/static/src/views/calendar/util.js` which
exports a method generating a datespan expression given two dates.

task-3226525

closes odoo/odoo#125749

X-original-commit: eb2b6068a998812890c8d37330c986fd3fc30d4f
Signed-off-by: Kevin Baptiste <kba@odoo.com>
This commit is contained in:
Dossogne Bertrand
2023-06-20 20:32:48 +02:00
parent e76459bc0e
commit d9b1b470b8
16 changed files with 295 additions and 66 deletions
+2 -2
View File
@@ -474,8 +474,8 @@
<field name="name">Public Time Off</field>
<field name="company_id" ref="base.main_company"/>
<field name="calendar_id" ref="resource.resource_calendar_std"/>
<field name="date_from" eval="(datetime.today() + relativedelta(days=+8)).strftime('%Y-%m-%d 07:00:00')"></field>
<field name="date_to" eval="(datetime.today() + relativedelta(days=+8)).strftime('%Y-%m-%d 16:00:00')"></field>
<field name="date_from" eval="(datetime.today() + relativedelta(days=+8)).strftime('%Y-%m-%d 05:00:00')"></field>
<field name="date_to" eval="(datetime.today() + relativedelta(days=+8)).strftime('%Y-%m-%d 17:00:00')"></field>
</record>
<!-- Stress day -->
+80 -12
View File
@@ -264,6 +264,14 @@ class HrEmployee(models.Model):
},
}
def _get_contextual_employee(self):
if self.env.context.get('employee_id'):
return self.browse(self.env.context['employee_id'])
return self.env.user.employee_id
def _is_leave_user(self):
return self == self.env.user.employee_id and self.user_has_groups('hr_holidays.group_hr_holidays_user')
def get_stress_days(self, start_date, end_date):
all_days = {}
@@ -277,22 +285,82 @@ class HrEmployee(models.Model):
return all_days
@api.model
def get_special_days_data(self, date_start, date_end):
return {
'stressDays': self.get_stress_days_data(date_start, date_end),
'bankHolidays': self.get_public_holidays_data(date_start, date_end),
}
@api.model
def get_public_holidays_data(self, date_start, date_end):
self = self._get_contextual_employee()
public_holidays = self._get_public_holidays(date_start, date_end).sorted('date_from')
return list(map(lambda bh: {
'id': -bh.id,
'colorIndex': 0,
'end': datetime.datetime.combine(bh.date_to, datetime.datetime.max.time()).isoformat(),
'endType': "datetime",
'isAllDay': True,
'start': datetime.datetime.combine(bh.date_from, datetime.datetime.min.time()).isoformat(),
'startType': "datetime",
'title': bh.name,
}, public_holidays))
def _get_public_holidays(self, date_start, date_end):
domain = [
('resource_id', '=', False),
('company_id', 'in', (self.company_id.id, False)),
('date_from', '<=', date_end),
('date_to', '>=', date_start),
]
# a user with hr_holidays permissions will be able to see all public holidays from his calendar
if not self._is_leave_user():
domain += [
'|',
('calendar_id', '=', False),
('calendar_id', '=', self.resource_calendar_id.id),
]
return self.env['resource.calendar.leaves'].search(domain)
@api.model
def get_stress_days_data(self, date_start, date_end):
self = self._get_contextual_employee()
stress_days = self._get_stress_days(date_start, date_end).sorted('start_date')
return list(map(lambda sd: {
'id': -sd.id,
'colorIndex': sd.color,
'end': datetime.datetime.combine(sd.end_date, datetime.datetime.max.time()).isoformat(),
'endType': "datetime",
'isAllDay': True,
'start': datetime.datetime.combine(sd.start_date, datetime.datetime.min.time()).isoformat(),
'startType': "datetime",
'title': sd.name,
}, stress_days))
def _get_stress_days(self, start_date, end_date):
stress_days = self.env['hr.leave.stress.day'].search([
domain = [
('start_date', '<=', end_date),
('end_date', '>=', start_date),
'|',
('resource_calendar_id', '=', False),
('resource_calendar_id', 'in', (self.resource_calendar_id | self.env.company.resource_calendar_id).ids),
('company_id', 'in', self.env.companies.ids),
])
]
# a user with hr_holidays permissions will be able to see all stress days from his calendar
is_leave_user = self == self.env.user.employee_id and self.user_has_groups('hr_holidays.group_hr_holidays_user')
if not self._is_leave_user():
domain += [
'|',
('resource_calendar_id', '=', False),
('resource_calendar_id', '=', self.resource_calendar_id.id),
]
if self.department_id:
domain += [
'|',
('department_ids', '=', False),
('department_ids', 'parent_of', self.department_id.id),
]
else:
domain += [('department_ids', '=', False)]
if not is_leave_user and stress_days.department_ids:
stress_days = stress_days.filtered(lambda sd:\
not sd.department_ids\
or self.department_id and not set(self.department_id.ids).isdisjoint(sd.department_ids.get_children_department_ids().ids))
return stress_days
return self.env['hr.leave.stress.day'].search(domain)
@@ -26,6 +26,13 @@ export class TimeOffCalendarController extends CalendarController {
return this.model.employeeId;
}
get filterPanelProps() {
return {
...super.filterPanelProps,
employee_id: this.employeeId,
};
}
newTimeOffRequest() {
const context = {};
if (this.employeeId) {
@@ -2,11 +2,13 @@
.o_calendar_renderer {
height: unset;
.o_calendar_widget {
@for $size from 1 through length($o-colors) {
.hr_stress_day_#{$size - 1}:not(.fc-disabled-day) {
background: nth($o-colors, $size) !important;
opacity: 0.4;
@for $size from 1 through length($o-colors) {
.o_calendar_widget {
.hr_stress_day_top_#{$size - 1}:not(.fc-disabled-day) {
.fc-day-number {
color: nth($o-colors, $size) !important;
font-weight: 600;
}
}
}
}
@@ -2,23 +2,55 @@
import { CalendarFilterPanel } from "@web/views/calendar/filter_panel/calendar_filter_panel";
import { TimeOffCardMobile } from "../../../dashboard/time_off_card";
import { getFormattedDateSpan } from '@web/views/calendar/utils';
import { useService } from "@web/core/utils/hooks";
import { serializeDate } from "@web/core/l10n/dates";
const { useState, onWillStart } = owl;
const { useState, onWillStart, onWillUpdateProps } = owl;
export class TimeOffCalendarFilterPanel extends CalendarFilterPanel {
setup() {
super.setup();
this.orm = useService('orm');
this.getFormattedDateSpan = getFormattedDateSpan;
this.leaveState = useState({
holidays: [],
stressDays: [],
bankHolidays: [],
});
onWillStart(async () => {
await this.loadFilterData();
await this.updateSpecialDays();
});
onWillUpdateProps(this.updateSpecialDays);
}
async updateSpecialDays() {
const context = {
'employee_id': this.props.employee_id,
}
const specialDays = await this.orm.call(
'hr.employee', 'get_special_days_data', [
serializeDate(this.props.model.rangeStart, "datetime"),
serializeDate(this.props.model.rangeEnd, "datetime"),
],
{
'context': context,
},
);
specialDays['bankHolidays'].forEach(bankHoliday => {
bankHoliday.start = luxon.DateTime.fromISO(bankHoliday.start)
bankHoliday.end = luxon.DateTime.fromISO(bankHoliday.end)
});
specialDays['stressDays'].forEach(stressDay => {
stressDay.start = luxon.DateTime.fromISO(stressDay.start)
stressDay.end = luxon.DateTime.fromISO(stressDay.end)
});
this.leaveState.bankHolidays = specialDays['bankHolidays'];
this.leaveState.stressDays = specialDays['stressDays'];
}
async loadFilterData() {
@@ -1,15 +1,45 @@
.o_calendar_filter img {
width:30px;
&.o_calendar_filter_plain {
content:var(--calendarFilter-icon--plain);
}
.o_calendar_filter {
span {
vertical-align: middle;
}
&.o_calendar_filter_hatched {
content:var(--calendarFilter-icon--hatched);
}
&.o_calendar_filter_line {
content:var(--calendarFilter-icon--line);
}
& img {
width:30px;
&.o_calendar_filter_plain {
content:var(--calendarFilter-icon--plain);
}
&.o_calendar_filter_hatched {
content:var(--calendarFilter-icon--hatched);
}
&.o_calendar_filter_line {
content:var(--calendarFilter-icon--line);
}
}
.o_timeoff_legend {
display: inline-block;
width: 24px;
height: 30px;
margin: 0 3px;
padding: 3px 0;
text-align: center;
&_bankholiday {
background-color: $gray-200;
}
&_stressday {
font-weight: 600;
}
}
@for $size from 1 through length($o-colors) {
.hr_stress_day_#{$size - 1}:not(.fc-disabled-day) {
color: nth($o-colors, $size) !important;
}
}
}
@@ -10,6 +10,31 @@
<span><img class="o_calendar_filter_plain" src="/hr/static/src/img/icons/plain.svg"/> Validated</span>
<span><img class="o_calendar_filter_hatched" src="/hr/static/src/img/icons/hatched.svg"/> To Approve</span>
<span><img class="o_calendar_filter_line" src="/hr/static/src/img/icons/line.svg"/> Refused</span>
<span><span class="o_timeoff_legend o_timeoff_legend_bankholiday">13</span> Public Holiday</span>
<span><span class="o_timeoff_legend o_timeoff_legend_stressday text-odoo">13</span> Stress Day</span>
</div>
<div class="d-flex flex-column mt-4" t-if="leaveState.stressDays.length">
<h5>Stress Days</h5>
<ul class="ps-2">
<li t-foreach="leaveState.stressDays" t-as="stressDay" t-key="stressDay.id" class="mt-2 list-unstyled">
<strong
t-esc="getFormattedDateSpan(stressDay.start, stressDay.end)"
t-att-class="'hr_stress_day_'+stressDay.colorIndex"/>
: <t t-esc="stressDay.title"/>
</li>
</ul>
</div>
<div class="d-flex flex-column mt-4" t-if="leaveState.bankHolidays.length">
<h5>Public Holidays</h5>
<ul class="ps-2">
<li t-foreach="leaveState.bankHolidays" t-as="bankHoliday" t-key="bankHoliday.id" class="mt-2 list-unstyled">
<strong
t-esc="getFormattedDateSpan(bankHoliday.start, bankHoliday.end)"/>
: <t t-esc="bankHoliday.title"/>
</li>
</ul>
</div>
</div>
</xpath>
@@ -0,0 +1,13 @@
/** @odoo-module **/
import { Dialog } from "@web/core/dialog/dialog";
import { CalendarYearPopover } from "@web/views/calendar/calendar_year/calendar_year_popover";
export class TimeOffCalendarYearPopover extends CalendarYearPopover {}
TimeOffCalendarYearPopover.components = { Dialog };
TimeOffCalendarYearPopover.template = "web.CalendarYearPopover";
TimeOffCalendarYearPopover.subTemplates = {
...CalendarYearPopover.subTemplates,
body: "hr_holidays.StressDayCalendarYearPopover.body",
};
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="hr_holidays.StressDayCalendarYearPopover.body" owl="1">
<t t-foreach="recordGroups" t-as="recordGroup" t-key="recordGroup.title">
<div class="fw-bold mt-2" t-esc="recordGroup.title" />
<t t-foreach="recordGroup.records" t-as="record" t-key="record.id">
<t t-if="record.id &lt; 0">
<p class="">
<t t-if="record.startHour"><t t-esc="record.startHour" /> </t>
<t t-esc="record.title"/>
</p>
</t>
<t t-else="">
<t t-call="{{ constructor.subTemplates.record }}" />
</t>
</t>
</t>
</t>
</templates>
@@ -2,14 +2,20 @@
import { CalendarYearRenderer } from '@web/views/calendar/calendar_year/calendar_year_renderer';
import { useService } from "@web/core/utils/hooks";
import { useStressDays } from '../../hooks';
import { useCalendarPopover } from '@web/views/calendar/hooks';
import { TimeOffCalendarYearPopover } from './calendar_year_popover';
const { useEffect } = owl;
export class TimeOffCalendarYearRenderer extends CalendarYearRenderer {
setup() {
super.setup();
this.orm = useService("orm");
this.stressDays = useStressDays(this.props);
this.stressDaysList = [];
this.stressDayPopover = useCalendarPopover(TimeOffCalendarYearPopover);
useEffect((el) => {
for (const week of el) {
@@ -33,8 +39,33 @@ export class TimeOffCalendarYearRenderer extends CalendarYearRenderer {
});
}
/** @override **/
async onDateClick(info) {
const is_stress_day = [...info.dayEl.classList].some(elClass => elClass.startsWith('hr_stress_day_'))
this.stressDayPopover.close();
if (is_stress_day && !this.env.isSmall) {
this.popover.close();
const date = luxon.DateTime.fromISO(info.dateStr);
const target = info.dayEl;
const stress_days_data = await this.orm.call("hr.employee", "get_stress_days_data", [date, date]);
stress_days_data.forEach(stress_day_data => {
stress_day_data['start'] = luxon.DateTime.fromISO(stress_day_data['start'])
stress_day_data['end'] = luxon.DateTime.fromISO(stress_day_data['end'])
});
const records = Object.values(this.props.model.records).filter((r) =>
luxon.Interval.fromDateTimes(r.start.startOf("day"), r.end.endOf("day")).contains(date)
);
const props = this.getPopoverProps(date, records)
props['records'] = stress_days_data.concat(props['records'])
this.stressDayPopover.open(target, props, "o_cw_popover");
}
else {
super.onDateClick(info);
}
}
onDayRender(info) {
super.onDayRender(info);
this.stressDays(info);
this.stressDaysList = this.stressDays(info);
}
}
+9 -1
View File
@@ -9,8 +9,16 @@ export function useStressDays(props) {
const date = luxon.DateTime.fromJSDate(info.date).toISODate();
const stressDay = props.model.stressDays[date];
if (stressDay) {
info.el.classList.add('hr_stress_day_' + stressDay);
const dayNumberElTop = info.view.el.querySelector(`.fc-day-top[data-date="${info.el.dataset.date }"]`)
const dayNumberEl = info.view.el.querySelector(`.fc-day[data-date="${info.el.dataset.date }"]`)
if (dayNumberElTop) {
dayNumberElTop.classList.add(`hr_stress_day_top_${stressDay}`);
}
if (dayNumberEl) {
dayNumberEl.classList.add(`hr_stress_day_${stressDay}`);
}
}
return props.model.stressDays;
}
}
@@ -5,6 +5,7 @@ import { is24HourFormat } from "@web/core/l10n/dates";
import { Field } from "@web/views/fields/field";
import { Record } from "@web/views/record";
import { evalDomain } from "@web/views/utils";
import { getFormattedDateSpan } from '@web/views/calendar/utils';
import { Component } from "@odoo/owl";
@@ -58,7 +59,7 @@ export class CalendarCommonPopover extends Component {
}
if (!this.props.model.isDateHidden) {
this.date = this.getFormattedDate(start, end, record.isAllDay);
this.date = getFormattedDateSpan(start, end);
if (record.isAllDay) {
if (isSameDay) {
@@ -70,17 +71,6 @@ export class CalendarCommonPopover extends Component {
}
}
}
getFormattedDate(start, end, isAllDay) {
const isSameDay = start.hasSame(end, "day");
if (!isSameDay && start.hasSame(end, "month")) {
// Simplify date-range if an event occurs into the same month (eg. "August, 4-5 2019")
return start.toFormat("LLLL d") + "-" + end.toFormat("d, y");
} else {
return isSameDay
? start.toFormat("DDDD")
: start.toFormat("DDD") + " - " + end.toFormat("DDD");
}
}
onEditEvent() {
this.props.editRecord(this.props.record);
@@ -3,6 +3,7 @@
import { Dialog } from "@web/core/dialog/dialog";
import { formatDate } from "@web/core/l10n/dates";
import { getColor } from "../colors";
import { getFormattedDateSpan } from '@web/views/calendar/utils';
import { Component } from "@odoo/owl";
@@ -30,7 +31,7 @@ export class CalendarYearPopover extends Component {
modifiedRecord.startHour =
!record.isAllDay && duration < 1 ? start.toFormat("HH:mm") : "";
const formattedDate = this.getFormattedDate(start, end);
const formattedDate = getFormattedDateSpan(start, end);
if (!(formattedDate in recordGroups)) {
recordGroups[formattedDate] = {
title: formattedDate,
@@ -71,17 +72,6 @@ export class CalendarYearPopover extends Component {
return a.start.toMillis() - b.start.toMillis();
});
}
getFormattedDate(start, end) {
const isSameDay = start.hasSame(end, "days");
if (!isSameDay && start.hasSame(end, "month")) {
// Simplify date-range if an event occurs into the same month (eg. "August 4-5, 2019")
return start.toFormat("LLLL d") + "-" + end.toFormat("d, y");
} else {
return isSameDay
? start.toFormat("DDD")
: start.toFormat("DDD") + " - " + end.toFormat("DDD");
}
}
onCreateButtonClick() {
this.props.createRecord({
@@ -0,0 +1,14 @@
/** @odoo-module **/
export function getFormattedDateSpan(start, end) {
const isSameDay = start.hasSame(end, "days");
if (!isSameDay && start.hasSame(end, "month")) {
// Simplify date-range if an event occurs into the same month (eg. "August 4-5, 2019")
return start.toFormat("LLLL d") + "-" + end.toFormat("d, y");
} else {
return isSameDay
? start.toFormat("DDD")
: start.toFormat("DDD") + " - " + end.toFormat("DDD");
}
}
@@ -62,7 +62,7 @@ QUnit.module("CalendarView - CommonPopover", ({ beforeEach }) => {
});
const dateTimeGroup = target.querySelector(`.list-group`);
const dateTimeLabels = dateTimeGroup.textContent.replace(/\s+/g, " ").trim();
assert.strictEqual(dateTimeLabels, "Friday, July 16, 2021 (All day)");
assert.strictEqual(dateTimeLabels, "July 16, 2021 (All day)");
});
QUnit.test("date duration: is all day and two days duration", async (assert) => {
@@ -154,7 +154,7 @@ QUnit.module("CalendarView - CommonPopover", ({ beforeEach }) => {
const dateTimeLabels = dateTimeGroup.textContent.replace(/\s+/g, " ").trim();
assert.strictEqual(
dateTimeLabels,
"Friday, July 16, 2021 08:00 - 11:15 (3 hours, 15 minutes)"
"July 16, 2021 08:00 - 11:15 (3 hours, 15 minutes)"
);
});
@@ -166,7 +166,7 @@ QUnit.module("CalendarView - CommonPopover", ({ beforeEach }) => {
});
const dateTimeGroup = target.querySelector(`.list-group`);
const dateTimeLabels = dateTimeGroup.textContent.replace(/\s+/g, " ").trim();
assert.strictEqual(dateTimeLabels, "Friday, July 16, 2021");
assert.strictEqual(dateTimeLabels, "July 16, 2021");
});
QUnit.test("isTimeHidden is false", async (assert) => {
@@ -179,7 +179,7 @@ QUnit.module("CalendarView - CommonPopover", ({ beforeEach }) => {
const dateTimeLabels = dateTimeGroup.textContent.replace(/\s+/g, " ").trim();
assert.strictEqual(
dateTimeLabels,
"Friday, July 16, 2021 08:00 - 11:15 (3 hours, 15 minutes)"
"July 16, 2021 08:00 - 11:15 (3 hours, 15 minutes)"
);
});
@@ -1314,8 +1314,8 @@ QUnit.module("Views", ({ beforeEach }) => {
);
assert.strictEqual(
target.querySelector(".o_cw_popover .list-group-item b.text-capitalize").textContent,
"Wednesday, December 14, 2016",
"should display date 'Wednesday, December 14, 2016'"
"December 14, 2016",
"should display date 'December 14, 2016'"
);
assert.containsN(
target,
@@ -3495,7 +3495,7 @@ QUnit.module("Views", ({ beforeEach }) => {
await clickEvent(target, 1);
assert.strictEqual(
target.querySelector(".o_cw_popover .list-group-item").textContent,
"Wednesday, December 14, 2016 (All day)"
"December 14, 2016 (All day)"
);
});