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:
@@ -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>
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user