Merge pull request #1011 from frappe/main-hotfix

This commit is contained in:
Shariq Ansari 2025-07-02 18:18:25 +05:30 committed by GitHub
commit 2828e76657
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 907 additions and 338 deletions

View File

@ -14,6 +14,8 @@
"column_break_ijan",
"status",
"deal_owner",
"lost_reason",
"lost_notes",
"section_break_jgpm",
"probability",
"deal_value",
@ -391,12 +393,25 @@
{
"fieldname": "column_break_kpxa",
"fieldtype": "Column Break"
},
{
"fieldname": "lost_reason",
"fieldtype": "Link",
"label": "Lost Reason",
"mandatory_depends_on": "eval: doc.status == \"Lost\"",
"options": "CRM Lost Reason"
},
{
"fieldname": "lost_notes",
"fieldtype": "Text",
"label": "Lost Notes",
"mandatory_depends_on": "eval: doc.lost_reason == \"Other\""
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-06-16 11:42:49.413483",
"modified": "2025-07-02 11:07:50.192089",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Deal",

View File

@ -24,7 +24,8 @@ class CRMDeal(Document):
self.assign_agent(self.deal_owner)
if self.has_value_changed("status"):
add_status_change_log(self)
self.update_close_date()
self.validate_forcasting_fields()
self.validate_lost_reason()
def after_insert(self):
if self.deal_owner:
@ -141,6 +142,32 @@ class CRMDeal(Document):
if self.status == "Won" and not self.close_date:
self.close_date = frappe.utils.nowdate()
def update_default_probability(self):
"""
Update the default probability based on the status.
"""
if not self.probability or self.probability == 0:
self.probability = frappe.db.get_value("CRM Deal Status", self.status, "probability") or 0
def validate_forcasting_fields(self):
self.update_close_date()
self.update_default_probability()
if frappe.db.get_single_value("FCRM Settings", "enable_forecasting"):
if not self.deal_value or self.deal_value == 0:
frappe.throw(_("Deal Value is required."), frappe.MandatoryError)
if not self.close_date:
frappe.throw(_("Close Date is required."), frappe.MandatoryError)
def validate_lost_reason(self):
"""
Validate the lost reason if the status is set to "Lost".
"""
if self.status == "Lost":
if not self.lost_reason:
frappe.throw(_("Please specify a reason for losing the deal."), frappe.ValidationError)
elif self.lost_reason == "Other" and not self.lost_notes:
frappe.throw(_("Please specify the reason for losing the deal."), frappe.ValidationError)
@staticmethod
def default_list_data():
columns = [

View File

@ -37,13 +37,14 @@
{
"fieldname": "probability",
"fieldtype": "Percent",
"in_list_view": 1,
"label": "Probability"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-06-11 13:00:34.518808",
"modified": "2025-07-01 12:06:42.937440",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Deal Status",

View File

@ -47,6 +47,13 @@ def get_fields_layout(doctype: str, type: str, parent_doctype: str | None = None
fields = frappe.get_meta(doctype).fields
fields = [field for field in fields if field.fieldname in allowed_fields]
required_fields = []
if type == "Required Fields":
required_fields = [
field for field in frappe.get_meta(doctype, False).fields if field.reqd and not field.default
]
for tab in tabs:
for section in tab.get("sections"):
if section.get("columns"):
@ -60,6 +67,32 @@ def get_fields_layout(doctype: str, type: str, parent_doctype: str | None = None
handle_perm_level_restrictions(field, doctype, parent_doctype)
column["fields"][column.get("fields").index(field["fieldname"])] = field
# remove field from required_fields if it is already present
if (
type == "Required Fields"
and field.reqd
and any(f.get("fieldname") == field.get("fieldname") for f in required_fields)
):
required_fields = [
f for f in required_fields if f.get("fieldname") != field.get("fieldname")
]
if type == "Required Fields" and required_fields and tabs:
tabs[-1].get("sections").append(
{
"label": "Required Fields",
"name": "required_fields_section_" + str(random_string(4)),
"opened": True,
"hideLabel": True,
"columns": [
{
"name": "required_fields_column_" + str(random_string(4)),
"fields": [field.as_dict() for field in required_fields],
}
],
}
)
return tabs or []
@ -83,6 +116,8 @@ def get_sidepanel_sections(doctype):
fields = frappe.get_meta(doctype).fields
fields = [field for field in fields if field.fieldtype not in not_allowed_fieldtypes]
add_forecasting_section(layout, doctype)
for section in layout:
section["name"] = section.get("name") or section.get("label")
for column in section.get("columns") if section.get("columns") else []:
@ -100,6 +135,38 @@ def get_sidepanel_sections(doctype):
return layout
def add_forecasting_section(layout, doctype):
if (
doctype == "CRM Deal"
and frappe.db.get_single_value("FCRM Settings", "enable_forecasting")
and not any(section.get("name") == "forecasted_sales_section" for section in layout)
):
contacts_section_index = next(
(
i
for i, section in enumerate(layout)
if section.get("name") == "contacts_section" or section.get("label") == "Contacts"
),
None,
)
if contacts_section_index is not None:
layout.insert(
contacts_section_index + 1,
{
"name": "forecasted_sales_section",
"label": "Forecasted Sales",
"opened": True,
"columns": [
{
"name": "column_" + str(random_string(4)),
"fields": ["close_date", "probability", "deal_value"],
}
],
},
)
def handle_perm_level_restrictions(field, doctype, parent_doctype=None):
if field.permlevel == 0:
return

View File

@ -27,9 +27,10 @@
"label": "Details"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-01-02 22:13:30.498404",
"modified": "2025-06-30 16:53:51.721752",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Lead Source",
@ -44,7 +45,7 @@
"print": 1,
"read": 1,
"report": 1,
"role": "Sales User",
"role": "System Manager",
"share": 1,
"write": 1
},
@ -60,6 +61,15 @@
"share": 1,
"write": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Sales User",
"share": 1
},
{
"email": 1,
"export": 1,
@ -71,7 +81,8 @@
}
],
"quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}
}

View File

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

View File

@ -0,0 +1,79 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "field:lost_reason",
"creation": "2025-06-30 16:51:31.082360",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"lost_reason",
"description"
],
"fields": [
{
"fieldname": "lost_reason",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Lost Reason",
"reqd": 1,
"unique": 1
},
{
"fieldname": "description",
"fieldtype": "Text Editor",
"label": "Description"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-06-30 16:59:15.094049",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Lost Reason",
"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
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Sales Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Sales User",
"share": 1,
"write": 1
}
],
"quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

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

View File

