feat(web): adopt vuesax icons and shared skeleton loading

Replace the inline SVG icon set with the vuesax linear pack, add skeleton primitives for loading states, and keep page scroll inside the app shell.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-05 14:46:35 +03:30
co-authored by Cursor
parent 41f310f3a2
commit 6c6e32e099
133 changed files with 9069 additions and 7416 deletions
+275 -307
View File
@@ -1,30 +1,46 @@
<script setup lang="ts">
import { computed, inject, onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
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 {
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';
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
Typography;
const shell = inject(ROUTINES_CTX)!;
const route = useRoute();
const router = useRouter();
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: '',
@@ -35,7 +51,7 @@ const form = reactive({
categoryId: '',
priority: 'medium' as ActivityPriority,
timeOfDay: '09:00',
durationMinutes: '' as string | number,
durationMinutes: null as number | null,
startDate: todayKey(),
endDate: '',
isMandatory: true,
@@ -61,13 +77,26 @@ const loading = ref(false);
const saving = ref(false);
const error = ref('');
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 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: 'سر ساعت' },
@@ -88,7 +117,7 @@ function fill(r: RoutineDto) {
categoryId: r.category?.id ?? '',
priority: r.priority,
timeOfDay: r.timeOfDay,
durationMinutes: r.durationMinutes ?? '',
durationMinutes: r.durationMinutes ?? null,
startDate: r.startDate,
endDate: r.endDate ?? '',
isMandatory: r.isMandatory,
@@ -230,301 +259,240 @@ async function save() {
</script>
<template>
<div class="rform">
<UiPageHeader
:title="isEdit ? 'ویرایش روتین' : 'روتین جدید'"
lead="کاری که باید به‌صورت منظم انجام شود؛ هر روزِ تکرار، یک کار برای مسئول ساخته می‌شود."
back-to="/routines/manage"
/>
<a-flex vertical :gap="16">
<a-flex vertical :gap="4">
<a-breadcrumb>
<a-breadcrumb-item>
<RouterLink to="/routines/manage">مدیریت روتین‌ها</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>
<div v-if="loading" class="state">در حال بارگذاری…</div>
<CrmSkeleton :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>
<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>
<a-card :bordered="false" title="مسئول">
<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>
</a-card>
<UiCard>
<h3 class="sec-title">مسئول</h3>
<div 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>
<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>
<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>
<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>
<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>
<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>
<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>
<a-alert type="info" show-icon>
<template #message>خلاصه</template>
<template #description>
<a-typography-paragraph :style="{ marginBottom: 0 }">
{{ summary }}
</a-typography-paragraph>
</template>
</a-alert>
<div class="summary">
<span>خلاصه</span>
<p>{{ summary }}</p>
</div>
<a-alert v-if="error" type="error" show-icon :message="error" />
<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>
<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('/routines/manage')">
انصراف
</a-button>
</a-space>
</a-flex>
</a-col>
</a-row>
</a-form>
</CrmSkeletonCard>
</CrmSkeleton>
</a-flex>
</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);
}
.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>