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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user