@ -0,0 +1,30 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
from frappe.tests import IntegrationTestCase, UnitTestCase
# On IntegrationTestCase, the doctype test records and all
# link-field test record dependencies are recursively loaded
# Use these module variables to add/remove to/from that list
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
class UnitTestCRMLostReason(UnitTestCase):
"""
Unit tests for CRMLostReason.
Use this class for testing individual functions and methods.
"""
pass
class IntegrationTestCRMLostReason(IntegrationTestCase):
"""
Integration tests for CRMLostReason.
Use this class for testing interactions between multiple components.
"""
pass

View File

@ -60,7 +60,7 @@
},
{
"default": "0",
"description": "It will make deal's \"Expected Closure Date\" mandatory to get accurate forecasting insights",
"description": "It will make deal's \"Close Date\" & \"Deal Value\" mandatory to get accurate forecasting insights",
"fieldname": "enable_forecasting",
"fieldtype": "Check",
"label": "Enable Forecasting"
@ -69,7 +69,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2025-06-11 19:12:16.762499",
"modified": "2025-07-01 13:20:48.757603",
"modified_by": "Administrator",
"module": "FCRM",
"name": "FCRM Settings",

View File

@ -39,6 +39,11 @@ class FCRMSettings(Document):
"reqd",
"close_date",
)
delete_property_setter(
"CRM Deal",
"reqd",
"deal_value",
)
else:
make_property_setter(
"CRM Deal",
@ -47,6 +52,13 @@ class FCRMSettings(Document):
1 if self.enable_forecasting else 0,
"Check",
)
make_property_setter(
"CRM Deal",
"deal_value",
"reqd",
1 if self.enable_forecasting else 0,
"Check",
)
def get_standard_dropdown_items():

View File

@ -20,6 +20,7 @@ def after_install(force=False):
add_email_template_custom_fields()
add_default_industries()
add_default_lead_sources()
add_default_lost_reasons()
add_standard_dropdown_items()
add_default_scripts()
frappe.db.commit()
@ -68,30 +69,37 @@ def add_default_deal_statuses():
statuses = {
"Qualification": {
"color": "gray",
"probability": 10,
"position": 1,
},
"Demo/Making": {
"color": "orange",
"probability": 25,
"position": 2,
},
"Proposal/Quotation": {
"color": "blue",
"probability": 50,
"position": 3,
},
"Negotiation": {
"color": "yellow",
"probability": 70,
"position": 4,
},
"Ready to Close": {
"color": "purple",
"probability": 90,
"position": 5,
},
"Won": {
"color": "green",
"probability": 100,
"position": 6,
},
"Lost": {
"color": "red",
"probability": 0,
"position": 7,
},
}
@ -103,6 +111,7 @@ def add_default_deal_statuses():
doc = frappe.new_doc("CRM Deal Status")
doc.deal_status = status
doc.color = statuses[status]["color"]
doc.probability = statuses[status]["probability"]
doc.position = statuses[status]["position"]
doc.insert()
@ -343,6 +352,44 @@ def add_default_lead_sources():
doc.insert()
def add_default_lost_reasons():
lost_reasons = [
{
"reason": "Pricing",
"description": "The prospect found the pricing to be too high or not competitive.",
},
{"reason": "Competition", "description": "The prospect chose a competitor's product or service."},
{
"reason": "Budget Constraints",
"description": "The prospect did not have the budget to proceed with the purchase.",
},
{
"reason": "Missing Features",
"description": "The prospect felt that the product or service was missing key features they needed.",
},
{
"reason": "Long Sales Cycle",
"description": "The sales process took too long, leading to loss of interest.",
},
{
"reason": "No Decision-Maker",
"description": "The prospect was not the decision-maker and could not proceed.",
},
{"reason": "Unresponsive Prospect", "description": "The prospect did not respond to follow-ups."},
{"reason": "Poor Fit", "description": "The prospect was not a good fit for the product or service."},
{"reason": "Other", "description": ""},
]
for reason in lost_reasons:
if frappe.db.exists("CRM Lost Reason", reason["reason"]):
continue
doc = frappe.new_doc("CRM Lost Reason")
doc.lost_reason = reason["reason"]
doc.description = reason["description"]
doc.insert()
def add_standard_dropdown_items():
crm_settings = frappe.get_single("FCRM Settings")

View File

@ -12,4 +12,5 @@ crm.patches.v1_0.create_default_sidebar_fields_layout
crm.patches.v1_0.update_deal_quick_entry_layout
crm.patches.v1_0.update_layouts_to_new_format
crm.patches.v1_0.move_twilio_agent_to_telephony_agent
crm.patches.v1_0.create_default_scripts # 13-06-2025
crm.patches.v1_0.create_default_scripts # 13-06-2025
crm.patches.v1_0.update_deal_status_probabilities

View File

@ -0,0 +1,24 @@
import frappe
def execute():
deal_statuses = frappe.get_all("CRM Deal Status", fields=["name", "probability", "deal_status"])
for status in deal_statuses:
if status.probability is None or status.probability == 0:
if status.deal_status == "Qualification":
probability = 10
elif status.deal_status == "Demo/Making":
probability = 25
elif status.deal_status == "Proposal/Quotation":
probability = 50
elif status.deal_status == "Negotiation":
probability = 70
elif status.deal_status == "Ready to Close":
probability = 90
elif status.deal_status == "Won":
probability = 100
else:
probability = 0
frappe.db.set_value("CRM Deal Status", status.name, "probability", probability)

View File

