1
0
forked from test/crm

Merge pull request #22 from shariquerik/organization-page-new

This commit is contained in:
Shariq Ansari 2023-11-03 23:45:34 +05:30 committed by GitHub
commit b7d00bd2d3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1204 additions and 139 deletions

View File

@ -30,4 +30,18 @@ def get_contacts():
distinct=True,
).run(as_dict=1)
return contacts
return contacts
@frappe.whitelist()
def get_organizations():
if frappe.session.user == "Guest":
frappe.throw("Authentication failed", exc=frappe.AuthenticationError)
organizations = frappe.qb.get_query(
"CRM Organization",
fields=['name', 'organization_logo', 'website'],
order_by="name asc",
distinct=True,
).run(as_dict=1)
return organizations

View File

@ -0,0 +1,8 @@
// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
// frappe.ui.form.on("CRM Organization", {
// refresh(frm) {
// },
// });

View File

@ -0,0 +1,57 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "field:organization_name",
"creation": "2023-11-03 16:23:59.341751",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"organization_name",
"website",
"organization_logo"
],
"fields": [
{
"fieldname": "organization_name",
"fieldtype": "Data",
"label": "Organization Name",
"unique": 1
},
{
"fieldname": "website",
"fieldtype": "Data",
"label": "Website"
},
{
"fieldname": "organization_logo",
"fieldtype": "Attach Image",
"label": "Organization Logo"
}
],
"image_field": "organization_logo",
"index_web_pages_for_search": 1,
"links": [],
"modified": "2023-11-03 16:25:25.366741",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Organization",
"naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}

View File

@ -0,0 +1,9 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class CRMOrganization(Document):
pass

View File

@ -0,0 +1,9 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
class TestCRMOrganization(FrappeTestCase):
pass

View File

