TSK-1154: Statuses table support (#2974)

+ allow to export list of Vacancies

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2023-04-13 22:25:34 +07:00 committed by GitHub
parent e4a824247a
commit 12974b5bb7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 330 additions and 106 deletions

View File

@ -79,3 +79,23 @@ export function handler<T, EVT = MouseEvent> (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')
}

View File

@ -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 }))]
</script>
<div class="flex-col flex-wrap">
<div class="flex-row-center gap-2">
<div class="flex-col w-120">
<DropdownLabels minW0={false} label={getEmbeddedLabel('Vacancy field')} {items} bind:selected={vacancyField} />
<DropdownLabels minW0={false} label={getEmbeddedLabel('State field')} {items} bind:selected={stateField} />
<ObjectBox
label={getEmbeddedLabel('Template')}
searchField={'title'}
_class={task.class.KanbanTemplate}
docQuery={{ space: recruit.space.VacancyTemplates }}
bind:value={defaultTemplate}
/>
<div class="flex-row-center p-1">
<span class="w-22"> Vacancy: </span>
<DropdownLabels
width={'10rem'}
label={getEmbeddedLabel('Vacancy field')}
{items}
bind:selected={vacancyField}
/>
</div>
<div class="flex-row-center p-1">
<span class="w-22"> State: </span>
<DropdownLabels width={'10rem'} label={getEmbeddedLabel('State field')} {items} bind:selected={stateField} />
</div>
<div class="flex-row-center p-1">
<span class="w-22"> Template: </span>
<ObjectBox
width={'10rem'}
label={getEmbeddedLabel('Template')}
searchField={'title'}
_class={task.class.KanbanTemplate}
docQuery={{ space: recruit.space.VacancyTemplates }}
bind:value={defaultTemplate}
/>
</div>
{#each copyTalentFields as f, i}
<div class="flex-row-center pattern">
<DropdownLabelsIntl
minW0={false}
label={getEmbeddedLabel('Copy field')}
items={attrs}
bind:selected={f.candidate}
/> =>
<DropdownLabelsIntl
minW0={false}
label={getEmbeddedLabel('Copy field')}
items={applicantAttrs}
bind:selected={f.applicant}
/>
</div>
{/each}
<Button
icon={IconAdd}
size={'small'}
on:click={() => {
copyTalentFields = [...copyTalentFields, { candidate: allAttrs[0]._id, applicant: applicantAllAttrs[0]._id }]
}}
/>
<div class="mt-2 mb-1 flex-row-center p-1">
<span class="mr-2"> Copy following fields: </span>
<Button
icon={IconAdd}
size={'small'}
on:click={() => {
copyTalentFields = [
...copyTalentFields,
{ candidate: allAttrs[0]._id, applicant: applicantAllAttrs[0]._id }
]
}}
/>
</div>
<div class="flex-col flex-wrap">
{#each copyTalentFields as f, i}
<div class="flex-row-center pattern">
<DropdownLabelsIntl
width={'10rem'}
label={getEmbeddedLabel('Copy field')}
items={attrs}
bind:selected={f.candidate}
/> =>
<DropdownLabelsIntl
width={'10rem'}
label={getEmbeddedLabel('Copy field')}
items={applicantAttrs}
bind:selected={f.applicant}
/>
</div>
{/each}
</div>
<div class="mt-2 mb-1 flex-row-center p-1">
<span class="mr-2"> State mapping: </span>
<Button
icon={IconAdd}
size={'small'}
on:click={() => {
stateMapping = [...stateMapping, { sourceName: '', targetName: '', updateCandidate: [], doneState: '' }]
}}
/>
</div>
<div class="flex-co">
{#each stateMapping as m}
<div class="flex-row-center pattern flex-between flex-wrap">
<DropdownLabels
width={'10rem'}
label={getEmbeddedLabel('Source state')}
items={sourceStates}
kind={m.sourceName !== '' ? 'primary' : 'secondary'}
bind:selected={m.sourceName}
/> =>
<DropdownLabels
width={'10rem'}
kind={m.targetName !== '' ? 'primary' : 'secondary'}
label={getEmbeddedLabel('Final state')}
items={stateTitles}
bind:selected={m.targetName}
/>
<span class="ml-4"> Done state: </span>
<DropdownLabels
width={'10rem'}
kind={m.doneState !== '' ? 'primary' : 'secondary'}
label={getEmbeddedLabel('Done state')}
items={doneStateTitles}
bind:selected={m.doneState}
/>
{#each m.updateCandidate as c}
{@const attribute = allAttrs.find((it) => it.name === c.attr)}
<DropdownLabelsIntl
width={'10rem'}
label={getEmbeddedLabel('Field to fill')}
items={attrs}
bind:selected={c.attr}
/>
{#if attribute}
=>
<InlineAttributeBarEditor
_class={recruit.mixin.Candidate}
key={{ key: 'value', attr: attribute }}
draft
object={c}
/>
{/if}
{/each}
<Button
icon={IconAdd}
size={'small'}
on:click={() => {
m.updateCandidate = [...m.updateCandidate, { attr: allAttrs[0]._id, value: undefined }]
}}
/>
</div>
{/each}
</div>
</div>
</div>
</div>
@ -132,4 +244,7 @@
color: var(--caption-color);
}
}
.scroll {
overflow: auto;
}
</style>

View File

@ -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<Vacancy>,
doc: Doc
doc: Doc,
data: Data<Applicant>
): Promise<void> {
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()
})
}

