Compare commits

..

13 Commits

Author SHA1 Message Date
frappe-pr-bot
e70b4c091e chore: update POT file 2025-04-06 09:35:28 +00:00
Pratik Badhe
7e38d5e405
Merge pull request #707 from pratikb64/kanban-filter-fix
fix: kanban filter
2025-04-04 17:14:46 +05:30
Pratik
f810e82b45 fix: kanban filter 2025-04-04 17:07:54 +05:30
Pratik Badhe
dff9f93a6b
Merge pull request #704 from pratikb64/make-fields-mandatory
fix: add mandatory fields
2025-04-04 10:26:29 +05:30
Shariq Ansari
c4109ad6ac build(deps): bump frappeui to 0.1.123 2025-04-04 10:09:18 +05:30
Pratik
7a6efb900e fix: add mandatory fields 2025-04-01 17:26:46 +05:30
Pratik Badhe
82599f91d8
Merge pull request #698 from pratikb64/email-settings-fix
fix: ui alignment
2025-03-28 15:34:36 +05:30
Pratik
8fa156f625 fix: ui alignment 2025-03-28 15:33:40 +05:30
Pratik Badhe
55112cefa9
Merge pull request #697 from pratikb64/email-setting-fix
fix: broken images
2025-03-27 17:38:28 +05:30
Pratik
152c7c8a91 fix: broken images 2025-03-27 17:37:39 +05:30
Pratik Badhe
aa1c0da80e
Merge pull request #696 from pratikb64/add-email-setting
feat: add email account
2025-03-27 15:33:20 +05:30
Pratik
87174f207d feat: add email account 2025-03-27 15:32:37 +05:30
Shariq Ansari
400f879d29 fix: only allow invite by email for Sales Manager & Sales User role 2025-03-26 14:44:40 +05:30
286 changed files with 21202 additions and 135102 deletions

1
.gitignore vendored
View File

@ -7,5 +7,6 @@ dev-dist
tags tags
node_modules node_modules
crm/public/frontend crm/public/frontend
frontend/yarn.lock
crm/www/crm.html crm/www/crm.html
build build

View File

