2026-10-06 19:54:38 +03:30
< script setup lang = "ts" >
2026-11-04 19:34:36 +03:30
import { computed , inject , onMounted , reactive , ref , watch } from 'vue' ;
2026-11-05 14:46:35 +03:30
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' ;
2026-10-06 19:54:38 +03:30
import type { ActivityPriority , RoutineDto , RoutineEntityLink , SaveRoutineRequest } from '@mento/shared' ;
import { ROUTINE_ENTITY_LINK_LABELS , ROUTINE_WEEKDAY_LABELS } from '@mento/shared' ;
2026-11-05 14:46:35 +03:30
import { CrmSkeleton , CrmSkeletonCard } from '@/components/skeleton' ;
2026-10-06 19:54:38 +03:30
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' ;
2026-11-04 19:34:36 +03:30
import { useAuthStore } from '@/stores/auth' ;
2026-10-06 19:54:38 +03:30
2026-11-05 14:46:35 +03:30
const { Title : ATypographyTitle , Text : ATypographyText , Paragraph : ATypographyParagraph } =
Typography ;
2026-10-06 19:54:38 +03:30
const shell = inject ( ROUTINES_CTX ) ! ;
2026-11-04 19:34:36 +03:30
const auth = useAuthStore ();
2026-10-06 19:54:38 +03:30
const route = useRoute ();
const router = useRouter ();
2026-11-04 19:34:36 +03:30
const isManager = computed (() => !! shell . context . value ? . isManager );
const selfId = computed (() => auth . user ? . id ?? '' );
const backTo = computed (() => ( isManager . value ? '/routines/manage' : '/routines/list' ));
2026-10-06 19:54:38 +03:30
const id = computed (() => ( typeof route . params . id === 'string' ? route . params . id : null ));
const isEdit = computed (() => !! id . value );
2026-11-05 14:46:35 +03:30
const pageTitle = computed (() => ( isEdit . value ? 'ویرایش روتین' : 'روتین جدید' ));
2026-10-06 19:54:38 +03:30
const form = reactive ({
title : '' ,
description : '' ,
target : 'user' as 'user' | 'team' ,
assigneeId : '' ,
teamId : '' ,
categoryId : '' ,
priority : 'medium' as ActivityPriority ,
timeOfDay : '09:00' ,
2026-11-05 14:46:35 +03:30
durationMinutes : null as number | null ,
2026-10-06 19:54:38 +03:30
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 ( '' );
2026-11-04 19:34:36 +03:30
const loaded = ref < RoutineDto | null >( null );
const foreign = ref ( false );
2026-10-06 19:54:38 +03:30
const priorities = Object . entries ( PRIORITY_LABELS ) as Array < [ ActivityPriority , string ] > ;
const entityLinks = Object . entries ( ROUTINE_ENTITY_LINK_LABELS ) as Array < [ RoutineEntityLink , string ] > ;
2026-11-05 14:46:35 +03:30
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 }));
2026-10-06 19:54:38 +03:30
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 ,
2026-11-05 14:46:35 +03:30
durationMinutes : r . durationMinutes ?? null ,
2026-10-06 19:54:38 +03:30
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 () => {
2026-11-04 19:34:36 +03:30
if ( ! id . value ) {
if ( selfId . value ) form . assigneeId = selfId . value ;
return ;
}
2026-10-06 19:54:38 +03:30
loading . value = true ;
try {
const { data } = await api . get < RoutineDto >( `/routines/ ${ id . value } ` );
2026-11-04 19:34:36 +03:30
loaded . value = data ;
2026-10-06 19:54:38 +03:30
fill ( data );
} catch ( e ) {
error . value = apiError ( e , 'روتین پیدا نشد.' );
} finally {
loading . value = false ;
}
});
2026-11-04 19:34:36 +03:30
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 },
);
2026-10-06 19:54:38 +03:30
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 {
2026-11-04 19:34:36 +03:30
if ( ! isManager . value ) {
form . target = 'user' ;
form . assigneeId = selfId . value ;
form . teamId = '' ;
}
2026-10-06 19:54:38 +03:30
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 ();
2026-11-04 19:34:36 +03:30
void router . push ( backTo . value );
2026-10-06 19:54:38 +03:30
} catch ( e ) {
error . value = apiError ( e , 'ذخیره روتین ممکن نشد.' );
} finally {
saving . value = false ;
}
}
</ script >
< template >
2026-11-05 14:46:35 +03:30
< a-flex vertical :gap = "16" >
< a-flex vertical :gap = "4" >
< a-breadcrumb >
< a-breadcrumb-item >
2026-11-05 15:10:48 +03:30
< RouterLink :to = "backTo" >{{ isManager ? 'مدیریت روتینها' : 'روتینهای من' }}</ RouterLink >
2026-11-05 14:46:35 +03:30
</ 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 >
2026-10-06 19:54:38 +03:30
2026-11-05 15:10:48 +03:30
< a-alert v-if = "foreign" type="error" show-icon :message="error" />
2026-10-06 19:54:38 +03:30
2026-11-05 15:10:48 +03:30
< CrmSkeleton v-if = "!foreign" :loading="loading" >
2026-11-05 14:46:35 +03:30
< 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 >
2026-10-06 19:54:38 +03:30
2026-11-05 14:46:35 +03:30
< a-card :bordered = "false" title = "مسئول" >
2026-11-05 15:10:48 +03:30
< a-typography-text v-if = "!isManager" type="secondary" >
این روتین فقط برای خودتان ساخته میشود.
</ a -typography-text >
< template v-else >
2026-11-05 14:46:35 +03:30
< 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 >
2026-11-05 15:10:48 +03:30
</ template >
2026-11-05 14:46:35 +03:30
</ a-card >
2026-10-06 19:54:38 +03:30
2026-11-05 14:46:35 +03:30
< 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 >
2026-10-06 19:54:38 +03:30
2026-11-05 14:46:35 +03:30
< 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 >
2026-10-06 19:54:38 +03:30
2026-11-05 14:46:35 +03:30
< 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 >
2026-10-06 19:54:38 +03:30
2026-11-05 14:46:35 +03:30
< a-alert type = "info" show -icon >
< template # message > خلاصه </ template >
< template # description >
< a-typography-paragraph : style = "{ marginBottom: 0 }" >
{{ summary }}
</ a-typography-paragraph >
</ template >
</ a-alert >
2026-10-06 19:54:38 +03:30
2026-11-05 14:46:35 +03:30
< a-alert v-if = "error" type="error" show-icon :message="error" />
2026-10-06 19:54:38 +03:30
2026-11-05 14:46:35 +03:30
< a-space direction = "vertical" style = "width: 100%" >
< a-button type = "primary" html -type =" submit " block :loading = "saving" >
{{ isEdit ? 'ذخیره تغییرات' : 'ایجاد روتین' }}
</ a-button >
2026-11-05 15:10:48 +03:30
< a-button block :disabled = "saving" @click ="router.push(backTo)" >
2026-11-05 14:46:35 +03:30
انصراف
</ a -button >
</ a-space >
</ a-flex >
</ a-col >
</ a-row >
</ a-form >
</ CrmSkeletonCard >
</ CrmSkeleton >
</ a-flex >
2026-10-06 19:54:38 +03:30
</ template >