Merge pull request #845 from shariquerik/agents

This commit is contained in:
Shariq Ansari 2025-06-23 13:22:56 +05:30 committed by GitHub
commit 2c45673f54
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 1481 additions and 411 deletions

View File

@ -71,7 +71,7 @@ def check_app_permission():
roles = frappe.get_roles() roles = frappe.get_roles()
if any( 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 return True
@ -99,9 +99,9 @@ def accept_invitation(key: str | None = None):
@frappe.whitelist() @frappe.whitelist()
def invite_by_email(emails: str, role: str): def invite_by_email(emails: str, role: str):
frappe.only_for("Sales Manager") frappe.only_for(["Sales Manager", "System Manager"])
if role not in ["Sales Manager", "Sales User"]: if role not in ["System Manager", "Sales Manager", "Sales User"]:
frappe.throw("Cannot invite for this role") frappe.throw("Cannot invite for this role")
if not emails: if not emails:
@ -114,7 +114,10 @@ def invite_by_email(emails: str, role: str):
existing_members = frappe.db.get_all("User", filters={"email": ["in", email_list]}, pluck="email") existing_members = frappe.db.get_all("User", filters={"email": ["in", email_list]}, pluck="email")
existing_invites = frappe.db.get_all( existing_invites = frappe.db.get_all(
"CRM Invitation", "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", pluck="email",
) )

View File

@ -23,11 +23,35 @@ def get_users():
if frappe.session.user == user.name: if frappe.session.user == user.name:
user.session_user = True 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}) 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() @frappe.whitelist()

84
crm/api/user.py Normal file
View 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)

View File

@ -27,7 +27,7 @@
"fieldtype": "Select", "fieldtype": "Select",
"in_list_view": 1, "in_list_view": 1,
"label": "Role", "label": "Role",
"options": "\nSales User\nSales Manager", "options": "\nSales User\nSales Manager\nSystem Manager",
"reqd": 1 "reqd": 1
}, },
{ {
@ -66,7 +66,7 @@
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2024-09-03 14:59:29.450018", "modified": "2025-06-17 17:20:18.935395",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "FCRM", "module": "FCRM",
"name": "CRM Invitation", "name": "CRM Invitation",
@ -106,7 +106,8 @@
"share": 1 "share": 1
} }
], ],
"row_format": "Dynamic",
"sort_field": "creation", "sort_field": "creation",
"sort_order": "DESC", "sort_order": "DESC",
"states": [] "states": []
} }

View File

@ -35,7 +35,7 @@ class CRMInvitation(Document):
@frappe.whitelist() @frappe.whitelist()
def accept_invitation(self): def accept_invitation(self):
frappe.only_for("System Manager") frappe.only_for(["System Manager", "Sales Manager"])
self.accept() self.accept()
def accept(self): def accept(self):

@ -1 +1 @@
Subproject commit 8b615c0e899d75b99c7d36ec6df97b5d0386b2ca Subproject commit 883bb643d1e662d6467925927e347dd28376960f

View File

