TSK-1015: Bitrix Create Vacancy/Application (#2913)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2023-04-06 17:52:32 +07:00 committed by GitHub
parent ef836ac2f0
commit 921291ba24
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 381 additions and 30 deletions

View File

@ -53,7 +53,9 @@
"qs": "~6.11.0",
"@hcengineering/tags": "^0.6.3",
"@hcengineering/tags-resources": "^0.6.0",
"fast-equals": "^2.0.3"
"fast-equals": "^2.0.3",
"@hcengineering/recruit": "^0.6.8",
"@hcengineering/task": "^0.6.3"
},
"repository": "https://github.com/hcengineering/anticrm",
"publishConfig": {

View File

@ -70,6 +70,12 @@
action: (_: any, evt: MouseEvent) => {
addMapping(evt, MappingOperation.FindReference)
}
},
{
label: getEmbeddedLabel('Create Vacancy and application'),
action: (_: any, evt: MouseEvent) => {
addMapping(evt, MappingOperation.CreateHRApplication)
}
}
] as Action[]
</script>

View File

@ -5,6 +5,7 @@
import { Label } from '@hcengineering/ui'
import bitrix from '../plugin'
import CopyMapping from './mappings/CopyMapping.svelte'
import CreateAttachedDocMapping from './mappings/CreateHRApplicationMapping.svelte'
import CreateChannelMapping from './mappings/CreateChannelMapping.svelte'
import CreateTagMapping from './mappings/CreateTagMapping.svelte'
import DownloadAttachmentMapping from './mappings/DownloadAttachmentMapping.svelte'
@ -45,5 +46,7 @@
<DownloadAttachmentMapping {mapping} {fields} {attribute} {field} bind:this={op} />
{:else if _kind === MappingOperation.FindReference}
<FindReferenceMapping {mapping} {fields} {attribute} {field} bind:this={op} />
{:else if _kind === MappingOperation.CreateHRApplication}
<CreateAttachedDocMapping {mapping} {fields} {attribute} {field} bind:this={op} />
{/if}
</Card>

View File

@ -4,6 +4,7 @@
import { getClient } from '@hcengineering/presentation'
import { Button, Icon, IconArrowLeft, IconClose, Label } from '@hcengineering/ui'
import CopyMappingPresenter from './mappings/CopyMappingPresenter.svelte'
import CreateAttachedDocPresenter from './mappings/CreateHRApplicationPresenter.svelte'
import CreateChannelMappingPresenter from './mappings/CreateChannelMappingPresenter.svelte'
import CreateTagMappingPresenter from './mappings/CreateTagMappingPresenter.svelte'
import DownloadAttachmentPresenter from './mappings/DownloadAttachmentPresenter.svelte'
@ -40,6 +41,8 @@
<DownloadAttachmentPresenter {mapping} {value} />
{:else if kind === MappingOperation.FindReference}
<FindReferencePresenter {mapping} {value} />
{:else if kind === MappingOperation.CreateHRApplication}
<CreateAttachedDocPresenter {mapping} {value} />
{/if}
<Button

View File

