feat(web): migrate CRM screens to Ant Design Vue with central theme

Adopt ant-design-vue and a shared antdTheme for leads, pipeline, invoices, and shared form controls under strict single-page governance.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-18 19:39:59 +03:30
co-authored by Cursor
parent ab538b8304
commit 4abab55a6b
40 changed files with 7529 additions and 11795 deletions
@@ -1,12 +1,22 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import type { CompanyDto, LeadDto, PaginatedResponse, PersonDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import { UiInput, UiSelect } from '@/components/ui';
import {
Button as AButton,
Flex as AFlex,
Input as AInput,
Select as ASelect,
Space as ASpace,
Tag as ATag,
Typography,
} from 'ant-design-vue';
import { CloseOutlined } from '@ant-design/icons-vue';
import { api } from '@/lib/api';
import { ENTITY_TYPE_LABELS } from '@/lib/routines';
import { useAuthStore } from '@/stores/auth';
const { Text: ATypographyText } = Typography;
export type PickedEntity = { type: 'lead' | 'person' | 'company'; id: string; label: string };
const props = withDefaults(
@@ -33,6 +43,10 @@ const typeOptions = computed(() => {
return list;
});
const selectOptions = computed(() =>
typeOptions.value.map((t) => ({ value: t, label: ENTITY_TYPE_LABELS[t] })),
);
const type = ref<PickedEntity['type']>(typeOptions.value[0] ?? 'person');
const q = ref('');
const results = ref<PickedEntity[]>([]);
@@ -99,126 +113,55 @@ function pick(item: PickedEntity) {
</script>
<template>
<div class="ent-picker">
<div v-if="model" class="picked">
<span class="picked-type">{{ ENTITY_TYPE_LABELS[model.type] }}</span>
<strong>{{ model.label }}</strong>
<button type="button" aria-label="حذف اتصال" @click="model = null">
<UiIcon name="x" :size="14" />
</button>
</div>
<a-flex vertical :gap="8" style="width: 100%">
<a-flex v-if="model" justify="space-between" align="center" :gap="8">
<a-space :size="8">
<a-tag :bordered="false">{{ ENTITY_TYPE_LABELS[model.type] }}</a-tag>
<a-typography-text strong>{{ model.label }}</a-typography-text>
</a-space>
<a-button type="text" aria-label="حذف اتصال" @click="model = null">
<template #icon><CloseOutlined /></template>
</a-button>
</a-flex>
<template v-else-if="typeOptions.length">
<div class="search-row">
<UiSelect v-if="typeOptions.length > 1" v-model="type" class="type-sel">
<option v-for="t in typeOptions" :key="t" :value="t">{{ ENTITY_TYPE_LABELS[t] }}</option>
</UiSelect>
<UiInput v-model="q" :placeholder="`جستجوی ${ENTITY_TYPE_LABELS[type]}…`" />
</div>
<ul v-if="results.length || loading || q.trim().length >= 2" class="results">
<li v-if="loading" class="muted">در حال جستجو…</li>
<li v-else-if="!results.length" class="muted">موردی یافت نشد</li>
<li v-for="r in results" :key="r.id">
<button type="button" @click="pick(r)">{{ r.label }}</button>
</li>
</ul>
<a-flex :gap="8" wrap="wrap">
<a-select
v-if="typeOptions.length > 1"
v-model:value="type"
:options="selectOptions"
style="width: 120px"
/>
<a-input
v-model:value="q"
:placeholder="`جستجوی ${ENTITY_TYPE_LABELS[type]}…`"
style="flex: 1; min-width: 160px"
/>
</a-flex>
<a-flex
v-if="results.length || loading || q.trim().length >= 2"
vertical
:gap="4"
>
<a-typography-text v-if="loading" type="secondary">در حال جستجو…</a-typography-text>
<a-typography-text v-else-if="!results.length" type="secondary">
موردی یافت نشد
</a-typography-text>
<a-button
v-for="r in results"
:key="r.id"
type="text"
block
style="text-align: start"
@click="pick(r)"
>
{{ r.label }}
</a-button>
</a-flex>
</template>
<p v-else class="muted">ماژول لید/مشتری برای سازمان فعال نیست.</p>
</div>
<a-typography-text v-else type="secondary">
ماژول لید/مشتری برای سازمان فعال نیست.
</a-typography-text>
</a-flex>
</template>
<style scoped>
.ent-picker {
display: grid;
gap: var(--sp-2);
}
.search-row {
display: flex;
gap: var(--sp-2);
}
.type-sel {
width: 110px;
flex-shrink: 0;
}
.search-row > :last-child {
flex: 1 1 auto;
}
.results {
list-style: none;
margin: 0;
padding: 4px;
border: 1px solid var(--line);
border-radius: var(--radius);
max-height: 220px;
overflow-y: auto;
background: var(--surface);
}
.results li button {
width: 100%;
text-align: right;
border: 0;
background: transparent;
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
color: var(--ink);
}
.results li button:hover {
background: var(--accent-subtle);
color: var(--accent-deep);
}
.muted {
margin: 0;
padding: 8px 10px;
color: var(--muted);
font-size: var(--fs-sm);
}
.picked {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 6px 10px;
border-radius: var(--radius);
background: var(--accent-subtle);
border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent);
color: var(--accent-deep);
min-width: 0;
}
.picked strong {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.picked-type {
font-size: var(--fs-xs);
color: var(--muted);
}
.picked button {
border: 0;
background: transparent;
color: var(--muted);
cursor: pointer;
display: grid;
place-items: center;
width: 26px;
height: 26px;
border-radius: 6px;
}
.picked button:hover {
background: var(--surface);
color: var(--danger);
}
</style>
@@ -8,9 +8,22 @@ import type {
RoutineTaskDto,
RoutineTaskSource,
} from '@mento/shared';
import {
Alert as AAlert,
Checkbox as ACheckbox,
Col as ACol,
FormItem as AFormItem,
Input as AInput,
Modal as AModal,
Row as ARow,
Select as ASelect,
Space as ASpace,
Tag as ATag,
Textarea as ATextarea,
Typography,
} from 'ant-design-vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import EntityLinkPicker, { type PickedEntity } from '@/components/routines/EntityLinkPicker.vue';
import { UiButton, UiField, UiInput, UiModal, UiSelect, UiTextarea } from '@/components/ui';
import { api } from '@/lib/api';
import {
ENTITY_TYPE_LABELS,
@@ -22,6 +35,8 @@ import {
} from '@/lib/routines';
import { useAuthStore } from '@/stores/auth';
const { Text: ATypographyText } = Typography;
const props = withDefaults(
defineProps<{
open: boolean;
@@ -54,7 +69,23 @@ const form = reactive({
const saving = ref(false);
const error = ref('');
const priorities = Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>;
const priorityOptions = computed(() =>
(Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>).map(
([value, label]) => ({ value, label }),
),
);
const categoryOptions = computed(() => [
{ value: '', label: 'بدون دسته' },
...(ctx.value?.categories ?? []).map((c) => ({ value: c.id, label: c.name })),
]);
const assigneeOptions = computed(() =>
assignees.value.map((u) => ({
value: u.id,
label: `${u.fullName}${u.id === auth.user?.id ? ' (خودم)' : ''}`,
})),
);
async function ensureContext() {
if (ctx.value) return;
@@ -105,7 +136,7 @@ async function submit() {
error.value = '';
if (form.title.trim().length < 2) {
error.value = 'عنوان کار را وارد کنید.';
return;
return Promise.reject();
}
const link = props.preset ?? form.entity;
const body: CreateRoutineTaskRequest = {
@@ -128,6 +159,7 @@ async function submit() {
emit('close');
} catch (e) {
error.value = apiError(e, 'ثبت کار ممکن نشد.');
return Promise.reject();
} finally {
saving.value = false;
}
@@ -135,119 +167,90 @@ async function submit() {
</script>
<template>
<UiModal :open="open" title="کار جدید" @close="emit('close')">
<form class="qt-form" @submit.prevent="submit">
<UiField label="عنوان" required>
<UiInput v-model="form.title" placeholder="مثلاً: تماس با مشتری برای پیگیری پیش‌فاکتور" :maxlength="200" />
</UiField>
<UiField label="توضیحات">
<UiTextarea v-model="form.description" :rows="2" :maxlength="2000" />
</UiField>
<div class="grid-2">
<UiField label="تاریخ" required>
<a-modal
:open="open"
title="کار جدید"
:confirm-loading="saving"
ok-text="ثبت کار"
cancel-text="انصراف"
destroy-on-close
@ok="submit"
@cancel="emit('close')"
>
<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="2" :maxlength="2000" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="تاریخ" required>
<JalaliDateInput v-model="form.date" mode="date" :clearable="false" />
</UiField>
<UiField label="ساعت" hint="خالی = تا پایان روز">
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="ساعت" extra="خالی = تا پایان روز">
<JalaliDateInput v-model="form.time" mode="time" />
</UiField>
<UiField label="اولویت">
<UiSelect v-model="form.priority">
<option v-for="[k, label] in priorities" :key="k" :value="k">{{ label }}</option>
</UiSelect>
</UiField>
<UiField label="دسته‌بندی">
<UiSelect v-model="form.categoryId">
<option value="">بدون دسته</option>
<option v-for="c in ctx?.categories ?? []" :key="c.id" :value="c.id">{{ c.name }}</option>
</UiSelect>
</UiField>
<UiField v-if="assignees.length > 1" label="مسئول" class="span-2">
<UiSelect v-model="form.assigneeId">
<option v-for="u in assignees" :key="u.id" :value="u.id">
{{ u.fullName }}{{ u.id === auth.user?.id ? ' (خودم)' : '' }}
</option>
</UiSelect>
</UiField>
</div>
</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-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 v-if="assignees.length > 1" :span="24">
<a-form-item label="مسئول">
<a-select
v-model:value="form.assigneeId"
:options="assigneeOptions"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="اتصال به پرونده">
<a-space v-if="preset" :size="8">
<a-tag :bordered="false">{{ ENTITY_TYPE_LABELS[preset.type] }}</a-tag>
<a-typography-text strong>{{ preset.label }}</a-typography-text>
</a-space>
<EntityLinkPicker v-else v-model="form.entity" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-checkbox v-model:checked="form.isMandatory">
کار اجباری (در امتیاز روزانه و Streak حساب می‌شود)
</a-checkbox>
</a-col>
</a-row>
<UiField label="اتصال به پرونده">
<div v-if="preset" class="preset">
<span>{{ ENTITY_TYPE_LABELS[preset.type] }}</span>
<strong>{{ preset.label }}</strong>
</div>
<EntityLinkPicker v-else v-model="form.entity" />
</UiField>
<label class="check">
<input v-model="form.isMandatory" type="checkbox" />
<span>کار اجباری (در امتیاز روزانه و Streak حساب می‌شود)</span>
</label>
<p v-if="error" class="error">{{ error }}</p>
</form>
<template #footer>
<UiButton :loading="saving" @click="submit">ثبت کار</UiButton>
<UiButton variant="ghost" @click="emit('close')">انصراف</UiButton>
</template>
</UiModal>
<a-alert
v-if="error"
type="error"
show-icon
:message="error"
:style="{ marginTop: '12px' }"
/>
</a-modal>
</template>
<style scoped>
.qt-form {
display: grid;
gap: var(--sp-4);
}
.grid-2 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-3);
}
.span-2 {
grid-column: 1 / -1;
}
.preset {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 8px 12px;
border-radius: var(--radius);
background: var(--accent-subtle);
color: var(--accent-deep);
}
.preset span {
font-size: var(--fs-xs);
color: var(--muted);
}
.check {
display: flex;
align-items: center;
gap: var(--sp-2);
font-size: var(--fs-sm);
color: var(--ink-secondary);
cursor: pointer;
}
.check input {
width: 18px;
height: 18px;
accent-color: var(--accent);
}
.error {
margin: 0;
}
@media (max-width: 600px) {
.grid-2 {
grid-template-columns: 1fr;
}
}
</style>
@@ -1,18 +1,30 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Empty as AEmpty,
Flex as AFlex,
Space as ASpace,
Spin as ASpin,
Tag as ATag,
Typography,
} from 'ant-design-vue';
import { CheckOutlined, PlusOutlined } from '@ant-design/icons-vue';
import type { RoutineEntityRefDto, RoutineTaskDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import QuickTaskModal from '@/components/routines/QuickTaskModal.vue';
import RoutineTaskRow, { type RoutineTaskRowAction } from '@/components/routines/RoutineTaskRow.vue';
import RoutineTaskModals from '@/components/routines/RoutineTaskModals.vue';
import { UiButton } from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { apiError, isOpenStatus } from '@/lib/routines';
import { useRoutineTaskActions } from '@/lib/useRoutineTaskActions';
import { useAuthStore } from '@/stores/auth';
const { Text: ATypographyText, Title: ATypographyTitle } = Typography;
const props = withDefaults(
defineProps<{
entity: RoutineEntityRefDto;
@@ -85,223 +97,90 @@ function onCreated(t: RoutineTaskDto) {
</script>
<template>
<section v-if="enabled" class="related">
<header>
<h3>
<span class="mark"><UiIcon name="check" :size="15" /></span>
{{ title }}
<em v-if="open.length">{{ toFaDigits(open.length) }} باز</em>
</h3>
<UiButton size="sm" variant="outline" @click="quickOpen = true">
<template #icon><UiIcon name="plus" :size="14" /></template>
<a-card v-if="enabled" :bordered="false" :style="{ marginTop: '16px' }">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<a-space :size="8">
<CheckOutlined />
<a-typography-title :level="5" :style="{ margin: 0 }">{{ title }}</a-typography-title>
<a-tag v-if="open.length" :bordered="false">{{ toFaDigits(open.length) }} باز</a-tag>
</a-space>
<a-button @click="quickOpen = true">
<template #icon><PlusOutlined /></template>
ایجاد Task
</UiButton>
</header>
</a-button>
</a-flex>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="actions.error.value" class="error">{{ actions.error.value }}</p>
<div v-if="loading && !items.length" class="muted">در حال بارگذاری…</div>
<div v-else-if="!items.length" class="empty">
<div class="empty-art" aria-hidden="true">
<svg viewBox="0 0 72 72" width="72" height="72">
<rect x="18" y="16" width="36" height="44" rx="8" fill="#f4fbf8" stroke="#b7e4d4" stroke-width="1.6" />
<rect x="26" y="10" width="20" height="12" rx="4" fill="#e7f6f1" stroke="#7dcebb" stroke-width="1.6" />
<path d="M30 40l5 5 9-11" fill="none" stroke="#12856a" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<p class="muted">{{ emptyTitle }}</p>
<p v-if="emptyHint" class="hint">{{ emptyHint }}</p>
<button class="empty-cta" type="button" @click="quickOpen = true">
ایجاد Task
<UiIcon name="plus" :size="14" />
</button>
</div>
<a-alert
v-if="error"
type="error"
show-icon
:message="error"
:style="{ marginTop: '12px' }"
/>
<a-alert
v-if="actions.error.value"
type="error"
show-icon
:message="actions.error.value"
:style="{ marginTop: '12px' }"
/>
<div v-else class="rows">
<RoutineTaskRow
v-for="t in open"
:key="t.id"
:task="t"
:busy="actions.busyId.value === t.id"
show-date
show-assignee
show-delay
@toggle="actions.toggle"
@open="openTask"
@action="onAction"
/>
<button v-if="closed.length" type="button" class="toggle-closed" @click="showClosed = !showClosed">
{{ showClosed ? 'پنهان کردن' : 'نمایش' }} {{ toFaDigits(closed.length) }} کار انجام‌شده / بسته
</button>
<template v-if="showClosed">
<a-spin :spinning="loading && !items.length">
<a-empty
v-if="!loading && !items.length"
:description="emptyTitle"
:style="{ margin: '16px 0' }"
>
<template v-if="emptyHint" #description>
<div>
<div>{{ emptyTitle }}</div>
<a-typography-text type="secondary">{{ emptyHint }}</a-typography-text>
</div>
</template>
<a-button type="primary" @click="quickOpen = true">
<template #icon><PlusOutlined /></template>
ایجاد Task
</a-button>
</a-empty>
<a-flex v-else vertical :gap="8" :style="{ marginTop: '12px' }">
<RoutineTaskRow
v-for="t in closed"
v-for="t in open"
:key="t.id"
:task="t"
:busy="actions.busyId.value === t.id"
show-date
show-assignee
show-delay
@toggle="actions.toggle"
@open="openTask"
@action="onAction"
/>
</template>
</div>
<a-button
v-if="closed.length"
type="link"
@click="showClosed = !showClosed"
>
{{ showClosed ? 'پنهان کردن' : 'نمایش' }}
{{ toFaDigits(closed.length) }} کار انجام‌شده / بسته
</a-button>
<template v-if="showClosed">
<RoutineTaskRow
v-for="t in closed"
:key="t.id"
:task="t"
:busy="actions.busyId.value === t.id"
show-date
show-assignee
@toggle="actions.toggle"
@open="openTask"
@action="onAction"
/>
</template>
</a-flex>
</a-spin>
<QuickTaskModal :open="quickOpen" :preset="entity" @close="quickOpen = false" @created="onCreated" />
<RoutineTaskModals :actions="actions" />
</section>
</a-card>
</template>
<style scoped>
.related {
display: grid;
gap: var(--sp-3);
padding: var(--sp-4);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
}
h3 {
margin: 0;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: var(--fs-md);
font-weight: var(--fw-semibold);
color: var(--ink);
}
h3 em {
font-style: normal;
font-size: var(--fs-xs);
font-weight: var(--fw-medium);
padding: 1px 8px;
border-radius: 99px;
background: var(--accent-subtle);
color: var(--accent-deep);
}
.rows {
display: grid;
gap: var(--sp-2);
}
.muted {
margin: 0;
color: var(--muted);
font-size: var(--fs-sm);
}
.toggle-closed {
justify-self: start;
border: 0;
background: transparent;
color: var(--accent-deep);
font: inherit;
font-size: var(--fs-xs);
font-weight: var(--fw-semibold);
cursor: pointer;
padding: 4px 0;
}
.error {
margin: 0;
}
.empty {
display: grid;
gap: 0.35rem;
}
.empty-art,
.hint,
.empty-cta {
display: none;
}
@media (max-width: 900px) {
.fancy {
gap: 0.85rem;
padding: 0.95rem 0.85rem 1rem;
border: 1px solid #e7eef2;
border-radius: 18px;
background: #fff;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
}
.fancy .mark {
width: 28px;
height: 28px;
border-radius: 999px;
display: grid;
place-items: center;
background: #e7f6f1;
color: #0f766e;
}
.fancy header :deep(.ui-btn),
.fancy header :deep(button) {
border: 0;
border-radius: 999px;
background: #e7f6f1;
color: #0f766e;
box-shadow: none;
height: auto;
min-height: 34px;
padding: 0.3rem 0.75rem;
font-size: 0.75rem;
font-weight: 700;
flex-direction: row-reverse;
gap: 0.2rem;
}
.fancy .empty {
justify-items: center;
text-align: center;
gap: 0.35rem;
padding: 0.4rem 0.5rem 0.2rem;
}
.fancy .empty-art {
display: grid;
place-items: center;
width: 92px;
height: 92px;
margin-bottom: 0.2rem;
border-radius: 24px;
background: linear-gradient(180deg, #f7fcfa, #eef8f4);
}
.fancy .hint {
display: block;
margin: 0;
color: #94a3b8;
font-size: 0.78rem;
}
.fancy .empty-cta {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-top: 0.35rem;
border: 0;
border-radius: 999px;
background: #e7f6f1;
color: #0f766e;
padding: 0.45rem 0.9rem;
font: inherit;
font-size: 0.78rem;
font-weight: 800;
cursor: pointer;
}
}
</style>
@@ -1,9 +1,19 @@
<script setup lang="ts">
import { computed } from 'vue';
import {
Alert as AAlert,
Col as ACol,
FormItem as AFormItem,
Modal as AModal,
Row as ARow,
Textarea as ATextarea,
Typography,
} from 'ant-design-vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { UiButton, UiField, UiModal, UiTextarea } from '@/components/ui';
import type { RoutineTaskActions } from '@/lib/useRoutineTaskActions';
const { Text: ATypographyText } = Typography;
const props = defineProps<{ actions: RoutineTaskActions }>();
const modal = computed(() => props.actions.modal);
@@ -57,81 +67,63 @@ const submitLabel = computed(() => {
return 'ذخیره';
}
});
const okDanger = computed(() => modal.value.kind === 'cancel');
</script>
<template>
<UiModal :open="!!modal.kind" :title="title" nest @close="actions.close()">
<form class="rt-modal" @submit.prevent="actions.submit()">
<p v-if="modal.task" class="task-name">{{ modal.task.title }}</p>
<a-modal
:open="!!modal.kind"
:title="title"
:confirm-loading="modal.saving"
:ok-text="submitLabel"
:ok-button-props="{ danger: okDanger }"
cancel-text="انصراف"
destroy-on-close
@ok="actions.submit()"
@cancel="actions.close()"
>
<a-typography-text v-if="modal.task" strong>
{{ modal.task.title }}
</a-typography-text>
<template v-if="modal.kind === 'reschedule'">
<div class="when">
<UiField label="تاریخ" required>
<JalaliDateInput v-model="modal.date" mode="date" required :clearable="false" />
</UiField>
<UiField label="ساعت" hint="خالی = تا پایان روز">
<JalaliDateInput v-model="modal.time" mode="time" />
</UiField>
</div>
</template>
<a-row v-if="modal.kind === 'reschedule'" :gutter="[16, 0]" :style="{ marginTop: '12px' }">
<a-col :xs="24" :md="12">
<a-form-item label="تاریخ" required>
<JalaliDateInput v-model="modal.date" mode="date" required :clearable="false" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="ساعت" extra="خالی = تا پایان روز">
<JalaliDateInput v-model="modal.time" mode="time" />
</a-form-item>
</a-col>
</a-row>
<UiField v-else :label="noteLabel" :required="noteRequired">
<UiTextarea v-model="modal.note" :rows="4" :placeholder="placeholder" :maxlength="2000" />
</UiField>
<a-form-item
v-else
:label="noteLabel"
:required="noteRequired"
:style="{ marginTop: '12px' }"
>
<a-textarea
v-model:value="modal.note"
:rows="4"
:placeholder="placeholder"
:maxlength="2000"
/>
</a-form-item>
<p v-if="modal.kind === 'cancel'" class="hint">
کار لغو شده در محاسبه درصد انجام حساب نمی‌شود.
</p>
<p v-if="modal.error" class="error">{{ modal.error }}</p>
</form>
<a-typography-text v-if="modal.kind === 'cancel'" type="secondary">
کار لغو شده در محاسبه درصد انجام حساب نمی‌شود.
</a-typography-text>
<template #footer>
<UiButton
:variant="modal.kind === 'cancel' ? 'danger' : modal.kind === 'complete' ? 'success' : 'primary'"
:loading="modal.saving"
@click="actions.submit()"
>
{{ submitLabel }}
</UiButton>
<UiButton variant="ghost" @click="actions.close()">انصراف</UiButton>
</template>
</UiModal>
<a-alert
v-if="modal.error"
type="error"
show-icon
:message="modal.error"
:style="{ marginTop: '12px' }"
/>
</a-modal>
</template>
<style scoped>
.rt-modal {
display: grid;
gap: var(--sp-4);
}
.task-name {
margin: 0;
padding: var(--sp-2) var(--sp-3);
border-radius: var(--radius);
background: var(--surface-subtle);
font-weight: var(--fw-semibold);
color: var(--ink);
}
.when {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-3);
}
.hint {
margin: 0;
font-size: var(--fs-xs);
color: var(--muted);
}
.error {
margin: 0;
}
@media (max-width: 600px) {
.when {
grid-template-columns: 1fr;
}
}
</style>
@@ -1,17 +1,33 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { computed } from 'vue';
import { RouterLink } from 'vue-router';
import type { RoutineTaskDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import {
Button as AButton,
Checkbox as ACheckbox,
Dropdown as ADropdown,
Flex as AFlex,
Menu as AMenu,
Space as ASpace,
Tag as ATag,
Typography,
} from 'ant-design-vue';
import {
CalendarOutlined,
CheckOutlined,
ClockCircleOutlined,
FlagOutlined,
MoreOutlined,
WarningOutlined,
} from '@ant-design/icons-vue';
const AMenuItem = AMenu.Item;
import UserAvatar from '@/components/UserAvatar.vue';
import { UiBadge, UiDropdownMenu } from '@/components/ui';
import {
ENTITY_TYPE_LABELS,
PRIORITY_LABELS,
PRIORITY_VARIANTS,
ROUTINE_TASK_SOURCE_LABELS,
ROUTINE_TASK_STATUS_LABELS,
STATUS_VARIANTS,
entityRoute,
formatDayNumeric,
formatDelay,
@@ -20,6 +36,9 @@ import {
isOpenStatus,
} from '@/lib/routines';
const { Text: ATypographyText, Title: ATypographyTitle, Paragraph: ATypographyParagraph } =
Typography;
export type RoutineTaskRowAction =
| 'start'
| 'complete-note'
@@ -45,396 +64,135 @@ const emit = defineEmits<{
action: [kind: RoutineTaskRowAction, task: RoutineTaskDto];
}>();
const menuOpen = ref(false);
const done = computed(() => props.task.status === 'completed');
const closed = computed(() => props.task.status === 'skipped' || props.task.status === 'cancelled');
const open = computed(() => isOpenStatus(props.task.status));
const showTime = computed(() => hasExplicitTime(props.task));
const checkLabel = computed(() =>
done.value ? `برگرداندن «${props.task.title}» به حالت انجام‌نشده` : `علامت‌زدن «${props.task.title}» به‌عنوان انجام‌شده`,
done.value
? `برگرداندن «${props.task.title}» به حالت انجام‌نشده`
: `علامت‌زدن «${props.task.title}» به‌عنوان انجام‌شده`,
);
function act(kind: RoutineTaskRowAction) {
emit('action', kind, props.task);
}
function onMenuClick({ key }: { key: string | number }) {
const k = String(key);
if (k === 'open') {
emit('open', props.task);
return;
}
act(k as RoutineTaskRowAction);
}
</script>
<template>
<article
class="rt-row"
:class="[`st-${task.status}`, { done, closed, busy }]"
<a-flex
align="flex-start"
:gap="12"
:style="{
width: '100%',
padding: '12px 16px',
border: '1px solid var(--ant-color-border-secondary, #f0f0f0)',
borderRadius: '8px',
cursor: 'pointer',
opacity: closed ? 0.62 : 1,
}"
@click="emit('open', task)"
>
<button
type="button"
class="rt-check"
:class="{ on: done }"
<a-checkbox
:checked="done"
:disabled="busy || closed"
:aria-pressed="done"
:aria-label="checkLabel"
@click.stop="emit('toggle', task)"
>
<span class="box">
<UiIcon v-if="done" name="check-plain" :size="16" />
<span v-else-if="busy" class="spin" aria-hidden="true" />
</span>
</button>
@click.stop
@update:checked="emit('toggle', task)"
/>
<div class="rt-main">
<div class="rt-title-line">
<h3 class="rt-title">{{ task.title }}</h3>
<UiIcon v-if="task.isMandatory" class="mand" name="flag" :size="13" title="اجباری" />
</div>
<p v-if="task.description" class="rt-desc">{{ task.description }}</p>
<a-flex vertical :gap="4" style="flex: 1; min-width: 0">
<a-space :size="6">
<a-typography-title
:level="5"
:style="{
margin: 0,
textDecoration: done ? 'line-through' : undefined,
}"
>
{{ task.title }}
</a-typography-title>
<FlagOutlined v-if="task.isMandatory" />
</a-space>
<div class="rt-meta">
<span v-if="showDate" class="meta-item">
<UiIcon name="calendar" :size="13" />
{{ formatDayNumeric(task.scheduledFor) }}
</span>
<span v-if="showTime" class="meta-item">
<UiIcon name="clock" :size="13" />
{{ formatTime(task.dueAt) }}
</span>
<span v-if="showDelay && task.status === 'overdue'" class="meta-item late">
<UiIcon name="warning" :size="13" />
{{ formatDelay(task.dueAt) }} تأخیر
</span>
<span v-if="done && task.completedAt" class="meta-item ok">
<UiIcon name="check" :size="13" />
{{ formatTime(task.completedAt) }}
</span>
<span v-if="task.category" class="cat-chip">
<i :style="{ background: task.category.color }" />
{{ task.category.name }}
</span>
<UiBadge size="sm" :variant="PRIORITY_VARIANTS[task.priority]">
{{ PRIORITY_LABELS[task.priority] }}
</UiBadge>
<UiBadge v-if="task.status !== 'todo'" size="sm" :variant="STATUS_VARIANTS[task.status]">
<a-typography-paragraph
v-if="task.description"
type="secondary"
:ellipsis="{ rows: 1 }"
:style="{ marginBottom: 0 }"
>
{{ task.description }}
</a-typography-paragraph>
<a-space wrap :size="[8, 4]">
<a-typography-text v-if="showDate" type="secondary">
<CalendarOutlined /> {{ formatDayNumeric(task.scheduledFor) }}
</a-typography-text>
<a-typography-text v-if="showTime" type="secondary">
<ClockCircleOutlined /> {{ formatTime(task.dueAt) }}
</a-typography-text>
<a-typography-text
v-if="showDelay && task.status === 'overdue'"
type="danger"
>
<WarningOutlined /> {{ formatDelay(task.dueAt) }} تأخیر
</a-typography-text>
<a-typography-text v-if="done && task.completedAt" type="success">
<CheckOutlined /> {{ formatTime(task.completedAt) }}
</a-typography-text>
<a-tag v-if="task.category" :bordered="false">{{ task.category.name }}</a-tag>
<a-tag :bordered="false">{{ PRIORITY_LABELS[task.priority] }}</a-tag>
<a-tag v-if="task.status !== 'todo'" :bordered="false">
{{ ROUTINE_TASK_STATUS_LABELS[task.status] }}
</UiBadge>
<span class="src-chip" :title="`منبع: ${ROUTINE_TASK_SOURCE_LABELS[task.source]}`">
<UiIcon :name="task.source === 'routine' ? 'refresh' : 'pin'" :size="12" />
{{ ROUTINE_TASK_SOURCE_LABELS[task.source] }}
</span>
</a-tag>
<a-tag :bordered="false">{{ ROUTINE_TASK_SOURCE_LABELS[task.source] }}</a-tag>
<RouterLink
v-for="ent in task.entities"
:key="`${ent.type}-${ent.id}`"
class="ent-link"
:to="entityRoute(ent)"
@click.stop
>
<span class="ent-type">{{ ENTITY_TYPE_LABELS[ent.type] }}</span>
{{ ent.label }}
<a-tag color="processing" :bordered="false">
{{ ENTITY_TYPE_LABELS[ent.type] }} · {{ ent.label }}
</a-tag>
</RouterLink>
</div>
</div>
</a-space>
</a-flex>
<div class="rt-side" @click.stop>
<span v-if="showAssignee && task.assignee" class="assignee" :title="task.assignee.fullName">
<a-space :size="8" @click.stop>
<a-space v-if="showAssignee && task.assignee" :size="6">
<UserAvatar :name="task.assignee.fullName" :user-id="task.assignee.id" :size="26" />
<span class="assignee-name">{{ task.assignee.fullName }}</span>
</span>
<UiDropdownMenu v-model:open="menuOpen" align="end">
<template #trigger="{ toggle }">
<button type="button" class="rt-more" aria-label="عملیات بیشتر" @click="toggle">
<UiIcon name="dots" :size="18" />
</button>
<a-typography-text type="secondary">{{ task.assignee.fullName }}</a-typography-text>
</a-space>
<a-dropdown>
<a-button type="text" aria-label="عملیات بیشتر">
<template #icon><MoreOutlined /></template>
</a-button>
<template #overlay>
<a-menu @click="onMenuClick">
<a-menu-item key="open">جزئیات</a-menu-item>
<a-menu-item v-if="task.status === 'todo'" key="start">شروع کار</a-menu-item>
<a-menu-item v-if="open || done" key="complete-note">
{{ done ? 'ویرایش گزارش نتیجه' : 'انجام با ثبت گزارش' }}
</a-menu-item>
<a-menu-item v-if="open" key="reschedule">تغییر زمان</a-menu-item>
<a-menu-item v-if="task.status === 'overdue'" key="move-today">
انتقال به امروز
</a-menu-item>
<a-menu-item v-if="open && task.rules.allowSkip" key="skip">رد کردن</a-menu-item>
<a-menu-item v-if="open" key="cancel" danger>لغو کار</a-menu-item>
</a-menu>
</template>
<button type="button" @click="emit('open', task)">جزئیات</button>
<button v-if="task.status === 'todo'" type="button" @click="act('start')">شروع کار</button>
<button v-if="open || done" type="button" @click="act('complete-note')">
{{ done ? 'ویرایش گزارش نتیجه' : 'انجام با ثبت گزارش' }}
</button>
<button v-if="open" type="button" @click="act('reschedule')">تغییر زمان</button>
<button v-if="task.status === 'overdue'" type="button" @click="act('move-today')">انتقال به امروز</button>
<button v-if="open && task.rules.allowSkip" type="button" @click="act('skip')">رد کردن</button>
<button v-if="open" type="button" class="danger" @click="act('cancel')">لغو کار</button>
</UiDropdownMenu>
</div>
</article>
</a-dropdown>
</a-space>
</a-flex>
</template>
<style scoped>
.rt-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: start;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
cursor: pointer;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), opacity var(--transition-fast);
}
.rt-row:hover {
border-color: var(--line-strong);
box-shadow: var(--shadow-xs);
}
.rt-row.st-overdue {
border-inline-start: 3px solid var(--danger);
}
.rt-row.done {
background: color-mix(in srgb, var(--success-soft) 28%, var(--surface));
}
.rt-row.closed {
opacity: 0.62;
}
.rt-check {
width: 40px;
height: 40px;
margin: -6px;
padding: 0;
border: 0;
background: transparent;
display: grid;
place-items: center;
cursor: pointer;
border-radius: var(--radius);
}
.rt-check:disabled {
cursor: not-allowed;
}
.rt-check:focus-visible {
outline: 2px solid var(--accent);
}
.rt-check .box {
width: 24px;
height: 24px;
border-radius: 7px;
border: 2px solid var(--line-strong);
background: var(--surface);
display: grid;
place-items: center;
color: #fff;
transition: background var(--transition-fast), border-color var(--transition-fast), transform 0.12s ease;
}
.rt-check:not(:disabled):hover .box {
border-color: var(--accent);
}
.rt-check:active .box {
transform: scale(0.92);
}
.rt-check.on .box {
background: var(--success);
border-color: var(--success);
}
.st-overdue .rt-check .box {
border-color: color-mix(in srgb, var(--danger) 55%, white);
}
.spin {
width: 12px;
height: 12px;
border: 2px solid var(--line);
border-top-color: var(--accent);
border-radius: 50%;
animation: rt-spin 0.7s linear infinite;
}
@keyframes rt-spin {
to {
transform: rotate(360deg);
}
}
.rt-main {
min-width: 0;
display: grid;
gap: 4px;
}
.rt-title-line {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.rt-title {
margin: 0;
font-size: var(--fs-base);
font-weight: var(--fw-semibold);
color: var(--ink);
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.done .rt-title {
color: var(--muted);
text-decoration: line-through;
text-decoration-color: color-mix(in srgb, var(--muted) 60%, transparent);
}
.mand {
color: var(--warning-deep);
flex-shrink: 0;
}
.rt-desc {
margin: 0;
font-size: var(--fs-sm);
color: var(--muted);
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 1;
line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.rt-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px 10px;
margin-top: 2px;
font-size: var(--fs-xs);
color: var(--muted);
}
.meta-item {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.meta-item.late {
color: var(--danger);
font-weight: var(--fw-semibold);
}
.meta-item.ok {
color: var(--success-deep);
}
.cat-chip,
.src-chip {
display: inline-flex;
align-items: center;
gap: 5px;
white-space: nowrap;
}
.cat-chip i {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.src-chip {
color: var(--muted-light);
}
.ent-link {
display: inline-flex;
align-items: center;
gap: 5px;
max-width: 220px;
padding: 1px 8px;
border-radius: var(--radius-full);
background: var(--accent-subtle);
color: var(--accent-deep);
font-weight: var(--fw-medium);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ent-link:hover {
background: var(--accent-soft);
}
.ent-type {
color: var(--muted);
font-weight: var(--fw-normal);
}
.rt-side {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.assignee {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: var(--fs-xs);
color: var(--ink-secondary);
max-width: 150px;
}
.assignee-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rt-more {
width: 32px;
height: 32px;
border: 0;
border-radius: var(--radius);
background: transparent;
color: var(--muted);
display: grid;
place-items: center;
cursor: pointer;
}
.rt-more:hover {
background: var(--surface-subtle);
color: var(--ink);
}
:global(.ui-dd-panel button.danger) {
color: var(--danger);
}
@media (max-width: 900px) {
.rt-row {
padding: var(--sp-3);
gap: var(--sp-2);
}
.rt-check {
width: 48px;
height: 48px;
margin: -8px -6px;
}
.rt-check .box {
width: 28px;
height: 28px;
border-radius: 8px;
}
.rt-title {
white-space: normal;
}
.assignee-name {
display: none;
}
}
</style>