mirror of
https://github.com/hcengineering/platform.git
synced 2025-04-14 04:08:19 +00:00
UBER-615 Redesign hr schedule month view (#3538)
Signed-off-by: Alexander Onnikov <alexander.onnikov@xored.com>
This commit is contained in:
parent
746d8a1c41
commit
424c546089
@ -59,6 +59,7 @@
|
||||
--primary-color-skyblue: #93CAF3;
|
||||
--primary-color-pink: #FA8DA1;
|
||||
|
||||
--highlight-blue-01: #0084FF;
|
||||
--highlight-red: #F96E50;
|
||||
--highlight-red-hover: #ff967e;
|
||||
--highlight-red-press: #f96f50bd;
|
||||
@ -349,9 +350,13 @@
|
||||
--theme-calendar-today-color: #000;
|
||||
--theme-calendar-holiday-color: #eb5757;
|
||||
--theme-calendar-weekend-color: rgba(242, 153, 74, 1);
|
||||
--theme-calendar-today-bgcolor: rgba(43, 81, 144, .1);
|
||||
--theme-calendar-today-bgcolor: rgba(51, 157, 255, .1);
|
||||
--theme-calendar-holiday-bgcolor: rgba(235, 87, 87, .1);
|
||||
--theme-calendar-weekend-bgcolor: rgba(242, 153, 74, .1);
|
||||
--theme-calendar-event-available-color: rgba(55, 122, 230, 0.20);
|
||||
--theme-calendar-event-available-bgcolor: #f6f9fe;
|
||||
--theme-calendar-event-unavailable-color: rgba(244, 119, 88, 0.20);
|
||||
--theme-calendar-event-unavailable-bgcolor: #fdece7;
|
||||
|
||||
--theme-tooltip-color: #FFF;
|
||||
--theme-tooltip-bg: #444248;
|
||||
|
@ -98,6 +98,7 @@
|
||||
<span class="text-sm content-dark-color">
|
||||
<Label label={hr.string.NoMembers} />
|
||||
</span>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<span class="text-sm content-color over-underline" on:click={add}>
|
||||
<Label label={hr.string.AddMember} />
|
||||
</span>
|
||||
|
@ -1,90 +0,0 @@
|
||||
<!--
|
||||
// Copyright © 2022 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import hr, { Department, Request, RequestType, Staff } from '@hcengineering/hr'
|
||||
import { getClient } from '@hcengineering/presentation'
|
||||
import { closeTooltip, getPlatformColor, Icon, isWeekend, showPopup, themeStore } from '@hcengineering/ui'
|
||||
import { ContextMenu } from '@hcengineering/view-resources'
|
||||
import { getHolidayDatesForEmployee, isHoliday } from '../utils'
|
||||
import { Ref } from '@hcengineering/core'
|
||||
|
||||
export let requests: Request[]
|
||||
export let date: Date
|
||||
export let editable: boolean = false
|
||||
export let holidays: Map<Ref<Department>, Date[]>
|
||||
export let employee: Staff
|
||||
export let departments: Ref<Department>[]
|
||||
|
||||
const client = getClient()
|
||||
export let noWeekendHolidayType: Ref<RequestType>[]
|
||||
|
||||
async function getType (request: Request): Promise<RequestType | undefined> {
|
||||
return await client.findOne(hr.class.RequestType, {
|
||||
_id: request.type
|
||||
})
|
||||
}
|
||||
|
||||
function getStyle (type: RequestType, request: Request): string {
|
||||
let res = `background-color: ${
|
||||
(isWeekend(date) || isHoliday(getHolidayDatesForEmployee(staffDepartmentMap, employee._id, holidays), date)) &&
|
||||
noWeekendHolidayType.includes(type._id)
|
||||
? getPlatformColor(16, $themeStore.dark)
|
||||
: getPlatformColor(type.color, $themeStore.dark)
|
||||
};`
|
||||
if (Math.abs(type.value % 1) === 0.5) {
|
||||
res += ' height: 50%;'
|
||||
}
|
||||
if (!departments.includes(request.space)) res += ' opacity: 0.5;'
|
||||
return res
|
||||
}
|
||||
|
||||
function click (e: MouseEvent, request: Request) {
|
||||
if (!editable) return
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
closeTooltip()
|
||||
showPopup(ContextMenu, { object: request }, e.target as HTMLElement)
|
||||
}
|
||||
export let staffDepartmentMap: Map<Ref<Staff>, Department[]>
|
||||
</script>
|
||||
|
||||
<div class="w-full h-full relative p-1 flex">
|
||||
{#each requests as request}
|
||||
{#await getType(request) then type}
|
||||
{#if type}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div
|
||||
class="request flex-center"
|
||||
class:cursor-pointer={editable}
|
||||
style={getStyle(type, request)}
|
||||
on:click={(e) => {
|
||||
click(e, request)
|
||||
}}
|
||||
>
|
||||
<Icon icon={type.icon} size={Math.abs(type.value % 1) !== 0.5 ? 'large' : 'small'} />
|
||||
</div>
|
||||
{/if}
|
||||
{/await}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.request {
|
||||
border-radius: 3px;
|
||||
flex-grow: 1;
|
||||
// height: 100%;
|
||||
width: 30px;
|
||||
}
|
||||
</style>
|
@ -282,40 +282,43 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if staffDepartmentMap.size > 0}
|
||||
{#if mode === CalendarMode.Year}
|
||||
<YearView {departmentStaff} {employeeRequests} {types} {currentDate} {holidays} {staffDepartmentMap} />
|
||||
{:else if mode === CalendarMode.Month}
|
||||
{#if display === 'chart'}
|
||||
<MonthView
|
||||
{departmentStaff}
|
||||
{employeeRequests}
|
||||
{types}
|
||||
{startDate}
|
||||
{endDate}
|
||||
{editableList}
|
||||
{currentDate}
|
||||
{timeReports}
|
||||
{holidays}
|
||||
{department}
|
||||
{departments}
|
||||
{staffDepartmentMap}
|
||||
/>
|
||||
{:else if display === 'stats'}
|
||||
<MonthTableView
|
||||
{departmentStaff}
|
||||
{employeeRequests}
|
||||
{types}
|
||||
{currentDate}
|
||||
{timeReports}
|
||||
{holidays}
|
||||
{staffDepartmentMap}
|
||||
{getHolidays}
|
||||
/>
|
||||
<div class="w-full h-full background-comp-header-color">
|
||||
{#if staffDepartmentMap.size > 0}
|
||||
{#if mode === CalendarMode.Year}
|
||||
<YearView {departmentStaff} {employeeRequests} {types} {currentDate} {holidays} {staffDepartmentMap} />
|
||||
{:else if mode === CalendarMode.Month}
|
||||
{#if display === 'chart'}
|
||||
<MonthView
|
||||
{departmentStaff}
|
||||
{employeeRequests}
|
||||
{types}
|
||||
{startDate}
|
||||
{endDate}
|
||||
{editableList}
|
||||
{currentDate}
|
||||
{timeReports}
|
||||
{holidays}
|
||||
{department}
|
||||
{departments}
|
||||
{departmentById}
|
||||
{staffDepartmentMap}
|
||||
/>
|
||||
{:else if display === 'stats'}
|
||||
<MonthTableView
|
||||
{departmentStaff}
|
||||
{employeeRequests}
|
||||
{types}
|
||||
{currentDate}
|
||||
{timeReports}
|
||||
{holidays}
|
||||
{staffDepartmentMap}
|
||||
{getHolidays}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex-center h-full w-full flex-grow fs-title">
|
||||
<Label label={hr.string.NoEmployeesInDepartment} />
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex-center h-full w-full flex-grow fs-title">
|
||||
<Label label={hr.string.NoEmployeesInDepartment} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
@ -1,5 +1,5 @@
|
||||
<!--
|
||||
// Copyright © 2022 Hardcore Engineering Inc.
|
||||
// Copyright © 2022, 2023 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
@ -14,16 +14,14 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Employee } from '@hcengineering/contact'
|
||||
import { EmployeePresenter } from '@hcengineering/contact-resources'
|
||||
import contact from '@hcengineering/contact-resources/src/plugin'
|
||||
import contact from '@hcengineering/contact'
|
||||
import { AccountRole, getCurrentAccount, Ref } from '@hcengineering/core'
|
||||
import type { Department, Request, RequestType, Staff } from '@hcengineering/hr'
|
||||
import { tzDateCompare, type Department, type Request, type RequestType, type Staff } from '@hcengineering/hr'
|
||||
import {
|
||||
areDatesEqual,
|
||||
day as getDay,
|
||||
daysInMonth,
|
||||
eventToHTMLElement,
|
||||
floorFractionDigits,
|
||||
getWeekDayName,
|
||||
isWeekend,
|
||||
Label,
|
||||
@ -34,12 +32,35 @@
|
||||
deviceOptionsStore as deviceInfo
|
||||
} from '@hcengineering/ui'
|
||||
import hr from '../../plugin'
|
||||
import { EmployeeReports, getHolidayDatesForEmployee, getRequests, getTotal, isHoliday } from '../../utils'
|
||||
import { getHolidayDatesForEmployee, getRequests, isHoliday } from '../../utils'
|
||||
import CreateRequest from '../CreateRequest.svelte'
|
||||
import RequestsPopup from '../RequestsPopup.svelte'
|
||||
import ScheduleRequests from '../ScheduleRequests.svelte'
|
||||
import ReportsPopup from './ReportsPopup.svelte'
|
||||
import CreatePublicHoliday from './CreatePublicHoliday.svelte'
|
||||
import ScheduleRequest from './ScheduleRequest.svelte'
|
||||
import StaffPresenter from './StaffPresenter.svelte'
|
||||
|
||||
const headerHeightRem = 4.375
|
||||
const eventHeightRem = 1.5
|
||||
const eventMarginRem = 0.5
|
||||
const minColWidthRem = 2.5
|
||||
const minRowHeightRem = 4.375
|
||||
|
||||
interface TimelineElement {
|
||||
request: Request
|
||||
date: number
|
||||
dueDate: number
|
||||
length: number
|
||||
}
|
||||
|
||||
interface TimelineRowTrack {
|
||||
elements: TimelineElement[]
|
||||
}
|
||||
|
||||
interface TimelineRow {
|
||||
employee: Staff
|
||||
requests: Request[]
|
||||
tracks: TimelineRowTrack[]
|
||||
}
|
||||
|
||||
export let currentDate: Date = new Date()
|
||||
|
||||
@ -48,16 +69,92 @@
|
||||
|
||||
export let departmentStaff: Staff[]
|
||||
export let department: Ref<Department>
|
||||
export let departments: Ref<Department>[]
|
||||
export let departmentById: Map<Ref<Department>, Department>
|
||||
|
||||
export let employeeRequests: Map<Ref<Staff>, Request[]>
|
||||
export let editableList: Ref<Employee>[]
|
||||
export let types: Map<Ref<RequestType>, RequestType>
|
||||
export let timeReports: Map<Ref<Employee>, EmployeeReports>
|
||||
|
||||
export let staffDepartmentMap: Map<Ref<Staff>, Department[]>
|
||||
export let holidays: Map<Ref<Department>, Date[]>
|
||||
|
||||
const todayDate = new Date()
|
||||
|
||||
function createRequest (e: MouseEvent, date: Date, staff: Staff): void {
|
||||
const noWeekendHolidayType: Ref<RequestType>[] = [hr.ids.PTO, hr.ids.PTO2, hr.ids.Vacation]
|
||||
|
||||
function checkConflict(request1: Request, request2: Request): boolean {
|
||||
return (
|
||||
tzDateCompare(request1.tzDate, request2.tzDueDate) <= 0 && tzDateCompare(request1.tzDueDate, request2.tzDate) >= 0
|
||||
)
|
||||
}
|
||||
|
||||
function getMonthDate(request: Request): number {
|
||||
const year = currentDate.getFullYear()
|
||||
const month = currentDate.getMonth()
|
||||
|
||||
return request.tzDate.year === year && request.tzDate.month === month ? request.tzDate.day : startDate.getDate()
|
||||
}
|
||||
|
||||
function getMonthDueDate(request: Request): number {
|
||||
const year = currentDate.getFullYear()
|
||||
const month = currentDate.getMonth()
|
||||
|
||||
return request.tzDueDate.year === year && request.tzDueDate.month === month
|
||||
? request.tzDueDate.day
|
||||
: endDate.getDate()
|
||||
}
|
||||
|
||||
function getOrderedEmployeeRequests(employee: Staff): Request[] {
|
||||
const requests = getRequests(employeeRequests, startDate, endDate, employee._id)
|
||||
requests.sort((a, b) => {
|
||||
const res = tzDateCompare(a.tzDate, b.tzDate)
|
||||
if (res === 0) {
|
||||
return tzDateCompare(a.tzDueDate, b.tzDueDate)
|
||||
}
|
||||
return res
|
||||
})
|
||||
return requests
|
||||
}
|
||||
|
||||
function buildTimelineRows(): TimelineRow[] {
|
||||
const res: TimelineRow[] = []
|
||||
|
||||
for (const employee of departmentStaff) {
|
||||
const tracks: TimelineRowTrack[] = []
|
||||
|
||||
const requests = getOrderedEmployeeRequests(employee)
|
||||
for (const request of requests) {
|
||||
let found: TimelineRowTrack | undefined = undefined
|
||||
|
||||
for (const track of tracks) {
|
||||
const conflict = track.elements.some((it) => checkConflict(request, it.request))
|
||||
if (!conflict) {
|
||||
found = track
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (found === undefined) {
|
||||
found = { elements: [] }
|
||||
tracks.push(found)
|
||||
}
|
||||
|
||||
const date = getMonthDate(request)
|
||||
const dueDate = getMonthDueDate(request)
|
||||
|
||||
found.elements.push({
|
||||
request,
|
||||
date,
|
||||
dueDate,
|
||||
length: dueDate - date + 1
|
||||
})
|
||||
}
|
||||
|
||||
res.push({ employee, requests, tracks })
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
function createRequest(e: MouseEvent, date: Date, staff: Staff): void {
|
||||
if (!isEditable(staff)) return
|
||||
const readonly = editableList.length === 1
|
||||
|
||||
@ -76,7 +173,7 @@
|
||||
)
|
||||
}
|
||||
|
||||
function isFutureDate () {
|
||||
function isFutureDate() {
|
||||
const today = new Date(Date.now())
|
||||
return (
|
||||
currentDate >= today ||
|
||||
@ -84,20 +181,16 @@
|
||||
)
|
||||
}
|
||||
|
||||
function isEditable (employee: Staff): boolean {
|
||||
function isEditable(employee: Staff): boolean {
|
||||
return editableList.includes(employee._id) && (isFutureDate() || getCurrentAccount().role === AccountRole.Owner)
|
||||
}
|
||||
|
||||
const noWeekendHolidayType: Ref<RequestType>[] = [hr.ids.PTO, hr.ids.PTO2, hr.ids.Vacation]
|
||||
|
||||
function getTooltip (requests: Request[], day: Date, staff: Staff): LabelAndProps | undefined {
|
||||
function getTooltip(requests: Request[], day: Date, staff: Staff): LabelAndProps | undefined {
|
||||
if (requests.length === 0) return
|
||||
if (
|
||||
day &&
|
||||
(isWeekend(day) ||
|
||||
(holidays?.size > 0 && isHoliday(getHolidayDatesForEmployee(staffDepartmentMap, staff._id, holidays), day))) &&
|
||||
requests.some((req) => noWeekendHolidayType.includes(req.type))
|
||||
) {
|
||||
const weekend = isWeekend(day)
|
||||
const holiday =
|
||||
holidays?.size > 0 && isHoliday(getHolidayDatesForEmployee(staffDepartmentMap, staff._id, holidays), day)
|
||||
if (day && (weekend || holiday) && requests.some((req) => noWeekendHolidayType.includes(req.type))) {
|
||||
return
|
||||
}
|
||||
return {
|
||||
@ -106,203 +199,161 @@
|
||||
}
|
||||
}
|
||||
|
||||
$: values = [...Array(daysInMonth(currentDate)).keys()]
|
||||
|
||||
let hoveredIndex: number = -1
|
||||
let hoveredColumn: number = -1
|
||||
|
||||
function findReports (employee: Employee, date: Date, timeReports: Map<Ref<Employee>, EmployeeReports>): number {
|
||||
const wday = date.getDate()
|
||||
return floorFractionDigits(
|
||||
(timeReports.get(employee._id)?.reports ?? [])
|
||||
.filter((it) => new Date(it.date ?? 0).getDate() === wday)
|
||||
.reduce((a, b) => a + b.value, 0),
|
||||
3
|
||||
)
|
||||
}
|
||||
|
||||
function showReportInfo (employee: Staff, rTime: EmployeeReports | undefined): void {
|
||||
if (rTime === undefined) {
|
||||
return
|
||||
}
|
||||
showPopup(ReportsPopup, { employee, reports: rTime.reports }, 'top')
|
||||
}
|
||||
|
||||
function setPublicHoliday (date: Date) {
|
||||
function setPublicHoliday(date: Date) {
|
||||
showPopup(CreatePublicHoliday, { date, department })
|
||||
}
|
||||
|
||||
export let staffDepartmentMap: Map<Ref<Staff>, Department[]>
|
||||
export let holidays: Map<Ref<Department>, Date[]>
|
||||
function getRowHeight(row: TimelineRow): number {
|
||||
const height = row.tracks.length * (eventHeightRem + eventMarginRem) - eventMarginRem + 2
|
||||
return Math.max(height, minRowHeightRem)
|
||||
}
|
||||
|
||||
let colWidth: number
|
||||
$: colWidthRem = colWidth / $deviceInfo.fontSize
|
||||
function getColumnWidth(gridWidth: number, currentDate: Date): number {
|
||||
const width = gridWidth / daysInMonth(currentDate)
|
||||
return Math.max(width, minColWidthRem)
|
||||
}
|
||||
|
||||
export function getCellStyle(): string {
|
||||
return `width: ${columnWidthRem}rem;`
|
||||
}
|
||||
|
||||
export function getRowStyle(row: TimelineRow): string {
|
||||
const height = getRowHeight(row)
|
||||
return `height: ${height}rem;`
|
||||
}
|
||||
|
||||
export function getElementStyle(element: TimelineElement, trackIndex: number): string {
|
||||
const left = (element.date - 1) * columnWidthRem
|
||||
const top = trackIndex * (eventHeightRem + eventMarginRem)
|
||||
const width = columnWidthRem * element.length
|
||||
return `
|
||||
left: ${left}rem;
|
||||
top: ${top}rem;
|
||||
width: ${width}rem;
|
||||
`
|
||||
}
|
||||
|
||||
let headerWidth: number
|
||||
$: headerWidthRem = headerWidth / $deviceInfo.fontSize
|
||||
|
||||
let containerWidth: number
|
||||
$: containerWidthRem = containerWidth / $deviceInfo.fontSize
|
||||
|
||||
$: values = [...Array(daysInMonth(currentDate)).keys()]
|
||||
|
||||
$: columnWidthRem = getColumnWidth(containerWidthRem - headerWidthRem, currentDate)
|
||||
|
||||
let rows: TimelineRow[]
|
||||
$: departmentStaff, employeeRequests, (rows = buildTimelineRows())
|
||||
</script>
|
||||
|
||||
{#if departmentStaff.length}
|
||||
<Scroller fade={{ multipler: { top: 3, bottom: 3, left: colWidthRem } }} horizontal>
|
||||
<table class="scroller-first-column">
|
||||
<thead class="scroller-thead">
|
||||
<tr class="scroller-thead__tr">
|
||||
<th>
|
||||
<div class="fullfill center">
|
||||
<Label label={contact.string.Employee} />
|
||||
</div>
|
||||
</th>
|
||||
<th>#</th>
|
||||
<th>##</th>
|
||||
{#each values as value, i}
|
||||
{@const day = getDay(startDate, value)}
|
||||
<th
|
||||
class:today={areDatesEqual(todayDate, day)}
|
||||
class:holiday={isHoliday([...holidays.values()].flat(), day)}
|
||||
class:weekend={isWeekend(day)}
|
||||
class:hoveredCell={hoveredColumn === i}
|
||||
on:mousemove={() => {
|
||||
hoveredIndex = i
|
||||
}}
|
||||
on:mouseleave={() => {
|
||||
hoveredIndex = -1
|
||||
}}
|
||||
on:click={() => isFutureDate() && setPublicHoliday(day)}
|
||||
>
|
||||
{getWeekDayName(day, 'short')}
|
||||
<span>{day.getDate()}</span>
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each departmentStaff as employee, row}
|
||||
{@const requests = employeeRequests.get(employee._id) ?? []}
|
||||
{@const rTime = timeReports.get(employee._id)}
|
||||
<tr>
|
||||
<td bind:clientWidth={colWidth}>
|
||||
<div class="fullfill">
|
||||
<EmployeePresenter value={employee} />
|
||||
{#if rows.length}
|
||||
{@const dep = departmentById.get(department)}
|
||||
|
||||
<Scroller fade={{ multipler: { top: headerHeightRem, bottom: 0, left: headerWidthRem } }} horizontal>
|
||||
<div bind:clientWidth={containerWidth} class="timeline">
|
||||
{#key [containerWidthRem, columnWidthRem, headerWidthRem]}
|
||||
<!-- Resource Header -->
|
||||
<div bind:clientWidth={headerWidth} class="timeline-header timeline-resource-header">
|
||||
<div class="timeline-row">
|
||||
<div class="timeline-resource-cell">
|
||||
<div class="timeline-resource-header__title">
|
||||
{dep?.name}
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="flex-center p-1 whitespace-nowrap text-center"
|
||||
class:firstLine={row === 0}
|
||||
class:lastLine={row === departmentStaff.length - 1}
|
||||
>
|
||||
{getTotal(
|
||||
requests,
|
||||
startDate,
|
||||
endDate,
|
||||
types,
|
||||
getHolidayDatesForEmployee(staffDepartmentMap, employee._id, holidays)
|
||||
)}
|
||||
</td>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<td
|
||||
class="p-1 text-center whitespace-nowrap cursor-pointer"
|
||||
on:click={() => showReportInfo(employee, rTime)}
|
||||
>
|
||||
{#if rTime !== undefined}
|
||||
{floorFractionDigits(rTime.value, 3)}
|
||||
({rTime.tasks.size})
|
||||
{:else}
|
||||
0
|
||||
{/if}
|
||||
</td>
|
||||
{#each values as value, i}
|
||||
<div class="timeline-resource-header__subtitle">
|
||||
<Label label={contact.string.NumberMembers} params={{ count: rows.length }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resource Content -->
|
||||
<div bind:clientWidth={headerWidth} class="timeline-resource-content">
|
||||
{#each rows as row}
|
||||
<div class="timeline-row" style={getRowStyle(row)}>
|
||||
<div class="timeline-resource-cell">
|
||||
<StaffPresenter value={row.employee} />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Grid Header -->
|
||||
<div class="timeline-header timeline-grid-header">
|
||||
<div class="timeline-row flex">
|
||||
{#each values as value}
|
||||
{@const day = getDay(startDate, value)}
|
||||
{@const requests = getRequests(employeeRequests, day, day, employee._id)}
|
||||
{@const editable = isEditable(employee)}
|
||||
{@const tooltipValue = getTooltip(requests, day, employee)}
|
||||
{@const ww = findReports(employee, day, timeReports)}
|
||||
{#key [tooltipValue, editable]}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<td
|
||||
class="w-9 max-w-9 min-w-9"
|
||||
class:today={areDatesEqual(todayDate, day)}
|
||||
class:holiday={isHoliday(getHolidayDatesForEmployee(staffDepartmentMap, employee._id, holidays), day)}
|
||||
class:weekend={isWeekend(day) ||
|
||||
isHoliday(getHolidayDatesForEmployee(staffDepartmentMap, employee._id, holidays), day)}
|
||||
class:cursor-pointer={editable}
|
||||
class:hovered={i === hoveredIndex}
|
||||
class:firstLine={row === 0}
|
||||
class:lastLine={row === departmentStaff.length - 1}
|
||||
use:tooltip={tooltipValue}
|
||||
on:click={(e) => createRequest(e, day, employee)}
|
||||
on:mousemove={() => {
|
||||
hoveredColumn = i
|
||||
}}
|
||||
on:mouseleave={() => {
|
||||
hoveredColumn = -1
|
||||
}}
|
||||
{@const today = areDatesEqual(todayDate, day)}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div
|
||||
class="timeline-cell timeline-day-header flex-col-center justify-center"
|
||||
style={getCellStyle()}
|
||||
on:click={() => isFutureDate() && setPublicHoliday(day)}
|
||||
>
|
||||
<div
|
||||
class="timeline-day-header__day flex-col-center justify-center"
|
||||
class:timeline-day-header__day--today={today}
|
||||
>
|
||||
<div class:worked={ww > 0} class="h-full w-full">
|
||||
{#if requests.length}
|
||||
<ScheduleRequests
|
||||
{employeeRequests}
|
||||
{departments}
|
||||
{requests}
|
||||
{editable}
|
||||
date={day}
|
||||
{noWeekendHolidayType}
|
||||
{holidays}
|
||||
{employee}
|
||||
{staffDepartmentMap}
|
||||
/>
|
||||
{day.getDate()}
|
||||
</div>
|
||||
<div class="timeline-day-header__weekday">{getWeekDayName(day, 'short')}</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grid Content -->
|
||||
<div class="timeline-grid-content timeline-grid-bg">
|
||||
{#each rows as row}
|
||||
{@const employee = row.employee}
|
||||
{@const tracks = row.tracks}
|
||||
{@const editable = isEditable(employee)}
|
||||
|
||||
<div class="timeline-row flex" style={getRowStyle(row)}>
|
||||
<div class="timeline-events">
|
||||
{#each tracks as track, trackIndex}
|
||||
{#each track.elements as element}
|
||||
{@const request = element.request}
|
||||
<div class="timeline-event-wrapper" style={getElementStyle(element, trackIndex)}>
|
||||
<ScheduleRequest {request} {editable} shouldShowDescription={element.length > 1} />
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#each values as value, i}
|
||||
{@const day = getDay(startDate, value)}
|
||||
{@const today = areDatesEqual(todayDate, day)}
|
||||
{@const weekend = isWeekend(day)}
|
||||
{@const holiday = isHoliday(
|
||||
getHolidayDatesForEmployee(staffDepartmentMap, employee._id, holidays),
|
||||
day
|
||||
)}
|
||||
{@const requests = getRequests(employeeRequests, day, day, employee._id)}
|
||||
{@const tooltipValue = getTooltip(requests, day, employee)}
|
||||
|
||||
{#key [tooltipValue, editable]}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div
|
||||
class="timeline-cell"
|
||||
class:timeline-cell--today={today}
|
||||
class:timeline-cell--weekend={weekend}
|
||||
class:timeline-cell--holiday={holiday}
|
||||
style={getCellStyle()}
|
||||
use:tooltip={tooltipValue}
|
||||
on:click={(e) => createRequest(e, day, employee)}
|
||||
>
|
||||
{#if today}
|
||||
<div class="timeline-cell-today-marker" />
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
{/key}
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
<tfoot class="scroller-tfoot">
|
||||
<tr>
|
||||
<td class="summary">
|
||||
<div class="fullfill">
|
||||
<Label label={hr.string.Summary} />
|
||||
{/key}
|
||||
{/each}
|
||||
</div>
|
||||
</td>
|
||||
<td class="flex-center p-1 whitespace-nowrap text-center summary">
|
||||
{getTotal(
|
||||
Array.from(employeeRequests.values()).flat(),
|
||||
startDate,
|
||||
endDate,
|
||||
types,
|
||||
[...holidays.values()].flat()
|
||||
)}
|
||||
</td>
|
||||
<td class="p-1 text-center summary">
|
||||
{floorFractionDigits(
|
||||
Array.from(timeReports.values())
|
||||
.flat()
|
||||
.reduce((a, b) => a + b.value, 0),
|
||||
3
|
||||
)}
|
||||
</td>
|
||||
{#each values as value, i}
|
||||
{@const day = getDay(startDate, value)}
|
||||
<td
|
||||
class="p-1 text-center summary"
|
||||
class:hovered={i === hoveredIndex}
|
||||
class:holiday={isHoliday(
|
||||
getHolidayDatesForEmployee(staffDepartmentMap, departmentStaff[0]._id, holidays),
|
||||
day
|
||||
)}
|
||||
class:weekend={isWeekend(day)}
|
||||
class:today={areDatesEqual(todayDate, day)}
|
||||
on:mousemove={() => {
|
||||
hoveredColumn = i
|
||||
}}
|
||||
on:mouseleave={() => {
|
||||
hoveredColumn = -1
|
||||
}}
|
||||
>
|
||||
{getTotal([...employeeRequests.values()].flat(), day, day, types, [...holidays.values()].flat())}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
{/key}
|
||||
</div>
|
||||
</Scroller>
|
||||
{:else}
|
||||
<div class="flex-center h-full w-full flex-grow fs-title">
|
||||
@ -311,107 +362,162 @@
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
table {
|
||||
position: relative;
|
||||
$timeline-row-height: 4.375rem;
|
||||
$timeline-header-height: 4.5rem;
|
||||
$timeline-column-width: 2rem;
|
||||
|
||||
$timeline-bg-color: var(--theme-comp-header-color);
|
||||
$timeline-border-color: var(--theme-bg-divider-color);
|
||||
$timeline-border: 1px solid $timeline-border-color;
|
||||
|
||||
.timeline {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
|
||||
td,
|
||||
th {
|
||||
width: 2rem;
|
||||
min-width: 1.5rem;
|
||||
border: none;
|
||||
&:first-child {
|
||||
width: 15rem;
|
||||
}
|
||||
}
|
||||
th {
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
height: 3rem;
|
||||
min-height: 3rem;
|
||||
max-height: 3rem;
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
font-size: 0.75rem;
|
||||
line-height: 105%;
|
||||
color: var(--theme-halfcontent-color);
|
||||
box-shadow: inset 0 -1px 0 0 var(--theme-divider-color);
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
.timeline-header {
|
||||
height: $timeline-row-height;
|
||||
background-color: $timeline-bg-color;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
}
|
||||
&.today {
|
||||
color: var(--theme-calendar-today-color);
|
||||
}
|
||||
&.holiday:not(.today) {
|
||||
color: var(--theme-calendar-holiday-color);
|
||||
}
|
||||
&.weekend:not(.today) {
|
||||
color: var(--theme-calendar-weekend-color);
|
||||
}
|
||||
&.hoveredCell {
|
||||
background-color: var(--theme-button-pressed);
|
||||
}
|
||||
}
|
||||
td {
|
||||
height: 3.5rem;
|
||||
border: none;
|
||||
color: var(--caption-color);
|
||||
&.today {
|
||||
background-color: var(--theme-calendar-today-bgcolor);
|
||||
}
|
||||
&.summary {
|
||||
font-weight: 600;
|
||||
}
|
||||
&.weekend:not(.today) {
|
||||
background-color: var(--theme-calendar-weekend-bgcolor);
|
||||
}
|
||||
&.holiday:not(.today) {
|
||||
background-color: var(--theme-calendar-holiday-bgcolor);
|
||||
}
|
||||
}
|
||||
td:not(:last-child) {
|
||||
border-right: 1px solid var(--theme-bg-divider-color);
|
||||
}
|
||||
|
||||
tbody tr:not(:last-child),
|
||||
thead th:first-child .fullfill,
|
||||
tfoot tr {
|
||||
border-bottom: 1px solid var(--theme-bg-divider-color);
|
||||
}
|
||||
tfoot tr,
|
||||
tfoot tr td:first-child .fullfill {
|
||||
box-shadow: inset 0 1px 0 0 var(--theme-divider-color);
|
||||
}
|
||||
tfoot tr,
|
||||
tfoot tr td {
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
tr.scroller-thead__tr:not(:last-child) {
|
||||
border-right: 1px solid var(--theme-bg-divider-color);
|
||||
}
|
||||
|
||||
.hovered {
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
content: '';
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--theme-caption-color);
|
||||
opacity: 0.15;
|
||||
}
|
||||
.timeline-row {
|
||||
height: $timeline-row-height;
|
||||
min-height: $timeline-row-height;
|
||||
}
|
||||
}
|
||||
.worked {
|
||||
background-color: var(--highlight-select);
|
||||
|
||||
.timeline-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
|
||||
&.timeline-resource-header {
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-resource-header__title {
|
||||
white-space: nowrap;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.timeline-resource-header__subtitle {
|
||||
white-space: nowrap;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.25rem;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.timeline-resource-content {
|
||||
background-color: $timeline-bg-color;
|
||||
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.timeline-day-header {
|
||||
cursor: pointer;
|
||||
|
||||
.timeline-day-header__day {
|
||||
width: 1.3125rem;
|
||||
height: 1.3125rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
|
||||
&.timeline-day-header__day--today {
|
||||
color: white;
|
||||
background-color: #3871e0;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-day-header__weekday {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.25rem;
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-grid-bg {
|
||||
background-image: linear-gradient(
|
||||
135deg,
|
||||
$timeline-border-color 8.33%,
|
||||
$timeline-bg-color 8.33%,
|
||||
$timeline-bg-color 50%,
|
||||
$timeline-border-color 50%,
|
||||
$timeline-border-color 58.33%,
|
||||
$timeline-bg-color 58.33%,
|
||||
$timeline-bg-color 100%
|
||||
);
|
||||
background-size: 6px 6px;
|
||||
}
|
||||
|
||||
.timeline-row {
|
||||
position: relative;
|
||||
height: $timeline-row-height;
|
||||
border-bottom: $timeline-border;
|
||||
}
|
||||
|
||||
.timeline-events {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
top: 1em;
|
||||
bottom: 1em;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.timeline-cell {
|
||||
border-right: $timeline-border;
|
||||
|
||||
width: $timeline-column-width;
|
||||
height: 100%;
|
||||
|
||||
&:not(.timeline-cell--weekend, .timeline-cell--holiday) {
|
||||
background-color: $timeline-bg-color;
|
||||
}
|
||||
|
||||
&.timeline-cell--holiday {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&.timeline-cell--weekend {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&.timeline-cell--today {
|
||||
background-color: $timeline-bg-color;
|
||||
}
|
||||
|
||||
.timeline-cell-today-marker {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--theme-calendar-today-bgcolor);
|
||||
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-resource-cell {
|
||||
border-right: $timeline-border;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 1rem 2rem;
|
||||
}
|
||||
|
||||
.timeline-event-wrapper {
|
||||
position: absolute;
|
||||
height: 1.5rem;
|
||||
padding-left: 0.125rem;
|
||||
padding-right: 0.125rem;
|
||||
|
||||
pointer-events: all;
|
||||
}
|
||||
</style>
|
||||
|
@ -0,0 +1,99 @@
|
||||
<!--
|
||||
// Copyright © 2023 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import hr, { Request, RequestType } from '@hcengineering/hr'
|
||||
import { getClient } from '@hcengineering/presentation'
|
||||
import { closeTooltip, Icon, Label, showPopup } from '@hcengineering/ui'
|
||||
import { ContextMenu, HTMLPresenter } from '@hcengineering/view-resources'
|
||||
|
||||
export let request: Request
|
||||
export let editable: boolean = false
|
||||
export let shouldShowDescription: boolean = true
|
||||
|
||||
const client = getClient()
|
||||
|
||||
async function getType (request: Request): Promise<RequestType | undefined> {
|
||||
return await client.findOne(hr.class.RequestType, {
|
||||
_id: request.type
|
||||
})
|
||||
}
|
||||
|
||||
function isAvailable (type: RequestType, request: Request): boolean {
|
||||
// TODO Move availability to the Request model
|
||||
const available = type.value >= 0
|
||||
return available
|
||||
}
|
||||
|
||||
function click (e: MouseEvent, request: Request) {
|
||||
if (!editable) return
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
closeTooltip()
|
||||
showPopup(ContextMenu, { object: request }, e.target as HTMLElement)
|
||||
}
|
||||
</script>
|
||||
|
||||
{#await getType(request) then type}
|
||||
{#if type}
|
||||
{@const available = isAvailable(type, request)}
|
||||
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div
|
||||
class="request flex-row-center flex-gap-2"
|
||||
class:flex-center={!shouldShowDescription}
|
||||
class:request--available={available}
|
||||
class:request--unavailable={!available}
|
||||
class:cursor-pointer={editable}
|
||||
on:click={(e) => click(e, request)}
|
||||
>
|
||||
<Icon
|
||||
icon={type.icon}
|
||||
size={'small'}
|
||||
fill={available ? 'var(--highlight-blue-01)' : 'var(--primary-color-orange-02)'}
|
||||
/>
|
||||
|
||||
{#if shouldShowDescription}
|
||||
<span class="overflow-label">
|
||||
{#if request.description !== ''}
|
||||
<HTMLPresenter value={request.description} />
|
||||
{:else if type}
|
||||
<Label label={type.label} />
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/await}
|
||||
|
||||
<style lang="scss">
|
||||
.request {
|
||||
border-radius: 0.25rem;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
padding: 0rem 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.request--available {
|
||||
border: 1px solid var(--theme-calendar-event-available-color);
|
||||
background-color: var(--theme-calendar-event-available-bgcolor);
|
||||
}
|
||||
|
||||
.request--unavailable {
|
||||
border: 1px solid var(--theme-calendar-event-unavailable-color);
|
||||
background-color: var(--theme-calendar-event-unavailable-bgcolor);
|
||||
}
|
||||
</style>
|
@ -0,0 +1,68 @@
|
||||
<!--
|
||||
// Copyright © 2023 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { getName } from '@hcengineering/contact'
|
||||
import { Avatar } from '@hcengineering/contact-resources'
|
||||
import hr, { Department, Staff } from '@hcengineering/hr'
|
||||
import { getClient } from '@hcengineering/presentation'
|
||||
import { DocNavLink } from '@hcengineering/view-resources'
|
||||
|
||||
export let value: Staff
|
||||
|
||||
const client = getClient()
|
||||
|
||||
async function getDepartment (staff: Staff): Promise<Department | undefined> {
|
||||
return await client.findOne(hr.class.Department, {
|
||||
_id: staff.department
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if value}
|
||||
<DocNavLink object={value}>
|
||||
<div class="flex-row-center">
|
||||
<div class="member-icon mr-2">
|
||||
<Avatar size={'medium'} avatar={value.avatar} />
|
||||
</div>
|
||||
<div class="flex-col">
|
||||
<div class="member-title fs-title">
|
||||
{getName(value)}
|
||||
</div>
|
||||
{#await getDepartment(value) then department}
|
||||
{#if department}
|
||||
<div class="member-department text-md">
|
||||
{department.name}
|
||||
</div>
|
||||
{/if}
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
</DocNavLink>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.member-icon {
|
||||
color: var(--theme-dark-color);
|
||||
}
|
||||
.member-title {
|
||||
color: var(--theme-caption-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
.member-department {
|
||||
color: var(--theme-caption-color);
|
||||
opacity: 0.6;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
@ -39,3 +39,18 @@ export function fromTzDate (tzDate: TzDate): number {
|
||||
export function tzDateEqual (tzDate: TzDate, tzDate2: TzDate): boolean {
|
||||
return tzDate.year === tzDate2.year && tzDate.month === tzDate2.month && tzDate.day === tzDate2.day
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function tzDateCompare (tzDate1: TzDate, tzDate2: TzDate): number {
|
||||
if (tzDate1.year === tzDate2.year) {
|
||||
if (tzDate1.month === tzDate2.month) {
|
||||
return tzDate1.day - tzDate2.day
|
||||
} else {
|
||||
return tzDate1.month - tzDate2.month
|
||||
}
|
||||
} else {
|
||||
return tzDate1.year - tzDate2.year
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user