add working hours component

This commit is contained in:
Maxim 2022-07-11 15:09:27 +01:00
parent f6ababca7d
commit b2f693b835
12 changed files with 272 additions and 53 deletions

View file

@ -3,6 +3,9 @@ import React, { useEffect, useMemo } from 'react';
import { AppRootProps } from '@grafana/data';
import { Button, HorizontalGroup, LinkButton, VerticalGroup } from '@grafana/ui';
import dayjs from 'dayjs';
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
import localeData from 'dayjs/plugin/localeData';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
import weekday from 'dayjs/plugin/weekday';
@ -21,6 +24,9 @@ import { useNavModel } from 'utils/hooks';
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(weekday);
dayjs.extend(localeData);
dayjs.extend(isSameOrBefore);
dayjs.extend(isSameOrAfter);
// dayjs().weekday(0);

View file

@ -39,12 +39,12 @@ class Rotations extends Component<RotationsProps, RotationsState> {
const layers = [
{ id: 0, title: 'Layer 1' },
{ id: 1, title: 'Layer 2' },
/* { id: 1, title: 'Layer 2' },
{ id: 2, title: 'Layer 3' },
{ id: 3, title: 'Layer 4' },
{ id: 3, title: 'Layer 4' },*/
];
const rotations = [{}, {}];
const rotations = [{} /*, {}*/];
return (
<>

View file

@ -7,6 +7,11 @@
gap: 4px;
}
.working-hours{
position: absolute;
top: 0;
left: 0;
}
.stack {
display: flex;
@ -22,6 +27,7 @@
background: rgba(209, 14, 92, 0.2);
border: 1px dashed #FF5286;
color: rgba(209, 14, 92, .5);
visibility: hidden;
}
.root__inactive {

View file

@ -6,6 +6,7 @@ import { observer } from 'mobx-react';
import Line from 'components/ScheduleUserDetails/img/line.svg';
import Text from 'components/Text/Text';
import WorkingHours from 'components/WorkingHours/WorkingHours';
import { Shift } from 'models/schedule/schedule.types';
import { User } from 'models/user/user.types';
import { useStore } from 'state/useStore';
@ -57,7 +58,15 @@ const ScheduleSlot: FC<ScheduleSlotProps> = observer((props) => {
backgroundColor: color,
}}
>
<div style={{ left: `${left}%`, right: `${right}%` }} className={cx('striped')} />
{storeUser && (
<WorkingHours
className={cx('working-hours')}
timezone={storeUser.timezone}
workingHours={storeUser.working_hours}
startMoment={shift.start}
duration={shift.duration}
/>
)}
{label && (
<div className={cx('label')} style={{ color }}>
{label}

View file

@ -0,0 +1,9 @@
export const default_working_hours = {
friday: [{ end: '17:00:00', start: '09:00:00' }],
monday: [{ end: '17:00:00', start: '09:00:00' }],
sunday: [],
tuesday: [{ end: '17:00:00', start: '09:00:00' }],
saturday: [],
thursday: [{ end: '17:00:00', start: '09:00:00' }],
wednesday: [{ end: '17:00:00', start: '09:00:00' }],
};

View file

@ -0,0 +1,88 @@
import dayjs from 'dayjs';
export const getWorkingMoments = (
startMoment,
endMoment,
workingHours,
timezone,
) => {
const weekdays = dayjs.weekdays();
const dayOfWeekToStartIteration = startMoment.format('dddd');
const weekDaysToIterateChunk = [
dayOfWeekToStartIteration,
...weekdays.slice(weekdays.indexOf(dayOfWeekToStartIteration) + 1),
...weekdays.slice(0, weekdays.indexOf(dayOfWeekToStartIteration)),
];
const weeks = endMoment.diff(startMoment, 'weeks');
const weekDaysToIterate = [...weekDaysToIterateChunk];
for (let i = 0; i < weeks; i++) {
weekDaysToIterate.push(...weekDaysToIterateChunk);
}
const workingMoments = [];
for (const [i, weekday] of weekDaysToIterate.entries()) {
for (const range of workingHours[weekday.toLowerCase()]) {
const rangeStartData = range.start;
const rangeEndData = range.end;
const [start_HH, start_mm, start_ss] = rangeStartData.split(':');
const [end_HH, end_mm, end_ss] = rangeEndData.split(':');
const rangeStartMoment = dayjs(startMoment)
.tz(timezone)
.add(i, 'day')
.set('hour', Number(start_HH))
.set('minute', Number(start_mm))
.set('second', Number(start_ss));
const rangeEndMoment = dayjs(startMoment)
.tz(timezone)
.add(i, 'day')
.set('hour', Number(end_HH))
.set('minute', Number(end_mm))
.set('second', Number(end_ss));
if (rangeEndMoment.isSameOrBefore(startMoment)) {
continue;
} else if (rangeStartMoment.isSameOrAfter(endMoment)) {
continue;
}
if (
rangeStartMoment.isSameOrBefore(startMoment) &&
rangeEndMoment.isSameOrAfter(startMoment) &&
rangeEndMoment.isSameOrBefore(endMoment)
) {
workingMoments.push({ start: startMoment, end: rangeEndMoment });
} else if (
rangeEndMoment.isSameOrAfter(endMoment) &&
rangeStartMoment.isSameOrBefore(endMoment) &&
rangeStartMoment.isSameOrAfter(startMoment)
) {
workingMoments.push({ start: rangeStartMoment, end: endMoment });
} else {
workingMoments.push({ start: rangeStartMoment, end: rangeEndMoment });
}
}
}
return workingMoments;
};
export const getNonWorkingMoments = (startMoment, endMoment, workingHours) => {
const nonWorkingMoments = [{ start: startMoment, end: endMoment }];
let lastNonWorkingRange = nonWorkingMoments[0];
for (const [i, range] of workingHours.entries()) {
lastNonWorkingRange.end = range.start;
lastNonWorkingRange = { start: range.end, end: undefined };
nonWorkingMoments.push(lastNonWorkingRange);
}
lastNonWorkingRange.end = endMoment;
return nonWorkingMoments;
};

View file

@ -0,0 +1,3 @@
.root {
display: block;
}

View file

@ -0,0 +1,89 @@
import React, { FC, useMemo } from 'react';
import cn from 'classnames/bind';
import dayjs from 'dayjs';
import localeData from 'dayjs/plugin/localeData';
import { Timezone } from 'models/timezone/timezone.types';
import { default_working_hours } from './WorkingHours.config';
import { getNonWorkingMoments, getWorkingMoments } from './WorkingHours.helpers';
import styles from './WorkingHours.module.css';
import { start } from 'repl';
interface WorkingHoursProps {
timezone: Timezone;
workingHours: any;
startMoment: dayjs.Dayjs;
duration: number; // in seconds
width: number; // in pixels
className: string;
}
const cx = cn.bind(styles);
const WorkingHours: FC<WorkingHoursProps> = (props) => {
const {
timezone,
workingHours = default_working_hours,
startMoment = dayjs().utc().startOf('week'),
duration = 14 * 24 * 60 * 60,
className,
} = props;
timezone = dayjs.tz.guess();
const endMoment = startMoment.add(duration, 'seconds');
const workingMoments = useMemo(
() => getWorkingMoments(startMoment, endMoment, workingHours, timezone),
[startMoment, endMoment, workingHours, timezone]
);
const nonWorkingMoments = getNonWorkingMoments(startMoment, endMoment, workingMoments);
console.log(startMoment.tz(timezone).format('D MMM ddd HH:ss'));
/*console.log(
workingMoments.map(
(range) =>
`${range.start.tz(timezone).format('D MMM ddd HH:ss')} - ${range.end.tz(timezone).format('D MMM ddd HH:ss')}`
)
);
console.log(
nonWorkingMoments.map(
(range) =>
`${range.start.tz(timezone).format('D MMM ddd HH:ss')} - ${range.end.tz(timezone).format('D MMM ddd HH:ss')}`
)
);*/
return (
<svg version="1.1" width="100%" height="28px" xmlns="http://www.w3.org/2000/svg" className={className}>
<defs>
<pattern id="stripes" patternUnits="userSpaceOnUse" width="10" height="10" patternTransform="rotate(45)">
<line x1="0" y="0" x2="0" y2="10" stroke="rgba(17, 18, 23, 0.15)" strokeWidth="10" />
</pattern>
</defs>
{nonWorkingMoments.map((moment, index) => {
const start = moment.start.diff(startMoment, 'seconds');
const diff = moment.end.diff(moment.start, 'seconds');
return (
<rect
className={cx('stripes')}
key={index}
x={`${(start * 100) / duration}%`}
y={0}
width={`${(diff * 100) / duration}%`}
height="100%"
fill="url(#stripes)"
/>
);
})}
</svg>
);
};
export default WorkingHours;

View file

@ -118,20 +118,24 @@ export class ScheduleStore extends BaseStore {
const response = await new Promise((resolve, reject) => {
function getUsers() {
const rnd = Math.random();
/*
if (rnd > 0.66) {
return [];
}
*/
const users = [
'UCXTPJYKQHFW6',
'UFYP8IJV9BZDE',
'U122EFECQFN9Y',
'UZ2LWBDAZE962',
'U87ZI7PRWF7K1',
'U2VY9ZP5A1XKL',
'UTA6SS7RL3HC7',
'UAYAYSDVG5MYH',
'UQEAACAGQ5JHL',
'UEHYTCX4AMX75',
'U3U8343UTJ91U',
'UTNF7TCGBPADM',
'UWPPUTZHCC9U5',
'UDUG977U8V8AX',
'UNN22BHCXZ6TR',
'UTKBFZH8HM1TF',
'U1DJX6WMFTWY7',
'UPZ7AJPKVJL9K',
];
if (rnd > 0.33) {
@ -151,8 +155,9 @@ export class ScheduleStore extends BaseStore {
const shifts = [];
for (let i = 0; i < 14; i++) {
shifts.push({
start: dayjs(startMoment).add(3 * i, 'hour'),
duration: (Math.floor(Math.random() * 6) + 10) * 60 * 60,
start: dayjs(startMoment).add(12 * i, 'hour'),
//duration: (Math.floor(Math.random() * 6) + 10) * 60 * 60,
duration: 12 * 60 * 60,
users: getUsers(),
});
}

View file

@ -100,7 +100,7 @@ export class UserStore extends BaseStore {
...acc,
[item.pk]: {
...item,
tz: getRandomTimezone(),
timezone: getRandomTimezone(),
working_hours: {
monday: [{ start: '09:00:00', end: '18:00:00' }],
tuesday: [{ start: '09:00:00', end: '18:00:00' }],
@ -116,8 +116,6 @@ export class UserStore extends BaseStore {
),
};
console.log(this.items);
this.searchResult = {
count,
results: results.map((item: User) => item.pk),

View file

@ -4,6 +4,10 @@
margin-top: 24px;
}
.header{
position: sticky;
}
.desc{
width: 736px;
}

View file

@ -62,43 +62,45 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
return (
<div className={cx('root')}>
<VerticalGroup spacing="lg">
<HorizontalGroup justify="space-between">
<HorizontalGroup>
<PluginLink query={{ page: 'schedules' }}>
<IconButton style={{ marginTop: '5px' }} name="arrow-left" size="xxl" />
</PluginLink>
<Text.Title level={3}>Schedule Team {query.id}</Text.Title>
<ScheduleCounter
type="link"
count={5}
tooltipTitle="Used in escalations"
tooltipContent={
<>
<PluginLink query={{ page: 'integrations', id: 'CXBEG63MBJMDL' }}>Grafana 1</PluginLink>
<br />
<PluginLink query={{ page: 'integrations', id: 'CXBEG63MBJMDL' }}>Grafana 2</PluginLink>
<br />
<PluginLink query={{ page: 'integrations', id: 'CXBEG63MBJMDL' }}>Grafana 3</PluginLink>
</>
}
/>
<ScheduleCounter
type="warning"
count={2}
tooltipTitle="Warnings"
tooltipContent="Schedule has unassigned time periods during next 7 days"
/>
<div className={cx('header')}>
<HorizontalGroup justify="space-between">
<HorizontalGroup>
<PluginLink query={{ page: 'schedules' }}>
<IconButton style={{ marginTop: '5px' }} name="arrow-left" size="xxl" />
</PluginLink>
<Text.Title level={3}>Schedule Team {query.id}</Text.Title>
<ScheduleCounter
type="link"
count={5}
tooltipTitle="Used in escalations"
tooltipContent={
<>
<PluginLink query={{ page: 'integrations', id: 'CXBEG63MBJMDL' }}>Grafana 1</PluginLink>
<br />
<PluginLink query={{ page: 'integrations', id: 'CXBEG63MBJMDL' }}>Grafana 2</PluginLink>
<br />
<PluginLink query={{ page: 'integrations', id: 'CXBEG63MBJMDL' }}>Grafana 3</PluginLink>
</>
}
/>
<ScheduleCounter
type="warning"
count={2}
tooltipTitle="Warnings"
tooltipContent="Schedule has unassigned time periods during next 7 days"
/>
</HorizontalGroup>
<HorizontalGroup>
<UserTimezoneSelect value={tz} users={users} onChange={this.handleTimezoneChange} />
<ScheduleQuality quality={0.89} />
<ToolbarButton icon="copy" tooltip="Copy" />
<ToolbarButton icon="brackets-curly" tooltip="Code" />
<ToolbarButton icon="share-alt" tooltip="Share" />
<ToolbarButton icon="cog" tooltip="Settings" />
<ToolbarButton icon="trash-alt" tooltip="Delete" />
</HorizontalGroup>
</HorizontalGroup>
<HorizontalGroup>
<UserTimezoneSelect value={tz} users={users} onChange={this.handleTimezoneChange} />
<ScheduleQuality quality={0.89} />
<ToolbarButton icon="copy" tooltip="Copy" />
<ToolbarButton icon="brackets-curly" tooltip="Code" />
<ToolbarButton icon="share-alt" tooltip="Share" />
<ToolbarButton icon="cog" tooltip="Settings" />
<ToolbarButton icon="trash-alt" tooltip="Delete" />
</HorizontalGroup>
</HorizontalGroup>
</div>
<Text className={cx('desc')} size="small" type="secondary">
On-call Schedules. Use this to distribute notifications among team members you specified in the "Notify
Users from on-call schedule" step in escalation chains.