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-10-06 19:54:38 +03:30
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' ;
2026-11-04 19:34:36 +03:30
import { useAuthStore } from '@/stores/auth' ;
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 );
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 ( '' );
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 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 () => {
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 >
< div class = "rform" >
< UiPageHeader
: title = "isEdit ? 'ویرایش روتین' : 'روتین جدید'"
lead = "کاری که باید بهصورت منظم انجام شود؛ هر روزِ تکرار، یک کار برای مسئول ساخته میشود."
2026-11-04 19:34:36 +03:30
:back-to = "backTo"
2026-10-06 19:54:38 +03:30
/>
< div v-if = "loading" class="state" > در حال بارگذاری … </ div >
2026-11-04 19:34:36 +03:30
< p v-else-if = "foreign" class="error" >{{ error }}</ p >
2026-10-06 19:54:38 +03:30
< 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 >
2026-11-04 19:34:36 +03:30
< p v-if = "!isManager" class="self-note" > این روتین فقط برای خودتان ساخته میشود. </ p >
< div v-else class = "fields" >
2026-10-06 19:54:38 +03:30
< 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 , 1 fr ) 360 px ;
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 , 1 fr ));
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 : 26 px ;
}
. tg input {
width : 18 px ;
height : 18 px ;
margin - top : 2 px ;
accent - color : var ( -- accent );
flex - shrink : 0 ;
}
. tg span {
display : grid ;
gap : 2 px ;
}
. 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 : 1 px 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 : 4 px 0 0 ;
font - size : var ( -- fs - sm );
color : var ( -- ink );
line - height : 1.8 ;
}
. actions {
display : grid ;
gap : var ( -- sp - 2 );
}
2026-11-04 19:34:36 +03:30
. self - note {
margin : 0 ;
font - size : var ( -- fs - sm );
color : var ( -- muted );
line - height : 1.7 ;
}
2026-10-06 19:54:38 +03:30
. error {
margin : 0 ;
}
@ media ( max - width : 1100 px ) {
. grid {
grid - template - columns : 1 fr ;
}
. side {
position : static ;
}
}
@ media ( max - width : 600 px ) {
. grid - 2 {
grid - template - columns : 1 fr ;
}
}
</ style >