feat: migrate CRM screens to Ant Design Vue and expand home dashboard

Preserve the local Ant UI work across views/components and add the new home dashboard chart panels before merging latest main.
This commit is contained in:
2026-11-03 16:51:51 +03:30
parent 631a3267d1
commit 26792f9a37
80 changed files with 19982 additions and 24020 deletions
+193 -262
View File
@@ -5,18 +5,45 @@ 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 {
UiBadge,
UiButton,
UiCard,
UiChart,
UiEmptyState,
UiPageHeader,
type UiChartOption,
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
Alert as AAlert,
Button as AButton,
Card as ACard,
Col as ACol,
Empty as AEmpty,
Flex as AFlex,
Progress as AProgress,
Row as ARow,
Spin as ASpin,
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 '@ant-design/icons-vue';
import type { CommissionEntryDto } from '@mento/shared';
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[]>([]);
@@ -25,62 +52,45 @@ 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 chartOption = computed<UiChartOption>(() => {
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 names = Array.from(userMap.keys());
const values = Array.from(userMap.values());
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,
}));
});
return {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: unknown) => {
const p = (params as Array<{ name: string; value: number }>)[0];
if (!p) return '';
return `${p.name}: <b>${formatMoney(p.value)}</b>`;
},
const columns = computed<TableColumnsType<CommissionEntryDto>>(() => {
const all: TableColumnsType<CommissionEntryDto> = [
{ title: 'کارشناس فروش', key: 'کارشناس فروش' },
{ title: 'شماره و عنوان فاکتور', key: 'شماره و عنوان فاکتور' },
{
title: 'درصد',
key: 'درصد',
customRender: ({ record }) => `${toFaDigits(String(record.percent))}٪`,
},
grid: {
top: 20,
right: 20,
bottom: 40,
left: 60,
containLabel: true,
{ title: 'مبلغ پورسانت', key: 'مبلغ پورسانت' },
{ title: 'وضعیت', key: 'وضعیت' },
{
title: 'تاریخ ثبت',
key: 'تاریخ ثبت',
customRender: ({ record }) => formatJalaliNumeric(new Date(record.createdAt)),
},
xAxis: {
type: 'category',
data: names,
axisLabel: {
fontSize: 12,
color: '#64748b',
},
axisLine: { lineStyle: { color: '#e2e8f0' } },
},
yAxis: {
type: 'value',
axisLabel: {
fontSize: 11,
color: '#64748b',
formatter: (val: number) => (val >= 1000000 ? `${toFaDigits(String(val / 1000000))}M` : toFaDigits(String(val))),
},
splitLine: { lineStyle: { color: '#f1f5f9' } },
},
series: [
{
name: 'پورسانت',
type: 'bar',
barWidth: '36%',
data: values,
itemStyle: {
color: '#0f766e',
borderRadius: [4, 4, 0, 0],
},
},
],
};
];
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() {
@@ -100,209 +110,130 @@ onMounted(load);
</script>
<template>
<section class="crm-page commissions-page" dir="rtl">
<UiPageHeader
title="پورسانت فروش"
description="گزارش پورسانت ثبت‌شده برای کارشناسان فروش متصل به فاکتورهای صادره"
>
<template #actions>
<UiButton variant="ghost" :disabled="loading" @click="load">
<template #icon>
<UiIcon name="refresh" />
</template>
بروزرسانی
</UiButton>
</template>
</UiPageHeader>
<div class="kpi-grid">
<UiCard class="kpi-card">
<div class="kpi-icon-wrap primary">
<UiIcon name="dollar" />
</div>
<div class="kpi-body">
<span class="kpi-label">جمع پورسانت ثبت‌شده</span>
<strong class="kpi-val">{{ formatMoney(total) }}</strong>
<small class="kpi-sub">{{ toFaDigits(String(posted.length)) }} فاکتور تایید شده</small>
</div>
</UiCard>
<UiCard class="kpi-card">
<div class="kpi-icon-wrap success">
<UiIcon name="document" />
</div>
<div class="kpi-body">
<span class="kpi-label">تعداد تراکنش‌ها</span>
<strong class="kpi-val">{{ toFaDigits(String(items.length)) }}</strong>
<small class="kpi-sub">{{ toFaDigits(String(posted.length)) }} فعال / {{ toFaDigits(String(items.length - posted.length)) }} باطل</small>
</div>
</UiCard>
<UiCard class="kpi-card">
<div class="kpi-icon-wrap warning">
<UiIcon name="trend" />
</div>
<div class="kpi-body">
<span class="kpi-label">میانگین هر فاکتور</span>
<strong class="kpi-val">{{ formatMoney(avgCommission) }}</strong>
<small class="kpi-sub">بر اساس موارد فعال</small>
</div>
</UiCard>
</div>
<UiCard v-if="posted.length > 0" title="سهم پورسانت به تفکیک کارشناسان" class="chart-card">
<UiChart :option="chartOption" height="260px" />
</UiCard>
<div v-if="error" class="crm-alert crm-alert-danger">{{ error }}</div>
<UiEmptyState
v-else-if="!loading && !items.length"
title="پورسانتی ثبت نشده است"
description="هنوز هیچ فاکتوری دارای پورسانت در سامانه ثبت نگردیده است."
icon="document"
/>
<div v-else class="crm-table-container">
<div class="tc-row">
<TableColumnsMenu table-key="commissions" align="end" />
<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>
<a-typography-text type="secondary">
گزارش پورسانت ثبت‌شده برای کارشناسان فروش متصل به فاکتورهای صادره
</a-typography-text>
</div>
<table v-table-cols="'commissions'" class="crm-table">
<thead>
<tr>
<th>کارشناس فروش</th>
<th>شماره و عنوان فاکتور</th>
<th>درصد</th>
<th>مبلغ پورسانت</th>
<th>وضعیت</th>
<th>تاریخ ثبت</th>
</tr>
</thead>
<tbody>
<tr v-for="row in items" :key="row.id">
<td class="font-medium text-primary">{{ row.userName }}</td>
<td>
<RouterLink :to="{ name: 'invoice-detail', params: { id: row.invoiceId } }" class="table-link">
{{ row.invoiceNumber }}
</RouterLink>
<span v-if="row.invoiceTitle" class="invoice-sub-title">{{ row.invoiceTitle }}</span>
</td>
<td>
<UiBadge variant="neutral">{{ toFaDigits(String(row.percent)) }}٪</UiBadge>
</td>
<td class="amount-cell">{{ formatMoney(row.amount, row.currency) }}</td>
<td>
<UiBadge :variant="row.status === 'posted' ? 'success' : 'neutral'">
{{ row.status === 'posted' ? 'ثبت‌شده' : 'باطل' }}
</UiBadge>
</td>
<td>{{ formatJalaliNumeric(new Date(row.createdAt)) }}</td>
</tr>
</tbody>
</table>
</div>
</section>
<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' }"
/>
<a-spin :spinning="loading">
<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>
</a-spin>
</a-card>
</a-flex>
</template>
<style scoped>
.commissions-page {
display: flex;
flex-direction: column;
gap: var(--sp-6);
}
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
gap: var(--sp-4);
}
.kpi-card {
display: flex;
align-items: center;
gap: var(--sp-4);
padding: var(--sp-4) var(--sp-5);
}
.kpi-icon-wrap {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: var(--radius-lg);
font-size: 1.25rem;
flex-shrink: 0;
}
.kpi-icon-wrap.primary {
background: var(--color-brand-50, #f0fdfa);
color: var(--color-brand-600, #0f766e);
}
.kpi-icon-wrap.success {
background: var(--color-success-bg, #f0fdf4);
color: var(--color-success-text, #15803d);
}
.kpi-icon-wrap.warning {
background: var(--color-warning-bg, #fffbeb);
color: var(--color-warning-text, #b45309);
}
.kpi-body {
display: flex;
flex-direction: column;
gap: 2px;
}
.kpi-label {
font-size: var(--fs-xs);
color: var(--color-text-secondary);
}
.kpi-val {
font-size: var(--fs-lg);
font-weight: 700;
color: var(--color-text-primary);
}
.kpi-sub {
font-size: var(--fs-xs);
color: var(--color-text-muted);
}
.chart-card {
margin-top: var(--sp-2);
}
.font-medium {
font-weight: 600;
}
.text-primary {
color: var(--color-text-primary);
}
.amount-cell {
font-weight: 700;
color: var(--color-brand-700, #0f766e);
}
.table-link {
color: var(--color-brand-600, #0f766e);
font-weight: 600;
text-decoration: none;
}
.table-link:hover {
text-decoration: underline;
}
.invoice-sub-title {
display: block;
font-size: var(--fs-xs);
color: var(--color-text-muted);
margin-top: 2px;
}
</style>