@ -8,7 +8,7 @@
**Simplify Sales, Amplify Relationships** **Simplify Sales, Amplify Relationships**
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/frappe/crm)](https://github.com/frappe/crm/releases) ![GitHub release (latest by date)](https://img.shields.io/github/v/release/frappe/crm)
<div> <div>
<picture> <picture>
@ -84,14 +84,6 @@ The motivation behind building Frappe CRM stems from the need for a simple, cust
- [Frappe Framework](https://github.com/frappe/frappe): A full-stack web application framework. - [Frappe Framework](https://github.com/frappe/frappe): A full-stack web application framework.
- [Frappe UI](https://github.com/frappe/frappe-ui): A Vue-based UI library, to provide a modern user interface. - [Frappe UI](https://github.com/frappe/frappe-ui): A Vue-based UI library, to provide a modern user interface.
### Compatibility
This app is compatible with the following versions of Frappe and ERPNext:
| CRM branch | Stability | Frappe branch | ERPNext branch |
| :-------------------- | :-------- | :------------------- | :------------------- |
| main - v1.x | stable | v15.x | v15.x |
| develop - future/v2.x | unstable | develop - future/v16 | develop - future/v16 |
## Getting Started (Production) ## Getting Started (Production)
### Managed Hosting ### Managed Hosting
@ -189,7 +181,6 @@ You need Docker, docker-compose and git setup on your machine. Refer [Docker doc
- [Discuss Forum](https://discuss.frappe.io/c/frappe-crm) - [Discuss Forum](https://discuss.frappe.io/c/frappe-crm)
- [Documentation](https://docs.frappe.io/crm) - [Documentation](https://docs.frappe.io/crm)
- [YouTube](https://www.youtube.com/channel/UCn3bV5kx77HsVwtnlCeEi_A) - [YouTube](https://www.youtube.com/channel/UCn3bV5kx77HsVwtnlCeEi_A)
- [X/Twitter](https://x.com/frappetech)
<br> <br>
<br> <br>

View File

@ -1,4 +1,4 @@
__version__ = "1.53.1" __version__ = "2.0.0-dev"
__title__ = "Frappe CRM" __title__ = "Frappe CRM"

View File

@ -1,10 +1,9 @@
import frappe
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from frappe.config import get_modules_from_all_apps_for_user import frappe
from frappe.core.api.file import get_max_file_size
from frappe.translate import get_all_translations from frappe.translate import get_all_translations
from frappe.utils import cstr, split_emails, validate_email_address from frappe.utils import validate_email_address, split_emails, cstr
from frappe.utils.telemetry import POSTHOG_HOST_FIELD, POSTHOG_PROJECT_FIELD from frappe.utils.telemetry import POSTHOG_HOST_FIELD, POSTHOG_PROJECT_FIELD
from frappe.core.api.file import get_max_file_size
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)
@ -64,14 +63,9 @@ def check_app_permission():
if frappe.session.user == "Administrator": if frappe.session.user == "Administrator":
return True return True
allowed_modules = get_modules_from_all_apps_for_user()
allowed_modules = [x["module_name"] for x in allowed_modules]
if "FCRM" not in allowed_modules:
return False
roles = frappe.get_roles() roles = frappe.get_roles()
if any( if any(
role in ["System Manager", "Sales User", "Sales Manager"] for role in roles role in ["System Manager", "Sales User", "Sales Manager", "Sales Master Manager"] for role in roles
): ):
return True return True
@ -99,13 +93,14 @@ def accept_invitation(key: str | None = None):
@frappe.whitelist() @frappe.whitelist()
def invite_by_email(emails: str, role: str): def invite_by_email(emails: str, role: str):
frappe.only_for(["Sales Manager", "System Manager"]) frappe.only_for("Sales Manager")
if role not in ["System Manager", "Sales Manager", "Sales User"]: if role not in ["Sales Manager", "Sales User"]:
frappe.throw("Cannot invite for this role") frappe.throw("Cannot invite for this role")
if not emails: if not emails:
return return
email_string = validate_email_address(emails, throw=False) email_string = validate_email_address(emails, throw=False)
email_list = split_emails(email_string) email_list = split_emails(email_string)
if not email_list: if not email_list:
@ -113,10 +108,7 @@ def invite_by_email(emails: str, role: str):
existing_members = frappe.db.get_all("User", filters={"email": ["in", email_list]}, pluck="email") existing_members = frappe.db.get_all("User", filters={"email": ["in", email_list]}, pluck="email")
existing_invites = frappe.db.get_all( existing_invites = frappe.db.get_all(
"CRM Invitation", "CRM Invitation",
filters={ filters={"email": ["in", email_list], "role": ["in", ["Sales Manager", "Sales User"]]},
"email": ["in", email_list],
"role": ["in", ["System Manager", "Sales Manager", "Sales User"]],
},
pluck="email", pluck="email",
) )
@ -125,12 +117,6 @@ def invite_by_email(emails: str, role: str):
for email in to_invite: for email in to_invite:
frappe.get_doc(doctype="CRM Invitation", email=email, role=role).insert(ignore_permissions=True) frappe.get_doc(doctype="CRM Invitation", email=email, role=role).insert(ignore_permissions=True)
return {
"existing_members": existing_members,
"existing_invites": existing_invites,
"to_invite": to_invite,
}
@frappe.whitelist() @frappe.whitelist()
def get_file_uploader_defaults(doctype: str): def get_file_uploader_defaults(doctype: str):

View File

@ -124,7 +124,6 @@ def get_deal_activities(name):
activity = { activity = {
"activity_type": "communication", "activity_type": "communication",
"communication_type": communication.communication_type, "communication_type": communication.communication_type,
"communication_date": communication.communication_date or communication.creation,
"creation": communication.creation, "creation": communication.creation,
"data": { "data": {
"subject": communication.subject, "subject": communication.subject,
@ -256,7 +255,6 @@ def get_lead_activities(name):
activity = { activity = {
"activity_type": "communication", "activity_type": "communication",
"communication_type": communication.communication_type, "communication_type": communication.communication_type,
"communication_date": communication.communication_date or communication.creation,
"creation": communication.creation, "creation": communication.creation,
"data": { "data": {
"subject": communication.subject, "subject": communication.subject,

View File

@ -1,32 +0,0 @@
import frappe
@frappe.whitelist()
def get_assignment_rules_list():
assignment_rules = []
for docname in frappe.get_all(
"Assignment Rule", filters={"document_type": ["in", ["CRM Lead", "CRM Deal"]]}
):
doc = frappe.get_value(
"Assignment Rule",
docname,
fieldname=[
"name",
"description",
"disabled",
"priority",
],
as_dict=True,
)
users_exists = bool(frappe.db.exists("Assignment Rule User", {"parent": docname.name}))
assignment_rules.append({**doc, "users_exists": users_exists})
return assignment_rules
@frappe.whitelist()
def duplicate_assignment_rule(docname, new_name):
doc = frappe.get_doc("Assignment Rule", docname)
doc.name = new_name
doc.assignment_rule_name = new_name
doc.insert()
return doc

View File

@ -14,16 +14,32 @@ def update_deals_email_mobile_no(doc):
) )
for linked_deal in linked_deals: for linked_deal in linked_deals:
deal = frappe.db.get_values("CRM Deal", linked_deal.parent, ["email", "mobile_no"], as_dict=True)[0] deal = frappe.get_cached_doc("CRM Deal", linked_deal.parent)
if deal.email != doc.email_id or deal.mobile_no != doc.mobile_no: if deal.email != doc.email_id or deal.mobile_no != doc.mobile_no:
frappe.db.set_value( deal.email = doc.email_id
"CRM Deal", deal.mobile_no = doc.mobile_no
linked_deal.parent, deal.save(ignore_permissions=True)
{
"email": doc.email_id,
"mobile_no": doc.mobile_no, @frappe.whitelist()
}, def get_contact(name):
) Contact = frappe.qb.DocType("Contact")
query = frappe.qb.from_(Contact).select("*").where(Contact.name == name).limit(1)
contact = query.run(as_dict=True)
if not len(contact):
frappe.throw(_("Contact not found"), frappe.DoesNotExistError)
contact = contact.pop()
contact["doctype"] = "Contact"
contact["email_ids"] = frappe.get_all(
"Contact Email", filters={"parent": name}, fields=["name", "email_id", "is_primary"]
)
contact["phone_nos"] = frappe.get_all(
"Contact Phone", filters={"parent": name}, fields=["name", "phone", "is_primary_mobile_no"]
)
return contact
@frappe.whitelist() @frappe.whitelist()

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,6 @@ import json
import frappe import frappe
from frappe import _ from frappe import _
from frappe.custom.doctype.property_setter.property_setter import make_property_setter from frappe.custom.doctype.property_setter.property_setter import make_property_setter
from frappe.desk.form.assign_to import set_status
from frappe.model import no_value_fields from frappe.model import no_value_fields
from frappe.model.document import get_controller from frappe.model.document import get_controller
from frappe.utils import make_filter_tuple from frappe.utils import make_filter_tuple
@ -11,7 +10,6 @@ from pypika import Criterion
from crm.api.views import get_views from crm.api.views import get_views
from crm.fcrm.doctype.crm_form_script.crm_form_script import get_form_script from crm.fcrm.doctype.crm_form_script.crm_form_script import get_form_script
from crm.utils import get_dynamic_linked_docs, get_linked_docs
@frappe.whitelist() @frappe.whitelist()
@ -420,16 +418,23 @@ def get_data(
rows.append(field) rows.append(field)
for kc in kanban_columns: for kc in kanban_columns:
column_filters = {column_field: kc.get("name")} # Start with base filters
column_filters = []
# Convert and add the main filters first
if filters:
base_filters = convert_filter_to_tuple(doctype, filters)
column_filters.extend(base_filters)
# Add the column-specific filter
if column_field and kc.get("name"):
column_filters.append([doctype, column_field, "=", kc.get("name")])
order = kc.get("order") order = kc.get("order")
if (column_field in filters and filters.get(column_field) != kc.get("name")) or kc.get("delete"): if kc.get("delete"):
column_data = [] column_data = []
else: else:
column_filters.update(filters.copy()) page_length = kc.get("page_length", 20)
page_length = 20
if kc.get("page_length"):
page_length = kc.get("page_length")
if order: if order:
column_data = get_records_based_on_order( column_data = get_records_based_on_order(
@ -439,26 +444,20 @@ def get_data(
column_data = frappe.get_list( column_data = frappe.get_list(
doctype, doctype,
fields=rows, fields=rows,
filters=convert_filter_to_tuple(doctype, column_filters), filters=column_filters,
order_by=order_by, order_by=order_by,
page_length=page_length, page_length=page_length,
) )
new_filters = filters.copy()
new_filters.update({column_field: kc.get("name")})
all_count = frappe.get_list( all_count = frappe.get_list(
doctype, doctype,
filters=convert_filter_to_tuple(doctype, new_filters), filters=column_filters,
fields="count(*) as total_count", fields="count(*) as total_count",
)[0].total_count )[0].total_count
kc["all_count"] = all_count kc["all_count"] = all_count
kc["count"] = len(column_data) kc["count"] = len(column_data)
for d in column_data:
getCounts(d, doctype)
if order: if order:
column_data = sorted( column_data = sorted(
column_data, column_data,
@ -660,25 +659,6 @@ def get_fields_meta(doctype, restricted_fieldtypes=None, as_array=False, only_re
return fields_meta return fields_meta
@frappe.whitelist()
def remove_assignments(doctype, name, assignees, ignore_permissions=False):
assignees = frappe.parse_json(assignees)
if not assignees:
return
for assign_to in assignees:
set_status(
doctype,
name,
todo=None,
assign_to=assign_to,
status="Cancelled",
ignore_permissions=ignore_permissions,
)
@frappe.whitelist()
def get_assigned_users(doctype, name, default_assigned_to=None): def get_assigned_users(doctype, name, default_assigned_to=None):
assigned_users = frappe.get_all( assigned_users = frappe.get_all(
"ToDo", "ToDo",
@ -746,165 +726,3 @@ def getCounts(d, doctype):
"FCRM Note", filters={"reference_doctype": doctype, "reference_docname": d.get("name")} "FCRM Note", filters={"reference_doctype": doctype, "reference_docname": d.get("name")}
) )
return d return d
@frappe.whitelist()
def get_linked_docs_of_document(doctype, docname):
try:
doc = frappe.get_doc(doctype, docname)
except frappe.DoesNotExistError:
return []
linked_docs = get_linked_docs(doc)
dynamic_linked_docs = get_dynamic_linked_docs(doc)
linked_docs.extend(dynamic_linked_docs)
linked_docs = list({doc["reference_docname"]: doc for doc in linked_docs}.values())
docs_data = []
for doc in linked_docs:
if not doc.get("reference_doctype") or not doc.get("reference_docname"):
continue
try:
data = frappe.get_doc(doc["reference_doctype"], doc["reference_docname"])
except (frappe.DoesNotExistError, frappe.ValidationError):
continue
title = data.get("title")
if data.doctype == "CRM Call Log":
title = f"Call from {data.get('from')} to {data.get('to')}"
if data.doctype == "CRM Deal":
title = data.get("organization")
if data.doctype == "CRM Notification":
title = data.get("message")
docs_data.append(
{
"doc": data.doctype,
"title": title or data.get("name"),
"reference_docname": doc["reference_docname"],
"reference_doctype": doc["reference_doctype"],
}
)
return docs_data
def remove_doc_link(doctype, docname):
if not doctype or not docname:
return
try:
linked_doc_data = frappe.get_doc(doctype, docname)
if doctype == "CRM Notification":
delete_notification_type = {
"notification_type_doctype": "",
"notification_type_doc": "",
}
delete_references = {
"reference_doctype": "",
"reference_name": "",
}
if linked_doc_data.get("notification_type_doctype") == linked_doc_data.get("reference_doctype"):
delete_references.update(delete_notification_type)
linked_doc_data.update(delete_references)
else:
linked_doc_data.update(
{
"reference_doctype": "",
"reference_docname": "",
}
)
linked_doc_data.save(ignore_permissions=True)
except (frappe.DoesNotExistError, frappe.ValidationError):
pass
def remove_contact_link(doctype, docname):
if not doctype or not docname:
return
try:
linked_doc_data = frappe.get_doc(doctype, docname)
linked_doc_data.update(
{
"contact": None,
"contacts": [],
}
)
linked_doc_data.save(ignore_permissions=True)
except (frappe.DoesNotExistError, frappe.ValidationError):
pass
@frappe.whitelist()
def remove_linked_doc_reference(items, remove_contact=None, delete=False):
if isinstance(items, str):
items = frappe.parse_json(items)
for item in items:
if not item.get("doctype") or not item.get("docname"):
continue
try:
if remove_contact:
remove_contact_link(item["doctype"], item["docname"])
else:
remove_doc_link(item["doctype"], item["docname"])
if delete:
frappe.delete_doc(item["doctype"], item["docname"])
except (frappe.DoesNotExistError, frappe.ValidationError):
# Skip if document doesn't exist or has validation errors
continue
return "success"
@frappe.whitelist()
def delete_bulk_docs(doctype, items, delete_linked=False):
from frappe.desk.reportview import delete_bulk
if not doctype:
frappe.throw("Doctype is required")
if not items:
frappe.throw("Items are required")
items = frappe.parse_json(items)
if not isinstance(items, list):
frappe.throw("Items must be a list")
for doc in items:
try:
if not frappe.db.exists(doctype, doc):
frappe.log_error(f"Document {doctype} {doc} does not exist", "Bulk Delete Error")
continue
linked_docs = get_linked_docs_of_document(doctype, doc)
for linked_doc in linked_docs:
if not linked_doc.get("reference_doctype") or not linked_doc.get("reference_docname"):
continue
remove_linked_doc_reference(
[
{
"doctype": linked_doc["reference_doctype"],
"docname": linked_doc["reference_docname"],
}
],
remove_contact=doctype == "Contact",
delete=delete_linked,
)
except Exception as e:
frappe.log_error(
f"Error processing linked docs for {doctype} {doc}: {str(e)}", "Bulk Delete Error"
)
if len(items) > 10:
frappe.enqueue("frappe.desk.reportview.delete_bulk", doctype=doctype, items=items)
else:
delete_bulk(doctype, items)
return "success"

View File

@ -23,32 +23,11 @@ def get_users():
if frappe.session.user == user.name: if frappe.session.user == user.name:
user.session_user = True user.session_user = True
user.roles = frappe.get_roles(user.name) user.is_manager = "Sales Manager" in frappe.get_roles(user.name) or user.name == "Administrator"
user.role = "" user.is_agent = frappe.db.exists("CRM Telephony Agent", {"user": user.name})
if "System Manager" in user.roles: return users
user.role = "System Manager"
elif "Sales Manager" in user.roles:
user.role = "Sales Manager"
elif "Sales User" in user.roles:
user.role = "Sales User"
elif "Guest" in user.roles:
user.role = "Guest"
if frappe.session.user == user.name:
user.session_user = True
user.is_telephony_agent = frappe.db.exists("CRM Telephony Agent", {"user": user.name})
crm_users = []
# crm users are users with role Sales User or Sales Manager
for user in users:
if "Sales User" in user.roles or "Sales Manager" in user.roles:
crm_users.append(user)
return users, crm_users
@frappe.whitelist() @frappe.whitelist()

View File

@ -1,79 +1,90 @@
import frappe import frappe
from frappe import _ from frappe import _
from crm.fcrm.doctype.crm_notification.crm_notification import notify_user from crm.fcrm.doctype.crm_notification.crm_notification import notify_user
def after_insert(doc, method): def after_insert(doc, method):
if doc.reference_type in ["CRM Lead", "CRM Deal"] and doc.reference_name and doc.allocated_to: if (
fieldname = "lead_owner" if doc.reference_type == "CRM Lead" else "deal_owner" doc.reference_type in ["CRM Lead", "CRM Deal"]
owner = frappe.db.get_value(doc.reference_type, doc.reference_name, fieldname) and doc.reference_name
if not owner: and doc.allocated_to
frappe.db.set_value( ):
doc.reference_type, doc.reference_name, fieldname, doc.allocated_to, update_modified=False fieldname = "lead_owner" if doc.reference_type == "CRM Lead" else "deal_owner"
) lead_owner = frappe.db.get_value(
doc.reference_type, doc.reference_name, fieldname
)
if not lead_owner:
frappe.db.set_value(
doc.reference_type, doc.reference_name, fieldname, doc.allocated_to
)
if doc.reference_type in ["CRM Lead", "CRM Deal", "CRM Task"] and doc.reference_name and doc.allocated_to: if (
notify_assigned_user(doc) doc.reference_type in ["CRM Lead", "CRM Deal", "CRM Task"]
and doc.reference_name
and doc.allocated_to
):
notify_assigned_user(doc)
def on_update(doc, method): def on_update(doc, method):
if ( if (
doc.has_value_changed("status") doc.has_value_changed("status")
and doc.status == "Cancelled" and doc.status == "Cancelled"
and doc.reference_type in ["CRM Lead", "CRM Deal", "CRM Task"] and doc.reference_type in ["CRM Lead", "CRM Deal", "CRM Task"]
and doc.reference_name and doc.reference_name
and doc.allocated_to and doc.allocated_to
): ):
notify_assigned_user(doc, is_cancelled=True) notify_assigned_user(doc, is_cancelled=True)
def notify_assigned_user(doc, is_cancelled=False): def notify_assigned_user(doc, is_cancelled=False):
_doc = frappe.get_doc(doc.reference_type, doc.reference_name) _doc = frappe.get_doc(doc.reference_type, doc.reference_name)
owner = frappe.get_cached_value("User", frappe.session.user, "full_name") owner = frappe.get_cached_value("User", frappe.session.user, "full_name")
notification_text = get_notification_text(owner, doc, _doc, is_cancelled) notification_text = get_notification_text(owner, doc, _doc, is_cancelled)
message = ( message = (
_("Your assignment on {0} {1} has been removed by {2}").format( _("Your assignment on {0} {1} has been removed by {2}").format(
doc.reference_type, doc.reference_name, owner doc.reference_type, doc.reference_name, owner
) )
if is_cancelled if is_cancelled
else _("{0} assigned a {1} {2} to you").format(owner, doc.reference_type, doc.reference_name) else _("{0} assigned a {1} {2} to you").format(
) owner, doc.reference_type, doc.reference_name
)
)
redirect_to_doctype, redirect_to_name = get_redirect_to_doc(doc) redirect_to_doctype, redirect_to_name = get_redirect_to_doc(doc)
notify_user( notify_user(
{ {
"owner": frappe.session.user, "owner": frappe.session.user,
"assigned_to": doc.allocated_to, "assigned_to": doc.allocated_to,
"notification_type": "Assignment", "notification_type": "Assignment",
"message": message, "message": message,
"notification_text": notification_text, "notification_text": notification_text,
"reference_doctype": doc.reference_type, "reference_doctype": doc.reference_type,
"reference_docname": doc.reference_name, "reference_docname": doc.reference_name,
"redirect_to_doctype": redirect_to_doctype, "redirect_to_doctype": redirect_to_doctype,
"redirect_to_docname": redirect_to_name, "redirect_to_docname": redirect_to_name,
} }
) )
def get_notification_text(owner, doc, reference_doc, is_cancelled=False): def get_notification_text(owner, doc, reference_doc, is_cancelled=False):
name = doc.reference_name name = doc.reference_name
doctype = doc.reference_type doctype = doc.reference_type
if doctype.startswith("CRM "): if doctype.startswith("CRM "):
doctype = doctype[4:].lower() doctype = doctype[4:].lower()
if doctype in ["lead", "deal"]: if doctype in ["lead", "deal"]:
name = ( name = (
reference_doc.lead_name or name reference_doc.lead_name or name
if doctype == "lead" if doctype == "lead"
else reference_doc.organization or reference_doc.lead_name or name else reference_doc.organization or reference_doc.lead_name or name
) )
if is_cancelled: if is_cancelled:
return f""" return f"""
<div class="mb-2 leading-5 text-ink-gray-5"> <div class="mb-2 leading-5 text-ink-gray-5">
<span>{ _('Your assignment on {0} {1} has been removed by {2}').format( <span>{ _('Your assignment on {0} {1} has been removed by {2}').format(
doctype, doctype,
@ -83,7 +94,7 @@ def get_notification_text(owner, doc, reference_doc, is_cancelled=False):
</div> </div>
""" """
return f""" return f"""
<div class="mb-2 leading-5 text-ink-gray-5"> <div class="mb-2 leading-5 text-ink-gray-5">
<span class="font-medium text-ink-gray-9">{ owner }</span> <span class="font-medium text-ink-gray-9">{ owner }</span>
<span>{ _('assigned a {0} {1} to you').format( <span>{ _('assigned a {0} {1} to you').format(
@ -93,9 +104,9 @@ def get_notification_text(owner, doc, reference_doc, is_cancelled=False):
</div> </div>
""" """
if doctype == "task": if doctype == "task":
if is_cancelled: if is_cancelled:
return f""" return f"""
<div class="mb-2 leading-5 text-ink-gray-5"> <div class="mb-2 leading-5 text-ink-gray-5">
<span>{ _('Your assignment on task {0} has been removed by {1}').format( <span>{ _('Your assignment on task {0} has been removed by {1}').format(
f'<span class="font-medium text-ink-gray-9">{ reference_doc.title }</span>', f'<span class="font-medium text-ink-gray-9">{ reference_doc.title }</span>',
@ -103,7 +114,7 @@ def get_notification_text(owner, doc, reference_doc, is_cancelled=False):
) }</span> ) }</span>
</div> </div>
""" """
return f""" return f"""
<div class="mb-2 leading-5 text-ink-gray-5"> <div class="mb-2 leading-5 text-ink-gray-5">
<span class="font-medium text-ink-gray-9">{ owner }</span> <span class="font-medium text-ink-gray-9">{ owner }</span>
<span>{ _('assigned a new task {0} to you').format( <span>{ _('assigned a new task {0} to you').format(
@ -114,8 +125,8 @@ def get_notification_text(owner, doc, reference_doc, is_cancelled=False):
def get_redirect_to_doc(doc): def get_redirect_to_doc(doc):
if doc.reference_type == "CRM Task": if doc.reference_type == "CRM Task":
reference_doc = frappe.get_doc(doc.reference_type, doc.reference_name) reference_doc = frappe.get_doc(doc.reference_type, doc.reference_name)
return reference_doc.reference_doctype, reference_doc.reference_docname return reference_doc.reference_doctype, reference_doc.reference_docname
return doc.reference_type, doc.reference_name return doc.reference_type, doc.reference_name

View File

@ -1,84 +0,0 @@
import frappe
@frappe.whitelist()
def add_existing_users(users, role="Sales User"):
"""
Add existing users to the CRM by assigning them a role (Sales User or Sales Manager).
:param users: List of user names to be added
"""
frappe.only_for(["System Manager", "Sales Manager"])
users = frappe.parse_json(users)
for user in users:
add_user(user, role)
@frappe.whitelist()
def update_user_role(user, new_role):
"""
Update the role of the user to Sales Manager, Sales User, or System Manager.
:param user: The name of the user
:param new_role: The new role to assign (Sales Manager or Sales User)
"""
frappe.only_for(["System Manager", "Sales Manager"])
if new_role not in ["System Manager", "Sales Manager", "Sales User"]:
frappe.throw("Cannot assign this role")
user_doc = frappe.get_doc("User", user)
if new_role == "System Manager":
user_doc.append_roles("System Manager", "Sales Manager", "Sales User")
user_doc.set("block_modules", [])
if new_role == "Sales Manager":
user_doc.append_roles("Sales Manager", "Sales User")
user_doc.remove_roles("System Manager")
if new_role == "Sales User":
user_doc.append_roles("Sales User")
user_doc.remove_roles("Sales Manager", "System Manager")
update_module_in_user(user_doc, "FCRM")
user_doc.save(ignore_permissions=True)
@frappe.whitelist()
def add_user(user, role):
"""
Add a user means adding role (Sales User or/and Sales Manager) to the user.
:param user: The name of the user to be added
:param role: The role to be assigned (Sales User or Sales Manager)
"""
update_user_role(user, role)
@frappe.whitelist()
def remove_user(user):
"""
Remove a user means removing Sales User & Sales Manager roles from the user.
:param user: The name of the user to be removed
"""
frappe.only_for(["System Manager", "Sales Manager"])
user_doc = frappe.get_doc("User", user)
roles = [d.role for d in user_doc.roles]
if "Sales User" in roles:
user_doc.remove_roles("Sales User")
if "Sales Manager" in roles:
user_doc.remove_roles("Sales Manager")
user_doc.save(ignore_permissions=True)
frappe.msgprint(f"User {user} has been removed from CRM roles.")
def update_module_in_user(user, module):
block_modules = frappe.get_all(
"Module Def",
fields=["name as module"],
filters={"name": ["!=", module]},
)
if block_modules:
user.set("block_modules", block_modules)

View File

@ -10,15 +10,8 @@ from crm.fcrm.doctype.crm_notification.crm_notification import notify_user
def validate(doc, method): def validate(doc, method):
if doc.type == "Incoming" and doc.get("from"): if doc.type == "Incoming" and doc.get("from"):
name, doctype = get_lead_or_deal_from_number(doc.get("from")) name, doctype = get_lead_or_deal_from_number(doc.get("from"))
if name != None: doc.reference_doctype = doctype
doc.reference_doctype = doctype doc.reference_name = name
doc.reference_name = name
if doc.type == "Outgoing" and doc.get("to"):
name, doctype = get_lead_or_deal_from_number(doc.get("to"))
if name != None:
doc.reference_doctype = doctype
doc.reference_name = name
def on_update(doc, method): def on_update(doc, method):
@ -36,7 +29,7 @@ def on_update(doc, method):
def notify_agent(doc): def notify_agent(doc):
if doc.type == "Incoming": if doc.type == "Incoming":
doctype = doc.reference_doctype doctype = doc.reference_doctype
if doctype and doctype.startswith("CRM "): if doctype.startswith("CRM "):
doctype = doctype[4:].lower() doctype = doctype[4:].lower()
notification_text = f""" notification_text = f"""
<div class="mb-2 leading-5 text-ink-gray-5"> <div class="mb-2 leading-5 text-ink-gray-5">
@ -342,5 +335,5 @@ def get_from_name(message):
else: else:
from_name = doc.get("lead_name") from_name = doc.get("lead_name")
else: else:
from_name = " ".join(filter(None, [doc.get("first_name"), doc.get("last_name")])) from_name = doc.get("first_name") + " " + doc.get("last_name")
return from_name return from_name

View File

@ -190,20 +190,11 @@ def get_call_log(name):
@frappe.whitelist() @frappe.whitelist()
def create_lead_from_call_log(call_log, lead_details=None): def create_lead_from_call_log(call_log):
lead = frappe.new_doc("CRM Lead") lead = frappe.new_doc("CRM Lead")
lead_details = frappe.parse_json(lead_details or "{}") lead.first_name = "Lead from call " + call_log.get("from")
lead.mobile_no = call_log.get("from")
if not lead_details.get("lead_owner"): lead.lead_owner = frappe.session.user
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) lead.save(ignore_permissions=True)
# link call log with lead # link call log with lead

View File

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

View File

@ -1,105 +0,0 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "field:title",
"creation": "2025-07-14 12:19:49.725022",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"title",
"private",
"column_break_exbw",
"user",
"section_break_hfza",
"layout"
],
"fields": [
{
"fieldname": "column_break_exbw",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_hfza",
"fieldtype": "Section Break"
},
{
"default": "[]",
"fieldname": "layout",
"fieldtype": "Code",
"label": "Layout",
"options": "JSON"
},
{
"fieldname": "title",
"fieldtype": "Data",
"label": "Name",
"unique": 1
},
{
"depends_on": "private",
"fieldname": "user",
"fieldtype": "Link",
"label": "User",
"mandatory_depends_on": "private",
"options": "User"
},
{
"default": "0",
"fieldname": "private",
"fieldtype": "Check",
"label": "Private"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-07-14 12:36:10.831351",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Dashboard",
"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
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "title"
}

View File

@ -1,34 +0,0 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from frappe import _
from frappe.model.document import Document
class CRMDashboard(Document):
pass
def default_manager_dashboard_layout():
"""
Returns the default layout for the CRM Manager Dashboard.
"""
return '[{"name":"total_leads","type":"number_chart","tooltip":"Total number of leads","layout":{"x":0,"y":0,"w":4,"h":3,"i":"total_leads"}},{"name":"ongoing_deals","type":"number_chart","tooltip":"Total number of ongoing deals","layout":{"x":8,"y":0,"w":4,"h":3,"i":"ongoing_deals"}},{"name":"won_deals","type":"number_chart","tooltip":"Total number of won deals","layout":{"x":12,"y":0,"w":4,"h":3,"i":"won_deals"}},{"name":"average_won_deal_value","type":"number_chart","tooltip":"Average value of won deals","layout":{"x":16,"y":0,"w":4,"h":3,"i":"average_won_deal_value"}},{"name":"average_deal_value","type":"number_chart","tooltip":"Average deal value of ongoing and won deals","layout":{"x":0,"y":2,"w":4,"h":3,"i":"average_deal_value"}},{"name":"average_time_to_close_a_lead","type":"number_chart","tooltip":"Average time taken to close a lead","layout":{"x":4,"y":0,"w":4,"h":3,"i":"average_time_to_close_a_lead"}},{"name":"average_time_to_close_a_deal","type":"number_chart","layout":{"x":4,"y":2,"w":4,"h":3,"i":"average_time_to_close_a_deal"}},{"name":"spacer","type":"spacer","layout":{"x":8,"y":2,"w":12,"h":3,"i":"spacer"}},{"name":"sales_trend","type":"axis_chart","layout":{"x":0,"y":4,"w":10,"h":9,"i":"sales_trend"}},{"name":"forecasted_revenue","type":"axis_chart","layout":{"x":10,"y":4,"w":10,"h":9,"i":"forecasted_revenue"}},{"name":"funnel_conversion","type":"axis_chart","layout":{"x":0,"y":11,"w":10,"h":9,"i":"funnel_conversion"}},{"name":"deals_by_stage_donut","type":"donut_chart","layout":{"x":10,"y":11,"w":10,"h":9,"i":"deals_by_stage_donut"}},{"name":"lost_deal_reasons","type":"axis_chart","layout":{"x":0,"y":32,"w":20,"h":9,"i":"lost_deal_reasons"}},{"name":"leads_by_source","type":"donut_chart","layout":{"x":0,"y":18,"w":10,"h":9,"i":"leads_by_source"}},{"name":"deals_by_source","type":"donut_chart","layout":{"x":10,"y":18,"w":10,"h":9,"i":"deals_by_source"}},{"name":"deals_by_territory","type":"axis_chart","layout":{"x":0,"y":25,"w":10,"h":9,"i":"deals_by_territory"}},{"name":"deals_by_salesperson","type":"axis_chart","layout":{"x":10,"y":25,"w":10,"h":9,"i":"deals_by_salesperson"}}]'
def create_default_manager_dashboard(force=False):
"""
Creates the default CRM Manager Dashboard if it does not exist.
"""
if not frappe.db.exists("CRM Dashboard", "Manager Dashboard"):
doc = frappe.new_doc("CRM Dashboard")
doc.title = "Manager Dashboard"
doc.layout = default_manager_dashboard_layout()
doc.insert(ignore_permissions=True)
else:
doc = frappe.get_doc("CRM Dashboard", "Manager Dashboard")
if force:
doc.layout = default_manager_dashboard_layout()
doc.save(ignore_permissions=True)
return doc.layout

View File

@ -1,30 +0,0 @@
# 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 UnitTestCRMDashboard(UnitTestCase):
"""
Unit tests for CRMDashboard.
Use this class for testing individual functions and methods.
"""
pass
class IntegrationTestCRMDashboard(IntegrationTestCase):
"""
Integration tests for CRMDashboard.
Use this class for testing interactions between multiple components.
"""
pass

View File

@ -1,5 +1,18 @@
import frappe import frappe
from crm.api.doc import get_assigned_users, get_fields_meta
from crm.fcrm.doctype.crm_form_script.crm_form_script import get_form_script
@frappe.whitelist()
def get_deal(name):
deal = frappe.get_doc("CRM Deal", name).as_dict()
deal["fields_meta"] = get_fields_meta("CRM Deal")
deal["_form_script"] = get_form_script("CRM Deal")
deal["_assign"] = get_assigned_users("CRM Deal", deal.name)
return deal
@frappe.whitelist() @frappe.whitelist()
def get_deal_contacts(name): def get_deal_contacts(name):
@ -17,12 +30,24 @@ def get_deal_contacts(name):
is_primary = contact.is_primary is_primary = contact.is_primary
contact = frappe.get_doc("Contact", contact.contact).as_dict() contact = frappe.get_doc("Contact", contact.contact).as_dict()
def get_primary_email(contact):
for email in contact.email_ids:
if email.is_primary:
return email.email_id
return contact.email_ids[0].email_id if contact.email_ids else ""
def get_primary_mobile_no(contact):
for phone in contact.phone_nos:
if phone.is_primary:
return phone.phone
return contact.phone_nos[0].phone if contact.phone_nos else ""
_contact = { _contact = {
"name": contact.name, "name": contact.name,
"image": contact.image, "image": contact.image,
"full_name": contact.full_name, "full_name": contact.full_name,
"email": contact.email_id, "email": get_primary_email(contact),
"mobile_no": contact.mobile_no, "mobile_no": get_primary_mobile_no(contact),
"is_primary": is_primary, "is_primary": is_primary,
} }
deal_contacts.append(_contact) deal_contacts.append(_contact)

View File

@ -5,68 +5,4 @@ frappe.ui.form.on("CRM Deal", {
refresh(frm) { refresh(frm) {
frm.add_web_link(`/crm/deals/${frm.doc.name}`, __("Open in Portal")); frm.add_web_link(`/crm/deals/${frm.doc.name}`, __("Open in Portal"));
}, },
update_total: function (frm) {
let total = 0;
let total_qty = 0;
let net_total = 0;
frm.doc.products.forEach((d) => {
total += d.amount;
total_qty += d.qty;
net_total += d.net_amount;
});
frappe.model.set_value(frm.doctype, frm.docname, "total", total);
frappe.model.set_value(
frm.doctype,
frm.docname,
"net_total",
net_total || total
);
}
});
frappe.ui.form.on("CRM Products", {
products_add: function (frm, cdt, cdn) {
frm.trigger("update_total");
},
products_remove: function (frm, cdt, cdn) {
frm.trigger("update_total");
},
product_code: function (frm, cdt, cdn) {
let d = frappe.get_doc(cdt, cdn);
frappe.model.set_value(cdt, cdn, "product_name", d.product_code);
},
rate: function (frm, cdt, cdn) {
let d = frappe.get_doc(cdt, cdn);
if (d.rate && d.qty) {
frappe.model.set_value(cdt, cdn, "amount", d.rate * d.qty);
}
frm.trigger("update_total");
},
qty: function (frm, cdt, cdn) {
let d = frappe.get_doc(cdt, cdn);
if (d.rate && d.qty) {
frappe.model.set_value(cdt, cdn, "amount", d.rate * d.qty);
}
frm.trigger("update_total");
},
discount_percentage: function (frm, cdt, cdn) {
let d = frappe.get_doc(cdt, cdn);
if (d.discount_percentage && d.amount) {
discount_amount = (d.discount_percentage / 100) * d.amount;
frappe.model.set_value(
cdt,
cdn,
"discount_amount",
discount_amount
);
frappe.model.set_value(
cdt,
cdn,
"net_amount",
d.amount - discount_amount
);
}
frm.trigger("update_total");
}
}); });

View File

@ -11,18 +11,11 @@
"naming_series", "naming_series",
"organization", "organization",
"next_step", "next_step",
"probability",
"column_break_ijan", "column_break_ijan",
"status", "status",
"close_date",
"deal_owner", "deal_owner",
"lost_reason",
"lost_notes",
"section_break_jgpm",
"probability",
"expected_deal_value",
"deal_value",
"column_break_kpxa",
"expected_closure_date",
"closed_date",
"contacts_tab", "contacts_tab",
"contacts", "contacts",
"contact", "contact",
@ -39,7 +32,6 @@
"column_break_xbyf", "column_break_xbyf",
"territory", "territory",
"currency", "currency",
"exchange_rate",
"annual_revenue", "annual_revenue",
"industry", "industry",
"person_section", "person_section",
@ -51,12 +43,6 @@
"mobile_no", "mobile_no",
"phone", "phone",
"gender", "gender",
"products_tab",
"products",
"section_break_ccbj",
"total",
"column_break_udbq",
"net_total",
"sla_tab", "sla_tab",
"sla", "sla",
"sla_creation", "sla_creation",
@ -96,6 +82,11 @@
"fieldtype": "Data", "fieldtype": "Data",
"label": "Website" "label": "Website"
}, },
{
"fieldname": "close_date",
"fieldtype": "Date",
"label": "Close Date"
},
{ {
"fieldname": "next_step", "fieldname": "next_step",
"fieldtype": "Data", "fieldtype": "Data",
@ -128,13 +119,13 @@
{ {
"fieldname": "email", "fieldname": "email",
"fieldtype": "Data", "fieldtype": "Data",
"label": "Primary Email", "label": "Email",
"options": "Email" "options": "Email"
}, },
{ {
"fieldname": "mobile_no", "fieldname": "mobile_no",
"fieldtype": "Data", "fieldtype": "Data",
"label": "Primary Mobile No", "label": "Mobile No",
"options": "Phone" "options": "Phone"
}, },
{ {
@ -248,7 +239,7 @@
{ {
"fieldname": "phone", "fieldname": "phone",
"fieldtype": "Data", "fieldtype": "Data",
"label": "Primary Phone", "label": "Phone",
"options": "Phone" "options": "Phone"
}, },
{ {
@ -343,96 +334,11 @@
"fieldtype": "Link", "fieldtype": "Link",
"label": "Currency", "label": "Currency",
"options": "Currency" "options": "Currency"
},
{
"fieldname": "products_tab",
"fieldtype": "Tab Break",
"label": "Products"
},
{
"fieldname": "products",
"fieldtype": "Table",
"label": "Products",
"options": "CRM Products"
},
{
"fieldname": "section_break_ccbj",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_udbq",
"fieldtype": "Column Break"
},
{
"fieldname": "total",
"fieldtype": "Currency",
"label": "Total",
"options": "currency",
"read_only": 1
},
{
"description": "Total after discount",
"fieldname": "net_total",
"fieldtype": "Currency",
"label": "Net Total",
"options": "currency",
"read_only": 1
},
{
"fieldname": "section_break_jgpm",
"fieldtype": "Section Break"
},
{
"fieldname": "deal_value",
"fieldtype": "Currency",
"label": "Deal Value",
"options": "currency"
},
{
"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\""
},
{
"default": "1",
"description": "The rate used to convert the deal\u2019s currency to your crm's base currency (set in CRM Settings). It is set once when the currency is first added and doesn't change automatically.",
"fieldname": "exchange_rate",
"fieldtype": "Float",
"label": "Exchange Rate"
},
{
"fieldname": "expected_deal_value",
"fieldtype": "Currency",
"label": "Expected Deal Value",
"options": "currency"
},
{
"fieldname": "expected_closure_date",
"fieldtype": "Date",
"label": "Expected Closure Date"
},
{
"fieldname": "closed_date",
"fieldtype": "Date",
"label": "Closed Date"
} }
], ],
"grid_page_length": 50,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2025-08-26 12:12:56.324245", "modified": "2024-12-11 14:31:41.058895",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "FCRM", "module": "FCRM",
"name": "CRM Deal", "name": "CRM Deal",
@ -464,11 +370,10 @@
"write": 1 "write": 1
} }
], ],
"row_format": "Dynamic",
"show_title_field_in_link": 1, "show_title_field_in_link": 1,
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [], "states": [],
"title_field": "organization", "title_field": "organization",
"track_changes": 1 "track_changes": 1
} }

View File

@ -7,8 +7,9 @@ from frappe.desk.form.assign_to import add as assign
from frappe.model.document import Document from frappe.model.document import Document
from crm.fcrm.doctype.crm_service_level_agreement.utils import get_sla from crm.fcrm.doctype.crm_service_level_agreement.utils import get_sla
from crm.fcrm.doctype.crm_status_change_log.crm_status_change_log import add_status_change_log from crm.fcrm.doctype.crm_status_change_log.crm_status_change_log import (
from crm.fcrm.doctype.fcrm_settings.fcrm_settings import get_exchange_rate add_status_change_log,
)
class CRMDeal(Document): class CRMDeal(Document):
@ -23,11 +24,6 @@ class CRMDeal(Document):
self.assign_agent(self.deal_owner) self.assign_agent(self.deal_owner)
if self.has_value_changed("status"): if self.has_value_changed("status"):
add_status_change_log(self) add_status_change_log(self)
if frappe.db.get_value("CRM Deal Status", self.status, "type") == "Won":
self.closed_date = frappe.utils.nowdate()
self.validate_forecasting_fields()
self.validate_lost_reason()
self.update_exchange_rate()
def after_insert(self): def after_insert(self):
if self.deal_owner: if self.deal_owner:
@ -137,60 +133,6 @@ class CRMDeal(Document):
if sla: if sla:
sla.apply(self) sla.apply(self)
def update_closed_date(self):
"""
Update the closed date based on the "Won" status.
"""
if self.status == "Won" and not self.closed_date:
self.closed_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 update_expected_deal_value(self):
"""
Update the expected deal value based on the net total or total.
"""
if (
frappe.db.get_single_value("FCRM Settings", "auto_update_expected_deal_value")
and (self.net_total or self.total)
and self.expected_deal_value
):
self.expected_deal_value = self.net_total or self.total
def validate_forecasting_fields(self):
self.update_closed_date()
self.update_default_probability()
self.update_expected_deal_value()
if frappe.db.get_single_value("FCRM Settings", "enable_forecasting"):
if not self.expected_deal_value or self.expected_deal_value == 0:
frappe.throw(_("Expected Deal Value is required."), frappe.MandatoryError)
if not self.expected_closure_date:
frappe.throw(_("Expected Closure Date is required."), frappe.MandatoryError)
def validate_lost_reason(self):
"""
Validate the lost reason if the status is set to "Lost".
"""
if self.status and frappe.get_cached_value("CRM Deal Status", self.status, "type") == "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)
def update_exchange_rate(self):
if self.has_value_changed("currency") or not self.exchange_rate:
system_currency = frappe.db.get_single_value("FCRM Settings", "currency") or "USD"
exchange_rate = 1
if self.currency and self.currency != system_currency:
exchange_rate = get_exchange_rate(self.currency, system_currency)
self.db_set("exchange_rate", exchange_rate)
@staticmethod @staticmethod
def default_list_data(): def default_list_data():
columns = [ columns = [

View File

@ -7,11 +7,8 @@
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"deal_status", "deal_status",
"type", "color",
"position", "position"
"column_break_ojiu",
"probability",
"color"
], ],
"fields": [ "fields": [
{ {
@ -35,30 +32,11 @@
"fieldtype": "Int", "fieldtype": "Int",
"in_list_view": 1, "in_list_view": 1,
"label": "Position" "label": "Position"
},
{
"fieldname": "probability",
"fieldtype": "Percent",
"in_list_view": 1,
"label": "Probability"
},
{
"default": "Open",
"fieldname": "type",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Type",
"options": "Open\nOngoing\nOn Hold\nWon\nLost"
},
{
"fieldname": "column_break_ojiu",
"fieldtype": "Column Break"
} }
], ],
"grid_page_length": 50,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2025-07-11 16:03:28.077955", "modified": "2024-01-19 21:56:44.552134",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "FCRM", "module": "FCRM",
"name": "CRM Deal Status", "name": "CRM Deal Status",
@ -90,8 +68,7 @@
"write": 1 "write": 1
} }
], ],
"row_format": "Dynamic",
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [] "states": []
} }

View File

@ -27,9 +27,7 @@ def get_fields_layout(doctype: str, type: str, parent_doctype: str | None = None
if not tabs and type != "Required Fields": if not tabs and type != "Required Fields":
tabs = get_default_layout(doctype) tabs = get_default_layout(doctype)
has_tabs = False has_tabs = tabs[0].get("sections") if tabs and tabs[0] else False
if isinstance(tabs, list) and len(tabs) > 0 and isinstance(tabs[0], dict):
has_tabs = any("sections" in tab for tab in tabs)
if not has_tabs: if not has_tabs:
tabs = [{"name": "first_tab", "sections": tabs}] tabs = [{"name": "first_tab", "sections": tabs}]
@ -47,19 +45,9 @@ def get_fields_layout(doctype: str, type: str, parent_doctype: str | None = None
fields = frappe.get_meta(doctype).fields fields = frappe.get_meta(doctype).fields
fields = [field for field in fields if field.fieldname in allowed_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 tab in tabs:
for section in tab.get("sections"): for section in tab.get("sections"):
if section.get("columns"):
section["columns"] = [column for column in section.get("columns") if column]
for column in section.get("columns") if section.get("columns") else []: for column in section.get("columns") if section.get("columns") else []:
column["fields"] = [field for field in column.get("fields") if field]
for field in column.get("fields") if column.get("fields") else []: for field in column.get("fields") if column.get("fields") else []:
field = next((f for f in fields if f.fieldname == field), None) field = next((f for f in fields if f.fieldname == field), None)
if field: if field:
@ -67,32 +55,6 @@ def get_fields_layout(doctype: str, type: str, parent_doctype: str | None = None
handle_perm_level_restrictions(field, doctype, parent_doctype) handle_perm_level_restrictions(field, doctype, parent_doctype)
column["fields"][column.get("fields").index(field["fieldname"])] = field 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 [] return tabs or []
@ -116,8 +78,6 @@ def get_sidepanel_sections(doctype):
fields = frappe.get_meta(doctype).fields fields = frappe.get_meta(doctype).fields
fields = [field for field in fields if field.fieldtype not in not_allowed_fieldtypes] fields = [field for field in fields if field.fieldtype not in not_allowed_fieldtypes]
add_forecasting_section(layout, doctype)
for section in layout: for section in layout:
section["name"] = section.get("name") or section.get("label") section["name"] = section.get("name") or section.get("label")
for column in section.get("columns") if section.get("columns") else []: for column in section.get("columns") if section.get("columns") else []:
@ -135,38 +95,6 @@ def get_sidepanel_sections(doctype):
return layout 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": ["expected_closure_date", "probability", "expected_deal_value"],
}
],
},
)
def handle_perm_level_restrictions(field, doctype, parent_doctype=None): def handle_perm_level_restrictions(field, doctype, parent_doctype=None):
if field.permlevel == 0: if field.permlevel == 0:
return return

View File

@ -65,7 +65,7 @@
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2025-05-19 17:57:24.610295", "modified": "2024-09-16 19:40:19.340948",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "FCRM", "module": "FCRM",
"name": "CRM Form Script", "name": "CRM Form Script",
@ -83,19 +83,9 @@
"role": "Sales Manager", "role": "Sales Manager",
"share": 1, "share": 1,
"write": 1 "write": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "All",
"share": 1
} }
], ],
"row_format": "Dynamic",
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [] "states": []
} }

View File

@ -27,7 +27,7 @@
"fieldtype": "Select", "fieldtype": "Select",
"in_list_view": 1, "in_list_view": 1,
"label": "Role", "label": "Role",
"options": "\nSales User\nSales Manager\nSystem Manager", "options": "\nSales User\nSales Manager",
"reqd": 1 "reqd": 1
}, },
{ {
@ -66,7 +66,7 @@
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2025-06-17 17:20:18.935395", "modified": "2024-09-03 14:59:29.450018",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "FCRM", "module": "FCRM",
"name": "CRM Invitation", "name": "CRM Invitation",
@ -106,8 +106,7 @@
"share": 1 "share": 1
} }
], ],
"row_format": "Dynamic",
"sort_field": "creation", "sort_field": "creation",
"sort_order": "DESC", "sort_order": "DESC",
"states": [] "states": []
} }

View File

@ -21,7 +21,7 @@ class CRMInvitation(Document):
if frappe.local.dev_server: if frappe.local.dev_server:
print(f"Invite link for {self.email}: {invite_link}") print(f"Invite link for {self.email}: {invite_link}")
title = "Frappe CRM" title = f"Frappe CRM"
template = "crm_invitation" template = "crm_invitation"
frappe.sendmail( frappe.sendmail(
@ -35,7 +35,7 @@ class CRMInvitation(Document):
@frappe.whitelist() @frappe.whitelist()
def accept_invitation(self): def accept_invitation(self):
frappe.only_for(["System Manager", "Sales Manager"]) frappe.only_for("System Manager")
self.accept() self.accept()
def accept(self): def accept(self):
@ -44,28 +44,12 @@ class CRMInvitation(Document):
user = self.create_user_if_not_exists() user = self.create_user_if_not_exists()
user.append_roles(self.role) user.append_roles(self.role)
if self.role == "System Manager":
user.append_roles("Sales Manager", "Sales User")
elif self.role == "Sales Manager":
user.append_roles("Sales User")
if self.role == "Sales User":
self.update_module_in_user(user, "FCRM")
user.save(ignore_permissions=True) user.save(ignore_permissions=True)
self.status = "Accepted" self.status = "Accepted"
self.accepted_at = frappe.utils.now() self.accepted_at = frappe.utils.now()
self.save(ignore_permissions=True) self.save(ignore_permissions=True)
def update_module_in_user(self, user, module):
block_modules = frappe.get_all(
"Module Def",
fields=["name as module"],
filters={"name": ["!=", module]},
)
if block_modules:
user.set("block_modules", block_modules)
def create_user_if_not_exists(self): def create_user_if_not_exists(self):
if not frappe.db.exists("User", self.email): if not frappe.db.exists("User", self.email):
first_name = self.email.split("@")[0].title() first_name = self.email.split("@")[0].title()

View File

@ -0,0 +1,14 @@
import frappe
from crm.api.doc import get_assigned_users, get_fields_meta
from crm.fcrm.doctype.crm_form_script.crm_form_script import get_form_script
@frappe.whitelist()
def get_lead(name):
lead = frappe.get_doc("CRM Lead", name).as_dict()
lead["fields_meta"] = get_fields_meta("CRM Lead")
lead["_form_script"] = get_form_script("CRM Lead")
lead["_assign"] = get_assigned_users("CRM Lead", lead.name)
return lead

View File

@ -5,68 +5,4 @@ frappe.ui.form.on("CRM Lead", {
refresh(frm) { refresh(frm) {
frm.add_web_link(`/crm/leads/${frm.doc.name}`, __("Open in Portal")); frm.add_web_link(`/crm/leads/${frm.doc.name}`, __("Open in Portal"));
}, },
update_total: function (frm) {
let total = 0;
let total_qty = 0;
let net_total = 0;
frm.doc.products.forEach((d) => {
total += d.amount;
total_qty += d.qty;
net_total += d.net_amount;
});
frappe.model.set_value(frm.doctype, frm.docname, "total", total);
frappe.model.set_value(
frm.doctype,
frm.docname,
"net_total",
net_total || total
);
}
}); });
frappe.ui.form.on("CRM Products", {
products_add: function (frm, cdt, cdn) {
frm.trigger("update_total");
},
products_remove: function (frm, cdt, cdn) {
frm.trigger("update_total");
},
product_code: function (frm, cdt, cdn) {
let d = frappe.get_doc(cdt, cdn);
frappe.model.set_value(cdt, cdn, "product_name", d.product_code);
},
rate: function (frm, cdt, cdn) {
let d = frappe.get_doc(cdt, cdn);
if (d.rate && d.qty) {
frappe.model.set_value(cdt, cdn, "amount", d.rate * d.qty);
}
frm.trigger("update_total");
},
qty: function (frm, cdt, cdn) {
let d = frappe.get_doc(cdt, cdn);
if (d.rate && d.qty) {
frappe.model.set_value(cdt, cdn, "amount", d.rate * d.qty);
}
frm.trigger("update_total");
},
discount_percentage: function (frm, cdt, cdn) {
let d = frappe.get_doc(cdt, cdn);
if (d.discount_percentage && d.amount) {
discount_amount = (d.discount_percentage / 100) * d.amount;
frappe.model.set_value(
cdt,
cdn,
"discount_amount",
discount_amount
);
frappe.model.set_value(
cdt,
cdn,
"net_amount",
d.amount - discount_amount
);
}
frm.trigger("update_total");
}
});

View File

@ -37,12 +37,6 @@
"annual_revenue", "annual_revenue",
"image", "image",
"converted", "converted",
"products_tab",
"products",
"section_break_ggwh",
"total",
"column_break_uisv",
"net_total",
"sla_tab", "sla_tab",
"sla", "sla",
"sla_creation", "sla_creation",
@ -291,47 +285,12 @@
"fieldtype": "Table", "fieldtype": "Table",
"label": "Status Change Log", "label": "Status Change Log",
"options": "CRM Status Change Log" "options": "CRM Status Change Log"
},
{
"fieldname": "products_tab",
"fieldtype": "Tab Break",
"label": "Products"
},
{
"fieldname": "products",
"fieldtype": "Table",
"label": "Products",
"options": "CRM Products"
},
{
"fieldname": "section_break_ggwh",
"fieldtype": "Section Break"
},
{
"fieldname": "total",
"fieldtype": "Currency",
"label": "Total",
"options": "currency",
"read_only": 1
},
{
"fieldname": "column_break_uisv",
"fieldtype": "Column Break"
},
{
"description": "Total after discount",
"fieldname": "net_total",
"fieldtype": "Currency",
"label": "Net Total",
"options": "currency",
"read_only": 1
} }
], ],
"grid_page_length": 50,
"image_field": "image", "image_field": "image",
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2025-05-14 19:51:06.184569", "modified": "2025-01-02 22:14:01.991054",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "FCRM", "module": "FCRM",
"name": "CRM Lead", "name": "CRM Lead",
@ -372,7 +331,6 @@
"share": 1 "share": 1
} }
], ],
"row_format": "Dynamic",
"sender_field": "email", "sender_field": "email",
"sender_name_field": "first_name", "sender_name_field": "first_name",
"show_title_field_in_link": 1, "show_title_field_in_link": 1,
@ -381,4 +339,4 @@
"states": [], "states": [],
"title_field": "lead_name", "title_field": "lead_name",
"track_changes": 1 "track_changes": 1
} }

