Migrate remaining settings screens to Ant Design Vue, densify teams/roles UI, drop page-header subtitles project-wide, and polish notes cards plus contacts row actions. Co-authored-by: Cursor <cursoragent@cursor.com>
362 lines
11 KiB
Vue
362 lines
11 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, ref } from 'vue';
|
||
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||
import axios from 'axios';
|
||
import { api } from '@/lib/api';
|
||
import { canAssignToOthers } from '@/lib/assign-access';
|
||
import { useAuthStore } from '@/stores/auth';
|
||
import type { OrgUserDto, ProjectCardDto } from '@mento/shared';
|
||
import {
|
||
Affix as AAffix,
|
||
Alert as AAlert,
|
||
Breadcrumb as ABreadcrumb,
|
||
BreadcrumbItem as ABreadcrumbItem,
|
||
Button as AButton,
|
||
Card as ACard,
|
||
Col as ACol,
|
||
Descriptions as ADescriptions,
|
||
DescriptionsItem as ADescriptionsItem,
|
||
Divider as ADivider,
|
||
Flex as AFlex,
|
||
Form as AForm,
|
||
FormItem as AFormItem,
|
||
Input as AInput,
|
||
Row as ARow,
|
||
Select as ASelect,
|
||
Space as ASpace,
|
||
Spin as ASpin,
|
||
Textarea as ATextarea,
|
||
Typography,
|
||
} from 'ant-design-vue';
|
||
import {
|
||
CheckOutlined,
|
||
CloseOutlined,
|
||
DeleteOutlined,
|
||
FileTextOutlined,
|
||
SaveOutlined,
|
||
} from '@ant-design/icons-vue';
|
||
|
||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||
|
||
const props = withDefaults(
|
||
defineProps<{
|
||
embedded?: boolean;
|
||
cardId?: string;
|
||
initialBoardId?: string;
|
||
initialStageId?: string;
|
||
}>(),
|
||
{ embedded: false },
|
||
);
|
||
|
||
const emit = defineEmits<{
|
||
saved: [];
|
||
cancel: [];
|
||
}>();
|
||
|
||
const auth = useAuthStore();
|
||
const canAssignOthers = computed(() => canAssignToOthers(auth.user, 'projects'));
|
||
const route = useRoute();
|
||
const router = useRouter();
|
||
const id = computed(() => {
|
||
if (props.embedded) return props.cardId ?? '';
|
||
return String(route.params.id || '');
|
||
});
|
||
const isNew = computed(() => (props.embedded ? !props.cardId : route.name === 'project-card-new'));
|
||
const pageTitle = computed(() => (isNew.value ? 'کارت جدید' : 'ویرایش کارت'));
|
||
|
||
const loading = ref(false);
|
||
const saving = ref(false);
|
||
const error = ref('');
|
||
const title = ref('');
|
||
const notes = ref('');
|
||
const tagsRaw = ref('');
|
||
const selectedAssigneeIds = ref<string[]>([]);
|
||
const users = ref<OrgUserDto[]>([]);
|
||
|
||
const userOptions = computed(() =>
|
||
users.value.map((u) => ({ value: u.id, label: u.fullName })),
|
||
);
|
||
|
||
const parsedTags = computed(() => parseTags(tagsRaw.value));
|
||
|
||
const summaryItems = computed(() => [
|
||
{ label: 'عنوان', value: title.value.trim() || '—' },
|
||
{ label: 'برچسبها', value: parsedTags.value.length ? parsedTags.value.join('، ') : '—' },
|
||
{
|
||
label: 'مسئولان',
|
||
value: selectedAssigneeIds.value.length
|
||
? users.value
|
||
.filter((u) => selectedAssigneeIds.value.includes(u.id))
|
||
.map((u) => u.fullName)
|
||
.join('، ') || `${selectedAssigneeIds.value.length} نفر`
|
||
: '—',
|
||
},
|
||
{ label: 'یادداشت', value: notes.value.trim() ? 'دارد' : 'ندارد' },
|
||
]);
|
||
|
||
const canSave = computed(() => !saving.value && title.value.trim().length >= 2);
|
||
|
||
function parseTags(raw: string): string[] {
|
||
return [
|
||
...new Set(
|
||
raw
|
||
.split(/[,،#\n]/)
|
||
.map((t) => t.trim())
|
||
.filter(Boolean),
|
||
),
|
||
].slice(0, 40);
|
||
}
|
||
|
||
async function loadUsers() {
|
||
try {
|
||
const { data } = await api.get<OrgUserDto[]>('/users');
|
||
users.value = data;
|
||
} catch {
|
||
users.value = [];
|
||
}
|
||
}
|
||
|
||
async function load() {
|
||
if (isNew.value) return;
|
||
const cardId = id.value;
|
||
if (!cardId) return;
|
||
loading.value = true;
|
||
error.value = '';
|
||
try {
|
||
const { data } = await api.get<ProjectCardDto>(`/project-cards/${cardId}`);
|
||
title.value = data.title;
|
||
notes.value = data.notes ?? '';
|
||
tagsRaw.value = (data.tags ?? []).join('، ');
|
||
selectedAssigneeIds.value = (data.assignees ?? []).map((a) => a.id);
|
||
} catch (e) {
|
||
if (axios.isAxiosError(e) && !e.response) {
|
||
error.value = 'اتصال به API برقرار نشد.';
|
||
} else {
|
||
error.value = 'بارگذاری کارت ناموفق بود.';
|
||
}
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
async function save() {
|
||
if (title.value.trim().length < 2 || saving.value) return;
|
||
saving.value = true;
|
||
error.value = '';
|
||
try {
|
||
if (isNew.value) {
|
||
const boardId = props.embedded
|
||
? props.initialBoardId
|
||
: typeof route.query.boardId === 'string'
|
||
? route.query.boardId
|
||
: undefined;
|
||
const { data } = await api.post<ProjectCardDto>('/project-cards', {
|
||
title: title.value.trim(),
|
||
notes: notes.value.trim() || undefined,
|
||
tags: parseTags(tagsRaw.value),
|
||
assigneeIds: selectedAssigneeIds.value,
|
||
boardId,
|
||
stageId: props.embedded ? props.initialStageId : undefined,
|
||
personId:
|
||
typeof route.query.personId === 'string' ? route.query.personId : undefined,
|
||
});
|
||
if (props.embedded) {
|
||
emit('saved');
|
||
return;
|
||
}
|
||
await router.replace({ name: 'project-card-detail', params: { id: data.id } });
|
||
} else {
|
||
await api.patch(`/project-cards/${id.value}`, {
|
||
title: title.value.trim(),
|
||
notes: notes.value.trim() || null,
|
||
tags: parseTags(tagsRaw.value),
|
||
assigneeIds: selectedAssigneeIds.value,
|
||
});
|
||
if (props.embedded) {
|
||
emit('saved');
|
||
return;
|
||
}
|
||
}
|
||
} catch {
|
||
error.value = 'ذخیره کارت ناموفق بود.';
|
||
} finally {
|
||
saving.value = false;
|
||
}
|
||
}
|
||
|
||
async function removeCard() {
|
||
if (isNew.value || !id.value) return;
|
||
if (!confirm('کارت حذف شود؟')) return;
|
||
try {
|
||
await api.delete(`/project-cards/${id.value}`);
|
||
if (props.embedded) {
|
||
emit('saved');
|
||
return;
|
||
}
|
||
await router.push({ name: 'projects' });
|
||
} catch {
|
||
error.value = 'حذف کارت ناموفق بود.';
|
||
}
|
||
}
|
||
|
||
function cancel() {
|
||
if (props.embedded) {
|
||
emit('cancel');
|
||
return;
|
||
}
|
||
void router.push({ name: 'projects' });
|
||
}
|
||
|
||
onMounted(async () => {
|
||
await loadUsers();
|
||
await load();
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<a-flex vertical :gap="16">
|
||
<a-flex v-if="embedded" wrap="wrap" :gap="8">
|
||
<a-button type="primary" :loading="saving" :disabled="!canSave" @click="save">
|
||
<template #icon><SaveOutlined /></template>
|
||
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
|
||
</a-button>
|
||
<a-button @click="cancel">انصراف</a-button>
|
||
<a-button v-if="!isNew" danger @click="removeCard">
|
||
<template #icon><DeleteOutlined /></template>
|
||
حذف
|
||
</a-button>
|
||
</a-flex>
|
||
|
||
<a-flex
|
||
v-else
|
||
justify="space-between"
|
||
align="flex-start"
|
||
wrap="wrap"
|
||
:gap="12"
|
||
>
|
||
<a-flex vertical :gap="4">
|
||
<a-breadcrumb>
|
||
<a-breadcrumb-item>
|
||
<RouterLink :to="{ name: 'projects' }">پروژهها</RouterLink>
|
||
</a-breadcrumb-item>
|
||
<a-breadcrumb-item>{{ pageTitle }}</a-breadcrumb-item>
|
||
</a-breadcrumb>
|
||
<a-flex align="center" :gap="8">
|
||
<FileTextOutlined />
|
||
<a-typography-title :level="3" :style="{ margin: 0 }">{{ pageTitle }}</a-typography-title>
|
||
</a-flex>
|
||
</a-flex>
|
||
<a-space wrap>
|
||
<a-button type="primary" :loading="saving" :disabled="!canSave" @click="save">
|
||
<template #icon><CheckOutlined /></template>
|
||
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
|
||
</a-button>
|
||
<a-button @click="cancel">
|
||
<template #icon><CloseOutlined /></template>
|
||
انصراف
|
||
</a-button>
|
||
<a-button v-if="!isNew" danger @click="removeCard">
|
||
<template #icon><DeleteOutlined /></template>
|
||
حذف
|
||
</a-button>
|
||
</a-space>
|
||
</a-flex>
|
||
|
||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||
|
||
<a-spin :spinning="loading">
|
||
<a-row v-if="!loading" :gutter="[24, 24]" align="top">
|
||
<a-col :xs="24" :lg="17" :xl="18">
|
||
<a-card :bordered="false">
|
||
<a-form layout="vertical" @finish="save">
|
||
<a-divider orientation="right">اطلاعات کارت</a-divider>
|
||
<a-row :gutter="[16, 0]">
|
||
<a-col :xs="24" :md="12">
|
||
<a-form-item label="عنوان" required>
|
||
<a-input
|
||
v-model:value="title"
|
||
:maxlength="200"
|
||
placeholder="عنوان کارت"
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :xs="24" :md="12">
|
||
<a-form-item label="برچسبها">
|
||
<a-input
|
||
v-model:value="tagsRaw"
|
||
placeholder="فوری، پشتیبانی (با کاما جدا کنید)"
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :span="24">
|
||
<a-form-item label="مسئولان کارت">
|
||
<a-select
|
||
v-model:value="selectedAssigneeIds"
|
||
mode="multiple"
|
||
:options="userOptions"
|
||
:disabled="!canAssignOthers"
|
||
placeholder="انتخاب مسئولان"
|
||
style="width: 100%"
|
||
option-filter-prop="label"
|
||
/>
|
||
<a-typography-text type="secondary">
|
||
<template v-if="canAssignOthers">
|
||
میتوانید چند نفر را انتخاب کنید.
|
||
</template>
|
||
<template v-else>
|
||
بدون دسترسی «تخصیص کارت پروژه به دیگران» کارت فقط برای خودتان ثبت میشود.
|
||
</template>
|
||
</a-typography-text>
|
||
</a-form-item>
|
||
</a-col>
|
||
</a-row>
|
||
|
||
<a-divider orientation="right">یادداشت</a-divider>
|
||
<a-row :gutter="[16, 0]">
|
||
<a-col :span="24">
|
||
<a-form-item label="یادداشت">
|
||
<a-textarea
|
||
v-model:value="notes"
|
||
:rows="5"
|
||
:maxlength="4000"
|
||
style="width: 100%"
|
||
show-count
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
</a-row>
|
||
|
||
<a-flex v-if="!embedded" justify="flex-end" wrap="wrap" :gap="8">
|
||
<a-button type="primary" html-type="submit" :loading="saving" :disabled="!canSave">
|
||
<template #icon><SaveOutlined /></template>
|
||
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
|
||
</a-button>
|
||
<a-button v-if="!isNew" danger @click="removeCard">
|
||
<template #icon><DeleteOutlined /></template>
|
||
حذف
|
||
</a-button>
|
||
</a-flex>
|
||
</a-form>
|
||
</a-card>
|
||
</a-col>
|
||
|
||
<a-col :xs="24" :lg="7" :xl="6">
|
||
<a-affix :offset-top="24">
|
||
<a-card title="خلاصه کارت" :bordered="false">
|
||
<a-descriptions :column="1" size="small" bordered>
|
||
<a-descriptions-item
|
||
v-for="item in summaryItems"
|
||
:key="item.label"
|
||
:label="item.label"
|
||
>
|
||
{{ item.value }}
|
||
</a-descriptions-item>
|
||
</a-descriptions>
|
||
</a-card>
|
||
</a-affix>
|
||
</a-col>
|
||
</a-row>
|
||
</a-spin>
|
||
</a-flex>
|
||
</template>
|