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
@@ -321,7 +321,7 @@ function openAccessEditor(u: OrgUserDto) {
const hasOverride = DATA_ACCESS_SECTIONS.some((s) => Boolean(override[s]));
accessMode.value = hasOverride ? 'custom' : 'inherit';
draftAccess.value = hasOverride
? clonePolicy(override)
? { ...clonePolicy(u.effectiveDataAccess), ...clonePolicy(override) }
: clonePolicy(u.effectiveDataAccess);
expandedAccess.value = u.id;
}
@@ -1,8 +1,23 @@
<script setup lang="ts">
import { computed } from 'vue';
import { theme } from 'ant-design-vue';
import {
Alert as AAlert,
Card as ACard,
Col as ACol,
Empty as AEmpty,
List as AList,
Row as ARow,
Statistic as AStatistic,
Table as ATable,
Typography,
} from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
const { Paragraph: ATypographyParagraph, Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
import type {
SalesFactorOverviewDto,
SalesMovementStatsDto,
SalesNamedMovementDto,
TahesabSalesOverviewDto,
@@ -12,10 +27,9 @@ import { formatTahesabBedBes } from '@/lib/tahesab';
const props = defineProps<{
overview: TahesabSalesOverviewDto | null;
showInternet?: boolean;
}>();
const { token } = theme.useToken();
function formatNum(n: number | null | undefined) {
if (n == null || Number.isNaN(n)) return '—';
return toFaDigits(new Intl.NumberFormat('fa-IR', { maximumFractionDigits: 3 }).format(n));
@@ -23,28 +37,154 @@ function formatNum(n: number | null | undefined) {
function formatMali(n: number | null | undefined) {
if (n == null) return '—';
return formatTahesabBedBes(n);
return formatTahesabBedBes(n, 0);
}
function formatRial(n: number | null | undefined) {
if (n == null) return '—';
return `${toFaDigits(new Intl.NumberFormat('fa-IR', { maximumFractionDigits: 0 }).format(Math.abs(n)))} ریال`;
}
type GroupRow = {
id: string;
label: string;
netSaleWeight: number;
saleReturnWeight: number;
netSaleMali: number;
children?: GroupRow[];
};
const groupRows = computed<GroupRow[]>(() =>
(props.overview?.groups ?? []).map((group) => ({
id: group.id,
label: group.label,
netSaleWeight: group.netSaleWeight,
saleReturnWeight: group.saleReturnWeight,
netSaleMali: group.netSaleMali,
children: group.children.length
? group.children.map((child) => ({
id: child.id,
label: child.label,
netSaleWeight: child.netSaleWeight,
saleReturnWeight: child.saleReturnWeight,
netSaleMali: child.netSaleMali,
}))
: undefined,
})),
);
const groupColumns: TableColumnsType<GroupRow> = [
{ title: 'گروه', dataIndex: 'label', key: 'label', ellipsis: true },
{
title: 'فروش (گرم)',
key: 'weight',
width: 120,
customRender: ({ record }) => formatNum(record.netSaleWeight),
},
{
title: 'مرجوعی (گرم)',
key: 'return',
width: 120,
customRender: ({ record }) => formatNum(record.saleReturnWeight),
},
{
title: 'مبلغ',
key: 'mali',
width: 160,
customRender: ({ record }) => formatRial(record.netSaleMali),
},
];
function percentText(stats: SalesMovementStatsDto, empty = 'درصدی ثبت نشده') {
const p = stats.percent;
if (!p || p.avg == null) return empty;
return `اول ${formatNum(p.first)} · آخر ${formatNum(p.last)} · میانگین ${formatNum(p.avg)} · کم ${formatNum(p.min)} · بیش ${formatNum(p.max)}`;
}
function percentShort(stats: SalesMovementStatsDto) {
const p = stats.percent;
if (!p || p.avg == null) return '—';
return `میانگین ${formatNum(p.avg)} · از ${formatNum(p.min)} تا ${formatNum(p.max)}`;
}
const factors = computed(() =>
(props.overview?.factors ?? []).filter((f) => f.key !== 'other' || f.sale.grossWeight > 0 || f.purchase.grossWeight > 0),
);
const factorColumns = computed<TableColumnsType<SalesFactorOverviewDto>>(() => {
const cols: TableColumnsType<SalesFactorOverviewDto> = [
{
title: 'بخش کالا',
dataIndex: 'label',
key: 'label',
fixed: 'right',
width: 150,
customRender: ({ record }) => (record.key === 'other' ? 'سایر عنوان‌ها' : record.label),
},
{
title: 'وزن فروش (گرم)',
key: 'saleWeight',
customRender: ({ record }) => formatNum(record.sale.netWeight),
},
{
title: 'مرجوعی فروش (گرم)',
key: 'saleReturn',
customRender: ({ record }) => formatNum(record.sale.returnWeight),
},
{
title: 'مبلغ فروش (ریال)',
key: 'saleMali',
customRender: ({ record }) => formatRial(record.sale.netMali),
},
{
title: 'وزن خرید (گرم)',
key: 'buyWeight',
customRender: ({ record }) => formatNum(record.purchase.netWeight),
},
{
title: 'مرجوعی خرید (گرم)',
key: 'buyReturn',
customRender: ({ record }) => formatNum(record.purchase.returnWeight),
},
{
title: 'مبلغ خرید (ریال)',
key: 'buyMali',
customRender: ({ record }) => formatRial(record.purchase.netMali),
},
{
title: 'تعداد فروش',
key: 'saleCount',
width: 110,
customRender: ({ record }) =>
record.key === 'bullion' ? formatNum(record.sale.netCount) : '—',
},
];
if (props.overview?.percentReady) {
cols.push(
{
title: 'درصد فروش',
key: 'salePercent',
customRender: ({ record }) => percentShort(record.sale),
},
{
title: 'درصد خرید',
key: 'buyPercent',
customRender: ({ record }) => percentShort(record.purchase),
},
);
}
return cols;
});
const karColumns: TableColumnsType<SalesNamedMovementDto> = [
{ title: 'کارساخته', dataIndex: 'name', key: 'name', ellipsis: true },
{ title: 'نام کالا', dataIndex: 'name', key: 'name', ellipsis: true },
{
title: 'خالص فروش',
title: 'وزن فروش (گرم)',
key: 'saleNet',
customRender: ({ record }) => formatNum(record.sale.netWeight),
},
{
title: 'مرجوعی فروش',
title: 'مرجوعی فروش (گرم)',
key: 'saleRet',
customRender: ({ record }) => formatNum(record.sale.returnWeight),
},
@@ -54,12 +194,12 @@ const karColumns: TableColumnsType<SalesNamedMovementDto> = [
customRender: ({ record }) => percentText(record.sale),
},
{
title: 'خالص خرید',
title: 'وزن خرید (گرم)',
key: 'buyNet',
customRender: ({ record }) => formatNum(record.purchase.netWeight),
},
{
title: 'مرجوعی خرید',
title: 'مرجوعی خرید (گرم)',
key: 'buyRet',
customRender: ({ record }) => formatNum(record.purchase.returnWeight),
},
@@ -81,37 +221,25 @@ const karColumns: TableColumnsType<SalesNamedMovementDto> = [
/>
</a-col>
<a-col v-for="factor in factors" :key="factor.key" :xs="24" :sm="12" :xl="6">
<a-card :bordered="false" :title="factor.label">
<a-row :gutter="[12, 8]">
<a-col :span="12">
<a-statistic title="خالص فروش" :value="formatNum(factor.sale.netWeight)" />
<a-typography-text type="secondary">
مرجوعی {{ formatNum(factor.sale.returnWeight) }}
<template v-if="factor.key === 'bullion'">
· تعداد {{ formatNum(factor.sale.netCount) }}
</template>
</a-typography-text>
</a-col>
<a-col :span="12">
<a-statistic title="خالص خرید" :value="formatNum(factor.purchase.netWeight)" />
<a-typography-text type="secondary">
مرجوعی {{ formatNum(factor.purchase.returnWeight) }}
</a-typography-text>
</a-col>
<a-col :span="24">
<a-typography-text type="secondary">مبلغ فروش {{ formatMali(factor.sale.netMali) }}</a-typography-text>
</a-col>
<a-col v-if="percentText(factor.sale, '') || percentText(factor.purchase, '')" :span="24">
<div v-if="percentText(factor.sale, '')">خروج: {{ percentText(factor.sale, '') }}</div>
<div v-if="percentText(factor.purchase, '')">ورود: {{ percentText(factor.purchase, '') }}</div>
</a-col>
</a-row>
<a-col :span="24">
<a-card :bordered="false" title="نتیجهٔ این بازه">
<a-typography-paragraph type="secondary">
هر ردیف یک بخش کالاست. وزن فروش، گرم فروخته‌شده بعد از کم شدن مرجوعی همان بازه است.
مبلغ، ریال همان فروش است. تعداد فقط برای شمش و سکه معنی دارد.
«سایر عنوان‌ها» سندهایی است که در نامشان کارساخته، اتیکت، شمش یا سکه نبوده.
</a-typography-paragraph>
<a-table
:columns="factorColumns"
:data-source="factors"
row-key="key"
:pagination="false"
:scroll="{ x: 1100 }"
/>
</a-card>
</a-col>
<a-col :span="24">
<a-card :bordered="false" title="کارساخته، خالص بعد از مرجوعی">
<a-card :bordered="false" title="ریز کارساخته، به تفکیک نام کالا">
<a-table
:columns="karColumns"
:data-source="overview.karsakhte"
@@ -126,36 +254,34 @@ const karColumns: TableColumnsType<SalesNamedMovementDto> = [
</a-card>
</a-col>
<a-col :xs="24" :lg="12">
<a-card :bordered="false" title="گروه اصلی و فرعی">
<a-empty v-if="!overview.groups.length" description="گروهی در این بازه فروش ندارد." />
<div v-for="group in overview.groups" :key="group.id">
<a-flex justify="space-between" :style="{ marginTop: token.marginSM }">
<a-typography-text strong>{{ group.label }}</a-typography-text>
<a-typography-text>
خالص {{ formatNum(group.netSaleWeight) }} · مرجوعی {{ formatNum(group.saleReturnWeight) }}
</a-typography-text>
</a-flex>
<a-list v-if="group.children.length" :data-source="group.children">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta :title="item.label">
<template #description>
خالص {{ formatNum(item.netSaleWeight) }} · مبلغ {{ formatMali(item.netSaleMali) }} · مرجوعی
{{ formatNum(item.saleReturnWeight) }}
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</div>
<a-col :span="24">
<a-card :bordered="false" title="فروش به تفکیک گروه">
<a-typography-paragraph type="secondary">
عدد گروه اصلی، فروش خودش و زیرگروه‌ها را با هم نشان می‌دهد.
</a-typography-paragraph>
<a-table
:columns="groupColumns"
:data-source="groupRows"
row-key="id"
default-expand-all-rows
:pagination="groupRows.length > 6 ? { pageSize: 6, showSizeChanger: false } : false"
:scroll="{ x: 560 }"
>
<template #emptyText>
<a-empty description="گروهی در این بازه فروش ندارد." />
</template>
</a-table>
</a-card>
</a-col>
<a-col :xs="24" :lg="12">
<a-card :bordered="false" title="پورسانت کارشناس">
<a-typography-paragraph type="secondary">
کارساختهٔ نقره و ساچمه (اتیکت جواهر و فروش اینترنتی) جدا حساب می‌شوند.
{{
showInternet
? 'کارساختهٔ نقره جدا از سرمایه‌گذاری (اتیکت جواهر و فروش اینترنتی) حساب می‌شود.'
: 'کارساختهٔ نقره جدا از سرمایه‌گذاری (اتیکت جواهر) حساب می‌شود.'
}}
</a-typography-paragraph>
<a-list
:data-source="overview.experts"
@@ -179,7 +305,7 @@ const karColumns: TableColumnsType<SalesNamedMovementDto> = [
</a-card>
</a-col>
<a-col :span="24">
<a-col v-if="showInternet" :span="24">
<a-card :bordered="false" title="فروش اینترنتی (مونتیفای)">
<a-row :gutter="[16, 16]">
<a-col :xs="24" :sm="8">
@@ -203,12 +329,5 @@ const karColumns: TableColumnsType<SalesNamedMovementDto> = [
</a-card>
</a-col>
<a-col v-if="overview.otherNos.length" :span="24">
<a-card :bordered="false" title="سندهایی که در چهار بخش جا نگرفتند">
<a-space wrap>
<a-tag v-for="name in overview.otherNos" :key="name">{{ name }}</a-tag>
</a-space>
</a-card>
</a-col>
</a-row>
</template>