View File

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

View File

@ -1,8 +0,0 @@
// 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

@ -1,79 +0,0 @@
{
"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

@ -1,9 +0,0 @@
# 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

@ -1,30 +0,0 @@
# 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

@ -10,7 +10,6 @@
"organization_name", "organization_name",
"no_of_employees", "no_of_employees",
"currency", "currency",
"exchange_rate",
"annual_revenue", "annual_revenue",
"organization_logo", "organization_logo",
"column_break_pnpp", "column_break_pnpp",
@ -75,18 +74,12 @@
"fieldtype": "Link", "fieldtype": "Link",
"label": "Address", "label": "Address",
"options": "Address" "options": "Address"
},
{
"description": "The rate used to convert the organization\u2019s currency to your crm's base currency (set in CRM Settings). It is set once when the currency is first added and doesn't change automatically.",
"fieldname": "exchange_rate",
"fieldtype": "Float",
"label": "Exchange Rate"
} }
], ],
"image_field": "organization_logo", "image_field": "organization_logo",
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2025-07-15 11:40:12.175598", "modified": "2024-09-17 18:37:10.341062",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "FCRM", "module": "FCRM",
"name": "CRM Organization", "name": "CRM Organization",
@ -118,8 +111,7 @@
"write": 1 "write": 1
} }
], ],
"row_format": "Dynamic",
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [] "states": []
} }

