Merge branch 'shariquerik-contact-page' into contact-page

This commit is contained in:
Shariq Ansari 2023-11-04 00:00:25 +05:30
commit 5acfac869b
35 changed files with 4771 additions and 1981 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

@ -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",

View File

@ -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.

View File

@ -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

View File

@ -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"
}
}

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

@ -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,

View File

@ -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>

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

@ -0,0 +1,6 @@
import { getCurrentInstance } from 'vue'
export function is_twilio_enabled() {
const app = getCurrentInstance()
return app.appContext.config.globalProperties.is_twilio_enabled
}

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

@ -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',

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,
}
})

View File

@ -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',
],
},
})

File diff suppressed because it is too large Load Diff

View File

@ -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",

3490
yarn.lock

File diff suppressed because it is too large Load Diff