Merge pull request #846 from pratikb64/delete-from-record-view

This commit is contained in:
Shariq Ansari 2025-06-23 13:50:19 +05:30 committed by GitHub
commit 58d4691354
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 965 additions and 76 deletions

View File

@ -11,6 +11,7 @@ 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()
@ -677,6 +678,7 @@ def remove_assignments(doctype, name, assignees, ignore_permissions=False):
ignore_permissions=ignore_permissions, ignore_permissions=ignore_permissions,
) )
@frappe.whitelist() @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(
@ -745,3 +747,98 @@ 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):
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"

View File

@ -1,7 +1,10 @@
from frappe import frappe
import phonenumbers import phonenumbers
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
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"): def parse_phone_number(phone_number, default_country="IN"):
@ -93,3 +96,129 @@ 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

View File

@ -31,6 +31,7 @@ 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']
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']
@ -66,6 +67,7 @@ declare module 'vue' {
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']
@ -149,6 +151,7 @@ declare module 'vue' {
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']

View File

@ -0,0 +1,165 @@
<template>
<Dialog v-model="show" :options="{ size: 'xl' }">
<template #body>
<div class="bg-surface-modal px-4 pb-6 pt-5 sm:px-6">
<div class="mb-6 flex items-center justify-between">
<div>
<h3 class="text-2xl leading-6 text-ink-gray-9 font-semibold">
{{ __('Delete') }}
</h3>
</div>
<div class="flex items-center gap-1">
<Button variant="ghost" class="w-7" @click="show = false">
<FeatherIcon name="x" class="h-4 w-4" />
</Button>
</div>
</div>
<div>
<div class="text-ink-gray-5">
{{
__('Are you sure you want to delete {0} items?', [
props.items?.length,
])
}}
</div>
</div>
</div>
<div class="px-4 pb-7 pt-0 sm:px-6">
<div class="flex flex-row-reverse gap-2">
<Button theme="red" variant="solid" @click="confirmDelete()">
<div class="flex gap-1">
<FeatherIcon name="trash" class="h-4 w-4" />
<span>
{{ __('Delete {0} items', [props.items.length]) }}
</span>
</div>
</Button>
<Button variant="solid" @click="confirmUnlink()">
<div class="flex gap-1">
<FeatherIcon name="unlock" class="h-4 w-4" />
<span>
{{ __('Unlink and delete {0} items', [props.items.length]) }}
</span>
</div>
</Button>
</div>
</div>
</template>
<template #body v-if="confirmDeleteInfo.show">
<div class="bg-surface-modal px-4 pb-6 pt-5 sm:px-6">
<div class="mb-6 flex items-center justify-between">
<div>
<h3 class="text-2xl leading-6 text-ink-gray-9 font-semibold">
{{ __('Delete') }}
</h3>
</div>
<div class="flex items-center gap-1">
<Button variant="ghost" class="w-7" @click="show = false">
<FeatherIcon name="x" class="h-4 w-4" />
</Button>
</div>
</div>
<div>
<div class="text-ink-gray-5">
{{
confirmDeleteInfo.delete
? __(
'This will delete selected items and items linked to it, are you sure?',
)
: __(
'This will delete selected items and unlink linked items to it, are you sure?',
)
}}
</div>
</div>
</div>
<div class="px-4 pb-7 pt-0 sm:px-6">
<div class="flex flex-row-reverse gap-2">
<Button variant="solid" theme="red" @click="deleteDocs()">
<div class="flex gap-1">
<span>
{{
confirmDeleteInfo.delete
? __('Delete')
: __('Unlink and delete')
}}
</span>
</div>
</Button>
<Button variant="subtle" @click="confirmDeleteInfo.show = false">
<div class="flex gap-1">
<span>
{{ __('Cancel') }}
</span>
</div>
</Button>
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import { call } from 'frappe-ui'
import { ref } from 'vue'
const show = defineModel()
const props = defineProps({
doctype: {
type: String,
required: true,
},
items: {
type: Array,
required: true,
},
reload: {
type: Function,
required: true,
},
})
const confirmDeleteInfo = ref({
show: false,
title: '',
message: '',
delete: false,
})
const confirmDelete = () => {
confirmDeleteInfo.value = {
show: true,
title: __('Delete'),
message: __('Are you sure you want to delete {0} linked doc(s)?', [
props.items.length,
]),
delete: true,
}
}
const confirmUnlink = () => {
confirmDeleteInfo.value = {
show: true,
title: __('Unlink'),
message: __('Are you sure you want to unlink {0} linked doc(s)?', [
props.items.length,
]),
delete: false,
}
}
const deleteDocs = () => {
call('crm.api.doc.delete_bulk_docs', {
items: props.items,
doctype: props.doctype,
delete_linked: confirmDeleteInfo.value.delete,
}).then(() => {
confirmDeleteInfo.value = {
show: false,
title: '',
}
show.value = false
props.reload()
})
}
</script>

View File

@ -0,0 +1,280 @@
<template>
<Dialog v-model="show" :options="{ size: 'xl' }">
<template #body v-if="!confirmDeleteInfo.show">
<div class="bg-surface-modal px-4 pb-6 pt-5 sm:px-6">
<div class="mb-4 flex items-center justify-between">
<div>
<h3 class="text-2xl leading-6 text-ink-gray-9 font-semibold">
{{
linkedDocs?.length == 0
? __('Delete')
: __('Delete or unlink linked documents')
}}
</h3>
</div>
<div class="flex items-center gap-1">
<Button variant="ghost" class="w-7" @click="show = false">
<FeatherIcon name="x" class="h-4 w-4" />
</Button>
</div>
</div>
<div>
<div v-if="linkedDocs?.length > 0">
<span class="text-ink-gray-5 text-base">
{{
__(
'Delete or unlink these linked documents before deleting this document',
)
}}
</span>
<LinkedDocsListView
class="mt-4"
:rows="linkedDocs"
:columns="[
{
label: 'Document',
key: 'title',
},
{
label: 'Master',
key: 'reference_doctype',
width: '30%',
},
]"
@selectionsChanged="
(selections) => viewControls.updateSelections(selections)
"
:linkedDocsResource="linkedDocsResource"
:unlinkLinkedDoc="unlinkLinkedDoc"
/>
</div>
<div v-if="linkedDocs?.length == 0" class="text-ink-gray-5 text-base">
{{
__('Are you sure you want to delete {0} - {1}?', [
props.doctype,
props.docname,
])
}}
</div>
</div>
</div>
<div class="px-4 pb-7 pt-0 sm:px-6">
<div class="flex flex-row-reverse gap-2">
<Button
v-if="linkedDocs?.length > 0"
theme="red"
variant="solid"
@click="confirmDelete()"
>
<div class="flex gap-1">
<FeatherIcon name="trash" class="h-4 w-4" />
<span>
{{ __('Delete') }}
{{
viewControls?.selections?.length == 0
? __('all')
: `${viewControls?.selections?.length} item(s)`
}}
</span>
</div>
</Button>
<Button
v-if="linkedDocs?.length > 0"
variant="subtle"
theme="gray"
@click="confirmUnlink()"
>
<div class="flex gap-1">
<FeatherIcon name="unlock" class="h-4 w-4" />
<span>
{{ __('Unlink') }}
{{
viewControls?.selections?.length == 0
? __('all')
: `${viewControls?.selections?.length} item(s)`
}}
</span>
</div>
</Button>
<Button
v-if="linkedDocs?.length == 0"
variant="solid"
:label="__('Delete')"
:loading="isDealCreating"
@click="deleteDoc()"
theme="red"
/>
</div>
</div>
</template>
<template #body v-if="confirmDeleteInfo.show">
<div class="bg-surface-modal px-4 pb-6 pt-5 sm:px-6">
<div class="mb-6 flex items-center justify-between">
<div>
<h3 class="text-2xl leading-6 text-ink-gray-9 font-semibold">
{{ confirmDeleteInfo.title }}
</h3>
</div>
<div class="flex items-center gap-1">
<Button variant="ghost" class="w-7" @click="show = false">
<FeatherIcon name="x" class="h-4 w-4" />
</Button>
</div>
</div>
<div class="text-ink-gray-5 text-base">
{{ confirmDeleteInfo.message }}
</div>
<div class="flex justify-end gap-2 mt-6">
<Button variant="ghost" @click="cancel()">
{{ __('Cancel') }}
</Button>
<Button
variant="solid"
:label="confirmDeleteInfo.title"
@click="removeDocLinks()"
theme="red"
/>
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import { createResource, call } from 'frappe-ui'
import { useRouter } from 'vue-router'
import { computed, ref } from 'vue'
const show = defineModel()
const router = useRouter()
const props = defineProps({
name: {
type: String,
required: true,
},
doctype: {
type: String,
required: true,
},
docname: {
type: String,
required: true,
},
reload: {
type: Function,
},
})
const viewControls = ref({
selections: [],
updateSelections: (selections) => {
viewControls.value.selections = Array.from(selections || [])
},
})
const confirmDeleteInfo = ref({
show: false,
title: '',
})
const linkedDocsResource = createResource({
url: 'crm.api.doc.get_linked_docs_of_document',
params: {
doctype: props.doctype,
docname: props.docname,
},
auto: true,
validate(params) {
if (!params?.doctype || !params?.docname) {
return false
}
},
})
const linkedDocs = computed(() => {
return (
linkedDocsResource.data?.map((doc) => ({
id: doc.reference_docname,
...doc,
})) || []
)
})
const cancel = () => {
confirmDeleteInfo.value.show = false
viewControls.value.updateSelections([])
}
const unlinkLinkedDoc = (doc) => {
let selectedDocs = []
if (viewControls.value.selections.length > 0) {
Array.from(viewControls.value.selections).forEach((selection) => {
const docData = linkedDocs.value.find((d) => d.id == selection)
selectedDocs.push({
doctype: docData.reference_doctype,
docname: docData.reference_docname,
})
})
} else {
selectedDocs = linkedDocs.value.map((doc) => ({
doctype: doc.reference_doctype,
docname: doc.reference_docname,
}))
}
call('crm.api.doc.remove_linked_doc_reference', {
items: selectedDocs,
remove_contact: props.doctype == 'Contact',
delete: doc.delete,
}).then(() => {
linkedDocsResource.reload()
confirmDeleteInfo.value = {
show: false,
title: '',
}
})
}
const confirmDelete = () => {
const items =
viewControls.value.selections.length == 0
? 'all'
: viewControls.value.selections.length
confirmDeleteInfo.value = {
show: true,
title: __('Delete linked item'),
message: __('Are you sure you want to delete {0} linked item(s)?', [items]),
delete: true,
}
}
const confirmUnlink = () => {
const items =
viewControls.value.selections.length == 0
? 'all'
: viewControls.value.selections.length
confirmDeleteInfo.value = {
show: true,
title: __('Unlink linked item'),
message: __('Are you sure you want to unlink {0} linked item(s)?', [items]),
delete: false,
}
}
const removeDocLinks = () => {
unlinkLinkedDoc({
reference_doctype: props.doctype,
reference_docname: props.docname,
delete: confirmDeleteInfo.value.delete,
})
viewControls.value.updateSelections([])
}
const deleteDoc = async () => {
await call('frappe.client.delete', {
doctype: props.doctype,
name: props.docname,
})
router.push({ name: props.name })
props?.reload?.()
}
</script>

View File

@ -14,6 +14,20 @@
:doctype="doctype" :doctype="doctype"
@reload="reload" @reload="reload"
/> />
<DeleteLinkedDocModal
v-if="showDeleteDocModal.showLinkedDocsModal"
v-model="showDeleteDocModal.showLinkedDocsModal"
:doctype="props.doctype"
:docname="showDeleteDocModal.docname"
:reload="reload"
/>
<BulkDeleteLinkedDocModal
v-if="showDeleteDocModal.showDeleteModal"
v-model="showDeleteDocModal.showDeleteModal"
:doctype="props.doctype"
:items="showDeleteDocModal.items"
:reload="reload"
/>
</template> </template>
<script setup> <script setup>
@ -50,7 +64,11 @@ const { $dialog, $socket } = globalStore()
const showEditModal = ref(false) const showEditModal = ref(false)
const selectedValues = ref([]) const selectedValues = ref([])
const unselectAllAction = ref(() => {}) const unselectAllAction = ref(() => {})
const showDeleteDocModal = ref({
showLinkedDocsModal: false,
showDeleteModal: false,
docname: null,
})
function editValues(selections, unselectAll) { function editValues(selections, unselectAll) {
selectedValues.value = selections selectedValues.value = selections
showEditModal.value = true showEditModal.value = true
@ -88,33 +106,18 @@ function convertToDeal(selections, unselectAll) {
} }
function deleteValues(selections, unselectAll) { function deleteValues(selections, unselectAll) {
$dialog({ const selectedDocs = Array.from(selections)
title: __('Delete'), if (selectedDocs.length == 1) {
message: __('Are you sure you want to delete {0} item(s)?', [ showDeleteDocModal.value = {
selections.size, showLinkedDocsModal: true,
]), docname: selectedDocs[0],
variant: 'solid', }
theme: 'red', } else {
actions: [ showDeleteDocModal.value = {
{ showDeleteModal: true,
label: __('Delete'), items: selectedDocs,
variant: 'solid', }
theme: 'red', }
onClick: (close) => {
capture('bulk_delete')
call('frappe.desk.reportview.delete_items', {
items: JSON.stringify(Array.from(selections)),
doctype: props.doctype,
}).then(() => {
toast.success(__('Deleted successfully'))
unselectAll()
list.value.reload()
close()
})
},
},
],
})
} }
const showAssignmentModal = ref(false) const showAssignmentModal = ref(false)

View File

@ -0,0 +1,139 @@
<template>
<ListView
:class="$attrs.class"
:columns="columns"
:rows="rows"
:options="{
selectable: true,
showTooltip: true,
resizeColumn: true,
}"
row-key="reference_docname"
@update:selections="(selections) => emit('selectionsChanged', selections)"
ref="listViewRef"
>
<ListHeader @columnWidthUpdated="emit('columnWidthUpdated')">
<ListHeaderItem
v-for="column in columns"
:key="column.key"
:item="column"
@columnWidthUpdated="emit('columnWidthUpdated', column)"
>
</ListHeaderItem>
</ListHeader>
<div class="*:mx-0 *:sm:mx-0">
<ListRows :rows="rows" v-slot="{ idx, column, item, row }">
<ListRowItem
:item="item"
@click="listViewRef.toggleRow(row['reference_docname'])"
>
<template #default="{ label }">
<div
v-if="column.key === 'title'"
class="truncate text-base flex gap-2"
>
<span>
{{ label }}
</span>
<FeatherIcon
name="external-link"
class="h-4 w-4"
@click.stop="viewLinkedDoc(row)"
/>
</div>
<span
v-if="column.key === 'reference_doctype'"
class="truncate text-base flex gap-2"
>
{{ getDoctypeName(row.reference_doctype) }}
</span>
</template>
</ListRowItem>
</ListRows>
</div>
</ListView>
</template>
<script setup>
import ListRows from '@/components/ListViews/ListRows.vue'
import { ListView, ListHeader, ListHeaderItem, ListRowItem } from 'frappe-ui'
import { ref } from 'vue'
const props = defineProps({
rows: {
type: Array,
required: true,
},
columns: {
type: Array,
required: true,
},
linkedDocsResource: {
type: Object,
required: true,
},
unlinkLinkedDoc: {
type: Function,
required: true,
},
options: {
type: Object,
default: () => ({
selectable: true,
showTooltip: true,
resizeColumn: false,
totalCount: 0,
rowCount: 0,
}),
},
})
const emit = defineEmits([
'loadMore',
'updatePageCount',
'columnWidthUpdated',
'applyFilter',
'applyLikeFilter',
'likeDoc',
'selectionsChanged',
])
const listViewRef = ref(null)
const viewLinkedDoc = (doc) => {
let page = ''
let id = ''
switch (doc.reference_doctype) {
case 'CRM Lead':
page = 'leads'
id = doc.reference_docname
break
case 'CRM Call Log':
page = 'call-logs'
id = `view?open=${doc.reference_docname}`
break
case 'CRM Task':
page = 'tasks'
id = `view?open=${doc.reference_docname}`
break
case 'Contact':
page = 'contacts'
id = doc.reference_docname
break
case 'CRM Organization':
page = 'organizations'
id = doc.reference_docname
break
case 'FCRM Note':
page = 'notes'
id = `view?open=${doc.reference_docname}`
break
default:
break
}
window.open(`/crm/${page}/${id}`)
}
const getDoctypeName = (doctype) => {
return doctype.replace(/^(CRM|FCRM)\s*/, '')
}
</script>

View File

@ -80,7 +80,7 @@ import CallLogDetailModal from '@/components/Modals/CallLogDetailModal.vue'
import CallLogModal from '@/components/Modals/CallLogModal.vue' import CallLogModal from '@/components/Modals/CallLogModal.vue'
import { getCallLogDetail } from '@/utils/callLog' import { getCallLogDetail } from '@/utils/callLog'
import { createResource } from 'frappe-ui' import { createResource } from 'frappe-ui'
import { computed, ref } from 'vue' import { computed, ref, onMounted } from 'vue'
const callLogsListView = ref(null) const callLogsListView = ref(null)
const showCallLogModal = ref(false) const showCallLogModal = ref(false)
@ -124,4 +124,19 @@ function createCallLog() {
callLog.value = {} callLog.value = {}
showCallLogModal.value = true showCallLogModal.value = true
} }
const openCallLogFromURL = () => {
const searchParams = new URLSearchParams(window.location.search)
const callLogName = searchParams.get('open')
if (callLogName) {
showCallLog(callLogName)
searchParams.delete('open')
window.history.replaceState(null, '', window.location.pathname)
}
}
onMounted(() => {
openCallLogFromURL()
})
</script> </script>