View File

@ -4,65 +4,51 @@
import frappe import frappe
from frappe.model.document import Document from frappe.model.document import Document
from crm.fcrm.doctype.fcrm_settings.fcrm_settings import get_exchange_rate
class CRMOrganization(Document): class CRMOrganization(Document):
def validate(self): @staticmethod
self.update_exchange_rate() def default_list_data():
columns = [
def update_exchange_rate(self): {
if self.has_value_changed("currency") or not self.exchange_rate: 'label': 'Organization',
system_currency = frappe.db.get_single_value("FCRM Settings", "currency") or "USD" 'type': 'Data',
exchange_rate = 1 'key': 'organization_name',
if self.currency and self.currency != system_currency: 'width': '16rem',
exchange_rate = get_exchange_rate(self.currency, system_currency) },
{
self.db_set("exchange_rate", exchange_rate) 'label': 'Website',
'type': 'Data',
@staticmethod 'key': 'website',
def default_list_data(): 'width': '14rem',
columns = [ },
{ {
"label": "Organization", 'label': 'Industry',
"type": "Data", 'type': 'Link',
"key": "organization_name", 'key': 'industry',
"width": "16rem", 'options': 'CRM Industry',
}, 'width': '14rem',
{ },
"label": "Website", {
"type": "Data", 'label': 'Annual Revenue',
"key": "website", 'type': 'Currency',
"width": "14rem", 'key': 'annual_revenue',
}, 'width': '14rem',
{ },
"label": "Industry", {
"type": "Link", 'label': 'Last Modified',
"key": "industry", 'type': 'Datetime',
"options": "CRM Industry", 'key': 'modified',
"width": "14rem", 'width': '8rem',
}, },
{ ]
"label": "Annual Revenue", rows = [
"type": "Currency", "name",
"key": "annual_revenue", "organization_name",
"width": "14rem", "organization_logo",
}, "website",
{ "industry",
"label": "Last Modified", "currency",
"type": "Datetime", "annual_revenue",
"key": "modified", "modified",
"width": "8rem", ]
}, return {'columns': columns, 'rows': rows}
]
rows = [
"name",
"organization_name",
"organization_logo",
"website",
"industry",
"currency",
"annual_revenue",
"modified",
]
return {"columns": columns, "rows": rows}

View File

@ -1,9 +0,0 @@
// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
frappe.ui.form.on("CRM Product", {
product_code: function (frm) {
if (!frm.doc.product_name)
frm.set_value("product_name", frm.doc.product_code);
}
});

View File

@ -1,105 +0,0 @@
{
"actions": [],
"allow_import": 1,
"allow_rename": 1,
"autoname": "field:product_code",
"creation": "2025-04-28 11:45:09.309636",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"naming_series",
"product_code",
"product_name",
"column_break_bpdj",
"disabled",
"standard_rate",
"image",
"section_break_rtwm",
"description"
],
"fields": [
{
"fieldname": "naming_series",
"fieldtype": "Select",
"label": "Naming Series",
"options": "CRM-PROD-.YYYY.-"
},
{
"fieldname": "product_code",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Product Code",
"reqd": 1,
"unique": 1
},
{
"fieldname": "product_name",
"fieldtype": "Data",
"label": "Product Name"
},
{
"fieldname": "column_break_bpdj",
"fieldtype": "Column Break"
},
{
"default": "0",
"fieldname": "disabled",
"fieldtype": "Check",
"label": "Disabled"
},
{
"fieldname": "image",
"fieldtype": "Attach Image",
"label": "Image"
},
{
"fieldname": "section_break_rtwm",
"fieldtype": "Section Break"
},
{
"fieldname": "description",
"fieldtype": "Text Editor",
"label": "Description"
},
{
"fieldname": "standard_rate",
"fieldtype": "Currency",
"label": "Standard Selling Rate"
}
],
"grid_page_length": 50,
"image_field": "image",
"index_web_pages_for_search": 1,
"links": [],
"make_attachments_public": 1,
"modified": "2025-04-28 12:47:25.087957",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Product",
"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
}
],
"quick_entry": 1,
"row_format": "Dynamic",
"search_fields": "product_name,description",
"show_name_in_global_search": 1,
"show_preview_popup": 1,
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "product_name",
"track_changes": 1
}

View File

@ -1,16 +0,0 @@
# 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 CRMProduct(Document):
def validate(self):
self.set_product_name()
def set_product_name(self):
if not self.product_name:
self.product_name = self.product_code
else:
self.product_name = self.product_name.strip()

View File

@ -1,29 +0,0 @@
# 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 UnitTestCRMProduct(UnitTestCase):
"""
Unit tests for CRMProduct.
Use this class for testing individual functions and methods.
"""
pass
class IntegrationTestCRMProduct(IntegrationTestCase):
"""
Integration tests for CRMProduct.
Use this class for testing interactions between multiple components.
"""
pass

View File

@ -1,136 +0,0 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2025-04-28 12:50:49.812915",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"product_code",
"column_break_gvbc",
"product_name",
"section_break_fnvf",
"qty",
"column_break_ajac",
"rate",
"section_break_olqb",
"discount_percentage",
"column_break_uvra",
"discount_amount",
"section_break_cnpb",
"column_break_pozr",
"amount",
"column_break_ejqw",
"net_amount"
],
"fields": [
{
"fieldname": "column_break_gvbc",
"fieldtype": "Column Break"
},
{
"fieldname": "product_name",
"fieldtype": "Data",
"label": "Product Name",
"reqd": 1
},
{
"fieldname": "section_break_fnvf",
"fieldtype": "Section Break"
},
{
"fieldname": "section_break_olqb",
"fieldtype": "Section Break"
},
{
"bold": 1,
"fieldname": "discount_percentage",
"fieldtype": "Percent",
"label": "Discount %"
},
{
"fieldname": "discount_amount",
"fieldtype": "Currency",
"label": "Discount Amount",
"options": "currency",
"read_only": 1
},
{
"fieldname": "section_break_cnpb",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_pozr",
"fieldtype": "Column Break"
},
{
"bold": 1,
"fieldname": "rate",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Rate",
"options": "currency",
"reqd": 1
},
{
"bold": 1,
"fieldname": "amount",
"fieldtype": "Currency",
"label": "Amount",
"options": "currency",
"read_only": 1
},
{
"bold": 1,
"depends_on": "discount_percentage",
"description": "Amount after discount",
"fieldname": "net_amount",
"fieldtype": "Currency",
"label": "Net Amount",
"options": "currency",
"read_only": 1
},
{
"bold": 1,
"columns": 5,
"fieldname": "product_code",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Product",
"options": "CRM Product"
},
{
"bold": 1,
"default": "1",
"fieldname": "qty",
"fieldtype": "Float",
"label": "Quantity"
},
{
"fieldname": "column_break_ajac",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_uvra",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_ejqw",
"fieldtype": "Column Break"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-05-14 18:52:26.183306",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Products",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

@ -1,110 +0,0 @@
# 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 CRMProducts(Document):
pass
def create_product_details_script(doctype):
if not frappe.db.exists("CRM Form Script", "Product Details Script for " + doctype):
script = get_product_details_script(doctype)
frappe.get_doc(
{
"doctype": "CRM Form Script",
"name": "Product Details Script for " + doctype,
"dt": doctype,
"view": "Form",
"script": script,
"enabled": 1,
"is_standard": 1,
}
).insert()
def get_product_details_script(doctype):
doctype_class = "class " + doctype.replace(" ", "")
return (
doctype_class
+ " {"
+ """
update_total() {
let total = 0
let total_qty = 0
let net_total = 0
let discount_applied = false
this.doc.products.forEach((d) => {
total += d.amount
net_total += d.net_amount
if (d.discount_percentage > 0) {
discount_applied = true
}
})
this.doc.total = total
this.doc.net_total = net_total || total
if (!net_total && discount_applied) {
this.doc.net_total = net_total
}
}
}
class CRMProducts {
products_add() {
let row = this.doc.getRow('products')
row.trigger('qty')
this.doc.trigger('update_total')
}
products_remove() {
this.doc.trigger('update_total')
}
async product_code(idx) {
let row = this.doc.getRow('products', idx)
let a = await call("frappe.client.get_value", {
doctype: "CRM Product",
filters: { name: row.product_code },
fieldname: ["product_name", "standard_rate"],
})
row.product_name = a.product_name
if (a.standard_rate && !row.rate) {
row.rate = a.standard_rate
row.trigger("rate")
}
}
qty(idx) {
let row = this.doc.getRow('products', idx)
row.amount = row.qty * row.rate
row.trigger('discount_percentage', idx)
}
rate() {
let row = this.doc.getRow('products')
row.amount = row.qty * row.rate
row.trigger('discount_percentage')
}
discount_percentage(idx) {
let row = this.doc.getRow('products', idx)
if (!row.discount_percentage) {
row.net_amount = row.amount
row.discount_amount = 0
}
if (row.discount_percentage && row.amount) {
row.discount_amount = (row.discount_percentage / 100) * row.amount
row.net_amount = row.amount - row.discount_amount
}
this.doc.trigger('update_total')
}
}"""
)

View File

@ -13,8 +13,6 @@
"column_break_mwmz", "column_break_mwmz",
"duration", "duration",
"last_status_change_log", "last_status_change_log",
"from_type",
"to_type",
"log_owner" "log_owner"
], ],
"fields": [ "fields": [
@ -63,31 +61,18 @@
"fieldtype": "Link", "fieldtype": "Link",
"label": "Owner", "label": "Owner",
"options": "User" "options": "User"
},
{
"fieldname": "from_type",
"fieldtype": "Data",
"in_list_view": 1,
"label": "From Type"
},
{
"fieldname": "to_type",
"fieldtype": "Data",
"in_list_view": 1,
"label": "To Type"
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2025-07-13 12:37:41.278584", "modified": "2024-01-06 13:26:40.597277",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "FCRM", "module": "FCRM",
"name": "CRM Status Change Log", "name": "CRM Status Change Log",
"owner": "Administrator", "owner": "Administrator",
"permissions": [], "permissions": [],
"row_format": "Dynamic",
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [] "states": []
} }

