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>
573 lines
19 KiB
Vue
573 lines
19 KiB
Vue
<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>
|