diff --git a/crm/api/doc.py b/crm/api/doc.py index a1a7bb403..8505a7a89 100644 --- a/crm/api/doc.py +++ b/crm/api/doc.py @@ -4,6 +4,7 @@ from frappe.model.document import get_controller from frappe.model import no_value_fields from pypika import Criterion +from frappe.utils import make_filter_tuple from crm.api.views import get_views from crm.fcrm.doctype.crm_form_script.crm_form_script import get_form_script @@ -200,19 +201,27 @@ def get_quick_filters(doctype: str): return quick_filters @frappe.whitelist() -def get_list_data( +def get_data( doctype: str, filters: dict, order_by: str, page_length=20, page_length_count=20, - columns=None, - rows=None, + column_field=None, + title_field=None, + columns=[], + rows=[], + kanban_columns=[], + kanban_fields=[], view=None, default_filters=None, ): custom_view = False filters = frappe._dict(filters) + rows = frappe.parse_json(rows or "[]") + columns = frappe.parse_json(columns or "[]") + kanban_fields = frappe.parse_json(kanban_fields or "[]") + kanban_columns = frappe.parse_json(kanban_columns or "[]") custom_view_name = view.get('custom_view_name') if view else None view_type = view.get('view_type') if view else None @@ -235,61 +244,133 @@ def get_list_data( filters.update(default_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 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"] - - default_view_filters = { - "dt": doctype, - "type": view_type or 'list', - "is_default": 1, - "user": frappe.session.user, - } - + data = [] _list = get_controller(doctype) - - if not custom_view and frappe.db.exists("CRM View Settings", default_view_filters): - list_view_settings = frappe.get_doc("CRM View Settings", default_view_filters) - columns = frappe.parse_json(list_view_settings.columns) - rows = frappe.parse_json(list_view_settings.rows) - is_default = False - elif not custom_view or is_default and hasattr(_list, "default_list_data"): - columns = _list.default_list_data().get("columns") - if hasattr(_list, "default_list_data"): rows = _list.default_list_data().get("rows") - # check if rows has all keys from columns if not add them - for column in columns: - if column.get("key") not in rows: - rows.append(column.get("key")) - column["label"] = _(column.get("label")) + if view_type != "kanban": + if columns or rows: + custom_view = True + is_default = False + columns = frappe.parse_json(columns) + rows = frappe.parse_json(rows) + + 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"] + + default_view_filters = { + "dt": doctype, + "type": view_type or 'list', + "is_default": 1, + "user": frappe.session.user, + } - if column.get("key") == "_liked_by" and column.get("width") == "10rem": - column["width"] = "50px" + if not custom_view and frappe.db.exists("CRM View Settings", default_view_filters): + list_view_settings = frappe.get_doc("CRM View Settings", default_view_filters) + columns = frappe.parse_json(list_view_settings.columns) + rows = frappe.parse_json(list_view_settings.rows) + is_default = False + elif not custom_view or is_default and hasattr(_list, "default_list_data"): + columns = _list.default_list_data().get("columns") + + # check if rows has all keys from columns if not add them + for column in columns: + if column.get("key") not in rows: + rows.append(column.get("key")) + column["label"] = _(column.get("label")) + + if column.get("key") == "_liked_by" and column.get("width") == "10rem": + column["width"] = "50px" + + # check if rows has group_by_field if not add it + if group_by_field and group_by_field not in rows: + rows.append(group_by_field) + + data = frappe.get_list( + doctype, + fields=rows, + filters=filters, + order_by=order_by, + page_length=page_length, + ) or [] + + if view_type == "kanban": + if not kanban_columns and column_field: + field_meta = frappe.get_meta(doctype).get_field(column_field) + if field_meta.fieldtype == "Link": + kanban_columns = frappe.get_all( + field_meta.options, + fields=["name"], + order_by="modified asc", + ) + elif field_meta.fieldtype == "Select": + kanban_columns = [{"name": option} for option in field_meta.options.split("\n")] + + if not title_field: + title_field = "name" + if hasattr(_list, "default_kanban_settings"): + title_field = _list.default_kanban_settings().get("title_field") + + if title_field not in rows: + rows.append(title_field) + + if not kanban_fields: + kanban_fields = ["name"] + if hasattr(_list, "default_kanban_settings"): + kanban_fields = json.loads(_list.default_kanban_settings().get("kanban_fields")) + + for field in kanban_fields: + if field not in rows: + rows.append(field) + + for kc in kanban_columns: + column_filters = { column_field: kc.get('name') } + if column_field in filters and filters.get(column_field) != kc.name: + column_data = [] + else: + column_filters.update(filters.copy()) + page_length = 20 - # check if rows has group_by_field if not add it - if group_by_field and group_by_field not in rows: - rows.append(group_by_field) + if kc.get("page_length"): + page_length = kc.get("page_length") - data = frappe.get_list( - doctype, - fields=rows, - filters=filters, - order_by=order_by, - page_length=page_length, - ) or [] + order = kc.get("order") + if order: + column_data = get_records_based_on_order(doctype, rows, column_filters, page_length, order) + else: + column_data = frappe.get_list( + doctype, + fields=rows, + filters=convert_filter_to_tuple(doctype, column_filters), + order_by=order_by, + page_length=page_length, + ) + + new_filters = filters.copy() + new_filters.update({ column_field: kc.get('name') }) + + all_count = len(frappe.get_list(doctype, filters=convert_filter_to_tuple(doctype, new_filters))) + + kc["all_count"] = all_count + kc["count"] = len(column_data) + + for d in column_data: + getCounts(d, doctype) + + if order: + column_data = sorted( + column_data, key=lambda x: order.index(x.get("name")) + if x.get("name") in order else len(order) + ) + + data.append({"column": kc, "fields": kanban_fields, "data": column_data}) fields = frappe.get_meta(doctype).fields fields = [field for field in fields if field.fieldtype not in no_value_fields] @@ -365,6 +446,10 @@ def get_options(type, options): "columns": columns, "rows": rows, "fields": fields, + "column_field": column_field, + "title_field": title_field, + "kanban_columns": kanban_columns, + "kanban_fields": kanban_fields, "group_by_field": group_by_field, "page_length": page_length, "page_length_count": page_length_count, @@ -374,8 +459,46 @@ def get_options(type, options): "row_count": len(data), "form_script": get_form_script(doctype), "list_script": get_form_script(doctype, "List"), + "view_type": view_type, } +def convert_filter_to_tuple(doctype, filters): + if isinstance(filters, dict): + filters_items = filters.items() + filters = [] + for key, value in filters_items: + filters.append(make_filter_tuple(doctype, key, value)) + return filters + + +def get_records_based_on_order(doctype, rows, filters, page_length, order): + records = [] + filters = convert_filter_to_tuple(doctype, filters) + in_filters = filters.copy() + in_filters.append([doctype, "name", "in", order[:page_length]]) + records = frappe.get_list( + doctype, + fields=rows, + filters=in_filters, + order_by="creation desc", + page_length=page_length, + ) + + if len(records) < page_length: + not_in_filters = filters.copy() + not_in_filters.append([doctype, "name", "not in", order]) + remaining_records = frappe.get_list( + doctype, + fields=rows, + filters=not_in_filters, + order_by="creation desc", + page_length=page_length - len(records), + ) + for record in remaining_records: + records.append(record) + + return records + @frappe.whitelist() def get_fields_meta(doctype, restricted_fieldtypes=None, as_array=False): not_allowed_fieldtypes = [ @@ -391,12 +514,38 @@ def get_fields_meta(doctype, restricted_fieldtypes=None, as_array=False): fields = frappe.get_meta(doctype).fields fields = [field for field in fields if field.fieldtype not in not_allowed_fieldtypes] + standard_fields = [ + {"fieldname": "name", "fieldtype": "Link", "label": "ID", "options": doctype}, + { + "fieldname": "owner", + "fieldtype": "Link", + "label": "Created By", + "options": "User" + }, + { + "fieldname": "modified_by", + "fieldtype": "Link", + "label": "Last Updated By", + "options": "User", + }, + {"fieldname": "_user_tags", "fieldtype": "Data", "label": "Tags"}, + {"fieldname": "_liked_by", "fieldtype": "Data", "label": "Like"}, + {"fieldname": "_comments", "fieldtype": "Text", "label": "Comments"}, + {"fieldname": "_assign", "fieldtype": "Text", "label": "Assigned To"}, + {"fieldname": "creation", "fieldtype": "Datetime", "label": "Created On"}, + {"fieldname": "modified", "fieldtype": "Datetime", "label": "Last Updated On"}, + ] + + for field in standard_fields: + if not restricted_fieldtypes or field.get('fieldtype') not in restricted_fieldtypes: + fields.append(field) + if as_array: return fields fields_meta = {} for field in fields: - fields_meta[field.fieldname] = field + fields_meta[field.get('fieldname')] = field return fields_meta @@ -531,4 +680,13 @@ def get_fields(doctype: str, allow_all_fieldtypes: bool = False): "mandatory": field.reqd, }) - return _fields \ No newline at end of file + return _fields + + +def getCounts(d, doctype): + d["_email_count"] = frappe.db.count("Communication", filters={"reference_doctype": doctype, "reference_name": d.get("name"), "communication_type": "Communication"}) or 0 + d["_email_count"] = d["_email_count"] + frappe.db.count("Communication", filters={"reference_doctype": doctype, "reference_name": d.get("name"), "communication_type": "Automated Message"}) + d["_comment_count"] = frappe.db.count("Comment", filters={"reference_doctype": doctype, "reference_name": d.get("name"), "comment_type": "Comment"}) + d["_task_count"] = frappe.db.count("CRM Task", filters={"reference_doctype": doctype, "reference_docname": d.get("name")}) + d["_note_count"] = frappe.db.count("FCRM Note", filters={"reference_doctype": doctype, "reference_docname": d.get("name")}) + return d \ No newline at end of file diff --git a/crm/fcrm/doctype/crm_deal/crm_deal.py b/crm/fcrm/doctype/crm_deal/crm_deal.py index 83615ab05..4ca327ad1 100644 --- a/crm/fcrm/doctype/crm_deal/crm_deal.py +++ b/crm/fcrm/doctype/crm_deal/crm_deal.py @@ -190,6 +190,14 @@ def default_list_data(): ] return {'columns': columns, 'rows': rows} + @staticmethod + def default_kanban_settings(): + return { + "column_field": "status", + "title_field": "organization", + "kanban_fields": '["annual_revenue", "email", "mobile_no", "_assign", "modified"]' + } + @frappe.whitelist() def add_contact(deal, contact): if not frappe.has_permission("CRM Deal", "write", deal): diff --git a/crm/fcrm/doctype/crm_lead/crm_lead.py b/crm/fcrm/doctype/crm_lead/crm_lead.py index f4ebe1621..c9269e8a5 100644 --- a/crm/fcrm/doctype/crm_lead/crm_lead.py +++ b/crm/fcrm/doctype/crm_lead/crm_lead.py @@ -324,6 +324,15 @@ def default_list_data(): ] return {'columns': columns, 'rows': rows} + @staticmethod + def default_kanban_settings(): + return { + "column_field": "status", + "title_field": "lead_name", + "kanban_fields": '["organization", "email", "mobile_no", "_assign", "modified"]' + } + + @frappe.whitelist() def convert_to_deal(lead, doc=None): if not (doc and doc.flags.get("ignore_permissions")) and not frappe.has_permission("CRM Lead", "write", lead): diff --git a/crm/fcrm/doctype/crm_task/crm_task.py b/crm/fcrm/doctype/crm_task/crm_task.py index 1559ff3e0..cf1bc963a 100644 --- a/crm/fcrm/doctype/crm_task/crm_task.py +++ b/crm/fcrm/doctype/crm_task/crm_task.py @@ -60,3 +60,11 @@ def default_list_data(): "modified", ] return {'columns': columns, 'rows': rows} + + @staticmethod + def default_kanban_settings(): + return { + "column_field": "status", + "title_field": "title", + "kanban_fields": '["description", "priority", "creation"]' + } diff --git a/crm/fcrm/doctype/crm_view_settings/crm_view_settings.json b/crm/fcrm/doctype/crm_view_settings/crm_view_settings.json index de1ed9e88..dde0b1286 100644 --- a/crm/fcrm/doctype/crm_view_settings/crm_view_settings.json +++ b/crm/fcrm/doctype/crm_view_settings/crm_view_settings.json @@ -15,16 +15,23 @@ "route_name", "pinned", "public", - "columns_tab", - "load_default_columns", - "columns", - "rows", "filters_tab", "filters", "order_by_tab", "order_by", + "list_tab", + "list_section", + "load_default_columns", + "columns", + "rows", "group_by_tab", - "group_by_field" + "group_by_field", + "kanban_tab", + "kanban_section", + "column_field", + "title_field", + "kanban_columns", + "kanban_fields" ], "fields": [ { @@ -48,11 +55,6 @@ "fieldtype": "Code", "label": "Filters" }, - { - "fieldname": "columns_tab", - "fieldtype": "Tab Break", - "label": "Columns" - }, { "fieldname": "filters_tab", "fieldtype": "Tab Break", @@ -126,7 +128,7 @@ "fieldname": "type", "fieldtype": "Select", "label": "Type", - "options": "list\ngroup_by" + "options": "list\ngroup_by\nkanban" }, { "fieldname": "group_by_tab", @@ -137,11 +139,50 @@ "fieldname": "group_by_field", "fieldtype": "Data", "label": "Group By Field" + }, + { + "fieldname": "list_section", + "fieldtype": "Section Break" + }, + { + "fieldname": "kanban_section", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_field", + "fieldtype": "Data", + "label": "Column Field" + }, + { + "fieldname": "list_tab", + "fieldtype": "Tab Break", + "label": "List" + }, + { + "fieldname": "kanban_tab", + "fieldtype": "Tab Break", + "label": "Kanban" + }, + { + "fieldname": "kanban_columns", + "fieldtype": "Code", + "label": "Kanban Columns" + }, + { + "fieldname": "kanban_fields", + "fieldtype": "Code", + "label": "Kanban Fields" + }, + { + "default": "name", + "fieldname": "title_field", + "fieldtype": "Data", + "label": "Title Field" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2024-06-01 16:58:34.952945", + "modified": "2024-06-25 19:40:12.067788", "modified_by": "Administrator", "module": "FCRM", "name": "CRM View Settings", diff --git a/crm/fcrm/doctype/crm_view_settings/crm_view_settings.py b/crm/fcrm/doctype/crm_view_settings/crm_view_settings.py index f33f3e7f4..86f1bff53 100644 --- a/crm/fcrm/doctype/crm_view_settings/crm_view_settings.py +++ b/crm/fcrm/doctype/crm_view_settings/crm_view_settings.py @@ -16,13 +16,17 @@ def create(view): view.filters = parse_json(view.filters) or {} view.columns = parse_json(view.columns or '[]') view.rows = parse_json(view.rows or '[]') + view.kanban_columns = parse_json(view.kanban_columns or '[]') + view.kanban_fields = parse_json(view.kanban_fields or '[]') - default_rows = sync_default_list_rows(view.doctype) + default_rows = sync_default_rows(view.doctype) view.rows = view.rows + default_rows if default_rows else view.rows view.rows = remove_duplicates(view.rows) - if not view.columns: - view.columns = sync_default_list_columns(view.doctype) + if not view.kanban_columns and view.type == "kanban": + view.kanban_columns = sync_default_columns(view) + elif not view.columns: + view.columns = sync_default_columns(view) doc = frappe.new_doc("CRM View Settings") doc.name = view.label @@ -36,6 +40,10 @@ def create(view): doc.filters = json.dumps(view.filters) doc.order_by = view.order_by doc.group_by_field = view.group_by_field + doc.column_field = view.column_field + doc.title_field = view.title_field + doc.kanban_columns = json.dumps(view.kanban_columns) + doc.kanban_fields = json.dumps(view.kanban_fields) doc.columns = json.dumps(view.columns) doc.rows = json.dumps(view.rows) doc.insert() @@ -48,8 +56,10 @@ def update(view): filters = parse_json(view.filters) or {} columns = parse_json(view.columns) or [] rows = parse_json(view.rows) or [] + kanban_columns = parse_json(view.kanban_columns) or [] + kanban_fields = parse_json(view.kanban_fields) or [] - default_rows = sync_default_list_rows(view.doctype) + default_rows = sync_default_rows(view.doctype) rows = rows + default_rows if default_rows else rows rows = remove_duplicates(rows) @@ -62,6 +72,10 @@ def update(view): doc.filters = json.dumps(filters) doc.order_by = view.order_by doc.group_by_field = view.group_by_field + doc.column_field = view.column_field + doc.title_field = view.title_field + doc.kanban_columns = json.dumps(kanban_columns) + doc.kanban_fields = json.dumps(kanban_fields) doc.columns = json.dumps(columns) doc.rows = json.dumps(rows) doc.save() @@ -91,7 +105,7 @@ def pin(name, value): def remove_duplicates(l): return list(dict.fromkeys(l)) -def sync_default_list_rows(doctype): +def sync_default_rows(doctype, type="list"): list = get_controller(doctype) rows = [] @@ -100,11 +114,21 @@ def sync_default_list_rows(doctype): return rows -def sync_default_list_columns(doctype): - list = get_controller(doctype) +def sync_default_columns(view): + list = get_controller(view.doctype) columns = [] - if hasattr(list, "default_list_data"): + if view.type == "kanban" and view.column_field: + field_meta = frappe.get_meta(view.doctype).get_field(view.column_field) + if field_meta.fieldtype == "Link": + columns = frappe.get_all( + field_meta.options, + fields=["name"], + order_by="modified asc", + ) + elif field_meta.fieldtype == "Select": + columns = [{"name": option} for option in field_meta.options.split("\n")] + elif hasattr(list, "default_list_data"): columns = list.default_list_data().get("columns") return columns @@ -117,13 +141,17 @@ def create_or_update_default_view(view): filters = parse_json(view.filters) or {} columns = parse_json(view.columns or '[]') rows = parse_json(view.rows or '[]') + kanban_columns = parse_json(view.kanban_columns or '[]') + kanban_fields = parse_json(view.kanban_fields or '[]') - default_rows = sync_default_list_rows(view.doctype) + default_rows = sync_default_rows(view.doctype, view.type) rows = rows + default_rows if default_rows else rows rows = remove_duplicates(rows) - if not columns: - columns = sync_default_list_columns(view.doctype) + if not kanban_columns and view.type == "kanban": + kanban_columns = sync_default_columns(view) + elif not columns: + columns = sync_default_columns(view) doc = frappe.db.exists( "CRM View Settings", @@ -143,6 +171,10 @@ def create_or_update_default_view(view): doc.filters = json.dumps(filters) doc.order_by = view.order_by doc.group_by_field = view.group_by_field + doc.column_field = view.column_field + doc.title_field = view.title_field + doc.kanban_columns = json.dumps(kanban_columns) + doc.kanban_fields = json.dumps(kanban_fields) doc.columns = json.dumps(columns) doc.rows = json.dumps(rows) doc.save() @@ -159,6 +191,10 @@ def create_or_update_default_view(view): doc.filters = json.dumps(filters) doc.order_by = view.order_by doc.group_by_field = view.group_by_field + doc.column_field = view.column_field + doc.title_field = view.title_field + doc.kanban_columns = json.dumps(kanban_columns) + doc.kanban_fields = json.dumps(kanban_fields) doc.columns = json.dumps(columns) doc.rows = json.dumps(rows) doc.is_default = True diff --git a/frontend/src/components/Icons/KanbanIcon.vue b/frontend/src/components/Icons/KanbanIcon.vue new file mode 100644 index 000000000..bb12b4011 --- /dev/null +++ b/frontend/src/components/Icons/KanbanIcon.vue @@ -0,0 +1,18 @@ + diff --git a/frontend/src/components/Icons/TaskStatusIcon.vue b/frontend/src/components/Icons/TaskStatusIcon.vue index c7ed07b13..75e9e20a0 100644 --- a/frontend/src/components/Icons/TaskStatusIcon.vue +++ b/frontend/src/components/Icons/TaskStatusIcon.vue @@ -3,6 +3,7 @@ xmlns="http://www.w3.org/2000/svg" width="16" height="16" + viewBox="0 0 16 16" fill="none" class="text-gray-700" :aria-label="status" diff --git a/frontend/src/components/Kanban/KanbanSettings.vue b/frontend/src/components/Kanban/KanbanSettings.vue new file mode 100644 index 000000000..e04c396ac --- /dev/null +++ b/frontend/src/components/Kanban/KanbanSettings.vue @@ -0,0 +1,221 @@ + + diff --git a/frontend/src/components/Kanban/KanbanView.vue b/frontend/src/components/Kanban/KanbanView.vue new file mode 100644 index 000000000..d81365018 --- /dev/null +++ b/frontend/src/components/Kanban/KanbanView.vue @@ -0,0 +1,253 @@ + + diff --git a/frontend/src/components/Modals/DealModal.vue b/frontend/src/components/Modals/DealModal.vue index 15147f690..293e81ac1 100644 --- a/frontend/src/components/Modals/DealModal.vue +++ b/frontend/src/components/Modals/DealModal.vue @@ -46,6 +46,10 @@ import { Switch, createResource } from 'frappe-ui' import { computed, ref, reactive, onMounted } from 'vue' import { useRouter } from 'vue-router' +const props = defineProps({ + defaults: Object, +}) + const { getUser } = usersStore() const { getDealStatus, statusOptions } = statusesStore() @@ -194,6 +198,7 @@ function createDeal() { } onMounted(() => { + Object.assign(deal, props.defaults) if (!deal.deal_owner) { deal.deal_owner = getUser().email } diff --git a/frontend/src/components/Modals/LeadModal.vue b/frontend/src/components/Modals/LeadModal.vue index 6a3d35fd8..b29be41b2 100644 --- a/frontend/src/components/Modals/LeadModal.vue +++ b/frontend/src/components/Modals/LeadModal.vue @@ -31,6 +31,10 @@ import { createResource } from 'frappe-ui' import { computed, onMounted, ref, reactive } from 'vue' import { useRouter } from 'vue-router' +const props = defineProps({ + defaults: Object, +}) + const { getUser } = usersStore() const { getLeadStatus, statusOptions } = statusesStore() @@ -146,6 +150,7 @@ function createNewLead() { } onMounted(() => { + Object.assign(lead, props.defaults) if (!lead.lead_owner) { lead.lead_owner = getUser().email } diff --git a/frontend/src/components/Modals/TaskModal.vue b/frontend/src/components/Modals/TaskModal.vue index 46e903c3d..035fafea7 100644 --- a/frontend/src/components/Modals/TaskModal.vue +++ b/frontend/src/components/Modals/TaskModal.vue @@ -119,7 +119,7 @@ import Link from '@/components/Controls/Link.vue' import { taskStatusOptions, taskPriorityOptions } from '@/utils' import { usersStore } from '@/stores/users' import { TextEditor, Dropdown, Tooltip, call, DateTimePicker } from 'frappe-ui' -import { ref, watch, nextTick } from 'vue' +import { ref, watch, nextTick, onMounted } from 'vue' import { useRouter } from 'vue-router' const props = defineProps({ @@ -205,20 +205,23 @@ async function updateTask() { show.value = false } -watch( - () => show.value, - (value) => { - if (!value) return - editMode.value = false - nextTick(() => { - title.value.el.focus() - _task.value = { ...props.task } - if (_task.value.title) { - editMode.value = true - } - }) - } -) +function render() { + editMode.value = false + nextTick(() => { + title.value.el.focus() + _task.value = { ...props.task } + if (_task.value.title) { + editMode.value = true + } + }) +} + +onMounted(() => render()) + +watch(show, (value) => { + if (!value) return + render() +})