Merge branch 'shariquerik-contact-page' into contact-page
This commit is contained in:
commit
5acfac869b
@ -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
|
||||
|
||||
0
crm/fcrm/doctype/crm_organization/__init__.py
Normal file
0
crm/fcrm/doctype/crm_organization/__init__.py
Normal file
8
crm/fcrm/doctype/crm_organization/crm_organization.js
Normal file
8
crm/fcrm/doctype/crm_organization/crm_organization.js
Normal 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) {
|
||||
|
||||
// },
|
||||
// });
|
||||
57
crm/fcrm/doctype/crm_organization/crm_organization.json
Normal file
57
crm/fcrm/doctype/crm_organization/crm_organization.json
Normal 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": []
|
||||
}
|
||||
9
crm/fcrm/doctype/crm_organization/crm_organization.py
Normal file
9
crm/fcrm/doctype/crm_organization/crm_organization.py
Normal 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
|
||||
@ -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
|
||||
@ -7,6 +7,7 @@
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"section_break_malx",
|
||||
"account_sid",
|
||||
"api_key",
|
||||
"api_secret",
|
||||
@ -14,8 +15,9 @@
|
||||
"auth_token",
|
||||
"twiml_sid",
|
||||
"section_break_ssqj",
|
||||
"record_calls",
|
||||
"column_break_avmt"
|
||||
"enabled",
|
||||
"column_break_avmt",
|
||||
"record_calls"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@ -23,7 +25,7 @@
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Account SID",
|
||||
"reqd": 1
|
||||
"mandatory_depends_on": "eval: doc.enabled"
|
||||
},
|
||||
{
|
||||
"fieldname": "api_key",
|
||||
@ -46,7 +48,7 @@
|
||||
"fieldtype": "Password",
|
||||
"in_list_view": 1,
|
||||
"label": "Auth Token",
|
||||
"reqd": 1
|
||||
"mandatory_depends_on": "eval: doc.enabled"
|
||||
},
|
||||
{
|
||||
"fieldname": "twiml_sid",
|
||||
@ -67,12 +69,22 @@
|
||||
{
|
||||
"fieldname": "column_break_avmt",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_malx",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "enabled",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enabled"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2023-09-20 16:42:17.025651",
|
||||
"modified": "2023-11-03 15:13:09.155818",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "Twilio Settings",
|
||||
|
||||
@ -5,6 +5,10 @@ import frappe
|
||||
from frappe import _
|
||||
from .twilio_handler import Twilio, IncomingCall, TwilioCallDetails
|
||||
|
||||
@frappe.whitelist()
|
||||
def is_enabled():
|
||||
return frappe.db.get_single_value("Twilio Settings", "enabled")
|
||||
|
||||
@frappe.whitelist()
|
||||
def generate_access_token():
|
||||
"""Returns access token that is required to authenticate Twilio Client SDK.
|
||||
|
||||
@ -27,8 +27,8 @@ class Twilio:
|
||||
"""Make a twilio connection.
|
||||
"""
|
||||
settings = frappe.get_doc("Twilio Settings")
|
||||
# if not (settings and settings.enabled):
|
||||
# return
|
||||
if not (settings and settings.enabled):
|
||||
return
|
||||
return Twilio(settings=settings)
|
||||
|
||||
def get_phone_numbers(self):
|
||||
@ -115,6 +115,8 @@ class Twilio:
|
||||
@classmethod
|
||||
def get_twilio_client(self):
|
||||
twilio_settings = frappe.get_doc("Twilio Settings")
|
||||
if not twilio_settings.enabled:
|
||||
frappe.throw(_("Please enable twilio settings before making a call."))
|
||||
|
||||
auth_token = get_decrypted_password("Twilio Settings", "Twilio Settings", 'auth_token')
|
||||
client = TwilioClient(twilio_settings.account_sid, auth_token)
|
||||
|
||||
@ -1 +1 @@
|
||||
Subproject commit 89e3e41b9d1aa378c15591b4d9365102befc8b8c
|
||||
Subproject commit e0859c6165a5d54a999f94e7c1cf9647ecd2bf72
|
||||
@ -9,21 +9,23 @@
|
||||
"serve": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tiptap/vue-3": "^2.0.4",
|
||||
"@twilio/voice-sdk": "^2.7.1",
|
||||
"@vitejs/plugin-vue": "^4.2.3",
|
||||
"@vueuse/core": "^10.3.0",
|
||||
"@vueuse/integrations": "^10.3.0",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"feather-icons": "^4.28.0",
|
||||
"frappe-ui": "^0.1.13",
|
||||
"frappe-ui": "^0.1.14",
|
||||
"pinia": "^2.0.33",
|
||||
"postcss": "^8.4.5",
|
||||
"socket.io-client": "^4.7.2",
|
||||
"sortablejs": "^1.15.0",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"vite": "^4.4.9",
|
||||
"vue": "^3.3.4",
|
||||
"vue-router": "^4.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4.2.3",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"postcss": "^8.4.5",
|
||||
"vite": "^4.4.9"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -39,6 +39,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'
|
||||
@ -61,6 +62,11 @@ const links = [
|
||||
icon: ContactsIcon,
|
||||
to: 'Contacts',
|
||||
},
|
||||
{
|
||||
label: 'Organizations',
|
||||
icon: OrganizationsIcon,
|
||||
to: 'Organizations',
|
||||
},
|
||||
{
|
||||
label: 'Notes',
|
||||
icon: NoteIcon,
|
||||
|
||||
@ -2,17 +2,17 @@
|
||||
<div
|
||||
v-show="showCallPopup"
|
||||
ref="callPopup"
|
||||
class="fixed select-none z-10 bg-gray-900 text-gray-300 rounded-lg shadow-2xl p-4 flex flex-col w-60 cursor-move"
|
||||
class="fixed z-10 flex w-60 cursor-move select-none flex-col rounded-lg bg-gray-900 p-4 text-gray-300 shadow-2xl"
|
||||
:style="style"
|
||||
>
|
||||
<div class="flex items-center flex-row-reverse gap-1">
|
||||
<MinimizeIcon class="w-4 h-4 cursor-pointer" @click="toggleCallWindow" />
|
||||
<div class="flex flex-row-reverse items-center gap-1">
|
||||
<MinimizeIcon class="h-4 w-4 cursor-pointer" @click="toggleCallWindow" />
|
||||
</div>
|
||||
<div class="flex flex-col justify-center items-center gap-3">
|
||||
<div class="flex flex-col items-center justify-center gap-3">
|
||||
<Avatar
|
||||
:image="contact.image"
|
||||
:label="contact.full_name"
|
||||
class="flex items-center justify-center [&>div]:text-[30px] !h-24 !w-24 relative"
|
||||
class="relative flex !h-24 !w-24 items-center justify-center [&>div]:text-[30px]"
|
||||
:class="onCall || calling ? '' : 'pulse'"
|
||||
/>
|
||||
<div class="flex flex-col items-center justify-center gap-1">
|
||||
@ -22,11 +22,11 @@
|
||||
<div class="text-sm text-gray-600">{{ contact.mobile_no }}</div>
|
||||
</div>
|
||||
<CountUpTimer ref="counterUp">
|
||||
<div v-if="onCall" class="text-base my-1">
|
||||
<div v-if="onCall" class="my-1 text-base">
|
||||
{{ counterUp?.updatedTime }}
|
||||
</div>
|
||||
</CountUpTimer>
|
||||
<div v-if="!onCall" class="text-base my-1">
|
||||
<div v-if="!onCall" class="my-1 text-base">
|
||||
{{
|
||||
callStatus == 'ringing'
|
||||
? 'Ringing...'
|
||||
@ -43,13 +43,13 @@
|
||||
/>
|
||||
<Button class="rounded-full">
|
||||
<template #icon>
|
||||
<DialpadIcon class="rounded-full cursor-pointer" />
|
||||
<DialpadIcon class="cursor-pointer rounded-full" />
|
||||
</template>
|
||||
</Button>
|
||||
<Button class="rounded-full">
|
||||
<template #icon>
|
||||
<NoteIcon
|
||||
class="text-gray-900 rounded-full cursor-pointer h-4 w-4"
|
||||
class="h-4 w-4 cursor-pointer rounded-full text-gray-900"
|
||||
@click="showNoteModal = true"
|
||||
/>
|
||||
</template>
|
||||
@ -57,7 +57,7 @@
|
||||
<Button class="rounded-full bg-red-600 hover:bg-red-700">
|
||||
<template #icon>
|
||||
<PhoneIcon
|
||||
class="text-white fill-white h-4 w-4 rotate-[135deg]"
|
||||
class="h-4 w-4 rotate-[135deg] fill-white text-white"
|
||||
@click="hangUpCall"
|
||||
/>
|
||||
</template>
|
||||
@ -73,7 +73,7 @@
|
||||
class="rounded-lg"
|
||||
>
|
||||
<template #prefix>
|
||||
<PhoneIcon class="fill-white h-4 w-4 rotate-[135deg]" />
|
||||
<PhoneIcon class="h-4 w-4 rotate-[135deg] fill-white" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
@ -87,7 +87,7 @@
|
||||
@click="acceptIncomingCall"
|
||||
>
|
||||
<template #prefix>
|
||||
<PhoneIcon class="fill-white h-4 w-4" />
|
||||
<PhoneIcon class="h-4 w-4 fill-white" />
|
||||
</template>
|
||||
</Button>
|
||||
<Button
|
||||
@ -99,7 +99,7 @@
|
||||
@click="rejectIncomingCall"
|
||||
>
|
||||
<template #prefix>
|
||||
<PhoneIcon class="fill-white h-4 w-4 rotate-[135deg]" />
|
||||
<PhoneIcon class="h-4 w-4 rotate-[135deg] fill-white" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
@ -107,16 +107,16 @@
|
||||
</div>
|
||||
<div
|
||||
v-show="showSmallCallWindow"
|
||||
class="flex items-center justify-between gap-3 bg-gray-900 text-base text-gray-300 ml-2 px-2 py-[7px] rounded-lg cursor-pointer select-none"
|
||||
class="ml-2 flex cursor-pointer select-none items-center justify-between gap-3 rounded-lg bg-gray-900 px-2 py-[7px] text-base text-gray-300"
|
||||
@click="toggleCallWindow"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Avatar
|
||||
:image="contact.image"
|
||||
:label="contact.full_name"
|
||||
class="flex items-center justify-center !h-5 !w-5 relative"
|
||||
class="relative flex !h-5 !w-5 items-center justify-center"
|
||||
/>
|
||||
<div class="truncate max-w-[120px]">
|
||||
<div class="max-w-[120px] truncate">
|
||||
{{ contact.full_name }}
|
||||
</div>
|
||||
</div>
|
||||
@ -124,10 +124,10 @@
|
||||
<div class="my-1 min-w-[40px] text-center">
|
||||
{{ counterUp?.updatedTime }}
|
||||
</div>
|
||||
<Button variant="solid" theme="red" class="rounded-full !h-6 !w-6">
|
||||
<Button variant="solid" theme="red" class="!h-6 !w-6 rounded-full">
|
||||
<template #icon>
|
||||
<PhoneIcon
|
||||
class="fill-white h-4 w-4 rotate-[135deg]"
|
||||
class="h-4 w-4 rotate-[135deg] fill-white"
|
||||
@click.stop="hangUpCall"
|
||||
/>
|
||||
</template>
|
||||
@ -140,11 +140,11 @@
|
||||
<Button
|
||||
variant="solid"
|
||||
theme="red"
|
||||
class="rounded-full !h-6 !w-6"
|
||||
class="!h-6 !w-6 rounded-full"
|
||||
@click.stop="cancelCall"
|
||||
>
|
||||
<template #icon>
|
||||
<PhoneIcon class="fill-white h-4 w-4 rotate-[135deg]" />
|
||||
<PhoneIcon class="h-4 w-4 rotate-[135deg] fill-white" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
@ -152,21 +152,21 @@
|
||||
<Button
|
||||
variant="solid"
|
||||
theme="green"
|
||||
class="rounded-full !h-6 !w-6 pulse relative"
|
||||
class="pulse relative !h-6 !w-6 rounded-full"
|
||||
@click.stop="acceptIncomingCall"
|
||||
>
|
||||
<template #icon>
|
||||
<PhoneIcon class="fill-white h-4 w-4 animate-pulse" />
|
||||
<PhoneIcon class="h-4 w-4 animate-pulse fill-white" />
|
||||
</template>
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
theme="red"
|
||||
class="rounded-full !h-6 !w-6"
|
||||
class="!h-6 !w-6 rounded-full"
|
||||
@click.stop="rejectIncomingCall"
|
||||
>
|
||||
<template #icon>
|
||||
<PhoneIcon class="fill-white h-4 w-4 rotate-[135deg]" />
|
||||
<PhoneIcon class="h-4 w-4 rotate-[135deg] fill-white" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
@ -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()
|
||||
|
||||
@ -197,6 +197,7 @@ const contact = ref({
|
||||
mobile_no: '',
|
||||
})
|
||||
|
||||
let enabled = ref(false)
|
||||
let showCallPopup = ref(false)
|
||||
let showSmallCallWindow = ref(false)
|
||||
let onCall = ref(false)
|
||||
@ -244,6 +245,10 @@ let { style } = useDraggable(callPopup, {
|
||||
preventDefault: true,
|
||||
})
|
||||
|
||||
async function is_twilio_enabled() {
|
||||
return await call('crm.twilio.api.is_enabled')
|
||||
}
|
||||
|
||||
async function startupClient() {
|
||||
log.value = 'Requesting Access Token...'
|
||||
|
||||
@ -469,7 +474,10 @@ function toggleCallWindow() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => startupClient())
|
||||
onMounted(async () => {
|
||||
enabled.value = await is_twilio_enabled()
|
||||
enabled.value && startupClient()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => log.value,
|
||||
@ -481,6 +489,7 @@ watch(
|
||||
|
||||
const app = getCurrentInstance()
|
||||
app.appContext.config.globalProperties.makeCall = makeOutgoingCall
|
||||
app.appContext.config.globalProperties.is_twilio_enabled = enabled.value
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
16
frontend/src/components/Icons/OrganizationsIcon.vue
Normal file
16
frontend/src/components/Icons/OrganizationsIcon.vue
Normal 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>
|
||||
16
frontend/src/components/Icons/WebsiteIcon.vue
Normal file
16
frontend/src/components/Icons/WebsiteIcon.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.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>
|
||||
23
frontend/src/components/ListViews/ContactsListView.vue
Normal file
23
frontend/src/components/ListViews/ContactsListView.vue
Normal 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>
|
||||
79
frontend/src/components/ListViews/DealsListView.vue
Normal file
79
frontend/src/components/ListViews/DealsListView.vue
Normal 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>
|
||||
88
frontend/src/components/ListViews/LeadsListView.vue
Normal file
88
frontend/src/components/ListViews/LeadsListView.vue
Normal 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>
|
||||
144
frontend/src/components/Modals/OrganizationModal.vue
Normal file
144
frontend/src/components/Modals/OrganizationModal.vue
Normal 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>
|
||||
6
frontend/src/composables/twilio.js
Normal file
6
frontend/src/composables/twilio.js
Normal file
@ -0,0 +1,6 @@
|
||||
import { getCurrentInstance } from 'vue'
|
||||
|
||||
export function is_twilio_enabled() {
|
||||
const app = getCurrentInstance()
|
||||
return app.appContext.config.globalProperties.is_twilio_enabled
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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,
|
||||
|
||||
551
frontend/src/pages/Organization.vue
Normal file
551
frontend/src/pages/Organization.vue
Normal 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"
|
||||
>·</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"
|
||||
>·</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>
|
||||
107
frontend/src/pages/Organizations.vue
Normal file
107
frontend/src/pages/Organizations.vue
Normal 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>
|
||||
@ -47,6 +47,19 @@ const routes = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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',
|
||||
|
||||
34
frontend/src/stores/organizations.js
Normal file
34
frontend/src/stores/organizations.js
Normal 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,
|
||||
}
|
||||
})
|
||||
@ -5,23 +5,34 @@ import frappeui from 'frappe-ui/vite'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [frappeui(), vue({
|
||||
script: {
|
||||
defineModel: true,
|
||||
propsDestructure: true
|
||||
}
|
||||
})],
|
||||
plugins: [
|
||||
frappeui(),
|
||||
vue({
|
||||
script: {
|
||||
defineModel: true,
|
||||
propsDestructure: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: `../${path.basename(path.resolve('..'))}/public/frontend`,
|
||||
outDir: '../crm/public/frontend',
|
||||
emptyOutDir: true,
|
||||
commonjsOptions: {
|
||||
include: [/tailwind.config.js/, /node_modules/],
|
||||
},
|
||||
sourcemap: true,
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['frappe-ui > feather-icons', 'showdown', 'engine.io-client'],
|
||||
include: [
|
||||
'feather-icons',
|
||||
'showdown',
|
||||
'tailwind.config.js',
|
||||
'engine.io-client',
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
1795
frontend/yarn.lock
1795
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
{
|
||||
"private": true,
|
||||
"aworkspaces": ["frappe-ui", "frontend"],
|
||||
"workspaces": ["frontend", "frappe-ui"],
|
||||
"scripts": {
|
||||
"postinstall": "cd frontend && yarn install",
|
||||
"dev": "cd frontend && yarn dev",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user