View File

@ -1,17 +1,15 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors # Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt # For license information, please see license.txt
from datetime import datetime
import frappe import frappe
from frappe.model.document import Document from datetime import datetime
from frappe.utils import add_to_date, get_datetime from frappe.utils import add_to_date, get_datetime
from frappe.model.document import Document
class CRMStatusChangeLog(Document): class CRMStatusChangeLog(Document):
pass pass
def get_duration(from_date, to_date): def get_duration(from_date, to_date):
if not isinstance(from_date, datetime): if not isinstance(from_date, datetime):
from_date = get_datetime(from_date) from_date = get_datetime(from_date)
@ -20,45 +18,28 @@ def get_duration(from_date, to_date):
duration = to_date - from_date duration = to_date - from_date
return duration.total_seconds() return duration.total_seconds()
def add_status_change_log(doc): def add_status_change_log(doc):
to_status_type = frappe.db.get_value("CRM Deal Status", doc.status, "type") if doc.status else None
if not doc.is_new(): if not doc.is_new():
previous_status = doc.get_doc_before_save().status if doc.get_doc_before_save() else None previous_status = doc.get_doc_before_save().status if doc.get_doc_before_save() else None
previous_status_type = (
frappe.db.get_value("CRM Deal Status", previous_status, "type") if previous_status else None
)
if not doc.status_change_log and previous_status: if not doc.status_change_log and previous_status:
now_minus_one_minute = add_to_date(datetime.now(), minutes=-1) now_minus_one_minute = add_to_date(datetime.now(), minutes=-1)
doc.append( doc.append("status_change_log", {
"status_change_log", "from": previous_status,
{ "to": "",
"from": previous_status, "from_date": now_minus_one_minute,
"from_type": previous_status_type or "", "to_date": "",
"to": "", "log_owner": frappe.session.user,
"to_type": "", })
"from_date": now_minus_one_minute,
"to_date": "",
"log_owner": frappe.session.user,
},
)
last_status_change = doc.status_change_log[-1] last_status_change = doc.status_change_log[-1]
last_status_change.to = doc.status last_status_change.to = doc.status
last_status_change.to_type = to_status_type or ""
last_status_change.to_date = datetime.now() last_status_change.to_date = datetime.now()
last_status_change.log_owner = frappe.session.user last_status_change.log_owner = frappe.session.user
last_status_change.duration = get_duration(last_status_change.from_date, last_status_change.to_date) last_status_change.duration = get_duration(last_status_change.from_date, last_status_change.to_date)
doc.append( doc.append("status_change_log", {
"status_change_log", "from": doc.status,
{ "to": "",
"from": doc.status, "from_date": datetime.now(),
"from_type": to_status_type or "", "to_date": "",
"to": "", "log_owner": frappe.session.user,
"to_type": "", })
"from_date": datetime.now(),
"to_date": "",
"log_owner": frappe.session.user,
},
)

View File

@ -63,7 +63,8 @@
"fieldname": "twiml_sid", "fieldname": "twiml_sid",
"fieldtype": "Data", "fieldtype": "Data",
"label": "TwiML SID", "label": "TwiML SID",
"permlevel": 1 "permlevel": 1,
"read_only": 1
}, },
{ {
"fieldname": "section_break_ssqj", "fieldname": "section_break_ssqj",
@ -104,7 +105,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2025-08-19 13:36:19.823197", "modified": "2025-01-15 19:35:13.406254",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "FCRM", "module": "FCRM",
"name": "CRM Twilio Settings", "name": "CRM Twilio Settings",
@ -151,9 +152,8 @@
"write": 1 "write": 1
} }
], ],
"row_format": "Dynamic",
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [], "states": [],
"track_changes": 1 "track_changes": 1
} }

View File

@ -128,35 +128,14 @@ def get_quotation_url(crm_deal, organization):
address = address.get("name") if address else None address = address.get("name") if address else None
if not erpnext_crm_settings.is_erpnext_in_different_site: if not erpnext_crm_settings.is_erpnext_in_different_site:
base_url = f"{get_url_to_list('Quotation')}/new" quotation_url = get_url_to_list("Quotation")
params = { return f"{quotation_url}/new?quotation_to=CRM Deal&crm_deal={crm_deal}&party_name={crm_deal}&company={erpnext_crm_settings.erpnext_company}&contact_person={contact}&customer_address={address}"
"quotation_to": "CRM Deal",
"crm_deal": crm_deal,
"party_name": crm_deal,
"company": erpnext_crm_settings.erpnext_company,
"contact_person": contact,
"customer_address": address
}
else: else:
site_url = erpnext_crm_settings.get("erpnext_site_url") site_url = erpnext_crm_settings.get("erpnext_site_url")
base_url = f"{site_url}/app/quotation/new" quotation_url = f"{site_url}/app/quotation"
prospect = create_prospect_in_remote_site(crm_deal, erpnext_crm_settings)
params = {
"quotation_to": "Prospect",
"crm_deal": crm_deal,
"party_name": prospect,
"company": erpnext_crm_settings.erpnext_company,
"contact_person": contact,
"customer_address": address
}
# Filter out None values and build query string
query_string = "&".join(
f"{key}={value}" for key, value in params.items()
if value is not None
)
return f"{base_url}?{query_string}" prospect = create_prospect_in_remote_site(crm_deal, erpnext_crm_settings)
return f"{quotation_url}/new?quotation_to=Prospect&crm_deal={crm_deal}&party_name={prospect}&company={erpnext_crm_settings.erpnext_company}&contact_person={contact}&customer_address={address}"
def create_prospect_in_remote_site(crm_deal, erpnext_crm_settings): def create_prospect_in_remote_site(crm_deal, erpnext_crm_settings):
@ -285,7 +264,7 @@ def create_customer_in_remote_site(customer, erpnext_crm_settings):
@frappe.whitelist() @frappe.whitelist()
def get_crm_form_script(): def get_crm_form_script():
return """ return """
async function setupForm({ doc, call, $dialog, updateField, toast }) { async function setupForm({ doc, call, $dialog, updateField, createToast }) {
let actions = []; let actions = [];
let is_erpnext_integration_enabled = await call("frappe.client.get_single_value", {doctype: "ERPNext CRM Settings", field: "enabled"}); let is_erpnext_integration_enabled = await call("frappe.client.get_single_value", {doctype: "ERPNext CRM Settings", field: "enabled"});
if (!["Lost", "Won"].includes(doc?.status) && is_erpnext_integration_enabled) { if (!["Lost", "Won"].includes(doc?.status) && is_erpnext_integration_enabled) {

View File

@ -7,14 +7,6 @@
"field_order": [ "field_order": [
"defaults_tab", "defaults_tab",
"restore_defaults", "restore_defaults",
"enable_forecasting",
"auto_update_expected_deal_value",
"currency_tab",
"currency",
"exchange_rate_provider_section",
"service_provider",
"column_break_vqck",
"access_key",
"branding_tab", "branding_tab",
"brand_name", "brand_name",
"brand_logo", "brand_logo",
@ -36,7 +28,7 @@
{ {
"fieldname": "defaults_tab", "fieldname": "defaults_tab",
"fieldtype": "Tab Break", "fieldtype": "Tab Break",
"label": "Settings" "label": "Defaults"
}, },
{ {
"fieldname": "branding_tab", "fieldname": "branding_tab",
@ -64,61 +56,12 @@
"fieldname": "favicon", "fieldname": "favicon",
"fieldtype": "Attach", "fieldtype": "Attach",
"label": "Favicon" "label": "Favicon"
},
{
"default": "0",
"description": "It will make deal's \"Expected Closure Date\" & \"Expected Deal Value\" mandatory to get accurate forecasting insights",
"fieldname": "enable_forecasting",
"fieldtype": "Check",
"label": "Enable Forecasting"
},
{
"fieldname": "currency",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Currency",
"options": "Currency"
},
{
"fieldname": "currency_tab",
"fieldtype": "Tab Break",
"label": "Currency"
},
{
"fieldname": "exchange_rate_provider_section",
"fieldtype": "Section Break",
"label": "Exchange Rate Provider"
},
{
"default": "frankfurter.app",
"fieldname": "service_provider",
"fieldtype": "Select",
"label": "Service Provider",
"options": "frankfurter.app\nexchangerate.host"
},
{
"depends_on": "eval:doc.service_provider == 'exchangerate.host';",
"fieldname": "access_key",
"fieldtype": "Data",
"label": "Access Key",
"mandatory_depends_on": "eval:doc.service_provider == 'exchangerate.host';"
},
{
"fieldname": "column_break_vqck",
"fieldtype": "Column Break"
},
{
"default": "1",
"description": "Automatically update \"Expected Deal Value\" based on the total value of associated products in a deal",
"fieldname": "auto_update_expected_deal_value",
"fieldtype": "Check",
"label": "Auto Update Expected Deal Value"
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2025-09-16 17:33:26.406549", "modified": "2025-02-20 12:38:38.088477",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "FCRM", "module": "FCRM",
"name": "FCRM Settings", "name": "FCRM Settings",
@ -152,8 +95,7 @@
"share": 1 "share": 1
} }
], ],
"row_format": "Dynamic",
"sort_field": "creation", "sort_field": "creation",
"sort_order": "DESC", "sort_order": "DESC",
"states": [] "states": []
} }

View File

@ -2,9 +2,7 @@
# For license information, please see license.txt # For license information, please see license.txt
import frappe import frappe
import requests
from frappe import _ from frappe import _
from frappe.custom.doctype.property_setter.property_setter import delete_property_setter, make_property_setter
from frappe.model.document import Document from frappe.model.document import Document
from crm.install import after_install from crm.install import after_install
@ -17,8 +15,6 @@ class FCRMSettings(Document):
def validate(self): def validate(self):
self.do_not_allow_to_delete_if_standard() self.do_not_allow_to_delete_if_standard()
self.setup_forecasting()
self.make_currency_read_only()
def do_not_allow_to_delete_if_standard(self): def do_not_allow_to_delete_if_standard(self):
if not self.has_value_changed("dropdown_items"): if not self.has_value_changed("dropdown_items"):
@ -28,53 +24,8 @@ class FCRMSettings(Document):
standard_old_items = [d.name1 for d in old_items if d.is_standard] 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) deleted_standard_items = set(standard_old_items) - set(standard_new_items)
if deleted_standard_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))) frappe.throw(_("Cannot delete standard items {0}").format(", ".join(deleted_standard_items)))
def setup_forecasting(self):
if self.has_value_changed("enable_forecasting"):
if not self.enable_forecasting:
delete_property_setter(
"CRM Deal",
"reqd",
"expected_closure_date",
)
delete_property_setter(
"CRM Deal",
"reqd",
"expected_deal_value",
)
else:
make_property_setter(
"CRM Deal",
"expected_closure_date",
"reqd",
1 if self.enable_forecasting else 0,
"Check",
)
make_property_setter(
"CRM Deal",
"expected_deal_value",
"reqd",
1 if self.enable_forecasting else 0,
"Check",
)
def make_currency_read_only(self):
if self.currency and self.has_value_changed("currency"):
make_property_setter(
"FCRM Settings",
"currency",
"read_only",
1,
"Check",
)
def get_standard_dropdown_items():
return [item.get("name1") for item in frappe.get_hooks("standard_dropdown_items")]
def after_migrate(): def after_migrate():
@ -100,109 +51,3 @@ def sync_table(key, hook):
crm_settings.set(key, items) crm_settings.set(key, items)
crm_settings.save() crm_settings.save()
def create_forecasting_script():
if not frappe.db.exists("CRM Form Script", "Forecasting Script"):
script = get_forecasting_script()
frappe.get_doc(
{
"doctype": "CRM Form Script",
"name": "Forecasting Script",
"dt": "CRM Deal",
"view": "Form",
"script": script,
"enabled": 1,
"is_standard": 1,
}
).insert()
def get_forecasting_script():
return """class CRMDeal {
async status() {
await this.doc.trigger('updateProbability')
}
async updateProbability() {
let status = await call("frappe.client.get_value", {
doctype: "CRM Deal Status",
fieldname: "probability",
filters: { name: this.doc.status },
})
this.doc.probability = status.probability
}
}"""
def get_exchange_rate(from_currency, to_currency, date=None):
if not date:
date = "latest"
api_used = "frankfurter"
api_endpoint = f"https://api.frankfurter.app/{date}?from={from_currency}&to={to_currency}"
res = requests.get(api_endpoint, timeout=5)
if res.ok:
data = res.json()
return data["rates"][to_currency]
# Fallback to exchangerate.host if Frankfurter API fails
settings = FCRMSettings("FCRM Settings")
if settings and settings.service_provider == "exchangerate.host":
api_used = "exchangerate.host"
if not settings.access_key:
frappe.throw(
_("Access Key is required for Service Provider: {0}").format(
frappe.bold(settings.service_provider)
)
)
params = {
"access_key": settings.access_key,
"from": from_currency,
"to": to_currency,
"amount": 1,
}
if date != "latest":
params["date"] = date
api_endpoint = "https://api.exchangerate.host/convert"
res = requests.get(api_endpoint, params=params, timeout=5)
if res.ok:
data = res.json()
return data["result"]
frappe.log_error(
title="Exchange Rate Fetch Error",
message=f"Failed to fetch exchange rate from {from_currency} to {to_currency} using {api_used} API.",
)
if api_used == "frankfurter":
user = frappe.session.user
is_manager = (
"System Manager" in frappe.get_roles(user)
or "Sales Manager" in frappe.get_roles(user)
or user == "Administrator"
)
if not is_manager:
frappe.throw(
_(
"Ask your manager to set up the Exchange Rate Provider, as default provider does not support currency conversion for {0} to {1}."
).format(from_currency, to_currency)
)
else:
frappe.throw(
_(
"Setup the Exchange Rate Provider as 'Exchangerate Host' in settings, as default provider does not support currency conversion for {0} to {1}."
).format(from_currency, to_currency)
)
frappe.throw(
_(
"Failed to fetch exchange rate from {0} to {1} on {2}. Please check your internet connection or try again later."
).format(from_currency, to_currency, date)
)

View File

