Merge pull request #939 from frappe/main-hotfix
This commit is contained in:
commit
300a996ce5
@ -71,7 +71,7 @@ def check_app_permission():
|
||||
|
||||
roles = frappe.get_roles()
|
||||
if any(
|
||||
role in ["System Manager", "Sales User", "Sales Manager", "Sales Master Manager"] for role in roles
|
||||
role in ["System Manager", "Sales User", "Sales Manager"] for role in roles
|
||||
):
|
||||
return True
|
||||
|
||||
@ -99,7 +99,11 @@ def accept_invitation(key: str | None = None):
|
||||
|
||||
@frappe.whitelist()
|
||||
def invite_by_email(emails: str, role: str):
|
||||
frappe.only_for("Sales Manager")
|
||||
frappe.only_for(["Sales Manager", "System Manager"])
|
||||
|
||||
if role not in ["System Manager", "Sales Manager", "Sales User"]:
|
||||
frappe.throw("Cannot invite for this role")
|
||||
|
||||
if not emails:
|
||||
return
|
||||
email_string = validate_email_address(emails, throw=False)
|
||||
@ -109,7 +113,10 @@ def invite_by_email(emails: str, role: str):
|
||||
existing_members = frappe.db.get_all("User", filters={"email": ["in", email_list]}, pluck="email")
|
||||
existing_invites = frappe.db.get_all(
|
||||
"CRM Invitation",
|
||||
filters={"email": ["in", email_list], "role": ["in", ["Sales Manager", "Sales User"]]},
|
||||
filters={
|
||||
"email": ["in", email_list],
|
||||
"role": ["in", ["System Manager", "Sales Manager", "Sales User"]],
|
||||
},
|
||||
pluck="email",
|
||||
)
|
||||
|
||||
|
||||
@ -23,11 +23,35 @@ def get_users():
|
||||
if frappe.session.user == user.name:
|
||||
user.session_user = True
|
||||
|
||||
user.is_manager = "Sales Manager" in frappe.get_roles(user.name) or user.name == "Administrator"
|
||||
user.is_manager = "Sales Manager" in frappe.get_roles(user.name)
|
||||
user.is_admin = user.name == "Administrator"
|
||||
|
||||
user.roles = frappe.get_roles(user.name)
|
||||
|
||||
user.role = ""
|
||||
|
||||
if "System Manager" in user.roles:
|
||||
user.role = "System Manager"
|
||||
elif "Sales Manager" in user.roles:
|
||||
user.role = "Sales Manager"
|
||||
elif "Sales User" in user.roles:
|
||||
user.role = "Sales User"
|
||||
elif "Guest" in user.roles:
|
||||
user.role = "Guest"
|
||||
|
||||
if frappe.session.user == user.name:
|
||||
user.session_user = True
|
||||
|
||||
user.is_agent = frappe.db.exists("CRM Telephony Agent", {"user": user.name})
|
||||
|
||||
return users
|
||||
crm_users = []
|
||||
|
||||
# crm users are users with role Sales User or Sales Manager
|
||||
for user in users:
|
||||
if "Sales User" in user.roles or "Sales Manager" in user.roles:
|
||||
crm_users.append(user)
|
||||
|
||||
return users, crm_users
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
84
crm/api/user.py
Normal file
84
crm/api/user.py
Normal file
@ -0,0 +1,84 @@
|
||||
import frappe
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def add_existing_users(users, role="Sales User"):
|
||||
"""
|
||||
Add existing users to the CRM by assigning them a role (Sales User or Sales Manager).
|
||||
:param users: List of user names to be added
|
||||
"""
|
||||
frappe.only_for(["System Manager", "Sales Manager"])
|
||||
users = frappe.parse_json(users)
|
||||
|
||||
for user in users:
|
||||
add_user(user, role)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def update_user_role(user, new_role):
|
||||
"""
|
||||
Update the role of the user to Sales Manager, Sales User, or System Manager.
|
||||
:param user: The name of the user
|
||||
:param new_role: The new role to assign (Sales Manager or Sales User)
|
||||
"""
|
||||
|
||||
frappe.only_for(["System Manager", "Sales Manager"])
|
||||
|
||||
if new_role not in ["System Manager", "Sales Manager", "Sales User"]:
|
||||
frappe.throw("Cannot assign this role")
|
||||
|
||||
user_doc = frappe.get_doc("User", user)
|
||||
|
||||
if new_role == "System Manager":
|
||||
user_doc.append_roles("System Manager", "Sales Manager", "Sales User")
|
||||
user_doc.set("block_modules", [])
|
||||
if new_role == "Sales Manager":
|
||||
user_doc.append_roles("Sales Manager", "Sales User")
|
||||
user_doc.remove_roles("System Manager")
|
||||
if new_role == "Sales User":
|
||||
user_doc.append_roles("Sales User")
|
||||
user_doc.remove_roles("Sales Manager", "System Manager")
|
||||
update_module_in_user(user_doc, "FCRM")
|
||||
|
||||
user_doc.save(ignore_permissions=True)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def add_user(user, role):
|
||||
"""
|
||||
Add a user means adding role (Sales User or/and Sales Manager) to the user.
|
||||
:param user: The name of the user to be added
|
||||
:param role: The role to be assigned (Sales User or Sales Manager)
|
||||
"""
|
||||
update_user_role(user, role)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def remove_user(user):
|
||||
"""
|
||||
Remove a user means removing Sales User & Sales Manager roles from the user.
|
||||
:param user: The name of the user to be removed
|
||||
"""
|
||||
frappe.only_for(["System Manager", "Sales Manager"])
|
||||
|
||||
user_doc = frappe.get_doc("User", user)
|
||||
roles = [d.role for d in user_doc.roles]
|
||||
|
||||
if "Sales User" in roles:
|
||||
user_doc.remove_roles("Sales User")
|
||||
if "Sales Manager" in roles:
|
||||
user_doc.remove_roles("Sales Manager")
|
||||
|
||||
user_doc.save(ignore_permissions=True)
|
||||
frappe.msgprint(f"User {user} has been removed from CRM roles.")
|
||||
|
||||
|
||||
def update_module_in_user(user, module):
|
||||
block_modules = frappe.get_all(
|
||||
"Module Def",
|
||||
fields=["name as module"],
|
||||
filters={"name": ["!=", module]},
|
||||
)
|
||||
|
||||
if block_modules:
|
||||
user.set("block_modules", block_modules)
|
||||
@ -335,5 +335,5 @@ def get_from_name(message):
|
||||
else:
|
||||
from_name = doc.get("lead_name")
|
||||
else:
|
||||
from_name = doc.get("first_name") + " " + doc.get("last_name")
|
||||
from_name = " ".join(filter(None, [doc.get("first_name"), doc.get("last_name")]))
|
||||
return from_name
|
||||
|
||||
@ -11,11 +11,14 @@
|
||||
"naming_series",
|
||||
"organization",
|
||||
"next_step",
|
||||
"probability",
|
||||
"column_break_ijan",
|
||||
"status",
|
||||
"close_date",
|
||||
"deal_owner",
|
||||
"section_break_jgpm",
|
||||
"probability",
|
||||
"deal_value",
|
||||
"column_break_kpxa",
|
||||
"close_date",
|
||||
"contacts_tab",
|
||||
"contacts",
|
||||
"contact",
|
||||
@ -374,12 +377,26 @@
|
||||
"label": "Net Total",
|
||||
"options": "currency",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_jgpm",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "deal_value",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Deal Value",
|
||||
"options": "currency"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_kpxa",
|
||||
"fieldtype": "Column Break"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-05-12 12:30:55.415282",
|
||||
"modified": "2025-06-16 11:42:49.413483",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Deal",
|
||||
|
||||
@ -24,6 +24,7 @@ class CRMDeal(Document):
|
||||
self.assign_agent(self.deal_owner)
|
||||
if self.has_value_changed("status"):
|
||||
add_status_change_log(self)
|
||||
self.update_close_date()
|
||||
|
||||
def after_insert(self):
|
||||
if self.deal_owner:
|
||||
@ -133,6 +134,13 @@ class CRMDeal(Document):
|
||||
if sla:
|
||||
sla.apply(self)
|
||||
|
||||
def update_close_date(self):
|
||||
"""
|
||||
Update the close date based on the "Won" status.
|
||||
"""
|
||||
if self.status == "Won" and not self.close_date:
|
||||
self.close_date = frappe.utils.nowdate()
|
||||
|
||||
@staticmethod
|
||||
def default_list_data():
|
||||
columns = [
|
||||
|
||||
@ -8,7 +8,8 @@
|
||||
"field_order": [
|
||||
"deal_status",
|
||||
"color",
|
||||
"position"
|
||||
"position",
|
||||
"probability"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@ -32,11 +33,17 @@
|
||||
"fieldtype": "Int",
|
||||
"in_list_view": 1,
|
||||
"label": "Position"
|
||||
},
|
||||
{
|
||||
"fieldname": "probability",
|
||||
"fieldtype": "Percent",
|
||||
"label": "Probability"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-01-19 21:56:44.552134",
|
||||
"modified": "2025-06-11 13:00:34.518808",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Deal Status",
|
||||
@ -68,7 +75,8 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,7 +27,7 @@
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Role",
|
||||
"options": "\nSales User\nSales Manager",
|
||||
"options": "\nSales User\nSales Manager\nSystem Manager",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
@ -66,7 +66,7 @@
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-09-03 14:59:29.450018",
|
||||
"modified": "2025-06-17 17:20:18.935395",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Invitation",
|
||||
@ -106,7 +106,8 @@
|
||||
"share": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,7 +35,7 @@ class CRMInvitation(Document):
|
||||
|
||||
@frappe.whitelist()
|
||||
def accept_invitation(self):
|
||||
frappe.only_for("System Manager")
|
||||
frappe.only_for(["System Manager", "Sales Manager"])
|
||||
self.accept()
|
||||
|
||||
def accept(self):
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
"field_order": [
|
||||
"defaults_tab",
|
||||
"restore_defaults",
|
||||
"enable_forecasting",
|
||||
"branding_tab",
|
||||
"brand_name",
|
||||
"brand_logo",
|
||||
@ -28,7 +29,7 @@
|
||||
{
|
||||
"fieldname": "defaults_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Defaults"
|
||||
"label": "Settings"
|
||||
},
|
||||
{
|
||||
"fieldname": "branding_tab",
|
||||
@ -56,12 +57,19 @@
|
||||
"fieldname": "favicon",
|
||||
"fieldtype": "Attach",
|
||||
"label": "Favicon"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "It will make deal's \"Expected Closure Date\" mandatory to get accurate forecasting insights",
|
||||
"fieldname": "enable_forecasting",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enable Forecasting"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2025-02-20 12:38:38.088477",
|
||||
"modified": "2025-06-11 19:12:16.762499",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "FCRM Settings",
|
||||
@ -95,7 +103,8 @@
|
||||
"share": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.custom.doctype.property_setter.property_setter import delete_property_setter, make_property_setter
|
||||
from frappe.model.document import Document
|
||||
|
||||
from crm.install import after_install
|
||||
@ -15,6 +16,7 @@ class FCRMSettings(Document):
|
||||
|
||||
def validate(self):
|
||||
self.do_not_allow_to_delete_if_standard()
|
||||
self.setup_forecasting()
|
||||
|
||||
def do_not_allow_to_delete_if_standard(self):
|
||||
if not self.has_value_changed("dropdown_items"):
|
||||
@ -29,6 +31,23 @@ class FCRMSettings(Document):
|
||||
return
|
||||
frappe.throw(_("Cannot delete standard items {0}").format(", ".join(deleted_standard_items)))
|
||||
|
||||
def setup_forecasting(self):
|
||||
if self.has_value_changed("enable_forecasting"):
|
||||
if not self.enable_forecasting:
|
||||
delete_property_setter(
|
||||
"CRM Deal",
|
||||
"reqd",
|
||||
"close_date",
|
||||
)
|
||||
else:
|
||||
make_property_setter(
|
||||
"CRM Deal",
|
||||
"close_date",
|
||||
"reqd",
|
||||
1 if self.enable_forecasting else 0,
|
||||
"Check",
|
||||
)
|
||||
|
||||
|
||||
def get_standard_dropdown_items():
|
||||
return [item.get("name1") for item in frappe.get_hooks("standard_dropdown_items")]
|
||||
@ -57,3 +76,36 @@ def sync_table(key, hook):
|
||||
crm_settings.set(key, items)
|
||||
|
||||
crm_settings.save()
|
||||
|
||||
|
||||
def create_forecasting_script():
|
||||
if not frappe.db.exists("CRM Form Script", "Forecasting Script"):
|
||||
script = get_forecasting_script()
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "CRM Form Script",
|
||||
"name": "Forecasting Script",
|
||||
"dt": "CRM Deal",
|
||||
"view": "Form",
|
||||
"script": script,
|
||||
"enabled": 1,
|
||||
"is_standard": 1,
|
||||
}
|
||||
).insert()
|
||||
|
||||
|
||||
def get_forecasting_script():
|
||||
return """class CRMDeal {
|
||||
async status() {
|
||||
await this.doc.trigger('updateProbability')
|
||||
}
|
||||
async updateProbability() {
|
||||
let status = await call("frappe.client.get_value", {
|
||||
doctype: "CRM Deal Status",
|
||||
fieldname: "probability",
|
||||
filters: { name: this.doc.status },
|
||||
})
|
||||
|
||||
this.doc.probability = status.probability
|
||||
}
|
||||
}"""
|
||||
|
||||
@ -359,5 +359,8 @@ def add_standard_dropdown_items():
|
||||
|
||||
|
||||
def add_default_scripts():
|
||||
from crm.fcrm.doctype.fcrm_settings.fcrm_settings import create_forecasting_script
|
||||
|
||||
for doctype in ["CRM Lead", "CRM Deal"]:
|
||||
create_product_details_script(doctype)
|
||||
create_forecasting_script()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -12,4 +12,4 @@ crm.patches.v1_0.create_default_sidebar_fields_layout
|
||||
crm.patches.v1_0.update_deal_quick_entry_layout
|
||||
crm.patches.v1_0.update_layouts_to_new_format
|
||||
crm.patches.v1_0.move_twilio_agent_to_telephony_agent
|
||||
crm.patches.v1_0.create_default_scripts
|
||||
crm.patches.v1_0.create_default_scripts # 13-06-2025
|
||||
@ -1 +1 @@
|
||||
Subproject commit 8b615c0e899d75b99c7d36ec6df97b5d0386b2ca
|
||||
Subproject commit 883bb643d1e662d6467925927e347dd28376960f
|
||||
8
frontend/components.d.ts
vendored
8
frontend/components.d.ts
vendored
@ -12,6 +12,7 @@ declare module 'vue' {
|
||||
Activities: typeof import('./src/components/Activities/Activities.vue')['default']
|
||||
ActivityHeader: typeof import('./src/components/Activities/ActivityHeader.vue')['default']
|
||||
ActivityIcon: typeof import('./src/components/Icons/ActivityIcon.vue')['default']
|
||||
AddExistingUserModal: typeof import('./src/components/Modals/AddExistingUserModal.vue')['default']
|
||||
AddressIcon: typeof import('./src/components/Icons/AddressIcon.vue')['default']
|
||||
AddressModal: typeof import('./src/components/Modals/AddressModal.vue')['default']
|
||||
AllModals: typeof import('./src/components/Activities/AllModals.vue')['default']
|
||||
@ -38,6 +39,7 @@ declare module 'vue' {
|
||||
CallUI: typeof import('./src/components/Telephony/CallUI.vue')['default']
|
||||
CameraIcon: typeof import('./src/components/Icons/CameraIcon.vue')['default']
|
||||
CertificateIcon: typeof import('./src/components/Icons/CertificateIcon.vue')['default']
|
||||
ChangePasswordModal: typeof import('./src/components/Modals/ChangePasswordModal.vue')['default']
|
||||
CheckCircleIcon: typeof import('./src/components/Icons/CheckCircleIcon.vue')['default']
|
||||
CheckIcon: typeof import('./src/components/Icons/CheckIcon.vue')['default']
|
||||
CollapseSidebar: typeof import('./src/components/Icons/CollapseSidebar.vue')['default']
|
||||
@ -136,10 +138,11 @@ declare module 'vue' {
|
||||
InboxIcon: typeof import('./src/components/Icons/InboxIcon.vue')['default']
|
||||
IndicatorIcon: typeof import('./src/components/Icons/IndicatorIcon.vue')['default']
|
||||
InviteIcon: typeof import('./src/components/Icons/InviteIcon.vue')['default']
|
||||
InviteMemberPage: typeof import('./src/components/Settings/InviteMemberPage.vue')['default']
|
||||
InviteUserPage: typeof import('./src/components/Settings/InviteUserPage.vue')['default']
|
||||
KanbanIcon: typeof import('./src/components/Icons/KanbanIcon.vue')['default']
|
||||
KanbanSettings: typeof import('./src/components/Kanban/KanbanSettings.vue')['default']
|
||||
KanbanView: typeof import('./src/components/Kanban/KanbanView.vue')['default']
|
||||
KeyboardShortcut: typeof import('./src/components/KeyboardShortcut.vue')['default']
|
||||
LayoutHeader: typeof import('./src/components/LayoutHeader.vue')['default']
|
||||
LeadModal: typeof import('./src/components/Modals/LeadModal.vue')['default']
|
||||
LeadsIcon: typeof import('./src/components/Icons/LeadsIcon.vue')['default']
|
||||
@ -151,7 +154,6 @@ declare module 'vue' {
|
||||
ListIcon: typeof import('./src/components/Icons/ListIcon.vue')['default']
|
||||
ListRows: typeof import('./src/components/ListViews/ListRows.vue')['default']
|
||||
LoadingIndicator: typeof import('./src/components/Icons/LoadingIndicator.vue')['default']
|
||||
LucideCalendar: typeof import('~icons/lucide/calendar')['default']
|
||||
LucideInfo: typeof import('~icons/lucide/info')['default']
|
||||
LucidePlus: typeof import('~icons/lucide/plus')['default']
|
||||
LucideSearch: typeof import('~icons/lucide/search')['default']
|
||||
@ -167,6 +169,7 @@ declare module 'vue' {
|
||||
MultiActionButton: typeof import('./src/components/MultiActionButton.vue')['default']
|
||||
MultipleAvatar: typeof import('./src/components/MultipleAvatar.vue')['default']
|
||||
MultiSelectEmailInput: typeof import('./src/components/Controls/MultiSelectEmailInput.vue')['default']
|
||||
MultiSelectUserInput: typeof import('./src/components/Controls/MultiSelectUserInput.vue')['default']
|
||||
MuteIcon: typeof import('./src/components/Icons/MuteIcon.vue')['default']
|
||||
NestedPopover: typeof import('./src/components/NestedPopover.vue')['default']
|
||||
NoteArea: typeof import('./src/components/Activities/NoteArea.vue')['default']
|
||||
@ -231,6 +234,7 @@ declare module 'vue' {
|
||||
UnpinIcon: typeof import('./src/components/Icons/UnpinIcon.vue')['default']
|
||||
UserAvatar: typeof import('./src/components/UserAvatar.vue')['default']
|
||||
UserDropdown: typeof import('./src/components/UserDropdown.vue')['default']
|
||||
Users: typeof import('./src/components/Settings/Users.vue')['default']
|
||||
ViewBreadcrumbs: typeof import('./src/components/ViewBreadcrumbs.vue')['default']
|
||||
ViewControls: typeof import('./src/components/ViewControls.vue')['default']
|
||||
ViewModal: typeof import('./src/components/Modals/ViewModal.vue')['default']
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
"@tiptap/extension-paragraph": "^2.12.0",
|
||||
"@twilio/voice-sdk": "^2.10.2",
|
||||
"@vueuse/integrations": "^10.3.0",
|
||||
"frappe-ui": "^0.1.145",
|
||||
"frappe-ui": "^0.1.156",
|
||||
"gemoji": "^8.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"mime": "^4.0.1",
|
||||
|
||||
@ -806,5 +806,5 @@ const callActions = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
defineExpose({ emailBox, all_activities })
|
||||
defineExpose({ emailBox, all_activities, changeTabTo })
|
||||
</script>
|
||||
|
||||
@ -16,7 +16,9 @@
|
||||
v-if="isManager() && !isMobileView"
|
||||
@click="showDataFieldsModal = true"
|
||||
>
|
||||
<EditIcon class="h-4 w-4" />
|
||||
<template #icon>
|
||||
<EditIcon />
|
||||
</template>
|
||||
</Button>
|
||||
<Button
|
||||
label="Save"
|
||||
@ -29,7 +31,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="document.get.loading"
|
||||
class="flex flex-1 flex-col items-center justify-center gap-3 text-xl font-medium text-gray-500"
|
||||
class="flex flex-1 flex-col items-center justify-center gap-3 text-xl font-medium text-ink-gray-6"
|
||||
>
|
||||
<LoadingIndicator class="h-6 w-6" />
|
||||
<span>{{ __('Loading...') }}</span>
|
||||
|
||||
@ -30,20 +30,24 @@
|
||||
<DragIcon class="h-3.5" />
|
||||
<div>{{ __(element.label) }}</div>
|
||||
</div>
|
||||
<div class="flex cursor-pointer items-center gap-1">
|
||||
<div class="flex cursor-pointer items-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="!h-5 w-5 !p-1"
|
||||
@click="editColumn(element)"
|
||||
>
|
||||
<EditIcon class="h-3.5" />
|
||||
<template #icon>
|
||||
<EditIcon class="h-3.5" />
|
||||
</template>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="!h-5 w-5 !p-1"
|
||||
@click="removeColumn(element)"
|
||||
>
|
||||
<FeatherIcon name="x" class="h-3.5" />
|
||||
<template #icon>
|
||||
<FeatherIcon name="x" class="h-3.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -215,7 +219,9 @@ const fields = computed(() => {
|
||||
})
|
||||
|
||||
function addColumn(c) {
|
||||
let align = ['Float', 'Int', 'Percent', 'Currency'].includes(c.type) ? 'right' : 'left'
|
||||
let align = ['Float', 'Int', 'Percent', 'Currency'].includes(c.type)
|
||||
? 'right'
|
||||
: 'left'
|
||||
let _column = {
|
||||
label: c.label,
|
||||
type: c.fieldtype,
|
||||
|
||||
@ -149,7 +149,7 @@ function removeAttachment(attachment) {
|
||||
|
||||
const users = computed(() => {
|
||||
return (
|
||||
usersList.data
|
||||
usersList.data?.crmUsers
|
||||
?.filter((user) => user.enabled)
|
||||
.map((user) => ({
|
||||
label: user.full_name.trimEnd(),
|
||||
|
||||
@ -58,7 +58,9 @@
|
||||
variant="outline"
|
||||
@click="showGridFieldsEditorModal = true"
|
||||
>
|
||||
<FeatherIcon name="settings" class="h-4 w-4 text-ink-gray-7" />
|
||||
<template #icon>
|
||||
<FeatherIcon name="settings" class="size-4 text-ink-gray-7" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -281,7 +283,9 @@
|
||||
variant="outline"
|
||||
@click="showRowList[index] = true"
|
||||
>
|
||||
<EditIcon class="h-4 w-4 text-ink-gray-7" />
|
||||
<template #icon>
|
||||
<EditIcon class="text-ink-gray-7" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<GridRowModal
|
||||
@ -376,9 +380,9 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const triggerOnChange = inject('triggerOnChange')
|
||||
const triggerOnRowAdd = inject('triggerOnRowAdd')
|
||||
const triggerOnRowRemove = inject('triggerOnRowRemove')
|
||||
const triggerOnChange = inject('triggerOnChange', () => {})
|
||||
const triggerOnRowAdd = inject('triggerOnRowAdd', () => {})
|
||||
const triggerOnRowRemove = inject('triggerOnRowRemove', () => {})
|
||||
|
||||
const {
|
||||
getGridViewSettings,
|
||||
@ -389,7 +393,7 @@ const {
|
||||
getGridSettings,
|
||||
} = getMeta(props.doctype)
|
||||
getMeta(props.parentDoctype)
|
||||
const { getUser } = usersStore()
|
||||
const { users, getUser } = usersStore()
|
||||
|
||||
const rows = defineModel()
|
||||
const parentDoc = defineModel('parent')
|
||||
@ -434,6 +438,14 @@ function getFieldObj(field) {
|
||||
}
|
||||
}
|
||||
|
||||
if (field.fieldtype === 'Link' && field.options === 'User') {
|
||||
field.fieldtype = 'User'
|
||||
field.link_filters = JSON.stringify({
|
||||
...(field.link_filters ? JSON.parse(field.link_filters) : {}),
|
||||
name: ['in', users.data.crmUsers?.map((user) => user.name)],
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...field,
|
||||
filters: field.link_filters && JSON.parse(field.link_filters),
|
||||
@ -509,8 +521,7 @@ const deleteRows = () => {
|
||||
}
|
||||
|
||||
function fieldChange(value, field, row) {
|
||||
row[field.fieldname] = value
|
||||
triggerOnChange(field.fieldname, row)
|
||||
triggerOnChange(field.fieldname, value, row)
|
||||
}
|
||||
|
||||
function getDefaultValue(defaultValue, fieldtype) {
|
||||
|
||||
@ -15,10 +15,14 @@
|
||||
class="w-7"
|
||||
@click="openGridRowFieldsModal"
|
||||
>
|
||||
<EditIcon class="h-4 w-4" />
|
||||
<template #icon>
|
||||
<EditIcon />
|
||||
</template>
|
||||
</Button>
|
||||
<Button variant="ghost" class="w-7" @click="show = false">
|
||||
<FeatherIcon name="x" class="h-4 w-4" />
|
||||
<template #icon>
|
||||
<FeatherIcon name="x" class="size-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -70,7 +70,7 @@
|
||||
{{
|
||||
fetchContacts
|
||||
? __('No results found')
|
||||
: __('Type an email address to add')
|
||||
: __('Type an email address to invite')
|
||||
}}
|
||||
</div>
|
||||
<ComboboxOption
|
||||
@ -156,6 +156,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
existingEmails: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
const values = defineModel()
|
||||
@ -205,6 +209,14 @@ const filterOptions = createResource({
|
||||
value: email,
|
||||
}
|
||||
})
|
||||
|
||||
// Filter out existing emails
|
||||
if (props.existingEmails?.length) {
|
||||
allData = allData.filter((option) => {
|
||||
return !props.existingEmails.includes(option.value)
|
||||
})
|
||||
}
|
||||
|
||||
return allData
|
||||
},
|
||||
})
|
||||
|
||||
278
frontend/src/components/Controls/MultiSelectUserInput.vue
Normal file
278
frontend/src/components/Controls/MultiSelectUserInput.vue
Normal file
@ -0,0 +1,278 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Button
|
||||
ref="emails"
|
||||
v-for="value in values"
|
||||
:key="value"
|
||||
:label="value"
|
||||
theme="gray"
|
||||
variant="subtle"
|
||||
:class="{
|
||||
'rounded bg-surface-white hover:!bg-surface-gray-1 focus-visible:ring-outline-gray-4':
|
||||
variant === 'subtle',
|
||||
}"
|
||||
@keydown.delete.capture.stop="removeLastValue"
|
||||
>
|
||||
<template #suffix>
|
||||
<FeatherIcon
|
||||
class="h-3.5"
|
||||
name="x"
|
||||
@click.stop="removeValue(value)"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
<div class="flex-1">
|
||||
<Combobox v-model="selectedValue" nullable>
|
||||
<Popover class="w-full" v-model:show="showOptions">
|
||||
<template #target="{ togglePopover }">
|
||||
<ComboboxInput
|
||||
ref="search"
|
||||
class="search-input form-input w-full border-none focus:border-none focus:!shadow-none focus-visible:!ring-0"
|
||||
:class="[
|
||||
variant == 'ghost'
|
||||
? 'bg-surface-white hover:bg-surface-white'
|
||||
: 'bg-surface-gray-2 hover:bg-surface-gray-3',
|
||||
inputClass,
|
||||
]"
|
||||
:placeholder="placeholder"
|
||||
type="text"
|
||||
:value="query"
|
||||
@change="
|
||||
(e) => {
|
||||
query = e.target.value
|
||||
showOptions = true
|
||||
}
|
||||
"
|
||||
autocomplete="off"
|
||||
@focus="() => togglePopover()"
|
||||
@keydown.delete.capture.stop="removeLastValue"
|
||||
/>
|
||||
</template>
|
||||
<template #body="{ isOpen }">
|
||||
<div v-show="isOpen">
|
||||
<div
|
||||
class="mt-1 rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
>
|
||||
<ComboboxOptions
|
||||
class="p-1.5 max-h-[12rem] overflow-y-auto"
|
||||
static
|
||||
>
|
||||
<div
|
||||
v-if="!options.length"
|
||||
class="flex gap-2 rounded px-2 py-1 text-base text-ink-gray-5"
|
||||
>
|
||||
<FeatherIcon
|
||||
v-if="fetchUsers"
|
||||
name="search"
|
||||
class="h-4"
|
||||
/>
|
||||
{{
|
||||
fetchUsers
|
||||
? __('No results found')
|
||||
: __('Type an email address to invite')
|
||||
}}
|
||||
</div>
|
||||
<ComboboxOption
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:value="option"
|
||||
v-slot="{ active }"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'flex cursor-pointer items-center rounded px-2 py-1 text-base',
|
||||
{ 'bg-surface-gray-3': active },
|
||||
]"
|
||||
>
|
||||
<UserAvatar
|
||||
class="mr-2"
|
||||
:user="option.value"
|
||||
size="lg"
|
||||
/>
|
||||
<div class="flex flex-col gap-1 p-1 text-ink-gray-8">
|
||||
<div class="text-base font-medium">
|
||||
{{ option.label }}
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{ option.value }}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
</ComboboxOptions>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</Combobox>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" />
|
||||
<div
|
||||
v-if="info"
|
||||
class="whitespace-pre-line text-sm text-ink-blue-3 mt-2 pl-2"
|
||||
>
|
||||
{{ info }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxOptions,
|
||||
ComboboxOption,
|
||||
} from '@headlessui/vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import Popover from '@/components/frappe-ui/Popover.vue'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
validate: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'subtle',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
inputClass: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
errorMessage: {
|
||||
type: Function,
|
||||
default: (value) => `${value} is an Invalid value`,
|
||||
},
|
||||
fetchUsers: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
existingEmails: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
const values = defineModel()
|
||||
|
||||
const { users } = usersStore()
|
||||
|
||||
const emails = ref([])
|
||||
const search = ref(null)
|
||||
const error = ref(null)
|
||||
const info = ref(null)
|
||||
const query = ref('')
|
||||
const showOptions = ref(false)
|
||||
|
||||
const selectedValue = computed({
|
||||
get: () => query.value || '',
|
||||
set: (val) => {
|
||||
query.value = ''
|
||||
if (val) {
|
||||
showOptions.value = false
|
||||
}
|
||||
val?.value && addValue(val.value)
|
||||
},
|
||||
})
|
||||
|
||||
const options = computed(() => {
|
||||
let userEmails = props.fetchUsers ? users?.data?.allUsers : []
|
||||
|
||||
if (props.fetchUsers) {
|
||||
userEmails = userEmails.map((user) => ({
|
||||
label: user.full_name || user.name || user.email,
|
||||
value: user.email,
|
||||
}))
|
||||
|
||||
if (props.existingEmails?.length) {
|
||||
userEmails = userEmails.filter((option) => {
|
||||
return !props.existingEmails.includes(option.value)
|
||||
})
|
||||
}
|
||||
|
||||
if (query.value) {
|
||||
userEmails = userEmails.filter(
|
||||
(option) =>
|
||||
option.label.toLowerCase().includes(query.value.toLowerCase()) ||
|
||||
option.value.toLowerCase().includes(query.value.toLowerCase()),
|
||||
)
|
||||
}
|
||||
} else if (!userEmails?.length && query.value) {
|
||||
userEmails.push({
|
||||
label: query.value,
|
||||
value: query.value,
|
||||
})
|
||||
}
|
||||
|
||||
return userEmails || []
|
||||
})
|
||||
|
||||
const addValue = (value) => {
|
||||
error.value = null
|
||||
info.value = null
|
||||
if (value) {
|
||||
const splitValues = value.split(',')
|
||||
splitValues.forEach((value) => {
|
||||
value = value.trim()
|
||||
if (value) {
|
||||
// check if value is not already in the values array
|
||||
if (!values.value?.includes(value)) {
|
||||
// check if value is valid
|
||||
if (value && props.validate && !props.validate(value)) {
|
||||
error.value = props.errorMessage(value)
|
||||
query.value = value
|
||||
return
|
||||
}
|
||||
// add value to values array
|
||||
if (!values.value) {
|
||||
values.value = [value]
|
||||
} else {
|
||||
values.value.push(value)
|
||||
}
|
||||
value = value.replace(value, '')
|
||||
} else {
|
||||
info.value = __('email already exists')
|
||||
}
|
||||
}
|
||||
})
|
||||
!error.value && (value = '')
|
||||
}
|
||||
}
|
||||
|
||||
const removeValue = (value) => {
|
||||
values.value = values.value.filter((v) => v !== value)
|
||||
}
|
||||
|
||||
const removeLastValue = () => {
|
||||
if (query.value) return
|
||||
|
||||
let emailRef = emails.value[emails.value.length - 1]?.$el
|
||||
if (document.activeElement === emailRef) {
|
||||
values.value.pop()
|
||||
nextTick(() => {
|
||||
if (values.value.length) {
|
||||
emailRef = emails.value[emails.value.length - 1].$el
|
||||
emailRef?.focus()
|
||||
} else {
|
||||
setFocus()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
emailRef?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
function setFocus() {
|
||||
search.value.$el.focus()
|
||||
}
|
||||
|
||||
defineExpose({ setFocus })
|
||||
</script>
|
||||
@ -3,17 +3,47 @@
|
||||
:type="show ? 'text' : 'password'"
|
||||
:value="modelValue || value"
|
||||
v-bind="$attrs"
|
||||
@keydown.meta.i.prevent="show = !show"
|
||||
@keydown.ctrl.i.prevent="show = !show"
|
||||
>
|
||||
<template #prefix v-if="$slots.prefix">
|
||||
<slot name="prefix" />
|
||||
</template>
|
||||
<template #suffix>
|
||||
<Button v-show="showEye" class="!h-4" @click="show = !show">
|
||||
<FeatherIcon :name="show ? 'eye-off' : 'eye'" class="h-3" />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<template #body>
|
||||
<div
|
||||
class="rounded bg-surface-gray-7 py-1.5 px-2 text-xs text-ink-white shadow-xl"
|
||||
>
|
||||
<span class="flex items-center gap-1">
|
||||
{{ show ? __('Hide Password') : __('Show Password') }}
|
||||
<KeyboardShortcut
|
||||
bg
|
||||
ctrl
|
||||
class="!bg-surface-gray-5 !text-ink-gray-2 px-1"
|
||||
>
|
||||
<span class="font-mono leading-none tracking-widest">+I</span>
|
||||
</KeyboardShortcut>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
<FeatherIcon
|
||||
v-show="showEye"
|
||||
:name="show ? 'eye-off' : 'eye'"
|
||||
class="h-3 cursor-pointer mr-1"
|
||||
@click="show = !show"
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</template>
|
||||
</FormControl>
|
||||
</template>
|
||||
<script setup>
|
||||
import { FormControl } from 'frappe-ui'
|
||||
import KeyboardShortcut from '@/components/KeyboardShortcut.vue'
|
||||
import { FormControl, Tooltip } from 'frappe-ui'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
|
||||
@ -31,7 +31,9 @@
|
||||
class="opacity-0 hover:bg-surface-gray-4 group-hover:opacity-100"
|
||||
@click="option.onClick"
|
||||
>
|
||||
<SuccessIcon />
|
||||
<template #icon>
|
||||
<SuccessIcon />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
@ -43,7 +45,9 @@
|
||||
class="opacity-0 hover:bg-surface-gray-4 group-hover:opacity-100"
|
||||
@click="toggleEditMode"
|
||||
>
|
||||
<EditIcon />
|
||||
<template #icon>
|
||||
<EditIcon />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
@ -243,7 +243,7 @@ const isGridRow = inject('isGridRow')
|
||||
const { getFormattedPercent, getFormattedFloat, getFormattedCurrency } =
|
||||
getMeta(doctype)
|
||||
|
||||
const { getUser } = usersStore()
|
||||
const { users, getUser } = usersStore()
|
||||
|
||||
let triggerOnChange
|
||||
let parentDoc
|
||||
@ -260,7 +260,7 @@ if (!isGridRow) {
|
||||
provide('triggerOnRowAdd', triggerOnRowAdd)
|
||||
provide('triggerOnRowRemove', triggerOnRowRemove)
|
||||
} else {
|
||||
triggerOnChange = inject('triggerOnChange')
|
||||
triggerOnChange = inject('triggerOnChange', () => {})
|
||||
parentDoc = inject('parentDoc')
|
||||
}
|
||||
|
||||
@ -278,6 +278,10 @@ const field = computed(() => {
|
||||
|
||||
if (field.fieldtype === 'Link' && field.options === 'User') {
|
||||
field.fieldtype = 'User'
|
||||
field.link_filters = JSON.stringify({
|
||||
...(field.link_filters ? JSON.parse(field.link_filters) : {}),
|
||||
name: ['in', users.data.crmUsers?.map((user) => user.name)],
|
||||
})
|
||||
}
|
||||
|
||||
if (field.fieldtype === 'Link' && field.options !== 'User') {
|
||||
@ -332,12 +336,10 @@ const getPlaceholder = (field) => {
|
||||
}
|
||||
|
||||
function fieldChange(value, df) {
|
||||
data.value[df.fieldname] = value
|
||||
|
||||
if (isGridRow) {
|
||||
triggerOnChange(df.fieldname, data.value)
|
||||
triggerOnChange(df.fieldname, value, data.value)
|
||||
} else {
|
||||
triggerOnChange(df.fieldname)
|
||||
triggerOnChange(df.fieldname, value)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
33
frontend/src/components/KeyboardShortcut.vue
Normal file
33
frontend/src/components/KeyboardShortcut.vue
Normal file
@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div
|
||||
class="inline-flex items-center gap-0.5 text-sm"
|
||||
:class="{
|
||||
'bg-surface-gray-2 rounded-sm text-ink-gray-5 py-0.5 px-1': bg,
|
||||
'text-ink-gray-4': !bg,
|
||||
}"
|
||||
>
|
||||
<span v-if="ctrl || meta">
|
||||
<LucideCommand v-if="isMac" class="w-3 h-3" />
|
||||
<span v-else>Ctrl</span>
|
||||
</span>
|
||||
<span v-if="shift"><LucideShift class="w-3 h-3" /></span>
|
||||
<span v-if="alt"><LucideAlt class="w-3 h-3" /></span>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import LucideCommand from '~icons/lucide/command'
|
||||
import LucideShift from '~icons/lucide/arrow-big-up'
|
||||
import LucideAlt from '~icons/lucide/option'
|
||||
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
|
||||
defineProps({
|
||||
meta: Boolean,
|
||||
ctrl: Boolean,
|
||||
shift: Boolean,
|
||||
alt: Boolean,
|
||||
shortcut: String,
|
||||
bg: Boolean,
|
||||
})
|
||||
</script>
|
||||
@ -172,6 +172,7 @@ import {
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { showSettings, activeSettingsPage } from '@/composables/settings'
|
||||
import { showChangePasswordModal } from '@/composables/modals'
|
||||
import { FeatherIcon, call } from 'frappe-ui'
|
||||
import {
|
||||
SignupBanner,
|
||||
@ -329,8 +330,7 @@ const steps = reactive([
|
||||
completed: false,
|
||||
onClick: () => {
|
||||
minimize.value = true
|
||||
showSettings.value = true
|
||||
activeSettingsPage.value = 'Profile'
|
||||
showChangePasswordModal.value = true
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -351,7 +351,7 @@ const steps = reactive([
|
||||
onClick: () => {
|
||||
minimize.value = true
|
||||
showSettings.value = true
|
||||
activeSettingsPage.value = 'Invite Members'
|
||||
activeSettingsPage.value = 'Invite User'
|
||||
},
|
||||
condition: () => isManager(),
|
||||
},
|
||||
@ -529,7 +529,7 @@ const articles = ref([
|
||||
{ name: 'profile', title: __('Profile') },
|
||||
{ name: 'custom-branding', title: __('Custom branding') },
|
||||
{ name: 'home-actions', title: __('Home actions') },
|
||||
{ name: 'invite-members', title: __('Invite members') },
|
||||
{ name: 'invite-users', title: __('Invite users') },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
112
frontend/src/components/Modals/AddExistingUserModal.vue
Normal file
112
frontend/src/components/Modals/AddExistingUserModal.vue
Normal file
@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{ title: __('Add Existing User') }"
|
||||
@close="show = false"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="flex gap-1 border rounded mb-4 p-2 text-ink-gray-5">
|
||||
<FeatherIcon name="info" class="size-3.5" />
|
||||
<p class="text-sm">
|
||||
{{
|
||||
__(
|
||||
'Add existing system users to this CRM. Assign them a role to grant access with their current credentials.',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label class="block text-xs text-ink-gray-5 mb-1.5">
|
||||
{{ __('Users') }}
|
||||
</label>
|
||||
|
||||
<div class="p-2 group bg-surface-gray-2 hover:bg-surface-gray-3 rounded">
|
||||
<MultiSelectUserInput
|
||||
v-if="users?.data?.crmUsers?.length"
|
||||
class="flex-1"
|
||||
inputClass="!bg-surface-gray-2 hover:!bg-surface-gray-3 group-hover:!bg-surface-gray-3"
|
||||
:placeholder="__('john@doe.com')"
|
||||
v-model="newUsers"
|
||||
:validate="validateEmail"
|
||||
:existingEmails="[
|
||||
...users.data.crmUsers.map((user) => user.name),
|
||||
'admin@example.com',
|
||||
]"
|
||||
:error-message="
|
||||
(value) => __('{0} is an invalid email address', [value])
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<FormControl
|
||||
type="select"
|
||||
class="mt-4"
|
||||
v-model="role"
|
||||
:label="__('Role')"
|
||||
:options="roleOptions"
|
||||
:description="description"
|
||||
/>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="solid"
|
||||
:label="__('Add')"
|
||||
:disabled="!newUsers.length"
|
||||
@click="addNewUser.submit()"
|
||||
:loading="addNewUser.loading"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import MultiSelectUserInput from '@/components/Controls/MultiSelectUserInput.vue'
|
||||
import { validateEmail } from '@/utils'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { createResource, toast } from 'frappe-ui'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const { users, isAdmin, isManager } = usersStore()
|
||||
|
||||
const show = defineModel()
|
||||
|
||||
const newUsers = ref([])
|
||||
const role = ref('Sales User')
|
||||
|
||||
const description = computed(() => {
|
||||
return {
|
||||
'System Manager':
|
||||
'Can manage all aspects of the CRM, including user management, customizations and settings.',
|
||||
'Sales Manager':
|
||||
'Can manage and invite new users, and create public & private views (reports).',
|
||||
'Sales User':
|
||||
'Can work with leads and deals and create private views (reports).',
|
||||
}[role.value]
|
||||
})
|
||||
|
||||
const roleOptions = computed(() => {
|
||||
return [
|
||||
{ value: 'Sales User', label: __('Sales User') },
|
||||
...(isManager() ? [{ value: 'Sales Manager', label: __('Manager') }] : []),
|
||||
...(isAdmin() ? [{ value: 'System Manager', label: __('Admin') }] : []),
|
||||
]
|
||||
})
|
||||
|
||||
const addNewUser = createResource({
|
||||
url: 'crm.api.user.add_existing_users',
|
||||
makeParams: () => ({
|
||||
users: JSON.stringify(newUsers.value),
|
||||
role: role.value,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success(__('Users added successfully'))
|
||||
newUsers.value = []
|
||||
show.value = false
|
||||
users.reload()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.messages[0] || __('Failed to add users'))
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@ -15,10 +15,14 @@
|
||||
class="w-7"
|
||||
@click="openQuickEntryModal"
|
||||
>
|
||||
<EditIcon class="h-4 w-4" />
|
||||
<template #icon>
|
||||
<EditIcon />
|
||||
</template>
|
||||
</Button>
|
||||
<Button variant="ghost" class="w-7" @click="show = false">
|
||||
<FeatherIcon name="x" class="h-4 w-4" />
|
||||
<template #icon>
|
||||
<FeatherIcon name="x" class="size-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -33,6 +33,7 @@
|
||||
doctype="User"
|
||||
@change="(option) => addValue(option) && ($refs.input.value = '')"
|
||||
:placeholder="__('John Doe')"
|
||||
:filters="{ name: ['in', users.data.crmUsers?.map((user) => user.name)] }"
|
||||
:hideMe="true"
|
||||
>
|
||||
<template #item-prefix="{ option }">
|
||||
@ -105,7 +106,7 @@ const oldAssignees = ref([])
|
||||
|
||||
const error = ref('')
|
||||
|
||||
const { getUser } = usersStore()
|
||||
const { users, getUser } = usersStore()
|
||||
|
||||
const removeValue = (value) => {
|
||||
assignees.value = assignees.value.filter(
|
||||
|
||||
@ -39,10 +39,14 @@
|
||||
class="w-7"
|
||||
@click="openCallLogModal"
|
||||
>
|
||||
<EditIcon class="h-4 w-4" />
|
||||
<template #icon>
|
||||
<EditIcon />
|
||||
</template>
|
||||
</Button>
|
||||
<Button variant="ghost" class="w-7" @click="show = false">
|
||||
<FeatherIcon name="x" class="h-4 w-4" />
|
||||
<template #icon>
|
||||
<FeatherIcon name="x" class="size-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -16,10 +16,14 @@
|
||||
class="w-7"
|
||||
@click="openQuickEntryModal"
|
||||
>
|
||||
<EditIcon class="w-4 h-4" />
|
||||
<template #icon>
|
||||
<EditIcon />
|
||||
</template>
|
||||
</Button>
|
||||
<Button variant="ghost" class="w-7" @click="show = false">
|
||||
<FeatherIcon name="x" class="w-4 h-4" />
|
||||
<template #icon>
|
||||
<FeatherIcon name="x" class="size-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
129
frontend/src/components/Modals/ChangePasswordModal.vue
Normal file
129
frontend/src/components/Modals/ChangePasswordModal.vue
Normal file
@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<Dialog v-model="show" :options="{ title: __('Change Password') }">
|
||||
<template #body-content>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<Password v-model="newPassword" :placeholder="__('New Password')">
|
||||
<template #prefix>
|
||||
<LockKeyhole class="size-4 text-ink-gray-4" />
|
||||
</template>
|
||||
</Password>
|
||||
<p v-if="newPasswordMessage" class="text-sm text-ink-gray-5 mt-2">
|
||||
{{ newPasswordMessage }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Password
|
||||
v-model="confirmPassword"
|
||||
:placeholder="__('Confirm Password')"
|
||||
>
|
||||
<template #prefix>
|
||||
<LockKeyhole class="size-4 text-ink-gray-4" />
|
||||
</template>
|
||||
</Password>
|
||||
<p
|
||||
v-if="confirmPasswordMessage"
|
||||
class="text-sm text-ink-gray-5 mt-2"
|
||||
:class="
|
||||
confirmPasswordMessage === 'Passwords match'
|
||||
? 'text-ink-green-3'
|
||||
: 'text-ink-red-3'
|
||||
"
|
||||
>
|
||||
{{ confirmPasswordMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<ErrorMessage :message="error" />
|
||||
</div>
|
||||
<Button
|
||||
variant="solid"
|
||||
:label="__('Update')"
|
||||
:disabled="
|
||||
!newPassword || !confirmPassword || newPassword !== confirmPassword
|
||||
"
|
||||
:loading="updatePassword.loading"
|
||||
@click="updatePassword.submit()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import LockKeyhole from '~icons/lucide/lock-keyhole'
|
||||
import Password from '@/components/Controls/Password.vue'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { Dialog, toast, createResource } from 'frappe-ui'
|
||||
import { useOnboarding } from 'frappe-ui/frappe'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const show = defineModel()
|
||||
|
||||
const { getUser } = usersStore()
|
||||
const { updateOnboardingStep } = useOnboarding('frappecrm')
|
||||
|
||||
const newPassword = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const newPasswordMessage = ref('')
|
||||
const confirmPasswordMessage = ref('')
|
||||
|
||||
const error = ref('')
|
||||
|
||||
const updatePassword = createResource({
|
||||
url: 'frappe.client.set_value',
|
||||
makeParams() {
|
||||
return {
|
||||
doctype: 'User',
|
||||
name: getUser().name,
|
||||
fieldname: 'new_password',
|
||||
value: newPassword.value,
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
updateOnboardingStep('setup_your_password')
|
||||
toast.success(__('Password updated successfully'))
|
||||
show.value = false
|
||||
newPassword.value = ''
|
||||
confirmPassword.value = ''
|
||||
error.value = ''
|
||||
},
|
||||
onError: (err) => {
|
||||
error.value = err.messages[0] || __('Failed to update password')
|
||||
},
|
||||
})
|
||||
|
||||
function isStrongPassword(password) {
|
||||
const regex =
|
||||
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/
|
||||
return regex.test(password)
|
||||
}
|
||||
|
||||
watch([newPassword, confirmPassword], () => {
|
||||
confirmPasswordMessage.value = ''
|
||||
newPasswordMessage.value = ''
|
||||
|
||||
if (newPassword.value.length < 8) {
|
||||
newPasswordMessage.value = 'Password must be at least 8 characters'
|
||||
} else if (!isStrongPassword(newPassword.value)) {
|
||||
newPasswordMessage.value =
|
||||
'Password must contain uppercase, lowercase, number, and symbol'
|
||||
}
|
||||
|
||||
if (
|
||||
confirmPassword.value.length &&
|
||||
newPassword.value !== confirmPassword.value
|
||||
) {
|
||||
confirmPasswordMessage.value = 'Passwords do not match'
|
||||
} else if (
|
||||
newPassword.value === confirmPassword.value &&
|
||||
newPassword.value.length &&
|
||||
confirmPassword.value.length
|
||||
) {
|
||||
confirmPasswordMessage.value = 'Passwords match'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@ -15,10 +15,14 @@
|
||||
class="w-7"
|
||||
@click="openQuickEntryModal"
|
||||
>
|
||||
<EditIcon class="h-4 w-4" />
|
||||
<template #icon>
|
||||
<EditIcon />
|
||||
</template>
|
||||
</Button>
|
||||
<Button variant="ghost" class="w-7" @click="show = false">
|
||||
<FeatherIcon name="x" class="h-4 w-4" />
|
||||
<template #icon>
|
||||
<FeatherIcon name="x" class="size-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -15,10 +15,14 @@
|
||||
class="w-7"
|
||||
@click="openQuickEntryModal"
|
||||
>
|
||||
<EditIcon class="h-4 w-4" />
|
||||
<template #icon>
|
||||
<EditIcon />
|
||||
</template>
|
||||
</Button>
|
||||
<Button variant="ghost" class="w-7" @click="show = false">
|
||||
<FeatherIcon name="x" class="h-4 w-4" />
|
||||
<template #icon>
|
||||
<FeatherIcon name="x" class="size-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -15,10 +15,14 @@
|
||||
class="w-7"
|
||||
@click="openQuickEntryModal"
|
||||
>
|
||||
<EditIcon class="h-4 w-4" />
|
||||
<template #icon>
|
||||
<EditIcon />
|
||||
</template>
|
||||
</Button>
|
||||
<Button variant="ghost" class="w-7" @click="show = false">
|
||||
<FeatherIcon name="x" class="h-4 w-4" />
|
||||
<template #icon>
|
||||
<FeatherIcon name="x" class="size-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -94,7 +98,7 @@ const show = defineModel()
|
||||
const router = useRouter()
|
||||
const error = ref(null)
|
||||
|
||||
const { document: deal } = useDocument('CRM Deal')
|
||||
const { document: deal, triggerOnChange } = useDocument('CRM Deal')
|
||||
|
||||
const hasOrganizationSections = ref(true)
|
||||
const hasContactSections = ref(true)
|
||||
@ -164,7 +168,7 @@ const tabs = createResource({
|
||||
})
|
||||
|
||||
const dealStatuses = computed(() => {
|
||||
let statuses = statusOptions('deal')
|
||||
let statuses = statusOptions('deal', null, [], triggerOnChange)
|
||||
if (!deal.doc.status) {
|
||||
deal.doc.status = statuses[0].value
|
||||
}
|
||||
|
||||
@ -16,9 +16,14 @@
|
||||
v-model="showAddressModal"
|
||||
v-bind="addressProps"
|
||||
/>
|
||||
<ChangePasswordModal
|
||||
v-if="showChangePasswordModal"
|
||||
v-model="showChangePasswordModal"
|
||||
/>
|
||||
<AboutModal v-model="showAboutModal" />
|
||||
</template>
|
||||
<script setup>
|
||||
import ChangePasswordModal from '@/components/Modals/ChangePasswordModal.vue'
|
||||
import CreateDocumentModal from '@/components/Modals/CreateDocumentModal.vue'
|
||||
import QuickEntryModal from '@/components/Modals/QuickEntryModal.vue'
|
||||
import AddressModal from '@/components/Modals/AddressModal.vue'
|
||||
@ -34,6 +39,7 @@ import {
|
||||
quickEntryProps,
|
||||
showAddressModal,
|
||||
addressProps,
|
||||
showAboutModal
|
||||
showAboutModal,
|
||||
showChangePasswordModal,
|
||||
} from '@/composables/modals'
|
||||
</script>
|
||||
|
||||
@ -15,10 +15,14 @@
|
||||
class="w-7"
|
||||
@click="openQuickEntryModal"
|
||||
>
|
||||
<EditIcon class="h-4 w-4" />
|
||||
<template #icon>
|
||||
<EditIcon />
|
||||
</template>
|
||||
</Button>
|
||||
<Button variant="ghost" class="w-7" @click="show = false">
|
||||
<FeatherIcon name="x" class="h-4 w-4" />
|
||||
<template #icon>
|
||||
<FeatherIcon name="x" class="size-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -70,10 +74,10 @@ const router = useRouter()
|
||||
const error = ref(null)
|
||||
const isLeadCreating = ref(false)
|
||||
|
||||
const { document: lead } = useDocument('CRM Lead')
|
||||
const { document: lead, triggerOnChange } = useDocument('CRM Lead')
|
||||
|
||||
const leadStatuses = computed(() => {
|
||||
let statuses = statusOptions('lead')
|
||||
let statuses = statusOptions('lead', null, [], triggerOnChange)
|
||||
if (!lead.doc.status) {
|
||||
lead.doc.status = statuses?.[0]?.value
|
||||
}
|
||||
|
||||
@ -15,10 +15,14 @@
|
||||
class="w-7"
|
||||
@click="openQuickEntryModal"
|
||||
>
|
||||
<EditIcon class="w-4 h-4" />
|
||||
<template #icon>
|
||||
<EditIcon />
|
||||
</template>
|
||||
</Button>
|
||||
<Button variant="ghost" class="w-7" @click="show = false">
|
||||
<FeatherIcon name="x" class="w-4 h-4" />
|
||||
<template #icon>
|
||||
<FeatherIcon name="x" class="size-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,23 +1,32 @@
|
||||
<template>
|
||||
<Dialog v-model="show" :options="{
|
||||
size: 'xl',
|
||||
actions: [
|
||||
{
|
||||
label: editMode ? __('Update') : __('Create'),
|
||||
variant: 'solid',
|
||||
onClick: () => updateTask(),
|
||||
},
|
||||
],
|
||||
}">
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
size: 'xl',
|
||||
actions: [
|
||||
{
|
||||
label: editMode ? __('Update') : __('Create'),
|
||||
variant: 'solid',
|
||||
onClick: () => updateTask(),
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-title>
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="text-2xl font-semibold leading-6 text-ink-gray-9">
|
||||
{{ editMode ? __('Edit Task') : __('Create Task') }}
|
||||
</h3>
|
||||
<Button v-if="task?.reference_docname" size="sm" :label="task.reference_doctype == 'CRM Deal'
|
||||
? __('Open Deal')
|
||||
: __('Open Lead')
|
||||
" @click="redirect()">
|
||||
<Button
|
||||
v-if="task?.reference_docname"
|
||||
size="sm"
|
||||
:label="
|
||||
task.reference_doctype == 'CRM Deal'
|
||||
? __('Open Deal')
|
||||
: __('Open Lead')
|
||||
"
|
||||
@click="redirect()"
|
||||
>
|
||||
<template #suffix>
|
||||
<ArrowUpRightIcon class="w-4 h-4" />
|
||||
</template>
|
||||
@ -27,17 +36,29 @@
|
||||
<template #body-content>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<FormControl ref="title" :label="__('Title')" v-model="_task.title" :placeholder="__('Call with John Doe')"
|
||||
required />
|
||||
<FormControl
|
||||
ref="title"
|
||||
:label="__('Title')"
|
||||
v-model="_task.title"
|
||||
:placeholder="__('Call with John Doe')"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1.5 text-xs text-ink-gray-5">
|
||||
{{ __('Description') }}
|
||||
</div>
|
||||
<TextEditor variant="outline" ref="description"
|
||||
<TextEditor
|
||||
variant="outline"
|
||||
ref="description"
|
||||
editor-class="!prose-sm overflow-auto min-h-[180px] max-h-80 py-1.5 px-2 rounded border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 hover:shadow-sm focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors"
|
||||
:bubbleMenu="true" :content="_task.description" @change="(val) => (_task.description = val)" :placeholder="__('Took a call with John Doe and discussed the new project.')
|
||||
" />
|
||||
:bubbleMenu="true"
|
||||
:content="_task.description"
|
||||
@change="(val) => (_task.description = val)"
|
||||
:placeholder="
|
||||
__('Took a call with John Doe and discussed the new project.')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Dropdown :options="taskStatusOptions(updateTaskStatus)">
|
||||
@ -47,24 +68,38 @@
|
||||
</template>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<Link class="form-control" :value="getUser(_task.assigned_to).full_name" doctype="User"
|
||||
@change="(option) => (_task.assigned_to = option)" :placeholder="__('John Doe')" :hideMe="true">
|
||||
<template #prefix>
|
||||
<UserAvatar class="mr-2 !h-4 !w-4" :user="_task.assigned_to" />
|
||||
</template>
|
||||
<template #item-prefix="{ option }">
|
||||
<UserAvatar class="mr-2" :user="option.value" size="sm" />
|
||||
</template>
|
||||
<template #item-label="{ option }">
|
||||
<Tooltip :text="option.value">
|
||||
<div class="cursor-pointer text-ink-gray-9">
|
||||
{{ getUser(option.value).full_name }}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</template>
|
||||
<Link
|
||||
class="form-control"
|
||||
:value="getUser(_task.assigned_to).full_name"
|
||||
doctype="User"
|
||||
@change="(option) => (_task.assigned_to = option)"
|
||||
:placeholder="__('John Doe')"
|
||||
:filters="{
|
||||
name: ['in', users.data.crmUsers?.map((user) => user.name)],
|
||||
}"
|
||||
:hideMe="true"
|
||||
>
|
||||
<template #prefix>
|
||||
<UserAvatar class="mr-2 !h-4 !w-4" :user="_task.assigned_to" />
|
||||
</template>
|
||||
<template #item-prefix="{ option }">
|
||||
<UserAvatar class="mr-2" :user="option.value" size="sm" />
|
||||
</template>
|
||||
<template #item-label="{ option }">
|
||||
<Tooltip :text="option.value">
|
||||
<div class="cursor-pointer text-ink-gray-9">
|
||||
{{ getUser(option.value).full_name }}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</template>
|
||||
</Link>
|
||||
<DateTimePicker class="datepicker w-36" v-model="_task.due_date" :placeholder="__('01/04/2024 11:30 PM')"
|
||||
:formatter="(date) => getFormat(date, '', true, true)" input-class="border-none" />
|
||||
<DateTimePicker
|
||||
class="datepicker w-36"
|
||||
v-model="_task.due_date"
|
||||
:placeholder="__('01/04/2024 11:30 PM')"
|
||||
:formatter="(date) => getFormat(date, '', true, true)"
|
||||
input-class="border-none"
|
||||
/>
|
||||
<Dropdown :options="taskPriorityOptions(updateTaskPriority)">
|
||||
<Button :label="_task.priority" class="justify-between w-full">
|
||||
<template #prefix>
|
||||
@ -114,7 +149,7 @@ const tasks = defineModel('reloadTasks')
|
||||
const emit = defineEmits(['updateTask', 'after'])
|
||||
|
||||
const router = useRouter()
|
||||
const { getUser } = usersStore()
|
||||
const { users, getUser } = usersStore()
|
||||
const { updateOnboardingStep } = useOnboarding('frappecrm')
|
||||
|
||||
const error = ref(null)
|
||||
@ -164,20 +199,24 @@ async function updateTask() {
|
||||
emit('after', d)
|
||||
}
|
||||
} else {
|
||||
let d = await call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'CRM Task',
|
||||
reference_doctype: props.doctype,
|
||||
reference_docname: props.doc || null,
|
||||
..._task.value,
|
||||
let d = await call(
|
||||
'frappe.client.insert',
|
||||
{
|
||||
doc: {
|
||||
doctype: 'CRM Task',
|
||||
reference_doctype: props.doctype,
|
||||
reference_docname: props.doc || null,
|
||||
..._task.value,
|
||||
},
|
||||
},
|
||||
}, {
|
||||
onError: (err) => {
|
||||
if (err.error.exc_type == 'MandatoryError') {
|
||||
error.value = "Title is mandatory"
|
||||
}
|
||||
}
|
||||
})
|
||||
{
|
||||
onError: (err) => {
|
||||
if (err.error.exc_type == 'MandatoryError') {
|
||||
error.value = 'Title is mandatory'
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
if (d.name) {
|
||||
updateOnboardingStep('create_first_task')
|
||||
capture('task_created')
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<SettingsPage
|
||||
doctype="ERPNext CRM Settings"
|
||||
:title="__('ERPNext Settings')"
|
||||
:successMessage="__('ERPNext Settings updated')"
|
||||
:title="__('ERPNext settings')"
|
||||
:successMessage="__('ERPNext settings updated')"
|
||||
class="p-8"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@ -1,19 +1,19 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-between p-1 py-3 border-b border-gray-200 dark:border-gray-700 cursor-pointer"
|
||||
class="flex items-center justify-between p-1 py-3 border-b border-outline-gray-modals cursor-pointer"
|
||||
>
|
||||
<!-- avatar and name -->
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<EmailProviderIcon :logo="emailIcon[emailAccount.service]" />
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-ink-gray-9">
|
||||
<p class="text-sm font-semibold text-ink-gray-8">
|
||||
{{ emailAccount.email_account_name }}
|
||||
</p>
|
||||
<div class="text-sm text-gray-500">{{ emailAccount.email_id }}</div>
|
||||
<div class="text-sm text-ink-gray-4">{{ emailAccount.email_id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Badge variant="subtle" :label="badgeTitle" :theme="gray" />
|
||||
<Badge variant="subtle" :label="badgeTitle" theme="gray" />
|
||||
</div>
|
||||
<!-- email id -->
|
||||
</div>
|
||||
|
||||
@ -1,22 +1,30 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- header -->
|
||||
<div class="flex items-center justify-between text-ink-gray-9">
|
||||
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5">
|
||||
{{ __('Email Accounts') }}
|
||||
</h2>
|
||||
<Button
|
||||
:label="__('Add Account')"
|
||||
theme="gray"
|
||||
variant="solid"
|
||||
@click="emit('update:step', 'email-add')"
|
||||
class="mr-8"
|
||||
>
|
||||
<template #prefix>
|
||||
<LucidePlus class="w-4 h-4" />
|
||||
</template>
|
||||
</Button>
|
||||
<div class="flex justify-between text-ink-gray-8">
|
||||
<div class="flex flex-col gap-1 w-9/12">
|
||||
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5">
|
||||
{{ __('Email accounts') }}
|
||||
</h2>
|
||||
<p class="text-p-base text-ink-gray-6">
|
||||
{{
|
||||
__(
|
||||
'Manage your email accounts to send and receive emails directly from CRM. You can add multiple accounts and set one as default for incoming and outgoing emails.',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex item-center space-x-2 w-3/12 justify-end">
|
||||
<Button
|
||||
:label="__('Add Account')"
|
||||
theme="gray"
|
||||
variant="solid"
|
||||
icon-left="plus"
|
||||
@click="emit('update:step', 'email-add')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- list accounts -->
|
||||
<div
|
||||
v-if="!emailAccounts.loading && Boolean(emailAccounts.data?.length)"
|
||||
@ -30,7 +38,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- fallback if no email accounts -->
|
||||
<div v-else class="flex items-center justify-center h-64 text-gray-500">
|
||||
<div v-else class="flex items-center justify-center h-64 text-ink-gray-4">
|
||||
{{ __('Please add an email account to continue.') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -2,10 +2,10 @@
|
||||
<div class="flex flex-col h-full gap-4">
|
||||
<!-- title and desc -->
|
||||
<div role="heading" aria-level="1" class="flex flex-col gap-1">
|
||||
<h2 class="text-xl font-semibold text-ink-gray-9">
|
||||
<h2 class="text-xl font-semibold text-ink-gray-8">
|
||||
{{ __('Setup Email') }}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600">
|
||||
<p class="text-sm text-ink-gray-5">
|
||||
{{ __('Choose the email service provider you want to configure.') }}
|
||||
</p>
|
||||
</div>
|
||||
@ -27,14 +27,15 @@
|
||||
<div v-if="selectedService" class="flex flex-col gap-4">
|
||||
<!-- email service provider info -->
|
||||
<div
|
||||
class="flex items-center gap-2 p-2 rounded-md ring-1 ring-gray-400 dark:ring-gray-700 text-gray-700 dark:text-gray-500"
|
||||
class="flex items-center gap-2 p-2 rounded-md ring-1 ring-outline-gray-3 text-ink-gray-6"
|
||||
>
|
||||
<CircleAlert class="w-5 h-6 w-min-5 w-max-5 min-h-5 max-w-5" />
|
||||
<div class="text-xs text-wrap">
|
||||
{{ selectedService.info }}
|
||||
<a :href="selectedService.link" target="_blank" class="underline"
|
||||
>here</a
|
||||
>.
|
||||
<a :href="selectedService.link" target="_blank" class="underline">
|
||||
{{ __('here') }}
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
<!-- service provider fields -->
|
||||
@ -66,23 +67,22 @@
|
||||
:name="field.name"
|
||||
:type="field.type"
|
||||
/>
|
||||
<p class="text-gray-500 text-p-sm">{{ field.description }}</p>
|
||||
<p class="text-ink-gray-4 text-p-sm">{{ field.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorMessage v-if="error" class="ml-1" :message="error" />
|
||||
<ErrorMessage class="ml-1" :message="error" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- action button -->
|
||||
<div v-if="selectedService" class="flex justify-between mt-auto">
|
||||
<Button
|
||||
label="Back"
|
||||
theme="gray"
|
||||
:label="__('Back')"
|
||||
variant="outline"
|
||||
:disabled="addEmailRes.loading"
|
||||
@click="emit('update:step', 'email-list')"
|
||||
/>
|
||||
<Button
|
||||
label="Create"
|
||||
:label="__('Create')"
|
||||
variant="solid"
|
||||
:loading="addEmailRes.loading"
|
||||
@click="createEmailAccount"
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
<div class="flex flex-col h-full gap-4">
|
||||
<!-- title and desc -->
|
||||
<div role="heading" aria-level="1" class="flex justify-between gap-1">
|
||||
<h2 class="text-xl font-semibold text-ink-gray-9">
|
||||
<h2 class="text-xl font-semibold text-ink-gray-8">
|
||||
{{ __('Edit Email') }}
|
||||
</h2>
|
||||
</div>
|
||||
@ -14,16 +14,16 @@
|
||||
</div>
|
||||
<!-- banner for setting up email account -->
|
||||
<div
|
||||
class="flex items-center gap-2 p-2 rounded-md ring-1 ring-gray-400 dark:ring-gray-700"
|
||||
class="flex items-center gap-2 p-2 rounded-md ring-1 ring-outline-gray-3"
|
||||
>
|
||||
<CircleAlert
|
||||
class="size-6 text-gray-500 w-min-5 w-max-5 min-h-5 max-w-5"
|
||||
class="size-6 text-ink-gray-4 w-min-5 w-max-5 min-h-5 max-w-5"
|
||||
/>
|
||||
<div class="text-xs text-gray-700 dark:text-gray-500 text-wrap">
|
||||
<div class="text-xs text-ink-gray-6 text-wrap">
|
||||
{{ info.description }}
|
||||
<a :href="info.link" target="_blank" class="underline">{{
|
||||
__('here')
|
||||
}}</a>
|
||||
<a :href="info.link" target="_blank" class="underline">
|
||||
{{ __('here') }}
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
@ -56,7 +56,7 @@
|
||||
:name="field.name"
|
||||
:type="field.type"
|
||||
/>
|
||||
<p class="text-gray-500 text-p-sm">{{ field.description }}</p>
|
||||
<p class="text-ink-gray-4 text-p-sm">{{ field.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorMessage v-if="error" class="ml-1" :message="error" />
|
||||
|
||||
@ -1,14 +1,11 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-center w-8 h-8 bg-gray-100 cursor-pointer rounded-xl hover:bg-gray-200"
|
||||
:class="{ 'ring-2 ring-gray-500 dark:ring-gray-100': selected }"
|
||||
class="flex items-center justify-center w-8 h-8 bg-surface-gray-2 cursor-pointer rounded-xl hover:bg-surface-gray-3"
|
||||
:class="{ 'ring-2 ring-outline-gray-4': selected }"
|
||||
>
|
||||
<img :src="logo" class="w-4 h-4" />
|
||||
</div>
|
||||
<p
|
||||
v-if="serviceName"
|
||||
class="text-xs text-center text-gray-700 dark:text-gray-500 mt-2"
|
||||
>
|
||||
<p v-if="serviceName" class="text-xs text-center text-ink-gray-6 mt-2">
|
||||
{{ serviceName }}
|
||||
</p>
|
||||
</template>
|
||||
@ -29,5 +26,3 @@ defineProps({
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@ -1,14 +1,29 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col gap-8 p-8 text-ink-gray-9">
|
||||
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5">
|
||||
{{ __('General') }}
|
||||
<Badge
|
||||
v-if="settings.isDirty"
|
||||
:label="__('Not Saved')"
|
||||
variant="subtle"
|
||||
theme="orange"
|
||||
/>
|
||||
</h2>
|
||||
<div class="flex h-full flex-col gap-6 p-8 text-ink-gray-8">
|
||||
<div class="flex justify-between">
|
||||
<div class="flex flex-col gap-1 w-9/12">
|
||||
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5">
|
||||
{{ __('General') }}
|
||||
<Badge
|
||||
v-if="settings.isDirty"
|
||||
:label="__('Not Saved')"
|
||||
variant="subtle"
|
||||
theme="orange"
|
||||
/>
|
||||
</h2>
|
||||
<p class="text-p-base text-ink-gray-6">
|
||||
{{ __('Configure general settings for your CRM') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex item-center space-x-2 w-3/12 justify-end">
|
||||
<Button
|
||||
variant="solid"
|
||||
:label="__('Update')"
|
||||
:disabled="!settings.isDirty"
|
||||
@click="updateSettings"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="settings.doc" class="flex-1 flex flex-col gap-8 overflow-y-auto">
|
||||
<div class="flex w-full">
|
||||
@ -16,14 +31,14 @@
|
||||
type="text"
|
||||
class="w-1/2"
|
||||
v-model="settings.doc.brand_name"
|
||||
:label="__('Brand Name')"
|
||||
:label="__('Brand name')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- logo -->
|
||||
|
||||
<div class="flex flex-col justify-between gap-4">
|
||||
<span class="text-base font-semibold text-ink-gray-9">
|
||||
<span class="text-base font-semibold text-ink-gray-8">
|
||||
{{ __('Logo') }}
|
||||
</span>
|
||||
<div class="flex flex-1 gap-5">
|
||||
@ -58,7 +73,7 @@
|
||||
<!-- favicon -->
|
||||
|
||||
<div class="flex flex-col justify-between gap-4">
|
||||
<span class="text-base font-semibold text-ink-gray-9">
|
||||
<span class="text-base font-semibold text-ink-gray-8">
|
||||
{{ __('Favicon') }}
|
||||
</span>
|
||||
<div class="flex flex-1 gap-5">
|
||||
@ -93,7 +108,7 @@
|
||||
<!-- Home actions -->
|
||||
|
||||
<div class="flex flex-col justify-between gap-4">
|
||||
<span class="text-base font-semibold text-ink-gray-9">
|
||||
<span class="text-base font-semibold text-ink-gray-8">
|
||||
{{ __('Home actions') }}
|
||||
</span>
|
||||
<div class="flex flex-1">
|
||||
@ -107,15 +122,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between flex-row-reverse">
|
||||
<Button
|
||||
variant="solid"
|
||||
:label="__('Update')"
|
||||
:disabled="!settings.isDirty"
|
||||
@click="updateSettings"
|
||||
/>
|
||||
<ErrorMessage :message="settings.save.error" />
|
||||
</div>
|
||||
<ErrorMessage :message="settings.save.error" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
|
||||
@ -1,8 +1,28 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col gap-8 p-8 text-ink-gray-9">
|
||||
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5">
|
||||
{{ __('Send Invites To') }}
|
||||
</h2>
|
||||
<div class="flex h-full flex-col gap-6 p-8 text-ink-gray-8">
|
||||
<div class="flex justify-between">
|
||||
<div class="flex flex-col gap-1 w-9/12">
|
||||
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5">
|
||||
{{ __('Send invites to') }}
|
||||
</h2>
|
||||
<p class="text-p-base text-ink-gray-6">
|
||||
{{
|
||||
__(
|
||||
'Invite users to access CRM. Specify their roles to control access and permissions',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex item-center space-x-2 w-3/12 justify-end">
|
||||
<Button
|
||||
:label="__('Send Invites')"
|
||||
variant="solid"
|
||||
:disabled="!invitees.length"
|
||||
@click="inviteByEmail.submit()"
|
||||
:loading="inviteByEmail.loading"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col gap-8 overflow-y-auto">
|
||||
<div>
|
||||
<label class="block text-xs text-ink-gray-5 mb-1.5">
|
||||
@ -11,7 +31,7 @@
|
||||
<div
|
||||
class="p-2 group bg-surface-gray-2 hover:bg-surface-gray-3 rounded"
|
||||
>
|
||||
<MultiSelectEmailInput
|
||||
<MultiSelectUserInput
|
||||
class="flex-1"
|
||||
inputClass="!bg-surface-gray-2 hover:!bg-surface-gray-3 group-hover:!bg-surface-gray-3"
|
||||
:placeholder="__('john@doe.com')"
|
||||
@ -20,18 +40,21 @@
|
||||
:error-message="
|
||||
(value) => __('{0} is an invalid email address', [value])
|
||||
"
|
||||
:fetchContacts="false"
|
||||
:fetchUsers="false"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="userExistMessage || inviteeExistMessage"
|
||||
class="text-xs text-ink-red-3 mt-1.5"
|
||||
>
|
||||
{{ userExistMessage || inviteeExistMessage }}
|
||||
</div>
|
||||
<FormControl
|
||||
type="select"
|
||||
class="mt-4"
|
||||
v-model="role"
|
||||
:label="__('Invite as')"
|
||||
:options="[
|
||||
{ label: __('Regular Access'), value: 'Sales User' },
|
||||
{ label: __('Manager Access'), value: 'Sales Manager' },
|
||||
]"
|
||||
:options="roleOptions"
|
||||
:description="description"
|
||||
/>
|
||||
</div>
|
||||
@ -49,7 +72,7 @@
|
||||
:key="user.name"
|
||||
>
|
||||
<div class="text-base">
|
||||
<span class="text-ink-gray-9">
|
||||
<span class="text-ink-gray-8">
|
||||
{{ user.email }}
|
||||
</span>
|
||||
<span class="text-ink-gray-5">
|
||||
@ -76,21 +99,13 @@
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex justify-between items-center gap-2">
|
||||
<div><ErrorMessage v-if="error" :message="error" /></div>
|
||||
<Button
|
||||
:label="__('Send Invites')"
|
||||
variant="solid"
|
||||
:disabled="!invitees.length"
|
||||
@click="inviteByEmail.submit()"
|
||||
:loading="inviteByEmail.loading"
|
||||
/>
|
||||
</div>
|
||||
<ErrorMessage :message="error" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import MultiSelectEmailInput from '@/components/Controls/MultiSelectEmailInput.vue'
|
||||
import MultiSelectUserInput from '@/components/Controls/MultiSelectUserInput.vue'
|
||||
import { validateEmail, convertArrayToString } from '@/utils'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import {
|
||||
createListResource,
|
||||
createResource,
|
||||
@ -101,23 +116,67 @@ import { useOnboarding } from 'frappe-ui/frappe'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const { updateOnboardingStep } = useOnboarding('frappecrm')
|
||||
const { users, isAdmin, isManager } = usersStore()
|
||||
|
||||
const invitees = ref([])
|
||||
const role = ref('Sales User')
|
||||
const error = ref(null)
|
||||
|
||||
const userExistMessage = computed(() => {
|
||||
const inviteesSet = new Set(invitees.value)
|
||||
if (!inviteesSet.size) return null
|
||||
|
||||
if (!users.data?.crmUsers?.length) return null
|
||||
const existingEmails = users.data.crmUsers.map((user) => user.name)
|
||||
const existingUsersSet = new Set(existingEmails)
|
||||
|
||||
const existingInvitees = inviteesSet.intersection(existingUsersSet)
|
||||
if (existingInvitees.size === 0) return null
|
||||
|
||||
return __('User with email {0} already exists', [
|
||||
Array.from(existingInvitees).join(', '),
|
||||
])
|
||||
})
|
||||
|
||||
const inviteeExistMessage = computed(() => {
|
||||
const inviteesSet = new Set(invitees.value)
|
||||
if (!inviteesSet.size) return null
|
||||
|
||||
if (!pendingInvitations.data?.length) return null
|
||||
const existingEmails = pendingInvitations.data.map((user) => user.email)
|
||||
const existingUsersSet = new Set(existingEmails)
|
||||
|
||||
const existingInvitees = inviteesSet.intersection(existingUsersSet)
|
||||
if (existingInvitees.size === 0) return null
|
||||
|
||||
return __('User with email {0} already invited', [
|
||||
Array.from(existingInvitees).join(', '),
|
||||
])
|
||||
})
|
||||
|
||||
const description = computed(() => {
|
||||
return {
|
||||
'System Manager':
|
||||
'Can manage all aspects of the CRM, including user management, customizations and settings.',
|
||||
'Sales Manager':
|
||||
'Can manage and invite new members, and create public & private views (reports).',
|
||||
'Can manage and invite new users, and create public & private views (reports).',
|
||||
'Sales User':
|
||||
'Can work with leads and deals and create private views (reports).',
|
||||
}[role.value]
|
||||
})
|
||||
|
||||
const roleOptions = computed(() => {
|
||||
return [
|
||||
{ value: 'Sales User', label: __('Sales User') },
|
||||
...(isManager() ? [{ value: 'Sales Manager', label: __('Manager') }] : []),
|
||||
...(isAdmin() ? [{ value: 'System Manager', label: __('Admin') }] : []),
|
||||
]
|
||||
})
|
||||
|
||||
const roleMap = {
|
||||
'Sales User': __('Regular Access'),
|
||||
'Sales Manager': __('Manager Access'),
|
||||
'Sales User': __('Sales User'),
|
||||
'Sales Manager': __('Manager'),
|
||||
'System Manager': __('Admin'),
|
||||
}
|
||||
|
||||
const inviteByEmail = createResource({
|
||||
@ -130,7 +189,7 @@ const inviteByEmail = createResource({
|
||||
},
|
||||
onSuccess(data) {
|
||||
if (data?.existing_invites?.length) {
|
||||
error.value = __('Agent with email {0} already exists', [
|
||||
error.value = __('User with email {0} already exists', [
|
||||
data.existing_invites.join(', '),
|
||||
])
|
||||
} else {
|
||||
@ -1,58 +0,0 @@
|
||||
<template>
|
||||
<FileUploader
|
||||
@success="(file) => setUserImage(file.file_url)"
|
||||
:validateFile="validateIsImageFile"
|
||||
>
|
||||
<template v-slot="{ file, progress, error, uploading, openFileSelector }">
|
||||
<div class="flex flex-col items-center">
|
||||
<button
|
||||
class="group relative rounded-full border-2"
|
||||
@click="openFileSelector"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 grid place-items-center rounded-full bg-gray-400/20 text-base text-ink-gray-5 transition-opacity"
|
||||
:class="[
|
||||
uploading ? 'opacity-100' : 'opacity-0 group-hover:opacity-100',
|
||||
'drop-shadow-sm',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
class="inline-block rounded-md bg-surface-gray-7/60 px-2 py-1 text-ink-white"
|
||||
>
|
||||
{{
|
||||
uploading
|
||||
? `Uploading ${progress}%`
|
||||
: profile.user_image
|
||||
? 'Change Image'
|
||||
: 'Upload Image'
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<img
|
||||
v-if="profile.user_image"
|
||||
class="h-64 w-64 rounded-full object-cover"
|
||||
:src="profile.user_image"
|
||||
alt="Profile Photo"
|
||||
/>
|
||||
<div v-else class="h-64 w-64 rounded-full bg-surface-gray-2"></div>
|
||||
</button>
|
||||
<ErrorMessage class="mt-4" :message="error" />
|
||||
<div class="mt-4 flex items-center gap-4">
|
||||
<Button v-if="profile.user_image" @click="setUserImage(null)">
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
</template>
|
||||
<script setup>
|
||||
import { FileUploader } from 'frappe-ui'
|
||||
import { validateIsImageFile } from '@/utils';
|
||||
|
||||
const profile = defineModel()
|
||||
|
||||
function setUserImage(url) {
|
||||
profile.value.user_image = url
|
||||
}
|
||||
</script>
|
||||
@ -1,140 +1,160 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col gap-8 p-8 text-ink-gray-9">
|
||||
<div class="flex-1 flex flex-col gap-8 mt-2 overflow-y-auto">
|
||||
<div class="flex h-full flex-col gap-6 p-8 text-ink-gray-8">
|
||||
<div class="flex-1 flex flex-col gap-6 mt-2 overflow-y-auto">
|
||||
<div v-if="profile" class="flex w-full items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<Avatar
|
||||
class="!size-16"
|
||||
:image="profile.user_image"
|
||||
:label="profile.full_name"
|
||||
/>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-2xl font-semibold text-ink-gray-9">{{
|
||||
profile.full_name
|
||||
}}</span>
|
||||
<span class="text-base text-ink-gray-7">{{ profile.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
:label="__('Edit profile photo')"
|
||||
@click="showEditProfilePhotoModal = true"
|
||||
/>
|
||||
<Dialog
|
||||
:options="{ title: __('Edit profile photo') }"
|
||||
v-model="showEditProfilePhotoModal"
|
||||
<FileUploader
|
||||
@success="(file) => updateImage(file.file_url)"
|
||||
:validateFile="validateIsImageFile"
|
||||
>
|
||||
<template #body-content>
|
||||
<ProfileImageEditor v-model="profile" />
|
||||
<template #default="{ openFileSelector, error: _error }">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="group relative !size-[66px]">
|
||||
<Avatar
|
||||
class="!size-16"
|
||||
:image="profile.user_image"
|
||||
:label="profile.full_name"
|
||||
/>
|
||||
<component
|
||||
:is="profile.user_image ? Dropdown : 'div'"
|
||||
v-bind="
|
||||
profile.user_image
|
||||
? {
|
||||
options: [
|
||||
{
|
||||
icon: 'upload',
|
||||
label: profile.user_image
|
||||
? __('Change image')
|
||||
: __('Upload image'),
|
||||
onClick: openFileSelector,
|
||||
},
|
||||
{
|
||||
icon: 'trash-2',
|
||||
label: __('Remove image'),
|
||||
onClick: () => updateImage(),
|
||||
},
|
||||
],
|
||||
}
|
||||
: { onClick: openFileSelector }
|
||||
"
|
||||
class="!absolute bottom-0 left-0 right-0"
|
||||
>
|
||||
<div
|
||||
class="z-1 absolute bottom-0.5 left-0 right-0.5 flex h-9 cursor-pointer items-center justify-center rounded-b-full bg-black bg-opacity-40 pt-3 opacity-0 duration-300 ease-in-out group-hover:opacity-100"
|
||||
style="
|
||||
-webkit-clip-path: inset(12px 0 0 0);
|
||||
clip-path: inset(12px 0 0 0);
|
||||
"
|
||||
>
|
||||
<CameraIcon class="size-4 cursor-pointer text-white" />
|
||||
</div>
|
||||
</component>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-2xl font-semibold text-ink-gray-8">
|
||||
{{ profile.full_name }}
|
||||
</span>
|
||||
<span class="text-base text-ink-gray-7">
|
||||
{{ profile.email }}
|
||||
</span>
|
||||
<ErrorMessage :message="__(_error)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<Button
|
||||
variant="solid"
|
||||
class="w-full"
|
||||
:loading="loading"
|
||||
@click="updateUser"
|
||||
:label="__('Save')"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
</FileUploader>
|
||||
<Button
|
||||
:label="__('Change Password')"
|
||||
icon-left="lock"
|
||||
@click="showChangePasswordModal = true"
|
||||
/>
|
||||
<ChangePasswordModal
|
||||
v-if="showChangePasswordModal"
|
||||
v-model="showChangePasswordModal"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex justify-between gap-4">
|
||||
<FormControl
|
||||
class="w-full"
|
||||
label="First name"
|
||||
:label="__('First name')"
|
||||
v-model="profile.first_name"
|
||||
/>
|
||||
<FormControl
|
||||
class="w-full"
|
||||
label="Last name"
|
||||
:label="__('Last name')"
|
||||
v-model="profile.last_name"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<FormControl
|
||||
class="w-full"
|
||||
label="Email"
|
||||
v-model="profile.email"
|
||||
:disabled="true"
|
||||
/>
|
||||
<Password
|
||||
class="w-full"
|
||||
label="Set new password"
|
||||
v-model="profile.new_password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between flex-row-reverse">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<ErrorMessage :message="error" />
|
||||
</div>
|
||||
<Button
|
||||
variant="solid"
|
||||
:label="__('Update')"
|
||||
:loading="loading"
|
||||
@click="updateUser"
|
||||
:disabled="!dirty"
|
||||
:loading="setUser.loading"
|
||||
@click="setUser.submit()"
|
||||
/>
|
||||
<ErrorMessage :message="error" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import Password from '@/components/Controls/Password.vue'
|
||||
import ProfileImageEditor from '@/components/Settings/ProfileImageEditor.vue'
|
||||
import ChangePasswordModal from '@/components/Modals/ChangePasswordModal.vue'
|
||||
import CameraIcon from '@/components/Icons/CameraIcon.vue'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { Dialog, Avatar, createResource, ErrorMessage, toast } from 'frappe-ui'
|
||||
import { useOnboarding } from 'frappe-ui/frappe'
|
||||
import { validateIsImageFile } from '@/utils'
|
||||
import {
|
||||
Dropdown,
|
||||
FileUploader,
|
||||
Avatar,
|
||||
createResource,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
|
||||
const { getUser, users } = usersStore()
|
||||
const { updateOnboardingStep } = useOnboarding('frappecrm')
|
||||
|
||||
const user = computed(() => getUser() || {})
|
||||
|
||||
const showEditProfilePhotoModal = ref(false)
|
||||
|
||||
const profile = ref({})
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const showChangePasswordModal = ref(false)
|
||||
|
||||
function updateUser() {
|
||||
loading.value = true
|
||||
const dirty = computed(() => {
|
||||
return (
|
||||
profile.value.first_name !== user.value.first_name ||
|
||||
profile.value.last_name !== user.value.last_name
|
||||
)
|
||||
})
|
||||
|
||||
let passwordUpdated = false
|
||||
|
||||
if (profile.value.new_password) {
|
||||
passwordUpdated = true
|
||||
}
|
||||
|
||||
const fieldname = {
|
||||
first_name: profile.value.first_name,
|
||||
last_name: profile.value.last_name,
|
||||
user_image: profile.value.user_image,
|
||||
email: profile.value.email,
|
||||
new_password: profile.value.new_password,
|
||||
}
|
||||
createResource({
|
||||
url: 'frappe.client.set_value',
|
||||
params: {
|
||||
const setUser = createResource({
|
||||
url: 'frappe.client.set_value',
|
||||
makeParams() {
|
||||
return {
|
||||
doctype: 'User',
|
||||
name: user.value.name,
|
||||
fieldname,
|
||||
},
|
||||
auto: true,
|
||||
onSuccess: () => {
|
||||
if (passwordUpdated) {
|
||||
updateOnboardingStep('setup_your_password')
|
||||
}
|
||||
loading.value = false
|
||||
error.value = ''
|
||||
profile.value.new_password = ''
|
||||
showEditProfilePhotoModal.value = false
|
||||
toast.success(__('Profile updated successfully'))
|
||||
users.reload()
|
||||
},
|
||||
onError: (err) => {
|
||||
loading.value = false
|
||||
error.value = err.message
|
||||
},
|
||||
})
|
||||
fieldname: {
|
||||
first_name: profile.value.first_name,
|
||||
last_name: profile.value.last_name,
|
||||
user_image: profile.value.user_image,
|
||||
},
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
error.value = ''
|
||||
toast.success(__('Profile updated successfully'))
|
||||
users.reload()
|
||||
},
|
||||
onError: (err) => {
|
||||
error.value = err.messages[0] || __('Failed to update profile')
|
||||
},
|
||||
})
|
||||
|
||||
function updateImage(fileUrl = '') {
|
||||
profile.value.user_image = fileUrl
|
||||
setUser.submit()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
<template #body>
|
||||
<div class="flex h-[calc(100vh_-_8rem)]">
|
||||
<div class="flex flex-col p-2 w-52 shrink-0 bg-surface-gray-2">
|
||||
<h1 class="px-2 pt-2 mb-3 text-lg font-semibold text-ink-gray-9">
|
||||
<h1 class="px-2 pt-2 mb-3 text-lg font-semibold text-ink-gray-8">
|
||||
{{ __('Settings') }}
|
||||
</h1>
|
||||
<div v-for="tab in tabs">
|
||||
@ -28,20 +28,12 @@
|
||||
? 'bg-surface-selected shadow-sm hover:bg-surface-selected'
|
||||
: 'hover:bg-surface-gray-3'
|
||||
"
|
||||
@click="activeTab = i"
|
||||
@click="activeSettingsPage = i.label"
|
||||
/>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="relative flex flex-col flex-1 overflow-y-auto bg-surface-modal"
|
||||
>
|
||||
<Button
|
||||
class="absolute right-5 top-5"
|
||||
variant="ghost"
|
||||
icon="x"
|
||||
@click="showSettings = false"
|
||||
/>
|
||||
<div class="flex flex-col flex-1 overflow-y-auto bg-surface-modal">
|
||||
<component :is="activeTab.component" v-if="activeTab" />
|
||||
</div>
|
||||
</div>
|
||||
@ -52,10 +44,10 @@
|
||||
import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
|
||||
import ERPNextIcon from '@/components/Icons/ERPNextIcon.vue'
|
||||
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
|
||||
import InviteIcon from '@/components/Icons/InviteIcon.vue'
|
||||
import Email2Icon from '@/components/Icons/Email2Icon.vue'
|
||||
import Users from '@/components/Settings/Users.vue'
|
||||
import GeneralSettings from '@/components/Settings/GeneralSettings.vue'
|
||||
import InviteMemberPage from '@/components/Settings/InviteMemberPage.vue'
|
||||
import InviteUserPage from '@/components/Settings/InviteUserPage.vue'
|
||||
import ProfileSettings from '@/components/Settings/ProfileSettings.vue'
|
||||
import WhatsAppSettings from '@/components/Settings/WhatsAppSettings.vue'
|
||||
import ERPNextSettings from '@/components/Settings/ERPNextSettings.vue'
|
||||
@ -68,7 +60,7 @@ import {
|
||||
showSettings,
|
||||
activeSettingsPage,
|
||||
} from '@/composables/settings'
|
||||
import { Dialog, Button, Avatar } from 'frappe-ui'
|
||||
import { Dialog, Avatar } from 'frappe-ui'
|
||||
import { ref, markRaw, computed, watch, h } from 'vue'
|
||||
|
||||
const { isManager, isAgent, getUser } = usersStore()
|
||||
@ -98,9 +90,15 @@ const tabs = computed(() => {
|
||||
condition: () => isManager(),
|
||||
},
|
||||
{
|
||||
label: __('Invite Members'),
|
||||
icon: InviteIcon,
|
||||
component: markRaw(InviteMemberPage),
|
||||
label: __('Users'),
|
||||
icon: 'user',
|
||||
component: markRaw(Users),
|
||||
condition: () => isManager(),
|
||||
},
|
||||
{
|
||||
label: __('Invite User'),
|
||||
icon: 'user-plus',
|
||||
component: markRaw(InviteUserPage),
|
||||
condition: () => isManager(),
|
||||
},
|
||||
{
|
||||
@ -112,7 +110,7 @@ const tabs = computed(() => {
|
||||
],
|
||||
},
|
||||
{
|
||||
label: __('Integrations'),
|
||||
label: __('Integrations', null, 'FCRM'),
|
||||
items: [
|
||||
{
|
||||
label: __('Telephony'),
|
||||
|
||||
@ -1,16 +1,28 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col gap-8">
|
||||
<h2
|
||||
class="flex gap-2 text-xl font-semibold leading-none h-5 text-ink-gray-9"
|
||||
>
|
||||
<div>{{ title || __(doctype) }}</div>
|
||||
<Badge
|
||||
v-if="data.isDirty"
|
||||
:label="__('Not Saved')"
|
||||
variant="subtle"
|
||||
theme="orange"
|
||||
/>
|
||||
</h2>
|
||||
<div class="flex h-full flex-col gap-6">
|
||||
<div class="flex justify-between">
|
||||
<div class="flex flex-col gap-1 w-9/12">
|
||||
<h2
|
||||
class="flex gap-2 text-xl font-semibold leading-none h-5 text-ink-gray-8"
|
||||
>
|
||||
{{ title || __(doctype) }}
|
||||
<Badge
|
||||
v-if="data.isDirty"
|
||||
:label="__('Not Saved')"
|
||||
variant="subtle"
|
||||
theme="orange"
|
||||
/>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="flex item-center space-x-2 w-3/12 justify-end">
|
||||
<Button
|
||||
:loading="data.save.loading"
|
||||
:label="__('Update')"
|
||||
variant="solid"
|
||||
@click="data.save.submit()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!data.get.loading" class="flex-1 overflow-y-auto">
|
||||
<FieldLayout
|
||||
v-if="data?.doc && tabs"
|
||||
@ -22,17 +34,7 @@
|
||||
<div v-else class="flex flex-1 items-center justify-center">
|
||||
<Spinner class="size-8" />
|
||||
</div>
|
||||
<div class="flex justify-between gap-2">
|
||||
<div>
|
||||
<ErrorMessage class="mt-2" :message="data.save.error" />
|
||||
</div>
|
||||
<Button
|
||||
:loading="data.save.loading"
|
||||
:label="__('Update')"
|
||||
variant="solid"
|
||||
@click="data.save.submit()"
|
||||
/>
|
||||
</div>
|
||||
<ErrorMessage :message="data.save.error" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
|
||||
@ -1,16 +1,31 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col gap-8 p-8">
|
||||
<h2
|
||||
class="flex gap-2 text-xl font-semibold leading-none h-5 text-ink-gray-9"
|
||||
>
|
||||
<div>{{ __('Telephony Settings') }}</div>
|
||||
<Badge
|
||||
v-if="twilio.isDirty || exotel.isDirty || mediumChanged"
|
||||
:label="__('Not Saved')"
|
||||
variant="subtle"
|
||||
theme="orange"
|
||||
/>
|
||||
</h2>
|
||||
<div class="flex h-full flex-col gap-6 p-8">
|
||||
<div class="flex justify-between">
|
||||
<div class="flex flex-col gap-1 w-9/12">
|
||||
<h2
|
||||
class="flex gap-2 text-xl font-semibold leading-none h-5 text-ink-gray-8"
|
||||
>
|
||||
{{ __('Telephony settings') }}
|
||||
<Badge
|
||||
v-if="twilio.isDirty || exotel.isDirty || mediumChanged"
|
||||
:label="__('Not Saved')"
|
||||
variant="subtle"
|
||||
theme="orange"
|
||||
/>
|
||||
</h2>
|
||||
<p class="text-p-base text-ink-gray-6">
|
||||
{{ __('Configure telephony settings for your CRM') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex item-center space-x-2 w-3/12 justify-end">
|
||||
<Button
|
||||
:loading="twilio.save.loading || exotel.save.loading"
|
||||
:label="__('Update')"
|
||||
variant="solid"
|
||||
@click="update"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!twilio.get.loading || !exotel.get.loading"
|
||||
class="flex-1 flex flex-col gap-8 overflow-y-auto"
|
||||
@ -31,7 +46,7 @@
|
||||
|
||||
<!-- Twilio -->
|
||||
<div v-if="isManager()" class="flex flex-col justify-between gap-4">
|
||||
<span class="text-base font-semibold text-ink-gray-9">
|
||||
<span class="text-base font-semibold text-ink-gray-8">
|
||||
{{ __('Twilio') }}
|
||||
</span>
|
||||
<FieldLayout
|
||||
@ -44,7 +59,7 @@
|
||||
|
||||
<!-- Exotel -->
|
||||
<div v-if="isManager()" class="flex flex-col justify-between gap-4">
|
||||
<span class="text-base font-semibold text-ink-gray-9">
|
||||
<span class="text-base font-semibold text-ink-gray-8">
|
||||
{{ __('Exotel') }}
|
||||
</span>
|
||||
<FieldLayout
|
||||
@ -58,20 +73,7 @@
|
||||
<div v-else class="flex flex-1 items-center justify-center">
|
||||
<Spinner class="size-8" />
|
||||
</div>
|
||||
<div class="flex justify-between gap-2">
|
||||
<div>
|
||||
<ErrorMessage
|
||||
class="mt-2"
|
||||
:message="twilio.save.error || exotel.save.error || error"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
:loading="twilio.save.loading || exotel.save.loading"
|
||||
:label="__('Update')"
|
||||
variant="solid"
|
||||
@click="update"
|
||||
/>
|
||||
</div>
|
||||
<ErrorMessage :message="twilio.save.error || exotel.save.error || error" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
|
||||
283
frontend/src/components/Settings/Users.vue
Normal file
283
frontend/src/components/Settings/Users.vue
Normal file
@ -0,0 +1,283 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col gap-6 p-8 text-ink-gray-8">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between">
|
||||
<div class="flex flex-col gap-1 w-9/12">
|
||||
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5">
|
||||
{{ __('Users') }}
|
||||
</h2>
|
||||
<p class="text-p-base text-ink-gray-6">
|
||||
{{
|
||||
__(
|
||||
'Manage CRM users by adding or inviting them, and assign roles to control their access and permissions',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex item-center space-x-2 w-3/12 justify-end">
|
||||
<Dropdown
|
||||
:options="[
|
||||
{
|
||||
label: __('Add Existing User'),
|
||||
onClick: () => (showAddExistingModal = true),
|
||||
},
|
||||
{
|
||||
label: __('Invite New User'),
|
||||
onClick: () => (activeSettingsPage = 'Invite User'),
|
||||
},
|
||||
]"
|
||||
:button="{
|
||||
label: __('New'),
|
||||
iconLeft: 'plus',
|
||||
variant: 'solid',
|
||||
}"
|
||||
placement="right"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- loading state -->
|
||||
<div v-if="users.loading" class="flex mt-28 justify-between w-full h-full">
|
||||
<Button
|
||||
:loading="users.loading"
|
||||
variant="ghost"
|
||||
class="w-full"
|
||||
size="2xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
v-if="!users.loading && users.data?.crmUsers?.length == 1"
|
||||
class="flex justify-between w-full h-full"
|
||||
>
|
||||
<div
|
||||
class="text-ink-gray-4 border border-dashed rounded w-full flex items-center justify-center"
|
||||
>
|
||||
{{ __('No users found') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Users List -->
|
||||
<div v-if="!users.loading && users.data?.crmUsers?.length > 1">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<TextInput
|
||||
ref="searchRef"
|
||||
v-model="search"
|
||||
:placeholder="__('Search user')"
|
||||
class="w-1/3"
|
||||
:debounce="300"
|
||||
>
|
||||
<template #prefix>
|
||||
<FeatherIcon name="search" class="h-4 w-4 text-ink-gray-6" />
|
||||
</template>
|
||||
</TextInput>
|
||||
<FormControl
|
||||
type="select"
|
||||
v-model="currentRole"
|
||||
:options="[
|
||||
{ label: __('All'), value: 'All' },
|
||||
{ label: __('Admin'), value: 'System Manager' },
|
||||
{ label: __('Manager'), value: 'Sales Manager' },
|
||||
{ label: __('Sales User'), value: 'Sales User' },
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
<ul class="divide-y divide-outline-gray-modals overflow-auto">
|
||||
<template v-for="user in usersList" :key="user.name">
|
||||
<li class="flex items-center justify-between py-2">
|
||||
<div class="flex items-center">
|
||||
<Avatar
|
||||
:image="user.user_image"
|
||||
:label="user.full_name"
|
||||
size="xl"
|
||||
/>
|
||||
<div class="flex flex-col gap-1 ml-3">
|
||||
<div class="flex items-center text-base text-ink-gray-8 h-4">
|
||||
{{ user.full_name }}
|
||||
</div>
|
||||
<div class="text-base text-ink-gray-5">
|
||||
{{ user.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center flex-row-reverse">
|
||||
<Dropdown
|
||||
:options="getMoreOptions(user)"
|
||||
:button="{
|
||||
icon: 'more-horizontal',
|
||||
}"
|
||||
placement="right"
|
||||
/>
|
||||
<Dropdown
|
||||
:options="getDropdownOptions(user)"
|
||||
:button="{
|
||||
label: roleMap[user.role],
|
||||
iconRight: 'chevron-down',
|
||||
}"
|
||||
placement="right"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
<!-- Load More Button -->
|
||||
<div
|
||||
v-if="!users.loading && users.hasNextPage"
|
||||
class="flex justify-center"
|
||||
>
|
||||
<Button
|
||||
class="mt-3.5 p-2"
|
||||
@click="() => users.next()"
|
||||
:loading="users.loading"
|
||||
:label="__('Load More')"
|
||||
icon-left="refresh-cw"
|
||||
/>
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<AddExistingUserModal
|
||||
v-if="showAddExistingModal"
|
||||
v-model="showAddExistingModal"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import LucideCheck from '~icons/lucide/check'
|
||||
import AddExistingUserModal from '@/components/Modals/AddExistingUserModal.vue'
|
||||
import { activeSettingsPage } from '@/composables/settings'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { Avatar, TextInput, toast, call } from 'frappe-ui'
|
||||
import { ref, computed, h, onMounted } from 'vue'
|
||||
|
||||
const { users, getUserRole, isAdmin, isManager } = usersStore()
|
||||
|
||||
const showAddExistingModal = ref(false)
|
||||
const searchRef = ref(null)
|
||||
const search = ref('')
|
||||
const currentRole = ref('All')
|
||||
|
||||
const roleMap = {
|
||||
'System Manager': __('Admin'),
|
||||
'Sales Manager': __('Manager'),
|
||||
'Sales User': __('Sales User'),
|
||||
}
|
||||
|
||||
const usersList = computed(() => {
|
||||
let filteredUsers =
|
||||
users.data?.crmUsers?.filter((user) => user.name !== 'Administrator') || []
|
||||
|
||||
return filteredUsers
|
||||
.filter(
|
||||
(user) =>
|
||||
user.name?.includes(search.value) ||
|
||||
user.full_name?.includes(search.value),
|
||||
)
|
||||
.filter((user) => {
|
||||
if (currentRole.value === 'All') return true
|
||||
return user.role === currentRole.value
|
||||
})
|
||||
})
|
||||
|
||||
function getMoreOptions(user) {
|
||||
let options = [
|
||||
{
|
||||
label: __('Remove'),
|
||||
icon: 'trash-2',
|
||||
onClick: () => removeUser(user, true),
|
||||
},
|
||||
]
|
||||
|
||||
return options.filter((option) => option.condition?.() || true)
|
||||
}
|
||||
|
||||
function getDropdownOptions(user) {
|
||||
let options = [
|
||||
{
|
||||
label: __('Admin'),
|
||||
component: (props) =>
|
||||
RoleOption({
|
||||
role: __('Admin'),
|
||||
active: props.active,
|
||||
selected: user.role === 'System Manager',
|
||||
onClick: () => updateRole(user, 'System Manager'),
|
||||
}),
|
||||
condition: () => isAdmin(),
|
||||
},
|
||||
{
|
||||
label: __('Manager'),
|
||||
component: (props) =>
|
||||
RoleOption({
|
||||
role: __('Manager'),
|
||||
active: props.active,
|
||||
selected: user.role === 'Sales Manager',
|
||||
onClick: () => updateRole(user, 'Sales Manager'),
|
||||
}),
|
||||
condition: () => isManager(),
|
||||
},
|
||||
{
|
||||
label: __('Sales User'),
|
||||
component: (props) =>
|
||||
RoleOption({
|
||||
role: __('Sales User'),
|
||||
active: props.active,
|
||||
selected: user.role === 'Sales User',
|
||||
onClick: () => updateRole(user, 'Sales User'),
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
return options.filter((option) => option.condition?.() || true)
|
||||
}
|
||||
|
||||
function RoleOption({ active, role, onClick, selected }) {
|
||||
return h(
|
||||
'button',
|
||||
{
|
||||
class: [
|
||||
active ? 'bg-surface-gray-2' : 'text-ink-gray-8',
|
||||
'group flex w-full justify-between items-center rounded-md px-2 py-2 text-sm',
|
||||
],
|
||||
onClick: !selected ? onClick : null,
|
||||
},
|
||||
[
|
||||
h('span', { class: 'whitespace-nowrap' }, role),
|
||||
selected
|
||||
? h(LucideCheck, {
|
||||
class: ['h-4 w-4 shrink-0 text-ink-gray-7'],
|
||||
'aria-hidden': true,
|
||||
})
|
||||
: null,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
function updateRole(user, newRole) {
|
||||
if (user.role === newRole) return
|
||||
|
||||
call('crm.api.user.update_user_role', {
|
||||
user: user.name,
|
||||
new_role: newRole,
|
||||
}).then(() => {
|
||||
toast.success(
|
||||
__('{0} has been granted {1} access', [user.full_name, roleMap[newRole]]),
|
||||
)
|
||||
users.reload()
|
||||
})
|
||||
}
|
||||
|
||||
function removeUser(user) {
|
||||
call('crm.api.user.remove_user', {
|
||||
user: user.name,
|
||||
}).then(() => {
|
||||
toast.success(__('User {0} has been removed', [user.full_name]))
|
||||
users.reload()
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (searchRef.value) {
|
||||
searchRef.value.el.focus()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@ -25,7 +25,9 @@
|
||||
class="w-7 mr-2"
|
||||
@click="showSidePanelModal = true"
|
||||
>
|
||||
<EditIcon class="h-4 w-4" />
|
||||
<template #icon>
|
||||
<EditIcon />
|
||||
</template>
|
||||
</Button>
|
||||
</slot>
|
||||
</template>
|
||||
@ -44,18 +46,21 @@
|
||||
>
|
||||
<Tooltip :text="__(field.label)" :hoverDelay="1">
|
||||
<div
|
||||
class="w-[35%] min-w-20 shrink-0 truncate text-sm text-ink-gray-5"
|
||||
class="w-[35%] min-w-20 shrink-0 flex items-center gap-0.5"
|
||||
>
|
||||
{{ __(field.label) }}
|
||||
<span
|
||||
<div class="truncate text-sm text-ink-gray-5">
|
||||
{{ __(field.label) }}
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
field.reqd ||
|
||||
(field.mandatory_depends_on &&
|
||||
field.mandatory_via_depends_on)
|
||||
"
|
||||
class="text-ink-red-2"
|
||||
>*</span
|
||||
>
|
||||
*
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div class="flex items-center justify-between w-[65%]">
|
||||
@ -245,6 +250,7 @@
|
||||
"
|
||||
:placeholder="field.placeholder"
|
||||
placement="left-start"
|
||||
:hideIcon="true"
|
||||
@change="(v) => fieldChange(v, field)"
|
||||
/>
|
||||
</div>
|
||||
@ -258,6 +264,7 @@
|
||||
:formatter="(date) => getFormat(date, '', true)"
|
||||
:placeholder="field.placeholder"
|
||||
placement="left-start"
|
||||
:hideIcon="true"
|
||||
@change="(v) => fieldChange(v, field)"
|
||||
/>
|
||||
</div>
|
||||
@ -422,7 +429,7 @@ const emit = defineEmits(['afterFieldChange', 'reload'])
|
||||
const { getFormattedPercent, getFormattedFloat, getFormattedCurrency } =
|
||||
getMeta(props.doctype)
|
||||
|
||||
const { isManager, getUser } = usersStore()
|
||||
const { users, isManager, getUser } = usersStore()
|
||||
|
||||
const showSidePanelModal = ref(false)
|
||||
|
||||
@ -464,8 +471,11 @@ function parsedField(field) {
|
||||
}
|
||||
|
||||
if (field.fieldtype === 'Link' && field.options === 'User') {
|
||||
field.options = field.options
|
||||
field.fieldtype = 'User'
|
||||
field.link_filters = JSON.stringify({
|
||||
...(field.link_filters ? JSON.parse(field.link_filters) : {}),
|
||||
name: ['in', users.data?.crmUsers?.map((user) => user.name)],
|
||||
})
|
||||
}
|
||||
|
||||
let _field = {
|
||||
@ -489,9 +499,7 @@ function parsedField(field) {
|
||||
async function fieldChange(value, df) {
|
||||
if (props.preview) return
|
||||
|
||||
document.doc[df.fieldname] = value
|
||||
|
||||
await triggerOnChange(df.fieldname)
|
||||
await triggerOnChange(df.fieldname, value)
|
||||
|
||||
document.save.submit(null, {
|
||||
onSuccess: () => {
|
||||
|
||||
@ -41,7 +41,9 @@
|
||||
variant="ghost"
|
||||
@click="section.editingLabel = true"
|
||||
>
|
||||
<EditIcon class="h-3.5" />
|
||||
<template #icon>
|
||||
<EditIcon class="h-3.5" />
|
||||
</template>
|
||||
</Button>
|
||||
<Button
|
||||
v-if="section.editable !== false"
|
||||
|
||||
@ -45,6 +45,9 @@
|
||||
doctype="User"
|
||||
@change="(option) => (task.assigned_to = option)"
|
||||
:placeholder="__('John Doe')"
|
||||
:filters="{
|
||||
name: ['in', users.data?.crmUsers?.map((user) => user.name)],
|
||||
}"
|
||||
:hideMe="true"
|
||||
>
|
||||
<template #prefix>
|
||||
@ -94,7 +97,7 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const { getUser } = usersStore()
|
||||
const { users, getUser } = usersStore()
|
||||
|
||||
function updateTaskStatus(status) {
|
||||
props.task.status = status
|
||||
|
||||
@ -6,4 +6,6 @@ export const quickEntryProps = ref({});
|
||||
export const showAddressModal = ref(false);
|
||||
export const addressProps = ref({});
|
||||
|
||||
export const showAboutModal = ref(false);
|
||||
export const showAboutModal = ref(false);
|
||||
|
||||
export const showChangePasswordModal = ref(false);
|
||||
@ -23,7 +23,14 @@ export function useDocument(doctype, docname) {
|
||||
toast.success(__('Document updated successfully'))
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(__('Error updating document'))
|
||||
let errorMessage = __('Error updating document')
|
||||
if (err.exc_type == 'MandatoryError') {
|
||||
const fieldName = err.messages
|
||||
.map((msg) => msg.split(': ')[2].trim())
|
||||
.join(', ')
|
||||
errorMessage = __('Mandatory field error: {0}', [fieldName])
|
||||
}
|
||||
toast.error(errorMessage)
|
||||
console.error(err)
|
||||
},
|
||||
},
|
||||
@ -117,20 +124,26 @@ export function useDocument(doctype, docname) {
|
||||
await trigger(handler)
|
||||
}
|
||||
|
||||
async function triggerOnChange(fieldname, row) {
|
||||
async function triggerOnChange(fieldname, value, row) {
|
||||
const oldValue = documentsCache[doctype][docname || ''].doc[fieldname]
|
||||
documentsCache[doctype][docname || ''].doc[fieldname] = value
|
||||
|
||||
const handler = async function () {
|
||||
this.value = value
|
||||
this.oldValue = oldValue
|
||||
if (row) {
|
||||
this.currentRowIdx = row.idx
|
||||
this.value = row[fieldname]
|
||||
this.oldValue = getOldValue(fieldname, row)
|
||||
} else {
|
||||
this.value = documentsCache[doctype][docname || ''].doc[fieldname]
|
||||
this.oldValue = getOldValue(fieldname)
|
||||
}
|
||||
await this[fieldname]?.()
|
||||
}
|
||||
|
||||
await trigger(handler, row)
|
||||
try {
|
||||
await trigger(handler, row)
|
||||
} catch (error) {
|
||||
documentsCache[doctype][docname || ''].doc[fieldname] = oldValue
|
||||
console.error(handler)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerOnRowAdd(row) {
|
||||
|
||||
@ -46,6 +46,11 @@ export function getScript(doctype, view = 'Form') {
|
||||
helpers.router = router
|
||||
helpers.call = call
|
||||
|
||||
helpers.throwError = (message) => {
|
||||
toast.error(message || __('An error occurred'))
|
||||
throw new Error(message || __('An error occurred'))
|
||||
}
|
||||
|
||||
helpers.crm = {
|
||||
makePhoneCall: makeCall,
|
||||
}
|
||||
|
||||
@ -23,7 +23,14 @@
|
||||
/>
|
||||
<Dropdown
|
||||
v-if="document.doc"
|
||||
:options="statusOptions('deal', document, deal.data._customStatuses)"
|
||||
:options="
|
||||
statusOptions(
|
||||
'deal',
|
||||
document,
|
||||
deal.data._customStatuses,
|
||||
triggerOnChange,
|
||||
)
|
||||
"
|
||||
>
|
||||
<template #default="{ open }">
|
||||
<Button :label="document.doc.status">
|
||||
@ -85,42 +92,50 @@
|
||||
<Tooltip v-if="callEnabled" :text="__('Make a call')">
|
||||
<div>
|
||||
<Button class="h-7 w-7" @click="triggerCall">
|
||||
<PhoneIcon class="h-4 w-4" />
|
||||
<template #icon>
|
||||
<PhoneIcon />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip :text="__('Send an email')">
|
||||
<div>
|
||||
<Button class="h-7 w-7">
|
||||
<Email2Icon
|
||||
class="h-4 w-4"
|
||||
@click="
|
||||
deal.data.email
|
||||
? openEmailBox()
|
||||
: toast.error(__('No email set'))
|
||||
"
|
||||
/>
|
||||
<Button
|
||||
class="h-7 w-7"
|
||||
@click="
|
||||
deal.data.email
|
||||
? openEmailBox()
|
||||
: toast.error(__('No email set'))
|
||||
"
|
||||
>
|
||||
<template #icon>
|
||||
<Email2Icon />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip :text="__('Go to website')">
|
||||
<div>
|
||||
<Button class="h-7 w-7">
|
||||
<LinkIcon
|
||||
class="h-4 w-4"
|
||||
@click="
|
||||
deal.data.website
|
||||
? openWebsite(deal.data.website)
|
||||
: toast.error(__('No website set'))
|
||||
"
|
||||
/>
|
||||
<Button
|
||||
class="h-7 w-7"
|
||||
@click="
|
||||
deal.data.website
|
||||
? openWebsite(deal.data.website)
|
||||
: toast.error(__('No website set'))
|
||||
"
|
||||
>
|
||||
<template #icon>
|
||||
<LinkIcon />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip :text="__('Attach a file')">
|
||||
<div>
|
||||
<Button class="size-7" @click="showFilesUploader = true">
|
||||
<AttachmentIcon class="size-4" />
|
||||
<template #icon>
|
||||
<AttachmentIcon />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
@ -232,14 +247,18 @@
|
||||
})
|
||||
"
|
||||
>
|
||||
<ArrowUpRightIcon class="h-4 w-4" />
|
||||
<template #icon>
|
||||
<ArrowUpRightIcon class="h-4 w-4" />
|
||||
</template>
|
||||
</Button>
|
||||
<Button variant="ghost" @click="toggle()">
|
||||
<FeatherIcon
|
||||
name="chevron-right"
|
||||
class="h-4 w-4 text-ink-gray-9 transition-all duration-300 ease-in-out"
|
||||
:class="{ 'rotate-90': opened }"
|
||||
/>
|
||||
<template #icon>
|
||||
<FeatherIcon
|
||||
name="chevron-right"
|
||||
class="h-4 w-4 text-ink-gray-9 transition-all duration-300 ease-in-out"
|
||||
:class="{ 'rotate-90': opened }"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -361,7 +380,7 @@ import {
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { useOnboarding } from 'frappe-ui/frappe'
|
||||
import { ref, computed, h, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ref, computed, h, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useActiveTabManager } from '@/composables/useActiveTabManager'
|
||||
|
||||
@ -723,10 +742,17 @@ async function deleteDeal(name) {
|
||||
const activities = ref(null)
|
||||
|
||||
function openEmailBox() {
|
||||
activities.value.emailBox.show = true
|
||||
let currentTab = tabs.value[tabIndex.value]
|
||||
if (!['Emails', 'Comments', 'Activities'].includes(currentTab.name)) {
|
||||
activities.value.changeTabTo('emails')
|
||||
}
|
||||
nextTick(() => (activities.value.emailBox.show = true))
|
||||
}
|
||||
|
||||
const { assignees, document } = useDocument('CRM Deal', props.dealId)
|
||||
const { assignees, document, triggerOnChange } = useDocument(
|
||||
'CRM Deal',
|
||||
props.dealId,
|
||||
)
|
||||
|
||||
function reloadAssignees(data) {
|
||||
if (data?.hasOwnProperty('deal_owner')) {
|
||||
|
||||
@ -23,7 +23,14 @@
|
||||
/>
|
||||
<Dropdown
|
||||
v-if="document.doc"
|
||||
:options="statusOptions('lead', document, lead.data._customStatuses)"
|
||||
:options="
|
||||
statusOptions(
|
||||
'lead',
|
||||
document,
|
||||
lead.data._customStatuses,
|
||||
triggerOnChange,
|
||||
)
|
||||
"
|
||||
>
|
||||
<template #default="{ open }">
|
||||
<Button :label="document.doc.status">
|
||||
@ -135,42 +142,50 @@
|
||||
: toast.error(__('No phone number set'))
|
||||
"
|
||||
>
|
||||
<PhoneIcon class="h-4 w-4" />
|
||||
<template #icon>
|
||||
<PhoneIcon />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip :text="__('Send an email')">
|
||||
<div>
|
||||
<Button class="h-7 w-7">
|
||||
<Email2Icon
|
||||
class="h-4 w-4"
|
||||
@click="
|
||||
lead.data.email
|
||||
? openEmailBox()
|
||||
: toast.error(__('No email set'))
|
||||
"
|
||||
/>
|
||||
<Button
|
||||
class="h-7 w-7"
|
||||
@click="
|
||||
lead.data.email
|
||||
? openEmailBox()
|
||||
: toast.error(__('No email set'))
|
||||
"
|
||||
>
|
||||
<template #icon>
|
||||
<Email2Icon />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip :text="__('Go to website')">
|
||||
<div>
|
||||
<Button class="h-7 w-7">
|
||||
<LinkIcon
|
||||
class="h-4 w-4"
|
||||
@click="
|
||||
lead.data.website
|
||||
? openWebsite(lead.data.website)
|
||||
: toast.error(__('No website set'))
|
||||
"
|
||||
/>
|
||||
<Button
|
||||
class="h-7 w-7"
|
||||
@click="
|
||||
lead.data.website
|
||||
? openWebsite(lead.data.website)
|
||||
: toast.error(__('No website set'))
|
||||
"
|
||||
>
|
||||
<template #icon>
|
||||
<LinkIcon />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip :text="__('Attach a file')">
|
||||
<div>
|
||||
<Button class="h-7 w-7" @click="showFilesUploader = true">
|
||||
<AttachmentIcon class="h-4 w-4" />
|
||||
<template #icon>
|
||||
<AttachmentIcon />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
@ -231,14 +246,18 @@
|
||||
class="w-7"
|
||||
@click="openQuickEntryModal"
|
||||
>
|
||||
<EditIcon class="h-4 w-4" />
|
||||
<template #icon>
|
||||
<EditIcon class="h-4 w-4" />
|
||||
</template>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-7"
|
||||
@click="showConvertToDealModal = false"
|
||||
>
|
||||
<FeatherIcon name="x" class="h-4 w-4" />
|
||||
<template #icon>
|
||||
<FeatherIcon name="x" class="h-4 w-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -379,7 +398,7 @@ import {
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { useOnboarding } from 'frappe-ui/frappe'
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useActiveTabManager } from '@/composables/useActiveTabManager'
|
||||
|
||||
@ -610,10 +629,8 @@ const existingOrganizationChecked = ref(false)
|
||||
const existingContact = ref('')
|
||||
const existingOrganization = ref('')
|
||||
|
||||
const { triggerConvertToDeal, assignees, document } = useDocument(
|
||||
'CRM Lead',
|
||||
props.leadId,
|
||||
)
|
||||
const { triggerConvertToDeal, triggerOnChange, assignees, document } =
|
||||
useDocument('CRM Lead', props.leadId)
|
||||
|
||||
async function convertToDeal() {
|
||||
if (existingContactChecked.value && !existingContact.value) {
|
||||
@ -665,7 +682,11 @@ async function convertToDeal() {
|
||||
const activities = ref(null)
|
||||
|
||||
function openEmailBox() {
|
||||
activities.value.emailBox.show = true
|
||||
let currentTab = tabs.value[tabIndex.value]
|
||||
if (!['Emails', 'Comments', 'Activities'].includes(currentTab.name)) {
|
||||
activities.value.changeTabTo('emails')
|
||||
}
|
||||
nextTick(() => (activities.value.emailBox.show = true))
|
||||
}
|
||||
|
||||
const deal = reactive({})
|
||||
|
||||
@ -11,7 +11,14 @@
|
||||
<div class="absolute right-0">
|
||||
<Dropdown
|
||||
v-if="document.doc"
|
||||
:options="statusOptions('deal', document, deal.data._customStatuses)"
|
||||
:options="
|
||||
statusOptions(
|
||||
'deal',
|
||||
document,
|
||||
deal.data._customStatuses,
|
||||
triggerOnChange,
|
||||
)
|
||||
"
|
||||
>
|
||||
<template #default="{ open }">
|
||||
<Button :label="document.doc.status">
|
||||
@ -612,7 +619,10 @@ async function deleteDeal(name) {
|
||||
router.push({ name: 'Deals' })
|
||||
}
|
||||
|
||||
const { assignees, document } = useDocument('CRM Deal', props.dealId)
|
||||
const { assignees, document, triggerOnChange } = useDocument(
|
||||
'CRM Deal',
|
||||
props.dealId,
|
||||
)
|
||||
|
||||
function reloadAssignees(data) {
|
||||
if (data?.hasOwnProperty('deal_owner')) {
|
||||
|
||||
@ -11,7 +11,14 @@
|
||||
<div class="absolute right-0">
|
||||
<Dropdown
|
||||
v-if="document.doc"
|
||||
:options="statusOptions('lead', document, lead.data._customStatuses)"
|
||||
:options="
|
||||
statusOptions(
|
||||
'lead',
|
||||
document,
|
||||
lead.data._customStatuses,
|
||||
triggerOnChange,
|
||||
)
|
||||
"
|
||||
>
|
||||
<template #default="{ open }">
|
||||
<Button :label="document.doc.status">
|
||||
@ -461,7 +468,10 @@ async function convertToDeal() {
|
||||
}
|
||||
}
|
||||
|
||||
const { assignees, document } = useDocument('CRM Lead', props.leadId)
|
||||
const { assignees, document, triggerOnChange } = useDocument(
|
||||
'CRM Lead',
|
||||
props.leadId,
|
||||
)
|
||||
|
||||
function reloadAssignees(data) {
|
||||
if (data?.hasOwnProperty('lead_owner')) {
|
||||
|
||||
@ -77,7 +77,12 @@ export const statusesStore = defineStore('crm-statuses', () => {
|
||||
return communicationStatuses[name]
|
||||
}
|
||||
|
||||
function statusOptions(doctype, document, statuses = []) {
|
||||
function statusOptions(
|
||||
doctype,
|
||||
document,
|
||||
statuses = [],
|
||||
triggerOnChange = null,
|
||||
) {
|
||||
let statusesByName =
|
||||
doctype == 'deal' ? dealStatusesByName : leadStatusesByName
|
||||
|
||||
@ -85,7 +90,7 @@ export const statusesStore = defineStore('crm-statuses', () => {
|
||||
statuses = document.statuses
|
||||
}
|
||||
|
||||
if (statuses.length) {
|
||||
if (statuses?.length) {
|
||||
statusesByName = statuses.reduce((acc, status) => {
|
||||
acc[status] = statusesByName[status]
|
||||
return acc
|
||||
@ -98,10 +103,10 @@ export const statusesStore = defineStore('crm-statuses', () => {
|
||||
label: statusesByName[status]?.name,
|
||||
value: statusesByName[status]?.name,
|
||||
icon: () => h(IndicatorIcon, { class: statusesByName[status]?.color }),
|
||||
onClick: () => {
|
||||
onClick: async () => {
|
||||
capture('status_changed', { doctype, status })
|
||||
if (document) {
|
||||
document.doc.status = statusesByName[status]?.name
|
||||
await triggerOnChange?.('status', statusesByName[status]?.name)
|
||||
document.save.submit()
|
||||
}
|
||||
},
|
||||
|
||||
@ -12,17 +12,17 @@ export const usersStore = defineStore('crm-users', () => {
|
||||
|
||||
const users = createResource({
|
||||
url: 'crm.api.session.get_users',
|
||||
cache: 'Users',
|
||||
cache: 'crm-users',
|
||||
initialData: [],
|
||||
auto: true,
|
||||
transform(users) {
|
||||
for (let user of users) {
|
||||
transform([allUsers, crmUsers]) {
|
||||
for (let user of allUsers) {
|
||||
usersByName[user.name] = user
|
||||
if (user.name === 'Administrator') {
|
||||
usersByName[user.email] = user
|
||||
}
|
||||
}
|
||||
return users
|
||||
return { allUsers, crmUsers }
|
||||
},
|
||||
onError(error) {
|
||||
if (error && error.exc_type === 'AuthenticationError') {
|
||||
@ -49,6 +49,10 @@ export const usersStore = defineStore('crm-users', () => {
|
||||
return usersByName[email]
|
||||
}
|
||||
|
||||
function isAdmin(email) {
|
||||
return getUser(email).is_admin
|
||||
}
|
||||
|
||||
function isManager(email) {
|
||||
return getUser(email).is_manager
|
||||
}
|
||||
@ -57,10 +61,20 @@ export const usersStore = defineStore('crm-users', () => {
|
||||
return getUser(email).is_agent
|
||||
}
|
||||
|
||||
function getUserRole(email) {
|
||||
const user = getUser(email)
|
||||
if (user && user.role) {
|
||||
return user.role
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
users,
|
||||
getUser,
|
||||
isAdmin,
|
||||
isManager,
|
||||
isAgent,
|
||||
getUserRole,
|
||||
}
|
||||
})
|
||||
|
||||
17
yarn.lock
17
yarn.lock
@ -1320,6 +1320,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-heading/-/extension-heading-2.10.3.tgz#bf8efb3a580c75b86dce505a63f1ca7450a9aaea"
|
||||
integrity sha512-AlxXXPCWIvw8hQUDFRskasj32iMNB8Sb19VgyFWqwvntGs2/UffNu8VdsVqxD2HpZ0g5rLYCYtSW4wigs9R3og==
|
||||
|
||||
"@tiptap/extension-heading@^2.12.0":
|
||||
version "2.14.0"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-heading/-/extension-heading-2.14.0.tgz#c5a9dc761712e9c87073ba8446548cbe4d403360"
|
||||
integrity sha512-vM//6G3Ox3mxPv9eilhrDqylELCc8kEP1aQ4xUuOw7vCidjNtGggOa1ERnnpV2dCa2A9E8y4FHtN4Xh29stXQg==
|
||||
|
||||
"@tiptap/extension-highlight@^2.0.3":
|
||||
version "2.10.3"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-highlight/-/extension-highlight-2.10.3.tgz#d94667d435d9dc556b06e7b764449dc2a6c18743"
|
||||
@ -2565,10 +2570,10 @@ fraction.js@^4.3.7:
|
||||
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7"
|
||||
integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==
|
||||
|
||||
frappe-ui@^0.1.145:
|
||||
version "0.1.145"
|
||||
resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.145.tgz#19ec429badf85f3f2c45a85ec13c3c462ec11ee9"
|
||||
integrity sha512-DnnSJREu/EpUAJGNFaXEUF3re0hQMmLBOX/MSW9AsQtnCJwXkO5VbH/dyVHAZjqdb9Do3CNQF33/HB4NibNI8Q==
|
||||
frappe-ui@^0.1.156:
|
||||
version "0.1.156"
|
||||
resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.156.tgz#1a476aec80b0e0f72470f9dc3990bb023b2ebb09"
|
||||
integrity sha512-JsIODLL7YYFhKSYfWJJ9M1+VMmj8M0xZ1D5M7Cx0c+OWg5Qm0xda1592Tr+om1a7u0zWcfjuQnW9mHN1lW5HIA==
|
||||
dependencies:
|
||||
"@floating-ui/vue" "^1.1.6"
|
||||
"@headlessui/vue" "^1.7.14"
|
||||
@ -2579,6 +2584,7 @@ frappe-ui@^0.1.145:
|
||||
"@tiptap/extension-code-block" "^2.11.9"
|
||||
"@tiptap/extension-code-block-lowlight" "^2.11.5"
|
||||
"@tiptap/extension-color" "^2.0.3"
|
||||
"@tiptap/extension-heading" "^2.12.0"
|
||||
"@tiptap/extension-highlight" "^2.0.3"
|
||||
"@tiptap/extension-image" "^2.0.3"
|
||||
"@tiptap/extension-link" "^2.0.3"
|
||||
@ -2599,6 +2605,7 @@ frappe-ui@^0.1.145:
|
||||
dayjs "^1.11.13"
|
||||
echarts "^5.6.0"
|
||||
feather-icons "^4.28.0"
|
||||
highlight.js "^11.11.1"
|
||||
idb-keyval "^6.2.0"
|
||||
lowlight "^3.3.0"
|
||||
lucide-static "^0.479.0"
|
||||
@ -2806,7 +2813,7 @@ hasown@^2.0.1, hasown@^2.0.2:
|
||||
dependencies:
|
||||
function-bind "^1.1.2"
|
||||
|
||||
highlight.js@~11.11.0:
|
||||
highlight.js@^11.11.1, highlight.js@~11.11.0:
|
||||
version "11.11.1"
|
||||
resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.11.1.tgz#fca06fa0e5aeecf6c4d437239135fabc15213585"
|
||||
integrity sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user