Files
CRM_Montzar/apps/web/src/views/routines/RoutineFormView.vue
T
Mahdi 3db9c1c0fe merge: bring latest origin/main into dev (design local, logic main)
Keep the local Ant screens and icon pack. Take treasury parties, routine self-service, Tahesab auto-sync, public lead registration, and contact merge from main.
2026-11-05 15:10:48 +03:30

539 lines
21 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, inject, onMounted, reactive, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import {
Alert as AAlert,
Breadcrumb as ABreadcrumb,
BreadcrumbItem as ABreadcrumbItem,
Button as AButton,
Card as ACard,
Checkbox as ACheckbox,
Col as ACol,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Input as AInput,
InputNumber as AInputNumber,
RadioButton as ARadioButton,
RadioGroup as ARadioGroup,
Row as ARow,
Select as ASelect,
Space as ASpace,
Textarea as ATextarea,
Typography,
} from 'ant-design-vue';
import type { ActivityPriority, RoutineDto, RoutineEntityLink, SaveRoutineRequest } from '@mento/shared';
import { ROUTINE_ENTITY_LINK_LABELS, ROUTINE_WEEKDAY_LABELS } from '@mento/shared';
import { CrmSkeleton, CrmSkeletonCard } from '@/components/skeleton';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import RecurrencePicker, { type RecurrenceValue } from '@/components/routines/RecurrencePicker.vue';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { PRIORITY_LABELS, ROUTINES_CTX, apiError, formatDayNumeric, todayKey } from '@/lib/routines';
import { useAuthStore } from '@/stores/auth';
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
Typography;
const shell = inject(ROUTINES_CTX)!;
const auth = useAuthStore();
const route = useRoute();
const router = useRouter();
const isManager = computed(() => !!shell.context.value?.isManager);
const selfId = computed(() => auth.user?.id ?? '');
const backTo = computed(() => (isManager.value ? '/routines/manage' : '/routines/list'));
const id = computed(() => (typeof route.params.id === 'string' ? route.params.id : null));
const isEdit = computed(() => !!id.value);
const pageTitle = computed(() => (isEdit.value ? 'ویرایش روتین' : 'روتین جدید'));
const form = reactive({
title: '',
description: '',
target: 'user' as 'user' | 'team',
assigneeId: '',
teamId: '',
categoryId: '',
priority: 'medium' as ActivityPriority,
timeOfDay: '09:00',
durationMinutes: null as number | null,
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 loaded = ref<RoutineDto | null>(null);
const foreign = ref(false);
const priorities = Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>;
const entityLinks = Object.entries(ROUTINE_ENTITY_LINK_LABELS) as Array<[RoutineEntityLink, string]>;
const categoryOptions = computed(() => [
{ value: '', label: 'بدون دسته' },
...categories.value.map((c) => ({ value: c.id, label: c.name })),
]);
const priorityOptions = priorities.map(([value, label]) => ({ value, label }));
const userOptions = computed(() =>
users.value.map((u) => ({ value: u.id, label: u.fullName })),
);
const teamOptions = computed(() =>
teams.value.map((t) => ({ value: t.id, label: t.name })),
);
const entityLinkOptions = entityLinks.map(([value, label]) => ({ value, label }));
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 ?? null,
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) {
if (selfId.value) form.assigneeId = selfId.value;
return;
}
loading.value = true;
try {
const { data } = await api.get<RoutineDto>(`/routines/${id.value}`);
loaded.value = data;
fill(data);
} catch (e) {
error.value = apiError(e, 'روتین پیدا نشد.');
} finally {
loading.value = false;
}
});
watch(
[() => shell.context.value, loaded, selfId],
() => {
const ctx = shell.context.value;
const data = loaded.value;
if (!ctx || !data || ctx.isManager) {
foreign.value = false;
return;
}
foreign.value = data.createdById !== selfId.value;
if (foreign.value) {
error.value = 'این روتین را مدیر تعریف کرده و فقط خودش می‌تواند تغییرش دهد.';
}
},
{ immediate: true },
);
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 (!isManager.value) {
form.target = 'user';
form.assigneeId = selfId.value;
form.teamId = '';
}
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(backTo.value);
} catch (e) {
error.value = apiError(e, 'ذخیره روتین ممکن نشد.');
} finally {
saving.value = false;
}
}
</script>
<template>
<a-flex vertical :gap="16">
<a-flex vertical :gap="4">
<a-breadcrumb>
<a-breadcrumb-item>
<RouterLink :to="backTo">{{ isManager ? 'مدیریت روتین‌ها' : 'روتین‌های من' }}</RouterLink>
</a-breadcrumb-item>
<a-breadcrumb-item>{{ pageTitle }}</a-breadcrumb-item>
</a-breadcrumb>
<a-typography-title :level="3" :style="{ margin: 0 }">
{{ pageTitle }}
</a-typography-title>
<a-typography-text type="secondary">
کاری که باید به‌صورت منظم انجام شود؛ هر روزِ تکرار، یک کار برای مسئول ساخته می‌شود.
</a-typography-text>
</a-flex>
<a-alert v-if="foreign" type="error" show-icon :message="error" />
<CrmSkeleton v-if="!foreign" :loading="loading">
<CrmSkeletonCard :rows="8">
<a-form layout="vertical" @finish="save">
<a-row :gutter="[16, 16]">
<a-col :xs="24" :lg="16">
<a-flex vertical :gap="16">
<a-card :bordered="false" title="اطلاعات پایه">
<a-row :gutter="[16, 0]">
<a-col :span="24">
<a-form-item label="عنوان" required>
<a-input
v-model:value="form.title"
placeholder="مثلاً: تماس با لیدهای جدید"
:maxlength="200"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="توضیحات">
<a-textarea
v-model:value="form.description"
:rows="3"
placeholder="جزئیات یا دستورالعمل انجام کار"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="دسته‌بندی">
<a-select
v-model:value="form.categoryId"
:options="categoryOptions"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="اولویت">
<a-select
v-model:value="form.priority"
:options="priorityOptions"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
</a-card>
<a-card :bordered="false" title="مسئول">
<a-typography-text v-if="!isManager" type="secondary">
این روتین فقط برای خودتان ساخته می‌شود.
</a-typography-text>
<template v-else>
<a-form-item>
<a-radio-group v-model:value="form.target" button-style="solid">
<a-radio-button value="user">یک کاربر</a-radio-button>
<a-radio-button value="team">همه اعضای یک تیم</a-radio-button>
</a-radio-group>
</a-form-item>
<a-form-item v-if="form.target === 'user'" label="کاربر" required>
<a-select
v-model:value="form.assigneeId"
:options="userOptions"
placeholder="انتخاب کنید"
show-search
option-filter-prop="label"
style="width: 100%"
/>
</a-form-item>
<a-form-item
v-else
label="تیم"
required
extra="برای هر عضو فعال تیم یک کار جداگانه ساخته می‌شود."
>
<a-select
v-model:value="form.teamId"
:options="teamOptions"
placeholder="انتخاب کنید"
show-search
option-filter-prop="label"
style="width: 100%"
/>
</a-form-item>
</template>
</a-card>
<a-card :bordered="false" title="زمان‌بندی و تکرار">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="ساعت انجام" required>
<JalaliDateInput v-model="form.timeOfDay" mode="time" :clearable="false" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="مدت تقریبی (دقیقه)">
<a-input-number
:value="form.durationMinutes ?? undefined"
:min="1"
:precision="0"
placeholder="اختیاری"
style="width: 100%"
@update:value="(v: number | string | null) => { form.durationMinutes = typeof v === 'number' && v > 0 ? Math.floor(v) : null }"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="تاریخ شروع" required>
<JalaliDateInput v-model="form.startDate" mode="date" :clearable="false" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="تاریخ پایان" extra="خالی = بدون پایان">
<JalaliDateInput v-model="form.endDate" mode="date" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="الگوی تکرار" required>
<RecurrencePicker v-model="recurrence" />
</a-form-item>
</a-col>
</a-row>
</a-card>
</a-flex>
</a-col>
<a-col :xs="24" :lg="8">
<a-flex vertical :gap="16">
<a-card :bordered="false" title="قوانین انجام">
<a-flex vertical :gap="12">
<a-checkbox v-model:checked="form.isMandatory">
<a-flex vertical :gap="0">
<a-typography-text>اجباری</a-typography-text>
<a-typography-text type="secondary">
در امتیاز روزانه و روزهای پیاپی کامل حساب می‌شود.
</a-typography-text>
</a-flex>
</a-checkbox>
<a-checkbox v-model:checked="form.allowSkip">
<a-flex vertical :gap="0">
<a-typography-text>امکان رد کردن</a-typography-text>
<a-typography-text type="secondary">
کاربر می‌تواند این کار را برای یک روز رد کند.
</a-typography-text>
</a-flex>
</a-checkbox>
<a-checkbox
v-if="form.allowSkip"
v-model:checked="form.skipReasonRequired"
:style="{ marginInlineStart: '24px' }"
>
<a-typography-text>ثبت دلیل رد کردن الزامی باشد</a-typography-text>
</a-checkbox>
<a-checkbox v-model:checked="form.reportRequired">
<a-flex vertical :gap="0">
<a-typography-text>گزارش نتیجه الزامی</a-typography-text>
<a-typography-text type="secondary">
قبل از انجام‌شدن، کاربر باید نتیجه را بنویسد.
</a-typography-text>
</a-flex>
</a-checkbox>
<a-checkbox v-model:checked="form.allowAfterDeadline">
<a-flex vertical :gap="0">
<a-typography-text>انجام بعد از مهلت مجاز باشد</a-typography-text>
<a-typography-text type="secondary">
اگر خاموش باشد، کار عقب‌افتاده فقط توسط مدیر بسته می‌شود.
</a-typography-text>
</a-flex>
</a-checkbox>
<a-checkbox v-model:checked="form.carryOverOverdue">
<a-flex vertical :gap="0">
<a-typography-text>انتقال به روز بعد</a-typography-text>
<a-typography-text type="secondary">
کار انجام‌نشده در فهرست «کارهای امروز» روز بعد هم نمایش داده می‌شود.
</a-typography-text>
</a-flex>
</a-checkbox>
</a-flex>
<a-form-item label="اتصال به پرونده" :style="{ marginTop: '16px', marginBottom: 0 }">
<a-select
v-model:value="form.entityLink"
:options="entityLinkOptions"
style="width: 100%"
/>
</a-form-item>
</a-card>
<a-card :bordered="false" title="یادآوری و وضعیت">
<a-form-item label="یادآوری">
<a-select
v-model:value="form.reminderMinutesBefore"
:options="reminderOptions"
style="width: 100%"
/>
</a-form-item>
<a-checkbox v-model:checked="form.isActive">
<a-typography-text>روتین فعال است</a-typography-text>
</a-checkbox>
</a-card>
<a-alert type="info" show-icon>
<template #message>خلاصه</template>
<template #description>
<a-typography-paragraph :style="{ marginBottom: 0 }">
{{ summary }}
</a-typography-paragraph>
</template>
</a-alert>
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-space direction="vertical" style="width: 100%">
<a-button type="primary" html-type="submit" block :loading="saving">
{{ isEdit ? 'ذخیره تغییرات' : 'ایجاد روتین' }}
</a-button>
<a-button block :disabled="saving" @click="router.push(backTo)">
انصراف
</a-button>
</a-space>
</a-flex>
</a-col>
</a-row>
</a-form>
</CrmSkeletonCard>
</CrmSkeleton>
</a-flex>
</template>