mirror of
https://github.com/hcengineering/platform.git
synced 2025-04-14 20:39:03 +00:00
parent
ae09613d5f
commit
122cf92165
@ -112,6 +112,7 @@
|
||||
"csv-parse": "~5.1.0",
|
||||
"@anticrm/lead": "~0.6.0",
|
||||
"email-addresses": "^5.0.0",
|
||||
"libphonenumber-js": "^1.9.46"
|
||||
"libphonenumber-js": "^1.9.46",
|
||||
"@anticrm/setting": "~0.6.1"
|
||||
}
|
||||
}
|
||||
|
@ -23,7 +23,7 @@ import { updateClasses } from './classes'
|
||||
import { CustomCustomer, FieldType } from './types'
|
||||
import { filled } from './utils'
|
||||
|
||||
import { parse } from 'csv-parse'
|
||||
import { parseCSV } from './parseCSV'
|
||||
|
||||
const names = {
|
||||
orgName: 'Название компании',
|
||||
@ -125,31 +125,6 @@ async function updateStates<T extends State | DoneState> (
|
||||
}
|
||||
}
|
||||
|
||||
export async function parseCSV (csvData: string): Promise<any[]> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
parse(
|
||||
csvData,
|
||||
{
|
||||
delimiter: ';',
|
||||
columns: true,
|
||||
quote: '"',
|
||||
bom: true,
|
||||
cast: true,
|
||||
autoParse: true,
|
||||
castDate: false,
|
||||
skipEmptyLines: true,
|
||||
skipRecordsWithEmptyValues: true
|
||||
},
|
||||
(err, records) => {
|
||||
if (err !== undefined) {
|
||||
console.error(err)
|
||||
reject(err)
|
||||
}
|
||||
resolve(records)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
export async function importLead (transactorUrl: string, dbName: string, csvFile: string): Promise<void> {
|
||||
const connection = await connect(transactorUrl, dbName)
|
||||
|
27
dev/tool/src/csv/parseCSV.ts
Normal file
27
dev/tool/src/csv/parseCSV.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { parse } from 'csv-parse'
|
||||
|
||||
export async function parseCSV (csvData: string): Promise<any[]> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
parse(
|
||||
csvData,
|
||||
{
|
||||
delimiter: ';',
|
||||
columns: true,
|
||||
quote: '"',
|
||||
bom: true,
|
||||
cast: true,
|
||||
autoParse: true,
|
||||
castDate: false,
|
||||
skipEmptyLines: true,
|
||||
skipRecordsWithEmptyValues: true
|
||||
},
|
||||
(err, records) => {
|
||||
if (err !== undefined) {
|
||||
console.error(err)
|
||||
reject(err)
|
||||
}
|
||||
resolve(records)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
760
dev/tool/src/csv/talant-importer.ts
Normal file
760
dev/tool/src/csv/talant-importer.ts
Normal file
@ -0,0 +1,760 @@
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
|
||||
import contact, { ChannelProvider, combineName, Contact, EmployeeAccount, Person } from '@anticrm/contact'
|
||||
import core, {
|
||||
AnyAttribute,
|
||||
BackupClient,
|
||||
BlobData,
|
||||
Class,
|
||||
ClassifierKind,
|
||||
Client,
|
||||
Data,
|
||||
Doc,
|
||||
DOMAIN_BLOB,
|
||||
Enum,
|
||||
EnumOf,
|
||||
generateId,
|
||||
Ref,
|
||||
Timestamp,
|
||||
TxOperations,
|
||||
WithLookup
|
||||
} from '@anticrm/core'
|
||||
import { Asset, getEmbeddedLabel, IntlString } from '@anticrm/platform'
|
||||
import recruit, { Candidate } from '@anticrm/recruit'
|
||||
import { connect } from '@anticrm/server-tool'
|
||||
import setting from '@anticrm/setting'
|
||||
import { readFile } from 'fs/promises'
|
||||
import { parseCSV } from './parseCSV'
|
||||
import { FieldType } from './types'
|
||||
import { filled } from './utils'
|
||||
import got from 'got'
|
||||
import mimetypes from 'mime-types'
|
||||
import attachment, { Attachment } from '@anticrm/attachment'
|
||||
import { generateToken } from '@anticrm/server-token'
|
||||
import { recognize, updateContacts, updateSkills } from '../recruit'
|
||||
import { ReconiDocument } from '@anticrm/rekoni'
|
||||
import { findOrUpdateAttached } from '../utils'
|
||||
|
||||
const names = {
|
||||
status: 'Status',
|
||||
fullName: 'Lead Name',
|
||||
firstName: 'First Name',
|
||||
middleName: 'Middle Name',
|
||||
lastName: 'Last Name',
|
||||
birthDay: 'Date of birth',
|
||||
created: 'Created',
|
||||
kasource: 'Source',
|
||||
workEmail: 'Work E-mail',
|
||||
newsletterEmail: 'Newsletters email',
|
||||
otherEmail: 'Other E-mail',
|
||||
homeEmail: 'Home E-mail',
|
||||
telegram: 'Telegram account',
|
||||
vkAccount: 'VK account',
|
||||
skypeID: 'Skype ID',
|
||||
viberContact: 'Viber contact',
|
||||
otherContact: 'Other Contact',
|
||||
responsible: 'Responsible',
|
||||
resume: 'Резюме',
|
||||
profile: 'Ссылка на профайл',
|
||||
liveCountry: 'Страна проживания',
|
||||
phone: 'Телефон',
|
||||
phoneNumber: 'Номер телефона',
|
||||
githubPortfolio: 'Ссылка на гитхаб/портфолио',
|
||||
webSite: 'Веб-сайт',
|
||||
linkedRu1: 'Линкедин',
|
||||
englishLevel: 'Уровень английского',
|
||||
movedFromTrello: 'Полностью перенесен из Трелло',
|
||||
bot: 'С кем общался (бот)',
|
||||
research: 'Ресеч ',
|
||||
region: 'Регион',
|
||||
mainExperience: 'Опыт работы по основному стеку с года:',
|
||||
mainStack: 'Основной стек (выпадающий список)',
|
||||
extraStack: 'Доп.стек',
|
||||
education: 'Образование',
|
||||
relocationPreferences: 'Предпочтения по релокации',
|
||||
relocationIgnore: 'Какую компанию не предлагать! (или страну)',
|
||||
relocationWant: 'Какие страны/города рассматривает',
|
||||
language: 'Язык общения',
|
||||
languageExtra: 'Владение другими языками',
|
||||
seed: 'Посев',
|
||||
actualization: 'Актуализация (дата)',
|
||||
unsubscribe: 'Отказался от рассылки',
|
||||
companyName: 'Название компании (новое поле)'
|
||||
}
|
||||
|
||||
const kaDetails = 'recruit:mixin:KADetails' as Ref<Class<Doc>>
|
||||
const portfolioDetails = 'recruit:mixin:KAPortfolio' as Ref<Class<Doc>>
|
||||
const techDetails = 'recruit:mixin:TechDetails' as Ref<Class<Doc>>
|
||||
const locationDetails = 'recruit:mixin:CountryDetails' as Ref<Class<Doc>>
|
||||
|
||||
const fieldMapping: Record<string, FieldType> = {
|
||||
[names.fullName]: {
|
||||
name: 'fullName',
|
||||
type: core.class.TypeString,
|
||||
label: core.string.String,
|
||||
sourceClass: contact.class.Person
|
||||
},
|
||||
[names.middleName]: {
|
||||
name: 'middleName',
|
||||
type: core.class.TypeString,
|
||||
label: core.string.String,
|
||||
sourceClass: contact.class.Person
|
||||
},
|
||||
Language: {
|
||||
name: 'language',
|
||||
type: core.class.EnumOf,
|
||||
enumName: 'KAPrefferedLanguage',
|
||||
label: core.string.Enum,
|
||||
fName: names.language,
|
||||
sourceClass: contact.class.Person
|
||||
},
|
||||
[names.languageExtra]: {
|
||||
name: 'language_extra',
|
||||
type: core.class.TypeString,
|
||||
label: core.string.String,
|
||||
sourceClass: contact.class.Person
|
||||
},
|
||||
|
||||
[names.created]: { name: 'created', type: core.class.TypeDate, label: core.string.Date, sourceClass: kaDetails },
|
||||
[names.status]: {
|
||||
name: 'lead_status',
|
||||
type: core.class.EnumOf,
|
||||
enumName: 'KAStatus',
|
||||
label: core.string.Enum,
|
||||
sourceClass: kaDetails
|
||||
},
|
||||
[names.kasource]: {
|
||||
name: 'kasource',
|
||||
type: core.class.EnumOf,
|
||||
enumName: 'KASource',
|
||||
label: core.string.Enum,
|
||||
fName: names.kasource,
|
||||
sourceClass: kaDetails
|
||||
},
|
||||
[names.responsible]: {
|
||||
name: 'responsible',
|
||||
type: core.class.EnumOf,
|
||||
enumName: 'KAResponsible',
|
||||
label: core.string.Enum,
|
||||
sourceClass: kaDetails
|
||||
},
|
||||
[names.movedFromTrello]: {
|
||||
name: 'trello_moved',
|
||||
type: core.class.TypeBoolean,
|
||||
label: core.string.Boolean,
|
||||
sourceClass: kaDetails
|
||||
},
|
||||
[names.bot]: {
|
||||
name: 'bot',
|
||||
type: core.class.EnumOf,
|
||||
enumName: 'KABot',
|
||||
label: core.string.Enum,
|
||||
sourceClass: kaDetails
|
||||
},
|
||||
Ресерч: {
|
||||
name: 'research',
|
||||
type: core.class.EnumOf,
|
||||
enumName: 'KAResearch',
|
||||
label: core.string.Enum,
|
||||
fName: names.research,
|
||||
sourceClass: kaDetails
|
||||
},
|
||||
[names.seed]: {
|
||||
name: 'seed',
|
||||
type: core.class.EnumOf,
|
||||
enumName: 'KASeed',
|
||||
label: core.string.Enum,
|
||||
sourceClass: kaDetails
|
||||
},
|
||||
[names.actualization]: {
|
||||
name: 'actualization',
|
||||
type: core.class.TypeDate,
|
||||
label: core.string.Date,
|
||||
sourceClass: kaDetails
|
||||
},
|
||||
'Unsubscribe / Отказ от рассылки': {
|
||||
name: 'unsubscribe',
|
||||
type: core.class.TypeBoolean,
|
||||
label: core.string.Boolean,
|
||||
sourceClass: kaDetails
|
||||
},
|
||||
[names.companyName]: {
|
||||
name: 'company_name',
|
||||
type: core.class.TypeString,
|
||||
label: core.string.Boolean,
|
||||
sourceClass: kaDetails
|
||||
},
|
||||
|
||||
[names.resume]: {
|
||||
name: 'resume',
|
||||
type: core.class.TypeString,
|
||||
label: core.string.String,
|
||||
sourceClass: portfolioDetails
|
||||
},
|
||||
[names.profile]: {
|
||||
name: 'profile_link',
|
||||
type: core.class.TypeString,
|
||||
label: core.string.String,
|
||||
sourceClass: portfolioDetails
|
||||
},
|
||||
[names.githubPortfolio]: {
|
||||
name: 'github_portfolio',
|
||||
type: core.class.TypeString,
|
||||
label: core.string.String,
|
||||
sourceClass: portfolioDetails
|
||||
},
|
||||
[names.vkAccount]: {
|
||||
name: 'vk_account',
|
||||
type: core.class.TypeString,
|
||||
label: core.string.String,
|
||||
sourceClass: portfolioDetails
|
||||
},
|
||||
[names.viberContact]: {
|
||||
name: 'viberContact',
|
||||
type: core.class.TypeString,
|
||||
label: core.string.String,
|
||||
sourceClass: portfolioDetails
|
||||
},
|
||||
[names.otherContact]: {
|
||||
name: 'otherContact',
|
||||
type: core.class.TypeString,
|
||||
label: core.string.String,
|
||||
sourceClass: portfolioDetails
|
||||
},
|
||||
|
||||
'Основной Стек': {
|
||||
name: 'main_stack',
|
||||
type: core.class.EnumOf,
|
||||
enumName: 'KAMainTechStack',
|
||||
label: core.string.Enum,
|
||||
fName: names.mainStack,
|
||||
sourceClass: techDetails
|
||||
},
|
||||
[names.englishLevel]: {
|
||||
name: 'english_level',
|
||||
type: core.class.EnumOf,
|
||||
enumName: 'KAEnglishLevel',
|
||||
label: core.string.Enum,
|
||||
sourceClass: techDetails
|
||||
},
|
||||
[names.mainExperience]: {
|
||||
name: 'main_experience',
|
||||
type: core.class.TypeNumber,
|
||||
label: core.string.Number,
|
||||
sourceClass: techDetails
|
||||
},
|
||||
[names.extraStack]: {
|
||||
name: 'extra_stack',
|
||||
type: core.class.TypeString,
|
||||
label: core.string.String,
|
||||
sourceClass: techDetails
|
||||
},
|
||||
[names.education]: {
|
||||
name: 'education',
|
||||
type: core.class.TypeString,
|
||||
label: core.string.String,
|
||||
sourceClass: techDetails
|
||||
},
|
||||
|
||||
Region: {
|
||||
name: 'region',
|
||||
type: core.class.EnumOf,
|
||||
enumName: 'KARegion',
|
||||
label: core.string.Enum,
|
||||
fName: names.region,
|
||||
sourceClass: locationDetails
|
||||
},
|
||||
[names.relocationPreferences]: {
|
||||
name: 'relocation_preferences',
|
||||
type: core.class.EnumOf,
|
||||
enumName: 'KARelocationPreferences',
|
||||
label: core.string.Enum,
|
||||
sourceClass: locationDetails
|
||||
},
|
||||
[names.relocationIgnore]: {
|
||||
name: 'relocation_ignore',
|
||||
type: core.class.TypeString,
|
||||
label: core.string.String,
|
||||
sourceClass: locationDetails
|
||||
},
|
||||
[names.relocationWant]: {
|
||||
name: 'relocation_want',
|
||||
type: core.class.TypeString,
|
||||
label: core.string.String,
|
||||
sourceClass: locationDetails
|
||||
}
|
||||
}
|
||||
|
||||
function getAttr (client: TxOperations, _class: Ref<Class<Doc>>, name: string): AnyAttribute | undefined {
|
||||
try {
|
||||
return client.getHierarchy().getAttribute(_class, name)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateTalantClasses (
|
||||
client: TxOperations,
|
||||
records: any[],
|
||||
fieldMapping: Record<string, FieldType>,
|
||||
refClass: Ref<Class<Doc>> = recruit.mixin.Candidate
|
||||
): Promise<void> {
|
||||
const allAttrs = client.getHierarchy().getAllAttributes(refClass)
|
||||
for (const [k, v] of Object.entries(fieldMapping)) {
|
||||
if (v.type === undefined) {
|
||||
continue
|
||||
}
|
||||
let attr = v.sourceClass === undefined ? allAttrs.get(v.name) : getAttr(client, v.sourceClass, v.name)
|
||||
if (attr === undefined) {
|
||||
try {
|
||||
if (!client.getHierarchy().isDerived(v.type, core.class.Type)) {
|
||||
// Skip channels mapping
|
||||
continue
|
||||
}
|
||||
} catch (any) {
|
||||
continue
|
||||
}
|
||||
// Create attr
|
||||
const data: Data<AnyAttribute> = {
|
||||
attributeOf: v.sourceClass ?? refClass,
|
||||
name: v.name,
|
||||
label: getEmbeddedLabel(k),
|
||||
isCustom: true,
|
||||
type: {
|
||||
_class: v.type,
|
||||
label: v.label ?? core.string.String
|
||||
}
|
||||
}
|
||||
if (client.getHierarchy().isDerived(v.type, core.class.EnumOf)) {
|
||||
;(data.type as EnumOf).of = `recruit:class:${(v as any).enumName as string}` as Ref<Enum>
|
||||
}
|
||||
const attrId = ((v.sourceClass ?? refClass) + '.' + v.name) as Ref<AnyAttribute>
|
||||
await client.createDoc(core.class.Attribute, core.space.Model, data, attrId)
|
||||
attr = await client.findOne(core.class.Attribute, { _id: attrId })
|
||||
}
|
||||
if (attr === undefined) {
|
||||
continue
|
||||
}
|
||||
// Check update Enum/Values
|
||||
if (client.getHierarchy().isDerived(attr.type._class, core.class.EnumOf)) {
|
||||
const enumName = (v as any).enumName as string
|
||||
const enumId = `recruit:class:${enumName}` as Ref<Enum>
|
||||
let enumClass = await client.findOne(core.class.Enum, { _id: enumId })
|
||||
if (enumClass === undefined) {
|
||||
await client.createDoc(
|
||||
core.class.Enum,
|
||||
core.space.Model,
|
||||
{
|
||||
name: enumName,
|
||||
enumValues: []
|
||||
},
|
||||
enumId
|
||||
)
|
||||
enumClass = client.getModel().getObject(enumId)
|
||||
}
|
||||
// Check values
|
||||
const mapv = (v?: string): string =>
|
||||
(v?.toString() ?? '').trim().length === 0 ? 'не задано' : (v?.toString() ?? '').trim()
|
||||
const values = records
|
||||
.map((it) => it[v.fName ?? k])
|
||||
.map(mapv)
|
||||
.filter((it, idx, arr) => arr.indexOf(it) === idx)
|
||||
for (const v of values) {
|
||||
if (!enumClass.enumValues.includes(v)) {
|
||||
await client.update(enumClass, {
|
||||
$push: { enumValues: v }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function importTalants (
|
||||
transactorUrl: string,
|
||||
dbName: string,
|
||||
csvFile: string,
|
||||
rekoniUrl: string
|
||||
): Promise<void> {
|
||||
const connection = (await connect(transactorUrl, dbName, undefined, {
|
||||
mode: 'backup'
|
||||
})) as unknown as Client & BackupClient
|
||||
|
||||
try {
|
||||
console.log('loading cvs document...')
|
||||
|
||||
const csvData = await readFile(csvFile, 'utf-8')
|
||||
const records: any[] = await parseCSV(csvData)
|
||||
const uniqKeys: string[] = []
|
||||
const filledFields = records.map((it) => filled(it, uniqKeys))
|
||||
// console.log(filledFields)
|
||||
|
||||
const client = new TxOperations(connection, 'core:account:recruit-importer' as Ref<EmployeeAccount>)
|
||||
|
||||
await createMissingClass(client, kaDetails, getEmbeddedLabel('KA Details'))
|
||||
await createMissingClass(client, portfolioDetails, getEmbeddedLabel('Portfolio'))
|
||||
await createMissingClass(client, techDetails, getEmbeddedLabel('Technology'))
|
||||
await createMissingClass(client, locationDetails, getEmbeddedLabel('Language & Relocations'))
|
||||
|
||||
await updateTalantClasses(client, records, fieldMapping)
|
||||
await createTalants(client, filledFields, connection, rekoniUrl, generateToken('anticrm@hc.engineering', dbName))
|
||||
} catch (err: any) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
await connection.close()
|
||||
}
|
||||
}
|
||||
async function createMissingClass (
|
||||
client: TxOperations,
|
||||
ref: Ref<Class<Doc>>,
|
||||
label: IntlString,
|
||||
icon?: Asset
|
||||
): Promise<void> {
|
||||
const cl = await client.findOne(core.class.Class, { _id: ref })
|
||||
if (cl === undefined) {
|
||||
await client.createDoc(
|
||||
core.class.Mixin,
|
||||
core.space.Model,
|
||||
{
|
||||
extends: recruit.mixin.Candidate,
|
||||
label,
|
||||
kind: ClassifierKind.MIXIN,
|
||||
icon
|
||||
},
|
||||
ref
|
||||
)
|
||||
await client.createMixin(ref, core.class.Mixin, core.space.Model, setting.mixin.UserMixin, {})
|
||||
}
|
||||
}
|
||||
|
||||
export interface KaDetailsTalant extends Candidate {
|
||||
created: Timestamp
|
||||
lead_status: any
|
||||
kasource: any
|
||||
responsible: any
|
||||
trello_moved: boolean
|
||||
bot: any
|
||||
research: any
|
||||
seed: any
|
||||
actualization: Timestamp
|
||||
unsubscribe: boolean
|
||||
company_name: string
|
||||
}
|
||||
export interface PortfolioDetailsTalant extends Candidate {
|
||||
resume: string
|
||||
profile_link: string
|
||||
github_portfolio: string
|
||||
vk_account: string
|
||||
viberContact: string
|
||||
otherContact: string
|
||||
}
|
||||
|
||||
export interface TechDetailsTalant extends Candidate {
|
||||
main_stack: any
|
||||
english_level: any
|
||||
main_experience: number
|
||||
extra_stack: string
|
||||
education: string
|
||||
}
|
||||
|
||||
export interface LocationDetailsTalant extends Candidate {
|
||||
region: any
|
||||
relocation_preferences: any
|
||||
relocation_ignore: string
|
||||
relocation_want: string
|
||||
}
|
||||
|
||||
interface CustomPerson extends Person {
|
||||
fullName: string
|
||||
middleName: string
|
||||
language?: string
|
||||
language_extra?: string
|
||||
}
|
||||
|
||||
async function createTalants (
|
||||
client: TxOperations,
|
||||
filledFields: any[],
|
||||
connection: Client & BackupClient,
|
||||
rekoniUrl: string,
|
||||
token: string
|
||||
): Promise<void> {
|
||||
for (const record of filledFields) {
|
||||
const candidateId = `imported-${record.ID as string}` as Ref<Candidate>
|
||||
const leadName = record[names.fullName]
|
||||
const candidate = await client.findOne(recruit.mixin.Candidate, { _id: candidateId })
|
||||
console.log('processing', leadName)
|
||||
const dataKa = {
|
||||
created: new Date(record[names.created]).getTime(),
|
||||
lead_status: record[names.status],
|
||||
kasource: record[names.kasource],
|
||||
responsible: record[names.responsible],
|
||||
trello_moved: record[names.movedFromTrello] === 'yes',
|
||||
bot: record[names.bot],
|
||||
research: record[names.research],
|
||||
seed: record[names.seed],
|
||||
actualization: new Date(record[names.actualization]).getTime(),
|
||||
unsubscribe: record[names.unsubscribe] === 'yes',
|
||||
company_name: record[names.companyName]
|
||||
}
|
||||
const dataPortfolio = {
|
||||
resume: record[names.resume],
|
||||
profile_link: record[names.profile],
|
||||
github_portfolio: record[names.githubPortfolio],
|
||||
vk_account: record[names.vkAccount],
|
||||
viberContact: record[names.viberContact],
|
||||
otherContact: record[names.otherContact]
|
||||
}
|
||||
const dataTechStack = {
|
||||
main_stack: record[names.mainStack],
|
||||
english_level: record[names.englishLevel],
|
||||
main_experience: record[names.mainExperience],
|
||||
extra_stack: record[names.extraStack],
|
||||
education: record[names.education]
|
||||
}
|
||||
const dataLocationDetails = {
|
||||
region: record[names.region],
|
||||
relocation_preferences: record[names.relocationPreferences],
|
||||
relocation_ignore: record[names.relocationIgnore],
|
||||
relocation_want: record[names.relocationWant]
|
||||
}
|
||||
if (candidate !== undefined) {
|
||||
continue
|
||||
}
|
||||
const cdata = {
|
||||
name: combineName(record[names.firstName], record[names.lastName]),
|
||||
city: record[names.liveCountry],
|
||||
birthday: record[names.birthDay] !== '' ? new Date(record[names.birthDay]).getTime() : undefined,
|
||||
fullName: record[names.fullName],
|
||||
middleName: record[names.middleName],
|
||||
language: record[names.language],
|
||||
language_extra: record[names.languageExtra]
|
||||
}
|
||||
await client.createDoc<CustomPerson>(
|
||||
contact.class.Person,
|
||||
contact.space.Contacts,
|
||||
cdata,
|
||||
candidateId as unknown as Ref<CustomPerson>
|
||||
)
|
||||
|
||||
await client.createMixin<Contact, Candidate>(
|
||||
candidateId,
|
||||
contact.class.Person,
|
||||
contact.space.Contacts,
|
||||
recruit.mixin.Candidate,
|
||||
{}
|
||||
)
|
||||
await client.createMixin<Contact, KaDetailsTalant>(
|
||||
candidateId,
|
||||
contact.class.Person,
|
||||
contact.space.Contacts,
|
||||
kaDetails,
|
||||
dataKa
|
||||
)
|
||||
await client.createMixin<Contact, PortfolioDetailsTalant>(
|
||||
candidateId,
|
||||
contact.class.Person,
|
||||
contact.space.Contacts,
|
||||
portfolioDetails,
|
||||
dataPortfolio
|
||||
)
|
||||
await client.createMixin<Contact, TechDetailsTalant>(
|
||||
candidateId,
|
||||
contact.class.Person,
|
||||
contact.space.Contacts,
|
||||
techDetails,
|
||||
dataTechStack
|
||||
)
|
||||
await client.createMixin<Contact, LocationDetailsTalant>(
|
||||
candidateId,
|
||||
contact.class.Person,
|
||||
contact.space.Contacts,
|
||||
locationDetails,
|
||||
dataLocationDetails
|
||||
)
|
||||
|
||||
function getValid (...names: string[]): string | undefined {
|
||||
for (const o of names) {
|
||||
const v = record[o]
|
||||
if (v !== undefined && typeof v === 'string' && v.trim().length > 0) {
|
||||
return v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function updateChannel (value: string | undefined, provider: Ref<ChannelProvider>): Promise<void> {
|
||||
if (value === undefined) {
|
||||
return
|
||||
}
|
||||
const channels = await client.findAll(contact.class.Channel, { attachedTo: candidateId })
|
||||
const emailPr = channels.find((it) => it.value === value)
|
||||
if (emailPr === undefined) {
|
||||
await client.addCollection(
|
||||
contact.class.Channel,
|
||||
contact.space.Contacts,
|
||||
candidateId,
|
||||
contact.class.Person,
|
||||
'channels',
|
||||
{
|
||||
value,
|
||||
provider
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await updateChannel(
|
||||
getValid(record, names.workEmail, names.homeEmail, names.newsletterEmail, names.otherEmail),
|
||||
contact.channelProvider.Email
|
||||
)
|
||||
await updateChannel(getValid(record, names.webSite), contact.channelProvider.Homepage)
|
||||
await updateChannel(getValid(record, names.phone, names.phoneNumber), contact.channelProvider.Phone)
|
||||
await updateChannel(getValid(record, names.telegram), contact.channelProvider.Telegram)
|
||||
|
||||
const ghval = getValid(record, names.githubPortfolio)
|
||||
if (ghval?.includes('https://github.com') ?? false) {
|
||||
await updateChannel(ghval, contact.channelProvider.GitHub)
|
||||
}
|
||||
|
||||
const profile = getValid(record, names.profile)
|
||||
if (profile?.includes('linkedin.com') ?? false) {
|
||||
await updateChannel(profile, contact.channelProvider.LinkedIn)
|
||||
}
|
||||
|
||||
const resume = record[names.resume] as string
|
||||
|
||||
if (resume !== undefined) {
|
||||
const resumes = resume.split(',')
|
||||
for (const r of resumes) {
|
||||
try {
|
||||
const url = (r ?? '').trim()
|
||||
if (url.startsWith('http')) {
|
||||
const lastpos = url.lastIndexOf('/')
|
||||
const fname = url.substring(lastpos + 1)
|
||||
|
||||
const buffer = await got(url).buffer()
|
||||
const blobId = (candidateId + '_' + generateId()) as Ref<BlobData>
|
||||
const type = mimetypes.contentType(fname)
|
||||
const data: BlobData = {
|
||||
_id: blobId,
|
||||
space: contact.space.Contacts,
|
||||
modifiedBy: client.txFactory.account,
|
||||
modifiedOn: Date.now(),
|
||||
_class: core.class.BlobData,
|
||||
name: fname,
|
||||
size: buffer.length,
|
||||
type: type !== false ? type : 'unknown',
|
||||
base64Data: buffer.toString('base64')
|
||||
}
|
||||
await connection.upload(DOMAIN_BLOB, [data])
|
||||
|
||||
await client.addCollection(
|
||||
attachment.class.Attachment,
|
||||
contact.space.Contacts,
|
||||
candidateId,
|
||||
contact.class.Person,
|
||||
'attachments',
|
||||
{
|
||||
file: blobId,
|
||||
name: fname,
|
||||
size: buffer.length,
|
||||
type: type !== false ? type : 'unknown',
|
||||
lastModified: Date.now()
|
||||
}
|
||||
)
|
||||
|
||||
const doc = await client.findOne(recruit.mixin.Candidate, { _id: candidateId })
|
||||
if (doc === undefined) {
|
||||
continue
|
||||
}
|
||||
if (type === 'application/pdf') {
|
||||
const document = await recognize(rekoniUrl, data.base64Data, token)
|
||||
if (document !== undefined) {
|
||||
if (document.title !== undefined) {
|
||||
await client.update(doc, { title: document.title })
|
||||
}
|
||||
|
||||
await updateAvatar(doc, document, connection, client)
|
||||
|
||||
// Update contact
|
||||
await updateContacts(client, doc, document)
|
||||
|
||||
// Update skills
|
||||
await updateSkills(client, doc, document)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAvatar (
|
||||
c: WithLookup<Candidate>,
|
||||
document: ReconiDocument,
|
||||
connection: Client & BackupClient,
|
||||
client: TxOperations
|
||||
): Promise<void> {
|
||||
if (document.format !== 'headhunter' && document.format !== 'podbor') {
|
||||
// Only update avatar for this kind of resume formats.
|
||||
return
|
||||
}
|
||||
if (
|
||||
c.avatar === undefined &&
|
||||
document.avatar !== undefined &&
|
||||
document.avatarName !== undefined &&
|
||||
document.avatarFormat !== undefined
|
||||
) {
|
||||
const attachId = `${c._id}.${document.avatarName}` as Ref<Attachment>
|
||||
// Upload new avatar for candidate
|
||||
const data = Buffer.from(document.avatar, 'base64')
|
||||
|
||||
const bdata: BlobData = {
|
||||
_id: attachId as unknown as Ref<BlobData>,
|
||||
space: contact.space.Contacts,
|
||||
modifiedBy: client.txFactory.account,
|
||||
modifiedOn: Date.now(),
|
||||
_class: core.class.BlobData,
|
||||
name: document.avatarName,
|
||||
size: data.length,
|
||||
type: document.avatarFormat,
|
||||
base64Data: document.avatar
|
||||
}
|
||||
await connection.upload(DOMAIN_BLOB, [bdata])
|
||||
|
||||
await findOrUpdateAttached<Attachment>(
|
||||
client,
|
||||
contact.space.Contacts,
|
||||
attachment.class.Photo,
|
||||
attachId,
|
||||
{
|
||||
name: document.avatarName,
|
||||
file: attachId,
|
||||
type: document.avatarFormat,
|
||||
size: data.length,
|
||||
lastModified: Date.now()
|
||||
},
|
||||
{
|
||||
attachedTo: c._id,
|
||||
attachedClass: contact.class.Person,
|
||||
collection: 'photos'
|
||||
}
|
||||
)
|
||||
await client.update(c, { avatar: attachId })
|
||||
}
|
||||
}
|
@ -38,6 +38,7 @@ export interface FieldType {
|
||||
name: string
|
||||
type?: Ref<Class<Doc>>
|
||||
label?: IntlString
|
||||
sourceClass?: Ref<Class<Doc>>
|
||||
enumName?: string
|
||||
fName?: string
|
||||
}
|
@ -36,11 +36,12 @@ import toolPlugin, { prepareTools, version } from '@anticrm/server-tool'
|
||||
import { program } from 'commander'
|
||||
import { Db, MongoClient } from 'mongodb'
|
||||
import { exit } from 'process'
|
||||
import { removeDuplicates } from './csv/duplicates'
|
||||
import { importLead } from './csv/lead-importer'
|
||||
import { importLead2 } from './csv/lead-importer2'
|
||||
import { importTalants } from './csv/talant-importer'
|
||||
import { rebuildElastic } from './elastic'
|
||||
import { importXml } from './importer'
|
||||
import { removeDuplicates } from './leads/duplicates'
|
||||
import { importLead } from './leads/lead-importer'
|
||||
import { importLead2 } from './leads/lead-importer2'
|
||||
import { updateCandidates } from './recruit'
|
||||
import { clearTelegramHistory } from './telegram'
|
||||
import { diffWorkspace, dumpWorkspace, restoreWorkspace } from './workspace'
|
||||
@ -374,6 +375,18 @@ program
|
||||
return await importLead2(transactorUrl, workspace, fileName)
|
||||
})
|
||||
|
||||
program
|
||||
.command('import-talant-csv <workspace> <fileName>')
|
||||
.description('Import Talant csv')
|
||||
.action(async (workspace, fileName, cmd) => {
|
||||
const rekoniUrl = process.env.REKONI_URL
|
||||
if (rekoniUrl === undefined) {
|
||||
console.log('Please provide REKONI_URL environment variable')
|
||||
exit(1)
|
||||
}
|
||||
return await importTalants(transactorUrl, workspace, fileName, rekoniUrl)
|
||||
})
|
||||
|
||||
program
|
||||
.command('lead-duplicates <workspace>')
|
||||
.description('Find and remove duplicate organizations.')
|
||||
|
@ -30,7 +30,7 @@ import { ElasticTool } from './elastic'
|
||||
import { findOrUpdateAttached } from './utils'
|
||||
import { readMinioData } from './workspace'
|
||||
|
||||
async function recognize (rekoniUrl: string, data: string, token: string): Promise<ReconiDocument | undefined> {
|
||||
export async function recognize (rekoniUrl: string, data: string, token: string): Promise<ReconiDocument | undefined> {
|
||||
const { body }: { body?: ReconiDocument } = await got.post(rekoniUrl + '/recognize?format=pdf', {
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + token,
|
||||
@ -45,14 +45,14 @@ async function recognize (rekoniUrl: string, data: string, token: string): Promi
|
||||
return body
|
||||
}
|
||||
|
||||
function isUndef (value?: string): boolean {
|
||||
export function isUndef (value?: string): boolean {
|
||||
if (value == null || value.trim().length === 0) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function addChannel (
|
||||
export async function addChannel (
|
||||
client: TxOperations,
|
||||
channels: Channel[],
|
||||
c: Candidate,
|
||||
@ -149,7 +149,7 @@ export async function updateCandidates (
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSkills (client: TxOperations, c: Candidate, document: ReconiDocument): Promise<void> {
|
||||
export async function updateSkills (client: TxOperations, c: Candidate, document: ReconiDocument): Promise<void> {
|
||||
const skills = await client.findAll(tags.class.TagReference, { attachedTo: c._id })
|
||||
const namedSkills = new Set(Array.from(skills.map((it) => it.title.toLowerCase())))
|
||||
|
||||
@ -185,7 +185,11 @@ async function updateSkills (client: TxOperations, c: Candidate, document: Recon
|
||||
}
|
||||
}
|
||||
}
|
||||
async function updateContacts (client: TxOperations, c: WithLookup<Candidate>, document: ReconiDocument): Promise<void> {
|
||||
export async function updateContacts (
|
||||
client: TxOperations,
|
||||
c: WithLookup<Candidate>,
|
||||
document: ReconiDocument
|
||||
): Promise<void> {
|
||||
const channels = await client.findAll(contact.class.Channel, { attachedTo: c._id })
|
||||
await addChannel(client, channels, c, contact.channelProvider.Email, document.email)
|
||||
await addChannel(client, channels, c, contact.channelProvider.GitHub, document.github)
|
||||
|
@ -29,7 +29,18 @@ import {
|
||||
} from '@anticrm/contact'
|
||||
import type { Class, Domain, Ref, Timestamp } from '@anticrm/core'
|
||||
import { DOMAIN_MODEL, IndexKind } from '@anticrm/core'
|
||||
import { Builder, Collection, Index, Model, Prop, TypeRef, TypeString, TypeTimestamp, UX } from '@anticrm/model'
|
||||
import {
|
||||
Builder,
|
||||
Collection,
|
||||
Index,
|
||||
Model,
|
||||
Prop,
|
||||
TypeDate,
|
||||
TypeRef,
|
||||
TypeString,
|
||||
TypeTimestamp,
|
||||
UX
|
||||
} from '@anticrm/model'
|
||||
import attachment from '@anticrm/model-attachment'
|
||||
import chunter from '@anticrm/model-chunter'
|
||||
import core, { TAccount, TAttachedDoc, TDoc, TSpace } from '@anticrm/model-core'
|
||||
@ -94,7 +105,10 @@ export class TChannel extends TAttachedDoc implements Channel {
|
||||
|
||||
@Model(contact.class.Person, contact.class.Contact)
|
||||
@UX(contact.string.Person, contact.icon.Person, undefined, 'name')
|
||||
export class TPerson extends TContact implements Person {}
|
||||
export class TPerson extends TContact implements Person {
|
||||
@Prop(TypeDate(), contact.string.Birthday)
|
||||
birthday?: Timestamp
|
||||
}
|
||||
|
||||
@Model(contact.class.Member, core.class.AttachedDoc, DOMAIN_CONTACT)
|
||||
@UX(contact.string.Member, contact.icon.Person, undefined, 'name')
|
||||
|
@ -65,7 +65,8 @@ export default mergeIds(contactId, contact, {
|
||||
GitHub: '' as IntlString,
|
||||
Facebook: '' as IntlString,
|
||||
TypeLabel: '' as IntlString,
|
||||
Homepage: '' as IntlString
|
||||
Homepage: '' as IntlString,
|
||||
Birthday: '' as IntlString
|
||||
},
|
||||
completion: {
|
||||
PersonQuery: '' as Resource<ObjectSearchFactory>,
|
||||
|
@ -732,7 +732,7 @@ export function createModel (builder: Builder): void {
|
||||
})
|
||||
|
||||
builder.mixin(tracker.class.Issue, core.class.Class, view.mixin.ClassFilters, {
|
||||
filters: ['status', 'priority', 'assignee', 'project', 'dueDate', 'modifiedOn']
|
||||
filters: ['status', 'priority', 'assignee', 'project', 'sprint', 'dueDate', 'modifiedOn']
|
||||
})
|
||||
|
||||
builder.createDoc(
|
||||
|
@ -68,6 +68,7 @@
|
||||
"KickEmployeeDescr": "Are you sure you want to kick the employee out of the workspace? This action cannot be undone",
|
||||
"Email": "Email",
|
||||
"CreateEmployee": "Create an employee",
|
||||
"Inactive": "Inactive"
|
||||
"Inactive": "Inactive",
|
||||
"Birthday": "Birthday"
|
||||
}
|
||||
}
|
@ -68,6 +68,7 @@
|
||||
"KickEmployeeDescr": "Вы действительно хотите выгнать сотрудника из рабочего пространства? Это действие нельзя отменить",
|
||||
"Email": "Email",
|
||||
"CreateEmployee": "Создать сотрудника",
|
||||
"Inactive": "Не активный"
|
||||
"Inactive": "Не активный",
|
||||
"Birthday": "День рождения"
|
||||
}
|
||||
}
|
@ -76,7 +76,9 @@ export interface Contact extends Doc {
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface Person extends Contact {}
|
||||
export interface Person extends Contact {
|
||||
birthday?: Timestamp | null
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
|
@ -168,6 +168,7 @@
|
||||
<table class="antiTable">
|
||||
<thead class="scroller-thead">
|
||||
<tr class="scroller-thead__tr">
|
||||
<!-- <th>Field name</th> -->
|
||||
<th>
|
||||
<div class="antiTable-cells">
|
||||
<Label label={settings.string.Attribute} />
|
||||
@ -197,6 +198,9 @@
|
||||
}
|
||||
}}
|
||||
>
|
||||
<!-- <td class='select-text'>
|
||||
{attr.name}
|
||||
</td> -->
|
||||
<td>
|
||||
<div class="antiTable-cells__firstCell">
|
||||
<Label label={attr.label} />
|
||||
@ -207,7 +211,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<td class="select-text">
|
||||
<Label label={attr.type.label} />
|
||||
{#if attrType !== undefined}
|
||||
: <Label label={attrType} />
|
||||
|
@ -36,7 +36,7 @@
|
||||
|
||||
const data: Data<AnyAttribute> = {
|
||||
attributeOf: _class,
|
||||
name: name + generateId(),
|
||||
name: name.trim().replace('/', '').replace(' ', '') + '_' + generateId(),
|
||||
label: getEmbeddedLabel(name),
|
||||
isCustom: true,
|
||||
type
|
||||
|
@ -16,10 +16,6 @@
|
||||
import { WithLookup } from '@anticrm/core'
|
||||
import { Issue, IssueStatus } from '@anticrm/tracker'
|
||||
import { showPopup } from '@anticrm/ui'
|
||||
import StatusFilterMenuSection from './StatusFilterMenuSection.svelte'
|
||||
import PriorityFilterMenuSection from './PriorityFilterMenuSection.svelte'
|
||||
import ProjectFilterMenuSection from './ProjectFilterMenuSection.svelte'
|
||||
import FilterMenu from '../FilterMenu.svelte'
|
||||
import {
|
||||
defaultPriorities,
|
||||
FilterAction,
|
||||
@ -27,6 +23,11 @@
|
||||
getIssueFilterAssetsByType,
|
||||
IssueFilter
|
||||
} from '../../utils'
|
||||
import FilterMenu from '../FilterMenu.svelte'
|
||||
import PriorityFilterMenuSection from './PriorityFilterMenuSection.svelte'
|
||||
import ProjectFilterMenuSection from './ProjectFilterMenuSection.svelte'
|
||||
import SprintFilterMenuSection from './SprintFilterMenuSection.svelte'
|
||||
import StatusFilterMenuSection from './StatusFilterMenuSection.svelte'
|
||||
|
||||
export let targetHtml: HTMLElement
|
||||
export let filters: IssueFilter[] = []
|
||||
@ -42,6 +43,7 @@
|
||||
$: groupedByStatus = getGroupedIssues('status', issues, defaultStatusIds)
|
||||
$: groupedByPriority = getGroupedIssues('priority', issues, defaultPriorities)
|
||||
$: groupedByProject = getGroupedIssues('project', issues)
|
||||
$: groupedBySprint = getGroupedIssues('sprint', issues)
|
||||
|
||||
const handleStatusFilterMenuSectionOpened = (event: MouseEvent | KeyboardEvent) => {
|
||||
const statusGroups: { [key: string]: number } = {}
|
||||
@ -103,6 +105,25 @@
|
||||
)
|
||||
}
|
||||
|
||||
const handleSprintFilterMenuSectionOpened = (event: MouseEvent | KeyboardEvent) => {
|
||||
const sprintGroups: { [key: string]: number } = {}
|
||||
|
||||
for (const [project, value] of Object.entries(groupedBySprint)) {
|
||||
sprintGroups[project] = value?.length ?? 0
|
||||
}
|
||||
showPopup(
|
||||
SprintFilterMenuSection,
|
||||
{
|
||||
groups: sprintGroups,
|
||||
selectedElements: currentFilterQuery?.sprint?.[currentFilterMode] ?? [],
|
||||
index,
|
||||
onUpdate,
|
||||
onBack
|
||||
},
|
||||
targetHtml
|
||||
)
|
||||
}
|
||||
|
||||
const actions: FilterAction[] = [
|
||||
{
|
||||
...getIssueFilterAssetsByType('status'),
|
||||
@ -115,6 +136,10 @@
|
||||
{
|
||||
...getIssueFilterAssetsByType('project'),
|
||||
onSelect: handleProjectFilterMenuSectionOpened
|
||||
},
|
||||
{
|
||||
...getIssueFilterAssetsByType('sprint'),
|
||||
onSelect: handleSprintFilterMenuSectionOpened
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
@ -131,6 +131,7 @@
|
||||
lookup: {
|
||||
status: [tracker.class.IssueStatus, { category: tracker.class.IssueStatusCategory }],
|
||||
project: tracker.class.Project,
|
||||
sprint: tracker.class.Sprint,
|
||||
assignee: contact.class.Employee
|
||||
},
|
||||
sort: issuesGroupBySorting[groupBy]
|
||||
|
@ -0,0 +1,66 @@
|
||||
<!--
|
||||
// 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 { translate } from '@anticrm/platform'
|
||||
import { IconNavPrev } from '@anticrm/ui'
|
||||
import FilterMenuSection from '../FilterMenuSection.svelte'
|
||||
import tracker from '../../plugin'
|
||||
import { FilterSectionElement } from '../../utils'
|
||||
import { getClient } from '@anticrm/presentation'
|
||||
|
||||
export let selectedElements: any[] = []
|
||||
export let groups: { [key: string]: number }
|
||||
export let index: number = 0
|
||||
export let onUpdate: (result: { [p: string]: any }, filterIndex?: number) => void
|
||||
export let onBack: (() => void) | undefined = undefined
|
||||
|
||||
const getFilterElements = async (groups: { [key: string]: number }, selected: any[]) => {
|
||||
const elements: FilterSectionElement[] = []
|
||||
|
||||
const client = getClient()
|
||||
const sprints = await client.findAll(tracker.class.Sprint, {})
|
||||
for (const [key, value] of Object.entries(groups)) {
|
||||
const sprint = key === 'null' ? null : key
|
||||
const label = sprint
|
||||
? sprints.find(({ _id }) => _id === sprint)?.label
|
||||
: await translate(tracker.string.NoSprint, {})
|
||||
|
||||
if (!label) {
|
||||
continue
|
||||
}
|
||||
elements.splice(sprint ? 1 : 0, 0, {
|
||||
icon: tracker.icon.Sprint,
|
||||
title: label,
|
||||
count: value,
|
||||
isSelected: selected.includes(sprint),
|
||||
onSelect: () => onUpdate({ sprint: sprint }, index)
|
||||
})
|
||||
}
|
||||
return onBack
|
||||
? [
|
||||
{
|
||||
icon: IconNavPrev,
|
||||
title: await translate(tracker.string.Back, {}),
|
||||
onSelect: onBack
|
||||
},
|
||||
...elements
|
||||
]
|
||||
: elements
|
||||
}
|
||||
</script>
|
||||
|
||||
{#await getFilterElements(groups, selectedElements) then actions}
|
||||
<FilterMenuSection {actions} {onBack} on:close />
|
||||
{/await}
|
@ -39,6 +39,7 @@ export const issuesGroupByOptions: Record<IssuesGrouping, IntlString> = {
|
||||
[IssuesGrouping.Assignee]: tracker.string.Assignee,
|
||||
[IssuesGrouping.Priority]: tracker.string.Priority,
|
||||
[IssuesGrouping.Project]: tracker.string.Project,
|
||||
[IssuesGrouping.Sprint]: tracker.string.Sprint,
|
||||
[IssuesGrouping.NoGrouping]: tracker.string.NoGrouping
|
||||
}
|
||||
|
||||
@ -100,5 +101,6 @@ export const issuesGroupBySorting: Record<IssuesGrouping, SortingQuery<Issue>> =
|
||||
[IssuesGrouping.Assignee]: { '$lookup.assignee.name': SortingOrder.Ascending },
|
||||
[IssuesGrouping.Priority]: { priority: SortingOrder.Ascending },
|
||||
[IssuesGrouping.Project]: { '$lookup.project.label': SortingOrder.Ascending },
|
||||
[IssuesGrouping.Sprint]: { '$lookup.sprint.label': SortingOrder.Ascending },
|
||||
[IssuesGrouping.NoGrouping]: {}
|
||||
}
|
||||
|
@ -51,7 +51,7 @@ export interface Selection {
|
||||
currentSpecial?: string
|
||||
}
|
||||
|
||||
export type IssuesGroupByKeys = keyof Pick<Issue, 'status' | 'priority' | 'assignee' | 'project'>
|
||||
export type IssuesGroupByKeys = keyof Pick<Issue, 'status' | 'priority' | 'assignee' | 'project' | 'sprint'>
|
||||
export type IssuesOrderByKeys = keyof Pick<Issue, 'status' | 'priority' | 'modifiedOn' | 'dueDate' | 'rank'>
|
||||
|
||||
export const issuesGroupKeyMap: Record<IssuesGrouping, IssuesGroupByKeys | undefined> = {
|
||||
@ -59,6 +59,7 @@ export const issuesGroupKeyMap: Record<IssuesGrouping, IssuesGroupByKeys | undef
|
||||
[IssuesGrouping.Priority]: 'priority',
|
||||
[IssuesGrouping.Assignee]: 'assignee',
|
||||
[IssuesGrouping.Project]: 'project',
|
||||
[IssuesGrouping.Sprint]: 'sprint',
|
||||
[IssuesGrouping.NoGrouping]: undefined
|
||||
}
|
||||
|
||||
@ -78,10 +79,11 @@ export const issuesSortOrderMap: Record<IssuesOrderByKeys, SortingOrder> = {
|
||||
rank: SortingOrder.Ascending
|
||||
}
|
||||
|
||||
export const issuesGroupEditorMap: Record<'status' | 'priority' | 'project', AnyComponent | undefined> = {
|
||||
export const issuesGroupEditorMap: Record<'status' | 'priority' | 'project' | 'sprint', AnyComponent | undefined> = {
|
||||
status: tracker.component.StatusEditor,
|
||||
priority: tracker.component.PriorityEditor,
|
||||
project: tracker.component.ProjectEditor
|
||||
project: tracker.component.ProjectEditor,
|
||||
sprint: tracker.component.SprintEditor
|
||||
}
|
||||
|
||||
export const getIssuesModificationDatePeriodTime = (period: IssuesDateModificationPeriod | null): number => {
|
||||
@ -180,6 +182,12 @@ export const getIssueFilterAssetsByType = (type: string): { icon: Asset, label:
|
||||
label: tracker.string.Project
|
||||
}
|
||||
}
|
||||
case 'sprint': {
|
||||
return {
|
||||
icon: tracker.icon.Sprint,
|
||||
label: tracker.string.Sprint
|
||||
}
|
||||
}
|
||||
default: {
|
||||
return undefined
|
||||
}
|
||||
@ -447,6 +455,21 @@ export async function getKanbanStatuses (
|
||||
]
|
||||
}, [])
|
||||
}
|
||||
if (groupBy === IssuesGrouping.Sprint) {
|
||||
const noSprint = await translate(tracker.string.NoSprint, {})
|
||||
return issues.reduce<TypeState[]>((result, issue) => {
|
||||
if (result.find(({ _id }) => _id === issue.sprint) !== undefined) return result
|
||||
return [
|
||||
...result,
|
||||
{
|
||||
_id: issue.sprint,
|
||||
title: issue.$lookup?.sprint?.label ?? noSprint,
|
||||
color: UNSET_COLOR,
|
||||
icon: undefined
|
||||
}
|
||||
]
|
||||
}, [])
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
@ -481,6 +504,7 @@ export function getDefaultViewOptionsConfig (): ViewOptionModel[] {
|
||||
{ id: 'assignee', label: tracker.string.Assignee },
|
||||
{ id: 'priority', label: tracker.string.Priority },
|
||||
{ id: 'project', label: tracker.string.Project },
|
||||
{ id: 'sprint', label: tracker.string.Sprint },
|
||||
{ id: 'noGrouping', label: tracker.string.NoGrouping }
|
||||
],
|
||||
type: 'dropdown'
|
||||
|
@ -73,6 +73,7 @@ export enum IssuesGrouping {
|
||||
Assignee = 'assignee',
|
||||
Priority = 'priority',
|
||||
Project = 'project',
|
||||
Sprint = 'sprint',
|
||||
NoGrouping = 'noGrouping'
|
||||
}
|
||||
|
||||
|
@ -16,6 +16,7 @@
|
||||
<script lang="ts">
|
||||
import { Class, ClassifierKind, Doc, Mixin, Ref } from '@anticrm/core'
|
||||
import { getClient } from '@anticrm/presentation'
|
||||
import setting from '@anticrm/setting'
|
||||
import { Label } from '@anticrm/ui'
|
||||
import { getMixinStyle } from '../utils'
|
||||
|
||||
@ -41,7 +42,12 @@
|
||||
|
||||
mixins = hierarchy
|
||||
.getDescendants(parentClass)
|
||||
.filter((m) => hierarchy.getClass(m).kind === ClassifierKind.MIXIN && hierarchy.hasMixin(value, m))
|
||||
.filter(
|
||||
(m) =>
|
||||
hierarchy.getClass(m).kind === ClassifierKind.MIXIN &&
|
||||
hierarchy.hasMixin(value, m) &&
|
||||
!hierarchy.hasMixin(hierarchy.getClass(m), setting.mixin.UserMixin)
|
||||
)
|
||||
.map((m) => hierarchy.getClass(m) as Mixin<Doc>)
|
||||
}
|
||||
</script>
|
||||
|
@ -111,45 +111,53 @@
|
||||
const newValue = await result(filter, () => {
|
||||
makeQuery(query, filters)
|
||||
})
|
||||
if (newQuery[filter.key.key] === undefined) {
|
||||
newQuery[filter.key.key] = newValue
|
||||
|
||||
let filterKey = filter.key.key
|
||||
|
||||
const attr = client.getHierarchy().getAttribute(filter.key._class, filter.key.key)
|
||||
if (client.getHierarchy().isMixin(attr.attributeOf)) {
|
||||
filterKey = attr.attributeOf + '.' + filter.key.key
|
||||
}
|
||||
|
||||
if (newQuery[filterKey] === undefined) {
|
||||
newQuery[filterKey] = newValue
|
||||
} else {
|
||||
let merged = false
|
||||
for (const key in newValue) {
|
||||
if (newQuery[filter.key.key][key] === undefined) {
|
||||
if (key === '$in' && typeof newQuery[filter.key.key] === 'string') {
|
||||
newQuery[filter.key.key] = { $in: newValue[key].filter((p: any) => p === newQuery[filter.key.key]) }
|
||||
if (newQuery[filterKey][key] === undefined) {
|
||||
if (key === '$in' && typeof newQuery[filterKey] === 'string') {
|
||||
newQuery[filterKey] = { $in: newValue[key].filter((p: any) => p === newQuery[filterKey]) }
|
||||
} else {
|
||||
newQuery[filter.key.key][key] = newValue[key]
|
||||
newQuery[filterKey][key] = newValue[key]
|
||||
}
|
||||
merged = true
|
||||
continue
|
||||
}
|
||||
if (key === '$in') {
|
||||
newQuery[filter.key.key][key] = newQuery[filter.key.key][key].filter((p: any) => newValue[key].includes(p))
|
||||
newQuery[filterKey][key] = newQuery[filterKey][key].filter((p: any) => newValue[key].includes(p))
|
||||
merged = true
|
||||
continue
|
||||
}
|
||||
if (key === '$nin') {
|
||||
newQuery[filter.key.key][key] = [...newQuery[filter.key.key][key], ...newValue[key]]
|
||||
newQuery[filterKey][key] = [...newQuery[filterKey][key], ...newValue[key]]
|
||||
merged = true
|
||||
continue
|
||||
}
|
||||
if (key === '$lt') {
|
||||
newQuery[filter.key.key][key] =
|
||||
newQuery[filter.key.key][key] < newValue[key] ? newQuery[filter.key.key][key] : newValue[key]
|
||||
newQuery[filterKey][key] =
|
||||
newQuery[filterKey][key] < newValue[key] ? newQuery[filterKey][key] : newValue[key]
|
||||
merged = true
|
||||
continue
|
||||
}
|
||||
if (key === '$gt') {
|
||||
newQuery[filter.key.key][key] =
|
||||
newQuery[filter.key.key][key] > newValue[key] ? newQuery[filter.key.key][key] : newValue[key]
|
||||
newQuery[filterKey][key] =
|
||||
newQuery[filterKey][key] > newValue[key] ? newQuery[filterKey][key] : newValue[key]
|
||||
merged = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (!merged) {
|
||||
Object.assign(newQuery[filter.key.key], newValue)
|
||||
Object.assign(newQuery[filterKey], newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -33,7 +33,7 @@
|
||||
|
||||
function getTargetClass (): Ref<Class<Doc>> | undefined {
|
||||
try {
|
||||
return (hierarchy.getAttribute(_class, filter.key.key).type as RefTo<Doc>).to
|
||||
return (hierarchy.getAttribute(filter.key._class, filter.key.key).type as RefTo<Doc>).to
|
||||
} catch (err: any) {
|
||||
console.error(err)
|
||||
}
|
||||
|
@ -35,7 +35,7 @@
|
||||
const mixin = hierarchy.as(clazz, view.mixin.ClassFilters)
|
||||
if (mixin.filters === undefined) return []
|
||||
const filters = mixin.filters.map((p) => {
|
||||
return typeof p === 'string' ? buildFilterFromKey(p) : p
|
||||
return typeof p === 'string' ? buildFilterFromKey(_class, p) : p
|
||||
})
|
||||
const result: KeyFilter[] = []
|
||||
for (const filter of filters) {
|
||||
@ -44,12 +44,12 @@
|
||||
return result
|
||||
}
|
||||
|
||||
function buildFilterFromKey (key: string): KeyFilter | undefined {
|
||||
function buildFilterFromKey (_class: Ref<Class<Doc>>, key: string): KeyFilter | undefined {
|
||||
const attribute = hierarchy.getAttribute(_class, key)
|
||||
return buildFilter(key, attribute)
|
||||
return buildFilter(_class, key, attribute)
|
||||
}
|
||||
|
||||
function buildFilter (key: string, attribute: AnyAttribute): KeyFilter | undefined {
|
||||
function buildFilter (_class: Ref<Class<Doc>>, key: string, attribute: AnyAttribute): KeyFilter | undefined {
|
||||
const isCollection = hierarchy.isDerived(attribute.type._class, core.class.Collection)
|
||||
const targetClass = isCollection ? (attribute.type as Collection<AttachedDoc>).of : attribute.type._class
|
||||
const clazz = hierarchy.getClass(targetClass)
|
||||
@ -73,17 +73,45 @@
|
||||
return name
|
||||
}
|
||||
|
||||
function buildFilterForAttr (_class: Ref<Class<Doc>>, attribute: AnyAttribute, result: KeyFilter[]): void {
|
||||
if (attribute.isCustom !== true) {
|
||||
return
|
||||
}
|
||||
if (attribute.label === undefined || attribute.hidden) {
|
||||
return
|
||||
}
|
||||
const value = getValue(attribute.name, attribute.type)
|
||||
if (result.findIndex((p) => p.key === value) !== -1) {
|
||||
return
|
||||
}
|
||||
const filter = buildFilter(_class, value, attribute)
|
||||
if (filter !== undefined) {
|
||||
result.push(filter)
|
||||
}
|
||||
}
|
||||
function buildFilterFor (
|
||||
_class: Ref<Class<Doc>>,
|
||||
allAttributes: Map<string, AnyAttribute>,
|
||||
result: KeyFilter[]
|
||||
): void {
|
||||
for (const [, attribute] of allAttributes) {
|
||||
buildFilterForAttr(_class, attribute, result)
|
||||
}
|
||||
}
|
||||
|
||||
function getTypes (_class: Ref<Class<Doc>>): KeyFilter[] {
|
||||
const result = getFilters(_class)
|
||||
const allAttributes = hierarchy.getAllAttributes(_class)
|
||||
for (const [, attribute] of allAttributes) {
|
||||
if (attribute.isCustom !== true) continue
|
||||
if (attribute.label === undefined || attribute.hidden) continue
|
||||
const value = getValue(attribute.name, attribute.type)
|
||||
if (result.findIndex((p) => p.key === value) !== -1) continue
|
||||
const filter = buildFilter(value, attribute)
|
||||
if (filter !== undefined) {
|
||||
result.push(filter)
|
||||
buildFilterFor(_class, allAttributes, result)
|
||||
|
||||
const desc = hierarchy.getDescendants(_class)
|
||||
for (const d of desc) {
|
||||
const extra = hierarchy.getAllAttributes(d, _class)
|
||||
for (const [k, v] of extra) {
|
||||
if (!allAttributes.has(k)) {
|
||||
allAttributes.set(k, v)
|
||||
buildFilterForAttr(d, v, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -32,7 +32,7 @@
|
||||
|
||||
const client = getClient()
|
||||
const key = { key: filter.key.key }
|
||||
const promise = getPresenter(client, _class, key, key)
|
||||
const promise = getPresenter(client, filter.key._class, key, key)
|
||||
|
||||
let values = new Map<any, number>()
|
||||
let selectedValues: Set<any> = new Set<any>(filter.value.map((p) => p[0]))
|
||||
@ -53,8 +53,7 @@
|
||||
}
|
||||
: {}
|
||||
let prefix = ''
|
||||
|
||||
const attr = client.getHierarchy().getAttribute(_class, filter.key.key)
|
||||
const attr = client.getHierarchy().getAttribute(filter.key._class, filter.key.key)
|
||||
if (client.getHierarchy().isMixin(attr.attributeOf)) {
|
||||
prefix = attr.attributeOf + '.'
|
||||
console.log('prefix', prefix)
|
||||
@ -66,7 +65,11 @@
|
||||
const res = await objectsPromise
|
||||
|
||||
for (const object of res) {
|
||||
const realValue = getObjectValue(filter.key.key, object)
|
||||
let asDoc = object
|
||||
if (client.getHierarchy().isMixin(filter.key._class)) {
|
||||
asDoc = client.getHierarchy().as(object, filter.key._class)
|
||||
}
|
||||
const realValue = getObjectValue(filter.key.key, asDoc)
|
||||
const value = getValue(realValue)
|
||||
values.set(value, (values.get(value) ?? 0) + 1)
|
||||
realValues.set(value, (realValues.get(value) ?? new Set()).add(realValue))
|
||||
|
Loading…
Reference in New Issue
Block a user