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:
2026-10-27 18:07:42 +03:30
co-authored by Cursor
parent 8ae6a928f4
commit 9812e4e5ba
8 changed files with 549 additions and 114 deletions
+198 -27
View File
@@ -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;
+89 -13
View File
@@ -81,6 +81,59 @@ const period = ref<'custom' | 'week' | 'month' | 'season'>('month');
const overview = ref<TahesabSalesOverviewDto | null>(null);
const auth = useAuthStore();
const canGroups = computed(() => canManageSettings(auth.user?.role));
const showInternet = computed(() => {
const mods = auth.user?.enabledModules;
return Boolean(mods?.includes('montify'));
});
function normDocTitle(raw: string) {
return raw.replace(/\u064A/g, '\u06CC').replace(/\u0643/g, '\u06A9');
}
const otherDocGroups = computed(() => {
const buckets: Array<{ title: string; hint: string; test: (title: string) => boolean; names: string[] }> = [
{
title: 'درصد اجرت',
hint: 'نرخ اجرت روی سند است. وزن فروش نیست و در چهار بخش کالا جمع نمی‌شود.',
test: (title) => title.includes('درصد'),
names: [],
},
{
title: 'بانک، نقد و چک',
hint: 'جابه‌جایی پول است، فروش کالا نیست.',
test: (title) => /بانک|وجه نقد|چک|برداشت|واریز/.test(title),
names: [],
},
{
title: 'طلب، بدهی، تخفیف و تبدیل',
hint: 'حساب مشتری یا تبدیل پول و وزن است، فروش کالا نیست.',
test: (title) => /طلب|بدهی|تخفیف|پولی کردن/.test(title),
names: [],
},
{
title: 'فروش و خرید بدون نام کالا',
hint: 'این سند فروش یا خرید است، اما نوع کالا در عنوان آن نیامده. وزن آن در ردیف «سایر عنوان‌ها» محاسبه شده است.',
test: (title) => /فروش|خرید|کالا|آبشده|متفرقه/.test(title),
names: [],
},
];
const rest: string[] = [];
for (const name of overview.value?.otherNos ?? []) {
const title = normDocTitle(name);
const bucket = buckets.find((item) => item.test(title));
if (bucket) bucket.names.push(name);
else rest.push(name);
}
if (rest.length) {
buckets.push({
title: 'عنوان‌های دیگر',
hint: 'در چهار بخش کالا جا نگرفته‌اند.',
test: () => true,
names: rest,
});
}
return buckets.filter((item) => item.names.length);
});
const childGroupId = ref<string | undefined>();
const parentGroupId = ref<string | undefined>();
const overviewError = ref('');
@@ -226,12 +279,12 @@ function formatNum(n: number | null | undefined) {
}
function formatMali(n: number | null | undefined) {
return formatTahesabBedBes(n);
return formatTahesabBedBes(n, 0);
}
const columns: TableColumnsType<TahesabAsnadLineDto> = [
{
title: 'تاریخ',
title: 'تاریخ سند',
dataIndex: 'tarikh',
key: 'tarikh',
width: 110,
@@ -513,7 +566,7 @@ onUnmounted(() => stopPoll());
گزارش فروش ته‌حساب
</a-typography-title>
<a-typography-text type="secondary">
وزن خالص فروش و خرید بعد از مرجوعی، به تفکیک گروه، کارشناس و فروش اینترنتی
وزن خالص فروش و خرید بعد از مرجوعی، به تفکیک گروه و کارشناس{{ showInternet ? ' و فروش اینترنتی' : '' }}
</a-typography-text>
</div>
<a-space :size="8" wrap>
@@ -716,25 +769,30 @@ onUnmounted(() => stopPoll());
</a-col>
<a-col :span="24">
<SalesReportBoard :overview="overview" />
<SalesReportBoard :overview="overview" :show-internet="showInternet" />
</a-col>
<a-col v-if="summary" :span="24">
<a-typography-paragraph type="secondary" :style="{ marginBottom: 0 }">
سه عدد زیر جمع همهٔ سندهای بازه است، از جمله بانک و درصد. نتیجهٔ فروش کالا جدول «نتیجهٔ این بازه» است.
</a-typography-paragraph>
</a-col>
<a-col v-if="summary" :xs="24" :sm="8">
<a-card :bordered="false">
<a-statistic
title="تعداد ردیف"
title="تعداد ردیف سند"
:value="toFaDigits(String(summary.totalLines))"
/>
</a-card>
</a-col>
<a-col v-if="summary" :xs="24" :sm="8">
<a-card :bordered="false">
<a-statistic title="جمع مبلغ" :value="formatMali(summary.maliSum)" />
<a-statistic title="جمع مبلغ همهٔ سندها" :value="formatMali(summary.maliSum)" />
</a-card>
</a-col>
<a-col v-if="summary" :xs="24" :sm="8">
<a-card :bordered="false">
<a-statistic title="جمع وزن" :value="formatNum(summary.vaznSum)" />
<a-statistic title="جمع وزن همهٔ سندها" :value="formatNum(summary.vaznSum)" />
</a-card>
</a-col>
@@ -774,12 +832,30 @@ onUnmounted(() => stopPoll());
</a-card>
</a-col>
<a-col v-if="summary?.distinctNos?.length" :span="24">
<a-card :bordered="false" title="انواع سند خام (NO)">
<a-space wrap>
<a-tag v-for="no in summary.distinctNos" :key="no">{{ no }}</a-tag>
</a-space>
</a-card>
<a-col v-if="(overview?.otherNos?.length ?? 0) > 0" :span="24">
<a-collapse :bordered="false">
<a-collapse-panel
key="other"
:header="`سندهایی که نوع کالا مشخص نیست (${toFaDigits(String(overview?.otherNos.length ?? 0))} عنوان)`"
>
<a-typography-paragraph type="secondary">
جدول بالا فقط کارساخته، اتیکت، شمش و سکه است. بقیهٔ عنوان‌ها این‌جا دسته‌بندی شده‌اند.
</a-typography-paragraph>
<a-list :data-source="otherDocGroups" item-layout="vertical">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta
:title="`${item.title} (${toFaDigits(String(item.names.length))})`"
:description="item.hint"
/>
<a-space wrap>
<a-tag v-for="name in item.names" :key="name">{{ name }}</a-tag>
</a-space>
</a-list-item>
</template>
</a-list>
</a-collapse-panel>
</a-collapse>
</a-col>
<a-col :span="24">
+12 -1
View File
@@ -11,6 +11,7 @@ import { useAuthStore } from '@/stores/auth';
import UiIcon from '@/components/UiIcon.vue';
import { UiDropdownMenu, UiModal } from '@/components/ui';
import {
DATA_ACCESS_SECTIONS,
ROLE_PERMISSION_ITEMS,
type DataAccessPolicy,
type InviteUserRequest,
@@ -734,6 +735,14 @@ function clonePolicy(policy?: DataAccessPolicy | null): DataAccessPolicy {
return out;
}
function withOpenSections(policy: DataAccessPolicy): DataAccessPolicy {
const out = clonePolicy(policy);
for (const key of DATA_ACCESS_SECTIONS) {
if (!out[key]) out[key] = { mode: 'all', rules: [] };
}
return out;
}
function syncFocusDataAccess() {
focusDataAccess.value = clonePolicy(accessFocusRole.value?.dataAccessPolicy);
}
@@ -744,7 +753,9 @@ async function saveFocusDataAccess() {
accessSaving.value = true;
error.value = '';
try {
await api.patch(`/org-roles/${role.id}`, { dataAccessPolicy: focusDataAccess.value });
const dataAccessPolicy = withOpenSections(focusDataAccess.value);
focusDataAccess.value = dataAccessPolicy;
await api.patch(`/org-roles/${role.id}`, { dataAccessPolicy });
ok.value = `محدوده داده برای نقش «${role.name}» ذخیره شد.`;
await load();
syncFocusDataAccess();