2026-08-30 09:42:07 +03:30
|
|
|
|
<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';
|
2026-11-03 13:15:21 +03:30
|
|
|
|
import { canAssignToOthers } from '@/lib/assign-access';
|
|
|
|
|
|
import { useAuthStore } from '@/stores/auth';
|
2026-09-26 14:59:48 +03:30
|
|
|
|
import type { OrgUserDto, ProjectCardDto } from '@mento/shared';
|
2026-11-04 10:01:11 +03:30
|
|
|
|
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;
|
2026-08-30 09:42:07 +03:30
|
|
|
|
|
2026-09-15 19:13:30 +03:30
|
|
|
|
const props = withDefaults(
|
|
|
|
|
|
defineProps<{
|
|
|
|
|
|
embedded?: boolean;
|
|
|
|
|
|
cardId?: string;
|
|
|
|
|
|
initialBoardId?: string;
|
|
|
|
|
|
initialStageId?: string;
|
|
|
|
|
|
}>(),
|
|
|
|
|
|
{ embedded: false },
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits<{
|
|
|
|
|
|
saved: [];
|
|
|
|
|
|
cancel: [];
|
|
|
|
|
|
}>();
|
|
|
|
|
|
|
2026-11-03 13:15:21 +03:30
|
|
|
|
const auth = useAuthStore();
|
|
|
|
|
|
const canAssignOthers = computed(() => canAssignToOthers(auth.user, 'projects'));
|
2026-08-30 09:42:07 +03:30
|
|
|
|
const route = useRoute();
|
|
|
|
|
|
const router = useRouter();
|
2026-09-15 19:13:30 +03:30
|
|
|
|
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'));
|
2026-09-05 02:29:41 +03:30
|
|
|
|
const pageTitle = computed(() => (isNew.value ? 'کارت جدید' : 'ویرایش کارت'));
|
2026-08-30 09:42:07 +03:30
|
|
|
|
|
|
|
|
|
|
const loading = ref(false);
|
|
|
|
|
|
const saving = ref(false);
|
|
|
|
|
|
const error = ref('');
|
|
|
|
|
|
const title = ref('');
|
|
|
|
|
|
const notes = ref('');
|
2026-09-26 14:59:48 +03:30
|
|
|
|
const tagsRaw = ref('');
|
|
|
|
|
|
const selectedAssigneeIds = ref<string[]>([]);
|
|
|
|
|
|
const users = ref<OrgUserDto[]>([]);
|
|
|
|
|
|
|
2026-11-04 10:01:11 +03:30
|
|
|
|
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);
|
|
|
|
|
|
|
2026-09-26 14:59:48 +03:30
|
|
|
|
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 = [];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-30 09:42:07 +03:30
|
|
|
|
|
|
|
|
|
|
async function load() {
|
|
|
|
|
|
if (isNew.value) return;
|
2026-09-15 19:13:30 +03:30
|
|
|
|
const cardId = id.value;
|
|
|
|
|
|
if (!cardId) return;
|
2026-08-30 09:42:07 +03:30
|
|
|
|
loading.value = true;
|
|
|
|
|
|
error.value = '';
|
|
|
|
|
|
try {
|
2026-09-15 19:13:30 +03:30
|
|
|
|
const { data } = await api.get<ProjectCardDto>(`/project-cards/${cardId}`);
|
2026-08-30 09:42:07 +03:30
|
|
|
|
title.value = data.title;
|
|
|
|
|
|
notes.value = data.notes ?? '';
|
2026-09-26 14:59:48 +03:30
|
|
|
|
tagsRaw.value = (data.tags ?? []).join('، ');
|
|
|
|
|
|
selectedAssigneeIds.value = (data.assignees ?? []).map((a) => a.id);
|
2026-08-30 09:42:07 +03:30
|
|
|
|
} 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) {
|
2026-09-15 19:13:30 +03:30
|
|
|
|
const boardId = props.embedded
|
|
|
|
|
|
? props.initialBoardId
|
|
|
|
|
|
: typeof route.query.boardId === 'string'
|
|
|
|
|
|
? route.query.boardId
|
|
|
|
|
|
: undefined;
|
2026-08-30 09:42:07 +03:30
|
|
|
|
const { data } = await api.post<ProjectCardDto>('/project-cards', {
|
|
|
|
|
|
title: title.value.trim(),
|
|
|
|
|
|
notes: notes.value.trim() || undefined,
|
2026-09-26 14:59:48 +03:30
|
|
|
|
tags: parseTags(tagsRaw.value),
|
|
|
|
|
|
assigneeIds: selectedAssigneeIds.value,
|
2026-08-30 09:42:07 +03:30
|
|
|
|
boardId,
|
2026-09-15 19:13:30 +03:30
|
|
|
|
stageId: props.embedded ? props.initialStageId : undefined,
|
2026-09-02 21:29:00 +03:30
|
|
|
|
personId:
|
|
|
|
|
|
typeof route.query.personId === 'string' ? route.query.personId : undefined,
|
2026-08-30 09:42:07 +03:30
|
|
|
|
});
|
2026-09-15 19:13:30 +03:30
|
|
|
|
if (props.embedded) {
|
|
|
|
|
|
emit('saved');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-08-30 09:42:07 +03:30
|
|
|
|
await router.replace({ name: 'project-card-detail', params: { id: data.id } });
|
|
|
|
|
|
} else {
|
2026-09-15 19:13:30 +03:30
|
|
|
|
await api.patch(`/project-cards/${id.value}`, {
|
2026-08-30 09:42:07 +03:30
|
|
|
|
title: title.value.trim(),
|
|
|
|
|
|
notes: notes.value.trim() || null,
|
2026-09-26 14:59:48 +03:30
|
|
|
|
tags: parseTags(tagsRaw.value),
|
|
|
|
|
|
assigneeIds: selectedAssigneeIds.value,
|
2026-08-30 09:42:07 +03:30
|
|
|
|
});
|
2026-09-15 19:13:30 +03:30
|
|
|
|
if (props.embedded) {
|
|
|
|
|
|
emit('saved');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-08-30 09:42:07 +03:30
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
error.value = 'ذخیره کارت ناموفق بود.';
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
saving.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function removeCard() {
|
2026-09-15 19:13:30 +03:30
|
|
|
|
if (isNew.value || !id.value) return;
|
2026-08-30 09:42:07 +03:30
|
|
|
|
if (!confirm('کارت حذف شود؟')) return;
|
|
|
|
|
|
try {
|
2026-09-15 19:13:30 +03:30
|
|
|
|
await api.delete(`/project-cards/${id.value}`);
|
|
|
|
|
|
if (props.embedded) {
|
|
|
|
|
|
emit('saved');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-08-30 09:42:07 +03:30
|
|
|
|
await router.push({ name: 'projects' });
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
error.value = 'حذف کارت ناموفق بود.';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-05 02:29:41 +03:30
|
|
|
|
function cancel() {
|
2026-09-15 19:13:30 +03:30
|
|
|
|
if (props.embedded) {
|
|
|
|
|
|
emit('cancel');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-09-05 02:29:41 +03:30
|
|
|
|
void router.push({ name: 'projects' });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-26 14:59:48 +03:30
|
|
|
|
onMounted(async () => {
|
|
|
|
|
|
await loadUsers();
|
|
|
|
|
|
await load();
|
|
|
|
|
|
});
|
2026-08-30 09:42:07 +03:30
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<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>
|
2026-09-15 19:13:30 +03:30
|
|
|
|
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
|
2026-11-04 10:01:11 +03:30
|
|
|
|
</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>
|
2026-09-05 02:29:41 +03:30
|
|
|
|
<RouterLink :to="{ name: 'projects' }">پروژهها</RouterLink>
|
2026-11-04 10:01:11 +03:30
|
|
|
|
</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>
|
2026-09-05 02:29:41 +03:30
|
|
|
|
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
|
2026-11-04 10:01:11 +03:30
|
|
|
|
</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>
|
2026-09-05 02:29:41 +03:30
|
|
|
|
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<a-alert v-if="error" type="error" show-icon :message="error" />
|
2026-08-30 09:42:07 +03:30
|
|
|
|
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<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>
|
2026-08-30 09:42:07 +03:30
|
|
|
|
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<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>
|
2026-08-30 09:42:07 +03:30
|
|
|
|
</template>
|