@ -56,6 +56,7 @@ declare module 'vue' {
ContactsIcon: typeof import('./src/components/Icons/ContactsIcon.vue')['default']
ContactsListView: typeof import('./src/components/ListViews/ContactsListView.vue')['default']
ConvertIcon: typeof import('./src/components/Icons/ConvertIcon.vue')['default']
ConvertToDealModal: typeof import('./src/components/Modals/ConvertToDealModal.vue')['default']
CountUpTimer: typeof import('./src/components/CountUpTimer.vue')['default']
CreateDocumentModal: typeof import('./src/components/Modals/CreateDocumentModal.vue')['default']
CRMLogo: typeof import('./src/components/Icons/CRMLogo.vue')['default']
@ -161,7 +162,7 @@ declare module 'vue' {
ListIcon: typeof import('./src/components/Icons/ListIcon.vue')['default']
ListRows: typeof import('./src/components/ListViews/ListRows.vue')['default']
LoadingIndicator: typeof import('./src/components/Icons/LoadingIndicator.vue')['default']
LucideCalendar: typeof import('~icons/lucide/calendar')['default']
LostReasonModal: typeof import('./src/components/Modals/LostReasonModal.vue')['default']
LucideInfo: typeof import('~icons/lucide/info')['default']
LucideMoreHorizontal: typeof import('~icons/lucide/more-horizontal')['default']
LucidePlus: typeof import('~icons/lucide/plus')['default']

View File

@ -13,7 +13,7 @@
"@tiptap/extension-paragraph": "^2.12.0",
"@twilio/voice-sdk": "^2.10.2",
"@vueuse/integrations": "^10.3.0",
"frappe-ui": "^0.1.162",
"frappe-ui": "^0.1.166",
"gemoji": "^8.1.0",
"lodash": "^4.17.21",
"mime": "^4.0.1",

View File

@ -368,6 +368,7 @@
<DataFields
:doctype="doctype"
:docname="doc.data.name"
@beforeSave="(data) => emit('beforeSave', data)"
@afterSave="(data) => emit('afterSave', data)"
/>
</div>
@ -518,7 +519,7 @@ const props = defineProps({
},
})
const emit = defineEmits(['afterSave'])
const emit = defineEmits(['beforeSave', 'afterSave'])
const route = useRoute()

View File

@ -66,7 +66,7 @@ import LoadingIndicator from '@/components/Icons/LoadingIndicator.vue'
import { usersStore } from '@/stores/users'
import { useDocument } from '@/data/document'
import { isMobileView } from '@/composables/settings'
import { ref, watch } from 'vue'
import { ref, watch, getCurrentInstance } from 'vue'
const props = defineProps({
doctype: {
@ -79,10 +79,13 @@ const props = defineProps({
},
})
const emit = defineEmits(['afterSave'])
const emit = defineEmits(['beforeSave', 'afterSave'])
const { isManager } = usersStore()
const instance = getCurrentInstance()
const attrs = instance?.vnode?.props ?? {}
const showDataFieldsModal = ref(false)
const { document } = useDocument(props.doctype, props.docname)
@ -107,9 +110,15 @@ function saveChanges() {
return acc
}, {})
document.save.submit(null, {
onSuccess: () => emit('afterSave', changes),
})
const hasListener = attrs['onBeforeSave'] !== undefined
if (hasListener) {
emit('beforeSave', changes)
} else {
document.save.submit(null, {
onSuccess: () => emit('afterSave', changes),
})
}
}
watch(

View File

@ -121,7 +121,10 @@ const callBacks = {
loading.value = false
if (err.exc_type == 'MandatoryError') {
const errorMessage = err.messages
.map((msg) => msg.split(': ')[2].trim())
.map((msg) => {
let arr = msg.split(': ')
return arr[arr.length - 1].trim()
})
.join(', ')
error.value = __('These fields are required: {0}', [errorMessage])
return

View File

@ -124,7 +124,10 @@ const callBacks = {
loading.value = false
if (err.exc_type == 'MandatoryError') {
const errorMessage = err.messages
.map((msg) => msg.split(': ')[2].trim())
.map((msg) => {
let arr = msg.split(': ')
return arr[arr.length - 1].trim()
})
.join(', ')
error.value = __('These fields are required: {0}', [errorMessage])
return

View File

@ -0,0 +1,247 @@
<template>
<Dialog
v-model="show"
:options="{
size: 'xl',
actions: [
{
label: __('Convert'),
variant: 'solid',
onClick: convertToDeal,
},
],
}"
>
<template #body-header>
<div class="mb-6 flex items-center justify-between">
<div>
<h3 class="text-2xl font-semibold leading-6 text-ink-gray-9">
{{ __('Convert to Deal') }}
</h3>
</div>
<div class="flex items-center gap-1">
<Button
v-if="isManager() && !isMobileView"
variant="ghost"
@click="openQuickEntryModal"
>
<template #icon>
<EditIcon class="h-4 w-4" />
</template>
</Button>
<Button icon="x" variant="ghost" @click="show = false" />
</div>
</div>
</template>
<template #body-content>
<div class="mb-4 flex items-center gap-2 text-ink-gray-5">
<OrganizationsIcon class="h-4 w-4" />
<label class="block text-base">{{ __('Organization') }}</label>
</div>
<div class="ml-6 text-ink-gray-9">
<div class="flex items-center justify-between text-base">
<div>{{ __('Choose Existing') }}</div>
<Switch v-model="existingOrganizationChecked" />
</div>
<Link
v-if="existingOrganizationChecked"
class="form-control mt-2.5"
size="md"
:value="existingOrganization"
doctype="CRM Organization"
@change="(data) => (existingOrganization = data)"
/>
<div v-else class="mt-2.5 text-base">
{{
__(
'New organization will be created based on the data in details section',
)
}}
</div>
</div>
<div class="mb-4 mt-6 flex items-center gap-2 text-ink-gray-5">
<ContactsIcon class="h-4 w-4" />
<label class="block text-base">{{ __('Contact') }}</label>
</div>
<div class="ml-6 text-ink-gray-9">
<div class="flex items-center justify-between text-base">
<div>{{ __('Choose Existing') }}</div>
<Switch v-model="existingContactChecked" />
</div>
<Link
v-if="existingContactChecked"
class="form-control mt-2.5"
size="md"
:value="existingContact"
doctype="Contact"
@change="(data) => (existingContact = data)"
/>
<div v-else class="mt-2.5 text-base">
{{ __("New contact will be created based on the person's details") }}
</div>
</div>
<div v-if="dealTabs.data?.length" class="h-px w-full border-t my-6" />
<FieldLayout
v-if="dealTabs.data?.length"
:tabs="dealTabs.data"
:data="deal.doc"
doctype="CRM Deal"
/>
<ErrorMessage class="mt-4" :message="error" />
</template>
</Dialog>
</template>
<script setup>
import OrganizationsIcon from '@/components/Icons/OrganizationsIcon.vue'
import ContactsIcon from '@/components/Icons/ContactsIcon.vue'
import EditIcon from '@/components/Icons/EditIcon.vue'
import FieldLayout from '@/components/FieldLayout/FieldLayout.vue'
import Link from '@/components/Controls/Link.vue'
import { useDocument } from '@/data/document'
import { usersStore } from '@/stores/users'
import { sessionStore } from '@/stores/session'
import { statusesStore } from '@/stores/statuses'
import { showQuickEntryModal, quickEntryProps } from '@/composables/modals'
import { isMobileView } from '@/composables/settings'
import { capture } from '@/telemetry'
import { useOnboarding } from 'frappe-ui/frappe'
import { Switch, Dialog, createResource, call } from 'frappe-ui'
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
const props = defineProps({
lead: {
type: Object,
required: true,
},
})
const show = defineModel()
const router = useRouter()
const { statusOptions, getDealStatus } = statusesStore()
const { isManager } = usersStore()
const { user } = sessionStore()
const { updateOnboardingStep } = useOnboarding('frappecrm')
const existingContactChecked = ref(false)
const existingOrganizationChecked = ref(false)
const existingContact = ref('')
const existingOrganization = ref('')
const error = ref('')
const { triggerConvertToDeal } = useDocument('CRM Lead', props.lead.name)
const { document: deal } = useDocument('CRM Deal')
async function convertToDeal() {
error.value = ''
if (existingContactChecked.value && !existingContact.value) {
error.value = __('Please select an existing contact')
return
}
if (existingOrganizationChecked.value && !existingOrganization.value) {
error.value = __('Please select an existing organization')
return
}
if (!existingContactChecked.value && existingContact.value) {
existingContact.value = ''
}
if (!existingOrganizationChecked.value && existingOrganization.value) {
existingOrganization.value = ''
}
await triggerConvertToDeal?.(props.lead, deal.doc, () => (show.value = false))
let _deal = await call('crm.fcrm.doctype.crm_lead.crm_lead.convert_to_deal', {
lead: props.lead.name,
deal: deal.doc,
existing_contact: existingContact.value,
existing_organization: existingOrganization.value,
}).catch((err) => {
if (err.exc_type == 'MandatoryError') {
const errorMessage = err.messages
.map((msg) => {
let arr = msg.split(': ')
return arr[arr.length - 1].trim()
})
.join(', ')
if (errorMessage.toLowerCase().includes('required')) {
error.value = __(errorMessage)
} else {
error.value = __('{0} is required', [errorMessage])
}
return
}
error.value = __('Error converting to deal: {0}', [err.messages?.[0]])
})
if (_deal) {
show.value = false
existingContactChecked.value = false
existingOrganizationChecked.value = false
existingContact.value = ''
existingOrganization.value = ''
error.value = ''
updateOnboardingStep('convert_lead_to_deal', true, false, () => {
localStorage.setItem('firstDeal' + user, _deal)
})
capture('convert_lead_to_deal')
router.push({ name: 'Deal', params: { dealId: _deal } })
}
}
const dealStatuses = computed(() => {
let statuses = statusOptions('deal')
if (!deal.doc?.status) {
deal.doc.status = statuses[0].value
}
return statuses
})
const dealTabs = createResource({
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout',
cache: ['RequiredFields', 'CRM Deal'],
params: { doctype: 'CRM Deal', type: 'Required Fields' },
auto: true,
transform: (_tabs) => {
let hasFields = false
let parsedTabs = _tabs?.forEach((tab) => {
tab.sections?.forEach((section) => {
section.columns?.forEach((column) => {
column.fields?.forEach((field) => {
hasFields = true
if (field.fieldname == 'status') {
field.fieldtype = 'Select'
field.options = dealStatuses.value
field.prefix = getDealStatus(deal.doc.status).color
}
if (field.fieldtype === 'Table') {
deal.doc[field.fieldname] = []
}
})
})
})
})
return hasFields ? parsedTabs : []
},
})
function openQuickEntryModal() {
showQuickEntryModal.value = true
quickEntryProps.value = {
doctype: 'CRM Deal',
onlyRequired: true,
}
show.value = false
}
</script>

View File

@ -98,11 +98,7 @@ const show = defineModel()
const router = useRouter()
const error = ref(null)
const {
document: deal,
triggerOnChange,
triggerOnBeforeCreate,
} = useDocument('CRM Deal')
const { document: deal, triggerOnBeforeCreate } = useDocument('CRM Deal')
const hasOrganizationSections = ref(true)
const hasContactSections = ref(true)
@ -172,7 +168,7 @@ const tabs = createResource({
})
const dealStatuses = computed(() => {
let statuses = statusOptions('deal', null, [], triggerOnChange)
let statuses = statusOptions('deal')
if (!deal.doc.status) {
deal.doc.status = statuses[0].value
}

View File

@ -74,14 +74,10 @@ const router = useRouter()
const error = ref(null)
const isLeadCreating = ref(false)
const {
document: lead,
triggerOnChange,
triggerOnBeforeCreate,
} = useDocument('CRM Lead')
const { document: lead, triggerOnBeforeCreate } = useDocument('CRM Lead')
const leadStatuses = computed(() => {
let statuses = statusOptions('lead', null, [], triggerOnChange)
let statuses = statusOptions('lead')
if (!lead.doc.status) {
lead.doc.status = statuses?.[0]?.value
}

View File

@ -0,0 +1,98 @@
<template>
<Dialog
v-model="show"
:options="{ title: __('Lost reason') }"
@close="cancel"
>
<template #body-content>
<div class="-mt-3 mb-4 text-p-base text-ink-gray-7">
{{ __('Please provide a reason for marking this deal as lost') }}
</div>
<div class="flex flex-col gap-3">
<div>
<div class="mb-2 text-sm text-ink-gray-5">
{{ __('Lost reason') }}
<span class="text-ink-red-2">*</span>
</div>
<Link
class="form-control flex-1 truncate"
:value="lostReason"
doctype="CRM Lost Reason"
@change="(v) => (lostReason = v)"
:onCreate="onCreate"
/>
</div>
<div>
<div class="mb-2 text-sm text-ink-gray-5">
{{ __('Lost notes') }}
<span v-if="lostReason == 'Other'" class="text-ink-red-2">*</span>
</div>
<FormControl
class="form-control flex-1 truncate"
type="textarea"
:value="lostNotes"
@change="(e) => (lostNotes = e.target.value)"
/>
</div>
</div>
</template>
<template #actions>
<div class="flex justify-between items-center gap-2">
<div><ErrorMessage :message="error" /></div>
<div class="flex gap-2">
<Button :label="__('Cancel')" @click="cancel" />
<Button variant="solid" :label="__('Save')" @click="save" />
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import Link from '@/components/Controls/Link.vue'
import { createDocument } from '@/composables/document'
import { Dialog } from 'frappe-ui'
import { ref } from 'vue'
const props = defineProps({
deal: {
type: Object,
required: true,
},
})
const show = defineModel()
const lostReason = ref(props.deal.doc.lost_reason || '')
const lostNotes = ref(props.deal.doc.lost_notes || '')
const error = ref('')
function cancel() {
show.value = false
error.value = ''
lostReason.value = ''
lostNotes.value = ''
props.deal.doc.status = props.deal.originalDoc.status
}
function save() {
if (!lostReason.value) {
error.value = __('Lost reason is required')
return
}
if (lostReason.value === 'Other' && !lostNotes.value) {
error.value = __('Lost notes are required when lost reason is "Other"')
return
}
error.value = ''
show.value = false
props.deal.doc.lost_reason = lostReason.value
props.deal.doc.lost_notes = lostNotes.value
props.deal.save.submit()
}
function onCreate(value, close) {
createDocument('CRM Lost Reason', value, close)
}
</script>

View File

@ -400,7 +400,7 @@ import { getFormat, evaluateDependsOnValue } from '@/utils'
import { flt } from '@/utils/numberFormat.js'
import { Tooltip, DateTimePicker, DatePicker } from 'frappe-ui'
import { useDocument } from '@/data/document'
import { ref, computed } from 'vue'
import { ref, computed, getCurrentInstance } from 'vue'
const props = defineProps({
sections: {
@ -424,7 +424,7 @@ const props = defineProps({
},
})
const emit = defineEmits(['afterFieldChange', 'reload'])
const emit = defineEmits(['beforeFieldChange', 'afterFieldChange', 'reload'])
const { getFormattedPercent, getFormattedFloat, getFormattedCurrency } =
getMeta(props.doctype)
@ -496,18 +496,23 @@ function parsedField(field) {
return _field
}
const instance = getCurrentInstance()
const attrs = instance?.vnode?.props ?? {}
async function fieldChange(value, df) {
if (props.preview) return
await triggerOnChange(df.fieldname, value)
document.save.submit(null, {
onSuccess: () => {
emit('afterFieldChange', {
[df.fieldname]: value,
})
},
})
const hasListener = attrs['onBeforeFieldChange'] !== undefined
if (hasListener) {
emit('beforeFieldChange', { [df.fieldname]: value })
} else {
document.save.submit(null, {
onSuccess: () => emit('afterFieldChange', { [df.fieldname]: value }),
})
}
}
function parsedSection(section, editButtonAdded) {

View File

@ -26,7 +26,10 @@ export function useDocument(doctype, docname) {
let errorMessage = __('Error updating document')
if (err.exc_type == 'MandatoryError') {
const fieldName = err.messages
.map((msg) => msg.split(': ')[2].trim())
.map((msg) => {
let arr = msg.split(': ')
return arr[arr.length - 1].trim()
})
.join(', ')
errorMessage = __('Mandatory field error: {0}', [fieldName])
}
@ -133,8 +136,14 @@ export function useDocument(doctype, docname) {
}
async function triggerOnChange(fieldname, value, row) {
const oldValue = documentsCache[doctype][docname || ''].doc[fieldname]
documentsCache[doctype][docname || ''].doc[fieldname] = value
let oldValue = null
if (row) {
oldValue = row[fieldname]
row[fieldname] = value
} else {
oldValue = documentsCache[doctype][docname || ''].doc[fieldname]
documentsCache[doctype][docname || ''].doc[fieldname] = value
}
const handler = async function () {
this.value = value
@ -148,7 +157,11 @@ export function useDocument(doctype, docname) {
try {
await trigger(handler, row)
} catch (error) {
documentsCache[doctype][docname || ''].doc[fieldname] = oldValue
if (row) {
row[fieldname] = oldValue
} else {
documentsCache[doctype][docname || ''].doc[fieldname] = oldValue
}
console.error(handler)
throw error
}
@ -193,8 +206,7 @@ export function useDocument(doctype, docname) {
async function triggerConvertToDeal() {
const args = Array.from(arguments)
const handler = async function () {
await (this.convertToDeal?.(...args) ||
this.on_convert_to_deal?.(...args))
await (this.convertToDeal?.(...args) || this.convert_to_deal?.(...args))
}
await trigger(handler)
}

View File

@ -26,9 +26,10 @@
:options="
statusOptions(
'deal',
document,
deal.data._customStatuses,
triggerOnChange,
document.statuses?.length
? document.statuses
: deal.data._customStatuses,
triggerStatusChange,
)
"
>
@ -60,6 +61,7 @@
v-model:reload="reload"
v-model:tabIndex="tabIndex"
v-model="deal"
@beforeSave="beforeStatusChange"
@afterSave="reloadAssignees"
/>
</template>
@ -147,6 +149,7 @@
doctype="CRM Deal"
:docname="deal.data.name"
@reload="sections.reload"
@beforeFieldChange="beforeStatusChange"
@afterFieldChange="reloadAssignees"
>
<template #actions="{ section }">
@ -326,6 +329,11 @@
:docname="props.dealId"
name="Deals"
/>
<LostReasonModal
v-if="showLostReasonModal"
v-model="showLostReasonModal"
:deal="document"
/>
</template>
<script setup>
import ErrorPage from '@/components/ErrorPage.vue'
@ -349,6 +357,7 @@ import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue'
import LayoutHeader from '@/components/LayoutHeader.vue'
import Activities from '@/components/Activities/Activities.vue'
import OrganizationModal from '@/components/Modals/OrganizationModal.vue'
import LostReasonModal from '@/components/Modals/LostReasonModal.vue'
import AssignTo from '@/components/AssignTo.vue'
import FilesUploader from '@/components/FilesUploader/FilesUploader.vue'
import ContactModal from '@/components/Modals/ContactModal.vue'
@ -755,6 +764,36 @@ const { assignees, document, triggerOnChange } = useDocument(
props.dealId,
)
async function triggerStatusChange(value) {
await triggerOnChange('status', value)
setLostReason()
}
const showLostReasonModal = ref(false)
function setLostReason() {
if (
document.doc.status !== 'Lost' ||
(document.doc.lost_reason && document.doc.lost_reason !== 'Other') ||
(document.doc.lost_reason === 'Other' && document.doc.lost_notes)
) {
document.save.submit()
return
}
showLostReasonModal.value = true
}
function beforeStatusChange(data) {
if (data?.hasOwnProperty('status') && data.status == 'Lost') {
setLostReason()
} else {
document.save.submit(null, {
onSuccess: () => reloadAssignees(data),
})
}
}
function reloadAssignees(data) {
if (data?.hasOwnProperty('deal_owner')) {
assignees.reload()

View File

@ -26,9 +26,10 @@
:options="
statusOptions(
'lead',
document,
lead.data._customStatuses,
triggerOnChange,
document.statuses?.length
? document.statuses
: lead.data._customStatuses,
triggerStatusChange,
)
"
>
@ -216,108 +217,11 @@
:errorTitle="errorTitle"
:errorMessage="errorMessage"
/>
<Dialog
<ConvertToDealModal
v-if="showConvertToDealModal"
v-model="showConvertToDealModal"
:options="{
size: 'xl',
actions: [
{
label: __('Convert'),
variant: 'solid',
onClick: convertToDeal,
},
],
}"
>
<template #body-header>
<div class="mb-6 flex items-center justify-between">
<div>
<h3 class="text-2xl font-semibold leading-6 text-ink-gray-9">
{{ __('Convert to Deal') }}
</h3>
</div>
<div class="flex items-center gap-1">
<Button
v-if="isManager() && !isMobileView"
variant="ghost"
class="w-7"
@click="openQuickEntryModal"
>
<template #icon>
<EditIcon class="h-4 w-4" />
</template>
</Button>
<Button
variant="ghost"
class="w-7"
@click="showConvertToDealModal = false"
>
<template #icon>
<FeatherIcon name="x" class="h-4 w-4" />
</template>
</Button>
</div>
</div>
</template>
<template #body-content>
<div class="mb-4 flex items-center gap-2 text-ink-gray-5">
<OrganizationsIcon class="h-4 w-4" />
<label class="block text-base">{{ __('Organization') }}</label>
</div>
<div class="ml-6 text-ink-gray-9">
<div class="flex items-center justify-between text-base">
<div>{{ __('Choose Existing') }}</div>
<Switch v-model="existingOrganizationChecked" />
</div>
<Link
v-if="existingOrganizationChecked"
class="form-control mt-2.5"
size="md"
:value="existingOrganization"
doctype="CRM Organization"
@change="(data) => (existingOrganization = data)"
/>
<div v-else class="mt-2.5 text-base">
{{
__(
'New organization will be created based on the data in details section',
)
}}
</div>
</div>
<div class="mb-4 mt-6 flex items-center gap-2 text-ink-gray-5">
<ContactsIcon class="h-4 w-4" />
<label class="block text-base">{{ __('Contact') }}</label>
</div>
<div class="ml-6 text-ink-gray-9">
<div class="flex items-center justify-between text-base">
<div>{{ __('Choose Existing') }}</div>
<Switch v-model="existingContactChecked" />
</div>
<Link
v-if="existingContactChecked"
class="form-control mt-2.5"
size="md"
:value="existingContact"
doctype="Contact"
@change="(data) => (existingContact = data)"
/>
<div v-else class="mt-2.5 text-base">
{{ __("New contact will be created based on the person's details") }}
</div>
</div>
<div v-if="dealTabs.data?.length" class="h-px w-full border-t my-6" />
<FieldLayout
v-if="dealTabs.data?.length"
:tabs="dealTabs.data"
:data="deal"
doctype="CRM Deal"
/>
</template>
</Dialog>
:lead="lead.data"
/>
<FilesUploader
v-if="lead.data?.name"
v-model="showFilesUploader"
@ -354,40 +258,28 @@ import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
import CameraIcon from '@/components/Icons/CameraIcon.vue'
import LinkIcon from '@/components/Icons/LinkIcon.vue'
import OrganizationsIcon from '@/components/Icons/OrganizationsIcon.vue'
import ContactsIcon from '@/components/Icons/ContactsIcon.vue'
import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue'
import EditIcon from '@/components/Icons/EditIcon.vue'
import LayoutHeader from '@/components/LayoutHeader.vue'
import Activities from '@/components/Activities/Activities.vue'
import AssignTo from '@/components/AssignTo.vue'
import FilesUploader from '@/components/FilesUploader/FilesUploader.vue'
import Link from '@/components/Controls/Link.vue'
import SidePanelLayout from '@/components/SidePanelLayout.vue'
import FieldLayout from '@/components/FieldLayout/FieldLayout.vue'
import SLASection from '@/components/SLASection.vue'
import CustomActions from '@/components/CustomActions.vue'
import ConvertToDealModal from '@/components/Modals/ConvertToDealModal.vue'
import {
openWebsite,
setupCustomizations,
copyToClipboard,
validateIsImageFile,
} from '@/utils'
import { showQuickEntryModal, quickEntryProps } from '@/composables/modals'
import { getView } from '@/utils/view'
import { getSettings } from '@/stores/settings'
import { sessionStore } from '@/stores/session'
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,
isMobileView,
} from '@/composables/settings'
import { capture } from '@/telemetry'
import { whatsappEnabled, callEnabled } from '@/composables/settings'
import {
createResource,
FileUploader,
@ -395,26 +287,20 @@ import {
Tooltip,
Avatar,
Tabs,
Switch,
Breadcrumbs,
call,
usePageMeta,
toast,
} from 'frappe-ui'
import { useOnboarding } from 'frappe-ui/frappe'
import { ref, reactive, computed, onMounted, watch, nextTick } from 'vue'
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useActiveTabManager } from '@/composables/useActiveTabManager'
const { brand } = getSettings()
const { user } = sessionStore()
const { isManager } = usersStore()
const { $dialog, $socket, makeCall } = globalStore()
const { statusOptions, getLeadStatus, getDealStatus } = statusesStore()
const { statusOptions, getLeadStatus } = statusesStore()
const { doctypeMeta } = getMeta('CRM Lead')
const { updateOnboardingStep } = useOnboarding('frappecrm')
const route = useRoute()
const router = useRouter()
@ -428,6 +314,17 @@ const props = defineProps({
const errorTitle = ref('')
const errorMessage = ref('')
const showDeleteLinkedDocModal = ref(false)
const showConvertToDealModal = ref(false)
const { triggerOnChange, assignees, document } = useDocument(
'CRM Lead',
props.leadId,
)
async function triggerStatusChange(value) {
await triggerOnChange('status', value)
document.save.submit()
}
const lead = createResource({
url: 'crm.fcrm.doctype.crm_lead.api.get_lead',
@ -630,64 +527,6 @@ async function deleteLeadWithModal(name) {
showDeleteLinkedDocModal.value = true
}
// Convert to Deal
const showConvertToDealModal = ref(false)
const existingContactChecked = ref(false)
const existingOrganizationChecked = ref(false)
const existingContact = ref('')
const existingOrganization = ref('')
const { triggerConvertToDeal, triggerOnChange, assignees, document } =
useDocument('CRM Lead', props.leadId)
async function convertToDeal() {
if (existingContactChecked.value && !existingContact.value) {
toast.error(__('Please select an existing contact'))
return
}
if (existingOrganizationChecked.value && !existingOrganization.value) {
toast.error(__('Please select an existing organization'))
return
}
if (!existingContactChecked.value && existingContact.value) {
existingContact.value = ''
}
if (!existingOrganizationChecked.value && existingOrganization.value) {
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,
existing_contact: existingContact.value,
existing_organization: existingOrganization.value,
}).catch((err) => {
toast.error(__('Error converting to deal: {0}', [err.messages?.[0]]))
})
if (_deal) {
showConvertToDealModal.value = false
existingContactChecked.value = false
existingOrganizationChecked.value = false
existingContact.value = ''
existingOrganization.value = ''
updateOnboardingStep('convert_lead_to_deal', true, false, () => {
localStorage.setItem('firstDeal' + user, _deal)
})
capture('convert_lead_to_deal')
router.push({ name: 'Deal', params: { dealId: _deal } })
}
}
const activities = ref(null)
function openEmailBox() {
@ -698,54 +537,6 @@ function openEmailBox() {
nextTick(() => (activities.value.emailBox.show = true))
}
const deal = reactive({})
const dealStatuses = computed(() => {
let statuses = statusOptions('deal')
if (!deal.status) {
deal.status = statuses[0].value
}
return statuses
})
const dealTabs = createResource({
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout',
cache: ['RequiredFields', 'CRM Deal'],
params: { doctype: 'CRM Deal', type: 'Required Fields' },
auto: true,
transform: (_tabs) => {
let hasFields = false
let parsedTabs = _tabs?.forEach((tab) => {
tab.sections?.forEach((section) => {
section.columns?.forEach((column) => {
column.fields?.forEach((field) => {
hasFields = true
if (field.fieldname == 'status') {
field.fieldtype = 'Select'
field.options = dealStatuses.value
field.prefix = getDealStatus(deal.status).color
}
if (field.fieldtype === 'Table') {
deal[field.fieldname] = []
}
})
})
})
})
return hasFields ? parsedTabs : []
},
})
function openQuickEntryModal() {
showQuickEntryModal.value = true
quickEntryProps.value = {
doctype: 'CRM Deal',
onlyRequired: true,
}
showConvertToDealModal.value = false
}
function reloadAssignees(data) {
if (data?.hasOwnProperty('lead_owner')) {
assignees.reload()

View File

@ -14,9 +14,10 @@
:options="
statusOptions(
'deal',
document,
deal.data._customStatuses,
triggerOnChange,
document.statuses?.length
? document.statuses
: deal.data._customStatuses,
triggerStatusChange,
)
"
>
@ -78,6 +79,7 @@
doctype="CRM Deal"
:docname="deal.data.name"
@reload="sections.reload"
@beforeFieldChange="beforeStatusChange"
@afterFieldChange="reloadAssignees"
>
<template #actions="{ section }">
@ -222,6 +224,8 @@
v-model:reload="reload"
v-model:tabIndex="tabIndex"
v-model="deal"
@beforeSave="beforeStatusChange"
@afterSave="reloadAssignees"
/>
</TabPanel>
</Tabs>
@ -244,6 +248,11 @@
afterInsert: (doc) => addContact(doc.name),
}"
/>
<LostReasonModal
v-if="showLostReasonModal"
v-model="showLostReasonModal"
:deal="document"
/>
</template>
<script setup>
import Icon from '@/components/Icon.vue'
@ -264,6 +273,7 @@ import SuccessIcon from '@/components/Icons/SuccessIcon.vue'
import LayoutHeader from '@/components/LayoutHeader.vue'
import Activities from '@/components/Activities/Activities.vue'
import OrganizationModal from '@/components/Modals/OrganizationModal.vue'
import LostReasonModal from '@/components/Modals/LostReasonModal.vue'
import AssignTo from '@/components/AssignTo.vue'
import ContactModal from '@/components/Modals/ContactModal.vue'
import Section from '@/components/Section.vue'
@ -624,6 +634,36 @@ const { assignees, document, triggerOnChange } = useDocument(
props.dealId,
)
async function triggerStatusChange(value) {
await triggerOnChange('status', value)
setLostReason()
}
const showLostReasonModal = ref(false)
function setLostReason() {
if (
document.doc.status !== 'Lost' ||
(document.doc.lost_reason && document.doc.lost_reason !== 'Other') ||
(document.doc.lost_reason === 'Other' && document.doc.lost_notes)
) {
document.save.submit()
return
}
showLostReasonModal.value = true
}
function beforeStatusChange(data) {
if (data?.hasOwnProperty('status') && data.status == 'Lost') {
setLostReason()
} else {
document.save.submit(null, {
onSuccess: () => reloadAssignees(data),
})
}
}
function reloadAssignees(data) {
if (data?.hasOwnProperty('deal_owner')) {
assignees.reload()

View File

@ -14,9 +14,10 @@
:options="
statusOptions(
'lead',
document,
lead.data._customStatuses,
triggerOnChange,
document.statuses?.length
? document.statuses
: lead.data._customStatuses,
triggerStatusChange,
)
"
>
@ -473,6 +474,11 @@ const { assignees, document, triggerOnChange } = useDocument(
props.leadId,
)
async function triggerStatusChange(value) {
await triggerOnChange('status', value)
document.save.submit()
}
function reloadAssignees(data) {
if (data?.hasOwnProperty('lead_owner')) {
assignees.reload()

View File

@ -77,19 +77,10 @@ export const statusesStore = defineStore('crm-statuses', () => {
return communicationStatuses[name]
}
function statusOptions(
doctype,
document,
statuses = [],
triggerOnChange = null,
) {
function statusOptions(doctype, statuses = [], triggerStatusChange = null) {
let statusesByName =
doctype == 'deal' ? dealStatusesByName : leadStatusesByName
if (document?.statuses?.length) {
statuses = document.statuses
}
if (statuses?.length) {
statusesByName = statuses.reduce((acc, status) => {
acc[status] = statusesByName[status]
@ -104,11 +95,8 @@ export const statusesStore = defineStore('crm-statuses', () => {
value: statusesByName[status]?.name,
icon: () => h(IndicatorIcon, { class: statusesByName[status]?.color }),
onClick: async () => {
await triggerStatusChange?.(statusesByName[status]?.name)
capture('status_changed', { doctype, status })
if (document) {
await triggerOnChange?.('status', statusesByName[status]?.name)
document.save.submit()
}
},
})
}

View File

@ -85,7 +85,7 @@ export function prettyDate(date, mini = false) {
let nowDatetime = dayjs().tz(localTimezone || systemTimezone)
let diff = nowDatetime.diff(date, 'seconds')
let dayDiff = Math.floor(diff / 86400)
let dayDiff = diff / 86400
if (isNaN(dayDiff)) return ''
@ -93,18 +93,18 @@ export function prettyDate(date, mini = false) {
// Return short format of time difference
if (dayDiff < 0) {
if (Math.abs(dayDiff) < 1) {
if (diff < 60) {
if (Math.abs(diff) < 60) {
return __('now')
} else if (diff < 3600) {
return __('in {0} m', [Math.floor(diff / 60)])
} else if (diff < 86400) {
return __('in {0} h', [Math.floor(diff / 3600)])
} else if (Math.abs(diff) < 3600) {
return __('in {0} m', [Math.floor(Math.abs(diff) / 60)])
} else if (Math.abs(diff) < 86400) {
return __('in {0} h', [Math.floor(Math.abs(diff) / 3600)])
}
}
if (Math.abs(dayDiff) == 1) {
if (Math.abs(dayDiff) >= 1 && Math.abs(dayDiff) < 1.5) {
return __('tomorrow')
} else if (Math.abs(dayDiff) < 7) {
return __('in {0} d', [Math.abs(dayDiff)])
return __('in {0} d', [Math.floor(Math.abs(dayDiff))])
} else if (Math.abs(dayDiff) < 31) {
return __('in {0} w', [Math.floor(Math.abs(dayDiff) / 7)])
} else if (Math.abs(dayDiff) < 365) {
@ -112,7 +112,7 @@ export function prettyDate(date, mini = false) {
} else {
return __('in {0} y', [Math.floor(Math.abs(dayDiff) / 365)])
}
} else if (dayDiff == 0) {
} else if (dayDiff >= 0 && dayDiff < 1) {
if (diff < 60) {
return __('now')
} else if (diff < 3600) {
@ -121,6 +121,7 @@ export function prettyDate(date, mini = false) {
return __('{0} h', [Math.floor(diff / 3600)])
}
} else {
dayDiff = Math.floor(dayDiff)
if (dayDiff < 7) {
return __('{0} d', [dayDiff])
} else if (dayDiff < 31) {
@ -135,22 +136,22 @@ export function prettyDate(date, mini = false) {
// Return long format of time difference
if (dayDiff < 0) {
if (Math.abs(dayDiff) < 1) {
if (diff < 60) {
if (Math.abs(diff) < 60) {
return __('just now')
} else if (diff < 120) {
} else if (Math.abs(diff) < 120) {
return __('in 1 minute')
} else if (diff < 3600) {
return __('in {0} minutes', [Math.floor(diff / 60)])
} else if (diff < 7200) {
} else if (Math.abs(diff) < 3600) {
return __('in {0} minutes', [Math.floor(Math.abs(diff) / 60)])
} else if (Math.abs(diff) < 7200) {
return __('in 1 hour')
} else if (diff < 86400) {
return __('in {0} hours', [Math.floor(diff / 3600)])
} else if (Math.abs(diff) < 86400) {
return __('in {0} hours', [Math.floor(Math.abs(diff) / 3600)])
}
}
if (Math.abs(dayDiff) == 1) {
if (Math.abs(dayDiff) >= 1 && Math.abs(dayDiff) < 1.5) {
return __('tomorrow')
} else if (Math.abs(dayDiff) < 7) {
return __('in {0} days', [Math.abs(dayDiff)])
return __('in {0} days', [Math.floor(Math.abs(dayDiff))])
} else if (Math.abs(dayDiff) < 31) {
return __('in {0} weeks', [Math.floor(Math.abs(dayDiff) / 7)])
} else if (Math.abs(dayDiff) < 365) {
@ -160,7 +161,7 @@ export function prettyDate(date, mini = false) {
} else {
return __('in {0} years', [Math.floor(Math.abs(dayDiff) / 365)])
}
} else if (dayDiff == 0) {
} else if (dayDiff >= 0 && dayDiff < 1) {
if (diff < 60) {
return __('just now')
} else if (diff < 120) {
@ -173,6 +174,7 @@ export function prettyDate(date, mini = false) {
return __('{0} hours ago', [Math.floor(diff / 3600)])
}
} else {
dayDiff = Math.floor(dayDiff)
if (dayDiff == 1) {
return __('yesterday')
} else if (dayDiff < 7) {

View File

@ -1562,7 +1562,7 @@
dependencies:
"@types/node" "*"
"@types/trusted-types@^2.0.2":
"@types/trusted-types@^2.0.2", "@types/trusted-types@^2.0.7":
version "2.0.7"
resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11"
integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==
@ -2094,11 +2094,6 @@ commander@^4.0.0:
resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068"
integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==
commander@^9.0.0:
version "9.5.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-9.5.0.tgz#bc08d1eb5cedf7ccb797a96199d41c7bc3e60d30"
integrity sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==
common-tags@^1.8.0:
version "1.8.2"
resolved "https://registry.yarnpkg.com/common-tags/-/common-tags-1.8.2.tgz#94ebb3c076d26032745fd54face7f688ef5ac9c6"
@ -2273,6 +2268,13 @@ dlv@^1.1.3:
resolved "https://registry.yarnpkg.com/dlv/-/dlv-1.1.3.tgz#5c198a8a11453596e751494d49874bc7732f2e79"
integrity sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==
dompurify@^3.2.6:
version "3.2.6"
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.2.6.tgz#ca040a6ad2b88e2a92dc45f38c79f84a714a1cad"
integrity sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==
optionalDependencies:
"@types/trusted-types" "^2.0.7"
dunder-proto@^1.0.0, dunder-proto@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a"
@ -2570,10 +2572,10 @@ fraction.js@^4.3.7:
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7"
integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==
frappe-ui@^0.1.162:
version "0.1.162"
resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.162.tgz#01a2f06e9db70b1bce6e0b0f2089a9cc1cb8dd51"
integrity sha512-LdlEQ1I8oMj2TAmx0FGuJl+AwQ6/jqtwEy3lei3mH6SVArfGnoVDqLm8aeJTwAB6KUjgCj+ffWe6vN7HmZXIcg==
frappe-ui@^0.1.166:
version "0.1.166"
resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.166.tgz#2664d9373b4751a39144c283be67f219c5eb99e3"
integrity sha512-VSv2OE/JHa4ReOW0/9SafRzvQ6Dkxa1Bz6u58UU8FvagqpJVorQJlm2854LXuCk1IDV+uulPCr7uxiC8kwcjFw==
dependencies:
"@floating-ui/vue" "^1.1.6"
"@headlessui/vue" "^1.7.14"
@ -2603,12 +2605,14 @@ frappe-ui@^0.1.162:
"@tiptap/vue-3" "^2.0.3"
"@vueuse/core" "^10.4.1"
dayjs "^1.11.13"
dompurify "^3.2.6"
echarts "^5.6.0"
feather-icons "^4.28.0"
highlight.js "^11.11.1"
idb-keyval "^6.2.0"
lowlight "^3.3.0"
lucide-static "^0.479.0"
marked "^15.0.12"
ora "5.4.1"
prettier "^3.3.2"
prosemirror-model "^1.25.1"
@ -2616,7 +2620,6 @@ frappe-ui@^0.1.162:
prosemirror-view "^1.39.2"
radix-vue "^1.5.3"
reka-ui "^2.0.2"
showdown "^2.1.0"
socket.io-client "^4.5.1"
tippy.js "^6.3.7"
typescript "^5.0.2"
@ -3305,6 +3308,11 @@ markdown-it@^14.0.0:
punycode.js "^2.3.1"
uc.micro "^2.1.0"
marked@^15.0.12:
version "15.0.12"
resolved "https://registry.yarnpkg.com/marked/-/marked-15.0.12.tgz#30722c7346e12d0a2d0207ab9b0c4f0102d86c4e"
integrity sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==
math-intrinsics@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"
@ -4146,13 +4154,6 @@ shebang-regex@^3.0.0:
resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172"
integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
showdown@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/showdown/-/showdown-2.1.0.tgz#1251f5ed8f773f0c0c7bfc8e6fd23581f9e545c5"
integrity sha512-/6NVYu4U819R2pUIk79n67SYgJHWCce0a5xTP979WbNp0FL9MN1I1QK662IDU1b6JzKTvmhgI7T7JYIxBi3kMQ==
dependencies:
commander "^9.0.0"
side-channel-list@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad"