@@ -815,21 +422,3 @@
{/if}
-
-
diff --git a/plugins/recruit-resources/src/components/candidate/CandidateChannels.svelte b/plugins/recruit-resources/src/components/candidate/CandidateChannels.svelte
new file mode 100644
index 00000000000..df539f846e8
--- /dev/null
+++ b/plugins/recruit-resources/src/components/candidate/CandidateChannels.svelte
@@ -0,0 +1,31 @@
+
+
+
+
it.provider)}
+ kind={'regular'}
+ size={'large'}
+/>
\ No newline at end of file
diff --git a/plugins/recruit-resources/src/components/candidate/CandidatePersonalInfo.svelte b/plugins/recruit-resources/src/components/candidate/CandidatePersonalInfo.svelte
new file mode 100644
index 00000000000..d84cd522b16
--- /dev/null
+++ b/plugins/recruit-resources/src/components/candidate/CandidatePersonalInfo.svelte
@@ -0,0 +1,90 @@
+
+
+
+
\ No newline at end of file
diff --git a/plugins/recruit-resources/src/components/candidate/CandidateResume.svelte b/plugins/recruit-resources/src/components/candidate/CandidateResume.svelte
new file mode 100644
index 00000000000..a3bc2e060c4
--- /dev/null
+++ b/plugins/recruit-resources/src/components/candidate/CandidateResume.svelte
@@ -0,0 +1,112 @@
+
+
+
+ {
+ dragover = true
+ }}
+ on:dragleave={() => {
+ dragover = false
+ }}
+ on:drop|preventDefault|stopPropagation={drop}
+>
+ {#if loading && object.resumeUuid}
+
+ {:else}
+ {#if loading}
+
+ {:else if object.resumeUuid}
+
{
+ showPopup(
+ FilePreviewPopup,
+ {
+ file: object.resumeUuid,
+ contentType: object.resumeType,
+ name: object.resumeName
+ },
+ object.resumeType?.startsWith('image/') ? 'centered' : 'float'
+ )
+ }}
+ >
+
+ {object.resumeName}
+
+
+ {:else}
+
{
+ inputFile.click()
+ }}
+ />
+ {/if}
+
+ {/if}
+
+
+
+
+
+
\ No newline at end of file
diff --git a/plugins/recruit-resources/src/components/candidate/CandidateSkills.svelte b/plugins/recruit-resources/src/components/candidate/CandidateSkills.svelte
new file mode 100644
index 00000000000..142b05da2db
--- /dev/null
+++ b/plugins/recruit-resources/src/components/candidate/CandidateSkills.svelte
@@ -0,0 +1,99 @@
+
+
+
+ {
+ addTagRef(detail)
+ }}
+ on:delete={({ detail }) => {
+ object.skills = object.skills.filter((it) => it.tag !== detail._id)
+ }}
+/>
+{#if object.skills.length > 0}
+
+ {
+ addTagRef(detail)
+ }}
+ on:delete={({ detail }) => {
+ object.skills = object.skills.filter((it) => it._id !== detail)
+ }}
+ on:change={({ detail }) => {
+ detail.tag.weight = detail.tag.weight
+ object.skills = object.skills
+ }}
+ />
+
+{:else}
+
+{/if}
\ No newline at end of file
diff --git a/plugins/recruit-resources/src/components/candidate/CandidateWorkPreferences.svelte b/plugins/recruit-resources/src/components/candidate/CandidateWorkPreferences.svelte
new file mode 100644
index 00000000000..1f6750c2938
--- /dev/null
+++ b/plugins/recruit-resources/src/components/candidate/CandidateWorkPreferences.svelte
@@ -0,0 +1,40 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/plugins/recruit-resources/src/services/candidateDraftService.ts b/plugins/recruit-resources/src/services/candidateDraftService.ts
new file mode 100644
index 00000000000..34d7241aa1b
--- /dev/null
+++ b/plugins/recruit-resources/src/services/candidateDraftService.ts
@@ -0,0 +1,60 @@
+// Copyright © 2020 Anticrm Platform Contributors.
+//
+// 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.
+
+import { generateId } from '@hcengineering/core'
+import { Candidate, CandidateDraft } from '@hcengineering/recruit'
+import { DraftController, MultipleDraftController } from '@hcengineering/presentation'
+import recruit from '../plugin'
+
+export class CandidateDraftService {
+ private mDraftController: MultipleDraftController
+ private draftController: DraftController
+ private id: Ref
+
+ constructor(shouldSaveDraft: boolean = true) {
+ this.mDraftController = new MultipleDraftController(recruit.mixin.Candidate)
+ this.id = generateId()
+ this.draftController = new DraftController(
+ shouldSaveDraft ? this.mDraftController.getNext() ?? this.id : undefined,
+ recruit.mixin.Candidate
+ )
+ }
+
+ getEmptyCandidate(id: Ref | undefined = undefined): CandidateDraft {
+ return {
+ _id: id ?? generateId(),
+ firstName: '',
+ lastName: '',
+ title: '',
+ channels: [],
+ skills: [],
+ city: ''
+ }
+ }
+
+ getDraft(): CandidateDraft | undefined {
+ return this.draftController.get()
+ }
+
+ saveDraft(draft: CandidateDraft, empty: any): void {
+ this.draftController.save(draft, empty)
+ }
+
+ removeDraft(): void {
+ this.draftController.remove()
+ }
+
+ subscribe(callback: (draft: CandidateDraft | undefined) => void): () => void {
+ return this.draftController.subscribe(callback)
+ }
+}
\ No newline at end of file
diff --git a/plugins/recruit-resources/src/services/candidateRecognitionService.ts b/plugins/recruit-resources/src/services/candidateRecognitionService.ts
new file mode 100644
index 00000000000..a316fca66fd
--- /dev/null
+++ b/plugins/recruit-resources/src/services/candidateRecognitionService.ts
@@ -0,0 +1,221 @@
+// Copyright © 2020 Anticrm Platform Contributors.
+//
+// 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.
+
+import { getMetadata } from '@hcengineering/platform'
+import { recognizeDocument } from '@hcengineering/rekoni'
+import { TagElement, TagReference } from '@hcengineering/tags'
+import { CandidateDraft } from '@hcengineering/recruit'
+import { getClient } from '@hcengineering/presentation'
+import { getColorNumberByText } from '@hcengineering/ui'
+import recruit from '../plugin'
+
+export class CandidateRecognitionService {
+ private client = getClient()
+ private elements = new Map()
+ private namedElements = new Map()
+ private newElements: TagElement[] = []
+
+ constructor(private shouldCreateNewSkills: boolean = false) {}
+
+ async recognize(file: File, object: CandidateDraft): Promise {
+ const token = getMetadata(presentation.metadata.Token) ?? ''
+
+ try {
+ const doc = await recognizeDocument(token, file)
+
+ if (this.isUndef(object.title) && doc.title !== undefined) {
+ object.title = doc.title
+ }
+
+ if (this.isUndef(object.firstName) && doc.firstName !== undefined) {
+ object.firstName = doc.firstName
+ }
+
+ if (this.isUndef(object.lastName) && doc.lastName !== undefined) {
+ object.lastName = doc.lastName
+ }
+
+ if (this.isUndef(object.city) && doc.city !== undefined) {
+ object.city = doc.city
+ }
+
+ if (!object.avatar && doc.avatar !== undefined) {
+ const data = atob(doc.avatar)
+ let n = data.length
+ const u8arr = new Uint8Array(n)
+ while (n--) {
+ u8arr[n] = data.charCodeAt(n)
+ }
+ object.avatar = new File([u8arr], doc.avatarName ?? 'avatar.png', { type: doc.avatarFormat ?? 'image/png' })
+ }
+
+ const newChannels = [...object.channels]
+ this.addChannel(newChannels, contact.channelProvider.Email, doc.email)
+ this.addChannel(newChannels, contact.channelProvider.GitHub, doc.github)
+ this.addChannel(newChannels, contact.channelProvider.LinkedIn, doc.linkedin)
+ this.addChannel(newChannels, contact.channelProvider.Phone, doc.phone)
+ this.addChannel(newChannels, contact.channelProvider.Telegram, doc.telegram)
+ this.addChannel(newChannels, contact.channelProvider.Twitter, doc.twitter)
+ this.addChannel(newChannels, contact.channelProvider.Facebook, doc.facebook)
+ object.channels = newChannels
+
+ await this.processSkills(doc.skills, object)
+ } catch (err: any) {
+ Analytics.handleError(err)
+ console.error(err)
+ }
+ }
+
+ private isUndef(value?: string): boolean {
+ return value === undefined || value === ''
+ }
+
+ private addChannel(channels: AttachedData[], type: Ref, value?: string): void {
+ if (value !== undefined) {
+ const provider = channels.find((e) => e.provider === type)
+ if (provider === undefined) {
+ channels.push({
+ provider: type,
+ value
+ })
+ } else {
+ if (this.isUndef(provider.value)) {
+ provider.value = value
+ }
+ }
+ }
+ }
+
+ private async processSkills(skills: string[], object: CandidateDraft): Promise {
+ await this.loadElements()
+
+ const categories = await this.client.findAll(tags.class.TagCategory, { targetClass: recruit.mixin.Candidate })
+ const categoriesMap = toIdMap(categories)
+
+ const newSkills: TagReference[] = []
+ const formattedSkills = (skills.map((s) => s.toLowerCase()) ?? []).filter(
+ (skill) => !this.namedElements.has(skill)
+ )
+ const refactoredSkills: any[] = []
+
+ if (formattedSkills.length > 0) {
+ const existingTags = Array.from(this.namedElements.keys()).filter((x) => x.length > 2)
+ const regex = /\S+(?:[-+]\S+)+/g
+ const regexForEmpty = /^((?![a-zA-Zа-яА-Я]).)*$/g
+
+ for (let sk of formattedSkills) {
+ sk = sk.toLowerCase()
+ const toReplace = [...new Set([...existingTags, ...refactoredSkills])]
+ .filter((s) => sk.includes(s))
+ .sort((a, b) => b.length - a.length)
+
+ if (toReplace.length > 0) {
+ for (const replacing of toReplace) {
+ if (this.namedElements.has(replacing)) {
+ refactoredSkills.push(replacing)
+ sk = sk.replace(replacing, '').trim()
+ }
+ }
+ }
+
+ if (sk.includes(' ')) {
+ const skSplit = sk.split(' ')
+ for (const spl of skSplit) {
+ const fixedTitle = regex.test(spl) ? spl.replaceAll(/[+-]/g, '') : spl
+ if (this.namedElements.has(fixedTitle)) {
+ refactoredSkills.push(fixedTitle)
+ sk = sk.replace(spl, '').trim()
+ }
+ if ([...skills, ...refactoredSkills].includes(fixedTitle)) {
+ sk = sk.replace(spl, '').trim()
+ }
+ }
+ }
+
+ if (regex.test(sk)) {
+ const fixedTitle = sk.replaceAll(/[+-]/g, '')
+ if (this.namedElements.has(fixedTitle)) {
+ refactoredSkills.push(fixedTitle)
+ sk = ''
+ }
+ }
+
+ if (!regexForEmpty.test(sk) && !refactoredSkills.includes(sk)) {
+ refactoredSkills.push(sk)
+ }
+ }
+ }
+
+ const skillsToAdd = [...new Set([...skills.map((s) => s.toLowerCase()), ...refactoredSkills])]
+
+ for (const s of skillsToAdd) {
+ const title = s.trim().toLowerCase()
+ let e = this.namedElements.get(title)
+ if (e === undefined && this.shouldCreateNewSkills) {
+ const category = findTagCategory(s, categories)
+ const cinstance = categoriesMap.get(category)
+ e = TxProcessor.createDoc2Doc(
+ this.client.txFactory.createTxCreateDoc(tags.class.TagElement, core.space.Workspace, {
+ title,
+ description: `Imported skill ${s} of ${cinstance?.label ?? ''}`,
+ color: getColorNumberByText(s),
+ targetClass: recruit.mixin.Candidate,
+ category
+ })
+ )
+ this.namedElements.set(title, e)
+ this.elements.set(e._id, e)
+ this.newElements.push(e)
+ }
+ if (e !== undefined) {
+ newSkills.push(
+ TxProcessor.createDoc2Doc(
+ this.client.txFactory.createTxCreateDoc(tags.class.TagReference, core.space.Workspace, {
+ title: e.title,
+ color: e.color,
+ tag: e._id,
+ attachedTo: '' as Ref,
+ attachedToClass: recruit.mixin.Candidate,
+ collection: 'skills'
+ })
+ )
+ )
+ }
+ }
+
+ object.skills = [...object.skills, ...newSkills]
+ }
+
+ private async loadElements(): Promise {
+ const elementQuery = createQuery()
+ await new Promise((resolve) => {
+ elementQuery.query(
+ tags.class.TagElement,
+ {
+ targetClass: recruit.mixin.Candidate
+ },
+ (result) => {
+ const ne = new Map[, TagElement>()
+ const nne = new Map]()
+ for (const t of this.newElements.concat(result)) {
+ ne.set(t._id, t)
+ nne.set(t.title.trim().toLowerCase(), t)
+ }
+ this.elements = ne
+ this.namedElements = nne
+ resolve()
+ }
+ )
+ })
+ }
+}
\ No newline at end of file