feat: routines module; super admin subscription badge, 7-day trial and lifetime plan
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
<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;
|
||||
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>
|
||||
Reference in New Issue
Block a user