TSK-736, TSK-759, TSK-733 (#2691)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2023-03-01 16:22:39 +07:00 committed by GitHub
parent 3998ad4d55
commit 496e0cff38
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 966 additions and 109 deletions

View File

@ -117,6 +117,7 @@
"libphonenumber-js": "^1.9.46",
"@hcengineering/setting": "^0.6.2",
"@hcengineering/minio": "^0.6.0",
"@hcengineering/openai": "^0.6.0"
"@hcengineering/openai": "^0.6.0",
"@hcengineering/tracker": "^0.6.1"
}
}

127
dev/tool/src/clean.ts Normal file
View File

@ -0,0 +1,127 @@
//
// Copyright © 2023 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 attachment from '@hcengineering/attachment'
import contact from '@hcengineering/contact'
import core, { BackupClient, Client as CoreClient, DOMAIN_TX, TxOperations, WorkspaceId } from '@hcengineering/core'
import { MinioService } from '@hcengineering/minio'
import { getWorkspaceDB } from '@hcengineering/mongo'
import recruit from '@hcengineering/recruit'
import { connect } from '@hcengineering/server-tool'
import tracker from '@hcengineering/tracker'
import { MongoClient } from 'mongodb'
export async function cleanWorkspace (
mongoUrl: string,
workspaceId: WorkspaceId,
minio: MinioService,
elasticUrl: string,
transactorUrl: string,
opt: { recruit: boolean, tracker: boolean, removeTx: boolean }
): Promise<void> {
const connection = (await connect(transactorUrl, workspaceId, undefined, {
mode: 'backup',
model: 'upgrade'
})) as unknown as CoreClient & BackupClient
try {
const ops = new TxOperations(connection, core.account.System)
const hierarchy = ops.getHierarchy()
const attachments = await ops.findAll(attachment.class.Attachment, {})
const contacts = await ops.findAll(contact.class.Contact, {})
const files = new Set(
attachments.map((it) => it.file).concat(contacts.map((it) => it.avatar).filter((it) => it) as string[])
)
const minioList = await minio.list(workspaceId)
const toClean: string[] = []
for (const mv of minioList) {
if (!files.has(mv.name)) {
toClean.push(mv.name)
}
}
await minio.remove(workspaceId, toClean)
// connection.loadChunk(DOMAIN_BLOB, idx = )
if (opt.recruit) {
const contacts = await ops.findAll(recruit.mixin.Candidate, {})
console.log('removing Talents', contacts.length)
const filter = contacts.filter((it) => !hierarchy.isDerived(it._class, contact.class.Employee))
while (filter.length > 0) {
const part = filter.splice(0, 100)
const op = ops.apply('')
for (const c of part) {
await op.remove(c)
}
const t = Date.now()
console.log('remove:', part.map((it) => it.name).join(', '))
await op.commit()
const t2 = Date.now()
console.log('remove time:', t2 - t, filter.length)
}
// const vacancies = await ops.findAll(recruit.class.Vacancy, {})
// console.log('removing vacancies', vacancies.length)
// for (const c of vacancies) {
// console.log('Remove', c.name)
// await ops.remove(c)
// }
}
if (opt.tracker) {
const issues = await ops.findAll(tracker.class.Issue, {})
console.log('removing Issues', issues.length)
while (issues.length > 0) {
const part = issues.splice(0, 5)
const op = ops.apply('')
for (const c of part) {
await op.remove(c)
}
const t = Date.now()
await op.commit()
const t2 = Date.now()
console.log('remove time:', t2 - t, issues.length)
}
}
const client = new MongoClient(mongoUrl)
try {
await client.connect()
const db = getWorkspaceDB(client, workspaceId)
if (opt.removeTx) {
const txes = await db.collection(DOMAIN_TX).find({}).toArray()
for (const tx of txes) {
if (tx._class === core.class.TxRemoveDoc) {
// We need to remove all update and create operations for document
await db.collection(DOMAIN_TX).deleteMany({ objectId: tx.objectId })
}
}
}
} finally {
await client.close()
}
} catch (err: any) {
console.trace(err)
} finally {
await connection.close()
}
}

View File

