1
0
forked from test/crm

Merge pull request #155 from frappe/develop

chore: Merge develop to main
This commit is contained in:
Shariq Ansari 2024-04-29 19:39:55 +05:30 committed by GitHub
commit 0e247f0347
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 294 additions and 363 deletions

View File

@ -37,7 +37,11 @@
- **Tasks:** Create tasks for leads and deals.
- **Notes:** Add notes to leads and deals.
- **Call Logs:** See the call logs with call details and recordings.
- **Twilio Integration:** Integrate Twilio to make and receive calls from the CRM.
## Integrations
- **Twilio:** Integrate Twilio to make and receive calls from the CRM. You can also record calls. It is a built-in integration.
- **WhatsApp:** Integrate WhatsApp to send and receive messages from the CRM. [Frappe WhatsApp](https://github.com/shridarpatil/frappe_whatsapp) is used for this integration.
## Getting Started

View File

@ -1,3 +1,4 @@
__version__ = '1.2.0'
__version__ = "2.0.0-dev"
__title__ = "Frappe CRM"

View File

@ -0,0 +1,147 @@
<template>
<div class="flex flex-col gap-4">
<div
v-for="section in sections"
:key="section.section"
class="first:border-t-0 first:pt-0"
:class="section.hideBorder ? '' : 'border-t pt-4'"
>
<div
class="grid gap-4"
:class="
section.columns ? 'grid-cols-' + section.columns : 'grid-cols-3'
"
>
<div v-for="field in section.fields" :key="field.name">
<div class="mb-2 text-sm text-gray-600">
{{ __(field.label) }}
<span class="text-red-500" v-if="field.mandatory">*</span>
</div>
<FormControl
v-if="field.type === 'select'"
type="select"
class="form-control"
:class="field.prefix ? 'prefix' : ''"
:options="field.options"
v-model="data[field.name]"
:placeholder="__(field.placeholder)"
>
<template v-if="field.prefix" #prefix>
<IndicatorIcon :class="field.prefix" />
</template>
</FormControl>
<Link
v-else-if="field.type === 'link'"
class="form-control"
:value="data[field.name]"
:doctype="field.doctype"
@change="(v) => (data[field.name] = v)"
:placeholder="__(field.placeholder)"
:onCreate="field.create"
/>
<Link
v-else-if="field.type === 'user'"
class="form-control"
:value="getUser(data[field.name]).full_name"
:doctype="field.doctype"
@change="(v) => (data[field.name] = v)"
:placeholder="__(field.placeholder)"
:hideMe="true"
>
<template #prefix>
<UserAvatar class="mr-2" :user="data[field.name]" size="sm" />
</template>
<template #item-prefix="{ option }">
<UserAvatar class="mr-2" :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>
<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>
<FormControl
v-else
type="text"
:placeholder="__(field.placeholder)"
v-model="data[field.name]"
/>
</div>
</div>
</div>
</div>
</template>
<script setup>
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'
import { usersStore } from '@/stores/users'
import { Tooltip } from 'frappe-ui'
const { getUser } = usersStore()
const props = defineProps({
sections: Array,
data: Object,
})
</script>
<style scoped>
:deep(.form-control.prefix select) {
padding-left: 2rem;
}
</style>

View File

@ -57,82 +57,7 @@
<div v-else>{{ field.value }}</div>
</div>
</div>
<div class="flex flex-col gap-4" v-else>
<div class="flex gap-4" v-for="(section, i) in sections" :key="i">
<div v-for="(field, j) in section.fields" :key="j" class="flex-1">
<Link
v-if="field.type === 'link'"
variant="outline"
size="md"
:label="__(field.label)"
v-model="_contact[field.name]"
:doctype="field.doctype"
:placeholder="field.placeholder"
/>
<div class="space-y-1.5" v-if="field.type === 'dropdown'">
<label class="block text-base text-gray-600">
{{ __(field.label) }}
</label>
<NestedPopover>
<template #target="{ open }">
<Button
:label="_contact[field.name]"
class="dropdown-button h-8 w-full justify-between truncate rounded border border-gray-300 bg-white px-2.5 py-1.5 text-base placeholder-gray-500 hover:border-gray-400 hover:bg-white hover:shadow-sm focus:border-gray-500 focus:bg-white focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-gray-400"
>
<div class="truncate">{{ _contact[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>
<FormControl
v-else-if="field.type === 'data'"
variant="outline"
size="md"
type="text"
:label="__(field.label)"
:placeholder="field.placeholder"
v-model="_contact[field.name]"
/>
</div>
</div>
</div>
<Fields v-else :sections="sections" :data="_contact" />
</div>
</div>
<div v-if="!detailMode" class="px-4 pb-7 pt-4 sm:px-6">
@ -152,8 +77,7 @@
</template>
<script setup>
import NestedPopover from '@/components/NestedPopover.vue'
import DropdownItem from '@/components/DropdownItem.vue'
import Fields from '@/components/Fields.vue'
import ContactIcon from '@/components/Icons/ContactIcon.vue'
import GenderIcon from '@/components/Icons/GenderIcon.vue'
import EmailIcon from '@/components/Icons/EmailIcon.vue'
@ -162,9 +86,8 @@ import OrganizationsIcon from '@/components/Icons/OrganizationsIcon.vue'
import AddressIcon from '@/components/Icons/AddressIcon.vue'
import CertificateIcon from '@/components/Icons/CertificateIcon.vue'
import EditIcon from '@/components/Icons/EditIcon.vue'
import Link from '@/components/Controls/Link.vue'
import Dropdown from '@/components/frappe-ui/Dropdown.vue'
import { Tooltip, call } from 'frappe-ui'
import { call } from 'frappe-ui'
import { ref, nextTick, watch, computed, h } from 'vue'
import { createToast } from '@/utils'
import { useRouter } from 'vue-router'
@ -219,9 +142,9 @@ async function callInsertDoc() {
delete _contact.value.email_id
}
if (_contact.value.mobile_no) {
_contact.value.phone_nos = [{ phone: _contact.value.mobile_no }]
delete _contact.value.mobile_no
if (_contact.value.actual_mobile_no) {
_contact.value.phone_nos = [{ phone: _contact.value.actual_mobile_no }]
delete _contact.value.actual_mobile_no
}
const doc = await call('frappe.client.insert', {
@ -310,39 +233,48 @@ const detailFields = computed(() => {
const sections = computed(() => {
return [
{
section: 'Salutation',
columns: 1,
fields: [
{
label: 'Salutation',
type: 'link',
name: 'salutation',
placeholder: 'Mr./Mrs./Ms...',
type: 'link',
placeholder: 'Mr',
doctype: 'Salutation',
change: (value) => {
_contact.value.salutation = value
},
},
],
},
{
section: 'Full Name',
columns: 2,
hideBorder: true,
fields: [
{
label: 'First Name',
type: 'data',
name: 'first_name',
type: 'data',
mandatory: true,
placeholder: 'John',
},
{
label: 'Last Name',
type: 'data',
name: 'last_name',
type: 'data',
placeholder: 'Doe',
},
],
},
{
section: 'Email',
columns: 1,
hideBorder: true,
fields: [
{
label: 'Email',
type: props.contact?.data?.name ? 'dropdown' : 'data',
name: 'email_id',
type: props.contact?.data?.name ? 'dropdown' : 'data',
placeholder: 'john@doe.com',
options:
props.contact.data?.email_ids?.map((email) => {
return {
@ -395,11 +327,15 @@ const sections = computed(() => {
],
},
{
section: 'Mobile No. & Gender',
columns: 2,
hideBorder: true,
fields: [
{
label: 'Mobile No.',
type: props.contact?.data?.name ? 'dropdown' : 'data',
name: 'actual_mobile_no',
type: props.contact?.data?.name ? 'dropdown' : 'data',
placeholder: '+91 9876543210',
options:
props.contact.data?.phone_nos?.map((phone) => {
return {
@ -452,56 +388,37 @@ const sections = computed(() => {
},
{
label: 'Gender',
type: 'link',
name: 'gender',
placeholder: 'Select Gender',
type: 'link',
doctype: 'Gender',
change: (value) => {
_contact.value.gender = value
},
placeholder: 'Male',
},
],
},
{
section: 'Organization',
columns: 1,
hideBorder: true,
fields: [
{
label: 'Organization',
type: 'link',
name: 'company_name',
placeholder: 'Select Organization',
type: 'link',
doctype: 'CRM Organization',
change: (value) => {
_contact.value.company_name = value
},
link: (data) => {
router.push({
name: 'Organization',
params: { organizationId: data },
})
},
placeholder: 'Frappé Technologies',
},
],
},
{
section: 'Designation',
columns: 1,
hideBorder: true,
fields: [
{
label: 'Designation',
type: 'data',
name: 'designation',
},
],
},
{
fields: [
{
label: 'Address',
type: 'link',
name: 'address',
placeholder: 'Select Address',
doctype: 'Address',
change: (value) => {
_contact.value.address = value
},
type: 'data',
placeholder: 'CEO',
},
],
},

View File

@ -17,71 +17,7 @@
<Switch v-model="chooseExistingContact" />
</div>
</div>
<div class="flex flex-col gap-4">
<div
v-for="section in allFields"
:key="section.section"
class="border-t pt-4"
>
<div class="grid grid-cols-3 gap-4">
<div v-for="field in section.fields" :key="field.name">
<div class="mb-2 text-sm text-gray-600">
{{ __(field.label) }}
</div>
<FormControl
v-if="field.type === 'select'"
type="select"
class="form-control"
:options="field.options"
v-model="deal[field.name]"
:placeholder="__(field.placeholder)"
>
<template v-if="field.prefix" #prefix>
<IndicatorIcon :class="field.prefix" />
</template>
</FormControl>
<Link
v-else-if="field.type === 'link'"
class="form-control"
:value="deal[field.name]"
:doctype="field.doctype"
@change="(v) => (deal[field.name] = v)"
:placeholder="__(field.placeholder)"
:onCreate="field.create"
/>
<Link
v-else-if="field.type === 'user'"
class="form-control"
:value="getUser(deal[field.name]).full_name"
:doctype="field.doctype"
@change="(v) => (deal[field.name] = v)"
:placeholder="__(field.placeholder)"
:hideMe="true"
>
<template #prefix>
<UserAvatar class="mr-2" :user="deal[field.name]" size="sm" />
</template>
<template #item-prefix="{ option }">
<UserAvatar class="mr-2" :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>
<FormControl
v-else
type="text"
:placeholder="__(field.placeholder)"
v-model="deal[field.name]"
/>
</div>
</div>
</div>
</div>
<Fields class="border-t" :sections="sections" :data="deal" />
<ErrorMessage class="mt-4" v-if="error" :message="__(error)" />
</template>
<template #actions>
@ -98,12 +34,10 @@
</template>
<script setup>
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import Link from '@/components/Controls/Link.vue'
import Fields from '@/components/Fields.vue'
import { usersStore } from '@/stores/users'
import { statusesStore } from '@/stores/statuses'
import { Tooltip, Switch, createResource } from 'frappe-ui'
import { Switch, createResource } from 'frappe-ui'
import { computed, ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
@ -137,7 +71,7 @@ const isDealCreating = ref(false)
const chooseExistingContact = ref(false)
const chooseExistingOrganization = ref(false)
const allFields = computed(() => {
const sections = computed(() => {
let fields = []
if (chooseExistingOrganization.value) {
fields.push({
@ -267,12 +201,13 @@ const allFields = computed(() => {
}
fields.push({
section: 'Deal Details',
columns: 2,
fields: [
{
label: 'Status',
name: 'status',
type: 'select',
options: statusOptions('deal', (field, value) => (deal[field] = value)),
options: statusOptions('deal'),
prefix: getDealStatus(deal.status).iconColorClass,
},
{
@ -323,17 +258,8 @@ function createDeal() {
}
onMounted(() => {
if (!deal.status) {
deal.status = computed(() => getDealStatus().name)
}
if (!deal.deal_owner) {
deal.deal_owner = getUser().email
}
})
</script>
<style scoped>
:deep(.form-control select) {
padding-left: 2rem;
}
</style>

View File

@ -7,72 +7,7 @@
}"
>
<template #body-content>
<div class="flex flex-col gap-4">
<div
v-for="section in allFields"
:key="section.section"
class="border-t pt-4 first:border-t-0"
>
<div class="grid grid-cols-3 gap-4">
<div v-for="field in section.fields" :key="field.name">
<div class="mb-2 text-sm text-gray-600">
{{ __(field.label) }}
<span class="text-red-500" v-if="field.mandatory">*</span>
</div>
<FormControl
v-if="field.type === 'select'"
type="select"
class="form-control"
:options="field.options"
v-model="lead[field.name]"
:placeholder="__(field.placeholder)"
>
<template v-if="field.prefix" #prefix>
<IndicatorIcon :class="field.prefix" />
</template>
</FormControl>
<Link
v-else-if="field.type === 'link'"
class="form-control"
:value="lead[field.name]"
:doctype="field.doctype"
@change="(v) => (lead[field.name] = v)"
:placeholder="__(field.placeholder)"
:onCreate="field.create"
/>
<Link
v-else-if="field.type === 'user'"
class="form-control"
:value="getUser(lead[field.name]).full_name"
:doctype="field.doctype"
@change="(v) => (lead[field.name] = v)"
:placeholder="__(field.placeholder)"
:hideMe="true"
>
<template #prefix>
<UserAvatar class="mr-2" :user="lead[field.name]" size="sm" />
</template>
<template #item-prefix="{ option }">
<UserAvatar class="mr-2" :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>
<FormControl
v-else
type="text"
:placeholder="__(field.placeholder)"
v-model="lead[field.name]"
/>
</div>
</div>
</div>
</div>
<Fields :sections="sections" :data="lead" />
<ErrorMessage class="mt-4" v-if="error" :message="__(error)" />
</template>
<template #actions>
@ -84,12 +19,10 @@
</template>
<script setup>
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import Link from '@/components/Controls/Link.vue'
import Fields from '@/components/Fields.vue'
import { usersStore } from '@/stores/users'
import { statusesStore } from '@/stores/statuses'
import { Tooltip, createResource } from 'frappe-ui'
import { createResource } from 'frappe-ui'
import { computed, onMounted, ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
@ -118,7 +51,7 @@ const lead = reactive({
lead_owner: '',
})
const allFields = computed(() => {
const sections = computed(() => {
return [
{
section: 'Contact Details',
@ -219,15 +152,13 @@ const allFields = computed(() => {
},
{
section: 'Other Details',
columns: 2,
fields: [
{
label: 'Status',
name: 'status',
type: 'select',
options: statusOptions(
'lead',
(field, value) => (lead[field] = value)
),
options: statusOptions('lead'),
prefix: getLeadStatus(lead.status).iconColorClass,
},
{
@ -291,17 +222,8 @@ function createNewLead() {
}
onMounted(() => {
if (!lead.status) {
lead.status = computed(() => getLeadStatus().name)
}
if (!lead.lead_owner) {
lead.lead_owner = getUser().email
}
})
</script>
<style scoped>
:deep(.form-control select) {
padding-left: 2rem;
}
</style>

View File

@ -35,78 +35,7 @@
<div>{{ field.value }}</div>
</div>
</div>
<div v-else>
<div class="flex flex-col gap-4">
<FormControl
type="text"
ref="title"
size="md"
:label="__('Organization Name')"
variant="outline"
v-model="_organization.organization_name"
placeholder="Frappé Technologies"
/>
<div class="flex gap-4">
<FormControl
class="flex-1"
type="text"
size="md"
:label="__('Website')"
variant="outline"
v-model="_organization.website"
placeholder="https://example.com"
/>
<FormControl
class="flex-1"
type="text"
size="md"
:label="__('Annual Revenue')"
variant="outline"
v-model="_organization.annual_revenue"
:placeholder="__('9,999,999')"
/>
</div>
<Link
class="flex-1"
size="md"
:label="__('Territory')"
variant="outline"
v-model="_organization.territory"
doctype="CRM Territory"
placeholder="India"
/>
<div class="flex gap-4">
<FormControl
class="flex-1"
type="select"
:options="[
{ label: __('1-10'), value: '1-10' },
{ label: __('11-50'), value: '11-50' },
{ label: __('51-200'), value: '51-200' },
{ label: __('201-500'), value: '201-500' },
{ label: __('501-1000'), value: '501-1000' },
{ label: __('1001-5000'), value: '1001-5000' },
{ label: __('5001-10000'), value: '5001-10000' },
{ label: __('10001+'), value: '10001+' },
]"
size="md"
:label="__('No of Employees')"
variant="outline"
:placeholder="__('1-10')"
v-model="_organization.no_of_employees"
/>
<Link
class="flex-1"
size="md"
:label="__('Industry')"
variant="outline"
v-model="_organization.industry"
doctype="CRM Industry"
:placeholder="__('Technology')"
/>
</div>
</div>
</div>
<Fields v-else :sections="sections" :data="_organization" />
</div>
</div>
<div v-if="!detailMode" class="px-4 pb-7 pt-4 sm:px-6">
@ -126,11 +55,11 @@
</template>
<script setup>
import Fields from '@/components/Fields.vue'
import EditIcon from '@/components/Icons/EditIcon.vue'
import WebsiteIcon from '@/components/Icons/WebsiteIcon.vue'
import OrganizationsIcon from '@/components/Icons/OrganizationsIcon.vue'
import TerritoryIcon from '@/components/Icons/TerritoryIcon.vue'
import Link from '@/components/Controls/Link.vue'
import { call, FeatherIcon } from 'frappe-ui'
import { ref, nextTick, watch, computed, h } from 'vue'
import { useRouter } from 'vue-router'
@ -291,6 +220,85 @@ const fields = computed(() => {
return details.filter((field) => field.value)
})
const sections = computed(() => {
return [
{
section: 'Organization Name',
columns: 1,
fields: [
{
label: 'Organization Name',
name: 'organization_name',
type: 'data',
placeholder: 'Frappé Technologies',
},
],
},
{
section: 'Website & Revenue',
columns: 2,
hideBorder: true,
fields: [
{
label: 'Website',
name: 'website',
type: 'data',
placeholder: 'https://example.com',
},
{
label: 'Annual Revenue',
name: 'annual_revenue',
type: 'data',
placeholder: '9,999,999',
},
],
},
{
section: 'Territory',
columns: 1,
hideBorder: true,
fields: [
{
label: 'Territory',
name: 'territory',
type: 'link',
doctype: 'CRM Territory',
placeholder: 'India',
},
],
},
{
section: 'No of Employees & Industry',
columns: 2,
hideBorder: true,
fields: [
{
label: 'No of Employees',
name: 'no_of_employees',
type: 'select',
options: [
{ label: __('1-10'), value: '1-10' },
{ label: __('11-50'), value: '11-50' },
{ label: __('51-200'), value: '51-200' },
{ label: __('201-500'), value: '201-500' },
{ label: __('501-1000'), value: '501-1000' },
{ label: __('1001-5000'), value: '1001-5000' },
{ label: __('5001-10000'), value: '5001-10000' },
{ label: __('10001+'), value: '10001+' },
],
},
{
label: 'Industry',
name: 'industry',
type: 'link',
doctype: 'CRM Industry',
placeholder: 'Technology',
},
],
},
]
})
watch(
() => show.value,
(value) => {

View File

@ -17,6 +17,9 @@ export const usersStore = defineStore('crm-users', () => {
auto: true,
transform(users) {
for (let user of users) {
if (user.name === 'Administrator') {
user.email = 'Administrator'
}
usersByName[user.name] = user
}
return users

View File

@ -6,7 +6,10 @@ module.exports = {
'./node_modules/frappe-ui/src/components/**/*.{vue,js,ts,jsx,tsx}',
'../node_modules/frappe-ui/src/components/**/*.{vue,js,ts,jsx,tsx}',
],
safelist: [{ pattern: /!(text|bg)-/, variants: ['hover', 'active'] }],
safelist: [
{ pattern: /!(text|bg)-/, variants: ['hover', 'active'] },
{ pattern: /^grid-cols-/ },
],
theme: {
extend: {},
},