Allow to create customer for Organization (#1696)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2022-05-09 00:55:51 +07:00 committed by GitHub
parent 6c8afa1fda
commit 9266a61c98
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 191 additions and 74 deletions

View File

@ -149,10 +149,11 @@ export function createModel (builder: Builder): void {
descriptor: view.viewlet.Table, descriptor: view.viewlet.Table,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
options: { options: {
lookup: { _id: { channels: contact.class.Channel } } lookup: { _id: { channels: contact.class.Channel }, _class: core.class.Class }
}, },
config: [ config: [
'', '',
'$lookup._class.label',
'city', 'city',
{ {
presenter: attachment.component.AttachmentsPresenter, presenter: attachment.component.AttachmentsPresenter,

View File

@ -20,7 +20,7 @@ import type { Customer, Funnel, Lead } from '@anticrm/lead'
import { Builder, Collection, Index, Mixin, Model, Prop, TypeRef, TypeString, UX } from '@anticrm/model' import { Builder, Collection, Index, Mixin, Model, Prop, TypeRef, TypeString, UX } from '@anticrm/model'
import attachment from '@anticrm/model-attachment' import attachment from '@anticrm/model-attachment'
import chunter from '@anticrm/model-chunter' import chunter from '@anticrm/model-chunter'
import contact, { TPerson } from '@anticrm/model-contact' import contact, { TContact } from '@anticrm/model-contact'
import core from '@anticrm/model-core' import core from '@anticrm/model-core'
import task, { TSpaceWithStates, TTask } from '@anticrm/model-task' import task, { TSpaceWithStates, TTask } from '@anticrm/model-task'
import view, { createAction } from '@anticrm/model-view' import view, { createAction } from '@anticrm/model-view'
@ -53,7 +53,7 @@ export class TLead extends TTask implements Lead {
@Mixin(lead.mixin.Customer, contact.class.Contact) @Mixin(lead.mixin.Customer, contact.class.Contact)
@UX(lead.string.Customer, lead.icon.LeadApplication) @UX(lead.string.Customer, lead.icon.LeadApplication)
export class TCustomer extends TPerson implements Customer { export class TCustomer extends TContact implements Customer {
@Prop(Collection(lead.class.Lead), lead.string.Leads) @Prop(Collection(lead.class.Lead), lead.string.Leads)
leads?: number leads?: number
@ -112,7 +112,8 @@ export function createModel (builder: Builder): void {
createComponent: lead.component.CreateFunnel createComponent: lead.component.CreateFunnel
} }
] ]
} },
navHeaderComponent: lead.component.NewItemsHeader
}, },
lead.app.Lead lead.app.Lead
) )
@ -122,10 +123,11 @@ export function createModel (builder: Builder): void {
descriptor: view.viewlet.Table, descriptor: view.viewlet.Table,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
options: { options: {
lookup: { _id: { channels: contact.class.Channel } } as any lookup: { _id: { channels: contact.class.Channel }, _class: core.class.Class }
} as FindOptions<Doc>, // TODO: fix } as FindOptions<Doc>, // TODO: fix
config: [ config: [
'', '',
'$lookup._class.label',
{ key: 'leads', presenter: lead.component.LeadsPresenter, label: lead.string.Leads }, { key: 'leads', presenter: lead.component.LeadsPresenter, label: lead.string.Leads },
'modifiedOn', 'modifiedOn',
'$lookup.channels' '$lookup.channels'

View File

@ -42,7 +42,8 @@ export default mergeIds(leadId, lead, {
LeadPresenter: '' as AnyComponent, LeadPresenter: '' as AnyComponent,
TemplatesIcon: '' as AnyComponent, TemplatesIcon: '' as AnyComponent,
Customers: '' as AnyComponent, Customers: '' as AnyComponent,
Leads: '' as AnyComponent Leads: '' as AnyComponent,
NewItemsHeader: '' as AnyComponent
}, },
space: { space: {
DefaultFunnel: '' as Ref<Space> DefaultFunnel: '' as Ref<Space>

View File

@ -41,10 +41,10 @@
<form class="antiCard dialog" on:submit|preventDefault={() => {}}> <form class="antiCard dialog" on:submit|preventDefault={() => {}}>
<div class="antiCard-header"> <div class="antiCard-header">
<div class="antiCard-header__title-wrap"> <div class="antiCard-header__title-wrap">
{#if spaceClass && spaceLabel && spacePlaceholder} {#if (spaceClass && spaceLabel && spacePlaceholder) || $$slots.space}
{#if $$slots.space} {#if $$slots.space}
<slot name="space" /> <slot name="space" />
{:else} {:else if spaceClass && spaceLabel && spacePlaceholder}
<SpaceSelect _class={spaceClass} {spaceQuery} label={spaceLabel} bind:value={space} /> <SpaceSelect _class={spaceClass} {spaceQuery} label={spaceLabel} bind:value={space} />
{/if} {/if}
<span class="antiCard-header__divider"></span> <span class="antiCard-header__divider"></span>
@ -78,7 +78,9 @@
kind={'primary'} kind={'primary'}
on:click={() => { on:click={() => {
okAction() okAction()
dispatch('close') if (!createMore) {
dispatch('close')
}
}} }}
/> />
</div> </div>

View File

@ -77,7 +77,7 @@
display: flex; display: flex;
justify-content: stretch; justify-content: stretch;
align-items: stretch; align-items: stretch;
margin: .5rem .75rem .75rem; margin: .5rem 0.5rem .75rem 1rem;
} }
.antiNav-element { .antiNav-element {
flex-shrink: 0; flex-shrink: 0;

View File

@ -9,4 +9,10 @@
<symbol id="lead" viewBox="0 0 16 16"> <symbol id="lead" viewBox="0 0 16 16">
<path d="M13.2,9.4h-2.8l0.3-2.7h2.5c0.3,0,0.5-0.2,0.5-0.5s-0.2-0.5-0.5-0.5h-2.4l0.4-3.2c0-0.3-0.2-0.5-0.4-0.6 c-0.3,0-0.5,0.2-0.6,0.4L9.8,5.6H7.1l0.4-3.2c0-0.3-0.2-0.5-0.4-0.6c-0.3,0-0.5,0.2-0.6,0.4L6.1,5.6H3.2c-0.3,0-0.5,0.2-0.5,0.5 s0.2,0.5,0.5,0.5h2.8L5.6,9.4H3.2c-0.3,0-0.5,0.2-0.5,0.5s0.2,0.5,0.5,0.5h2.4l-0.4,3.2c0,0.3,0.2,0.5,0.4,0.6c0,0,0,0,0.1,0 c0.3,0,0.5-0.2,0.5-0.4l0.4-3.3h2.7l-0.4,3.2c0,0.3,0.2,0.5,0.4,0.6c0,0,0,0,0.1,0c0.3,0,0.5-0.2,0.5-0.4l0.4-3.3h2.9 c0.3,0,0.5-0.2,0.5-0.5S13.4,9.4,13.2,9.4z M6.6,9.4l0.3-2.7h2.7L9.4,9.4H6.6z"/> <path d="M13.2,9.4h-2.8l0.3-2.7h2.5c0.3,0,0.5-0.2,0.5-0.5s-0.2-0.5-0.5-0.5h-2.4l0.4-3.2c0-0.3-0.2-0.5-0.4-0.6 c-0.3,0-0.5,0.2-0.6,0.4L9.8,5.6H7.1l0.4-3.2c0-0.3-0.2-0.5-0.4-0.6c-0.3,0-0.5,0.2-0.6,0.4L6.1,5.6H3.2c-0.3,0-0.5,0.2-0.5,0.5 s0.2,0.5,0.5,0.5h2.8L5.6,9.4H3.2c-0.3,0-0.5,0.2-0.5,0.5s0.2,0.5,0.5,0.5h2.4l-0.4,3.2c0,0.3,0.2,0.5,0.4,0.6c0,0,0,0,0.1,0 c0.3,0,0.5-0.2,0.5-0.4l0.4-3.3h2.7l-0.4,3.2c0,0.3,0.2,0.5,0.4,0.6c0,0,0,0,0.1,0c0.3,0,0.5-0.2,0.5-0.4l0.4-3.3h2.9 c0.3,0,0.5-0.2,0.5-0.5S13.4,9.4,13.2,9.4z M6.6,9.4l0.3-2.7h2.7L9.4,9.4H6.6z"/>
</symbol> </symbol>
<symbol id="new-customer" viewBox="0 0 16 16">
<path d="M14.7826 3.26359C15.1313 2.69123 15.0606 1.93115 14.5705 1.43492C14.0757 0.933932 13.3153 0.865765 12.7483 1.23041C13.2123 2.09277 13.9198 2.79999 14.7826 3.26359Z"/>
<path d="M11.8171 2.11829L6.78472 7.18C7.77457 7.47517 8.57699 8.21908 8.95006 9.18198L13.9064 4.20208C13.0535 3.68436 12.3369 2.9696 11.8171 2.11829Z"/>
<path d="M7.91486 10.1761C7.80538 9.1186 6.91913 8.30487 5.8592 8.29183C5.53827 8.92804 5.21105 9.90848 5.01729 10.5311C4.93355 10.8002 5.16675 11.0527 5.44262 10.9905C6.16831 10.8268 7.36057 10.5217 7.91486 10.1761Z"/>
<path d="M4.75 2C2.67893 2 1 3.67893 1 5.75V11.25C1 13.3211 2.67893 15 4.75 15H10.2501C12.3212 15 14.0001 13.3211 14.0001 11.25V8C14.0001 7.58579 13.6643 7.25 13.2501 7.25C12.8359 7.25 12.5001 7.58579 12.5001 8V11.25C12.5001 12.4926 11.4927 13.5 10.2501 13.5H4.75C3.50736 13.5 2.5 12.4926 2.5 11.25V5.75C2.5 4.50736 3.50736 3.5 4.75 3.5H7C7.41421 3.5 7.75 3.16421 7.75 2.75C7.75 2.33579 7.41421 2 7 2H4.75Z"/>
</symbol>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@ -21,7 +21,8 @@ const icons = require('../assets/icons.svg') as string // eslint-disable-line
loadMetadata(lead.icon, { loadMetadata(lead.icon, {
Funnel: `${icons}#funnel`, Funnel: `${icons}#funnel`,
Lead: `${icons}#lead`, Lead: `${icons}#lead`,
LeadApplication: `${icons}#leadapplication` LeadApplication: `${icons}#leadapplication`,
CreateCustomer: `${icons}#new-customer`
}) })
addStringsLoader(leadId, async (lang: string) => await import(`../lang/${lang}.json`)) addStringsLoader(leadId, async (lang: string) => await import(`../lang/${lang}.json`))

View File

@ -14,53 +14,58 @@
--> -->
<script lang="ts"> <script lang="ts">
import attachment from '@anticrm/attachment' import attachment from '@anticrm/attachment'
import { Channel, combineName, findPerson, Person } from '@anticrm/contact' import { Channel, combineName, Contact, findPerson } from '@anticrm/contact'
import { ChannelsDropdown } from '@anticrm/contact-resources' import { ChannelsDropdown } from '@anticrm/contact-resources'
import PersonPresenter from '@anticrm/contact-resources/src/components/PersonPresenter.svelte' import PersonPresenter from '@anticrm/contact-resources/src/components/PersonPresenter.svelte'
import contact from '@anticrm/contact-resources/src/plugin' import contact from '@anticrm/contact-resources/src/plugin'
import { AttachedData, Data, generateId, MixinData, Ref } from '@anticrm/core' import { AttachedData, Class, Data, Doc, generateId, MixinData, Ref } from '@anticrm/core'
import type { Customer } from '@anticrm/lead' import type { Customer } from '@anticrm/lead'
import { getResource } from '@anticrm/platform' import { getResource } from '@anticrm/platform'
import { Card, EditableAvatar, getClient } from '@anticrm/presentation' import { Card, EditableAvatar, getClient } from '@anticrm/presentation'
import { EditBox, IconInfo, Label } from '@anticrm/ui' import { Button, EditBox, eventToHTMLElement, IconInfo, Label, SelectPopup, showPopup } from '@anticrm/ui'
import { createEventDispatcher } from 'svelte' import { createEventDispatcher } from 'svelte'
import lead from '../plugin' import lead from '../plugin'
let firstName = '' let firstName = ''
let lastName = '' let lastName = ''
let createMore: boolean = false
export function canClose (): boolean { export function canClose (): boolean {
return firstName === '' && lastName === '' return firstName === '' && lastName === ''
} }
const object: Customer = { let object: Customer = {
_class: contact.class.Person _class: contact.class.Person
} as Customer } as Customer
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const client = getClient() const client = getClient()
const customerId = generateId() let customerId = generateId()
let channels: AttachedData<Channel>[] = [] let channels: AttachedData<Channel>[] = []
let avatar: File | undefined let avatar: File | undefined
function formatName (targetClass: Ref<Class<Doc>>, firstName: string, lastName: string, objectName: string): string {
return targetClass === contact.class.Person ? combineName(firstName, lastName) : objectName
}
async function createCustomer () { async function createCustomer () {
const candidate: Data<Person> = { const candidate: Data<Contact> = {
name: combineName(firstName, lastName), name: formatName(targetClass._id, firstName, lastName, object.name),
city: object.city city: object.city
} }
if (avatar !== undefined) { if (avatar !== undefined) {
const uploadFile = await getResource(attachment.helper.UploadFile) const uploadFile = await getResource(attachment.helper.UploadFile)
candidate.avatar = await uploadFile(avatar) candidate.avatar = await uploadFile(avatar)
} }
const candidateData: MixinData<Person, Customer> = { const candidateData: MixinData<Contact, Customer> = {
description: object.description description: object.description
} }
const id = await client.createDoc(contact.class.Person, contact.space.Contacts, candidate, customerId) const id = await client.createDoc(targetClass._id, contact.space.Contacts, candidate, customerId)
await client.createMixin( await client.createMixin(
id as Ref<Person>, id as Ref<Contact>,
contact.class.Person, targetClass._id,
contact.space.Contacts, contact.space.Contacts,
lead.mixin.Customer, lead.mixin.Customer,
candidateData candidateData
@ -71,7 +76,7 @@
contact.class.Channel, contact.class.Channel,
contact.space.Contacts, contact.space.Contacts,
customerId, customerId,
contact.class.Person, targetClass._id,
'channels', 'channels',
{ {
value: channel.value, value: channel.value,
@ -80,7 +85,17 @@
) )
} }
dispatch('close') if (createMore) {
// Prepare for next
object = {
_class: targetClass._id
} as Customer
customerId = generateId()
avatar = undefined
firstName = ''
lastName = ''
channels = []
}
} }
function onAvatarDone (e: any) { function onAvatarDone (e: any) {
@ -89,65 +104,127 @@
avatar = file avatar = file
} }
let matches: Person[] = [] let matches: Contact[] = []
$: findPerson(client, { ...object, name: combineName(firstName, lastName) }, channels).then((p) => { $: findPerson(
client,
{ ...object, name: formatName(targetClass._id, firstName, lastName, object.name) },
channels
).then((p) => {
matches = p matches = p
}) })
function removeAvatar (): void { function removeAvatar (): void {
avatar = undefined avatar = undefined
} }
const targets = [
client.getModel().getObject(contact.class.Person),
client.getModel().getObject(contact.class.Organization)
]
let targetClass = targets[0]
function selectTarget (evt: MouseEvent): void {
showPopup(
SelectPopup,
{
value: targets.map((it) => ({ id: it._id, label: it.label, icon: it.icon })),
placeholder: contact.string.Contacts,
searchable: false
},
eventToHTMLElement(evt),
(ref) => {
if (ref != null) {
const newT = targets.find((it) => it._id === ref)
if (newT !== undefined) {
if (targetClass._id !== newT._id) {
targetClass = newT
object.name = ''
firstName = ''
lastName = ''
customerId = generateId()
avatar = undefined
}
}
}
}
)
}
$: canSave = formatName(targetClass._id, firstName, lastName, object.name).length > 0
</script> </script>
<Card <Card
label={lead.string.CreateCustomer} label={lead.string.CreateCustomer}
okAction={createCustomer} okAction={createCustomer}
canSave={firstName.length > 0 && lastName.length > 0 && matches.length === 0} {canSave}
space={contact.space.Contacts} space={contact.space.Contacts}
on:close={() => { on:close={() => {
dispatch('close') dispatch('close')
}} }}
bind:createMore
> >
<div class="flex-between flex-row-top"> <svelte:fragment slot="space">
<div class="flex-col flex-grow"> <Button
<EditBox icon={targetClass.icon}
placeholder={contact.string.PersonFirstNamePlaceholder} label={targetClass.label}
bind:value={firstName} size={'small'}
kind={'large-style'} kind={'no-border'}
maxWidth={'32rem'} on:click={selectTarget}
focus />
/> </svelte:fragment>
<EditBox {#if targetClass._id === contact.class.Person}
placeholder={contact.string.PersonLastNamePlaceholder} <div class="flex-between flex-row-top mt-2 mb-2">
bind:value={lastName} <div class="flex-col flex-grow">
kind={'large-style'}
maxWidth={'32rem'}
/>
<div class="mt-1">
<EditBox <EditBox
placeholder={contact.string.PersonLocationPlaceholder} placeholder={contact.string.PersonFirstNamePlaceholder}
bind:value={object.city} bind:value={firstName}
kind={'large-style'}
maxWidth={'32rem'}
focus
/>
<EditBox
placeholder={contact.string.PersonLastNamePlaceholder}
bind:value={lastName}
kind={'large-style'}
maxWidth={'32rem'}
/>
<div class="mt-1">
<EditBox
placeholder={contact.string.PersonLocationPlaceholder}
bind:value={object.city}
kind={'small-style'}
maxWidth={'32rem'}
/>
</div>
<EditBox
placeholder={lead.string.IssueDescriptionPlaceholder}
bind:value={object.description}
kind={'small-style'} kind={'small-style'}
maxWidth={'32rem'} maxWidth={'32rem'}
/> />
</div> </div>
<div class="ml-4 flex">
<EditableAvatar
bind:direct={avatar}
avatar={object.avatar}
size={'large'}
on:remove={removeAvatar}
on:done={onAvatarDone}
/>
</div>
</div>
{:else}
<div class="flex-row-center clear-mins mt-2 mb-2">
<div class="mr-3">
<Button icon={contact.icon.Company} size={'medium'} kind={'link-bordered'} disabled />
</div>
<EditBox <EditBox
placeholder={lead.string.IssueDescriptionPlaceholder} placeholder={contact.string.OrganizationNamePlaceholder}
bind:value={object.description} bind:value={object.name}
kind={'small-style'} maxWidth={'37.5rem'}
maxWidth={'32rem'} kind={'large-style'}
focus
/> />
</div> </div>
<div class="ml-4 flex"> {/if}
<EditableAvatar
bind:direct={avatar}
avatar={object.avatar}
size={'large'}
on:remove={removeAvatar}
on:done={onAvatarDone}
/>
</div>
</div>
<svelte:fragment slot="pool"> <svelte:fragment slot="pool">
<ChannelsDropdown bind:value={channels} editable /> <ChannelsDropdown bind:value={channels} editable />
</svelte:fragment> </svelte:fragment>

View File

@ -16,11 +16,10 @@
<script lang="ts"> <script lang="ts">
import { Doc, DocumentQuery } from '@anticrm/core' import { Doc, DocumentQuery } from '@anticrm/core'
import { getClient } from '@anticrm/presentation' import { getClient } from '@anticrm/presentation'
import { Button, Icon, IconAdd, Label, Scroller, SearchEdit, showPopup } from '@anticrm/ui' import { Icon, Label, Scroller, SearchEdit } from '@anticrm/ui'
import view, { Viewlet } from '@anticrm/view' import view, { Viewlet } from '@anticrm/view'
import { Table } from '@anticrm/view-resources' import { Table } from '@anticrm/view-resources'
import lead from '../plugin' import lead from '../plugin'
import CreateCustomer from './CreateCustomer.svelte'
let search = '' let search = ''
let resultQuery: DocumentQuery<Doc> = {} let resultQuery: DocumentQuery<Doc> = {}
@ -34,10 +33,6 @@
function updateResultQuery (search: string): void { function updateResultQuery (search: string): void {
resultQuery = search === '' ? {} : { $search: search } resultQuery = search === '' ? {} : { $search: search }
} }
function showCreateDialog (ev: Event) {
showPopup(CreateCustomer, {}, 'top')
}
</script> </script>
<div class="ac-header full"> <div class="ac-header full">
@ -52,13 +47,6 @@
updateResultQuery(search) updateResultQuery(search)
}} }}
/> />
<Button
icon={IconAdd}
label={lead.string.CreateCustomerLabel}
kind={'primary'}
on:click={(ev) => showCreateDialog(ev)}
/>
</div> </div>
<Scroller tableFade> <Scroller tableFade>

View File

@ -0,0 +1,36 @@
<!--
// 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 { Button, showPopup } from '@anticrm/ui'
import lead from '../plugin'
import CreateCustomer from './CreateCustomer.svelte'
async function newIssue (): Promise<void> {
showPopup(CreateCustomer, {}, 'top')
}
</script>
<div class="antiNav-subheader gap-2">
<div class="flex-grow text-md">
<Button
icon={lead.icon.CreateCustomer}
label={lead.string.CreateCustomerLabel}
justify={'left'}
width={'100%'}
on:click={newIssue}
/>
</div>
<!-- <Button icon={lead.icon.Magnifier} on:click={async () => {}} /> -->
</div>

View File

@ -25,6 +25,7 @@ import Leads from './components/Leads.svelte'
import LeadsPresenter from './components/LeadsPresenter.svelte' import LeadsPresenter from './components/LeadsPresenter.svelte'
import TemplatesIcon from './components/TemplatesIcon.svelte' import TemplatesIcon from './components/TemplatesIcon.svelte'
import CreateCustomer from './components/CreateCustomer.svelte' import CreateCustomer from './components/CreateCustomer.svelte'
import NewItemsHeader from './components/NewItemsHeader.svelte'
export default async (): Promise<Resources> => ({ export default async (): Promise<Resources> => ({
component: { component: {
@ -37,6 +38,7 @@ export default async (): Promise<Resources> => ({
Customers, Customers,
LeadsPresenter, LeadsPresenter,
Leads, Leads,
CreateCustomer CreateCustomer,
NewItemsHeader
} }
}) })

View File

@ -70,7 +70,8 @@ const lead = plugin(leadId, {
icon: { icon: {
Funnel: '' as Asset, Funnel: '' as Asset,
Lead: '' as Asset, Lead: '' as Asset,
LeadApplication: '' as Asset LeadApplication: '' as Asset,
CreateCustomer: '' as Asset
}, },
space: { space: {
FunnelTemplates: '' as Ref<KanbanTemplateSpace> FunnelTemplates: '' as Ref<KanbanTemplateSpace>