@ -12,6 +12,7 @@ declare module 'vue' {
Activities: typeof import('./src/components/Activities/Activities.vue')['default'] Activities: typeof import('./src/components/Activities/Activities.vue')['default']
ActivityHeader: typeof import('./src/components/Activities/ActivityHeader.vue')['default'] ActivityHeader: typeof import('./src/components/Activities/ActivityHeader.vue')['default']
ActivityIcon: typeof import('./src/components/Icons/ActivityIcon.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'] AddressIcon: typeof import('./src/components/Icons/AddressIcon.vue')['default']
AddressModal: typeof import('./src/components/Modals/AddressModal.vue')['default'] AddressModal: typeof import('./src/components/Modals/AddressModal.vue')['default']
AllModals: typeof import('./src/components/Activities/AllModals.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'] CallUI: typeof import('./src/components/Telephony/CallUI.vue')['default']
CameraIcon: typeof import('./src/components/Icons/CameraIcon.vue')['default'] CameraIcon: typeof import('./src/components/Icons/CameraIcon.vue')['default']
CertificateIcon: typeof import('./src/components/Icons/CertificateIcon.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'] CheckCircleIcon: typeof import('./src/components/Icons/CheckCircleIcon.vue')['default']
CheckIcon: typeof import('./src/components/Icons/CheckIcon.vue')['default'] CheckIcon: typeof import('./src/components/Icons/CheckIcon.vue')['default']
CollapseSidebar: typeof import('./src/components/Icons/CollapseSidebar.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'] InboxIcon: typeof import('./src/components/Icons/InboxIcon.vue')['default']
IndicatorIcon: typeof import('./src/components/Icons/IndicatorIcon.vue')['default'] IndicatorIcon: typeof import('./src/components/Icons/IndicatorIcon.vue')['default']
InviteIcon: typeof import('./src/components/Icons/InviteIcon.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'] KanbanIcon: typeof import('./src/components/Icons/KanbanIcon.vue')['default']
KanbanSettings: typeof import('./src/components/Kanban/KanbanSettings.vue')['default'] KanbanSettings: typeof import('./src/components/Kanban/KanbanSettings.vue')['default']
KanbanView: typeof import('./src/components/Kanban/KanbanView.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'] LayoutHeader: typeof import('./src/components/LayoutHeader.vue')['default']
LeadModal: typeof import('./src/components/Modals/LeadModal.vue')['default'] LeadModal: typeof import('./src/components/Modals/LeadModal.vue')['default']
LeadsIcon: typeof import('./src/components/Icons/LeadsIcon.vue')['default'] LeadsIcon: typeof import('./src/components/Icons/LeadsIcon.vue')['default']
@ -151,7 +154,9 @@ declare module 'vue' {
ListIcon: typeof import('./src/components/Icons/ListIcon.vue')['default'] ListIcon: typeof import('./src/components/Icons/ListIcon.vue')['default']
ListRows: typeof import('./src/components/ListViews/ListRows.vue')['default'] ListRows: typeof import('./src/components/ListViews/ListRows.vue')['default']
LoadingIndicator: typeof import('./src/components/Icons/LoadingIndicator.vue')['default'] LoadingIndicator: typeof import('./src/components/Icons/LoadingIndicator.vue')['default']
LucideInfo: typeof import('~icons/lucide/info')['default']
LucidePlus: typeof import('~icons/lucide/plus')['default'] LucidePlus: typeof import('~icons/lucide/plus')['default']
LucideSearch: typeof import('~icons/lucide/search')['default']
MarkAsDoneIcon: typeof import('./src/components/Icons/MarkAsDoneIcon.vue')['default'] MarkAsDoneIcon: typeof import('./src/components/Icons/MarkAsDoneIcon.vue')['default']
MaximizeIcon: typeof import('./src/components/Icons/MaximizeIcon.vue')['default'] MaximizeIcon: typeof import('./src/components/Icons/MaximizeIcon.vue')['default']
MenuIcon: typeof import('./src/components/Icons/MenuIcon.vue')['default'] MenuIcon: typeof import('./src/components/Icons/MenuIcon.vue')['default']
@ -164,6 +169,7 @@ declare module 'vue' {
MultiActionButton: typeof import('./src/components/MultiActionButton.vue')['default'] MultiActionButton: typeof import('./src/components/MultiActionButton.vue')['default']
MultipleAvatar: typeof import('./src/components/MultipleAvatar.vue')['default'] MultipleAvatar: typeof import('./src/components/MultipleAvatar.vue')['default']
MultiSelectEmailInput: typeof import('./src/components/Controls/MultiSelectEmailInput.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'] MuteIcon: typeof import('./src/components/Icons/MuteIcon.vue')['default']
NestedPopover: typeof import('./src/components/NestedPopover.vue')['default'] NestedPopover: typeof import('./src/components/NestedPopover.vue')['default']
NoteArea: typeof import('./src/components/Activities/NoteArea.vue')['default'] NoteArea: typeof import('./src/components/Activities/NoteArea.vue')['default']
@ -228,6 +234,7 @@ declare module 'vue' {
UnpinIcon: typeof import('./src/components/Icons/UnpinIcon.vue')['default'] UnpinIcon: typeof import('./src/components/Icons/UnpinIcon.vue')['default']
UserAvatar: typeof import('./src/components/UserAvatar.vue')['default'] UserAvatar: typeof import('./src/components/UserAvatar.vue')['default']
UserDropdown: typeof import('./src/components/UserDropdown.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'] ViewBreadcrumbs: typeof import('./src/components/ViewBreadcrumbs.vue')['default']
ViewControls: typeof import('./src/components/ViewControls.vue')['default'] ViewControls: typeof import('./src/components/ViewControls.vue')['default']
ViewModal: typeof import('./src/components/Modals/ViewModal.vue')['default'] ViewModal: typeof import('./src/components/Modals/ViewModal.vue')['default']

View File

@ -31,7 +31,7 @@
</div> </div>
<div <div
v-if="document.get.loading" 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" /> <LoadingIndicator class="h-6 w-6" />
<span>{{ __('Loading...') }}</span> <span>{{ __('Loading...') }}</span>

View File

@ -149,7 +149,7 @@ function removeAttachment(attachment) {
const users = computed(() => { const users = computed(() => {
return ( return (
usersList.data usersList.data?.crmUsers
?.filter((user) => user.enabled) ?.filter((user) => user.enabled)
.map((user) => ({ .map((user) => ({
label: user.full_name.trimEnd(), label: user.full_name.trimEnd(),

View File

@ -380,9 +380,9 @@ const props = defineProps({
}, },
}) })
const triggerOnChange = inject('triggerOnChange') const triggerOnChange = inject('triggerOnChange', () => {})
const triggerOnRowAdd = inject('triggerOnRowAdd') const triggerOnRowAdd = inject('triggerOnRowAdd', () => {})
const triggerOnRowRemove = inject('triggerOnRowRemove') const triggerOnRowRemove = inject('triggerOnRowRemove', () => {})
const { const {
getGridViewSettings, getGridViewSettings,
@ -393,7 +393,7 @@ const {
getGridSettings, getGridSettings,
} = getMeta(props.doctype) } = getMeta(props.doctype)
getMeta(props.parentDoctype) getMeta(props.parentDoctype)
const { getUser } = usersStore() const { users, getUser } = usersStore()
const rows = defineModel() const rows = defineModel()
const parentDoc = defineModel('parent') const parentDoc = defineModel('parent')
@ -438,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 { return {
...field, ...field,
filters: field.link_filters && JSON.parse(field.link_filters), filters: field.link_filters && JSON.parse(field.link_filters),

View File

@ -70,7 +70,7 @@
{{ {{
fetchContacts fetchContacts
? __('No results found') ? __('No results found')
: __('Type an email address to add') : __('Type an email address to invite')
}} }}
</div> </div>
<ComboboxOption <ComboboxOption
@ -156,6 +156,10 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: true, default: true,
}, },
existingEmails: {
type: Array,
default: () => [],
},
}) })
const values = defineModel() const values = defineModel()
@ -205,6 +209,14 @@ const filterOptions = createResource({
value: email, value: email,
} }
}) })
// Filter out existing emails
if (props.existingEmails?.length) {
allData = allData.filter((option) => {
return !props.existingEmails.includes(option.value)
})
}
return allData return allData
}, },
}) })

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

View File

@ -3,17 +3,47 @@
:type="show ? 'text' : 'password'" :type="show ? 'text' : 'password'"
:value="modelValue || value" :value="modelValue || value"
v-bind="$attrs" 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> <template #suffix>
<Button v-show="showEye" class="!h-4" @click="show = !show"> <Tooltip>
<FeatherIcon :name="show ? 'eye-off' : 'eye'" class="h-3" /> <template #body>
</Button> <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> </template>
</FormControl> </FormControl>
</template> </template>
<script setup> <script setup>
import { FormControl } from 'frappe-ui' import KeyboardShortcut from '@/components/KeyboardShortcut.vue'
import { FormControl, Tooltip } from 'frappe-ui'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
type: [String, Number], type: [String, Number],

View File

