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.
539 lines
21 KiB
Vue
539 lines
21 KiB
Vue
<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>
|