feat: add list view & handle bulk delete, unlink - cherry-picked
This commit is contained in:
parent
f66de4cc72
commit
0bc1ee4c4e
@ -752,37 +752,28 @@ def getLinkedDocs(doctype, docname):
|
|||||||
doc = frappe.get_doc(doctype, docname)
|
doc = frappe.get_doc(doctype, docname)
|
||||||
linked_docs = get_linked_docs(doc)
|
linked_docs = get_linked_docs(doc)
|
||||||
dynamic_linked_docs = get_dynamic_linked_docs(doc)
|
dynamic_linked_docs = get_dynamic_linked_docs(doc)
|
||||||
|
|
||||||
linked_docs.extend(dynamic_linked_docs)
|
linked_docs.extend(dynamic_linked_docs)
|
||||||
|
linked_docs = list({doc["reference_docname"]: doc for doc in linked_docs}.values())
|
||||||
|
|
||||||
return 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"CRM Call Log - from {data.get('from')} to {data.get('to')}"
|
||||||
|
|
||||||
|
if data.doctype == "CRM Deal":
|
||||||
|
title = data.get("organization")
|
||||||
|
|
||||||
@frappe.whitelist()
|
docs_data.append({
|
||||||
def removeLinkedDocReference(doctype=None, docname=None,removeAll=False,removeContact=None):
|
"doc": data.doctype,
|
||||||
|
"title": title or data.get("name"),
|
||||||
|
"reference_docname": doc["reference_docname"],
|
||||||
|
"reference_doctype": doc["reference_doctype"],
|
||||||
|
})
|
||||||
|
return docs_data
|
||||||
|
|
||||||
if (not doctype or not docname) and not removeAll:
|
|
||||||
return "Invalid doctype or docname"
|
|
||||||
|
|
||||||
if removeAll:
|
|
||||||
if removeContact:
|
|
||||||
ref_doc = getLinkedDocs(doctype, docname)
|
|
||||||
for linked_doc in ref_doc:
|
|
||||||
removeContactLink(linked_doc["reference_doctype"], linked_doc["reference_docname"])
|
|
||||||
return "success"
|
|
||||||
|
|
||||||
|
|
||||||
linked_docs = getLinkedDocs(doctype, docname)
|
|
||||||
|
|
||||||
for linked_doc in linked_docs:
|
|
||||||
removeDocLink(linked_doc["reference_doctype"], linked_doc["reference_docname"])
|
|
||||||
return "success"
|
|
||||||
else:
|
|
||||||
if removeContact:
|
|
||||||
removeContactLink(doctype, docname)
|
|
||||||
return "success"
|
|
||||||
|
|
||||||
removeDocLink(doctype, docname)
|
|
||||||
return "success"
|
|
||||||
|
|
||||||
def removeDocLink(doctype, docname):
|
def removeDocLink(doctype, docname):
|
||||||
linked_doc_data = frappe.get_doc(doctype, docname)
|
linked_doc_data = frappe.get_doc(doctype, docname)
|
||||||
@ -801,12 +792,40 @@ def removeContactLink(doctype, docname):
|
|||||||
linked_doc_data.save(ignore_permissions=True)
|
linked_doc_data.save(ignore_permissions=True)
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def deleteBulkDocs(doctype, items):
|
def removeLinkedDocReference(items, removeContact=None, delete=False):
|
||||||
|
|
||||||
|
if isinstance(items, str):
|
||||||
|
items = frappe.parse_json(items)
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
if removeContact:
|
||||||
|
removeContactLink(item["doctype"], item["docname"])
|
||||||
|
else:
|
||||||
|
removeDocLink(item["doctype"], item["docname"])
|
||||||
|
|
||||||
|
if delete:
|
||||||
|
frappe.delete_doc(item["doctype"], item["docname"])
|
||||||
|
|
||||||
|
return "success"
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def deleteBulkDocs(doctype, items, deleteLinked=False):
|
||||||
from frappe.desk.reportview import delete_bulk
|
from frappe.desk.reportview import delete_bulk
|
||||||
|
|
||||||
items = frappe.parse_json(items)
|
items = frappe.parse_json(items)
|
||||||
for doc in items:
|
for doc in items:
|
||||||
removeLinkedDocReference(doctype, doc, removeAll=True,removeContact=doctype=="Contact")
|
linked_docs = getLinkedDocs(doctype, doc)
|
||||||
|
for linked_doc in linked_docs:
|
||||||
|
removeLinkedDocReference([
|
||||||
|
{
|
||||||
|
"doctype": linked_doc["reference_doctype"],
|
||||||
|
"docname": linked_doc["reference_docname"],
|
||||||
|
}
|
||||||
|
] ,
|
||||||
|
removeContact=doctype=="Contact",
|
||||||
|
delete=deleteLinked
|
||||||
|
)
|
||||||
|
|
||||||
if len(items) > 10:
|
if len(items) > 10:
|
||||||
frappe.enqueue("frappe.desk.reportview.delete_bulk", doctype=doctype, items=items)
|
frappe.enqueue("frappe.desk.reportview.delete_bulk", doctype=doctype, items=items)
|
||||||
|
|||||||
4
frontend/components.d.ts
vendored
4
frontend/components.d.ts
vendored
@ -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']
|
||||||
@ -55,6 +56,7 @@ 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']
|
||||||
|
copy: typeof import('./src/components/DeleteLinkedDocModal copy.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']
|
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']
|
||||||
@ -150,11 +152,13 @@ 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']
|
||||||
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']
|
||||||
|
LucideCalendar: typeof import('~icons/lucide/calendar')['default']
|
||||||
LucideInfo: typeof import('~icons/lucide/info')['default']
|
LucideInfo: typeof import('~icons/lucide/info')['default']
|
||||||
LucidePlus: typeof import('~icons/lucide/plus')['default']
|
LucidePlus: typeof import('~icons/lucide/plus')['default']
|
||||||
LucideSearch: typeof import('~icons/lucide/search')['default']
|
LucideSearch: typeof import('~icons/lucide/search')['default']
|
||||||
|
|||||||
167
frontend/src/components/BulkDeleteLinkedDocModal.vue
Normal file
167
frontend/src/components/BulkDeleteLinkedDocModal.vue
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
<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>
|
||||||
|
{{
|
||||||
|
__('Are you sure you want to delete {0} items?', [
|
||||||
|
props.items?.length,
|
||||||
|
])
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<ErrorMessage class="mt-4" v-if="error" :message="__(error)" />
|
||||||
|
</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>
|
||||||
|
{{
|
||||||
|
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>
|
||||||
|
<ErrorMessage class="mt-4" v-if="error" :message="__(error)" />
|
||||||
|
</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.deleteBulkDocs', {
|
||||||
|
items: props.items,
|
||||||
|
doctype: props.doctype,
|
||||||
|
deleteLinked: confirmDeleteInfo.value.delete,
|
||||||
|
}).then(() => {
|
||||||
|
confirmDeleteInfo.value = {
|
||||||
|
show: false,
|
||||||
|
title: '',
|
||||||
|
}
|
||||||
|
show.value = false
|
||||||
|
props.reload()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<Dialog v-model="show" :options="{ size: 'xl' }">
|
<Dialog v-model="show" :options="{ size: 'xl' }">
|
||||||
<template #body>
|
<template #body v-if="!confirmDeleteInfo.show">
|
||||||
<div class="bg-surface-modal px-4 pb-6 pt-5 sm:px-6">
|
<div class="bg-surface-modal px-4 pb-6 pt-5 sm:px-6">
|
||||||
<div class="mb-6 flex items-center justify-between">
|
<div class="mb-6 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@ -15,44 +15,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div v-if="linkedDocs.data?.length > 0">
|
<div v-if="linkedDocs?.length > 0">
|
||||||
<span>
|
<span>
|
||||||
{{
|
{{
|
||||||
__(
|
__(
|
||||||
'Unlink these linked documents before deleting this document',
|
'Delete or unlink these linked documents before deleting this document',
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
<ul class="mt-5 space-y-1">
|
<LinkedDocsListView
|
||||||
<hr />
|
class="mt-4"
|
||||||
<li v-for="doc in linkedDocs.data" :key="doc.name">
|
:rows="linkedDocs"
|
||||||
<div class="flex justify-between items-center">
|
:columns="[
|
||||||
<span
|
{
|
||||||
class="text-lg font-medium text-ellipsis overflow-hidden whitespace-nowrap w-full"
|
label: 'Document',
|
||||||
>{{ doc.reference_doctype }} ({{
|
key: 'title',
|
||||||
doc.reference_docname
|
},
|
||||||
}})</span
|
{
|
||||||
>
|
label: 'Doctype',
|
||||||
<div class="flex gap-2">
|
key: 'reference_doctype',
|
||||||
<Button variant="ghost" @click="viewLinkedDoc(doc)">
|
width: '30%',
|
||||||
<div class="flex gap-1">
|
},
|
||||||
<FeatherIcon name="external-link" class="h-4 w-4" />
|
]"
|
||||||
<span> View </span>
|
@selectionsChanged="
|
||||||
</div>
|
(selections) => viewControls.updateSelections(selections)
|
||||||
</Button>
|
"
|
||||||
<Button variant="ghost" @click="unlinkLinkedDoc(doc)">
|
:linkedDocsResource="linkedDocsResource"
|
||||||
<div class="flex gap-1">
|
:unlinkLinkedDoc="unlinkLinkedDoc"
|
||||||
<FeatherIcon name="unlock" class="h-4 w-4" />
|
/>
|
||||||
<span> Unlink </span>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<hr class="my-2 w-full" />
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="linkedDocs.data?.length == 0">
|
<div v-if="linkedDocs?.length == 0">
|
||||||
{{
|
{{
|
||||||
__('Are you sure you want to delete {0} - {1}?', [
|
__('Are you sure you want to delete {0} - {1}?', [
|
||||||
props.doctype,
|
props.doctype,
|
||||||
@ -66,23 +58,42 @@
|
|||||||
<div class="px-4 pb-7 pt-0 sm:px-6">
|
<div class="px-4 pb-7 pt-0 sm:px-6">
|
||||||
<div class="flex flex-row-reverse gap-2">
|
<div class="flex flex-row-reverse gap-2">
|
||||||
<Button
|
<Button
|
||||||
v-if="linkedDocs.data?.length > 0"
|
v-if="linkedDocs?.length > 0"
|
||||||
|
theme="red"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
@click="
|
@click="confirmDelete()"
|
||||||
unlinkLinkedDoc({
|
|
||||||
reference_doctype: props.doctype,
|
|
||||||
reference_docname: props.docname,
|
|
||||||
removeAll: true,
|
|
||||||
})
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
<FeatherIcon name="unlock" class="h-4 w-4" />
|
<FeatherIcon name="trash" class="h-4 w-4" />
|
||||||
<span> Unlink all </span>
|
<span>
|
||||||
|
Delete and unlink
|
||||||
|
{{
|
||||||
|
viewControls?.selections?.length == 0
|
||||||
|
? 'all'
|
||||||
|
: `${viewControls?.selections?.length} item(s)`
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
v-if="linkedDocs.data?.length == 0"
|
v-if="linkedDocs?.length > 0"
|
||||||
|
variant="solid"
|
||||||
|
@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"
|
variant="solid"
|
||||||
:label="__('Delete')"
|
:label="__('Delete')"
|
||||||
:loading="isDealCreating"
|
:loading="isDealCreating"
|
||||||
@ -92,12 +103,43 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
||||||
|
{{ confirmDeleteInfo.message }}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row-reverse gap-2 mt-6">
|
||||||
|
<Button variant="ghost" @click="confirmDeleteInfo.show = false">
|
||||||
|
{{ __('Cancel') }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="solid"
|
||||||
|
:label="confirmDeleteInfo.title"
|
||||||
|
@click="removeDocLinks()"
|
||||||
|
theme="red"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { createResource, call } from 'frappe-ui'
|
import { createResource, call } from 'frappe-ui'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
const show = defineModel()
|
const show = defineModel()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -114,9 +156,23 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
reload: {
|
||||||
|
type: Function,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const viewControls = ref({
|
||||||
|
selections: [],
|
||||||
|
updateSelections: (selections) => {
|
||||||
|
viewControls.value.selections = Array.from(selections || [])
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const linkedDocs = createResource({
|
const confirmDeleteInfo = ref({
|
||||||
|
show: false,
|
||||||
|
title: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const linkedDocsResource = createResource({
|
||||||
url: 'crm.api.doc.getLinkedDocs',
|
url: 'crm.api.doc.getLinkedDocs',
|
||||||
params: {
|
params: {
|
||||||
doctype: props.doctype,
|
doctype: props.doctype,
|
||||||
@ -130,18 +186,76 @@ const linkedDocs = createResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const viewLinkedDoc = (doc) => {
|
const linkedDocs = computed(() => {
|
||||||
window.open(`/app/Form/${doc.reference_doctype}/${doc.reference_docname}`)
|
return (
|
||||||
}
|
linkedDocsResource.data?.map((doc) => ({
|
||||||
|
id: doc.reference_docname,
|
||||||
|
...doc,
|
||||||
|
})) || []
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const unlinkLinkedDoc = (doc) => {
|
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.removeLinkedDocReference', {
|
call('crm.api.doc.removeLinkedDocReference', {
|
||||||
doctype: doc.reference_doctype,
|
items: selectedDocs,
|
||||||
docname: doc.reference_docname,
|
|
||||||
removeAll: doc.removeAll,
|
|
||||||
removeContact: props.doctype == 'Contact',
|
removeContact: props.doctype == 'Contact',
|
||||||
|
delete: doc.delete,
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
linkedDocs.reload()
|
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'),
|
||||||
|
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'),
|
||||||
|
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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -151,5 +265,6 @@ const deleteDoc = async () => {
|
|||||||
name: props.docname,
|
name: props.docname,
|
||||||
})
|
})
|
||||||
router.push({ name: props.name })
|
router.push({ name: props.name })
|
||||||
|
props?.reload?.()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -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('crm.api.doc.deleteBulkDocs', {
|
|
||||||
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)
|
||||||
|
|||||||
139
frontend/src/components/ListViews/LinkedDocsListView.vue
Normal file
139
frontend/src/components/ListViews/LinkedDocsListView.vue
Normal 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)"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
v-if="column.key == 'reference_docname'"
|
||||||
|
variant="ghosted"
|
||||||
|
class="!h-4"
|
||||||
|
:class="isLikeFilterApplied ? 'fill-red-500' : 'fill-white'"
|
||||||
|
@click="() => emit('applyLikeFilter')"
|
||||||
|
>
|
||||||
|
<HeartIcon class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</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>
|
||||||
|
</template>
|
||||||
|
</ListRowItem>
|
||||||
|
</ListRows>
|
||||||
|
</div>
|
||||||
|
</ListView>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import HeartIcon from '@/components/Icons/HeartIcon.vue'
|
||||||
|
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 '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}`)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user