@ -264,6 +264,22 @@ standard_dropdown_items = [
"route": "#", "route": "#",
"is_standard": 1, "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", "name1": "toggle_theme",
"label": "Toggle theme", "label": "Toggle theme",
@ -287,14 +303,6 @@ standard_dropdown_items = [
"route": "#", "route": "#",
"is_standard": 1, "is_standard": 1,
}, },
{
"name1": "about",
"label": "About",
"type": "Route",
"icon": "info",
"route": "#",
"is_standard": 1,
},
{ {
"name1": "separator", "name1": "separator",
"label": "", "label": "",

View File

@ -4,9 +4,6 @@ import click
import frappe import frappe
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
from crm.fcrm.doctype.crm_dashboard.crm_dashboard import create_default_manager_dashboard
from crm.fcrm.doctype.crm_products.crm_products import create_product_details_script
def before_install(): def before_install():
pass pass
@ -21,12 +18,7 @@ def after_install(force=False):
add_email_template_custom_fields() add_email_template_custom_fields()
add_default_industries() add_default_industries()
add_default_lead_sources() add_default_lead_sources()
add_default_lost_reasons()
add_standard_dropdown_items() add_standard_dropdown_items()
add_default_scripts()
create_default_manager_dashboard(force)
create_assignment_rule_custom_fields()
add_assignment_rule_property_setters()
frappe.db.commit() frappe.db.commit()
@ -73,44 +65,30 @@ def add_default_deal_statuses():
statuses = { statuses = {
"Qualification": { "Qualification": {
"color": "gray", "color": "gray",
"type": "Open",
"probability": 10,
"position": 1, "position": 1,
}, },
"Demo/Making": { "Demo/Making": {
"color": "orange", "color": "orange",
"type": "Ongoing",
"probability": 25,
"position": 2, "position": 2,
}, },
"Proposal/Quotation": { "Proposal/Quotation": {
"color": "blue", "color": "blue",
"type": "Ongoing",
"probability": 50,
"position": 3, "position": 3,
}, },
"Negotiation": { "Negotiation": {
"color": "yellow", "color": "yellow",
"type": "Ongoing",
"probability": 70,
"position": 4, "position": 4,
}, },
"Ready to Close": { "Ready to Close": {
"color": "purple", "color": "purple",
"type": "Ongoing",
"probability": 90,
"position": 5, "position": 5,
}, },
"Won": { "Won": {
"color": "green", "color": "green",
"type": "Won",
"probability": 100,
"position": 6, "position": 6,
}, },
"Lost": { "Lost": {
"color": "red", "color": "red",
"type": "Lost",
"probability": 0,
"position": 7, "position": 7,
}, },
} }
@ -122,8 +100,6 @@ def add_default_deal_statuses():
doc = frappe.new_doc("CRM Deal Status") doc = frappe.new_doc("CRM Deal Status")
doc.deal_status = status doc.deal_status = status
doc.color = statuses[status]["color"] doc.color = statuses[status]["color"]
doc.type = statuses[status]["type"]
doc.probability = statuses[status]["probability"]
doc.position = statuses[status]["position"] doc.position = statuses[status]["position"]
doc.insert() doc.insert()
@ -194,7 +170,7 @@ def add_default_fields_layout(force=False):
}, },
"CRM Deal-Data Fields": { "CRM Deal-Data Fields": {
"doctype": "CRM Deal", "doctype": "CRM Deal",
"layout": '[{"name":"first_tab","sections":[{"label":"Details","name":"details_section","opened":true,"columns":[{"name":"column_z9XL","fields":["organization","annual_revenue","next_step"]},{"name":"column_gM4w","fields":["website","closed_date","deal_owner"]},{"name":"column_gWmE","fields":["territory","probability"]}]},{"label":"Products","name":"section_jHhQ","opened":true,"columns":[{"name":"column_xiNF","fields":["products"]}],"editingLabel":false,"hideLabel":true},{"label":"New Section","name":"section_WNOQ","opened":true,"columns":[{"name":"column_ziBW","fields":["total"]},{"label":"","name":"column_wuwA","fields":["net_total"]}],"hideBorder":true,"hideLabel":true}]}]', "layout": '[{"label": "Details", "name": "details_section", "opened": true, "columns": [{"name": "column_z9XL", "fields": ["organization", "annual_revenue", "next_step"]}, {"name": "column_gM4w", "fields": ["website", "close_date", "deal_owner"]}, {"name": "column_gWmE", "fields": ["territory", "probability"]}]}]',
}, },
} }
@ -364,44 +340,6 @@ def add_default_lead_sources():
doc.insert() 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(): def add_standard_dropdown_items():
crm_settings = frappe.get_single("FCRM Settings") crm_settings = frappe.get_single("FCRM Settings")
@ -415,88 +353,3 @@ def add_standard_dropdown_items():
crm_settings.append("dropdown_items", item) crm_settings.append("dropdown_items", item)
crm_settings.save() crm_settings.save()
def add_default_scripts():
from crm.fcrm.doctype.fcrm_settings.fcrm_settings import create_forecasting_script
for doctype in ["CRM Lead", "CRM Deal"]:
create_product_details_script(doctype)
create_forecasting_script()
def add_assignment_rule_property_setters():
"""Add a property setter to the Assignment Rule DocType for assign_condition and unassign_condition."""
default_fields = {
"doctype": "Property Setter",
"doctype_or_field": "DocField",
"doc_type": "Assignment Rule",
"property_type": "Data",
"is_system_generated": 1,
}
if not frappe.db.exists("Property Setter", {"name": "Assignment Rule-assign_condition-depends_on"}):
frappe.get_doc(
{
**default_fields,
"name": "Assignment Rule-assign_condition-depends_on",
"field_name": "assign_condition",
"property": "depends_on",
"value": "eval: !doc.assign_condition_json",
}
).insert()
else:
frappe.db.set_value(
"Property Setter",
{"name": "Assignment Rule-assign_condition-depends_on"},
"value",
"eval: !doc.assign_condition_json",
)
if not frappe.db.exists("Property Setter", {"name": "Assignment Rule-unassign_condition-depends_on"}):
frappe.get_doc(
{
**default_fields,
"name": "Assignment Rule-unassign_condition-depends_on",
"field_name": "unassign_condition",
"property": "depends_on",
"value": "eval: !doc.unassign_condition_json",
}
).insert()
else:
frappe.db.set_value(
"Property Setter",
{"name": "Assignment Rule-unassign_condition-depends_on"},
"value",
"eval: !doc.unassign_condition_json",
)
def create_assignment_rule_custom_fields():
if not frappe.get_meta("Assignment Rule").has_field("assign_condition_json"):
click.secho("* Installing Custom Fields in Assignment Rule")
create_custom_fields(
{
"Assignment Rule": [
{
"description": "Autogenerated field by CRM App",
"fieldname": "assign_condition_json",
"fieldtype": "Code",
"label": "Assign Condition JSON",
"insert_after": "assign_condition",
"depends_on": "eval: doc.assign_condition_json",
},
{
"description": "Autogenerated field by CRM App",
"fieldname": "unassign_condition_json",
"fieldtype": "Code",
"label": "Unassign Condition JSON",
"insert_after": "unassign_condition",
"depends_on": "eval: doc.unassign_condition_json",
},
],
}
)
frappe.clear_cache(doctype="Assignment Rule")

View File

@ -35,7 +35,7 @@ def set_default_calling_medium(medium):
frappe.get_doc( frappe.get_doc(
{ {
"doctype": "CRM Telephony Agent", "doctype": "CRM Telephony Agent",
"user": frappe.session.user, "agent": frappe.session.user,
"default_medium": medium, "default_medium": medium,
} }
).insert(ignore_permissions=True) ).insert(ignore_permissions=True)
@ -110,12 +110,12 @@ def get_contact_by_phone_number(phone_number):
number = parse_phone_number(phone_number) number = parse_phone_number(phone_number)
if number.get("is_valid"): if number.get("is_valid"):
return get_contact(number.get("national_number"), number.get("country")) return get_contact(number.get("national_number"))
else: else:
return get_contact(phone_number, number.get("country"), exact_match=True) return get_contact(phone_number, exact_match=True)
def get_contact(phone_number, country="IN", exact_match=False): def get_contact(phone_number, exact_match=False):
if not phone_number: if not phone_number:
return {"mobile_no": phone_number} return {"mobile_no": phone_number}
@ -149,11 +149,11 @@ def get_contact(phone_number, country="IN", exact_match=False):
deal = frappe.db.get_value( deal = frappe.db.get_value(
"CRM Contacts", {"contact": contact.name, "is_primary": 1}, "parent" "CRM Contacts", {"contact": contact.name, "is_primary": 1}, "parent"
) )
if are_same_phone_number(contact.mobile_no, phone_number, country, validate=not exact_match): if are_same_phone_number(contact.mobile_no, phone_number, validate=not exact_match):
contact["deal"] = deal contact["deal"] = deal
return contact return contact
# Else, return the first contact # Else, return the first contact
if are_same_phone_number(contacts[0].mobile_no, phone_number, country, validate=not exact_match): if are_same_phone_number(contacts[0].mobile_no, phone_number, validate=not exact_match):
return contacts[0] return contacts[0]
# Else, Check if the number is associated with a lead # Else, Check if the number is associated with a lead
@ -173,7 +173,7 @@ def get_contact(phone_number, country="IN", exact_match=False):
if len(leads): if len(leads):
for lead in leads: for lead in leads:
if are_same_phone_number(lead.mobile_no, phone_number, country, validate=not exact_match): if are_same_phone_number(lead.mobile_no, phone_number, validate=not exact_match):
lead["lead"] = lead.name lead["lead"] = lead.name
lead["full_name"] = lead.lead_name lead["full_name"] = lead.lead_name
return lead return lead

View File

@ -242,18 +242,19 @@ def get_call_log_status(call_payload, direction="inbound"):
elif status == "failed": elif status == "failed":
return "Failed" return "Failed"
status = call_payload.get("DialCallStatus")
call_type = call_payload.get("CallType") call_type = call_payload.get("CallType")
status = call_payload.get("DialCallStatus") or call_payload.get("Status") dial_call_status = call_payload.get("DialCallStatus")
if call_type == "incomplete" and status == "no-answer": if call_type == "incomplete" and dial_call_status == "no-answer":
status = "No Answer" status = "No Answer"
elif call_type == "client-hangup" and status == "canceled": elif call_type == "client-hangup" and dial_call_status == "canceled":
status = "Canceled" status = "Canceled"
elif call_type == "incomplete" and status == "failed": elif call_type == "incomplete" and dial_call_status == "failed":
status = "Failed" status = "Failed"
elif call_type == "completed": elif call_type == "completed":
status = "Completed" status = "Completed"
elif status == "busy": elif dial_call_status == "busy":
status = "Ringing" status = "Ringing"
return status return status

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -11,9 +11,4 @@ crm.patches.v1_0.create_default_fields_layout #22/01/2025
crm.patches.v1_0.create_default_sidebar_fields_layout crm.patches.v1_0.create_default_sidebar_fields_layout
crm.patches.v1_0.update_deal_quick_entry_layout crm.patches.v1_0.update_deal_quick_entry_layout
crm.patches.v1_0.update_layouts_to_new_format crm.patches.v1_0.update_layouts_to_new_format
crm.patches.v1_0.move_twilio_agent_to_telephony_agent crm.patches.v1_0.move_twilio_agent_to_telephony_agent
crm.patches.v1_0.create_default_scripts # 13-06-2025
crm.patches.v1_0.update_deal_status_probabilities
crm.patches.v1_0.update_deal_status_type
crm.patches.v1_0.create_default_lost_reasons
crm.patches.v1_0.add_fields_in_assignment_rule

View File

@ -1,9 +0,0 @@
from crm.install import (
add_assignment_rule_property_setters,
create_assignment_rule_custom_fields,
)
def execute():
create_assignment_rule_custom_fields()
add_assignment_rule_property_setters()

View File

@ -1,5 +0,0 @@
from crm.install import add_default_lost_reasons
def execute():
add_default_lost_reasons()

View File

@ -1,5 +0,0 @@
from crm.install import add_default_scripts
def execute():
add_default_scripts()

View File

@ -1,24 +0,0 @@
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

@ -1,44 +0,0 @@
import frappe
def execute():
deal_statuses = frappe.get_all("CRM Deal Status", fields=["name", "type", "deal_status"])
openStatuses = ["New", "Open", "Unassigned", "Qualification"]
ongoingStatuses = [
"Demo/Making",
"Proposal/Quotation",
"Negotiation",
"Ready to Close",
"Demo Scheduled",
"Follow Up",
]
onHoldStatuses = ["On Hold", "Paused", "Stalled", "Awaiting Reply"]
wonStatuses = ["Won", "Closed Won", "Successful", "Completed"]
lostStatuses = [
"Lost",
"Closed",
"Closed Lost",
"Junk",
"Unqualified",
"Disqualified",
"Cancelled",
"No Response",
]
for status in deal_statuses:
if not status.type or status.type is None or status.type == "Open":
if status.deal_status in openStatuses:
type = "Open"
elif status.deal_status in ongoingStatuses:
type = "Ongoing"
elif status.deal_status in onHoldStatuses:
type = "On Hold"
elif status.deal_status in wonStatuses:
type = "Won"
elif status.deal_status in lostStatuses:
type = "Lost"
else:
type = "Ongoing"
frappe.db.set_value("CRM Deal Status", status.name, "type", type)

View File

@ -1,4 +1,4 @@
<p>You have been invited to join Frappe CRM</p> <h2>You have been invited to join Frappe CRM</h2>
<p> <p>
<a class="btn btn-primary" href="{{ invite_link }}">Accept Invitation</a> <a class="btn btn-primary" href="{{ invite_link }}">Accept Invitation</a>
</p> </p>

View File

@ -1,13 +1,12 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# MIT License. See license.txt # MIT License. See license.txt
from __future__ import unicode_literals
import click import click
import frappe import frappe
def before_uninstall(): def before_uninstall():
delete_email_template_custom_fields() delete_email_template_custom_fields()
def delete_email_template_custom_fields(): def delete_email_template_custom_fields():
if frappe.get_meta("Email Template").has_field("enabled"): if frappe.get_meta("Email Template").has_field("enabled"):
click.secho("* Uninstalling Custom Fields from Email Template") click.secho("* Uninstalling Custom Fields from Email Template")
@ -20,4 +19,4 @@ def delete_email_template_custom_fields():
for fieldname in fieldnames: for fieldname in fieldnames:
frappe.db.delete("Custom Field", {"name": "Email Template-" + fieldname}) frappe.db.delete("Custom Field", {"name": "Email Template-" + fieldname})
frappe.clear_cache(doctype="Email Template") frappe.clear_cache(doctype="Email Template")

View File