@ -619,8 +619,8 @@ import EmailAtIcon from '@/components/Icons/EmailAtIcon.vue'
import InboundCallIcon from '@/components/Icons/InboundCallIcon.vue'
import OutboundCallIcon from '@/components/Icons/OutboundCallIcon.vue'
import CommunicationArea from '@/components/CommunicationArea.vue'
import NoteModal from '@/components/NoteModal.vue'
import TaskModal from '@/components/TaskModal.vue'
import NoteModal from '@/components/Modals/NoteModal.vue'
import TaskModal from '@/components/Modals/TaskModal.vue'
import {
timeAgo,
dateFormat,

View File

@ -37,6 +37,7 @@ import UserDropdown from '@/components/UserDropdown.vue'
import LeadsIcon from '@/components/Icons/LeadsIcon.vue'
import DealsIcon from '@/components/Icons/DealsIcon.vue'
import ContactsIcon from '@/components/Icons/ContactsIcon.vue'
import OrganizationsIcon from '@/components/Icons/OrganizationsIcon.vue'
import NoteIcon from '@/components/Icons/NoteIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
@ -59,6 +60,11 @@ const links = [
icon: ContactsIcon,
to: 'Contacts',
},
{
label: 'Organizations',
icon: OrganizationsIcon,
to: 'Organizations',
},
{
label: 'Notes',
icon: NoteIcon,

View File

@ -180,12 +180,12 @@ import MinimizeIcon from '@/components/Icons/MinimizeIcon.vue'
import DialpadIcon from '@/components/Icons/DialpadIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import CountUpTimer from '@/components/CountUpTimer.vue'
import NoteModal from '@/components/Modals/NoteModal.vue'
import { Device } from '@twilio/voice-sdk'
import { useDraggable, useWindowSize } from '@vueuse/core'
import { contactsStore } from '@/stores/contacts'
import { Avatar, call } from 'frappe-ui'
import { onMounted, ref, watch, getCurrentInstance } from 'vue'
import NoteModal from './NoteModal.vue'
const { getContact } = contactsStore()

View File

@ -0,0 +1,16 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M2.5 4.5C2.5 3.39543 3.39543 2.5 4.5 2.5H8.5C8.77614 2.5 9 2.27614 9 2C9 1.72386 8.77614 1.5 8.5 1.5H4.5C2.84315 1.5 1.5 2.84315 1.5 4.5V11.5C1.5 13.1569 2.84315 14.5 4.5 14.5H11.5C13.1569 14.5 14.5 13.1569 14.5 11.5V7.5C14.5 7.22386 14.2761 7 14 7C13.7239 7 13.5 7.22386 13.5 7.5V11.5C13.5 12.6046 12.6046 13.5 11.5 13.5H4.5C3.39543 13.5 2.5 12.6046 2.5 11.5V4.5ZM14.1255 2.58446C14.3207 2.3892 14.3207 2.07261 14.1255 1.87735C13.9302 1.68209 13.6136 1.68209 13.4184 1.87735L6.68616 8.60954C6.4909 8.8048 6.4909 9.12139 6.68616 9.31665C6.88143 9.51191 7.19801 9.51191 7.39327 9.31665L14.1255 2.58446Z"
fill="currentColor"
/>
</svg>
</template>

View File

@ -0,0 +1,16 @@
<template>
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M4.83144 1.7793L4.81079 1.7793C4.40911 1.77929 4.07754 1.77929 3.80742 1.80135C3.52685 1.82428 3.26886 1.87348 3.0265 1.99697C2.65072 2.18844 2.3452 2.49396 2.15373 2.86974C2.03024 3.1121 1.98104 3.37009 1.95811 3.65066C1.93604 3.92078 1.93605 4.25235 1.93606 4.65403L1.93606 4.67468V15.2533H1.6875C1.41136 15.2533 1.1875 15.4772 1.1875 15.7533C1.1875 16.0295 1.41136 16.2533 1.6875 16.2533H2.43606H9.92162H15.9101H16.6586C16.9348 16.2533 17.1586 16.0295 17.1586 15.7533C17.1586 15.4772 16.9348 15.2533 16.6586 15.2533H16.4101V10.6631V10.6425C16.4101 10.2408 16.4101 9.90924 16.388 9.63911C16.3651 9.35854 16.3159 9.10055 16.1924 8.85819C16.0009 8.48241 15.6954 8.17689 15.3196 7.98542C15.0773 7.86193 14.8193 7.81273 14.5387 7.7898C14.2686 7.76774 13.937 7.76774 13.5353 7.76775H13.5147H10.4216V4.67468V4.65402C10.4216 4.25235 10.4216 3.92077 10.3996 3.65066C10.3766 3.37009 10.3274 3.1121 10.2039 2.86974C10.0125 2.49396 9.70696 2.18844 9.33118 1.99697C9.08882 1.87348 8.83082 1.82428 8.55026 1.80135C8.28014 1.77929 7.94856 1.77929 7.54688 1.7793L7.52624 1.7793H4.83144ZM9.42162 15.2533H2.93606V4.67468C2.93606 4.2472 2.93645 3.95666 2.95479 3.73209C2.97266 3.51337 3.00505 3.40162 3.04473 3.32373C3.14033 3.13611 3.29287 2.98357 3.48049 2.88798C3.55838 2.84829 3.67013 2.8159 3.88885 2.79803C4.11342 2.77969 4.40396 2.7793 4.83144 2.7793H7.52624C7.95372 2.7793 8.24426 2.77969 8.46882 2.79803C8.68755 2.8159 8.7993 2.84829 8.87719 2.88798C9.06481 2.98357 9.21734 3.13611 9.31294 3.32373C9.35263 3.40162 9.38501 3.51337 9.40288 3.73209C9.42123 3.95666 9.42162 4.2472 9.42162 4.67468V8.26775V15.2533ZM10.4216 15.2533V8.76775H13.5147C13.9422 8.76775 14.2327 8.76814 14.4573 8.78648C14.676 8.80435 14.7877 8.83674 14.8656 8.87643C15.0533 8.97202 15.2058 9.12456 15.3014 9.31218C15.3411 9.39007 15.3735 9.50182 15.3913 9.72054C15.4097 9.94511 15.4101 10.2356 15.4101 10.6631V15.2533H10.4216ZM5.05616 4.77338C4.78002 4.77338 4.55616 4.99724 4.55616 5.27338C4.55616 5.54952 4.78002 5.77338 5.05616 5.77338H7.30183C7.57797 5.77338 7.80183 5.54952 7.80183 5.27338C7.80183 4.99724 7.57797 4.77338 7.30183 4.77338H5.05616ZM5.05616 7.76761C4.78002 7.76761 4.55616 7.99146 4.55616 8.26761C4.55616 8.54375 4.78002 8.76761 5.05616 8.76761H7.30183C7.57797 8.76761 7.80183 8.54375 7.80183 8.26761C7.80183 7.99146 7.57797 7.76761 7.30183 7.76761H5.05616ZM5.05616 10.7618C4.78002 10.7618 4.55616 10.9857 4.55616 11.2618C4.55616 11.538 4.78002 11.7618 5.05616 11.7618H7.30183C7.57797 11.7618 7.80183 11.538 7.80183 11.2618C7.80183 10.9857 7.57797 10.7618 7.30183 10.7618H5.05616Z"
fill="currentColor"
/>
</svg>
</template>

View File

@ -0,0 +1,16 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M2.02062 7.49896C2.49506 7.49716 2.97742 7.4933 3.47006 7.48935C3.94277 7.48556 4.42495 7.48169 4.9187 7.47951C4.97589 6.3862 5.14998 5.23014 5.51484 4.17085C5.76241 3.45208 6.1023 2.76557 6.56226 2.17336C4.09972 2.77895 2.23508 4.90491 2.02062 7.49896ZM8 2.1204C7.29452 2.69277 6.79592 3.52219 6.46032 4.49652C6.13931 5.4285 5.97681 6.46734 5.92025 7.4779C6.5896 7.47899 7.28127 7.485 8 7.49989C8.7546 7.48425 9.43077 7.47841 10.0797 7.47777C10.0232 6.46725 9.86068 5.42846 9.53968 4.49651C9.20408 3.52219 8.70548 2.69277 8 2.1204ZM10.0822 8.47771C10.0279 9.50221 9.8654 10.5578 9.53968 11.5035C9.20409 12.4778 8.70548 13.3072 8.00001 13.8796C7.29453 13.3072 6.79592 12.4778 6.46032 11.5035C6.13462 10.5579 5.9721 9.5023 5.91784 8.47784C6.58355 8.47892 7.27164 8.48494 7.98959 8.49989C7.99653 8.50004 8.00347 8.50004 8.01042 8.49989C8.76493 8.48417 9.43715 8.47833 10.0822 8.47771ZM4.91661 8.47951C4.43411 8.48168 3.962 8.48548 3.49714 8.48922C2.99784 8.49323 2.50691 8.49718 2.02045 8.49899C2.2341 11.094 4.09907 13.2209 6.56227 13.8266C6.1023 13.2344 5.76241 12.5479 5.51484 11.8291C5.14541 10.7566 4.97157 9.58487 4.91661 8.47951ZM9.43774 13.8266C9.89771 13.2344 10.2376 12.5479 10.4852 11.8291C10.8545 10.7569 11.0283 9.58549 11.0834 8.48039C11.4269 8.48234 11.771 8.48511 12.1244 8.48796C12.7045 8.49264 13.3098 8.49752 13.9795 8.4993C13.7658 11.0941 11.9008 13.2209 9.43774 13.8266ZM13.9794 7.49928C13.3241 7.49751 12.7264 7.49271 12.1508 7.48809C11.7885 7.48518 11.435 7.48234 11.0814 7.48037C11.0242 6.38681 10.8501 5.23042 10.4852 4.17085C10.2376 3.45208 9.89771 2.76557 9.43774 2.17336C11.9004 2.77897 13.7651 4.90509 13.9794 7.49928ZM1 8C1 4.13401 4.13401 1 8 1C11.866 1 15 4.13401 15 8C15 11.866 11.866 15 8 15C4.13401 15 1 11.866 1 8Z"
fill="currentColor"
/>
</svg>
</template>

View File

@ -0,0 +1,23 @@
<template>
<ListView
class="px-5"
v-if="rows"
:columns="columns"
:rows="rows"
row-key="name"
/>
</template>
<script setup>
import { ListView } from 'frappe-ui'
const props = defineProps({
rows: {
type: Array,
required: true,
},
columns: {
type: Array,
required: true,
},
})
</script>

View File

@ -0,0 +1,79 @@
<template>
<ListView
:columns="columns"
:rows="rows"
:options="{
getRowRoute: (row) => ({ name: 'Deal', params: { dealId: row.name } }),
}"
row-key="name"
>
<ListHeader class="mx-5" />
<ListRows>
<ListRow
class="mx-5"
v-for="row in rows"
:key="row.name"
v-slot="{ column, item }"
:row="row"
>
<ListRowItem :item="item">
<template #prefix>
<div v-if="column.key === 'deal_status'">
<IndicatorIcon :class="item.color" />
</div>
<div v-else-if="column.key === 'organization_name'">
<Avatar
v-if="item.label"
class="flex items-center"
:image="item.logo"
:label="item.label"
size="sm"
/>
</div>
<div v-else-if="column.key === 'lead_owner'">
<Avatar
v-if="item.full_name"
class="flex items-center"
:image="item.user_image"
:label="item.full_name"
size="sm"
/>
</div>
<div v-else-if="column.key === 'mobile_no'">
<PhoneIcon class="h-4 w-4" />
</div>
</template>
<div v-if="column.key === 'modified'" class="truncate text-base">
{{ item.timeAgo }}
</div>
</ListRowItem>
</ListRow>
</ListRows>
<ListSelectBanner />
</ListView>
</template>
<script setup>
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import {
Avatar,
ListView,
ListHeader,
ListRows,
ListRow,
ListRowItem,
ListSelectBanner,
} from 'frappe-ui'
const props = defineProps({
rows: {
type: Array,
required: true,
},
columns: {
type: Array,
required: true,
},
})
</script>

View File

@ -0,0 +1,88 @@
<template>
<ListView
:columns="columns"
:rows="rows"
:options="{
getRowRoute: (row) => ({ name: 'Lead', params: { leadId: row.name } }),
}"
row-key="name"
>
<ListHeader class="mx-5" />
<ListRows>
<ListRow
class="mx-5"
v-for="row in rows"
:key="row.name"
v-slot="{ column, item }"
:row="row"
>
<ListRowItem :item="item">
<template #prefix>
<div v-if="column.key === 'status'">
<IndicatorIcon :class="item.color" />
</div>
<div v-else-if="column.key === 'lead_name'">
<Avatar
v-if="item.label"
class="flex items-center"
:image="item.image"
:label="item.image_label"
size="sm"
/>
</div>
<div v-else-if="column.key === 'organization_name'">
<Avatar
v-if="item.label"
class="flex items-center"
:image="item.logo"
:label="item.label"
size="sm"
/>
</div>
<div v-else-if="column.key === 'lead_owner'">
<Avatar
v-if="item.full_name"
class="flex items-center"
:image="item.user_image"
:label="item.full_name"
size="sm"
/>
</div>
<div v-else-if="column.key === 'mobile_no'">
<PhoneIcon class="h-4 w-4" />
</div>
</template>
<div v-if="column.key === 'modified'" class="truncate text-base">
{{ item.timeAgo }}
</div>
</ListRowItem>
</ListRow>
</ListRows>
<ListSelectBanner />
</ListView>
</template>
<script setup>
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import {
Avatar,
ListView,
ListHeader,
ListRows,
ListRow,
ListSelectBanner,
ListRowItem,
} from 'frappe-ui'
const props = defineProps({
rows: {
type: Array,
required: true,
},
columns: {
type: Array,
required: true,
},
})
</script>

