Merge pull request #435 from shariquerik/contact-organization-page

This commit is contained in:
Shariq Ansari 2024-10-31 15:55:58 +05:30 committed by GitHub
commit bb8c2322e4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 2029 additions and 715 deletions

View File

@ -144,6 +144,14 @@ def add_default_fields_layout(force=False):
"doctype": "CRM Deal",
"layout": '[{"label":"Contacts","name":"contacts_section","opened":true,"editable":false,"contacts":[]},{"label":"Organization Details","name":"organization_tab","opened":true,"fields":["organization","website","territory","annual_revenue","close_date","probability","next_step","deal_owner"]}]'
},
"Contact-Side Panel": {
"doctype": "Contact",
"layout": '[{"label":"Details","name":"details","opened":true,"fields":["salutation","first_name","last_name","email_id","mobile_no","gender","company_name","designation","address"]}]'
},
"CRM Organization-Side Panel": {
"doctype": "CRM Organization",
"layout": '[{"label":"Details","name":"details","opened":true,"fields":["organization_name","website","territory","industry","no_of_employees","address"]}]'
},
}
for layout in quick_entry_layouts:

View File

@ -6,6 +6,6 @@ crm.patches.v1_0.move_crm_note_data_to_fcrm_note
[post_model_sync]
# Patches added in this section will be executed after doctypes are migrated
crm.patches.v1_0.create_email_template_custom_fields
crm.patches.v1_0.create_default_fields_layout #13/09/2024
crm.patches.v1_0.create_default_fields_layout #31/10/2024
crm.patches.v1_0.create_default_sidebar_fields_layout
crm.patches.v1_0.update_deal_quick_entry_layout

View File

