Files
CRM_Montzar/apps/web/src/views/CommissionsView.vue
T
MahdiandCursor 6c6e32e099 feat(web): adopt vuesax icons and shared skeleton loading
Replace the inline SVG icon set with the vuesax linear pack, add skeleton primitives for loading states, and keep page scroll inside the app shell.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 14:46:35 +03:30

239 lines
8.5 KiB
Vue

<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import { RouterLink } from 'vue-router';
import { api } from '@/lib/api';
import { formatMoney } from '@/lib/money';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Col as ACol,
Empty as AEmpty,
Flex as AFlex,
Progress as AProgress,
Row as ARow,
Statistic as AStatistic,
Table as ATable,
Tag as ATag,
Typography,
} from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
import {
FileTextOutlined,
ReloadOutlined,
RiseOutlined,
WalletOutlined,
} from '@/icons/ant-bridge';
import type { CommissionEntryDto } from '@mento/shared';
import { CrmSkeleton, CrmSkeletonTable } from '@/components/skeleton';
const { Title: ATypographyTitle, Text: ATypographyText, Link: ATypographyLink } = Typography;
const COMMISSION_COL_LABELS = [
'کارشناس فروش',
'شماره و عنوان فاکتور',
'درصد',
'مبلغ پورسانت',
'وضعیت',
'تاریخ ثبت',
] as const;
registerTableColumns('commissions', [...COMMISSION_COL_LABELS]);
const tableCols = useTableColumns(() => 'commissions');
const loading = ref(false);
const error = ref('');
const items = ref<CommissionEntryDto[]>([]);
const posted = computed(() => items.value.filter((r) => r.status === 'posted'));
const total = computed(() => posted.value.reduce((sum, r) => sum + r.amount, 0));
const avgCommission = computed(() => (posted.value.length ? Math.round(total.value / posted.value.length) : 0));
const chartData = computed(() => {
const userMap = new Map<string, number>();
for (const r of posted.value) {
userMap.set(r.userName, (userMap.get(r.userName) || 0) + r.amount);
}
const rows = Array.from(userMap.entries())
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value);
const max = rows.length ? rows[0].value : 0;
return rows.map((row) => ({
...row,
percent: max > 0 ? Math.round((row.value / max) * 100) : 0,
}));
});
const columns = computed<TableColumnsType<CommissionEntryDto>>(() => {
const all: TableColumnsType<CommissionEntryDto> = [
{ title: 'کارشناس فروش', key: 'کارشناس فروش' },
{ title: 'شماره و عنوان فاکتور', key: 'شماره و عنوان فاکتور' },
{
title: 'درصد',
key: 'درصد',
customRender: ({ record }) => `${toFaDigits(String(record.percent))}٪`,
},
{ title: 'مبلغ پورسانت', key: 'مبلغ پورسانت' },
{ title: 'وضعیت', key: 'وضعیت' },
{
title: 'تاریخ ثبت',
key: 'تاریخ ثبت',
customRender: ({ record }) => formatJalaliNumeric(new Date(record.createdAt)),
},
];
return all.filter((col) => {
const key = String(col.key ?? '');
if (!COMMISSION_COL_LABELS.includes(key as (typeof COMMISSION_COL_LABELS)[number])) {
return true;
}
return tableCols.isVisible(key);
});
});
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<CommissionEntryDto[]>('/commissions');
items.value = data;
} catch {
error.value = 'بارگذاری پورسانت‌ها ناموفق بود.';
} finally {
loading.value = false;
}
}
onMounted(load);
</script>
<template>
<a-flex vertical :gap="16">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="12">
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">پورسانت فروش</a-typography-title>
</div>
<a-button :disabled="loading" :loading="loading" @click="load">
<template #icon><ReloadOutlined /></template>
بروزرسانی
</a-button>
</a-flex>
<a-row :gutter="[16, 16]">
<a-col :xs="24" :md="8">
<a-card :bordered="false">
<a-statistic title="جمع پورسانت ثبت‌شده" :value="formatMoney(total)">
<template #prefix><WalletOutlined /></template>
</a-statistic>
<a-typography-text type="secondary">
{{ toFaDigits(String(posted.length)) }} فاکتور تایید شده
</a-typography-text>
</a-card>
</a-col>
<a-col :xs="24" :md="8">
<a-card :bordered="false">
<a-statistic title="تعداد تراکنش‌ها" :value="toFaDigits(String(items.length))">
<template #prefix><FileTextOutlined /></template>
</a-statistic>
<a-typography-text type="secondary">
{{ toFaDigits(String(posted.length)) }} فعال /
{{ toFaDigits(String(items.length - posted.length)) }} باطل
</a-typography-text>
</a-card>
</a-col>
<a-col :xs="24" :md="8">
<a-card :bordered="false">
<a-statistic title="میانگین هر فاکتور" :value="formatMoney(avgCommission)">
<template #prefix><RiseOutlined /></template>
</a-statistic>
<a-typography-text type="secondary">بر اساس موارد فعال</a-typography-text>
</a-card>
</a-col>
</a-row>
<a-card v-if="posted.length > 0" title="سهم پورسانت به تفکیک کارشناسان" :bordered="false">
<a-flex vertical :gap="16">
<div v-for="row in chartData" :key="row.name">
<a-flex justify="space-between" align="center" :gap="12">
<a-typography-text strong>{{ row.name }}</a-typography-text>
<a-typography-text>{{ formatMoney(row.value) }}</a-typography-text>
</a-flex>
<a-progress
:percent="row.percent"
:format="(p?: number) => `${toFaDigits(String(p ?? 0))}٪`"
/>
</div>
</a-flex>
</a-card>
<a-card :bordered="false">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<a-typography-text type="secondary">
{{ toFaDigits(String(items.length)) }} پورسانت
</a-typography-text>
<TableColumnsMenu table-key="commissions" align="end" />
</a-flex>
<a-alert
v-if="error"
type="error"
show-icon
:message="error"
:style="{ marginTop: '16px' }"
/>
<CrmSkeleton :loading="loading">
<CrmSkeletonTable :rows="8" :columns="6" :card="false">
<a-table
:columns="columns"
:data-source="items"
:pagination="false"
row-key="id"
:scroll="{ x: 'max-content' }"
:style="{ marginTop: '16px' }"
>
<template #emptyText>
<a-empty
description="پورسانتی ثبت نشده است؛ هنوز هیچ فاکتوری دارای پورسانت در سامانه ثبت نگردیده است."
/>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'کارشناس فروش'">
<a-typography-text strong>{{ record.userName }}</a-typography-text>
</template>
<template v-else-if="column.key === 'شماره و عنوان فاکتور'">
<a-flex vertical :gap="0">
<RouterLink
:to="{ name: 'invoice-detail', params: { id: record.invoiceId } }"
custom
v-slot="{ href, navigate }"
>
<a-typography-link :href="href" @click="navigate">
{{ record.invoiceNumber }}
</a-typography-link>
</RouterLink>
<a-typography-text v-if="record.invoiceTitle" type="secondary">
{{ record.invoiceTitle }}
</a-typography-text>
</a-flex>
</template>
<template v-else-if="column.key === 'مبلغ پورسانت'">
<a-typography-text strong>
{{ formatMoney(record.amount, record.currency) }}
</a-typography-text>
</template>
<template v-else-if="column.key === 'وضعیت'">
<a-tag :color="record.status === 'posted' ? 'success' : 'default'" :bordered="false">
{{ record.status === 'posted' ? 'ثبت‌شده' : 'باطل' }}
</a-tag>
</template>
</template>
</a-table>
</CrmSkeletonTable>
</CrmSkeleton>
</a-card>
</a-flex>
</template>