Merge branch 'develop' into saas-signup
This commit is contained in:
commit
bfc6f112d8
34
README.md
34
README.md
@ -45,10 +45,42 @@
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Cloud Hosting
|
||||
### Managed Hosting
|
||||
|
||||
Get started with your personal or business site with a few clicks on [Frappe Cloud](https://frappecloud.com/marketplace/apps/crm).
|
||||
|
||||
### Docker (Recommended)
|
||||
|
||||
The quickest way to set up Frappe CRM and take it for a test ride.
|
||||
|
||||
Frappe framework is multi-tenant and supports multiple apps by default. This docker compose is just a standalone version with Frappe CRM pre-installed. Just put it behind your desired reverse-proxy if needed, and you're good to go.
|
||||
|
||||
If you wish to use multiple Frappe apps or need multi-tenancy. Take a look at our production ready self-hosted workflow, or join us on Frappe Cloud to get first party support and hassle-free hosting.
|
||||
|
||||
**Step 1**: Setup folder and download the required files
|
||||
|
||||
mkdir frappe-crm
|
||||
cd frappe-crm
|
||||
|
||||
**Step 2**: Download the required files
|
||||
|
||||
Docker Compose File:
|
||||
|
||||
wget -O docker-compose.yml https://raw.githubusercontent.com/frappe/crm/develop/docker/docker-compose.yml
|
||||
|
||||
Frappe CRM bench setup script
|
||||
|
||||
wget -O init.sh https://raw.githubusercontent.com/frappe/crm/develop/docker/init.sh
|
||||
|
||||
**Step 3**: Run the container and daemonize it
|
||||
|
||||
docker compose up -d
|
||||
|
||||
**Step 4**: The site [http://crm.localhost](http://crm.localhost) should now be available. The default credentials are:
|
||||
|
||||
> username: administrator
|
||||
> password: admin
|
||||
|
||||
### Self-hosting
|
||||
|
||||
If you prefer self-hosting, follow the official [Frappe Bench Installation](https://github.com/frappe/bench#installation) instructions.
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
@ -2,7 +2,7 @@ version: "3.7"
|
||||
name: crm
|
||||
services:
|
||||
mariadb:
|
||||
image: mariadb:10.6
|
||||
image: mariadb:10.8
|
||||
command:
|
||||
- --character-set-server=utf8mb4
|
||||
- --collation-server=utf8mb4_unicode_ci
|
||||
|
||||
@ -22,7 +22,7 @@ bench set-redis-socketio-host redis:6379
|
||||
sed -i '/redis/d' ./Procfile
|
||||
sed -i '/watch/d' ./Procfile
|
||||
|
||||
bench get-app crm
|
||||
bench get-app crm --branch develop
|
||||
|
||||
bench new-site crm.localhost \
|
||||
--force \
|
||||
@ -34,7 +34,6 @@ bench --site crm.localhost install-app crm
|
||||
bench --site crm.localhost set-config developer_mode 1
|
||||
bench --site crm.localhost clear-cache
|
||||
bench --site crm.localhost set-config mute_emails 1
|
||||
bench --site crm.localhost add-user alex@example.com --first-name Alex --last-name Scott --password 123 --user-type 'System User' --add-role 'crm Admin'
|
||||
bench use crm.localhost
|
||||
|
||||
bench start
|
||||
@ -79,6 +79,10 @@
|
||||
v-else-if="whatsapp.content_type == 'text'"
|
||||
v-html="formatWhatsAppMessage(whatsapp.message)"
|
||||
/>
|
||||
<div
|
||||
v-else-if="whatsapp.content_type == 'button'"
|
||||
v-html="formatWhatsAppMessage(whatsapp.message)"
|
||||
/>
|
||||
<div v-else-if="whatsapp.content_type == 'image'">
|
||||
<img
|
||||
:src="whatsapp.attach"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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>
|
||||
|
||||
16
frontend/src/components/Icons/MenuIcon.vue
Normal file
16
frontend/src/components/Icons/MenuIcon.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -18,15 +18,12 @@
|
||||
>
|
||||
<template #right>
|
||||
<Badge
|
||||
v-if="
|
||||
!isSidebarCollapsed &&
|
||||
notificationsStore().unreadNotificationsCount
|
||||
"
|
||||
:label="notificationsStore().unreadNotificationsCount"
|
||||
v-if="!isSidebarCollapsed && unreadNotificationsCount"
|
||||
:label="unreadNotificationsCount"
|
||||
variant="subtle"
|
||||
/>
|
||||
<div
|
||||
v-else-if="notificationsStore().unreadNotificationsCount"
|
||||
v-else-if="unreadNotificationsCount"
|
||||
class="absolute -left-1.5 top-1 z-20 h-[5px] w-[5px] translate-x-6 translate-y-1 rounded-full bg-gray-800 ring-1 ring-white"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -25,8 +25,8 @@
|
||||
>
|
||||
<template #right>
|
||||
<Badge
|
||||
v-if="notificationsStore().unreadNotificationsCount"
|
||||
:label="notificationsStore().unreadNotificationsCount"
|
||||
v-if="unreadNotificationsCount"
|
||||
:label="unreadNotificationsCount"
|
||||
variant="subtle"
|
||||
/>
|
||||
</template>
|
||||
@ -101,7 +101,7 @@ import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
|
||||
import NotificationsIcon from '@/components/Icons/NotificationsIcon.vue'
|
||||
import SidebarLink from '@/components/SidebarLink.vue'
|
||||
import { viewsStore } from '@/stores/views'
|
||||
import { notificationsStore } from '@/stores/notifications'
|
||||
import { unreadNotificationsCount } from '@/stores/notifications'
|
||||
import { computed, h } from 'vue'
|
||||
import { mobileSidebarOpened as sidebarOpened } from '@/composables/settings'
|
||||
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="notificationsStore().visible"
|
||||
v-if="visible"
|
||||
ref="target"
|
||||
class="absolute z-20 h-screen bg-white transition-all duration-300 ease-in-out"
|
||||
:style="{
|
||||
@ -27,7 +27,7 @@
|
||||
</Tooltip>
|
||||
<Tooltip :text="__('Close')">
|
||||
<div>
|
||||
<Button variant="ghost" @click="() => toggleNotificationPanel()">
|
||||
<Button variant="ghost" @click="() => toggle()">
|
||||
<template #icon>
|
||||
<FeatherIcon name="x" class="h-4 w-4" />
|
||||
</template>
|
||||
@ -37,11 +37,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="notificationsStore().allNotifications?.length"
|
||||
v-if="notifications.data?.length"
|
||||
class="divide-y overflow-auto text-base"
|
||||
>
|
||||
<RouterLink
|
||||
v-for="n in notificationsStore().allNotifications"
|
||||
v-for="n in notifications.data"
|
||||
:key="n.comment"
|
||||
:to="getRoute(n)"
|
||||
class="flex cursor-pointer items-start gap-2.5 px-4 py-2.5 hover:bg-gray-100"
|
||||
@ -91,7 +91,11 @@ import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
|
||||
import MarkAsDoneIcon from '@/components/Icons/MarkAsDoneIcon.vue'
|
||||
import NotificationsIcon from '@/components/Icons/NotificationsIcon.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { notificationsStore } from '@/stores/notifications'
|
||||
import {
|
||||
visible,
|
||||
notifications,
|
||||
notificationsStore,
|
||||
} from '@/stores/notifications'
|
||||
import { globalStore } from '@/stores/global'
|
||||
import { timeAgo } from '@/utils'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
@ -100,32 +104,27 @@ import { Tooltip } from 'frappe-ui'
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
const { $socket } = globalStore()
|
||||
const { mark_as_read, toggle, mark_doc_as_read } = notificationsStore()
|
||||
|
||||
const target = ref(null)
|
||||
onClickOutside(
|
||||
target,
|
||||
() => {
|
||||
if (notificationsStore().visible) {
|
||||
toggleNotificationPanel()
|
||||
}
|
||||
if (visible.value) toggle()
|
||||
},
|
||||
{
|
||||
ignore: ['#notifications-btn'],
|
||||
},
|
||||
)
|
||||
|
||||
function toggleNotificationPanel() {
|
||||
notificationsStore().toggle()
|
||||
}
|
||||
|
||||
function markAsRead(doc) {
|
||||
capture('notification_mark_as_read')
|
||||
notificationsStore().mark_doc_as_read(doc)
|
||||
mark_doc_as_read(doc)
|
||||
}
|
||||
|
||||
function markAllAsRead() {
|
||||
capture('notification_mark_all_as_read')
|
||||
notificationsStore().mark_as_read.reload()
|
||||
mark_as_read.reload()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@ -134,7 +133,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
onMounted(() => {
|
||||
$socket.on('crm_notification', () => {
|
||||
notificationsStore().notifications.reload()
|
||||
notifications.reload()
|
||||
})
|
||||
})
|
||||
|
||||
@ -154,6 +153,4 @@ function getRoute(notification) {
|
||||
hash: notification.hash,
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {})
|
||||
</script>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
>
|
||||
·
|
||||
</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"
|
||||
>
|
||||
·
|
||||
</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"
|
||||
>
|
||||
·
|
||||
</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>
|
||||
652
frontend/src/pages/MobileContact.vue
Normal file
652
frontend/src/pages/MobileContact.vue
Normal 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>
|
||||
@ -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 }">
|
||||
|
||||
@ -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 }">
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
<div>
|
||||
<Button
|
||||
:label="__('Mark all as read')"
|
||||
@click="() => notificationsStore().mark_as_read.reload()"
|
||||
@click="() => mark_as_read.reload()"
|
||||
>
|
||||
<template #prefix>
|
||||
<MarkAsDoneIcon class="h-4 w-4" />
|
||||
@ -24,15 +24,15 @@
|
||||
</LayoutHeader>
|
||||
<div class="flex flex-col overflow-hidden">
|
||||
<div
|
||||
v-if="notificationsStore().allNotifications?.length"
|
||||
v-if="notifications.data?.length"
|
||||
class="divide-y overflow-y-auto text-base"
|
||||
>
|
||||
<RouterLink
|
||||
v-for="n in notificationsStore().allNotifications"
|
||||
v-for="n in notifications.data"
|
||||
:key="n.comment"
|
||||
:to="getRoute(n)"
|
||||
class="flex cursor-pointer items-start gap-3 px-2.5 py-3 hover:bg-gray-100"
|
||||
@click="mark_as_read(n.comment || n.notification_type_doc)"
|
||||
@click="mark_doc_as_read(n.comment || n.notification_type_doc)"
|
||||
>
|
||||
<div class="mt-1 flex items-center gap-2.5">
|
||||
<div
|
||||
@ -75,17 +75,14 @@ import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
|
||||
import MarkAsDoneIcon from '@/components/Icons/MarkAsDoneIcon.vue'
|
||||
import NotificationsIcon from '@/components/Icons/NotificationsIcon.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { notificationsStore } from '@/stores/notifications'
|
||||
import { notifications, notificationsStore } from '@/stores/notifications'
|
||||
import { globalStore } from '@/stores/global'
|
||||
import { timeAgo } from '@/utils'
|
||||
import { Breadcrumbs, Tooltip } from 'frappe-ui'
|
||||
import { onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
const { $socket } = globalStore()
|
||||
|
||||
function mark_as_read(doc) {
|
||||
notificationsStore().mark_doc_as_read(doc)
|
||||
}
|
||||
const { mark_as_read, mark_doc_as_read } = notificationsStore()
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
$socket.off('crm_notification')
|
||||
@ -93,7 +90,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
onMounted(() => {
|
||||
$socket.on('crm_notification', () => {
|
||||
notificationsStore().notifications.reload()
|
||||
notifications.reload()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
563
frontend/src/pages/MobileOrganization.vue
Normal file
563
frontend/src/pages/MobileOrganization.vue
Normal 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>
|
||||
@ -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"
|
||||
>
|
||||
·
|
||||
</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"
|
||||
>
|
||||
·
|
||||
</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"
|
||||
>
|
||||
·
|
||||
</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"
|
||||
>
|
||||
·
|
||||
</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',
|
||||
|
||||
@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@ -2,18 +2,21 @@ import { defineStore } from 'pinia'
|
||||
import { createResource } from 'frappe-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
export const visible = ref(false)
|
||||
|
||||
export const notifications = createResource({
|
||||
url: 'crm.api.notifications.get_notifications',
|
||||
initialData: [],
|
||||
auto: true,
|
||||
})
|
||||
|
||||
export const unreadNotificationsCount = computed(
|
||||
() => notifications.data?.filter((n) => !n.read).length || 0,
|
||||
)
|
||||
|
||||
export const notificationsStore = defineStore('crm-notifications', () => {
|
||||
let visible = ref(false)
|
||||
|
||||
const notifications = createResource({
|
||||
url: 'crm.api.notifications.get_notifications',
|
||||
initialData: [],
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const mark_as_read = createResource({
|
||||
url: 'crm.api.notifications.mark_as_read',
|
||||
auto: false,
|
||||
onSuccess: () => {
|
||||
mark_as_read.params = {}
|
||||
notifications.reload()
|
||||
@ -24,11 +27,6 @@ export const notificationsStore = defineStore('crm-notifications', () => {
|
||||
visible.value = !visible.value
|
||||
}
|
||||
|
||||
const allNotifications = computed(() => notifications.data || [])
|
||||
const unreadNotificationsCount = computed(
|
||||
() => notifications.data?.filter((n) => !n.read).length || 0
|
||||
)
|
||||
|
||||
function mark_doc_as_read(doc) {
|
||||
mark_as_read.params = { doc: doc }
|
||||
mark_as_read.reload()
|
||||
@ -36,9 +34,6 @@ export const notificationsStore = defineStore('crm-notifications', () => {
|
||||
}
|
||||
|
||||
return {
|
||||
notifications,
|
||||
visible,
|
||||
allNotifications,
|
||||
unreadNotificationsCount,
|
||||
mark_as_read,
|
||||
mark_doc_as_read,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user