From 261284df292e232dafc8d1f13d4f9a6e53c5eefb Mon Sep 17 00:00:00 2001 From: Andrey Sobolev <haiodo@users.noreply.github.com> Date: Wed, 20 Jul 2022 22:21:52 +0700 Subject: [PATCH] Fix HR statistics (#2242) Signed-off-by: Andrey Sobolev <haiodo@gmail.com> --- changelog.md | 12 +- models/hr/src/index.ts | 56 +++- models/hr/src/migration.ts | 156 ++++++----- models/hr/src/plugin.ts | 5 +- packages/model/src/migration.ts | 12 +- packages/ui/src/components/Component.svelte | 5 +- .../src/components/CreateRequest.svelte | 14 +- .../src/components/RequestPresenter.svelte | 44 +++ .../src/components/RequestsPopup.svelte | 12 +- .../src/components/Schedule.svelte | 19 -- .../src/components/ScheduleView.svelte | 22 +- .../src/components/TzDateEditor.svelte | 51 ++++ .../src/components/TzDatePresenter.svelte | 26 ++ .../components/schedule/MonthTableView.svelte | 259 +++++++++++++----- .../src/components/schedule/MonthView.svelte | 26 +- .../components/schedule/StatPresenter.svelte | 20 +- .../src/components/schedule/YearView.svelte | 11 +- plugins/hr-resources/src/index.ts | 8 +- plugins/hr-resources/src/utils.ts | 41 +-- plugins/hr/src/index.ts | 25 +- .../src/components/ProjectSelector.svelte | 42 +-- .../src/components/issues/IssuesList.svelte | 1 + 22 files changed, 559 insertions(+), 308 deletions(-) create mode 100644 plugins/hr-resources/src/components/RequestPresenter.svelte create mode 100644 plugins/hr-resources/src/components/TzDateEditor.svelte create mode 100644 plugins/hr-resources/src/components/TzDatePresenter.svelte diff --git a/changelog.md b/changelog.md index 176e6deff5..7c6f724255 100644 --- a/changelog.md +++ b/changelog.md @@ -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 diff --git a/models/hr/src/index.ts b/models/hr/src/index.ts index 77762ccf2f..903a9cc023 100644 --- a/models/hr/src/index.ts +++ b/models/hr/src/index.ts @@ -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' diff --git a/models/hr/src/migration.ts b/models/hr/src/migration.ts index a873b1e31f..d7bb1ab16d 100644 --- a/models/hr/src/migration.ts +++ b/models/hr/src/migration.ts @@ -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 } } + { _id: utx._id }, + { + tx: { + ...ctx, + attributes: { + ...attributes, + tzDate: toTzDate(date as unknown as number), + tzDueDate: toTzDate((dueDate ?? date) as unknown as number) + } + } + } ) - )[0] - if (createTx !== undefined) { - const attributes = createTx.attributes - if (updateDateTx === undefined) { - attributes.date = date + } + 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 (updateDueTx === undefined) { - attributes.dueDate = dueDate + 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) } diff --git a/models/hr/src/plugin.ts b/models/hr/src/plugin.ts index 39f07c5821..3248b33135 100644 --- a/models/hr/src/plugin.ts +++ b/models/hr/src/plugin.ts @@ -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> diff --git a/packages/model/src/migration.ts b/packages/model/src/migration.ts index 7c09eeea8e..1f8aad02a3 100644 --- a/packages/model/src/migration.ts +++ b/packages/model/src/migration.ts @@ -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 } diff --git a/packages/ui/src/components/Component.svelte b/packages/ui/src/components/Component.svelte index 9d5200630d..7e3953a897 100644 --- a/packages/ui/src/components/Component.svelte +++ b/packages/ui/src/components/Component.svelte @@ -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} - <Loading {shrink} /> + {#if showLoading} + <Loading {shrink} /> + {/if} {:then Ctor} <ErrorBoundary> <Ctor {...props} on:change on:close on:open on:click on:delete> diff --git a/plugins/hr-resources/src/components/CreateRequest.svelte b/plugins/hr-resources/src/components/CreateRequest.svelte index bb5a9e169f..f27e49253b 100644 --- a/plugins/hr-resources/src/components/CreateRequest.svelte +++ b/plugins/hr-resources/src/components/CreateRequest.svelte @@ -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() } diff --git a/plugins/hr-resources/src/components/RequestPresenter.svelte b/plugins/hr-resources/src/components/RequestPresenter.svelte new file mode 100644 index 0000000000..fc91943302 --- /dev/null +++ b/plugins/hr-resources/src/components/RequestPresenter.svelte @@ -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} diff --git a/plugins/hr-resources/src/components/RequestsPopup.svelte b/plugins/hr-resources/src/components/RequestsPopup.svelte index 3b01d511c6..3db1660b6a 100644 --- a/plugins/hr-resources/src/components/RequestsPopup.svelte +++ b/plugins/hr-resources/src/components/RequestsPopup.svelte @@ -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 } }} /> diff --git a/plugins/hr-resources/src/components/Schedule.svelte b/plugins/hr-resources/src/components/Schedule.svelte index cbaff39b3f..035fe0336c 100644 --- a/plugins/hr-resources/src/components/Schedule.svelte +++ b/plugins/hr-resources/src/components/Schedule.svelte @@ -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} /> diff --git a/plugins/hr-resources/src/components/ScheduleView.svelte b/plugins/hr-resources/src/components/ScheduleView.svelte index cf6d933fc2..d1a70179fa 100644 --- a/plugins/hr-resources/src/components/ScheduleView.svelte +++ b/plugins/hr-resources/src/components/ScheduleView.svelte @@ -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} diff --git a/plugins/hr-resources/src/components/TzDateEditor.svelte b/plugins/hr-resources/src/components/TzDateEditor.svelte new file mode 100644 index 0000000000..bf46ead2b4 --- /dev/null +++ b/plugins/hr-resources/src/components/TzDateEditor.svelte @@ -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) + } + } + }} +/> diff --git a/plugins/hr-resources/src/components/TzDatePresenter.svelte b/plugins/hr-resources/src/components/TzDatePresenter.svelte new file mode 100644 index 0000000000..8c01cda344 --- /dev/null +++ b/plugins/hr-resources/src/components/TzDatePresenter.svelte @@ -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} /> diff --git a/plugins/hr-resources/src/components/schedule/MonthTableView.svelte b/plugins/hr-resources/src/components/schedule/MonthTableView.svelte index 63bf1e11f6..6c20f6cebf 100644 --- a/plugins/hr-resources/src/components/schedule/MonthTableView.svelte +++ b/plugins/hr-resources/src/components/schedule/MonthTableView.svelte @@ -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) => ({ - 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(' ') + 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()), + display: (req: Request[]) => + req + .filter((r) => r.type === it._id) + .map((it) => getDateRange(it)) + .join(' '), + getRequests + } + } + ]) ) - $: config = [ - '', - '$lookup.department', - { - key: '', - label: getEmbeddedLabel('Working days'), - presenter: NumberPresenter, - props: { - month: month ?? getMonth(currentDate, currentDate.getMonth()), - employeeRequests, - display: (req: Request[]) => wDays + getTotal(req, types) + $: overrideConfig = new Map<string, BuildModelKey>([ + [ + '@wdCount', + { + key: '', + label: getEmbeddedLabel('Working days'), + presenter: NumberPresenter, + props: { + month: month ?? getMonth(currentDate, currentDate.getMonth()), + 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) } - }, - { - 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)) + ], + [ + '@ptoCount', + { + key: '', + label: getEmbeddedLabel('PTOs'), + presenter: NumberPresenter, + props: { + month: month ?? getMonth(currentDate, currentDate.getMonth()), + 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)) } - }, - { - 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)) + ], + [ + '@extraCount', + { + key: '', + label: getEmbeddedLabel('EXTRa'), + presenter: NumberPresenter, + props: { + month: month ?? getMonth(currentDate, currentDate.getMonth()), + display: (req: Request[]) => getTotal(req, types, (a) => (a > 0 ? Math.abs(a) : 0)), + getRequests + }, + 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 ?? []) - ] + ], + ...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"> - <Table - tableId={'exportableData'} - _class={hr.mixin.Staff} - query={{ _id: { $in: departmentStaff.map((it) => it._id) } }} - {config} - {options} - /> + {#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={createConfig(descr, preference)} + options={descr.options} + /> + {/if} + {/if} </div> </Scroller> {:else} diff --git a/plugins/hr-resources/src/components/schedule/MonthView.svelte b/plugins/hr-resources/src/components/schedule/MonthView.svelte index c622e026c0..55c6acd26d 100644 --- a/plugins/hr-resources/src/components/schedule/MonthView.svelte +++ b/plugins/hr-resources/src/components/schedule/MonthView.svelte @@ -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)} diff --git a/plugins/hr-resources/src/components/schedule/StatPresenter.svelte b/plugins/hr-resources/src/components/schedule/StatPresenter.svelte index 23bc78ba60..e14fdef18b 100644 --- a/plugins/hr-resources/src/components/schedule/StatPresenter.svelte +++ b/plugins/hr-resources/src/components/schedule/StatPresenter.svelte @@ -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) diff --git a/plugins/hr-resources/src/components/schedule/YearView.svelte b/plugins/hr-resources/src/components/schedule/YearView.svelte index 5b314f558f..7ea6b2214a 100644 --- a/plugins/hr-resources/src/components/schedule/YearView.svelte +++ b/plugins/hr-resources/src/components/schedule/YearView.svelte @@ -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() && diff --git a/plugins/hr-resources/src/index.ts b/plugins/hr-resources/src/index.ts index 5aebc05a1d..2994426cee 100644 --- a/plugins/hr-resources/src/index.ts +++ b/plugins/hr-resources/src/index.ts @@ -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 } }) diff --git a/plugins/hr-resources/src/utils.ts b/plugins/hr-resources/src/utils.ts index 868dde6341..2ad1bb17d7 100644 --- a/plugins/hr-resources/src/utils.ts +++ b/plugins/hr-resources/src/utils.ts @@ -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()) - } - return res.setUTCHours(hours, mins, sec, 0) +} + +/** + * @public + */ +export function fromTzDate (tzDate: TzDate): number { + return new Date().setFullYear(tzDate?.year ?? 0, tzDate.month, tzDate.day) +} + +/** + * @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) { diff --git a/plugins/hr/src/index.ts b/plugins/hr/src/index.ts index d8598fb871..e9ffa03da9 100644 --- a/plugins/hr/src/index.ts +++ b/plugins/hr/src/index.ts @@ -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> } }) diff --git a/plugins/tracker-resources/src/components/ProjectSelector.svelte b/plugins/tracker-resources/src/components/ProjectSelector.svelte index e9fe226952..c634548efa 100644 --- a/plugins/tracker-resources/src/components/ProjectSelector.svelte +++ b/plugins/tracker-resources/src/components/ProjectSelector.svelte @@ -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} - 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} +<Button + {kind} + {size} + {shape} + {width} + {justify} + label={onlyIcon || projectText === undefined ? undefined : getEmbeddedLabel(projectText)} + icon={projectIcon} + disabled={!isEditable} + on:click={handleProjectEditorOpened} +/> diff --git a/plugins/tracker-resources/src/components/issues/IssuesList.svelte b/plugins/tracker-resources/src/components/issues/IssuesList.svelte index 8b53774c0a..eafd76cf67 100644 --- a/plugins/tracker-resources/src/components/issues/IssuesList.svelte +++ b/plugins/tracker-resources/src/components/issues/IssuesList.svelte @@ -228,6 +228,7 @@ </div> <Component is={notification.component.NotificationPresenter} + showLoading={false} props={{ value: docObject, kind: 'table' }} /> </div>