View File

@ -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<Doc>, date: Timestamp): Promise<Doc> {
// 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<BitrixSyncDoc>
}
// 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<Doc>, Ref<Doc>>()
// Find all attachment documents to existing.
@ -322,8 +330,6 @@ export async function syncDocument (
async function updateMainDoc (applyOp: ApplyOperations): Promise<BitrixSyncDoc> {
if (existing !== undefined) {
// We need update doucment id.
resultDoc.document._id = existing._id as Ref<BitrixSyncDoc>
// 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.

View File

@ -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<KanbanTemplate>
copyTalentFields?: { candidate: Ref<AnyAttribute>, applicant: Ref<AnyAttribute> }[]
// We would like to map some of bitrix states to our states, name matching is used to hold values.
stateMapping?: BitrixStateMapping[]
}
/**

View File

@ -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<void> => {
const vacancyName = extractValue(operation.vacancyField)
const statusName = extractValue(operation.stateField)
const sourceStatusName = extractValue(operation.stateField)
postOperations.push(async (doc, extraDocs, existingDoc) => {
let vacancyId: Ref<Vacancy> | 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<Candidate>) &&
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<Candidate>) &&
it.space === vacancyId
)
const candidate = doc.mixins[recruit.mixin.Candidate] as Data<Candidate>
const ops = new TxOperations(client, document.modifiedBy)
const candidate = doc.mixins[recruit.mixin.Candidate] as Data<Candidate>
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<Applicant> = {}
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<Applicant>)
}
} else {
await createApplication(ops, state, vacancyId, { ...document, ...update })
}
}
}
@ -604,3 +639,21 @@ export async function convert (
export function toClassRef (val: any): Ref<Class<Doc>> {
return val as Ref<Class<Doc>>
}
/**
* @public
*/
export function getAllAttributes (client: Client, _class: Ref<Class<Doc>>): Map<string, AnyAttribute> {
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
}

View File

@ -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'

View File

@ -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<Ref<Staff>, Department[]>,
employee: Ref<Staff>,

View File

@ -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",

View File

@ -109,7 +109,8 @@
"Organizations": "Компании",
"TemplateReplace": "Вы хотите заменить выбранный шаблон?",
"TemplateReplaceConfirm": "Все внесенные изменения в будут заменены значениями из шаблоном",
"OpenVacancyList": "Открыть список"
"OpenVacancyList": "Открыть список",
"Export": "Экспорт"
},
"status": {
"TalentRequired": "Пожалуйста выберите таланта",

View File

@ -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}
/>
<ViewletSettingButton bind:viewOptions viewlet={descr} />
<Button
label={recruit.string.Export}
size={'small'}
on:click={() => {
// Download it
const filename = 'vacancies' + 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,%EF%BB%BF' + encodeURIComponent(tableToCSV('vacanciesData'))
)
link.setAttribute('download', filename)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}}
/>
</div>
</div>
@ -194,6 +222,7 @@
_class={recruit.class.Vacancy}
config={createConfig(descr, preference, applications)}
options={descr.options}
tableId={'vacanciesData'}
query={{
...resultQuery,
archived

View File

@ -127,7 +127,8 @@ export default mergeIds(recruitId, recruit, {
TemplateReplace: '' as IntlString,
TemplateReplaceConfirm: '' as IntlString,
OpenVacancyList: '' as IntlString
OpenVacancyList: '' as IntlString,
Export: '' as IntlString
},
space: {
CandidatesPublic: '' as Ref<Space>

View File

@ -32,6 +32,7 @@
export let config: (BuildModelKey | string)[]
export let showFilterBar = true
export let enableChecking = true
export let tableId: string | undefined = undefined
// If defined, will show a number of dummy items before real data will appear.
export let loadingProps: LoadingProps | undefined = undefined
@ -97,6 +98,7 @@
showFooter
checked={$selectionStore ?? []}
{prefferedSorting}
{tableId}
selection={listProvider.current($focusStore)}
on:row-focus={(evt) => {
listProvider.updateFocus(evt.detail)