View File

@ -0,0 +1,144 @@
<template>
<Dialog
v-model="show"
:options="{
title: editMode ? 'Edit Organization' : 'Create Organization',
size: 'xl',
actions: [
{
label: editMode ? 'Update' : 'Create',
variant: 'solid',
onClick: ({ close }) => updateOrganization(close),
},
],
}"
>
<template #body-content>
<div class="flex flex-col gap-4">
<div>
<div class="mb-1.5 text-sm text-gray-600">Organization name</div>
<TextInput
ref="title"
variant="outline"
v-model="_organization.name"
placeholder="Add organization name"
/>
</div>
<div>
<div class="mb-1.5 text-sm text-gray-600">Website</div>
<TextInput
variant="outline"
v-model="_organization.website"
placeholder="Add website"
/>
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import { TextInput, Dialog, call } from 'frappe-ui'
import { ref, defineModel, nextTick, watch } from 'vue'
import { useRouter } from 'vue-router'
const props = defineProps({
organization: {
type: Object,
default: {},
},
})
const show = defineModel()
const organizations = defineModel('reloadOrganizations')
const title = ref(null)
const editMode = ref(false)
let _organization = ref({})
const router = useRouter()
async function updateOrganization(close) {
const old = { ...props.organization }
const newOrg = { ..._organization.value }
const nameChanged = old.name !== newOrg.name
delete old.name
delete newOrg.name
const otherFieldChanged = JSON.stringify(old) !== JSON.stringify(newOrg)
const values = newOrg
if (!nameChanged && !otherFieldChanged) {
close()
return
}
if (editMode.value) {
let name
if (nameChanged) {
name = await callRenameDoc()
}
if (otherFieldChanged) {
name = await callSetValue(values)
}
handleOrganizationUpdate(name)
} else {
await callInsertDoc()
}
close()
}
async function callRenameDoc() {
const d = await call('frappe.client.rename_doc', {
doctype: 'CRM Organization',
old_name: props.organization.name,
new_name: _organization.value.name,
})
return d
}
async function callSetValue(values) {
const d = await call('frappe.client.set_value', {
doctype: 'CRM Organization',
name: _organization.value.name,
fieldname: values,
})
return d.name
}
async function callInsertDoc() {
const d = await call('frappe.client.insert', {
doc: {
doctype: 'CRM Organization',
organization_name: _organization.value.name,
website: _organization.value.website,
},
})
d.name && handleOrganizationUpdate()
}
function handleOrganizationUpdate(name) {
organizations.value.reload()
if (name) {
router.push({
name: 'Organization',
params: { organizationId: name },
})
}
}
watch(
() => show.value,
(value) => {
if (!value) return
editMode.value = false
nextTick(() => {
title.value.el.focus()
_organization.value = { ...props.organization }
if (_organization.value.name) {
editMode.value = true
}
})
}
)
</script>

