Files
CRM_Montzar/apps/web/src/views/routines/RoutinesTeamView.vue
T
Mahdi 26792f9a37 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.
2026-11-03 16:51:51 +03:30

440 lines
17 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, inject, reactive, ref, watch } from 'vue';
import type {
RoutineExecutionRowDto,
RoutineStatsQuery,
RoutineTaskSource,
RoutineTaskStatus,
RoutineTeamPerformanceDto,
} from '@mento/shared';
import { ROUTINE_TASK_SOURCE_LABELS, ROUTINE_TASK_STATUS_LABELS } from '@mento/shared';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import KpiCard from '@/components/routines/KpiCard.vue';
import TeamPerformanceTable from '@/components/routines/TeamPerformanceTable.vue';
import WeeklyReport from '@/components/routines/WeeklyReport.vue';
import MonthlyReport from '@/components/routines/MonthlyReport.vue';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import {
ROUTINES_CTX,
addDaysKey,
apiError,
formatDayNumeric,
formatPercent,
percentTone,
todayKey,
} from '@/lib/routines';
import type { TableColumnsType } from 'ant-design-vue';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Col as ACol,
Divider as ADivider,
Empty as AEmpty,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Progress as AProgress,
Row as ARow,
Segmented as ASegmented,
Select as ASelect,
Spin as ASpin,
Table as ATable,
Tooltip as ATooltip,
Typography,
} from 'ant-design-vue';
import { CloseOutlined } from '@ant-design/icons-vue';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const shell = inject(ROUTINES_CTX)!;
type Section = 'overview' | 'weekly' | 'monthly' | 'routines';
const section = ref<Section>('overview');
const sections = [
{ value: 'overview' as const, label: 'نمای کلی تیم' },
{ value: 'weekly' as const, label: 'گزارش هفتگی' },
{ value: 'monthly' as const, label: 'گزارش ماهانه' },
{ value: 'routines' as const, label: 'اجرای روتین‌ها' },
];
const f = reactive({
from: addDaysKey(todayKey(), -29),
to: todayKey(),
userId: '',
teamId: '',
categoryId: '',
source: '' as RoutineTaskSource | '',
status: '' as RoutineTaskStatus | '',
});
const users = computed(() => shell.context.value?.users ?? []);
const teams = computed(() => shell.context.value?.teams ?? []);
const categories = computed(() => shell.context.value?.categories ?? []);
const sources = Object.entries(ROUTINE_TASK_SOURCE_LABELS) as Array<[RoutineTaskSource, string]>;
const statuses = Object.entries(ROUTINE_TASK_STATUS_LABELS) as Array<[RoutineTaskStatus, string]>;
const userOptions = computed(() => [
{ value: '', label: 'همه کاربران' },
...users.value.map((u) => ({ value: u.id, label: u.fullName })),
]);
const teamOptions = computed(() => [
{ value: '', label: 'همه تیم‌ها' },
...teams.value.map((t) => ({ value: t.id, label: t.name })),
]);
const categoryOptions = computed(() => [
{ value: '', label: 'همه دسته‌ها' },
...categories.value.map((c) => ({ value: c.id, label: c.name })),
]);
const sourceOptions = [
{ value: '', label: 'همه' },
...sources.map(([value, label]) => ({ value, label })),
];
const statusOptions = [
{ value: '', label: 'همه' },
...statuses.map(([value, label]) => ({ value, label })),
];
const execSortOptions = [
{ value: 'percent', label: 'مرتب‌سازی: کمترین اجرا' },
{ value: 'occurrences', label: 'مرتب‌سازی: بیشترین تکرار' },
];
const query = computed<RoutineStatsQuery>(() => {
const q: RoutineStatsQuery = {};
if (f.from) q.from = f.from;
if (f.to) q.to = f.to;
if (f.userId) q.userId = f.userId;
if (f.teamId) q.teamId = f.teamId;
if (f.categoryId) q.categoryId = f.categoryId;
if (f.source) q.source = f.source;
if (f.status) q.status = f.status;
return q;
});
const hasFilters = computed(() => !!(f.userId || f.teamId || f.categoryId || f.source || f.status));
function resetFilters() {
Object.assign(f, { userId: '', teamId: '', categoryId: '', source: '', status: '' });
}
const data = ref<RoutineTeamPerformanceDto | null>(null);
const exec = ref<RoutineExecutionRowDto[]>([]);
const loading = ref(false);
const error = ref('');
async function load() {
if (section.value !== 'overview' && section.value !== 'routines') return;
loading.value = true;
error.value = '';
try {
if (section.value === 'overview') {
const res = await api.get<RoutineTeamPerformanceDto>('/routines/stats/team', { params: query.value });
data.value = res.data;
} else {
const res = await api.get<RoutineExecutionRowDto[]>('/routines/stats/routines', { params: query.value });
exec.value = res.data;
}
} catch (e) {
error.value = apiError(e, 'بارگذاری گزارش تیم ممکن نشد.');
} finally {
loading.value = false;
}
}
watch([section, () => JSON.stringify(query.value), shell.refreshTick], load, { immediate: true });
const d = computed(() => data.value);
const activeMembers = computed(() => (d.value?.members ?? []).filter((m) => m.total > 0).length);
const statusRows = computed(() => {
const s = d.value?.status ?? { completed: 0, notDone: 0, overdue: 0, skipped: 0, cancelled: 0 };
const totalAll = s.completed + s.notDone + s.overdue + s.skipped + s.cancelled;
const rows = [
{ key: 'completed', label: 'انجام‌شده', value: s.completed, color: 'var(--ant-color-success)' },
{ key: 'notDone', label: 'انجام‌نشده', value: s.notDone, color: 'var(--ant-color-text-quaternary)' },
{ key: 'overdue', label: 'عقب‌افتاده', value: s.overdue, color: 'var(--ant-color-error)' },
{ key: 'skipped', label: 'رد شده', value: s.skipped, color: 'var(--ant-color-warning)' },
{ key: 'cancelled', label: 'لغو شده', value: s.cancelled, color: 'var(--ant-color-warning)' },
];
return rows.map((r) => ({
...r,
percent: totalAll > 0 ? Math.round((r.value / totalAll) * 100) : 0,
}));
});
const memberRows = computed(() =>
[...(d.value?.members ?? [])]
.filter((m) => m.total > 0)
.sort((a, b) => (b.percent ?? -1) - (a.percent ?? -1)),
);
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)',
};
const execSort = ref<'percent' | 'occurrences'>('percent');
const execRows = computed(() =>
[...exec.value].sort((a, b) =>
execSort.value === 'percent' ? (a.percent ?? -1) - (b.percent ?? -1) : b.occurrences - a.occurrences,
),
);
const execColumns: TableColumnsType<RoutineExecutionRowDto> = [
{
title: 'روتین',
key: 'title',
customRender: ({ record }) => record.title,
},
{ title: 'مسئول', dataIndex: 'assigneeLabel', key: 'assigneeLabel' },
{ title: 'تکرار', dataIndex: 'recurrenceLabel', key: 'recurrenceLabel' },
{
title: 'دفعات',
key: 'occurrences',
customRender: ({ record }) => toFaDigits(record.occurrences),
},
{
title: 'انجام شده',
key: 'completed',
customRender: ({ record }) => toFaDigits(record.completed),
},
{
title: 'انجام نشده',
key: 'notDone',
customRender: ({ record }) => toFaDigits(record.notDone),
},
{
title: 'رد شده',
key: 'skipped',
customRender: ({ record }) => toFaDigits(record.skipped),
},
{ title: 'درصد اجرا', key: 'percent', width: 180 },
];
</script>
<template>
<a-card :bordered="false">
<a-flex vertical :gap="16">
<a-segmented v-model:value="section" :options="sections" />
<a-form layout="vertical">
<a-row :gutter="[16, 0]">
<a-col v-if="section === 'overview' || section === 'routines'" :xs="24" :sm="12" :md="8">
<a-form-item label="از">
<JalaliDateInput v-model="f.from" mode="date" :clearable="false" style="width: 100%" />
</a-form-item>
</a-col>
<a-col v-if="section === 'overview' || section === 'routines'" :xs="24" :sm="12" :md="8">
<a-form-item label="تا">
<JalaliDateInput v-model="f.to" mode="date" :clearable="false" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8">
<a-form-item label="کاربر">
<a-select v-model:value="f.userId" :options="userOptions" style="width: 100%" />
</a-form-item>
</a-col>
<a-col v-if="teams.length" :xs="24" :sm="12" :md="8">
<a-form-item label="تیم">
<a-select v-model:value="f.teamId" :options="teamOptions" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8">
<a-form-item label="دسته‌بندی">
<a-select v-model:value="f.categoryId" :options="categoryOptions" style="width: 100%" />
</a-form-item>
</a-col>
<a-col v-if="section !== 'routines'" :xs="24" :sm="12" :md="8">
<a-form-item label="نوع کار">
<a-select v-model:value="f.source" :options="sourceOptions" style="width: 100%" />
</a-form-item>
</a-col>
<a-col v-if="section === 'overview'" :xs="24" :sm="12" :md="8">
<a-form-item label="وضعیت">
<a-select v-model:value="f.status" :options="statusOptions" style="width: 100%" />
</a-form-item>
</a-col>
<a-col v-if="hasFilters" :xs="24" :sm="12" :md="8">
<a-form-item label=" ">
<a-button danger @click="resetFilters">
<template #icon><CloseOutlined /></template>
حذف فیلترها
</a-button>
</a-form-item>
</a-col>
</a-row>
</a-form>
<a-alert v-if="error" type="error" show-icon :message="error" />
<template v-if="section === 'overview'">
<a-spin :spinning="loading">
<a-row :gutter="[16, 16]">
<a-col :xs="12" :lg="6">
<KpiCard label="درصد انجام تیم" :value="formatPercent(d?.percent)" icon="target" tone="accent" />
</a-col>
<a-col :xs="12" :lg="6">
<KpiCard
label="انجام‌شده"
:value="toFaDigits(d?.completed ?? 0)"
:hint="`از ${toFaDigits(d?.total ?? 0)} کار`"
icon="check"
tone="success"
/>
</a-col>
<a-col :xs="12" :lg="6">
<KpiCard label="عقب‌افتاده" :value="toFaDigits(d?.overdue ?? 0)" icon="warning" tone="danger" />
</a-col>
<a-col :xs="12" :lg="6">
<KpiCard
label="اعضای فعال"
:value="toFaDigits(activeMembers)"
:hint="`از ${toFaDigits(d?.members.length ?? 0)} نفر`"
icon="users"
tone="info"
/>
</a-col>
</a-row>
<a-row :gutter="[24, 24]" :style="{ marginTop: '24px' }">
<a-col :xs="24" :lg="14">
<a-typography-title :level="5" :style="{ margin: 0 }">روند درصد انجام تیم</a-typography-title>
<a-flex align="end" :gap="2" :style="{ height: '180px', marginTop: '16px' }">
<a-tooltip
v-for="p in d?.trend ?? []"
:key="p.date"
:title="`${formatDayNumeric(p.date)} — ${formatPercent(p.percent)} (${toFaDigits(p.completed)} از ${toFaDigits(p.total)})`"
>
<a-flex
:style="{
flex: 1,
minWidth: '3px',
height: `${Math.max(2, Math.round(p.percent ?? 0))}%`,
background: p.percent == null ? 'var(--ant-color-fill-secondary)' : 'var(--ant-color-primary)',
borderRadius: '2px 2px 0 0',
}"
/>
</a-tooltip>
<a-typography-text v-if="!d?.trend?.length" type="secondary">
داده‌ای برای نمایش نیست.
</a-typography-text>
</a-flex>
</a-col>
<a-col :xs="24" :lg="10">
<a-typography-title :level="5" :style="{ margin: 0 }">وضعیت کارها</a-typography-title>
<a-flex vertical :gap="12" :style="{ marginTop: '16px' }">
<div v-for="r in statusRows" :key="r.key">
<a-flex justify="space-between" align="center" :gap="8">
<a-typography-text>{{ r.label }}</a-typography-text>
<a-typography-text strong>
{{ toFaDigits(r.value) }} ({{ toFaDigits(r.percent) }}٪)
</a-typography-text>
</a-flex>
<a-progress :percent="r.percent" :show-info="false" :stroke-color="r.color" />
</div>
</a-flex>
</a-col>
<a-col :xs="24" :lg="14">
<a-typography-title :level="5" :style="{ margin: 0 }">مقایسه اعضا</a-typography-title>
<a-flex v-if="memberRows.length" vertical :gap="12" :style="{ marginTop: '16px' }">
<div v-for="m in memberRows" :key="m.user.id">
<a-flex justify="space-between" align="center" :gap="8">
<a-typography-text>{{ m.user.fullName }}</a-typography-text>
<a-typography-text strong>
{{ formatPercent(m.percent) }} · {{ toFaDigits(m.completed) }} از {{ toFaDigits(m.total) }}
</a-typography-text>
</a-flex>
<a-progress
:percent="Math.max(0, Math.min(100, Math.round(m.percent ?? 0)))"
:show-info="false"
:stroke-color="TONE_COLORS[percentTone(m.percent)]"
/>
</div>
</a-flex>
<a-empty v-else description="داده‌ای برای نمایش نیست." :style="{ marginTop: '16px' }" />
</a-col>
<a-col :xs="24" :lg="10">
<a-typography-title :level="5" :style="{ margin: 0 }">انجام به تفکیک دسته‌بندی</a-typography-title>
<a-flex v-if="d?.byCategory.length" vertical :gap="12" :style="{ marginTop: '16px' }">
<div v-for="c in d.byCategory" :key="c.categoryId ?? c.name">
<a-flex justify="space-between" align="center" :gap="8">
<a-typography-text>{{ c.name }}</a-typography-text>
<a-typography-text strong>
{{ formatPercent(c.percent) }} · {{ toFaDigits(c.completed) }} از {{ toFaDigits(c.total) }}
</a-typography-text>
</a-flex>
<a-progress
:percent="Math.max(0, Math.min(100, Math.round(c.percent ?? 0)))"
:show-info="false"
:stroke-color="c.color"
/>
</div>
</a-flex>
<a-empty v-else description="داده‌ای برای نمایش نیست." :style="{ marginTop: '16px' }" />
</a-col>
</a-row>
</a-spin>
<a-divider orientation="right">عملکرد اعضا</a-divider>
<TeamPerformanceTable :members="d?.members ?? []" @select="(id) => (f.userId = id)" />
</template>
<WeeklyReport
v-else-if="section === 'weekly'"
view="team"
:filters="query"
:refresh-key="shell.refreshTick.value"
/>
<MonthlyReport
v-else-if="section === 'monthly'"
view="team"
:filters="query"
:refresh-key="shell.refreshTick.value"
/>
<template v-else>
<a-flex justify="space-between" align="center" wrap="wrap" :gap="12">
<a-typography-title :level="5" :style="{ margin: 0 }">گزارش اجرای روتین‌ها</a-typography-title>
<a-select
v-model:value="execSort"
:options="execSortOptions"
:style="{ width: '240px' }"
/>
</a-flex>
<a-spin :spinning="loading">
<a-table
:columns="execColumns"
:data-source="execRows"
:pagination="false"
:row-key="(r: RoutineExecutionRowDto) => r.routineId"
:scroll="{ x: 'max-content' }"
>
<template #emptyText>
<a-empty description="در این بازه روتینی اجرا نشده است." />
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'title'">
<a-typography-text strong>{{ record.title }}</a-typography-text>
</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>
</a-spin>
</template>
</a-flex>
</a-card>
</template>