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
+248
View File
@@ -0,0 +1,248 @@
import type {
RoutineCalendarDayDto,
RoutineCategoryStatDto,
RoutineMemberStatDto,
RoutineStatusBreakdownDto,
RoutineTrendPointDto,
} from '@mento/shared';
import type { UiChartOption } from '@/components/ui';
import { toFaDigits } from '@/lib/jalali';
import { formatDayNumeric } from '@/lib/routines';
const FONT = 'IRANSans, Tahoma, sans-serif';
const AXIS_LABEL = { color: '#64748b', fontFamily: FONT, fontSize: 11 };
const SPLIT = { lineStyle: { color: '#f1f5f9', type: 'dashed' as const } };
const LEGEND = { bottom: 0, left: 'center', textStyle: { fontFamily: FONT, fontSize: 11, color: '#475569' } };
export const STATUS_COLORS = {
completed: '#10b981',
notDone: '#94a3b8',
overdue: '#ef4444',
skipped: '#f59e0b',
cancelled: '#cbd5e1',
};
/* eslint-disable @typescript-eslint/no-explicit-any */
/** روند درصد انجام روزانه */
export function trendOption(trend: RoutineTrendPointDto[]): UiChartOption {
return {
textStyle: { fontFamily: FONT },
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
const item = Array.isArray(params) ? params[0] : params;
const p = trend[item?.dataIndex ?? -1];
if (!p) return '';
return `${formatDayNumeric(p.date)}<br/>درصد انجام: <strong>${p.percent == null ? '—' : `${toFaDigits(p.percent)}٪`}</strong><br/>${toFaDigits(p.completed)} از ${toFaDigits(p.total)}`;
},
},
grid: { left: 8, right: 16, top: 16, bottom: 8, containLabel: true },
xAxis: {
type: 'category',
boundaryGap: false,
data: trend.map((p) => formatDayNumeric(p.date)),
axisLine: { lineStyle: { color: '#cbd5e1' } },
axisLabel: { ...AXIS_LABEL, hideOverlap: true },
},
yAxis: {
type: 'value',
min: 0,
max: 100,
splitLine: SPLIT,
axisLabel: { ...AXIS_LABEL, formatter: (v: number) => `${toFaDigits(v)}٪` },
},
series: [
{
name: 'درصد انجام',
type: 'line',
smooth: true,
connectNulls: true,
showSymbol: false,
itemStyle: { color: '#14b8a6' },
lineStyle: { width: 3, color: '#0d9488' },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(20, 184, 166, 0.3)' },
{ offset: 1, color: 'rgba(20, 184, 166, 0.01)' },
],
},
},
data: trend.map((p) => p.percent),
},
],
};
}
/** دونات وضعیت کارها */
export function statusDonut(s: RoutineStatusBreakdownDto): UiChartOption {
const rows = [
{ name: 'انجام شده', value: s.completed, color: STATUS_COLORS.completed },
{ name: 'انجام نشده', value: s.notDone, color: STATUS_COLORS.notDone },
{ name: 'عقب‌افتاده', value: s.overdue, color: STATUS_COLORS.overdue },
{ name: 'رد شده', value: s.skipped, color: STATUS_COLORS.skipped },
].filter((r) => r.value > 0);
return {
textStyle: { fontFamily: FONT },
tooltip: {
trigger: 'item',
formatter: (p: any) => `${p.name}: <strong>${toFaDigits(p.value)}</strong> (${toFaDigits(p.percent)}٪)`,
},
legend: LEGEND,
series: [
{
name: 'وضعیت',
type: 'pie',
radius: ['48%', '72%'],
center: ['50%', '44%'],
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
label: { show: false },
data: rows.map((r) => ({ name: r.name, value: r.value, itemStyle: { color: r.color } })),
},
],
};
}
/** میله‌ای انجام به تفکیک دسته */
export function categoryBar(rows: RoutineCategoryStatDto[]): UiChartOption {
return {
textStyle: { fontFamily: FONT },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: any) => {
const item = Array.isArray(params) ? params[0] : params;
const r = rows[item?.dataIndex ?? -1];
if (!r) return '';
return `${r.name}<br/>${toFaDigits(r.completed)} از ${toFaDigits(r.total)} · <strong>${r.percent == null ? '—' : `${toFaDigits(r.percent)}٪`}</strong>`;
},
},
grid: { left: 8, right: 16, top: 16, bottom: 8, containLabel: true },
xAxis: {
type: 'category',
data: rows.map((r) => r.name),
axisLine: { lineStyle: { color: '#cbd5e1' } },
axisLabel: { ...AXIS_LABEL, interval: 0 },
},
yAxis: {
type: 'value',
min: 0,
max: 100,
splitLine: SPLIT,
axisLabel: { ...AXIS_LABEL, formatter: (v: number) => `${toFaDigits(v)}٪` },
},
series: [
{
name: 'درصد انجام',
type: 'bar',
barMaxWidth: 36,
data: rows.map((r) => ({
value: r.percent ?? 0,
itemStyle: { color: r.color, borderRadius: [6, 6, 0, 0] },
})),
},
],
};
}
/** میله‌ای انجام‌شده/کل به تفکیک روز */
export function daysBar(days: RoutineCalendarDayDto[], labels?: string[]): UiChartOption {
return {
textStyle: { fontFamily: FONT },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: any) => {
const list = Array.isArray(params) ? params : [params];
const d = days[list[0]?.dataIndex ?? -1];
if (!d) return '';
return `${formatDayNumeric(d.date)}<br/>انجام شده: <strong>${toFaDigits(d.completed)}</strong> از ${toFaDigits(d.total)}`;
},
},
legend: LEGEND,
grid: { left: 8, right: 16, top: 16, bottom: 32, containLabel: true },
xAxis: {
type: 'category',
data: labels ?? days.map((d) => formatDayNumeric(d.date)),
axisLine: { lineStyle: { color: '#cbd5e1' } },
axisLabel: { ...AXIS_LABEL, hideOverlap: true },
},
yAxis: {
type: 'value',
minInterval: 1,
splitLine: SPLIT,
axisLabel: { ...AXIS_LABEL, formatter: (v: number) => toFaDigits(v) },
},
series: [
{
name: 'انجام شده',
type: 'bar',
stack: 'all',
barMaxWidth: 28,
itemStyle: { color: STATUS_COLORS.completed },
data: days.map((d) => d.completed),
},
{
name: 'باقی‌مانده',
type: 'bar',
stack: 'all',
barMaxWidth: 28,
itemStyle: { color: '#e2e8f0', borderRadius: [6, 6, 0, 0] },
data: days.map((d) => Math.max(0, d.total - d.completed)),
},
],
};
}
/** میله‌ای افقی مقایسه اعضا */
export function membersBar(members: RoutineMemberStatDto[]): UiChartOption {
const rows = [...members].filter((m) => m.total > 0).sort((a, b) => (a.percent ?? 0) - (b.percent ?? 0));
return {
textStyle: { fontFamily: FONT },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: any) => {
const item = Array.isArray(params) ? params[0] : params;
const m = rows[item?.dataIndex ?? -1];
if (!m) return '';
return `${m.user.fullName}<br/>${toFaDigits(m.completed)} از ${toFaDigits(m.total)} · <strong>${m.percent == null ? '—' : `${toFaDigits(m.percent)}٪`}</strong>`;
},
},
grid: { left: 8, right: 24, top: 8, bottom: 8, containLabel: true },
xAxis: {
type: 'value',
min: 0,
max: 100,
splitLine: SPLIT,
axisLabel: { ...AXIS_LABEL, formatter: (v: number) => `${toFaDigits(v)}٪` },
},
yAxis: {
type: 'category',
data: rows.map((m) => m.user.fullName),
axisLabel: AXIS_LABEL,
axisLine: { show: false },
axisTick: { show: false },
},
series: [
{
name: 'درصد انجام',
type: 'bar',
barMaxWidth: 18,
data: rows.map((m) => ({
value: m.percent ?? 0,
itemStyle: {
borderRadius: [0, 6, 6, 0],
color: (m.percent ?? 0) >= 85 ? '#10b981' : (m.percent ?? 0) >= 60 ? '#f59e0b' : '#ef4444',
},
})),
},
],
};
}
+256
View File
@@ -0,0 +1,256 @@
import axios from 'axios';
import type { InjectionKey, Ref } from 'vue';
import type {
ActivityPriority,
RoutineContextDto,
RoutineDayState,
RoutineEntityRefDto,
RoutineTaskDto,
RoutineTaskStatus,
} from '@mento/shared';
import {
addJalaliMonths,
dateToJalali,
formatJalaliLong,
formatJalaliShort,
jalaliMonthLength,
jalaliMonthName,
jalaliToDate,
toFaDigits,
} from '@/lib/jalali';
export {
ROUTINE_ENTITY_LINK_LABELS,
ROUTINE_RECURRENCE_LABELS,
ROUTINE_TASK_SOURCE_LABELS,
ROUTINE_TASK_STATUS_LABELS,
ROUTINE_WEEKDAY_LABELS,
} from '@mento/shared';
type BadgeVariant = 'neutral' | 'accent' | 'success' | 'warning' | 'danger' | 'info';
export const PRIORITY_LABELS: Record<ActivityPriority, string> = {
very_low: 'خیلی کم',
low: 'کم',
medium: 'متوسط',
high: 'زیاد',
very_high: 'خیلی زیاد',
};
export const PRIORITY_VARIANTS: Record<ActivityPriority, BadgeVariant> = {
very_low: 'neutral',
low: 'neutral',
medium: 'info',
high: 'warning',
very_high: 'danger',
};
export const STATUS_VARIANTS: Record<RoutineTaskStatus, BadgeVariant> = {
todo: 'neutral',
in_progress: 'info',
completed: 'success',
overdue: 'danger',
skipped: 'warning',
cancelled: 'neutral',
};
export const DAY_STATE_LABELS: Record<RoutineDayState, string> = {
complete: 'روز کامل',
partial: 'روز ناقص',
missed: 'روز از دست رفته',
empty: 'بدون کار',
};
export const DAY_STATE_SYMBOLS: Record<RoutineDayState, string> = {
complete: '✓',
partial: '△',
missed: '×',
empty: '·',
};
/** زمینه مشترک بخش کارهای روتین (کاربران، دسته‌ها، نقش مدیر، رفرش) */
export type RoutinesShellContext = {
context: Ref<RoutineContextDto | null>;
refreshTick: Ref<number>;
notifyChanged: () => void;
openTask: (id: string) => void;
};
export const ROUTINES_CTX: InjectionKey<RoutinesShellContext> = Symbol('routines-ctx');
/* ───────── تاریخ‌ها (کلید روز YYYY-MM-DD به وقت تهران) ───────── */
const TEHRAN_DAY = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Tehran',
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
const TEHRAN_TIME = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Asia/Tehran',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
});
export function todayKey(): string {
return TEHRAN_DAY.format(new Date());
}
export function dayKeyOf(iso: string | Date): string {
return TEHRAN_DAY.format(typeof iso === 'string' ? new Date(iso) : iso);
}
/** Date محلی (نیمه‌شب) برای نمایش شمسی */
export function keyToDate(key: string): Date {
const [y, m, d] = key.split('-').map(Number);
return new Date(y!, m! - 1, d!);
}
export function dateToKey(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
export function addDaysKey(key: string, n: number): string {
const d = keyToDate(key);
d.setDate(d.getDate() + n);
return dateToKey(d);
}
/** ۰ = شنبه */
export function weekdayIndex(key: string): number {
return (keyToDate(key).getDay() + 1) % 7;
}
export function weekStart(key: string): string {
return addDaysKey(key, -weekdayIndex(key));
}
export function jalaliMonthRange(key: string): { from: string; to: string; label: string } {
const { jy, jm } = dateToJalali(keyToDate(key));
const from = dateToKey(jalaliToDate(jy, jm, 1));
const to = dateToKey(jalaliToDate(jy, jm, jalaliMonthLength(jy, jm)));
return { from, to, label: `${jalaliMonthName(jm)} ${toFaDigits(jy)}` };
}
export function shiftJalaliMonth(key: string, delta: number): string {
const { jy, jm } = dateToJalali(keyToDate(key));
const next = addJalaliMonths(jy, jm, delta);
return dateToKey(jalaliToDate(next.jy, next.jm, 1));
}
export function formatDayLong(key: string): string {
return formatJalaliLong(keyToDate(key));
}
export function formatDayShort(key: string): string {
return formatJalaliShort(keyToDate(key));
}
export function formatDayNumeric(key: string): string {
const { jm, jd } = dateToJalali(keyToDate(key));
return `${toFaDigits(jd)} ${jalaliMonthName(jm)}`;
}
export function formatTime(iso: string | null | undefined): string {
if (!iso) return '—';
return toFaDigits(TEHRAN_TIME.format(new Date(iso)));
}
export function timeOf(iso: string | null | undefined): string {
if (!iso) return '';
return TEHRAN_TIME.format(new Date(iso));
}
/** زمان پایان روز بدون ساعت مشخص (۲۳:۵۹) در UI نمایش داده نمی‌شود */
export function hasExplicitTime(task: Pick<RoutineTaskDto, 'dueAt'>): boolean {
return !!task.dueAt && timeOf(task.dueAt) !== '23:59';
}
export function formatDelay(iso: string | null | undefined): string {
if (!iso) return '—';
const diff = Date.now() - new Date(iso).getTime();
if (diff <= 0) return '—';
const minutes = Math.floor(diff / 60_000);
if (minutes < 60) return `${toFaDigits(minutes)} دقیقه`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${toFaDigits(hours)} ساعت`;
return `${toFaDigits(Math.floor(hours / 24))} روز`;
}
export function formatMinutes(min: number | null | undefined): string {
if (min == null) return '—';
if (min < 60) return `${toFaDigits(min)} دقیقه`;
const h = Math.floor(min / 60);
const m = min % 60;
return m ? `${toFaDigits(h)} ساعت و ${toFaDigits(m)} دقیقه` : `${toFaDigits(h)} ساعت`;
}
export function formatPercent(p: number | null | undefined): string {
return p == null ? '—' : `${toFaDigits(p)}٪`;
}
export function percentTone(p: number | null | undefined): 'good' | 'mid' | 'low' | 'none' {
if (p == null) return 'none';
if (p >= 85) return 'good';
if (p >= 60) return 'mid';
return 'low';
}
export function isoToLocalInput(iso: string | null | undefined): { date: string; time: string } {
if (!iso) return { date: '', time: '' };
return { date: dayKeyOf(iso), time: timeOf(iso) };
}
/** ورودی تاریخ/ساعت همیشه به وقت تهران تفسیر می‌شود */
export function localInputToIso(date: string, time: string): string | null {
if (!date) return null;
const d = new Date(`${date}T${time || '23:59'}:00+03:30`);
return Number.isNaN(d.getTime()) ? null : d.toISOString();
}
/* ───────── پیوند پرونده‌ها ───────── */
export const ENTITY_TYPE_LABELS: Record<RoutineEntityRefDto['type'], string> = {
lead: 'لید',
person: 'مشتری',
company: 'شرکت',
deal: 'معامله',
invoice: 'فاکتور',
activity: 'پیگیری',
};
export function entityRoute(ref: RoutineEntityRefDto): string {
switch (ref.type) {
case 'lead':
return `/leads/${ref.id}`;
case 'person':
return `/contacts/people/${ref.id}`;
case 'company':
return `/contacts/companies/${ref.id}`;
case 'deal':
return `/deals/${ref.id}`;
case 'invoice':
return `/invoices/${ref.id}`;
case 'activity':
return `/activities/${ref.id}`;
default:
return '/';
}
}
export function apiError(e: unknown, fallback: string): string {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
if (Array.isArray(msg)) return msg.join('، ');
if (msg) return msg;
if (!e.response) return 'اتصال به سرور برقرار نشد.';
}
return fallback;
}
export function isOpenStatus(status: RoutineTaskStatus): boolean {
return status === 'todo' || status === 'in_progress' || status === 'overdue';
}
+131
View File
@@ -0,0 +1,131 @@
import { reactive, ref } from 'vue';
import type { RoutineTaskDto } from '@mento/shared';
import { api } from '@/lib/api';
import { apiError, isoToLocalInput, localInputToIso, todayKey } from '@/lib/routines';
export type RoutineTaskModalKind = 'complete' | 'skip' | 'reschedule' | 'cancel';
/**
* اکشن‌های مشترک روی یک Task (تیک، رد، جابه‌جایی، لغو) + وضعیت مودال‌ها.
* `onChanged` بعد از هر تغییر موفق با Task به‌روزشده صدا زده می‌شود.
*/
export function useRoutineTaskActions(onChanged: (task: RoutineTaskDto) => void) {
const busyId = ref<string | null>(null);
const error = ref('');
const modal = reactive({
kind: null as RoutineTaskModalKind | null,
task: null as RoutineTaskDto | null,
note: '',
date: '',
time: '',
saving: false,
error: '',
});
async function run(task: RoutineTaskDto, path: string, body: object = {}) {
busyId.value = task.id;
error.value = '';
try {
const { data } = await api.post<RoutineTaskDto>(`/routine-tasks/${task.id}/${path}`, body);
onChanged(data);
return data;
} catch (e) {
error.value = apiError(e, 'انجام عملیات ممکن نشد.');
return null;
} finally {
busyId.value = null;
}
}
function open(kind: RoutineTaskModalKind, task: RoutineTaskDto) {
const due = isoToLocalInput(task.dueAt);
Object.assign(modal, {
kind,
task,
note: kind === 'complete' ? (task.resultNote ?? '') : '',
date: kind === 'reschedule' ? due.date || todayKey() : '',
time: kind === 'reschedule' ? (due.time === '23:59' ? '' : due.time) : '',
saving: false,
error: '',
});
}
function close() {
modal.kind = null;
modal.task = null;
}
/** تیک یک‌کلیکی؛ اگر گزارش اجباری باشد مودال نتیجه باز می‌شود */
async function toggle(task: RoutineTaskDto) {
if (busyId.value) return;
if (task.status === 'completed') return run(task, 'uncomplete');
if (task.status === 'cancelled' || task.status === 'skipped') return null;
if (task.rules.reportRequired) {
open('complete', task);
return null;
}
return run(task, 'complete');
}
async function submit() {
const task = modal.task;
if (!task || !modal.kind) return;
modal.error = '';
let path = '';
let body: object = {};
if (modal.kind === 'complete') {
if (task.rules.reportRequired && !modal.note.trim()) {
modal.error = 'ثبت گزارش برای این کار الزامی است.';
return;
}
path = 'complete';
body = { note: modal.note.trim() || null };
} else if (modal.kind === 'skip') {
if (task.rules.skipReasonRequired && !modal.note.trim()) {
modal.error = 'ثبت دلیل رد کردن الزامی است.';
return;
}
path = 'skip';
body = { reason: modal.note.trim() || null };
} else if (modal.kind === 'cancel') {
path = 'cancel';
body = { reason: modal.note.trim() || null };
} else {
const dueAt = localInputToIso(modal.date, modal.time);
if (!dueAt) {
modal.error = 'تاریخ جدید را انتخاب کنید.';
return;
}
path = 'reschedule';
body = { dueAt };
}
modal.saving = true;
try {
const { data } = await api.post<RoutineTaskDto>(`/routine-tasks/${task.id}/${path}`, body);
onChanged(data);
close();
} catch (e) {
modal.error = apiError(e, 'ذخیره ممکن نشد.');
} finally {
modal.saving = false;
}
}
return {
busyId,
error,
modal,
toggle,
start: (task: RoutineTaskDto) => run(task, 'start'),
moveToday: (task: RoutineTaskDto) => run(task, 'move-today'),
openComplete: (task: RoutineTaskDto) => open('complete', task),
openSkip: (task: RoutineTaskDto) => open('skip', task),
openReschedule: (task: RoutineTaskDto) => open('reschedule', task),
openCancel: (task: RoutineTaskDto) => open('cancel', task),
close,
submit,
};
}
export type RoutineTaskActions = ReturnType<typeof useRoutineTaskActions>;