Merge pull request #414 from shariquerik/files-uploader
This commit is contained in:
commit
c79cb3d52f
@ -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),
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
@ -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
|
||||
@ -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",
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
<ActivityHeader
|
||||
v-model="tabIndex"
|
||||
v-model:showWhatsappTemplates="showWhatsappTemplates"
|
||||
v-model:showFilesUploader="showFilesUploader"
|
||||
:tabs="tabs"
|
||||
:title="title"
|
||||
:doc="doc"
|
||||
@ -66,13 +67,7 @@
|
||||
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"
|
||||
/>
|
||||
<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 +98,15 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="title == 'Attachments'"
|
||||
class="px-3 pb-3 sm:px-10 sm:pb-5 overflow-x-auto sm:w-full w-max"
|
||||
>
|
||||
<AttachmentArea
|
||||
:attachments="activities"
|
||||
@reload="all_activities.reload() && scroll()"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
v-for="(activity, i) in activities"
|
||||
@ -177,6 +181,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 +400,11 @@
|
||||
:label="__('Create Task')"
|
||||
@click="modalRef.showTask()"
|
||||
/>
|
||||
<Button
|
||||
v-else-if="title == 'Attachments'"
|
||||
:label="__('Upload Attachment')"
|
||||
@click="showFilesUploader = true"
|
||||
/>
|
||||
</div>
|
||||
</FadedScrollableDiv>
|
||||
<div>
|
||||
@ -395,6 +438,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 +458,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 +483,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 +533,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 +584,7 @@ const all_activities = createResource({
|
||||
}
|
||||
})
|
||||
}
|
||||
return { versions, calls, notes, tasks }
|
||||
return { versions, calls, notes, tasks, attachments }
|
||||
},
|
||||
})
|
||||
|
||||
@ -584,9 +650,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 +672,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 +696,7 @@ const activities = computed(() => {
|
||||
})
|
||||
}
|
||||
})
|
||||
return sortByCreation(activities)
|
||||
return sortByCreation(_activities)
|
||||
})
|
||||
|
||||
function sortByCreation(list) {
|
||||
@ -667,6 +736,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 +756,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 +782,9 @@ function timelineIcon(activity_type, is_lead) {
|
||||
case 'outgoing_call':
|
||||
icon = OutboundCallIcon
|
||||
break
|
||||
case 'attachment_log':
|
||||
icon = AttachmentIcon
|
||||
break
|
||||
default:
|
||||
icon = DotIcon
|
||||
}
|
||||
@ -744,5 +820,5 @@ function scroll(hash) {
|
||||
}, 500)
|
||||
}
|
||||
|
||||
defineExpose({ emailBox })
|
||||
defineExpose({ emailBox, all_activities })
|
||||
</script>
|
||||
|
||||
@ -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'),
|
||||
|
||||
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,6 +1,6 @@
|
||||
<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)"
|
||||
|
||||
237
frontend/src/components/FilesUploader/FilesUploader.vue
Normal file
237
frontend/src/components/FilesUploader/FilesUploader.vue
Normal file
@ -0,0 +1,237 @@
|
||||
<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="__('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 { 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>
|
||||
388
frontend/src/components/FilesUploader/FilesUploaderArea.vue
Normal file
388
frontend/src/components/FilesUploader/FilesUploaderArea.vue
Normal file
@ -0,0 +1,388 @@
|
||||
<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 py-3"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<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">
|
||||
<div class="text-base text-gray-800">
|
||||
{{ 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')
|
||||
|
||||
async function startCamera() {
|
||||
showCamera.value = true
|
||||
|
||||
let stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
facingMode: facingMode.value,
|
||||
},
|
||||
audio: false,
|
||||
})
|
||||
video.value.srcObject = stream
|
||||
}
|
||||
|
||||
function switchCamera() {
|
||||
facingMode.value = facingMode.value === 'environment' ? 'user' : 'environment'
|
||||
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,
|
||||
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,
|
||||
),
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user