Files
CRM_Montzar/apps/web/src/views/ProjectCardFormView.vue
T
MahdiandCursor cb3a488b21 feat(web): continue Ant settings migration and tighten page chrome
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>
2026-11-04 10:05:28 +03:30

362 lines
11 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>