View File

@ -105,7 +105,7 @@
:label="__('Delete')" :label="__('Delete')"
theme="red" theme="red"
size="sm" size="sm"
@click="deleteContact" @click="deleteContact()"
> >
<template #prefix> <template #prefix>
<FeatherIcon name="trash-2" class="h-4 w-4" /> <FeatherIcon name="trash-2" class="h-4 w-4" />
@ -172,6 +172,13 @@
:errorTitle="errorTitle" :errorTitle="errorTitle"
:errorMessage="errorMessage" :errorMessage="errorMessage"
/> />
<DeleteLinkedDocModal
v-if="showDeleteLinkedDocModal"
v-model="showDeleteLinkedDocModal"
:doctype="'Contact'"
:docname="contact.data.name"
name="Contacts"
/>
</template> </template>
<script setup> <script setup>
@ -293,6 +300,11 @@ usePageMeta(() => {
icon: brand.favicon, icon: brand.favicon,
} }
}) })
const showDeleteLinkedDocModal = ref(false)
async function deleteContact() {
showDeleteLinkedDocModal.value = true
}
async function changeContactImage(file) { async function changeContactImage(file) {
await call('frappe.client.set_value', { await call('frappe.client.set_value', {
@ -304,28 +316,6 @@ async function changeContactImage(file) {
contact.reload() contact.reload()
} }
async function deleteContact() {
$dialog({
title: __('Delete contact'),
message: __('Are you sure you want to delete this contact?'),
actions: [
{
label: __('Delete'),
theme: 'red',
variant: 'solid',
async onClick(close) {
await call('frappe.client.delete', {
doctype: 'Contact',
name: props.contactId,
})
close()
router.push({ name: 'Contacts' })
},
},
],
})
}
const tabIndex = ref(0) const tabIndex = ref(0)
const tabs = [ const tabs = [
{ {

View File

@ -139,6 +139,18 @@
</Button> </Button>
</div> </div>
</Tooltip> </Tooltip>
<Tooltip :text="__('Delete')">
<div>
<Button
class="h-7 w-7"
@click="deleteDealWithModal(deal.data.name)"
variant="subtle"
theme="red"
>
<FeatherIcon name="trash" class="h-4 w-4" />
</Button>
</div>
</Tooltip>
</div> </div>
</div> </div>
</div> </div>
@ -329,6 +341,13 @@
} }
" "
/> />
<DeleteLinkedDocModal
v-if="showDeleteLinkedDocModal"
v-model="showDeleteLinkedDocModal"
:doctype="'CRM Deal'"
:docname="props.dealId"
name="Deals"
/>
</template> </template>
<script setup> <script setup>
import ErrorPage from '@/components/ErrorPage.vue' import ErrorPage from '@/components/ErrorPage.vue'
@ -472,7 +491,11 @@ const reload = ref(false)
const showOrganizationModal = ref(false) const showOrganizationModal = ref(false)
const showFilesUploader = ref(false) const showFilesUploader = ref(false)
const _organization = ref({}) const _organization = ref({})
const showDeleteLinkedDocModal = ref(false)
async function deleteDealWithModal() {
showDeleteLinkedDocModal.value = true
}
function updateDeal(fieldname, value, callback) { function updateDeal(fieldname, value, callback) {
value = Array.isArray(fieldname) ? '' : value value = Array.isArray(fieldname) ? '' : value

View File

@ -189,6 +189,18 @@
</Button> </Button>
</div> </div>
</Tooltip> </Tooltip>
<Tooltip :text="__('Delete')">
<div>
<Button
class="h-7 w-7"
@click="deleteLeadWithModal(lead.data.name)"
variant="subtle"
theme="red"
>
<FeatherIcon name="trash" class="h-4 w-4" />
</Button>
</div>
</Tooltip>
</div> </div>
<ErrorMessage :message="__(error)" /> <ErrorMessage :message="__(error)" />
</div> </div>
@ -333,6 +345,13 @@
} }
" "
/> />
<DeleteLinkedDocModal
v-if="showDeleteLinkedDocModal"
v-model="showDeleteLinkedDocModal"
:doctype="'CRM Lead'"
:docname="props.leadId"
name="Leads"
/>
</template> </template>
<script setup> <script setup>
import ErrorPage from '@/components/ErrorPage.vue' import ErrorPage from '@/components/ErrorPage.vue'
@ -423,6 +442,7 @@ const props = defineProps({
const errorTitle = ref('') const errorTitle = ref('')
const errorMessage = ref('') const errorMessage = ref('')
const showDeleteLinkedDocModal = ref(false)
const lead = createResource({ const lead = createResource({
url: 'crm.fcrm.doctype.crm_lead.api.get_lead', url: 'crm.fcrm.doctype.crm_lead.api.get_lead',
@ -621,6 +641,10 @@ async function deleteLead(name) {
router.push({ name: 'Leads' }) router.push({ name: 'Leads' })
} }
async function deleteLeadWithModal(name) {
showDeleteLinkedDocModal.value = true
}
// Convert to Deal // Convert to Deal
const showConvertToDealModal = ref(false) const showConvertToDealModal = ref(false)
const existingContactChecked = ref(false) const existingContactChecked = ref(false)

View File

@ -127,6 +127,7 @@ const viewControls = ref(null)
watch( watch(
() => notes.value?.data?.page_length_count, () => notes.value?.data?.page_length_count,
(val, old_value) => { (val, old_value) => {
openNoteFromURL()
if (!val || val === old_value) return if (!val || val === old_value) return
updatedPageCount.value = val updatedPageCount.value = val
}, },
@ -152,4 +153,20 @@ async function deleteNote(name) {
}) })
notes.value.reload() notes.value.reload()
} }
const openNoteFromURL = () => {
const searchParams = new URLSearchParams(window.location.search)
const noteName = searchParams.get('open')
if (noteName && notes.value?.data?.data) {
const foundNote = notes.value.data.data.find(
(note) => note.name === noteName,
)
if (foundNote) {
editNote(foundNote)
}
searchParams.delete('open')
window.history.replaceState(null, '', window.location.pathname)
}
}
</script> </script>

View File

@ -83,7 +83,7 @@
:label="__('Delete')" :label="__('Delete')"
theme="red" theme="red"
size="sm" size="sm"
@click="deleteOrganization" @click="deleteOrganization()"
> >
<template #prefix> <template #prefix>
<FeatherIcon name="trash-2" class="h-4 w-4" /> <FeatherIcon name="trash-2" class="h-4 w-4" />
@ -164,6 +164,13 @@
:errorTitle="errorTitle" :errorTitle="errorTitle"
:errorMessage="errorMessage" :errorMessage="errorMessage"
/> />
<DeleteLinkedDocModal
v-if="showDeleteLinkedDocModal"
v-model="showDeleteLinkedDocModal"
:doctype="'CRM Organization'"
:docname="props.organizationId"
name="Organizations"
/>
</template> </template>
<script setup> <script setup>
@ -202,6 +209,7 @@ import {
} from 'frappe-ui' } from 'frappe-ui'
import { h, computed, ref } from 'vue' import { h, computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import DeleteLinkedDocModal from '@/components/DeleteLinkedDocModal.vue'
const props = defineProps({ const props = defineProps({
organizationId: { organizationId: {
@ -222,6 +230,8 @@ const router = useRouter()
const errorTitle = ref('') const errorTitle = ref('')
const errorMessage = ref('') const errorMessage = ref('')
const showDeleteLinkedDocModal = ref(false)
const organization = createDocumentResource({ const organization = createDocumentResource({
doctype: 'CRM Organization', doctype: 'CRM Organization',
name: props.organizationId, name: props.organizationId,
@ -286,6 +296,10 @@ usePageMeta(() => {
} }
}) })
async function deleteOrganization() {
showDeleteLinkedDocModal.value = true
}
async function changeOrganizationImage(file) { async function changeOrganizationImage(file) {
await call('frappe.client.set_value', { await call('frappe.client.set_value', {
doctype: 'CRM Organization', doctype: 'CRM Organization',
@ -296,28 +310,6 @@ async function changeOrganizationImage(file) {
organization.reload() organization.reload()
} }
async function deleteOrganization() {
$dialog({
title: __('Delete organization'),
message: __('Are you sure you want to delete this organization?'),
actions: [
{
label: __('Delete'),
theme: 'red',
variant: 'solid',
async onClick(close) {
await call('frappe.client.delete', {
doctype: 'CRM Organization',
name: props.organizationId,
})
close()
router.push({ name: 'Organizations' })
},
},
],
})
}
function website(url) { function website(url) {
return url && url.replace(/^(?:https?:\/\/)?(?:www\.)?/i, '') return url && url.replace(/^(?:https?:\/\/)?(?:www\.)?/i, '')
} }

View File

@ -211,7 +211,7 @@ import { getMeta } from '@/stores/meta'
import { usersStore } from '@/stores/users' import { usersStore } from '@/stores/users'
import { formatDate, timeAgo } from '@/utils' import { formatDate, timeAgo } from '@/utils'
import { Tooltip, Avatar, TextEditor, Dropdown, call } from 'frappe-ui' import { Tooltip, Avatar, TextEditor, Dropdown, call } from 'frappe-ui'
import { computed, ref } from 'vue' import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
const { getFormattedPercent, getFormattedFloat, getFormattedCurrency } = const { getFormattedPercent, getFormattedFloat, getFormattedCurrency } =
@ -246,6 +246,7 @@ const rows = computed(() => {
return getKanbanRows(tasks.value.data.data, tasks.value.data.fields) return getKanbanRows(tasks.value.data.data, tasks.value.data.fields)
} }
openTaskFromURL()
return parseRows(tasks.value?.data.data, tasks.value?.data.columns) return parseRows(tasks.value?.data.data, tasks.value?.data.columns)
}) })
@ -391,4 +392,15 @@ function redirect(doctype, docname) {
} }
router.push({ name: name, params: params }) router.push({ name: name, params: params })
} }
const openTaskFromURL = () => {
const searchParams = new URLSearchParams(window.location.search)
const taskName = searchParams.get('open')
if (taskName && rows.value?.length) {
showTask(parseInt(taskName))
searchParams.delete('open')
window.history.replaceState(null, '', window.location.pathname)
}
}
</script> </script>