@ -1,11 +1,4 @@
import functools
import frappe
import phonenumbers import phonenumbers
import requests
from frappe import _
from frappe.model.docstatus import DocStatus
from frappe.model.dynamic_links import get_dynamic_link_map
from frappe.utils import floor from frappe.utils import floor
from phonenumbers import NumberParseException from phonenumbers import NumberParseException
from phonenumbers import PhoneNumberFormat as PNF from phonenumbers import PhoneNumberFormat as PNF
@ -100,170 +93,3 @@ def seconds_to_duration(seconds):
return f"{seconds}s" return f"{seconds}s"
else: else:
return "0s" return "0s"
# Extracted from frappe core frappe/model/delete_doc.py/check_if_doc_is_linked
def get_linked_docs(doc, method="Delete"):
from frappe.model.rename_doc import get_link_fields
link_fields = get_link_fields(doc.doctype)
ignored_doctypes = set()
if method == "Cancel" and (doc_ignore_flags := doc.get("ignore_linked_doctypes")):
ignored_doctypes.update(doc_ignore_flags)
if method == "Delete":
ignored_doctypes.update(frappe.get_hooks("ignore_links_on_delete"))
docs = []
for lf in link_fields:
link_dt, link_field, issingle = lf["parent"], lf["fieldname"], lf["issingle"]
if link_dt in ignored_doctypes or (link_field == "amended_from" and method == "Cancel"):
continue
try:
meta = frappe.get_meta(link_dt)
except frappe.DoesNotExistError:
frappe.clear_last_message()
# This mostly happens when app do not remove their customizations, we shouldn't
# prevent link checks from failing in those cases
continue
if issingle:
if frappe.db.get_single_value(link_dt, link_field) == doc.name:
docs.append({"doc": doc.name, "link_dt": link_dt, "link_field": link_field})
continue
fields = ["name", "docstatus"]
if meta.istable:
fields.extend(["parent", "parenttype"])
for item in frappe.db.get_values(link_dt, {link_field: doc.name}, fields, as_dict=True):
# available only in child table cases
item_parent = getattr(item, "parent", None)
linked_parent_doctype = item.parenttype if item_parent else link_dt
if linked_parent_doctype in ignored_doctypes:
continue
if method != "Delete" and (method != "Cancel" or not DocStatus(item.docstatus).is_submitted()):
# don't raise exception if not
# linked to a non-cancelled doc when deleting or to a submitted doc when cancelling
continue
elif link_dt == doc.doctype and (item_parent or item.name) == doc.name:
# don't raise exception if not
# linked to same item or doc having same name as the item
continue
else:
reference_docname = item_parent or item.name
docs.append(
{
"doc": doc.name,
"reference_doctype": linked_parent_doctype,
"reference_docname": reference_docname,
}
)
return docs
# Extracted from frappe core frappe/model/delete_doc.py/check_if_doc_is_dynamically_linked
def get_dynamic_linked_docs(doc, method="Delete"):
docs = []
for df in get_dynamic_link_map().get(doc.doctype, []):
ignore_linked_doctypes = doc.get("ignore_linked_doctypes") or []
if df.parent in frappe.get_hooks("ignore_links_on_delete") or (
df.parent in ignore_linked_doctypes and method == "Cancel"
):
# don't check for communication and todo!
continue
meta = frappe.get_meta(df.parent)
if meta.issingle:
# dynamic link in single doc
refdoc = frappe.db.get_singles_dict(df.parent)
if (
refdoc.get(df.options) == doc.doctype
and refdoc.get(df.fieldname) == doc.name
and (
# linked to an non-cancelled doc when deleting
(method == "Delete" and not DocStatus(refdoc.docstatus).is_cancelled())
# linked to a submitted doc when cancelling
or (method == "Cancel" and DocStatus(refdoc.docstatus).is_submitted())
)
):
docs.append({"doc": doc.name, "reference_doctype": df.parent, "reference_docname": df.parent})
else:
# dynamic link in table
df["table"] = ", `parent`, `parenttype`, `idx`" if meta.istable else ""
for refdoc in frappe.db.sql(
"""select `name`, `docstatus` {table} from `tab{parent}` where
`{options}`=%s and `{fieldname}`=%s""".format(**df),
(doc.doctype, doc.name),
as_dict=True,
):
# linked to an non-cancelled doc when deleting
# or linked to a submitted doc when cancelling
if (method == "Delete" and not DocStatus(refdoc.docstatus).is_cancelled()) or (
method == "Cancel" and DocStatus(refdoc.docstatus).is_submitted()
):
reference_doctype = refdoc.parenttype if meta.istable else df.parent
reference_docname = refdoc.parent if meta.istable else refdoc.name
if reference_doctype in frappe.get_hooks("ignore_links_on_delete") or (
reference_doctype in ignore_linked_doctypes and method == "Cancel"
):
# don't check for communication and todo!
continue
at_position = f"at Row: {refdoc.idx}" if meta.istable else ""
docs.append(
{
"doc": doc.name,
"reference_doctype": reference_doctype,
"reference_docname": reference_docname,
"at_position": at_position,
}
)
return docs
def is_admin(user: str | None = None) -> bool:
"""
Check whether `user` is an admin
:param user: User to check against, defaults to current user
:return: Whether `user` is an admin
"""
user = user or frappe.session.user
return user == "Administrator"
def is_sales_user(user: str | None = None) -> bool:
"""
Check whether `user` is an agent
:param user: User to check against, defaults to current user
:return: Whether `user` is an agent
"""
user = user or frappe.session.user
return is_admin() or "Sales Manager" in frappe.get_roles(user) or "Sales User" in frappe.get_roles(user)
def sales_user_only(fn):
"""Decorator to validate if user is an agent."""
@functools.wraps(fn)
def wrapper(*args, **kwargs):
if not is_sales_user():
frappe.throw(
msg=_("You are not permitted to access this resource."),
title=_("Not Allowed"),
exc=frappe.PermissionError,
)
return fn(*args, **kwargs)
return wrapper

View File

@ -1,10 +1,8 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# GNU GPLv3 License. See license.txt # GNU GPLv3 License. See license.txt
import os
import subprocess
import frappe import frappe
from frappe import safe_decode
from frappe.integrations.frappe_providers.frappecloud_billing import is_fc_site from frappe.integrations.frappe_providers.frappecloud_billing import is_fc_site
from frappe.utils import cint, get_system_timezone from frappe.utils import cint, get_system_timezone
from frappe.utils.telemetry import capture from frappe.utils.telemetry import capture
@ -51,15 +49,3 @@ def get_boot():
def get_default_route(): def get_default_route():
return "/crm" return "/crm"
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 ""

View File

@ -1,8 +1,3 @@
files: files:
- source: /crm/locale/main.pot - source: /crm/locale/main.pot
translation: /crm/locale/%two_letters_code%.po translation: /crm/locale/%two_letters_code%.po
pull_request_title: "chore: sync translations from crowdin"
pull_request_labels:
- translation
commit_message: "chore: %language% translations"
append_commit_message: false

View File

@ -8,21 +8,21 @@ else
echo "Creating new bench..." echo "Creating new bench..."
fi fi
bench init --skip-redis-config-generation frappe-bench --version version-15 bench init --skip-redis-config-generation frappe-bench
cd frappe-bench cd frappe-bench
# Use containers instead of localhost # Use containers instead of localhost
bench set-mariadb-host mariadb bench set-mariadb-host mariadb
bench set-redis-cache-host redis://redis:6379 bench set-redis-cache-host redis:6379
bench set-redis-queue-host redis://redis:6379 bench set-redis-queue-host redis:6379
bench set-redis-socketio-host redis://redis:6379 bench set-redis-socketio-host redis:6379
# Remove redis, watch from Procfile # Remove redis, watch from Procfile
sed -i '/redis/d' ./Procfile sed -i '/redis/d' ./Procfile
sed -i '/watch/d' ./Procfile sed -i '/watch/d' ./Procfile
bench get-app crm --branch main bench get-app crm --branch develop
bench new-site crm.localhost \ bench new-site crm.localhost \
--force \ --force \
@ -32,9 +32,8 @@ bench new-site crm.localhost \
bench --site crm.localhost install-app crm bench --site crm.localhost install-app crm
bench --site crm.localhost set-config developer_mode 1 bench --site crm.localhost set-config developer_mode 1
bench --site crm.localhost set-config mute_emails 1
bench --site crm.localhost set-config server_script_enabled 1
bench --site crm.localhost clear-cache bench --site crm.localhost clear-cache
bench --site crm.localhost set-config mute_emails 1
bench use crm.localhost bench use crm.localhost
bench start bench start

@ -1 +1 @@
Subproject commit c9a0fc937cc897864857271b3708a0c675379015 Subproject commit 29307e4fffaacdbb3d9c5d95c5270b2f245a5607

3
frontend/.gitignore vendored
View File

@ -2,5 +2,4 @@ node_modules
.DS_Store .DS_Store
dist dist
dist-ssr dist-ssr
*.local *.local
components.d.ts

View File

@ -1,10 +0,0 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
}

View File

