Files
CRM_Montzar/apps/web/src/views/routines/RoutineFormView.vue
T
MahdiandCursor 78a7d6a68a feat(routines): let each person create a routine for themselves
Open the new-routine action to every member, while keeping assignment to other people and teams limited to managers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 19:34:36 +03:30

573 lines
19 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 { 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';
import { useAuthStore } from '@/stores/auth';
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 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 loaded = ref<RoutineDto | null>(null);
const foreign = ref(false);
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) {
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>
<div class="rform">
<UiPageHeader
:title="isEdit ? 'ویرایش روتین' : 'روتین جدید'"
lead="کاری که باید به‌صورت منظم انجام شود؛ هر روزِ تکرار، یک کار برای مسئول ساخته می‌شود."
:back-to="backTo"
/>
<div v-if="loading" class="state">در حال بارگذاری…</div>
<p v-else-if="foreign" class="error">{{ error }}</p>
<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>
<p v-if="!isManager" class="self-note">این روتین فقط برای خودتان ساخته می‌شود.</p>
<div v-else 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);
}
.self-note {
margin: 0;
font-size: var(--fs-sm);
color: var(--muted);
line-height: 1.7;
}
.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>