Merge pull request #424 from frappe/develop

This commit is contained in:
Shariq Ansari 2024-10-21 22:10:03 +05:30 committed by GitHub
commit 4f699d486b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 1443 additions and 94 deletions

View File

@ -3,6 +3,7 @@ import frappe
from frappe.translate import get_all_translations
from frappe.utils import validate_email_address, split_emails, cstr
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)
@ -107,3 +108,20 @@ def invite_by_email(emails: str, role: str):
for email in to_invite:
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),
}

View File

@ -1,5 +1,6 @@
import json
from bs4 import BeautifulSoup
import frappe
from frappe import _
from frappe.utils.caching import redis_cache
@ -35,10 +36,11 @@ def get_deal_activities(name):
calls = []
notes = []
tasks = []
attachments = []
creation_text = "created this deal"
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"
activities.append({
@ -131,14 +133,26 @@ def get_deal_activities(name):
}
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)
notes = notes + get_linked_notes(name)
tasks = tasks + get_linked_tasks(name)
attachments = attachments + get_attachments('CRM Deal', name)
activities.sort(key=lambda x: x["creation"], reverse=True)
activities = handle_multiple_versions(activities)
return activities, calls, notes, tasks
return activities, calls, notes, tasks, attachments
def get_lead_activities(name):
get_docinfo('', "CRM Lead", name)
@ -245,22 +259,34 @@ def get_lead_activities(name):
}
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)
notes = get_linked_notes(name)
tasks = get_linked_tasks(name)
attachments = get_attachments('CRM Lead', name)
activities.sort(key=lambda x: x["creation"], reverse=True)
activities = handle_multiple_versions(activities)
return activities, calls, notes, tasks
return activities, calls, notes, tasks, attachments
@redis_cache()
def get_attachments(doctype, name):
return frappe.db.get_all(
"File",
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):
activities = []
@ -342,3 +368,26 @@ def get_linked_tasks(name):
],
)
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,
}

View File

@ -103,7 +103,41 @@ def is_whatsapp_installed():
def get_whatsapp_messages(reference_doctype, reference_name):
if not frappe.db.exists("DocType", "WhatsApp Message"):
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",
filters={
"reference_doctype": reference_doctype,

View File

@ -2,8 +2,8 @@
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests import UnitTestCase
class TestCRMCallLog(FrappeTestCase):
class TestCRMCallLog(UnitTestCase):
pass

View File

@ -2,8 +2,8 @@
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests import UnitTestCase
class TestCRMCommunicationStatus(FrappeTestCase):
class TestCRMCommunicationStatus(UnitTestCase):
pass

View File

@ -2,8 +2,8 @@
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests import UnitTestCase
class TestCRMDeal(FrappeTestCase):
class TestCRMDeal(UnitTestCase):
pass

View File

@ -2,8 +2,8 @@
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests import UnitTestCase
class TestCRMDealStatus(FrappeTestCase):
class TestCRMDealStatus(UnitTestCase):
pass

View File

@ -2,8 +2,8 @@
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests import UnitTestCase
class TestCRMFieldsLayout(FrappeTestCase):
class TestCRMFieldsLayout(UnitTestCase):
pass

View File

@ -2,8 +2,8 @@
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests import UnitTestCase
class TestCRMFormScript(FrappeTestCase):
class TestCRMFormScript(UnitTestCase):
pass

View File

@ -2,8 +2,8 @@
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests import UnitTestCase
class TestCRMHolidayList(FrappeTestCase):
class TestCRMHolidayList(UnitTestCase):
pass

View File

@ -2,8 +2,8 @@
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests import UnitTestCase
class TestCRMIndustry(FrappeTestCase):
class TestCRMIndustry(UnitTestCase):
pass

View File

@ -2,8 +2,8 @@
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests import UnitTestCase
class TestCRMInvitation(FrappeTestCase):
class TestCRMInvitation(UnitTestCase):
pass

View File

@ -2,8 +2,8 @@
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests import UnitTestCase
class TestCRMLead(FrappeTestCase):
class TestCRMLead(UnitTestCase):
pass

View File

@ -2,8 +2,8 @@
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests import UnitTestCase
class TestCRMLeadSource(FrappeTestCase):
class TestCRMLeadSource(UnitTestCase):
pass

View File

@ -2,8 +2,8 @@
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests import UnitTestCase
class TestCRMLeadStatus(FrappeTestCase):
class TestCRMLeadStatus(UnitTestCase):
pass

View File

@ -2,8 +2,8 @@
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests import UnitTestCase
class TestCRMNotification(FrappeTestCase):
class TestCRMNotification(UnitTestCase):
pass

View File

@ -2,8 +2,8 @@
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests import UnitTestCase
class TestCRMOrganization(FrappeTestCase):
class TestCRMOrganization(UnitTestCase):
pass

View File

@ -2,8 +2,8 @@
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests import UnitTestCase
class TestCRMServiceLevelAgreement(FrappeTestCase):
class TestCRMServiceLevelAgreement(UnitTestCase):
pass

View File

@ -2,8 +2,8 @@
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests import UnitTestCase
class TestCRMServiceLevelPriority(FrappeTestCase):
class TestCRMServiceLevelPriority(UnitTestCase):
pass

View File

@ -2,8 +2,8 @@
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests import UnitTestCase
class TestCRMTask(FrappeTestCase):
class TestCRMTask(UnitTestCase):
pass

View File

@ -2,8 +2,8 @@
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests import UnitTestCase
class TestCRMTerritory(FrappeTestCase):
class TestCRMTerritory(UnitTestCase):
pass

View File

@ -2,8 +2,8 @@
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests import UnitTestCase
class TestCRMViewSettings(FrappeTestCase):
class TestCRMViewSettings(UnitTestCase):
pass

View File

@ -2,8 +2,8 @@
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests import UnitTestCase
class TestERPNextCRMSettings(FrappeTestCase):
class TestERPNextCRMSettings(UnitTestCase):
pass

View File

@ -2,8 +2,8 @@
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests import UnitTestCase
class TestFCRMNote(FrappeTestCase):
class TestFCRMNote(UnitTestCase):
pass

View 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);
},
});

