Merge remote-tracking branch 'origin/develop' into delete-from-record-view
This commit is contained in:
commit
df698387dc
@ -124,6 +124,7 @@ def get_deal_activities(name):
|
||||
activity = {
|
||||
"activity_type": "communication",
|
||||
"communication_type": communication.communication_type,
|
||||
"communication_date": communication.communication_date or communication.creation,
|
||||
"creation": communication.creation,
|
||||
"data": {
|
||||
"subject": communication.subject,
|
||||
@ -255,6 +256,7 @@ def get_lead_activities(name):
|
||||
activity = {
|
||||
"activity_type": "communication",
|
||||
"communication_type": communication.communication_type,
|
||||
"communication_date": communication.communication_date or communication.creation,
|
||||
"creation": communication.creation,
|
||||
"data": {
|
||||
"subject": communication.subject,
|
||||
|
||||
@ -190,11 +190,20 @@ def get_call_log(name):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_lead_from_call_log(call_log):
|
||||
def create_lead_from_call_log(call_log, lead_details=None):
|
||||
lead = frappe.new_doc("CRM Lead")
|
||||
lead.first_name = "Lead from call " + call_log.get("from")
|
||||
lead.mobile_no = call_log.get("from")
|
||||
lead.lead_owner = frappe.session.user
|
||||
lead_details = frappe.parse_json(lead_details or "{}")
|
||||
|
||||
if not lead_details.get("lead_owner"):
|
||||
lead_details["lead_owner"] = frappe.session.user
|
||||
if not lead_details.get("mobile_no"):
|
||||
lead_details["mobile_no"] = call_log.get("from") or ""
|
||||
if not lead_details.get("first_name"):
|
||||
lead_details["first_name"] = "Lead from call " + (
|
||||
lead_details.get("mobile_no") or call_log.get("name")
|
||||
)
|
||||
|
||||
lead.update(lead_details)
|
||||
lead.save(ignore_permissions=True)
|
||||
|
||||
# link call log with lead
|
||||
|
||||
@ -24,9 +24,15 @@ class FCRMSettings(Document):
|
||||
standard_old_items = [d.name1 for d in old_items if d.is_standard]
|
||||
deleted_standard_items = set(standard_old_items) - set(standard_new_items)
|
||||
if deleted_standard_items:
|
||||
standard_dropdown_items = get_standard_dropdown_items()
|
||||
if not deleted_standard_items.intersection(standard_dropdown_items):
|
||||
return
|
||||
frappe.throw(_("Cannot delete standard items {0}").format(", ".join(deleted_standard_items)))
|
||||
|
||||
|
||||
def get_standard_dropdown_items():
|
||||
return [item.get("name1") for item in frappe.get_hooks("standard_dropdown_items")]
|
||||
|
||||
|
||||
def after_migrate():
|
||||
sync_table("dropdown_items", "standard_dropdown_items")
|
||||
|
||||
24
crm/hooks.py
24
crm/hooks.py
@ -264,22 +264,6 @@ standard_dropdown_items = [
|
||||
"route": "#",
|
||||
"is_standard": 1,
|
||||
},
|
||||
{
|
||||
"name1": "support_link",
|
||||
"label": "Support",
|
||||
"type": "Route",
|
||||
"icon": "life-buoy",
|
||||
"route": "https://t.me/frappecrm",
|
||||
"is_standard": 1,
|
||||
},
|
||||
{
|
||||
"name1": "docs_link",
|
||||
"label": "Docs",
|
||||
"type": "Route",
|
||||
"icon": "book-open",
|
||||
"route": "https://docs.frappe.io/crm",
|
||||
"is_standard": 1,
|
||||
},
|
||||
{
|
||||
"name1": "toggle_theme",
|
||||
"label": "Toggle theme",
|
||||
@ -303,6 +287,14 @@ standard_dropdown_items = [
|
||||
"route": "#",
|
||||
"is_standard": 1,
|
||||
},
|
||||
{
|
||||
"name1": "about",
|
||||
"label": "About",
|
||||
"type": "Route",
|
||||
"icon": "info",
|
||||
"route": "#",
|
||||
"is_standard": 1,
|
||||
},
|
||||
{
|
||||
"name1": "separator",
|
||||
"label": "",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,8 +1,10 @@
|
||||
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# GNU GPLv3 License. See license.txt
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import frappe
|
||||
from frappe import safe_decode
|
||||
from frappe.integrations.frappe_providers.frappecloud_billing import is_fc_site
|
||||
from frappe.utils import cint, get_system_timezone
|
||||
from frappe.utils.telemetry import capture
|
||||
@ -43,9 +45,41 @@ def get_boot():
|
||||
"user": frappe.db.get_value("User", frappe.session.user, "time_zone")
|
||||
or get_system_timezone(),
|
||||
},
|
||||
"app_version": get_app_version(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_default_route():
|
||||
return "/crm"
|
||||
|
||||
|
||||
def get_app_version():
|
||||
app = "crm"
|
||||
branch = run_git_command(f"cd ../apps/{app} && git rev-parse --abbrev-ref HEAD")
|
||||
commit = run_git_command(f"git -C ../apps/{app} rev-parse --short=7 HEAD")
|
||||
tag = run_git_command(f"git -C ../apps/{app} describe --tags --abbrev=0")
|
||||
dirty = run_git_command(f"git -C ../apps/{app} diff --quiet || echo 'dirty'") == "dirty"
|
||||
commit_date = run_git_command(f"git -C ../apps/{app} log -1 --format=%cd")
|
||||
commit_message = run_git_command(f"git -C ../apps/{app} log -1 --pretty=%B")
|
||||
|
||||
return {
|
||||
"branch": branch,
|
||||
"commit": commit,
|
||||
"commit_date": commit_date,
|
||||
"commit_message": commit_message,
|
||||
"tag": tag,
|
||||
"dirty": dirty,
|
||||
}
|
||||
|
||||
|
||||
def run_git_command(command):
|
||||
try:
|
||||
with open(os.devnull, "wb") as null_stream:
|
||||
result = subprocess.check_output(command, shell=True, stdin=null_stream, stderr=null_stream)
|
||||
return safe_decode(result).strip()
|
||||
except Exception:
|
||||
frappe.log_error(
|
||||
title="Git Command Error",
|
||||
)
|
||||
return ""
|
||||
|
||||
1
frontend/components.d.ts
vendored
1
frontend/components.d.ts
vendored
@ -158,6 +158,7 @@ declare module 'vue' {
|
||||
LucideCalendar: typeof import('~icons/lucide/calendar')['default']
|
||||
LucideInfo: typeof import('~icons/lucide/info')['default']
|
||||
LucidePlus: typeof import('~icons/lucide/plus')['default']
|
||||
LucideSearch: typeof import('~icons/lucide/search')['default']
|
||||
MarkAsDoneIcon: typeof import('./src/components/Icons/MarkAsDoneIcon.vue')['default']
|
||||
MaximizeIcon: typeof import('./src/components/Icons/MaximizeIcon.vue')['default']
|
||||
MenuIcon: typeof import('./src/components/Icons/MenuIcon.vue')['default']
|
||||
|
||||
@ -22,9 +22,9 @@
|
||||
variant="subtle"
|
||||
:theme="status.color"
|
||||
/>
|
||||
<Tooltip :text="formatDate(activity.creation)">
|
||||
<Tooltip :text="formatDate(activity.communication_date)">
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{ __(timeAgo(activity.creation)) }}
|
||||
{{ __(timeAgo(activity.communication_date)) }}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div class="flex gap-0.5">
|
||||
|
||||
@ -64,7 +64,10 @@ const emit = defineEmits(['change'])
|
||||
|
||||
const { getFields } = getMeta(props.doctype)
|
||||
|
||||
const values = defineModel()
|
||||
const values = defineModel({
|
||||
type: Array,
|
||||
default: () => [],
|
||||
})
|
||||
|
||||
const valuesRef = ref([])
|
||||
const error = ref(null)
|
||||
|
||||
@ -206,7 +206,7 @@
|
||||
v-else
|
||||
type="text"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
:value="data[field.fieldname]"
|
||||
:value="getDataValue(data[field.fieldname], field)"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
:description="field.description"
|
||||
@change="fieldChange($event.target.value, field)"
|
||||
@ -340,6 +340,13 @@ function fieldChange(value, df) {
|
||||
triggerOnChange(df.fieldname)
|
||||
}
|
||||
}
|
||||
|
||||
function getDataValue(value, field) {
|
||||
if (field.fieldtype === 'Duration') {
|
||||
return value || 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
:deep(.form-control.prefix select) {
|
||||
|
||||
@ -126,7 +126,7 @@
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Autocomplete
|
||||
value=""
|
||||
:options="filterableFields.data"
|
||||
:options="availableFilters"
|
||||
@change="(e) => setfilter(e)"
|
||||
:placeholder="__('First name')"
|
||||
>
|
||||
@ -217,6 +217,19 @@ const filters = computed(() => {
|
||||
return convertFilters(filterableFields.data, allFilters)
|
||||
})
|
||||
|
||||
const availableFilters = computed(() => {
|
||||
if (!filterableFields.data) return []
|
||||
|
||||
const selectedFieldNames = new Set()
|
||||
for (const filter of filters.value) {
|
||||
selectedFieldNames.add(filter.fieldname)
|
||||
}
|
||||
|
||||
return filterableFields.data.filter(
|
||||
(field) => !selectedFieldNames.has(field.fieldname),
|
||||
)
|
||||
})
|
||||
|
||||
function removeCommonFilters(commonFilters, allFilters) {
|
||||
for (const key in commonFilters) {
|
||||
if (commonFilters.hasOwnProperty(key) && allFilters.hasOwnProperty(key)) {
|
||||
|
||||
21
frontend/src/components/Icons/TelegramIcon.vue
Normal file
21
frontend/src/components/Icons/TelegramIcon.vue
Normal file
@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 64 64"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
stroke-width="3"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
>
|
||||
<g
|
||||
id="SVGRepo_tracerCarrier"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
></g>
|
||||
<g id="SVGRepo_iconCarrier">
|
||||
<path
|
||||
d="M26.67,38.57l-.82,11.54A2.88,2.88,0,0,0,28.14,49l5.5-5.26,11.42,8.35c2.08,1.17,3.55.56,4.12-1.92l7.49-35.12h0c.66-3.09-1.08-4.33-3.16-3.55l-44,16.85C6.47,29.55,6.54,31.23,9,32l11.26,3.5L45.59,20.71c1.23-.83,2.36-.37,1.44.44Z"
|
||||
stroke-linecap="round"
|
||||
></path>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
100
frontend/src/components/Modals/AboutModal.vue
Normal file
100
frontend/src/components/Modals/AboutModal.vue
Normal file
@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<Dialog v-model="show" :options="{ size: 'sm' }">
|
||||
<template #body>
|
||||
<div class="p-4 pt-5">
|
||||
<div class="flex justify-center">
|
||||
<div class="flex flex-col items-center">
|
||||
<CRMLogo class="mb-3 size-12" />
|
||||
<h3 class="font-semibold text-xl text-ink-gray-9">Frappe CRM</h3>
|
||||
<div class="flex items-center mt-1">
|
||||
<div class="text-base text-ink-gray-6">
|
||||
{{ appVersion.branch != 'main' ? appVersion.branch : '' }}
|
||||
<template v-if="appVersion.branch != 'main'">
|
||||
({{ appVersion.commit }})
|
||||
</template>
|
||||
<template v-else>{{ appVersion.tag }}</template>
|
||||
</div>
|
||||
|
||||
<Tooltip
|
||||
:text="`${appVersion.commit_message} - ${appVersion.commit_date}`"
|
||||
placement="top"
|
||||
>
|
||||
<LucideInfo class="size-3.5 text-ink-gray-8 ml-1" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="border-t my-3 mx-2" />
|
||||
<div>
|
||||
<a
|
||||
v-for="link in links"
|
||||
:key="link.label"
|
||||
class="flex py-2 px-2 hover:bg-surface-gray-1 rounded cursor-pointer"
|
||||
target="_blank"
|
||||
:href="link.url"
|
||||
>
|
||||
<component
|
||||
v-if="link.icon"
|
||||
:is="link.icon"
|
||||
class="size-4 mr-2 text-ink-gray-7"
|
||||
/>
|
||||
<span class="text-base text-ink-gray-8">
|
||||
{{ link.label }}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<hr class="border-t my-3 mx-2" />
|
||||
<p class="text-sm text-ink-gray-6 px-2 mt-2">
|
||||
© Frappe Technologies Pvt. Ltd. and contributors
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Tooltip } from 'frappe-ui'
|
||||
import CRMLogo from '@/components/Icons/CRMLogo.vue'
|
||||
import LucideGlobe from '~icons/lucide/globe'
|
||||
import LucideGitHub from '~icons/lucide/github'
|
||||
import LucideHeadset from '~icons/lucide/headset'
|
||||
import LucideBug from '~icons/lucide/bug'
|
||||
import LucideBookOpen from '~icons/lucide/book-open'
|
||||
import TelegramIcon from '@/components/Icons/TelegramIcon.vue'
|
||||
|
||||
let show = defineModel()
|
||||
|
||||
let links = [
|
||||
{
|
||||
label: __('Website'),
|
||||
url: 'https://frappe.io/crm',
|
||||
icon: LucideGlobe,
|
||||
},
|
||||
{
|
||||
label: __('GitHub Repository'),
|
||||
url: 'https://github.com/frappe/crm',
|
||||
icon: LucideGitHub,
|
||||
},
|
||||
{
|
||||
label: __('Documentation'),
|
||||
url: 'https://docs.frappe.io/crm',
|
||||
icon: LucideBookOpen,
|
||||
},
|
||||
{
|
||||
label: __('Telegram Channel'),
|
||||
url: 'https://t.me/frappecrm',
|
||||
icon: TelegramIcon,
|
||||
},
|
||||
{
|
||||
label: __('Report an Issue'),
|
||||
url: 'https://github.com/frappe/crm/issues',
|
||||
icon: LucideBug,
|
||||
},
|
||||
{
|
||||
label: __('Contact Support'),
|
||||
url: 'https://support.frappe.io',
|
||||
icon: LucideHeadset,
|
||||
},
|
||||
]
|
||||
|
||||
let appVersion = window.app_version
|
||||
</script>
|
||||
@ -172,8 +172,9 @@ import FadedScrollableDiv from '@/components/FadedScrollableDiv.vue'
|
||||
import { getCallLogDetail } from '@/utils/callLog'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { isMobileView } from '@/composables/settings'
|
||||
import { useDocument } from '@/data/document'
|
||||
import { FeatherIcon, Dropdown, Avatar, Tooltip, call } from 'frappe-ui'
|
||||
import { ref, computed, h, nextTick } from 'vue'
|
||||
import { ref, computed, h, nextTick, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const { isManager } = usersStore()
|
||||
@ -289,9 +290,19 @@ const detailFields = computed(() => {
|
||||
.filter((detail) => (detail.condition ? detail.condition() : true))
|
||||
})
|
||||
|
||||
function createLead() {
|
||||
const d = ref({})
|
||||
const leadDetails = ref({})
|
||||
|
||||
async function createLead() {
|
||||
await d.value.triggerOnCreateLead?.(
|
||||
callLog.value?.data,
|
||||
leadDetails.value,
|
||||
() => (show.value = false),
|
||||
)
|
||||
|
||||
call('crm.fcrm.doctype.crm_call_log.crm_call_log.create_lead_from_call_log', {
|
||||
call_log: callLog.value?.data,
|
||||
lead_details: leadDetails.value,
|
||||
}).then((d) => {
|
||||
if (d) {
|
||||
router.push({ name: 'Lead', params: { leadId: d } })
|
||||
@ -351,6 +362,14 @@ async function addTaskToCallLog(_task, insert_mode = false) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => callLog.value?.data?.name,
|
||||
(value) => {
|
||||
if (!value) return
|
||||
d.value = useDocument('CRM Call Log', value)
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@ -12,16 +12,19 @@
|
||||
v-model="showQuickEntryModal"
|
||||
:doctype="quickEntryDoctype"
|
||||
/>
|
||||
<AboutModal v-model="showAboutModal" />
|
||||
</template>
|
||||
<script setup>
|
||||
import CreateDocumentModal from '@/components/Modals/CreateDocumentModal.vue'
|
||||
import QuickEntryModal from '@/components/Modals/QuickEntryModal.vue'
|
||||
import AboutModal from '@/components/Modals/AboutModal.vue'
|
||||
import {
|
||||
showCreateDocumentModal,
|
||||
createDocumentDoctype,
|
||||
createDocumentData,
|
||||
createDocumentCallback,
|
||||
} from '@/composables/document'
|
||||
import { showAboutModal } from '@/composables/settings'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const showQuickEntryModal = ref(false)
|
||||
|
||||
@ -55,7 +55,11 @@ import Apps from '@/components/Apps.vue'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { getSettings } from '@/stores/settings'
|
||||
import { showSettings, isMobileView } from '@/composables/settings'
|
||||
import {
|
||||
showSettings,
|
||||
isMobileView,
|
||||
showAboutModal,
|
||||
} from '@/composables/settings'
|
||||
import { confirmLoginToFrappeCloud } from '@/composables/frappecloud'
|
||||
import { Dropdown } from 'frappe-ui'
|
||||
import { theme, toggleTheme } from '@/stores/theme'
|
||||
@ -131,20 +135,6 @@ function getStandardItem(item) {
|
||||
return {
|
||||
component: markRaw(Apps),
|
||||
}
|
||||
case 'support_link':
|
||||
return {
|
||||
icon: item.icon,
|
||||
label: __(item.label),
|
||||
onClick: () =>
|
||||
window.open(item.route, item.open_in_new_window ? '_blank' : ''),
|
||||
}
|
||||
case 'docs_link':
|
||||
return {
|
||||
icon: item.icon,
|
||||
label: __(item.label),
|
||||
onClick: () =>
|
||||
window.open(item.route, item.open_in_new_window ? '_blank' : ''),
|
||||
}
|
||||
case 'toggle_theme':
|
||||
return {
|
||||
icon: theme.value === 'dark' ? 'sun' : item.icon,
|
||||
@ -165,6 +155,12 @@ function getStandardItem(item) {
|
||||
onClick: () => confirmLoginToFrappeCloud(),
|
||||
condition: () => !isMobileView.value && window.is_fc_site,
|
||||
}
|
||||
case 'about':
|
||||
return {
|
||||
icon: item.icon,
|
||||
label: __(item.label),
|
||||
onClick: () => (showAboutModal.value = true),
|
||||
}
|
||||
case 'logout':
|
||||
return {
|
||||
icon: item.icon,
|
||||
|
||||
@ -566,7 +566,7 @@ async function exportRows() {
|
||||
page_length = list.value.data.total_count
|
||||
}
|
||||
|
||||
let url = `/api/method/frappe.desk.reportview.export_query?file_format_type=${export_type.value}&title=${props.doctype}&doctype=${props.doctype}&fields=${fields}&filters=${filters}&order_by=${order_by}&page_length=${page_length}&start=0&view=Report&with_comment_count=1`
|
||||
let url = `/api/method/frappe.desk.reportview.export_query?file_format_type=${export_type.value}&title=${props.doctype}&doctype=${props.doctype}&fields=${fields}&filters=${encodeURIComponent(filters)}&order_by=${order_by}&page_length=${page_length}&start=0&view=Report&with_comment_count=1`
|
||||
|
||||
// Add selected items parameter if rows are selected
|
||||
if (selectedRows.value?.length && !export_all.value) {
|
||||
@ -752,6 +752,7 @@ const quickFilterOptions = computed(() => {
|
||||
let fields = getFields()
|
||||
if (!fields) return []
|
||||
|
||||
let existingQuickFilters = newQuickFilters.value.map((f) => f.fieldname)
|
||||
let restrictedFieldtypes = [
|
||||
'Tab Break',
|
||||
'Section Break',
|
||||
@ -766,6 +767,7 @@ const quickFilterOptions = computed(() => {
|
||||
]
|
||||
let options = fields
|
||||
.filter((f) => f.label && !restrictedFieldtypes.includes(f.fieldtype))
|
||||
.filter((f) => !existingQuickFilters.includes(f.fieldname))
|
||||
.map((field) => ({
|
||||
label: field.label,
|
||||
value: field.fieldname,
|
||||
|
||||
@ -42,3 +42,5 @@ export const isMobileView = computed(() => window.innerWidth < 768)
|
||||
|
||||
export const showSettings = ref(false)
|
||||
export const activeSettingsPage = ref('')
|
||||
|
||||
export const showAboutModal = ref(false)
|
||||
|
||||
@ -14,7 +14,7 @@ export function useDocument(doctype, docname) {
|
||||
documentsCache[doctype][docname] = createDocumentResource({
|
||||
doctype: doctype,
|
||||
name: docname,
|
||||
onSuccess: () => setupFormScript(),
|
||||
onSuccess: async () => await setupFormScript(),
|
||||
setValue: {
|
||||
onSuccess: () => {
|
||||
toast.success(__('Document updated successfully'))
|
||||
@ -27,23 +27,49 @@ export function useDocument(doctype, docname) {
|
||||
})
|
||||
}
|
||||
|
||||
function setupFormScript() {
|
||||
if (controllersCache[doctype]?.[docname]) return
|
||||
async function setupFormScript() {
|
||||
if (
|
||||
controllersCache[doctype] &&
|
||||
typeof controllersCache[doctype][docname] === 'object'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!controllersCache[doctype]) {
|
||||
controllersCache[doctype] = {}
|
||||
}
|
||||
|
||||
controllersCache[doctype][docname] = setupScript(
|
||||
documentsCache[doctype][docname],
|
||||
)
|
||||
controllersCache[doctype][docname] = {}
|
||||
|
||||
const controllersArray = await setupScript(documentsCache[doctype][docname])
|
||||
|
||||
if (!controllersArray || controllersArray.length === 0) return
|
||||
|
||||
const organizedControllers = {}
|
||||
for (const controller of controllersArray) {
|
||||
const controllerKey = controller.constructor.name // e.g., "CRMLead", "CRMProducts"
|
||||
if (!organizedControllers[controllerKey]) {
|
||||
organizedControllers[controllerKey] = []
|
||||
}
|
||||
organizedControllers[controllerKey].push(controller)
|
||||
}
|
||||
controllersCache[doctype][docname] = organizedControllers
|
||||
}
|
||||
|
||||
function getControllers(row = null) {
|
||||
const _doctype = row?.doctype || doctype
|
||||
return (controllersCache[doctype]?.[docname] || []).filter(
|
||||
(c) => c.constructor.name === _doctype.replace(/\s+/g, ''),
|
||||
)
|
||||
const controllerKey = _doctype.replace(/\s+/g, '')
|
||||
|
||||
const docControllers = controllersCache[doctype]?.[docname]
|
||||
|
||||
if (
|
||||
typeof docControllers === 'object' &&
|
||||
docControllers !== null &&
|
||||
!Array.isArray(docControllers)
|
||||
) {
|
||||
return docControllers[controllerKey] || []
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
async function triggerOnRefresh() {
|
||||
@ -97,6 +123,22 @@ export function useDocument(doctype, docname) {
|
||||
await trigger(handler, rows[0])
|
||||
}
|
||||
|
||||
async function triggerOnCreateLead() {
|
||||
const args = Array.from(arguments)
|
||||
const handler = async function () {
|
||||
await this.on_create_lead?.(...args)
|
||||
}
|
||||
await trigger(handler)
|
||||
}
|
||||
|
||||
async function triggerConvertToDeal() {
|
||||
const args = Array.from(arguments)
|
||||
const handler = async function () {
|
||||
await this.convert_to_deal?.(...args)
|
||||
}
|
||||
await trigger(handler)
|
||||
}
|
||||
|
||||
async function trigger(taskFn, row = null) {
|
||||
const controllers = getControllers(row)
|
||||
if (!controllers.length) return
|
||||
@ -130,5 +172,7 @@ export function useDocument(doctype, docname) {
|
||||
triggerOnRowRemove,
|
||||
triggerOnRefresh,
|
||||
setupFormScript,
|
||||
triggerOnCreateLead,
|
||||
triggerConvertToDeal,
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,15 +20,23 @@ export function getScript(doctype, view = 'Form') {
|
||||
doctypeScripts[doctype][script.name] = script || {}
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error(
|
||||
`Error loading CRM Form Scripts for ${doctype} (view: ${view}):`,
|
||||
err,
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
if (!doctypeScripts[doctype] && !scripts.loading) {
|
||||
scripts.fetch()
|
||||
}
|
||||
|
||||
function setupScript(document, helpers = {}) {
|
||||
let scripts = doctypeScripts[doctype]
|
||||
if (!scripts) return null
|
||||
async function setupScript(document, helpers = {}) {
|
||||
await scripts.promise
|
||||
|
||||
let scriptDefs = doctypeScripts[doctype]
|
||||
if (!scriptDefs || Object.keys(scriptDefs).length === 0) return null
|
||||
|
||||
const { $dialog, $socket, makeCall } = globalStore()
|
||||
|
||||
@ -42,7 +50,7 @@ export function getScript(doctype, view = 'Form') {
|
||||
makePhoneCall: makeCall,
|
||||
}
|
||||
|
||||
return setupMultipleFormControllers(scripts, document, helpers)
|
||||
return setupMultipleFormControllers(scriptDefs, document, helpers)
|
||||
}
|
||||
|
||||
function setupMultipleFormControllers(scriptStrings, document, helpers) {
|
||||
@ -126,10 +134,10 @@ export function getScript(doctype, view = 'Form') {
|
||||
return meta[doctype]
|
||||
}
|
||||
|
||||
setupHelperMethods(FormClass, document)
|
||||
const getDoc = () => document.doc
|
||||
|
||||
if (isChildDoctype) {
|
||||
instance.doc = createDocProxy(document.doc, parentInstance, instance)
|
||||
instance.doc = createDocProxy(getDoc, parentInstance, instance)
|
||||
|
||||
if (!parentInstance._childInstances) {
|
||||
parentInstance._childInstances = []
|
||||
@ -137,22 +145,21 @@ export function getScript(doctype, view = 'Form') {
|
||||
|
||||
parentInstance._childInstances.push(instance)
|
||||
} else {
|
||||
instance.doc = createDocProxy(document.doc, instance)
|
||||
instance.doc = createDocProxy(getDoc, instance)
|
||||
}
|
||||
|
||||
return instance
|
||||
}
|
||||
|
||||
function setupHelperMethods(FormClass, document) {
|
||||
function setupHelperMethods(FormClass) {
|
||||
if (typeof FormClass.prototype.getRow !== 'function') {
|
||||
FormClass.prototype.getRow = function (parentField, idx) {
|
||||
let data = document.doc
|
||||
idx = idx || this.currentRowIdx
|
||||
|
||||
let dt = null
|
||||
|
||||
if (this instanceof Array) {
|
||||
const { getFields } = getMeta(data.doctype)
|
||||
const { getFields } = getMeta(this.doc.doctype)
|
||||
let fields = getFields()
|
||||
let field = fields.find((f) => f.fieldname === parentField)
|
||||
dt = field?.options?.replace(/\s+/g, '')
|
||||
@ -162,13 +169,13 @@ export function getScript(doctype, view = 'Form') {
|
||||
}
|
||||
}
|
||||
|
||||
if (!data[parentField]) {
|
||||
if (!this.doc[parentField]) {
|
||||
console.warn(
|
||||
__('⚠️ No data found for parent field: {0}', [parentField]),
|
||||
)
|
||||
return null
|
||||
}
|
||||
const row = data[parentField].find((r) => r.idx === idx)
|
||||
const row = this.doc[parentField].find((r) => r.idx === idx)
|
||||
|
||||
if (!row) {
|
||||
console.warn(
|
||||
@ -180,7 +187,7 @@ export function getScript(doctype, view = 'Form') {
|
||||
return null
|
||||
}
|
||||
|
||||
row.parent = row.parent || data.name
|
||||
row.parent = row.parent || this.doc.name
|
||||
|
||||
if (this instanceof Array && dt) {
|
||||
return createDocProxy(
|
||||
@ -220,46 +227,76 @@ export function getScript(doctype, view = 'Form') {
|
||||
const FormClass = new Function(...helperKeys, wrappedScript)(
|
||||
...helperValues,
|
||||
)
|
||||
|
||||
setupHelperMethods(FormClass)
|
||||
|
||||
return FormClass
|
||||
}
|
||||
|
||||
function createDocProxy(data, instance, childInstance = null) {
|
||||
return new Proxy(data, {
|
||||
get(target, prop) {
|
||||
if (prop === 'trigger') {
|
||||
if ('trigger' in data) {
|
||||
console.warn(
|
||||
__(
|
||||
'⚠️ Avoid using "trigger" as a field name — it conflicts with the built-in trigger() method.',
|
||||
),
|
||||
function createDocProxy(source, instance, childInstance = null) {
|
||||
const isFunction = typeof source === 'function'
|
||||
const getCurrentData = () => (isFunction ? source() : source)
|
||||
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
get(target, prop) {
|
||||
const currentDocData = getCurrentData()
|
||||
if (!currentDocData) return undefined
|
||||
|
||||
if (prop === 'trigger') {
|
||||
if (currentDocData && 'trigger' in currentDocData) {
|
||||
console.warn(
|
||||
__(
|
||||
'⚠️ Avoid using "trigger" as a field name — it conflicts with the built-in trigger() method.',
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return (methodName, ...args) => {
|
||||
const method = instance[methodName]
|
||||
if (typeof method === 'function') {
|
||||
return method.apply(instance, args)
|
||||
} else {
|
||||
console.warn(
|
||||
__('⚠️ Method "{0}" not found in class.', [methodName]),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (prop === 'getRow') {
|
||||
return instance.getRow.bind(
|
||||
childInstance || instance._childInstances || instance,
|
||||
)
|
||||
}
|
||||
|
||||
return (methodName, ...args) => {
|
||||
const method = instance[methodName]
|
||||
if (typeof method === 'function') {
|
||||
return method.apply(instance, args)
|
||||
} else {
|
||||
console.warn(
|
||||
__('⚠️ Method "{0}" not found in class.', [methodName]),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return currentDocData[prop]
|
||||
},
|
||||
set(target, prop, value) {
|
||||
const currentDocData = getCurrentData()
|
||||
if (!currentDocData) return false
|
||||
|
||||
if (prop === 'getRow') {
|
||||
return instance.getRow.bind(
|
||||
childInstance || instance._childInstances || instance,
|
||||
)
|
||||
}
|
||||
|
||||
return target[prop]
|
||||
currentDocData[prop] = value
|
||||
return true
|
||||
},
|
||||
has(target, prop) {
|
||||
const currentDocData = getCurrentData()
|
||||
if (!currentDocData) return false
|
||||
return prop in currentDocData
|
||||
},
|
||||
ownKeys(target) {
|
||||
const currentDocData = getCurrentData()
|
||||
if (!currentDocData) return []
|
||||
return Reflect.ownKeys(currentDocData)
|
||||
},
|
||||
getOwnPropertyDescriptor(target, prop) {
|
||||
const currentDocData = getCurrentData()
|
||||
if (!currentDocData) return undefined
|
||||
return Reflect.getOwnPropertyDescriptor(currentDocData, prop)
|
||||
},
|
||||
},
|
||||
set(target, prop, value) {
|
||||
target[prop] = value
|
||||
return true
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@ -374,6 +374,7 @@ import { usersStore } from '@/stores/users'
|
||||
import { globalStore } from '@/stores/global'
|
||||
import { statusesStore } from '@/stores/statuses'
|
||||
import { getMeta } from '@/stores/meta'
|
||||
import { useDocument } from '@/data/document'
|
||||
import {
|
||||
whatsappEnabled,
|
||||
callEnabled,
|
||||
@ -638,6 +639,8 @@ const existingOrganizationChecked = ref(false)
|
||||
const existingContact = ref('')
|
||||
const existingOrganization = ref('')
|
||||
|
||||
const { triggerConvertToDeal } = useDocument('CRM Lead', props.leadId)
|
||||
|
||||
async function convertToDeal() {
|
||||
if (existingContactChecked.value && !existingContact.value) {
|
||||
toast.error(__('Please select an existing contact'))
|
||||
@ -657,6 +660,12 @@ async function convertToDeal() {
|
||||
existingOrganization.value = ''
|
||||
}
|
||||
|
||||
await triggerConvertToDeal?.(
|
||||
lead.data,
|
||||
deal,
|
||||
() => (showConvertToDealModal.value = false),
|
||||
)
|
||||
|
||||
let _deal = await call('crm.fcrm.doctype.crm_lead.crm_lead.convert_to_deal', {
|
||||
lead: lead.data.name,
|
||||
deal,
|
||||
|
||||
@ -2,9 +2,8 @@ import TaskStatusIcon from '@/components/Icons/TaskStatusIcon.vue'
|
||||
import TaskPriorityIcon from '@/components/Icons/TaskPriorityIcon.vue'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { gemoji } from 'gemoji'
|
||||
import { useTimeAgo } from '@vueuse/core'
|
||||
import { getMeta } from '@/stores/meta'
|
||||
import { toast, dayjsLocal, dayjs } from 'frappe-ui'
|
||||
import { toast, dayjsLocal, dayjs, getConfig } from 'frappe-ui'
|
||||
import { h } from 'vue'
|
||||
|
||||
export function formatTime(seconds) {
|
||||
@ -65,7 +64,85 @@ export function getFormat(
|
||||
}
|
||||
|
||||
export function timeAgo(date) {
|
||||
return useTimeAgo(date).value
|
||||
return prettyDate(date)
|
||||
}
|
||||
|
||||
function getBrowserTimezone() {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
}
|
||||
|
||||
export function prettyDate(date, mini = false) {
|
||||
if (!date) return ''
|
||||
|
||||
let systemTimezone = getConfig('systemTimezone')
|
||||
let localTimezone = getConfig('localTimezone') || getBrowserTimezone()
|
||||
|
||||
if (typeof date == 'string') {
|
||||
date = dayjsLocal(date)
|
||||
}
|
||||
|
||||
let nowDatetime = dayjs().tz(localTimezone || systemTimezone)
|
||||
let diff = nowDatetime.diff(date, 'seconds')
|
||||
|
||||
let dayDiff = Math.floor(diff / 86400)
|
||||
|
||||
if (isNaN(dayDiff) || dayDiff < 0) return ''
|
||||
|
||||
if (mini) {
|
||||
// Return short format of time difference
|
||||
if (dayDiff == 0) {
|
||||
if (diff < 60) {
|
||||
return __('now')
|
||||
} else if (diff < 3600) {
|
||||
return __('{0} m', [Math.floor(diff / 60)])
|
||||
} else if (diff < 86400) {
|
||||
return __('{0} h', [Math.floor(diff / 3600)])
|
||||
}
|
||||
} else {
|
||||
if (dayDiff < 7) {
|
||||
return __('{0} d', [dayDiff])
|
||||
} else if (dayDiff < 31) {
|
||||
return __('{0} w', [Math.floor(dayDiff / 7)])
|
||||
} else if (dayDiff < 365) {
|
||||
return __('{0} M', [Math.floor(dayDiff / 30)])
|
||||
} else {
|
||||
return __('{0} y', [Math.floor(dayDiff / 365)])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Return long format of time difference
|
||||
if (dayDiff == 0) {
|
||||
if (diff < 60) {
|
||||
return __('just now')
|
||||
} else if (diff < 120) {
|
||||
return __('1 minute ago')
|
||||
} else if (diff < 3600) {
|
||||
return __('{0} minutes ago', [Math.floor(diff / 60)])
|
||||
} else if (diff < 7200) {
|
||||
return __('1 hour ago')
|
||||
} else if (diff < 86400) {
|
||||
return __('{0} hours ago', [Math.floor(diff / 3600)])
|
||||
}
|
||||
} else {
|
||||
if (dayDiff == 1) {
|
||||
return __('yesterday')
|
||||
} else if (dayDiff < 7) {
|
||||
return __('{0} days ago', [dayDiff])
|
||||
} else if (dayDiff < 14) {
|
||||
return __('1 week ago')
|
||||
} else if (dayDiff < 31) {
|
||||
return __('{0} weeks ago', [Math.floor(dayDiff / 7)])
|
||||
} else if (dayDiff < 62) {
|
||||
return __('1 month ago')
|
||||
} else if (dayDiff < 365) {
|
||||
return __('{0} months ago', [Math.floor(dayDiff / 30)])
|
||||
} else if (dayDiff < 730) {
|
||||
return __('1 year ago')
|
||||
} else {
|
||||
return __('{0} years ago', [Math.floor(dayDiff / 365)])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function taskStatusOptions(action, data) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user