mirror of
https://github.com/hcengineering/platform.git
synced 2025-06-04 23:04:47 +00:00
Fix HR statistics (#2242)
Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
parent
0e0b8ab766
commit
261284df29
12
changelog.md
12
changelog.md
@ -1,11 +1,21 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 0.6.31 (upcoming)
|
## 0.6.32 (upcoming)
|
||||||
|
|
||||||
|
## 0.6.31
|
||||||
|
|
||||||
Core:
|
Core:
|
||||||
|
|
||||||
- Fix password change settings
|
- Fix password change settings
|
||||||
- Fix settings collapse
|
- 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
|
## 0.6.30
|
||||||
|
|
||||||
|
@ -14,8 +14,8 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import { Employee } from '@anticrm/contact'
|
import { Employee } from '@anticrm/contact'
|
||||||
import { Arr, Class, Domain, DOMAIN_MODEL, IndexKind, Markup, Ref, Timestamp } from '@anticrm/core'
|
import { Arr, Class, Domain, DOMAIN_MODEL, IndexKind, Markup, Ref, Type } from '@anticrm/core'
|
||||||
import { Department, DepartmentMember, hrId, Request, RequestType, Staff } from '@anticrm/hr'
|
import { Department, DepartmentMember, hrId, Request, RequestType, Staff, TzDate } from '@anticrm/hr'
|
||||||
import {
|
import {
|
||||||
ArrOf,
|
ArrOf,
|
||||||
Builder,
|
Builder,
|
||||||
@ -25,7 +25,6 @@ import {
|
|||||||
Mixin,
|
Mixin,
|
||||||
Model,
|
Model,
|
||||||
Prop,
|
Prop,
|
||||||
TypeDate,
|
|
||||||
TypeIntlString,
|
TypeIntlString,
|
||||||
TypeMarkup,
|
TypeMarkup,
|
||||||
TypeRef,
|
TypeRef,
|
||||||
@ -36,8 +35,8 @@ import attachment from '@anticrm/model-attachment'
|
|||||||
import calendar from '@anticrm/model-calendar'
|
import calendar from '@anticrm/model-calendar'
|
||||||
import chunter from '@anticrm/model-chunter'
|
import chunter from '@anticrm/model-chunter'
|
||||||
import contact, { TEmployee, TEmployeeAccount } from '@anticrm/model-contact'
|
import contact, { TEmployee, TEmployeeAccount } from '@anticrm/model-contact'
|
||||||
import core, { TAttachedDoc, TDoc, TSpace } from '@anticrm/model-core'
|
import core, { TAttachedDoc, TDoc, TSpace, TType } from '@anticrm/model-core'
|
||||||
import view, { createAction } from '@anticrm/model-view'
|
import view, { classPresenter, createAction } from '@anticrm/model-view'
|
||||||
import workbench from '@anticrm/model-workbench'
|
import workbench from '@anticrm/model-workbench'
|
||||||
import { Asset, IntlString } from '@anticrm/platform'
|
import { Asset, IntlString } from '@anticrm/platform'
|
||||||
import hr from './plugin'
|
import hr from './plugin'
|
||||||
@ -95,6 +94,22 @@ export class TRequestType extends TDoc implements RequestType {
|
|||||||
color!: number
|
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)
|
@Model(hr.class.Request, core.class.AttachedDoc, DOMAIN_HR)
|
||||||
@UX(hr.string.Request, hr.icon.PTO)
|
@UX(hr.string.Request, hr.icon.PTO)
|
||||||
export class TRequest extends TAttachedDoc implements Request {
|
export class TRequest extends TAttachedDoc implements Request {
|
||||||
@ -123,18 +138,15 @@ export class TRequest extends TAttachedDoc implements Request {
|
|||||||
@Index(IndexKind.FullText)
|
@Index(IndexKind.FullText)
|
||||||
description!: Markup
|
description!: Markup
|
||||||
|
|
||||||
@Prop(TypeDate(false), calendar.string.Date)
|
@Prop(TypeTzDate(), calendar.string.Date)
|
||||||
date!: Timestamp
|
tzDate!: TzDate
|
||||||
|
|
||||||
@Prop(TypeDate(false), calendar.string.DueTo)
|
@Prop(TypeTzDate(), calendar.string.DueTo)
|
||||||
dueDate!: Timestamp
|
tzDueDate!: TzDate
|
||||||
|
|
||||||
// @Prop(TypeNumber(), calendar.string.Date)
|
|
||||||
timezoneOffset!: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createModel (builder: Builder): void {
|
export function createModel (builder: Builder): void {
|
||||||
builder.createModel(TDepartment, TDepartmentMember, TRequest, TRequestType, TStaff)
|
builder.createModel(TDepartment, TDepartmentMember, TRequest, TRequestType, TStaff, TTzDate)
|
||||||
|
|
||||||
builder.createDoc(
|
builder.createDoc(
|
||||||
workbench.class.Application,
|
workbench.class.Application,
|
||||||
@ -183,6 +195,8 @@ export function createModel (builder: Builder): void {
|
|||||||
editor: hr.component.DepartmentStaff
|
editor: hr.component.DepartmentStaff
|
||||||
})
|
})
|
||||||
|
|
||||||
|
classPresenter(builder, hr.class.TzDate, hr.component.TzDatePresenter, hr.component.TzDateEditor)
|
||||||
|
|
||||||
builder.createDoc(
|
builder.createDoc(
|
||||||
hr.class.RequestType,
|
hr.class.RequestType,
|
||||||
core.space.Model,
|
core.space.Model,
|
||||||
@ -337,6 +351,18 @@ export function createModel (builder: Builder): void {
|
|||||||
hr.viewlet.TableMember
|
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, {
|
createAction(builder, {
|
||||||
action: view.actionImpl.ValueSelector,
|
action: view.actionImpl.ValueSelector,
|
||||||
actionPopup: view.component.ValueSelector,
|
actionPopup: view.component.ValueSelector,
|
||||||
@ -359,6 +385,10 @@ export function createModel (builder: Builder): void {
|
|||||||
group: 'associate'
|
group: 'associate'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
builder.mixin(hr.class.Request, core.class.Class, view.mixin.AttributePresenter, {
|
||||||
|
presenter: hr.component.RequestPresenter
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export { hrOperation } from './migration'
|
export { hrOperation } from './migration'
|
||||||
|
@ -13,8 +13,9 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
//
|
//
|
||||||
|
|
||||||
import { DOMAIN_TX, SortingOrder, TxCreateDoc, TxOperations, TxUpdateDoc } from '@anticrm/core'
|
import { Employee } from '@anticrm/contact'
|
||||||
import { Request } from '@anticrm/hr'
|
import { DOMAIN_TX, TxCollectionCUD, TxCreateDoc, TxOperations, TxUpdateDoc } from '@anticrm/core'
|
||||||
|
import { Request, TzDate } from '@anticrm/hr'
|
||||||
import { MigrateOperation, MigrationClient, MigrationUpgradeClient } from '@anticrm/model'
|
import { MigrateOperation, MigrationClient, MigrationUpgradeClient } from '@anticrm/model'
|
||||||
import core from '@anticrm/model-core'
|
import core from '@anticrm/model-core'
|
||||||
import hr, { DOMAIN_HR } from './index'
|
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)
|
const res = new Date(date)
|
||||||
if (res.getUTCFullYear() !== res.getFullYear()) {
|
return {
|
||||||
res.setUTCFullYear(res.getFullYear())
|
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> {
|
async function migrateRequestTime (client: MigrationClient, request: Request): Promise<void> {
|
||||||
const date = toUTC(request.date)
|
const date = toTzDate((request as any).date as unknown as number)
|
||||||
const dueDate = isDefault(request.date, request.dueDate) ? date : toUTC(request.dueDate)
|
const dueDate = toTzDate((request as any).dueDate as unknown as number)
|
||||||
await client.update(
|
await client.update(
|
||||||
DOMAIN_HR,
|
DOMAIN_HR,
|
||||||
{ _id: request._id },
|
{ _id: request._id },
|
||||||
{
|
{
|
||||||
date,
|
tzDate: date,
|
||||||
dueDate
|
tzDueDate: dueDate
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const updateDateTx = (
|
const txes = await client.find<TxCollectionCUD<Employee, Request>>(DOMAIN_TX, {
|
||||||
await client.find<TxUpdateDoc<Request>>(
|
'tx._class': { $in: [core.class.TxCreateDoc, core.class.TxUpdateDoc] },
|
||||||
DOMAIN_TX,
|
'tx.objectId': request._id
|
||||||
{ _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
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updateDueTx === undefined || updateDateTx === undefined) {
|
for (const utx of txes) {
|
||||||
const createTx = (
|
if (utx.tx._class === core.class.TxCreateDoc) {
|
||||||
await client.find<TxCreateDoc<Request>>(
|
const ctx = utx.tx as TxCreateDoc<Request>
|
||||||
|
const { date, dueDate, ...attributes } = ctx.attributes as any
|
||||||
|
await client.update(
|
||||||
DOMAIN_TX,
|
DOMAIN_TX,
|
||||||
{ _class: core.class.TxCreateDoc, objectId: request._id },
|
{ _id: utx._id },
|
||||||
{ sort: { modifiedOn: SortingOrder.Descending } }
|
{
|
||||||
|
tx: {
|
||||||
|
...ctx,
|
||||||
|
attributes: {
|
||||||
|
...attributes,
|
||||||
|
tzDate: toTzDate(date as unknown as number),
|
||||||
|
tzDueDate: toTzDate((dueDate ?? date) as unknown as number)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
)[0]
|
}
|
||||||
if (createTx !== undefined) {
|
if (utx.tx._class === core.class.TxUpdateDoc) {
|
||||||
const attributes = createTx.attributes
|
const ctx = utx.tx as TxUpdateDoc<Request>
|
||||||
if (updateDateTx === undefined) {
|
const { date, dueDate, ...operations } = ctx.operations as any
|
||||||
attributes.date = date
|
const ops: any = {
|
||||||
|
...operations
|
||||||
}
|
}
|
||||||
if (updateDueTx === undefined) {
|
if (date !== undefined) {
|
||||||
attributes.dueDate = dueDate
|
ops.tzDate = toTzDate(date as unknown as number)
|
||||||
|
}
|
||||||
|
if (dueDate !== undefined) {
|
||||||
|
ops.tzDueDate = toTzDate(dueDate as unknown as number)
|
||||||
}
|
}
|
||||||
await client.update(
|
await client.update(
|
||||||
DOMAIN_TX,
|
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> {
|
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) {
|
for (const request of requests) {
|
||||||
await migrateRequestTime(client, request)
|
await migrateRequestTime(client, request)
|
||||||
}
|
}
|
||||||
|
@ -39,7 +39,10 @@ export default mergeIds(hrId, hr, {
|
|||||||
DepartmentStaff: '' as AnyComponent,
|
DepartmentStaff: '' as AnyComponent,
|
||||||
DepartmentEditor: '' as AnyComponent,
|
DepartmentEditor: '' as AnyComponent,
|
||||||
Schedule: '' as AnyComponent,
|
Schedule: '' as AnyComponent,
|
||||||
EditRequest: '' as AnyComponent
|
EditRequest: '' as AnyComponent,
|
||||||
|
TzDatePresenter: '' as AnyComponent,
|
||||||
|
TzDateEditor: '' as AnyComponent,
|
||||||
|
RequestPresenter: '' as AnyComponent
|
||||||
},
|
},
|
||||||
category: {
|
category: {
|
||||||
HR: '' as Ref<ActionCategory>
|
HR: '' as Ref<ActionCategory>
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
ArrayAsElementPosition,
|
||||||
Client,
|
Client,
|
||||||
Doc,
|
Doc,
|
||||||
DocumentQuery,
|
DocumentQuery,
|
||||||
@ -6,16 +7,25 @@ import {
|
|||||||
FindOptions,
|
FindOptions,
|
||||||
IncOptions,
|
IncOptions,
|
||||||
ObjQueryType,
|
ObjQueryType,
|
||||||
|
OmitNever,
|
||||||
PushOptions,
|
PushOptions,
|
||||||
Ref
|
Ref
|
||||||
} from '@anticrm/core'
|
} from '@anticrm/core'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
export interface UnsetOptions<T extends object> {
|
||||||
|
$unset?: Partial<OmitNever<ArrayAsElementPosition<Required<T>>>>
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
export type MigrateUpdate<T extends Doc> = Partial<T> &
|
export type MigrateUpdate<T extends Doc> = Partial<T> &
|
||||||
Omit<PushOptions<T>, '$move'> &
|
Omit<PushOptions<T>, '$move'> &
|
||||||
IncOptions<T> & {
|
IncOptions<T> &
|
||||||
|
UnsetOptions<T> & {
|
||||||
// For any other mongo stuff
|
// For any other mongo stuff
|
||||||
[key: string]: any
|
[key: string]: any
|
||||||
}
|
}
|
||||||
|
@ -23,13 +23,16 @@
|
|||||||
export let is: AnyComponent
|
export let is: AnyComponent
|
||||||
export let props = {}
|
export let props = {}
|
||||||
export let shrink: boolean = false
|
export let shrink: boolean = false
|
||||||
|
export let showLoading = true
|
||||||
|
|
||||||
$: component = is != null ? getResource(is) : Promise.reject(new Error('is not defined'))
|
$: component = is != null ? getResource(is) : Promise.reject(new Error('is not defined'))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if is}
|
{#if is}
|
||||||
{#await component}
|
{#await component}
|
||||||
<Loading {shrink} />
|
{#if showLoading}
|
||||||
|
<Loading {shrink} />
|
||||||
|
{/if}
|
||||||
{:then Ctor}
|
{:then Ctor}
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Ctor {...props} on:change on:close on:open on:click on:delete>
|
<Ctor {...props} on:change on:close on:open on:click on:delete>
|
||||||
|
@ -22,7 +22,7 @@
|
|||||||
import ui, { Button, DateRangePresenter, DropdownLabelsIntl, IconAttachment } from '@anticrm/ui'
|
import ui, { Button, DateRangePresenter, DropdownLabelsIntl, IconAttachment } from '@anticrm/ui'
|
||||||
import { createEventDispatcher } from 'svelte'
|
import { createEventDispatcher } from 'svelte'
|
||||||
import hr from '../plugin'
|
import hr from '../plugin'
|
||||||
import { toUTC } from '../utils'
|
import { toTzDate } from '../utils'
|
||||||
|
|
||||||
export let staff: Staff
|
export let staff: Staff
|
||||||
export let date: Date
|
export let date: Date
|
||||||
@ -59,15 +59,11 @@
|
|||||||
if (value != null) date = value
|
if (value != null) date = value
|
||||||
if (date === undefined) return
|
if (date === undefined) return
|
||||||
if (type === undefined) return
|
if (type === undefined) return
|
||||||
await client.createDoc(hr.class.Request, staff.department, {
|
await client.addCollection(hr.class.Request, staff.department, staff._id, staff._class, 'requests', {
|
||||||
attachedTo: staff._id,
|
|
||||||
attachedToClass: staff._class,
|
|
||||||
type: type._id,
|
type: type._id,
|
||||||
date: toUTC(date),
|
tzDate: toTzDate(new Date(date)),
|
||||||
dueDate: toUTC(dueDate),
|
tzDueDate: toTzDate(new Date(dueDate)),
|
||||||
description,
|
description
|
||||||
collection: 'requests',
|
|
||||||
timezoneOffset: new Date(date).getTimezoneOffset()
|
|
||||||
})
|
})
|
||||||
await descriptionBox.createAttachments()
|
await descriptionBox.createAttachments()
|
||||||
}
|
}
|
||||||
|
44
plugins/hr-resources/src/components/RequestPresenter.svelte
Normal file
44
plugins/hr-resources/src/components/RequestPresenter.svelte
Normal 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}
|
@ -14,21 +14,17 @@
|
|||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Ref, SortingOrder } from '@anticrm/core'
|
import { Ref, SortingOrder } from '@anticrm/core'
|
||||||
import hr, { Staff } from '@anticrm/hr'
|
import hr, { Request } from '@anticrm/hr'
|
||||||
import { Table } from '@anticrm/view-resources'
|
import { Table } from '@anticrm/view-resources'
|
||||||
|
|
||||||
export let date: Date
|
export let requests: Ref<Request>[]
|
||||||
export let endDate: number
|
|
||||||
export let employee: Ref<Staff>
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Table
|
<Table
|
||||||
_class={hr.class.Request}
|
_class={hr.class.Request}
|
||||||
query={{
|
query={{
|
||||||
attachedTo: employee,
|
_id: { $in: requests }
|
||||||
dueDate: { $gt: date.getTime() },
|
|
||||||
date: { $lt: endDate }
|
|
||||||
}}
|
}}
|
||||||
config={['$lookup.type.label', 'date', 'dueDate']}
|
config={['$lookup.type.label', 'tzDate', 'tzDueDate']}
|
||||||
options={{ sort: { date: SortingOrder.Ascending } }}
|
options={{ sort: { date: SortingOrder.Ascending } }}
|
||||||
/>
|
/>
|
||||||
|
@ -17,12 +17,10 @@
|
|||||||
import calendar from '@anticrm/calendar-resources/src/plugin'
|
import calendar from '@anticrm/calendar-resources/src/plugin'
|
||||||
import { Ref } from '@anticrm/core'
|
import { Ref } from '@anticrm/core'
|
||||||
import { Department } from '@anticrm/hr'
|
import { Department } from '@anticrm/hr'
|
||||||
import { getEmbeddedLabel } from '@anticrm/platform'
|
|
||||||
import { createQuery, SpaceSelector } from '@anticrm/presentation'
|
import { createQuery, SpaceSelector } from '@anticrm/presentation'
|
||||||
import { Button, Icon, IconBack, IconForward, Label } from '@anticrm/ui'
|
import { Button, Icon, IconBack, IconForward, Label } from '@anticrm/ui'
|
||||||
import view from '@anticrm/view'
|
import view from '@anticrm/view'
|
||||||
import hr from '../plugin'
|
import hr from '../plugin'
|
||||||
import { tableToCSV } from '../utils'
|
|
||||||
import ScheduleMonthView from './ScheduleView.svelte'
|
import ScheduleMonthView from './ScheduleView.svelte'
|
||||||
|
|
||||||
let department = hr.ids.Head
|
let department = hr.ids.Head
|
||||||
@ -140,23 +138,6 @@
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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}
|
{/if}
|
||||||
|
|
||||||
<SpaceSelector _class={hr.class.Department} label={hr.string.Department} bind:space={department} />
|
<SpaceSelector _class={hr.class.Department} label={hr.string.Department} bind:space={department} />
|
||||||
|
@ -15,7 +15,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { CalendarMode } from '@anticrm/calendar-resources'
|
import { CalendarMode } from '@anticrm/calendar-resources'
|
||||||
import { Employee } from '@anticrm/contact'
|
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 type { Department, Request, RequestType, Staff } from '@anticrm/hr'
|
||||||
import { createQuery } from '@anticrm/presentation'
|
import { createQuery } from '@anticrm/presentation'
|
||||||
import { Label } from '@anticrm/ui'
|
import { Label } from '@anticrm/ui'
|
||||||
@ -33,11 +33,12 @@
|
|||||||
|
|
||||||
$: startDate = new Date(
|
$: startDate = new Date(
|
||||||
new Date(mode === CalendarMode.Year ? new Date(currentDate).setMonth(1) : currentDate).setDate(1)
|
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
|
mode === CalendarMode.Year
|
||||||
? new Date(startDate).setFullYear(new Date(startDate).getFullYear() + 1)
|
? new Date(startDate).setFullYear(new Date(startDate).getFullYear() + 1)
|
||||||
: new Date(startDate).setMonth(new Date(startDate).getMonth() + 1)
|
: new Date(startDate).setMonth(new Date(startDate).getMonth() + 1)
|
||||||
|
)
|
||||||
$: departments = [department, ...getDescendants(department, descendants)]
|
$: departments = [department, ...getDescendants(department, descendants)]
|
||||||
|
|
||||||
const lq = createQuery()
|
const lq = createQuery()
|
||||||
@ -76,12 +77,14 @@
|
|||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
function update (departments: Ref<Department>[], startDate: Timestamp, endDate: Timestamp) {
|
function update (departments: Ref<Department>[], startDate: Date, endDate: Date) {
|
||||||
lq.query(
|
lq.query(
|
||||||
hr.class.Request,
|
hr.class.Request,
|
||||||
{
|
{
|
||||||
dueDate: { $gte: startDate },
|
'tzDueDate.year': { $gte: startDate.getFullYear() },
|
||||||
date: { $lt: endDate },
|
'tzDueDate.month': { $gte: startDate.getMonth() },
|
||||||
|
'tzDate.year': { $lte: endDate.getFullYear() },
|
||||||
|
'tzDate.month': { $lte: endDate.getFullYear() },
|
||||||
space: { $in: departments }
|
space: { $in: departments }
|
||||||
},
|
},
|
||||||
(res) => {
|
(res) => {
|
||||||
@ -116,14 +119,13 @@
|
|||||||
<MonthView
|
<MonthView
|
||||||
{departmentStaff}
|
{departmentStaff}
|
||||||
{employeeRequests}
|
{employeeRequests}
|
||||||
{startDate}
|
|
||||||
{endDate}
|
|
||||||
teamLead={getTeamLead(department)}
|
|
||||||
{types}
|
{types}
|
||||||
|
{startDate}
|
||||||
|
teamLead={getTeamLead(department)}
|
||||||
{currentDate}
|
{currentDate}
|
||||||
/>
|
/>
|
||||||
{:else if display === 'stats'}
|
{:else if display === 'stats'}
|
||||||
<MonthTableView {departmentStaff} {employeeRequests} {startDate} {endDate} {types} {currentDate} />
|
<MonthTableView {departmentStaff} {employeeRequests} {types} {currentDate} />
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
|
51
plugins/hr-resources/src/components/TzDateEditor.svelte
Normal file
51
plugins/hr-resources/src/components/TzDateEditor.svelte
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
26
plugins/hr-resources/src/components/TzDatePresenter.svelte
Normal file
26
plugins/hr-resources/src/components/TzDatePresenter.svelte
Normal 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} />
|
@ -13,20 +13,19 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { FindOptions, Ref } from '@anticrm/core'
|
import { Doc, Ref } from '@anticrm/core'
|
||||||
import type { Request, RequestType, Staff } from '@anticrm/hr'
|
import type { Request, RequestType, Staff } from '@anticrm/hr'
|
||||||
import { getEmbeddedLabel } from '@anticrm/platform'
|
import { getEmbeddedLabel } from '@anticrm/platform'
|
||||||
import { Label, Scroller } from '@anticrm/ui'
|
import { createQuery, getClient } from '@anticrm/presentation'
|
||||||
import { Table } from '@anticrm/view-resources'
|
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 hr from '../../plugin'
|
||||||
import { getMonth, getTotal, weekDays } from '../../utils'
|
import { fromTzDate, getMonth, getTotal, tableToCSV, weekDays } from '../../utils'
|
||||||
import NumberPresenter from './StatPresenter.svelte'
|
import NumberPresenter from './StatPresenter.svelte'
|
||||||
|
|
||||||
export let currentDate: Date = new Date()
|
export let currentDate: Date = new Date()
|
||||||
|
|
||||||
export let startDate: number
|
|
||||||
export let endDate: number
|
|
||||||
|
|
||||||
export let departmentStaff: Staff[]
|
export let departmentStaff: Staff[]
|
||||||
export let types: Map<Ref<RequestType>, RequestType>
|
export let types: Map<Ref<RequestType>, RequestType>
|
||||||
|
|
||||||
@ -35,91 +34,203 @@
|
|||||||
$: month = getMonth(currentDate, currentDate.getMonth())
|
$: month = getMonth(currentDate, currentDate.getMonth())
|
||||||
$: wDays = weekDays(month.getUTCFullYear(), month.getUTCMonth())
|
$: wDays = weekDays(month.getUTCFullYear(), month.getUTCMonth())
|
||||||
|
|
||||||
const options: FindOptions<Staff> = {
|
function getDateRange (request: Request): string {
|
||||||
lookup: {
|
const st = new Date(fromTzDate(request.tzDate)).getDate()
|
||||||
department: hr.class.Department
|
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))
|
||||||
|
|
||||||
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)
|
|
||||||
let ds = Array.from(Array(days).keys()).map((it) => st + it)
|
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) {
|
if ((type?.value ?? -1) < 0) {
|
||||||
ds = ds.filter((it) => ![0, 6].includes(new Date(stDate.setDate(it)).getDay()))
|
ds = ds.filter((it) => ![0, 6].includes(new Date(stDate.setDate(it)).getDay()))
|
||||||
}
|
}
|
||||||
return ds.join(' ')
|
return ds.join(' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
$: typevals = Array.from(
|
function getEndDate (date: Date): number {
|
||||||
Array.from(types.values()).map((it) => ({
|
return new Date(date).setMonth(date.getMonth() + 1)
|
||||||
key: '',
|
}
|
||||||
label: it.label,
|
function getRequests (employee: Ref<Staff>, date: Date): Request[] {
|
||||||
presenter: NumberPresenter,
|
const requests = employeeRequests.get(employee)
|
||||||
props: {
|
if (requests === undefined) return []
|
||||||
month: month ?? getMonth(currentDate, currentDate.getMonth()),
|
const res: Request[] = []
|
||||||
employeeRequests,
|
const time = date.getTime()
|
||||||
display: (req: Request[]) =>
|
const endTime = getEndDate(date)
|
||||||
req
|
for (const request of requests) {
|
||||||
.filter((r) => r.type === it._id)
|
if (fromTzDate(request.tzDate) <= endTime && fromTzDate(request.tzDueDate) > time) {
|
||||||
.map((it) => getDateRange(it))
|
res.push(request)
|
||||||
.join(' ')
|
|
||||||
}
|
}
|
||||||
}))
|
}
|
||||||
|
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 = [
|
$: overrideConfig = new Map<string, BuildModelKey>([
|
||||||
'',
|
[
|
||||||
'$lookup.department',
|
'@wdCount',
|
||||||
{
|
{
|
||||||
key: '',
|
key: '',
|
||||||
label: getEmbeddedLabel('Working days'),
|
label: getEmbeddedLabel('Working days'),
|
||||||
presenter: NumberPresenter,
|
presenter: NumberPresenter,
|
||||||
props: {
|
props: {
|
||||||
month: month ?? getMonth(currentDate, currentDate.getMonth()),
|
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)
|
||||||
}
|
}
|
||||||
},
|
],
|
||||||
{
|
[
|
||||||
key: '',
|
'@ptoCount',
|
||||||
label: getEmbeddedLabel('PTOs'),
|
{
|
||||||
presenter: NumberPresenter,
|
key: '',
|
||||||
props: {
|
label: getEmbeddedLabel('PTOs'),
|
||||||
month: month ?? getMonth(currentDate, currentDate.getMonth()),
|
presenter: NumberPresenter,
|
||||||
employeeRequests,
|
props: {
|
||||||
display: (req: Request[]) => getTotal(req, types, (a) => (a < 0 ? Math.abs(a) : 0))
|
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: '',
|
'@extraCount',
|
||||||
label: getEmbeddedLabel('EXTRa'),
|
{
|
||||||
presenter: NumberPresenter,
|
key: '',
|
||||||
props: {
|
label: getEmbeddedLabel('EXTRa'),
|
||||||
month: month ?? getMonth(currentDate, currentDate.getMonth()),
|
presenter: NumberPresenter,
|
||||||
employeeRequests,
|
props: {
|
||||||
display: (req: Request[]) => getTotal(req, types, (a) => (a > 0 ? Math.abs(a) : 0))
|
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>
|
</script>
|
||||||
|
|
||||||
{#if departmentStaff.length}
|
{#if departmentStaff.length}
|
||||||
<Scroller tableFade>
|
<Scroller tableFade>
|
||||||
<div class="p-2">
|
<div class="p-2">
|
||||||
<Table
|
{#if descr}
|
||||||
tableId={'exportableData'}
|
{#if loading}
|
||||||
_class={hr.mixin.Staff}
|
<Loading />
|
||||||
query={{ _id: { $in: departmentStaff.map((it) => it._id) } }}
|
{:else}
|
||||||
{config}
|
<div class="flex-row-center flex-reverse">
|
||||||
{options}
|
<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>
|
</div>
|
||||||
</Scroller>
|
</Scroller>
|
||||||
{:else}
|
{:else}
|
||||||
|
@ -32,20 +32,20 @@
|
|||||||
tooltip
|
tooltip
|
||||||
} from '@anticrm/ui'
|
} from '@anticrm/ui'
|
||||||
import hr from '../../plugin'
|
import hr from '../../plugin'
|
||||||
|
import { fromTzDate, getTotal } from '../../utils'
|
||||||
import CreateRequest from '../CreateRequest.svelte'
|
import CreateRequest from '../CreateRequest.svelte'
|
||||||
import RequestsPopup from '../RequestsPopup.svelte'
|
import RequestsPopup from '../RequestsPopup.svelte'
|
||||||
import ScheduleRequests from '../ScheduleRequests.svelte'
|
import ScheduleRequests from '../ScheduleRequests.svelte'
|
||||||
|
|
||||||
export let currentDate: Date = new Date()
|
export let currentDate: Date = new Date()
|
||||||
|
|
||||||
export let startDate: number
|
export let startDate: Date
|
||||||
export let endDate: number
|
|
||||||
|
|
||||||
export let departmentStaff: Staff[]
|
export let departmentStaff: Staff[]
|
||||||
export let types: Map<Ref<RequestType>, RequestType>
|
|
||||||
|
|
||||||
export let employeeRequests: Map<Ref<Staff>, Request[]>
|
export let employeeRequests: Map<Ref<Staff>, Request[]>
|
||||||
export let teamLead: Ref<Employee> | undefined
|
export let teamLead: Ref<Employee> | undefined
|
||||||
|
export let types: Map<Ref<RequestType>, RequestType>
|
||||||
|
|
||||||
const todayDate = new Date()
|
const todayDate = new Date()
|
||||||
|
|
||||||
@ -56,7 +56,7 @@
|
|||||||
const time = date.getTime()
|
const time = date.getTime()
|
||||||
const endTime = getEndDate(date)
|
const endTime = getEndDate(date)
|
||||||
for (const request of requests) {
|
for (const request of requests) {
|
||||||
if (request.date <= endTime && request.dueDate > time) {
|
if (fromTzDate(request.tzDate) <= endTime && fromTzDate(request.tzDueDate) > time) {
|
||||||
res.push(request)
|
res.push(request)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -85,15 +85,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getEndDate (date: Date): number {
|
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
|
if (requests.length === 0) return
|
||||||
const endDate = getEndDate(date)
|
|
||||||
return {
|
return {
|
||||||
component: RequestsPopup,
|
component: RequestsPopup,
|
||||||
props: { date, endDate, employee: employee._id }
|
props: { requests: requests.map((it) => it._id) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,8 +109,9 @@
|
|||||||
<th>
|
<th>
|
||||||
<Label label={contact.string.Employee} />
|
<Label label={contact.string.Employee} />
|
||||||
</th>
|
</th>
|
||||||
|
<th>#</th>
|
||||||
{#each values as value, i}
|
{#each values as value, i}
|
||||||
{@const day = getDay(new Date(startDate), value)}
|
{@const day = getDay(startDate, value)}
|
||||||
<th
|
<th
|
||||||
class:today={areDatesEqual(todayDate, day)}
|
class:today={areDatesEqual(todayDate, day)}
|
||||||
class:weekend={isWeekend(day)}
|
class:weekend={isWeekend(day)}
|
||||||
@ -130,15 +130,19 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each departmentStaff as employee, row}
|
{#each departmentStaff as employee, row}
|
||||||
|
{@const requests = employeeRequests.get(employee._id) ?? []}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<EmployeePresenter value={employee} />
|
<EmployeePresenter value={employee} />
|
||||||
</td>
|
</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}
|
{#each values as value, i}
|
||||||
{@const date = getDay(new Date(startDate), value)}
|
{@const date = getDay(startDate, value)}
|
||||||
{@const requests = getRequests(employee._id, date)}
|
{@const requests = getRequests(employee._id, date)}
|
||||||
{@const editable = isEditable(employee)}
|
{@const editable = isEditable(employee)}
|
||||||
{@const tooltipValue = getTooltip(requests, employee, date)}
|
{@const tooltipValue = getTooltip(requests)}
|
||||||
{#key [tooltipValue, editable]}
|
{#key [tooltipValue, editable]}
|
||||||
<td
|
<td
|
||||||
class:today={areDatesEqual(todayDate, date)}
|
class:today={areDatesEqual(todayDate, date)}
|
||||||
|
@ -18,27 +18,9 @@
|
|||||||
import { Request, Staff } from '@anticrm/hr'
|
import { Request, Staff } from '@anticrm/hr'
|
||||||
|
|
||||||
export let value: Staff
|
export let value: Staff
|
||||||
export let employeeRequests: Map<Ref<Staff>, Request[]>
|
|
||||||
export let display: (requests: Request[]) => number | string
|
export let display: (requests: Request[]) => number | string
|
||||||
export let month: Date
|
export let month: Date
|
||||||
|
export let getRequests: (employee: Ref<Staff>, date: Date) => Request[]
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
$: reqs = getRequests(value._id, month)
|
$: reqs = getRequests(value._id, month)
|
||||||
$: _value = display(reqs)
|
$: _value = display(reqs)
|
||||||
|
@ -19,7 +19,7 @@
|
|||||||
import type { Request, RequestType, Staff } from '@anticrm/hr'
|
import type { Request, RequestType, Staff } from '@anticrm/hr'
|
||||||
import { Label, LabelAndProps, Scroller, tooltip } from '@anticrm/ui'
|
import { Label, LabelAndProps, Scroller, tooltip } from '@anticrm/ui'
|
||||||
import hr from '../../plugin'
|
import hr from '../../plugin'
|
||||||
import { getMonth, getTotal, weekDays } from '../../utils'
|
import { fromTzDate, getMonth, getTotal, weekDays } from '../../utils'
|
||||||
import RequestsPopup from '../RequestsPopup.svelte'
|
import RequestsPopup from '../RequestsPopup.svelte'
|
||||||
|
|
||||||
export let currentDate: Date = new Date()
|
export let currentDate: Date = new Date()
|
||||||
@ -38,7 +38,7 @@
|
|||||||
const time = date.getTime()
|
const time = date.getTime()
|
||||||
const endTime = getEndDate(date)
|
const endTime = getEndDate(date)
|
||||||
for (const request of requests) {
|
for (const request of requests) {
|
||||||
if (request.date <= endTime && request.dueDate > time) {
|
if (fromTzDate(request.tzDate) <= endTime && fromTzDate(request.tzDueDate) > time) {
|
||||||
res.push(request)
|
res.push(request)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -49,12 +49,11 @@
|
|||||||
return new Date(date).setMonth(date.getMonth() + 1)
|
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
|
if (requests.length === 0) return
|
||||||
const endDate = getEndDate(date)
|
|
||||||
return {
|
return {
|
||||||
component: RequestsPopup,
|
component: RequestsPopup,
|
||||||
props: { date, endDate, employee: employee._id }
|
props: { requests: requests.map((it) => it._id) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -119,7 +118,7 @@
|
|||||||
{#each values as value, i}
|
{#each values as value, i}
|
||||||
{@const month = getMonth(currentDate, value)}
|
{@const month = getMonth(currentDate, value)}
|
||||||
{@const requests = getRequests(employeeRequests, employee._id, month)}
|
{@const requests = getRequests(employeeRequests, employee._id, month)}
|
||||||
{@const tooltipValue = getTooltip(requests, employee, month)}
|
{@const tooltipValue = getTooltip(requests)}
|
||||||
{#key tooltipValue}
|
{#key tooltipValue}
|
||||||
<td
|
<td
|
||||||
class:today={month.getFullYear() === todayDate.getFullYear() &&
|
class:today={month.getFullYear() === todayDate.getFullYear() &&
|
||||||
|
@ -20,6 +20,9 @@ import EditDepartment from './components/EditDepartment.svelte'
|
|||||||
import EditRequest from './components/EditRequest.svelte'
|
import EditRequest from './components/EditRequest.svelte'
|
||||||
import Schedule from './components/Schedule.svelte'
|
import Schedule from './components/Schedule.svelte'
|
||||||
import Structure from './components/Structure.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> => ({
|
export default async (): Promise<Resources> => ({
|
||||||
component: {
|
component: {
|
||||||
@ -28,6 +31,9 @@ export default async (): Promise<Resources> => ({
|
|||||||
DepartmentStaff,
|
DepartmentStaff,
|
||||||
DepartmentEditor,
|
DepartmentEditor,
|
||||||
Schedule,
|
Schedule,
|
||||||
EditRequest
|
EditRequest,
|
||||||
|
TzDatePresenter,
|
||||||
|
TzDateEditor,
|
||||||
|
RequestPresenter
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { Employee, formatName } from '@anticrm/contact'
|
import { Employee, formatName } from '@anticrm/contact'
|
||||||
import { Ref, TxOperations } from '@anticrm/core'
|
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 { MessageBox } from '@anticrm/presentation'
|
||||||
import { showPopup } from '@anticrm/ui'
|
import { showPopup } from '@anticrm/ui'
|
||||||
import hr from './plugin'
|
import hr from './plugin'
|
||||||
@ -56,18 +56,27 @@ export async function addMember (client: TxOperations, employee?: Employee, valu
|
|||||||
/**
|
/**
|
||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
export function toUTC (date: Date | number, hours = 12, mins = 0, sec = 0): number {
|
export function toTzDate (date: Date): TzDate {
|
||||||
const res = new Date(date)
|
return {
|
||||||
if (res.getUTCFullYear() !== res.getFullYear()) {
|
year: date.getFullYear(),
|
||||||
res.setUTCFullYear(res.getFullYear())
|
month: date.getMonth(),
|
||||||
|
day: date.getDate(),
|
||||||
|
offset: date.getTimezoneOffset()
|
||||||
}
|
}
|
||||||
if (res.getUTCMonth() !== res.getMonth()) {
|
}
|
||||||
res.setUTCMonth(res.getMonth())
|
|
||||||
}
|
/**
|
||||||
if (res.getUTCDate() !== res.getDate()) {
|
* @public
|
||||||
res.setUTCDate(res.getDate())
|
*/
|
||||||
}
|
export function fromTzDate (tzDate: TzDate): number {
|
||||||
return res.setUTCHours(hours, mins, sec, 0)
|
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
|
let total = 0
|
||||||
for (const request of requests) {
|
for (const request of requests) {
|
||||||
const type = types.get(request.type)
|
const type = types.get(request.type)
|
||||||
let days = Math.abs((request.dueDate - request.date) / 1000 / 60 / 60 / 24)
|
const days =
|
||||||
if (days === 0) {
|
Math.floor(Math.abs((1 + fromTzDate(request.tzDueDate) - fromTzDate(request.tzDate)) / 1000 / 60 / 60 / 24)) + 1
|
||||||
days = 1
|
const stDate = new Date(fromTzDate(request.tzDate))
|
||||||
}
|
|
||||||
const stDate = new Date(request.date)
|
|
||||||
const stDateDate = stDate.getDate()
|
const stDateDate = stDate.getDate()
|
||||||
let ds = Array.from(Array(days).keys()).map((it) => stDateDate + it)
|
let ds = Array.from(Array(days).keys()).map((it) => stDateDate + it)
|
||||||
if ((type?.value ?? -1) < 0) {
|
if ((type?.value ?? -1) < 0) {
|
||||||
|
@ -14,7 +14,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import type { Employee, EmployeeAccount } from '@anticrm/contact'
|
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 type { Asset, IntlString, Plugin } from '@anticrm/platform'
|
||||||
import { plugin } from '@anticrm/platform'
|
import { plugin } from '@anticrm/platform'
|
||||||
import { Viewlet } from '@anticrm/view'
|
import { Viewlet } from '@anticrm/view'
|
||||||
@ -54,6 +54,16 @@ export interface RequestType extends Doc {
|
|||||||
color: number
|
color: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
export interface TzDate {
|
||||||
|
year: number
|
||||||
|
month: number
|
||||||
|
day: number
|
||||||
|
offset: number
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
@ -71,11 +81,8 @@ export interface Request extends AttachedDoc {
|
|||||||
attachments?: number
|
attachments?: number
|
||||||
|
|
||||||
// Date always in UTC
|
// Date always in UTC
|
||||||
date: Timestamp
|
tzDate: TzDate
|
||||||
dueDate: Timestamp
|
tzDueDate: TzDate
|
||||||
|
|
||||||
// Timezone offset in minutes.
|
|
||||||
timezoneOffset: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -94,7 +101,8 @@ const hr = plugin(hrId, {
|
|||||||
Department: '' as Ref<Class<Department>>,
|
Department: '' as Ref<Class<Department>>,
|
||||||
DepartmentMember: '' as Ref<Class<DepartmentMember>>,
|
DepartmentMember: '' as Ref<Class<DepartmentMember>>,
|
||||||
Request: '' as Ref<Class<Request>>,
|
Request: '' as Ref<Class<Request>>,
|
||||||
RequestType: '' as Ref<Class<RequestType>>
|
RequestType: '' as Ref<Class<RequestType>>,
|
||||||
|
TzDate: '' as Ref<Class<Type<TzDate>>>
|
||||||
},
|
},
|
||||||
mixin: {
|
mixin: {
|
||||||
Staff: '' as Ref<Mixin<Staff>>
|
Staff: '' as Ref<Mixin<Staff>>
|
||||||
@ -121,7 +129,8 @@ const hr = plugin(hrId, {
|
|||||||
Overtime2: '' as Ref<RequestType>
|
Overtime2: '' as Ref<RequestType>
|
||||||
},
|
},
|
||||||
viewlet: {
|
viewlet: {
|
||||||
TableMember: '' as Ref<Viewlet>
|
TableMember: '' as Ref<Viewlet>,
|
||||||
|
StaffStats: '' as Ref<Viewlet>
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -14,7 +14,7 @@
|
|||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Ref, SortingOrder } from '@anticrm/core'
|
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 { createQuery } from '@anticrm/presentation'
|
||||||
import { Project } from '@anticrm/tracker'
|
import { Project } from '@anticrm/tracker'
|
||||||
import type { ButtonKind, ButtonSize } from '@anticrm/ui'
|
import type { ButtonKind, ButtonSize } from '@anticrm/ui'
|
||||||
@ -89,32 +89,14 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if onlyIcon}
|
<Button
|
||||||
<Button
|
{kind}
|
||||||
{kind}
|
{size}
|
||||||
{size}
|
{shape}
|
||||||
{shape}
|
{width}
|
||||||
{width}
|
{justify}
|
||||||
{justify}
|
label={onlyIcon || projectText === undefined ? undefined : getEmbeddedLabel(projectText)}
|
||||||
icon={projectIcon}
|
icon={projectIcon}
|
||||||
disabled={!isEditable}
|
disabled={!isEditable}
|
||||||
on:click={handleProjectEditorOpened}
|
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}
|
|
||||||
|
@ -228,6 +228,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<Component
|
<Component
|
||||||
is={notification.component.NotificationPresenter}
|
is={notification.component.NotificationPresenter}
|
||||||
|
showLoading={false}
|
||||||
props={{ value: docObject, kind: 'table' }}
|
props={{ value: docObject, kind: 'table' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
Loading…
Reference in New Issue
Block a user