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
@@ -1,29 +1,26 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { computed } from 'vue';
import type { RoutineMemberStatDto } from '@mento/shared';
import UserAvatar from '@/components/UserAvatar.vue';
import { toFaDigits } from '@/lib/jalali';
import { formatMinutes, formatPercent, percentTone } from '@/lib/routines';
import {
Empty as AEmpty,
Flex as AFlex,
Progress as AProgress,
Space as ASpace,
Table as ATable,
Typography,
} from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
const { Text: ATypographyText } = Typography;
const props = defineProps<{ members: RoutineMemberStatDto[] }>();
const emit = defineEmits<{ select: [userId: string] }>();
type SortKey = 'name' | 'total' | 'completed' | 'overdue' | 'skipped' | 'percent' | 'streak' | 'avg';
const sortKey = ref<SortKey>('percent');
const sortDir = ref<'asc' | 'desc'>('desc');
const columns: Array<{ key: SortKey; label: string }> = [
{ key: 'name', label: 'کاربر' },
{ key: 'total', label: 'کل کارها' },
{ key: 'completed', label: 'انجام شده' },
{ key: 'overdue', label: 'عقب‌افتاده' },
{ key: 'skipped', label: 'رد شده' },
{ key: 'percent', label: 'درصد انجام' },
{ key: 'streak', label: 'روز پیاپی کامل' },
{ key: 'avg', label: 'میانگین زمان انجام' },
];
function value(m: RoutineMemberStatDto, key: SortKey): number | string {
switch (key) {
case 'name':
@@ -37,155 +34,101 @@ function value(m: RoutineMemberStatDto, key: SortKey): number | string {
}
}
const rows = computed(() => {
const dir = sortDir.value === 'asc' ? 1 : -1;
return [...props.members].sort((a, b) => {
const va = value(a, sortKey.value);
const vb = value(b, sortKey.value);
if (typeof va === 'string' && typeof vb === 'string') return va.localeCompare(vb, 'fa') * dir;
return ((va as number) - (vb as number)) * dir;
});
});
function sorterFor(key: SortKey) {
return (a: RoutineMemberStatDto, b: RoutineMemberStatDto) => {
const va = value(a, key);
const vb = value(b, key);
if (typeof va === 'string' && typeof vb === 'string') return va.localeCompare(vb, 'fa');
return (va as number) - (vb as number);
};
}
function sortBy(key: SortKey) {
if (sortKey.value === key) sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc';
else {
sortKey.value = key;
sortDir.value = key === 'name' || key === 'avg' ? 'asc' : 'desc';
}
const columns = computed<TableColumnsType<RoutineMemberStatDto>>(() => [
{ title: 'کاربر', key: 'name', sorter: sorterFor('name'), defaultSortOrder: undefined },
{
title: 'کل کارها',
key: 'total',
sorter: sorterFor('total'),
customRender: ({ record }) => toFaDigits(record.total),
},
{
title: 'انجام شده',
key: 'completed',
sorter: sorterFor('completed'),
customRender: ({ record }) => toFaDigits(record.completed),
},
{
title: 'عقب‌افتاده',
key: 'overdue',
sorter: sorterFor('overdue'),
customRender: ({ record }) => toFaDigits(record.overdue),
},
{
title: 'رد شده',
key: 'skipped',
sorter: sorterFor('skipped'),
customRender: ({ record }) => toFaDigits(record.skipped),
},
{ title: 'درصد انجام', key: 'percent', sorter: sorterFor('percent'), defaultSortOrder: 'descend' },
{
title: 'روز پیاپی کامل',
key: 'streak',
sorter: sorterFor('streak'),
customRender: ({ record }) => toFaDigits(record.streak),
},
{
title: 'میانگین زمان انجام',
key: 'avg',
sorter: sorterFor('avg'),
customRender: ({ record }) => formatMinutes(record.avgCompletionMinutes),
},
]);
const TONE_COLORS: Record<string, string> = {
good: 'var(--ant-color-success)',
mid: 'var(--ant-color-warning)',
low: 'var(--ant-color-error)',
none: 'var(--ant-color-text-quaternary)',
};
function rowProps(record: RoutineMemberStatDto) {
return {
onClick: () => emit('select', record.user.id),
style: { cursor: 'pointer' },
};
}
</script>
<template>
<div class="crm-table-container">
<table class="crm-table">
<thead>
<tr>
<th
v-for="c in columns"
:key="c.key"
:aria-sort="sortKey === c.key ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none'"
>
<button type="button" class="sort" :class="{ on: sortKey === c.key }" @click="sortBy(c.key)">
{{ c.label }}
<span class="arrow">{{ sortKey === c.key ? (sortDir === 'asc' ? '▲' : '▼') : '↕' }}</span>
</button>
</th>
</tr>
</thead>
<tbody>
<tr v-for="m in rows" :key="m.user.id" class="clickable" @click="emit('select', m.user.id)">
<td>
<span class="who">
<UserAvatar :name="m.user.fullName" :user-id="m.user.id" :size="28" />
{{ m.user.fullName }}
</span>
</td>
<td>{{ toFaDigits(m.total) }}</td>
<td class="ok">{{ toFaDigits(m.completed) }}</td>
<td :class="{ bad: m.overdue > 0 }">{{ toFaDigits(m.overdue) }}</td>
<td>{{ toFaDigits(m.skipped) }}</td>
<td>
<span class="pct" :class="`tone-${percentTone(m.percent)}`">
<strong>{{ formatPercent(m.percent) }}</strong>
<i><b :style="{ width: `${m.percent ?? 0}%` }" /></i>
</span>
</td>
<td>{{ toFaDigits(m.streak) }}</td>
<td class="nowrap">{{ formatMinutes(m.avgCompletionMinutes) }}</td>
</tr>
<tr v-if="!rows.length">
<td colspan="8" class="empty">داده‌ای برای این بازه نیست.</td>
</tr>
</tbody>
</table>
</div>
<a-table
:columns="columns"
:data-source="props.members"
:pagination="false"
:row-key="(r: RoutineMemberStatDto) => r.user.id"
:custom-row="rowProps"
:scroll="{ x: 'max-content' }"
>
<template #emptyText>
<a-empty description="داده‌ای برای این بازه نیست." />
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<a-space :size="8">
<UserAvatar :name="record.user.fullName" :user-id="record.user.id" :size="28" />
<a-typography-text strong>{{ record.user.fullName }}</a-typography-text>
</a-space>
</template>
<template v-else-if="column.key === 'percent'">
<a-flex align="center" :gap="8" :style="{ minWidth: '8rem' }">
<a-typography-text strong>{{ formatPercent(record.percent) }}</a-typography-text>
<a-progress
:percent="Math.max(0, Math.min(100, Math.round(record.percent ?? 0)))"
:show-info="false"
:stroke-color="TONE_COLORS[percentTone(record.percent)]"
:style="{ flex: 1 }"
/>
</a-flex>
</template>
</template>
</a-table>
</template>
<style scoped>
.sort {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
background: transparent;
color: inherit;
font: inherit;
font-weight: inherit;
cursor: pointer;
padding: 0;
white-space: nowrap;
}
.sort.on {
color: var(--accent-deep);
}
.arrow {
font-size: 0.6rem;
opacity: 0.7;
}
.clickable {
cursor: pointer;
}
.who {
display: inline-flex;
align-items: center;
gap: 8px;
white-space: nowrap;
font-weight: var(--fw-medium);
}
.ok {
color: var(--success-deep);
}
.bad {
color: var(--danger);
font-weight: var(--fw-semibold);
}
.pct {
display: grid;
grid-template-columns: 44px 80px;
align-items: center;
gap: 8px;
}
.pct i {
height: 6px;
border-radius: 99px;
background: var(--surface-subtle);
overflow: hidden;
}
.pct b {
display: block;
height: 100%;
background: var(--line-strong);
}
.tone-good b {
background: var(--success);
}
.tone-mid b {
background: var(--warning);
}
.tone-low b {
background: var(--danger);
}
.nowrap {
white-space: nowrap;
}
.empty {
text-align: center;
color: var(--muted);
padding: var(--sp-6);
}
</style>