@ -2,7 +2,7 @@
<div
class="group flex w-full items-center justify-between rounded bg-transparent p-1 pl-2 text-base text-gray-800 transition-colors hover:bg-gray-200 active:bg-gray-300"
>
<div class="flex items-center justify-between gap-7">
<div class="flex flex-1 items-center justify-between gap-7">
<div v-show="!editMode">{{ option.value }}</div>
<TextInput
ref="inputRef"
@ -15,6 +15,14 @@
/>
<div class="actions flex items-center justify-center">
<Button
v-if="editMode"
variant="ghost"
:label="__('Save')"
size="sm"
class="opacity-0 hover:bg-gray-300 group-hover:opacity-100"
@click="saveOption"
/>
<Tooltip text="Set As Primary" v-if="!isNew && !option.selected">
<div>
<Button
@ -27,7 +35,7 @@
</Button>
</div>
</Tooltip>
<Tooltip text="Edit">
<Tooltip v-if="!editMode" text="Edit">
<div>
<Button
variant="ghost"
@ -52,13 +60,8 @@
</Tooltip>
</div>
</div>
<div>
<FeatherIcon
v-if="option.selected"
name="check"
class="text-primary-500 h-4 w-6"
size="sm"
/>
<div v-if="option.selected">
<FeatherIcon name="check" class="text-primary-500 h-4 w-6" size="sm" />
</div>
</div>
</template>
@ -93,7 +96,8 @@ const toggleEditMode = () => {
editMode.value && nextTick(() => inputRef.value.el.focus())
}
const saveOption = () => {
const saveOption = (e) => {
if (!e.target.value) return
toggleEditMode()
props.option.onSave(props.option, isNew.value)
isNew.value = false

View File

@ -130,55 +130,6 @@
</Tooltip>
</template>
</Link>
<div v-else-if="field.type === 'Dropdown'">
<NestedPopover>
<template #target="{ open }">
<Button
:label="data[field.name]"
class="dropdown-button flex w-full items-center justify-between rounded border border-gray-100 bg-gray-100 px-2 py-1.5 text-base text-gray-800 placeholder-gray-500 transition-colors hover:border-gray-200 hover:bg-gray-200 focus:border-gray-500 focus:bg-white focus:shadow-sm focus:outline-none focus:ring-0 focus-visible:ring-2 focus-visible:ring-gray-400"
>
<div class="truncate">{{ data[field.name] }}</div>
<template #suffix>
<FeatherIcon
:name="open ? 'chevron-up' : 'chevron-down'"
class="h-4 text-gray-600"
/>
</template>
</Button>
</template>
<template #body>
<div
class="my-2 space-y-1.5 divide-y rounded-lg border border-gray-100 bg-white p-1.5 shadow-xl"
>
<div>
<DropdownItem
v-if="field.options?.length"
v-for="option in field.options"
:key="option.name"
:option="option"
/>
<div v-else>
<div class="p-1.5 px-7 text-base text-gray-500">
{{ __('No {0} Available', [field.label]) }}
</div>
</div>
</div>
<div class="pt-1.5">
<Button
variant="ghost"
class="w-full !justify-start"
:label="__('Create New')"
@click="field.create()"
>
<template #prefix>
<FeatherIcon name="plus" class="h-4" />
</template>
</Button>
</div>
</div>
</template>
</NestedPopover>
</div>
<DateTimePicker
v-else-if="field.type === 'Datetime'"
v-model="data[field.name]"
@ -221,8 +172,6 @@
<script setup>
import EditIcon from '@/components/Icons/EditIcon.vue'
import NestedPopover from '@/components/NestedPopover.vue'
import DropdownItem from '@/components/DropdownItem.vue'
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import Link from '@/components/Controls/Link.vue'

View File

@ -1,20 +1,16 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-list-collapse"
xmlns="http://www.w3.org/2000/svg"
>
<path d="m3 10 2.5-2.5L3 5" />
<path d="m3 19 2.5-2.5L3 14" />
<path d="M10 6h11" />
<path d="M10 12h11" />
<path d="M10 18h11" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M11.7499 2H4.24988C3.42145 2 2.74988 2.67157 2.74988 3.5V12.5C2.74988 13.3284 3.42145 14 4.24988 14H11.7499C12.5783 14 13.2499 13.3284 13.2499 12.5V3.5C13.2499 2.67157 12.5783 2 11.7499 2ZM4.24988 1C2.86917 1 1.74988 2.11929 1.74988 3.5V12.5C1.74988 13.8807 2.86917 15 4.24988 15H11.7499C13.1306 15 14.2499 13.8807 14.2499 12.5V3.5C14.2499 2.11929 13.1306 1 11.7499 1H4.24988ZM5.99997 8.125C5.99997 8.47018 5.72015 8.75 5.37497 8.75C5.02979 8.75 4.74997 8.47018 4.74997 8.125C4.74997 7.77982 5.02979 7.5 5.37497 7.5C5.72015 7.5 5.99997 7.77982 5.99997 8.125ZM5.37497 5.75C5.72015 5.75 5.99997 5.47018 5.99997 5.125C5.99997 4.77982 5.72015 4.5 5.37497 4.5C5.02979 4.5 4.74997 4.77982 4.74997 5.125C4.74997 5.47018 5.02979 5.75 5.37497 5.75ZM5.99997 11.125C5.99997 11.4702 5.72015 11.75 5.37497 11.75C5.02979 11.75 4.74997 11.4702 4.74997 11.125C4.74997 10.7798 5.02979 10.5 5.37497 10.5C5.72015 10.5 5.99997 10.7798 5.99997 11.125ZM7.24997 4.625C6.97383 4.625 6.74997 4.84886 6.74997 5.125C6.74997 5.40114 6.97383 5.625 7.24997 5.625H11.25C11.5261 5.625 11.75 5.40114 11.75 5.125C11.75 4.84886 11.5261 4.625 11.25 4.625H7.24997ZM6.74997 8.125C6.74997 7.84886 6.97383 7.625 7.24997 7.625H11.25C11.5261 7.625 11.75 7.84886 11.75 8.125C11.75 8.40114 11.5261 8.625 11.25 8.625H7.24997C6.97383 8.625 6.74997 8.40114 6.74997 8.125ZM7.24997 10.625C6.97383 10.625 6.74997 10.8489 6.74997 11.125C6.74997 11.4011 6.97383 11.625 7.24997 11.625H11.25C11.5261 11.625 11.75 11.4011 11.75 11.125C11.75 10.8489 11.5261 10.625 11.25 10.625H7.24997Z"
fill="currentColor"
/>
</svg>
</template>

View File

@ -0,0 +1,16 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M2.5 3.2002C2.5 2.92405 2.72386 2.7002 3 2.7002H13C13.2761 2.7002 13.5 2.92405 13.5 3.2002C13.5 3.47634 13.2761 3.7002 13 3.7002H3C2.72386 3.7002 2.5 3.47634 2.5 3.2002ZM2.5 8.00024C2.5 7.7241 2.72386 7.50024 3 7.50024H13C13.2761 7.50024 13.5 7.7241 13.5 8.00024C13.5 8.27639 13.2761 8.50024 13 8.50024H3C2.72386 8.50024 2.5 8.27639 2.5 8.00024ZM3 12.3003C2.72386 12.3003 2.5 12.5242 2.5 12.8003C2.5 13.0764 2.72386 13.3003 3 13.3003H13C13.2761 13.3003 13.5 13.0764 13.5 12.8003C13.5 12.5242 13.2761 12.3003 13 12.3003H3Z"
fill="currentColor"
/>
</svg>
</template>

View File

@ -1,7 +1,7 @@
<template>
<Teleport to="#app-header" v-if="showHeader">
<slot>
<header class="flex h-10.5 items-center justify-between py-[7px] pl-5">
<header class="flex h-10.5 items-center justify-between py-[7px] sm:pl-5 pl-2">
<div class="flex items-center gap-2">
<slot name="left-header" />
</div>

View File

@ -1,8 +1,12 @@
<template>
<div class="flex border-b pr-3">
<div class="z-20 -mr-4 ml-1 flex items-center justify-center">
<Button variant="ghosted" @click="sidebarOpened = !sidebarOpened">
<FeatherIcon name="menu" class="size-4" />
<div class="flex pr-3">
<div class="z-20 ml-2 flex items-center justify-center">
<Button
class="size-7"
variant="ghosted"
@click="sidebarOpened = !sidebarOpened"
>
<MenuIcon class="h-4" />
</Button>
</div>
<div id="app-header" class="flex-1" />
@ -11,6 +15,7 @@
</template>
<script setup>
import MenuIcon from '@/components/Icons/MenuIcon.vue'
import CallUI from '@/components/CallUI.vue'
import { mobileSidebarOpened as sidebarOpened } from '@/composables/settings'
</script>

View File

@ -97,7 +97,6 @@ import { usersStore } from '@/stores/users'
import { capture } from '@/telemetry'
import { call, createResource } from 'frappe-ui'
import { ref, nextTick, watch, computed } from 'vue'
import { createToast } from '@/utils'
import { useRouter } from 'vue-router'
const props = defineProps({
@ -259,110 +258,7 @@ const filteredSections = computed(() => {
allSections.forEach((s) => {
s.fields.forEach((field) => {
if (field.name == 'email_id') {
field.type = props.contact?.data?.name ? 'Dropdown' : 'Data'
field.options =
props.contact.data?.email_ids?.map((email) => {
return {
name: email.name,
value: email.email_id,
selected: email.email_id === props.contact.data.email_id,
placeholder: 'john@doe.com',
onClick: () => {
_contact.value.email_id = email.email_id
setAsPrimary('email', email.email_id)
},
onSave: (option, isNew) => {
if (isNew) {
createNew('email', option.value)
if (props.contact.data.email_ids.length === 1) {
_contact.value.email_id = option.value
}
} else {
editOption('Contact Email', option.name, 'email_id', option.value)
}
},
onDelete: async (option, isNew) => {
props.contact.data.email_ids =
props.contact.data.email_ids.filter(
(email) => email.name !== option.name,
)
!isNew && (await deleteOption('Contact Email', option.name))
if (_contact.value.email_id === option.value) {
if (props.contact.data.email_ids.length === 0) {
_contact.value.email_id = ''
} else {
_contact.value.email_id = props.contact.data.email_ids.find(
(email) => email.is_primary,
)?.email_id
}
}
},
}
}) || []
field.create = () => {
props.contact.data?.email_ids?.push({
name: 'new-1',
value: '',
selected: false,
isNew: true,
})
}
} else if (
field.name == 'mobile_no' ||
field.name == 'actual_mobile_no'
) {
field.type = props.contact?.data?.name ? 'Dropdown' : 'Data'
field.name = 'actual_mobile_no'
field.options =
props.contact.data?.phone_nos?.map((phone) => {
return {
name: phone.name,
value: phone.phone,
selected: phone.phone === props.contact.data.actual_mobile_no,
onClick: () => {
_contact.value.actual_mobile_no = phone.phone
_contact.value.mobile_no = phone.phone
setAsPrimary('mobile_no', phone.phone)
},
onSave: (option, isNew) => {
if (isNew) {
createNew('phone', option.value)
if (props.contact.data.phone_nos.length === 1) {
_contact.value.actual_mobile_no = option.value
}
} else {
editOption('Contact Phone', option.name, 'phone', option.value)
}
},
onDelete: async (option, isNew) => {
props.contact.data.phone_nos =
props.contact.data.phone_nos.filter(
(phone) => phone.name !== option.name,
)
!isNew && (await deleteOption('Contact Phone', option.name))
if (_contact.value.actual_mobile_no === option.value) {
if (props.contact.data.phone_nos.length === 0) {
_contact.value.actual_mobile_no = ''
} else {
_contact.value.actual_mobile_no =
props.contact.data.phone_nos.find(
(phone) => phone.is_primary_mobile_no,
)?.phone
}
}
},
}
}) || []
field.create = () => {
props.contact.data?.phone_nos?.push({
name: 'new-1',
value: '',
selected: false,
isNew: true,
})
}
} else if (field.name == 'address') {
if (field.name == 'address') {
field.create = (value, close) => {
_contact.value.address = value
_address.value = {}
@ -383,68 +279,6 @@ const filteredSections = computed(() => {
return allSections
})
async function setAsPrimary(field, value) {
let d = await call('crm.api.contact.set_as_primary', {
contact: props.contact.data.name,
field,
value,
})
if (d) {
props.contact.reload()
createToast({
title: 'Contact updated',
icon: 'check',
iconClasses: 'text-green-600',
})
}
}
async function createNew(field, value) {
let d = await call('crm.api.contact.create_new', {
contact: props.contact.data.name,
field,
value,
})
if (d) {
props.contact.reload()
createToast({
title: 'Contact updated',
icon: 'check',
iconClasses: 'text-green-600',
})
}
}
async function editOption(doctype, name, fieldname, value) {
let d = await call('frappe.client.set_value', {
doctype,
name,
fieldname,
value,
})
if (d) {
props.contact.reload()
createToast({
title: 'Contact updated',
icon: 'check',
iconClasses: 'text-green-600',
})
}
}
async function deleteOption(doctype, name) {
await call('frappe.client.delete', {
doctype,
name,
})
await props.contact.reload()
createToast({
title: 'Contact updated',
icon: 'check',
iconClasses: 'text-green-600',
})
}
const dirty = computed(() => {
return JSON.stringify(props.contact.data) !== JSON.stringify(_contact.value)
})

View File

@ -2,8 +2,12 @@
<div class="relative" :style="{ width: `${sidebarWidth}px` }">
<slot v-bind="{ sidebarResizing, sidebarWidth }" />
<div
class="absolute left-0 z-10 h-full w-1 cursor-col-resize bg-gray-300 opacity-0 transition-opacity hover:opacity-100"
:class="{ 'opacity-100': sidebarResizing }"
class="absolute z-10 h-full w-1 cursor-col-resize bg-gray-300 opacity-0 transition-opacity hover:opacity-100"
:class="{
'opacity-100': sidebarResizing,
'left-0': side == 'right',
'right-0': side == 'left',
}"
@mousedown="startResize"
/>
</div>
@ -81,6 +85,6 @@ function resize(e) {
function distance() {
if (!props.parent) return 0
const rect = props.parent.getBoundingClientRect()
return window.innerWidth - rect[props.side]
return rect[props.side]
}
</script>

View File

@ -10,112 +10,178 @@
class="section-field flex items-center gap-2 px-3 leading-5 first:mt-3"
>
<Tooltip :text="__(field.label)" :hoverDelay="1">
<div class="sm:w-[106px] w-36 shrink-0 truncate text-sm text-gray-600">
<div class="w-[35%] min-w-20 shrink-0 truncate text-sm text-gray-600">
<span>{{ __(field.label) }}</span>
<span class="text-red-500">{{ field.reqd ? ' *' : '' }}</span>
</div>
</Tooltip>
<div
class="grid min-h-[28px] flex-1 items-center overflow-hidden text-base"
>
<div class="flex items-center justify-between w-[65%]">
<div
v-if="field.read_only && field.type !== 'checkbox'"
class="flex h-7 cursor-pointer items-center px-2 py-1 text-gray-600"
class="grid min-h-[28px] flex-1 items-center overflow-hidden text-base"
>
<Tooltip :text="__(field.tooltip)">
<div>{{ data[field.name] }}</div>
</Tooltip>
</div>
<FormControl
v-else-if="field.type == 'checkbox'"
class="form-control"
:type="field.type"
v-model="data[field.name]"
@change.stop="emit('update', field.name, $event.target.checked)"
:disabled="Boolean(field.read_only)"
/>
<FormControl
v-else-if="
['email', 'number', 'date', 'password', 'textarea'].includes(
field.type,
)
"
class="form-control"
:class="{
'[&_input]:text-gray-500':
field.type === 'date' && !data[field.name],
}"
:type="field.type"
:value="data[field.name]"
:placeholder="field.placeholder"
:debounce="500"
@change.stop="emit('update', field.name, $event.target.value)"
/>
<FormControl
v-else-if="field.type === 'select'"
class="form-control cursor-pointer [&_select]:cursor-pointer"
type="select"
v-model="data[field.name]"
:options="field.options"
:placeholder="field.placeholder"
@change.stop="emit('update', field.name, $event.target.value)"
/>
<Link
v-else-if="['lead_owner', 'deal_owner'].includes(field.name)"
class="form-control"
:value="data[field.name] && getUser(data[field.name]).full_name"
doctype="User"
:filters="field.filters"
@change="(data) => emit('update', field.name, data)"
:placeholder="'Select' + ' ' + field.label + '...'"
:hideMe="true"
>
<template v-if="data[field.name]" #prefix>
<UserAvatar class="mr-1.5" :user="data[field.name]" size="sm" />
</template>
<template #item-prefix="{ option }">
<UserAvatar class="mr-1.5" :user="option.value" size="sm" />
</template>
<template #item-label="{ option }">
<Tooltip :text="option.value">
<div class="cursor-pointer">
{{ getUser(option.value).full_name }}
</div>
<div
v-if="
field.read_only && !['checkbox', 'dropdown'].includes(field.type)
"
class="flex h-7 cursor-pointer items-center px-2 py-1 text-gray-600"
>
<Tooltip :text="__(field.tooltip)">
<div>{{ data[field.name] }}</div>
</Tooltip>
</template>
</Link>
<Link
v-else-if="field.type === 'link'"
class="form-control select-text"
:value="data[field.name]"
:doctype="field.doctype"
:filters="field.filters"
:placeholder="field.placeholder"
@change="(data) => emit('update', field.name, data)"
:onCreate="field.create"
</div>
<div v-else-if="field.type === 'dropdown'">
<NestedPopover>
<template #target="{ open }">
<Button
:label="data[field.name]"
class="dropdown-button flex w-full items-center justify-between rounded border border-gray-100 bg-gray-100 px-2 py-1.5 text-base text-gray-800 placeholder-gray-500 transition-colors hover:border-gray-200 hover:bg-gray-200 focus:border-gray-500 focus:bg-white focus:shadow-sm focus:outline-none focus:ring-0 focus-visible:ring-2 focus-visible:ring-gray-400"
>
<div v-if="data[field.name]" class="truncate">
{{ data[field.name] }}
</div>
<div v-else class="text-base leading-5 text-gray-500 truncate">
{{ field.placeholder }}
</div>
<template #suffix>
<FeatherIcon
:name="open ? 'chevron-up' : 'chevron-down'"
class="h-4 text-gray-600"
/>
</template>
</Button>
</template>
<template #body>
<div
class="my-2 space-y-1.5 divide-y rounded-lg border border-gray-100 bg-white p-1.5 shadow-xl"
>
<div>
<DropdownItem
v-if="field.options?.length"
v-for="option in field.options"
:key="option.name"
:option="option"
/>
<div v-else>
<div class="p-1.5 px-7 text-base text-gray-500">
{{ __('No {0} Available', [field.label]) }}
</div>
</div>
</div>
<div class="pt-1.5">
<Button
variant="ghost"
class="w-full !justify-start"
:label="__('Create New')"
@click="field.create()"
>
<template #prefix>
<FeatherIcon name="plus" class="h-4" />
</template>
</Button>
</div>
</div>
</template>
</NestedPopover>
</div>
<FormControl
v-else-if="field.type == 'checkbox'"
class="form-control"
:type="field.type"
v-model="data[field.name]"
@change.stop="emit('update', field.name, $event.target.checked)"
:disabled="Boolean(field.read_only)"
/>
<FormControl
v-else-if="
['email', 'number', 'date', 'password', 'textarea'].includes(
field.type,
)
"
class="form-control"
:class="{
'[&_input]:text-gray-500':
field.type === 'date' && !data[field.name],
}"
:type="field.type"
:value="data[field.name]"
:placeholder="field.placeholder"
:debounce="500"
@change.stop="emit('update', field.name, $event.target.value)"
/>
<FormControl
v-else-if="field.type === 'select'"
class="form-control cursor-pointer [&_select]:cursor-pointer truncate"
type="select"
v-model="data[field.name]"
:options="field.options"
:placeholder="field.placeholder"
@change.stop="emit('update', field.name, $event.target.value)"
/>
<Link
v-else-if="['lead_owner', 'deal_owner'].includes(field.name)"
class="form-control"
:value="data[field.name] && getUser(data[field.name]).full_name"
doctype="User"
:filters="field.filters"
@change="(data) => emit('update', field.name, data)"
:placeholder="'Select' + ' ' + field.label + '...'"
:hideMe="true"
>
<template v-if="data[field.name]" #prefix>
<UserAvatar class="mr-1.5" :user="data[field.name]" size="sm" />
</template>
<template #item-prefix="{ option }">
<UserAvatar class="mr-1.5" :user="option.value" size="sm" />
</template>
<template #item-label="{ option }">
<Tooltip :text="option.value">
<div class="cursor-pointer">
{{ getUser(option.value).full_name }}
</div>
</Tooltip>
</template>
</Link>
<Link
v-else-if="field.type === 'link'"
class="form-control select-text"
:value="data[field.name]"
:doctype="field.doctype"
:filters="field.filters"
:placeholder="field.placeholder"
@change="(data) => emit('update', field.name, data)"
:onCreate="field.create"
/>
<FormControl
v-else
class="form-control"
type="text"
:value="data[field.name]"
:placeholder="field.placeholder"
:debounce="500"
@change.stop="emit('update', field.name, $event.target.value)"
/>
</div>
<ArrowUpRightIcon
v-if="field.type === 'link' && field.link && data[field.name]"
class="h-4 w-4 shrink-0 cursor-pointer text-gray-600 hover:text-gray-800"
@click="field.link(data[field.name])"
/>
<FormControl
v-else
class="form-control"
type="text"
:value="data[field.name]"
:placeholder="field.placeholder"
:debounce="500"
@change.stop="emit('update', field.name, $event.target.value)"
<EditIcon
v-if="field.type === 'link' && field.edit && data[field.name]"
class="size-3.5 shrink-0 cursor-pointer text-gray-600 hover:text-gray-800"
@click="field.edit(data[field.name])"
/>
</div>
<ArrowUpRightIcon
v-if="field.type === 'link' && field.link && data[field.name]"
class="h-4 w-4 shrink-0 cursor-pointer text-gray-600 hover:text-gray-800"
@click="field.link(data[field.name])"
/>
</div>
</FadedScrollableDiv>
</template>
<script setup>
import NestedPopover from '@/components/NestedPopover.vue'
import DropdownItem from '@/components/DropdownItem.vue'
import FadedScrollableDiv from '@/components/FadedScrollableDiv.vue'
import ArrowUpRightIcon from '@/components/Icons/ArrowUpRightIcon.vue'
import EditIcon from '@/components/Icons/EditIcon.vue'
import Link from '@/components/Controls/Link.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import { usersStore } from '@/stores/users'
@ -188,7 +254,8 @@ function evaluate(code, context = {}) {
:deep(.form-control input:not([type='checkbox'])),
:deep(.form-control select),
:deep(.form-control textarea),
:deep(.form-control button) {
:deep(.form-control button),
.dropdown-button {
border-color: transparent;
background: white;
}

View File

@ -20,7 +20,7 @@
type="select"
class="w-1/4"
v-model="_doctype"
:options="['CRM Lead', 'CRM Deal']"
:options="['CRM Lead', 'CRM Deal', 'Contact', 'CRM Organization']"
@change="reload"
/>
<Switch

View File

@ -8,165 +8,148 @@
</Breadcrumbs>
</template>
</LayoutHeader>
<div v-if="contact.data" class="flex h-full flex-col overflow-hidden">
<FileUploader @success="changeContactImage" :validateFile="validateFile">
<template #default="{ openFileSelector, error }">
<div class="flex items-start justify-start gap-6 p-5 sm:items-center">
<div class="group relative h-24 w-24">
<Avatar
size="3xl"
class="h-24 w-24"
:label="contact.data.full_name"
:image="contact.data.image"
/>
<component
:is="contact.data.image ? Dropdown : 'div'"
v-bind="
contact.data.image
? {
options: [
{
icon: 'upload',
label: contact.data.image
? __('Change image')
: __('Upload image'),
onClick: openFileSelector,
},
{
icon: 'trash-2',
label: __('Remove image'),
onClick: () => changeContactImage(''),
},
],
}
: { onClick: openFileSelector }
"
class="!absolute bottom-0 left-0 right-0"
>
<div
class="z-1 absolute bottom-0 left-0 right-0 flex h-14 cursor-pointer items-center justify-center rounded-b-full bg-black bg-opacity-40 pt-3 opacity-0 duration-300 ease-in-out group-hover:opacity-100"
style="
-webkit-clip-path: inset(12px 0 0 0);
clip-path: inset(12px 0 0 0);
"
>
<CameraIcon class="h-6 w-6 cursor-pointer text-white" />
<div ref="parentRef" class="flex h-full">
<Resizer
v-if="contact.data"
:parent="$refs.parentRef"
class="flex h-full flex-col overflow-hidden border-r"
>
<div class="border-b">
<FileUploader
@success="changeContactImage"
:validateFile="validateFile"
>
<template #default="{ openFileSelector, error }">
<div class="flex flex-col items-start justify-start gap-4 p-5">
<div class="flex gap-4 items-center">
<div class="group relative h-15.5 w-15.5">
<Avatar
size="3xl"
class="h-15.5 w-15.5"
:label="contact.data.full_name"
:image="contact.data.image"
/>
<component
:is="contact.data.image ? Dropdown : 'div'"
v-bind="
contact.data.image
? {
options: [
{
icon: 'upload',
label: contact.data.image
? __('Change image')
: __('Upload image'),
onClick: openFileSelector,
},
{
icon: 'trash-2',
label: __('Remove image'),
onClick: () => changeContactImage(''),
},
],
}
: { onClick: openFileSelector }
"
class="!absolute bottom-0 left-0 right-0"
>
<div
class="z-1 absolute bottom-0 left-0 right-0 flex h-14 cursor-pointer items-center justify-center rounded-b-full bg-black bg-opacity-40 pt-5 opacity-0 duration-300 ease-in-out group-hover:opacity-100"
style="
-webkit-clip-path: inset(22px 0 0 0);
clip-path: inset(22px 0 0 0);
"
>
<CameraIcon class="h-6 w-6 cursor-pointer text-white" />
</div>
</component>
</div>
<div class="flex flex-col gap-2 truncate">
<div class="truncate text-2xl font-medium">
<span v-if="contact.data.salutation">
{{ contact.data.salutation + '. ' }}
</span>
<span>{{ contact.data.full_name }}</span>
</div>
<div
v-if="contact.data.company_name"
class="flex items-center gap-1.5 text-base text-gray-800"
>
<Avatar
size="xs"
:label="contact.data.company_name"
:image="
getOrganization(contact.data.company_name)
?.organization_logo
"
/>
<span class="">{{ contact.data.company_name }}</span>
</div>
<ErrorMessage :message="__(error)" />
</div>
</div>
</component>
</div>
<div class="flex flex-col gap-2 truncate sm:gap-0.5">
<div class="truncate text-3xl font-semibold">
<span v-if="contact.data.salutation">
{{ contact.data.salutation + '. ' }}
</span>
<span>{{ contact.data.full_name }}</span>
</div>
<div
class="flex flex-col flex-wrap gap-3 text-base text-gray-700 sm:flex-row sm:items-center sm:gap-2"
>
<div
v-if="contact.data.email_id"
class="flex items-center gap-1.5"
>
<Email2Icon class="h-4 w-4" />
<span class="">{{ contact.data.email_id }}</span>
</div>
<span
v-if="contact.data.email_id"
class="hidden text-3xl leading-[0] text-gray-600 sm:flex"
>
&middot;
</span>
<component
:is="callEnabled ? Tooltip : 'div'"
:text="__('Make Call')"
v-if="contact.data.actual_mobile_no"
>
<div
class="flex items-center gap-1.5"
:class="callEnabled ? 'cursor-pointer' : ''"
<div class="flex gap-1.5">
<Button
v-if="contact.data.actual_mobile_no"
:label="__('Make Call')"
size="sm"
@click="
callEnabled && makeCall(contact.data.actual_mobile_no)
"
>
<PhoneIcon class="h-4 w-4" />
<span class="">{{ contact.data.actual_mobile_no }}</span>
</div>
</component>
<span
v-if="contact.data.actual_mobile_no"
class="hidden text-3xl leading-[0] text-gray-600 sm:flex"
>
&middot;
</span>
<div
v-if="contact.data.company_name"
class="flex items-center gap-1.5"
>
<Avatar
size="xs"
:label="contact.data.company_name"
:image="
getOrganization(contact.data.company_name)
?.organization_logo
"
/>
<span class="">{{ contact.data.company_name }}</span>
<template #prefix>
<PhoneIcon class="h-4 w-4" />
</template>
</Button>
<Button
:label="__('Delete')"
theme="red"
size="sm"
@click="deleteContact"
>
<template #prefix>
<FeatherIcon name="trash-2" class="h-4 w-4" />
</template>
</Button>
</div>
<span
v-if="contact.data.company_name"
class="hidden text-3xl leading-[0] text-gray-600 sm:flex"
>
&middot;
</span>
<Button
v-if="
contact.data.email_id ||
contact.data.mobile_no ||
contact.data.company_name
"
variant="ghost"
:label="__('More')"
class="w-fit cursor-pointer hover:text-gray-900 sm:-ml-1"
@click="
() => {
detailMode = true
showContactModal = true
}
"
/>
</div>
<div class="mt-2 flex gap-1.5">
<Button
:label="__('Edit')"
size="sm"
@click="
() => {
detailMode = false
showContactModal = true
}
"
>
<template #prefix>
</template>
</FileUploader>
</div>
<div
v-if="fieldsLayout.data"
class="flex flex-1 flex-col justify-between overflow-hidden"
>
<div class="flex flex-col overflow-y-auto">
<div
v-for="(section, i) in fieldsLayout.data"
:key="section.label"
class="flex flex-col p-3"
:class="{ 'border-b': i !== fieldsLayout.data.length - 1 }"
>
<Section :is-opened="section.opened" :label="section.label">
<template #actions>
<Button
v-if="i == 0 && isManager()"
variant="ghost"
class="w-7"
@click="showSidePanelModal = true"
>
<EditIcon class="h-4 w-4" />
</template>
</Button>
<Button
:label="__('Delete')"
theme="red"
size="sm"
@click="deleteContact"
>
<template #prefix>
<FeatherIcon name="trash-2" class="h-4 w-4" />
</template>
</Button>
</div>
<ErrorMessage :message="__(error)" />
</Button>
</template>
<SectionFields
v-if="section.fields"
:fields="section.fields"
:isLastSection="i == fieldsLayout.data.length - 1"
v-model="contact.data"
@update="updateField"
/>
</Section>
</div>
</div>
</template>
</FileUploader>
</div>
</Resizer>
<Tabs class="overflow-hidden" v-model="tabIndex" :tabs="tabs">
<template #tab="{ tab, selected }">
<button
@ -206,36 +189,34 @@
</template>
</Tabs>
</div>
<ContactModal
v-model="showContactModal"
v-model:quickEntry="showQuickEntryModal"
:contact="contact"
:options="{ detailMode }"
/>
<QuickEntryModal
v-if="showQuickEntryModal"
v-model="showQuickEntryModal"
<SidePanelModal
v-if="showSidePanelModal"
v-model="showSidePanelModal"
doctype="Contact"
@reload="() => fieldsLayout.reload()"
/>
<AddressModal v-model="showAddressModal" v-model:address="_address" />
</template>
<script setup>
import Resizer from '@/components/Resizer.vue'
import Icon from '@/components/Icon.vue'
import Dropdown from '@/components/frappe-ui/Dropdown.vue'
import Section from '@/components/Section.vue'
import SectionFields from '@/components/SectionFields.vue'
import LayoutHeader from '@/components/LayoutHeader.vue'
import Email2Icon from '@/components/Icons/Email2Icon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import EditIcon from '@/components/Icons/EditIcon.vue'
import CameraIcon from '@/components/Icons/CameraIcon.vue'
import DealsIcon from '@/components/Icons/DealsIcon.vue'
import DealsListView from '@/components/ListViews/DealsListView.vue'
import ContactModal from '@/components/Modals/ContactModal.vue'
import QuickEntryModal from '@/components/Modals/QuickEntryModal.vue'
import SidePanelModal from '@/components/Settings/SidePanelModal.vue'
import AddressModal from '@/components/Modals/AddressModal.vue'
import {
dateFormat,
dateTooltipFormat,
timeAgo,
formatNumberIntoCurrency,
createToast,
} from '@/utils'
import { getView } from '@/utils/view'
import { globalStore } from '@/stores/global.js'
@ -247,18 +228,18 @@ import {
Breadcrumbs,
Avatar,
FileUploader,
Tooltip,
Tabs,
call,
createResource,
usePageMeta,
Dropdown,
} from 'frappe-ui'
import { ref, computed, h } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const { $dialog, makeCall } = globalStore()
const { getUser } = usersStore()
const { getUser, isManager } = usersStore()
const { getOrganization } = organizationsStore()
const { getDealStatus } = statusesStore()
@ -272,9 +253,10 @@ const props = defineProps({
const route = useRoute()
const router = useRouter()
const showContactModal = ref(false)
const showQuickEntryModal = ref(false)
const detailMode = ref(false)
const showAddressModal = ref(false)
const showSidePanelModal = ref(false)
const _contact = ref({})
const _address = ref({})
const contact = createResource({
url: 'crm.api.contact.get_contact',
@ -386,6 +368,240 @@ const rows = computed(() => {
return deals.data.map((row) => getDealRowObject(row))
})
const fieldsLayout = createResource({
url: 'crm.api.doc.get_sidebar_fields',
cache: ['fieldsLayout', props.contactId],
params: { doctype: 'Contact', name: props.contactId },
auto: true,
transform: (data) => getParsedFields(data),
})
function getParsedFields(data) {
return data.map((section) => {
return {
...section,
fields: computed(() =>
section.fields.map((field) => {
if (field.name === 'email_id') {
return {
...field,
type: 'dropdown',
options:
contact.data?.email_ids?.map((email) => {
return {
name: email.name,
value: email.email_id,
selected: email.email_id === contact.data.email_id,
placeholder: 'john@doe.com',
onClick: () => {
_contact.value.email_id = email.email_id
setAsPrimary('email', email.email_id)
},
onSave: (option, isNew) => {
if (isNew) {
createNew('email', option.value)
if (contact.data.email_ids.length === 1) {
_contact.value.email_id = option.value
}
} else {
editOption(
'Contact Email',
option.name,
'email_id',
option.value,
)
}
},
onDelete: async (option, isNew) => {
contact.data.email_ids = contact.data.email_ids.filter(
(email) => email.name !== option.name,
)
!isNew &&
(await deleteOption('Contact Email', option.name))
if (_contact.value.email_id === option.value) {
if (contact.data.email_ids.length === 0) {
_contact.value.email_id = ''
} else {
_contact.value.email_id = contact.data.email_ids.find(
(email) => email.is_primary,
)?.email_id
}
}
},
}
}) || [],
create: () => {
contact.data?.email_ids?.push({
name: 'new-1',
value: '',
selected: false,
isNew: true,
})
},
}
} else if (field.name === 'mobile_no') {
return {
...field,
type: 'dropdown',
options:
contact.data?.phone_nos?.map((phone) => {
return {
name: phone.name,
value: phone.phone,
selected: phone.phone === contact.data.actual_mobile_no,
onClick: () => {
_contact.value.actual_mobile_no = phone.phone
_contact.value.mobile_no = phone.phone
setAsPrimary('mobile_no', phone.phone)
},
onSave: (option, isNew) => {
if (isNew) {
createNew('phone', option.value)
if (contact.data.phone_nos.length === 1) {
_contact.value.actual_mobile_no = option.value
}
} else {
editOption(
'Contact Phone',
option.name,
'phone',
option.value,
)
}
},
onDelete: async (option, isNew) => {
contact.data.phone_nos = contact.data.phone_nos.filter(
(phone) => phone.name !== option.name,
)
!isNew &&
(await deleteOption('Contact Phone', option.name))
if (_contact.value.actual_mobile_no === option.value) {
if (contact.data.phone_nos.length === 0) {
_contact.value.actual_mobile_no = ''
} else {
_contact.value.actual_mobile_no =
contact.data.phone_nos.find(
(phone) => phone.is_primary_mobile_no,
)?.phone
}
}
},
}
}) || [],
create: () => {
contact.data?.phone_nos?.push({
name: 'new-1',
value: '',
selected: false,
isNew: true,
})
},
}
} else if (field.name === 'address') {
return {
...field,
create: (value, close) => {
_contact.value.address = value
_address.value = {}
showAddressModal.value = true
close()
},
edit: async (addr) => {
_address.value = await call('frappe.client.get', {
doctype: 'Address',
name: addr,
})
showAddressModal.value = true
},
}
} else {
return field
}
}),
),
}
})
}
async function setAsPrimary(field, value) {
let d = await call('crm.api.contact.set_as_primary', {
contact: contact.data.name,
field,
value,
})
if (d) {
contact.reload()
createToast({
title: 'Contact updated',
icon: 'check',
iconClasses: 'text-green-600',
})
}
}
async function createNew(field, value) {
if (!value) return
let d = await call('crm.api.contact.create_new', {
contact: contact.data.name,
field,
value,
})
if (d) {
contact.reload()
createToast({
title: 'Contact updated',
icon: 'check',
iconClasses: 'text-green-600',
})
}
}
async function editOption(doctype, name, fieldname, value) {
let d = await call('frappe.client.set_value', {
doctype,
name,
fieldname,
value,
})
if (d) {
contact.reload()
createToast({
title: 'Contact updated',
icon: 'check',
iconClasses: 'text-green-600',
})
}
}
async function deleteOption(doctype, name) {
await call('frappe.client.delete', {
doctype,
name,
})
await contact.reload()
createToast({
title: 'Contact updated',
icon: 'check',
iconClasses: 'text-green-600',
})
}
async function updateField(fieldname, value) {
await call('frappe.client.set_value', {
doctype: 'Contact',
name: props.contactId,
fieldname,
value,
})
createToast({
title: 'Contact updated',
icon: 'check',
iconClasses: 'text-green-600',
})
contact.reload()
}
const columns = computed(() => dealColumns)
function getDealRowObject(deal) {
@ -453,38 +669,4 @@ const dealColumns = [
width: '8rem',
},
]
</script>
<style scoped>
:deep(.form-control input),
:deep(.form-control select),
:deep(.form-control button) {
border-color: transparent;
background: white;
}
:deep(.form-control button) {
gap: 0;
}
:deep(.form-control button > div) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:deep(.form-control button svg) {
color: white;
width: 0;
}
:deep(:has(> .dropdown-button)) {
width: 100%;
}
:deep(.dropdown-button > button > span) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
</script>

View File

@ -0,0 +1,652 @@
<template>
<LayoutHeader v-if="contact.data">
<header
class="relative flex h-10.5 items-center justify-between gap-2 py-2.5 pl-2"
>
<Breadcrumbs :items="breadcrumbs">
<template #prefix="{ item }">
<Icon v-if="item.icon" :icon="item.icon" class="mr-2 h-4" />
</template>
</Breadcrumbs>
</header>
</LayoutHeader>
<div v-if="contact.data" class="flex flex-col h-full overflow-hidden">
<FileUploader @success="changeContactImage" :validateFile="validateFile">
<template #default="{ openFileSelector, error }">
<div class="flex flex-col items-start justify-start gap-4 p-4">
<div class="flex gap-4 items-center">
<div class="group relative h-14.5 w-14.5">
<Avatar
size="3xl"
class="h-14.5 w-14.5"
:label="contact.data.full_name"
:image="contact.data.image"
/>
<component
:is="contact.data.image ? Dropdown : 'div'"
v-bind="
contact.data.image
? {
options: [
{
icon: 'upload',
label: contact.data.image
? __('Change image')
: __('Upload image'),
onClick: openFileSelector,
},
{
icon: 'trash-2',
label: __('Remove image'),
onClick: () => changeContactImage(''),
},
],
}
: { onClick: openFileSelector }
"
class="!absolute bottom-0 left-0 right-0"
>
<div
class="z-1 absolute bottom-0 left-0 right-0 flex h-14 cursor-pointer items-center justify-center rounded-b-full bg-black bg-opacity-40 pt-5 opacity-0 duration-300 ease-in-out group-hover:opacity-100"
style="
-webkit-clip-path: inset(22px 0 0 0);
clip-path: inset(22px 0 0 0);
"
>
<CameraIcon class="h-6 w-6 cursor-pointer text-white" />
</div>
</component>
</div>
<div class="flex flex-col gap-2 truncate">
<div class="truncate text-lg font-medium">
<span v-if="contact.data.salutation">
{{ contact.data.salutation + '. ' }}
</span>
<span>{{ contact.data.full_name }}</span>
</div>
<div class="flex items-center gap-1.5">
<Button
v-if="contact.data.actual_mobile_no"
:label="__('Make Call')"
size="sm"
@click="
callEnabled && makeCall(contact.data.actual_mobile_no)
"
>
<template #prefix>
<PhoneIcon class="h-4 w-4" />
</template>
</Button>
<Button
:label="__('Delete')"
theme="red"
size="sm"
@click="deleteContact"
>
<template #prefix>
<FeatherIcon name="trash-2" class="h-4 w-4" />
</template>
</Button>
<Avatar
v-if="contact.data.company_name"
size="md"
:label="contact.data.company_name"
:image="
getOrganization(contact.data.company_name)
?.organization_logo
"
/>
</div>
<ErrorMessage :message="__(error)" />
</div>
</div>
</div>
</template>
</FileUploader>
<Tabs
v-model="tabIndex"
:tabs="tabs"
tablistClass="!px-4"
class="overflow-auto"
>
<template #tab="{ tab, selected }">
<button
v-if="tab.name == 'Deals'"
class="group flex items-center gap-2 border-b border-transparent py-2.5 text-base text-gray-600 duration-300 ease-in-out hover:border-gray-400 hover:text-gray-900"
:class="{ 'text-gray-900': selected }"
>
<component v-if="tab.icon" :is="tab.icon" class="h-5" />
{{ __(tab.label) }}
<Badge
class="group-hover:bg-gray-900"
:class="[selected ? 'bg-gray-900' : 'bg-gray-600']"
variant="solid"
theme="gray"
size="sm"
>
{{ tab.count }}
</Badge>
</button>
</template>
<template #default="{ tab }">
<div v-if="tab.name == 'Details'">
<div
v-if="fieldsLayout.data"
class="flex flex-1 flex-col justify-between overflow-hidden"
>
<div class="flex flex-col overflow-y-auto">
<div
v-for="(section, i) in fieldsLayout.data"
:key="section.label"
class="flex flex-col px-2 py-3 sm:p-3"
:class="{ 'border-b': i !== fieldsLayout.data.length - 1 }"
>
<Section :is-opened="section.opened" :label="section.label">
<SectionFields
:fields="section.fields"
:isLastSection="i == fieldsLayout.data.length - 1"
v-model="contact.data"
@update="updateField"
/>
</Section>
</div>
</div>
</div>
</div>
<DealsListView
v-else-if="tab.label === 'Deals' && rows.length"
class="mt-4"
:rows="rows"
:columns="columns"
:options="{ selectable: false, showTooltip: false }"
/>
<div
v-if="tab.label === 'Deals' && !rows.length"
class="grid flex-1 place-items-center text-xl font-medium text-gray-500"
>
<div class="flex flex-col items-center justify-center space-y-3">
<component :is="tab.icon" class="!h-10 !w-10" />
<div>{{ __('No {0} Found', [__(tab.label)]) }}</div>
</div>
</div>
</template>
</Tabs>
</div>
<AddressModal v-model="showAddressModal" v-model:address="_address" />
</template>
<script setup>
import Icon from '@/components/Icon.vue'
import Section from '@/components/Section.vue'
import SectionFields from '@/components/SectionFields.vue'
import LayoutHeader from '@/components/LayoutHeader.vue'
import DetailsIcon from '@/components/Icons/DetailsIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import CameraIcon from '@/components/Icons/CameraIcon.vue'
import DealsIcon from '@/components/Icons/DealsIcon.vue'
import DealsListView from '@/components/ListViews/DealsListView.vue'
import AddressModal from '@/components/Modals/AddressModal.vue'
import {
dateFormat,
dateTooltipFormat,
timeAgo,
formatNumberIntoCurrency,
createToast,
} from '@/utils'
import { getView } from '@/utils/view'
import { globalStore } from '@/stores/global.js'
import { usersStore } from '@/stores/users.js'
import { organizationsStore } from '@/stores/organizations.js'
import { statusesStore } from '@/stores/statuses'
import { callEnabled } from '@/composables/settings'
import {
Breadcrumbs,
Avatar,
FileUploader,
Tabs,
call,
createResource,
usePageMeta,
Dropdown,
} from 'frappe-ui'
import { ref, computed, h } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const { $dialog, makeCall } = globalStore()
const { getUser } = usersStore()
const { getOrganization } = organizationsStore()
const { getDealStatus } = statusesStore()
const props = defineProps({
contactId: {
type: String,
required: true,
},
})
const route = useRoute()
const router = useRouter()
const showAddressModal = ref(false)
const _contact = ref({})
const _address = ref({})
const contact = createResource({
url: 'crm.api.contact.get_contact',
cache: ['contact', props.contactId],
params: {
name: props.contactId,
},
auto: true,
transform: (data) => {
return {
...data,
actual_mobile_no: data.mobile_no,
mobile_no: data.mobile_no,
}
},
})
const breadcrumbs = computed(() => {
let items = [{ label: __('Contacts'), route: { name: 'Contacts' } }]
if (route.query.view || route.query.viewType) {
let view = getView(route.query.view, route.query.viewType, 'Contact')
if (view) {
items.push({
label: __(view.label),
icon: view.icon,
route: {
name: 'Contacts',
params: { viewType: route.query.viewType },
query: { view: route.query.view },
},
})
}
}
items.push({
label: contact.data?.full_name,
route: { name: 'Contact', params: { contactId: props.contactId } },
})
return items
})
usePageMeta(() => {
return {
title: contact.data?.full_name || contact.data?.name,
}
})
function validateFile(file) {
let extn = file.name.split('.').pop().toLowerCase()
if (!['png', 'jpg', 'jpeg'].includes(extn)) {
return __('Only PNG and JPG images are allowed')
}
}
async function changeContactImage(file) {
await call('frappe.client.set_value', {
doctype: 'Contact',
name: props.contactId,
fieldname: 'image',
value: file?.file_url || '',
})
contact.reload()
}
async function deleteContact() {
$dialog({
title: __('Delete contact'),
message: __('Are you sure you want to delete this contact?'),
actions: [
{
label: __('Delete'),
theme: 'red',
variant: 'solid',
async onClick(close) {
await call('frappe.client.delete', {
doctype: 'Contact',
name: props.contactId,
})
close()
router.push({ name: 'Contacts' })
},
},
],
})
}
const tabIndex = ref(0)
const tabs = [
{
name: 'Details',
label: __('Details'),
icon: DetailsIcon,
},
{
name: 'Deals',
label: __('Deals'),
icon: h(DealsIcon, { class: 'h-4 w-4' }),
count: computed(() => deals.data?.length),
},
]
const deals = createResource({
url: 'crm.api.contact.get_linked_deals',
cache: ['deals', props.contactId],
params: {
contact: props.contactId,
},
auto: true,
})
const rows = computed(() => {
if (!deals.data || deals.data == []) return []
return deals.data.map((row) => getDealRowObject(row))
})
const fieldsLayout = createResource({
url: 'crm.api.doc.get_sidebar_fields',
cache: ['fieldsLayout', props.contactId],
params: { doctype: 'Contact', name: props.contactId },
auto: true,
transform: (data) => getParsedFields(data),
})
function getParsedFields(data) {
return data.map((section) => {
return {
...section,
fields: computed(() =>
section.fields.map((field) => {
if (field.name === 'email_id') {
return {
...field,
type: 'dropdown',
options:
contact.data?.email_ids?.map((email) => {
return {
name: email.name,
value: email.email_id,
selected: email.email_id === contact.data.email_id,
placeholder: 'john@doe.com',
onClick: () => {
_contact.value.email_id = email.email_id
setAsPrimary('email', email.email_id)
},
onSave: (option, isNew) => {
if (isNew) {
createNew('email', option.value)
if (contact.data.email_ids.length === 1) {
_contact.value.email_id = option.value
}
} else {
editOption(
'Contact Email',
option.name,
'email_id',
option.value,
)
}
},
onDelete: async (option, isNew) => {
contact.data.email_ids = contact.data.email_ids.filter(
(email) => email.name !== option.name,
)
!isNew &&
(await deleteOption('Contact Email', option.name))
if (_contact.value.email_id === option.value) {
if (contact.data.email_ids.length === 0) {
_contact.value.email_id = ''
} else {
_contact.value.email_id = contact.data.email_ids.find(
(email) => email.is_primary,
)?.email_id
}
}
},
}
}) || [],
create: () => {
contact.data?.email_ids?.push({
name: 'new-1',
value: '',
selected: false,
isNew: true,
})
},
}
} else if (field.name === 'mobile_no') {
return {
...field,
type: 'dropdown',
options:
contact.data?.phone_nos?.map((phone) => {
return {
name: phone.name,
value: phone.phone,
selected: phone.phone === contact.data.actual_mobile_no,
onClick: () => {
_contact.value.actual_mobile_no = phone.phone
_contact.value.mobile_no = phone.phone
setAsPrimary('mobile_no', phone.phone)
},
onSave: (option, isNew) => {
if (isNew) {
createNew('phone', option.value)
if (contact.data.phone_nos.length === 1) {
_contact.value.actual_mobile_no = option.value
}
} else {
editOption(
'Contact Phone',
option.name,
'phone',
option.value,
)
}
},
onDelete: async (option, isNew) => {
contact.data.phone_nos = contact.data.phone_nos.filter(
(phone) => phone.name !== option.name,
)
!isNew &&
(await deleteOption('Contact Phone', option.name))
if (_contact.value.actual_mobile_no === option.value) {
if (contact.data.phone_nos.length === 0) {
_contact.value.actual_mobile_no = ''
} else {
_contact.value.actual_mobile_no =
contact.data.phone_nos.find(
(phone) => phone.is_primary_mobile_no,
)?.phone
}
}
},
}
}) || [],
create: () => {
contact.data?.phone_nos?.push({
name: 'new-1',
value: '',
selected: false,
isNew: true,
})
},
}
} else if (field.name === 'address') {
return {
...field,
create: (value, close) => {
_contact.value.address = value
_address.value = {}
showAddressModal.value = true
close()
},
edit: async (addr) => {
_address.value = await call('frappe.client.get', {
doctype: 'Address',
name: addr,
})
showAddressModal.value = true
},
}
} else {
return field
}
}),
),
}
})
}
async function setAsPrimary(field, value) {
let d = await call('crm.api.contact.set_as_primary', {
contact: contact.data.name,
field,
value,
})
if (d) {
contact.reload()
createToast({
title: 'Contact updated',
icon: 'check',
iconClasses: 'text-green-600',
})
}
}
async function createNew(field, value) {
if (!value) return
let d = await call('crm.api.contact.create_new', {
contact: contact.data.name,
field,
value,
})
if (d) {
contact.reload()
createToast({
title: 'Contact updated',
icon: 'check',
iconClasses: 'text-green-600',
})
}
}
async function editOption(doctype, name, fieldname, value) {
let d = await call('frappe.client.set_value', {
doctype,
name,
fieldname,
value,
})
if (d) {
contact.reload()
createToast({
title: 'Contact updated',
icon: 'check',
iconClasses: 'text-green-600',
})
}
}
async function deleteOption(doctype, name) {
await call('frappe.client.delete', {
doctype,
name,
})
await contact.reload()
createToast({
title: 'Contact updated',
icon: 'check',
iconClasses: 'text-green-600',
})
}
async function updateField(fieldname, value) {
await call('frappe.client.set_value', {
doctype: 'Contact',
name: props.contactId,
fieldname,
value,
})
createToast({
title: 'Contact updated',
icon: 'check',
iconClasses: 'text-green-600',
})
contact.reload()
}
const columns = computed(() => dealColumns)
function getDealRowObject(deal) {
return {
name: deal.name,
organization: {
label: deal.organization,
logo: getOrganization(deal.organization)?.organization_logo,
},
annual_revenue: formatNumberIntoCurrency(
deal.annual_revenue,
deal.currency,
),
status: {
label: deal.status,
color: getDealStatus(deal.status)?.iconColorClass,
},
email: deal.email,
mobile_no: deal.mobile_no,
deal_owner: {
label: deal.deal_owner && getUser(deal.deal_owner).full_name,
...(deal.deal_owner && getUser(deal.deal_owner)),
},
modified: {
label: dateFormat(deal.modified, dateTooltipFormat),
timeAgo: __(timeAgo(deal.modified)),
},
}
}
const dealColumns = [
{
label: __('Organization'),
key: 'organization',
width: '11rem',
},
{
label: __('Amount'),
key: 'annual_revenue',
width: '9rem',
},
{
label: __('Status'),
key: 'status',
width: '10rem',
},
{
label: __('Email'),
key: 'email',
width: '12rem',
},
{
label: __('Mobile no'),
key: 'mobile_no',
width: '11rem',
},
{
label: __('Deal owner'),
key: 'deal_owner',
width: '10rem',
},
{
label: __('Last modified'),
key: 'modified',
width: '8rem',
},
]
</script>

View File

@ -1,7 +1,7 @@
<template>
<LayoutHeader v-if="deal.data">
<header
class="relative flex h-12 items-center justify-between gap-2 py-2.5 pl-5"
class="relative flex h-10.5 items-center justify-between gap-2 py-2.5 pl-2"
>
<Breadcrumbs :items="breadcrumbs">
<template #prefix="{ item }">

View File

@ -1,7 +1,7 @@
<template>
<LayoutHeader v-if="lead.data">
<header
class="relative flex h-12 items-center justify-between gap-2 py-2.5 pl-5"
class="relative flex h-10.5 items-center justify-between gap-2 py-2.5 pl-2"
>
<Breadcrumbs :items="breadcrumbs">
<template #prefix="{ item }">

View File

@ -0,0 +1,563 @@
<template>
<LayoutHeader v-if="organization.doc">
<header
class="relative flex h-10.5 items-center justify-between gap-2 py-2.5 pl-2"
>
<Breadcrumbs :items="breadcrumbs">
<template #prefix="{ item }">
<Icon v-if="item.icon" :icon="item.icon" class="mr-2 h-4" />
</template>
</Breadcrumbs>
</header>
</LayoutHeader>
<div v-if="organization.doc" class="flex flex-col h-full overflow-hidden">
<FileUploader
@success="changeOrganizationImage"
:validateFile="validateFile"
>
<template #default="{ openFileSelector, error }">
<div class="flex flex-col items-start justify-start gap-4 p-4">
<div class="flex gap-4 items-center">
<div class="group relative h-14.5 w-14.5">
<Avatar
size="3xl"
class="h-14.5 w-14.5"
:label="organization.doc.organization_name"
:image="organization.doc.organization_logo"
/>
<component
:is="organization.doc.organization_logo ? Dropdown : 'div'"
v-bind="
organization.doc.organization_logo
? {
options: [
{
icon: 'upload',
label: organization.doc.organization_logo
? __('Change image')
: __('Upload image'),
onClick: openFileSelector,
},
{
icon: 'trash-2',
label: __('Remove image'),
onClick: () => changeOrganizationImage(''),
},
],
}
: { onClick: openFileSelector }
"
class="!absolute bottom-0 left-0 right-0"
>
<div
class="z-1 absolute bottom-0 left-0 right-0 flex h-14 cursor-pointer items-center justify-center rounded-b-full bg-black bg-opacity-40 pt-5 opacity-0 duration-300 ease-in-out group-hover:opacity-100"
style="
-webkit-clip-path: inset(22px 0 0 0);
clip-path: inset(22px 0 0 0);
"
>
<CameraIcon class="h-6 w-6 cursor-pointer text-white" />
</div>
</component>
</div>
<div class="flex flex-col gap-2 truncate">
<div class="truncate text-lg font-medium">
{{ organization.doc.name }}
</div>
<div class="flex items-center gap-1.5">
<Button @click="openWebsite">
<FeatherIcon name="link" class="h-4 w-4" />
</Button>
<Button
:label="__('Delete')"
theme="red"
size="sm"
@click="deleteOrganization"
>
<template #prefix>
<FeatherIcon name="trash-2" class="h-4 w-4" />
</template>
</Button>
</div>
<ErrorMessage :message="__(error)" />
</div>
</div>
</div>
</template>
</FileUploader>
<Tabs
v-model="tabIndex"
:tabs="tabs"
tablistClass="!px-4"
class="overflow-auto"
>
<template #tab="{ tab, selected }">
<button
v-if="tab.name !== 'Details'"
class="group flex items-center gap-2 border-b border-transparent py-2.5 text-base text-gray-600 duration-300 ease-in-out hover:border-gray-400 hover:text-gray-900"
:class="{ 'text-gray-900': selected }"
>
<component v-if="tab.icon" :is="tab.icon" class="h-5" />
{{ __(tab.label) }}
<Badge
class="group-hover:bg-gray-900"
:class="[selected ? 'bg-gray-900' : 'bg-gray-600']"
variant="solid"
theme="gray"
size="sm"
>
{{ tab.count }}
</Badge>
</button>
</template>
<template #default="{ tab }">
<div v-if="tab.name == 'Details'">
<div
v-if="fieldsLayout.data"
class="flex flex-1 flex-col justify-between overflow-hidden"
>
<div class="flex flex-col overflow-y-auto">
<div
v-for="(section, i) in fieldsLayout.data"
:key="section.label"
class="flex flex-col px-2 py-3 sm:p-3"
:class="{ 'border-b': i !== fieldsLayout.data.length - 1 }"
>
<Section :is-opened="section.opened" :label="section.label">
<SectionFields
:fields="section.fields"
:isLastSection="i == fieldsLayout.data.length - 1"
v-model="organization.doc"
@update="updateField"
/>
</Section>
</div>
</div>
</div>
</div>
<DealsListView
class="mt-4"
v-if="tab.label === 'Deals' && rows.length"
:rows="rows"
:columns="columns"
:options="{ selectable: false, showTooltip: false }"
/>
<ContactsListView
class="mt-4"
v-if="tab.label === 'Contacts' && rows.length"
:rows="rows"
:columns="columns"
:options="{ selectable: false, showTooltip: false }"
/>
<div
v-if="!rows.length && tab.name !== 'Details'"
class="grid flex-1 place-items-center text-xl font-medium text-gray-500"
>
<div class="flex flex-col items-center justify-center space-y-3">
<component :is="tab.icon" class="!h-10 !w-10" />
<div>{{ __('No {0} Found', [__(tab.label)]) }}</div>
</div>
</div>
</template>
</Tabs>
</div>
<AddressModal v-model="showAddressModal" v-model:address="_address" />
</template>
<script setup>
import Section from '@/components/Section.vue'
import SectionFields from '@/components/SectionFields.vue'
import Icon from '@/components/Icon.vue'
import LayoutHeader from '@/components/LayoutHeader.vue'
import AddressModal from '@/components/Modals/AddressModal.vue'
import DealsListView from '@/components/ListViews/DealsListView.vue'
import ContactsListView from '@/components/ListViews/ContactsListView.vue'
import DetailsIcon from '@/components/Icons/DetailsIcon.vue'
import CameraIcon from '@/components/Icons/CameraIcon.vue'
import DealsIcon from '@/components/Icons/DealsIcon.vue'
import ContactsIcon from '@/components/Icons/ContactsIcon.vue'
import { globalStore } from '@/stores/global'
import { usersStore } from '@/stores/users'
import { statusesStore } from '@/stores/statuses'
import { getView } from '@/utils/view'
import {
dateFormat,
dateTooltipFormat,
timeAgo,
formatNumberIntoCurrency,
createToast,
} from '@/utils'
import {
Breadcrumbs,
Avatar,
FileUploader,
Dropdown,
Tabs,
call,
createListResource,
createDocumentResource,
usePageMeta,
createResource,
} from 'frappe-ui'
import { h, computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const props = defineProps({
organizationId: {
type: String,
required: true,
},
})
const { getUser } = usersStore()
const { $dialog } = globalStore()
const { getDealStatus } = statusesStore()
const route = useRoute()
const router = useRouter()
const organization = createDocumentResource({
doctype: 'CRM Organization',
name: props.organizationId,
cache: ['organization', props.organizationId],
fields: ['*'],
auto: true,
})
async function updateField(fieldname, value) {
await organization.setValue.submit({
[fieldname]: value,
})
createToast({
title: __('Organization updated'),
icon: 'check',
iconClasses: 'text-green-600',
})
}
const breadcrumbs = computed(() => {
let items = [{ label: __('Organizations'), route: { name: 'Organizations' } }]
if (route.query.view || route.query.viewType) {
let view = getView(
route.query.view,
route.query.viewType,
'CRM Organization',
)
if (view) {
items.push({
label: __(view.label),
icon: view.icon,
route: {
name: 'Organizations',
params: { viewType: route.query.viewType },
query: { view: route.query.view },
},
})
}
}
items.push({
label: props.organizationId,
route: {
name: 'Organization',
params: { organizationId: props.organizationId },
},
})
return items
})
usePageMeta(() => {
return {
title: props.organizationId,
}
})
function validateFile(file) {
let extn = file.name.split('.').pop().toLowerCase()
if (!['png', 'jpg', 'jpeg'].includes(extn)) {
return __('Only PNG and JPG images are allowed')
}
}
async function changeOrganizationImage(file) {
await call('frappe.client.set_value', {
doctype: 'CRM Organization',
name: props.organizationId,
fieldname: 'organization_logo',
value: file?.file_url || '',
})
organization.reload()
}
async function deleteOrganization() {
$dialog({
title: __('Delete organization'),
message: __('Are you sure you want to delete this organization?'),
actions: [
{
label: __('Delete'),
theme: 'red',
variant: 'solid',
async onClick(close) {
await call('frappe.client.delete', {
doctype: 'CRM Organization',
name: props.organizationId,
})
close()
router.push({ name: 'Organizations' })
},
},
],
})
}
function openWebsite() {
if (!organization.doc.website)
createToast({
title: __('Website not found'),
icon: 'x',
iconClasses: 'text-red-600',
})
else window.open(organization.doc.website, '_blank')
}
const showAddressModal = ref(false)
const _organization = ref({})
const _address = ref({})
const fieldsLayout = createResource({
url: 'crm.api.doc.get_sidebar_fields',
cache: ['fieldsLayout', props.organizationId],
params: { doctype: 'CRM Organization', name: props.organizationId },
auto: true,
transform: (data) => getParsedFields(data),
})
function getParsedFields(data) {
return data.map((section) => {
return {
...section,
fields: computed(() =>
section.fields.map((field) => {
if (field.name === 'address') {
return {
...field,
create: (value, close) => {
_organization.value.address = value
_address.value = {}
showAddressModal.value = true
close()
},
edit: async (addr) => {
_address.value = await call('frappe.client.get', {
doctype: 'Address',
name: addr,
})
showAddressModal.value = true
},
}
} else {
return field
}
}),
),
}
})
}
const tabIndex = ref(0)
const tabs = [
{
name: 'Details',
label: __('Details'),
icon: DetailsIcon,
},
{
name: 'Deals',
label: __('Deals'),
icon: h(DealsIcon, { class: 'h-4 w-4' }),
count: computed(() => deals.data?.length),
},
{
name: 'Contacts',
label: __('Contacts'),
icon: h(ContactsIcon, { class: 'h-4 w-4' }),
count: computed(() => contacts.data?.length),
},
]
const deals = createListResource({
type: 'list',
doctype: 'CRM Deal',
cache: ['deals', props.organizationId],
fields: [
'name',
'organization',
'currency',
'annual_revenue',
'status',
'email',
'mobile_no',
'deal_owner',
'modified',
],
filters: {
organization: props.organizationId,
},
orderBy: 'modified desc',
pageLength: 20,
auto: true,
})
const contacts = createListResource({
type: 'list',
doctype: 'Contact',
cache: ['contacts', props.organizationId],
fields: [
'name',
'full_name',
'image',
'email_id',
'mobile_no',
'company_name',
'modified',
],
filters: {
company_name: props.organizationId,
},
orderBy: 'modified desc',
pageLength: 20,
auto: true,
})
const rows = computed(() => {
let list = []
list = !tabIndex.value ? deals : contacts
if (!list.data) return []
return list.data.map((row) => {
return !tabIndex.value ? getDealRowObject(row) : getContactRowObject(row)
})
})
const columns = computed(() => {
return tabIndex.value === 0 ? dealColumns : contactColumns
})
function getDealRowObject(deal) {
return {
name: deal.name,
organization: {
label: deal.organization,
logo: props.organization?.organization_logo,
},
annual_revenue: formatNumberIntoCurrency(
deal.annual_revenue,
deal.currency,
),
status: {
label: deal.status,
color: getDealStatus(deal.status)?.iconColorClass,
},
email: deal.email,
mobile_no: deal.mobile_no,
deal_owner: {
label: deal.deal_owner && getUser(deal.deal_owner).full_name,
...(deal.deal_owner && getUser(deal.deal_owner)),
},
modified: {
label: dateFormat(deal.modified, dateTooltipFormat),
timeAgo: __(timeAgo(deal.modified)),
},
}
}
function getContactRowObject(contact) {
return {
name: contact.name,
full_name: {
label: contact.full_name,
image_label: contact.full_name,
image: contact.image,
},
email: contact.email_id,
mobile_no: contact.mobile_no,
company_name: {
label: contact.company_name,
logo: props.organization?.organization_logo,
},
modified: {
label: dateFormat(contact.modified, dateTooltipFormat),
timeAgo: __(timeAgo(contact.modified)),
},
}
}
const dealColumns = [
{
label: __('Organization'),
key: 'organization',
width: '11rem',
},
{
label: __('Amount'),
key: 'annual_revenue',
width: '9rem',
},
{
label: __('Status'),
key: 'status',
width: '10rem',
},
{
label: __('Email'),
key: 'email',
width: '12rem',
},
{
label: __('Mobile no'),
key: 'mobile_no',
width: '11rem',
},
{
label: __('Deal owner'),
key: 'deal_owner',
width: '10rem',
},
{
label: __('Last modified'),
key: 'modified',
width: '8rem',
},
]
const contactColumns = [
{
label: __('Name'),
key: 'full_name',
width: '17rem',
},
{
label: __('Email'),
key: 'email',
width: '12rem',
},
{
label: __('Phone'),
key: 'mobile_no',
width: '12rem',
},
{
label: __('Organization'),
key: 'company_name',
width: '12rem',
},
{
label: __('Last modified'),
key: 'modified',
width: '8rem',
},
]
</script>

View File

@ -8,169 +8,134 @@
</Breadcrumbs>
</template>
</LayoutHeader>
<div v-if="organization.doc" class="flex flex-1 flex-col overflow-hidden">
<FileUploader
@success="changeOrganizationImage"
:validateFile="validateFile"
<div ref="parentRef" class="flex h-full">
<Resizer
v-if="organization.doc"
:parent="$refs.parentRef"
class="flex h-full flex-col overflow-hidden border-r"
>
<template #default="{ openFileSelector, error }">
<div class="flex items-start justify-start gap-6 p-5 sm:items-center">
<div class="group relative h-24 w-24">
<Avatar
size="3xl"
:image="organization.doc.organization_logo"
:label="organization.doc.name"
class="!h-24 !w-24"
/>
<component
:is="organization.doc.organization_logo ? Dropdown : 'div'"
v-bind="
organization.doc.organization_logo
? {
options: [
{
icon: 'upload',
label: organization.doc.organization_logo
? __('Change image')
: __('Upload image'),
onClick: openFileSelector,
},
{
icon: 'trash-2',
label: __('Remove image'),
onClick: () => changeOrganizationImage(''),
},
],
}
: { onClick: openFileSelector }
"
class="!absolute bottom-0 left-0 right-0"
>
<div
class="z-1 absolute bottom-0 left-0 right-0 flex h-13 cursor-pointer items-center justify-center rounded-b-full bg-black bg-opacity-40 pt-3 opacity-0 duration-300 ease-in-out group-hover:opacity-100"
style="
-webkit-clip-path: inset(12px 0 0 0);
clip-path: inset(12px 0 0 0);
"
>
<CameraIcon class="h-6 w-6 cursor-pointer text-white" />
<div class="border-b">
<FileUploader
@success="changeOrganizationImage"
:validateFile="validateFile"
>
<template #default="{ openFileSelector, error }">
<div class="flex flex-col items-start justify-start gap-4 p-5">
<div class="flex gap-4 items-center">
<div class="group relative h-15.5 w-15.5">
<Avatar
size="3xl"
class="h-15.5 w-15.5"
:label="organization.doc.organization_name"
:image="organization.doc.organization_logo"
/>
<component
:is="organization.doc.image ? Dropdown : 'div'"
v-bind="
organization.doc.image
? {
options: [
{
icon: 'upload',
label: organization.doc.image
? __('Change image')
: __('Upload image'),
onClick: openFileSelector,
},
{
icon: 'trash-2',
label: __('Remove image'),
onClick: () => changeOrganizationImage(''),
},
],
}
: { onClick: openFileSelector }
"
class="!absolute bottom-0 left-0 right-0"
>
<div
class="z-1 absolute bottom-0 left-0 right-0 flex h-14 cursor-pointer items-center justify-center rounded-b-full bg-black bg-opacity-40 pt-5 opacity-0 duration-300 ease-in-out group-hover:opacity-100"
style="
-webkit-clip-path: inset(22px 0 0 0);
clip-path: inset(22px 0 0 0);
"
>
<CameraIcon class="h-6 w-6 cursor-pointer text-white" />
</div>
</component>
</div>
<div class="flex flex-col gap-2 truncate">
<div class="truncate text-2xl font-medium">
<span>{{ organization.doc.name }}</span>
</div>
<div
v-if="organization.doc.website"
class="flex items-center gap-1.5 text-base text-gray-800"
>
<WebsiteIcon class="size-4" />
<span>{{ website(organization.doc.website) }}</span>
</div>
<ErrorMessage :message="__(error)" />
</div>
</div>
<div class="flex gap-1.5">
<Button
:label="__('Delete')"
theme="red"
size="sm"
@click="deleteOrganization"
>
<template #prefix>
<FeatherIcon name="trash-2" class="h-4 w-4" />
</template>
</Button>
<Tooltip :text="__('Open website')">
<div>
<Button @click="openWebsite">
<FeatherIcon name="link" class="h-4 w-4" />
</Button>
</div>
</Tooltip>
</div>
</component>
</div>
<div class="flex flex-col justify-center gap-2 sm:gap-0.5">
<div class="text-3xl font-semibold text-gray-900">
{{ organization.doc.name }}
</div>
<div
class="flex flex-col flex-wrap gap-3 text-base text-gray-700 sm:flex-row sm:items-center sm:gap-2"
>
<div
v-if="organization.doc.website"
class="flex items-center gap-1.5"
>
<WebsiteIcon class="h-4 w-4" />
<span class="">{{ website(organization.doc.website) }}</span>
</div>
<span
v-if="organization.doc.website"
class="hidden text-3xl leading-[0] text-gray-600 sm:flex"
>
&middot;
</span>
<div
v-if="organization.doc.industry"
class="flex items-center gap-1.5"
>
<FeatherIcon name="briefcase" class="h-4 w-4" />
<span class="">{{ organization.doc.industry }}</span>
</div>
<span
v-if="organization.doc.industry"
class="hidden text-3xl leading-[0] text-gray-600 sm:flex"
>
&middot;
</span>
<div
v-if="organization.doc.territory"
class="flex items-center gap-1.5"
>
<TerritoryIcon class="h-4 w-4" />
<span class="">{{ organization.doc.territory }}</span>
</div>
<span
v-if="organization.doc.territory"
class="hidden text-3xl leading-[0] text-gray-600 sm:flex"
>
&middot;
</span>
<div
v-if="organization.doc.annual_revenue"
class="flex items-center gap-1.5"
>
<MoneyIcon class="size-4" />
<span class="">{{
formatNumberIntoCurrency(
organization.doc.annual_revenue,
organization.doc.currency,
)
}}</span>
</div>
<span
v-if="organization.doc.annual_revenue"
class="hidden text-3xl leading-[0] text-gray-600 sm:flex"
>
&middot;
</span>
<Button
v-if="
organization.doc.website ||
organization.doc.industry ||
organization.doc.territory ||
organization.doc.annual_revenue
"
variant="ghost"
:label="__('More')"
class="w-fit cursor-pointer hover:text-gray-900 sm:-ml-1"
@click="
() => {
detailMode = true
showOrganizationModal = true
}
"
/>
</div>
<div class="mt-2 flex gap-1.5">
<Button
:label="__('Edit')"
size="sm"
@click="
() => {
detailMode = false
showOrganizationModal = true
}
"
>
<template #prefix>
</template>
</FileUploader>
</div>
<div
v-if="fieldsLayout.data"
class="flex flex-1 flex-col justify-between overflow-hidden"
>
<div class="flex flex-col overflow-y-auto">
<div
v-for="(section, i) in fieldsLayout.data"
:key="section.label"
class="flex flex-col p-3"
:class="{ 'border-b': i !== fieldsLayout.data.length - 1 }"
>
<Section :is-opened="section.opened" :label="section.label">
<template #actions>
<Button
v-if="i == 0 && isManager()"
variant="ghost"
class="w-7"
@click="showSidePanelModal = true"
>
<EditIcon class="h-4 w-4" />
</template>
</Button>
<Button
:label="__('Delete')"
theme="red"
size="sm"
@click="deleteOrganization"
>
<template #prefix>
<FeatherIcon name="trash-2" class="h-4 w-4" />
</template>
</Button>
</div>
<ErrorMessage class="mt-2" :message="__(error)" />
</Button>
</template>
<SectionFields
v-if="section.fields"
:fields="section.fields"
:isLastSection="i == fieldsLayout.data.length - 1"
v-model="organization.doc"
@update="updateField"
/>
</Section>
</div>
</div>
</template>
</FileUploader>
<Tabs v-model="tabIndex" :tabs="tabs">
</div>
</Resizer>
<Tabs class="overflow-hidden" v-model="tabIndex" :tabs="tabs">
<template #tab="{ tab, selected }">
<button
class="group flex items-center gap-2 border-b border-transparent py-2.5 text-base text-gray-600 duration-300 ease-in-out hover:border-gray-400 hover:text-gray-900"
@ -216,29 +181,32 @@
</template>
</Tabs>
</div>
<OrganizationModal
v-model="showOrganizationModal"
v-model:quickEntry="showQuickEntryModal"
v-model:organization="organization"
:options="{ detailMode }"
<SidePanelModal
v-if="showSidePanelModal"
v-model="showSidePanelModal"
doctype="CRM Organization"
@reload="() => fieldsLayout.reload()"
/>
<QuickEntryModal
v-if="showQuickEntryModal"
v-model="showQuickEntryModal"
doctype="CRM Organization"
/>
<AddressModal v-model="showAddressModal" v-model:address="_address" />
</template>
<script setup>
import Resizer from '@/components/Resizer.vue'
import Section from '@/components/Section.vue'
import SectionFields from '@/components/SectionFields.vue'
import SidePanelModal from '@/components/Settings/SidePanelModal.vue'
import Icon from '@/components/Icon.vue'
import LayoutHeader from '@/components/LayoutHeader.vue'
import OrganizationModal from '@/components/Modals/OrganizationModal.vue'
import QuickEntryModal from '@/components/Modals/QuickEntryModal.vue'
import AddressModal from '@/components/Modals/AddressModal.vue'
import DealsListView from '@/components/ListViews/DealsListView.vue'
import ContactsListView from '@/components/ListViews/ContactsListView.vue'
import WebsiteIcon from '@/components/Icons/WebsiteIcon.vue'
import TerritoryIcon from '@/components/Icons/TerritoryIcon.vue'
import MoneyIcon from '@/components/Icons/MoneyIcon.vue'
import EditIcon from '@/components/Icons/EditIcon.vue'
import CameraIcon from '@/components/Icons/CameraIcon.vue'
import DealsIcon from '@/components/Icons/DealsIcon.vue'
@ -252,8 +220,10 @@ import {
dateTooltipFormat,
timeAgo,
formatNumberIntoCurrency,
createToast,
} from '@/utils'
import {
Tooltip,
Breadcrumbs,
Avatar,
FileUploader,
@ -263,6 +233,7 @@ import {
createListResource,
createDocumentResource,
usePageMeta,
createResource,
} from 'frappe-ui'
import { h, computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@ -274,11 +245,11 @@ const props = defineProps({
},
})
const { getUser, isManager } = usersStore()
const { $dialog } = globalStore()
const { getDealStatus } = statusesStore()
const showOrganizationModal = ref(false)
const showSidePanelModal = ref(false)
const showQuickEntryModal = ref(false)
const detailMode = ref(false)
const route = useRoute()
const router = useRouter()
@ -291,6 +262,17 @@ const organization = createDocumentResource({
auto: true,
})
async function updateField(fieldname, value) {
await organization.setValue.submit({
[fieldname]: value,
})
createToast({
title: __('Organization updated'),
icon: 'check',
iconClasses: 'text-green-600',
})
}
const breadcrumbs = computed(() => {
let items = [{ label: __('Organizations'), route: { name: 'Organizations' } }]
@ -372,6 +354,60 @@ function website(url) {
return url && url.replace(/^(?:https?:\/\/)?(?:www\.)?/i, '')
}
function openWebsite() {
if (!organization.doc.website)
createToast({
title: __('Website not found'),
icon: 'x',
iconClasses: 'text-red-600',
})
else window.open(organization.doc.website, '_blank')
}
const showAddressModal = ref(false)
const _organization = ref({})
const _address = ref({})
const fieldsLayout = createResource({
url: 'crm.api.doc.get_sidebar_fields',
cache: ['fieldsLayout', props.organizationId],
params: { doctype: 'CRM Organization', name: props.organizationId },
auto: true,
transform: (data) => getParsedFields(data),
})
function getParsedFields(data) {
return data.map((section) => {
return {
...section,
fields: computed(() =>
section.fields.map((field) => {
if (field.name === 'address') {
return {
...field,
create: (value, close) => {
_organization.value.address = value
_address.value = {}
showAddressModal.value = true
close()
},
edit: async (addr) => {
_address.value = await call('frappe.client.get', {
doctype: 'Address',
name: addr,
})
showAddressModal.value = true
},
}
} else {
return field
}
}),
),
}
})
}
const tabIndex = ref(0)
const tabs = [
{
@ -386,8 +422,6 @@ const tabs = [
},
]
const { getUser } = usersStore()
const deals = createListResource({
type: 'list',
doctype: 'CRM Deal',

View File

@ -61,7 +61,7 @@ const routes = [
{
path: '/contacts/:contactId',
name: 'Contact',
component: () => import('@/pages/Contact.vue'),
component: () => import(`@/pages/${handleMobileView('Contact')}.vue`),
props: true,
},
{
@ -74,7 +74,7 @@ const routes = [
{
path: '/organizations/:organizationId',
name: 'Organization',
component: () => import('@/pages/Organization.vue'),
component: () => import(`@/pages/${handleMobileView('Organization')}.vue`),
props: true,
},
{