From 12974b5bb7029148ec07962c36a9a1cadae0c7ae Mon Sep 17 00:00:00 2001 From: Andrey Sobolev Date: Thu, 13 Apr 2023 22:25:34 +0700 Subject: [PATCH] TSK-1154: Statuses table support (#2974) + allow to export list of Vacancies Signed-off-by: Andrey Sobolev --- packages/ui/src/utils.ts | 20 ++ .../CreateHRApplicationMapping.svelte | 193 ++++++++++++++---- plugins/bitrix/src/hr.ts | 12 +- plugins/bitrix/src/sync.ts | 18 +- plugins/bitrix/src/types.ts | 16 ++ plugins/bitrix/src/utils.ts | 103 +++++++--- .../components/schedule/MonthTableView.svelte | 3 +- plugins/hr-resources/src/utils.ts | 21 +- plugins/recruit-assets/lang/en.json | 3 +- plugins/recruit-assets/lang/ru.json | 3 +- .../src/components/Vacancies.svelte | 39 +++- plugins/recruit-resources/src/plugin.ts | 3 +- .../src/components/TableBrowser.svelte | 2 + 13 files changed, 330 insertions(+), 106 deletions(-) diff --git a/packages/ui/src/utils.ts b/packages/ui/src/utils.ts index 65f601b4cf..fd07ec922d 100644 --- a/packages/ui/src/utils.ts +++ b/packages/ui/src/utils.ts @@ -79,3 +79,23 @@ export function handler (target: T, op: (value: T, evt: EVT op(target, evt) } } + +/** + * @public + */ +export function tableToCSV (tableId: string, separator = ','): string { + const rows = document.querySelectorAll('table#' + tableId + ' tr') + // Construct csv + const csv: string[] = [] + for (let i = 0; i < rows.length; i++) { + const row: string[] = [] + const cols = rows[i].querySelectorAll('td, th') + for (let j = 0; j < cols.length; j++) { + let data = (cols[j] as HTMLElement).innerText.replace(/(\r\n|\n|\r)/gm, '').replace(/(\s\s)/gm, ' ') + data = data.replace(/"/g, '""') + row.push('"' + data + '"') + } + csv.push(row.join(separator)) + } + return csv.join('\n') +} diff --git a/plugins/bitrix-resources/src/components/mappings/CreateHRApplicationMapping.svelte b/plugins/bitrix-resources/src/components/mappings/CreateHRApplicationMapping.svelte index fa04ccde79..836dab443f 100644 --- a/plugins/bitrix-resources/src/components/mappings/CreateHRApplicationMapping.svelte +++ b/plugins/bitrix-resources/src/components/mappings/CreateHRApplicationMapping.svelte @@ -4,12 +4,15 @@ BitrixFieldMapping, CreateHRApplication, Fields, - MappingOperation + MappingOperation, + getAllAttributes } from '@hcengineering/bitrix' import { AnyAttribute } from '@hcengineering/core' import { getEmbeddedLabel } from '@hcengineering/platform' - import { getClient } from '@hcengineering/presentation' - import task from '@hcengineering/task' + import { createQuery, getClient } from '@hcengineering/presentation' + import InlineAttributeBarEditor from '@hcengineering/presentation/src/components/InlineAttributeBarEditor.svelte' + import recruit from '@hcengineering/recruit' + import task, { DoneStateTemplate, StateTemplate } from '@hcengineering/task' import { Button, DropdownIntlItem, @@ -20,7 +23,6 @@ } from '@hcengineering/ui' import { ObjectBox } from '@hcengineering/view-resources' import bitrix from '../../plugin' - import recruit from '@hcengineering/recruit' export let mapping: BitrixEntityMapping export let fields: Fields = {} @@ -31,6 +33,7 @@ let vacancyField = (field?.operation as CreateHRApplication)?.vacancyField let defaultTemplate = (field?.operation as CreateHRApplication)?.defaultTemplate let copyTalentFields = (field?.operation as CreateHRApplication)?.copyTalentFields ?? [] + let stateMapping = (field?.operation as CreateHRApplication)?.stateMapping ?? [] const client = getClient() @@ -42,7 +45,8 @@ stateField, vacancyField, defaultTemplate, - copyTalentFields + copyTalentFields, + stateMapping } }) } else { @@ -54,7 +58,8 @@ stateField, vacancyField, defaultTemplate, - copyTalentFields + copyTalentFields, + stateMapping } }) } @@ -68,49 +73,156 @@ } $: items = getItems(fields) - $: allAttrs = Array.from(client.getHierarchy().getAllAttributes(recruit.mixin.Candidate).values()) + $: allAttrs = Array.from(getAllAttributes(client, recruit.mixin.Candidate).values()) $: attrs = allAttrs.map((it) => ({ id: it.name, label: it.label } as DropdownIntlItem)) $: applicantAllAttrs = Array.from(client.getHierarchy().getAllAttributes(recruit.class.Applicant).values()) $: applicantAttrs = applicantAllAttrs.map((it) => ({ id: it.name, label: it.label } as DropdownIntlItem)) + + $: sourceStates = Array.from(mapping.bitrixFields[stateField].items?.values() ?? []).map( + (it) => ({ id: it.VALUE, label: it.VALUE } as DropdownTextItem) + ) + + const statusQuery = createQuery() + const doneQuery = createQuery() + + let stateTemplates: StateTemplate[] = [] + let doneStateTemplates: DoneStateTemplate[] = [] + + $: statusQuery.query(task.class.StateTemplate, { attachedTo: defaultTemplate }, (res) => { + stateTemplates = res + }) + + $: doneQuery.query(task.class.DoneStateTemplate, { attachedTo: defaultTemplate }, (res) => { + doneStateTemplates = res + }) + + $: stateTitles = [{ id: '', label: 'None' }, ...stateTemplates.map((it) => ({ id: it.name, label: it.name }))] + $: doneStateTitles = [{ id: '', label: 'None' }, ...doneStateTemplates.map((it) => ({ id: it.name, label: it.name }))]
- - - +
+ Vacancy: + +
+
+ State: + +
+
+ Template: + +
- {#each copyTalentFields as f, i} -
- => - -
- {/each} -
+
+ {#each copyTalentFields as f, i} +
+ => + +
+ {/each} +
+
+ State mapping: +
+
+ {#each stateMapping as m} +
+ => + + Done state: + + {#each m.updateCandidate as c} + {@const attribute = allAttrs.find((it) => it.name === c.attr)} + + {#if attribute} + => + + {/if} + {/each} +
+ {/each} +
@@ -132,4 +244,7 @@ color: var(--caption-color); } } + .scroll { + overflow: auto; + } diff --git a/plugins/bitrix/src/hr.ts b/plugins/bitrix/src/hr.ts index 7c9eb465b9..9e345f42a3 100644 --- a/plugins/bitrix/src/hr.ts +++ b/plugins/bitrix/src/hr.ts @@ -1,6 +1,6 @@ import { Organization } from '@hcengineering/contact' -import core, { Account, Client, Doc, Ref, SortingOrder, TxOperations } from '@hcengineering/core' -import recruit, { Vacancy } from '@hcengineering/recruit' +import core, { Account, Client, Data, Doc, Ref, SortingOrder, TxOperations } from '@hcengineering/core' +import recruit, { Applicant, Vacancy } from '@hcengineering/recruit' import task, { KanbanTemplate, State, calcRank, createKanban } from '@hcengineering/task' export async function createVacancy ( @@ -42,7 +42,8 @@ export async function createApplication ( client: TxOperations, selectedState: State, _space: Ref, - doc: Doc + doc: Doc, + data: Data ): Promise { if (selectedState === undefined) { throw new Error(`Please select initial state:${_space}`) @@ -60,13 +61,10 @@ export async function createApplication ( const incResult = await client.update(sequence, { $inc: { sequence: 1 } }, true) await client.addCollection(recruit.class.Applicant, _space, doc._id, recruit.mixin.Candidate, 'applications', { + ...data, state: state._id, - doneState: null, number: (incResult as any).object.sequence, - assignee: null, rank: calcRank(lastOne, undefined), - startDate: null, - dueDate: null, createOn: Date.now() }) } diff --git a/plugins/bitrix/src/sync.ts b/plugins/bitrix/src/sync.ts index 13e65520ab..e9009b2a0d 100644 --- a/plugins/bitrix/src/sync.ts +++ b/plugins/bitrix/src/sync.ts @@ -25,6 +25,7 @@ import core, { WithLookup } from '@hcengineering/core' import gmail, { Message } from '@hcengineering/gmail' +import recruit from '@hcengineering/recruit' import tags, { TagElement } from '@hcengineering/tags' import { deepEqual } from 'fast-equals' import { BitrixClient } from './client' @@ -40,7 +41,6 @@ import { LoginInfo } from './types' import { convert, ConvertResult } from './utils' -import recruit from '@hcengineering/recruit' async function updateDoc (client: ApplyOperations, doc: Doc, raw: Doc | Data, date: Timestamp): Promise { // We need to update fields if they are different. @@ -109,6 +109,17 @@ export async function syncDocument ( try { const applyOp = client.apply('bitrix') + + if (existing !== undefined) { + // We need update document id. + resultDoc.document._id = existing._id as Ref + } + + // Operations could add more change instructions + for (const op of resultDoc.postOperations) { + await op(resultDoc, extraDocs, existing) + } + // const newDoc = existing === undefined existing = await updateMainDoc(applyOp) @@ -130,9 +141,6 @@ export async function syncDocument ( await applyOp.createDoc(_class, space, data, _id, resultDoc.document.modifiedOn, resultDoc.document.modifiedBy) } - for (const op of resultDoc.postOperations) { - await op(resultDoc, extraDocs, existing) - } const idMapping = new Map, Ref>() // Find all attachment documents to existing. @@ -322,8 +330,6 @@ export async function syncDocument ( async function updateMainDoc (applyOp: ApplyOperations): Promise { if (existing !== undefined) { - // We need update doucment id. - resultDoc.document._id = existing._id as Ref // We need to update fields if they are different. return (await updateDoc(applyOp, existing, resultDoc.document, resultDoc.document.modifiedOn)) as BitrixSyncDoc // Go over extra documents. diff --git a/plugins/bitrix/src/types.ts b/plugins/bitrix/src/types.ts index 55644a4d7e..68e41e88f6 100644 --- a/plugins/bitrix/src/types.ts +++ b/plugins/bitrix/src/types.ts @@ -271,6 +271,19 @@ export interface CreateAttachedField { referenceField?: string } +/** + * @public + */ +export interface BitrixStateMapping { + sourceName: string + targetName: string // if empty will not create application + + doneState: string // Alternative is to set doneState to value + + // Allow to put some values, in case of some statues + updateCandidate: { attr: string, value: any }[] +} + /** * @public */ @@ -283,6 +296,9 @@ export interface CreateHRApplication { defaultTemplate: Ref copyTalentFields?: { candidate: Ref, applicant: Ref }[] + + // We would like to map some of bitrix states to our states, name matching is used to hold values. + stateMapping?: BitrixStateMapping[] } /** diff --git a/plugins/bitrix/src/utils.ts b/plugins/bitrix/src/utils.ts index 6fbb47eb9d..0be8f58719 100644 --- a/plugins/bitrix/src/utils.ts +++ b/plugins/bitrix/src/utils.ts @@ -24,6 +24,7 @@ import bitrix, { BitrixEntityMapping, BitrixEntityType, BitrixFieldMapping, + BitrixStateMapping, BitrixSyncDoc, CopyValueOperation, CreateChannelOperation, @@ -404,8 +405,7 @@ export async function convert ( const getCreateAttachedValue = async (attr: AnyAttribute, operation: CreateHRApplication): Promise => { const vacancyName = extractValue(operation.vacancyField) - const statusName = extractValue(operation.stateField) - + const sourceStatusName = extractValue(operation.stateField) postOperations.push(async (doc, extraDocs, existingDoc) => { let vacancyId: Ref | undefined @@ -440,42 +440,77 @@ export async function convert ( vacancies.push(vacancy) } } - } else { - return } - // Check if candidate already have vacancy - const existing = applications.find( - (it) => - it.attachedTo === ((existingDoc?._id ?? doc.document._id) as unknown as Ref) && - it.space === vacancyId - ) + if (sourceStatusName != null && sourceStatusName !== '') { + // Check if candidate already have vacancy + const existing = applications.find( + (it) => + it.attachedTo === ((existingDoc?._id ?? doc.document._id) as unknown as Ref) && + it.space === vacancyId + ) - const candidate = doc.mixins[recruit.mixin.Candidate] as Data - const ops = new TxOperations(client, document.modifiedBy) + const candidate = doc.mixins[recruit.mixin.Candidate] as Data + const ops = new TxOperations(client, document.modifiedBy) + let statusName = sourceStatusName + let mapping: BitrixStateMapping | undefined + for (const t of operation.stateMapping ?? []) { + if (t.sourceName === sourceStatusName) { + statusName = t.targetName + mapping = t + break + } + } - if (statusName != null && statusName !== '') { + const attrs = getAllAttributes(client, recruit.mixin.Candidate) + + // Update candidate operations + for (const u of mapping?.updateCandidate ?? []) { + const attribute = attrs.get(u.attr) + if (attribute === undefined) { + console.error('failed to fill attribute', u.attr) + continue + } + if (client.getHierarchy().isMixin(attribute.attributeOf)) { + doc.mixins[attribute.attributeOf] = { ...(doc.mixins[attribute.attributeOf] ?? {}), [u.attr]: u.value } + } else { + ;(doc.document as any)[u.attr] = u.value + } + } // Find status for vacancy - const states = await client.findAll(task.class.State, { space: vacancyId }) - const update: DocumentUpdate = {} for (const k of operation.copyTalentFields ?? []) { const val = (candidate as any)[k.candidate] - if ((existing as any)[k.applicant] !== val) { + if ((existing as any)?.[k.applicant] !== val) { ;(update as any)[k.applicant] = val } } - const state = states.find((it) => it.name.toLowerCase().trim() === statusName.toLowerCase().trim()) - if (state !== undefined) { - if (existing !== undefined) { - if (existing.state !== state?._id) { - update.state = state._id + if (vacancyId !== undefined) { + const states = await client.findAll(task.class.State, { space: vacancyId }) + const state = states.find((it) => it.name.toLowerCase().trim() === statusName.toLowerCase().trim()) + if (state !== undefined) { + if (mapping?.doneState !== '') { + const doneStates = await client.findAll(task.class.DoneState, { space: vacancyId }) + const doneState = doneStates.find( + (it) => it.name.toLowerCase().trim() === mapping?.doneState.toLowerCase().trim() + ) + if (doneState !== undefined) { + if (doneState !== undefined && existing?.doneState !== doneState._id) { + update.doneState = doneState._id + } + } } - if (Object.keys(update).length > 0) { - await ops.update(existing, update) + + if (existing !== undefined) { + if (existing.state !== state?._id) { + update.state = state._id + } + if (Object.keys(update).length > 0) { + await ops.update(existing, update) + } + } else { + await createApplication(ops, state, vacancyId, document, update as Data) } - } else { - await createApplication(ops, state, vacancyId, { ...document, ...update }) } } } @@ -604,3 +639,21 @@ export async function convert ( export function toClassRef (val: any): Ref> { return val as Ref> } + +/** + * @public + */ +export function getAllAttributes (client: Client, _class: Ref>): Map { + const h = client.getHierarchy() + const _classAttrs = h.getAllAttributes(recruit.mixin.Candidate) + + const ancestors = h.getAncestors(_class) + for (const a of ancestors) { + for (const m of h.getDescendants(a).filter((it) => h.isMixin(it))) { + for (const [k, v] of h.getOwnAttributes(m)) { + _classAttrs.set(k, v) + } + } + } + return _classAttrs +} diff --git a/plugins/hr-resources/src/components/schedule/MonthTableView.svelte b/plugins/hr-resources/src/components/schedule/MonthTableView.svelte index 3add58b0a0..8c9be531c9 100644 --- a/plugins/hr-resources/src/components/schedule/MonthTableView.svelte +++ b/plugins/hr-resources/src/components/schedule/MonthTableView.svelte @@ -18,7 +18,7 @@ import type { Request, RequestType, Staff } from '@hcengineering/hr' import { getEmbeddedLabel } from '@hcengineering/platform' import { createQuery, getClient } from '@hcengineering/presentation' - import { Button, Label, Loading, Scroller, tableSP } from '@hcengineering/ui' + import { Button, Label, Loading, Scroller, tableSP, tableToCSV } from '@hcengineering/ui' import view, { BuildModelKey, Viewlet, ViewletPreference } from '@hcengineering/view' import { getViewOptions, @@ -37,7 +37,6 @@ getRequests, getStartDate, getTotal, - tableToCSV, weekDays } from '../../utils' import StatPresenter from './StatPresenter.svelte' diff --git a/plugins/hr-resources/src/utils.ts b/plugins/hr-resources/src/utils.ts index 00f624ce5c..6564253c3c 100644 --- a/plugins/hr-resources/src/utils.ts +++ b/plugins/hr-resources/src/utils.ts @@ -1,9 +1,9 @@ import { Employee, getName } from '@hcengineering/contact' import { Ref, TxOperations } from '@hcengineering/core' -import { Department, fromTzDate, Request, RequestType, Staff } from '@hcengineering/hr' +import { Department, Request, RequestType, Staff, fromTzDate } from '@hcengineering/hr' import { MessageBox } from '@hcengineering/presentation' import { Issue, TimeSpendReport } from '@hcengineering/tracker' -import { areDatesEqual, isWeekend, MILLISECONDS_IN_DAY, showPopup } from '@hcengineering/ui' +import { MILLISECONDS_IN_DAY, areDatesEqual, isWeekend, showPopup } from '@hcengineering/ui' import hr from './plugin' const todayDate = new Date() @@ -209,23 +209,6 @@ export function isHoliday (holidays: Date[] | undefined, day: Date): boolean { return holidays.some((date) => areDatesEqual(day, date)) } -export function tableToCSV (tableId: string, separator = ','): string { - const rows = document.querySelectorAll('table#' + tableId + ' tr') - // Construct csv - const csv = [] - for (let i = 0; i < rows.length; i++) { - const row = [] - const cols = rows[i].querySelectorAll('td, th') - for (let j = 0; j < cols.length; j++) { - let data = (cols[j] as HTMLElement).innerText.replace(/(\r\n|\n|\r)/gm, '').replace(/(\s\s)/gm, ' ') - data = data.replace(/"/g, '""') - row.push('"' + data + '"') - } - csv.push(row.join(separator)) - } - return csv.join('\n') -} - export function getHolidayDatesForEmployee ( departmentMap: Map, Department[]>, employee: Ref, diff --git a/plugins/recruit-assets/lang/en.json b/plugins/recruit-assets/lang/en.json index d7ec4139f1..005b8bc370 100644 --- a/plugins/recruit-assets/lang/en.json +++ b/plugins/recruit-assets/lang/en.json @@ -109,7 +109,8 @@ "TemplateReplace": "You you replace selected template?", "TemplateReplaceConfirm": "All field changes will be override by template values", - "OpenVacancyList": "Open list" + "OpenVacancyList": "Open list", + "Export": "Export" }, "status": { "TalentRequired": "Please select talent", diff --git a/plugins/recruit-assets/lang/ru.json b/plugins/recruit-assets/lang/ru.json index 815a469074..791aa4d778 100644 --- a/plugins/recruit-assets/lang/ru.json +++ b/plugins/recruit-assets/lang/ru.json @@ -109,7 +109,8 @@ "Organizations": "Компании", "TemplateReplace": "Вы хотите заменить выбранный шаблон?", "TemplateReplaceConfirm": "Все внесенные изменения в будут заменены значениями из шаблоном", - "OpenVacancyList": "Открыть список" + "OpenVacancyList": "Открыть список", + "Export": "Экспорт" }, "status": { "TalentRequired": "Пожалуйста выберите таланта", diff --git a/plugins/recruit-resources/src/components/Vacancies.svelte b/plugins/recruit-resources/src/components/Vacancies.svelte index 443cee6001..b681d270a5 100644 --- a/plugins/recruit-resources/src/components/Vacancies.svelte +++ b/plugins/recruit-resources/src/components/Vacancies.svelte @@ -16,20 +16,29 @@ import core, { Doc, DocumentQuery, Ref } from '@hcengineering/core' import { createQuery, getClient } from '@hcengineering/presentation' import { Vacancy } from '@hcengineering/recruit' - import { Button, Icon, IconAdd, Label, Loading, SearchEdit, showPopup } from '@hcengineering/ui' + import { + Button, + Icon, + IconAdd, + Label, + Loading, + SearchEdit, + deviceOptionsStore as deviceInfo, + showPopup, + tableToCSV + } from '@hcengineering/ui' import view, { BuildModelKey, Viewlet, ViewletPreference } from '@hcengineering/view' import { FilterBar, FilterButton, + TableBrowser, + ViewletSettingButton, getViewOptions, setActiveViewletId, - TableBrowser, - viewOptionStore, - ViewletSettingButton + viewOptionStore } from '@hcengineering/view-resources' import recruit from '../plugin' import CreateVacancy from './CreateVacancy.svelte' - import { deviceOptionsStore as deviceInfo } from '@hcengineering/ui' export let archived = false @@ -176,6 +185,25 @@ on:click={showCreateDialog} /> +