View 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": []
}

View 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)

View 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

View File

@ -2,8 +2,8 @@
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests import UnitTestCase
class TestTwilioAgents(FrappeTestCase):
class TestTwilioAgents(UnitTestCase):
pass

View File

@ -2,8 +2,8 @@
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests import UnitTestCase
class TestTwilioSettings(FrappeTestCase):
class TestTwilioSettings(UnitTestCase):
pass

View File

@ -9,11 +9,11 @@ from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
def before_install():
pass
def after_install():
def after_install(force=False):
add_default_lead_statuses()
add_default_deal_statuses()
add_default_communication_statuses()
add_default_fields_layout()
add_default_fields_layout(force)
add_property_setter()
add_email_template_custom_fields()
add_default_industries()
@ -111,7 +111,7 @@ def add_default_communication_statuses():
doc.status = status
doc.insert()
def add_default_fields_layout():
def add_default_fields_layout(force=False):
quick_entry_layouts = {
"CRM Lead-Quick Entry": {
"doctype": "CRM Lead",
@ -148,7 +148,10 @@ def add_default_fields_layout():
for layout in quick_entry_layouts:
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.type = "Quick Entry"
@ -158,7 +161,10 @@ def add_default_fields_layout():
for layout in sidebar_fields_layouts:
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.type = "Side Panel"
@ -217,7 +223,6 @@ def add_default_industries():
def add_default_lead_sources():
lead_sources = ["Existing Customer", "Reference", "Advertisement", "Cold Calling", "Exhibition", "Supplier Reference", "Mass Mailing", "Customer's Vendor", "Campaign", "Walk In"]
for source in lead_sources:

View File

@ -9,11 +9,9 @@ no_cache = 1
def get_context():
csrf_token = frappe.sessions.get_csrf_token()
frappe.db.commit()
context = frappe._dict()
context.boot = get_boot()
context.boot.csrf_token = csrf_token
if frappe.session.user != "Guest":
capture("active_site", "crm")
return context
@ -33,6 +31,7 @@ def get_boot():
"default_route": get_default_route(),
"site_name": frappe.local.site,
"read_only_mode": frappe.flags.read_only,
"csrf_token": frappe.sessions.get_csrf_token(),
}
)

@ -1 +1 @@
Subproject commit 427b76188fe8b20e683bccf9bb4003821253259f
Subproject commit b2dbd41936905aa46b18d3c22e5d09a7b08a9b98

View File

@ -14,7 +14,7 @@
"@vueuse/core": "^10.3.0",
"@vueuse/integrations": "^10.3.0",
"feather-icons": "^4.28.0",
"frappe-ui": "^0.1.70",
"frappe-ui": "^0.1.71",
"gemoji": "^8.1.0",
"lodash": "^4.17.21",
"mime": "^4.0.1",