@ -0,0 +1,95 @@
<script lang="ts">
import {
BitrixEntityMapping,
BitrixFieldMapping,
CreateHRApplication,
Fields,
MappingOperation
} from '@hcengineering/bitrix'
import { AnyAttribute } from '@hcengineering/core'
import { getEmbeddedLabel } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import task from '@hcengineering/task'
import { DropdownLabels, DropdownTextItem } 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 = {}
export let attribute: AnyAttribute
export let field: BitrixFieldMapping | undefined
let stateField = (field?.operation as CreateHRApplication)?.stateField
let vacancyField = (field?.operation as CreateHRApplication)?.vacancyField
let defaultTemplate = (field?.operation as CreateHRApplication)?.defaultTemplate
const client = getClient()
export async function save (): Promise<void> {
if (field !== undefined) {
await client.update(field, {
operation: {
kind: MappingOperation.CreateHRApplication,
stateField,
vacancyField,
defaultTemplate
}
})
} else {
await client.addCollection(bitrix.class.FieldMapping, mapping.space, mapping._id, mapping._class, 'fields', {
ofClass: attribute.attributeOf,
attributeName: attribute.name,
operation: {
kind: MappingOperation.CreateHRApplication,
stateField,
vacancyField,
defaultTemplate
}
})
}
}
function getItems (fields: Fields): DropdownTextItem[] {
return Object.entries(fields).map((it) => ({
id: it[0],
label: `${it[1].formLabel ?? it[1].title}${it[0].startsWith('UF_') ? ' *' : ''}`
}))
}
$: items = getItems(fields)
</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>
</div>
</div>
<style lang="scss">
.pattern {
margin: 0.5rem;
padding: 0.5rem;
flex-shrink: 0;
border: 1px dashed var(--accent-color);
border-radius: 0.25rem;
font-weight: 500;
font-size: 0.75rem;
// text-transform: uppercase;
color: var(--accent-color);
&:hover {
color: var(--caption-color);
}
}
</style>

View File

@ -0,0 +1,37 @@
<script lang="ts">
import { BitrixEntityMapping, BitrixFieldMapping, CreateHRApplication } from '@hcengineering/bitrix'
import task from '@hcengineering/task'
import { ObjectPresenter } from '@hcengineering/view-resources'
export let mapping: BitrixEntityMapping
export let value: BitrixFieldMapping
$: op = value.operation as CreateHRApplication
</script>
<div class="flex flex-wrap">
<div class="pattern flex-row-center gap-2">
{mapping.bitrixFields[op.vacancyField]?.filterLabel} -> {mapping.bitrixFields[op.stateField]?.filterLabel}
<span class="p-1">-></span>
<ObjectPresenter objectId={op.defaultTemplate} _class={task.class.KanbanTemplate} />
</div>
</div>
<style lang="scss">
.pattern {
margin: 0.1rem;
padding: 0.3rem;
flex-shrink: 0;
border: 1px dashed var(--accent-color);
border-radius: 0.25rem;
font-weight: 500;
font-size: 0.75rem;
// text-transform: uppercase;
color: var(--accent-color);
&:hover {
color: var(--caption-color);
}
}
</style>

View File

