diff --git a/crm/api/doc.py b/crm/api/doc.py
index 16e6b2ef..1369a952 100644
--- a/crm/api/doc.py
+++ b/crm/api/doc.py
@@ -11,6 +11,7 @@ from pypika import Criterion
from crm.api.views import get_views
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()
@@ -676,6 +677,7 @@ def remove_assignments(doctype, name, assignees, ignore_permissions=False):
ignore_permissions=ignore_permissions,
)
+
@frappe.whitelist()
def get_assigned_users(doctype, name, default_assigned_to=None):
assigned_users = frappe.get_all(
@@ -744,3 +746,98 @@ def getCounts(d, doctype):
"FCRM Note", filters={"reference_doctype": doctype, "reference_docname": d.get("name")}
)
return d
+
+
+@frappe.whitelist()
+def get_linked_docs_of_document(doctype, docname):
+ doc = frappe.get_doc(doctype, docname)
+ 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:
+ data = frappe.get_doc(doc["reference_doctype"], doc["reference_docname"])
+ 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")
+
+ 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):
+ linked_doc_data = frappe.get_doc(doctype, docname)
+ linked_doc_data.update(
+ {
+ "reference_doctype": None,
+ "reference_docname": None,
+ }
+ )
+ linked_doc_data.save(ignore_permissions=True)
+
+
+def remove_contact_link(doctype, docname):
+ linked_doc_data = frappe.get_doc(doctype, docname)
+ linked_doc_data.update(
+ {
+ "contact": None,
+ "contacts": [],
+ }
+ )
+ linked_doc_data.save(ignore_permissions=True)
+
+
+@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 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"])
+
+ return "success"
+
+
+@frappe.whitelist()
+def delete_bulk_docs(doctype, items, delete_linked=False):
+ from frappe.desk.reportview import delete_bulk
+
+ items = frappe.parse_json(items)
+ for doc in items:
+ linked_docs = get_linked_docs_of_document(doctype, doc)
+ for linked_doc in linked_docs:
+ remove_linked_doc_reference(
+ [
+ {
+ "doctype": linked_doc["reference_doctype"],
+ "docname": linked_doc["reference_docname"],
+ }
+ ],
+ remove_contact=doctype == "Contact",
+ delete=delete_linked,
+ )
+
+ if len(items) > 10:
+ frappe.enqueue("frappe.desk.reportview.delete_bulk", doctype=doctype, items=items)
+ else:
+ delete_bulk(doctype, items)
+ return "success"
diff --git a/crm/utils/__init__.py b/crm/utils/__init__.py
index cd4a4ff8..9b954859 100644
--- a/crm/utils/__init__.py
+++ b/crm/utils/__init__.py
@@ -1,7 +1,10 @@
+from frappe import frappe
import phonenumbers
from frappe.utils import floor
from phonenumbers import NumberParseException
from phonenumbers import PhoneNumberFormat as PNF
+from frappe.model.docstatus import DocStatus
+from frappe.model.dynamic_links import get_dynamic_link_map
def parse_phone_number(phone_number, default_country="IN"):
@@ -93,3 +96,129 @@ def seconds_to_duration(seconds):
return f"{seconds}s"
else:
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
diff --git a/frontend/components.d.ts b/frontend/components.d.ts
index acc8ffde..d4d287e4 100644
--- a/frontend/components.d.ts
+++ b/frontend/components.d.ts
@@ -31,6 +31,7 @@ declare module 'vue' {
Autocomplete: typeof import('./src/components/frappe-ui/Autocomplete.vue')['default']
AvatarIcon: typeof import('./src/components/Icons/AvatarIcon.vue')['default']
BrandLogo: typeof import('./src/components/BrandLogo.vue')['default']
+ BulkDeleteLinkedDocModal: typeof import('./src/components/BulkDeleteLinkedDocModal.vue')['default']
CalendarIcon: typeof import('./src/components/Icons/CalendarIcon.vue')['default']
CallArea: typeof import('./src/components/Activities/CallArea.vue')['default']
CallLogDetailModal: typeof import('./src/components/Modals/CallLogDetailModal.vue')['default']
@@ -66,6 +67,7 @@ declare module 'vue' {
DealsIcon: typeof import('./src/components/Icons/DealsIcon.vue')['default']
DealsListView: typeof import('./src/components/ListViews/DealsListView.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']
DesktopLayout: typeof import('./src/components/Layouts/DesktopLayout.vue')['default']
DetailsIcon: typeof import('./src/components/Icons/DetailsIcon.vue')['default']
@@ -153,11 +155,13 @@ declare module 'vue' {
LeadsListView: typeof import('./src/components/ListViews/LeadsListView.vue')['default']
LightningIcon: typeof import('./src/components/Icons/LightningIcon.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']
ListBulkActions: typeof import('./src/components/ListBulkActions.vue')['default']
ListIcon: typeof import('./src/components/Icons/ListIcon.vue')['default']
ListRows: typeof import('./src/components/ListViews/ListRows.vue')['default']
LoadingIndicator: typeof import('./src/components/Icons/LoadingIndicator.vue')['default']
+ LucideCalendar: typeof import('~icons/lucide/calendar')['default']
LucideInfo: typeof import('~icons/lucide/info')['default']
LucideMoreHorizontal: typeof import('~icons/lucide/more-horizontal')['default']
LucidePlus: typeof import('~icons/lucide/plus')['default']
diff --git a/frontend/src/components/ListBulkActions.vue b/frontend/src/components/ListBulkActions.vue
index 76b41650..75026362 100644
--- a/frontend/src/components/ListBulkActions.vue
+++ b/frontend/src/components/ListBulkActions.vue
@@ -14,6 +14,20 @@
:doctype="doctype"
@reload="reload"
/>
+
+
diff --git a/frontend/src/pages/Contact.vue b/frontend/src/pages/Contact.vue
index dff41b97..396db22f 100644
--- a/frontend/src/pages/Contact.vue
+++ b/frontend/src/pages/Contact.vue
@@ -105,7 +105,7 @@
:label="__('Delete')"
theme="red"
size="sm"
- @click="deleteContact"
+ @click="deleteContact()"
>
@@ -172,6 +172,13 @@
:errorTitle="errorTitle"
:errorMessage="errorMessage"
/>
+
diff --git a/frontend/src/pages/Organization.vue b/frontend/src/pages/Organization.vue
index a339e75d..ab4239ab 100644
--- a/frontend/src/pages/Organization.vue
+++ b/frontend/src/pages/Organization.vue
@@ -83,7 +83,7 @@
:label="__('Delete')"
theme="red"
size="sm"
- @click="deleteOrganization"
+ @click="deleteOrganization()"
>
@@ -164,6 +164,13 @@
:errorTitle="errorTitle"
:errorMessage="errorMessage"
/>
+
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index f4de2403..97d2b193 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -2,11 +2,72 @@ import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import path from 'path'
+import fs from 'fs'
import frappeui from 'frappe-ui/vite'
import { VitePWA } from 'vite-plugin-pwa'
+function appPath(app) {
+ const root = path.resolve(__dirname, '../..') // points to apps
+ const frontendPaths = [
+ // Standard frontend structure: appname/frontend/src
+ path.join(root, app, 'frontend', 'src'),
+ // Desk-based apps: appname/desk/src
+ path.join(root, app, 'desk', 'src'),
+ // Alternative frontend structures
+ path.join(root, app, 'client', 'src'),
+ path.join(root, app, 'ui', 'src'),
+ // Direct src structure: appname/src
+ path.join(root, app, 'src'),
+ ]
+ return frontendPaths.find((srcPath) => fs.existsSync(srcPath)) || null
+}
+
+function hasApp(app) {
+ return fs.existsSync(appPath(app))
+}
+
+// List of frontend apps used in this project
+let apps = []
+
+const alias = [
+ // Default "@" for this app
+ {
+ find: '@',
+ replacement: path.resolve(__dirname, 'src'),
+ },
+
+ // App-specific aliases like @helpdesk, @hrms, etc.
+ ...apps.map((app) =>
+ hasApp(app)
+ ? { find: `@${app}`, replacement: appPath(app) }
+ : { find: `@${app}`, replacement: `virtual:${app}` },
+ ),
+]
+
+const defineFlags = Object.fromEntries(
+ apps.map((app) => [
+ `__HAS_${app.toUpperCase()}__`,
+ JSON.stringify(hasApp(app)),
+ ]),
+)
+
+const virtualStubPlugin = {
+ name: 'virtual-empty-modules',
+ resolveId(id) {
+ if (id.startsWith('virtual:')) return '\0' + id
+ },
+ load(id) {
+ if (id.startsWith('\0virtual:')) {
+ return 'export default {}; export const missing = true;'
+ }
+ },
+}
+
+console.log('Generated app aliases:', alias)
+
// https://vitejs.dev/config/
export default defineConfig({
+ define: defineFlags,
plugins: [
frappeui({
frappeProxy: true,
@@ -60,12 +121,9 @@ export default defineConfig({
],
},
}),
+ virtualStubPlugin,
],
- resolve: {
- alias: {
- '@': path.resolve(__dirname, 'src'),
- },
- },
+ resolve: { alias },
optimizeDeps: {
include: [
'feather-icons',