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
+26
View File
@@ -223,6 +223,29 @@ async function remove() {
}
}
const routinesEnabled = computed(() => {
const mods = auth.user?.enabledModules;
return !mods || mods.includes('routines');
});
const converting = ref(false);
/** پیگیری → Task در «کارهای روتین» (تکراری نمی‌سازد) */
async function convertToTask() {
if (!id.value || converting.value) return;
converting.value = true;
error.value = '';
try {
const { data } = await api.post<{ id: string }>(`/routine-tasks/from-activity/${id.value}`);
moreOpen.value = false;
void router.push({ path: '/routines', query: { task: data.id } });
} catch (e) {
const msg = (e as { response?: { data?: { message?: string | string[] } } }).response?.data?.message;
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'تبدیل به Task ممکن نشد.');
} finally {
converting.value = false;
}
}
watch(id, () => {
commentOpen.value = false;
postponeOpen.value = false;
@@ -247,6 +270,9 @@ onMounted(() => {
</button>
<div v-if="moreOpen && activity" class="menu">
<RouterLink :to="{ name: 'activity-edit', params: { id } }">ویرایش</RouterLink>
<button v-if="routinesEnabled" type="button" :disabled="converting" @click="convertToTask">
تبدیل به Task
</button>
<button type="button" class="danger" @click="remove">حذف</button>
</div>
</div>
+2
View File
@@ -6,6 +6,7 @@ import { api } from '@/lib/api';
import type { CompanyDto, CreateCompanyRequest } from '@mento/shared';
import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
import EntityTimeline from '@/components/EntityTimeline.vue';
import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { formatLocalDateInput, parseLocalInput } from '@/lib/jalali';
import {
@@ -347,6 +348,7 @@ onMounted(load);
</UiCard>
<CustomFieldsSection entity-type="company" v-model="customFields" />
<RelatedTasksPanel v-if="!isNew && id" :entity="{ type: 'company', id, label: name }" />
<EntityTimeline v-if="!isNew && id" entity-type="company" :entity-id="id" />
<div class="form-bottom-actions">
+8
View File
@@ -8,6 +8,7 @@ import { toFaDigits } from '@/lib/jalali';
import { currencySuffix, formatMoney } from '@/lib/money';
import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
import EntityTimeline from '@/components/EntityTimeline.vue';
import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import MoneyInput from '@/components/MoneyInput.vue';
import UiIcon from '@/components/UiIcon.vue';
@@ -813,6 +814,13 @@ onMounted(load);
/>
</div>
<RelatedTasksPanel
v-if="!isNew && id"
class="timeline"
:class="{ hide: tab !== 'main' }"
:entity="{ type: 'deal', id, label: title }"
/>
<EntityTimeline
v-if="!isNew && id"
class="timeline"
+3
View File
@@ -15,6 +15,7 @@ import {
UiPageHeader,
UiSelect,
} from '@/components/ui';
import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue';
import type {
ConvertLeadResult,
LeadDto,
@@ -391,6 +392,8 @@ onMounted(load);
</dl>
</UiCard>
<RelatedTasksPanel :entity="{ type: 'lead', id: lead.id, label: lead.title }" />
<UiCard title="جدول زمانی">
<ul v-if="lead.events.length" class="timeline">
<li v-for="ev in lead.events" :key="ev.id">
+8 -1
View File
@@ -24,6 +24,7 @@ import ProfileEmptyState from '@/components/person-profile/ProfileEmptyState.vue
import ProfileMoreMenu from '@/components/person-profile/ProfileMoreMenu.vue';
import PersonAssetsPanel from '@/components/person-assets/PersonAssetsPanel.vue';
import InternalPartyBalance from '@/components/person-assets/InternalPartyBalance.vue';
import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue';
import type { ProfileMoreItem } from '@/components/person-profile/ProfileMoreMenu.vue';
import type {
ActivityDto,
@@ -44,7 +45,7 @@ import type {
} from '@mento/shared';
import { PERSON_MOOD_LABELS } from '@/lib/person-mood';
type TabKey = 'activities' | 'deals' | 'cards' | 'assets' | 'info';
type TabKey = 'activities' | 'tasks' | 'deals' | 'cards' | 'assets' | 'info';
type TimelineTone = 'call' | 'quote' | 'note' | 'meeting' | 'task';
type HistoryFilter = 'all' | TimelineTone;
@@ -67,6 +68,7 @@ type AttachmentRow = {
const TABS: Array<{ key: TabKey; label: string }> = [
{ key: 'activities', label: 'فعالیت‌ها' },
{ key: 'tasks', label: 'کارهای مرتبط' },
{ key: 'deals', label: 'معاملات' },
{ key: 'cards', label: 'کارت‌ها' },
{ key: 'assets', label: 'دارایی' },
@@ -389,6 +391,7 @@ function readTab() {
const q = route.query.tab;
if (
q === 'activities' ||
q === 'tasks' ||
q === 'deals' ||
q === 'cards' ||
q === 'assets' ||
@@ -928,6 +931,10 @@ watch(() => route.query.tab, readTab, { immediate: true });
</div>
</div>
<div v-if="tab === 'tasks'" class="panel">
<RelatedTasksPanel :entity="{ type: 'person', id, label: fullName }" />
</div>
<div v-show="tab === 'assets'" class="panel assets-panel">
<InternalPartyBalance :person-id="id" />
<PersonAssetsPanel :person-id="id" @link-changed="onTahesabLinkChanged" />
+6 -2
View File
@@ -1,6 +1,10 @@
<script setup lang="ts">
import type { BillingAccountDto, BillingPlanCode } from '@mento/shared';
import { BILLING_PLAN_CODES, BILLING_PLAN_LABELS } from '@mento/shared';
import {
BILLING_PLAN_CODES,
BILLING_PLAN_LABELS,
isPermanentBillingPlan,
} from '@mento/shared';
import { toFaDigits } from '@/lib/jalali';
defineProps<{
@@ -20,7 +24,7 @@ const emit = defineEmits<{
<p v-if="billing" class="muted">
{{ BILLING_PLAN_LABELS[billing.planCode] }}
· {{ billing.status }}
· {{ toFaDigits(billing.daysRemaining) }} روز
· {{ isPermanentBillingPlan(billing.planCode) ? 'بدون انقضا' : `${toFaDigits(billing.daysRemaining)} روز` }}
· سقف محیط {{ toFaDigits(billing.maxOrganizations) }}
</p>
<p v-else class="muted">هنوز پلن ندارد — یکی از پلن‌ها را اعمال کنید.</p>
@@ -20,6 +20,7 @@ import {
USER_ROLE_LABELS,
USER_ROLES,
isBusinessField,
isPermanentBillingPlan,
} from '@mento/shared';
import { platformApi } from '@/lib/platform-api';
import { BUSINESS_FIELDS, BUSINESS_FIELD_LABELS } from '@/lib/business-fields';
@@ -89,6 +90,27 @@ function resetCreateForm() {
createForm.ownerMobile = '';
}
function subscriptionBadge(org: PlatformOrganizationDto): {
label: string;
tone: 'ok' | 'warn' | 'expired' | 'none';
} {
const sub = org.subscription;
if (!sub) return { label: 'بدون اشتراک', tone: 'none' };
const plan = BILLING_PLAN_LABELS[sub.planCode] ?? sub.planCode;
if (sub.status === 'suspended') return { label: `${plan} · معلق`, tone: 'expired' };
if (isPermanentBillingPlan(sub.planCode)) return { label: plan, tone: 'ok' };
if (sub.status === 'expired' || sub.daysRemaining <= 0) {
return {
label: `${plan} · منقضی شده (${formatJalaliNumeric(new Date(sub.expiresAt))})`,
tone: 'expired',
};
}
return {
label: `${plan} · ${toFaDigits(sub.daysRemaining)} روز مانده`,
tone: sub.daysRemaining <= 3 ? 'warn' : 'ok',
};
}
async function load() {
loading.value = true;
error.value = '';
@@ -356,6 +378,11 @@ async function applyPlan(userId: string, planCode: BillingPlanCode) {
);
detail.value = data;
}
const { data: list } = await platformApi.get<PlatformOrganizationDto[]>(
'/platform/organizations',
{ params: query.value.trim() ? { q: query.value.trim() } : undefined },
);
items.value = list;
success.value = `پلن «${BILLING_PLAN_LABELS[planCode]}» اعمال شد`;
} catch (e) {
error.value = apiError(e, 'اعمال پلن ناموفق بود');
@@ -438,6 +465,9 @@ onMounted(() => {
</div>
<div class="meta">
<em :class="org.isActive ? 'on' : 'off'">{{ org.isActive ? 'فعال' : 'غیرفعال' }}</em>
<b class="sub-badge" :class="subscriptionBadge(org).tone">
{{ subscriptionBadge(org).label }}
</b>
<small>
{{ toFaDigits(org.memberCount) }} عضو ·
{{ toFaDigits(org.personCount) }} شخص ·
@@ -779,6 +809,40 @@ label span {
text-align: end;
}
.sub-badge {
display: inline-block;
margin-inline-start: 0.4rem;
padding: 0.12rem 0.5rem;
border-radius: 999px;
font-size: 0.74rem;
font-weight: 700;
border: 1px solid transparent;
}
.sub-badge.ok {
color: var(--success-deep);
background: #ecfdf5;
border-color: #a7f3d0;
}
.sub-badge.warn {
color: #b45309;
background: #fffbeb;
border-color: #fde68a;
}
.sub-badge.expired {
color: var(--danger);
background: #fef2f2;
border-color: #fecaca;
}
.sub-badge.none {
color: var(--muted);
background: #f1f5f9;
border-color: var(--line);
}
.actions {
display: flex;
gap: 0.45rem;
@@ -0,0 +1,530 @@
<script setup lang="ts">
import { computed, inject, onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import type { ActivityPriority, RoutineDto, RoutineEntityLink, SaveRoutineRequest } from '@mento/shared';
import { ROUTINE_ENTITY_LINK_LABELS, ROUTINE_WEEKDAY_LABELS } from '@mento/shared';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import RecurrencePicker, { type RecurrenceValue } from '@/components/routines/RecurrencePicker.vue';
import {
UiButton,
UiCard,
UiField,
UiInput,
UiPageHeader,
UiSegmented,
UiSelect,
UiTextarea,
} from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { PRIORITY_LABELS, ROUTINES_CTX, apiError, formatDayNumeric, todayKey } from '@/lib/routines';
const shell = inject(ROUTINES_CTX)!;
const route = useRoute();
const router = useRouter();
const id = computed(() => (typeof route.params.id === 'string' ? route.params.id : null));
const isEdit = computed(() => !!id.value);
const form = reactive({
title: '',
description: '',
target: 'user' as 'user' | 'team',
assigneeId: '',
teamId: '',
categoryId: '',
priority: 'medium' as ActivityPriority,
timeOfDay: '09:00',
durationMinutes: '' as string | number,
startDate: todayKey(),
endDate: '',
isMandatory: true,
allowSkip: false,
skipReasonRequired: true,
reportRequired: false,
allowAfterDeadline: true,
carryOverOverdue: false,
entityLink: 'none' as RoutineEntityLink,
reminderMinutesBefore: '' as string,
isActive: true,
});
const recurrence = ref<RecurrenceValue>({
recurrence: 'daily',
weekdays: [],
intervalDays: null,
monthDay: null,
specificDates: [],
});
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const targetOptions = [
{ value: 'user' as const, label: 'یک کاربر' },
{ value: 'team' as const, label: 'همه اعضای یک تیم' },
];
const priorities = Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>;
const entityLinks = Object.entries(ROUTINE_ENTITY_LINK_LABELS) as Array<[RoutineEntityLink, string]>;
const reminderOptions = [
{ value: '', label: 'طبق تنظیمات هر کاربر' },
{ value: '0', label: 'سر ساعت' },
{ value: '10', label: '۱۰ دقیقه قبل' },
{ value: '15', label: '۱۵ دقیقه قبل' },
{ value: '30', label: '۳۰ دقیقه قبل' },
{ value: '60', label: '۱ ساعت قبل' },
{ value: '120', label: '۲ ساعت قبل' },
];
function fill(r: RoutineDto) {
Object.assign(form, {
title: r.title,
description: r.description ?? '',
target: r.team ? 'team' : 'user',
assigneeId: r.assignee?.id ?? '',
teamId: r.team?.id ?? '',
categoryId: r.category?.id ?? '',
priority: r.priority,
timeOfDay: r.timeOfDay,
durationMinutes: r.durationMinutes ?? '',
startDate: r.startDate,
endDate: r.endDate ?? '',
isMandatory: r.isMandatory,
allowSkip: r.allowSkip,
skipReasonRequired: r.skipReasonRequired,
reportRequired: r.reportRequired,
allowAfterDeadline: r.allowAfterDeadline,
carryOverOverdue: r.carryOverOverdue,
entityLink: r.entityLink,
reminderMinutesBefore: r.reminderMinutesBefore == null ? '' : String(r.reminderMinutesBefore),
isActive: r.isActive,
});
recurrence.value = {
recurrence: r.recurrence,
weekdays: [...r.weekdays],
intervalDays: r.intervalDays,
monthDay: r.monthDay,
specificDates: [...r.specificDates],
};
}
onMounted(async () => {
if (!id.value) return;
loading.value = true;
try {
const { data } = await api.get<RoutineDto>(`/routines/${id.value}`);
fill(data);
} catch (e) {
error.value = apiError(e, 'روتین پیدا نشد.');
} finally {
loading.value = false;
}
});
const users = computed(() => shell.context.value?.users ?? []);
const teams = computed(() => shell.context.value?.teams ?? []);
const categories = computed(() => shell.context.value?.categories ?? []);
function joinFa(items: string[]) {
if (items.length <= 1) return items.join('');
return `${items.slice(0, -1).join('، ')} و ${items[items.length - 1]}`;
}
const summary = computed(() => {
const r = recurrence.value;
const days = r.weekdays.map((d) => ROUTINE_WEEKDAY_LABELS[d]!);
let rec = '';
switch (r.recurrence) {
case 'daily':
rec = 'هر روز';
break;
case 'weekdays':
case 'weekly':
rec = days.length ? `هر ${joinFa(days)}` : 'روز هفته انتخاب نشده';
break;
case 'biweekly':
rec = days.length ? `یک هفته در میان، ${joinFa(days)}` : 'روز هفته انتخاب نشده';
break;
case 'monthly':
rec = r.monthDay === 1 ? 'اول هر ماه' : `روز ${toFaDigits(r.monthDay ?? 1)} هر ماه`;
break;
case 'every_n_days':
rec = `هر ${toFaDigits(r.intervalDays ?? 1)} روز یک‌بار`;
break;
case 'specific_dates':
rec = `${toFaDigits(r.specificDates.length)} تاریخ مشخص`;
break;
}
const who =
form.target === 'team'
? teams.value.find((t) => t.id === form.teamId)?.name
? `برای همه اعضای تیم «${teams.value.find((t) => t.id === form.teamId)?.name}»`
: ''
: users.value.find((u) => u.id === form.assigneeId)?.fullName
? `برای ${users.value.find((u) => u.id === form.assigneeId)?.fullName}`
: '';
return `${rec}، ساعت ${toFaDigits(form.timeOfDay || '—')}${who ? ` ${who}` : ''} · از ${formatDayNumeric(form.startDate)}${form.endDate ? ` تا ${formatDayNumeric(form.endDate)}` : ''}`;
});
function validate(): string | null {
if (form.title.trim().length < 2) return 'عنوان روتین را وارد کنید.';
if (form.target === 'user' && !form.assigneeId) return 'مسئول روتین را انتخاب کنید.';
if (form.target === 'team' && !form.teamId) return 'تیم را انتخاب کنید.';
if (!/^\d{2}:\d{2}$/.test(form.timeOfDay)) return 'ساعت انجام را وارد کنید.';
if (!form.startDate) return 'تاریخ شروع را انتخاب کنید.';
if (form.endDate && form.endDate < form.startDate) return 'تاریخ پایان نباید قبل از تاریخ شروع باشد.';
const r = recurrence.value;
if (['weekdays', 'weekly', 'biweekly'].includes(r.recurrence) && !r.weekdays.length) {
return 'حداقل یک روز هفته را انتخاب کنید.';
}
if (r.recurrence === 'every_n_days' && !r.intervalDays) return 'فاصله تکرار را وارد کنید.';
if (r.recurrence === 'specific_dates' && !r.specificDates.length) return 'حداقل یک تاریخ اضافه کنید.';
return null;
}
async function save() {
error.value = validate() ?? '';
if (error.value) return;
const r = recurrence.value;
const duration = Number(form.durationMinutes);
const body: SaveRoutineRequest = {
title: form.title.trim(),
description: form.description.trim() || null,
assigneeId: form.target === 'user' ? form.assigneeId : null,
teamId: form.target === 'team' ? form.teamId : null,
categoryId: form.categoryId || null,
priority: form.priority,
timeOfDay: form.timeOfDay,
durationMinutes: duration > 0 ? Math.floor(duration) : null,
startDate: form.startDate,
endDate: form.endDate || null,
recurrence: r.recurrence,
weekdays: ['weekdays', 'weekly', 'biweekly'].includes(r.recurrence) ? r.weekdays : [],
intervalDays: r.recurrence === 'every_n_days' ? r.intervalDays : null,
monthDay: r.recurrence === 'monthly' ? r.monthDay : null,
specificDates: r.recurrence === 'specific_dates' ? r.specificDates : [],
isMandatory: form.isMandatory,
allowSkip: form.allowSkip,
skipReasonRequired: form.allowSkip && form.skipReasonRequired,
reportRequired: form.reportRequired,
allowAfterDeadline: form.allowAfterDeadline,
carryOverOverdue: form.carryOverOverdue,
entityLink: form.entityLink,
reminderMinutesBefore: form.reminderMinutesBefore === '' ? null : Number(form.reminderMinutesBefore),
isActive: form.isActive,
};
saving.value = true;
try {
if (isEdit.value) await api.patch(`/routines/${id.value}`, body);
else await api.post('/routines', body);
shell.notifyChanged();
void router.push('/routines/manage');
} catch (e) {
error.value = apiError(e, 'ذخیره روتین ممکن نشد.');
} finally {
saving.value = false;
}
}
</script>
<template>
<div class="rform">
<UiPageHeader
:title="isEdit ? 'ویرایش روتین' : 'روتین جدید'"
lead="کاری که باید به‌صورت منظم انجام شود؛ هر روزِ تکرار، یک کار برای مسئول ساخته می‌شود."
back-to="/routines/manage"
/>
<div v-if="loading" class="state">در حال بارگذاری…</div>
<form v-else class="grid" @submit.prevent="save">
<div class="col">
<UiCard>
<h3 class="sec-title">اطلاعات پایه</h3>
<div class="fields">
<UiField label="عنوان" required>
<UiInput v-model="form.title" placeholder="مثلاً: تماس با لیدهای جدید" :maxlength="200" />
</UiField>
<UiField label="توضیحات">
<UiTextarea v-model="form.description" :rows="3" placeholder="جزئیات یا دستورالعمل انجام کار" />
</UiField>
<div class="grid-2">
<UiField label="دسته‌بندی">
<UiSelect v-model="form.categoryId">
<option value="">بدون دسته</option>
<option v-for="c in categories" :key="c.id" :value="c.id">{{ c.name }}</option>
</UiSelect>
</UiField>
<UiField label="اولویت">
<UiSelect v-model="form.priority">
<option v-for="[k, label] in priorities" :key="k" :value="k">{{ label }}</option>
</UiSelect>
</UiField>
</div>
</div>
</UiCard>
<UiCard>
<h3 class="sec-title">مسئول</h3>
<div class="fields">
<UiSegmented v-model="form.target" :options="targetOptions" />
<UiField v-if="form.target === 'user'" label="کاربر" required>
<UiSelect v-model="form.assigneeId">
<option value="" disabled>انتخاب کنید</option>
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
</UiSelect>
</UiField>
<UiField v-else label="تیم" required hint="برای هر عضو فعال تیم یک کار جداگانه ساخته می‌شود.">
<UiSelect v-model="form.teamId">
<option value="" disabled>انتخاب کنید</option>
<option v-for="t in teams" :key="t.id" :value="t.id">{{ t.name }}</option>
</UiSelect>
</UiField>
</div>
</UiCard>
<UiCard>
<h3 class="sec-title">زمان‌بندی و تکرار</h3>
<div class="fields">
<div class="grid-2">
<UiField label="ساعت انجام" required>
<JalaliDateInput v-model="form.timeOfDay" mode="time" :clearable="false" />
</UiField>
<UiField label="مدت تقریبی (دقیقه)">
<UiInput v-model="form.durationMinutes" type="number" placeholder="اختیاری" />
</UiField>
<UiField label="تاریخ شروع" required>
<JalaliDateInput v-model="form.startDate" mode="date" :clearable="false" />
</UiField>
<UiField label="تاریخ پایان" hint="خالی = بدون پایان">
<JalaliDateInput v-model="form.endDate" mode="date" />
</UiField>
</div>
<UiField label="الگوی تکرار" required>
<RecurrencePicker v-model="recurrence" />
</UiField>
</div>
</UiCard>
</div>
<div class="col side">
<UiCard>
<h3 class="sec-title">قوانین انجام</h3>
<div class="toggles">
<label class="tg">
<input v-model="form.isMandatory" type="checkbox" />
<span>
<strong>اجباری</strong>
<small>در امتیاز روزانه و روزهای پیاپی کامل حساب می‌شود.</small>
</span>
</label>
<label class="tg">
<input v-model="form.allowSkip" type="checkbox" />
<span>
<strong>امکان رد کردن</strong>
<small>کاربر می‌تواند این کار را برای یک روز رد کند.</small>
</span>
</label>
<label v-if="form.allowSkip" class="tg nested">
<input v-model="form.skipReasonRequired" type="checkbox" />
<span><strong>ثبت دلیل رد کردن الزامی باشد</strong></span>
</label>
<label class="tg">
<input v-model="form.reportRequired" type="checkbox" />
<span>
<strong>گزارش نتیجه الزامی</strong>
<small>قبل از انجام‌شدن، کاربر باید نتیجه را بنویسد.</small>
</span>
</label>
<label class="tg">
<input v-model="form.allowAfterDeadline" type="checkbox" />
<span>
<strong>انجام بعد از مهلت مجاز باشد</strong>
<small>اگر خاموش باشد، کار عقب‌افتاده فقط توسط مدیر بسته می‌شود.</small>
</span>
</label>
<label class="tg">
<input v-model="form.carryOverOverdue" type="checkbox" />
<span>
<strong>انتقال به روز بعد</strong>
<small>کار انجام‌نشده در فهرست «کارهای امروز» روز بعد هم نمایش داده می‌شود.</small>
</span>
</label>
</div>
<UiField label="اتصال به پرونده" class="mt">
<UiSelect v-model="form.entityLink">
<option v-for="[k, label] in entityLinks" :key="k" :value="k">{{ label }}</option>
</UiSelect>
</UiField>
</UiCard>
<UiCard>
<h3 class="sec-title">یادآوری و وضعیت</h3>
<div class="fields">
<UiField label="یادآوری">
<UiSelect v-model="form.reminderMinutesBefore">
<option v-for="o in reminderOptions" :key="o.value" :value="o.value">{{ o.label }}</option>
</UiSelect>
</UiField>
<label class="tg">
<input v-model="form.isActive" type="checkbox" />
<span><strong>روتین فعال است</strong></span>
</label>
</div>
</UiCard>
<div class="summary">
<span>خلاصه</span>
<p>{{ summary }}</p>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div class="actions">
<UiButton type="submit" :loading="saving" block>{{ isEdit ? 'ذخیره تغییرات' : 'ایجاد روتین' }}</UiButton>
<UiButton variant="ghost" block @click="router.push('/routines/manage')">انصراف</UiButton>
</div>
</div>
</form>
</div>
</template>
<style scoped>
.rform {
display: grid;
gap: var(--sp-4);
}
.state {
padding: var(--sp-8);
text-align: center;
color: var(--muted);
}
.grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
gap: var(--sp-4);
align-items: start;
}
.col {
display: grid;
gap: var(--sp-4);
min-width: 0;
}
.side {
position: sticky;
top: var(--sp-4);
}
.sec-title {
margin: 0 0 var(--sp-4);
font-size: var(--fs-md);
font-weight: var(--fw-semibold);
color: var(--ink);
}
.fields {
display: grid;
gap: var(--sp-4);
}
.grid-2 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-3);
}
.toggles {
display: grid;
gap: var(--sp-3);
}
.tg {
display: flex;
align-items: flex-start;
gap: var(--sp-2);
cursor: pointer;
}
.tg.nested {
margin-inline-start: 26px;
}
.tg input {
width: 18px;
height: 18px;
margin-top: 2px;
accent-color: var(--accent);
flex-shrink: 0;
}
.tg span {
display: grid;
gap: 2px;
}
.tg strong {
font-size: var(--fs-sm);
font-weight: var(--fw-medium);
color: var(--ink);
}
.tg small {
font-size: var(--fs-xs);
color: var(--muted);
line-height: 1.6;
}
.mt {
margin-top: var(--sp-4);
}
.summary {
padding: var(--sp-3) var(--sp-4);
border-radius: var(--radius);
background: var(--accent-subtle);
border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent);
}
.summary span {
font-size: var(--fs-xs);
color: var(--accent-deep);
font-weight: var(--fw-semibold);
}
.summary p {
margin: 4px 0 0;
font-size: var(--fs-sm);
color: var(--ink);
line-height: 1.8;
}
.actions {
display: grid;
gap: var(--sp-2);
}
.error {
margin: 0;
}
@media (max-width: 1100px) {
.grid {
grid-template-columns: 1fr;
}
.side {
position: static;
}
}
@media (max-width: 600px) {
.grid-2 {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,553 @@
<script setup lang="ts">
import { computed, inject, onMounted, ref, watch } from 'vue';
import type { RoutineCalendarDayDto, RoutineCalendarDto, RoutineTaskDto } from '@mento/shared';
import { ROUTINE_WEEKDAY_LABELS } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import RoutineMonthGrid from '@/components/routines/RoutineMonthGrid.vue';
import RoutineTaskRow, { type RoutineTaskRowAction } from '@/components/routines/RoutineTaskRow.vue';
import RoutineTaskModals from '@/components/routines/RoutineTaskModals.vue';
import { UiButton, UiEmptyState, UiSegmented, UiSelect } from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import {
DAY_STATE_LABELS,
ROUTINES_CTX,
addDaysKey,
apiError,
formatDayLong,
formatDayNumeric,
formatPercent,
formatTime,
hasExplicitTime,
jalaliMonthRange,
shiftJalaliMonth,
todayKey,
weekStart,
} from '@/lib/routines';
import { useRoutineTaskActions } from '@/lib/useRoutineTaskActions';
type View = 'day' | 'week' | 'month';
const shell = inject(ROUTINES_CTX)!;
const view = ref<View>('month');
const anchor = ref(todayKey());
const selected = ref(todayKey());
const userId = ref('');
const cal = ref<RoutineCalendarDto>({ days: [], tasks: [] });
const loading = ref(true);
const error = ref('');
const viewOptions = [
{ value: 'day' as const, label: 'روز' },
{ value: 'week' as const, label: 'هفته' },
{ value: 'month' as const, label: 'ماه' },
];
const range = computed(() => {
if (view.value === 'day') return { from: anchor.value, to: anchor.value, label: formatDayLong(anchor.value) };
if (view.value === 'week') {
const from = weekStart(anchor.value);
const to = addDaysKey(from, 6);
return { from, to, label: `${formatDayNumeric(from)} تا ${formatDayNumeric(to)}` };
}
return jalaliMonthRange(anchor.value);
});
async function load(silent = false) {
if (!silent) loading.value = true;
error.value = '';
try {
const { data } = await api.get<RoutineCalendarDto>('/routine-tasks/calendar', {
params: {
from: range.value.from,
to: range.value.to,
...(userId.value ? { userId: userId.value } : {}),
},
});
cal.value = data;
} catch (e) {
error.value = apiError(e, 'بارگذاری تقویم ممکن نشد.');
} finally {
loading.value = false;
}
}
watch(view, (v) => {
if (v !== 'month') anchor.value = selected.value;
});
onMounted(() => load());
watch([() => range.value.from, () => range.value.to, userId], () => load());
watch(shell.refreshTick, () => load(true));
function move(delta: number) {
if (view.value === 'day') anchor.value = addDaysKey(anchor.value, delta);
else if (view.value === 'week') anchor.value = addDaysKey(anchor.value, delta * 7);
else anchor.value = shiftJalaliMonth(anchor.value, delta);
if (view.value === 'day') selected.value = anchor.value;
}
function goToday() {
anchor.value = todayKey();
selected.value = todayKey();
}
function selectDay(day: string) {
selected.value = day;
}
function openDay(day: string) {
anchor.value = day;
selected.value = day;
view.value = 'day';
}
const tasksByDay = computed(() => {
const map = new Map<string, RoutineTaskDto[]>();
for (const t of cal.value.tasks) {
const list = map.get(t.scheduledFor) ?? [];
list.push(t);
map.set(t.scheduledFor, list);
}
return map;
});
const dayMap = computed(() => new Map(cal.value.days.map((d) => [d.date, d])));
const weekDays = computed(() => {
const out: Array<{ key: string; day?: RoutineCalendarDayDto; tasks: RoutineTaskDto[] }> = [];
for (let i = 0; i < 7; i++) {
const key = addDaysKey(range.value.from, i);
out.push({ key, day: dayMap.value.get(key), tasks: tasksByDay.value.get(key) ?? [] });
}
return out;
});
const focusDay = computed(() => (view.value === 'day' ? anchor.value : selected.value));
const focusTasks = computed(() => tasksByDay.value.get(focusDay.value) ?? []);
const focusStat = computed(() => dayMap.value.get(focusDay.value));
const actions = useRoutineTaskActions((updated) => {
const i = cal.value.tasks.findIndex((t) => t.id === updated.id);
if (i >= 0) cal.value.tasks[i] = updated;
shell.notifyChanged();
});
function onAction(kind: RoutineTaskRowAction, task: RoutineTaskDto) {
if (kind === 'start') void actions.start(task);
else if (kind === 'complete-note') actions.openComplete(task);
else if (kind === 'reschedule') actions.openReschedule(task);
else if (kind === 'move-today') void actions.moveToday(task);
else if (kind === 'skip') actions.openSkip(task);
else actions.openCancel(task);
}
const users = computed(() => shell.context.value?.users ?? []);
const isManager = computed(() => !!shell.context.value?.isManager);
const today = todayKey();
</script>
<template>
<div class="calendar">
<div class="cal-toolbar">
<div class="nav">
<UiButton variant="icon" aria-label="قبلی" @click="move(-1)">
<UiIcon name="chevron-right" :size="18" />
</UiButton>
<strong class="range-label">{{ range.label }}</strong>
<UiButton variant="icon" aria-label="بعدی" @click="move(1)">
<UiIcon name="chevron-left" :size="18" />
</UiButton>
<UiButton variant="ghost" size="sm" @click="goToday">امروز</UiButton>
</div>
<div class="tools">
<UiSelect v-if="isManager && users.length > 1" v-model="userId" size="sm" class="who">
<option value="">تقویم خودم</option>
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
</UiSelect>
<UiSegmented v-model="view" :options="viewOptions" />
</div>
</div>
<div class="legend">
<span><i class="lg complete" /> روز کامل</span>
<span><i class="lg partial" /> بخشی انجام شده</span>
<span><i class="lg missed" /> انجام نشده</span>
<span><i class="lg empty" /> بدون کار</span>
</div>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="actions.error.value" class="error">{{ actions.error.value }}</p>
<div class="cal-body" :class="{ dim: loading }">
<!-- ماه -->
<div v-if="view === 'month'" class="month-layout">
<RoutineMonthGrid
:from="range.from"
:to="range.to"
:days="cal.days"
:selected="selected"
@select="selectDay"
/>
</div>
<!-- هفته -->
<div v-else-if="view === 'week'" class="week">
<div
v-for="(d, i) in weekDays"
:key="d.key"
class="week-col"
:class="[`s-${d.day?.state ?? 'empty'}`, { today: d.key === today, sel: d.key === selected }]"
>
<button type="button" class="week-head" @click="openDay(d.key)">
<span>{{ ROUTINE_WEEKDAY_LABELS[i] }}</span>
<strong>{{ formatDayNumeric(d.key) }}</strong>
<em v-if="d.day?.total">{{ toFaDigits(d.day.completed) }}/{{ toFaDigits(d.day.total) }}</em>
</button>
<ul class="week-tasks">
<li
v-for="t in d.tasks"
:key="t.id"
:class="`st-${t.status}`"
@click="shell.openTask(t.id)"
>
<span class="wt-dot" />
<span class="wt-title">{{ t.title }}</span>
<small v-if="hasExplicitTime(t)">{{ formatTime(t.dueAt) }}</small>
</li>
<li v-if="!d.tasks.length" class="none">—</li>
</ul>
</div>
</div>
</div>
<!-- جزئیات روز انتخاب‌شده / نمای روز -->
<section v-if="view !== 'week'" class="day-panel">
<header>
<div>
<h3>{{ formatDayLong(focusDay) }}</h3>
<p v-if="focusStat?.total" class="day-sub">
<span :class="`chip s-${focusStat.state}`">{{ DAY_STATE_LABELS[focusStat.state] }}</span>
{{ toFaDigits(focusStat.completed) }} از {{ toFaDigits(focusStat.total) }} انجام شده
· {{ formatPercent(focusStat.percent) }}
</p>
</div>
<UiButton v-if="view === 'month'" variant="ghost" size="sm" @click="openDay(focusDay)">نمای روز</UiButton>
</header>
<div v-if="focusTasks.length" class="rows">
<RoutineTaskRow
v-for="t in focusTasks"
:key="t.id"
:task="t"
:busy="actions.busyId.value === t.id"
:show-assignee="!!userId"
@toggle="actions.toggle"
@open="shell.openTask(t.id)"
@action="onAction"
/>
</div>
<UiEmptyState
v-else
icon="calendar"
title="کاری برای این روز نیست"
:description="focusDay > today ? 'کارهای روتین روزهای آینده صبح همان روز ساخته می‌شوند.' : undefined"
/>
</section>
<RoutineTaskModals :actions="actions" />
</div>
</template>
<style scoped>
.calendar {
display: grid;
gap: var(--sp-4);
}
.cal-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--sp-3);
}
.nav {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.range-label {
min-width: 150px;
text-align: center;
font-size: var(--fs-md);
color: var(--ink);
}
.tools {
display: flex;
align-items: center;
gap: var(--sp-2);
flex-wrap: wrap;
}
.who {
width: 200px;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: var(--sp-4);
font-size: var(--fs-xs);
color: var(--muted);
}
.legend span {
display: inline-flex;
align-items: center;
gap: 6px;
}
.lg {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
}
.lg.complete {
background: var(--success);
}
.lg.partial {
background: var(--warning);
}
.lg.missed {
background: var(--danger);
}
.lg.empty {
background: var(--surface);
border: 1px solid var(--line-strong);
}
.cal-body {
transition: opacity var(--transition-fast);
}
.cal-body.dim {
opacity: 0.55;
}
.week {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 6px;
}
.week-col {
display: grid;
grid-template-rows: auto 1fr;
min-height: 260px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
overflow: hidden;
}
.week-col.today {
border-color: var(--accent);
}
.week-head {
display: grid;
gap: 2px;
padding: 8px;
border: 0;
border-bottom: 1px solid var(--line-light);
background: var(--surface-subtle);
text-align: right;
font: inherit;
cursor: pointer;
}
.s-complete .week-head {
background: var(--success-soft);
}
.s-partial .week-head {
background: var(--warning-soft);
}
.s-missed .week-head {
background: var(--danger-soft);
}
.week-head span {
font-size: var(--fs-xs);
color: var(--muted);
}
.week-head strong {
font-size: var(--fs-sm);
color: var(--ink);
}
.week-head em {
font-style: normal;
font-size: var(--fs-xs);
color: var(--ink-secondary);
}
.week-tasks {
list-style: none;
margin: 0;
padding: 6px;
display: grid;
align-content: start;
gap: 4px;
}
.week-tasks li {
display: grid;
grid-template-columns: 8px minmax(0, 1fr);
align-items: center;
gap: 2px 6px;
padding: 6px;
border-radius: 6px;
font-size: var(--fs-xs);
cursor: pointer;
background: var(--surface-subtle);
}
.week-tasks li:hover {
background: var(--accent-subtle);
}
.week-tasks li small {
grid-column: 2;
color: var(--muted);
}
.week-tasks li.none {
background: transparent;
color: var(--muted-light);
cursor: default;
text-align: center;
display: block;
}
.wt-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--line-strong);
}
.st-completed .wt-dot {
background: var(--success);
}
.st-completed .wt-title {
text-decoration: line-through;
color: var(--muted);
}
.st-overdue .wt-dot {
background: var(--danger);
}
.st-skipped .wt-dot,
.st-cancelled .wt-dot {
background: var(--warning);
}
.wt-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.day-panel {
display: grid;
gap: var(--sp-3);
padding: var(--sp-4);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
}
.day-panel header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--sp-2);
}
.day-panel h3 {
margin: 0;
font-size: var(--fs-md);
color: var(--ink);
}
.day-sub {
margin: 4px 0 0;
font-size: var(--fs-sm);
color: var(--muted);
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.chip {
padding: 1px 8px;
border-radius: 99px;
font-size: var(--fs-xs);
font-weight: var(--fw-semibold);
background: var(--surface-subtle);
}
.chip.s-complete {
background: var(--success-soft);
color: var(--success-deep);
}
.chip.s-partial {
background: var(--warning-soft);
color: var(--warning-deep);
}
.chip.s-missed {
background: var(--danger-soft);
color: var(--danger);
}
.rows {
display: grid;
gap: var(--sp-2);
}
.error {
margin: 0;
}
@media (max-width: 900px) {
.week {
grid-template-columns: 1fr;
}
.week-col {
min-height: 0;
}
.tools,
.who {
width: 100%;
}
}
</style>
@@ -0,0 +1,255 @@
<script setup lang="ts">
import { computed, inject, onMounted, ref, watch } from 'vue';
import type { RoutineDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import { UiBadge, UiEmptyState, UiSearch } from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import {
PRIORITY_LABELS,
PRIORITY_VARIANTS,
ROUTINES_CTX,
apiError,
formatDayNumeric,
formatPercent,
percentTone,
} from '@/lib/routines';
const shell = inject(ROUTINES_CTX)!;
const items = ref<RoutineDto[]>([]);
const loading = ref(true);
const error = ref('');
const q = ref('');
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<RoutineDto[]>('/routines', { params: { scope: 'mine' } });
items.value = data;
} catch (e) {
error.value = apiError(e, 'بارگذاری روتین‌ها ممکن نشد.');
} finally {
loading.value = false;
}
}
onMounted(load);
watch(shell.refreshTick, load);
const filtered = computed(() => {
const term = q.value.trim();
if (!term) return items.value;
return items.value.filter((r) => r.title.includes(term) || (r.description ?? '').includes(term));
});
</script>
<template>
<div class="rlist">
<div class="toolbar">
<p class="lead">روتین‌هایی که به شما یا تیم شما سپرده شده‌اند. کارهای هر روز از روی این‌ها ساخته می‌شوند.</p>
<UiSearch v-model="q" placeholder="جستجوی روتین…" class="search" />
</div>
<p v-if="error" class="error">{{ error }}</p>
<div v-if="loading" class="state">در حال بارگذاری…</div>
<UiEmptyState
v-else-if="!filtered.length"
icon="refresh"
:title="q ? 'روتینی با این عنوان پیدا نشد' : 'هنوز روتینی برای شما تعریف نشده'"
description="روتین‌ها توسط مدیر تیم تعریف می‌شوند و هر روز به‌صورت خودکار در «کارهای امروز» ظاهر می‌شوند."
/>
<div v-else class="cards">
<article v-for="r in filtered" :key="r.id" class="rcard">
<header>
<span class="ico" :style="{ background: r.category?.color ?? 'var(--accent)' }">
<UiIcon name="refresh" :size="16" />
</span>
<div class="titles">
<h3>{{ r.title }}</h3>
<p>{{ r.recurrenceLabel }} · ساعت {{ toFaDigits(r.timeOfDay) }}</p>
</div>
</header>
<p v-if="r.description" class="desc">{{ r.description }}</p>
<div class="tags">
<UiBadge v-if="r.category" size="sm">{{ r.category.name }}</UiBadge>
<UiBadge size="sm" :variant="PRIORITY_VARIANTS[r.priority]">{{ PRIORITY_LABELS[r.priority] }}</UiBadge>
<UiBadge v-if="r.isMandatory" size="sm" variant="warning">اجباری</UiBadge>
<UiBadge v-if="r.reportRequired" size="sm" variant="info">گزارش الزامی</UiBadge>
<UiBadge v-if="r.team" size="sm" variant="accent">تیم {{ r.team.name }}</UiBadge>
</div>
<footer>
<div class="exec" :class="`tone-${percentTone(r.executionRate)}`">
<span>اجرا در ۳۰ روز اخیر</span>
<strong>{{ formatPercent(r.executionRate) }}</strong>
<i class="bar"><b :style="{ width: `${r.executionRate ?? 0}%` }" /></i>
</div>
<small class="since">
از {{ formatDayNumeric(r.startDate) }}{{ r.endDate ? ` تا ${formatDayNumeric(r.endDate)}` : '' }}
</small>
</footer>
</article>
</div>
</div>
</template>
<style scoped>
.rlist {
display: grid;
gap: var(--sp-4);
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--sp-3);
}
.lead {
margin: 0;
font-size: var(--fs-sm);
color: var(--muted);
}
.search {
width: 260px;
}
.state {
padding: var(--sp-8);
text-align: center;
color: var(--muted);
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: var(--sp-3);
}
.rcard {
display: grid;
gap: var(--sp-3);
align-content: start;
padding: var(--sp-4);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
box-shadow: var(--shadow-xs);
}
.rcard header {
display: flex;
align-items: center;
gap: var(--sp-3);
}
.ico {
width: 36px;
height: 36px;
border-radius: 10px;
display: grid;
place-items: center;
color: #fff;
flex-shrink: 0;
}
.titles {
min-width: 0;
}
.titles h3 {
margin: 0;
font-size: var(--fs-base);
font-weight: var(--fw-semibold);
color: var(--ink);
}
.titles p {
margin: 2px 0 0;
font-size: var(--fs-xs);
color: var(--muted);
}
.desc {
margin: 0;
font-size: var(--fs-sm);
color: var(--ink-secondary);
line-height: 1.7;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.rcard footer {
display: grid;
gap: 6px;
padding-top: var(--sp-3);
border-top: 1px solid var(--line-light);
}
.exec {
display: grid;
grid-template-columns: 1fr auto;
gap: 4px;
font-size: var(--fs-xs);
color: var(--muted);
}
.exec strong {
color: var(--ink);
}
.bar {
grid-column: 1 / -1;
height: 5px;
border-radius: 99px;
background: var(--surface-subtle);
overflow: hidden;
}
.bar b {
display: block;
height: 100%;
background: var(--line-strong);
}
.tone-good .bar b {
background: var(--success);
}
.tone-mid .bar b {
background: var(--warning);
}
.tone-low .bar b {
background: var(--danger);
}
.since {
color: var(--muted-light);
font-size: var(--fs-xs);
}
.error {
margin: 0;
}
@media (max-width: 900px) {
.search {
width: 100%;
}
.cards {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,673 @@
<script setup lang="ts">
import { computed, inject, onMounted, reactive, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import type { RoutineCategoryDto, RoutineDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import UserAvatar from '@/components/UserAvatar.vue';
import {
UiBadge,
UiButton,
UiDropdownMenu,
UiEmptyState,
UiField,
UiInput,
UiModal,
UiSearch,
UiSegmented,
UiSelect,
} from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { ROUTINES_CTX, apiError, formatPercent, percentTone } from '@/lib/routines';
const shell = inject(ROUTINES_CTX)!;
const router = useRouter();
const items = ref<RoutineDto[]>([]);
const loading = ref(true);
const error = ref('');
const q = ref('');
const status = ref<'all' | 'active' | 'inactive'>('all');
const assignee = ref('');
const busyId = ref<string | null>(null);
const menuFor = ref<string | null>(null);
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<RoutineDto[]>('/routines', { params: { scope: 'all' } });
items.value = data;
} catch (e) {
error.value = apiError(e, 'بارگذاری روتین‌ها ممکن نشد.');
} finally {
loading.value = false;
}
}
onMounted(load);
watch(shell.refreshTick, load);
const statusOptions = [
{ value: 'all' as const, label: 'همه' },
{ value: 'active' as const, label: 'فعال' },
{ value: 'inactive' as const, label: 'غیرفعال' },
];
const filtered = computed(() => {
const term = q.value.trim();
return items.value.filter((r) => {
if (status.value === 'active' && !r.isActive) return false;
if (status.value === 'inactive' && r.isActive) return false;
if (assignee.value && r.assignee?.id !== assignee.value && `team:${r.team?.id}` !== assignee.value) return false;
if (term && !r.title.includes(term)) return false;
return true;
});
});
const counts = computed(() => ({
total: items.value.length,
active: items.value.filter((r) => r.isActive).length,
}));
function replace(updated: RoutineDto) {
const i = items.value.findIndex((r) => r.id === updated.id);
if (i >= 0) items.value[i] = updated;
else items.value.unshift(updated);
}
async function toggle(r: RoutineDto) {
busyId.value = r.id;
error.value = '';
try {
const { data } = await api.patch<RoutineDto>(`/routines/${r.id}/toggle`, { isActive: !r.isActive });
replace(data);
shell.notifyChanged();
} catch (e) {
error.value = apiError(e, 'تغییر وضعیت ممکن نشد.');
} finally {
busyId.value = null;
}
}
async function duplicate(r: RoutineDto) {
busyId.value = r.id;
error.value = '';
try {
const { data } = await api.post<RoutineDto>(`/routines/${r.id}/duplicate`);
replace(data);
} catch (e) {
error.value = apiError(e, 'کپی روتین ممکن نشد.');
} finally {
busyId.value = null;
}
}
/* حذف */
const removing = ref<RoutineDto | null>(null);
const removeBusy = ref(false);
async function confirmRemove() {
const r = removing.value;
if (!r) return;
removeBusy.value = true;
try {
await api.delete(`/routines/${r.id}`);
items.value = items.value.filter((x) => x.id !== r.id);
removing.value = null;
shell.notifyChanged();
} catch (e) {
error.value = apiError(e, 'حذف روتین ممکن نشد.');
removing.value = null;
} finally {
removeBusy.value = false;
}
}
/* تغییر مسئول */
const reassign = reactive({ routine: null as RoutineDto | null, target: '', saving: false, error: '' });
function openReassign(r: RoutineDto) {
Object.assign(reassign, {
routine: r,
target: r.team ? `team:${r.team.id}` : (r.assignee?.id ?? ''),
saving: false,
error: '',
});
}
async function saveReassign() {
const r = reassign.routine;
if (!r || !reassign.target) {
reassign.error = 'مسئول را انتخاب کنید.';
return;
}
const body = reassign.target.startsWith('team:')
? { teamId: reassign.target.slice(5), assigneeId: null }
: { assigneeId: reassign.target, teamId: null };
reassign.saving = true;
try {
const { data } = await api.patch<RoutineDto>(`/routines/${r.id}/assignee`, body);
replace(data);
reassign.routine = null;
shell.notifyChanged();
} catch (e) {
reassign.error = apiError(e, 'تغییر مسئول ممکن نشد.');
} finally {
reassign.saving = false;
}
}
/* دسته‌بندی‌ها */
const catsOpen = ref(false);
const catForm = reactive({ id: '', name: '', color: '#14b8a6', saving: false, error: '' });
const CAT_COLORS = ['#14b8a6', '#3b82f6', '#8b5cf6', '#f59e0b', '#f43f5e', '#10b981', '#06b6d4', '#64748b'];
const categories = computed(() => shell.context.value?.categories ?? []);
function editCat(c: RoutineCategoryDto | null) {
Object.assign(catForm, { id: c?.id ?? '', name: c?.name ?? '', color: c?.color ?? '#14b8a6', error: '' });
}
async function refreshCategories() {
const ctx = shell.context.value;
if (!ctx) return;
const { data } = await api.get<RoutineCategoryDto[]>('/routines/categories');
ctx.categories = data;
}
async function saveCat() {
if (!catForm.name.trim()) {
catForm.error = 'نام دسته را وارد کنید.';
return;
}
catForm.saving = true;
catForm.error = '';
try {
const body = { name: catForm.name.trim(), color: catForm.color };
if (catForm.id) await api.patch(`/routines/categories/${catForm.id}`, body);
else await api.post('/routines/categories', body);
await refreshCategories();
editCat(null);
} catch (e) {
catForm.error = apiError(e, 'ذخیره دسته ممکن نشد.');
} finally {
catForm.saving = false;
}
}
async function deleteCat(c: RoutineCategoryDto) {
catForm.error = '';
try {
await api.delete(`/routines/categories/${c.id}`);
await refreshCategories();
if (catForm.id === c.id) editCat(null);
} catch (e) {
catForm.error = apiError(e, 'حذف دسته ممکن نشد.');
}
}
const users = computed(() => shell.context.value?.users ?? []);
const teams = computed(() => shell.context.value?.teams ?? []);
</script>
<template>
<div class="manage">
<div class="toolbar">
<UiSearch v-model="q" placeholder="جستجوی عنوان…" class="search" />
<UiSelect v-model="assignee" size="sm" class="who">
<option value="">همه مسئول‌ها</option>
<optgroup label="کاربران">
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
</optgroup>
<optgroup v-if="teams.length" label="تیم‌ها">
<option v-for="t in teams" :key="t.id" :value="`team:${t.id}`">{{ t.name }}</option>
</optgroup>
</UiSelect>
<UiSegmented v-model="status" :options="statusOptions" />
<span class="spacer" />
<UiButton variant="ghost" size="sm" @click="catsOpen = true; editCat(null)">
<template #icon><UiIcon name="layers" :size="15" /></template>
دسته‌بندی‌ها
</UiButton>
<UiButton @click="router.push('/routines/manage/new')">
<template #icon><UiIcon name="plus" :size="16" /></template>
روتین جدید
</UiButton>
</div>
<p class="summary">
{{ toFaDigits(counts.total) }} روتین · {{ toFaDigits(counts.active) }} فعال
</p>
<p v-if="error" class="error">{{ error }}</p>
<div v-if="loading" class="state">در حال بارگذاری…</div>
<UiEmptyState
v-else-if="!filtered.length"
icon="refresh"
:title="items.length ? 'روتینی با این فیلتر پیدا نشد' : 'هنوز روتینی تعریف نشده'"
description="روتین‌ها کارهای تکرارشونده‌ای هستند که هر روز به‌صورت خودکار برای مسئولشان ساخته می‌شوند."
>
<template v-if="!items.length" #action>
<UiButton @click="router.push('/routines/manage/new')">ساخت اولین روتین</UiButton>
</template>
</UiEmptyState>
<div v-else class="crm-table-container">
<table class="crm-table">
<thead>
<tr>
<th>عنوان</th>
<th>مسئول</th>
<th>تکرار</th>
<th>ساعت</th>
<th>وضعیت</th>
<th>درصد انجام</th>
<th />
</tr>
</thead>
<tbody>
<tr v-for="r in filtered" :key="r.id" :class="{ inactive: !r.isActive }">
<td class="title-cell">
<span class="cat-dot" :style="{ background: r.category?.color ?? 'var(--line-strong)' }" />
<div>
<strong>{{ r.title }}</strong>
<small>
{{ r.category?.name ?? 'بدون دسته' }}
<template v-if="r.isMandatory"> · اجباری</template>
</small>
</div>
</td>
<td>
<span v-if="r.team" class="who-cell">
<span class="team-ico"><UiIcon name="users" :size="13" /></span>
تیم {{ r.team.name }}
</span>
<span v-else-if="r.assignee" class="who-cell">
<UserAvatar :name="r.assignee.fullName" :user-id="r.assignee.id" :size="24" />
{{ r.assignee.fullName }}
</span>
</td>
<td class="nowrap">{{ r.recurrenceLabel }}</td>
<td class="nowrap">{{ toFaDigits(r.timeOfDay) }}</td>
<td>
<button
type="button"
class="switch"
:class="{ on: r.isActive }"
:disabled="busyId === r.id"
:aria-pressed="r.isActive"
:aria-label="r.isActive ? 'غیرفعال کردن' : 'فعال کردن'"
@click="toggle(r)"
>
<i />
</button>
<UiBadge size="sm" :variant="r.isActive ? 'success' : 'neutral'">
{{ r.isActive ? 'فعال' : 'غیرفعال' }}
</UiBadge>
</td>
<td>
<div class="exec" :class="`tone-${percentTone(r.executionRate)}`">
<strong>{{ formatPercent(r.executionRate) }}</strong>
<i class="bar"><b :style="{ width: `${r.executionRate ?? 0}%` }" /></i>
<small v-if="r.executionTotal">
{{ toFaDigits(r.executionDone) }} از {{ toFaDigits(r.executionTotal) }}
</small>
</div>
</td>
<td class="actions-cell">
<div class="crm-table-actions">
<UiButton
variant="icon"
size="sm"
aria-label="ویرایش"
@click="router.push(`/routines/manage/${r.id}/edit`)"
>
<UiIcon name="pencil" :size="15" />
</UiButton>
<UiDropdownMenu
:open="menuFor === r.id"
@update:open="(v: boolean) => (menuFor = v ? r.id : null)"
>
<template #trigger="{ toggle: t }">
<UiButton variant="icon" size="sm" aria-label="عملیات بیشتر" @click="t">
<UiIcon name="dots" :size="16" />
</UiButton>
</template>
<button type="button" @click="router.push(`/routines/manage/${r.id}/edit`)">ویرایش</button>
<button type="button" @click="openReassign(r)">تغییر مسئول</button>
<button type="button" @click="duplicate(r)">کپی روتین</button>
<button type="button" @click="toggle(r)">{{ r.isActive ? 'غیرفعال کردن' : 'فعال کردن' }}</button>
<button type="button" class="danger" @click="removing = r">حذف</button>
</UiDropdownMenu>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- حذف -->
<UiModal :open="!!removing" title="حذف روتین" @close="removing = null">
<p class="confirm">
روتین «{{ removing?.title }}» حذف شود؟ کارهای انجام‌نشده امروزِ آن لغو می‌شوند؛ سوابق قبلی در گزارش‌ها باقی
می‌ماند.
</p>
<template #footer>
<UiButton variant="danger" :loading="removeBusy" @click="confirmRemove">حذف روتین</UiButton>
<UiButton variant="ghost" @click="removing = null">انصراف</UiButton>
</template>
</UiModal>
<!-- تغییر مسئول -->
<UiModal :open="!!reassign.routine" title="تغییر مسئول روتین" @close="reassign.routine = null">
<div class="form">
<p class="confirm">{{ reassign.routine?.title }}</p>
<UiField label="مسئول جدید" hint="کارهای انجام‌نشده امروز به مسئول جدید منتقل می‌شوند.">
<UiSelect v-model="reassign.target">
<option value="" disabled>انتخاب کنید</option>
<optgroup label="کاربران">
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
</optgroup>
<optgroup v-if="teams.length" label="کل اعضای تیم">
<option v-for="t in teams" :key="t.id" :value="`team:${t.id}`">{{ t.name }}</option>
</optgroup>
</UiSelect>
</UiField>
<p v-if="reassign.error" class="error">{{ reassign.error }}</p>
</div>
<template #footer>
<UiButton :loading="reassign.saving" @click="saveReassign">ذخیره</UiButton>
<UiButton variant="ghost" @click="reassign.routine = null">انصراف</UiButton>
</template>
</UiModal>
<!-- دسته‌بندی‌ها -->
<UiModal :open="catsOpen" title="دسته‌بندی کارها" @close="catsOpen = false">
<div class="form">
<ul class="cat-list">
<li v-for="c in categories" :key="c.id">
<span class="cat-dot" :style="{ background: c.color }" />
<span class="cat-name">{{ c.name }}</span>
<UiButton variant="icon" size="sm" aria-label="ویرایش" @click="editCat(c)">
<UiIcon name="pencil" :size="14" />
</UiButton>
<UiButton variant="icon" size="sm" aria-label="حذف" @click="deleteCat(c)">
<UiIcon name="trash" :size="14" />
</UiButton>
</li>
</ul>
<div class="cat-form">
<UiField :label="catForm.id ? 'ویرایش دسته' : 'دسته جدید'">
<UiInput v-model="catForm.name" placeholder="مثلاً: پشتیبانی" :maxlength="60" />
</UiField>
<div class="swatches" role="radiogroup" aria-label="رنگ">
<button
v-for="col in CAT_COLORS"
:key="col"
type="button"
class="swatch"
:class="{ on: catForm.color === col }"
:style="{ background: col }"
:aria-label="col"
@click="catForm.color = col"
/>
</div>
<p v-if="catForm.error" class="error">{{ catForm.error }}</p>
</div>
</div>
<template #footer>
<UiButton :loading="catForm.saving" @click="saveCat">{{ catForm.id ? 'ذخیره تغییرات' : 'افزودن دسته' }}</UiButton>
<UiButton v-if="catForm.id" variant="ghost" @click="editCat(null)">انصراف از ویرایش</UiButton>
<UiButton variant="ghost" @click="catsOpen = false">بستن</UiButton>
</template>
</UiModal>
</div>
</template>
<style scoped>
.manage {
display: grid;
gap: var(--sp-3);
}
.toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sp-2);
}
.search {
width: 240px;
}
.who {
width: 190px;
}
.spacer {
flex: 1 1 auto;
}
.summary {
margin: 0;
font-size: var(--fs-xs);
color: var(--muted);
}
.state {
padding: var(--sp-8);
text-align: center;
color: var(--muted);
}
tr.inactive td {
color: var(--muted);
}
tr.inactive .title-cell strong {
color: var(--muted);
}
.title-cell {
display: flex;
align-items: center;
gap: var(--sp-2);
min-width: 220px;
}
.title-cell div {
display: grid;
gap: 2px;
}
.title-cell strong {
font-weight: var(--fw-semibold);
}
.title-cell small {
color: var(--muted);
font-size: var(--fs-xs);
}
.cat-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.who-cell {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.team-ico {
width: 24px;
height: 24px;
border-radius: 50%;
display: grid;
place-items: center;
background: var(--accent-subtle);
color: var(--accent-deep);
}
.nowrap {
white-space: nowrap;
}
td:has(.switch) {
white-space: nowrap;
}
.switch {
width: 34px;
height: 20px;
padding: 2px;
margin-inline-end: 8px;
border: 0;
border-radius: 99px;
background: var(--line-strong);
cursor: pointer;
vertical-align: middle;
transition: background var(--transition-fast);
}
.switch i {
display: block;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
box-shadow: var(--shadow-xs);
transition: transform var(--transition-fast);
}
.switch.on {
background: var(--accent);
}
/* RTL: روشن = دکمه سمت چپ */
.switch.on i {
transform: translateX(-14px);
}
.exec {
display: grid;
grid-template-columns: auto 80px;
align-items: center;
gap: 2px 8px;
min-width: 130px;
}
.exec small {
grid-column: 1 / -1;
color: var(--muted);
font-size: var(--fs-xs);
}
.bar {
height: 6px;
border-radius: 99px;
background: var(--surface-subtle);
overflow: hidden;
}
.bar b {
display: block;
height: 100%;
background: var(--line-strong);
}
.tone-good .bar b {
background: var(--success);
}
.tone-mid .bar b {
background: var(--warning);
}
.tone-low .bar b {
background: var(--danger);
}
:global(.ui-dd-panel button.danger) {
color: var(--danger);
}
.confirm {
margin: 0;
line-height: 1.8;
color: var(--ink-secondary);
}
.form {
display: grid;
gap: var(--sp-4);
}
.cat-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 4px;
}
.cat-list li {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 6px 10px;
border-radius: var(--radius);
background: var(--surface-subtle);
}
.cat-name {
flex: 1 1 auto;
}
.cat-form {
display: grid;
gap: var(--sp-3);
padding-top: var(--sp-3);
border-top: 1px solid var(--line-light);
}
.swatches {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.swatch {
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid var(--surface);
box-shadow: 0 0 0 1px var(--line);
cursor: pointer;
}
.swatch.on {
box-shadow: 0 0 0 2px var(--ink);
}
.error {
margin: 0;
}
@media (max-width: 900px) {
.search,
.who {
width: 100%;
}
.spacer {
display: none;
}
}
</style>
@@ -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>
@@ -0,0 +1,319 @@
<script setup lang="ts">
import { computed, inject, onMounted, ref, watch } from 'vue';
import { RouterLink } from 'vue-router';
import type { RoutineTaskDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import UserAvatar from '@/components/UserAvatar.vue';
import RoutineTaskRow, { type RoutineTaskRowAction } from '@/components/routines/RoutineTaskRow.vue';
import RoutineTaskModals from '@/components/routines/RoutineTaskModals.vue';
import { UiBadge, UiButton, UiEmptyState, UiSelect } from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import {
ENTITY_TYPE_LABELS,
PRIORITY_LABELS,
PRIORITY_VARIANTS,
ROUTINES_CTX,
apiError,
entityRoute,
formatDayNumeric,
formatDelay,
formatTime,
hasExplicitTime,
} from '@/lib/routines';
import { useRoutineTaskActions } from '@/lib/useRoutineTaskActions';
const shell = inject(ROUTINES_CTX)!;
const items = ref<RoutineTaskDto[]>([]);
const loading = ref(true);
const error = ref('');
const who = ref('');
const isManager = computed(() => !!shell.context.value?.isManager);
const users = computed(() => shell.context.value?.users ?? []);
async function load(silent = false) {
if (!silent) loading.value = true;
error.value = '';
try {
const { data } = await api.get<RoutineTaskDto[]>('/routine-tasks/overdue', {
params: who.value ? { userId: who.value } : {},
});
items.value = data;
} catch (e) {
error.value = apiError(e, 'بارگذاری کارهای عقب‌افتاده ممکن نشد.');
} finally {
loading.value = false;
}
}
onMounted(() => load());
watch(who, () => load());
watch(shell.refreshTick, () => load(true));
const actions = useRoutineTaskActions((updated) => {
if (updated.status === 'overdue') {
const i = items.value.findIndex((t) => t.id === updated.id);
if (i >= 0) items.value[i] = updated;
} else {
items.value = items.value.filter((t) => t.id !== updated.id);
}
shell.notifyChanged();
});
function onAction(kind: RoutineTaskRowAction, task: RoutineTaskDto) {
if (kind === 'complete-note') actions.openComplete(task);
else if (kind === 'reschedule') actions.openReschedule(task);
else if (kind === 'move-today') void actions.moveToday(task);
else if (kind === 'skip') actions.openSkip(task);
else if (kind === 'cancel') actions.openCancel(task);
}
const showAssignee = computed(() => isManager.value && who.value !== '');
</script>
<template>
<div class="overdue">
<div class="toolbar">
<p class="lead">
<UiIcon name="warning" :size="16" />
<template v-if="items.length">{{ toFaDigits(items.length) }} کار از مهلتش گذشته است.</template>
<template v-else>کار عقب‌افتاده‌ای ندارید.</template>
</p>
<UiSelect v-if="isManager" v-model="who" size="sm" class="who">
<option value="">کارهای خودم</option>
<option value="all">همه اعضای تیم</option>
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
</UiSelect>
</div>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="actions.error.value" class="error">{{ actions.error.value }}</p>
<div v-if="loading" class="state">در حال بارگذاری…</div>
<UiEmptyState
v-else-if="!items.length"
icon="check"
title="همه چیز به‌روز است"
description="هیچ کاری از مهلتش عقب نیفتاده."
/>
<template v-else>
<!-- دسکتاپ: جدول -->
<div class="crm-table-container desk">
<table class="crm-table">
<thead>
<tr>
<th>کار</th>
<th>مهلت</th>
<th>تأخیر</th>
<th v-if="showAssignee">مسئول</th>
<th>اولویت</th>
<th>پرونده</th>
<th />
</tr>
</thead>
<tbody>
<tr v-for="t in items" :key="t.id" class="clickable" @click="shell.openTask(t.id)">
<td class="title-cell">
<strong>{{ t.title }}</strong>
<small v-if="t.category" class="cat"><i :style="{ background: t.category.color }" />{{ t.category.name }}</small>
</td>
<td class="nowrap">
{{ formatDayNumeric(t.scheduledFor) }}
<small v-if="hasExplicitTime(t)" class="muted">· {{ formatTime(t.dueAt) }}</small>
</td>
<td class="nowrap late">{{ formatDelay(t.dueAt) }}</td>
<td v-if="showAssignee">
<span v-if="t.assignee" class="who-cell">
<UserAvatar :name="t.assignee.fullName" :user-id="t.assignee.id" :size="24" />
{{ t.assignee.fullName }}
</span>
</td>
<td>
<UiBadge size="sm" :variant="PRIORITY_VARIANTS[t.priority]">{{ PRIORITY_LABELS[t.priority] }}</UiBadge>
</td>
<td>
<RouterLink
v-for="ent in t.entities"
:key="ent.id"
:to="entityRoute(ent)"
class="ent"
@click.stop
>
{{ ENTITY_TYPE_LABELS[ent.type] }}: {{ ent.label }}
</RouterLink>
<span v-if="!t.entities.length" class="muted">—</span>
</td>
<td class="actions-cell" @click.stop>
<div class="crm-table-actions">
<UiButton
size="sm"
variant="success"
:loading="actions.busyId.value === t.id"
@click="actions.toggle(t)"
>
انجام شد
</UiButton>
<UiButton size="sm" variant="ghost" @click="actions.moveToday(t)">به امروز</UiButton>
<UiButton size="sm" variant="ghost" @click="actions.openReschedule(t)">تغییر زمان</UiButton>
<UiButton size="sm" variant="ghost" class="danger-text" @click="actions.openCancel(t)">لغو</UiButton>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- موبایل: کارت -->
<div class="mobile rows">
<RoutineTaskRow
v-for="t in items"
:key="t.id"
:task="t"
:busy="actions.busyId.value === t.id"
show-date
show-delay
:show-assignee="showAssignee"
@toggle="actions.toggle"
@open="shell.openTask(t.id)"
@action="onAction"
/>
</div>
</template>
<RoutineTaskModals :actions="actions" />
</div>
</template>
<style scoped>
.overdue {
display: grid;
gap: var(--sp-4);
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--sp-3);
}
.lead {
margin: 0;
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--ink-secondary);
font-size: var(--fs-sm);
}
.lead :deep(svg) {
color: var(--danger);
}
.who {
width: 220px;
}
.state {
padding: var(--sp-8);
text-align: center;
color: var(--muted);
}
.clickable {
cursor: pointer;
}
.title-cell {
display: grid;
gap: 2px;
min-width: 200px;
}
.title-cell strong {
font-weight: var(--fw-semibold);
}
.cat {
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--muted);
}
.cat i {
width: 8px;
height: 8px;
border-radius: 50%;
}
.nowrap {
white-space: nowrap;
}
.late {
color: var(--danger);
font-weight: var(--fw-semibold);
}
.muted {
color: var(--muted);
}
.who-cell {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.ent {
display: block;
color: var(--accent-deep);
font-size: var(--fs-xs);
white-space: nowrap;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
}
.ent:hover {
text-decoration: underline;
}
.danger-text {
color: var(--danger) !important;
}
.rows {
display: grid;
gap: var(--sp-2);
}
.mobile {
display: none;
}
.error {
margin: 0;
}
@media (max-width: 900px) {
.desk {
display: none;
}
.mobile {
display: grid;
}
.who {
width: 100%;
}
}
</style>
@@ -0,0 +1,219 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import type { RoutineUserSettingsDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import { UiButton, UiCard, UiField, UiSelect } from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { apiError } from '@/lib/routines';
const form = reactive({
remind: '30',
endOfDaySummary: true,
summaryHour: '16',
overdueAlert: true,
});
const loading = ref(true);
const saving = ref(false);
const error = ref('');
const saved = ref(false);
const remindOptions = [
{ value: '', label: 'خاموش' },
{ value: '0', label: 'سر ساعت مهلت' },
{ value: '10', label: '۱۰ دقیقه قبل' },
{ value: '15', label: '۱۵ دقیقه قبل' },
{ value: '30', label: '۳۰ دقیقه قبل' },
{ value: '60', label: '۱ ساعت قبل' },
{ value: '120', label: '۲ ساعت قبل' },
];
const hours = Array.from({ length: 12 }, (_, i) => i + 12);
onMounted(async () => {
try {
const { data } = await api.get<RoutineUserSettingsDto>('/routine-tasks/settings');
Object.assign(form, {
remind: data.remindBeforeMinutes == null ? '' : String(data.remindBeforeMinutes),
endOfDaySummary: data.endOfDaySummary,
summaryHour: String(data.summaryHour),
overdueAlert: data.overdueAlert,
});
} catch (e) {
error.value = apiError(e, 'بارگذاری تنظیمات ممکن نشد.');
} finally {
loading.value = false;
}
});
async function save() {
saving.value = true;
error.value = '';
saved.value = false;
try {
await api.put('/routine-tasks/settings', {
remindBeforeMinutes: form.remind === '' ? null : Number(form.remind),
endOfDaySummary: form.endOfDaySummary,
summaryHour: Number(form.summaryHour),
overdueAlert: form.overdueAlert,
});
saved.value = true;
} catch (e) {
error.value = apiError(e, 'ذخیره تنظیمات ممکن نشد.');
} finally {
saving.value = false;
}
}
</script>
<template>
<div class="settings">
<UiCard>
<header class="head">
<span class="ico"><UiIcon name="bell" :size="18" /></span>
<div>
<h3>یادآوری‌ها و اعلان‌های کارهای روتین</h3>
<p>این تنظیمات فقط برای حساب شما اعمال می‌شود. اعلان‌ها در زنگوله بالای صفحه نمایش داده می‌شوند.</p>
</div>
</header>
<div v-if="loading" class="state">در حال بارگذاری…</div>
<form v-else class="form" @submit.prevent="save">
<UiField label="یادآوری قبل از مهلت" hint="برای کارهایی که ساعت مشخص دارند. روتین‌ها ممکن است یادآوری اختصاصی داشته باشند.">
<UiSelect v-model="form.remind">
<option v-for="o in remindOptions" :key="o.value" :value="o.value">{{ o.label }}</option>
</UiSelect>
</UiField>
<label class="tg">
<input v-model="form.overdueAlert" type="checkbox" />
<span>
<strong>هشدار کار اجباری انجام‌نشده</strong>
<small>وقتی مهلت یک کار اجباری بگذرد و هنوز انجام نشده باشد.</small>
</span>
</label>
<label class="tg">
<input v-model="form.endOfDaySummary" type="checkbox" />
<span>
<strong>خلاصه پایان روز</strong>
<small>اگر کاری باقی مانده باشد، یک اعلان خلاصه دریافت می‌کنید؛ مثل «۳ کار تا پایان امروز باقی مانده است».</small>
</span>
</label>
<UiField v-if="form.endOfDaySummary" label="ساعت ارسال خلاصه" class="nested">
<UiSelect v-model="form.summaryHour">
<option v-for="h in hours" :key="h" :value="String(h)">ساعت {{ toFaDigits(h) }}:۰۰</option>
</UiSelect>
</UiField>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="saved" class="ok">تنظیمات ذخیره شد.</p>
<div class="actions">
<UiButton type="submit" :loading="saving">ذخیره تنظیمات</UiButton>
</div>
</form>
</UiCard>
</div>
</template>
<style scoped>
.settings {
max-width: 680px;
}
.head {
display: flex;
align-items: flex-start;
gap: var(--sp-3);
margin-bottom: var(--sp-5);
}
.ico {
width: 38px;
height: 38px;
border-radius: 10px;
display: grid;
place-items: center;
background: var(--accent-subtle);
color: var(--accent-deep);
flex-shrink: 0;
}
.head h3 {
margin: 0;
font-size: var(--fs-md);
font-weight: var(--fw-semibold);
color: var(--ink);
}
.head p {
margin: 4px 0 0;
font-size: var(--fs-sm);
color: var(--muted);
line-height: 1.7;
}
.state {
padding: var(--sp-6);
text-align: center;
color: var(--muted);
}
.form {
display: grid;
gap: var(--sp-4);
}
.tg {
display: flex;
align-items: flex-start;
gap: var(--sp-2);
cursor: pointer;
}
.tg input {
width: 18px;
height: 18px;
margin-top: 2px;
accent-color: var(--accent);
flex-shrink: 0;
}
.tg span {
display: grid;
gap: 2px;
}
.tg strong {
font-size: var(--fs-sm);
font-weight: var(--fw-medium);
color: var(--ink);
}
.tg small {
font-size: var(--fs-xs);
color: var(--muted);
line-height: 1.6;
}
.nested {
margin-inline-start: 26px;
max-width: 260px;
}
.ok {
margin: 0;
color: var(--success-deep);
font-size: var(--fs-sm);
}
.error {
margin: 0;
}
.actions {
display: flex;
}
</style>
@@ -0,0 +1,231 @@
<script setup lang="ts">
import { computed, onMounted, provide, ref, watch } from 'vue';
import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router';
import type { RoutineContextDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import RoutineTaskDrawer from '@/components/routines/RoutineTaskDrawer.vue';
import QuickTaskModal from '@/components/routines/QuickTaskModal.vue';
import { UiButton, UiPageHeader } from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { ROUTINES_CTX, apiError } from '@/lib/routines';
const route = useRoute();
const router = useRouter();
const context = ref<RoutineContextDto | null>(null);
const contextError = ref('');
const refreshTick = ref(0);
const overdueCount = ref(0);
const quickOpen = ref(false);
async function loadContext() {
try {
const { data } = await api.get<RoutineContextDto>('/routines/context');
context.value = data;
contextError.value = '';
} catch (e) {
contextError.value = apiError(e, 'بارگذاری اطلاعات کارهای روتین ممکن نشد.');
}
}
async function loadBadge() {
try {
const { data } = await api.get<{ remaining: number; overdue: number }>('/routine-tasks/badge');
overdueCount.value = data.overdue;
} catch {
overdueCount.value = 0;
}
}
function notifyChanged() {
refreshTick.value += 1;
void loadBadge();
}
function openTask(id: string) {
void router.replace({ query: { ...route.query, task: id } });
}
function closeTask() {
const query = { ...route.query };
delete query.task;
void router.replace({ query });
}
provide(ROUTINES_CTX, { context, refreshTick, notifyChanged, openTask });
onMounted(() => {
void loadContext();
void loadBadge();
});
const taskId = computed(() => (typeof route.query.task === 'string' ? route.query.task : null));
const isManager = computed(() => !!context.value?.isManager);
type Tab = { to: string; name: string; label: string; icon: InstanceType<typeof UiIcon>['$props']['name']; manager?: boolean };
const tabs: Tab[] = [
{ to: '/routines', name: 'routines-today', label: 'کارهای امروز', icon: 'calendar-sun' },
{ to: '/routines/overdue', name: 'routines-overdue', label: 'عقب‌افتاده', icon: 'warning' },
{ to: '/routines/calendar', name: 'routines-calendar', label: 'تقویم', icon: 'calendar' },
{ to: '/routines/list', name: 'routines-list', label: 'روتین‌ها', icon: 'refresh' },
{ to: '/routines/me', name: 'routines-me', label: 'عملکرد من', icon: 'chart' },
{ to: '/routines/team', name: 'routines-team', label: 'عملکرد تیم', icon: 'users', manager: true },
{ to: '/routines/manage', name: 'routines-manage', label: 'مدیریت روتین‌ها', icon: 'sliders', manager: true },
];
const visibleTabs = computed(() => tabs.filter((t) => !t.manager || isManager.value));
const managerOnlyRoutes = new Set(['routines-team', 'routines-manage', 'routine-new', 'routine-edit']);
watch(
[() => route.name, context],
([name, ctx]) => {
if (ctx && !ctx.isManager && managerOnlyRoutes.has(String(name))) {
void router.replace('/routines');
}
},
{ immediate: true },
);
function tabActive(tab: Tab) {
const name = String(route.name ?? '');
if (tab.name === 'routines-manage') return name === 'routines-manage' || name === 'routine-new' || name === 'routine-edit';
return name === tab.name;
}
const hideHeader = computed(() => route.name === 'routine-new' || route.name === 'routine-edit');
</script>
<template>
<section class="crm-page routines-shell">
<template v-if="!hideHeader">
<UiPageHeader title="کارهای روتین" lead="برنامه روزانه، روتین‌های تکرارشونده و پیگیری انجام کارها">
<template #actions>
<RouterLink to="/routines/settings" class="icon-link" title="تنظیمات یادآوری و اعلان‌ها">
<UiIcon name="bell" :size="17" />
</RouterLink>
<UiButton v-if="isManager" variant="outline" @click="router.push('/routines/manage/new')">
<template #icon><UiIcon name="refresh" :size="16" /></template>
روتین جدید
</UiButton>
<UiButton @click="quickOpen = true">
<template #icon><UiIcon name="plus" :size="16" /></template>
کار جدید
</UiButton>
</template>
</UiPageHeader>
<nav class="r-tabs" aria-label="بخش‌های کارهای روتین">
<RouterLink
v-for="tab in visibleTabs"
:key="tab.name"
:to="tab.to"
class="r-tab"
:class="{ on: tabActive(tab) }"
>
<UiIcon :name="tab.icon" :size="15" />
<span>{{ tab.label }}</span>
<em v-if="tab.name === 'routines-overdue' && overdueCount > 0" class="count">
{{ toFaDigits(overdueCount) }}
</em>
</RouterLink>
</nav>
</template>
<p v-if="contextError" class="error">{{ contextError }}</p>
<RouterView v-slot="{ Component }">
<component :is="Component" :key="String(route.name)" />
</RouterView>
<RoutineTaskDrawer :task-id="taskId" @close="closeTask" />
<QuickTaskModal :open="quickOpen" @close="quickOpen = false" />
</section>
</template>
<style scoped>
.routines-shell {
gap: var(--sp-4);
}
.icon-link {
width: var(--control-h);
height: var(--control-h);
display: inline-grid;
place-items: center;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
color: var(--ink-secondary);
flex: 0 0 auto !important;
}
.icon-link:hover,
.icon-link.router-link-active {
border-color: var(--accent);
color: var(--accent-deep);
background: var(--accent-subtle);
}
.r-tabs {
display: flex;
gap: var(--sp-5);
border-bottom: 1px solid var(--line);
overflow-x: auto;
scrollbar-width: none;
}
.r-tabs::-webkit-scrollbar {
display: none;
}
.r-tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0.6rem 0.15rem;
margin-bottom: -1px;
border-bottom: 2px solid transparent;
color: var(--muted-light);
font-weight: var(--fw-bold);
font-size: var(--fs-sm);
white-space: nowrap;
transition: color var(--transition-fast), border-color var(--transition-fast);
}
.r-tab:hover {
color: var(--ink-secondary);
}
.r-tab.on {
color: var(--accent-hover);
border-bottom-color: var(--accent);
}
.count {
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
background: var(--danger);
color: #fff;
font-style: normal;
font-size: 0.68rem;
display: inline-grid;
place-items: center;
}
.error {
margin: 0;
}
@media (max-width: 900px) {
.r-tabs {
gap: var(--sp-4);
margin-inline: calc(-1 * var(--sp-4));
padding-inline: var(--sp-4);
}
}
</style>
@@ -0,0 +1,460 @@
<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 UiIcon from '@/components/UiIcon.vue';
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 { UiButton, UiChart, UiSegmented, UiSelect } from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { categoryBar, membersBar, statusDonut, trendOption } from '@/lib/routineCharts';
import {
ROUTINES_CTX,
addDaysKey,
apiError,
formatPercent,
percentTone,
todayKey,
} from '@/lib/routines';
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 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 trendOpt = computed(() => trendOption(d.value?.trend ?? []));
const donutOpt = computed(() =>
statusDonut(d.value?.status ?? { completed: 0, notDone: 0, overdue: 0, skipped: 0, cancelled: 0 }),
);
const membersOpt = computed(() => membersBar(d.value?.members ?? []));
const catOpt = computed(() => categoryBar(d.value?.byCategory ?? []));
const membersChartHeight = computed(() => `${Math.max(200, activeMembers.value * 34 + 40)}px`);
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,
),
);
</script>
<template>
<div class="team">
<UiSegmented v-model="section" :options="sections" class="sections" />
<!-- فیلترها -->
<section class="filters">
<div v-if="section === 'overview' || section === 'routines'" class="f-item date">
<label>از</label>
<JalaliDateInput v-model="f.from" mode="date" :clearable="false" />
</div>
<div v-if="section === 'overview' || section === 'routines'" class="f-item date">
<label>تا</label>
<JalaliDateInput v-model="f.to" mode="date" :clearable="false" />
</div>
<div class="f-item">
<label>کاربر</label>
<UiSelect v-model="f.userId" size="sm">
<option value="">همه کاربران</option>
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
</UiSelect>
</div>
<div v-if="teams.length" class="f-item">
<label>تیم</label>
<UiSelect v-model="f.teamId" size="sm">
<option value="">همه تیم‌ها</option>
<option v-for="t in teams" :key="t.id" :value="t.id">{{ t.name }}</option>
</UiSelect>
</div>
<div class="f-item">
<label>دسته‌بندی</label>
<UiSelect v-model="f.categoryId" size="sm">
<option value="">همه دسته‌ها</option>
<option v-for="c in categories" :key="c.id" :value="c.id">{{ c.name }}</option>
</UiSelect>
</div>
<div v-if="section !== 'routines'" class="f-item">
<label>نوع کار</label>
<UiSelect v-model="f.source" size="sm">
<option value="">همه</option>
<option v-for="[k, label] in sources" :key="k" :value="k">{{ label }}</option>
</UiSelect>
</div>
<div v-if="section === 'overview'" class="f-item">
<label>وضعیت</label>
<UiSelect v-model="f.status" size="sm">
<option value="">همه</option>
<option v-for="[k, label] in statuses" :key="k" :value="k">{{ label }}</option>
</UiSelect>
</div>
<UiButton v-if="hasFilters" variant="ghost" size="sm" class="reset" @click="resetFilters">
<template #icon><UiIcon name="x" :size="14" /></template>
حذف فیلترها
</UiButton>
</section>
<p v-if="error" class="error">{{ error }}</p>
<!-- نمای کلی -->
<template v-if="section === 'overview'">
<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?.overdue ?? 0)" icon="warning" tone="danger" />
<KpiCard
label="اعضای فعال"
:value="toFaDigits(activeMembers)"
:hint="`از ${toFaDigits(d?.members.length ?? 0)} نفر`"
icon="users"
tone="info"
/>
</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">
<h3>مقایسه اعضا</h3>
<UiChart v-if="activeMembers" :option="membersOpt" :height="membersChartHeight" :loading="loading" />
<p v-else class="muted">داده‌ای برای نمایش نیست.</p>
</section>
<section class="card wide">
<h3>انجام به تفکیک دسته‌بندی</h3>
<UiChart v-if="d?.byCategory.length" :option="catOpt" height="240px" :loading="loading" />
<p v-else class="muted">داده‌ای برای نمایش نیست.</p>
</section>
</div>
<section class="card">
<h3>عملکرد اعضا</h3>
<TeamPerformanceTable :members="d?.members ?? []" @select="(id) => (f.userId = id)" />
</section>
</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"
/>
<!-- گزارش اجرای روتین‌ها -->
<section v-else class="card">
<div class="exec-head">
<h3>گزارش اجرای روتین‌ها</h3>
<UiSelect v-model="execSort" size="sm" class="exec-sort">
<option value="percent">مرتب‌سازی: کمترین اجرا</option>
<option value="occurrences">مرتب‌سازی: بیشترین تکرار</option>
</UiSelect>
</div>
<div class="crm-table-container" :class="{ dim: loading }">
<table class="crm-table">
<thead>
<tr>
<th>روتین</th>
<th>مسئول</th>
<th>تکرار</th>
<th>دفعات</th>
<th>انجام شده</th>
<th>انجام نشده</th>
<th>رد شده</th>
<th>درصد اجرا</th>
</tr>
</thead>
<tbody>
<tr v-for="r in execRows" :key="r.routineId">
<td><strong>{{ r.title }}</strong></td>
<td class="nowrap">{{ r.assigneeLabel }}</td>
<td class="nowrap">{{ r.recurrenceLabel }}</td>
<td>{{ toFaDigits(r.occurrences) }}</td>
<td class="ok">{{ toFaDigits(r.completed) }}</td>
<td :class="{ bad: r.notDone > 0 }">{{ toFaDigits(r.notDone) }}</td>
<td>{{ toFaDigits(r.skipped) }}</td>
<td>
<span class="pct" :class="`tone-${percentTone(r.percent)}`">
<strong>{{ formatPercent(r.percent) }}</strong>
<i><b :style="{ width: `${r.percent ?? 0}%` }" /></i>
</span>
</td>
</tr>
<tr v-if="!execRows.length && !loading">
<td colspan="8" class="empty">در این بازه روتینی اجرا نشده است.</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</template>
<style scoped>
.team {
display: grid;
gap: var(--sp-4);
}
.sections {
justify-self: start;
max-width: 100%;
overflow-x: auto;
}
.filters {
display: flex;
align-items: flex-end;
flex-wrap: wrap;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
}
.f-item {
display: grid;
gap: 4px;
min-width: 150px;
}
.f-item.date {
min-width: 160px;
}
.f-item label {
font-size: var(--fs-xs);
color: var(--muted);
}
.reset {
margin-inline-start: auto;
}
.kpis {
display: grid;
grid-template-columns: repeat(4, 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 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);
}
.exec-head {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--sp-2);
}
.exec-sort {
width: 220px;
}
.nowrap {
white-space: nowrap;
}
.ok {
color: var(--success-deep);
}
.bad {
color: var(--danger);
font-weight: var(--fw-semibold);
}
.pct {
display: grid;
grid-template-columns: 44px 80px;
align-items: center;
gap: 8px;
}
.pct i {
height: 6px;
border-radius: 99px;
background: var(--surface-subtle);
overflow: hidden;
}
.pct b {
display: block;
height: 100%;
background: var(--line-strong);
}
.tone-good b {
background: var(--success);
}
.tone-mid b {
background: var(--warning);
}
.tone-low b {
background: var(--danger);
}
.empty {
text-align: center;
color: var(--muted);
padding: var(--sp-6);
}
.error {
margin: 0;
}
@media (max-width: 1000px) {
.charts {
grid-template-columns: 1fr;
}
.kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-2);
}
}
@media (max-width: 700px) {
.f-item,
.f-item.date {
min-width: calc(50% - var(--sp-3));
flex: 1 1 calc(50% - var(--sp-3));
}
.exec-sort {
width: 100%;
}
}
</style>
@@ -0,0 +1,529 @@
<script setup lang="ts">
import { computed, inject, onMounted, ref, watch } from 'vue';
import { RouterLink } from 'vue-router';
import type { RoutineCalendarDto, RoutineTaskDto, RoutineTodayDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import KpiCard from '@/components/routines/KpiCard.vue';
import DailyScoreCard from '@/components/routines/DailyScoreCard.vue';
import RoutineTaskRow, { type RoutineTaskRowAction } from '@/components/routines/RoutineTaskRow.vue';
import RoutineTaskModals from '@/components/routines/RoutineTaskModals.vue';
import { UiEmptyState, UiSegmented, UiSelect } from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import {
ROUTINES_CTX,
addDaysKey,
apiError,
formatDayLong,
formatPercent,
isOpenStatus,
todayKey,
} from '@/lib/routines';
import { useRoutineTaskActions } from '@/lib/useRoutineTaskActions';
import { useAuthStore } from '@/stores/auth';
const shell = inject(ROUTINES_CTX)!;
const auth = useAuthStore();
const data = ref<RoutineTodayDto | null>(null);
const recent = ref<RoutineCalendarDto['days']>([]);
const loading = ref(true);
const error = ref('');
const userId = ref('');
const filter = ref<'all' | 'open' | 'done'>('all');
const viewingSelf = computed(() => !userId.value || userId.value === auth.user?.id);
async function load(silent = false) {
if (!silent) loading.value = true;
error.value = '';
const params = userId.value ? { userId: userId.value } : {};
try {
const today = todayKey();
const [todayRes, calRes] = await Promise.all([
api.get<RoutineTodayDto>('/routine-tasks/today', { params }),
api.get<RoutineCalendarDto>('/routine-tasks/calendar', {
params: { ...params, from: addDaysKey(today, -13), to: today },
}),
]);
data.value = todayRes.data;
recent.value = calRes.data.days;
} catch (e) {
error.value = apiError(e, 'بارگذاری کارهای امروز ممکن نشد.');
} finally {
loading.value = false;
}
}
onMounted(() => load());
watch(userId, () => load());
watch(shell.refreshTick, () => load(true));
function applyLocal(updated: RoutineTaskDto) {
const d = data.value;
if (!d) return;
for (const list of [d.tasks, d.overdue]) {
const i = list.findIndex((t) => t.id === updated.id);
if (i >= 0) list[i] = updated;
}
}
const actions = useRoutineTaskActions((updated) => {
applyLocal(updated);
shell.notifyChanged();
});
function onAction(kind: RoutineTaskRowAction, task: RoutineTaskDto) {
if (kind === 'start') void actions.start(task);
else if (kind === 'complete-note') actions.openComplete(task);
else if (kind === 'reschedule') actions.openReschedule(task);
else if (kind === 'move-today') void actions.moveToday(task);
else if (kind === 'skip') actions.openSkip(task);
else actions.openCancel(task);
}
const stats = computed(() => data.value?.stats ?? { total: 0, completed: 0, remaining: 0, overdue: 0, percent: null });
const tasks = computed(() => {
const list = data.value?.tasks ?? [];
if (filter.value === 'open') return list.filter((t) => isOpenStatus(t.status));
if (filter.value === 'done') return list.filter((t) => !isOpenStatus(t.status));
return list;
});
const openTasks = computed(() => tasks.value.filter((t) => isOpenStatus(t.status)));
const closedTasks = computed(() => tasks.value.filter((t) => !isOpenStatus(t.status)));
const progressPct = computed(() => stats.value.percent ?? 0);
const filterOptions = computed(() => [
{ value: 'all' as const, label: `همه (${toFaDigits(data.value?.tasks.length ?? 0)})` },
{ value: 'open' as const, label: 'باقی‌مانده' },
{ value: 'done' as const, label: 'انجام‌شده' },
]);
const users = computed(() => shell.context.value?.users ?? []);
const showUserPicker = computed(() => !!shell.context.value?.isManager && users.value.length > 1);
const greeting = computed(() => {
if (!viewingSelf.value) {
const u = users.value.find((x) => x.id === userId.value);
return u ? `کارهای امروز ${u.fullName}` : 'کارهای امروز';
}
return 'امروز چه کارهایی داری؟';
});
</script>
<template>
<div class="today">
<!-- سربرگ روز -->
<section class="day-hero">
<div class="hero-copy">
<h2>کارهای امروز</h2>
<p class="sub">{{ greeting }}</p>
<p class="date">
<UiIcon name="calendar" :size="14" />
{{ formatDayLong(data?.date ?? todayKey()) }}
</p>
</div>
<div class="hero-progress" :aria-label="`${stats.completed} از ${stats.total} کار انجام شده`">
<div class="ring" :style="{ '--p': progressPct }">
<span>{{ formatPercent(stats.percent) }}</span>
</div>
<div class="progress-copy">
<strong>{{ toFaDigits(stats.completed) }} از {{ toFaDigits(stats.total) }}</strong>
<span>کار امروز انجام شده</span>
<i class="pbar"><b :style="{ width: `${progressPct}%` }" /></i>
</div>
</div>
<div v-if="showUserPicker" class="hero-user">
<UiSelect v-model="userId" size="sm">
<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>
</div>
</section>
<!-- کارت‌های آماری -->
<section class="stat-grid">
<KpiCard label="انجام شده" :value="toFaDigits(stats.completed)" icon="check" tone="success" />
<KpiCard label="باقی‌مانده" :value="toFaDigits(stats.remaining)" icon="clock" tone="info" />
<KpiCard
label="عقب‌افتاده"
:value="toFaDigits(stats.overdue)"
icon="warning"
:tone="stats.overdue ? 'danger' : 'neutral'"
/>
<KpiCard label="درصد تکمیل" :value="formatPercent(stats.percent)" icon="target" tone="accent" />
</section>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="actions.error.value" class="error">{{ actions.error.value }}</p>
<div class="layout">
<section class="main">
<div class="list-head">
<UiSegmented v-model="filter" :options="filterOptions" />
</div>
<div v-if="loading" class="skeleton">
<span v-for="i in 4" :key="i" />
</div>
<UiEmptyState
v-else-if="!data?.tasks.length"
icon="calendar-sun"
title="برای امروز کاری ثبت نشده"
description="روتین‌های فعال به‌صورت خودکار هر روز کار می‌سازند. می‌توانید یک کار دستی هم اضافه کنید."
/>
<template v-else>
<div v-if="openTasks.length" class="group">
<h3 class="group-title">باقی‌مانده <em>{{ toFaDigits(openTasks.length) }}</em></h3>
<TransitionGroup name="rt-list" tag="div" class="rows">
<RoutineTaskRow
v-for="t in openTasks"
:key="t.id"
:task="t"
:busy="actions.busyId.value === t.id"
show-delay
@toggle="actions.toggle"
@open="shell.openTask(t.id)"
@action="onAction"
/>
</TransitionGroup>
</div>
<div v-else-if="filter !== 'done'" class="all-done">
<UiIcon name="check" :size="20" />
همه کارهای امروز انجام شد.
</div>
<div v-if="closedTasks.length" class="group">
<h3 class="group-title">انجام‌شده / بسته <em>{{ toFaDigits(closedTasks.length) }}</em></h3>
<TransitionGroup name="rt-list" tag="div" class="rows">
<RoutineTaskRow
v-for="t in closedTasks"
:key="t.id"
:task="t"
:busy="actions.busyId.value === t.id"
@toggle="actions.toggle"
@open="shell.openTask(t.id)"
@action="onAction"
/>
</TransitionGroup>
</div>
</template>
</section>
<aside class="side">
<DailyScoreCard :score="data?.score ?? null" :days="recent" />
<section v-if="data?.overdue.length" class="overdue-card">
<header>
<h3>
<UiIcon name="warning" :size="15" />
عقب‌افتاده از روزهای قبل
</h3>
<RouterLink to="/routines/overdue">همه ({{ toFaDigits(data.overdue.length) }})</RouterLink>
</header>
<div class="rows">
<RoutineTaskRow
v-for="t in data.overdue.slice(0, 4)"
:key="t.id"
:task="t"
:busy="actions.busyId.value === t.id"
show-date
@toggle="actions.toggle"
@open="shell.openTask(t.id)"
@action="onAction"
/>
</div>
</section>
</aside>
</div>
<RoutineTaskModals :actions="actions" />
</div>
</template>
<style scoped>
.today {
display: grid;
gap: var(--sp-4);
}
.day-hero {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--sp-4);
padding: var(--sp-5);
border-radius: var(--radius);
border: 1px solid color-mix(in srgb, var(--accent) 22%, var(--line));
background: linear-gradient(135deg, var(--accent-subtle), var(--surface) 70%);
}
.hero-copy h2 {
margin: 0;
font-size: var(--fs-xl);
font-weight: var(--fw-bold);
color: var(--ink);
}
.sub {
margin: 4px 0 0;
color: var(--ink-secondary);
font-size: var(--fs-base);
}
.date {
margin: 8px 0 0;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: var(--fs-sm);
color: var(--accent-deep);
font-weight: var(--fw-semibold);
}
.hero-progress {
display: flex;
align-items: center;
gap: var(--sp-4);
}
.ring {
--p: 0;
width: 76px;
height: 76px;
border-radius: 50%;
display: grid;
place-items: center;
background: conic-gradient(var(--accent) calc(var(--p) * 1%), var(--line-light) 0);
flex-shrink: 0;
}
.ring span {
width: 60px;
height: 60px;
border-radius: 50%;
background: var(--surface);
display: grid;
place-items: center;
font-weight: var(--fw-bold);
font-size: var(--fs-md);
color: var(--accent-deep);
}
.progress-copy {
display: grid;
gap: 4px;
min-width: 160px;
}
.progress-copy strong {
font-size: var(--fs-xl);
color: var(--ink);
}
.progress-copy span {
font-size: var(--fs-sm);
color: var(--muted);
}
.pbar {
height: 6px;
border-radius: 99px;
background: var(--line-light);
overflow: hidden;
}
.pbar b {
display: block;
height: 100%;
background: var(--accent);
border-radius: inherit;
transition: width 0.4s ease;
}
.hero-user {
width: 200px;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--sp-3);
}
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: var(--sp-4);
align-items: start;
}
.main {
display: grid;
gap: var(--sp-4);
min-width: 0;
}
.list-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
}
.group {
display: grid;
gap: var(--sp-2);
}
.group-title {
margin: 0;
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
color: var(--ink-secondary);
display: flex;
align-items: center;
gap: 6px;
}
.group-title em {
font-style: normal;
color: var(--muted);
font-weight: var(--fw-normal);
}
.rows {
position: relative;
display: grid;
gap: var(--sp-2);
}
.all-done {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-4);
border-radius: var(--radius);
background: var(--success-soft);
color: var(--success-deep);
font-weight: var(--fw-semibold);
}
.side {
display: grid;
gap: var(--sp-4);
position: sticky;
top: var(--sp-4);
}
.overdue-card {
display: grid;
gap: var(--sp-3);
padding: var(--sp-4);
border: 1px solid color-mix(in srgb, var(--danger) 20%, var(--line));
border-radius: var(--radius);
background: var(--surface);
}
.overdue-card header {
display: flex;
align-items: center;
justify-content: space-between;
}
.overdue-card h3 {
margin: 0;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: var(--fs-sm);
color: var(--danger);
}
.overdue-card a {
font-size: var(--fs-xs);
color: var(--accent-deep);
font-weight: var(--fw-semibold);
}
.skeleton {
display: grid;
gap: var(--sp-2);
}
.skeleton span {
height: 72px;
border-radius: var(--radius);
background: linear-gradient(90deg, var(--surface-subtle), var(--line-light), var(--surface-subtle));
background-size: 200% 100%;
animation: sk 1.2s ease-in-out infinite;
}
@keyframes sk {
to {
background-position: -200% 0;
}
}
.rt-list-move,
.rt-list-enter-active,
.rt-list-leave-active {
transition: all 0.25s ease;
}
.rt-list-enter-from,
.rt-list-leave-to {
opacity: 0;
transform: translateY(6px);
}
.rt-list-leave-active {
position: absolute;
width: 100%;
}
.error {
margin: 0;
}
@media (max-width: 1100px) {
.layout {
grid-template-columns: 1fr;
}
.side {
position: static;
}
}
@media (max-width: 900px) {
.day-hero {
padding: var(--sp-4);
}
.hero-progress {
width: 100%;
}
.progress-copy {
flex: 1 1 auto;
}
.hero-user {
width: 100%;
}
.stat-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-2);
}
}
</style>