View File

@ -2,6 +2,7 @@
<ActivityHeader
v-model="tabIndex"
v-model:showWhatsappTemplates="showWhatsappTemplates"
v-model:showFilesUploader="showFilesUploader"
:tabs="tabs"
:title="title"
:doc="doc"
@ -62,17 +63,8 @@
</div>
</div>
</div>
<div
v-else-if="title == 'Tasks'"
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 v-else-if="title == 'Tasks'" class="px-3 pb-3 sm:px-10 sm:pb-5">
<TaskArea :modalRef="modalRef" :tasks="activities" :doctype="doctype" />
</div>
<div v-else-if="title == 'Calls'" class="activity">
<div v-for="(call, i) in activities">
@ -103,6 +95,15 @@
</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
v-else
v-for="(activity, i) in activities"
@ -177,6 +178,40 @@
>
<CommentArea :activity="activity" />
</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
v-else-if="
activity.activity_type == 'incoming_call' ||
@ -362,6 +397,11 @@
:label="__('Create Task')"
@click="modalRef.showTask()"
/>
<Button
v-else-if="title == 'Attachments'"
:label="__('Upload Attachment')"
@click="showFilesUploader = true"
/>
</div>
</FadedScrollableDiv>
<div>
@ -395,6 +435,18 @@
:doctype="doctype"
:doc="doc"
/>
<FilesUploader
v-if="doc.data?.name"
v-model="showFilesUploader"
:doctype="doctype"
:docname="doc.data.name"
@after="
() => {
all_activities.reload()
changeTabTo('attachments')
}
"
/>
</template>
<script setup>
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 NoteArea from '@/components/Activities/NoteArea.vue'
import TaskArea from '@/components/Activities/TaskArea.vue'
import AttachmentArea from '@/components/Activities/AttachmentArea.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import ActivityIcon from '@/components/Icons/ActivityIcon.vue'
import Email2Icon from '@/components/Icons/Email2Icon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import NoteIcon from '@/components/Icons/NoteIcon.vue'
import TaskIcon from '@/components/Icons/TaskIcon.vue'
import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue'
import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
import WhatsAppArea from '@/components/Activities/WhatsAppArea.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 WhatsappTemplateSelectorModal from '@/components/Modals/WhatsappTemplateSelectorModal.vue'
import AllModals from '@/components/Activities/AllModals.vue'
import FilesUploader from '@/components/FilesUploader/FilesUploader.vue'
import {
timeAgo,
dateFormat,
@ -475,15 +530,23 @@ const tabIndex = defineModel('tabIndex')
const reload_email = ref(false)
const modalRef = ref(null)
const showFilesUploader = ref(false)
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({
url: 'crm.api.activities.get_activities',
params: { name: doc.value.data.name },
cache: ['activity', doc.value.data.name],
auto: true,
transform: ([versions, calls, notes, tasks]) => {
transform: ([versions, calls, notes, tasks, attachments]) => {
if (calls?.length) {
calls.forEach((doc) => {
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(() => {
let activities = []
let _activities = []
if (title.value == 'Activity') {
activities = get_activities()
_activities = get_activities()
} else if (title.value == 'Emails') {
if (!all_activities.data?.versions) return []
activities = all_activities.data.versions.filter(
@ -606,9 +669,12 @@ const activities = computed(() => {
} else if (title.value == 'Notes') {
if (!all_activities.data?.notes) return []
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)
if (
@ -627,7 +693,7 @@ const activities = computed(() => {
})
}
})
return sortByCreation(activities)
return sortByCreation(_activities)
})
function sortByCreation(list) {
@ -667,6 +733,8 @@ const emptyText = computed(() => {
text = 'No Notes'
} else if (title.value == 'Tasks') {
text = 'No Tasks'
} else if (title.value == 'Attachments') {
text = 'No Attachments'
} else if (title.value == 'WhatsApp') {
text = 'No WhatsApp Messages'
}
@ -685,6 +753,8 @@ const emptyTextIcon = computed(() => {
icon = NoteIcon
} else if (title.value == 'Tasks') {
icon = TaskIcon
} else if (title.value == 'Attachments') {
icon = AttachmentIcon
} else if (title.value == 'WhatsApp') {
icon = WhatsAppIcon
}
@ -709,6 +779,9 @@ function timelineIcon(activity_type, is_lead) {
case 'outgoing_call':
icon = OutboundCallIcon
break
case 'attachment_log':
icon = AttachmentIcon
break
default:
icon = DotIcon
}
@ -744,5 +817,5 @@ function scroll(hash) {
}, 500)
}
defineExpose({ emailBox })
defineExpose({ emailBox, all_activities })
</script>

View File

@ -55,6 +55,16 @@
</template>
<span>{{ __('New Task') }}</span>
</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'">
<Button
:label="__('Send Template')"
@ -91,6 +101,7 @@ import CommentIcon from '@/components/Icons/CommentIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import NoteIcon from '@/components/Icons/NoteIcon.vue'
import TaskIcon from '@/components/Icons/TaskIcon.vue'
import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue'
import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
import { globalStore } from '@/stores/global'
import { whatsappEnabled, callEnabled } from '@/composables/settings'
@ -110,6 +121,7 @@ const { makeCall } = globalStore()
const tabIndex = defineModel()
const showWhatsappTemplates = defineModel('showWhatsappTemplates')
const showFilesUploader = defineModel('showFilesUploader')
const defaultActions = computed(() => {
let actions = [
@ -139,6 +151,11 @@ const defaultActions = computed(() => {
label: __('New Task'),
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' }),
label: __('New WhatsApp Message'),

View 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>

View File

@ -1,12 +1,12 @@
<template>
<div v-if="tasks.length">
<div v-for="(task, i) in tasks">
<div v-for="(task, i) in tasks" :key="task.name">
<div
class="activity flex cursor-pointer gap-6 rounded p-2.5 duration-300 ease-in-out hover:bg-gray-50"
@click="modalRef.showTask(task)"
>
<div class="flex flex-1 flex-col gap-1.5 text-base">
<div class="font-medium text-gray-900">
<div class="flex flex-1 flex-col gap-1.5 text-base truncate">
<div class="font-medium text-gray-900 truncate">
{{ task.title }}
</div>
<div class="flex gap-1.5 text-gray-800">

View 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>

View 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>

View 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

View 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>

View 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>

View File

@ -39,7 +39,7 @@ import {
Badge,
ErrorMessage,
} from 'frappe-ui'
import { evaluate_depends_on_value, createToast } from '@/utils'
import { evaluateDependsOnValue, createToast } from '@/utils'
import { ref, computed } from 'vue'
const props = defineProps({
@ -123,11 +123,11 @@ const sections = computed(() => {
_sections[_sections.length - 1].fields.push({
...field,
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,
data.doc,
),
mandatory_via_depends_on: evaluate_depends_on_value(
mandatory_via_depends_on: evaluateDependsOnValue(
field.mandatory_depends_on,
data.doc,
),

View File

@ -7,6 +7,12 @@ export function useActiveTabManager(tabs, storageKey) {
const route = useRoute()
const router = useRouter()
const changeTabTo = (tabName) => {
let index = findTabIndex(tabName)
if (index == -1) return
tabIndex.value = index
}
const preserveLastVisitedTab = useDebounceFn((tabName) => {
activeTab.value = tabName.toLowerCase()
}, 300)
@ -78,5 +84,5 @@ export function useActiveTabManager(tabs, storageKey) {
tabIndex.value = getActiveTab()
})
return { tabIndex }
return { tabIndex, changeTabTo }
}

View File

@ -100,6 +100,11 @@
/>
</Button>
</Tooltip>
<Tooltip :text="__('Attach a file')">
<Button class="size-7" @click="showFilesUploader = true">
<AttachmentIcon class="size-4" />
</Button>
</Tooltip>
</div>
</div>
</div>
@ -299,6 +304,18 @@
doctype="CRM Deal"
@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>
<script setup>
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 ArrowUpRightIcon from '@/components/Icons/ArrowUpRightIcon.vue'
import SuccessIcon from '@/components/Icons/SuccessIcon.vue'
import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue'
import LayoutHeader from '@/components/LayoutHeader.vue'
import Activities from '@/components/Activities/Activities.vue'
import OrganizationModal from '@/components/Modals/OrganizationModal.vue'
import AssignmentModal from '@/components/Modals/AssignmentModal.vue'
import FilesUploader from '@/components/FilesUploader/FilesUploader.vue'
import MultipleAvatar from '@/components/MultipleAvatar.vue'
import ContactModal from '@/components/Modals/ContactModal.vue'
import SidePanelModal from '@/components/Settings/SidePanelModal.vue'
@ -435,6 +454,7 @@ const reload = ref(false)
const showOrganizationModal = ref(false)
const showAssignmentModal = ref(false)
const showSidePanelModal = ref(false)
const showFilesUploader = ref(false)
const _organization = ref({})
function updateDeal(fieldname, value, callback) {
@ -550,6 +570,11 @@ const tabs = computed(() => {
label: __('Notes'),
icon: NoteIcon,
},
{
name: 'Attachments',
label: __('Attachments'),
icon: AttachmentIcon,
},
{
name: 'WhatsApp',
label: __('WhatsApp'),

View File

@ -150,6 +150,11 @@
/>
</Button>
</Tooltip>
<Tooltip :text="__('Attach a file')">
<Button class="h-7 w-7" @click="showFilesUploader = true">
<AttachmentIcon class="h-4 w-4" />
</Button>
</Tooltip>
</div>
<ErrorMessage :message="__(error)" />
</div>
@ -272,6 +277,18 @@
v-model="showSidePanelModal"
@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>
<script setup>
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 OrganizationsIcon from '@/components/Icons/OrganizationsIcon.vue'
import ContactsIcon from '@/components/Icons/ContactsIcon.vue'
import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue'
import LayoutHeader from '@/components/LayoutHeader.vue'
import Activities from '@/components/Activities/Activities.vue'
import AssignmentModal from '@/components/Modals/AssignmentModal.vue'
import FilesUploader from '@/components/FilesUploader/FilesUploader.vue'
import SidePanelModal from '@/components/Settings/SidePanelModal.vue'
import MultipleAvatar from '@/components/MultipleAvatar.vue'
import Link from '@/components/Controls/Link.vue'
@ -382,6 +401,7 @@ onMounted(() => {
const reload = ref(false)
const showAssignmentModal = ref(false)
const showSidePanelModal = ref(false)
const showFilesUploader = ref(false)
function updateLead(fieldname, value, callback) {
value = Array.isArray(fieldname) ? '' : value
@ -496,6 +516,11 @@ const tabs = computed(() => {
label: __('Notes'),
icon: NoteIcon,
},
{
name: 'Attachments',
label: __('Attachments'),
icon: AttachmentIcon,
},
{
name: 'WhatsApp',
label: __('WhatsApp'),
@ -506,7 +531,7 @@ const tabs = computed(() => {
return tabOptions.filter((tab) => (tab.condition ? tab.condition() : true))
})
const { tabIndex } = useActiveTabManager(tabs, 'lastLeadTab')
const { tabIndex, changeTabTo } = useActiveTabManager(tabs, 'lastLeadTab')
watch(tabs, (value) => {
if (value && route.params.tabName) {

View File

@ -254,6 +254,7 @@ import CommentIcon from '@/components/Icons/CommentIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import TaskIcon from '@/components/Icons/TaskIcon.vue'
import NoteIcon from '@/components/Icons/NoteIcon.vue'
import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue'
import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
import ArrowUpRightIcon from '@/components/Icons/ArrowUpRightIcon.vue'
@ -467,6 +468,11 @@ const tabs = computed(() => {
label: __('Notes'),
icon: NoteIcon,
},
{
name: 'Attachments',
label: __('Attachments'),
icon: AttachmentIcon,
},
{
name: 'WhatsApp',
label: __('WhatsApp'),

View File

@ -179,6 +179,7 @@ import CommentIcon from '@/components/Icons/CommentIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import TaskIcon from '@/components/Icons/TaskIcon.vue'
import NoteIcon from '@/components/Icons/NoteIcon.vue'
import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue'
import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
import OrganizationsIcon from '@/components/Icons/OrganizationsIcon.vue'
@ -377,6 +378,11 @@ const tabs = computed(() => {
label: __('Notes'),
icon: NoteIcon,
},
{
name: 'Attachments',
label: __('Attachments'),
icon: AttachmentIcon,
},
{
name: 'WhatsApp',
label: __('WhatsApp'),

View File

@ -105,15 +105,15 @@ export const statusesStore = defineStore('crm-statuses', () => {
let options = []
for (const status in statusesByName) {
options.push({
label: statusesByName[status].name,
value: statusesByName[status].name,
label: statusesByName[status]?.name,
value: statusesByName[status]?.name,
icon: () =>
h(IndicatorIcon, {
class: statusesByName[status].iconColorClass,
class: statusesByName[status]?.iconColorClass,
}),
onClick: () => {
capture('status_changed', { doctype, status })
action && action('status', statusesByName[status].name)
action && action('status', statusesByName[status]?.name)
},
})
}

View File

@ -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 (!doc) return true
@ -274,3 +274,20 @@ export function evaluate_depends_on_value(expression, doc) {
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(),
)
}