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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user