@ -36,7 +36,9 @@
"@hcengineering/attachment": "^0.6.1",
"fast-equals": "^2.0.3",
"qs": "~6.11.0",
"@hcengineering/gmail": "^0.6.4"
"@hcengineering/gmail": "^0.6.4",
"@hcengineering/recruit": "^0.6.8",
"@hcengineering/task": "^0.6.3"
},
"repository": "https://github.com/hcengineering/anticrm",
"publishConfig": {

72
plugins/bitrix/src/hr.ts Normal file
View File

@ -0,0 +1,72 @@
import { Organization } from '@hcengineering/contact'
import core, { Account, Client, Doc, Ref, SortingOrder, TxOperations } from '@hcengineering/core'
import recruit, { Vacancy } from '@hcengineering/recruit'
import task, { KanbanTemplate, State, calcRank, createKanban } from '@hcengineering/task'
export async function createVacancy (
rawClient: Client,
name: string,
templateId: Ref<KanbanTemplate>,
account: Ref<Account>,
company?: Ref<Organization>
): Promise<Ref<Vacancy>> {
const client = new TxOperations(rawClient, account)
const template = await client.findOne(task.class.KanbanTemplate, { _id: templateId })
if (template === undefined) {
throw Error(`Failed to find target kanban template: ${templateId}`)
}
const sequence = await client.findOne(task.class.Sequence, { attachedTo: recruit.class.Vacancy })
if (sequence === undefined) {
throw new Error('sequence object not found')
}
const incResult = await client.update(sequence, { $inc: { sequence: 1 } }, true)
const id = await client.createDoc(recruit.class.Vacancy, core.space.Space, {
name,
description: template.shortDescription ?? '',
fullDescription: template.description,
private: false,
archived: false,
company,
number: (incResult as any).object.sequence,
members: []
})
await createKanban(client, id, templateId)
return id
}
export async function createApplication (
client: TxOperations,
selectedState: State,
_space: Ref<Vacancy>,
doc: Doc
): Promise<void> {
if (selectedState === undefined) {
throw new Error(`Please select initial state:${_space}`)
}
const state = await client.findOne(task.class.State, { space: _space, _id: selectedState?._id })
if (state === undefined) {
throw new Error(`create application: state not found space:${_space}`)
}
const sequence = await client.findOne(task.class.Sequence, { attachedTo: recruit.class.Applicant })
if (sequence === undefined) {
throw new Error('sequence object not found')
}
const lastOne = await client.findOne(recruit.class.Applicant, {}, { sort: { rank: SortingOrder.Descending } })
const incResult = await client.update(sequence, { $inc: { sequence: 1 } }, true)
await client.addCollection(recruit.class.Applicant, _space, doc._id, recruit.mixin.Candidate, 'applications', {
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

@ -123,20 +123,19 @@ export async function syncDocument (
// Just create supplier documents, like TagElements.
for (const ed of resultDoc.extraDocs) {
await applyOp.createDoc(
ed._class,
ed.space,
ed,
ed._id,
resultDoc.document.modifiedOn,
resultDoc.document.modifiedBy
)
const { _class, space, _id, ...data } = ed
await applyOp.createDoc(_class, space, data, _id, resultDoc.document.modifiedOn, resultDoc.document.modifiedBy)
}
for (const op of resultDoc.postOperations) {
await op(resultDoc.document, existing)
}
const idMapping = new Map<Ref<Doc>, Ref<Doc>>()
// Find all attachment documents to existing.
const byClass = new Map<Ref<Class<Doc>>, (AttachedDoc & BitrixSyncDoc)[]>()
const idMapping = new Map<Ref<Doc>, Ref<Doc>>()
for (const d of resultDoc.extraSync) {
byClass.set(d._class, [...(byClass.get(d._class) ?? []), d])
}

View File

@ -1,6 +1,7 @@
import { ChannelProvider } from '@hcengineering/contact'
import { AttachedDoc, Class, Doc, Mixin, Ref } from '@hcengineering/core'
import { ExpertKnowledge, InitialKnowledge, MeaningfullKnowledge } from '@hcengineering/tags'
import { KanbanTemplate } from '@hcengineering/task'
/**
* @public
@ -174,7 +175,8 @@ export enum MappingOperation {
CreateTag, // Create tag
CreateChannel, // Create channel
DownloadAttachment,
FindReference
FindReference,
CreateHRApplication
}
/**
* @public
@ -252,6 +254,35 @@ export interface FindReferenceOperation {
referenceClass: Ref<Class<Doc>>
}
/**
* @public
*/
export interface CreateAttachedField {
match: boolean // We should match type and pass if exists.
// Original document field to use value from.
sourceField: string
valueField: string // final value should go into valueField, field name to match, like `space`
// If reference is defined, we should find for some existing document by matching field with sourceField value.
// Document we should match value against.
referenceClass: Ref<Class<Doc>>
// Field to check for matched value against.
referenceField?: string
}
/**
* @public
*/
export interface CreateHRApplication {
kind: MappingOperation.CreateHRApplication
vacancyField: string // Name of vacancy in bitrix.
stateField: string // Name of status in bitrix.
defaultTemplate: Ref<KanbanTemplate>
}
/**
* @public
*/
@ -265,6 +296,7 @@ export interface BitrixFieldMapping extends AttachedDoc {
| CreateChannelOperation
| DownloadAttachmentOperation
| FindReferenceOperation
| CreateHRApplication
}
/**

View File

@ -1,5 +1,5 @@
import attachment, { Attachment } from '@hcengineering/attachment'
import contact, { Channel, EmployeeAccount } from '@hcengineering/contact'
import contact, { Channel, EmployeeAccount, Organization } from '@hcengineering/contact'
import core, {
AnyAttribute,
AttachedDoc,
@ -7,14 +7,18 @@ import core, {
Client,
Data,
Doc,
generateId,
Mixin,
Ref,
RefTo,
Space,
WithLookup
TxOperations,
WithLookup,
generateId
} from '@hcengineering/core'
import { Message } from '@hcengineering/gmail'
import recruit, { Candidate, Vacancy } from '@hcengineering/recruit'
import tags, { TagCategory, TagElement, TagReference } from '@hcengineering/tags'
import task from '@hcengineering/task'
import bitrix, {
BitrixEntityMapping,
BitrixEntityType,
@ -22,11 +26,13 @@ import bitrix, {
BitrixSyncDoc,
CopyValueOperation,
CreateChannelOperation,
CreateHRApplication,
CreateTagOperation,
DownloadAttachmentOperation,
FindReferenceOperation,
MappingOperation
} from '.'
import { createApplication, createVacancy } from './hr'
/**
* @public
@ -60,6 +66,11 @@ export interface BitrixSyncRequest {
update: (doc: Ref<Doc>) => void
}
/**
* @public
*/
export type PostOperation = (doc: BitrixSyncDoc, existing?: Doc) => Promise<void>
/**
* @public
*/
@ -71,6 +82,7 @@ export interface ConvertResult {
gmailDocuments: (Message & BitrixSyncDoc)[]
blobs: [Attachment & BitrixSyncDoc, () => Promise<File | undefined>, (file: File, attach: Attachment) => void][]
syncRequests: BitrixSyncRequest[]
postOperations: PostOperation[]
}
/**
@ -113,6 +125,8 @@ export async function convert (
][] = []
const mixins: Record<Ref<Mixin<Doc>>, Data<Doc>> = {}
const postOperations: PostOperation[] = []
// Fill required mixins.
for (const m of entity.mixins ?? []) {
mixins[m] = {}
@ -383,6 +397,65 @@ export async function convert (
}
}
const getCreateAttachedValue = async (attr: AnyAttribute, operation: CreateHRApplication): Promise<void> => {
const vacancyName = extractValue(operation.vacancyField)
const statusName = extractValue(operation.stateField)
postOperations.push(async (doc, existingDoc) => {
const vacancies = await client.findAll(recruit.class.Vacancy, {})
let vacancyId: Ref<Vacancy> | undefined
if (vacancyName !== undefined) {
const tName = vacancyName.trim().toLowerCase()
const vacancy = vacancies.find((it) => it.name.toLowerCase().trim() === tName)
let refOrgField: Ref<Organization> | undefined
const allAttrs = hierarchy.getAllAttributes(recruit.mixin.Candidate)
for (const a of allAttrs.values()) {
if (a.type._class === core.class.RefTo && (a.type as RefTo<Doc>).to === contact.class.Organization) {
refOrgField = (mixins as any)[recruit.mixin.Candidate][a.name] as Ref<Organization>
}
}
if (vacancy !== undefined) {
vacancyId = vacancy?._id
} else {
vacancyId = await createVacancy(
client,
vacancyName.trim(),
operation.defaultTemplate,
document.modifiedBy,
refOrgField
)
}
} else {
return
}
// Check if candidate already have vacancy
const existing = await client.findOne(recruit.class.Applicant, {
attachedTo: (existingDoc?._id ?? doc._id) as unknown as Ref<Candidate>,
space: vacancyId
})
if (statusName != null && statusName !== '') {
// Find status for vacancy
const states = await client.findAll(task.class.State, { space: vacancyId })
const state = states.find((it) => it.name.toLowerCase().trim() === statusName.toLowerCase().trim())
const ops = new TxOperations(client, document.modifiedBy)
if (state !== undefined) {
if (existing !== undefined && existing.state !== state?._id) {
await ops.update(existing, { state: state._id })
} else {
await createApplication(ops, state, vacancyId, document)
}
}
}
})
}
const setValue = (value: any, attr: AnyAttribute): void => {
if (value !== undefined) {
if (hierarchy.isMixin(attr.attributeOf)) {
@ -479,6 +552,10 @@ export async function convert (
}
break
}
case MappingOperation.CreateHRApplication: {
await getCreateAttachedValue(attr, f.operation)
break
}
}
setValue(value, attr)
}
@ -490,7 +567,8 @@ export async function convert (
extraDocs: newExtraDocs,
blobs,
syncRequests,
gmailDocuments: []
gmailDocuments: [],
postOperations
}
}

View File

@ -107,7 +107,9 @@
"Organizations": "Companies",
"TemplateReplace": "You you replace selected template?",
"TemplateReplaceConfirm": "All field changes will be override by template values"
"TemplateReplaceConfirm": "All field changes will be override by template values",
"OpenVacancyList": "Open list"
},
"status": {
"TalentRequired": "Please select talent",

View File

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

View File

@ -13,16 +13,17 @@
// limitations under the License.
-->
<script lang="ts">
import core, { FindOptions, Ref, SortingOrder, Space } from '@hcengineering/core'
import core, { FindOptions, SortingOrder } from '@hcengineering/core'
import { createQuery } from '@hcengineering/presentation'
import { Applicant } from '@hcengineering/recruit'
import { Applicant, Vacancy } from '@hcengineering/recruit'
import task from '@hcengineering/task'
import { Loading } from '@hcengineering/ui'
import { Button, Label, Loading } from '@hcengineering/ui'
import view, { Viewlet, ViewletPreference } from '@hcengineering/view'
import { Table } from '@hcengineering/view-resources'
import { DocNavLink, ObjectPresenter, Table } from '@hcengineering/view-resources'
import recruit from '../plugin'
export let value: Ref<Space>
export let value: Vacancy
export let openList: () => void
const options: FindOptions<Applicant> = {
lookup: {
@ -62,13 +63,27 @@
)
</script>
<div class="flex flex-between flex-grow p-1 mb-4">
<div class="fs-title flex-row-center">
<Label label={recruit.string.Applications} />
<div class="ml-2">
<Button label={recruit.string.OpenVacancyList} on:click={openList} size={'small'} kind={'link-bordered'} />
</div>
</div>
<div class="flex-row-center">
<DocNavLink object={value}>
<ObjectPresenter _class={value._class} objectId={value._id} {value} />
</DocNavLink>
</div>
</div>
<div class="popup-table">
{#if viewlet && !loading}
<Table
_class={recruit.class.Applicant}
config={preference?.config ?? viewlet.config}
query={{ space: value._id }}
{options}
query={{ space: value }}
loadingProps={{ length: 0 }}
/>
{:else}

View File

@ -14,8 +14,8 @@
-->
<script lang="ts">
import { Ref } from '@hcengineering/core'
import { Vacancy, recruitId } from '@hcengineering/recruit'
import { Icon, getCurrentLocation, navigate, tooltip } from '@hcengineering/ui'
import { recruitId, Vacancy } from '@hcengineering/recruit'
import { closeTooltip, getCurrentLocation, Icon, navigate, tooltip } from '@hcengineering/ui'
import recruit from '../plugin'
import VacancyApplicationsPopup from './VacancyApplicationsPopup.svelte'
@ -23,24 +23,27 @@
export let applications: Map<Ref<Vacancy>, { count: number; modifiedOn: number }> | undefined
function click () {
closeTooltip()
const loc = getCurrentLocation()
loc.fragment = undefined
loc.query = undefined
loc.path[2] = recruitId
loc.path[3] = value._id
loc.path.length = 4
navigate(loc)
}
</script>
{#if value}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="sm-tool-icon"
use:tooltip={{
label: recruit.string.Applications,
// label: recruit.string.Applications,
component: VacancyApplicationsPopup,
props: { value: value._id }
props: { value, openList: click }
}}
on:click={click}
on:click|stopPropagation|preventDefault={click}
>
<div class="icon">
<Icon icon={recruit.icon.Application} size={'small'} />

View File

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