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:
@@ -62,6 +62,11 @@ export function resolveSectionAccess(
|
|||||||
if (rolePolicy[sectionKey]) {
|
if (rolePolicy[sectionKey]) {
|
||||||
return { section: rolePolicy[sectionKey]!, source: 'role' };
|
return { section: rolePolicy[sectionKey]!, source: 'role' };
|
||||||
}
|
}
|
||||||
|
// فرم محدودهٔ نقش، بخشِ ننوشته را «بدون محدودیت» نشان میدهد.
|
||||||
|
// اگر نقش حتی یک بخش ذخیره کرده باشد، بقیه نباید به دید قدیمی own/team برگردند.
|
||||||
|
if (Object.keys(rolePolicy).length > 0) {
|
||||||
|
return { section: { mode: 'all', rules: [] }, source: 'role' };
|
||||||
|
}
|
||||||
if (input.legacyVisibility != null || input.legacyScopes) {
|
if (input.legacyVisibility != null || input.legacyScopes) {
|
||||||
return {
|
return {
|
||||||
section: migrateVisibilityToSectionAccess(
|
section: migrateVisibilityToSectionAccess(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it } from 'node:test';
|
import { describe, it } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { leadsDeepWhere, resolveSectionAccess } from '../src/common/data-access';
|
||||||
import {
|
import {
|
||||||
noteAccessWhere,
|
noteAccessWhere,
|
||||||
normalizeDataVisibility,
|
normalizeDataVisibility,
|
||||||
@@ -67,3 +68,57 @@ describe('سطح دسترسی به پروندهها', () => {
|
|||||||
assert.equal(normalizeDataVisibility('weird'), 'own');
|
assert.equal(normalizeDataVisibility('weird'), 'own');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('عمق داده وقتی نقش فقط بعضی بخشها را ذخیره کرده', () => {
|
||||||
|
const legacyOwn = {
|
||||||
|
role: 'sales',
|
||||||
|
legacyVisibility: 'own' as const,
|
||||||
|
legacyScopes: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
it('بخش ننوشتهٔ نقشِ تنظیمشده بدون محدودیت است، نه فقط مسئول خودش', () => {
|
||||||
|
const leads = resolveSectionAccess('leads', {
|
||||||
|
...legacyOwn,
|
||||||
|
rolePolicy: { customers: { mode: 'rules', rules: [] } },
|
||||||
|
override: {},
|
||||||
|
});
|
||||||
|
assert.equal(leads.source, 'role');
|
||||||
|
assert.deepEqual(leads.section, { mode: 'all', rules: [] });
|
||||||
|
assert.deepEqual(
|
||||||
|
leadsDeepWhere({ userId: 'u1', role: 'sales', leadsAccess: leads.section, teamPeerIds: null }),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('نقش بدون سیاست جدید همچنان دید قدیمی را نگه میدارد', () => {
|
||||||
|
const leads = resolveSectionAccess('leads', {
|
||||||
|
...legacyOwn,
|
||||||
|
rolePolicy: {},
|
||||||
|
override: {},
|
||||||
|
});
|
||||||
|
assert.deepEqual(leads.section, { mode: 'rules', rules: [{ owners: ['self'] }] });
|
||||||
|
assert.deepEqual(
|
||||||
|
leadsDeepWhere({ userId: 'u1', role: 'sales', leadsAccess: leads.section, teamPeerIds: null }),
|
||||||
|
{ assigneeId: 'u1' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('override کاربر بر بخش باز نقش اولویت دارد', () => {
|
||||||
|
const leads = resolveSectionAccess('leads', {
|
||||||
|
...legacyOwn,
|
||||||
|
rolePolicy: { customers: { mode: 'all', rules: [] } },
|
||||||
|
override: { leads: { mode: 'rules', rules: [{ owners: ['self'] }] } },
|
||||||
|
});
|
||||||
|
assert.equal(leads.source, 'override');
|
||||||
|
assert.deepEqual(leads.section, { mode: 'rules', rules: [{ owners: ['self'] }] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('سفارشی کردن فقط مشتریان، لید را از دید قدیمی جدا نمیکند', () => {
|
||||||
|
const leads = resolveSectionAccess('leads', {
|
||||||
|
...legacyOwn,
|
||||||
|
rolePolicy: {},
|
||||||
|
override: { customers: { mode: 'all', rules: [] } },
|
||||||
|
});
|
||||||
|
assert.deepEqual(leads.section, { mode: 'rules', rules: [{ owners: ['self'] }] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -321,7 +321,7 @@ function openAccessEditor(u: OrgUserDto) {
|
|||||||
const hasOverride = DATA_ACCESS_SECTIONS.some((s) => Boolean(override[s]));
|
const hasOverride = DATA_ACCESS_SECTIONS.some((s) => Boolean(override[s]));
|
||||||
accessMode.value = hasOverride ? 'custom' : 'inherit';
|
accessMode.value = hasOverride ? 'custom' : 'inherit';
|
||||||
draftAccess.value = hasOverride
|
draftAccess.value = hasOverride
|
||||||
? clonePolicy(override)
|
? { ...clonePolicy(u.effectiveDataAccess), ...clonePolicy(override) }
|
||||||
: clonePolicy(u.effectiveDataAccess);
|
: clonePolicy(u.effectiveDataAccess);
|
||||||
expandedAccess.value = u.id;
|
expandedAccess.value = u.id;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
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';
|
import type { TableColumnsType } from 'ant-design-vue';
|
||||||
|
|
||||||
|
const { Paragraph: ATypographyParagraph, Text: ATypographyText } = Typography;
|
||||||
|
const AListItem = AList.Item;
|
||||||
|
const AListItemMeta = AList.Item.Meta;
|
||||||
import type {
|
import type {
|
||||||
|
SalesFactorOverviewDto,
|
||||||
SalesMovementStatsDto,
|
SalesMovementStatsDto,
|
||||||
SalesNamedMovementDto,
|
SalesNamedMovementDto,
|
||||||
TahesabSalesOverviewDto,
|
TahesabSalesOverviewDto,
|
||||||
@@ -12,10 +27,9 @@ import { formatTahesabBedBes } from '@/lib/tahesab';
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
overview: TahesabSalesOverviewDto | null;
|
overview: TahesabSalesOverviewDto | null;
|
||||||
|
showInternet?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { token } = theme.useToken();
|
|
||||||
|
|
||||||
function formatNum(n: number | null | undefined) {
|
function formatNum(n: number | null | undefined) {
|
||||||
if (n == null || Number.isNaN(n)) return '—';
|
if (n == null || Number.isNaN(n)) return '—';
|
||||||
return toFaDigits(new Intl.NumberFormat('fa-IR', { maximumFractionDigits: 3 }).format(n));
|
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) {
|
function formatMali(n: number | null | undefined) {
|
||||||
if (n == null) return '—';
|
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 = 'درصدی ثبت نشده') {
|
function percentText(stats: SalesMovementStatsDto, empty = 'درصدی ثبت نشده') {
|
||||||
const p = stats.percent;
|
const p = stats.percent;
|
||||||
if (!p || p.avg == null) return empty;
|
if (!p || p.avg == null) return empty;
|
||||||
return `اول ${formatNum(p.first)} · آخر ${formatNum(p.last)} · میانگین ${formatNum(p.avg)} · کم ${formatNum(p.min)} · بیش ${formatNum(p.max)}`;
|
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(() =>
|
const factors = computed(() =>
|
||||||
(props.overview?.factors ?? []).filter((f) => f.key !== 'other' || f.sale.grossWeight > 0 || f.purchase.grossWeight > 0),
|
(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> = [
|
const karColumns: TableColumnsType<SalesNamedMovementDto> = [
|
||||||
{ title: 'کارساخته', dataIndex: 'name', key: 'name', ellipsis: true },
|
{ title: 'نام کالا', dataIndex: 'name', key: 'name', ellipsis: true },
|
||||||
{
|
{
|
||||||
title: 'خالص فروش',
|
title: 'وزن فروش (گرم)',
|
||||||
key: 'saleNet',
|
key: 'saleNet',
|
||||||
customRender: ({ record }) => formatNum(record.sale.netWeight),
|
customRender: ({ record }) => formatNum(record.sale.netWeight),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'مرجوعی فروش',
|
title: 'مرجوعی فروش (گرم)',
|
||||||
key: 'saleRet',
|
key: 'saleRet',
|
||||||
customRender: ({ record }) => formatNum(record.sale.returnWeight),
|
customRender: ({ record }) => formatNum(record.sale.returnWeight),
|
||||||
},
|
},
|
||||||
@@ -54,12 +194,12 @@ const karColumns: TableColumnsType<SalesNamedMovementDto> = [
|
|||||||
customRender: ({ record }) => percentText(record.sale),
|
customRender: ({ record }) => percentText(record.sale),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'خالص خرید',
|
title: 'وزن خرید (گرم)',
|
||||||
key: 'buyNet',
|
key: 'buyNet',
|
||||||
customRender: ({ record }) => formatNum(record.purchase.netWeight),
|
customRender: ({ record }) => formatNum(record.purchase.netWeight),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'مرجوعی خرید',
|
title: 'مرجوعی خرید (گرم)',
|
||||||
key: 'buyRet',
|
key: 'buyRet',
|
||||||
customRender: ({ record }) => formatNum(record.purchase.returnWeight),
|
customRender: ({ record }) => formatNum(record.purchase.returnWeight),
|
||||||
},
|
},
|
||||||
@@ -81,37 +221,25 @@ const karColumns: TableColumnsType<SalesNamedMovementDto> = [
|
|||||||
/>
|
/>
|
||||||
</a-col>
|
</a-col>
|
||||||
|
|
||||||
<a-col v-for="factor in factors" :key="factor.key" :xs="24" :sm="12" :xl="6">
|
<a-col :span="24">
|
||||||
<a-card :bordered="false" :title="factor.label">
|
<a-card :bordered="false" title="نتیجهٔ این بازه">
|
||||||
<a-row :gutter="[12, 8]">
|
<a-typography-paragraph type="secondary">
|
||||||
<a-col :span="12">
|
هر ردیف یک بخش کالاست. وزن فروش، گرم فروختهشده بعد از کم شدن مرجوعی همان بازه است.
|
||||||
<a-statistic title="خالص فروش" :value="formatNum(factor.sale.netWeight)" />
|
مبلغ، ریال همان فروش است. تعداد فقط برای شمش و سکه معنی دارد.
|
||||||
<a-typography-text type="secondary">
|
«سایر عنوانها» سندهایی است که در نامشان کارساخته، اتیکت، شمش یا سکه نبوده.
|
||||||
مرجوعی {{ formatNum(factor.sale.returnWeight) }}
|
</a-typography-paragraph>
|
||||||
<template v-if="factor.key === 'bullion'">
|
<a-table
|
||||||
· تعداد {{ formatNum(factor.sale.netCount) }}
|
:columns="factorColumns"
|
||||||
</template>
|
:data-source="factors"
|
||||||
</a-typography-text>
|
row-key="key"
|
||||||
</a-col>
|
:pagination="false"
|
||||||
<a-col :span="12">
|
:scroll="{ x: 1100 }"
|
||||||
<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-card>
|
</a-card>
|
||||||
</a-col>
|
</a-col>
|
||||||
|
|
||||||
<a-col :span="24">
|
<a-col :span="24">
|
||||||
<a-card :bordered="false" title="کارساخته، خالص بعد از مرجوعی">
|
<a-card :bordered="false" title="ریز کارساخته، به تفکیک نام کالا">
|
||||||
<a-table
|
<a-table
|
||||||
:columns="karColumns"
|
:columns="karColumns"
|
||||||
:data-source="overview.karsakhte"
|
:data-source="overview.karsakhte"
|
||||||
@@ -126,36 +254,34 @@ const karColumns: TableColumnsType<SalesNamedMovementDto> = [
|
|||||||
</a-card>
|
</a-card>
|
||||||
</a-col>
|
</a-col>
|
||||||
|
|
||||||
<a-col :xs="24" :lg="12">
|
<a-col :span="24">
|
||||||
<a-card :bordered="false" title="گروه اصلی و فرعی">
|
<a-card :bordered="false" title="فروش به تفکیک گروه">
|
||||||
<a-empty v-if="!overview.groups.length" description="گروهی در این بازه فروش ندارد." />
|
<a-typography-paragraph type="secondary">
|
||||||
<div v-for="group in overview.groups" :key="group.id">
|
عدد گروه اصلی، فروش خودش و زیرگروهها را با هم نشان میدهد.
|
||||||
<a-flex justify="space-between" :style="{ marginTop: token.marginSM }">
|
</a-typography-paragraph>
|
||||||
<a-typography-text strong>{{ group.label }}</a-typography-text>
|
<a-table
|
||||||
<a-typography-text>
|
:columns="groupColumns"
|
||||||
خالص {{ formatNum(group.netSaleWeight) }} · مرجوعی {{ formatNum(group.saleReturnWeight) }}
|
:data-source="groupRows"
|
||||||
</a-typography-text>
|
row-key="id"
|
||||||
</a-flex>
|
default-expand-all-rows
|
||||||
<a-list v-if="group.children.length" :data-source="group.children">
|
:pagination="groupRows.length > 6 ? { pageSize: 6, showSizeChanger: false } : false"
|
||||||
<template #renderItem="{ item }">
|
:scroll="{ x: 560 }"
|
||||||
<a-list-item>
|
>
|
||||||
<a-list-item-meta :title="item.label">
|
<template #emptyText>
|
||||||
<template #description>
|
<a-empty description="گروهی در این بازه فروش ندارد." />
|
||||||
خالص {{ formatNum(item.netSaleWeight) }} · مبلغ {{ formatMali(item.netSaleMali) }} · مرجوعی
|
</template>
|
||||||
{{ formatNum(item.saleReturnWeight) }}
|
</a-table>
|
||||||
</template>
|
|
||||||
</a-list-item-meta>
|
|
||||||
</a-list-item>
|
|
||||||
</template>
|
|
||||||
</a-list>
|
|
||||||
</div>
|
|
||||||
</a-card>
|
</a-card>
|
||||||
</a-col>
|
</a-col>
|
||||||
|
|
||||||
<a-col :xs="24" :lg="12">
|
<a-col :xs="24" :lg="12">
|
||||||
<a-card :bordered="false" title="پورسانت کارشناس">
|
<a-card :bordered="false" title="پورسانت کارشناس">
|
||||||
<a-typography-paragraph type="secondary">
|
<a-typography-paragraph type="secondary">
|
||||||
کارساختهٔ نقره و ساچمه (اتیکت جواهر و فروش اینترنتی) جدا حساب میشوند.
|
{{
|
||||||
|
showInternet
|
||||||
|
? 'کارساختهٔ نقره جدا از سرمایهگذاری (اتیکت جواهر و فروش اینترنتی) حساب میشود.'
|
||||||
|
: 'کارساختهٔ نقره جدا از سرمایهگذاری (اتیکت جواهر) حساب میشود.'
|
||||||
|
}}
|
||||||
</a-typography-paragraph>
|
</a-typography-paragraph>
|
||||||
<a-list
|
<a-list
|
||||||
:data-source="overview.experts"
|
:data-source="overview.experts"
|
||||||
@@ -179,7 +305,7 @@ const karColumns: TableColumnsType<SalesNamedMovementDto> = [
|
|||||||
</a-card>
|
</a-card>
|
||||||
</a-col>
|
</a-col>
|
||||||
|
|
||||||
<a-col :span="24">
|
<a-col v-if="showInternet" :span="24">
|
||||||
<a-card :bordered="false" title="فروش اینترنتی (مونتیفای)">
|
<a-card :bordered="false" title="فروش اینترنتی (مونتیفای)">
|
||||||
<a-row :gutter="[16, 16]">
|
<a-row :gutter="[16, 16]">
|
||||||
<a-col :xs="24" :sm="8">
|
<a-col :xs="24" :sm="8">
|
||||||
@@ -203,12 +329,5 @@ const karColumns: TableColumnsType<SalesNamedMovementDto> = [
|
|||||||
</a-card>
|
</a-card>
|
||||||
</a-col>
|
</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>
|
</a-row>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -14,16 +14,14 @@ export const TAHSAB_SANAD_LABELS: Record<string, string> = {
|
|||||||
* منفی → قدرمطلق + «بد» (بدهکار)، مثبت → قدرمطلق + «بس» (بستانکار).
|
* منفی → قدرمطلق + «بد» (بدهکار)، مثبت → قدرمطلق + «بس» (بستانکار).
|
||||||
* صفر بدون پسوند.
|
* صفر بدون پسوند.
|
||||||
*/
|
*/
|
||||||
export function formatTahesabBedBes(v: unknown): string {
|
export function formatTahesabBedBes(v: unknown, maximumFractionDigits = 3): string {
|
||||||
if (v == null) return '—';
|
if (v == null) return '—';
|
||||||
const s = String(v).trim();
|
const s = String(v).trim();
|
||||||
if (!s || s === 'null' || s === 'undefined' || s === 'NaN') return '—';
|
if (!s || s === 'null' || s === 'undefined' || s === 'NaN') return '—';
|
||||||
const n = Number(s.replace(/,/g, ''));
|
const n = Number(s.replace(/,/g, ''));
|
||||||
if (!Number.isFinite(n)) return toFaDigits(s);
|
if (!Number.isFinite(n)) return toFaDigits(s);
|
||||||
const abs = toFaDigits(
|
const abs = toFaDigits(
|
||||||
new Intl.NumberFormat('fa-IR', { maximumFractionDigits: 3 }).format(
|
new Intl.NumberFormat('fa-IR', { maximumFractionDigits }).format(Math.abs(n)),
|
||||||
Math.abs(n),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
if (n === 0) return abs;
|
if (n === 0) return abs;
|
||||||
return `${abs} ${n < 0 ? 'بد' : 'بس'}`;
|
return `${abs} ${n < 0 ? 'بد' : 'بس'}`;
|
||||||
|
|||||||
@@ -119,6 +119,8 @@ const smsAssigneesWithoutMobile = computed(() =>
|
|||||||
|
|
||||||
const typeOpen = ref(false);
|
const typeOpen = ref(false);
|
||||||
const assigneeOpen = ref(false);
|
const assigneeOpen = ref(false);
|
||||||
|
const assigneeQuery = ref('');
|
||||||
|
const assigneeSearchInput = ref<HTMLInputElement | null>(null);
|
||||||
const personOpen = ref(false);
|
const personOpen = ref(false);
|
||||||
const personQuery = ref('');
|
const personQuery = ref('');
|
||||||
const personResults = ref<PersonDto[] | null>(null);
|
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() {
|
function togglePersonMenu() {
|
||||||
personOpen.value = !personOpen.value;
|
personOpen.value = !personOpen.value;
|
||||||
if (personOpen.value) {
|
if (personOpen.value) {
|
||||||
@@ -236,11 +251,74 @@ const selectedAssignees = computed(() =>
|
|||||||
.map((id) => users.value.find((u) => u.id === id))
|
.map((id) => users.value.find((u) => u.id === id))
|
||||||
.filter((u): u is (typeof users.value)[number] => Boolean(u)),
|
.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 assigneePickerLabel = computed(() => {
|
||||||
const list = selectedAssignees.value;
|
const roles = selectedRoles.value;
|
||||||
if (!list.length) return 'انتخاب مسئول';
|
const extras = extraAssignees.value;
|
||||||
if (list.length === 1) return list[0]!.fullName;
|
if (!assigneeIds.value.length) return 'انتخاب مسئول';
|
||||||
return `${list[0]!.fullName} و ${toFaDigits(list.length - 1)} نفر دیگر`;
|
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 selectedDeal = computed(() => deals.value.find((d) => d.id === dealId.value) ?? null);
|
||||||
const personDeals = computed(() =>
|
const personDeals = computed(() =>
|
||||||
@@ -297,6 +375,7 @@ function resetForm() {
|
|||||||
personId.value = '';
|
personId.value = '';
|
||||||
dealId.value = '';
|
dealId.value = '';
|
||||||
assigneeIds.value = auth.user?.id ? [auth.user.id] : [];
|
assigneeIds.value = auth.user?.id ? [auth.user.id] : [];
|
||||||
|
pickedRoleIds.value = [];
|
||||||
isDone.value = false;
|
isDone.value = false;
|
||||||
needsNote.value = false;
|
needsNote.value = false;
|
||||||
reminderAt.value = '';
|
reminderAt.value = '';
|
||||||
@@ -380,6 +459,7 @@ async function load() {
|
|||||||
: auth.user?.id
|
: auth.user?.id
|
||||||
? [auth.user.id]
|
? [auth.user.id]
|
||||||
: [];
|
: [];
|
||||||
|
refreshPickedRoles();
|
||||||
isDone.value = data.status === 'done';
|
isDone.value = data.status === 'done';
|
||||||
} else {
|
} else {
|
||||||
resetForm();
|
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) {
|
function toggleAssignee(userId: string) {
|
||||||
const idx = assigneeIds.value.indexOf(userId);
|
const idx = assigneeIds.value.indexOf(userId);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
if (assigneeIds.value.length <= 1) return;
|
if (assigneeIds.value.length <= 1) return;
|
||||||
assigneeIds.value = assigneeIds.value.filter((id) => id !== userId);
|
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;
|
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() {
|
function validate() {
|
||||||
@@ -703,7 +818,7 @@ watch([id, isNew], () => void load(), { immediate: true });
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>مسئولان انجام این فعالیت <i>*</i></label>
|
<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">
|
<div class="assignee-avatars">
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
v-for="user in selectedAssignees.slice(0, 3)"
|
v-for="user in selectedAssignees.slice(0, 3)"
|
||||||
@@ -724,25 +839,66 @@ watch([id, isNew], () => void load(), { immediate: true });
|
|||||||
</span>
|
</span>
|
||||||
<UiIcon name="chevron" :size="16" />
|
<UiIcon name="chevron" :size="16" />
|
||||||
</button>
|
</button>
|
||||||
<ul v-if="assigneeOpen" class="menu multi-menu" @click.stop>
|
<div v-if="assigneeOpen" class="menu person-menu multi-menu" @click.stop>
|
||||||
<li v-for="user in users" :key="user.id">
|
<label class="person-search">
|
||||||
<button
|
<UiIcon name="search" :size="15" />
|
||||||
type="button"
|
<input
|
||||||
class="multi-option"
|
ref="assigneeSearchInput"
|
||||||
:class="{ on: assigneeIds.includes(user.id) }"
|
v-model="assigneeQuery"
|
||||||
@click="toggleAssignee(user.id)"
|
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" />
|
</li>
|
||||||
</span>
|
</ul>
|
||||||
<UserAvatar :name="user.fullName" :user-id="user.id" :has-avatar="user.hasAvatar" :size="28" />
|
</div>
|
||||||
{{ user.fullName }}
|
<small class="hint">یک نقش را انتخاب کنید تا همهٔ اعضای آن مسئول شوند، یا چند نفر را جدا انتخاب کنید.</small>
|
||||||
</button>
|
<div v-if="selectedRoles.length || extraAssignees.length" class="assignee-chips">
|
||||||
</li>
|
<span v-for="role in selectedRoles" :key="role.id" class="chip">
|
||||||
</ul>
|
{{ role.name }}
|
||||||
<small class="hint">میتوانید چند نفر را بهعنوان مسئول انتخاب کنید.</small>
|
<button type="button" aria-label="حذف نقش" @click="toggleRole(role.id, role.userIds)">×</button>
|
||||||
<div v-if="selectedAssignees.length" class="assignee-chips">
|
</span>
|
||||||
<span v-for="user in selectedAssignees" :key="user.id" class="chip">
|
<span v-for="user in extraAssignees" :key="user.id" class="chip">
|
||||||
{{ user.fullName }}
|
{{ user.fullName }}
|
||||||
<button
|
<button
|
||||||
v-if="assigneeIds.length > 1"
|
v-if="assigneeIds.length > 1"
|
||||||
@@ -950,9 +1106,7 @@ watch([id, isNew], () => void load(), { immediate: true });
|
|||||||
<dt>مسئولان</dt>
|
<dt>مسئولان</dt>
|
||||||
<dd>
|
<dd>
|
||||||
{{
|
{{
|
||||||
selectedAssignees.length
|
assigneeSummaryLabel
|
||||||
? selectedAssignees.map((u) => u.fullName).join('، ')
|
|
||||||
: '—'
|
|
||||||
}}
|
}}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
@@ -1213,6 +1367,23 @@ watch([id, isNew], () => void load(), { immediate: true });
|
|||||||
padding: 0 0.1rem;
|
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 {
|
.multi-menu .multi-option {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -81,6 +81,59 @@ const period = ref<'custom' | 'week' | 'month' | 'season'>('month');
|
|||||||
const overview = ref<TahesabSalesOverviewDto | null>(null);
|
const overview = ref<TahesabSalesOverviewDto | null>(null);
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const canGroups = computed(() => canManageSettings(auth.user?.role));
|
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 childGroupId = ref<string | undefined>();
|
||||||
const parentGroupId = ref<string | undefined>();
|
const parentGroupId = ref<string | undefined>();
|
||||||
const overviewError = ref('');
|
const overviewError = ref('');
|
||||||
@@ -226,12 +279,12 @@ function formatNum(n: number | null | undefined) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatMali(n: number | null | undefined) {
|
function formatMali(n: number | null | undefined) {
|
||||||
return formatTahesabBedBes(n);
|
return formatTahesabBedBes(n, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: TableColumnsType<TahesabAsnadLineDto> = [
|
const columns: TableColumnsType<TahesabAsnadLineDto> = [
|
||||||
{
|
{
|
||||||
title: 'تاریخ',
|
title: 'تاریخ سند',
|
||||||
dataIndex: 'tarikh',
|
dataIndex: 'tarikh',
|
||||||
key: 'tarikh',
|
key: 'tarikh',
|
||||||
width: 110,
|
width: 110,
|
||||||
@@ -513,7 +566,7 @@ onUnmounted(() => stopPoll());
|
|||||||
گزارش فروش تهحساب
|
گزارش فروش تهحساب
|
||||||
</a-typography-title>
|
</a-typography-title>
|
||||||
<a-typography-text type="secondary">
|
<a-typography-text type="secondary">
|
||||||
وزن خالص فروش و خرید بعد از مرجوعی، به تفکیک گروه، کارشناس و فروش اینترنتی
|
وزن خالص فروش و خرید بعد از مرجوعی، به تفکیک گروه و کارشناس{{ showInternet ? ' و فروش اینترنتی' : '' }}
|
||||||
</a-typography-text>
|
</a-typography-text>
|
||||||
</div>
|
</div>
|
||||||
<a-space :size="8" wrap>
|
<a-space :size="8" wrap>
|
||||||
@@ -716,25 +769,30 @@ onUnmounted(() => stopPoll());
|
|||||||
</a-col>
|
</a-col>
|
||||||
|
|
||||||
<a-col :span="24">
|
<a-col :span="24">
|
||||||
<SalesReportBoard :overview="overview" />
|
<SalesReportBoard :overview="overview" :show-internet="showInternet" />
|
||||||
</a-col>
|
</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-col v-if="summary" :xs="24" :sm="8">
|
||||||
<a-card :bordered="false">
|
<a-card :bordered="false">
|
||||||
<a-statistic
|
<a-statistic
|
||||||
title="تعداد ردیف"
|
title="تعداد ردیف سند"
|
||||||
:value="toFaDigits(String(summary.totalLines))"
|
:value="toFaDigits(String(summary.totalLines))"
|
||||||
/>
|
/>
|
||||||
</a-card>
|
</a-card>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col v-if="summary" :xs="24" :sm="8">
|
<a-col v-if="summary" :xs="24" :sm="8">
|
||||||
<a-card :bordered="false">
|
<a-card :bordered="false">
|
||||||
<a-statistic title="جمع مبلغ" :value="formatMali(summary.maliSum)" />
|
<a-statistic title="جمع مبلغ همهٔ سندها" :value="formatMali(summary.maliSum)" />
|
||||||
</a-card>
|
</a-card>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col v-if="summary" :xs="24" :sm="8">
|
<a-col v-if="summary" :xs="24" :sm="8">
|
||||||
<a-card :bordered="false">
|
<a-card :bordered="false">
|
||||||
<a-statistic title="جمع وزن" :value="formatNum(summary.vaznSum)" />
|
<a-statistic title="جمع وزن همهٔ سندها" :value="formatNum(summary.vaznSum)" />
|
||||||
</a-card>
|
</a-card>
|
||||||
</a-col>
|
</a-col>
|
||||||
|
|
||||||
@@ -774,12 +832,30 @@ onUnmounted(() => stopPoll());
|
|||||||
</a-card>
|
</a-card>
|
||||||
</a-col>
|
</a-col>
|
||||||
|
|
||||||
<a-col v-if="summary?.distinctNos?.length" :span="24">
|
<a-col v-if="(overview?.otherNos?.length ?? 0) > 0" :span="24">
|
||||||
<a-card :bordered="false" title="انواع سند خام (NO)">
|
<a-collapse :bordered="false">
|
||||||
<a-space wrap>
|
<a-collapse-panel
|
||||||
<a-tag v-for="no in summary.distinctNos" :key="no">{{ no }}</a-tag>
|
key="other"
|
||||||
</a-space>
|
:header="`سندهایی که نوع کالا مشخص نیست (${toFaDigits(String(overview?.otherNos.length ?? 0))} عنوان)`"
|
||||||
</a-card>
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
<a-col :span="24">
|
<a-col :span="24">
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useAuthStore } from '@/stores/auth';
|
|||||||
import UiIcon from '@/components/UiIcon.vue';
|
import UiIcon from '@/components/UiIcon.vue';
|
||||||
import { UiDropdownMenu, UiModal } from '@/components/ui';
|
import { UiDropdownMenu, UiModal } from '@/components/ui';
|
||||||
import {
|
import {
|
||||||
|
DATA_ACCESS_SECTIONS,
|
||||||
ROLE_PERMISSION_ITEMS,
|
ROLE_PERMISSION_ITEMS,
|
||||||
type DataAccessPolicy,
|
type DataAccessPolicy,
|
||||||
type InviteUserRequest,
|
type InviteUserRequest,
|
||||||
@@ -734,6 +735,14 @@ function clonePolicy(policy?: DataAccessPolicy | null): DataAccessPolicy {
|
|||||||
return out;
|
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() {
|
function syncFocusDataAccess() {
|
||||||
focusDataAccess.value = clonePolicy(accessFocusRole.value?.dataAccessPolicy);
|
focusDataAccess.value = clonePolicy(accessFocusRole.value?.dataAccessPolicy);
|
||||||
}
|
}
|
||||||
@@ -744,7 +753,9 @@ async function saveFocusDataAccess() {
|
|||||||
accessSaving.value = true;
|
accessSaving.value = true;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
try {
|
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}» ذخیره شد.`;
|
ok.value = `محدوده داده برای نقش «${role.name}» ذخیره شد.`;
|
||||||
await load();
|
await load();
|
||||||
syncFocusDataAccess();
|
syncFocusDataAccess();
|
||||||
|
|||||||
Reference in New Issue
Block a user