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:
2026-10-06 19:54:38 +03:30
co-authored by Cursor
parent 3de2d0fa67
commit b3784818fc
58 changed files with 13018 additions and 18 deletions
@@ -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>