fix(crm): honor open data scopes and role assignees
Unset sections on a saved role policy were still filtered as own-only, so full lead access showed nothing. Activities can now be assigned by org role, and the Tahesab sales report reads as a result instead of a tag wall. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -119,6 +119,8 @@ const smsAssigneesWithoutMobile = computed(() =>
|
||||
|
||||
const typeOpen = ref(false);
|
||||
const assigneeOpen = ref(false);
|
||||
const assigneeQuery = ref('');
|
||||
const assigneeSearchInput = ref<HTMLInputElement | null>(null);
|
||||
const personOpen = ref(false);
|
||||
const personQuery = ref('');
|
||||
const personResults = ref<PersonDto[] | null>(null);
|
||||
@@ -144,6 +146,19 @@ async function ensurePersonLoaded(pid: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAssigneeMenu() {
|
||||
assigneeOpen.value = !assigneeOpen.value;
|
||||
if (assigneeOpen.value) {
|
||||
typeOpen.value = false;
|
||||
personOpen.value = false;
|
||||
void nextTick(() => assigneeSearchInput.value?.focus());
|
||||
}
|
||||
}
|
||||
|
||||
watch(assigneeOpen, (open) => {
|
||||
if (!open) assigneeQuery.value = '';
|
||||
});
|
||||
|
||||
function togglePersonMenu() {
|
||||
personOpen.value = !personOpen.value;
|
||||
if (personOpen.value) {
|
||||
@@ -236,11 +251,74 @@ const selectedAssignees = computed(() =>
|
||||
.map((id) => users.value.find((u) => u.id === id))
|
||||
.filter((u): u is (typeof users.value)[number] => Boolean(u)),
|
||||
);
|
||||
const pickedRoleIds = ref<string[]>([]);
|
||||
const roleGroups = computed(() => {
|
||||
const map = new Map<string, { id: string; name: string; userIds: string[] }>();
|
||||
for (const user of users.value) {
|
||||
if (user.isActive === false || !user.orgRoleId || !user.orgRoleName) continue;
|
||||
const row = map.get(user.orgRoleId) ?? {
|
||||
id: user.orgRoleId,
|
||||
name: user.orgRoleName,
|
||||
userIds: [],
|
||||
};
|
||||
row.userIds.push(user.id);
|
||||
map.set(user.orgRoleId, row);
|
||||
}
|
||||
return [...map.values()].sort((a, b) => a.name.localeCompare(b.name, 'fa'));
|
||||
});
|
||||
function normalizeSearch(value: string) {
|
||||
return value.replace(/ي/g, 'ی').replace(/ك/g, 'ک').replace(/\u200c/g, ' ').trim().toLowerCase();
|
||||
}
|
||||
const assigneeSearch = computed(() => normalizeSearch(assigneeQuery.value));
|
||||
const filteredRoleGroups = computed(() => {
|
||||
const q = assigneeSearch.value;
|
||||
if (!q) return roleGroups.value;
|
||||
return roleGroups.value.filter((role) => normalizeSearch(role.name).includes(q));
|
||||
});
|
||||
const filteredUsers = computed(() => {
|
||||
const q = assigneeSearch.value;
|
||||
if (!q) return users.value;
|
||||
return users.value.filter(
|
||||
(user) =>
|
||||
normalizeSearch(user.fullName).includes(q) ||
|
||||
normalizeSearch(user.orgRoleName ?? '').includes(q),
|
||||
);
|
||||
});
|
||||
function isRoleSelected(userIds: string[]) {
|
||||
return userIds.length > 0 && userIds.every((id) => assigneeIds.value.includes(id));
|
||||
}
|
||||
const selectedRoles = computed(() =>
|
||||
roleGroups.value.filter((role) => pickedRoleIds.value.includes(role.id)),
|
||||
);
|
||||
const extraAssignees = computed(() => {
|
||||
const covered = new Set(selectedRoles.value.flatMap((role) => role.userIds));
|
||||
return selectedAssignees.value.filter((user) => !covered.has(user.id));
|
||||
});
|
||||
const assigneePickerLabel = computed(() => {
|
||||
const list = selectedAssignees.value;
|
||||
if (!list.length) return 'انتخاب مسئول';
|
||||
if (list.length === 1) return list[0]!.fullName;
|
||||
return `${list[0]!.fullName} و ${toFaDigits(list.length - 1)} نفر دیگر`;
|
||||
const roles = selectedRoles.value;
|
||||
const extras = extraAssignees.value;
|
||||
if (!assigneeIds.value.length) return 'انتخاب مسئول';
|
||||
if (roles.length && !extras.length) {
|
||||
if (roles.length === 1) return `${roles[0]!.name} (${toFaDigits(roles[0]!.userIds.length)} نفر)`;
|
||||
if (roles.length === 2) return `${roles[0]!.name} و ${roles[1]!.name}`;
|
||||
return `${roles[0]!.name} و ${toFaDigits(roles.length - 1)} نقش دیگر`;
|
||||
}
|
||||
if (roles.length && extras.length) {
|
||||
const roleLabel = roles.map((role) => role.name).join('، ');
|
||||
return `${roleLabel} و ${toFaDigits(extras.length)} نفر`;
|
||||
}
|
||||
if (selectedAssignees.value.length === 1) return selectedAssignees.value[0]!.fullName;
|
||||
return `${selectedAssignees.value[0]!.fullName} و ${toFaDigits(selectedAssignees.value.length - 1)} نفر دیگر`;
|
||||
});
|
||||
const assigneeSummaryLabel = computed(() => {
|
||||
if (!assigneeIds.value.length) return '—';
|
||||
const parts = [
|
||||
...selectedRoles.value.map(
|
||||
(role) => `${role.name} (${toFaDigits(role.userIds.length)} نفر)`,
|
||||
),
|
||||
...extraAssignees.value.map((user) => user.fullName),
|
||||
];
|
||||
return parts.join('، ');
|
||||
});
|
||||
const selectedDeal = computed(() => deals.value.find((d) => d.id === dealId.value) ?? null);
|
||||
const personDeals = computed(() =>
|
||||
@@ -297,6 +375,7 @@ function resetForm() {
|
||||
personId.value = '';
|
||||
dealId.value = '';
|
||||
assigneeIds.value = auth.user?.id ? [auth.user.id] : [];
|
||||
pickedRoleIds.value = [];
|
||||
isDone.value = false;
|
||||
needsNote.value = false;
|
||||
reminderAt.value = '';
|
||||
@@ -380,6 +459,7 @@ async function load() {
|
||||
: auth.user?.id
|
||||
? [auth.user.id]
|
||||
: [];
|
||||
refreshPickedRoles();
|
||||
isDone.value = data.status === 'done';
|
||||
} else {
|
||||
resetForm();
|
||||
@@ -456,14 +536,49 @@ function payload(): CreateActivityRequest {
|
||||
};
|
||||
}
|
||||
|
||||
function refreshPickedRoles() {
|
||||
pickedRoleIds.value = roleGroups.value
|
||||
.filter((role) => {
|
||||
if (!isRoleSelected(role.userIds)) return false;
|
||||
if (role.userIds.length >= 2) return true;
|
||||
return pickedRoleIds.value.includes(role.id);
|
||||
})
|
||||
.map((role) => role.id);
|
||||
}
|
||||
|
||||
function toggleAssignee(userId: string) {
|
||||
const idx = assigneeIds.value.indexOf(userId);
|
||||
if (idx >= 0) {
|
||||
if (assigneeIds.value.length <= 1) return;
|
||||
assigneeIds.value = assigneeIds.value.filter((id) => id !== userId);
|
||||
} else {
|
||||
assigneeIds.value = [...assigneeIds.value, userId];
|
||||
}
|
||||
refreshPickedRoles();
|
||||
}
|
||||
|
||||
function toggleRole(roleId: string, userIds: string[]) {
|
||||
if (pickedRoleIds.value.includes(roleId)) {
|
||||
pickedRoleIds.value = pickedRoleIds.value.filter((id) => id !== roleId);
|
||||
const still = new Set(
|
||||
roleGroups.value
|
||||
.filter((role) => pickedRoleIds.value.includes(role.id))
|
||||
.flatMap((role) => role.userIds),
|
||||
);
|
||||
assigneeIds.value = assigneeIds.value.filter((id) => !userIds.includes(id) || still.has(id));
|
||||
refreshPickedRoles();
|
||||
return;
|
||||
}
|
||||
assigneeIds.value = [...assigneeIds.value, userId];
|
||||
const onlyDefaultUser =
|
||||
pickedRoleIds.value.length === 0 &&
|
||||
assigneeIds.value.length === 1 &&
|
||||
assigneeIds.value[0] === auth.user?.id &&
|
||||
!userIds.includes(auth.user?.id ?? '');
|
||||
pickedRoleIds.value = [...pickedRoleIds.value, roleId];
|
||||
assigneeIds.value = onlyDefaultUser
|
||||
? [...userIds]
|
||||
: [...new Set([...assigneeIds.value, ...userIds])];
|
||||
refreshPickedRoles();
|
||||
}
|
||||
|
||||
function validate() {
|
||||
@@ -703,7 +818,7 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
<div class="card">
|
||||
<div class="field">
|
||||
<label>مسئولان انجام این فعالیت <i>*</i></label>
|
||||
<button class="picker person-pick" type="button" @click.stop="assigneeOpen = !assigneeOpen">
|
||||
<button class="picker person-pick" type="button" @click.stop="toggleAssigneeMenu">
|
||||
<div class="assignee-avatars">
|
||||
<UserAvatar
|
||||
v-for="user in selectedAssignees.slice(0, 3)"
|
||||
@@ -724,25 +839,66 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
</span>
|
||||
<UiIcon name="chevron" :size="16" />
|
||||
</button>
|
||||
<ul v-if="assigneeOpen" class="menu multi-menu" @click.stop>
|
||||
<li v-for="user in users" :key="user.id">
|
||||
<button
|
||||
type="button"
|
||||
class="multi-option"
|
||||
:class="{ on: assigneeIds.includes(user.id) }"
|
||||
@click="toggleAssignee(user.id)"
|
||||
<div v-if="assigneeOpen" class="menu person-menu multi-menu" @click.stop>
|
||||
<label class="person-search">
|
||||
<UiIcon name="search" :size="15" />
|
||||
<input
|
||||
ref="assigneeSearchInput"
|
||||
v-model="assigneeQuery"
|
||||
type="search"
|
||||
placeholder="جستجوی نقش یا نام عضو…"
|
||||
@keydown.esc="assigneeOpen = false"
|
||||
/>
|
||||
</label>
|
||||
<ul class="person-list">
|
||||
<template v-if="filteredRoleGroups.length">
|
||||
<li class="menu-label">نقشها</li>
|
||||
<li v-for="role in filteredRoleGroups" :key="role.id">
|
||||
<button
|
||||
type="button"
|
||||
class="multi-option"
|
||||
:class="{ on: pickedRoleIds.includes(role.id) }"
|
||||
@click="toggleRole(role.id, role.userIds)"
|
||||
>
|
||||
<span class="check" :class="{ on: pickedRoleIds.includes(role.id) }">
|
||||
<UiIcon v-if="pickedRoleIds.includes(role.id)" name="check-plain" :size="12" />
|
||||
</span>
|
||||
<span class="role-name">{{ role.name }}</span>
|
||||
<small class="role-count">{{ toFaDigits(role.userIds.length) }} نفر</small>
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
<li v-if="roleGroups.length && filteredUsers.length" class="menu-label">اعضا</li>
|
||||
<li v-for="user in filteredUsers" :key="user.id">
|
||||
<button
|
||||
type="button"
|
||||
class="multi-option"
|
||||
:class="{ on: assigneeIds.includes(user.id) }"
|
||||
@click="toggleAssignee(user.id)"
|
||||
>
|
||||
<span class="check" :class="{ on: assigneeIds.includes(user.id) }">
|
||||
<UiIcon v-if="assigneeIds.includes(user.id)" name="check-plain" :size="12" />
|
||||
</span>
|
||||
<UserAvatar :name="user.fullName" :user-id="user.id" :has-avatar="user.hasAvatar" :size="28" />
|
||||
<span class="role-name">{{ user.fullName }}</span>
|
||||
<small v-if="user.orgRoleName" class="role-count">{{ user.orgRoleName }}</small>
|
||||
</button>
|
||||
</li>
|
||||
<li
|
||||
v-if="assigneeSearch && !filteredRoleGroups.length && !filteredUsers.length"
|
||||
class="person-empty"
|
||||
>
|
||||
<span class="check" :class="{ on: assigneeIds.includes(user.id) }">
|
||||
<UiIcon v-if="assigneeIds.includes(user.id)" name="check-plain" :size="12" />
|
||||
</span>
|
||||
<UserAvatar :name="user.fullName" :user-id="user.id" :has-avatar="user.hasAvatar" :size="28" />
|
||||
{{ user.fullName }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<small class="hint">میتوانید چند نفر را بهعنوان مسئول انتخاب کنید.</small>
|
||||
<div v-if="selectedAssignees.length" class="assignee-chips">
|
||||
<span v-for="user in selectedAssignees" :key="user.id" class="chip">
|
||||
نقشی یا عضوی پیدا نشد.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<small class="hint">یک نقش را انتخاب کنید تا همهٔ اعضای آن مسئول شوند، یا چند نفر را جدا انتخاب کنید.</small>
|
||||
<div v-if="selectedRoles.length || extraAssignees.length" class="assignee-chips">
|
||||
<span v-for="role in selectedRoles" :key="role.id" class="chip">
|
||||
{{ role.name }}
|
||||
<button type="button" aria-label="حذف نقش" @click="toggleRole(role.id, role.userIds)">×</button>
|
||||
</span>
|
||||
<span v-for="user in extraAssignees" :key="user.id" class="chip">
|
||||
{{ user.fullName }}
|
||||
<button
|
||||
v-if="assigneeIds.length > 1"
|
||||
@@ -950,9 +1106,7 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
<dt>مسئولان</dt>
|
||||
<dd>
|
||||
{{
|
||||
selectedAssignees.length
|
||||
? selectedAssignees.map((u) => u.fullName).join('، ')
|
||||
: '—'
|
||||
assigneeSummaryLabel
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
@@ -1213,6 +1367,23 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
padding: 0 0.1rem;
|
||||
}
|
||||
|
||||
.menu-label {
|
||||
padding: 0.45rem 0.75rem 0.15rem;
|
||||
color: #64748b;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.multi-menu .role-name {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.multi-menu .role-count {
|
||||
margin-inline-start: auto;
|
||||
color: #94a3b8;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.multi-menu .multi-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user