274 lines
7.6 KiB
Vue
274 lines
7.6 KiB
Vue
<script setup lang="ts">
|
||
import { computed, inject, ref, watch } from 'vue';
|
||
import type { RoutinePerformanceDto } from '@mento/shared';
|
||
import KpiCard from '@/components/routines/KpiCard.vue';
|
||
import WeeklyReport from '@/components/routines/WeeklyReport.vue';
|
||
import MonthlyReport from '@/components/routines/MonthlyReport.vue';
|
||
import ActivityHistoryList from '@/components/routines/ActivityHistoryList.vue';
|
||
import { UiChart, UiSegmented, UiSelect } from '@/components/ui';
|
||
import { api } from '@/lib/api';
|
||
import { toFaDigits } from '@/lib/jalali';
|
||
import { categoryBar, statusDonut, trendOption } from '@/lib/routineCharts';
|
||
import {
|
||
ROUTINES_CTX,
|
||
addDaysKey,
|
||
apiError,
|
||
formatDayNumeric,
|
||
formatMinutes,
|
||
formatPercent,
|
||
todayKey,
|
||
} from '@/lib/routines';
|
||
import { useAuthStore } from '@/stores/auth';
|
||
|
||
const shell = inject(ROUTINES_CTX)!;
|
||
const auth = useAuthStore();
|
||
|
||
type Section = 'overview' | 'weekly' | 'monthly' | 'history';
|
||
const section = ref<Section>('overview');
|
||
const sections = [
|
||
{ value: 'overview' as const, label: 'نمای کلی' },
|
||
{ value: 'weekly' as const, label: 'گزارش هفتگی' },
|
||
{ value: 'monthly' as const, label: 'گزارش ماهانه' },
|
||
{ value: 'history' as const, label: 'تاریخچه فعالیت' },
|
||
];
|
||
|
||
const days = ref<'7' | '30' | '90'>('30');
|
||
const dayOptions = [
|
||
{ value: '7' as const, label: '۷ روز' },
|
||
{ value: '30' as const, label: '۳۰ روز' },
|
||
{ value: '90' as const, label: '۹۰ روز' },
|
||
];
|
||
|
||
/** مدیر میتواند عملکرد یک عضو را هم ببیند */
|
||
const userId = ref('');
|
||
const users = computed(() => shell.context.value?.users ?? []);
|
||
const canPickUser = computed(() => !!shell.context.value?.isManager && users.value.length > 1);
|
||
const filters = computed(() => (userId.value ? { userId: userId.value } : {}));
|
||
|
||
const data = ref<RoutinePerformanceDto | null>(null);
|
||
const loading = ref(false);
|
||
const error = ref('');
|
||
|
||
async function load() {
|
||
if (section.value !== 'overview') return;
|
||
loading.value = true;
|
||
error.value = '';
|
||
const to = todayKey();
|
||
try {
|
||
const res = await api.get<RoutinePerformanceDto>('/routines/stats/me', {
|
||
params: { ...filters.value, from: addDaysKey(to, -(Number(days.value) - 1)), to },
|
||
});
|
||
data.value = res.data;
|
||
} catch (e) {
|
||
error.value = apiError(e, 'بارگذاری عملکرد ممکن نشد.');
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
watch([section, days, userId, shell.refreshTick], load, { immediate: true });
|
||
|
||
const d = computed(() => data.value);
|
||
const trendOpt = computed(() => trendOption(d.value?.trend ?? []));
|
||
const donutOpt = computed(() =>
|
||
statusDonut(d.value?.status ?? { completed: 0, notDone: 0, overdue: 0, skipped: 0, cancelled: 0 }),
|
||
);
|
||
const catOpt = computed(() => categoryBar(d.value?.byCategory ?? []));
|
||
</script>
|
||
|
||
<template>
|
||
<div class="perf">
|
||
<div class="toolbar">
|
||
<UiSegmented v-model="section" :options="sections" />
|
||
<div class="tools">
|
||
<UiSelect v-if="canPickUser" v-model="userId" size="sm" class="who">
|
||
<option value="">عملکرد خودم</option>
|
||
<option v-for="u in users.filter((x) => x.id !== auth.user?.id)" :key="u.id" :value="u.id">
|
||
{{ u.fullName }}
|
||
</option>
|
||
</UiSelect>
|
||
<UiSegmented v-if="section === 'overview'" v-model="days" :options="dayOptions" />
|
||
</div>
|
||
</div>
|
||
|
||
<template v-if="section === 'overview'">
|
||
<p v-if="error" class="error">{{ error }}</p>
|
||
<p v-if="d" class="range">بازه {{ formatDayNumeric(d.from) }} تا {{ formatDayNumeric(d.to) }}</p>
|
||
|
||
<div class="kpis" :class="{ dim: loading }">
|
||
<KpiCard label="درصد انجام" :value="formatPercent(d?.percent)" icon="target" tone="accent" />
|
||
<KpiCard
|
||
label="کارهای انجامشده"
|
||
:value="toFaDigits(d?.completed ?? 0)"
|
||
:hint="`از ${toFaDigits(d?.total ?? 0)} کار`"
|
||
icon="check"
|
||
tone="success"
|
||
/>
|
||
<KpiCard
|
||
label="انجامنشده"
|
||
:value="toFaDigits(d?.notDone ?? 0)"
|
||
:hint="`عقبافتاده ${toFaDigits(d?.overdue ?? 0)}`"
|
||
icon="x-circle"
|
||
tone="danger"
|
||
/>
|
||
<KpiCard
|
||
label="میانگین زمان انجام"
|
||
:value="formatMinutes(d?.avgCompletionMinutes)"
|
||
hint="از زمان برنامه تا انجام"
|
||
icon="clock"
|
||
tone="info"
|
||
/>
|
||
<KpiCard
|
||
label="روزهای کامل"
|
||
:value="toFaDigits(d?.completeDays ?? 0)"
|
||
:hint="`ناقص ${toFaDigits(d?.partialDays ?? 0)} · از دست رفته ${toFaDigits(d?.missedDays ?? 0)}`"
|
||
icon="calendar"
|
||
tone="success"
|
||
/>
|
||
<KpiCard
|
||
label="روزهای پیاپی کامل"
|
||
:value="toFaDigits(d?.streak ?? 0)"
|
||
hint="انجام همه کارهای اجباری"
|
||
icon="flag"
|
||
tone="warning"
|
||
/>
|
||
</div>
|
||
|
||
<div class="charts">
|
||
<section class="card wide">
|
||
<h3>روند درصد انجام</h3>
|
||
<UiChart :option="trendOpt" height="260px" :loading="loading" />
|
||
</section>
|
||
<section class="card">
|
||
<h3>وضعیت کارها</h3>
|
||
<UiChart :option="donutOpt" height="260px" :loading="loading" />
|
||
</section>
|
||
<section class="card full">
|
||
<h3>انجام به تفکیک دستهبندی</h3>
|
||
<UiChart v-if="d?.byCategory.length" :option="catOpt" height="240px" :loading="loading" />
|
||
<p v-else class="muted">دادهای برای نمایش نیست.</p>
|
||
</section>
|
||
</div>
|
||
</template>
|
||
|
||
<WeeklyReport v-else-if="section === 'weekly'" view="me" :filters="filters" :refresh-key="shell.refreshTick.value" />
|
||
<MonthlyReport v-else-if="section === 'monthly'" view="me" :filters="filters" :refresh-key="shell.refreshTick.value" />
|
||
<ActivityHistoryList
|
||
v-else
|
||
:user-id="userId || null"
|
||
:refresh-key="shell.refreshTick.value"
|
||
@open="shell.openTask"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.perf {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr);
|
||
gap: var(--sp-4);
|
||
}
|
||
|
||
.toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
flex-wrap: wrap;
|
||
gap: var(--sp-3);
|
||
}
|
||
|
||
.tools {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--sp-2);
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.who {
|
||
width: 200px;
|
||
}
|
||
|
||
.range {
|
||
margin: 0;
|
||
font-size: var(--fs-xs);
|
||
color: var(--muted);
|
||
}
|
||
|
||
.kpis {
|
||
display: grid;
|
||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||
gap: var(--sp-3);
|
||
transition: opacity var(--transition-fast);
|
||
}
|
||
|
||
.dim {
|
||
opacity: 0.55;
|
||
}
|
||
|
||
.charts {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr);
|
||
gap: var(--sp-4);
|
||
}
|
||
|
||
.card {
|
||
padding: var(--sp-4);
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius);
|
||
background: var(--surface);
|
||
display: grid;
|
||
gap: var(--sp-3);
|
||
align-content: start;
|
||
min-width: 0;
|
||
}
|
||
|
||
.card.full {
|
||
grid-column: 1 / -1;
|
||
}
|
||
|
||
.card h3 {
|
||
margin: 0;
|
||
font-size: var(--fs-sm);
|
||
font-weight: var(--fw-semibold);
|
||
color: var(--ink-secondary);
|
||
}
|
||
|
||
.muted {
|
||
margin: 0;
|
||
color: var(--muted);
|
||
font-size: var(--fs-sm);
|
||
text-align: center;
|
||
padding: var(--sp-6);
|
||
}
|
||
|
||
.error {
|
||
margin: 0;
|
||
}
|
||
|
||
@media (max-width: 1200px) {
|
||
.kpis {
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
}
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
.kpis {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: var(--sp-2);
|
||
}
|
||
|
||
.charts {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.who {
|
||
width: 100%;
|
||
}
|
||
|
||
.toolbar > :first-child {
|
||
overflow-x: auto;
|
||
max-width: 100%;
|
||
}
|
||
}
|
||
</style>
|