Merge pull request #424 from frappe/develop
This commit is contained in:
commit
4f699d486b
@ -3,6 +3,7 @@ import frappe
|
|||||||
from frappe.translate import get_all_translations
|
from frappe.translate import get_all_translations
|
||||||
from frappe.utils import validate_email_address, split_emails, cstr
|
from frappe.utils import validate_email_address, split_emails, cstr
|
||||||
from frappe.utils.telemetry import POSTHOG_HOST_FIELD, POSTHOG_PROJECT_FIELD
|
from frappe.utils.telemetry import POSTHOG_HOST_FIELD, POSTHOG_PROJECT_FIELD
|
||||||
|
from frappe.core.api.file import get_max_file_size
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist(allow_guest=True)
|
@frappe.whitelist(allow_guest=True)
|
||||||
@ -107,3 +108,20 @@ def invite_by_email(emails: str, role: str):
|
|||||||
|
|
||||||
for email in to_invite:
|
for email in to_invite:
|
||||||
frappe.get_doc(doctype="CRM Invitation", email=email, role=role).insert(ignore_permissions=True)
|
frappe.get_doc(doctype="CRM Invitation", email=email, role=role).insert(ignore_permissions=True)
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_file_uploader_defaults(doctype: str):
|
||||||
|
max_number_of_files = None
|
||||||
|
make_attachments_public = False
|
||||||
|
if doctype:
|
||||||
|
meta = frappe.get_meta(doctype)
|
||||||
|
max_number_of_files = meta.get("max_attachments")
|
||||||
|
make_attachments_public = meta.get("make_attachments_public")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'allowed_file_types': frappe.get_system_settings("allowed_file_extensions"),
|
||||||
|
'max_file_size': get_max_file_size(),
|
||||||
|
'max_number_of_files': max_number_of_files,
|
||||||
|
'make_attachments_public': bool(make_attachments_public),
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
import frappe
|
import frappe
|
||||||
from frappe import _
|
from frappe import _
|
||||||
from frappe.utils.caching import redis_cache
|
from frappe.utils.caching import redis_cache
|
||||||
@ -35,10 +36,11 @@ def get_deal_activities(name):
|
|||||||
calls = []
|
calls = []
|
||||||
notes = []
|
notes = []
|
||||||
tasks = []
|
tasks = []
|
||||||
|
attachments = []
|
||||||
creation_text = "created this deal"
|
creation_text = "created this deal"
|
||||||
|
|
||||||
if lead:
|
if lead:
|
||||||
activities, calls, notes, tasks = get_lead_activities(lead)
|
activities, calls, notes, tasks, attachments = get_lead_activities(lead)
|
||||||
creation_text = "converted the lead to this deal"
|
creation_text = "converted the lead to this deal"
|
||||||
|
|
||||||
activities.append({
|
activities.append({
|
||||||
@ -131,14 +133,26 @@ def get_deal_activities(name):
|
|||||||
}
|
}
|
||||||
activities.append(activity)
|
activities.append(activity)
|
||||||
|
|
||||||
|
for attachment_log in docinfo.attachment_logs:
|
||||||
|
activity = {
|
||||||
|
"name": attachment_log.name,
|
||||||
|
"activity_type": "attachment_log",
|
||||||
|
"creation": attachment_log.creation,
|
||||||
|
"owner": attachment_log.owner,
|
||||||
|
"data": parse_attachment_log(attachment_log.content, attachment_log.comment_type),
|
||||||
|
"is_lead": False,
|
||||||
|
}
|
||||||
|
activities.append(activity)
|
||||||
|
|
||||||
calls = calls + get_linked_calls(name)
|
calls = calls + get_linked_calls(name)
|
||||||
notes = notes + get_linked_notes(name)
|
notes = notes + get_linked_notes(name)
|
||||||
tasks = tasks + get_linked_tasks(name)
|
tasks = tasks + get_linked_tasks(name)
|
||||||
|
attachments = attachments + get_attachments('CRM Deal', name)
|
||||||
|
|
||||||
activities.sort(key=lambda x: x["creation"], reverse=True)
|
activities.sort(key=lambda x: x["creation"], reverse=True)
|
||||||
activities = handle_multiple_versions(activities)
|
activities = handle_multiple_versions(activities)
|
||||||
|
|
||||||
return activities, calls, notes, tasks
|
return activities, calls, notes, tasks, attachments
|
||||||
|
|
||||||
def get_lead_activities(name):
|
def get_lead_activities(name):
|
||||||
get_docinfo('', "CRM Lead", name)
|
get_docinfo('', "CRM Lead", name)
|
||||||
@ -245,22 +259,34 @@ def get_lead_activities(name):
|
|||||||
}
|
}
|
||||||
activities.append(activity)
|
activities.append(activity)
|
||||||
|
|
||||||
|
for attachment_log in docinfo.attachment_logs:
|
||||||
|
activity = {
|
||||||
|
"name": attachment_log.name,
|
||||||
|
"activity_type": "attachment_log",
|
||||||
|
"creation": attachment_log.creation,
|
||||||
|
"owner": attachment_log.owner,
|
||||||
|
"data": parse_attachment_log(attachment_log.content, attachment_log.comment_type),
|
||||||
|
"is_lead": True,
|
||||||
|
}
|
||||||
|
activities.append(activity)
|
||||||
|
|
||||||
calls = get_linked_calls(name)
|
calls = get_linked_calls(name)
|
||||||
notes = get_linked_notes(name)
|
notes = get_linked_notes(name)
|
||||||
tasks = get_linked_tasks(name)
|
tasks = get_linked_tasks(name)
|
||||||
|
attachments = get_attachments('CRM Lead', name)
|
||||||
|
|
||||||
activities.sort(key=lambda x: x["creation"], reverse=True)
|
activities.sort(key=lambda x: x["creation"], reverse=True)
|
||||||
activities = handle_multiple_versions(activities)
|
activities = handle_multiple_versions(activities)
|
||||||
|
|
||||||
return activities, calls, notes, tasks
|
return activities, calls, notes, tasks, attachments
|
||||||
|
|
||||||
|
|
||||||
@redis_cache()
|
|
||||||
def get_attachments(doctype, name):
|
def get_attachments(doctype, name):
|
||||||
return frappe.db.get_all(
|
return frappe.db.get_all(
|
||||||
"File",
|
"File",
|
||||||
filters={"attached_to_doctype": doctype, "attached_to_name": name},
|
filters={"attached_to_doctype": doctype, "attached_to_name": name},
|
||||||
fields=["name", "file_name", "file_url", "file_size", "is_private"],
|
fields=["name", "file_name", "file_type", "file_url", "file_size", "is_private", "creation", "owner"],
|
||||||
)
|
) or []
|
||||||
|
|
||||||
def handle_multiple_versions(versions):
|
def handle_multiple_versions(versions):
|
||||||
activities = []
|
activities = []
|
||||||
@ -342,3 +368,26 @@ def get_linked_tasks(name):
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
return tasks or []
|
return tasks or []
|
||||||
|
|
||||||
|
def parse_attachment_log(html, type):
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
a_tag = soup.find("a")
|
||||||
|
type = "added" if type == "Attachment" else "removed"
|
||||||
|
if not a_tag:
|
||||||
|
return {
|
||||||
|
"type": type,
|
||||||
|
"file_name": html.replace("Removed ", ""),
|
||||||
|
"file_url": "",
|
||||||
|
"is_private": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
is_private = False
|
||||||
|
if "private/files" in a_tag["href"]:
|
||||||
|
is_private = True
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": type,
|
||||||
|
"file_name": a_tag.text,
|
||||||
|
"file_url": a_tag["href"],
|
||||||
|
"is_private": is_private,
|
||||||
|
}
|
||||||
@ -103,7 +103,41 @@ def is_whatsapp_installed():
|
|||||||
def get_whatsapp_messages(reference_doctype, reference_name):
|
def get_whatsapp_messages(reference_doctype, reference_name):
|
||||||
if not frappe.db.exists("DocType", "WhatsApp Message"):
|
if not frappe.db.exists("DocType", "WhatsApp Message"):
|
||||||
return []
|
return []
|
||||||
messages = frappe.get_all(
|
messages = []
|
||||||
|
|
||||||
|
if reference_doctype == 'CRM Deal':
|
||||||
|
lead = frappe.db.get_value(reference_doctype, reference_name, 'lead')
|
||||||
|
if lead:
|
||||||
|
messages = frappe.get_all(
|
||||||
|
"WhatsApp Message",
|
||||||
|
filters={
|
||||||
|
"reference_doctype": "CRM Lead",
|
||||||
|
"reference_name": lead,
|
||||||
|
},
|
||||||
|
fields=[
|
||||||
|
"name",
|
||||||
|
"type",
|
||||||
|
"to",
|
||||||
|
"from",
|
||||||
|
"content_type",
|
||||||
|
"message_type",
|
||||||
|
"attach",
|
||||||
|
"template",
|
||||||
|
"use_template",
|
||||||
|
"message_id",
|
||||||
|
"is_reply",
|
||||||
|
"reply_to_message_id",
|
||||||
|
"creation",
|
||||||
|
"message",
|
||||||
|
"status",
|
||||||
|
"reference_doctype",
|
||||||
|
"reference_name",
|
||||||
|
"template_parameters",
|
||||||
|
"template_header_parameters",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
messages += frappe.get_all(
|
||||||
"WhatsApp Message",
|
"WhatsApp Message",
|
||||||
filters={
|
filters={
|
||||||
"reference_doctype": reference_doctype,
|
"reference_doctype": reference_doctype,
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
# import frappe
|
# import frappe
|
||||||
from frappe.tests.utils import FrappeTestCase
|
from frappe.tests import UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
class TestCRMCallLog(FrappeTestCase):
|
class TestCRMCallLog(UnitTestCase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
# import frappe
|
# import frappe
|
||||||
from frappe.tests.utils import FrappeTestCase
|
from frappe.tests import UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
class TestCRMCommunicationStatus(FrappeTestCase):
|
class TestCRMCommunicationStatus(UnitTestCase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
# import frappe
|
# import frappe
|
||||||
from frappe.tests.utils import FrappeTestCase
|
from frappe.tests import UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
class TestCRMDeal(FrappeTestCase):
|
class TestCRMDeal(UnitTestCase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
# import frappe
|
# import frappe
|
||||||
from frappe.tests.utils import FrappeTestCase
|
from frappe.tests import UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
class TestCRMDealStatus(FrappeTestCase):
|
class TestCRMDealStatus(UnitTestCase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
# import frappe
|
# import frappe
|
||||||
from frappe.tests.utils import FrappeTestCase
|
from frappe.tests import UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
class TestCRMFieldsLayout(FrappeTestCase):
|
class TestCRMFieldsLayout(UnitTestCase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
# import frappe
|
# import frappe
|
||||||
from frappe.tests.utils import FrappeTestCase
|
from frappe.tests import UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
class TestCRMFormScript(FrappeTestCase):
|
class TestCRMFormScript(UnitTestCase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
# import frappe
|
# import frappe
|
||||||
from frappe.tests.utils import FrappeTestCase
|
from frappe.tests import UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
class TestCRMHolidayList(FrappeTestCase):
|
class TestCRMHolidayList(UnitTestCase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
# import frappe
|
# import frappe
|
||||||
from frappe.tests.utils import FrappeTestCase
|
from frappe.tests import UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
class TestCRMIndustry(FrappeTestCase):
|
class TestCRMIndustry(UnitTestCase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
# import frappe
|
# import frappe
|
||||||
from frappe.tests.utils import FrappeTestCase
|
from frappe.tests import UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
class TestCRMInvitation(FrappeTestCase):
|
class TestCRMInvitation(UnitTestCase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
# import frappe
|
# import frappe
|
||||||
from frappe.tests.utils import FrappeTestCase
|
from frappe.tests import UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
class TestCRMLead(FrappeTestCase):
|
class TestCRMLead(UnitTestCase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
# import frappe
|
# import frappe
|
||||||
from frappe.tests.utils import FrappeTestCase
|
from frappe.tests import UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
class TestCRMLeadSource(FrappeTestCase):
|
class TestCRMLeadSource(UnitTestCase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
# import frappe
|
# import frappe
|
||||||
from frappe.tests.utils import FrappeTestCase
|
from frappe.tests import UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
class TestCRMLeadStatus(FrappeTestCase):
|
class TestCRMLeadStatus(UnitTestCase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
# import frappe
|
# import frappe
|
||||||
from frappe.tests.utils import FrappeTestCase
|
from frappe.tests import UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
class TestCRMNotification(FrappeTestCase):
|
class TestCRMNotification(UnitTestCase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
# import frappe
|
# import frappe
|
||||||
from frappe.tests.utils import FrappeTestCase
|
from frappe.tests import UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
class TestCRMOrganization(FrappeTestCase):
|
class TestCRMOrganization(UnitTestCase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
# import frappe
|
# import frappe
|
||||||
from frappe.tests.utils import FrappeTestCase
|
from frappe.tests import UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
class TestCRMServiceLevelAgreement(FrappeTestCase):
|
class TestCRMServiceLevelAgreement(UnitTestCase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
# import frappe
|
# import frappe
|
||||||
from frappe.tests.utils import FrappeTestCase
|
from frappe.tests import UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
class TestCRMServiceLevelPriority(FrappeTestCase):
|
class TestCRMServiceLevelPriority(UnitTestCase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
# import frappe
|
# import frappe
|
||||||
from frappe.tests.utils import FrappeTestCase
|
from frappe.tests import UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
class TestCRMTask(FrappeTestCase):
|
class TestCRMTask(UnitTestCase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
# import frappe
|
# import frappe
|
||||||
from frappe.tests.utils import FrappeTestCase
|
from frappe.tests import UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
class TestCRMTerritory(FrappeTestCase):
|
class TestCRMTerritory(UnitTestCase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
# import frappe
|
# import frappe
|
||||||
from frappe.tests.utils import FrappeTestCase
|
from frappe.tests import UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
class TestCRMViewSettings(FrappeTestCase):
|
class TestCRMViewSettings(UnitTestCase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
# import frappe
|
# import frappe
|
||||||
from frappe.tests.utils import FrappeTestCase
|
from frappe.tests import UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
class TestERPNextCRMSettings(FrappeTestCase):
|
class TestERPNextCRMSettings(UnitTestCase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
# import frappe
|
# import frappe
|
||||||
from frappe.tests.utils import FrappeTestCase
|
from frappe.tests import UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
class TestFCRMNote(FrappeTestCase):
|
class TestFCRMNote(UnitTestCase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
0
crm/fcrm/doctype/fcrm_settings/__init__.py
Normal file
0
crm/fcrm/doctype/fcrm_settings/__init__.py
Normal file
42
crm/fcrm/doctype/fcrm_settings/fcrm_settings.js
Normal file
42
crm/fcrm/doctype/fcrm_settings/fcrm_settings.js
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
// Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
|
||||||
|
// For license information, please see license.txt
|
||||||
|
|
||||||
|
frappe.ui.form.on("FCRM Settings", {
|
||||||
|
// refresh(frm) {
|
||||||
|
|
||||||
|
// },
|
||||||
|
restore_defaults: function (frm) {
|
||||||
|
let message = __(
|
||||||
|
"This will restore (if not exist) all the default statuses, custom fields and layouts. Delete & Restore will delete default layouts and then restore them."
|
||||||
|
);
|
||||||
|
let d = new frappe.ui.Dialog({
|
||||||
|
title: __("Restore Defaults"),
|
||||||
|
primary_action_label: __("Restore"),
|
||||||
|
primary_action: () => {
|
||||||
|
frm.call("restore_defaults", { force: false }, () => {
|
||||||
|
frappe.show_alert({
|
||||||
|
message: __(
|
||||||
|
"Default statuses, custom fields and layouts restored successfully."
|
||||||
|
),
|
||||||
|
indicator: "green",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
d.hide();
|
||||||
|
},
|
||||||
|
secondary_action_label: __("Delete & Restore"),
|
||||||
|
secondary_action: () => {
|
||||||
|
frm.call("restore_defaults", { force: true }, () => {
|
||||||
|
frappe.show_alert({
|
||||||
|
message: __(
|
||||||
|
"Default statuses, custom fields and layouts restored successfully."
|
||||||
|
),
|
||||||
|
indicator: "green",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
d.hide();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
d.show();
|
||||||
|
d.set_message(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
40
crm/fcrm/doctype/fcrm_settings/fcrm_settings.json
Normal file
40
crm/fcrm/doctype/fcrm_settings/fcrm_settings.json
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"actions": [],
|
||||||
|
"allow_rename": 1,
|
||||||
|
"creation": "2024-09-29 13:48:02.715924",
|
||||||
|
"doctype": "DocType",
|
||||||
|
"engine": "InnoDB",
|
||||||
|
"field_order": [
|
||||||
|
"restore_defaults"
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldname": "restore_defaults",
|
||||||
|
"fieldtype": "Button",
|
||||||
|
"label": "Restore Defaults"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"index_web_pages_for_search": 1,
|
||||||
|
"issingle": 1,
|
||||||
|
"links": [],
|
||||||
|
"modified": "2024-09-29 13:49:07.835379",
|
||||||
|
"modified_by": "Administrator",
|
||||||
|
"module": "FCRM",
|
||||||
|
"name": "FCRM Settings",
|
||||||
|
"owner": "Administrator",
|
||||||
|
"permissions": [
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"role": "System Manager",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sort_field": "creation",
|
||||||
|
"sort_order": "DESC",
|
||||||
|
"states": []
|
||||||
|
}
|
||||||
12
crm/fcrm/doctype/fcrm_settings/fcrm_settings.py
Normal file
12
crm/fcrm/doctype/fcrm_settings/fcrm_settings.py
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
|
||||||
|
# For license information, please see license.txt
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
from frappe.model.document import Document
|
||||||
|
from crm.install import after_install
|
||||||
|
|
||||||
|
|
||||||
|
class FCRMSettings(Document):
|
||||||
|
@frappe.whitelist()
|
||||||
|
def restore_defaults(self, force=False):
|
||||||
|
after_install(force)
|
||||||
9
crm/fcrm/doctype/fcrm_settings/test_fcrm_settings.py
Normal file
9
crm/fcrm/doctype/fcrm_settings/test_fcrm_settings.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
|
# See license.txt
|
||||||
|
|
||||||
|
# import frappe
|
||||||
|
from frappe.tests import UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
|
class TestFCRMSettings(UnitTestCase):
|
||||||
|
pass
|
||||||
@ -2,8 +2,8 @@
|
|||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
# import frappe
|
# import frappe
|
||||||
from frappe.tests.utils import FrappeTestCase
|
from frappe.tests import UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
class TestTwilioAgents(FrappeTestCase):
|
class TestTwilioAgents(UnitTestCase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
# import frappe
|
# import frappe
|
||||||
from frappe.tests.utils import FrappeTestCase
|
from frappe.tests import UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
class TestTwilioSettings(FrappeTestCase):
|
class TestTwilioSettings(UnitTestCase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@ -9,11 +9,11 @@ from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
|
|||||||
def before_install():
|
def before_install():
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def after_install():
|
def after_install(force=False):
|
||||||
add_default_lead_statuses()
|
add_default_lead_statuses()
|
||||||
add_default_deal_statuses()
|
add_default_deal_statuses()
|
||||||
add_default_communication_statuses()
|
add_default_communication_statuses()
|
||||||
add_default_fields_layout()
|
add_default_fields_layout(force)
|
||||||
add_property_setter()
|
add_property_setter()
|
||||||
add_email_template_custom_fields()
|
add_email_template_custom_fields()
|
||||||
add_default_industries()
|
add_default_industries()
|
||||||
@ -111,7 +111,7 @@ def add_default_communication_statuses():
|
|||||||
doc.status = status
|
doc.status = status
|
||||||
doc.insert()
|
doc.insert()
|
||||||
|
|
||||||
def add_default_fields_layout():
|
def add_default_fields_layout(force=False):
|
||||||
quick_entry_layouts = {
|
quick_entry_layouts = {
|
||||||
"CRM Lead-Quick Entry": {
|
"CRM Lead-Quick Entry": {
|
||||||
"doctype": "CRM Lead",
|
"doctype": "CRM Lead",
|
||||||
@ -148,7 +148,10 @@ def add_default_fields_layout():
|
|||||||
|
|
||||||
for layout in quick_entry_layouts:
|
for layout in quick_entry_layouts:
|
||||||
if frappe.db.exists("CRM Fields Layout", layout):
|
if frappe.db.exists("CRM Fields Layout", layout):
|
||||||
continue
|
if force:
|
||||||
|
frappe.delete_doc("CRM Fields Layout", layout)
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
doc = frappe.new_doc("CRM Fields Layout")
|
doc = frappe.new_doc("CRM Fields Layout")
|
||||||
doc.type = "Quick Entry"
|
doc.type = "Quick Entry"
|
||||||
@ -158,7 +161,10 @@ def add_default_fields_layout():
|
|||||||
|
|
||||||
for layout in sidebar_fields_layouts:
|
for layout in sidebar_fields_layouts:
|
||||||
if frappe.db.exists("CRM Fields Layout", layout):
|
if frappe.db.exists("CRM Fields Layout", layout):
|
||||||
continue
|
if force:
|
||||||
|
frappe.delete_doc("CRM Fields Layout", layout)
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
doc = frappe.new_doc("CRM Fields Layout")
|
doc = frappe.new_doc("CRM Fields Layout")
|
||||||
doc.type = "Side Panel"
|
doc.type = "Side Panel"
|
||||||
@ -217,7 +223,6 @@ def add_default_industries():
|
|||||||
|
|
||||||
|
|
||||||
def add_default_lead_sources():
|
def add_default_lead_sources():
|
||||||
|
|
||||||
lead_sources = ["Existing Customer", "Reference", "Advertisement", "Cold Calling", "Exhibition", "Supplier Reference", "Mass Mailing", "Customer's Vendor", "Campaign", "Walk In"]
|
lead_sources = ["Existing Customer", "Reference", "Advertisement", "Cold Calling", "Exhibition", "Supplier Reference", "Mass Mailing", "Customer's Vendor", "Campaign", "Walk In"]
|
||||||
|
|
||||||
for source in lead_sources:
|
for source in lead_sources:
|
||||||
|
|||||||
@ -9,11 +9,9 @@ no_cache = 1
|
|||||||
|
|
||||||
|
|
||||||
def get_context():
|
def get_context():
|
||||||
csrf_token = frappe.sessions.get_csrf_token()
|
|
||||||
frappe.db.commit()
|
frappe.db.commit()
|
||||||
context = frappe._dict()
|
context = frappe._dict()
|
||||||
context.boot = get_boot()
|
context.boot = get_boot()
|
||||||
context.boot.csrf_token = csrf_token
|
|
||||||
if frappe.session.user != "Guest":
|
if frappe.session.user != "Guest":
|
||||||
capture("active_site", "crm")
|
capture("active_site", "crm")
|
||||||
return context
|
return context
|
||||||
@ -33,6 +31,7 @@ def get_boot():
|
|||||||
"default_route": get_default_route(),
|
"default_route": get_default_route(),
|
||||||
"site_name": frappe.local.site,
|
"site_name": frappe.local.site,
|
||||||
"read_only_mode": frappe.flags.read_only,
|
"read_only_mode": frappe.flags.read_only,
|
||||||
|
"csrf_token": frappe.sessions.get_csrf_token(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
Subproject commit 427b76188fe8b20e683bccf9bb4003821253259f
|
Subproject commit b2dbd41936905aa46b18d3c22e5d09a7b08a9b98
|
||||||
@ -14,7 +14,7 @@
|
|||||||
"@vueuse/core": "^10.3.0",
|
"@vueuse/core": "^10.3.0",
|
||||||
"@vueuse/integrations": "^10.3.0",
|
"@vueuse/integrations": "^10.3.0",
|
||||||
"feather-icons": "^4.28.0",
|
"feather-icons": "^4.28.0",
|
||||||
"frappe-ui": "^0.1.70",
|
"frappe-ui": "^0.1.71",
|
||||||
"gemoji": "^8.1.0",
|
"gemoji": "^8.1.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"mime": "^4.0.1",
|
"mime": "^4.0.1",
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
<ActivityHeader
|
<ActivityHeader
|
||||||
v-model="tabIndex"
|
v-model="tabIndex"
|
||||||
v-model:showWhatsappTemplates="showWhatsappTemplates"
|
v-model:showWhatsappTemplates="showWhatsappTemplates"
|
||||||
|
v-model:showFilesUploader="showFilesUploader"
|
||||||
:tabs="tabs"
|
:tabs="tabs"
|
||||||
:title="title"
|
:title="title"
|
||||||
:doc="doc"
|
:doc="doc"
|
||||||
@ -62,17 +63,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div v-else-if="title == 'Tasks'" class="px-3 pb-3 sm:px-10 sm:pb-5">
|
||||||
v-else-if="title == 'Tasks'"
|
<TaskArea :modalRef="modalRef" :tasks="activities" :doctype="doctype" />
|
||||||
class="px-3 pb-3 sm:px-10 sm:pb-5 overflow-x-auto sm:w-full w-max"
|
|
||||||
>
|
|
||||||
<TaskArea
|
|
||||||
v-model="all_activities"
|
|
||||||
v-model:doc="doc"
|
|
||||||
:modalRef="modalRef"
|
|
||||||
:tasks="activities"
|
|
||||||
:doctype="doctype"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="title == 'Calls'" class="activity">
|
<div v-else-if="title == 'Calls'" class="activity">
|
||||||
<div v-for="(call, i) in activities">
|
<div v-for="(call, i) in activities">
|
||||||
@ -103,6 +95,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="title == 'Attachments'"
|
||||||
|
class="px-3 pb-3 sm:px-10 sm:pb-5"
|
||||||
|
>
|
||||||
|
<AttachmentArea
|
||||||
|
:attachments="activities"
|
||||||
|
@reload="all_activities.reload() && scroll()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
v-for="(activity, i) in activities"
|
v-for="(activity, i) in activities"
|
||||||
@ -177,6 +178,40 @@
|
|||||||
>
|
>
|
||||||
<CommentArea :activity="activity" />
|
<CommentArea :activity="activity" />
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
class="mb-4 flex flex-col gap-2 py-1.5"
|
||||||
|
:id="activity.name"
|
||||||
|
v-else-if="activity.activity_type == 'attachment_log'"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-stretch gap-2 text-base">
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center flex-wrap gap-1.5 text-gray-800 font-medium"
|
||||||
|
>
|
||||||
|
<span class="font-medium">{{ activity.owner_name }}</span>
|
||||||
|
<span class="text-gray-600">{{ __(activity.data.type) }}</span>
|
||||||
|
<a
|
||||||
|
v-if="activity.data.file_url"
|
||||||
|
:href="activity.data.file_url"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<span>{{ activity.data.file_name }}</span>
|
||||||
|
</a>
|
||||||
|
<span v-else>{{ activity.data.file_name }}</span>
|
||||||
|
<FeatherIcon
|
||||||
|
v-if="activity.data.is_private"
|
||||||
|
name="lock"
|
||||||
|
class="size-3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="ml-auto whitespace-nowrap">
|
||||||
|
<Tooltip :text="dateFormat(activity.creation, dateTooltipFormat)">
|
||||||
|
<div class="text-sm text-gray-600">
|
||||||
|
{{ __(timeAgo(activity.creation)) }}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="
|
v-else-if="
|
||||||
activity.activity_type == 'incoming_call' ||
|
activity.activity_type == 'incoming_call' ||
|
||||||
@ -362,6 +397,11 @@
|
|||||||
:label="__('Create Task')"
|
:label="__('Create Task')"
|
||||||
@click="modalRef.showTask()"
|
@click="modalRef.showTask()"
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
v-else-if="title == 'Attachments'"
|
||||||
|
:label="__('Upload Attachment')"
|
||||||
|
@click="showFilesUploader = true"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</FadedScrollableDiv>
|
</FadedScrollableDiv>
|
||||||
<div>
|
<div>
|
||||||
@ -395,6 +435,18 @@
|
|||||||
:doctype="doctype"
|
:doctype="doctype"
|
||||||
:doc="doc"
|
:doc="doc"
|
||||||
/>
|
/>
|
||||||
|
<FilesUploader
|
||||||
|
v-if="doc.data?.name"
|
||||||
|
v-model="showFilesUploader"
|
||||||
|
:doctype="doctype"
|
||||||
|
:docname="doc.data.name"
|
||||||
|
@after="
|
||||||
|
() => {
|
||||||
|
all_activities.reload()
|
||||||
|
changeTabTo('attachments')
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import ActivityHeader from '@/components/Activities/ActivityHeader.vue'
|
import ActivityHeader from '@/components/Activities/ActivityHeader.vue'
|
||||||
@ -403,12 +455,14 @@ import CommentArea from '@/components/Activities/CommentArea.vue'
|
|||||||
import CallArea from '@/components/Activities/CallArea.vue'
|
import CallArea from '@/components/Activities/CallArea.vue'
|
||||||
import NoteArea from '@/components/Activities/NoteArea.vue'
|
import NoteArea from '@/components/Activities/NoteArea.vue'
|
||||||
import TaskArea from '@/components/Activities/TaskArea.vue'
|
import TaskArea from '@/components/Activities/TaskArea.vue'
|
||||||
|
import AttachmentArea from '@/components/Activities/AttachmentArea.vue'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import ActivityIcon from '@/components/Icons/ActivityIcon.vue'
|
import ActivityIcon from '@/components/Icons/ActivityIcon.vue'
|
||||||
import Email2Icon from '@/components/Icons/Email2Icon.vue'
|
import Email2Icon from '@/components/Icons/Email2Icon.vue'
|
||||||
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
|
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
|
||||||
import NoteIcon from '@/components/Icons/NoteIcon.vue'
|
import NoteIcon from '@/components/Icons/NoteIcon.vue'
|
||||||
import TaskIcon from '@/components/Icons/TaskIcon.vue'
|
import TaskIcon from '@/components/Icons/TaskIcon.vue'
|
||||||
|
import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue'
|
||||||
import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
|
import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
|
||||||
import WhatsAppArea from '@/components/Activities/WhatsAppArea.vue'
|
import WhatsAppArea from '@/components/Activities/WhatsAppArea.vue'
|
||||||
import WhatsAppBox from '@/components/Activities/WhatsAppBox.vue'
|
import WhatsAppBox from '@/components/Activities/WhatsAppBox.vue'
|
||||||
@ -426,6 +480,7 @@ import FadedScrollableDiv from '@/components/FadedScrollableDiv.vue'
|
|||||||
import CommunicationArea from '@/components/CommunicationArea.vue'
|
import CommunicationArea from '@/components/CommunicationArea.vue'
|
||||||
import WhatsappTemplateSelectorModal from '@/components/Modals/WhatsappTemplateSelectorModal.vue'
|
import WhatsappTemplateSelectorModal from '@/components/Modals/WhatsappTemplateSelectorModal.vue'
|
||||||
import AllModals from '@/components/Activities/AllModals.vue'
|
import AllModals from '@/components/Activities/AllModals.vue'
|
||||||
|
import FilesUploader from '@/components/FilesUploader/FilesUploader.vue'
|
||||||
import {
|
import {
|
||||||
timeAgo,
|
timeAgo,
|
||||||
dateFormat,
|
dateFormat,
|
||||||
@ -475,15 +530,23 @@ const tabIndex = defineModel('tabIndex')
|
|||||||
|
|
||||||
const reload_email = ref(false)
|
const reload_email = ref(false)
|
||||||
const modalRef = ref(null)
|
const modalRef = ref(null)
|
||||||
|
const showFilesUploader = ref(false)
|
||||||
|
|
||||||
const title = computed(() => props.tabs?.[tabIndex.value]?.name || 'Activity')
|
const title = computed(() => props.tabs?.[tabIndex.value]?.name || 'Activity')
|
||||||
|
|
||||||
|
const changeTabTo = (tabName) => {
|
||||||
|
const tabNames = props.tabs?.map((tab) => tab.name?.toLowerCase())
|
||||||
|
const index = tabNames?.indexOf(tabName)
|
||||||
|
if (index == -1) return
|
||||||
|
tabIndex.value = index
|
||||||
|
}
|
||||||
|
|
||||||
const all_activities = createResource({
|
const all_activities = createResource({
|
||||||
url: 'crm.api.activities.get_activities',
|
url: 'crm.api.activities.get_activities',
|
||||||
params: { name: doc.value.data.name },
|
params: { name: doc.value.data.name },
|
||||||
cache: ['activity', doc.value.data.name],
|
cache: ['activity', doc.value.data.name],
|
||||||
auto: true,
|
auto: true,
|
||||||
transform: ([versions, calls, notes, tasks]) => {
|
transform: ([versions, calls, notes, tasks, attachments]) => {
|
||||||
if (calls?.length) {
|
if (calls?.length) {
|
||||||
calls.forEach((doc) => {
|
calls.forEach((doc) => {
|
||||||
doc.show_recording = false
|
doc.show_recording = false
|
||||||
@ -518,7 +581,7 @@ const all_activities = createResource({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return { versions, calls, notes, tasks }
|
return { versions, calls, notes, tasks, attachments }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -584,9 +647,9 @@ function get_activities() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const activities = computed(() => {
|
const activities = computed(() => {
|
||||||
let activities = []
|
let _activities = []
|
||||||
if (title.value == 'Activity') {
|
if (title.value == 'Activity') {
|
||||||
activities = get_activities()
|
_activities = get_activities()
|
||||||
} else if (title.value == 'Emails') {
|
} else if (title.value == 'Emails') {
|
||||||
if (!all_activities.data?.versions) return []
|
if (!all_activities.data?.versions) return []
|
||||||
activities = all_activities.data.versions.filter(
|
activities = all_activities.data.versions.filter(
|
||||||
@ -606,9 +669,12 @@ const activities = computed(() => {
|
|||||||
} else if (title.value == 'Notes') {
|
} else if (title.value == 'Notes') {
|
||||||
if (!all_activities.data?.notes) return []
|
if (!all_activities.data?.notes) return []
|
||||||
return sortByCreation(all_activities.data.notes)
|
return sortByCreation(all_activities.data.notes)
|
||||||
|
} else if (title.value == 'Attachments') {
|
||||||
|
if (!all_activities.data?.attachments) return []
|
||||||
|
return sortByCreation(all_activities.data.attachments)
|
||||||
}
|
}
|
||||||
|
|
||||||
activities.forEach((activity) => {
|
_activities.forEach((activity) => {
|
||||||
activity.icon = timelineIcon(activity.activity_type, activity.is_lead)
|
activity.icon = timelineIcon(activity.activity_type, activity.is_lead)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -627,7 +693,7 @@ const activities = computed(() => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return sortByCreation(activities)
|
return sortByCreation(_activities)
|
||||||
})
|
})
|
||||||
|
|
||||||
function sortByCreation(list) {
|
function sortByCreation(list) {
|
||||||
@ -667,6 +733,8 @@ const emptyText = computed(() => {
|
|||||||
text = 'No Notes'
|
text = 'No Notes'
|
||||||
} else if (title.value == 'Tasks') {
|
} else if (title.value == 'Tasks') {
|
||||||
text = 'No Tasks'
|
text = 'No Tasks'
|
||||||
|
} else if (title.value == 'Attachments') {
|
||||||
|
text = 'No Attachments'
|
||||||
} else if (title.value == 'WhatsApp') {
|
} else if (title.value == 'WhatsApp') {
|
||||||
text = 'No WhatsApp Messages'
|
text = 'No WhatsApp Messages'
|
||||||
}
|
}
|
||||||
@ -685,6 +753,8 @@ const emptyTextIcon = computed(() => {
|
|||||||
icon = NoteIcon
|
icon = NoteIcon
|
||||||
} else if (title.value == 'Tasks') {
|
} else if (title.value == 'Tasks') {
|
||||||
icon = TaskIcon
|
icon = TaskIcon
|
||||||
|
} else if (title.value == 'Attachments') {
|
||||||
|
icon = AttachmentIcon
|
||||||
} else if (title.value == 'WhatsApp') {
|
} else if (title.value == 'WhatsApp') {
|
||||||
icon = WhatsAppIcon
|
icon = WhatsAppIcon
|
||||||
}
|
}
|
||||||
@ -709,6 +779,9 @@ function timelineIcon(activity_type, is_lead) {
|
|||||||
case 'outgoing_call':
|
case 'outgoing_call':
|
||||||
icon = OutboundCallIcon
|
icon = OutboundCallIcon
|
||||||
break
|
break
|
||||||
|
case 'attachment_log':
|
||||||
|
icon = AttachmentIcon
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
icon = DotIcon
|
icon = DotIcon
|
||||||
}
|
}
|
||||||
@ -744,5 +817,5 @@ function scroll(hash) {
|
|||||||
}, 500)
|
}, 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({ emailBox })
|
defineExpose({ emailBox, all_activities })
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -55,6 +55,16 @@
|
|||||||
</template>
|
</template>
|
||||||
<span>{{ __('New Task') }}</span>
|
<span>{{ __('New Task') }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-else-if="title == 'Attachments'"
|
||||||
|
variant="solid"
|
||||||
|
@click="showFilesUploader = true"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<FeatherIcon name="plus" class="h-4 w-4" />
|
||||||
|
</template>
|
||||||
|
<span>{{ __('Upload Attachment') }}</span>
|
||||||
|
</Button>
|
||||||
<div class="flex gap-2 shrink-0" v-else-if="title == 'WhatsApp'">
|
<div class="flex gap-2 shrink-0" v-else-if="title == 'WhatsApp'">
|
||||||
<Button
|
<Button
|
||||||
:label="__('Send Template')"
|
:label="__('Send Template')"
|
||||||
@ -91,6 +101,7 @@ import CommentIcon from '@/components/Icons/CommentIcon.vue'
|
|||||||
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
|
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
|
||||||
import NoteIcon from '@/components/Icons/NoteIcon.vue'
|
import NoteIcon from '@/components/Icons/NoteIcon.vue'
|
||||||
import TaskIcon from '@/components/Icons/TaskIcon.vue'
|
import TaskIcon from '@/components/Icons/TaskIcon.vue'
|
||||||
|
import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue'
|
||||||
import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
|
import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
|
||||||
import { globalStore } from '@/stores/global'
|
import { globalStore } from '@/stores/global'
|
||||||
import { whatsappEnabled, callEnabled } from '@/composables/settings'
|
import { whatsappEnabled, callEnabled } from '@/composables/settings'
|
||||||
@ -110,6 +121,7 @@ const { makeCall } = globalStore()
|
|||||||
|
|
||||||
const tabIndex = defineModel()
|
const tabIndex = defineModel()
|
||||||
const showWhatsappTemplates = defineModel('showWhatsappTemplates')
|
const showWhatsappTemplates = defineModel('showWhatsappTemplates')
|
||||||
|
const showFilesUploader = defineModel('showFilesUploader')
|
||||||
|
|
||||||
const defaultActions = computed(() => {
|
const defaultActions = computed(() => {
|
||||||
let actions = [
|
let actions = [
|
||||||
@ -139,6 +151,11 @@ const defaultActions = computed(() => {
|
|||||||
label: __('New Task'),
|
label: __('New Task'),
|
||||||
onClick: () => props.modalRef.showTask(),
|
onClick: () => props.modalRef.showTask(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: h(AttachmentIcon, { class: 'h-4 w-4' }),
|
||||||
|
label: __('Upload Attachment'),
|
||||||
|
onClick: () => (showFilesUploader.value = true),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: h(WhatsAppIcon, { class: 'h-4 w-4' }),
|
icon: h(WhatsAppIcon, { class: 'h-4 w-4' }),
|
||||||
label: __('New WhatsApp Message'),
|
label: __('New WhatsApp Message'),
|
||||||
|
|||||||
164
frontend/src/components/Activities/AttachmentArea.vue
Normal file
164
frontend/src/components/Activities/AttachmentArea.vue
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="attachments.length">
|
||||||
|
<div v-for="(attachment, i) in attachments" :key="attachment.name">
|
||||||
|
<div
|
||||||
|
class="activity flex justify-between gap-2 hover:bg-gray-50 rounded text-base p-2.5 cursor-pointer"
|
||||||
|
@click="openFile(attachment)"
|
||||||
|
>
|
||||||
|
<div class="flex gap-2 truncate">
|
||||||
|
<div
|
||||||
|
class="size-11 bg-white rounded overflow-hidden flex-shrink-0 flex justify-center items-center"
|
||||||
|
:class="{ border: !isImage(attachment.file_type) }"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="isImage(attachment.file_type)"
|
||||||
|
class="size-full object-cover"
|
||||||
|
:src="attachment.file_url"
|
||||||
|
:alt="attachment.file_name"
|
||||||
|
/>
|
||||||
|
<component
|
||||||
|
v-else
|
||||||
|
class="size-4"
|
||||||
|
:is="fileIcon(attachment.file_type)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col justify-center gap-1 truncate">
|
||||||
|
<div class="text-base text-gray-800 truncate">
|
||||||
|
{{ attachment.file_name }}
|
||||||
|
</div>
|
||||||
|
<div class="mb-1 text-sm text-gray-600">
|
||||||
|
{{ convertSize(attachment.file_size) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-end gap-2 flex-shrink-0">
|
||||||
|
<Tooltip :text="dateFormat(attachment.creation, dateTooltipFormat)">
|
||||||
|
<div class="text-sm text-gray-600">
|
||||||
|
{{ __(timeAgo(attachment.creation)) }}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<Tooltip
|
||||||
|
:text="
|
||||||
|
attachment.is_private ? __('Make public') : __('Make private')
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
class="!size-5"
|
||||||
|
@click.stop="
|
||||||
|
togglePrivate(attachment.name, attachment.is_private)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<FeatherIcon
|
||||||
|
:name="attachment.is_private ? 'lock' : 'unlock'"
|
||||||
|
class="size-3 text-gray-700"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip :text="__('Delete attachment')">
|
||||||
|
<Button
|
||||||
|
class="!size-5"
|
||||||
|
@click.stop="() => deleteAttachment(attachment.name)"
|
||||||
|
>
|
||||||
|
<FeatherIcon name="trash-2" class="size-3 text-gray-700" />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="i < attachments.length - 1"
|
||||||
|
class="mx-2 h-px border-t border-gray-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import FileAudioIcon from '@/components/Icons/FileAudioIcon.vue'
|
||||||
|
import FileTextIcon from '@/components/Icons/FileTextIcon.vue'
|
||||||
|
import FileVideoIcon from '@/components/Icons/FileVideoIcon.vue'
|
||||||
|
import { globalStore } from '@/stores/global'
|
||||||
|
import { call, Tooltip } from 'frappe-ui'
|
||||||
|
import {
|
||||||
|
dateFormat,
|
||||||
|
timeAgo,
|
||||||
|
dateTooltipFormat,
|
||||||
|
convertSize,
|
||||||
|
isImage,
|
||||||
|
} from '@/utils'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
attachments: Array,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['reload'])
|
||||||
|
|
||||||
|
const { $dialog } = globalStore()
|
||||||
|
|
||||||
|
function openFile(attachment) {
|
||||||
|
window.open(attachment.file_url, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePrivate(fileName, isPrivate) {
|
||||||
|
let changeTo = isPrivate ? __('public') : __('private')
|
||||||
|
let title = __('Make attachment {0}', [changeTo])
|
||||||
|
let message = __('Are you sure you want to make this attachment {0}?', [
|
||||||
|
changeTo,
|
||||||
|
])
|
||||||
|
$dialog({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: __('Make {0}', [changeTo]),
|
||||||
|
variant: 'solid',
|
||||||
|
onClick: async (close) => {
|
||||||
|
await call('frappe.client.set_value', {
|
||||||
|
doctype: 'File',
|
||||||
|
name: fileName,
|
||||||
|
fieldname: {
|
||||||
|
is_private: !isPrivate,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
emit('reload')
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteAttachment(fileName) {
|
||||||
|
$dialog({
|
||||||
|
title: __('Delete attachment'),
|
||||||
|
message: __('Are you sure you want to delete this attachment?'),
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: __('Delete'),
|
||||||
|
variant: 'solid',
|
||||||
|
theme: 'red',
|
||||||
|
onClick: async (close) => {
|
||||||
|
await call('frappe.client.delete', {
|
||||||
|
doctype: 'File',
|
||||||
|
name: fileName,
|
||||||
|
})
|
||||||
|
emit('reload')
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileIcon(type) {
|
||||||
|
if (!type) return FileTextIcon
|
||||||
|
let audioExtentions = ['wav', 'mp3', 'ogg', 'flac', 'aac']
|
||||||
|
let videoExtentions = ['mp4', 'avi', 'mkv', 'flv', 'mov']
|
||||||
|
if (audioExtentions.includes(type.toLowerCase())) {
|
||||||
|
return FileAudioIcon
|
||||||
|
} else if (videoExtentions.includes(type.toLowerCase())) {
|
||||||
|
return FileVideoIcon
|
||||||
|
}
|
||||||
|
return FileTextIcon
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="tasks.length">
|
<div v-if="tasks.length">
|
||||||
<div v-for="(task, i) in tasks">
|
<div v-for="(task, i) in tasks" :key="task.name">
|
||||||
<div
|
<div
|
||||||
class="activity flex cursor-pointer gap-6 rounded p-2.5 duration-300 ease-in-out hover:bg-gray-50"
|
class="activity flex cursor-pointer gap-6 rounded p-2.5 duration-300 ease-in-out hover:bg-gray-50"
|
||||||
@click="modalRef.showTask(task)"
|
@click="modalRef.showTask(task)"
|
||||||
>
|
>
|
||||||
<div class="flex flex-1 flex-col gap-1.5 text-base">
|
<div class="flex flex-1 flex-col gap-1.5 text-base truncate">
|
||||||
<div class="font-medium text-gray-900">
|
<div class="font-medium text-gray-900 truncate">
|
||||||
{{ task.title }}
|
{{ task.title }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-1.5 text-gray-800">
|
<div class="flex gap-1.5 text-gray-800">
|
||||||
|
|||||||
238
frontend/src/components/FilesUploader/FilesUploader.vue
Normal file
238
frontend/src/components/FilesUploader/FilesUploader.vue
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
title: __('Attach'),
|
||||||
|
size: 'xl',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<FilesUploaderArea
|
||||||
|
ref="filesUploaderArea"
|
||||||
|
v-model="files"
|
||||||
|
:doctype="doctype"
|
||||||
|
:options="options"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #actions>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
v-if="files.length"
|
||||||
|
variant="subtle"
|
||||||
|
:label="__('Remove all')"
|
||||||
|
:disabled="fileUploadStarted"
|
||||||
|
@click="removeAllFiles"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="
|
||||||
|
filesUploaderArea?.showWebLink || filesUploaderArea?.showCamera
|
||||||
|
"
|
||||||
|
:label="isMobileView ? __('Back') : __('Back to file upload')"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
filesUploaderArea.showWebLink = false
|
||||||
|
filesUploaderArea.showCamera = false
|
||||||
|
filesUploaderArea.webLink = null
|
||||||
|
filesUploaderArea.cameraImage = null
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<FeatherIcon name="arrow-left" class="size-4" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="
|
||||||
|
filesUploaderArea?.showCamera && !filesUploaderArea?.cameraImage
|
||||||
|
"
|
||||||
|
:label="__('Switch camera')"
|
||||||
|
@click="() => filesUploaderArea.switchCamera()"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="filesUploaderArea?.cameraImage"
|
||||||
|
:label="__('Retake')"
|
||||||
|
@click="filesUploaderArea.cameraImage = null"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
v-if="isAllPrivate && files.length"
|
||||||
|
variant="subtle"
|
||||||
|
:label="__('Set all as public')"
|
||||||
|
:disabled="fileUploadStarted"
|
||||||
|
@click="setAllPublic"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-else-if="files.length"
|
||||||
|
variant="subtle"
|
||||||
|
:label="__('Set all as private')"
|
||||||
|
:disabled="fileUploadStarted"
|
||||||
|
@click="setAllPrivate"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="!filesUploaderArea?.showCamera"
|
||||||
|
variant="solid"
|
||||||
|
:label="__('Attach')"
|
||||||
|
:loading="fileUploadStarted"
|
||||||
|
:disabled="disableAttachButton"
|
||||||
|
@click="attachFiles"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="
|
||||||
|
filesUploaderArea?.showCamera && filesUploaderArea?.cameraImage
|
||||||
|
"
|
||||||
|
variant="solid"
|
||||||
|
:label="__('Upload')"
|
||||||
|
@click="() => filesUploaderArea.uploadViaCamera()"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="
|
||||||
|
filesUploaderArea?.showCamera && !filesUploaderArea?.cameraImage
|
||||||
|
"
|
||||||
|
variant="solid"
|
||||||
|
:label="__('Capture')"
|
||||||
|
@click="() => filesUploaderArea.captureImage()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import FilesUploaderArea from '@/components/FilesUploader/FilesUploaderArea.vue'
|
||||||
|
import FilesUploadHandler from './filesUploaderHandler'
|
||||||
|
import { isMobileView } from '@/composables/settings'
|
||||||
|
import { createToast } from '@/utils'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
doctype: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
docname: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({
|
||||||
|
folder: 'Home/Attachments',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['after'])
|
||||||
|
|
||||||
|
const show = defineModel()
|
||||||
|
|
||||||
|
const filesUploaderArea = ref(null)
|
||||||
|
const files = ref([])
|
||||||
|
|
||||||
|
const isAllPrivate = computed(() => files.value.every((a) => a.private))
|
||||||
|
|
||||||
|
function setAllPrivate() {
|
||||||
|
files.value.forEach((file) => (file.private = true))
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAllPublic() {
|
||||||
|
files.value.forEach((file) => (file.private = false))
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAllFiles() {
|
||||||
|
files.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const disableAttachButton = computed(() => {
|
||||||
|
if (filesUploaderArea.value?.showCamera) {
|
||||||
|
return !filesUploaderArea.value.cameraImage
|
||||||
|
}
|
||||||
|
if (filesUploaderArea.value?.showWebLink) {
|
||||||
|
return !filesUploaderArea.value.webLink
|
||||||
|
}
|
||||||
|
return !files.value.length
|
||||||
|
})
|
||||||
|
|
||||||
|
function attachFiles() {
|
||||||
|
if (filesUploaderArea.value.showWebLink) {
|
||||||
|
return uploadViaWebLink()
|
||||||
|
}
|
||||||
|
files.value.forEach((file, i) => attachFile(file, i))
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadViaWebLink() {
|
||||||
|
let fileUrl = filesUploaderArea.value.webLink
|
||||||
|
if (!fileUrl) {
|
||||||
|
createToast({
|
||||||
|
title: __('Error'),
|
||||||
|
title: __('Please enter a valid URL'),
|
||||||
|
icon: 'x',
|
||||||
|
iconClasses: 'text-red-600',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fileUrl = decodeURI(fileUrl)
|
||||||
|
show.value = false
|
||||||
|
return attachFile({
|
||||||
|
fileUrl,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploader = ref(null)
|
||||||
|
const fileUploadStarted = ref(false)
|
||||||
|
|
||||||
|
function attachFile(file, i) {
|
||||||
|
const args = {
|
||||||
|
fileObj: file.fileObj || {},
|
||||||
|
type: file.type,
|
||||||
|
private: file.private,
|
||||||
|
fileUrl: file.fileUrl,
|
||||||
|
folder: props.options.folder,
|
||||||
|
doctype: props.doctype,
|
||||||
|
docname: props.docname,
|
||||||
|
}
|
||||||
|
|
||||||
|
uploader.value = new FilesUploadHandler()
|
||||||
|
|
||||||
|
uploader.value.on('start', () => {
|
||||||
|
file.uploading = true
|
||||||
|
fileUploadStarted.value = true
|
||||||
|
})
|
||||||
|
uploader.value.on('progress', (data) => {
|
||||||
|
file.uploaded = data.uploaded
|
||||||
|
file.total = data.total
|
||||||
|
})
|
||||||
|
uploader.value.on('error', (error) => {
|
||||||
|
file.uploading = false
|
||||||
|
file.errorMessage = error || 'Error Uploading File'
|
||||||
|
})
|
||||||
|
uploader.value.on('finish', () => {
|
||||||
|
file.uploading = false
|
||||||
|
})
|
||||||
|
|
||||||
|
uploader.value
|
||||||
|
.upload(file, args || {})
|
||||||
|
.then(() => {
|
||||||
|
if (i === files.value.length - 1) {
|
||||||
|
files.value = []
|
||||||
|
show.value = false
|
||||||
|
fileUploadStarted.value = false
|
||||||
|
emit('after')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
file.uploading = false
|
||||||
|
let errorMessage = 'Error Uploading File'
|
||||||
|
if (error?._server_messages) {
|
||||||
|
errorMessage = JSON.parse(JSON.parse(error._server_messages)[0]).message
|
||||||
|
} else if (error?.exc) {
|
||||||
|
errorMessage = JSON.parse(error.exc)[0].split('\n').slice(-2, -1)[0]
|
||||||
|
} else if (typeof error === 'string') {
|
||||||
|
errorMessage = error
|
||||||
|
}
|
||||||
|
file.errorMessage = errorMessage
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
396
frontend/src/components/FilesUploader/FilesUploaderArea.vue
Normal file
396
frontend/src/components/FilesUploader/FilesUploaderArea.vue
Normal file
@ -0,0 +1,396 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="showWebLink">
|
||||||
|
<TextInput v-model="webLink" placeholder="https://example.com" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="showCamera">
|
||||||
|
<video v-show="!cameraImage" ref="video" class="rounded" autoplay></video>
|
||||||
|
<canvas
|
||||||
|
v-show="cameraImage"
|
||||||
|
ref="canvas"
|
||||||
|
class="rounded"
|
||||||
|
style="width: -webkit-fill-available"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center justify-center gap-4 rounded-lg border border-dashed min-h-64 text-gray-600"
|
||||||
|
@dragover.prevent="dragover"
|
||||||
|
@dragleave.prevent="dragleave"
|
||||||
|
@drop.prevent="dropfiles"
|
||||||
|
v-show="files.length === 0"
|
||||||
|
>
|
||||||
|
<div v-if="!isDragging" class="flex flex-col gap-3">
|
||||||
|
<div class="text-center text-gray-600">
|
||||||
|
{{ __('Drag and drop files here or upload from') }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="grid grid-flow-col justify-center gap-4 text-center text-base"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
class="hidden"
|
||||||
|
ref="fileInput"
|
||||||
|
@change="onFileInput"
|
||||||
|
:multiple="allowMultiple"
|
||||||
|
:accept="(restrictions.allowedFileTypes || []).join(', ')"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Button icon="monitor" size="md" @click="browseFiles" />
|
||||||
|
<div class="mt-1">{{ __('Device') }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="!disableFileBrowser">
|
||||||
|
<Button icon="folder" size="md" @click="showFileBrowser = true" />
|
||||||
|
<div class="mt-1">{{ __('Library') }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="allowWebLink">
|
||||||
|
<Button icon="link" size="md" @click="showWebLink = true" />
|
||||||
|
<div class="mt-1">{{ __('Link') }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="allowTakePhoto">
|
||||||
|
<Button icon="camera" size="md" @click="startCamera" />
|
||||||
|
<div class="mt-1">{{ __('Camera') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
{{ __('Drop files here') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-show="files.length" class="flex flex-col divide-y">
|
||||||
|
<div
|
||||||
|
v-for="file in files"
|
||||||
|
:key="file.name"
|
||||||
|
class="flex items-center justify-between gap-2 py-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-4 truncate">
|
||||||
|
<div
|
||||||
|
class="size-11 rounded overflow-hidden flex-shrink-0 flex justify-center items-center"
|
||||||
|
:class="{ border: !file.type?.startsWith('image') }"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="file.type?.startsWith('image')"
|
||||||
|
class="size-full object-cover"
|
||||||
|
:src="file.src"
|
||||||
|
:alt="file.name"
|
||||||
|
/>
|
||||||
|
<component v-else class="size-4" :is="fileIcon(file.type)" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1 text-sm text-gray-600 truncate">
|
||||||
|
<div class="text-base text-gray-800 truncate">
|
||||||
|
{{ file.name }}
|
||||||
|
</div>
|
||||||
|
<div class="mb-1">
|
||||||
|
{{ convertSize(file.fileObj.size) }}
|
||||||
|
</div>
|
||||||
|
<FormControl
|
||||||
|
v-model="file.private"
|
||||||
|
type="checkbox"
|
||||||
|
class="[&>label]:text-sm [&>label]:text-gray-600"
|
||||||
|
:label="__('Private')"
|
||||||
|
/>
|
||||||
|
<ErrorMessage
|
||||||
|
class="mt-2"
|
||||||
|
v-if="file.errorMessage"
|
||||||
|
:message="file.errorMessage"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CircularProgressBar
|
||||||
|
v-if="file.uploading || file.uploaded == file.total"
|
||||||
|
:class="{
|
||||||
|
'text-green-500': file.uploaded == file.total,
|
||||||
|
}"
|
||||||
|
:theme="{
|
||||||
|
primary: '#22C55E',
|
||||||
|
secondary: 'lightgray',
|
||||||
|
}"
|
||||||
|
:step="file.uploaded || 1"
|
||||||
|
:totalSteps="file.total || 100"
|
||||||
|
size="xs"
|
||||||
|
variant="outline"
|
||||||
|
:showPercentage="file.uploading"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-else
|
||||||
|
variant="ghost"
|
||||||
|
icon="trash-2"
|
||||||
|
@click="removeFile(file.name)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import FileTextIcon from '@/components/Icons/FileTextIcon.vue'
|
||||||
|
import FileAudioIcon from '@/components/Icons/FileAudioIcon.vue'
|
||||||
|
import FileVideoIcon from '@/components/Icons/FileVideoIcon.vue'
|
||||||
|
import { createToast, dateFormat, convertSize } from '@/utils'
|
||||||
|
import { FormControl, CircularProgressBar, createResource } from 'frappe-ui'
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
doctype: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const files = defineModel()
|
||||||
|
|
||||||
|
const fileInput = ref(null)
|
||||||
|
const isDragging = ref(false)
|
||||||
|
const showWebLink = ref(false)
|
||||||
|
const showFileBrowser = ref(false)
|
||||||
|
const showCamera = ref(false)
|
||||||
|
|
||||||
|
const webLink = ref('')
|
||||||
|
const cameraImage = ref(null)
|
||||||
|
|
||||||
|
const allowMultiple = ref(props.options.allowMultiple == false ? false : true)
|
||||||
|
const disableFileBrowser = ref(props.options.disableFileBrowser || true)
|
||||||
|
const allowWebLink = ref(props.options.allowWebLink == false ? false : true)
|
||||||
|
const allowTakePhoto = ref(
|
||||||
|
props.options.allowTakePhoto || window.navigator.mediaDevices || false,
|
||||||
|
)
|
||||||
|
const restrictions = ref(props.options.restrictions || {})
|
||||||
|
const makeAttachmentsPublic = ref(props.options.makeAttachmentsPublic || false)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
createResource({
|
||||||
|
url: 'crm.api.get_file_uploader_defaults',
|
||||||
|
params: { doctype: props.doctype },
|
||||||
|
cache: ['file_uploader_defaults', props.doctype],
|
||||||
|
auto: true,
|
||||||
|
transform: (data) => {
|
||||||
|
restrictions.value = {
|
||||||
|
allowedFileTypes: data.allowed_file_types
|
||||||
|
? data.allowed_file_types.split('\n').map((ext) => `.${ext}`)
|
||||||
|
: [],
|
||||||
|
maxFileSize: data.max_file_size,
|
||||||
|
maxNumberOfFiles: data.max_number_of_files,
|
||||||
|
}
|
||||||
|
makeAttachmentsPublic.value = Boolean(data.make_attachments_public)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function dragover() {
|
||||||
|
isDragging.value = true
|
||||||
|
}
|
||||||
|
function dragleave() {
|
||||||
|
isDragging.value = false
|
||||||
|
}
|
||||||
|
function dropfiles(e) {
|
||||||
|
isDragging.value = false
|
||||||
|
addFiles(e.dataTransfer.files)
|
||||||
|
}
|
||||||
|
|
||||||
|
function browseFiles() {
|
||||||
|
fileInput.value.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileInput(event) {
|
||||||
|
addFiles(fileInput.value.files)
|
||||||
|
}
|
||||||
|
|
||||||
|
const video = ref(null)
|
||||||
|
const facingMode = ref('environment')
|
||||||
|
const stream = ref(null)
|
||||||
|
|
||||||
|
async function startCamera() {
|
||||||
|
showCamera.value = true
|
||||||
|
|
||||||
|
stream.value = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: {
|
||||||
|
facingMode: facingMode.value,
|
||||||
|
},
|
||||||
|
audio: false,
|
||||||
|
})
|
||||||
|
video.value.srcObject = stream.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopStream() {
|
||||||
|
stream.value.getTracks().forEach((track) => track.stop())
|
||||||
|
showCamera.value = false
|
||||||
|
cameraImage.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchCamera() {
|
||||||
|
facingMode.value = facingMode.value === 'environment' ? 'user' : 'environment'
|
||||||
|
stopStream()
|
||||||
|
startCamera()
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = ref(null)
|
||||||
|
|
||||||
|
function captureImage() {
|
||||||
|
const width = video.value.videoWidth
|
||||||
|
const height = video.value.videoHeight
|
||||||
|
|
||||||
|
canvas.value.width = width
|
||||||
|
canvas.value.height = height
|
||||||
|
|
||||||
|
canvas.value.getContext('2d').drawImage(video.value, 0, 0, width, height)
|
||||||
|
|
||||||
|
cameraImage.value = canvas.value.toDataURL('image/png')
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadViaCamera() {
|
||||||
|
const nowDatetime = dateFormat(new Date(), 'YYYY_MM_DD_HH_mm_ss')
|
||||||
|
let filename = `capture_${nowDatetime}.png`
|
||||||
|
urlToFile(cameraImage.value, filename, 'image/png').then((file) => {
|
||||||
|
addFiles([file])
|
||||||
|
showCamera.value = false
|
||||||
|
cameraImage.value = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function urlToFile(url, filename, mime_type) {
|
||||||
|
return fetch(url)
|
||||||
|
.then((res) => res.arrayBuffer())
|
||||||
|
.then((buffer) => new File([buffer], filename, { type: mime_type }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFiles(fileArray) {
|
||||||
|
let _files = Array.from(fileArray)
|
||||||
|
.filter(checkRestrictions)
|
||||||
|
.map((file, i) => {
|
||||||
|
let isImage = file.type?.startsWith('image')
|
||||||
|
let sizeKb = file.size / 1024
|
||||||
|
return {
|
||||||
|
index: i,
|
||||||
|
src: isImage ? URL.createObjectURL(file) : null,
|
||||||
|
fileObj: file,
|
||||||
|
cropperFile: file,
|
||||||
|
cropBoxData: null,
|
||||||
|
type: file.type,
|
||||||
|
optimize: sizeKb > 200 && isImage && !file.type?.includes('svg'),
|
||||||
|
name: file.name,
|
||||||
|
doc: null,
|
||||||
|
progress: 0,
|
||||||
|
total: 0,
|
||||||
|
failed: false,
|
||||||
|
requestSucceeded: false,
|
||||||
|
errorMessage: null,
|
||||||
|
uploading: false,
|
||||||
|
private: !makeAttachmentsPublic.value,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// pop extra files as per FileUploader.restrictions.maxNumberOfFiles
|
||||||
|
let maxNumberOfFiles = restrictions.value.maxNumberOfFiles
|
||||||
|
if (maxNumberOfFiles && _files.length > maxNumberOfFiles) {
|
||||||
|
_files.slice(maxNumberOfFiles).forEach((file) => {
|
||||||
|
showMaxFilesNumberWarning(file, maxNumberOfFiles)
|
||||||
|
})
|
||||||
|
|
||||||
|
_files = _files.slice(0, maxNumberOfFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
files.value = files.value.concat(_files)
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkRestrictions(file) {
|
||||||
|
let { maxFileSize, allowedFileTypes = [] } = restrictions.value
|
||||||
|
|
||||||
|
let isCorrectType = true
|
||||||
|
let validFileSize = true
|
||||||
|
|
||||||
|
if (allowedFileTypes && allowedFileTypes.length) {
|
||||||
|
isCorrectType = allowedFileTypes.some((type) => {
|
||||||
|
// is this is a mime-type
|
||||||
|
if (type.includes('/')) {
|
||||||
|
if (!file.type) return false
|
||||||
|
return file.type.match(type)
|
||||||
|
}
|
||||||
|
|
||||||
|
// otherwise this is likely an extension
|
||||||
|
if (type[0] === '.') {
|
||||||
|
return file.name.toLowerCase().endsWith(type.toLowerCase())
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxFileSize && file.size != null) {
|
||||||
|
validFileSize = file.size < maxFileSize
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCorrectType) {
|
||||||
|
console.warn('File skipped because of invalid file type', file)
|
||||||
|
createToast({
|
||||||
|
title: __('File "{0}" was skipped because of invalid file type', [
|
||||||
|
file.name,
|
||||||
|
]),
|
||||||
|
icon: 'alert-circle',
|
||||||
|
iconClasses: 'text-orange-600',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!validFileSize) {
|
||||||
|
console.warn('File skipped because of invalid file size', file.size, file)
|
||||||
|
createToast({
|
||||||
|
title: __('File "{0}" was skipped because size exceeds {1} MB', [
|
||||||
|
file.name,
|
||||||
|
maxFileSize / (1024 * 1024),
|
||||||
|
]),
|
||||||
|
icon: 'alert-circle',
|
||||||
|
iconClasses: 'text-orange-600',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return isCorrectType && validFileSize
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMaxFilesNumberWarning(file, maxNumberOfFiles) {
|
||||||
|
console.warn(
|
||||||
|
`File skipped because it exceeds the allowed specified limit of ${maxNumberOfFiles} uploads`,
|
||||||
|
file,
|
||||||
|
)
|
||||||
|
let message = __(
|
||||||
|
'File "{0}" was skipped because only {1} uploads are allowed',
|
||||||
|
[file.name, maxNumberOfFiles],
|
||||||
|
)
|
||||||
|
if (props.doctype) {
|
||||||
|
message = __(
|
||||||
|
'File "{0}" was skipped because only {1} uploads are allowed for DocType "{2}"',
|
||||||
|
[file.name, maxNumberOfFiles, props.doctype],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
createToast({
|
||||||
|
title: message,
|
||||||
|
icon: 'alert-circle',
|
||||||
|
iconClasses: 'text-orange-600',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFile(name) {
|
||||||
|
files.value = files.value.filter((file) => file.name !== name)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileIcon(type) {
|
||||||
|
if (type?.startsWith('audio')) {
|
||||||
|
return FileAudioIcon
|
||||||
|
} else if (type?.startsWith('video')) {
|
||||||
|
return FileVideoIcon
|
||||||
|
}
|
||||||
|
return FileTextIcon
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
showFileBrowser,
|
||||||
|
showWebLink,
|
||||||
|
webLink,
|
||||||
|
showCamera,
|
||||||
|
cameraImage,
|
||||||
|
captureImage,
|
||||||
|
uploadViaCamera,
|
||||||
|
switchCamera,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
129
frontend/src/components/FilesUploader/filesUploaderHandler.ts
Normal file
129
frontend/src/components/FilesUploader/filesUploaderHandler.ts
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
interface UploadOptions {
|
||||||
|
fileObj?: File
|
||||||
|
private?: boolean
|
||||||
|
fileUrl?: string
|
||||||
|
folder?: string
|
||||||
|
doctype?: string
|
||||||
|
docname?: string
|
||||||
|
type?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type EventListenerOption = 'start' | 'progress' | 'finish' | 'error'
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
csrf_token?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FilesUploadHandler {
|
||||||
|
listeners: { [event: string]: Function[] }
|
||||||
|
failed: boolean
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.listeners = {}
|
||||||
|
this.failed = false
|
||||||
|
}
|
||||||
|
|
||||||
|
on(event: EventListenerOption, handler: Function) {
|
||||||
|
this.listeners[event] = this.listeners[event] || []
|
||||||
|
this.listeners[event].push(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger(event: string, data?: any) {
|
||||||
|
let handlers = this.listeners[event] || []
|
||||||
|
handlers.forEach((handler) => {
|
||||||
|
handler.call(this, data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
upload(file: File | null, options: UploadOptions): Promise<any> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let xhr = new XMLHttpRequest()
|
||||||
|
xhr.upload.addEventListener('loadstart', () => {
|
||||||
|
this.trigger('start')
|
||||||
|
})
|
||||||
|
xhr.upload.addEventListener('progress', (e) => {
|
||||||
|
if (e.lengthComputable) {
|
||||||
|
this.trigger('progress', {
|
||||||
|
uploaded: e.loaded,
|
||||||
|
total: e.total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
xhr.upload.addEventListener('load', () => {
|
||||||
|
this.trigger('finish')
|
||||||
|
})
|
||||||
|
xhr.addEventListener('error', () => {
|
||||||
|
this.trigger('error')
|
||||||
|
reject()
|
||||||
|
})
|
||||||
|
xhr.onreadystatechange = () => {
|
||||||
|
if (xhr.readyState == XMLHttpRequest.DONE) {
|
||||||
|
let error: any = null
|
||||||
|
if (xhr.status === 200) {
|
||||||
|
let r: any = null
|
||||||
|
try {
|
||||||
|
r = JSON.parse(xhr.responseText)
|
||||||
|
} catch (e) {
|
||||||
|
r = xhr.responseText
|
||||||
|
}
|
||||||
|
let out = r.message || r
|
||||||
|
resolve(out)
|
||||||
|
} else if (xhr.status === 403) {
|
||||||
|
error = JSON.parse(xhr.responseText)
|
||||||
|
} else if (xhr.status === 413) {
|
||||||
|
this.failed = true
|
||||||
|
error = 'Size exceeds the maximum allowed file size.'
|
||||||
|
} else {
|
||||||
|
this.failed = true
|
||||||
|
try {
|
||||||
|
error = JSON.parse(xhr.responseText)
|
||||||
|
} catch (e) {
|
||||||
|
// pass
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (error && error.exc) {
|
||||||
|
console.error(JSON.parse(error.exc)[0])
|
||||||
|
}
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
xhr.open('POST', '/api/method/upload_file', true)
|
||||||
|
xhr.setRequestHeader('Accept', 'application/json')
|
||||||
|
|
||||||
|
if (window.csrf_token && window.csrf_token !== '{{ csrf_token }}') {
|
||||||
|
xhr.setRequestHeader('X-Frappe-CSRF-Token', window.csrf_token)
|
||||||
|
}
|
||||||
|
|
||||||
|
let formData = new FormData()
|
||||||
|
|
||||||
|
if (options.fileObj && file?.name) {
|
||||||
|
formData.append('file', options.fileObj, file.name)
|
||||||
|
}
|
||||||
|
formData.append('is_private', options.private || false ? '1' : '0')
|
||||||
|
formData.append('folder', options.folder || 'Home')
|
||||||
|
|
||||||
|
if (options.fileUrl) {
|
||||||
|
formData.append('file_url', options.fileUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.doctype) {
|
||||||
|
formData.append('doctype', options.doctype)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.docname) {
|
||||||
|
formData.append('docname', options.docname)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.type) {
|
||||||
|
formData.append('type', options.type)
|
||||||
|
}
|
||||||
|
|
||||||
|
xhr.send(formData)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FilesUploadHandler
|
||||||
20
frontend/src/components/Icons/FileAudioIcon.vue
Normal file
20
frontend/src/components/Icons/FileAudioIcon.vue
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="lucide lucide-file-audio-2"
|
||||||
|
>
|
||||||
|
<path d="M4 22h14a2 2 0 0 0 2-2V7l-5-5H6a2 2 0 0 0-2 2v2" />
|
||||||
|
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||||
|
<circle cx="3" cy="17" r="1" />
|
||||||
|
<path d="M2 17v-3a4 4 0 0 1 8 0v3" />
|
||||||
|
<circle cx="9" cy="17" r="1" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
19
frontend/src/components/Icons/FileVideoIcon.vue
Normal file
19
frontend/src/components/Icons/FileVideoIcon.vue
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="lucide lucide-file-video-2"
|
||||||
|
>
|
||||||
|
<path d="M4 22h14a2 2 0 0 0 2-2V7l-5-5H6a2 2 0 0 0-2 2v4" />
|
||||||
|
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||||
|
<rect width="8" height="6" x="2" y="12" rx="1" />
|
||||||
|
<path d="m10 15.5 4 2.5v-6l-4 2.5" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@ -39,7 +39,7 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
ErrorMessage,
|
ErrorMessage,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { evaluate_depends_on_value, createToast } from '@/utils'
|
import { evaluateDependsOnValue, createToast } from '@/utils'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@ -123,11 +123,11 @@ const sections = computed(() => {
|
|||||||
_sections[_sections.length - 1].fields.push({
|
_sections[_sections.length - 1].fields.push({
|
||||||
...field,
|
...field,
|
||||||
filters: field.link_filters && JSON.parse(field.link_filters),
|
filters: field.link_filters && JSON.parse(field.link_filters),
|
||||||
display_via_depends_on: evaluate_depends_on_value(
|
display_via_depends_on: evaluateDependsOnValue(
|
||||||
field.depends_on,
|
field.depends_on,
|
||||||
data.doc,
|
data.doc,
|
||||||
),
|
),
|
||||||
mandatory_via_depends_on: evaluate_depends_on_value(
|
mandatory_via_depends_on: evaluateDependsOnValue(
|
||||||
field.mandatory_depends_on,
|
field.mandatory_depends_on,
|
||||||
data.doc,
|
data.doc,
|
||||||
),
|
),
|
||||||
|
|||||||
@ -7,6 +7,12 @@ export function useActiveTabManager(tabs, storageKey) {
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
const changeTabTo = (tabName) => {
|
||||||
|
let index = findTabIndex(tabName)
|
||||||
|
if (index == -1) return
|
||||||
|
tabIndex.value = index
|
||||||
|
}
|
||||||
|
|
||||||
const preserveLastVisitedTab = useDebounceFn((tabName) => {
|
const preserveLastVisitedTab = useDebounceFn((tabName) => {
|
||||||
activeTab.value = tabName.toLowerCase()
|
activeTab.value = tabName.toLowerCase()
|
||||||
}, 300)
|
}, 300)
|
||||||
@ -78,5 +84,5 @@ export function useActiveTabManager(tabs, storageKey) {
|
|||||||
tabIndex.value = getActiveTab()
|
tabIndex.value = getActiveTab()
|
||||||
})
|
})
|
||||||
|
|
||||||
return { tabIndex }
|
return { tabIndex, changeTabTo }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -100,6 +100,11 @@
|
|||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<Tooltip :text="__('Attach a file')">
|
||||||
|
<Button class="size-7" @click="showFilesUploader = true">
|
||||||
|
<AttachmentIcon class="size-4" />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -299,6 +304,18 @@
|
|||||||
doctype="CRM Deal"
|
doctype="CRM Deal"
|
||||||
@reload="() => fieldsLayout.reload()"
|
@reload="() => fieldsLayout.reload()"
|
||||||
/>
|
/>
|
||||||
|
<FilesUploader
|
||||||
|
v-if="deal.data?.name"
|
||||||
|
v-model="showFilesUploader"
|
||||||
|
doctype="CRM Deal"
|
||||||
|
:docname="deal.data.name"
|
||||||
|
@after="
|
||||||
|
() => {
|
||||||
|
activities?.all_activities?.reload()
|
||||||
|
changeTabTo('attachments')
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import Icon from '@/components/Icon.vue'
|
import Icon from '@/components/Icon.vue'
|
||||||
@ -317,10 +334,12 @@ import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
|
|||||||
import LinkIcon from '@/components/Icons/LinkIcon.vue'
|
import LinkIcon from '@/components/Icons/LinkIcon.vue'
|
||||||
import ArrowUpRightIcon from '@/components/Icons/ArrowUpRightIcon.vue'
|
import ArrowUpRightIcon from '@/components/Icons/ArrowUpRightIcon.vue'
|
||||||
import SuccessIcon from '@/components/Icons/SuccessIcon.vue'
|
import SuccessIcon from '@/components/Icons/SuccessIcon.vue'
|
||||||
|
import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue'
|
||||||
import LayoutHeader from '@/components/LayoutHeader.vue'
|
import LayoutHeader from '@/components/LayoutHeader.vue'
|
||||||
import Activities from '@/components/Activities/Activities.vue'
|
import Activities from '@/components/Activities/Activities.vue'
|
||||||
import OrganizationModal from '@/components/Modals/OrganizationModal.vue'
|
import OrganizationModal from '@/components/Modals/OrganizationModal.vue'
|
||||||
import AssignmentModal from '@/components/Modals/AssignmentModal.vue'
|
import AssignmentModal from '@/components/Modals/AssignmentModal.vue'
|
||||||
|
import FilesUploader from '@/components/FilesUploader/FilesUploader.vue'
|
||||||
import MultipleAvatar from '@/components/MultipleAvatar.vue'
|
import MultipleAvatar from '@/components/MultipleAvatar.vue'
|
||||||
import ContactModal from '@/components/Modals/ContactModal.vue'
|
import ContactModal from '@/components/Modals/ContactModal.vue'
|
||||||
import SidePanelModal from '@/components/Settings/SidePanelModal.vue'
|
import SidePanelModal from '@/components/Settings/SidePanelModal.vue'
|
||||||
@ -435,6 +454,7 @@ const reload = ref(false)
|
|||||||
const showOrganizationModal = ref(false)
|
const showOrganizationModal = ref(false)
|
||||||
const showAssignmentModal = ref(false)
|
const showAssignmentModal = ref(false)
|
||||||
const showSidePanelModal = ref(false)
|
const showSidePanelModal = ref(false)
|
||||||
|
const showFilesUploader = ref(false)
|
||||||
const _organization = ref({})
|
const _organization = ref({})
|
||||||
|
|
||||||
function updateDeal(fieldname, value, callback) {
|
function updateDeal(fieldname, value, callback) {
|
||||||
@ -550,6 +570,11 @@ const tabs = computed(() => {
|
|||||||
label: __('Notes'),
|
label: __('Notes'),
|
||||||
icon: NoteIcon,
|
icon: NoteIcon,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Attachments',
|
||||||
|
label: __('Attachments'),
|
||||||
|
icon: AttachmentIcon,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'WhatsApp',
|
name: 'WhatsApp',
|
||||||
label: __('WhatsApp'),
|
label: __('WhatsApp'),
|
||||||
|
|||||||
@ -150,6 +150,11 @@
|
|||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<Tooltip :text="__('Attach a file')">
|
||||||
|
<Button class="h-7 w-7" @click="showFilesUploader = true">
|
||||||
|
<AttachmentIcon class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
<ErrorMessage :message="__(error)" />
|
<ErrorMessage :message="__(error)" />
|
||||||
</div>
|
</div>
|
||||||
@ -272,6 +277,18 @@
|
|||||||
v-model="showSidePanelModal"
|
v-model="showSidePanelModal"
|
||||||
@reload="() => fieldsLayout.reload()"
|
@reload="() => fieldsLayout.reload()"
|
||||||
/>
|
/>
|
||||||
|
<FilesUploader
|
||||||
|
v-if="lead.data?.name"
|
||||||
|
v-model="showFilesUploader"
|
||||||
|
doctype="CRM Lead"
|
||||||
|
:docname="lead.data.name"
|
||||||
|
@after="
|
||||||
|
() => {
|
||||||
|
activities?.all_activities?.reload()
|
||||||
|
changeTabTo('attachments')
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import Icon from '@/components/Icon.vue'
|
import Icon from '@/components/Icon.vue'
|
||||||
@ -290,9 +307,11 @@ import CameraIcon from '@/components/Icons/CameraIcon.vue'
|
|||||||
import LinkIcon from '@/components/Icons/LinkIcon.vue'
|
import LinkIcon from '@/components/Icons/LinkIcon.vue'
|
||||||
import OrganizationsIcon from '@/components/Icons/OrganizationsIcon.vue'
|
import OrganizationsIcon from '@/components/Icons/OrganizationsIcon.vue'
|
||||||
import ContactsIcon from '@/components/Icons/ContactsIcon.vue'
|
import ContactsIcon from '@/components/Icons/ContactsIcon.vue'
|
||||||
|
import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue'
|
||||||
import LayoutHeader from '@/components/LayoutHeader.vue'
|
import LayoutHeader from '@/components/LayoutHeader.vue'
|
||||||
import Activities from '@/components/Activities/Activities.vue'
|
import Activities from '@/components/Activities/Activities.vue'
|
||||||
import AssignmentModal from '@/components/Modals/AssignmentModal.vue'
|
import AssignmentModal from '@/components/Modals/AssignmentModal.vue'
|
||||||
|
import FilesUploader from '@/components/FilesUploader/FilesUploader.vue'
|
||||||
import SidePanelModal from '@/components/Settings/SidePanelModal.vue'
|
import SidePanelModal from '@/components/Settings/SidePanelModal.vue'
|
||||||
import MultipleAvatar from '@/components/MultipleAvatar.vue'
|
import MultipleAvatar from '@/components/MultipleAvatar.vue'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
@ -382,6 +401,7 @@ onMounted(() => {
|
|||||||
const reload = ref(false)
|
const reload = ref(false)
|
||||||
const showAssignmentModal = ref(false)
|
const showAssignmentModal = ref(false)
|
||||||
const showSidePanelModal = ref(false)
|
const showSidePanelModal = ref(false)
|
||||||
|
const showFilesUploader = ref(false)
|
||||||
|
|
||||||
function updateLead(fieldname, value, callback) {
|
function updateLead(fieldname, value, callback) {
|
||||||
value = Array.isArray(fieldname) ? '' : value
|
value = Array.isArray(fieldname) ? '' : value
|
||||||
@ -496,6 +516,11 @@ const tabs = computed(() => {
|
|||||||
label: __('Notes'),
|
label: __('Notes'),
|
||||||
icon: NoteIcon,
|
icon: NoteIcon,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Attachments',
|
||||||
|
label: __('Attachments'),
|
||||||
|
icon: AttachmentIcon,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'WhatsApp',
|
name: 'WhatsApp',
|
||||||
label: __('WhatsApp'),
|
label: __('WhatsApp'),
|
||||||
@ -506,7 +531,7 @@ const tabs = computed(() => {
|
|||||||
return tabOptions.filter((tab) => (tab.condition ? tab.condition() : true))
|
return tabOptions.filter((tab) => (tab.condition ? tab.condition() : true))
|
||||||
})
|
})
|
||||||
|
|
||||||
const { tabIndex } = useActiveTabManager(tabs, 'lastLeadTab')
|
const { tabIndex, changeTabTo } = useActiveTabManager(tabs, 'lastLeadTab')
|
||||||
|
|
||||||
watch(tabs, (value) => {
|
watch(tabs, (value) => {
|
||||||
if (value && route.params.tabName) {
|
if (value && route.params.tabName) {
|
||||||
|
|||||||
@ -254,6 +254,7 @@ import CommentIcon from '@/components/Icons/CommentIcon.vue'
|
|||||||
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
|
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
|
||||||
import TaskIcon from '@/components/Icons/TaskIcon.vue'
|
import TaskIcon from '@/components/Icons/TaskIcon.vue'
|
||||||
import NoteIcon from '@/components/Icons/NoteIcon.vue'
|
import NoteIcon from '@/components/Icons/NoteIcon.vue'
|
||||||
|
import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue'
|
||||||
import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
|
import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
|
||||||
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
|
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
|
||||||
import ArrowUpRightIcon from '@/components/Icons/ArrowUpRightIcon.vue'
|
import ArrowUpRightIcon from '@/components/Icons/ArrowUpRightIcon.vue'
|
||||||
@ -467,6 +468,11 @@ const tabs = computed(() => {
|
|||||||
label: __('Notes'),
|
label: __('Notes'),
|
||||||
icon: NoteIcon,
|
icon: NoteIcon,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Attachments',
|
||||||
|
label: __('Attachments'),
|
||||||
|
icon: AttachmentIcon,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'WhatsApp',
|
name: 'WhatsApp',
|
||||||
label: __('WhatsApp'),
|
label: __('WhatsApp'),
|
||||||
|
|||||||
@ -179,6 +179,7 @@ import CommentIcon from '@/components/Icons/CommentIcon.vue'
|
|||||||
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
|
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
|
||||||
import TaskIcon from '@/components/Icons/TaskIcon.vue'
|
import TaskIcon from '@/components/Icons/TaskIcon.vue'
|
||||||
import NoteIcon from '@/components/Icons/NoteIcon.vue'
|
import NoteIcon from '@/components/Icons/NoteIcon.vue'
|
||||||
|
import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue'
|
||||||
import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
|
import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
|
||||||
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
|
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
|
||||||
import OrganizationsIcon from '@/components/Icons/OrganizationsIcon.vue'
|
import OrganizationsIcon from '@/components/Icons/OrganizationsIcon.vue'
|
||||||
@ -377,6 +378,11 @@ const tabs = computed(() => {
|
|||||||
label: __('Notes'),
|
label: __('Notes'),
|
||||||
icon: NoteIcon,
|
icon: NoteIcon,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Attachments',
|
||||||
|
label: __('Attachments'),
|
||||||
|
icon: AttachmentIcon,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'WhatsApp',
|
name: 'WhatsApp',
|
||||||
label: __('WhatsApp'),
|
label: __('WhatsApp'),
|
||||||
|
|||||||
@ -105,15 +105,15 @@ export const statusesStore = defineStore('crm-statuses', () => {
|
|||||||
let options = []
|
let options = []
|
||||||
for (const status in statusesByName) {
|
for (const status in statusesByName) {
|
||||||
options.push({
|
options.push({
|
||||||
label: statusesByName[status].name,
|
label: statusesByName[status]?.name,
|
||||||
value: statusesByName[status].name,
|
value: statusesByName[status]?.name,
|
||||||
icon: () =>
|
icon: () =>
|
||||||
h(IndicatorIcon, {
|
h(IndicatorIcon, {
|
||||||
class: statusesByName[status].iconColorClass,
|
class: statusesByName[status]?.iconColorClass,
|
||||||
}),
|
}),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
capture('status_changed', { doctype, status })
|
capture('status_changed', { doctype, status })
|
||||||
action && action('status', statusesByName[status].name)
|
action && action('status', statusesByName[status]?.name)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -247,7 +247,7 @@ export function _eval(code, context = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function evaluate_depends_on_value(expression, doc) {
|
export function evaluateDependsOnValue(expression, doc) {
|
||||||
if (!expression) return true
|
if (!expression) return true
|
||||||
if (!doc) return true
|
if (!doc) return true
|
||||||
|
|
||||||
@ -274,3 +274,20 @@ export function evaluate_depends_on_value(expression, doc) {
|
|||||||
|
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function convertSize(size) {
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
let unitIndex = 0
|
||||||
|
while (size > 1024) {
|
||||||
|
size /= 1024
|
||||||
|
unitIndex++
|
||||||
|
}
|
||||||
|
return `${size?.toFixed(2)} ${units[unitIndex]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isImage(extention) {
|
||||||
|
if (!extention) return false
|
||||||
|
return ['png', 'jpg', 'jpeg', 'gif', 'svg', 'bmp', 'webp'].includes(
|
||||||
|
extention.toLowerCase(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user