@ -8,12 +8,9 @@ export {}
/* prettier-ignore */ /* prettier-ignore */
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
AboutModal: typeof import('./src/components/Modals/AboutModal.vue')['default']
Activities: typeof import('./src/components/Activities/Activities.vue')['default'] Activities: typeof import('./src/components/Activities/Activities.vue')['default']
ActivityHeader: typeof import('./src/components/Activities/ActivityHeader.vue')['default'] ActivityHeader: typeof import('./src/components/Activities/ActivityHeader.vue')['default']
ActivityIcon: typeof import('./src/components/Icons/ActivityIcon.vue')['default'] ActivityIcon: typeof import('./src/components/Icons/ActivityIcon.vue')['default']
AddChartModal: typeof import('./src/components/Dashboard/AddChartModal.vue')['default']
AddExistingUserModal: typeof import('./src/components/Modals/AddExistingUserModal.vue')['default']
AddressIcon: typeof import('./src/components/Icons/AddressIcon.vue')['default'] AddressIcon: typeof import('./src/components/Icons/AddressIcon.vue')['default']
AddressModal: typeof import('./src/components/Modals/AddressModal.vue')['default'] AddressModal: typeof import('./src/components/Modals/AddressModal.vue')['default']
AllModals: typeof import('./src/components/Activities/AllModals.vue')['default'] AllModals: typeof import('./src/components/Activities/AllModals.vue')['default']
@ -25,7 +22,6 @@ declare module 'vue' {
AscendingIcon: typeof import('./src/components/Icons/AscendingIcon.vue')['default'] AscendingIcon: typeof import('./src/components/Icons/AscendingIcon.vue')['default']
AssignmentModal: typeof import('./src/components/Modals/AssignmentModal.vue')['default'] AssignmentModal: typeof import('./src/components/Modals/AssignmentModal.vue')['default']
AssignTo: typeof import('./src/components/AssignTo.vue')['default'] AssignTo: typeof import('./src/components/AssignTo.vue')['default']
AssignToBody: typeof import('./src/components/AssignToBody.vue')['default']
AttachmentArea: typeof import('./src/components/Activities/AttachmentArea.vue')['default'] AttachmentArea: typeof import('./src/components/Activities/AttachmentArea.vue')['default']
AttachmentIcon: typeof import('./src/components/Icons/AttachmentIcon.vue')['default'] AttachmentIcon: typeof import('./src/components/Icons/AttachmentIcon.vue')['default']
AttachmentItem: typeof import('./src/components/AttachmentItem.vue')['default'] AttachmentItem: typeof import('./src/components/AttachmentItem.vue')['default']
@ -33,8 +29,6 @@ declare module 'vue' {
Autocomplete: typeof import('./src/components/frappe-ui/Autocomplete.vue')['default'] Autocomplete: typeof import('./src/components/frappe-ui/Autocomplete.vue')['default']
AvatarIcon: typeof import('./src/components/Icons/AvatarIcon.vue')['default'] AvatarIcon: typeof import('./src/components/Icons/AvatarIcon.vue')['default']
BrandLogo: typeof import('./src/components/BrandLogo.vue')['default'] BrandLogo: typeof import('./src/components/BrandLogo.vue')['default']
BrandSettings: typeof import('./src/components/Settings/BrandSettings.vue')['default']
BulkDeleteLinkedDocModal: typeof import('./src/components/BulkDeleteLinkedDocModal.vue')['default']
CalendarIcon: typeof import('./src/components/Icons/CalendarIcon.vue')['default'] CalendarIcon: typeof import('./src/components/Icons/CalendarIcon.vue')['default']
CallArea: typeof import('./src/components/Activities/CallArea.vue')['default'] CallArea: typeof import('./src/components/Activities/CallArea.vue')['default']
CallLogDetailModal: typeof import('./src/components/Modals/CallLogDetailModal.vue')['default'] CallLogDetailModal: typeof import('./src/components/Modals/CallLogDetailModal.vue')['default']
@ -43,7 +37,6 @@ declare module 'vue' {
CallUI: typeof import('./src/components/Telephony/CallUI.vue')['default'] CallUI: typeof import('./src/components/Telephony/CallUI.vue')['default']
CameraIcon: typeof import('./src/components/Icons/CameraIcon.vue')['default'] CameraIcon: typeof import('./src/components/Icons/CameraIcon.vue')['default']
CertificateIcon: typeof import('./src/components/Icons/CertificateIcon.vue')['default'] CertificateIcon: typeof import('./src/components/Icons/CertificateIcon.vue')['default']
ChangePasswordModal: typeof import('./src/components/Modals/ChangePasswordModal.vue')['default']
CheckCircleIcon: typeof import('./src/components/Icons/CheckCircleIcon.vue')['default'] CheckCircleIcon: typeof import('./src/components/Icons/CheckCircleIcon.vue')['default']
CheckIcon: typeof import('./src/components/Icons/CheckIcon.vue')['default'] CheckIcon: typeof import('./src/components/Icons/CheckIcon.vue')['default']
CollapseSidebar: typeof import('./src/components/Icons/CollapseSidebar.vue')['default'] CollapseSidebar: typeof import('./src/components/Icons/CollapseSidebar.vue')['default']
@ -59,22 +52,16 @@ declare module 'vue' {
ContactsIcon: typeof import('./src/components/Icons/ContactsIcon.vue')['default'] ContactsIcon: typeof import('./src/components/Icons/ContactsIcon.vue')['default']
ContactsListView: typeof import('./src/components/ListViews/ContactsListView.vue')['default'] ContactsListView: typeof import('./src/components/ListViews/ContactsListView.vue')['default']
ConvertIcon: typeof import('./src/components/Icons/ConvertIcon.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'] 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'] CRMLogo: typeof import('./src/components/Icons/CRMLogo.vue')['default']
CurrencySettings: typeof import('./src/components/Settings/CurrencySettings.vue')['default']
CustomActions: typeof import('./src/components/CustomActions.vue')['default'] CustomActions: typeof import('./src/components/CustomActions.vue')['default']
DashboardGrid: typeof import('./src/components/Dashboard/DashboardGrid.vue')['default']
DashboardIcon: typeof import('./src/components/Icons/DashboardIcon.vue')['default'] DashboardIcon: typeof import('./src/components/Icons/DashboardIcon.vue')['default']
DashboardItem: typeof import('./src/components/Dashboard/DashboardItem.vue')['default']
DataFields: typeof import('./src/components/Activities/DataFields.vue')['default'] DataFields: typeof import('./src/components/Activities/DataFields.vue')['default']
DataFieldsModal: typeof import('./src/components/Modals/DataFieldsModal.vue')['default'] DataFieldsModal: typeof import('./src/components/Modals/DataFieldsModal.vue')['default']
DealModal: typeof import('./src/components/Modals/DealModal.vue')['default'] DealModal: typeof import('./src/components/Modals/DealModal.vue')['default']
DealsIcon: typeof import('./src/components/Icons/DealsIcon.vue')['default'] DealsIcon: typeof import('./src/components/Icons/DealsIcon.vue')['default']
DealsListView: typeof import('./src/components/ListViews/DealsListView.vue')['default'] DealsListView: typeof import('./src/components/ListViews/DealsListView.vue')['default']
DeclinedCallIcon: typeof import('./src/components/Icons/DeclinedCallIcon.vue')['default'] DeclinedCallIcon: typeof import('./src/components/Icons/DeclinedCallIcon.vue')['default']
DeleteLinkedDocModal: typeof import('./src/components/DeleteLinkedDocModal.vue')['default']
DesendingIcon: typeof import('./src/components/Icons/DesendingIcon.vue')['default'] DesendingIcon: typeof import('./src/components/Icons/DesendingIcon.vue')['default']
DesktopLayout: typeof import('./src/components/Layouts/DesktopLayout.vue')['default'] DesktopLayout: typeof import('./src/components/Layouts/DesktopLayout.vue')['default']
DetailsIcon: typeof import('./src/components/Icons/DetailsIcon.vue')['default'] DetailsIcon: typeof import('./src/components/Icons/DetailsIcon.vue')['default']
@ -85,9 +72,9 @@ declare module 'vue' {
DragIcon: typeof import('./src/components/Icons/DragIcon.vue')['default'] DragIcon: typeof import('./src/components/Icons/DragIcon.vue')['default']
DragVerticalIcon: typeof import('./src/components/Icons/DragVerticalIcon.vue')['default'] DragVerticalIcon: typeof import('./src/components/Icons/DragVerticalIcon.vue')['default']
Dropdown: typeof import('./src/components/frappe-ui/Dropdown.vue')['default'] Dropdown: typeof import('./src/components/frappe-ui/Dropdown.vue')['default']
DropdownItem: typeof import('./src/components/DropdownItem.vue')['default']
DuplicateIcon: typeof import('./src/components/Icons/DuplicateIcon.vue')['default'] DuplicateIcon: typeof import('./src/components/Icons/DuplicateIcon.vue')['default']
DurationIcon: typeof import('./src/components/Icons/DurationIcon.vue')['default'] DurationIcon: typeof import('./src/components/Icons/DurationIcon.vue')['default']
EditEmailTemplate: typeof import('./src/components/Settings/EmailTemplate/EditEmailTemplate.vue')['default']
EditIcon: typeof import('./src/components/Icons/EditIcon.vue')['default'] EditIcon: typeof import('./src/components/Icons/EditIcon.vue')['default']
EditValueModal: typeof import('./src/components/Modals/EditValueModal.vue')['default'] EditValueModal: typeof import('./src/components/Modals/EditValueModal.vue')['default']
Email2Icon: typeof import('./src/components/Icons/Email2Icon.vue')['default'] Email2Icon: typeof import('./src/components/Icons/Email2Icon.vue')['default']
@ -102,13 +89,11 @@ declare module 'vue' {
EmailEditor: typeof import('./src/components/EmailEditor.vue')['default'] EmailEditor: typeof import('./src/components/EmailEditor.vue')['default']
EmailIcon: typeof import('./src/components/Icons/EmailIcon.vue')['default'] EmailIcon: typeof import('./src/components/Icons/EmailIcon.vue')['default']
EmailProviderIcon: typeof import('./src/components/Settings/EmailProviderIcon.vue')['default'] EmailProviderIcon: typeof import('./src/components/Settings/EmailProviderIcon.vue')['default']
EmailTemplateIcon: typeof import('./src/components/Icons/EmailTemplateIcon.vue')['default'] EmailTemplateModal: typeof import('./src/components/Modals/EmailTemplateModal.vue')['default']
EmailTemplatePage: typeof import('./src/components/Settings/EmailTemplate/EmailTemplatePage.vue')['default']
EmailTemplates: typeof import('./src/components/Settings/EmailTemplate/EmailTemplates.vue')['default']
EmailTemplateSelectorModal: typeof import('./src/components/Modals/EmailTemplateSelectorModal.vue')['default'] EmailTemplateSelectorModal: typeof import('./src/components/Modals/EmailTemplateSelectorModal.vue')['default']
EmailTemplatesListView: typeof import('./src/components/ListViews/EmailTemplatesListView.vue')['default']
ERPNextIcon: typeof import('./src/components/Icons/ERPNextIcon.vue')['default'] ERPNextIcon: typeof import('./src/components/Icons/ERPNextIcon.vue')['default']
ERPNextSettings: typeof import('./src/components/Settings/ERPNextSettings.vue')['default'] ERPNextSettings: typeof import('./src/components/Settings/ERPNextSettings.vue')['default']
ErrorPage: typeof import('./src/components/ErrorPage.vue')['default']
ExotelCallUI: typeof import('./src/components/Telephony/ExotelCallUI.vue')['default'] ExotelCallUI: typeof import('./src/components/Telephony/ExotelCallUI.vue')['default']
ExportIcon: typeof import('./src/components/Icons/ExportIcon.vue')['default'] ExportIcon: typeof import('./src/components/Icons/ExportIcon.vue')['default']
ExternalLinkIcon: typeof import('./src/components/Icons/ExternalLinkIcon.vue')['default'] ExternalLinkIcon: typeof import('./src/components/Icons/ExternalLinkIcon.vue')['default']
@ -127,11 +112,9 @@ declare module 'vue' {
FileVideoIcon: typeof import('./src/components/Icons/FileVideoIcon.vue')['default'] FileVideoIcon: typeof import('./src/components/Icons/FileVideoIcon.vue')['default']
Filter: typeof import('./src/components/Filter.vue')['default'] Filter: typeof import('./src/components/Filter.vue')['default']
FilterIcon: typeof import('./src/components/Icons/FilterIcon.vue')['default'] FilterIcon: typeof import('./src/components/Icons/FilterIcon.vue')['default']
ForecastingSettings: typeof import('./src/components/Settings/ForecastingSettings.vue')['default']
FormattedInput: typeof import('./src/components/Controls/FormattedInput.vue')['default']
FrappeCloudIcon: typeof import('./src/components/Icons/FrappeCloudIcon.vue')['default'] FrappeCloudIcon: typeof import('./src/components/Icons/FrappeCloudIcon.vue')['default']
GenderIcon: typeof import('./src/components/Icons/GenderIcon.vue')['default'] GenderIcon: typeof import('./src/components/Icons/GenderIcon.vue')['default']
GlobalModals: typeof import('./src/components/Modals/GlobalModals.vue')['default'] GeneralSettings: typeof import('./src/components/Settings/GeneralSettings.vue')['default']
GoogleIcon: typeof import('./src/components/Icons/GoogleIcon.vue')['default'] GoogleIcon: typeof import('./src/components/Icons/GoogleIcon.vue')['default']
Grid: typeof import('./src/components/Controls/Grid.vue')['default'] Grid: typeof import('./src/components/Controls/Grid.vue')['default']
GridFieldsEditorModal: typeof import('./src/components/Controls/GridFieldsEditorModal.vue')['default'] GridFieldsEditorModal: typeof import('./src/components/Controls/GridFieldsEditorModal.vue')['default']
@ -141,7 +124,6 @@ declare module 'vue' {
GroupByIcon: typeof import('./src/components/Icons/GroupByIcon.vue')['default'] GroupByIcon: typeof import('./src/components/Icons/GroupByIcon.vue')['default']
HeartIcon: typeof import('./src/components/Icons/HeartIcon.vue')['default'] HeartIcon: typeof import('./src/components/Icons/HeartIcon.vue')['default']
HelpIcon: typeof import('./src/components/Icons/HelpIcon.vue')['default'] HelpIcon: typeof import('./src/components/Icons/HelpIcon.vue')['default']
HomeActions: typeof import('./src/components/Settings/HomeActions.vue')['default']
Icon: typeof import('./src/components/Icon.vue')['default'] Icon: typeof import('./src/components/Icon.vue')['default']
IconPicker: typeof import('./src/components/IconPicker.vue')['default'] IconPicker: typeof import('./src/components/IconPicker.vue')['default']
ImageUploader: typeof import('./src/components/Controls/ImageUploader.vue')['default'] ImageUploader: typeof import('./src/components/Controls/ImageUploader.vue')['default']
@ -149,25 +131,22 @@ declare module 'vue' {
InboxIcon: typeof import('./src/components/Icons/InboxIcon.vue')['default'] InboxIcon: typeof import('./src/components/Icons/InboxIcon.vue')['default']
IndicatorIcon: typeof import('./src/components/Icons/IndicatorIcon.vue')['default'] IndicatorIcon: typeof import('./src/components/Icons/IndicatorIcon.vue')['default']
InviteIcon: typeof import('./src/components/Icons/InviteIcon.vue')['default'] InviteIcon: typeof import('./src/components/Icons/InviteIcon.vue')['default']
InviteUserPage: typeof import('./src/components/Settings/InviteUserPage.vue')['default'] InviteMemberPage: typeof import('./src/components/Settings/InviteMemberPage.vue')['default']
KanbanIcon: typeof import('./src/components/Icons/KanbanIcon.vue')['default'] KanbanIcon: typeof import('./src/components/Icons/KanbanIcon.vue')['default']
KanbanSettings: typeof import('./src/components/Kanban/KanbanSettings.vue')['default'] KanbanSettings: typeof import('./src/components/Kanban/KanbanSettings.vue')['default']
KanbanView: typeof import('./src/components/Kanban/KanbanView.vue')['default'] KanbanView: typeof import('./src/components/Kanban/KanbanView.vue')['default']
KeyboardShortcut: typeof import('./src/components/KeyboardShortcut.vue')['default']
LayoutHeader: typeof import('./src/components/LayoutHeader.vue')['default'] LayoutHeader: typeof import('./src/components/LayoutHeader.vue')['default']
LeadModal: typeof import('./src/components/Modals/LeadModal.vue')['default'] LeadModal: typeof import('./src/components/Modals/LeadModal.vue')['default']
LeadsIcon: typeof import('./src/components/Icons/LeadsIcon.vue')['default'] LeadsIcon: typeof import('./src/components/Icons/LeadsIcon.vue')['default']
LeadsListView: typeof import('./src/components/ListViews/LeadsListView.vue')['default'] LeadsListView: typeof import('./src/components/ListViews/LeadsListView.vue')['default']
LightningIcon: typeof import('./src/components/Icons/LightningIcon.vue')['default'] LightningIcon: typeof import('./src/components/Icons/LightningIcon.vue')['default']
Link: typeof import('./src/components/Controls/Link.vue')['default'] Link: typeof import('./src/components/Controls/Link.vue')['default']
LinkedDocsListView: typeof import('./src/components/ListViews/LinkedDocsListView.vue')['default']
LinkIcon: typeof import('./src/components/Icons/LinkIcon.vue')['default'] LinkIcon: typeof import('./src/components/Icons/LinkIcon.vue')['default']
ListBulkActions: typeof import('./src/components/ListBulkActions.vue')['default'] ListBulkActions: typeof import('./src/components/ListBulkActions.vue')['default']
ListIcon: typeof import('./src/components/Icons/ListIcon.vue')['default'] ListIcon: typeof import('./src/components/Icons/ListIcon.vue')['default']
ListRows: typeof import('./src/components/ListViews/ListRows.vue')['default'] ListRows: typeof import('./src/components/ListViews/ListRows.vue')['default']
LoadingIndicator: typeof import('./src/components/Icons/LoadingIndicator.vue')['default'] LoadingIndicator: typeof import('./src/components/Icons/LoadingIndicator.vue')['default']
LostReasonModal: typeof import('./src/components/Modals/LostReasonModal.vue')['default'] LucidePlus: typeof import('~icons/lucide/plus')['default']
LucideCalendar: typeof import('~icons/lucide/calendar')['default']
MarkAsDoneIcon: typeof import('./src/components/Icons/MarkAsDoneIcon.vue')['default'] MarkAsDoneIcon: typeof import('./src/components/Icons/MarkAsDoneIcon.vue')['default']
MaximizeIcon: typeof import('./src/components/Icons/MaximizeIcon.vue')['default'] MaximizeIcon: typeof import('./src/components/Icons/MaximizeIcon.vue')['default']
MenuIcon: typeof import('./src/components/Icons/MenuIcon.vue')['default'] MenuIcon: typeof import('./src/components/Icons/MenuIcon.vue')['default']
@ -177,12 +156,10 @@ declare module 'vue' {
MobileLayout: typeof import('./src/components/Layouts/MobileLayout.vue')['default'] MobileLayout: typeof import('./src/components/Layouts/MobileLayout.vue')['default']
MobileSidebar: typeof import('./src/components/Mobile/MobileSidebar.vue')['default'] MobileSidebar: typeof import('./src/components/Mobile/MobileSidebar.vue')['default']
MoneyIcon: typeof import('./src/components/Icons/MoneyIcon.vue')['default'] MoneyIcon: typeof import('./src/components/Icons/MoneyIcon.vue')['default']
MultiActionButton: typeof import('./src/components/MultiActionButton.vue')['default']
MultipleAvatar: typeof import('./src/components/MultipleAvatar.vue')['default'] MultipleAvatar: typeof import('./src/components/MultipleAvatar.vue')['default']
MultiSelectEmailInput: typeof import('./src/components/Controls/MultiSelectEmailInput.vue')['default'] MultiSelectEmailInput: typeof import('./src/components/Controls/MultiSelectEmailInput.vue')['default']
MultiSelectUserInput: typeof import('./src/components/Controls/MultiSelectUserInput.vue')['default']
MuteIcon: typeof import('./src/components/Icons/MuteIcon.vue')['default'] MuteIcon: typeof import('./src/components/Icons/MuteIcon.vue')['default']
NewEmailTemplate: typeof import('./src/components/Settings/EmailTemplate/NewEmailTemplate.vue')['default'] NestedPopover: typeof import('./src/components/NestedPopover.vue')['default']
NoteArea: typeof import('./src/components/Activities/NoteArea.vue')['default'] NoteArea: typeof import('./src/components/Activities/NoteArea.vue')['default']
NoteIcon: typeof import('./src/components/Icons/NoteIcon.vue')['default'] NoteIcon: typeof import('./src/components/Icons/NoteIcon.vue')['default']
NoteModal: typeof import('./src/components/Modals/NoteModal.vue')['default'] NoteModal: typeof import('./src/components/Modals/NoteModal.vue')['default']
@ -192,7 +169,6 @@ declare module 'vue' {
OrganizationsIcon: typeof import('./src/components/Icons/OrganizationsIcon.vue')['default'] OrganizationsIcon: typeof import('./src/components/Icons/OrganizationsIcon.vue')['default']
OrganizationsListView: typeof import('./src/components/ListViews/OrganizationsListView.vue')['default'] OrganizationsListView: typeof import('./src/components/ListViews/OrganizationsListView.vue')['default']
OutboundCallIcon: typeof import('./src/components/Icons/OutboundCallIcon.vue')['default'] OutboundCallIcon: typeof import('./src/components/Icons/OutboundCallIcon.vue')['default']
Password: typeof import('./src/components/Controls/Password.vue')['default']
PauseIcon: typeof import('./src/components/Icons/PauseIcon.vue')['default'] PauseIcon: typeof import('./src/components/Icons/PauseIcon.vue')['default']
PhoneIcon: typeof import('./src/components/Icons/PhoneIcon.vue')['default'] PhoneIcon: typeof import('./src/components/Icons/PhoneIcon.vue')['default']
PinIcon: typeof import('./src/components/Icons/PinIcon.vue')['default'] PinIcon: typeof import('./src/components/Icons/PinIcon.vue')['default']
@ -200,8 +176,7 @@ declare module 'vue' {
PlaybackSpeedOption: typeof import('./src/components/Activities/PlaybackSpeedOption.vue')['default'] PlaybackSpeedOption: typeof import('./src/components/Activities/PlaybackSpeedOption.vue')['default']
PlayIcon: typeof import('./src/components/Icons/PlayIcon.vue')['default'] PlayIcon: typeof import('./src/components/Icons/PlayIcon.vue')['default']
Popover: typeof import('./src/components/frappe-ui/Popover.vue')['default'] Popover: typeof import('./src/components/frappe-ui/Popover.vue')['default']
PrimaryDropdown: typeof import('./src/components/PrimaryDropdown.vue')['default'] ProfileImageEditor: typeof import('./src/components/Settings/ProfileImageEditor.vue')['default']
PrimaryDropdownItem: typeof import('./src/components/PrimaryDropdownItem.vue')['default']
ProfileSettings: typeof import('./src/components/Settings/ProfileSettings.vue')['default'] ProfileSettings: typeof import('./src/components/Settings/ProfileSettings.vue')['default']
QuickEntryModal: typeof import('./src/components/Modals/QuickEntryModal.vue')['default'] QuickEntryModal: typeof import('./src/components/Modals/QuickEntryModal.vue')['default']
QuickFilterField: typeof import('./src/components/QuickFilterField.vue')['default'] QuickFilterField: typeof import('./src/components/QuickFilterField.vue')['default']
@ -228,8 +203,6 @@ declare module 'vue' {
SmileIcon: typeof import('./src/components/Icons/SmileIcon.vue')['default'] SmileIcon: typeof import('./src/components/Icons/SmileIcon.vue')['default']
SortBy: typeof import('./src/components/SortBy.vue')['default'] SortBy: typeof import('./src/components/SortBy.vue')['default']
SortIcon: typeof import('./src/components/Icons/SortIcon.vue')['default'] SortIcon: typeof import('./src/components/Icons/SortIcon.vue')['default']
SparkleIcon: typeof import('./src/components/Icons/SparkleIcon.vue')['default']
SquareAsterisk: typeof import('./src/components/Icons/SquareAsterisk.vue')['default']
StepsIcon: typeof import('./src/components/Icons/StepsIcon.vue')['default'] StepsIcon: typeof import('./src/components/Icons/StepsIcon.vue')['default']
SuccessIcon: typeof import('./src/components/Icons/SuccessIcon.vue')['default'] SuccessIcon: typeof import('./src/components/Icons/SuccessIcon.vue')['default']
TableMultiselectInput: typeof import('./src/components/Controls/TableMultiselectInput.vue')['default'] TableMultiselectInput: typeof import('./src/components/Controls/TableMultiselectInput.vue')['default']
@ -240,14 +213,12 @@ declare module 'vue' {
TaskPriorityIcon: typeof import('./src/components/Icons/TaskPriorityIcon.vue')['default'] TaskPriorityIcon: typeof import('./src/components/Icons/TaskPriorityIcon.vue')['default']
TasksListView: typeof import('./src/components/ListViews/TasksListView.vue')['default'] TasksListView: typeof import('./src/components/ListViews/TasksListView.vue')['default']
TaskStatusIcon: typeof import('./src/components/Icons/TaskStatusIcon.vue')['default'] TaskStatusIcon: typeof import('./src/components/Icons/TaskStatusIcon.vue')['default']
TelegramIcon: typeof import('./src/components/Icons/TelegramIcon.vue')['default']
TelephonySettings: typeof import('./src/components/Settings/TelephonySettings.vue')['default'] TelephonySettings: typeof import('./src/components/Settings/TelephonySettings.vue')['default']
TerritoryIcon: typeof import('./src/components/Icons/TerritoryIcon.vue')['default'] TerritoryIcon: typeof import('./src/components/Icons/TerritoryIcon.vue')['default']
TwilioCallUI: typeof import('./src/components/Telephony/TwilioCallUI.vue')['default'] TwilioCallUI: typeof import('./src/components/Telephony/TwilioCallUI.vue')['default']
UnpinIcon: typeof import('./src/components/Icons/UnpinIcon.vue')['default'] UnpinIcon: typeof import('./src/components/Icons/UnpinIcon.vue')['default']
UserAvatar: typeof import('./src/components/UserAvatar.vue')['default'] UserAvatar: typeof import('./src/components/UserAvatar.vue')['default']
UserDropdown: typeof import('./src/components/UserDropdown.vue')['default'] UserDropdown: typeof import('./src/components/UserDropdown.vue')['default']
Users: typeof import('./src/components/Settings/Users.vue')['default']
ViewBreadcrumbs: typeof import('./src/components/ViewBreadcrumbs.vue')['default'] ViewBreadcrumbs: typeof import('./src/components/ViewBreadcrumbs.vue')['default']
ViewControls: typeof import('./src/components/ViewControls.vue')['default'] ViewControls: typeof import('./src/components/ViewControls.vue')['default']
ViewModal: typeof import('./src/components/Modals/ViewModal.vue')['default'] ViewModal: typeof import('./src/components/Modals/ViewModal.vue')['default']

Some files were not shown because too many files have changed in this diff Show More