@ -51,6 +51,7 @@ import { MigrateOperation } from '@hcengineering/model'
import { openAIConfigDefaults } from '@hcengineering/openai'
import { rebuildElastic } from './elastic'
import { openAIConfig } from './openai'
import { cleanWorkspace } from './clean'
/**
* @public
@ -428,5 +429,25 @@ export function devTool (
console.log(decodeToken(token))
})
program
.command('clean-workspace <workspace>')
.description('set user role')
.option('--recruit', 'Clean recruit', false)
.option('--tracker', 'Clean tracker', false)
.option('--removedTx', 'Clean removed transactions', false)
.action(async (workspace: string, cmd: { recruit: boolean, tracker: boolean, removeTx: boolean }) => {
const { mongodbUri, minio } = prepareTools()
return await withDatabase(mongodbUri, async (db) => {
await cleanWorkspace(
mongodbUri,
getWorkspaceId(workspace, productId),
minio,
getElasticUrl(),
transactorUrl,
cmd
)
})
})
program.parse(process.argv)
}

View File

@ -226,6 +226,7 @@ export function createModel (builder: Builder): void {
const candidatesId = 'candidates'
const archiveId = 'archive'
const assignedId = 'assigned'
const organizationsId = 'organizations'
builder.createDoc(
workbench.class.Application,
@ -246,6 +247,13 @@ export function createModel (builder: Builder): void {
createItemLabel: recruit.string.VacancyCreateLabel,
position: 'vacancy'
},
{
id: organizationsId,
component: recruit.component.Organizations,
icon: contact.icon.Company,
label: recruit.string.Organizations,
position: 'vacancy'
},
{
id: candidatesId,
component: workbench.component.SpecialView,
@ -403,6 +411,32 @@ export function createModel (builder: Builder): void {
},
recruit.viewlet.TableVacancy
)
builder.createDoc(
view.class.Viewlet,
core.space.Model,
{
attachTo: recruit.mixin.VacancyList,
descriptor: view.viewlet.Table,
config: [
'',
{
key: '@vacancies',
label: recruit.string.Vacancies
},
{
key: '@applications',
label: recruit.string.Applications
},
'$lookup.channels',
{
key: '@applications.modifiedOn',
label: core.string.Modified
}
],
hiddenKeys: ['name', 'space', 'modifiedOn']
},
recruit.viewlet.TableVacancyList
)
builder.createDoc(
view.class.Viewlet,

View File

@ -77,6 +77,7 @@ export default mergeIds(recruitId, recruit, {
Applications: '' as AnyComponent,
SkillsView: '' as AnyComponent,
Vacancies: '' as AnyComponent,
Organizations: '' as AnyComponent,
CreateReview: '' as AnyComponent,
Reviews: '' as AnyComponent,
@ -104,6 +105,7 @@ export default mergeIds(recruitId, recruit, {
TableApplicant: '' as Ref<Viewlet>,
TableApplicantMatch: '' as Ref<Viewlet>,
CalendarReview: '' as Ref<Viewlet>,
TableReview: '' as Ref<Viewlet>
TableReview: '' as Ref<Viewlet>,
TableVacancyList: '' as Ref<Viewlet>
}
})

View File

@ -30,7 +30,7 @@
fname.length > maxLenght ? fname.substr(0, (maxLenght - 1) / 2) + '...' + fname.substr(-(maxLenght - 1) / 2) : fname
function iconLabel (name: string): string {
const parts = name.split('.')
const parts = `${name}`.split('.')
const ext = parts[parts.length - 1]
return ext.substring(0, 4).toUpperCase()
}

View File

@ -23,7 +23,8 @@
core.class.TypeNumber,
core.class.EnumOf,
core.class.Collection,
core.class.ArrOf
core.class.ArrOf,
core.class.RefTo
])
function addMapping (evt: MouseEvent, kind: MappingOperation): void {
@ -63,6 +64,12 @@
action: (_: any, evt: MouseEvent) => {
addMapping(evt, MappingOperation.DownloadAttachment)
}
},
{
label: getEmbeddedLabel('Add Field reference mapping'),
action: (_: any, evt: MouseEvent) => {
addMapping(evt, MappingOperation.FindReference)
}
}
] as Action[]
</script>

View File

@ -8,6 +8,7 @@
import CreateChannelMapping from './mappings/CreateChannelMapping.svelte'
import CreateTagMapping from './mappings/CreateTagMapping.svelte'
import DownloadAttachmentMapping from './mappings/DownloadAttachmentMapping.svelte'
import FindReferenceMapping from './mappings/FindReferenceMapping.svelte'
export let mapping: BitrixEntityMapping
export let fields: Fields = {}
@ -42,5 +43,7 @@
<CreateChannelMapping {mapping} {fields} {attribute} {field} bind:this={op} />
{:else if _kind === MappingOperation.DownloadAttachment}
<DownloadAttachmentMapping {mapping} {fields} {attribute} {field} bind:this={op} />
{:else if _kind === MappingOperation.FindReference}
<FindReferenceMapping {mapping} {fields} {attribute} {field} bind:this={op} />
{/if}
</Card>

View File

@ -7,6 +7,7 @@
import CreateChannelMappingPresenter from './mappings/CreateChannelMappingPresenter.svelte'
import CreateTagMappingPresenter from './mappings/CreateTagMappingPresenter.svelte'
import DownloadAttachmentPresenter from './mappings/DownloadAttachmentPresenter.svelte'
import FindReferencePresenter from './mappings/FindReferencePresenter.svelte'
export let mapping: BitrixEntityMapping
export let value: BitrixFieldMapping
@ -37,6 +38,8 @@
<CreateChannelMappingPresenter {mapping} {value} />
{:else if kind === MappingOperation.DownloadAttachment}
<DownloadAttachmentPresenter {mapping} {value} />
{:else if kind === MappingOperation.FindReference}
<FindReferencePresenter {mapping} {value} />
{/if}
<Button

View File

@ -58,7 +58,7 @@
})
.map((it) => ({
id: it[0],
label: `${it[1].formLabel ?? it[1].title}${it[0].startsWith('UF_') ? ' *' : ''} - ${it[0]}`
label: `${it[1].formLabel ?? it[1].title}${it[0].startsWith('UF_') ? ' *' : ''} - ${it[0]} - ${it[1].type}`
}))
</script>

View File

@ -0,0 +1,101 @@
<script lang="ts">
import {
BitrixEntityMapping,
BitrixEntityType,
BitrixFieldMapping,
Fields,
FindReferenceOperation,
MappingOperation,
mappingTypes
} from '@hcengineering/bitrix'
import core, { AnyAttribute } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { DropdownTextItem } from '@hcengineering/ui'
import DropdownLabels from '@hcengineering/ui/src/components/DropdownLabels.svelte'
import bitrix from '../../plugin'
export let mapping: BitrixEntityMapping
export let fields: Fields = {}
export let attribute: AnyAttribute
export let field: BitrixFieldMapping | undefined
let findField: string = (field?.operation as FindReferenceOperation)?.field ?? ''
let referenceType = (field?.operation as FindReferenceOperation)?.referenceType ?? BitrixEntityType.Company
let referenceClass = (field?.operation as FindReferenceOperation)?.referenceClass ?? core.class.Doc
const client = getClient()
export async function save (): Promise<void> {
if (field !== undefined) {
await client.update(field, {
operation: {
kind: MappingOperation.FindReference,
field: findField,
referenceType,
referenceClass
}
})
} else {
await client.addCollection(bitrix.class.FieldMapping, mapping.space, mapping._id, mapping._class, 'fields', {
ofClass: attribute.attributeOf,
attributeName: attribute.name,
operation: {
kind: MappingOperation.FindReference,
field: findField,
referenceType,
referenceClass
}
})
}
}
function getItems (fields: Fields): DropdownTextItem[] {
return Object.entries(fields)
.filter((it) => it[1].type === 'crm_company' || it[1].type === 'user' || it[1].type === 'crm')
.map((it) => ({
id: it[0],
label: `${it[1].formLabel ?? it[1].title}${it[0].startsWith('UF_') ? ' *' : ''} - ${it[0]} - ${it[1].type}`
}))
}
$: items = getItems(fields)
let classes: DropdownTextItem[] = []
client.findAll(core.class.Class, {}).then((res) => {
classes = res.map((it) => ({ id: it._id, label: it._id }))
})
</script>
<div class="flex-col flex-wrap">
<div class="pattern flex-row-center gap-2">
<DropdownLabels minW0={false} label={bitrix.string.FieldMapping} {items} bind:selected={findField} />
<DropdownLabels
minW0={false}
label={bitrix.string.FieldMapping}
items={[...mappingTypes, { id: '#', label: 'None' }]}
bind:selected={referenceType}
/>
<DropdownLabels minW0={false} label={bitrix.string.FieldMapping} items={classes} bind:selected={referenceClass} />
</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,36 @@
<script lang="ts">
import { BitrixEntityMapping, BitrixFieldMapping, FindReferenceOperation } from '@hcengineering/bitrix'
export let mapping: BitrixEntityMapping
export let value: BitrixFieldMapping
$: op = value.operation as FindReferenceOperation
</script>
<div class="flex flex-wrap">
<div class="pattern flex-row-center gap-2">
{#if mapping.bitrixFields}
{op.field ? mapping.bitrixFields[op.field]?.formLabel ?? mapping.bitrixFields[op.field]?.title : op.field ?? ''}
=> {op.referenceType} === {op.referenceClass}
{/if}
</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

@ -35,7 +35,8 @@
"@hcengineering/chunter": "^0.6.2",
"@hcengineering/attachment": "^0.6.1",
"fast-equals": "^2.0.3",
"qs": "~6.11.0"
"qs": "~6.11.0",
"@hcengineering/gmail": "^0.6.3"
},
"repository": "https://github.com/hcengineering/anticrm",
"publishConfig": {

View File

@ -532,6 +532,26 @@ async function doPerformSync (ops: SyncOptions & SyncOptionsExtra): Promise<Bitr
added++
const total = result.total
if (res.syncRequests.length > 0) {
for (const r of res.syncRequests) {
const m = ops.allMappings.find((it) => it.type === r.type)
if (m !== undefined) {
const [d] = await doPerformSync({
...ops,
mapping: m,
extraFilter: { ID: r.bitrixId },
monitor: (total) => {
console.log('total', total)
}
})
if (d !== undefined) {
r.update(d._id)
}
}
}
}
await syncDocument(ops.client, existingDoc, res, ops.loginInfo, ops.frontUrl, () => {
ops.monitor?.(total)
})
@ -660,7 +680,7 @@ async function downloadComments (
_id: generateId(),
_class: chunter.class.Comment,
message: processComment(it.COMMENT as string),
bitrixId: it.ID,
bitrixId: `${it.ID as string}`,
type: it.ENTITY_TYPE,
attachedTo: res.document._id,
attachedToClass: res.document._class,
@ -738,7 +758,7 @@ async function downloadComments (
_id: generateId(),
_class: chunter.class.Comment,
message,
bitrixId: comm.ID,
bitrixId: `${comm.ID}`,
type: 'email',
attachedTo: res.document._id,
attachedToClass: res.document._class,
@ -770,12 +790,16 @@ async function synchronizeUsers (
): Promise<void> {
let totalUsers = 1
let next = 0
const employees = new Map((await ops.client.findAll(contact.class.Employee, {})).map((it) => [it._id, it]))
while (userList.size < totalUsers) {
const users = await ops.bitrixClient.call('user.search', { start: next })
next = users.next
totalUsers = users.total
for (const u of users.result) {
let accountId = allEmployee.find((it) => it.email === u.EMAIL)?._id
const account = allEmployee.find((it) => it.email === u.EMAIL)
let accountId = account?._id
if (accountId === undefined) {
const employeeId = await ops.client.createDoc(contact.class.Employee, contact.space.Contacts, {
name: combineName(u.NAME, u.LAST_NAME),
@ -790,6 +814,26 @@ async function synchronizeUsers (
employee: employeeId,
role: AccountRole.User
})
await ops.client.createMixin<Doc, BitrixSyncDoc>(
employeeId,
contact.class.Employee,
contact.space.Contacts,
bitrix.mixin.BitrixSyncDoc,
{
type: 'employee',
bitrixId: `${u.ID as string}`,
syncTime: Date.now()
}
)
} else if (account != null) {
const emp = employees.get(account.employee)
if (emp !== undefined && !ops.client.getHierarchy().hasMixin(emp, bitrix.mixin.BitrixSyncDoc)) {
await ops.client.createMixin<Doc, BitrixSyncDoc>(emp._id, emp._class, emp.space, bitrix.mixin.BitrixSyncDoc, {
type: 'employee',
bitrixId: `${u.ID as string}`,
syncTime: Date.now()
})
}
}
userList.set(u.ID, accountId)
}

View File

@ -172,7 +172,8 @@ export enum MappingOperation {
CopyValue,
CreateTag, // Create tag
CreateChannel, // Create channel
DownloadAttachment
DownloadAttachment,
FindReference
}
/**
* @public
@ -235,6 +236,21 @@ export interface DownloadAttachmentOperation {
fields: { field: string }[]
}
/**
* @public
*/
export interface FindReferenceOperation {
kind: MappingOperation.FindReference
field: string
// If missing will trigger sync for this kind with extraFilter ID={referenceID}
// If defined will be used to synchronize
referenceType?: BitrixEntityType | null
referenceClass: Ref<Class<Doc>>
}
/**
* @public
*/
@ -242,7 +258,12 @@ export interface BitrixFieldMapping extends AttachedDoc {
ofClass: Ref<Class<Doc>> // Specify mixin if applicable
attributeName: string
operation: CopyValueOperation | CreateTagOperation | CreateChannelOperation | DownloadAttachmentOperation
operation:
| CopyValueOperation
| CreateTagOperation
| CreateChannelOperation
| DownloadAttachmentOperation
| FindReferenceOperation
}
/**

View File

@ -14,14 +14,16 @@ import core, {
WithLookup
} from '@hcengineering/core'
import tags, { TagCategory, TagElement, TagReference } from '@hcengineering/tags'
import {
import bitrix, {
BitrixEntityMapping,
BitrixEntityType,
BitrixFieldMapping,
BitrixSyncDoc,
CopyValueOperation,
CreateChannelOperation,
CreateTagOperation,
DownloadAttachmentOperation,
FindReferenceOperation,
MappingOperation
} from '.'
@ -48,16 +50,26 @@ export function collectFields (fieldMapping: BitrixFieldMapping[]): string[] {
return fields
}
/**
* @public
*/
export interface BitrixSyncRequest {
type: BitrixEntityType
bitrixId: string
update: (doc: Ref<Doc>) => void
}
/**
* @public
*/
export interface ConvertResult {
document: BitrixSyncDoc // Document we should achive
document: BitrixSyncDoc // Document we should sync
rawData: any
mixins: Record<Ref<Mixin<Doc>>, Data<Doc>> // Mixins of document we will achive
extraDocs: Doc[] // Extra documents we will achive, etc.
extraSync: (AttachedDoc & BitrixSyncDoc)[] // Extra documents we will achive, etc.
mixins: Record<Ref<Mixin<Doc>>, Data<Doc>> // Mixins of document we will sync
extraDocs: Doc[] // Extra documents we will sync, etc.
extraSync: (AttachedDoc & BitrixSyncDoc)[] // Extra documents we will sync, etc.
blobs: [Attachment & BitrixSyncDoc, () => Promise<File | undefined>, (file: File, attach: Attachment) => void][]
syncRequests: BitrixSyncRequest[]
}
/**
@ -100,6 +112,8 @@ export async function convert (
][] = []
const mixins: Record<Ref<Mixin<Doc>>, Data<Doc>> = {}
const syncRequests: BitrixSyncRequest[] = []
const extractValue = (field?: string, alternatives?: string[]): any | undefined => {
if (field !== undefined) {
let lval = rawDocument[field]
@ -125,9 +139,9 @@ export async function convert (
}
} else if (bfield.type === 'file') {
if (Array.isArray(lval) && bfield.isMultiple) {
return lval.map((it) => ({ id: it.id, file: it.downloadUrl }))
return lval.map((it) => ({ id: `${it.id as string}`, file: it.downloadUrl }))
} else if (lval != null) {
return [{ id: lval.id, file: lval.downloadUrl }]
return [{ id: `${lval.id as string}`, file: lval.downloadUrl }]
}
} else if (bfield.type === 'string' || bfield.type === 'url' || bfield.type === 'crm_company') {
if (bfield.isMultiple && Array.isArray(lval)) {
@ -164,6 +178,8 @@ export async function convert (
}
}
}
} else {
return lval
}
}
}
@ -311,10 +327,41 @@ export async function convert (
return undefined
}
const getFindValue = async (
attr: AnyAttribute,
operation: FindReferenceOperation
): Promise<{ ref?: Ref<Doc>, bitrixId: string } | undefined> => {
const lval = extractValue(operation.field)
if (lval != null) {
const bid = Array.isArray(lval) ? lval[0] : lval
const doc = await client.findOne(operation.referenceClass, {
[bitrix.mixin.BitrixSyncDoc + '.bitrixId']: bid
})
if (doc !== undefined) {
return { ref: doc._id, bitrixId: bid }
} else {
return { bitrixId: bid }
}
}
}
const setValue = (value: any, attr: AnyAttribute): void => {
if (value !== undefined) {
if (hierarchy.isMixin(attr.attributeOf)) {
mixins[attr.attributeOf] = {
...mixins[attr.attributeOf],
[attr.name]: value
}
} else {
;(document as any)[attr.name] = value
}
}
}
for (const f of fields) {
const attr = hierarchy.getAttribute(f.ofClass, f.attributeName)
if (attr === undefined) {
console.trace('Attribue not found', f)
console.trace('Attribute not found', f)
continue
}
let value: any
@ -333,7 +380,7 @@ export async function convert (
for (const blobRef of blobRefs) {
const attachDoc: Attachment & BitrixSyncDoc = {
_id: generateId(),
bitrixId: blobRef.id,
bitrixId: `${blobRef.id}`,
file: '', // Empty since not uploaded yet.
name: blobRef.id,
size: -1,
@ -379,20 +426,34 @@ export async function convert (
break
}
}
if (value !== undefined) {
if (hierarchy.isMixin(attr.attributeOf)) {
mixins[attr.attributeOf] = {
...mixins[attr.attributeOf],
[attr.name]: value
case MappingOperation.FindReference: {
const ret = await getFindValue(attr, f.operation)
if (ret?.ref !== undefined) {
value = ret.ref
} else if (ret !== undefined && f.operation.referenceType != null) {
syncRequests.push({
bitrixId: `${ret.bitrixId}`,
type: f.operation.referenceType,
update: (newRef: Ref<Doc>) => {
setValue(newRef, attr)
}
})
}
} else {
;(document as any)[attr.name] = value
break
}
}
setValue(value, attr)
}
return { document, mixins, extraSync: newExtraSyncDocs, extraDocs: newExtraDocs, blobs, rawData: rawDocument }
return {
document,
mixins,
extraSync: newExtraSyncDocs,
extraDocs: newExtraDocs,
blobs,
rawData: rawDocument,
syncRequests
}
}
/**

View File

@ -45,7 +45,7 @@
export let timeReports: Map<Ref<Employee>, EmployeeReports>
$: month = getStartDate(currentDate.getFullYear(), currentDate.getMonth()) // getMonth(currentDate, currentDate.getMonth())
$: wDays = weekDays(month.getUTCFullYear(), month.getUTCMonth())
$: wDays = weekDays(month.getFullYear(), month.getMonth())
function getDateRange (request: Request): string {
const ds = getRequestDates(request, types, month.getFullYear(), month.getMonth())

View File

@ -80,7 +80,7 @@
{@const startDate = getStartDate(currentDate.getFullYear(), value)}
<th class="fixed last-row" class:today={isToday(startDate)}>
<span class="flex-center">
{weekDays(startDate.getUTCFullYear(), startDate.getUTCMonth())}
{weekDays(startDate.getFullYear(), startDate.getMonth())}
</span>
</th>
{/each}

View File

@ -86,9 +86,17 @@ export function tzDateEqual (tzDate: TzDate, tzDate2: TzDate): boolean {
* @public
*/
export function weekDays (year: number, month: number): number {
return new Array(32 - new Date(year, month, 32).getDate())
.fill(1)
.filter((id, index) => ![0, 6].includes(new Date(year, month, index + 1).getDay())).length
const daysInMonth = new Date(year, month + 1, 0).getDate()
let days = 0
for (let i = 1; i <= daysInMonth; i++) {
const dayOfWeek = new Date(year, month, i).getDay()
if (dayOfWeek !== 0 && dayOfWeek !== 6) {
days++
}
}
return days
}
/**

View File

@ -104,7 +104,8 @@
"Match": "Match",
"PerformMatch": "Match",
"MoveApplication": "Move to another vacancy",
"SearchVacancy": "Search vacancy..."
"SearchVacancy": "Search vacancy...",
"Organizations": "Companies"
},
"status": {
"TalentRequired": "Please select talent",

View File

@ -106,7 +106,8 @@
"Match": "Совпадение",
"PerformMatch": "Сопоставить",
"MoveApplication": "Поменять Вакансию",
"SearchVacancy": "Найти вакансию..."
"SearchVacancy": "Найти вакансию...",
"Organizations": "Компании"
},
"status": {
"TalentRequired": "Пожалуйста выберите таланта",

View File

@ -26,7 +26,7 @@
export let disableClick = false
const client = getClient()
const shortLabel = client.getHierarchy().getClass(value._class).shortLabel
const shortLabel = value && client.getHierarchy().getClass(value._class).shortLabel
</script>
{#if value && shortLabel}

View File

@ -52,7 +52,7 @@
template = result[0]
if (!changed || descriptionBox?.isEmptyContent()) {
changed = false
fullDescription = template.description ?? fullDescription
fullDescription = template?.description ?? fullDescription
}
})

View File

@ -0,0 +1,285 @@
<!--
// 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 { Organization } from '@hcengineering/contact'
import core, { Doc, DocumentQuery, Ref } from '@hcengineering/core'
import { createQuery, getClient } from '@hcengineering/presentation'
import { Applicant, Vacancy } from '@hcengineering/recruit'
import {
Button,
deviceOptionsStore as deviceInfo,
Icon,
IconAdd,
Label,
Loading,
SearchEdit,
showPopup
} from '@hcengineering/ui'
import view, { BuildModelKey, Viewlet, ViewletPreference } from '@hcengineering/view'
import {
FilterBar,
FilterButton,
getViewOptions,
setActiveViewletId,
TableBrowser,
ViewletSettingButton
} from '@hcengineering/view-resources'
import recruit from '../plugin'
import CreateVacancy from './CreateVacancy.svelte'
import VacancyListApplicationsPopup from './organizations/VacancyListApplicationsPopup.svelte'
import VacancyListCountPresenter from './organizations/VacancyListCountPresenter.svelte'
import VacancyPopup from './organizations/VacancyPopup.svelte'
let search: string = ''
let searchQuery: DocumentQuery<Doc> = {}
let resultQuery: DocumentQuery<Doc> = {}
$: searchQuery = search === '' ? {} : { $search: search }
type CountInfo = {
count: number
modifiedOn: number
vacancies: Ref<Vacancy>[]
}
let applications: Map<Ref<Organization>, CountInfo> = new Map<Ref<Organization>, CountInfo>()
let vacancies: Map<Ref<Organization>, CountInfo> = new Map<Ref<Organization>, CountInfo>()
let vmap: Map<Ref<Vacancy>, Vacancy> | undefined
const vacancyQuery = createQuery()
let applicationsList: Applicant[] = []
let vacanciesList: Vacancy[] = []
$: vacancyQuery.query(recruit.class.Vacancy, { company: { $exists: true }, archived: false }, (res) => {
vacanciesList = res
vmap = new Map(res.map((it) => [it._id, it]))
})
$: vacancies = vacanciesList.reduce<Map<Ref<Organization>, CountInfo>>((map, it) => {
const ifo = map.get(it.company as Ref<Organization>) ?? {
count: 0,
modifiedOn: 0,
vacancies: []
}
map.set(it.company as Ref<Organization>, {
count: ifo.count + 1,
modifiedOn: Math.max(ifo.modifiedOn, it.modifiedOn),
vacancies: [...ifo.vacancies, it._id]
})
return map
}, new Map())
const applicantQuery = createQuery()
$: applicantQuery.query(
recruit.class.Applicant,
{
doneState: null
},
(res) => {
applicationsList = res
},
{
projection: {
_id: 1,
modifiedOn: 1,
space: 1
}
}
)
$: {
const _applications = new Map<Ref<Organization>, CountInfo>()
for (const d of applicationsList) {
const vacancy = vmap?.get(d.space)
if (vacancy?.company !== undefined) {
const v = _applications.get(vacancy.company) ?? {
count: 0,
modifiedOn: 0,
vacancies: []
}
if (!v.vacancies.includes(vacancy._id)) {
v.vacancies.push(vacancy._id)
}
v.count++
v.modifiedOn = Math.max(v.modifiedOn, d.modifiedOn)
_applications.set(vacancy.company, v)
}
}
applications = _applications
}
function showCreateDialog () {
showPopup(CreateVacancy, { space: recruit.space.CandidatesPublic }, 'top')
}
const applicationSorting = (a: Doc, b: Doc) =>
(applications?.get(b._id as Ref<Organization>)?.count ?? 0) -
(applications?.get(a._id as Ref<Organization>)?.count ?? 0) ?? 0
const vacancySorting = (a: Doc, b: Doc) =>
(vacancies?.get(b._id as Ref<Organization>)?.count ?? 0) -
(vacancies?.get(a._id as Ref<Organization>)?.count ?? 0) ?? 0
const modifiedSorting = (a: Doc, b: Doc) =>
(applications?.get(b._id as Ref<Organization>)?.modifiedOn ?? b.modifiedOn) -
(applications?.get(a._id as Ref<Organization>)?.modifiedOn ?? a.modifiedOn)
$: replacedKeys = new Map<string, BuildModelKey>([
[
'@vacancies',
{
key: '',
presenter: VacancyListCountPresenter,
label: recruit.string.Vacancies,
props: {
values: vacancies,
label: recruit.string.Vacancies,
icon: recruit.icon.Vacancy,
component: VacancyPopup
},
sortingKey: '@vacancies',
sortingFunction: vacancySorting
}
],
[
'@applications',
{
key: '',
presenter: VacancyListCountPresenter,
label: recruit.string.Applications,
props: {
values: applications,
label: recruit.string.Applications,
component: VacancyListApplicationsPopup,
icon: recruit.icon.Application,
resultQuery: {
doneState: null
}
},
sortingKey: '@applications',
sortingFunction: applicationSorting
}
],
[
'@applications.modifiedOn',
{
key: '',
presenter: recruit.component.VacancyModifiedPresenter,
label: core.string.Modified,
props: { applications },
sortingKey: 'modifiedOn',
sortingFunction: modifiedSorting
}
]
])
const client = getClient()
let descr: Viewlet | undefined
let loading = true
const preferenceQuery = createQuery()
let preference: ViewletPreference | undefined
client
.findOne<Viewlet>(view.class.Viewlet, {
attachTo: recruit.mixin.VacancyList,
descriptor: view.viewlet.Table
})
.then((res) => {
descr = res
if (res !== undefined) {
setActiveViewletId(res._id)
preferenceQuery.query(
view.class.ViewletPreference,
{
attachedTo: res._id
},
(res) => {
preference = res[0]
loading = false
},
{ limit: 1 }
)
}
})
function createConfig (descr: Viewlet, preference: ViewletPreference | undefined): (string | BuildModelKey)[] {
const base = preference?.config ?? descr.config
const result: (string | BuildModelKey)[] = []
for (const key of base) {
if (typeof key === 'string') {
result.push(replacedKeys.get(key) ?? key)
} else {
result.push(replacedKeys.get(key.key) ?? key)
}
}
return result
}
$: twoRows = $deviceInfo.twoRows
$: viewOptions = getViewOptions(descr)
</script>
<div class="ac-header withSettings" class:full={!twoRows} class:mini={twoRows}>
<div class:ac-header-full={!twoRows} class:flex-between={twoRows}>
<div class="ac-header__wrap-title mr-3">
<div class="ac-header__icon"><Icon icon={recruit.icon.Vacancy} size={'small'} /></div>
<span class="ac-header__title"><Label label={recruit.string.Organizations} /></span>
<div class="ml-4"><FilterButton _class={recruit.class.Vacancy} /></div>
</div>
<SearchEdit
bind:value={search}
on:change={(e) => {
search = e.detail
}}
/>
</div>
<div class="ac-header-full" class:secondRow={twoRows}>
<Button
icon={IconAdd}
label={recruit.string.VacancyCreateLabel}
size={'small'}
kind={'primary'}
on:click={showCreateDialog}
/>
<ViewletSettingButton bind:viewOptions viewlet={descr} />
</div>
</div>
<FilterBar
_class={recruit.mixin.VacancyList}
{viewOptions}
query={searchQuery}
on:change={(e) => (resultQuery = e.detail)}
/>
{#if descr}
{#if loading}
<Loading />
{:else}
<TableBrowser
_class={recruit.mixin.VacancyList}
config={createConfig(descr, preference)}
options={descr.options}
query={{
...resultQuery,
_id: { $in: Array.from(vacancies.keys()) }
}}
showNotification
/>
{/if}
{/if}

View File

@ -0,0 +1,52 @@
<!--
// 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 core, { Doc, DocumentQuery, FindOptions, Ref, Space } from '@hcengineering/core'
import recruit, { Applicant } from '@hcengineering/recruit'
import task from '@hcengineering/task'
import { Table } from '@hcengineering/view-resources'
export let value: Ref<Space>[]
export let resultQuery: DocumentQuery<Doc>
const options: FindOptions<Applicant> = {
lookup: {
state: task.class.State,
space: core.class.Space,
doneState: task.class.DoneState,
attachedTo: recruit.mixin.Candidate
},
limit: 10
}
</script>
<div class="popup-table">
<Table
_class={recruit.class.Applicant}
config={['', 'attachedTo', 'state', 'doneState', 'modifiedOn']}
{options}
query={{ ...(resultQuery ?? {}), space: { $in: value } }}
loadingProps={{ length: 0 }}
/>
</div>
<style lang="scss">
.popup-table {
overflow: auto;
// width: 70rem;
// max-width: 70rem !important;
max-height: 30rem;
}
</style>

View File

@ -0,0 +1,50 @@
<!--
// 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 { Organization } from '@hcengineering/contact'
import { Doc, DocumentQuery, Ref } from '@hcengineering/core'
import { Asset, IntlString } from '@hcengineering/platform'
import { Vacancy, VacancyList } from '@hcengineering/recruit'
import { AnySvelteComponent, Icon, tooltip } from '@hcengineering/ui'
export let value: VacancyList
export let values:
| Map<Ref<Organization>, { count: number; modifiedOn: number; vacancies: Ref<Vacancy>[] }>
| undefined
export let resultQuery: DocumentQuery<Doc>
export let label: IntlString
export let component: AnySvelteComponent
export let icon: Asset
$: countValue = values?.get(value._id)
$: count = countValue?.count ?? 0
</script>
{#if countValue && count > 0}
<div
class="sm-tool-icon"
use:tooltip={{
component,
props: { value: countValue.vacancies, resultQuery }
}}
>
<div class="icon">
<Icon {icon} size={'small'} />
</div>
&nbsp;
{values?.get(value._id)?.count ?? 0}
</div>
{/if}

View File

@ -0,0 +1,45 @@
<!--
// 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 { Doc, DocumentQuery, FindOptions, Ref } from '@hcengineering/core'
import recruit, { Vacancy } from '@hcengineering/recruit'
import { Table } from '@hcengineering/view-resources'
export let value: Ref<Vacancy>[]
export let resultQuery: DocumentQuery<Doc>
const options: FindOptions<Vacancy> = {
limit: 10
}
</script>
<div class="popup-table">
<Table
_class={recruit.class.Vacancy}
config={['', 'modifiedOn']}
{options}
query={{ ...(resultQuery ?? {}), _id: { $in: value } }}
loadingProps={{ length: 0 }}
/>
</div>
<style lang="scss">
.popup-table {
overflow: auto;
// width: 70rem;
// max-width: 70rem !important;
max-height: 30rem;
}
</style>

View File

@ -27,7 +27,7 @@
const client = getClient()
let shortLabel = ''
const label = client.getHierarchy().getClass(value._class).shortLabel
const label = client.getHierarchy().getClass(value?._class)?.shortLabel
if (label !== undefined) {
translate(label, {}).then((r) => {

View File

@ -63,6 +63,7 @@ import { objectIdProvider, objectLinkProvider, getApplicationTitle } from './uti
import VacancyList from './components/VacancyList.svelte'
import VacancyTemplateEditor from './components/VacancyTemplateEditor.svelte'
import MatchVacancy from './components/MatchVacancy.svelte'
import Organizations from './components/Organizations.svelte'
import { MoveApplicant } from './actionImpl'
@ -287,6 +288,7 @@ export default async (): Promise<Resources> => ({
VacancyPresenter,
SkillsView,
Vacancies,
Organizations,
VacancyItemPresenter,
VacancyCountPresenter,
VacancyModifiedPresenter,

View File

@ -78,6 +78,7 @@ export default mergeIds(recruitId, recruit, {
Location: '' as IntlString,
Title: '' as IntlString,
Vacancies: '' as IntlString,
Organizations: '' as IntlString,
CopyLink: '' as IntlString,
CopyId: '' as IntlString,

View File

@ -142,13 +142,22 @@ class TServerStorage implements ServerStorage {
return adapter
}
private async routeTx (ctx: MeasureContext, ...txes: Tx[]): Promise<TxResult> {
private async routeTx (ctx: MeasureContext, removedDocs: Map<Ref<Doc>, Doc>, ...txes: Tx[]): Promise<TxResult> {
let part: TxCUD<Doc>[] = []
let lastDomain: Domain | undefined
const result: TxResult[] = []
const processPart = async (): Promise<void> => {
if (part.length > 0) {
// Find all deleted documents
const adapter = this.getAdapter(lastDomain as Domain)
const toDelete = part.filter((it) => it._class === core.class.TxRemoveDoc).map((it) => it.objectId)
const toDeleteDocs = await adapter.load(lastDomain as Domain, toDelete)
for (const ddoc of toDeleteDocs) {
removedDocs.set(ddoc._id, ddoc)
}
const r = await adapter.tx(...part)
if (Array.isArray(r)) {
result.push(...r)
@ -345,62 +354,6 @@ class TServerStorage implements ServerStorage {
)
}
private async buildRemovedDoc (ctx: MeasureContext, rawTxes: Tx[], findAll: ServerStorage['findAll']): Promise<Doc[]> {
const removeObjectIds: Ref<Doc>[] = []
const removeAttachObjectIds: Ref<AttachedDoc>[] = []
const removeTxes = rawTxes
.map((it) => TxProcessor.extractTx(it) as TxRemoveDoc<Doc>)
.filter((it) => this.hierarchy.isDerived(it._class, core.class.TxRemoveDoc))
for (const rtx of removeTxes) {
const isAttached = this.hierarchy.isDerived(rtx.objectClass, core.class.AttachedDoc)
if (isAttached) {
removeAttachObjectIds.push(rtx.objectId as Ref<AttachedDoc>)
} else {
removeObjectIds.push(rtx.objectId)
}
}
const txes =
removeObjectIds.length > 0
? await findAll<TxCUD<Doc>>(
ctx,
core.class.TxCUD,
{
objectId: { $in: removeObjectIds }
},
{ sort: { modifiedOn: 1 } }
)
: []
const result: Doc[] = []
const txesAttach =
removeAttachObjectIds.length > 0
? await findAll<TxCollectionCUD<Doc, AttachedDoc>>(
ctx,
core.class.TxCollectionCUD,
{ 'tx.objectId': { $in: removeAttachObjectIds } },
{ sort: { modifiedOn: 1 } }
)
: []
for (const rtx of removeTxes) {
const isAttached = this.hierarchy.isDerived(rtx.objectClass, core.class.AttachedDoc)
const objTxex = isAttached
? txesAttach.filter((tx) => tx.tx.objectId === rtx.objectId)
: txes.filter((it) => it.objectId === rtx.objectId)
const doc = TxProcessor.buildDoc2Doc(objTxex)
if (doc !== undefined) {
result.push(doc)
}
}
return result
}
private async processRemove (
ctx: MeasureContext,
txes: Tx[],
@ -409,11 +362,6 @@ class TServerStorage implements ServerStorage {
): Promise<Tx[]> {
const result: Tx[] = []
const objects = await this.buildRemovedDoc(ctx, txes, findAll)
for (const obj of objects) {
removedMap.set(obj._id, obj)
}
for (const tx of txes) {
const actualTx = TxProcessor.extractTx(tx)
if (!this.hierarchy.isDerived(actualTx._class, core.class.TxRemoveDoc)) {
@ -532,7 +480,7 @@ class TServerStorage implements ServerStorage {
return result
}
private async proccessDerived (
private async processDerived (
ctx: MeasureContext,
txes: Tx[],
triggerFx: Effects,
@ -594,11 +542,11 @@ class TServerStorage implements ServerStorage {
): Promise<Tx[]> {
derived.sort((a, b) => a.modifiedOn - b.modifiedOn)
await ctx.with('derived-route-tx', {}, (ctx) => this.routeTx(ctx, ...derived))
await ctx.with('derived-route-tx', {}, (ctx) => this.routeTx(ctx, removedMap, ...derived))
const nestedTxes: Tx[] = []
if (derived.length > 0) {
nestedTxes.push(...(await this.proccessDerived(ctx, derived, triggerFx, findAll, removedMap)))
nestedTxes.push(...(await this.processDerived(ctx, derived, triggerFx, findAll, removedMap)))
}
const res = [...derived, ...nestedTxes]
@ -650,14 +598,15 @@ class TServerStorage implements ServerStorage {
)
await ctx.with('domain-tx', {}, async () => await this.getAdapter(DOMAIN_TX).tx(...txToStore))
await ctx.with('apply', {}, (ctx) => this.routeTx(ctx, ...tx))
const removedMap = new Map<Ref<Doc>, Doc>()
await ctx.with('apply', {}, (ctx) => this.routeTx(ctx, removedMap, ...tx))
// send transactions
if (broadcast) {
this.options?.broadcast?.(tx)
}
// invoke triggers and store derived objects
const derived = await this.proccessDerived(ctx, tx, triggerFx, cacheFind, new Map<Ref<Doc>, Doc>())
const derived = await this.processDerived(ctx, tx, triggerFx, cacheFind, removedMap)
// index object
for (const _tx of tx) {
@ -680,6 +629,7 @@ class TServerStorage implements ServerStorage {
const _class = txClass(tx)
const cacheFind = createCacheFindAll(this)
const objClass = txObjectClass(tx)
const removedDocs = new Map<Ref<Doc>, Doc>()
return await ctx.with('tx', { _class, objClass }, async (ctx) => {
if (tx.space !== core.space.DerivedTx && !this.hierarchy.isDerived(tx._class, core.class.TxApplyIf)) {
await ctx.with('domain-tx', { _class, objClass }, async () => await this.getAdapter(DOMAIN_TX).tx(tx))
@ -711,13 +661,13 @@ class TServerStorage implements ServerStorage {
const atx = await this.getAdapter(DOMAIN_TX)
await atx.tx(...applyIf.txes)
})
derived = await this.processDerivedTxes(applyIf.txes, ctx, triggerFx, cacheFind, new Map<Ref<Doc>, Doc>())
derived = await this.processDerivedTxes(applyIf.txes, ctx, triggerFx, cacheFind, removedDocs)
}
} else {
// store object
result = await ctx.with('route-tx', { _class, objClass }, (ctx) => this.routeTx(ctx, tx))
result = await ctx.with('route-tx', { _class, objClass }, (ctx) => this.routeTx(ctx, removedDocs, tx))
// invoke triggers and store derived objects
derived = await this.proccessDerived(ctx, [tx], triggerFx, cacheFind, new Map<Ref<Doc>, Doc>())
derived = await this.processDerived(ctx, [tx], triggerFx, cacheFind, removedDocs)
}
// index object