View File

@ -142,7 +142,7 @@
<script setup>
import LayoutHeader from '@/components/LayoutHeader.vue'
import DurationIcon from '@/components/Icons/DurationIcon.vue'
import NoteModal from '@/components/NoteModal.vue'
import NoteModal from '@/components/Modals/NoteModal.vue'
import { dateFormat, timeAgo, dateTooltipFormat } from '@/utils'
import {
TextEditor,

View File

@ -33,58 +33,7 @@
<Button icon="more-horizontal" />
</div>
</div>
<ListView
:columns="columns"
:rows="rows"
:options="{
getRowRoute: (row) => ({ name: 'Deal', params: { dealId: row.name } }),
}"
row-key="name"
>
<ListHeader class="mx-5" />
<ListRows>
<ListRow
class="mx-5"
v-for="row in rows"
:key="row.name"
v-slot="{ column, item }"
:row="row"
>
<ListRowItem :item="item">
<template #prefix>
<div v-if="column.key === 'deal_status'">
<IndicatorIcon :class="item.color" />
</div>
<div v-else-if="column.key === 'organization_name'">
<Avatar
v-if="item.label"
class="flex items-center"
:image="item.logo"
:label="item.label"
size="sm"
/>
</div>
<div v-else-if="column.key === 'lead_owner'">
<Avatar
v-if="item.full_name"
class="flex items-center"
:image="item.user_image"
:label="item.full_name"
size="sm"
/>
</div>
<div v-else-if="column.key === 'mobile_no'">
<PhoneIcon class="h-4 w-4" />
</div>
</template>
<div v-if="column.key === 'modified'" class="truncate text-base">
{{ item.timeAgo }}
</div>
</ListRowItem>
</ListRow>
</ListRows>
<ListSelectBanner />
</ListView>
<DealsListView :rows="rows" :columns="columns" />
<Dialog
v-model="showNewDialog"
:options="{
@ -106,11 +55,10 @@
<script setup>
import LayoutHeader from '@/components/LayoutHeader.vue'
import DealsListView from '@/components/ListViews/DealsListView.vue'
import NewDeal from '@/components/NewDeal.vue'
import SortBy from '@/components/SortBy.vue'
import Filter from '@/components/Filter.vue'
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import { usersStore } from '@/stores/users'
import { useOrderBy } from '@/composables/orderby'
import { useFilter } from '@/composables/filter'
@ -123,7 +71,6 @@ import {
formatNumberIntoCurrency,
} from '@/utils'
import {
Avatar,
FeatherIcon,
Dialog,
Button,
@ -131,12 +78,6 @@ import {
createListResource,
createResource,
Breadcrumbs,
ListView,
ListHeader,
ListRows,
ListRow,
ListRowItem,
ListSelectBanner,
} from 'frappe-ui'
import { useRouter } from 'vue-router'
import { ref, computed, reactive, watch } from 'vue'
@ -238,7 +179,8 @@ const columns = [
]
const rows = computed(() => {
return leads.data?.map((lead) => {
if (!leads.data) return []
return leads.data.map((lead) => {
return {
name: lead.name,
organization_name: {

View File

@ -32,67 +32,7 @@
<Button icon="more-horizontal" />
</div>
</div>
<ListView
:columns="columns"
:rows="rows"
:options="{
getRowRoute: (row) => ({ name: 'Lead', params: { leadId: row.name } }),
}"
row-key="name"
>
<ListHeader class="mx-5" />
<ListRows>
<ListRow
class="mx-5"
v-for="row in rows"
:key="row.name"
v-slot="{ column, item }"
:row="row"
>
<ListRowItem :item="item">
<template #prefix>
<div v-if="column.key === 'status'">
<IndicatorIcon :class="item.color" />
</div>
<div v-else-if="column.key === 'lead_name'">
<Avatar
v-if="item.label"
class="flex items-center"
:image="item.image"
:label="item.image_label"
size="sm"
/>
</div>
<div v-else-if="column.key === 'organization_name'">
<Avatar
v-if="item.label"
class="flex items-center"
:image="item.logo"
:label="item.label"
size="sm"
/>
</div>
<div v-else-if="column.key === 'lead_owner'">
<Avatar
v-if="item.full_name"
class="flex items-center"
:image="item.user_image"
:label="item.full_name"
size="sm"
/>
</div>
<div v-else-if="column.key === 'mobile_no'">
<PhoneIcon class="h-4 w-4" />
</div>
</template>
<div v-if="column.key === 'modified'" class="truncate text-base">
{{ item.timeAgo }}
</div>
</ListRowItem>
</ListRow>
</ListRows>
<ListSelectBanner />
</ListView>
<LeadsListView :rows="rows" :columns="columns" />
<Dialog
v-model="showNewDialog"
:options="{
@ -114,18 +54,16 @@
<script setup>
import LayoutHeader from '@/components/LayoutHeader.vue'
import LeadsListView from '@/components/ListViews/LeadsListView.vue'
import NewLead from '@/components/NewLead.vue'
import SortBy from '@/components/SortBy.vue'
import Filter from '@/components/Filter.vue'
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import { usersStore } from '@/stores/users'
import { useOrderBy } from '@/composables/orderby'
import { useFilter } from '@/composables/filter'
import { useDebounceFn } from '@vueuse/core'
import { leadStatuses, dateFormat, dateTooltipFormat, timeAgo } from '@/utils'
import {
Avatar,
FeatherIcon,
Dialog,
Button,
@ -133,12 +71,6 @@ import {
createListResource,
createResource,
Breadcrumbs,
ListView,
ListHeader,
ListRows,
ListRow,
ListSelectBanner,
ListRowItem,
} from 'frappe-ui'
import { useRouter } from 'vue-router'
import { ref, computed, reactive, watch } from 'vue'
@ -246,7 +178,8 @@ const columns = [
]
const rows = computed(() => {
return leads.data?.map((lead) => {
if (!leads.data) return []
return leads.data.map((lead) => {
return {
name: lead.name,
lead_name: {

View File

@ -81,7 +81,7 @@
import LayoutHeader from '@/components/LayoutHeader.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import NoteIcon from '@/components/Icons/NoteIcon.vue'
import NoteModal from '@/components/NoteModal.vue'
import NoteModal from '@/components/Modals/NoteModal.vue'
import { timeAgo, dateFormat, dateTooltipFormat } from '@/utils'
import {
FeatherIcon,

View File

@ -0,0 +1,551 @@
<template>
<div class="flex flex-1 flex-col overflow-hidden">
<div class="flex gap-6 p-5">
<FileUploader
@success="changeOrganizationImage"
:validateFile="validateFile"
>
<template #default="{ openFileSelector, error }">
<div class="group relative h-24 w-24">
<Avatar
size="3xl"
:image="organization.organization_logo"
:label="organization.name"
class="!h-24 !w-24"
/>
<component
:is="organization.organization_logo ? Dropdown : 'div'"
v-bind="
organization.organization_logo
? {
options: [
{
icon: 'upload',
label: organization.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>
</component>
<ErrorMessage class="mt-2" :message="error" />
</div>
</template>
</FileUploader>
<div class="flex flex-col justify-center gap-2">
<div class="text-3xl font-semibold text-gray-900">
{{ organization.name }}
</div>
<div class="flex items-center gap-2 text-base text-gray-700">
<div v-if="organization.website" class="flex items-center gap-1.5">
<WebsiteIcon class="h-4 w-4" />
<span class="">{{ website(organization.website) }}</span>
</div>
<span
v-if="organization.email_id"
class="text-3xl leading-[0] text-gray-600"
>&middot;</span
>
<div v-if="organization.email_id" class="flex items-center gap-1.5">
<EmailIcon class="h-4 w-4" />
<span class="">{{ organization.email_id }}</span>
</div>
<span
v-if="
(organization.name || organization.email_id) &&
organization.mobile_no
"
class="text-3xl leading-[0] text-gray-600"
>&middot;</span
>
<div v-if="organization.mobile_no" class="flex items-center gap-1.5">
<PhoneIcon class="h-4 w-4" />
<span class="">{{ organization.mobile_no }}</span>
</div>
</div>
<div class="mt-1 flex gap-2">
<Button label="Edit" size="sm" @click="showOrganizationModal = true">
<template #prefix>
<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>
<!-- <Button label="Add lead" size="sm">
<template #prefix>
<FeatherIcon name="plus" class="h-4 w-4" />
</template>
</Button>
<Button label="Add deal" size="sm">
<template #prefix>
<FeatherIcon name="plus" class="h-4 w-4" />
</template>
</Button> -->
</div>
</div>
</div>
<Tabs v-model="tabIndex" :tabs="tabs">
<template #tab="{ tab, selected }">
<button
class="group -mb-px 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 class="flex h-full">
<LeadsListView
class="mt-4"
v-if="tab.label === 'Leads' && rows.length"
:rows="rows"
:columns="columns"
/>
<DealsListView
class="mt-4"
v-if="tab.label === 'Deals' && rows.length"
:rows="rows"
:columns="columns"
/>
<ContactsListView
class="mt-4"
v-if="tab.label === 'Contacts' && rows.length"
:rows="rows"
:columns="columns"
/>
<div
v-if="!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-2">
<component :is="tab.icon" class="!h-10 !w-10" />
<div>No {{ tab.label.toLowerCase() }} found</div>
</div>
</div>
</div>
</template>
</Tabs>
<OrganizationModal
v-model="showOrganizationModal"
v-model:reloadOrganizations="organizations"
:organization="organization"
/>
</div>
</template>
<script setup>
import {
FeatherIcon,
Avatar,
FileUploader,
ErrorMessage,
Dropdown,
Tabs,
Badge,
call,
createListResource,
} from 'frappe-ui'
import OrganizationModal from '@/components/Modals/OrganizationModal.vue'
import LeadsListView from '@/components/ListViews/LeadsListView.vue'
import DealsListView from '@/components/ListViews/DealsListView.vue'
import ContactsListView from '@/components/ListViews/ContactsListView.vue'
import WebsiteIcon from '@/components/Icons/WebsiteIcon.vue'
import EmailIcon from '@/components/Icons/EmailIcon.vue'
import EditIcon from '@/components/Icons/EditIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import CameraIcon from '@/components/Icons/CameraIcon.vue'
import LeadsIcon from '@/components/Icons/LeadsIcon.vue'
import DealsIcon from '@/components/Icons/DealsIcon.vue'
import ContactsIcon from '@/components/Icons/ContactsIcon.vue'
import { organizationsStore } from '@/stores/organizations.js'
import {
dateFormat,
dateTooltipFormat,
timeAgo,
leadStatuses,
dealStatuses,
formatNumberIntoCurrency,
} from '@/utils'
import { usersStore } from '@/stores/users'
import { h, computed, ref, watch, onMounted } from 'vue'
const props = defineProps({
organization: {
type: Object,
required: true,
},
})
const { organizations } = organizationsStore()
const showOrganizationModal = ref(false)
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.organization.name,
fieldname: 'organization_logo',
value: file?.file_url || '',
})
organizations.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.organization.name,
})
organizations.reload()
close()
},
},
],
})
}
function website(url) {
return url && url.replace(/^(?:https?:\/\/)?(?:www\.)?/i, '')
}
const tabIndex = ref(0)
const tabs = [
{
label: 'Leads',
icon: h(LeadsIcon, { class: 'h-4 w-4' }),
count: computed(() => leads.data?.length),
},
{
label: 'Deals',
icon: h(DealsIcon, { class: 'h-4 w-4' }),
count: computed(() => deals.data?.length),
},
{
label: 'Contacts',
icon: h(ContactsIcon, { class: 'h-4 w-4' }),
count: computed(() => contacts.data?.length),
},
]
const { getUser } = usersStore()
const leads = createListResource({
type: 'list',
doctype: 'CRM Lead',
cache: ['leads', props.organization.name],
fields: [
'name',
'first_name',
'lead_name',
'image',
'organization_name',
'organization_logo',
'status',
'email',
'mobile_no',
'lead_owner',
'modified',
],
filters: {
organization_name: props.organization.name,
is_deal: 0,
},
orderBy: 'modified desc',
pageLength: 20,
auto: true,
})
const deals = createListResource({
type: 'list',
doctype: 'CRM Lead',
cache: ['deals', props.organization.name],
fields: [
'name',
'organization_name',
'organization_logo',
'annual_revenue',
'deal_status',
'email',
'mobile_no',
'lead_owner',
'modified',
],
filters: {
organization_name: props.organization.name,
is_deal: 1,
},
orderBy: 'modified desc',
pageLength: 20,
auto: true,
})
const contacts = createListResource({
type: 'list',
doctype: 'Contact',
cache: ['contacts', props.organization.name],
fields: ['name', 'email_id', 'mobile_no', 'company_name', 'modified'],
filters: {
company_name: props.organization.name,
},
orderBy: 'modified desc',
pageLength: 20,
auto: true,
})
const rows = computed(() => {
let list = []
list = !tabIndex.value ? leads : tabIndex.value == 1 ? deals : contacts
if (!list.data) return []
return list.data.map((row) => {
return !tabIndex.value
? getLeadRowObject(row)
: tabIndex.value == 1
? getDealRowObject(row)
: getContactRowObject(row)
})
})
const columns = computed(() => {
return tabIndex.value === 0
? leadColumns
: tabIndex.value === 1
? dealColumns
: contactColumns
})
function getLeadRowObject(lead) {
return {
name: lead.name,
lead_name: {
label: lead.lead_name,
image: lead.image,
image_label: lead.first_name,
},
organization_name: {
label: lead.organization_name,
logo: lead.organization_logo,
},
status: {
label: lead.status,
color: leadStatuses[lead.status]?.color,
},
email: lead.email,
mobile_no: lead.mobile_no,
lead_owner: {
label: lead.lead_owner && getUser(lead.lead_owner).full_name,
...(lead.lead_owner && getUser(lead.lead_owner)),
},
modified: {
label: dateFormat(lead.modified, dateTooltipFormat),
timeAgo: timeAgo(lead.modified),
},
}
}
function getDealRowObject(deal) {
return {
name: deal.name,
organization_name: {
label: deal.organization_name,
logo: deal.organization_logo,
},
annual_revenue: formatNumberIntoCurrency(deal.annual_revenue),
deal_status: {
label: deal.deal_status,
color: dealStatuses[deal.deal_status]?.color,
},
email: deal.email,
mobile_no: deal.mobile_no,
lead_owner: {
label: deal.lead_owner && getUser(deal.lead_owner).full_name,
...(deal.lead_owner && getUser(deal.lead_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: contact.company_name,
}
}
const leadColumns = [
{
label: 'Name',
key: 'lead_name',
width: '12rem',
},
{
label: 'Organization',
key: 'organization_name',
width: '10rem',
},
{
label: 'Status',
key: 'status',
width: '8rem',
},
{
label: 'Email',
key: 'email',
width: '12rem',
},
{
label: 'Mobile no',
key: 'mobile_no',
width: '11rem',
},
{
label: 'Lead owner',
key: 'lead_owner',
width: '10rem',
},
{
label: 'Last modified',
key: 'modified',
width: '8rem',
},
]
const dealColumns = [
{
label: 'Organization',
key: 'organization_name',
width: '11rem',
},
{
label: 'Amount',
key: 'annual_revenue',
width: '9rem',
},
{
label: 'Status',
key: 'deal_status',
width: '10rem',
},
{
label: 'Email',
key: 'email',
width: '12rem',
},
{
label: 'Mobile no',
key: 'mobile_no',
width: '11rem',
},
{
label: 'Lead owner',
key: 'lead_owner',
width: '10rem',
},
{
label: 'Last modified',
key: 'modified',
width: '8rem',
},
]
const contactColumns = [
{
label: 'Full name',
key: 'full_name',
width: '12rem',
},
{
label: 'Email',
key: 'email',
width: '12rem',
},
{
label: 'Phone',
key: 'mobile_no',
width: '12rem',
},
{
label: 'Organization',
key: 'company_name',
width: '12rem',
},
]
function reload(val) {
leads.filters.organization_name = val
deals.filters.organization_name = val
contacts.filters.company_name = val
leads.reload()
deals.reload()
contacts.reload()
}
watch(
() => props.organization.name,
(val) => val && reload(val)
)
onMounted(() => reload(props.organization.name))
</script>

View File

@ -0,0 +1,107 @@
<template>
<LayoutHeader>
<template #left-header>
<Breadcrumbs :items="breadcrumbs" />
</template>
<template #right-header>
<Button
variant="solid"
label="Create"
@click="showOrganizationModal = true"
>
<template #prefix><FeatherIcon name="plus" class="h-4" /></template>
Create organization
</Button>
</template>
</LayoutHeader>
<div class="flex h-full overflow-hidden">
<div class="flex shrink-0 flex-col overflow-y-auto border-r">
<router-link
:to="{
name: 'Organization',
params: { organizationId: organization.name },
}"
v-for="(organization, i) in organizations.data"
:key="i"
:class="[
currentOrganization?.name === organization.name
? 'bg-gray-50 hover:bg-gray-100'
: 'hover:bg-gray-50',
]"
>
<div class="flex w-[352px] items-center gap-3 border-b px-5 py-4">
<Avatar
:image="organization.organization_logo"
:label="organization.name"
size="xl"
/>
<div class="flex flex-col items-start gap-1">
<span class="text-base font-medium text-gray-900">
{{ organization.name }}
</span>
<span class="text-sm text-gray-700">{{
website(organization.website)
}}</span>
</div>
</div>
</router-link>
</div>
<router-view
v-if="currentOrganization"
:organization="currentOrganization"
/>
<div
v-else
class="grid h-full flex-1 place-items-center text-xl font-medium text-gray-500"
>
<div class="flex flex-col items-center justify-center space-y-2">
<OrganizationsIcon class="h-10 w-10" />
<div>No organization selected</div>
</div>
</div>
</div>
<OrganizationModal
v-model="showOrganizationModal"
v-model:reloadOrganizations="organizations"
:organization="{}"
/>
</template>
<script setup>
import LayoutHeader from '@/components/LayoutHeader.vue'
import OrganizationModal from '@/components/Modals/OrganizationModal.vue'
import OrganizationsIcon from '@/components/Icons/OrganizationsIcon.vue'
import { FeatherIcon, Breadcrumbs, Avatar } from 'frappe-ui'
import { organizationsStore } from '@/stores/organizations.js'
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
const { organizations } = organizationsStore()
const route = useRoute()
const showOrganizationModal = ref(false)
const currentOrganization = computed(() => {
return organizations.data.find(
(organization) => organization.name === route.params.organizationId
)
})
const breadcrumbs = computed(() => {
let items = [{ label: 'Organizations', route: { name: 'Organizations' } }]
if (!currentOrganization.value) return items
items.push({
label: currentOrganization.value.name,
route: {
name: 'Organization',
params: { organizationId: currentOrganization.value.name },
},
})
return items
})
onMounted(() => {
const el = document.querySelector('.router-link-active')
if (el)
setTimeout(() => {
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
})
function website(url) {
return url && url.replace(/^(?:https?:\/\/)?(?:www\.)?/i, '')
}
</script>

View File

@ -39,6 +39,19 @@ const routes = [
name: 'Contacts',
component: () => import('@/pages/Contacts.vue'),
},
{
path: '/organizations',
name: 'Organizations',
component: () => import('@/pages/Organizations.vue'),
children: [
{
path: '/organizations/:organizationId?',
name: 'Organization',
component: () => import('@/pages/Organization.vue'),
props: true,
},
],
},
{
path: '/call-logs',
name: 'Call Logs',

View File

@ -0,0 +1,34 @@
import { defineStore } from 'pinia'
import { createResource } from 'frappe-ui'
import { reactive } from 'vue'
export const organizationsStore = defineStore('crm-organizations', () => {
let organizationsByName = reactive({})
const organizations = createResource({
url: 'crm.api.session.get_organizations',
cache: 'organizations',
initialData: [],
transform(organizations) {
for (let organization of organizations) {
organizationsByName[organization.name] = organization
}
return organizations
},
onError(error) {
if (error && error.exc_type === 'AuthenticationError') {
router.push('/login')
}
},
})
organizations.fetch()
function getOrganization(name) {
return organizationsByName[name]
}
return {
organizations,
getOrganization,
}
})