Vacancy improvements (#2268)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2022-09-14 11:53:48 +07:00 committed by GitHub
parent e83718f45b
commit 0f3ce39cae
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
59 changed files with 1032 additions and 316 deletions

View File

@ -364,6 +364,10 @@ export function createModel (builder: Builder): void {
presenter: contact.component.PersonPresenter
})
builder.mixin(core.class.Account, core.class.Class, view.mixin.ArrayEditor, {
inlineEditor: contact.component.AccountArrayEditor
})
builder.mixin(core.class.Account, core.class.Class, view.mixin.AttributePresenter, {
presenter: contact.component.EmployeeAccountPresenter
})
@ -388,9 +392,13 @@ export function createModel (builder: Builder): void {
filters: ['_class', 'city', 'modifiedOn']
})
builder.mixin(contact.class.Contact, core.class.Class, setting.mixin.Editable, {})
builder.mixin(contact.class.Contact, core.class.Class, setting.mixin.Editable, {
value: true
})
builder.mixin(contact.class.Member, core.class.Class, setting.mixin.Editable, {})
builder.mixin(contact.class.Member, core.class.Class, setting.mixin.Editable, {
value: true
})
builder.createDoc(
presentation.class.ObjectSearchCategory,

View File

@ -41,7 +41,8 @@ export default mergeIds(contactId, contact, {
EditMember: '' as AnyComponent,
EmployeeArrayEditor: '' as AnyComponent,
EmployeeEditor: '' as AnyComponent,
CreateEmployee: '' as AnyComponent
CreateEmployee: '' as AnyComponent,
AccountArrayEditor: '' as AnyComponent
},
string: {
Persons: '' as IntlString,

View File

@ -21,6 +21,7 @@ export default mergeIds(coreId, core, {
Private: '' as IntlString,
Archived: '' as IntlString,
ClassLabel: '' as IntlString,
ClassPropertyLabel: '' as IntlString
ClassPropertyLabel: '' as IntlString,
Members: '' as IntlString
}
})

View File

@ -14,7 +14,7 @@
//
import { Account, AccountRole, Arr, Domain, DOMAIN_MODEL, IndexKind, Ref, Space } from '@anticrm/core'
import { Index, Model, Prop, TypeBoolean, TypeString, UX } from '@anticrm/model'
import { ArrOf, Index, Model, Prop, TypeBoolean, TypeRef, TypeString, UX } from '@anticrm/model'
import core from './component'
import { TDoc } from './core'
@ -39,6 +39,7 @@ export class TSpace extends TDoc implements Space {
@Prop(TypeBoolean(), core.string.Archived)
archived!: boolean
@Prop(ArrOf(TypeRef(core.class.Account)), core.string.Members)
members!: Arr<Ref<Account>>
}

View File

@ -95,7 +95,9 @@ export function createModel (builder: Builder): void {
editor: inventory.component.EditProduct
})
builder.mixin(inventory.class.Product, core.class.Class, setting.mixin.Editable, {})
builder.mixin(inventory.class.Product, core.class.Class, setting.mixin.Editable, {
value: true
})
builder.createDoc(
view.class.Viewlet,

View File

@ -93,7 +93,9 @@ export function createModel (builder: Builder): void {
editor: lead.component.EditFunnel
})
builder.mixin(lead.class.Lead, core.class.Class, setting.mixin.Editable, {})
builder.mixin(lead.class.Lead, core.class.Class, setting.mixin.Editable, {
value: true
})
builder.createDoc(
workbench.class.Application,

View File

@ -16,6 +16,7 @@
import type { Employee, Organization } from '@anticrm/contact'
import { Doc, FindOptions, IndexKind, Lookup, Ref, Timestamp } from '@anticrm/core'
import {
ArrOf,
Builder,
Collection,
Index,
@ -32,17 +33,17 @@ import {
import attachment from '@anticrm/model-attachment'
import calendar from '@anticrm/model-calendar'
import chunter from '@anticrm/model-chunter'
import contact, { TPerson } from '@anticrm/model-contact'
import contact, { TOrganization, TPerson } from '@anticrm/model-contact'
import core, { TSpace } from '@anticrm/model-core'
import presentation from '@anticrm/model-presentation'
import tags from '@anticrm/model-tags'
import task, { TSpaceWithStates, TTask, actionTemplates } from '@anticrm/model-task'
import view, { createAction, actionTemplates as viewTemplates } from '@anticrm/model-view'
import task, { actionTemplates, TSpaceWithStates, TTask } from '@anticrm/model-task'
import view, { actionTemplates as viewTemplates, createAction } from '@anticrm/model-view'
import workbench, { Application, createNavigateAction } from '@anticrm/model-workbench'
import { IntlString } from '@anticrm/platform'
import { Applicant, Candidate, Candidates, recruitId, Vacancy } from '@anticrm/recruit'
import { KeyBinding } from '@anticrm/view'
import { Applicant, Candidate, Candidates, recruitId, Vacancy, VacancyList } from '@anticrm/recruit'
import setting from '@anticrm/setting'
import { KeyBinding } from '@anticrm/view'
import recruit from './plugin'
import { createReviewModel, reviewTableConfig, reviewTableOptions } from './review'
import { TOpinion, TReview } from './review-model'
@ -69,6 +70,9 @@ export class TVacancy extends TSpaceWithStates implements Vacancy {
@Prop(Collection(chunter.class.Comment), chunter.string.Comments)
comments?: number
@Prop(Collection(chunter.class.Backlink), chunter.string.Comments)
relations!: number
}
@Model(recruit.class.Candidates, core.class.Space)
@ -102,6 +106,13 @@ export class TCandidate extends TPerson implements Candidate {
reviews?: number
}
@Mixin(recruit.mixin.VacancyList, contact.class.Organization)
@UX(recruit.string.VacancyList, recruit.icon.RecruitApplication, undefined, 'name')
export class TVacancyList extends TOrganization implements VacancyList {
@Prop(ArrOf(TypeRef(recruit.class.Vacancy)), recruit.string.Vacancies)
vacancies!: number
}
@Model(recruit.class.Applicant, task.class.Task)
@UX(recruit.string.Application, recruit.icon.Application, recruit.string.ApplicationShort, 'number')
export class TApplicant extends TTask implements Applicant {
@ -126,7 +137,7 @@ export class TApplicant extends TTask implements Applicant {
}
export function createModel (builder: Builder): void {
builder.createModel(TVacancy, TCandidates, TCandidate, TApplicant, TReview, TOpinion)
builder.createModel(TVacancy, TCandidates, TCandidate, TApplicant, TReview, TOpinion, TVacancyList)
builder.mixin(recruit.class.Vacancy, core.class.Class, workbench.mixin.SpaceView, {
view: {
@ -140,13 +151,24 @@ export function createModel (builder: Builder): void {
editor: recruit.component.Applications
})
builder.mixin(recruit.class.Vacancy, core.class.Class, view.mixin.ArrayEditor, {
editor: recruit.component.VacancyList
})
builder.mixin(recruit.mixin.Candidate, core.class.Mixin, view.mixin.ObjectFactory, {
component: recruit.component.CreateCandidate
})
builder.mixin(recruit.class.Applicant, core.class.Class, setting.mixin.Editable, {})
builder.mixin(recruit.class.Applicant, core.class.Class, setting.mixin.Editable, {
value: true
})
builder.mixin(recruit.class.Vacancy, core.class.Class, setting.mixin.Editable, {})
builder.mixin(recruit.class.Vacancy, core.class.Class, setting.mixin.Editable, {
value: true
})
builder.mixin(recruit.mixin.VacancyList, core.class.Class, setting.mixin.Editable, {
value: false
})
const vacanciesId = 'vacancies'
const talentsId = 'talents'

View File

@ -13,11 +13,15 @@
// limitations under the License.
//
import { Organization } from '@anticrm/contact'
import core, { Doc, Ref, Space, TxOperations } from '@anticrm/core'
import { createOrUpdate, MigrateOperation, MigrationClient, MigrationUpgradeClient } from '@anticrm/model'
import { DOMAIN_CALENDAR } from '@anticrm/model-calendar'
import contact, { DOMAIN_CONTACT } from '@anticrm/model-contact'
import { DOMAIN_SPACE } from '@anticrm/model-core'
import tags, { TagCategory } from '@anticrm/model-tags'
import { createKanbanTemplate, createSequence } from '@anticrm/model-task'
import { Vacancy } from '@anticrm/recruit'
import { getCategories } from '@anticrm/skillset'
import { KanbanTemplate } from '@anticrm/task'
import recruit from './plugin'
@ -34,6 +38,33 @@ export const recruitOperation: MigrateOperation = {
space: recruit.space.Reviews
}
)
const vacancies = await client.find<Vacancy>(
DOMAIN_SPACE,
{ _class: recruit.class.Vacancy, company: { $exists: true } },
{ projection: { _id: 1, company: 1 } }
)
const orgIds = Array.from(vacancies.map((it) => it.company))
.filter((it) => it != null)
.filter((it, idx, arr) => arr.indexOf(it) === idx) as Ref<Organization>[]
const orgs = await client.find<Organization>(DOMAIN_CONTACT, {
_class: contact.class.Organization,
_id: { $in: orgIds }
})
for (const o of orgs) {
if ((o as any)[recruit.mixin.VacancyList] === undefined) {
await client.update(
DOMAIN_CONTACT,
{ _id: o._id },
{
[recruit.mixin.VacancyList]: {
vacancies: vacancies.filter((it) => it.company === o._id).reduce((a) => a + 1, 0)
}
}
)
}
}
},
async upgrade (client: MigrationUpgradeClient): Promise<void> {
const tx = new TxOperations(client, core.account.System)

View File

@ -56,7 +56,8 @@ export default mergeIds(recruitId, recruit, {
GotoSkills: '' as IntlString,
GotoAssigned: '' as IntlString,
GotoApplicants: '' as IntlString,
GotoRecruitApplication: '' as IntlString
GotoRecruitApplication: '' as IntlString,
VacancyList: '' as IntlString
},
validator: {
ApplicantValidator: '' as Resource<<T extends Doc>(doc: T, client: Client) => Promise<Status>>
@ -81,7 +82,8 @@ export default mergeIds(recruitId, recruit, {
Opinions: '' as AnyComponent,
OpinionPresenter: '' as AnyComponent,
NewCandidateHeader: '' as AnyComponent,
ApplicantFilter: '' as AnyComponent
ApplicantFilter: '' as AnyComponent,
VacancyList: '' as AnyComponent
},
template: {
DefaultVacancy: '' as Ref<KanbanTemplate>,

View File

@ -19,6 +19,7 @@ import core from '@anticrm/core'
import recruit from '@anticrm/recruit'
import view from '@anticrm/view'
import serverRecruit from '@anticrm/server-recruit'
import serverCore from '@anticrm/server-core'
export function createModel (builder: Builder): void {
builder.mixin(recruit.class.Applicant, core.class.Class, view.mixin.HTMLPresenter, {
@ -36,4 +37,8 @@ export function createModel (builder: Builder): void {
builder.mixin(recruit.class.Vacancy, core.class.Class, view.mixin.TextPresenter, {
presenter: serverRecruit.function.VacancyTextPresenter
})
builder.createDoc(serverCore.class.Trigger, core.space.Model, {
trigger: serverRecruit.trigger.OnVacancyUpdate
})
}

View File

@ -71,7 +71,9 @@ export class TIntegrationType extends TDoc implements IntegrationType {
}
@Mixin(setting.mixin.Editable, core.class.Class)
export class TEditable extends TClass implements Editable {}
export class TEditable extends TClass implements Editable {
value!: boolean
}
@Mixin(setting.mixin.UserMixin, core.class.Class)
export class TUserMixin extends TClass implements UserMixin {}
@ -332,6 +334,8 @@ export function createModel (builder: Builder): void {
},
setting.action.DeleteMixin
)
// builder.mixin(core.class.Space, core.class.Class, setting.mixin.Editable, {})
}
export { settingOperation } from './migration'

View File

@ -567,7 +567,9 @@ export function createModel (builder: Builder): void {
presenter: tracker.component.SprintTitlePresenter
})
builder.mixin(tracker.class.Issue, core.class.Class, setting.mixin.Editable, {})
builder.mixin(tracker.class.Issue, core.class.Class, setting.mixin.Editable, {
value: true
})
builder.mixin(tracker.class.TypeProjectStatus, core.class.Class, view.mixin.AttributeEditor, {
inlineEditor: tracker.component.ProjectStatusEditor

View File

@ -24,6 +24,7 @@
"Collection": "Collection",
"Array": "Array",
"Bag": "Bag",
"Enum": "Enum"
"Enum": "Enum",
"Members": "Members"
}
}

View File

@ -24,6 +24,7 @@
"Collection": "Коллекция",
"Array": "Массив",
"Bag": "Bag",
"Enum": "Справочник"
"Enum": "Справочник",
"Members": "Участники"
}
}

View File

@ -14,7 +14,7 @@
// limitations under the License.
//
import type { IntlString, Asset } from '@anticrm/platform'
import type { Asset, IntlString } from '@anticrm/platform'
/**
* @public

View File

@ -18,7 +18,7 @@
"Spaces": "Пространства",
"Unassigned": "Не назначен",
"CreateMore": "Создать еще",
"NumberMembers": "{count, plural, =0 {нет участников} =1 {1 участник} other {# участника}}",
"NumberMembers": "{count, plural, =0 {нет участников} =1 {1 участник} =2 {2 участника} =3 {3 участника} =4 {4 участника} other {# участников}}",
"NumberSpaces": "{count, plural, =0 {В} =1 {В 1 месте} other {В # местах}}",
"InThis": "В этом {space}",
"NoMatchesInThis": "В этом {space} совпадения не обнаружены",

View File

@ -38,6 +38,7 @@
export let width: string | undefined = undefined
export let focusIndex = -1
export let showTooltip: LabelAndProps | undefined = undefined
export let showNavigate = true
</script>
<UserBox
@ -56,5 +57,6 @@
{width}
{focusIndex}
{showTooltip}
{showNavigate}
on:change
/>

View File

@ -16,17 +16,23 @@
<script lang="ts">
import contact, { Contact, formatName } from '@anticrm/contact'
import type { Class, DocumentQuery, FindOptions, Ref } from '@anticrm/core'
import type { Asset, IntlString } from '@anticrm/platform'
import { Asset, getEmbeddedLabel, IntlString } from '@anticrm/platform'
import {
ActionIcon,
AnySvelteComponent,
Button,
ButtonKind,
ButtonSize,
getFocusManager,
Icon,
IconOpen,
Label,
LabelAndProps,
showPanel,
showPopup,
LabelAndProps
tooltip
} from '@anticrm/ui'
import view from '@anticrm/view'
import { createEventDispatcher } from 'svelte'
import presentation from '..'
import { ObjectCreate } from '../types'
@ -52,6 +58,7 @@
export let width: string | undefined = undefined
export let focusIndex = -1
export let showTooltip: LabelAndProps | undefined = undefined
export let showNavigate = true
export let create: ObjectCreate | undefined = undefined
@ -110,17 +117,15 @@
</script>
<div bind:this={container} class="min-w-0" class:w-full={width === '100%'}>
<Button
{focusIndex}
icon={hideIcon || selected ? undefined : icon}
width={width ?? 'min-content'}
{size}
{kind}
{justify}
{showTooltip}
on:click={_click}
<Button {focusIndex} width={width ?? 'min-content'} {size} {kind} {justify} {showTooltip} on:click={_click}>
<span slot="content" class="overflow-label flex-grow" class:flex-between={showNavigate && selected}>
<div
class="disabled"
style:width={showNavigate && selected
? `calc(${width ?? 'min-content'} - 1.5rem)`
: `${width ?? 'min-content'}`}
use:tooltip={selected !== undefined ? { label: getEmbeddedLabel(getName(selected)) } : undefined}
>
<span slot="content" class="overflow-label disabled">
{#if selected}
{#if hideIcon || selected}
<UserInfo value={selected} size={kind === 'link' ? 'x-small' : 'tiny'} {icon} />
@ -128,7 +133,26 @@
{getName(selected)}
{/if}
{:else}
<div class="flex-row-center">
{#if icon}
<Icon {icon} size={kind === 'link' ? 'small' : size} />
{/if}
<div class="ml-2">
<Label {label} />
</div>
</div>
{/if}
</div>
{#if selected && showNavigate}
<ActionIcon
icon={IconOpen}
size={'small'}
action={() => {
if (selected) {
showPanel(view.component.EditDoc, selected._id, selected._class, 'content')
}
}}
/>
{/if}
</span>
</Button>

View File

@ -132,9 +132,9 @@
}
&.dialog {
width: 40rem;
width: 45rem;
height: max-content;
max-width: 40rem;
max-width: 60rem;
max-height: calc(100vh - 2rem);
.antiCard-header {

View File

@ -32,7 +32,7 @@
class="button {size}"
use:tooltip={{ label, direction, props: labelProps }}
tabindex="0"
on:click|stopPropagation={action}
on:click|stopPropagation|preventDefault={action}
>
<div class="icon {size}" class:invisible>
<Icon {icon} {size} />

View File

@ -0,0 +1,11 @@
<script lang="ts">
export let size: 'small' | 'medium' | 'large'
const fill: string = 'currentColor'
</script>
<svg class="svg-{size}" {fill} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<path d="M14,13.1H9.2c-0.3,0-0.5,0.2-0.5,0.5s0.2,0.5,0.5,0.5H14c0.3,0,0.5-0.2,0.5-0.5S14.3,13.1,14,13.1z" />
<path
d="M11.4,7.1C11.4,7.1,11.4,7.1,11.4,7.1c1.2-1.6,1.3-1.6,1.3-1.6C12.9,5,13,4.5,12.9,4c-0.1-0.5-0.4-0.9-0.8-1.2 c0,0-1.1-0.9-1.1-0.9c-0.8-0.7-2.1-0.6-2.8,0.3c0,0,0,0,0,0l-6.3,7.9c-0.3,0.4-0.4,0.9-0.3,1.4l0.5,2.3c0.1,0.2,0.3,0.4,0.5,0.4 c0,0,0,0,0,0l2.4,0c0.5,0,1-0.2,1.3-0.6C8.9,10.2,10.5,8.2,11.4,7.1C11.4,7.1,11.4,7.1,11.4,7.1z M8.9,2.8c0.3-0.4,1-0.5,1.4-0.1 c0,0,1.2,0.9,1.2,0.9c0.2,0.1,0.4,0.3,0.4,0.6c0.1,0.2,0,0.5-0.1,0.7c0,0-0.4,0.5-0.9,1.2L8.1,3.9L8.9,2.8z M5.5,12.9 C5.4,13,5.2,13.1,5,13.1l-2,0l-0.5-1.9c0-0.2,0-0.4,0.1-0.5l4.8-6l2.8,2.2C8.9,8.6,6.8,11.2,5.5,12.9z"
/>
</svg>

View File

@ -132,6 +132,7 @@ export { default as IconDetails } from './components/icons/Details.svelte'
export { default as IconDetailsFilled } from './components/icons/DetailsFilled.svelte'
export { default as IconScale } from './components/icons/Scale.svelte'
export { default as IconScaleFull } from './components/icons/ScaleFull.svelte'
export { default as IconOpen } from './components/icons/Open.svelte'
export { default as PanelInstance } from './components/PanelInstance.svelte'
export { default as Panel } from './components/Panel.svelte'

View File

@ -0,0 +1,41 @@
<script lang="ts">
import contact, { Employee, EmployeeAccount } from '@anticrm/contact'
import core, { Account, Ref } from '@anticrm/core'
import { IntlString } from '@anticrm/platform'
import { createQuery, getClient, UserBoxList } from '@anticrm/presentation'
export let label: IntlString
export let value: Ref<Account>[]
export let onChange: (refs: Ref<Account>[]) => void
let timer: any
const client = getClient()
function onUpdate (evt: CustomEvent<Ref<Employee>[]>): void {
clearTimeout(timer)
timer = setTimeout(async () => {
const accounts = await client.findAll(contact.class.EmployeeAccount, { employee: { $in: evt.detail } })
onChange(accounts.map((it) => it._id))
}, 500)
}
const accountQuery = createQuery()
let accounts: Account[] = []
$: accountQuery.query(core.class.Account, { _id: { $in: value } }, (res) => {
accounts = res
})
$: employess = accounts.map((it) => (it as EmployeeAccount).employee)
</script>
<UserBoxList
items={employess}
{label}
on:update={onUpdate}
kind={'link'}
size={'medium'}
justify={'left'}
width={'100%'}
/>

View File

@ -44,6 +44,8 @@
{size}
{justify}
{width}
allowDeselect
titleDeselect={contact.string.Cancel}
bind:value
on:change={(e) => onChange(e.detail)}
/>

View File

@ -16,12 +16,9 @@
import { Organization } from '@anticrm/contact'
import { Ref } from '@anticrm/core'
import { IntlString } from '@anticrm/platform'
import { createQuery } from '@anticrm/presentation'
import { DropdownPopup, Label, showPopup, Button } from '@anticrm/ui'
import { UserBox } from '@anticrm/presentation'
import type { ButtonKind, ButtonSize } from '@anticrm/ui'
import { ListItem } from '@anticrm/ui/src/types'
import contact from '../plugin'
import Company from './icons/Company.svelte'
export let value: Ref<Organization> | undefined
export let label: IntlString = contact.string.Organization
@ -29,80 +26,21 @@
export let kind: ButtonKind = 'no-border'
export let size: ButtonSize = 'small'
export let justify: 'left' | 'center' = 'center'
export let justify: 'left' | 'center' = 'left'
export let width: string | undefined = 'min-content'
const query = createQuery()
query.query(contact.class.Organization, {}, (res) => {
items = res.map((org) => {
return {
_id: org._id,
label: org.name,
image: org.avatar === null ? undefined : org.avatar
}
})
if (value !== undefined) {
selected = items.find((p) => p._id === value)
}
})
let items: ListItem[] = []
let selected: ListItem | undefined
function setValue (res: ListItem | undefined): void {
selected = res
if (selected === undefined) {
value = undefined
} else {
value = selected._id as Ref<Organization>
}
onChange(value)
}
let opened: boolean = false
const icon = Company
let tool: HTMLElement
</script>
<div class="clear-mins" bind:this={tool} />
<Button
<UserBox
_class={contact.class.Organization}
{label}
{value}
{kind}
{size}
{justify}
{width}
{size}
{kind}
on:click={() => {
if (!opened) {
opened = true
showPopup(DropdownPopup, { title: label, items, icon }, tool, (result) => {
if (result) setValue(result)
opened = false
})
}
allowDeselect
titleDeselect={contact.string.Cancel}
on:change={(evt) => {
onChange(evt.detail)
}}
>
<svelte:fragment slot="content">
{#if selected}
<div class="flex-row-center pointer-events-none">
<div class="icon"><Company size={'small'} /></div>
<span class="overflow-label">{selected.label}</span>
</div>
{:else}
<span class="overflow-label disabled"><Label {label} /></span>
{/if}
</svelte:fragment>
</Button>
<style lang="scss">
.icon {
margin-right: 0.5rem;
padding: 0.25rem;
color: var(--accent-color);
background-color: var(--avatar-bg-color);
border-radius: 50%;
&:hover {
color: var(--caption-color);
}
}
</style>
/>

View File

@ -1,84 +0,0 @@
<!--
// 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 '@anticrm/contact'
import { Ref } from '@anticrm/core'
import { IntlString } from '@anticrm/platform'
import { createQuery } from '@anticrm/presentation'
import type { ButtonKind, ButtonSize, TooltipAlignment } from '@anticrm/ui'
import { Dropdown } from '@anticrm/ui'
import { ListItem } from '@anticrm/ui/src/types'
import { createEventDispatcher } from 'svelte'
import contact from '../plugin'
import Company from './icons/Company.svelte'
export let value: Ref<Organization> | undefined
export let label: IntlString = contact.string.Organization
export let kind: ButtonKind = 'no-border'
export let size: ButtonSize = 'small'
export let justify: 'left' | 'center' = 'center'
export let width: string | undefined = undefined
export let labelDirection: TooltipAlignment | undefined = undefined
export let focusIndex = -1
const query = createQuery()
const dispatch = createEventDispatcher()
let items: ListItem[] = []
let selected: ListItem | undefined
let resolved = false
query.query(contact.class.Organization, {}, (res) => {
items = res.map((org) => {
return {
_id: org._id,
label: org.name,
image: org.avatar === null ? undefined : org.avatar
}
})
if (value !== undefined) {
selected = items.find((p) => p._id === value)
}
resolved = true
})
$: if (resolved) {
setValue(selected)
}
function setValue (selected: ListItem | undefined): void {
if (selected === undefined) {
value = undefined
} else {
value = selected._id as Ref<Organization>
}
dispatch('change', value)
}
</script>
<Dropdown
{focusIndex}
icon={Company}
{label}
placeholder={label}
{items}
bind:selected
{kind}
{size}
{justify}
{width}
{labelDirection}
/>

View File

@ -37,6 +37,7 @@ import EditOrganization from './components/EditOrganization.svelte'
import EditPerson from './components/EditPerson.svelte'
import EmployeeAccountPresenter from './components/EmployeeAccountPresenter.svelte'
import EmployeeArrayEditor from './components/EmployeeArrayEditor.svelte'
import AccountArrayEditor from './components/AccountArrayEditor.svelte'
import EmployeeBrowser from './components/EmployeeBrowser.svelte'
import EmployeeEditor from './components/EmployeeEditor.svelte'
import EmployeePresenter from './components/EmployeePresenter.svelte'
@ -44,7 +45,6 @@ import MemberPresenter from './components/MemberPresenter.svelte'
import Members from './components/Members.svelte'
import OrganizationEditor from './components/OrganizationEditor.svelte'
import OrganizationPresenter from './components/OrganizationPresenter.svelte'
import OrganizationSelector from './components/OrganizationSelector.svelte'
import PersonEditor from './components/PersonEditor.svelte'
import PersonPresenter from './components/PersonPresenter.svelte'
import SocialEditor from './components/SocialEditor.svelte'
@ -55,7 +55,6 @@ export {
ChannelsEditor,
ContactPresenter,
ChannelsView,
OrganizationSelector,
ChannelsDropdown,
EmployeePresenter,
PersonPresenter,
@ -144,7 +143,8 @@ export default async (): Promise<Resources> => ({
EditMember,
EmployeeArrayEditor,
EmployeeEditor,
CreateEmployee
CreateEmployee,
AccountArrayEditor
},
completion: {
EmployeeQuery: async (

View File

@ -92,7 +92,9 @@
"CopyLink": "Copy link",
"HasActiveApplicant":"Active Only",
"HasNoActiveApplicant": "No Active",
"NoneApplications": "None"
"NoneApplications": "None",
"RelatedIssues": "Related issues",
"VacancyList": "Vacancies"
},
"status": {
"TalentRequired": "Please select talent",

View File

@ -94,7 +94,9 @@
"CopyLink": "Копировать ссылку",
"HasActiveApplicant":"Только активные",
"HasNoActiveApplicant": "Не активные",
"NoneApplications": "Отсутствуют"
"NoneApplications": "Отсутствуют",
"RelatedIssues": "Связанные задачи",
"VacancyList": "Вакансии"
},
"status": {
"TalentRequired": "Пожалуйста выберите таланта",

View File

@ -56,6 +56,7 @@
"@anticrm/rekoni": "~0.6.0",
"@anticrm/notification": "~0.6.0",
"@anticrm/tags": "~0.6.2",
"@anticrm/calendar": "~0.6.0"
"@anticrm/calendar": "~0.6.0",
"@anticrm/tracker": "~0.6.0"
}
}

View File

@ -28,7 +28,8 @@
let name: string = ''
const description: string = ''
let templateId: Ref<KanbanTemplate> | undefined
let company: Ref<Organization> | undefined
export let company: Ref<Organization> | undefined
export let preserveCompany: boolean = false
export function canClose (): boolean {
return name === '' && templateId !== undefined
@ -87,12 +88,15 @@
_class={contact.class.Organization}
label={recruit.string.Company}
placeholder={recruit.string.Company}
justify={'left'}
bind:value={company}
allowDeselect
titleDeselect={recruit.string.UnAssignCompany}
kind={'no-border'}
size={'small'}
icon={Company}
readonly={preserveCompany}
showNavigate={false}
create={{ component: contact.component.CreateOrganization, label: contact.string.CreateOrganization }}
/>
<Component

View File

@ -18,10 +18,11 @@
import type { Ref } from '@anticrm/core'
import core from '@anticrm/core'
import { Panel } from '@anticrm/panel'
import { createQuery, getClient, MembersBox } from '@anticrm/presentation'
import { createQuery, getClient } from '@anticrm/presentation'
import { Vacancy } from '@anticrm/recruit'
import { FullDescriptionBox } from '@anticrm/text-editor'
import { Button, EditBox, Grid, IconMoreH, showPopup } from '@anticrm/ui'
import tracker from '@anticrm/tracker'
import { Button, Component, EditBox, Grid, Icon, IconAdd, IconMoreH, Label, showPopup } from '@anticrm/ui'
import { ClassAttributeBar, ContextMenu } from '@anticrm/view-resources'
import { createEventDispatcher } from 'svelte'
import recruit from '../plugin'
@ -58,6 +59,7 @@
showPopup(ContextMenu, { object }, (ev as MouseEvent).target as HTMLElement)
}
}
let isCreateIssue = false
</script>
{#if object}
@ -79,8 +81,8 @@
<ClassAttributeBar
{object}
_class={object._class}
ignoreKeys={['name', 'description', 'fullDescription']}
to={core.class.Space}
ignoreKeys={['name', 'description', 'fullDescription', 'private', 'archived']}
to={core.class.Doc}
/>
</div>
</div>
@ -130,7 +132,41 @@
space={object.space}
attachments={object.attachments ?? 0}
/>
<MembersBox label={recruit.string.Members} space={object} />
</Grid>
<!-- <MembersBox label={recruit.string.Members} space={object} /> -->
<div class="antiSection">
<div class="antiSection-header">
<div class="antiSection-header__icon">
<Icon icon={tracker.icon.Issue} size={'small'} />
</div>
<span class="antiSection-header__title">
<Label label={recruit.string.RelatedIssues} />
</span>
<div class="buttons-group small-gap">
<Button
id="add-sub-issue"
width="min-content"
icon={IconAdd}
label={undefined}
labelParams={{ subIssues: 0 }}
kind={'transparent'}
size={'small'}
on:click={() => {
isCreateIssue = true
}}
/>
</div>
</div>
<div class="flex-row">
<Component
is={tracker.component.RelatedIssues}
props={{ object: object, isCreating: isCreateIssue }}
on:close={() => {
isCreateIssue = false
}}
/>
</div>
</div></Grid
>
</Panel>
{/if}

View File

@ -0,0 +1,79 @@
<!--
// 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, Ref } from '@anticrm/core'
import { getEmbeddedLabel } from '@anticrm/platform'
import presentation from '@anticrm/presentation'
import { Button, Icon, IconAdd, Label, showPopup } from '@anticrm/ui'
import view, { BuildModelKey } from '@anticrm/view'
import { Table } from '@anticrm/view-resources'
import recruit from '../plugin'
import CreateVacancy from './CreateVacancy.svelte'
import FileDuo from './icons/FileDuo.svelte'
export let objectId: Ref<Doc>
export let vacancies: number | undefined
const createApp = (ev: MouseEvent): void => {
showPopup(CreateVacancy, { company: objectId, preserveCompany: true }, ev.target as HTMLElement)
}
const config: (BuildModelKey | string)[] = [
'',
'comments',
'attachments',
{
key: 'archived',
presenter: view.component.BooleanTruePresenter,
label: presentation.string.Archived,
props: {
useInvert: false,
trueColor: 14
}
},
'modifiedOn'
]
</script>
<div class="antiSection">
<div class="antiSection-header">
<div class="antiSection-header__icon">
<Icon icon={recruit.icon.Vacancy} size={'small'} />
</div>
<span class="antiSection-header__title">
<Label label={recruit.string.Vacancies} />
</span>
<Button id="appls.add" icon={IconAdd} kind={'transparent'} shape={'circle'} on:click={createApp} />
</div>
{#if (vacancies ?? 0) > 0}
<Table
_class={recruit.class.Vacancy}
{config}
query={{ company: objectId }}
loadingProps={{ length: vacancies ?? 0 }}
/>
{:else}
<div class="antiSection-empty solid flex-col-center mt-3">
<div class="caption-color">
<FileDuo size={'large'} />
</div>
<span class="dark-color">
<Label label={getEmbeddedLabel('No Vacancies')} />
</span>
<span class="over-underline content-accent-color" on:click={createApp}>
<Label label={recruit.string.CreateVacancy} />
</span>
</div>
{/if}
</div>

View File

@ -16,7 +16,6 @@
import calendar from '@anticrm/calendar'
import type { Contact, EmployeeAccount, Organization, Person } from '@anticrm/contact'
import contact from '@anticrm/contact'
import { OrganizationSelector } from '@anticrm/contact-resources'
import { Account, Class, Client, Doc, generateId, getCurrentAccount, Ref } from '@anticrm/core'
import { getResource, OK, Resource, Severity, Status } from '@anticrm/platform'
import { Card, getClient, UserBox, UserBoxList } from '@anticrm/presentation'
@ -171,7 +170,13 @@
create={{ component: recruit.component.CreateCandidate, label: recruit.string.CreateTalent }}
/>
{/if}
<OrganizationSelector bind:value={company} label={recruit.string.Company} kind={'no-border'} size={'small'} />
<UserBox
_class={contact.class.Organization}
bind:value={company}
label={recruit.string.Company}
kind={'no-border'}
size={'small'}
/>
<DateRangePresenter
bind:value={startDate}
labelNull={recruit.string.StartDate}

View File

@ -46,7 +46,10 @@
let candidate: Contact | undefined = undefined
async function updateSelected (object: Review) {
candidate = await client.findOne<Contact>(object.attachedToClass, { _id: object.attachedTo })
candidate =
object?.attachedTo !== undefined
? await client.findOne<Contact>(object.attachedToClass, { _id: object.attachedTo })
: undefined
}
$: updateSelected(object)

View File

@ -51,6 +51,7 @@ import VacancyModifiedPresenter from './components/VacancyModifiedPresenter.svel
import VacancyPresenter from './components/VacancyPresenter.svelte'
import recruit from './plugin'
import { copyToClipboard, getApplicationTitle } from './utils'
import VacancyList from './components/VacancyList.svelte'
async function createOpinion (object: Doc): Promise<void> {
showPopup(CreateOpinion, { space: object.space, review: object._id })
@ -287,7 +288,9 @@ export default async (): Promise<Resources> => ({
NewCandidateHeader,
ApplicantFilter
ApplicantFilter,
VacancyList
},
completion: {
ApplicationQuery: async (

View File

@ -105,7 +105,8 @@ export default mergeIds(recruitId, recruit, {
FullDescription: '' as IntlString,
HasActiveApplicant: '' as IntlString,
HasNoActiveApplicant: '' as IntlString,
NoneApplications: '' as IntlString
NoneApplications: '' as IntlString,
RelatedIssues: '' as IntlString
},
space: {
CandidatesPublic: '' as Ref<Space>

View File

@ -32,6 +32,13 @@ export interface Vacancy extends SpaceWithStates {
company?: Ref<Organization>
}
/**
* @public
*/
export interface VacancyList extends Organization {
vacancies: number
}
/**
* @public
*/
@ -105,7 +112,8 @@ const recruit = plugin(recruitId, {
Opinion: '' as Ref<Class<Opinion>>
},
mixin: {
Candidate: '' as Ref<Mixin<Candidate>>
Candidate: '' as Ref<Mixin<Candidate>>,
VacancyList: '' as Ref<Mixin<VacancyList>>
},
component: {
EditVacancy: '' as AnyComponent

View File

@ -36,7 +36,8 @@
cls.extends === _class &&
!cls.hidden &&
[ClassifierKind.CLASS, ClassifierKind.MIXIN].includes(cls.kind) &&
cls.label !== undefined
cls.label !== undefined &&
(!hierarchy.hasMixin(cls, settings.mixin.Editable) || hierarchy.as(cls, settings.mixin.Editable).value)
) {
result.push(clazz)
}

View File

@ -37,7 +37,12 @@
let classes: Ref<Class<Doc>>[] = []
clQuery.query(core.class.Class, {}, (res) => {
classes = res
.filter((p) => hierarchy.hasMixin(p, setting.mixin.Editable) && !hierarchy.hasMixin(p, setting.mixin.UserMixin))
.filter(
(p) =>
hierarchy.hasMixin(p, setting.mixin.Editable) &&
hierarchy.as(p, setting.mixin.Editable).value &&
!hierarchy.hasMixin(p, setting.mixin.UserMixin)
)
.map((p) => p._id)
})
</script>

View File

@ -34,7 +34,9 @@
icon: value.icon
}
const id = await client.createDoc(core.class.Mixin, core.space.Model, data)
await client.createMixin(id, core.class.Mixin, core.space.Model, setting.mixin.Editable, {})
await client.createMixin(id, core.class.Mixin, core.space.Model, setting.mixin.Editable, {
value: true
})
await client.createMixin(id, core.class.Mixin, core.space.Model, setting.mixin.UserMixin, {})
dispatch('close')
}

View File

@ -47,7 +47,9 @@ export interface Integration extends Doc {
/**
* @public
*/
export interface Editable extends Class<Doc> {}
export interface Editable extends Class<Doc> {
value: boolean // true is editable, false is not
}
/**
* @public

View File

@ -336,6 +336,7 @@
placeholder={tracker.string.IssueDescriptionPlaceholder}
/>
<svelte:fragment slot="pool">
<div class="flex flex-wrap" style:gap={'0.2vw'}>
{#if issueStatuses}
<div id="status-editor">
<StatusEditor
@ -384,10 +385,11 @@
{#if object.dueDate !== null}
<DatePresenter bind:value={object.dueDate} editable />
{/if}
<ActionIcon icon={IconMoreH} size={'medium'} action={showMoreActions} />
{:else}
<Spinner size="small" />
{/if}
</div>
<ActionIcon icon={IconMoreH} size={'medium'} action={showMoreActions} />
</svelte:fragment>
<svelte:fragment slot="footer">
<Button

View File

@ -53,6 +53,7 @@
{size}
{kind}
{width}
showNavigate={false}
justify={'left'}
showTooltip={{ label: tracker.string.AssignTo, direction: tooltipAlignment }}
on:change={({ detail }) => handleAssigneeChanged(detail)}

View File

@ -172,11 +172,13 @@
focus
/>
<div class="mt-4">
{#key newIssue.description}
<StyledTextArea
bind:content={newIssue.description}
placeholder={tracker.string.IssueDescriptionPlaceholder}
showButtons={false}
/>
{/key}
</div>
</div>
</div>

View File

@ -275,8 +275,14 @@
{/if}
</div>
<div class="mt-6">
{#key issue._id}
<SubIssues {issue} {issueStatuses} {currentTeam} />
{#key issue._id && currentTeam !== undefined}
{#if currentTeam !== undefined && issueStatuses !== undefined}
<SubIssues
{issue}
issueStatuses={new Map([[currentTeam._id, issueStatuses]])}
teams={new Map([[currentTeam?._id, currentTeam]])}
/>
{/if}
{/key}
</div>
<div class="mt-6">

View File

@ -13,7 +13,7 @@
// limitations under the License.
-->
<script lang="ts">
import { Doc, WithLookup } from '@anticrm/core'
import { Doc, Ref, WithLookup } from '@anticrm/core'
import { Issue, IssueStatus, Team } from '@anticrm/tracker'
import { getEventPositionElement, showPanel, showPopup } from '@anticrm/ui'
import {
@ -35,8 +35,9 @@
import EstimationEditor from '../timereport/EstimationEditor.svelte'
export let issues: Issue[]
export let issueStatuses: WithLookup<IssueStatus>[]
export let currentTeam: Team
export let teams: Map<Ref<Team>, Team>
export let issueStatuses: Map<Ref<Team>, WithLookup<IssueStatus>[]>
const dispatch = createEventDispatcher()
@ -99,6 +100,7 @@
/>
{#each issues as issue, index (issue._id)}
{@const currentTeam = teams.get(issue.space)}
<div
class="flex-between row"
class:is-dragging={index === draggingIndex}
@ -133,12 +135,14 @@
justify={'left'}
on:update={(result) => checkWidth('issue', result)}
>
{#if currentTeam}
{getIssueId(currentTeam, issue)}
{/if}
</FixedColumn>
</span>
<StatusEditor
value={issue}
statuses={issueStatuses}
statuses={issueStatuses.get(issue.space)}
justify="center"
kind={'list'}
size={'small'}

View File

@ -13,7 +13,7 @@
// limitations under the License.
-->
<script lang="ts">
import { SortingOrder, WithLookup } from '@anticrm/core'
import { Ref, SortingOrder, WithLookup } from '@anticrm/core'
import { createQuery, getClient } from '@anticrm/presentation'
import { calcRank, Issue, IssueStatus, Team } from '@anticrm/tracker'
import { Button, Spinner, ExpandCollapse, closeTooltip, IconAdd } from '@anticrm/ui'
@ -24,8 +24,8 @@
import SubIssueList from './SubIssueList.svelte'
export let issue: Issue
export let currentTeam: Team | undefined
export let issueStatuses: WithLookup<IssueStatus>[] | undefined
export let teams: Map<Ref<Team>, Team>
export let issueStatuses: Map<Ref<Team>, WithLookup<IssueStatus>[]>
const subIssuesQuery = createQuery()
const client = getClient()
@ -86,20 +86,29 @@
/>
</div>
<div class="mt-1">
{#if subIssues && issueStatuses && currentTeam}
{#if subIssues && issueStatuses}
<ExpandCollapse isExpanded={!isCollapsed} duration={400}>
{#if hasSubIssues}
<div class="list" class:collapsed={isCollapsed}>
<SubIssueList issues={subIssues} {issueStatuses} {currentTeam} on:move={handleIssueSwap} />
<SubIssueList issues={subIssues} {issueStatuses} {teams} on:move={handleIssueSwap} />
</div>
{/if}
</ExpandCollapse>
<ExpandCollapse isExpanded={!isCollapsed} duration={400}>
{#if isCreating}
{@const team = teams.get(issue.space)}
{@const statuses = issueStatuses.get(issue.space)}
{#if team !== undefined && statuses !== undefined}
<div class="pt-4">
<CreateSubIssue parentIssue={issue} {issueStatuses} {currentTeam} on:close={() => (isCreating = false)} />
<CreateSubIssue
parentIssue={issue}
issueStatuses={statuses}
currentTeam={team}
on:close={() => (isCreating = false)}
/>
</div>
{/if}
{/if}
</ExpandCollapse>
{:else}
<div class="flex-center pt-3">

View File

@ -0,0 +1,255 @@
<!--
// 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 { createEventDispatcher } from 'svelte'
import core, { Account, AttachedData, Doc, generateId, Ref, SortingOrder, WithLookup } from '@anticrm/core'
import presentation, { getClient, KeyedAttribute, SpaceSelector } from '@anticrm/presentation'
import { StyledTextArea } from '@anticrm/text-editor'
import { IssueStatus, IssuePriority, Issue, Team, calcRank } from '@anticrm/tracker'
import { Button, Component, EditBox } from '@anticrm/ui'
import tags, { TagElement, TagReference } from '@anticrm/tags'
import tracker from '../../../plugin'
import AssigneeEditor from '../AssigneeEditor.svelte'
import StatusEditor from '../StatusEditor.svelte'
import PriorityEditor from '../PriorityEditor.svelte'
import EstimationEditor from '../timereport/EstimationEditor.svelte'
export let related: Doc
export let issueStatuses: Map<Ref<Team>, WithLookup<IssueStatus>[]>
export let teams: Map<Ref<Team>, Team> | undefined
const dispatch = createEventDispatcher()
const client = getClient()
let newIssue: AttachedData<Issue> = getIssueDefaults()
let thisRef: HTMLDivElement
let focusIssueTitle: () => void
let labels: TagReference[] = []
let currentTeam: Ref<Team>
$: if (currentTeam === undefined) {
currentTeam = teams?.values().next()?.value
}
const key: KeyedAttribute = {
key: 'labels',
attr: client.getHierarchy().getAttribute(tracker.class.Issue, 'labels')
}
function getIssueDefaults (): AttachedData<Issue> {
return {
title: '',
description: '',
assignee: null,
project: null,
number: 0,
rank: '',
status: '' as Ref<IssueStatus>,
priority: IssuePriority.NoPriority,
dueDate: null,
comments: 0,
subIssues: 0,
parents: [],
relations: [{ _id: related?._id, _class: related?._class }],
sprint: undefined,
estimation: 0,
reportedTime: 0,
reports: 0,
childInfo: []
}
}
function resetToDefaults () {
newIssue = getIssueDefaults()
focusIssueTitle?.()
}
function getTitle (value: string) {
return value.trim()
}
function close () {
dispatch('close')
}
async function createIssue () {
if (!canSave) {
return
}
const lastOne = await client.findOne<Issue>(
tracker.class.Issue,
{ space: currentTeam },
{ sort: { rank: SortingOrder.Descending } }
)
const incResult = await client.updateDoc(
tracker.class.Team,
core.space.Space,
currentTeam,
{ $inc: { sequence: 1 } },
true
)
const value: AttachedData<Issue> = {
...newIssue,
title: getTitle(newIssue.title),
number: (incResult as any).object.sequence,
rank: calcRank(lastOne, undefined),
parents: [{ parentId: tracker.ids.NoParent, parentTitle: '' }]
}
const objectId = await client.addCollection(
tracker.class.Issue,
currentTeam,
tracker.ids.NoParent,
tracker.class.Issue,
'subIssues',
value
)
for (const label of labels) {
await client.addCollection(label._class, label.space, objectId, tracker.class.Issue, 'labels', {
title: label.title,
color: label.color,
tag: label.tag
})
}
resetToDefaults()
}
function addTagRef (tag: TagElement): void {
labels = [
...labels,
{
_class: tags.class.TagReference,
_id: generateId() as Ref<TagReference>,
attachedTo: '' as Ref<Doc>,
attachedToClass: tracker.class.Issue,
collection: 'labels',
space: tags.space.Tags,
modifiedOn: 0,
modifiedBy: '' as Ref<Account>,
title: tag.title,
tag: tag._id,
color: tag.color
}
]
}
$: thisRef && thisRef.scrollIntoView({ behavior: 'smooth' })
$: canSave = getTitle(newIssue.title ?? '').length > 0
$: if (!newIssue.status) {
const t = teams?.get(currentTeam)
if (t?.defaultIssueStatus !== undefined) {
newIssue.status = t?.defaultIssueStatus
}
}
</script>
<div bind:this={thisRef} class="flex-col root">
<div class="flex-row-top">
<div id="status-editor" class="mr-1">
<StatusEditor
value={newIssue}
statuses={issueStatuses.get(currentTeam)}
kind="transparent"
size="medium"
justify="center"
tooltipAlignment="bottom"
on:change={({ detail }) => (newIssue.status = detail)}
/>
</div>
<div class="w-full flex-col content">
<EditBox
bind:value={newIssue.title}
bind:focusInput={focusIssueTitle}
maxWidth="33rem"
placeholder={tracker.string.IssueTitlePlaceholder}
focus
/>
<div class="mt-4">
{#key newIssue.description}
<StyledTextArea
bind:content={newIssue.description}
placeholder={tracker.string.IssueDescriptionPlaceholder}
showButtons={false}
/>
{/key}
</div>
</div>
</div>
<div class="mt-4 flex-between">
<div class="buttons-group xsmall-gap">
<SpaceSelector _class={tracker.class.Team} label={tracker.string.Team} bind:space={currentTeam} />
<PriorityEditor
value={newIssue}
shouldShowLabel
isEditable
kind="no-border"
size="small"
justify="center"
on:change={({ detail }) => (newIssue.priority = detail)}
/>
{#key newIssue.assignee}
<AssigneeEditor
value={newIssue}
size="small"
kind="no-border"
on:change={({ detail }) => (newIssue.assignee = detail)}
/>
{/key}
<Component
is={tags.component.TagsDropdownEditor}
props={{
items: labels,
key,
targetClass: tracker.class.Issue,
countLabel: tracker.string.NumberLabels
}}
on:open={(evt) => {
addTagRef(evt.detail)
}}
on:delete={(evt) => {
labels = labels.filter((it) => it._id !== evt.detail)
}}
/>
<EstimationEditor kind={'no-border'} size={'small'} value={newIssue} />
</div>
<div class="buttons-group small-gap">
<Button label={presentation.string.Cancel} size="small" kind="transparent" on:click={close} />
<Button
disabled={!canSave}
label={presentation.string.Save}
size="small"
kind="no-border"
on:click={createIssue}
/>
</div>
</div>
</div>
<style lang="scss">
.root {
padding: 0.5rem 1.5rem;
background-color: var(--theme-bg-accent-color);
border: 1px solid var(--theme-button-border-enabled);
border-radius: 0.5rem;
overflow: hidden;
.content {
padding-top: 0.3rem;
}
}
</style>

View File

@ -0,0 +1,120 @@
<!--
// 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, Ref, SortingOrder, WithLookup } from '@anticrm/core'
import presentation, { createQuery, getClient } from '@anticrm/presentation'
import { calcRank, Issue, IssueStatus, Team } from '@anticrm/tracker'
import { Label, Spinner } from '@anticrm/ui'
import { createEventDispatcher } from 'svelte'
import tracker from '../../../plugin'
import SubIssueList from '../edit/SubIssueList.svelte'
import CreateRelatedIssue from './CreateRelatedIssue.svelte'
export let object: Doc
export let isCreating = false
let query: DocumentQuery<Issue>
$: query = { 'relations._id': object._id, 'relations._class': object._class }
const subIssuesQuery = createQuery()
const client = getClient()
const dispatch = createEventDispatcher()
let subIssues: Issue[] = []
let teams: Map<Ref<Team>, Team> | undefined
async function handleIssueSwap (ev: CustomEvent<{ fromIndex: number; toIndex: number }>) {
const { fromIndex, toIndex } = ev.detail
const [prev, next] = [
subIssues[fromIndex < toIndex ? toIndex : toIndex - 1],
subIssues[fromIndex < toIndex ? toIndex + 1 : toIndex]
]
const issue = subIssues[fromIndex]
await client.update(issue, { rank: calcRank(prev, next) })
}
$: subIssuesQuery.query(tracker.class.Issue, query, async (result) => (subIssues = result), {
sort: { rank: SortingOrder.Ascending }
})
const teamsQuery = createQuery()
$: teamsQuery.query(tracker.class.Team, {}, async (result) => {
teams = new Map(result.map((it) => [it._id, it]))
})
let issueStatuses = new Map<Ref<Team>, WithLookup<IssueStatus>[]>()
const statusesQuery = createQuery()
$: statusesQuery.query(
tracker.class.IssueStatus,
{},
(statuses) => {
const st = new Map<Ref<Team>, WithLookup<IssueStatus>[]>()
for (const s of statuses) {
const id = s.attachedTo as Ref<Team>
st.set(id, [...(st.get(id) ?? []), s])
}
issueStatuses = st
},
{
lookup: { category: tracker.class.IssueStatusCategory },
sort: { rank: SortingOrder.Ascending }
}
)
</script>
<div class="mt-1">
{#if subIssues !== undefined}
{#if issueStatuses.size > 0 && teams}
<SubIssueList issues={subIssues} {teams} {issueStatuses} on:move={handleIssueSwap} />
{:else}
<div class="p-1">
<Label label={presentation.string.NoMatchesFound} />
</div>
{/if}
{#if isCreating}
<div class="pt-4">
<CreateRelatedIssue
related={object}
{issueStatuses}
{teams}
on:close={() => {
isCreating = false
dispatch('close')
}}
/>
</div>
{/if}
{:else}
<div class="flex-center pt-3">
<Spinner />
</div>
{/if}
</div>
<style lang="scss">
.list {
border-top: 1px solid var(--divider-color);
&.collapsed {
padding-top: 1px;
border-top: none;
}
}
</style>

View File

@ -75,6 +75,8 @@ import EstimationEditor from './components/issues/timereport/EstimationEditor.sv
import ReportedTimeEditor from './components/issues/timereport/ReportedTimeEditor.svelte'
import TimeSpendReport from './components/issues/timereport/TimeSpendReport.svelte'
import RelatedIssues from './components/issues/related/RelatedIssues.svelte'
export async function queryIssue<D extends Issue> (
_class: Ref<Class<D>>,
client: Client,
@ -189,7 +191,8 @@ export default async (): Promise<Resources> => ({
TimeSpendReport,
EstimationEditor,
SubIssuesSelector,
GrowPresenter
GrowPresenter,
RelatedIssues
},
completion: {
IssueQuery: async (client: Client, query: string, filter?: { in?: RelatedDocument[], nin?: RelatedDocument[] }) =>

View File

@ -293,7 +293,8 @@ export default plugin(trackerId, {
},
component: {
Tracker: '' as AnyComponent,
TrackerApp: '' as AnyComponent
TrackerApp: '' as AnyComponent,
RelatedIssues: '' as AnyComponent
},
issueStatusCategory: {
Backlog: '' as Ref<IssueStatusCategory>,

View File

@ -17,11 +17,15 @@
import { getPlatformColor } from '@anticrm/ui'
export let value: boolean
export let trueColor = 0
export let falseColor = 11
export let useInvert = false
$: color = value ? getPlatformColor(0) : getPlatformColor(11)
$: val = useInvert ? !value : value
$: color = val ? getPlatformColor(trueColor) : getPlatformColor(falseColor)
</script>
{#if value}
{#if val}
<div class="flex-center">
<div class="pinned-container" style="background-color: {color};" />
</div>

View File

@ -30,5 +30,7 @@
<ClassAttributeBar _class={object._class} {object} {ignoreKeys} to={undefined} {allowedCollections} on:update />
{#each mixins as mixin}
{@const to = !hierarchy.hasMixin(mixin, setting.mixin.UserMixin) ? object._class : mixin.extends}
{#if !hierarchy.hasMixin(mixin, setting.mixin.Editable) || hierarchy.as(mixin, setting.mixin.Editable).value}
<ClassAttributeBar _class={mixin._id} {object} {ignoreKeys} {to} {allowedCollections} on:update />
{/if}
{/each}

View File

@ -32,7 +32,7 @@
import view from '@anticrm/view'
import { createEventDispatcher, onDestroy } from 'svelte'
import { ContextMenu } from '..'
import { fieldsFilter, getCollectionCounter, getFiltredKeys } from '../utils'
import { categorizeFields, getCollectionCounter, getFiltredKeys } from '../utils'
import ActionContext from './ActionContext.svelte'
import DocAttributeBar from './DocAttributeBar.svelte'
import UpDownNavigator from './UpDownNavigator.svelte'
@ -112,12 +112,13 @@
}
}
const filtredKeys = Array.from(keysMap.values())
keys = fieldsFilter(hierarchy, filtredKeys, false, allowedCollections).map((it) => it.key)
const fieldKeys = fieldsFilter(hierarchy, filtredKeys, true, collectionArrays)
const { attributes, collections } = categorizeFields(hierarchy, filtredKeys, collectionArrays, allowedCollections)
keys = attributes.map((it) => it.key)
const editors: { key: KeyedAttribute; editor: AnyComponent; category: AttributeCategory }[] = []
const newInplaceAttributes = []
for (const k of fieldKeys) {
for (const k of collections) {
if (allowedCollections.includes(k.key.key)) continue
const editor = await getFieldEditor(k.key)
if (editor === undefined) continue

View File

@ -406,20 +406,44 @@ export function getFiltredKeys (
return filterKeys(hierarchy, keys, ignoreKeys)
}
export function fieldsFilter (
export interface CategoryKey {
key: KeyedAttribute
category: AttributeCategory
}
export function categorizeFields (
hierarchy: Hierarchy,
keys: KeyedAttribute[],
get: boolean,
include: string[]
): Array<{ key: KeyedAttribute, category: AttributeCategory }> {
const result: Array<{ key: KeyedAttribute, category: AttributeCategory }> = []
useAsCollection: string[],
useAsAttribute: string[]
): {
attributes: CategoryKey[]
collections: CategoryKey[]
} {
const result = {
attributes: [] as CategoryKey[],
collections: [] as CategoryKey[]
}
for (const key of keys) {
const cl = getAttributePresenterClass(hierarchy, key.attr)
if (include.includes(key.key)) {
result.push({ key: key, category: cl.category })
} else if ((cl.category === 'collection') === get || (cl.category === 'inplace') === get) {
result.push({ key, category: cl.category })
if (useAsCollection.includes(key.key)) {
result.collections.push({ key, category: cl.category })
} else if (useAsAttribute.includes(key.key)) {
result.attributes.push({ key, category: cl.category })
} else if (cl.category === 'collection' || cl.category === 'inplace') {
result.collections.push({ key, category: cl.category })
} else if (cl.category === 'array') {
const attrClass = getAttributePresenterClass(hierarchy, key.attr)
const clazz = hierarchy.getClass(attrClass.attrClass)
const mix = hierarchy.as(clazz, view.mixin.ArrayEditor)
if (mix.editor !== undefined && mix.inlineEditor === undefined) {
result.collections.push({ key, category: cl.category })
} else {
result.attributes.push({ key, category: cl.category })
}
} else {
result.attributes.push({ key, category: cl.category })
}
}
return result

View File

@ -32,6 +32,7 @@
"@anticrm/recruit": "~0.6.3",
"@anticrm/view": "~0.6.0",
"@anticrm/login": "~0.6.1",
"@anticrm/workbench": "~0.6.1"
"@anticrm/workbench": "~0.6.1",
"@anticrm/contact": "~0.6.5"
}
}

View File

@ -13,12 +13,14 @@
// limitations under the License.
//
import recruit, { Applicant, recruitId, Vacancy } from '@anticrm/recruit'
import { Doc } from '@anticrm/core'
import contact from '@anticrm/contact'
import core, { Doc, Tx, TxCreateDoc, TxProcessor, TxRemoveDoc, TxUpdateDoc } from '@anticrm/core'
import login from '@anticrm/login'
import { getMetadata } from '@anticrm/platform'
import { workbenchId } from '@anticrm/workbench'
import recruit, { Applicant, recruitId, Vacancy } from '@anticrm/recruit'
import { TriggerControl } from '@anticrm/server-core'
import view from '@anticrm/view'
import { workbenchId } from '@anticrm/workbench'
/**
* @public
@ -54,6 +56,106 @@ export function applicationTextPresenter (doc: Doc): string {
return `APP-${applicant.number}`
}
/**
* @public
*/
export async function OnVacancyUpdate (tx: Tx, control: TriggerControl): Promise<Tx[]> {
const actualTx = TxProcessor.extractTx(tx)
if (actualTx._class === core.class.TxCreateDoc) {
const createTx = actualTx as TxCreateDoc<Vacancy>
if (control.hierarchy.isDerived(createTx.objectClass, recruit.class.Vacancy)) {
const vacancy = TxProcessor.createDoc2Doc(createTx)
const res: Tx[] = []
if (vacancy.company !== undefined) {
return [
control.txFactory.createTxMixin(
vacancy.company,
contact.class.Organization,
contact.space.Contacts,
recruit.mixin.VacancyList,
{
$inc: { vacancies: 1 }
}
)
]
}
return res
}
}
if (actualTx._class === core.class.TxUpdateDoc) {
const updateTx = actualTx as TxUpdateDoc<Vacancy>
if (control.hierarchy.isDerived(updateTx.objectClass, recruit.class.Vacancy)) {
if (updateTx.operations.company !== undefined) {
// It could be null or new value
const txes = await control.findAll(core.class.TxCUD, {
objectId: updateTx.objectId,
_id: { $nin: [updateTx._id] }
})
const vacancy = TxProcessor.buildDoc2Doc(txes) as Vacancy
const res: Tx[] = []
if (vacancy.company != null) {
// We have old value
res.push(
control.txFactory.createTxMixin(
vacancy.company,
contact.class.Organization,
contact.space.Contacts,
recruit.mixin.VacancyList,
{
$inc: { vacancies: -1 }
}
)
)
}
if (updateTx.operations.company !== null) {
res.push(
control.txFactory.createTxMixin(
updateTx.operations.company,
contact.class.Organization,
contact.space.Contacts,
recruit.mixin.VacancyList,
{
$inc: { vacancies: 1 }
}
)
)
}
return res
}
}
}
if (actualTx._class === core.class.TxRemoveDoc) {
const removeTx = actualTx as TxRemoveDoc<Vacancy>
if (control.hierarchy.isDerived(removeTx.objectClass, recruit.class.Vacancy)) {
// It could be null or new value
const txes = await control.findAll(core.class.TxCUD, {
objectId: removeTx.objectId,
_id: { $nin: [removeTx._id] }
})
const vacancy = TxProcessor.buildDoc2Doc(txes) as Vacancy
const res: Tx[] = []
if (vacancy.company != null) {
// We have old value
res.push(
control.txFactory.createTxMixin(
vacancy.company,
contact.class.Organization,
contact.space.Contacts,
recruit.mixin.VacancyList,
{
$inc: { vacancies: -1 }
}
)
)
}
return []
}
}
return []
}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export default async () => ({
function: {
@ -61,5 +163,8 @@ export default async () => ({
VacancyTextPresenter: vacancyTextPresenter,
ApplicationHTMLPresenter: applicationHTMLPresenter,
ApplicationTextPresenter: applicationTextPresenter
},
trigger: {
OnVacancyUpdate
}
})

View File

@ -16,6 +16,7 @@
import type { Resource, Plugin } from '@anticrm/platform'
import { plugin } from '@anticrm/platform'
import { Doc } from '@anticrm/core'
import { TriggerFunc } from '@anticrm/server-core'
/**
* @public
@ -31,5 +32,8 @@ export default plugin(serverRecruitId, {
ApplicationTextPresenter: '' as Resource<(doc: Doc) => string>,
VacancyHTMLPresenter: '' as Resource<(doc: Doc) => string>,
VacancyTextPresenter: '' as Resource<(doc: Doc) => string>
},
trigger: {
OnVacancyUpdate: '' as Resource<TriggerFunc>
}
})