Preserve the local Ant UI work across views/components and add the new home dashboard chart panels before merging latest main.
440 lines
17 KiB
Vue
440 lines
17 KiB
Vue
<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>
|