@ -243,7 +243,7 @@ const isGridRow = inject('isGridRow')
const { getFormattedPercent, getFormattedFloat, getFormattedCurrency } = const { getFormattedPercent, getFormattedFloat, getFormattedCurrency } =
getMeta(doctype) getMeta(doctype)
const { getUser } = usersStore() const { users, getUser } = usersStore()
let triggerOnChange let triggerOnChange
let parentDoc let parentDoc
@ -260,7 +260,7 @@ if (!isGridRow) {
provide('triggerOnRowAdd', triggerOnRowAdd) provide('triggerOnRowAdd', triggerOnRowAdd)
provide('triggerOnRowRemove', triggerOnRowRemove) provide('triggerOnRowRemove', triggerOnRowRemove)
} else { } else {
triggerOnChange = inject('triggerOnChange') triggerOnChange = inject('triggerOnChange', () => {})
parentDoc = inject('parentDoc') parentDoc = inject('parentDoc')
} }
@ -278,6 +278,10 @@ const field = computed(() => {
if (field.fieldtype === 'Link' && field.options === 'User') { if (field.fieldtype === 'Link' && field.options === 'User') {
field.fieldtype = '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') { if (field.fieldtype === 'Link' && field.options !== 'User') {

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

View File

@ -172,6 +172,7 @@ import {
import { usersStore } from '@/stores/users' import { usersStore } from '@/stores/users'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'
import { showSettings, activeSettingsPage } from '@/composables/settings' import { showSettings, activeSettingsPage } from '@/composables/settings'
import { showChangePasswordModal } from '@/composables/modals'
import { FeatherIcon, call } from 'frappe-ui' import { FeatherIcon, call } from 'frappe-ui'
import { import {
SignupBanner, SignupBanner,
@ -329,8 +330,7 @@ const steps = reactive([
completed: false, completed: false,
onClick: () => { onClick: () => {
minimize.value = true minimize.value = true
showSettings.value = true showChangePasswordModal.value = true
activeSettingsPage.value = 'Profile'
}, },
}, },
{ {
@ -351,7 +351,7 @@ const steps = reactive([
onClick: () => { onClick: () => {
minimize.value = true minimize.value = true
showSettings.value = true showSettings.value = true
activeSettingsPage.value = 'Invite Members' activeSettingsPage.value = 'Invite User'
}, },
condition: () => isManager(), condition: () => isManager(),
}, },
@ -529,7 +529,7 @@ const articles = ref([
{ name: 'profile', title: __('Profile') }, { name: 'profile', title: __('Profile') },
{ name: 'custom-branding', title: __('Custom branding') }, { name: 'custom-branding', title: __('Custom branding') },
{ name: 'home-actions', title: __('Home actions') }, { name: 'home-actions', title: __('Home actions') },
{ name: 'invite-members', title: __('Invite members') }, { name: 'invite-users', title: __('Invite users') },
], ],
}, },
{ {

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

View File

@ -33,6 +33,7 @@
doctype="User" doctype="User"
@change="(option) => addValue(option) && ($refs.input.value = '')" @change="(option) => addValue(option) && ($refs.input.value = '')"
:placeholder="__('John Doe')" :placeholder="__('John Doe')"
:filters="{ name: ['in', users.data.crmUsers?.map((user) => user.name)] }"
:hideMe="true" :hideMe="true"
> >
<template #item-prefix="{ option }"> <template #item-prefix="{ option }">
@ -105,7 +106,7 @@ const oldAssignees = ref([])
const error = ref('') const error = ref('')
const { getUser } = usersStore() const { users, getUser } = usersStore()
const removeValue = (value) => { const removeValue = (value) => {
assignees.value = assignees.value.filter( assignees.value = assignees.value.filter(

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

View File

@ -16,9 +16,14 @@
v-model="showAddressModal" v-model="showAddressModal"
v-bind="addressProps" v-bind="addressProps"
/> />
<ChangePasswordModal
v-if="showChangePasswordModal"
v-model="showChangePasswordModal"
/>
<AboutModal v-model="showAboutModal" /> <AboutModal v-model="showAboutModal" />
</template> </template>
<script setup> <script setup>
import ChangePasswordModal from '@/components/Modals/ChangePasswordModal.vue'
import CreateDocumentModal from '@/components/Modals/CreateDocumentModal.vue' import CreateDocumentModal from '@/components/Modals/CreateDocumentModal.vue'
import QuickEntryModal from '@/components/Modals/QuickEntryModal.vue' import QuickEntryModal from '@/components/Modals/QuickEntryModal.vue'
import AddressModal from '@/components/Modals/AddressModal.vue' import AddressModal from '@/components/Modals/AddressModal.vue'
@ -34,6 +39,7 @@ import {
quickEntryProps, quickEntryProps,
showAddressModal, showAddressModal,
addressProps, addressProps,
showAboutModal showAboutModal,
showChangePasswordModal,
} from '@/composables/modals' } from '@/composables/modals'
</script> </script>

View File

@ -1,23 +1,32 @@
<template> <template>
<Dialog v-model="show" :options="{ <Dialog
size: 'xl', v-model="show"
actions: [ :options="{
{ size: 'xl',
label: editMode ? __('Update') : __('Create'), actions: [
variant: 'solid', {
onClick: () => updateTask(), label: editMode ? __('Update') : __('Create'),
}, variant: 'solid',
], onClick: () => updateTask(),
}"> },
],
}"
>
<template #body-title> <template #body-title>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<h3 class="text-2xl font-semibold leading-6 text-ink-gray-9"> <h3 class="text-2xl font-semibold leading-6 text-ink-gray-9">
{{ editMode ? __('Edit Task') : __('Create Task') }} {{ editMode ? __('Edit Task') : __('Create Task') }}
</h3> </h3>
<Button v-if="task?.reference_docname" size="sm" :label="task.reference_doctype == 'CRM Deal' <Button
? __('Open Deal') v-if="task?.reference_docname"
: __('Open Lead') size="sm"
" @click="redirect()"> :label="
task.reference_doctype == 'CRM Deal'
? __('Open Deal')
: __('Open Lead')
"
@click="redirect()"
>
<template #suffix> <template #suffix>
<ArrowUpRightIcon class="w-4 h-4" /> <ArrowUpRightIcon class="w-4 h-4" />
</template> </template>
@ -27,17 +36,29 @@
<template #body-content> <template #body-content>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div> <div>
<FormControl ref="title" :label="__('Title')" v-model="_task.title" :placeholder="__('Call with John Doe')" <FormControl
required /> ref="title"
:label="__('Title')"
v-model="_task.title"
:placeholder="__('Call with John Doe')"
required
/>
</div> </div>
<div> <div>
<div class="mb-1.5 text-xs text-ink-gray-5"> <div class="mb-1.5 text-xs text-ink-gray-5">
{{ __('Description') }} {{ __('Description') }}
</div> </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" 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>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<Dropdown :options="taskStatusOptions(updateTaskStatus)"> <Dropdown :options="taskStatusOptions(updateTaskStatus)">
@ -47,24 +68,38 @@
</template> </template>
</Button> </Button>
</Dropdown> </Dropdown>
<Link class="form-control" :value="getUser(_task.assigned_to).full_name" doctype="User" <Link
@change="(option) => (_task.assigned_to = option)" :placeholder="__('John Doe')" :hideMe="true"> class="form-control"
<template #prefix> :value="getUser(_task.assigned_to).full_name"
<UserAvatar class="mr-2 !h-4 !w-4" :user="_task.assigned_to" /> doctype="User"
</template> @change="(option) => (_task.assigned_to = option)"
<template #item-prefix="{ option }"> :placeholder="__('John Doe')"
<UserAvatar class="mr-2" :user="option.value" size="sm" /> :filters="{
</template> name: ['in', users.data.crmUsers?.map((user) => user.name)],
<template #item-label="{ option }"> }"
<Tooltip :text="option.value"> :hideMe="true"
<div class="cursor-pointer text-ink-gray-9"> >
{{ getUser(option.value).full_name }} <template #prefix>
</div> <UserAvatar class="mr-2 !h-4 !w-4" :user="_task.assigned_to" />
</Tooltip> </template>
</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> </Link>
<DateTimePicker class="datepicker w-36" v-model="_task.due_date" :placeholder="__('01/04/2024 11:30 PM')" <DateTimePicker
:formatter="(date) => getFormat(date, '', true, true)" input-class="border-none" /> 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)"> <Dropdown :options="taskPriorityOptions(updateTaskPriority)">
<Button :label="_task.priority" class="justify-between w-full"> <Button :label="_task.priority" class="justify-between w-full">
<template #prefix> <template #prefix>
@ -114,7 +149,7 @@ const tasks = defineModel('reloadTasks')
const emit = defineEmits(['updateTask', 'after']) const emit = defineEmits(['updateTask', 'after'])
const router = useRouter() const router = useRouter()
const { getUser } = usersStore() const { users, getUser } = usersStore()
const { updateOnboardingStep } = useOnboarding('frappecrm') const { updateOnboardingStep } = useOnboarding('frappecrm')
const error = ref(null) const error = ref(null)
@ -164,20 +199,24 @@ async function updateTask() {
emit('after', d) emit('after', d)
} }
} else { } else {
let d = await call('frappe.client.insert', { let d = await call(
doc: { 'frappe.client.insert',
doctype: 'CRM Task', {
reference_doctype: props.doctype, doc: {
reference_docname: props.doc || null, doctype: 'CRM Task',
..._task.value, reference_doctype: props.doctype,
reference_docname: props.doc || null,
..._task.value,
},
}, },
}, { {
onError: (err) => { onError: (err) => {
if (err.error.exc_type == 'MandatoryError') { if (err.error.exc_type == 'MandatoryError') {
error.value = "Title is mandatory" error.value = 'Title is mandatory'
} }
} },
}) },
)
if (d.name) { if (d.name) {
updateOnboardingStep('create_first_task') updateOnboardingStep('create_first_task')
capture('task_created') capture('task_created')

View File

@ -1,8 +1,8 @@
<template> <template>
<SettingsPage <SettingsPage
doctype="ERPNext CRM Settings" doctype="ERPNext CRM Settings"
:title="__('ERPNext Settings')" :title="__('ERPNext settings')"
:successMessage="__('ERPNext Settings updated')" :successMessage="__('ERPNext settings updated')"
class="p-8" class="p-8"
/> />
</template> </template>

View File

@ -1,19 +1,19 @@
<template> <template>
<div <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 --> <!-- avatar and name -->
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<EmailProviderIcon :logo="emailIcon[emailAccount.service]" /> <EmailProviderIcon :logo="emailIcon[emailAccount.service]" />
<div> <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 }} {{ emailAccount.email_account_name }}
</p> </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> </div>
<div> <div>
<Badge variant="subtle" :label="badgeTitle" :theme="gray" /> <Badge variant="subtle" :label="badgeTitle" theme="gray" />
</div> </div>
<!-- email id --> <!-- email id -->
</div> </div>

View File

@ -1,22 +1,30 @@
<template> <template>
<div> <div>
<!-- header --> <!-- header -->
<div class="flex items-center justify-between text-ink-gray-9"> <div class="flex justify-between text-ink-gray-8">
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5"> <div class="flex flex-col gap-1 w-9/12">
{{ __('Email Accounts') }} <h2 class="flex gap-2 text-xl font-semibold leading-none h-5">
</h2> {{ __('Email accounts') }}
<Button </h2>
:label="__('Add Account')" <p class="text-p-base text-ink-gray-6">
theme="gray" {{
variant="solid" __(
@click="emit('update:step', 'email-add')" '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.',
class="mr-8" )
> }}
<template #prefix> </p>
<LucidePlus class="w-4 h-4" /> </div>
</template> <div class="flex item-center space-x-2 w-3/12 justify-end">
</Button> <Button
:label="__('Add Account')"
theme="gray"
variant="solid"
icon-left="plus"
@click="emit('update:step', 'email-add')"
/>
</div>
</div> </div>
<!-- list accounts --> <!-- list accounts -->
<div <div
v-if="!emailAccounts.loading && Boolean(emailAccounts.data?.length)" v-if="!emailAccounts.loading && Boolean(emailAccounts.data?.length)"
@ -30,7 +38,7 @@
</div> </div>
</div> </div>
<!-- fallback if no email accounts --> <!-- 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.') }} {{ __('Please add an email account to continue.') }}
</div> </div>
</div> </div>

View File

@ -2,10 +2,10 @@
<div class="flex flex-col h-full gap-4"> <div class="flex flex-col h-full gap-4">
<!-- title and desc --> <!-- title and desc -->
<div role="heading" aria-level="1" class="flex flex-col gap-1"> <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') }} {{ __('Setup Email') }}
</h2> </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.') }} {{ __('Choose the email service provider you want to configure.') }}
</p> </p>
</div> </div>
@ -27,14 +27,15 @@
<div v-if="selectedService" class="flex flex-col gap-4"> <div v-if="selectedService" class="flex flex-col gap-4">
<!-- email service provider info --> <!-- email service provider info -->
<div <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" /> <CircleAlert class="w-5 h-6 w-min-5 w-max-5 min-h-5 max-w-5" />
<div class="text-xs text-wrap"> <div class="text-xs text-wrap">
{{ selectedService.info }} {{ selectedService.info }}
<a :href="selectedService.link" target="_blank" class="underline" <a :href="selectedService.link" target="_blank" class="underline">
>here</a {{ __('here') }}
>. </a>
.
</div> </div>
</div> </div>
<!-- service provider fields --> <!-- service provider fields -->
@ -66,23 +67,22 @@
:name="field.name" :name="field.name"
:type="field.type" :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>
</div> </div>
<ErrorMessage v-if="error" class="ml-1" :message="error" /> <ErrorMessage class="ml-1" :message="error" />
</div> </div>
</div> </div>
<!-- action button --> <!-- action button -->
<div v-if="selectedService" class="flex justify-between mt-auto"> <div v-if="selectedService" class="flex justify-between mt-auto">
<Button <Button
label="Back" :label="__('Back')"
theme="gray"
variant="outline" variant="outline"
:disabled="addEmailRes.loading" :disabled="addEmailRes.loading"
@click="emit('update:step', 'email-list')" @click="emit('update:step', 'email-list')"
/> />
<Button <Button
label="Create" :label="__('Create')"
variant="solid" variant="solid"
:loading="addEmailRes.loading" :loading="addEmailRes.loading"
@click="createEmailAccount" @click="createEmailAccount"

View File

@ -2,7 +2,7 @@
<div class="flex flex-col h-full gap-4"> <div class="flex flex-col h-full gap-4">
<!-- title and desc --> <!-- title and desc -->
<div role="heading" aria-level="1" class="flex justify-between gap-1"> <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') }} {{ __('Edit Email') }}
</h2> </h2>
</div> </div>
@ -14,16 +14,16 @@
</div> </div>
<!-- banner for setting up email account --> <!-- banner for setting up email account -->
<div <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 <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 }} {{ info.description }}
<a :href="info.link" target="_blank" class="underline">{{ <a :href="info.link" target="_blank" class="underline">
__('here') {{ __('here') }}
}}</a> </a>
. .
</div> </div>
</div> </div>
@ -56,7 +56,7 @@
:name="field.name" :name="field.name"
:type="field.type" :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>
</div> </div>
<ErrorMessage v-if="error" class="ml-1" :message="error" /> <ErrorMessage v-if="error" class="ml-1" :message="error" />

View File

@ -1,14 +1,11 @@
<template> <template>
<div <div
class="flex items-center justify-center w-8 h-8 bg-gray-100 cursor-pointer rounded-xl hover:bg-gray-200" 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-gray-500 dark:ring-gray-100': selected }" :class="{ 'ring-2 ring-outline-gray-4': selected }"
> >
<img :src="logo" class="w-4 h-4" /> <img :src="logo" class="w-4 h-4" />
</div> </div>
<p <p v-if="serviceName" class="text-xs text-center text-ink-gray-6 mt-2">
v-if="serviceName"
class="text-xs text-center text-gray-700 dark:text-gray-500 mt-2"
>
{{ serviceName }} {{ serviceName }}
</p> </p>
</template> </template>
@ -29,5 +26,3 @@ defineProps({
}, },
}) })
</script> </script>
<style scoped></style>

View File

@ -1,14 +1,29 @@
<template> <template>
<div class="flex h-full flex-col gap-8 p-8 text-ink-gray-9"> <div class="flex h-full flex-col gap-6 p-8 text-ink-gray-8">
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5"> <div class="flex justify-between">
{{ __('General') }} <div class="flex flex-col gap-1 w-9/12">
<Badge <h2 class="flex gap-2 text-xl font-semibold leading-none h-5">
v-if="settings.isDirty" {{ __('General') }}
:label="__('Not Saved')" <Badge
variant="subtle" v-if="settings.isDirty"
theme="orange" :label="__('Not Saved')"
/> variant="subtle"
</h2> 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 v-if="settings.doc" class="flex-1 flex flex-col gap-8 overflow-y-auto">
<div class="flex w-full"> <div class="flex w-full">
@ -16,14 +31,14 @@
type="text" type="text"
class="w-1/2" class="w-1/2"
v-model="settings.doc.brand_name" v-model="settings.doc.brand_name"
:label="__('Brand Name')" :label="__('Brand name')"
/> />
</div> </div>
<!-- logo --> <!-- logo -->
<div class="flex flex-col justify-between gap-4"> <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') }} {{ __('Logo') }}
</span> </span>
<div class="flex flex-1 gap-5"> <div class="flex flex-1 gap-5">
@ -58,7 +73,7 @@
<!-- favicon --> <!-- favicon -->
<div class="flex flex-col justify-between gap-4"> <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') }} {{ __('Favicon') }}
</span> </span>
<div class="flex flex-1 gap-5"> <div class="flex flex-1 gap-5">
@ -93,7 +108,7 @@
<!-- Home actions --> <!-- Home actions -->
<div class="flex flex-col justify-between gap-4"> <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') }} {{ __('Home actions') }}
</span> </span>
<div class="flex flex-1"> <div class="flex flex-1">
@ -107,15 +122,7 @@
</div> </div>
</div> </div>
<div class="flex justify-between flex-row-reverse"> <ErrorMessage :message="settings.save.error" />
<Button
variant="solid"
:label="__('Update')"
:disabled="!settings.isDirty"
@click="updateSettings"
/>
<ErrorMessage :message="settings.save.error" />
</div>
</div> </div>
</template> </template>
<script setup> <script setup>

View File

@ -1,8 +1,28 @@
<template> <template>
<div class="flex h-full flex-col gap-8 p-8 text-ink-gray-9"> <div class="flex h-full flex-col gap-6 p-8 text-ink-gray-8">
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5"> <div class="flex justify-between">
{{ __('Send Invites To') }} <div class="flex flex-col gap-1 w-9/12">
</h2> <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 class="flex-1 flex flex-col gap-8 overflow-y-auto">
<div> <div>
<label class="block text-xs text-ink-gray-5 mb-1.5"> <label class="block text-xs text-ink-gray-5 mb-1.5">
@ -11,7 +31,7 @@
<div <div
class="p-2 group bg-surface-gray-2 hover:bg-surface-gray-3 rounded" class="p-2 group bg-surface-gray-2 hover:bg-surface-gray-3 rounded"
> >
<MultiSelectEmailInput <MultiSelectUserInput
class="flex-1" class="flex-1"
inputClass="!bg-surface-gray-2 hover:!bg-surface-gray-3 group-hover:!bg-surface-gray-3" inputClass="!bg-surface-gray-2 hover:!bg-surface-gray-3 group-hover:!bg-surface-gray-3"
:placeholder="__('john@doe.com')" :placeholder="__('john@doe.com')"
@ -20,18 +40,21 @@
:error-message=" :error-message="
(value) => __('{0} is an invalid email address', [value]) (value) => __('{0} is an invalid email address', [value])
" "
:fetchContacts="false" :fetchUsers="false"
/> />
</div> </div>
<div
v-if="userExistMessage || inviteeExistMessage"
class="text-xs text-ink-red-3 mt-1.5"
>
{{ userExistMessage || inviteeExistMessage }}
</div>
<FormControl <FormControl
type="select" type="select"
class="mt-4" class="mt-4"
v-model="role" v-model="role"
:label="__('Invite as')" :label="__('Invite as')"
:options="[ :options="roleOptions"
{ label: __('Regular Access'), value: 'Sales User' },
{ label: __('Manager Access'), value: 'Sales Manager' },
]"
:description="description" :description="description"
/> />
</div> </div>
@ -49,7 +72,7 @@
:key="user.name" :key="user.name"
> >
<div class="text-base"> <div class="text-base">
<span class="text-ink-gray-9"> <span class="text-ink-gray-8">
{{ user.email }} {{ user.email }}
</span> </span>
<span class="text-ink-gray-5"> <span class="text-ink-gray-5">
@ -76,21 +99,13 @@
</div> </div>
</template> </template>
</div> </div>
<div class="flex justify-between items-center gap-2"> <ErrorMessage :message="error" />
<div><ErrorMessage v-if="error" :message="error" /></div>
<Button
:label="__('Send Invites')"
variant="solid"
:disabled="!invitees.length"
@click="inviteByEmail.submit()"
:loading="inviteByEmail.loading"
/>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import MultiSelectEmailInput from '@/components/Controls/MultiSelectEmailInput.vue' import MultiSelectUserInput from '@/components/Controls/MultiSelectUserInput.vue'
import { validateEmail, convertArrayToString } from '@/utils' import { validateEmail, convertArrayToString } from '@/utils'
import { usersStore } from '@/stores/users'
import { import {
createListResource, createListResource,
createResource, createResource,
@ -101,23 +116,67 @@ import { useOnboarding } from 'frappe-ui/frappe'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
const { updateOnboardingStep } = useOnboarding('frappecrm') const { updateOnboardingStep } = useOnboarding('frappecrm')
const { users, isAdmin, isManager } = usersStore()
const invitees = ref([]) const invitees = ref([])
const role = ref('Sales User') const role = ref('Sales User')
const error = ref(null) 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(() => { const description = computed(() => {
return { return {
'System Manager':
'Can manage all aspects of the CRM, including user management, customizations and settings.',
'Sales Manager': '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': 'Sales User':
'Can work with leads and deals and create private views (reports).', 'Can work with leads and deals and create private views (reports).',
}[role.value] }[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 = { const roleMap = {
'Sales User': __('Regular Access'), 'Sales User': __('Sales User'),
'Sales Manager': __('Manager Access'), 'Sales Manager': __('Manager'),
'System Manager': __('Admin'),
} }
const inviteByEmail = createResource({ const inviteByEmail = createResource({
@ -130,7 +189,7 @@ const inviteByEmail = createResource({
}, },
onSuccess(data) { onSuccess(data) {
if (data?.existing_invites?.length) { 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(', '), data.existing_invites.join(', '),
]) ])
} else { } else {

View File

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

View File

@ -1,140 +1,160 @@
<template> <template>
<div class="flex h-full flex-col gap-8 p-8 text-ink-gray-9"> <div class="flex h-full flex-col gap-6 p-8 text-ink-gray-8">
<div class="flex-1 flex flex-col gap-8 mt-2 overflow-y-auto"> <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 v-if="profile" class="flex w-full items-center justify-between">
<div class="flex items-center gap-4"> <FileUploader
<Avatar @success="(file) => updateImage(file.file_url)"
class="!size-16" :validateFile="validateIsImageFile"
: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"
> >
<template #body-content> <template #default="{ openFileSelector, error: _error }">
<ProfileImageEditor v-model="profile" /> <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>
<template #actions> </FileUploader>
<Button <Button
variant="solid" :label="__('Change Password')"
class="w-full" icon-left="lock"
:loading="loading" @click="showChangePasswordModal = true"
@click="updateUser" />
:label="__('Save')" <ChangePasswordModal
/> v-if="showChangePasswordModal"
</template> v-model="showChangePasswordModal"
</Dialog> />
</div> </div>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div class="flex justify-between gap-4"> <div class="flex justify-between gap-4">
<FormControl <FormControl
class="w-full" class="w-full"
label="First name" :label="__('First name')"
v-model="profile.first_name" v-model="profile.first_name"
/> />
<FormControl <FormControl
class="w-full" class="w-full"
label="Last name" :label="__('Last name')"
v-model="profile.last_name" v-model="profile.last_name"
/> />
</div> </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> </div>
<div class="flex justify-between flex-row-reverse"> <div class="flex justify-between items-center">
<div>
<ErrorMessage :message="error" />
</div>
<Button <Button
variant="solid" variant="solid"
:label="__('Update')" :label="__('Update')"
:loading="loading" :disabled="!dirty"
@click="updateUser" :loading="setUser.loading"
@click="setUser.submit()"
/> />
<ErrorMessage :message="error" />
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import Password from '@/components/Controls/Password.vue' import ChangePasswordModal from '@/components/Modals/ChangePasswordModal.vue'
import ProfileImageEditor from '@/components/Settings/ProfileImageEditor.vue' import CameraIcon from '@/components/Icons/CameraIcon.vue'
import { usersStore } from '@/stores/users' import { usersStore } from '@/stores/users'
import { Dialog, Avatar, createResource, ErrorMessage, toast } from 'frappe-ui' import { validateIsImageFile } from '@/utils'
import { useOnboarding } from 'frappe-ui/frappe' import {
Dropdown,
FileUploader,
Avatar,
createResource,
toast,
} from 'frappe-ui'
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
const { getUser, users } = usersStore() const { getUser, users } = usersStore()
const { updateOnboardingStep } = useOnboarding('frappecrm')
const user = computed(() => getUser() || {}) const user = computed(() => getUser() || {})
const showEditProfilePhotoModal = ref(false)
const profile = ref({}) const profile = ref({})
const loading = ref(false)
const error = ref('') const error = ref('')
const showChangePasswordModal = ref(false)
function updateUser() { const dirty = computed(() => {
loading.value = true return (
profile.value.first_name !== user.value.first_name ||
profile.value.last_name !== user.value.last_name
)
})
let passwordUpdated = false const setUser = createResource({
url: 'frappe.client.set_value',
if (profile.value.new_password) { makeParams() {
passwordUpdated = true return {
}
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: {
doctype: 'User', doctype: 'User',
name: user.value.name, name: user.value.name,
fieldname, fieldname: {
}, first_name: profile.value.first_name,
auto: true, last_name: profile.value.last_name,
onSuccess: () => { user_image: profile.value.user_image,
if (passwordUpdated) { },
updateOnboardingStep('setup_your_password') }
} },
loading.value = false onSuccess: () => {
error.value = '' error.value = ''
profile.value.new_password = '' toast.success(__('Profile updated successfully'))
showEditProfilePhotoModal.value = false users.reload()
toast.success(__('Profile updated successfully')) },
users.reload() onError: (err) => {
}, error.value = err.messages[0] || __('Failed to update profile')
onError: (err) => { },
loading.value = false })
error.value = err.message
}, function updateImage(fileUrl = '') {
}) profile.value.user_image = fileUrl
setUser.submit()
} }
onMounted(() => { onMounted(() => {

View File

@ -7,7 +7,7 @@
<template #body> <template #body>
<div class="flex h-[calc(100vh_-_8rem)]"> <div class="flex h-[calc(100vh_-_8rem)]">
<div class="flex flex-col p-2 w-52 shrink-0 bg-surface-gray-2"> <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') }} {{ __('Settings') }}
</h1> </h1>
<div v-for="tab in tabs"> <div v-for="tab in tabs">
@ -28,20 +28,12 @@
? 'bg-surface-selected shadow-sm hover:bg-surface-selected' ? 'bg-surface-selected shadow-sm hover:bg-surface-selected'
: 'hover:bg-surface-gray-3' : 'hover:bg-surface-gray-3'
" "
@click="activeTab = i" @click="activeSettingsPage = i.label"
/> />
</nav> </nav>
</div> </div>
</div> </div>
<div <div class="flex flex-col flex-1 overflow-y-auto bg-surface-modal">
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"
/>
<component :is="activeTab.component" v-if="activeTab" /> <component :is="activeTab.component" v-if="activeTab" />
</div> </div>
</div> </div>
@ -52,10 +44,10 @@
import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue' import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
import ERPNextIcon from '@/components/Icons/ERPNextIcon.vue' import ERPNextIcon from '@/components/Icons/ERPNextIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue' import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import InviteIcon from '@/components/Icons/InviteIcon.vue'
import Email2Icon from '@/components/Icons/Email2Icon.vue' import Email2Icon from '@/components/Icons/Email2Icon.vue'
import Users from '@/components/Settings/Users.vue'
import GeneralSettings from '@/components/Settings/GeneralSettings.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 ProfileSettings from '@/components/Settings/ProfileSettings.vue'
import WhatsAppSettings from '@/components/Settings/WhatsAppSettings.vue' import WhatsAppSettings from '@/components/Settings/WhatsAppSettings.vue'
import ERPNextSettings from '@/components/Settings/ERPNextSettings.vue' import ERPNextSettings from '@/components/Settings/ERPNextSettings.vue'
@ -68,7 +60,7 @@ import {
showSettings, showSettings,
activeSettingsPage, activeSettingsPage,
} from '@/composables/settings' } from '@/composables/settings'
import { Dialog, Button, Avatar } from 'frappe-ui' import { Dialog, Avatar } from 'frappe-ui'
import { ref, markRaw, computed, watch, h } from 'vue' import { ref, markRaw, computed, watch, h } from 'vue'
const { isManager, isAgent, getUser } = usersStore() const { isManager, isAgent, getUser } = usersStore()
@ -98,9 +90,15 @@ const tabs = computed(() => {
condition: () => isManager(), condition: () => isManager(),
}, },
{ {
label: __('Invite Members'), label: __('Users'),
icon: InviteIcon, icon: 'user',
component: markRaw(InviteMemberPage), component: markRaw(Users),
condition: () => isManager(),
},
{
label: __('Invite User'),
icon: 'user-plus',
component: markRaw(InviteUserPage),
condition: () => isManager(), condition: () => isManager(),
}, },
{ {

View File

@ -1,16 +1,28 @@
<template> <template>
<div class="flex h-full flex-col gap-8"> <div class="flex h-full flex-col gap-6">
<h2 <div class="flex justify-between">
class="flex gap-2 text-xl font-semibold leading-none h-5 text-ink-gray-9" <div class="flex flex-col gap-1 w-9/12">
> <h2
<div>{{ title || __(doctype) }}</div> class="flex gap-2 text-xl font-semibold leading-none h-5 text-ink-gray-8"
<Badge >
v-if="data.isDirty" {{ title || __(doctype) }}
:label="__('Not Saved')" <Badge
variant="subtle" v-if="data.isDirty"
theme="orange" :label="__('Not Saved')"
/> variant="subtle"
</h2> 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"> <div v-if="!data.get.loading" class="flex-1 overflow-y-auto">
<FieldLayout <FieldLayout
v-if="data?.doc && tabs" v-if="data?.doc && tabs"
@ -22,17 +34,7 @@
<div v-else class="flex flex-1 items-center justify-center"> <div v-else class="flex flex-1 items-center justify-center">
<Spinner class="size-8" /> <Spinner class="size-8" />
</div> </div>
<div class="flex justify-between gap-2"> <ErrorMessage :message="data.save.error" />
<div>
<ErrorMessage class="mt-2" :message="data.save.error" />
</div>
<Button
:loading="data.save.loading"
:label="__('Update')"
variant="solid"
@click="data.save.submit()"
/>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>

View File

@ -1,16 +1,31 @@
<template> <template>
<div class="flex h-full flex-col gap-8 p-8"> <div class="flex h-full flex-col gap-6 p-8">
<h2 <div class="flex justify-between">
class="flex gap-2 text-xl font-semibold leading-none h-5 text-ink-gray-9" <div class="flex flex-col gap-1 w-9/12">
> <h2
<div>{{ __('Telephony Settings') }}</div> class="flex gap-2 text-xl font-semibold leading-none h-5 text-ink-gray-8"
<Badge >
v-if="twilio.isDirty || exotel.isDirty || mediumChanged" {{ __('Telephony settings') }}
:label="__('Not Saved')" <Badge
variant="subtle" v-if="twilio.isDirty || exotel.isDirty || mediumChanged"
theme="orange" :label="__('Not Saved')"
/> variant="subtle"
</h2> 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 <div
v-if="!twilio.get.loading || !exotel.get.loading" v-if="!twilio.get.loading || !exotel.get.loading"
class="flex-1 flex flex-col gap-8 overflow-y-auto" class="flex-1 flex flex-col gap-8 overflow-y-auto"
@ -31,7 +46,7 @@
<!-- Twilio --> <!-- Twilio -->
<div v-if="isManager()" class="flex flex-col justify-between gap-4"> <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') }} {{ __('Twilio') }}
</span> </span>
<FieldLayout <FieldLayout
@ -44,7 +59,7 @@
<!-- Exotel --> <!-- Exotel -->
<div v-if="isManager()" class="flex flex-col justify-between gap-4"> <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') }} {{ __('Exotel') }}
</span> </span>
<FieldLayout <FieldLayout
@ -58,20 +73,7 @@
<div v-else class="flex flex-1 items-center justify-center"> <div v-else class="flex flex-1 items-center justify-center">
<Spinner class="size-8" /> <Spinner class="size-8" />
</div> </div>
<div class="flex justify-between gap-2"> <ErrorMessage :message="twilio.save.error || exotel.save.error || error" />
<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>
</div> </div>
</template> </template>
<script setup> <script setup>

View File

@ -0,0 +1,244 @@
<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 &&
users.data?.crmUsers[0].name == 'Administrator'
"
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 -->
<ul
v-if="!users.loading && Boolean(users.data?.crmUsers?.length)"
class="divide-y divide-outline-gray-modals overflow-auto"
>
<template v-for="user in users.data?.crmUsers" :key="user.name">
<li
v-if="user.name !== 'Administrator'"
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[getUserRole(user.name)],
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>
<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, toast, call } from 'frappe-ui'
import { ref, h } from 'vue'
const { users, getUserRole, isAdmin, isManager } = usersStore()
const showAddExistingModal = ref(false)
const roleMap = {
'System Manager': __('Admin'),
'Sales Manager': __('Manager'),
'Sales User': __('Sales User'),
}
function getMoreOptions(user) {
let options = [
{
label: __('Remove'),
icon: 'trash-2',
onClick: () => removeUser(user, true),
},
]
return options.filter((option) => option.condition?.() || true)
}
function getDropdownOptions(user) {
const userRole = getUserRole(user.name)
let options = [
{
label: __('Admin'),
component: (props) =>
RoleOption({
role: __('Admin'),
active: props.active,
selected: userRole === 'System Manager',
onClick: () => updateRole(user, 'System Manager'),
}),
condition: () => isAdmin(),
},
{
label: __('Manager'),
component: (props) =>
RoleOption({
role: __('Manager'),
active: props.active,
selected: userRole === 'Sales Manager',
onClick: () => updateRole(user, 'Sales Manager'),
}),
condition: () => isManager(),
},
{
label: __('Sales User'),
component: (props) =>
RoleOption({
role: __('Sales User'),
active: props.active,
selected: userRole === '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()
})
}
</script>

View File

@ -429,7 +429,7 @@ const emit = defineEmits(['afterFieldChange', 'reload'])
const { getFormattedPercent, getFormattedFloat, getFormattedCurrency } = const { getFormattedPercent, getFormattedFloat, getFormattedCurrency } =
getMeta(props.doctype) getMeta(props.doctype)
const { isManager, getUser } = usersStore() const { users, isManager, getUser } = usersStore()
const showSidePanelModal = ref(false) const showSidePanelModal = ref(false)
@ -471,8 +471,11 @@ function parsedField(field) {
} }
if (field.fieldtype === 'Link' && field.options === 'User') { if (field.fieldtype === 'Link' && field.options === 'User') {
field.options = field.options
field.fieldtype = '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)],
})
} }
let _field = { let _field = {

View File

@ -45,6 +45,9 @@
doctype="User" doctype="User"
@change="(option) => (task.assigned_to = option)" @change="(option) => (task.assigned_to = option)"
:placeholder="__('John Doe')" :placeholder="__('John Doe')"
:filters="{
name: ['in', users.data?.crmUsers?.map((user) => user.name)],
}"
:hideMe="true" :hideMe="true"
> >
<template #prefix> <template #prefix>
@ -94,7 +97,7 @@ const props = defineProps({
}, },
}) })
const { getUser } = usersStore() const { users, getUser } = usersStore()
function updateTaskStatus(status) { function updateTaskStatus(status) {
props.task.status = status props.task.status = status

View File

@ -6,4 +6,6 @@ export const quickEntryProps = ref({});
export const showAddressModal = ref(false); export const showAddressModal = ref(false);
export const addressProps = ref({}); export const addressProps = ref({});
export const showAboutModal = ref(false); export const showAboutModal = ref(false);
export const showChangePasswordModal = ref(false);

View File

@ -12,17 +12,17 @@ export const usersStore = defineStore('crm-users', () => {
const users = createResource({ const users = createResource({
url: 'crm.api.session.get_users', url: 'crm.api.session.get_users',
cache: 'Users', cache: 'crm-users',
initialData: [], initialData: [],
auto: true, auto: true,
transform(users) { transform([allUsers, crmUsers]) {
for (let user of users) { for (let user of allUsers) {
usersByName[user.name] = user usersByName[user.name] = user
if (user.name === 'Administrator') { if (user.name === 'Administrator') {
usersByName[user.email] = user usersByName[user.email] = user
} }
} }
return users return { allUsers, crmUsers }
}, },
onError(error) { onError(error) {
if (error && error.exc_type === 'AuthenticationError') { if (error && error.exc_type === 'AuthenticationError') {
@ -49,6 +49,10 @@ export const usersStore = defineStore('crm-users', () => {
return usersByName[email] return usersByName[email]
} }
function isAdmin(email) {
return getUser(email).is_admin
}
function isManager(email) { function isManager(email) {
return getUser(email).is_manager return getUser(email).is_manager
} }
@ -57,10 +61,20 @@ export const usersStore = defineStore('crm-users', () => {
return getUser(email).is_agent return getUser(email).is_agent
} }
function getUserRole(email) {
const user = getUser(email)
if (user && user.role) {
return user.role
}
return null
}
return { return {
users, users,
getUser, getUser,
isAdmin,
isManager, isManager,
isAgent, isAgent,
getUserRole,
} }
}) })