commit
6842affa31
@ -30,6 +30,11 @@ def get_filterable_fields(doctype: str):
|
||||
"Text",
|
||||
]
|
||||
|
||||
c = get_controller(doctype)
|
||||
restricted_fields = []
|
||||
if hasattr(c, "get_non_filterable_fields"):
|
||||
restricted_fields = c.get_non_filterable_fields()
|
||||
|
||||
from_doc_fields = (
|
||||
frappe.qb.from_(DocField)
|
||||
.select(
|
||||
@ -42,6 +47,7 @@ def get_filterable_fields(doctype: str):
|
||||
.where(DocField.parent == doctype)
|
||||
.where(DocField.hidden == False)
|
||||
.where(Criterion.any([DocField.fieldtype == i for i in allowed_fieldtypes]))
|
||||
.where(Criterion.all([DocField.fieldname != i for i in restricted_fields]))
|
||||
.run(as_dict=True)
|
||||
)
|
||||
res = []
|
||||
@ -50,21 +56,31 @@ def get_filterable_fields(doctype: str):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_list_data(doctype: str, filters: dict, order_by: str):
|
||||
columns = [
|
||||
{"label": "Name", "type": "Data", "key": "name", "width": "16rem"},
|
||||
{"label": "Last Modified", "type": "Datetime", "key": "modified", "width": "8rem"},
|
||||
]
|
||||
rows = ["name"]
|
||||
|
||||
def get_list_data(doctype: str, filters: dict, order_by: str, columns=None , rows=None, custom_view_name=None):
|
||||
custom_view = False
|
||||
filters = frappe._dict(filters)
|
||||
is_default = True
|
||||
if columns or rows:
|
||||
custom_view = True
|
||||
is_default = False
|
||||
columns = frappe.parse_json(columns)
|
||||
rows = frappe.parse_json(rows)
|
||||
|
||||
if frappe.db.exists("CRM List View Settings", doctype):
|
||||
list_view_settings = frappe.get_doc("CRM List View Settings", doctype)
|
||||
if not columns:
|
||||
columns = [
|
||||
{"label": "Name", "type": "Data", "key": "name", "width": "16rem"},
|
||||
{"label": "Last Modified", "type": "Datetime", "key": "modified", "width": "8rem"},
|
||||
]
|
||||
|
||||
if not rows:
|
||||
rows = ["name"]
|
||||
|
||||
if not custom_view and frappe.db.exists("CRM View Settings", doctype):
|
||||
list_view_settings = frappe.get_doc("CRM View Settings", doctype)
|
||||
columns = frappe.parse_json(list_view_settings.columns)
|
||||
rows = frappe.parse_json(list_view_settings.rows)
|
||||
is_default = False
|
||||
else:
|
||||
elif not custom_view or is_default:
|
||||
list = get_controller(doctype)
|
||||
|
||||
if hasattr(list, "default_list_data"):
|
||||
@ -117,14 +133,26 @@ def get_list_data(doctype: str, filters: dict, order_by: str):
|
||||
if field not in fields:
|
||||
fields.append(field)
|
||||
|
||||
if not is_default and custom_view_name:
|
||||
is_default = frappe.db.get_value("CRM View Settings", custom_view_name, "default_columns")
|
||||
|
||||
return {
|
||||
"data": data,
|
||||
"columns": columns,
|
||||
"rows": rows,
|
||||
"fields": fields,
|
||||
"is_default": is_default,
|
||||
"views": get_views(doctype),
|
||||
}
|
||||
|
||||
def get_views(doctype):
|
||||
views = frappe.get_all(
|
||||
"CRM View Settings",
|
||||
fields=["*"],
|
||||
filters={"dt": doctype, "user": frappe.session.user}
|
||||
)
|
||||
return views
|
||||
|
||||
|
||||
def get_doctype_fields(doctype):
|
||||
not_allowed_fieldtypes = [
|
||||
|
||||
@ -196,6 +196,10 @@ class CRMLead(Document):
|
||||
{ "label": 'Mobile no', "value": 'mobile_no' },
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def get_non_filterable_fields():
|
||||
return ["converted"]
|
||||
|
||||
@staticmethod
|
||||
def default_list_data():
|
||||
columns = [
|
||||
|
||||
@ -1,58 +0,0 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"autoname": "prompt",
|
||||
"creation": "2023-11-27 16:29:10.993403",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"user",
|
||||
"columns",
|
||||
"rows"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "columns",
|
||||
"fieldtype": "Code",
|
||||
"label": "Columns"
|
||||
},
|
||||
{
|
||||
"fieldname": "user",
|
||||
"fieldtype": "Link",
|
||||
"label": "User",
|
||||
"options": "User"
|
||||
},
|
||||
{
|
||||
"fieldname": "rows",
|
||||
"fieldtype": "Code",
|
||||
"label": "Rows"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2023-11-28 00:17:42.675332",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM List View Settings",
|
||||
"naming_rule": "Set by user",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"read_only": 1,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
@ -1,50 +0,0 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
import json
|
||||
import frappe
|
||||
from frappe.model.document import Document, get_controller
|
||||
|
||||
|
||||
class CRMListViewSettings(Document):
|
||||
pass
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def update(doctype, columns, rows):
|
||||
default_rows = sync_default_list_rows(doctype)
|
||||
|
||||
if default_rows:
|
||||
rows = rows + default_rows
|
||||
|
||||
rows = remove_duplicates(rows)
|
||||
|
||||
if not frappe.db.exists("CRM List View Settings", doctype):
|
||||
# create new CRM List View Settings
|
||||
doc = frappe.new_doc("CRM List View Settings")
|
||||
doc.name = doctype
|
||||
doc.columns = json.dumps(columns)
|
||||
doc.rows = json.dumps(rows)
|
||||
doc.insert()
|
||||
else:
|
||||
# update existing CRM List View Settings
|
||||
doc = frappe.get_doc("CRM List View Settings", doctype)
|
||||
doc.columns = json.dumps(columns)
|
||||
doc.rows = json.dumps(rows)
|
||||
doc.save()
|
||||
|
||||
def remove_duplicates(l):
|
||||
return list(dict.fromkeys(l))
|
||||
|
||||
def sync_default_list_rows(doctype):
|
||||
list = get_controller(doctype)
|
||||
rows = []
|
||||
|
||||
if hasattr(list, "default_list_data"):
|
||||
rows = list.default_list_data().get("rows")
|
||||
|
||||
return rows
|
||||
|
||||
@frappe.whitelist()
|
||||
def reset_to_default(doctype):
|
||||
if frappe.db.exists("CRM List View Settings", doctype):
|
||||
frappe.delete_doc("CRM List View Settings", doctype)
|
||||
@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
// frappe.ui.form.on("CRM List View Settings", {
|
||||
// frappe.ui.form.on("CRM View Settings", {
|
||||
// refresh(frm) {
|
||||
|
||||
// },
|
||||
116
crm/fcrm/doctype/crm_view_settings/crm_view_settings.json
Normal file
116
crm/fcrm/doctype/crm_view_settings/crm_view_settings.json
Normal file
@ -0,0 +1,116 @@
|
||||
{
|
||||
"actions": [],
|
||||
"autoname": "autoincrement",
|
||||
"creation": "2023-11-27 16:29:10.993403",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"label",
|
||||
"user",
|
||||
"column_break_zacm",
|
||||
"dt",
|
||||
"columns_tab",
|
||||
"default_columns",
|
||||
"columns",
|
||||
"rows",
|
||||
"filters_tab",
|
||||
"filters",
|
||||
"order_by_tab",
|
||||
"order_by"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "columns",
|
||||
"fieldtype": "Code",
|
||||
"label": "Columns"
|
||||
},
|
||||
{
|
||||
"fieldname": "user",
|
||||
"fieldtype": "Link",
|
||||
"label": "User",
|
||||
"options": "User"
|
||||
},
|
||||
{
|
||||
"fieldname": "rows",
|
||||
"fieldtype": "Code",
|
||||
"label": "Rows"
|
||||
},
|
||||
{
|
||||
"fieldname": "filters",
|
||||
"fieldtype": "Code",
|
||||
"label": "Filters"
|
||||
},
|
||||
{
|
||||
"fieldname": "columns_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Columns"
|
||||
},
|
||||
{
|
||||
"fieldname": "filters_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Filters"
|
||||
},
|
||||
{
|
||||
"fieldname": "label",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Label"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_zacm",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "dt",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "DocType",
|
||||
"options": "DocType"
|
||||
},
|
||||
{
|
||||
"fieldname": "order_by_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Order By"
|
||||
},
|
||||
{
|
||||
"fieldname": "order_by",
|
||||
"fieldtype": "Code",
|
||||
"label": "Order By"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "default_columns",
|
||||
"fieldtype": "Check",
|
||||
"label": "Default Columns"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2023-12-30 19:28:02.541487",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM View Settings",
|
||||
"naming_rule": "Autoincrement",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"read_only": 1,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
85
crm/fcrm/doctype/crm_view_settings/crm_view_settings.py
Normal file
85
crm/fcrm/doctype/crm_view_settings/crm_view_settings.py
Normal file
@ -0,0 +1,85 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
import json
|
||||
import frappe
|
||||
from frappe.model.document import Document, get_controller
|
||||
|
||||
|
||||
class CRMViewSettings(Document):
|
||||
pass
|
||||
|
||||
@frappe.whitelist()
|
||||
def create(view, duplicate=False):
|
||||
view = frappe._dict(view)
|
||||
|
||||
if duplicate:
|
||||
view.filters = json.loads(view.filters)
|
||||
view.columns = json.loads(view.columns)
|
||||
view.rows = json.loads(view.rows)
|
||||
|
||||
doc = frappe.new_doc("CRM View Settings")
|
||||
doc.name = view.label
|
||||
doc.label = view.label
|
||||
doc.dt = view.doctype
|
||||
doc.user = frappe.session.user
|
||||
doc.filters = json.dumps(view.filters)
|
||||
doc.order_by = view.order_by
|
||||
doc.default_columns = view.default_columns or False
|
||||
|
||||
if not view.columns:
|
||||
view.columns = []
|
||||
if not view.rows:
|
||||
view.rows = []
|
||||
|
||||
default_rows = sync_default_list_rows(view.doctype)
|
||||
|
||||
if default_rows:
|
||||
view.rows = view.rows + default_rows
|
||||
|
||||
view.rows = remove_duplicates(view.rows)
|
||||
|
||||
doc.columns = json.dumps(view.columns)
|
||||
doc.rows = json.dumps(view.rows)
|
||||
doc.insert()
|
||||
return doc
|
||||
|
||||
@frappe.whitelist()
|
||||
def update(view):
|
||||
view = frappe._dict(view)
|
||||
default_rows = sync_default_list_rows(view.doctype)
|
||||
columns = view.columns or []
|
||||
filters = view.filters
|
||||
rows = view.rows or []
|
||||
default_columns = view.default_columns or False
|
||||
|
||||
if default_rows:
|
||||
rows = rows + default_rows
|
||||
|
||||
rows = remove_duplicates(rows)
|
||||
|
||||
doc = frappe.get_doc("CRM View Settings", view.name)
|
||||
doc.label = view.label
|
||||
doc.default_columns = default_columns
|
||||
doc.filters = json.dumps(filters)
|
||||
doc.order_by = view.order_by
|
||||
doc.columns = json.dumps(columns)
|
||||
doc.rows = json.dumps(rows)
|
||||
doc.save()
|
||||
return doc
|
||||
|
||||
@frappe.whitelist()
|
||||
def delete(name):
|
||||
if frappe.db.exists("CRM View Settings", name):
|
||||
frappe.delete_doc("CRM View Settings", name)
|
||||
|
||||
def remove_duplicates(l):
|
||||
return list(dict.fromkeys(l))
|
||||
|
||||
def sync_default_list_rows(doctype):
|
||||
list = get_controller(doctype)
|
||||
rows = []
|
||||
|
||||
if hasattr(list, "default_list_data"):
|
||||
rows = list.default_list_data().get("rows")
|
||||
|
||||
return rows
|
||||
@ -5,5 +5,5 @@
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
|
||||
|
||||
class TestCRMListViewSettings(FrappeTestCase):
|
||||
class TestCRMViewSettings(FrappeTestCase):
|
||||
pass
|
||||
@ -88,17 +88,17 @@ watchDebounced(
|
||||
val = val || ''
|
||||
if (text.value === val) return
|
||||
text.value = val
|
||||
options.update({
|
||||
params: {
|
||||
txt: val,
|
||||
doctype: props.doctype,
|
||||
},
|
||||
})
|
||||
options.reload()
|
||||
reload(val)
|
||||
},
|
||||
{ debounce: 300, immediate: true }
|
||||
)
|
||||
|
||||
watchDebounced(
|
||||
() => props.doctype,
|
||||
() => reload(''),
|
||||
{ debounce: 300, immediate: true }
|
||||
)
|
||||
|
||||
const options = createResource({
|
||||
url: 'frappe.desk.search.search_link',
|
||||
cache: [props.doctype, text.value],
|
||||
@ -117,6 +117,16 @@ const options = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
function reload(val) {
|
||||
options.update({
|
||||
params: {
|
||||
txt: val,
|
||||
doctype: props.doctype,
|
||||
},
|
||||
})
|
||||
options.reload()
|
||||
}
|
||||
|
||||
const labelClasses = computed(() => {
|
||||
return [
|
||||
{
|
||||
|
||||
@ -3,27 +3,27 @@
|
||||
<template #target>
|
||||
<Button label="Filter">
|
||||
<template #prefix><FilterIcon class="h-4" /></template>
|
||||
<template v-if="storage.size" #suffix>
|
||||
<template v-if="filters?.size" #suffix>
|
||||
<div
|
||||
class="flex justify-center items-center w-5 h-5 text-2xs font-medium pt-[1px] bg-gray-900 text-white rounded"
|
||||
class="flex h-5 w-5 items-center justify-center rounded bg-gray-900 pt-[1px] text-2xs font-medium text-white"
|
||||
>
|
||||
{{ storage.size }}
|
||||
{{ filters.size }}
|
||||
</div>
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
<template #body="{ close }">
|
||||
<div class="rounded-lg border border-gray-100 bg-white shadow-xl my-2">
|
||||
<div class="p-2 min-w-[400px]">
|
||||
<div class="my-2 rounded-lg border border-gray-100 bg-white shadow-xl">
|
||||
<div class="min-w-[400px] p-2">
|
||||
<div
|
||||
v-if="storage.size"
|
||||
v-for="(f, i) in storage"
|
||||
v-if="filters?.size"
|
||||
v-for="(f, i) in filters"
|
||||
:key="i"
|
||||
id="filter-list"
|
||||
class="flex items-center justify-between gap-2 mb-3"
|
||||
class="mb-3 flex items-center justify-between gap-2"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="text-gray-600 text-base pl-2 w-13 text-end">
|
||||
<div class="w-13 pl-2 text-end text-base text-gray-600">
|
||||
{{ i == 0 ? 'Where' : 'And' }}
|
||||
</div>
|
||||
<div id="fieldname" class="!min-w-[140px]">
|
||||
@ -37,23 +37,26 @@
|
||||
<div id="operator">
|
||||
<FormControl
|
||||
type="select"
|
||||
v-model="f.operator"
|
||||
:value="f.operator"
|
||||
@change="(e) => updateOperator(e, f)"
|
||||
:options="getOperators(f.field.fieldtype)"
|
||||
placeholder="Operator"
|
||||
/>
|
||||
</div>
|
||||
<div id="value" class="!min-w-[140px]">
|
||||
<SearchComplete
|
||||
<Link
|
||||
v-if="typeLink.includes(f.field.fieldtype)"
|
||||
:doctype="f.field.options"
|
||||
class="form-control"
|
||||
:value="f.value"
|
||||
@change="(v) => (f.value = v.value)"
|
||||
:doctype="f.field.options"
|
||||
@change="(v) => updateValue(v, f)"
|
||||
placeholder="Value"
|
||||
/>
|
||||
<component
|
||||
v-else
|
||||
:is="getValSelect(f.field.fieldtype, f.field.options)"
|
||||
v-model="f.value"
|
||||
:value="f.value"
|
||||
@change="(e) => updateValue(e.target.value, f)"
|
||||
placeholder="Value"
|
||||
/>
|
||||
</div>
|
||||
@ -62,7 +65,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="text-gray-600 flex items-center text-sm px-3 h-7 mb-3"
|
||||
class="mb-3 flex h-7 items-center px-3 text-sm text-gray-600"
|
||||
>
|
||||
Empty - Choose a field to filter by
|
||||
</div>
|
||||
@ -87,7 +90,7 @@
|
||||
</template>
|
||||
</Autocomplete>
|
||||
<Button
|
||||
v-if="storage.size"
|
||||
v-if="filters?.size"
|
||||
class="!text-gray-600"
|
||||
variant="ghost"
|
||||
label="Clear all Filter"
|
||||
@ -102,16 +105,20 @@
|
||||
<script setup>
|
||||
import NestedPopover from '@/components/NestedPopover.vue'
|
||||
import FilterIcon from '@/components/Icons/FilterIcon.vue'
|
||||
import SearchComplete from '@/components/SearchComplete.vue'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { useFilter } from '@/composables/filter'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import {
|
||||
FeatherIcon,
|
||||
Autocomplete,
|
||||
FormControl,
|
||||
createResource,
|
||||
} from 'frappe-ui'
|
||||
import { h, watch } from 'vue'
|
||||
import { h, defineModel, computed } from 'vue'
|
||||
|
||||
const typeCheck = ['Check']
|
||||
const typeLink = ['Link']
|
||||
const typeNumber = ['Float', 'Int']
|
||||
const typeSelect = ['Select']
|
||||
const typeString = ['Data', 'Long Text', 'Small Text', 'Text Editor', 'Text']
|
||||
|
||||
const props = defineProps({
|
||||
doctype: {
|
||||
@ -120,6 +127,10 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update'])
|
||||
|
||||
const list = defineModel()
|
||||
|
||||
const filterableFields = createResource({
|
||||
url: 'crm.api.doc.get_filterable_fields',
|
||||
auto: true,
|
||||
@ -139,18 +150,49 @@ const filterableFields = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const { apply, storage } = useFilter(() => filterableFields.data)
|
||||
const typeCheck = ['Check']
|
||||
const typeLink = ['Link']
|
||||
const typeNumber = ['Float', 'Int']
|
||||
const typeSelect = ['Select']
|
||||
const typeString = ['Data', 'Long Text', 'Small Text', 'Text Editor', 'Text']
|
||||
const filters = computed(() => {
|
||||
if (!list.value?.data) return new Set()
|
||||
let allFilters = list.value?.params?.filters
|
||||
if (!allFilters || !filterableFields.data) return new Set()
|
||||
// remove default filters
|
||||
if (list.value.data._defaultFilters) {
|
||||
allFilters = removeCommonFilters(list.value.data._defaultFilters, allFilters)
|
||||
}
|
||||
return convertFilters(filterableFields.data, allFilters)
|
||||
})
|
||||
|
||||
watch(
|
||||
storage,
|
||||
useDebounceFn(() => apply(), 300),
|
||||
{ deep: true }
|
||||
)
|
||||
function removeCommonFilters(commonFilters, allFilters) {
|
||||
for (const key in commonFilters) {
|
||||
if (commonFilters.hasOwnProperty(key) && allFilters.hasOwnProperty(key)) {
|
||||
if (commonFilters[key] === allFilters[key]) {
|
||||
delete allFilters[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
return allFilters;
|
||||
}
|
||||
|
||||
function convertFilters(data, allFilters) {
|
||||
let f = []
|
||||
for (let [key, value] of Object.entries(allFilters)) {
|
||||
let field = data.find((f) => f.fieldname === key)
|
||||
if (typeof value !== 'object') {
|
||||
value = ['=', value]
|
||||
if (field.fieldtype === 'Check') {
|
||||
value = ['equals', value[1] ? 'Yes' : 'No']
|
||||
}
|
||||
}
|
||||
if (field) {
|
||||
f.push({
|
||||
field,
|
||||
fieldname: key,
|
||||
operator: oppositeOperatorMap[value[0]],
|
||||
value: value[1],
|
||||
})
|
||||
}
|
||||
}
|
||||
return new Set(f)
|
||||
}
|
||||
|
||||
function getOperators(fieldtype) {
|
||||
let options = []
|
||||
@ -231,7 +273,7 @@ function getSelectOptions(options) {
|
||||
}
|
||||
|
||||
function setfilter(data) {
|
||||
storage.value.add({
|
||||
filters.value.add({
|
||||
field: {
|
||||
label: data.label,
|
||||
fieldname: data.value,
|
||||
@ -242,11 +284,12 @@ function setfilter(data) {
|
||||
operator: getDefaultOperator(data.fieldtype),
|
||||
value: getDefaultValue(data),
|
||||
})
|
||||
apply()
|
||||
}
|
||||
|
||||
function updateFilter(data, index) {
|
||||
storage.value.delete(Array.from(storage.value)[index])
|
||||
storage.value.add({
|
||||
filters.value.delete(Array.from(filters.value)[index])
|
||||
filters.value.add({
|
||||
fieldname: data.value,
|
||||
operator: getDefaultOperator(data.fieldtype),
|
||||
value: getDefaultValue(data),
|
||||
@ -257,14 +300,93 @@ function updateFilter(data, index) {
|
||||
options: data.options,
|
||||
},
|
||||
})
|
||||
apply()
|
||||
}
|
||||
|
||||
function removeFilter(index) {
|
||||
storage.value.delete(Array.from(storage.value)[index])
|
||||
filters.value.delete(Array.from(filters.value)[index])
|
||||
apply()
|
||||
}
|
||||
|
||||
function clearfilter(close) {
|
||||
storage.value.clear()
|
||||
filters.value.clear()
|
||||
apply()
|
||||
close()
|
||||
}
|
||||
|
||||
function updateValue(value, filter) {
|
||||
filter.value = value
|
||||
apply()
|
||||
}
|
||||
|
||||
function updateOperator(event, filter) {
|
||||
filter.operator = event.target.value
|
||||
apply()
|
||||
}
|
||||
|
||||
function apply() {
|
||||
let _filters = []
|
||||
filters.value.forEach((f) => {
|
||||
_filters.push({
|
||||
fieldname: f.fieldname,
|
||||
operator: f.operator,
|
||||
value: f.value,
|
||||
})
|
||||
})
|
||||
emit('update', parseFilters(_filters))
|
||||
}
|
||||
|
||||
function parseFilters(filters) {
|
||||
const l__ = Array.from(filters)
|
||||
const obj = l__.map(transformIn).reduce((p, c) => {
|
||||
if (['equals', '='].includes(c.operator)) {
|
||||
p[c.fieldname] =
|
||||
c.value == 'Yes' ? true : c.value == 'No' ? false : c.value
|
||||
} else {
|
||||
p[c.fieldname] = [operatorMap[c.operator.toLowerCase()], c.value]
|
||||
}
|
||||
return p
|
||||
}, {})
|
||||
const merged = { ...obj }
|
||||
return merged
|
||||
}
|
||||
|
||||
function transformIn(f) {
|
||||
if (f.fieldname === '_assign') {
|
||||
f.operator = f.operator === 'is' ? 'like' : 'not like'
|
||||
}
|
||||
if (f.operator.includes('like') && !f.value.includes('%')) {
|
||||
f.value = `%${f.value}%`
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
const operatorMap = {
|
||||
is: '=',
|
||||
'is not': '!=',
|
||||
equals: '=',
|
||||
'not equals': '!=',
|
||||
yes: true,
|
||||
no: false,
|
||||
like: 'LIKE',
|
||||
'not like': 'NOT LIKE',
|
||||
'>': '>',
|
||||
'<': '<',
|
||||
'>=': '>=',
|
||||
'<=': '<=',
|
||||
}
|
||||
|
||||
const oppositeOperatorMap = {
|
||||
'=': 'is',
|
||||
equals: 'equals',
|
||||
'!=': 'is not',
|
||||
true: 'yes',
|
||||
false: 'no',
|
||||
LIKE: 'like',
|
||||
'NOT LIKE': 'not like',
|
||||
'>': '>',
|
||||
'<': '<',
|
||||
'>=': '>=',
|
||||
'<=': '<=',
|
||||
}
|
||||
</script>
|
||||
|
||||
101
frontend/src/components/Modals/ViewModal.vue
Normal file
101
frontend/src/components/Modals/ViewModal.vue
Normal file
@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: editMode
|
||||
? 'Edit View'
|
||||
: duplicateMode
|
||||
? 'Duplicate View'
|
||||
: 'Create View',
|
||||
actions: [
|
||||
{
|
||||
label: editMode ? 'Save Changes' : duplicateMode ? 'Duplicate' : 'Create',
|
||||
variant: 'solid',
|
||||
onClick: () => (editMode ? update() : create()),
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<FormControl
|
||||
variant="outline"
|
||||
size="md"
|
||||
type="text"
|
||||
label="View Name"
|
||||
placeholder="View Name"
|
||||
v-model="view.label"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Dialog, FormControl, call } from 'frappe-ui'
|
||||
import { ref, watch, defineModel, nextTick } from 'vue'
|
||||
|
||||
const show = defineModel()
|
||||
const editMode = ref(false)
|
||||
const duplicateMode = ref(false)
|
||||
const _view = ref({
|
||||
name: '',
|
||||
label: '',
|
||||
filters: {},
|
||||
order_by: 'modified desc',
|
||||
columns: '',
|
||||
rows: '',
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
doctype: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
view: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default: {
|
||||
afterCreate: () => {},
|
||||
afterUpdate: () => {},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
async function create() {
|
||||
props.view.doctype = props.doctype
|
||||
let v = await call(
|
||||
'crm.fcrm.doctype.crm_view_settings.crm_view_settings.create',
|
||||
{
|
||||
view: props.view,
|
||||
duplicate: duplicateMode.value,
|
||||
}
|
||||
)
|
||||
show.value = false
|
||||
props.options.afterCreate?.(v, props.view)
|
||||
}
|
||||
|
||||
async function update() {
|
||||
props.view.doctype = props.doctype
|
||||
await call('crm.fcrm.doctype.crm_view_settings.crm_view_settings.update', {
|
||||
view: props.view,
|
||||
})
|
||||
show.value = false
|
||||
props.options.afterUpdate?.(props.view)
|
||||
}
|
||||
|
||||
watch(show, (value) => {
|
||||
if (!value) return
|
||||
editMode.value = false
|
||||
duplicateMode.value = false
|
||||
nextTick(() => {
|
||||
_view.value = { ...props.view }
|
||||
if (_view.value.name) {
|
||||
editMode.value = true
|
||||
} else if (_view.value.label) {
|
||||
duplicateMode.value = true
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@ -1,85 +0,0 @@
|
||||
<template>
|
||||
<Autocomplete
|
||||
placeholder="Select an option"
|
||||
:options="options"
|
||||
:value="selection"
|
||||
@update:query="(q) => onUpdateQuery(q)"
|
||||
@change="(v) => (selection = v)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Autocomplete, createListResource } from 'frappe-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
doctype: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
searchField: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'name',
|
||||
},
|
||||
labelField: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'name',
|
||||
},
|
||||
valueField: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'name',
|
||||
},
|
||||
pageLength: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 10,
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.doctype,
|
||||
(value) => {
|
||||
r.doctype = value
|
||||
r.reload()
|
||||
}
|
||||
)
|
||||
|
||||
const r = createListResource({
|
||||
doctype: props.doctype,
|
||||
pageLength: props.pageLength,
|
||||
cache: ['link_doctype', props.doctype],
|
||||
auto: true,
|
||||
fields: [props.labelField, props.searchField, props.valueField],
|
||||
onSuccess: () => {
|
||||
selection.value = props.value
|
||||
? options.value.find((o) => o.value === props.value)
|
||||
: null
|
||||
},
|
||||
})
|
||||
const options = computed(
|
||||
() =>
|
||||
r.data?.map((result) => ({
|
||||
label: result[props.labelField],
|
||||
value: result[props.valueField],
|
||||
})) || []
|
||||
)
|
||||
const selection = ref(null)
|
||||
|
||||
function onUpdateQuery(query) {
|
||||
r.update({
|
||||
filters: {
|
||||
[props.searchField]: ['like', `%${query}%`],
|
||||
},
|
||||
})
|
||||
|
||||
r.reload()
|
||||
}
|
||||
</script>
|
||||
@ -3,29 +3,29 @@
|
||||
<template #target>
|
||||
<Button label="Sort" ref="sortButtonRef">
|
||||
<template #prefix><SortIcon class="h-4" /></template>
|
||||
<template v-if="sortValues.length" #suffix>
|
||||
<template v-if="sortValues?.size" #suffix>
|
||||
<div
|
||||
class="flex justify-center items-center w-5 h-5 text-2xs font-medium pt-[1px] bg-gray-900 text-white rounded"
|
||||
class="flex h-5 w-5 items-center justify-center rounded bg-gray-900 pt-[1px] text-2xs font-medium text-white"
|
||||
>
|
||||
{{ sortValues.length }}
|
||||
{{ sortValues.size }}
|
||||
</div>
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
<template #body="{ close }">
|
||||
<div class="rounded-lg border border-gray-100 bg-white shadow-xl my-2">
|
||||
<div class="p-2 min-w-[352px]">
|
||||
<div class="my-2 rounded-lg border border-gray-100 bg-white shadow-xl">
|
||||
<div class="min-w-[352px] p-2">
|
||||
<div
|
||||
v-if="sortValues.length"
|
||||
v-if="sortValues?.size"
|
||||
id="sort-list"
|
||||
class="flex flex-col gap-2 mb-3"
|
||||
class="mb-3 flex flex-col gap-2"
|
||||
>
|
||||
<div
|
||||
v-for="(sort, i) in sortValues"
|
||||
:key="sort.fieldname"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<div class="flex items-center justify-center h-7 w-7 handle">
|
||||
<div class="handle flex h-7 w-7 items-center justify-center">
|
||||
<DragIcon class="h-4 w-4 cursor-grab text-gray-600" />
|
||||
</div>
|
||||
<Autocomplete
|
||||
@ -38,11 +38,17 @@
|
||||
<FormControl
|
||||
class="!w-32"
|
||||
type="select"
|
||||
v-model="sort.direction"
|
||||
:value="sort.direction"
|
||||
:options="[
|
||||
{ label: 'Ascending', value: 'asc' },
|
||||
{ label: 'Descending', value: 'desc' },
|
||||
]"
|
||||
@change="
|
||||
(e) => {
|
||||
sort.direction = e.target.value
|
||||
apply()
|
||||
}
|
||||
"
|
||||
placeholder="Sort by"
|
||||
/>
|
||||
<Button variant="ghost" icon="x" @click="removeSort(i)" />
|
||||
@ -50,7 +56,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="text-gray-600 flex items-center text-sm px-3 h-7 mb-3"
|
||||
class="mb-3 flex h-7 items-center px-3 text-sm text-gray-600"
|
||||
>
|
||||
Empty - Choose a field to sort by
|
||||
</div>
|
||||
@ -75,7 +81,7 @@
|
||||
</template>
|
||||
</Autocomplete>
|
||||
<Button
|
||||
v-if="sortValues.length"
|
||||
v-if="sortValues?.size"
|
||||
class="!text-gray-600"
|
||||
variant="ghost"
|
||||
label="Clear Sort"
|
||||
@ -93,7 +99,6 @@ import NestedPopover from '@/components/NestedPopover.vue'
|
||||
import SortIcon from '@/components/Icons/SortIcon.vue'
|
||||
import DragIcon from '@/components/Icons/DragIcon.vue'
|
||||
import { useSortable } from '@vueuse/integrations/useSortable'
|
||||
import { useOrderBy } from '@/composables/orderby'
|
||||
import {
|
||||
FeatherIcon,
|
||||
Button,
|
||||
@ -101,7 +106,7 @@ import {
|
||||
FormControl,
|
||||
createResource,
|
||||
} from 'frappe-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, ref, nextTick } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
doctype: {
|
||||
@ -110,10 +115,10 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const { get: getOrderBy, set: setOrderBy } = useOrderBy()
|
||||
const emit = defineEmits(['update'])
|
||||
const list = defineModel()
|
||||
|
||||
const sortButtonRef = ref(null)
|
||||
const sortValues = ref(initialOrderBy())
|
||||
|
||||
const sortOptions = createResource({
|
||||
url: 'crm.api.doc.sort_options',
|
||||
@ -123,75 +128,83 @@ const sortOptions = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const sortValues = computed({
|
||||
get: () => {
|
||||
if (!list.value?.data) return new Set()
|
||||
let allSortValues = list.value?.params?.order_by
|
||||
if (!allSortValues || !sortOptions.data) return new Set()
|
||||
if (allSortValues.trim() === 'modified desc') return new Set()
|
||||
allSortValues = allSortValues.split(', ').map((sortValue) => {
|
||||
const [fieldname, direction] = sortValue.split(' ')
|
||||
return { fieldname, direction }
|
||||
})
|
||||
return new Set(allSortValues)
|
||||
},
|
||||
set: (value) => {
|
||||
list.value.params.order_by = convertToString(value)
|
||||
},
|
||||
})
|
||||
|
||||
const options = computed(() => {
|
||||
if (!sortOptions.data) return []
|
||||
const selectedOptions = sortValues.value.map((sort) => sort.fieldname)
|
||||
if (!sortValues.value.size) return sortOptions.data
|
||||
const selectedOptions = [...sortValues.value].map((sort) => sort.fieldname)
|
||||
restartSort()
|
||||
return sortOptions.data.filter((option) => {
|
||||
return !selectedOptions.includes(option.value)
|
||||
})
|
||||
})
|
||||
|
||||
function initialOrderBy() {
|
||||
const orderBy = getOrderBy()
|
||||
if (!orderBy) return []
|
||||
const sortOptions = orderBy.split(', ')
|
||||
return sortOptions.map((sortOption) => {
|
||||
const [fieldname, direction] = sortOption.split(' ')
|
||||
return { fieldname, direction }
|
||||
})
|
||||
}
|
||||
|
||||
const sortSortable = useSortable('#sort-list', sortValues, {
|
||||
handle: '.handle',
|
||||
animation: 200,
|
||||
onEnd: () => apply(),
|
||||
})
|
||||
|
||||
watch(
|
||||
() => sortValues.value,
|
||||
(value) => {
|
||||
const updatedSort = value
|
||||
.map((sort) => {
|
||||
const option = sortOptions.data.find((o) => o.value === sort.fieldname)
|
||||
return `${option.value} ${sort.direction}`
|
||||
})
|
||||
.join(', ')
|
||||
setOrderBy(updatedSort)
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => getOrderBy(),
|
||||
(value) => {
|
||||
if (!value) {
|
||||
sortValues.value = []
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function setSort(data) {
|
||||
sortValues.value = [
|
||||
...sortValues.value,
|
||||
{ fieldname: data.value, direction: 'asc' },
|
||||
]
|
||||
sortSortable.start()
|
||||
sortValues.value.add({ fieldname: data.value, direction: 'asc' })
|
||||
restartSort()
|
||||
apply()
|
||||
}
|
||||
|
||||
function updateSort(data, index) {
|
||||
sortValues.value[index] = {
|
||||
let oldSort = Array.from(sortValues.value)[index]
|
||||
sortValues.value.delete(oldSort)
|
||||
sortValues.value.add({
|
||||
fieldname: data.value,
|
||||
direction: sortValues.value[index].direction,
|
||||
}
|
||||
direction: oldSort.direction,
|
||||
})
|
||||
apply()
|
||||
}
|
||||
|
||||
function removeSort(index) {
|
||||
sortValues.value.splice(index, 1)
|
||||
sortValues.value.delete(Array.from(sortValues.value)[index])
|
||||
apply()
|
||||
}
|
||||
|
||||
function clearSort(close) {
|
||||
sortValues.value = []
|
||||
sortValues.value.clear()
|
||||
apply()
|
||||
close()
|
||||
}
|
||||
|
||||
function apply() {
|
||||
nextTick(() => {
|
||||
emit('update', convertToString(sortValues.value))
|
||||
})
|
||||
}
|
||||
|
||||
function convertToString(values) {
|
||||
let _sortValues = ''
|
||||
values.forEach((f) => {
|
||||
_sortValues += `${f.fieldname} ${f.direction}, `
|
||||
})
|
||||
_sortValues = _sortValues.slice(0, -2)
|
||||
return _sortValues
|
||||
}
|
||||
|
||||
function restartSort() {
|
||||
sortSortable.stop()
|
||||
sortSortable.start()
|
||||
}
|
||||
</script>
|
||||
|
||||
362
frontend/src/components/ViewControls.vue
Normal file
362
frontend/src/components/ViewControls.vue
Normal file
@ -0,0 +1,362 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between px-5 pb-4 pt-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Dropdown :options="viewsDropdownOptions">
|
||||
<template #default="{ open }">
|
||||
<Button :label="currentView.label">
|
||||
<template #prefix>
|
||||
<FeatherIcon :name="currentView.icon" class="h-4" />
|
||||
</template>
|
||||
<template #suffix>
|
||||
<FeatherIcon
|
||||
:name="open ? 'chevron-up' : 'chevron-down'"
|
||||
class="h-4 text-gray-600"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="viewUpdated" class="flex items-center gap-2 border-r pr-2">
|
||||
<Button label="Cancel" @click="cancelChanges" />
|
||||
<Button
|
||||
:label="view?.name ? 'Save Changes' : 'Create View'"
|
||||
@click="saveView"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Filter v-model="list" :doctype="doctype" @update="updateFilter" />
|
||||
<SortBy v-model="list" :doctype="doctype" @update="updateSort" />
|
||||
<ViewSettings
|
||||
v-model="list"
|
||||
:doctype="doctype"
|
||||
@update="(isDefault) => updateColumns(isDefault)"
|
||||
/>
|
||||
<Dropdown :options="viewActions">
|
||||
<template #default>
|
||||
<Button>
|
||||
<FeatherIcon name="more-horizontal" class="h-4 w-4" />
|
||||
</Button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ViewModal
|
||||
:doctype="doctype"
|
||||
:view="view"
|
||||
:options="{
|
||||
afterCreate: (v) => {
|
||||
viewUpdated = false
|
||||
router.push({ name: route.name, query: { view: v.name } })
|
||||
},
|
||||
afterUpdate: (v) => {
|
||||
viewUpdated = false
|
||||
currentView = {
|
||||
label: v.label,
|
||||
icon: v.icon || 'list',
|
||||
}
|
||||
},
|
||||
}"
|
||||
v-model="showViewModal"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import ViewModal from '@/components/Modals/ViewModal.vue'
|
||||
import SortBy from '@/components/SortBy.vue'
|
||||
import Filter from '@/components/Filter.vue'
|
||||
import ViewSettings from '@/components/ViewSettings.vue'
|
||||
import { globalStore } from '@/stores/global'
|
||||
import { viewsStore } from '@/stores/views'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { createResource, FeatherIcon, Dropdown, call } from 'frappe-ui'
|
||||
import { computed, ref, defineModel, onMounted, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
|
||||
const props = defineProps({
|
||||
doctype: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
filters: {
|
||||
type: Object,
|
||||
default: {},
|
||||
},
|
||||
})
|
||||
|
||||
const { $dialog } = globalStore()
|
||||
const { getView } = viewsStore()
|
||||
|
||||
const list = defineModel()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const defaultParams = ref('')
|
||||
|
||||
const viewUpdated = ref(false)
|
||||
const showViewModal = ref(false)
|
||||
|
||||
const currentView = ref({
|
||||
label: 'List View',
|
||||
icon: 'list',
|
||||
})
|
||||
|
||||
const view = ref({
|
||||
name: '',
|
||||
label: '',
|
||||
filters: props.filters,
|
||||
order_by: 'modified desc',
|
||||
columns: '',
|
||||
rows: '',
|
||||
default_columns: false,
|
||||
})
|
||||
|
||||
function getParams() {
|
||||
let _view = getView(route.query.view)
|
||||
const filters = (_view?.filters && JSON.parse(_view.filters)) || props.filters
|
||||
const order_by = _view?.order_by || 'modified desc'
|
||||
const columns = _view?.columns || ''
|
||||
const rows = _view?.rows || ''
|
||||
|
||||
if (_view) {
|
||||
view.value = {
|
||||
name: _view.name,
|
||||
label: _view.label,
|
||||
filters: _view.filters,
|
||||
order_by: _view.order_by,
|
||||
columns: _view.columns,
|
||||
rows: _view.rows,
|
||||
default_columns: _view.row,
|
||||
}
|
||||
} else {
|
||||
view.value = {
|
||||
name: '',
|
||||
label: '',
|
||||
filters: props.filters,
|
||||
order_by: 'modified desc',
|
||||
columns: '',
|
||||
rows: '',
|
||||
default_columns: true,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
doctype: props.doctype,
|
||||
filters: filters,
|
||||
order_by: order_by,
|
||||
columns: columns,
|
||||
rows: rows,
|
||||
custom_view_name: _view?.name || '',
|
||||
}
|
||||
}
|
||||
|
||||
list.value = createResource({
|
||||
url: 'crm.api.doc.get_list_data',
|
||||
params: getParams(),
|
||||
onSuccess(data) {
|
||||
setupViews(data.views)
|
||||
setupDefaults(data)
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
useDebounceFn(() => reload(), 100)()
|
||||
})
|
||||
|
||||
function reload() {
|
||||
list.value.params = getParams()
|
||||
list.value.reload()
|
||||
}
|
||||
|
||||
const viewsDropdownOptions = ref([])
|
||||
|
||||
const defaultViews = [
|
||||
{
|
||||
label: 'List View',
|
||||
icon: 'list',
|
||||
onClick() {
|
||||
viewUpdated.value = false
|
||||
router.push({ name: route.name })
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
function setupViews(views) {
|
||||
viewsDropdownOptions.value = [
|
||||
{
|
||||
group: 'Default Views',
|
||||
hideLabel: true,
|
||||
items: defaultViews,
|
||||
},
|
||||
]
|
||||
|
||||
views?.forEach((view) => {
|
||||
view.icon = view.icon || 'list'
|
||||
view.filters = JSON.parse(view.filters)
|
||||
view.onClick = () => {
|
||||
viewUpdated.value = false
|
||||
router.push({ ...route, query: { view: view.name } })
|
||||
}
|
||||
})
|
||||
|
||||
if (views.length) {
|
||||
viewsDropdownOptions.value.push({
|
||||
group: 'Saved Views',
|
||||
items: views,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function setupDefaults(data) {
|
||||
let cv = getView(route.query.view)
|
||||
currentView.value = {
|
||||
label: cv?.label || 'List View',
|
||||
icon: cv?.icon || 'list',
|
||||
}
|
||||
|
||||
defaultParams.value = {
|
||||
doctype: props.doctype,
|
||||
filters: list.value.params.filters,
|
||||
order_by: list.value.params.order_by,
|
||||
columns: data.columns,
|
||||
rows: data.rows,
|
||||
custom_view_name: cv?.name || '',
|
||||
}
|
||||
|
||||
data._defaultFilters = props.filters
|
||||
}
|
||||
|
||||
function updateFilter(filters) {
|
||||
viewUpdated.value = true
|
||||
if (!defaultParams.value) {
|
||||
defaultParams.value = getParams()
|
||||
}
|
||||
list.value.params = defaultParams.value
|
||||
list.value.params.filters = filters
|
||||
list.value.reload()
|
||||
}
|
||||
|
||||
function updateSort(order_by) {
|
||||
viewUpdated.value = true
|
||||
if (!defaultParams.value) {
|
||||
defaultParams.value = getParams()
|
||||
}
|
||||
list.value.params = defaultParams.value
|
||||
list.value.params.order_by = order_by
|
||||
list.value.reload()
|
||||
}
|
||||
|
||||
function updateColumns(obj) {
|
||||
defaultParams.value.columns = obj.isDefault ? '' : obj.columns
|
||||
defaultParams.value.rows = obj.isDefault ? '' : obj.rows
|
||||
view.value.default_columns = obj.isDefault
|
||||
|
||||
if (obj.reset) {
|
||||
defaultParams.value.columns = getParams().columns
|
||||
defaultParams.value.rows = getParams().rows
|
||||
}
|
||||
|
||||
if (obj.reload) {
|
||||
list.value.params = defaultParams.value
|
||||
list.value.reload()
|
||||
}
|
||||
viewUpdated.value = true
|
||||
}
|
||||
|
||||
// View Actions
|
||||
const viewActions = computed(() => {
|
||||
let o = [
|
||||
{
|
||||
group: 'Default Views',
|
||||
hideLabel: true,
|
||||
items: [
|
||||
{
|
||||
label: 'Duplicate View',
|
||||
icon: 'copy',
|
||||
onClick: () => {
|
||||
view.value.name = ''
|
||||
view.value.label = view.value.label + ' New'
|
||||
showViewModal.value = true
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
if (route.query.view) {
|
||||
o.push({
|
||||
group: 'Delete View',
|
||||
hideLabel: true,
|
||||
items: [
|
||||
{
|
||||
label: 'Delete',
|
||||
icon: 'trash-2',
|
||||
onClick: () =>
|
||||
$dialog({
|
||||
title: 'Delete View',
|
||||
message: 'Are you sure you want to delete this view?',
|
||||
variant: 'danger',
|
||||
actions: [
|
||||
{
|
||||
label: 'Delete',
|
||||
variant: 'solid',
|
||||
theme: 'red',
|
||||
onClick: (close) => {
|
||||
close()
|
||||
call(
|
||||
'crm.fcrm.doctype.crm_view_settings.crm_view_settings.delete',
|
||||
{
|
||||
name: route.query.view,
|
||||
}
|
||||
).then(() => {
|
||||
router.push({ name: route.name })
|
||||
})
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
return o
|
||||
})
|
||||
|
||||
function cancelChanges() {
|
||||
reload()
|
||||
viewUpdated.value = false
|
||||
}
|
||||
|
||||
function saveView() {
|
||||
view.value = {
|
||||
label: view.value.label,
|
||||
name: view.value.name,
|
||||
filters: defaultParams.value.filters,
|
||||
order_by: defaultParams.value.order_by,
|
||||
columns: defaultParams.value.columns,
|
||||
rows: defaultParams.value.rows,
|
||||
default_columns: view.value.default_columns,
|
||||
}
|
||||
showViewModal.value = true
|
||||
}
|
||||
|
||||
// Watchers
|
||||
watch(
|
||||
() => getView(route.query.view),
|
||||
(value, old_value) => {
|
||||
if (JSON.stringify(value) === JSON.stringify(old_value)) return
|
||||
reload()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => route,
|
||||
(value, old_value) => {
|
||||
if (value === old_value) return
|
||||
reload()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@ -7,14 +7,14 @@
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
<template #body>
|
||||
<template #body="{ close }">
|
||||
<div
|
||||
class="my-2 rounded-lg border border-gray-100 bg-white p-1.5 shadow-xl"
|
||||
>
|
||||
<div v-if="!edit">
|
||||
<Draggable
|
||||
:list="columns"
|
||||
@end="updateColumnDetails"
|
||||
@end="apply"
|
||||
item-key="key"
|
||||
class="list-group"
|
||||
>
|
||||
@ -64,11 +64,22 @@
|
||||
</Button>
|
||||
</template>
|
||||
</Autocomplete>
|
||||
<Button
|
||||
v-if="columnsUpdated"
|
||||
class="w-full !justify-start !text-gray-600"
|
||||
variant="ghost"
|
||||
@click="reset(close)"
|
||||
label="Reset Changes"
|
||||
>
|
||||
<template #prefix>
|
||||
<ReloadIcon class="h-4" />
|
||||
</template>
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!is_default"
|
||||
class="w-full !justify-start !text-gray-600"
|
||||
variant="ghost"
|
||||
@click="resetToDefault"
|
||||
@click="resetToDefault(close)"
|
||||
label="Reset to Default"
|
||||
>
|
||||
<template #prefix>
|
||||
@ -131,7 +142,8 @@ import NestedPopover from '@/components/NestedPopover.vue'
|
||||
import Autocomplete from '@/components/frappe-ui/Autocomplete.vue'
|
||||
import Draggable from 'vuedraggable'
|
||||
import { computed, defineModel, ref } from 'vue'
|
||||
import { FeatherIcon, FormControl, call } from 'frappe-ui'
|
||||
import { FeatherIcon, FormControl } from 'frappe-ui'
|
||||
import { watchOnce } from '@vueuse/core'
|
||||
|
||||
const props = defineProps({
|
||||
doctype: {
|
||||
@ -140,6 +152,15 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update'])
|
||||
const columnsUpdated = ref(false)
|
||||
|
||||
const oldValues = ref({
|
||||
columns: [],
|
||||
rows: [],
|
||||
isDefault: false,
|
||||
})
|
||||
|
||||
const list = defineModel()
|
||||
const edit = ref(false)
|
||||
const column = ref({
|
||||
@ -179,7 +200,7 @@ const fields = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
async function addColumn(c) {
|
||||
function addColumn(c) {
|
||||
let _column = {
|
||||
label: c.label,
|
||||
type: c.type,
|
||||
@ -188,8 +209,7 @@ async function addColumn(c) {
|
||||
}
|
||||
columns.value.push(_column)
|
||||
rows.value.push(c.value)
|
||||
await updateColumnDetails()
|
||||
list.value.reload()
|
||||
apply(true)
|
||||
}
|
||||
|
||||
function removeColumn(c) {
|
||||
@ -197,7 +217,7 @@ function removeColumn(c) {
|
||||
if (c.key !== 'name') {
|
||||
rows.value = rows.value.filter((row) => row !== c.key)
|
||||
}
|
||||
updateColumnDetails()
|
||||
apply()
|
||||
}
|
||||
|
||||
function editColumn(c) {
|
||||
@ -215,7 +235,7 @@ function updateColumn(c) {
|
||||
if (columns.value[index].old) {
|
||||
delete columns.value[index].old
|
||||
}
|
||||
updateColumnDetails()
|
||||
apply()
|
||||
}
|
||||
|
||||
function cancelUpdate() {
|
||||
@ -225,25 +245,43 @@ function cancelUpdate() {
|
||||
delete column.value.old
|
||||
}
|
||||
|
||||
async function updateColumnDetails() {
|
||||
is_default.value = false
|
||||
await call(
|
||||
'crm.fcrm.doctype.crm_list_view_settings.crm_list_view_settings.update',
|
||||
{
|
||||
doctype: props.doctype,
|
||||
columns: columns.value,
|
||||
rows: rows.value,
|
||||
}
|
||||
)
|
||||
function reset(close) {
|
||||
apply(true, false, true)
|
||||
close()
|
||||
}
|
||||
|
||||
async function resetToDefault() {
|
||||
await call(
|
||||
'crm.fcrm.doctype.crm_list_view_settings.crm_list_view_settings.reset_to_default',
|
||||
{
|
||||
doctype: props.doctype,
|
||||
}
|
||||
)
|
||||
list.value.reload()
|
||||
function resetToDefault(close) {
|
||||
apply(true, true)
|
||||
close()
|
||||
}
|
||||
|
||||
function apply(reload = false, isDefault = false, reset = false) {
|
||||
is_default.value = isDefault
|
||||
columnsUpdated.value = true
|
||||
let obj = {
|
||||
columns: reset ? oldValues.value.columns : columns.value,
|
||||
rows: reset ? oldValues.value.rows : rows.value,
|
||||
isDefault: reset ? oldValues.value.isDefault : isDefault,
|
||||
reload,
|
||||
reset,
|
||||
}
|
||||
emit('update', obj)
|
||||
|
||||
if (reload) {
|
||||
setTimeout(() => {
|
||||
is_default.value = reset ? oldValues.value.isDefault : isDefault
|
||||
columnsUpdated.value = !reset
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
watchOnce(
|
||||
() => list.value.data,
|
||||
(val) => {
|
||||
if (!val) return
|
||||
oldValues.value.columns = JSON.parse(JSON.stringify(val.columns))
|
||||
oldValues.value.rows = JSON.parse(JSON.stringify(val.rows))
|
||||
oldValues.value.isDefault = val.is_default
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
@ -1,104 +0,0 @@
|
||||
import { ref, watchEffect } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { toValue } from '@vueuse/core'
|
||||
import { usersStore } from '@/stores/users'
|
||||
|
||||
const operatorMap = {
|
||||
is: '=',
|
||||
'is not': '!=',
|
||||
equals: '=',
|
||||
'not equals': '!=',
|
||||
yes: true,
|
||||
no: false,
|
||||
like: 'LIKE',
|
||||
'not like': 'NOT LIKE',
|
||||
'>': '>',
|
||||
'<': '<',
|
||||
'>=': '>=',
|
||||
'<=': '<=',
|
||||
}
|
||||
|
||||
export function useFilter(fields) {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { getUser } = usersStore()
|
||||
const storage = ref(new Set())
|
||||
|
||||
watchEffect(() => {
|
||||
const f__ = toValue(fields)
|
||||
if (fields && !f__) return
|
||||
storage.value = new Set()
|
||||
const q = route.query.q || ''
|
||||
q.split(' ')
|
||||
.map((f) => {
|
||||
const [fieldname, operator, value] = f
|
||||
.split(':')
|
||||
.map(decodeURIComponent)
|
||||
const field = (f__ || []).find((f) => f.fieldname === fieldname)
|
||||
return {
|
||||
field,
|
||||
fieldname,
|
||||
operator,
|
||||
value,
|
||||
}
|
||||
})
|
||||
.filter((f) => !f__ || (f__ && f.field))
|
||||
.filter((f) => operatorMap[f.operator])
|
||||
.forEach((f) => storage.value.add(f))
|
||||
})
|
||||
|
||||
function getArgs(old) {
|
||||
old = old || {}
|
||||
const l__ = Array.from(storage.value)
|
||||
const obj = l__.map(transformIn).reduce((p, c) => {
|
||||
p[c.fieldname] = [operatorMap[c.operator.toLowerCase()], c.value]
|
||||
return p
|
||||
}, {})
|
||||
const merged = { ...old, ...obj }
|
||||
return merged
|
||||
}
|
||||
|
||||
function apply(r) {
|
||||
r = r || route
|
||||
const l__ = Array.from(storage.value)
|
||||
const q = l__
|
||||
.map(transformOut)
|
||||
.map((f) =>
|
||||
[f.fieldname, f.operator.toLowerCase(), f.value]
|
||||
.map(encodeURIComponent)
|
||||
.join(':')
|
||||
)
|
||||
.join(' ')
|
||||
if (!q && !r.query.q) {
|
||||
router.push({ ...r, query: { ...r.query } })
|
||||
} else {
|
||||
router.push({ ...r, query: { ...r.query, q } })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to set fields internally. These will not reflect in URL.
|
||||
* Can be used for APIs
|
||||
*/
|
||||
function transformIn(f) {
|
||||
if (f.fieldname === '_assign') {
|
||||
f.operator = f.operator === 'is' ? 'like' : 'not like'
|
||||
}
|
||||
if (f.operator.includes('like') && !f.value.includes('%')) {
|
||||
f.value = `%${f.value}%`
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to set fields in URL query
|
||||
*/
|
||||
function transformOut(f) {
|
||||
if (f.value === '@me') {
|
||||
f.value = getUser()
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
return { apply, getArgs, storage }
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
export function useOrderBy() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
function get() {
|
||||
const q = route.query.sort ?? ''
|
||||
const d = decodeURIComponent(q)
|
||||
return d
|
||||
}
|
||||
|
||||
function set(sort, r) {
|
||||
r = r || route
|
||||
const q = encodeURIComponent(sort)
|
||||
router.push({ ...r, query: { ...r.query, sort: q } })
|
||||
}
|
||||
|
||||
return {
|
||||
get,
|
||||
set,
|
||||
}
|
||||
}
|
||||
@ -4,21 +4,16 @@
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
</template>
|
||||
</LayoutHeader>
|
||||
<div class="flex items-center justify-between px-5 pb-4 pt-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<SortBy doctype="CRM Call Log" />
|
||||
<Filter doctype="CRM Call Log" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<ViewSettings doctype="CRM Call Log" v-model="callLogs" />
|
||||
</div>
|
||||
</div>
|
||||
<ViewControls v-model="callLogs" doctype="CRM Call Log" />
|
||||
<CallLogsListView
|
||||
v-if="callLogs.data && rows.length"
|
||||
:rows="rows"
|
||||
:columns="callLogs.data.columns"
|
||||
/>
|
||||
<div v-else-if="callLogs.data" class="flex h-full items-center justify-center">
|
||||
<div
|
||||
v-else-if="callLogs.data"
|
||||
class="flex h-full items-center justify-center"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-center gap-3 text-xl font-medium text-gray-500"
|
||||
>
|
||||
@ -31,9 +26,7 @@
|
||||
<script setup>
|
||||
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
|
||||
import LayoutHeader from '@/components/LayoutHeader.vue'
|
||||
import SortBy from '@/components/SortBy.vue'
|
||||
import Filter from '@/components/Filter.vue'
|
||||
import ViewSettings from '@/components/ViewSettings.vue'
|
||||
import ViewControls from '@/components/ViewControls.vue'
|
||||
import CallLogsListView from '@/components/ListViews/CallLogsListView.vue'
|
||||
import {
|
||||
secondsToDuration,
|
||||
@ -43,61 +36,21 @@ import {
|
||||
} from '@/utils'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { contactsStore } from '@/stores/contacts'
|
||||
import { useOrderBy } from '@/composables/orderby'
|
||||
import { useFilter } from '@/composables/filter'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { createResource, Breadcrumbs, FeatherIcon } from 'frappe-ui'
|
||||
import { computed, watch } from 'vue'
|
||||
import { Breadcrumbs } from 'frappe-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const { getUser } = usersStore()
|
||||
const { getContact } = contactsStore()
|
||||
const { get: getOrderBy } = useOrderBy()
|
||||
const { getArgs, storage } = useFilter()
|
||||
|
||||
const breadcrumbs = [{ label: 'Call Logs', route: { name: 'Call Logs' } }]
|
||||
|
||||
function getParams() {
|
||||
const filters = getArgs() || {}
|
||||
const order_by = getOrderBy() || 'creation desc'
|
||||
|
||||
return {
|
||||
doctype: 'CRM Call Log',
|
||||
filters: filters,
|
||||
order_by: order_by,
|
||||
}
|
||||
}
|
||||
|
||||
const callLogs = createResource({
|
||||
url: 'crm.api.doc.get_list_data',
|
||||
params: getParams(),
|
||||
auto: true,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => getOrderBy(),
|
||||
(value, old_value) => {
|
||||
if (!value && !old_value) return
|
||||
callLogs.params = getParams()
|
||||
callLogs.reload()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
storage,
|
||||
useDebounceFn((value, old_value) => {
|
||||
if (JSON.stringify([...value]) === JSON.stringify([...old_value])) return
|
||||
callLogs.params = getParams()
|
||||
callLogs.reload()
|
||||
}, 300),
|
||||
{ deep: true }
|
||||
)
|
||||
const callLogs = ref({})
|
||||
|
||||
const rows = computed(() => {
|
||||
if (!callLogs.data?.data) return []
|
||||
return callLogs.data.data.map((callLog) => {
|
||||
if (!callLogs.value?.data?.data) return []
|
||||
return callLogs.value?.data.data.map((callLog) => {
|
||||
let _rows = {}
|
||||
callLogs.data.rows.forEach((row) => {
|
||||
callLogs.value?.data.rows.forEach((row) => {
|
||||
_rows[row] = callLog[row]
|
||||
|
||||
let incoming = callLog.type === 'Incoming'
|
||||
|
||||
@ -9,36 +9,16 @@
|
||||
</Button>
|
||||
</template>
|
||||
</LayoutHeader>
|
||||
<div class="flex items-center justify-between px-5 pb-4 pt-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Dropdown :options="viewsDropdownOptions">
|
||||
<template #default="{ open }">
|
||||
<Button :label="currentView.label">
|
||||
<template #prefix>
|
||||
<FeatherIcon :name="currentView.icon" class="h-4" />
|
||||
</template>
|
||||
<template #suffix>
|
||||
<FeatherIcon
|
||||
:name="open ? 'chevron-up' : 'chevron-down'"
|
||||
class="h-4 text-gray-600"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Filter doctype="Contact" />
|
||||
<SortBy doctype="Contact" />
|
||||
<ViewSettings doctype="Contact" v-model="contacts" />
|
||||
</div>
|
||||
</div>
|
||||
<ViewControls v-model="contacts" doctype="Contact" />
|
||||
<ContactsListView
|
||||
v-if="contacts.data && rows.length"
|
||||
:rows="rows"
|
||||
:columns="contacts.data.columns"
|
||||
/>
|
||||
<div v-else-if="contacts.data" class="flex h-full items-center justify-center">
|
||||
<div
|
||||
v-else-if="contacts.data"
|
||||
class="flex h-full items-center justify-center"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-center gap-3 text-xl font-medium text-gray-500"
|
||||
>
|
||||
@ -57,27 +37,20 @@ import ContactsIcon from '@/components/Icons/ContactsIcon.vue'
|
||||
import LayoutHeader from '@/components/LayoutHeader.vue'
|
||||
import ContactModal from '@/components/Modals/ContactModal.vue'
|
||||
import ContactsListView from '@/components/ListViews/ContactsListView.vue'
|
||||
import SortBy from '@/components/SortBy.vue'
|
||||
import Filter from '@/components/Filter.vue'
|
||||
import ViewSettings from '@/components/ViewSettings.vue'
|
||||
import { FeatherIcon, Breadcrumbs, Dropdown, createResource } from 'frappe-ui'
|
||||
import ViewControls from '@/components/ViewControls.vue'
|
||||
import { FeatherIcon, Breadcrumbs } from 'frappe-ui'
|
||||
import { organizationsStore } from '@/stores/organizations.js'
|
||||
import { useOrderBy } from '@/composables/orderby'
|
||||
import { useFilter } from '@/composables/filter'
|
||||
import { dateFormat, dateTooltipFormat, timeAgo } from '@/utils'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const { getOrganization } = organizationsStore()
|
||||
const { get: getOrderBy } = useOrderBy()
|
||||
const { getArgs, storage } = useFilter()
|
||||
const route = useRoute()
|
||||
|
||||
const showContactModal = ref(false)
|
||||
|
||||
const currentContact = computed(() => {
|
||||
return contacts.data?.data?.find(
|
||||
return contacts.value?.data?.data?.find(
|
||||
(contact) => contact.name === route.params.contactId
|
||||
)
|
||||
})
|
||||
@ -95,53 +68,13 @@ const breadcrumbs = computed(() => {
|
||||
return items
|
||||
})
|
||||
|
||||
const currentView = ref({
|
||||
label: 'List',
|
||||
icon: 'list',
|
||||
})
|
||||
|
||||
function getParams() {
|
||||
const filters = getArgs() || {}
|
||||
const order_by = getOrderBy() || 'modified desc'
|
||||
|
||||
return {
|
||||
doctype: 'Contact',
|
||||
filters: filters,
|
||||
order_by: order_by,
|
||||
}
|
||||
}
|
||||
|
||||
const contacts = createResource({
|
||||
url: 'crm.api.doc.get_list_data',
|
||||
params: getParams(),
|
||||
auto: true,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => getOrderBy(),
|
||||
(value, old_value) => {
|
||||
if (!value && !old_value) return
|
||||
contacts.params = getParams()
|
||||
contacts.reload()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
storage,
|
||||
useDebounceFn((value, old_value) => {
|
||||
if (JSON.stringify([...value]) === JSON.stringify([...old_value])) return
|
||||
contacts.params = getParams()
|
||||
contacts.reload()
|
||||
}, 300),
|
||||
{ deep: true }
|
||||
)
|
||||
const contacts = ref({})
|
||||
|
||||
const rows = computed(() => {
|
||||
if (!contacts.data?.data) return []
|
||||
return contacts.data.data.map((contact) => {
|
||||
if (!contacts.value?.data?.data) return []
|
||||
return contacts.value?.data.data.map((contact) => {
|
||||
let _rows = {}
|
||||
contacts.data.rows.forEach((row) => {
|
||||
contacts.value?.data.rows.forEach((row) => {
|
||||
_rows[row] = contact[row]
|
||||
|
||||
if (row == 'full_name') {
|
||||
@ -165,47 +98,4 @@ const rows = computed(() => {
|
||||
return _rows
|
||||
})
|
||||
})
|
||||
|
||||
const viewsDropdownOptions = [
|
||||
{
|
||||
label: 'List',
|
||||
icon: 'list',
|
||||
onClick() {
|
||||
currentView.value = {
|
||||
label: 'List',
|
||||
icon: 'list',
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Table',
|
||||
icon: 'grid',
|
||||
onClick() {
|
||||
currentView.value = {
|
||||
label: 'Table',
|
||||
icon: 'grid',
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Calender',
|
||||
icon: 'calendar',
|
||||
onClick() {
|
||||
currentView.value = {
|
||||
label: 'Calender',
|
||||
icon: 'calendar',
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Board',
|
||||
icon: 'columns',
|
||||
onClick() {
|
||||
currentView.value = {
|
||||
label: 'Board',
|
||||
icon: 'columns',
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
@ -9,30 +9,7 @@
|
||||
</Button>
|
||||
</template>
|
||||
</LayoutHeader>
|
||||
<div class="flex items-center justify-between px-5 pb-4 pt-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Dropdown :options="viewsDropdownOptions">
|
||||
<template #default="{ open }">
|
||||
<Button :label="currentView.label">
|
||||
<template #prefix>
|
||||
<FeatherIcon :name="currentView.icon" class="h-4" />
|
||||
</template>
|
||||
<template #suffix>
|
||||
<FeatherIcon
|
||||
:name="open ? 'chevron-up' : 'chevron-down'"
|
||||
class="h-4 text-gray-600"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Filter doctype="CRM Deal" />
|
||||
<SortBy doctype="CRM Deal" />
|
||||
<ViewSettings doctype="CRM Deal" v-model="deals" />
|
||||
</div>
|
||||
</div>
|
||||
<ViewControls v-model="deals" doctype="CRM Deal" />
|
||||
<DealsListView
|
||||
v-if="deals.data && rows.length"
|
||||
:rows="rows"
|
||||
@ -73,15 +50,10 @@ import DealsIcon from '@/components/Icons/DealsIcon.vue'
|
||||
import LayoutHeader from '@/components/LayoutHeader.vue'
|
||||
import DealsListView from '@/components/ListViews/DealsListView.vue'
|
||||
import NewDeal from '@/components/NewDeal.vue'
|
||||
import SortBy from '@/components/SortBy.vue'
|
||||
import Filter from '@/components/Filter.vue'
|
||||
import ViewSettings from '@/components/ViewSettings.vue'
|
||||
import ViewControls from '@/components/ViewControls.vue'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { organizationsStore } from '@/stores/organizations'
|
||||
import { statusesStore } from '@/stores/statuses'
|
||||
import { useOrderBy } from '@/composables/orderby'
|
||||
import { useFilter } from '@/composables/filter'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import {
|
||||
dateFormat,
|
||||
dateTooltipFormat,
|
||||
@ -93,68 +65,29 @@ import {
|
||||
FeatherIcon,
|
||||
Dialog,
|
||||
Button,
|
||||
Dropdown,
|
||||
createResource,
|
||||
Breadcrumbs,
|
||||
} from 'frappe-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref, computed, reactive, watch } from 'vue'
|
||||
import { ref, computed, reactive } from 'vue'
|
||||
|
||||
const breadcrumbs = [{ label: 'Deals', route: { name: 'Deals' } }]
|
||||
|
||||
const { getUser } = usersStore()
|
||||
const { getOrganization } = organizationsStore()
|
||||
const { getDealStatus } = statusesStore()
|
||||
const { get: getOrderBy } = useOrderBy()
|
||||
const { getArgs, storage } = useFilter()
|
||||
|
||||
const currentView = ref({
|
||||
label: 'List',
|
||||
icon: 'list',
|
||||
})
|
||||
const router = useRouter()
|
||||
|
||||
function getParams() {
|
||||
const filters = getArgs() || {}
|
||||
const order_by = getOrderBy() || 'modified desc'
|
||||
|
||||
return {
|
||||
doctype: 'CRM Deal',
|
||||
filters: filters,
|
||||
order_by: order_by,
|
||||
}
|
||||
}
|
||||
|
||||
const deals = createResource({
|
||||
url: 'crm.api.doc.get_list_data',
|
||||
params: getParams(),
|
||||
auto: true,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => getOrderBy(),
|
||||
(value, old_value) => {
|
||||
if (!value && !old_value) return
|
||||
deals.params = getParams()
|
||||
deals.reload()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
storage,
|
||||
useDebounceFn((value, old_value) => {
|
||||
if (JSON.stringify([...value]) === JSON.stringify([...old_value])) return
|
||||
deals.params = getParams()
|
||||
deals.reload()
|
||||
}, 300),
|
||||
{ deep: true }
|
||||
)
|
||||
// deals data is loaded in the ViewControls component
|
||||
const deals = ref({})
|
||||
|
||||
// Rows
|
||||
const rows = computed(() => {
|
||||
if (!deals.data?.data) return []
|
||||
return deals.data.data.map((deal) => {
|
||||
if (!deals.value?.data?.data) return []
|
||||
return deals.value.data.data.map((deal) => {
|
||||
let _rows = {}
|
||||
deals.data.rows.forEach((row) => {
|
||||
deals.value.data.rows.forEach((row) => {
|
||||
_rows[row] = deal[row]
|
||||
|
||||
let org = getOrganization(deal.organization)
|
||||
@ -229,49 +162,7 @@ const rows = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const viewsDropdownOptions = [
|
||||
{
|
||||
label: 'List',
|
||||
icon: 'list',
|
||||
onClick() {
|
||||
currentView.value = {
|
||||
label: 'List',
|
||||
icon: 'list',
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Table',
|
||||
icon: 'grid',
|
||||
onClick() {
|
||||
currentView.value = {
|
||||
label: 'Table',
|
||||
icon: 'grid',
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Calender',
|
||||
icon: 'calendar',
|
||||
onClick() {
|
||||
currentView.value = {
|
||||
label: 'Calender',
|
||||
icon: 'calendar',
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Board',
|
||||
icon: 'columns',
|
||||
onClick() {
|
||||
currentView.value = {
|
||||
label: 'Board',
|
||||
icon: 'columns',
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// New Deal
|
||||
const showNewDialog = ref(false)
|
||||
|
||||
let newDeal = reactive({
|
||||
@ -294,8 +185,6 @@ const createDeal = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function createNewDeal(close) {
|
||||
createDeal
|
||||
.submit(newDeal, {
|
||||
|
||||
@ -9,29 +9,11 @@
|
||||
</Button>
|
||||
</template>
|
||||
</LayoutHeader>
|
||||
<div class="flex items-center justify-between px-5 pb-4 pt-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Dropdown :options="viewsDropdownOptions">
|
||||
<template #default="{ open }">
|
||||
<Button :label="currentView.label">
|
||||
<template #prefix
|
||||
><FeatherIcon :name="currentView.icon" class="h-4"
|
||||
/></template>
|
||||
<template #suffix
|
||||
><FeatherIcon
|
||||
:name="open ? 'chevron-up' : 'chevron-down'"
|
||||
class="h-4 text-gray-600"
|
||||
/></template>
|
||||
</Button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Filter doctype="CRM Lead" />
|
||||
<SortBy doctype="CRM Lead" />
|
||||
<ViewSettings doctype="CRM Lead" v-model="leads" />
|
||||
</div>
|
||||
</div>
|
||||
<ViewControls
|
||||
v-model="leads"
|
||||
doctype="CRM Lead"
|
||||
:filters="{ converted: 0 }"
|
||||
/>
|
||||
<LeadsListView
|
||||
v-if="leads.data && rows.length"
|
||||
:rows="rows"
|
||||
@ -72,86 +54,38 @@ import LeadsIcon from '@/components/Icons/LeadsIcon.vue'
|
||||
import LayoutHeader from '@/components/LayoutHeader.vue'
|
||||
import LeadsListView from '@/components/ListViews/LeadsListView.vue'
|
||||
import NewLead from '@/components/NewLead.vue'
|
||||
import SortBy from '@/components/SortBy.vue'
|
||||
import Filter from '@/components/Filter.vue'
|
||||
import ViewSettings from '@/components/ViewSettings.vue'
|
||||
import ViewControls from '@/components/ViewControls.vue'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { organizationsStore } from '@/stores/organizations'
|
||||
import { statusesStore } from '@/stores/statuses'
|
||||
import { useOrderBy } from '@/composables/orderby'
|
||||
import { useFilter } from '@/composables/filter'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { dateFormat, dateTooltipFormat, timeAgo, formatTime } from '@/utils'
|
||||
import {
|
||||
FeatherIcon,
|
||||
Dialog,
|
||||
Button,
|
||||
Dropdown,
|
||||
createResource,
|
||||
Breadcrumbs,
|
||||
} from 'frappe-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref, computed, reactive, watch } from 'vue'
|
||||
import { ref, computed, reactive } from 'vue'
|
||||
|
||||
const breadcrumbs = [{ label: 'Leads', route: { name: 'Leads' } }]
|
||||
|
||||
const { getUser } = usersStore()
|
||||
const { getOrganization } = organizationsStore()
|
||||
const { getLeadStatus } = statusesStore()
|
||||
const { get: getOrderBy } = useOrderBy()
|
||||
const { getArgs, storage } = useFilter()
|
||||
|
||||
const currentView = ref({
|
||||
label: 'List',
|
||||
icon: 'list',
|
||||
})
|
||||
const router = useRouter()
|
||||
|
||||
function getParams() {
|
||||
const filters = {
|
||||
converted: 0,
|
||||
...(getArgs() || {}),
|
||||
}
|
||||
|
||||
const order_by = getOrderBy() || 'modified desc'
|
||||
|
||||
return {
|
||||
doctype: 'CRM Lead',
|
||||
filters: filters,
|
||||
order_by: order_by,
|
||||
}
|
||||
}
|
||||
|
||||
const leads = createResource({
|
||||
url: 'crm.api.doc.get_list_data',
|
||||
params: getParams(),
|
||||
auto: true,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => getOrderBy(),
|
||||
(value, old_value) => {
|
||||
if (!value && !old_value) return
|
||||
leads.params = getParams()
|
||||
leads.reload()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
storage,
|
||||
useDebounceFn((value, old_value) => {
|
||||
if (JSON.stringify([...value]) === JSON.stringify([...old_value])) return
|
||||
leads.params = getParams()
|
||||
leads.reload()
|
||||
}, 300),
|
||||
{ deep: true }
|
||||
)
|
||||
// leads data is loaded in the ViewControls component
|
||||
const leads = ref({})
|
||||
|
||||
// Rows
|
||||
const rows = computed(() => {
|
||||
if (!leads.data?.data) return []
|
||||
return leads.data.data.map((lead) => {
|
||||
if (!leads.value?.data?.data) return []
|
||||
return leads.value?.data.data.map((lead) => {
|
||||
let _rows = {}
|
||||
leads.data.rows.forEach((row) => {
|
||||
leads.value?.data.rows.forEach((row) => {
|
||||
_rows[row] = lead[row]
|
||||
|
||||
if (row == 'lead_name') {
|
||||
@ -228,49 +162,7 @@ const rows = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const viewsDropdownOptions = [
|
||||
{
|
||||
label: 'List',
|
||||
icon: 'list',
|
||||
onClick() {
|
||||
currentView.value = {
|
||||
label: 'List',
|
||||
icon: 'list',
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Table',
|
||||
icon: 'grid',
|
||||
onClick() {
|
||||
currentView.value = {
|
||||
label: 'Table',
|
||||
icon: 'grid',
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Calender',
|
||||
icon: 'calendar',
|
||||
onClick() {
|
||||
currentView.value = {
|
||||
label: 'Calender',
|
||||
icon: 'calendar',
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Board',
|
||||
icon: 'columns',
|
||||
onClick() {
|
||||
currentView.value = {
|
||||
label: 'Board',
|
||||
icon: 'columns',
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// New Lead
|
||||
const showNewDialog = ref(false)
|
||||
|
||||
let newLead = reactive({
|
||||
@ -297,8 +189,6 @@ const createLead = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function createNewLead(close) {
|
||||
createLead
|
||||
.submit(newLead, {
|
||||
|
||||
@ -13,36 +13,16 @@
|
||||
</Button>
|
||||
</template>
|
||||
</LayoutHeader>
|
||||
<div class="flex items-center justify-between px-5 pb-4 pt-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Dropdown :options="viewsDropdownOptions">
|
||||
<template #default="{ open }">
|
||||
<Button :label="currentView.label">
|
||||
<template #prefix>
|
||||
<FeatherIcon :name="currentView.icon" class="h-4" />
|
||||
</template>
|
||||
<template #suffix>
|
||||
<FeatherIcon
|
||||
:name="open ? 'chevron-up' : 'chevron-down'"
|
||||
class="h-4 text-gray-600"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Filter doctype="CRM Organization" />
|
||||
<SortBy doctype="CRM Organization" />
|
||||
<ViewSettings doctype="CRM Organization" v-model="organizations" />
|
||||
</div>
|
||||
</div>
|
||||
<ViewControls v-model="organizations" doctype="CRM Organization" />
|
||||
<OrganizationsListView
|
||||
v-if="organizations.data && rows.length"
|
||||
:rows="rows"
|
||||
:columns="organizations.data.columns"
|
||||
/>
|
||||
<div v-else-if="organizations.data" class="flex h-full items-center justify-center">
|
||||
<div
|
||||
v-else-if="organizations.data"
|
||||
class="flex h-full items-center justify-center"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-center gap-3 text-xl font-medium text-gray-500"
|
||||
>
|
||||
@ -60,30 +40,23 @@ import OrganizationsIcon from '@/components/Icons/OrganizationsIcon.vue'
|
||||
import LayoutHeader from '@/components/LayoutHeader.vue'
|
||||
import OrganizationModal from '@/components/Modals/OrganizationModal.vue'
|
||||
import OrganizationsListView from '@/components/ListViews/OrganizationsListView.vue'
|
||||
import SortBy from '@/components/SortBy.vue'
|
||||
import Filter from '@/components/Filter.vue'
|
||||
import ViewSettings from '@/components/ViewSettings.vue'
|
||||
import { useOrderBy } from '@/composables/orderby'
|
||||
import { useFilter } from '@/composables/filter'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { FeatherIcon, Breadcrumbs, Dropdown, createResource } from 'frappe-ui'
|
||||
import ViewControls from '@/components/ViewControls.vue'
|
||||
import { FeatherIcon, Breadcrumbs } from 'frappe-ui'
|
||||
import {
|
||||
dateFormat,
|
||||
dateTooltipFormat,
|
||||
timeAgo,
|
||||
formatNumberIntoCurrency,
|
||||
} from '@/utils'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const { get: getOrderBy } = useOrderBy()
|
||||
const { getArgs, storage } = useFilter()
|
||||
|
||||
const showOrganizationModal = ref(false)
|
||||
|
||||
const currentOrganization = computed(() => {
|
||||
return organizations.data?.data?.find(
|
||||
return organizations.value?.data?.data?.find(
|
||||
(organization) => organization.name === route.params.organizationId
|
||||
)
|
||||
})
|
||||
@ -101,53 +74,13 @@ const breadcrumbs = computed(() => {
|
||||
return items
|
||||
})
|
||||
|
||||
const currentView = ref({
|
||||
label: 'List',
|
||||
icon: 'list',
|
||||
})
|
||||
|
||||
function getParams() {
|
||||
const filters = getArgs() || {}
|
||||
const order_by = getOrderBy() || 'modified desc'
|
||||
|
||||
return {
|
||||
doctype: 'CRM Organization',
|
||||
filters: filters,
|
||||
order_by: order_by,
|
||||
}
|
||||
}
|
||||
|
||||
const organizations = createResource({
|
||||
url: 'crm.api.doc.get_list_data',
|
||||
params: getParams(),
|
||||
auto: true,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => getOrderBy(),
|
||||
(value, old_value) => {
|
||||
if (!value && !old_value) return
|
||||
organizations.params = getParams()
|
||||
organizations.reload()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
storage,
|
||||
useDebounceFn((value, old_value) => {
|
||||
if (JSON.stringify([...value]) === JSON.stringify([...old_value])) return
|
||||
organizations.params = getParams()
|
||||
organizations.reload()
|
||||
}, 300),
|
||||
{ deep: true }
|
||||
)
|
||||
const organizations = ref({})
|
||||
|
||||
const rows = computed(() => {
|
||||
if (!organizations.data?.data) return []
|
||||
return organizations.data.data.map((organization) => {
|
||||
if (!organizations.value?.data?.data) return []
|
||||
return organizations.value?.data.data.map((organization) => {
|
||||
let _rows = {}
|
||||
organizations.data.rows.forEach((row) => {
|
||||
organizations.value?.data.rows.forEach((row) => {
|
||||
_rows[row] = organization[row]
|
||||
|
||||
if (row === 'organization_name') {
|
||||
@ -170,49 +103,6 @@ const rows = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const viewsDropdownOptions = [
|
||||
{
|
||||
label: 'List',
|
||||
icon: 'list',
|
||||
onClick() {
|
||||
currentView.value = {
|
||||
label: 'List',
|
||||
icon: 'list',
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Table',
|
||||
icon: 'grid',
|
||||
onClick() {
|
||||
currentView.value = {
|
||||
label: 'Table',
|
||||
icon: 'grid',
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Calender',
|
||||
icon: 'calendar',
|
||||
onClick() {
|
||||
currentView.value = {
|
||||
label: 'Calender',
|
||||
icon: 'calendar',
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Board',
|
||||
icon: 'columns',
|
||||
onClick() {
|
||||
currentView.value = {
|
||||
label: 'Board',
|
||||
icon: 'columns',
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
function website(url) {
|
||||
return url && url.replace(/^(?:https?:\/\/)?(?:www\.)?/i, '')
|
||||
}
|
||||
|
||||
34
frontend/src/stores/views.js
Normal file
34
frontend/src/stores/views.js
Normal file
@ -0,0 +1,34 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { createListResource } from 'frappe-ui'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
export const viewsStore = defineStore('crm-views', () => {
|
||||
let viewsByName = reactive({})
|
||||
|
||||
const views = createListResource({
|
||||
doctype: 'CRM View Settings',
|
||||
fields: ['*'],
|
||||
cache: 'crm-views',
|
||||
initialData: [],
|
||||
auto: true,
|
||||
transform(views) {
|
||||
for (let view of views) {
|
||||
viewsByName[view.name] = view
|
||||
}
|
||||
return views
|
||||
},
|
||||
})
|
||||
|
||||
function getView(view) {
|
||||
if (!view) return null
|
||||
if (!viewsByName[view]) {
|
||||
views.reload()
|
||||
}
|
||||
return viewsByName[view]
|
||||
}
|
||||
|
||||
return {
|
||||
views,
|
||||
getView,
|
||||
}
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user