Fix HR statistics (#2242)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2022-07-20 22:21:52 +07:00 committed by GitHub
parent 0e0b8ab766
commit 261284df29
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 559 additions and 308 deletions

View File

@ -1,11 +1,21 @@
# Changelog
## 0.6.31 (upcoming)
## 0.6.32 (upcoming)
## 0.6.31
Core:
- Fix password change settings
- Fix settings collapse
- Allow to add multiple enum values
- Fix password change issues
- Fix minxin query
HR:
- Talant with Active/Inactive Application filter
- Improve PTO table statistics
## 0.6.30

View File

@ -14,8 +14,8 @@
//
import { Employee } from '@anticrm/contact'
import { Arr, Class, Domain, DOMAIN_MODEL, IndexKind, Markup, Ref, Timestamp } from '@anticrm/core'
import { Department, DepartmentMember, hrId, Request, RequestType, Staff } from '@anticrm/hr'
import { Arr, Class, Domain, DOMAIN_MODEL, IndexKind, Markup, Ref, Type } from '@anticrm/core'
import { Department, DepartmentMember, hrId, Request, RequestType, Staff, TzDate } from '@anticrm/hr'
import {
ArrOf,
Builder,
@ -25,7 +25,6 @@ import {
Mixin,
Model,
Prop,
TypeDate,
TypeIntlString,
TypeMarkup,
TypeRef,
@ -36,8 +35,8 @@ import attachment from '@anticrm/model-attachment'
import calendar from '@anticrm/model-calendar'
import chunter from '@anticrm/model-chunter'
import contact, { TEmployee, TEmployeeAccount } from '@anticrm/model-contact'
import core, { TAttachedDoc, TDoc, TSpace } from '@anticrm/model-core'
import view, { createAction } from '@anticrm/model-view'
import core, { TAttachedDoc, TDoc, TSpace, TType } from '@anticrm/model-core'
import view, { classPresenter, createAction } from '@anticrm/model-view'
import workbench from '@anticrm/model-workbench'
import { Asset, IntlString } from '@anticrm/platform'
import hr from './plugin'
@ -95,6 +94,22 @@ export class TRequestType extends TDoc implements RequestType {
color!: number
}
@Model(hr.class.TzDate, core.class.Type)
@UX(core.string.Timestamp)
export class TTzDate extends TType {
year!: number
month!: number
day!: number
offset!: number
}
/**
* @public
*/
export function TypeTzDate (): Type<TzDate> {
return { _class: hr.class.TzDate, label: core.string.Timestamp }
}
@Model(hr.class.Request, core.class.AttachedDoc, DOMAIN_HR)
@UX(hr.string.Request, hr.icon.PTO)
export class TRequest extends TAttachedDoc implements Request {
@ -123,18 +138,15 @@ export class TRequest extends TAttachedDoc implements Request {
@Index(IndexKind.FullText)
description!: Markup
@Prop(TypeDate(false), calendar.string.Date)
date!: Timestamp
@Prop(TypeTzDate(), calendar.string.Date)
tzDate!: TzDate
@Prop(TypeDate(false), calendar.string.DueTo)
dueDate!: Timestamp
// @Prop(TypeNumber(), calendar.string.Date)
timezoneOffset!: number
@Prop(TypeTzDate(), calendar.string.DueTo)
tzDueDate!: TzDate
}
export function createModel (builder: Builder): void {
builder.createModel(TDepartment, TDepartmentMember, TRequest, TRequestType, TStaff)
builder.createModel(TDepartment, TDepartmentMember, TRequest, TRequestType, TStaff, TTzDate)
builder.createDoc(
workbench.class.Application,
@ -183,6 +195,8 @@ export function createModel (builder: Builder): void {
editor: hr.component.DepartmentStaff
})
classPresenter(builder, hr.class.TzDate, hr.component.TzDatePresenter, hr.component.TzDateEditor)
builder.createDoc(
hr.class.RequestType,
core.space.Model,
@ -337,6 +351,18 @@ export function createModel (builder: Builder): void {
hr.viewlet.TableMember
)
builder.createDoc(
view.class.Viewlet,
core.space.Model,
{
attachTo: hr.mixin.Staff,
descriptor: view.viewlet.Table,
config: [''],
hiddenKeys: []
},
hr.viewlet.StaffStats
)
createAction(builder, {
action: view.actionImpl.ValueSelector,
actionPopup: view.component.ValueSelector,
@ -359,6 +385,10 @@ export function createModel (builder: Builder): void {
group: 'associate'
}
})
builder.mixin(hr.class.Request, core.class.Class, view.mixin.AttributePresenter, {
presenter: hr.component.RequestPresenter
})
}
export { hrOperation } from './migration'

View File

@ -13,8 +13,9 @@
// limitations under the License.
//
import { DOMAIN_TX, SortingOrder, TxCreateDoc, TxOperations, TxUpdateDoc } from '@anticrm/core'
import { Request } from '@anticrm/hr'
import { Employee } from '@anticrm/contact'
import { DOMAIN_TX, TxCollectionCUD, TxCreateDoc, TxOperations, TxUpdateDoc } from '@anticrm/core'
import { Request, TzDate } from '@anticrm/hr'
import { MigrateOperation, MigrationClient, MigrationUpgradeClient } from '@anticrm/model'
import core from '@anticrm/model-core'
import hr, { DOMAIN_HR } from './index'
@ -40,102 +41,72 @@ async function createSpace (tx: TxOperations): Promise<void> {
}
}
function toUTC (date: Date | number): number {
function toTzDate (date: number): TzDate {
const res = new Date(date)
if (res.getUTCFullYear() !== res.getFullYear()) {
res.setUTCFullYear(res.getFullYear())
return {
year: res.getFullYear(),
month: res.getMonth(),
day: res.getDate(),
offset: res.getTimezoneOffset()
}
if (res.getUTCMonth() !== res.getMonth()) {
res.setUTCMonth(res.getMonth())
}
if (res.getUTCDate() !== res.getDate()) {
res.setUTCDate(res.getDate())
}
return res.setUTCHours(12, 0, 0, 0)
}
function isDefault (date: number, due: number): boolean {
const start = new Date(date)
const end = new Date(due)
if (start.getDate() === end.getDate() && end.getHours() - start.getHours() === 12) {
return true
}
if (start.getDate() + 1 === end.getDate() && end.getHours() === start.getHours()) {
return true
}
return false
}
async function migrateRequestTime (client: MigrationClient, request: Request): Promise<void> {
const date = toUTC(request.date)
const dueDate = isDefault(request.date, request.dueDate) ? date : toUTC(request.dueDate)
const date = toTzDate((request as any).date as unknown as number)
const dueDate = toTzDate((request as any).dueDate as unknown as number)
await client.update(
DOMAIN_HR,
{ _id: request._id },
{
date,
dueDate
tzDate: date,
tzDueDate: dueDate
}
)
const updateDateTx = (
await client.find<TxUpdateDoc<Request>>(
DOMAIN_TX,
{ _class: core.class.TxUpdateDoc, objectId: request._id, 'operations.date': { $exists: true } },
{ sort: { modifiedOn: SortingOrder.Descending } }
)
)[0]
if (updateDateTx !== undefined) {
const operations = updateDateTx.operations
operations.dueDate = date
await client.update(
DOMAIN_TX,
{ _id: updateDateTx._id },
{
operations
}
)
}
const updateDueTx = (
await client.find<TxUpdateDoc<Request>>(
DOMAIN_TX,
{ _class: core.class.TxUpdateDoc, objectId: request._id, 'operations.dueDate': { $exists: true } },
{ sort: { modifiedOn: SortingOrder.Descending } }
)
)[0]
if (updateDueTx !== undefined) {
const operations = updateDueTx.operations
operations.dueDate = dueDate
await client.update(
DOMAIN_TX,
{ _id: updateDateTx._id },
{
operations
}
)
}
const txes = await client.find<TxCollectionCUD<Employee, Request>>(DOMAIN_TX, {
'tx._class': { $in: [core.class.TxCreateDoc, core.class.TxUpdateDoc] },
'tx.objectId': request._id
})
if (updateDueTx === undefined || updateDateTx === undefined) {
const createTx = (
await client.find<TxCreateDoc<Request>>(
for (const utx of txes) {
if (utx.tx._class === core.class.TxCreateDoc) {
const ctx = utx.tx as TxCreateDoc<Request>
const { date, dueDate, ...attributes } = ctx.attributes as any
await client.update(
DOMAIN_TX,
{ _class: core.class.TxCreateDoc, objectId: request._id },
{ sort: { modifiedOn: SortingOrder.Descending } }
)
)[0]
if (createTx !== undefined) {
const attributes = createTx.attributes
if (updateDateTx === undefined) {
attributes.date = date
{ _id: utx._id },
{
tx: {
...ctx,
attributes: {
...attributes,
tzDate: toTzDate(date as unknown as number),
tzDueDate: toTzDate((dueDate ?? date) as unknown as number)
}
if (updateDueTx === undefined) {
attributes.dueDate = dueDate
}
}
)
}
if (utx.tx._class === core.class.TxUpdateDoc) {
const ctx = utx.tx as TxUpdateDoc<Request>
const { date, dueDate, ...operations } = ctx.operations as any
const ops: any = {
...operations
}
if (date !== undefined) {
ops.tzDate = toTzDate(date as unknown as number)
}
if (dueDate !== undefined) {
ops.tzDueDate = toTzDate(dueDate as unknown as number)
}
await client.update(
DOMAIN_TX,
{ _id: createTx._id },
{ _id: utx._id },
{
attributes
tx: {
...ctx,
operations: ops
}
}
)
}
@ -143,7 +114,34 @@ async function migrateRequestTime (client: MigrationClient, request: Request): P
}
async function migrateTime (client: MigrationClient): Promise<void> {
const requests = await client.find<Request>(DOMAIN_HR, { _class: hr.class.Request })
const createTxes = await client.find<TxCreateDoc<Request>>(DOMAIN_TX, {
_class: core.class.TxCreateDoc,
objectClass: hr.class.Request
})
for (const tx of createTxes) {
await client.update(
DOMAIN_TX,
{ _id: tx._id },
{
_class: core.class.TxCollectionCUD,
tx: tx,
collection: tx.attributes.collection,
objectId: tx.attributes.attachedTo,
objectClass: tx.attributes.attachedToClass
}
)
await client.update(
DOMAIN_TX,
{ _id: tx._id },
{
$unset: {
attributes: ''
}
}
)
}
const requests = await client.find<Request>(DOMAIN_HR, { _class: hr.class.Request, tzDate: { $exists: false } })
for (const request of requests) {
await migrateRequestTime(client, request)
}

View File

@ -39,7 +39,10 @@ export default mergeIds(hrId, hr, {
DepartmentStaff: '' as AnyComponent,
DepartmentEditor: '' as AnyComponent,
Schedule: '' as AnyComponent,
EditRequest: '' as AnyComponent
EditRequest: '' as AnyComponent,
TzDatePresenter: '' as AnyComponent,
TzDateEditor: '' as AnyComponent,
RequestPresenter: '' as AnyComponent
},
category: {
HR: '' as Ref<ActionCategory>

View File

@ -1,4 +1,5 @@
import {
ArrayAsElementPosition,
Client,
Doc,
DocumentQuery,
@ -6,16 +7,25 @@ import {
FindOptions,
IncOptions,
ObjQueryType,
OmitNever,
PushOptions,
Ref
} from '@anticrm/core'
/**
* @public
*/
export interface UnsetOptions<T extends object> {
$unset?: Partial<OmitNever<ArrayAsElementPosition<Required<T>>>>
}
/**
* @public
*/
export type MigrateUpdate<T extends Doc> = Partial<T> &
Omit<PushOptions<T>, '$move'> &
IncOptions<T> & {
IncOptions<T> &
UnsetOptions<T> & {
// For any other mongo stuff
[key: string]: any
}

View File

@ -23,13 +23,16 @@
export let is: AnyComponent
export let props = {}
export let shrink: boolean = false
export let showLoading = true
$: component = is != null ? getResource(is) : Promise.reject(new Error('is not defined'))
</script>
{#if is}
{#await component}
{#if showLoading}
<Loading {shrink} />
{/if}
{:then Ctor}
<ErrorBoundary>
<Ctor {...props} on:change on:close on:open on:click on:delete>

View File

@ -22,7 +22,7 @@
import ui, { Button, DateRangePresenter, DropdownLabelsIntl, IconAttachment } from '@anticrm/ui'
import { createEventDispatcher } from 'svelte'
import hr from '../plugin'
import { toUTC } from '../utils'
import { toTzDate } from '../utils'
export let staff: Staff
export let date: Date
@ -59,15 +59,11 @@
if (value != null) date = value
if (date === undefined) return
if (type === undefined) return
await client.createDoc(hr.class.Request, staff.department, {
attachedTo: staff._id,
attachedToClass: staff._class,
await client.addCollection(hr.class.Request, staff.department, staff._id, staff._class, 'requests', {
type: type._id,
date: toUTC(date),
dueDate: toUTC(dueDate),
description,
collection: 'requests',
timezoneOffset: new Date(date).getTimezoneOffset()
tzDate: toTzDate(new Date(date)),
tzDueDate: toTzDate(new Date(dueDate)),
description
})
await descriptionBox.createAttachments()
}

View File

@ -0,0 +1,44 @@
<!--
// Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021 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 { Request } from '@anticrm/hr'
import { getClient } from '@anticrm/presentation'
import { DateRangePresenter, Label } from '@anticrm/ui'
import { fromTzDate, tzDateEqual } from '../utils'
export let value: Request | null | undefined
export let noShift: boolean = false
const client = getClient()
$: type = value?.type !== undefined ? client.getModel().getObject(value?.type) : undefined
</script>
{#if type && value != null}
<div class="flex-row-center gap-2">
<div class="fs-title">
<Label label={type.label} />
</div>
{#if value.tzDate && tzDateEqual(value.tzDate, value.tzDueDate)}
<DateRangePresenter value={fromTzDate(value.tzDate)} {noShift} />
{:else if value.tzDate}
<DateRangePresenter value={fromTzDate(value.tzDate)} {noShift} />
{#if value.tzDueDate}
<DateRangePresenter value={fromTzDate(value.tzDueDate)} {noShift} />
{/if}
{/if}
</div>
{/if}

View File

@ -14,21 +14,17 @@
-->
<script lang="ts">
import { Ref, SortingOrder } from '@anticrm/core'
import hr, { Staff } from '@anticrm/hr'
import hr, { Request } from '@anticrm/hr'
import { Table } from '@anticrm/view-resources'
export let date: Date
export let endDate: number
export let employee: Ref<Staff>
export let requests: Ref<Request>[]
</script>
<Table
_class={hr.class.Request}
query={{
attachedTo: employee,
dueDate: { $gt: date.getTime() },
date: { $lt: endDate }
_id: { $in: requests }
}}
config={['$lookup.type.label', 'date', 'dueDate']}
config={['$lookup.type.label', 'tzDate', 'tzDueDate']}
options={{ sort: { date: SortingOrder.Ascending } }}
/>

View File

@ -17,12 +17,10 @@
import calendar from '@anticrm/calendar-resources/src/plugin'
import { Ref } from '@anticrm/core'
import { Department } from '@anticrm/hr'
import { getEmbeddedLabel } from '@anticrm/platform'
import { createQuery, SpaceSelector } from '@anticrm/presentation'
import { Button, Icon, IconBack, IconForward, Label } from '@anticrm/ui'
import view from '@anticrm/view'
import hr from '../plugin'
import { tableToCSV } from '../utils'
import ScheduleMonthView from './ScheduleView.svelte'
let department = hr.ids.Head
@ -140,23 +138,6 @@
}}
/>
</div>
{#if display === 'stats'}
<Button
label={getEmbeddedLabel('Export')}
on:click={() => {
// Download it
const filename = 'exportStaff' + new Date().toLocaleDateString() + '.csv'
const link = document.createElement('a')
link.style.display = 'none'
link.setAttribute('target', '_blank')
link.setAttribute('href', 'data:text/csv;charset=utf-8,' + encodeURIComponent(tableToCSV('exportableData')))
link.setAttribute('download', filename)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}}
/>
{/if}
{/if}
<SpaceSelector _class={hr.class.Department} label={hr.string.Department} bind:space={department} />

View File

@ -15,7 +15,7 @@
<script lang="ts">
import { CalendarMode } from '@anticrm/calendar-resources'
import { Employee } from '@anticrm/contact'
import { Ref, Timestamp } from '@anticrm/core'
import { Ref } from '@anticrm/core'
import type { Department, Request, RequestType, Staff } from '@anticrm/hr'
import { createQuery } from '@anticrm/presentation'
import { Label } from '@anticrm/ui'
@ -33,11 +33,12 @@
$: startDate = new Date(
new Date(mode === CalendarMode.Year ? new Date(currentDate).setMonth(1) : currentDate).setDate(1)
).setHours(0, 0, 0, 0)
$: endDate =
)
$: endDate = new Date(
mode === CalendarMode.Year
? new Date(startDate).setFullYear(new Date(startDate).getFullYear() + 1)
: new Date(startDate).setMonth(new Date(startDate).getMonth() + 1)
)
$: departments = [department, ...getDescendants(department, descendants)]
const lq = createQuery()
@ -76,12 +77,14 @@
return res
}
function update (departments: Ref<Department>[], startDate: Timestamp, endDate: Timestamp) {
function update (departments: Ref<Department>[], startDate: Date, endDate: Date) {
lq.query(
hr.class.Request,
{
dueDate: { $gte: startDate },
date: { $lt: endDate },
'tzDueDate.year': { $gte: startDate.getFullYear() },
'tzDueDate.month': { $gte: startDate.getMonth() },
'tzDate.year': { $lte: endDate.getFullYear() },
'tzDate.month': { $lte: endDate.getFullYear() },
space: { $in: departments }
},
(res) => {
@ -116,14 +119,13 @@
<MonthView
{departmentStaff}
{employeeRequests}
{startDate}
{endDate}
teamLead={getTeamLead(department)}
{types}
{startDate}
teamLead={getTeamLead(department)}
{currentDate}
/>
{:else if display === 'stats'}
<MonthTableView {departmentStaff} {employeeRequests} {startDate} {endDate} {types} {currentDate} />
<MonthTableView {departmentStaff} {employeeRequests} {types} {currentDate} />
{/if}
{/if}
{:else}

View File

@ -0,0 +1,51 @@
<!--
// 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 { TzDate } from '@anticrm/hr'
// import { IntlString } from '@anticrm/platform'
import { DateRangePresenter } from '@anticrm/ui'
import { fromTzDate, toTzDate } from '../utils'
export let value: TzDate | null | undefined
export let onChange: (value: TzDate | null | undefined) => void
export let kind: 'no-border' | 'link' = 'no-border'
export let noShift: boolean = false
$: _value = value != null ? fromTzDate(value) : null
</script>
<DateRangePresenter
value={_value}
withTime={false}
editable
{kind}
{noShift}
on:change={(res) => {
if (res.detail != null) {
const dte = new Date(res.detail)
const tzdte = {
year: dte.getFullYear(),
month: dte.getMonth(),
day: dte.getDate(),
offset: dte.getTimezoneOffset()
}
const tzd = toTzDate(new Date(_value ?? Date.now()))
if (tzd.year !== tzdte.year || tzd.month !== tzdte.month || tzd.day !== tzdte.day) {
onChange(tzdte)
}
}
}}
/>

View File

@ -0,0 +1,26 @@
<!--
// Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021 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 { TzDate } from '@anticrm/hr'
import { DateRangePresenter } from '@anticrm/ui'
export let value: TzDate | null | undefined
export let noShift: boolean = false
$: _value = value != null ? new Date().setFullYear(value?.year ?? 0, value?.month, value?.day) : null
</script>
<DateRangePresenter value={_value} {noShift} />

View File

@ -13,20 +13,19 @@
// limitations under the License.
-->
<script lang="ts">
import { FindOptions, Ref } from '@anticrm/core'
import { Doc, Ref } from '@anticrm/core'
import type { Request, RequestType, Staff } from '@anticrm/hr'
import { getEmbeddedLabel } from '@anticrm/platform'
import { Label, Scroller } from '@anticrm/ui'
import { Table } from '@anticrm/view-resources'
import { createQuery, getClient } from '@anticrm/presentation'
import { Button, Label, Loading, Scroller } from '@anticrm/ui'
import view, { BuildModelKey, Viewlet, ViewletPreference } from '@anticrm/view'
import { Table, ViewletSettingButton } from '@anticrm/view-resources'
import hr from '../../plugin'
import { getMonth, getTotal, weekDays } from '../../utils'
import { fromTzDate, getMonth, getTotal, tableToCSV, weekDays } from '../../utils'
import NumberPresenter from './StatPresenter.svelte'
export let currentDate: Date = new Date()
export let startDate: number
export let endDate: number
export let departmentStaff: Staff[]
export let types: Map<Ref<RequestType>, RequestType>
@ -35,91 +34,203 @@
$: month = getMonth(currentDate, currentDate.getMonth())
$: wDays = weekDays(month.getUTCFullYear(), month.getUTCMonth())
const options: FindOptions<Staff> = {
lookup: {
department: hr.class.Department
}
}
function getDateRange (req: Request): string {
const st = new Date(req.date).getDate()
let days = Math.abs((req.dueDate - req.date) / 1000 / 60 / 60 / 24)
if (days === 0) {
days = 1
}
const stDate = new Date(req.date)
function getDateRange (request: Request): string {
const st = new Date(fromTzDate(request.tzDate)).getDate()
const days =
Math.floor(Math.abs((1 + fromTzDate(request.tzDueDate) - fromTzDate(request.tzDate)) / 1000 / 60 / 60 / 24)) + 1
const stDate = new Date(fromTzDate(request.tzDate))
let ds = Array.from(Array(days).keys()).map((it) => st + it)
const type = types.get(req.type)
const type = types.get(request.type)
if ((type?.value ?? -1) < 0) {
ds = ds.filter((it) => ![0, 6].includes(new Date(stDate.setDate(it)).getDay()))
}
return ds.join(' ')
}
$: typevals = Array.from(
Array.from(types.values()).map((it) => ({
function getEndDate (date: Date): number {
return new Date(date).setMonth(date.getMonth() + 1)
}
function getRequests (employee: Ref<Staff>, date: Date): Request[] {
const requests = employeeRequests.get(employee)
if (requests === undefined) return []
const res: Request[] = []
const time = date.getTime()
const endTime = getEndDate(date)
for (const request of requests) {
if (fromTzDate(request.tzDate) <= endTime && fromTzDate(request.tzDueDate) > time) {
res.push(request)
}
}
return res
}
$: typevals = new Map<string, BuildModelKey>(
Array.from(types.values()).map((it) => [
it.label as string,
{
key: '',
label: it.label,
presenter: NumberPresenter,
props: {
month: month ?? getMonth(currentDate, currentDate.getMonth()),
employeeRequests,
display: (req: Request[]) =>
req
.filter((r) => r.type === it._id)
.map((it) => getDateRange(it))
.join(' ')
.join(' '),
getRequests
}
}))
}
])
)
$: config = [
'',
'$lookup.department',
$: overrideConfig = new Map<string, BuildModelKey>([
[
'@wdCount',
{
key: '',
label: getEmbeddedLabel('Working days'),
presenter: NumberPresenter,
props: {
month: month ?? getMonth(currentDate, currentDate.getMonth()),
employeeRequests,
display: (req: Request[]) => wDays + getTotal(req, types)
}
display: (req: Request[]) => wDays + getTotal(req, types),
getRequests
},
sortingKey: '@wdCount',
sortingFunction: (a: Doc, b: Doc) =>
getTotal(getRequests(b._id as Ref<Staff>, month), types) -
getTotal(getRequests(a._id as Ref<Staff>, month), types)
}
],
[
'@ptoCount',
{
key: '',
label: getEmbeddedLabel('PTOs'),
presenter: NumberPresenter,
props: {
month: month ?? getMonth(currentDate, currentDate.getMonth()),
employeeRequests,
display: (req: Request[]) => getTotal(req, types, (a) => (a < 0 ? Math.abs(a) : 0))
}
display: (req: Request[]) => getTotal(req, types, (a) => (a < 0 ? Math.abs(a) : 0)),
getRequests
},
sortingKey: '@ptoCount',
sortingFunction: (a: Doc, b: Doc) =>
getTotal(getRequests(b._id as Ref<Staff>, month), types, (a) => (a < 0 ? Math.abs(a) : 0)) -
getTotal(getRequests(a._id as Ref<Staff>, month), types, (a) => (a < 0 ? Math.abs(a) : 0))
}
],
[
'@extraCount',
{
key: '',
label: getEmbeddedLabel('EXTRa'),
presenter: NumberPresenter,
props: {
month: month ?? getMonth(currentDate, currentDate.getMonth()),
employeeRequests,
display: (req: Request[]) => getTotal(req, types, (a) => (a > 0 ? Math.abs(a) : 0))
}
display: (req: Request[]) => getTotal(req, types, (a) => (a > 0 ? Math.abs(a) : 0)),
getRequests
},
...(typevals ?? [])
]
sortingKey: '@extraCount',
sortingFunction: (a: Doc, b: Doc) =>
getTotal(getRequests(b._id as Ref<Staff>, month), types, (a) => (a > 0 ? Math.abs(a) : 0)) -
getTotal(getRequests(a._id as Ref<Staff>, month), types, (a) => (a > 0 ? Math.abs(a) : 0))
}
],
...typevals
])
const preferenceQuery = createQuery()
let preference: ViewletPreference | undefined
let descr: Viewlet | undefined
$: updateDescriptor(hr.viewlet.StaffStats)
const client = getClient()
let loading = false
function updateDescriptor (id: Ref<Viewlet>) {
loading = true
client
.findOne<Viewlet>(view.class.Viewlet, {
_id: id
})
.then((res) => {
descr = res
if (res !== undefined) {
preferenceQuery.query(
view.class.ViewletPreference,
{
attachedTo: res._id
},
(res) => {
preference = res[0]
loading = false
},
{ limit: 1 }
)
}
})
}
function createConfig (descr: Viewlet, preference: ViewletPreference | undefined): (string | BuildModelKey)[] {
const base = preference?.config ?? descr.config
const result: (string | BuildModelKey)[] = []
for (const c of overrideConfig.values()) {
base.push(c)
}
for (const key of base) {
if (typeof key === 'string') {
result.push(overrideConfig.get(key) ?? key)
} else {
result.push(overrideConfig.get(key.key) ?? key)
}
}
return result
}
</script>
{#if departmentStaff.length}
<Scroller tableFade>
<div class="p-2">
{#if descr}
{#if loading}
<Loading />
{:else}
<div class="flex-row-center flex-reverse">
<div class="ml-1">
<ViewletSettingButton viewlet={descr} />
</div>
<Button
label={getEmbeddedLabel('Export')}
size={'small'}
on:click={() => {
// Download it
const filename = 'exportStaff' + new Date().toLocaleDateString() + '.csv'
const link = document.createElement('a')
link.style.display = 'none'
link.setAttribute('target', '_blank')
link.setAttribute(
'href',
'data:text/csv;charset=utf-8,' + encodeURIComponent(tableToCSV('exportableData'))
)
link.setAttribute('download', filename)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}}
/>
</div>
<Table
tableId={'exportableData'}
_class={hr.mixin.Staff}
query={{ _id: { $in: departmentStaff.map((it) => it._id) } }}
{config}
{options}
config={createConfig(descr, preference)}
options={descr.options}
/>
{/if}
{/if}
</div>
</Scroller>
{:else}

View File

@ -32,20 +32,20 @@
tooltip
} from '@anticrm/ui'
import hr from '../../plugin'
import { fromTzDate, getTotal } from '../../utils'
import CreateRequest from '../CreateRequest.svelte'
import RequestsPopup from '../RequestsPopup.svelte'
import ScheduleRequests from '../ScheduleRequests.svelte'
export let currentDate: Date = new Date()
export let startDate: number
export let endDate: number
export let startDate: Date
export let departmentStaff: Staff[]
export let types: Map<Ref<RequestType>, RequestType>
export let employeeRequests: Map<Ref<Staff>, Request[]>
export let teamLead: Ref<Employee> | undefined
export let types: Map<Ref<RequestType>, RequestType>
const todayDate = new Date()
@ -56,7 +56,7 @@
const time = date.getTime()
const endTime = getEndDate(date)
for (const request of requests) {
if (request.date <= endTime && request.dueDate > time) {
if (fromTzDate(request.tzDate) <= endTime && fromTzDate(request.tzDueDate) > time) {
res.push(request)
}
}
@ -85,15 +85,14 @@
}
function getEndDate (date: Date): number {
return new Date(date).setDate(date.getDate() + 1) - 1
return new Date(date).setDate(date.getDate() + 1)
}
function getTooltip (requests: Request[], employee: Staff, date: Date): LabelAndProps | undefined {
function getTooltip (requests: Request[]): LabelAndProps | undefined {
if (requests.length === 0) return
const endDate = getEndDate(date)
return {
component: RequestsPopup,
props: { date, endDate, employee: employee._id }
props: { requests: requests.map((it) => it._id) }
}
}
@ -110,8 +109,9 @@
<th>
<Label label={contact.string.Employee} />
</th>
<th>#</th>
{#each values as value, i}
{@const day = getDay(new Date(startDate), value)}
{@const day = getDay(startDate, value)}
<th
class:today={areDatesEqual(todayDate, day)}
class:weekend={isWeekend(day)}
@ -130,15 +130,19 @@
</thead>
<tbody>
{#each departmentStaff as employee, row}
{@const requests = employeeRequests.get(employee._id) ?? []}
<tr>
<td>
<EmployeePresenter value={employee} />
</td>
<td class="flex-center p-1" class:firstLine={row === 0} class:lastLine={row === departmentStaff.length - 1}>
{getTotal(requests, types)}
</td>
{#each values as value, i}
{@const date = getDay(new Date(startDate), value)}
{@const date = getDay(startDate, value)}
{@const requests = getRequests(employee._id, date)}
{@const editable = isEditable(employee)}
{@const tooltipValue = getTooltip(requests, employee, date)}
{@const tooltipValue = getTooltip(requests)}
{#key [tooltipValue, editable]}
<td
class:today={areDatesEqual(todayDate, date)}

View File

@ -18,27 +18,9 @@
import { Request, Staff } from '@anticrm/hr'
export let value: Staff
export let employeeRequests: Map<Ref<Staff>, Request[]>
export let display: (requests: Request[]) => number | string
export let month: Date
function getEndDate (date: Date): number {
return new Date(date).setMonth(date.getMonth() + 1)
}
function getRequests (employee: Ref<Staff>, date: Date): Request[] {
const requests = employeeRequests.get(employee)
if (requests === undefined) return []
const res: Request[] = []
const time = date.getTime()
const endTime = getEndDate(date)
for (const request of requests) {
if (request.date <= endTime && request.dueDate > time) {
res.push(request)
}
}
return res
}
export let getRequests: (employee: Ref<Staff>, date: Date) => Request[]
$: reqs = getRequests(value._id, month)
$: _value = display(reqs)

View File

@ -19,7 +19,7 @@
import type { Request, RequestType, Staff } from '@anticrm/hr'
import { Label, LabelAndProps, Scroller, tooltip } from '@anticrm/ui'
import hr from '../../plugin'
import { getMonth, getTotal, weekDays } from '../../utils'
import { fromTzDate, getMonth, getTotal, weekDays } from '../../utils'
import RequestsPopup from '../RequestsPopup.svelte'
export let currentDate: Date = new Date()
@ -38,7 +38,7 @@
const time = date.getTime()
const endTime = getEndDate(date)
for (const request of requests) {
if (request.date <= endTime && request.dueDate > time) {
if (fromTzDate(request.tzDate) <= endTime && fromTzDate(request.tzDueDate) > time) {
res.push(request)
}
}
@ -49,12 +49,11 @@
return new Date(date).setMonth(date.getMonth() + 1)
}
function getTooltip (requests: Request[], employee: Staff, date: Date): LabelAndProps | undefined {
function getTooltip (requests: Request[]): LabelAndProps | undefined {
if (requests.length === 0) return
const endDate = getEndDate(date)
return {
component: RequestsPopup,
props: { date, endDate, employee: employee._id }
props: { requests: requests.map((it) => it._id) }
}
}
@ -119,7 +118,7 @@
{#each values as value, i}
{@const month = getMonth(currentDate, value)}
{@const requests = getRequests(employeeRequests, employee._id, month)}
{@const tooltipValue = getTooltip(requests, employee, month)}
{@const tooltipValue = getTooltip(requests)}
{#key tooltipValue}
<td
class:today={month.getFullYear() === todayDate.getFullYear() &&

View File

@ -20,6 +20,9 @@ import EditDepartment from './components/EditDepartment.svelte'
import EditRequest from './components/EditRequest.svelte'
import Schedule from './components/Schedule.svelte'
import Structure from './components/Structure.svelte'
import TzDatePresenter from './components/TzDatePresenter.svelte'
import TzDateEditor from './components/TzDateEditor.svelte'
import RequestPresenter from './components/RequestPresenter.svelte'
export default async (): Promise<Resources> => ({
component: {
@ -28,6 +31,9 @@ export default async (): Promise<Resources> => ({
DepartmentStaff,
DepartmentEditor,
Schedule,
EditRequest
EditRequest,
TzDatePresenter,
TzDateEditor,
RequestPresenter
}
})

View File

@ -1,6 +1,6 @@
import { Employee, formatName } from '@anticrm/contact'
import { Ref, TxOperations } from '@anticrm/core'
import { Department, Request, RequestType } from '@anticrm/hr'
import { Department, Request, RequestType, TzDate } from '@anticrm/hr'
import { MessageBox } from '@anticrm/presentation'
import { showPopup } from '@anticrm/ui'
import hr from './plugin'
@ -56,18 +56,27 @@ export async function addMember (client: TxOperations, employee?: Employee, valu
/**
* @public
*/
export function toUTC (date: Date | number, hours = 12, mins = 0, sec = 0): number {
const res = new Date(date)
if (res.getUTCFullYear() !== res.getFullYear()) {
res.setUTCFullYear(res.getFullYear())
export function toTzDate (date: Date): TzDate {
return {
year: date.getFullYear(),
month: date.getMonth(),
day: date.getDate(),
offset: date.getTimezoneOffset()
}
if (res.getUTCMonth() !== res.getMonth()) {
res.setUTCMonth(res.getMonth())
}
if (res.getUTCDate() !== res.getDate()) {
res.setUTCDate(res.getDate())
/**
* @public
*/
export function fromTzDate (tzDate: TzDate): number {
return new Date().setFullYear(tzDate?.year ?? 0, tzDate.month, tzDate.day)
}
return res.setUTCHours(hours, mins, sec, 0)
/**
* @public
*/
export function tzDateEqual (tzDate: TzDate, tzDate2: TzDate): boolean {
return tzDate.year === tzDate2.year && tzDate.month === tzDate2.month && tzDate.day === tzDate2.day
}
/**
@ -97,11 +106,9 @@ export function getTotal (
let total = 0
for (const request of requests) {
const type = types.get(request.type)
let days = Math.abs((request.dueDate - request.date) / 1000 / 60 / 60 / 24)
if (days === 0) {
days = 1
}
const stDate = new Date(request.date)
const days =
Math.floor(Math.abs((1 + fromTzDate(request.tzDueDate) - fromTzDate(request.tzDate)) / 1000 / 60 / 60 / 24)) + 1
const stDate = new Date(fromTzDate(request.tzDate))
const stDateDate = stDate.getDate()
let ds = Array.from(Array(days).keys()).map((it) => stDateDate + it)
if ((type?.value ?? -1) < 0) {

View File

@ -14,7 +14,7 @@
//
import type { Employee, EmployeeAccount } from '@anticrm/contact'
import type { Arr, AttachedDoc, Class, Doc, Markup, Mixin, Ref, Space, Timestamp } from '@anticrm/core'
import type { Arr, AttachedDoc, Class, Doc, Markup, Mixin, Ref, Space, Type } from '@anticrm/core'
import type { Asset, IntlString, Plugin } from '@anticrm/platform'
import { plugin } from '@anticrm/platform'
import { Viewlet } from '@anticrm/view'
@ -54,6 +54,16 @@ export interface RequestType extends Doc {
color: number
}
/**
* @public
*/
export interface TzDate {
year: number
month: number
day: number
offset: number
}
/**
* @public
*/
@ -71,11 +81,8 @@ export interface Request extends AttachedDoc {
attachments?: number
// Date always in UTC
date: Timestamp
dueDate: Timestamp
// Timezone offset in minutes.
timezoneOffset: number
tzDate: TzDate
tzDueDate: TzDate
}
/**
@ -94,7 +101,8 @@ const hr = plugin(hrId, {
Department: '' as Ref<Class<Department>>,
DepartmentMember: '' as Ref<Class<DepartmentMember>>,
Request: '' as Ref<Class<Request>>,
RequestType: '' as Ref<Class<RequestType>>
RequestType: '' as Ref<Class<RequestType>>,
TzDate: '' as Ref<Class<Type<TzDate>>>
},
mixin: {
Staff: '' as Ref<Mixin<Staff>>
@ -121,7 +129,8 @@ const hr = plugin(hrId, {
Overtime2: '' as Ref<RequestType>
},
viewlet: {
TableMember: '' as Ref<Viewlet>
TableMember: '' as Ref<Viewlet>,
StaffStats: '' as Ref<Viewlet>
}
})

View File

@ -14,7 +14,7 @@
-->
<script lang="ts">
import { Ref, SortingOrder } from '@anticrm/core'
import { IntlString, translate } from '@anticrm/platform'
import { getEmbeddedLabel, IntlString, translate } from '@anticrm/platform'
import { createQuery } from '@anticrm/presentation'
import { Project } from '@anticrm/tracker'
import type { ButtonKind, ButtonSize } from '@anticrm/ui'
@ -89,32 +89,14 @@
}
</script>
{#if onlyIcon}
<Button
{kind}
{size}
{shape}
{width}
{justify}
label={onlyIcon || projectText === undefined ? undefined : getEmbeddedLabel(projectText)}
icon={projectIcon}
disabled={!isEditable}
on:click={handleProjectEditorOpened}
/>
{:else}
<Button
{kind}
{size}
{shape}
{width}
{justify}
icon={projectIcon}
disabled={!isEditable}
on:click={handleProjectEditorOpened}
>
<svelte:fragment slot="content">
{#if projectText}
<span class="overflow-label disabled">{projectText}</span>
{/if}
</svelte:fragment>
</Button>
{/if}

View File

@ -228,6 +228,7 @@
</div>
<Component
is={notification.component.NotificationPresenter}
showLoading={false}
props={{ value: docObject, kind: 'table' }}
/>
</div>