Files
CRM_Montzar/apps/web/src/views/DealFormView.vue
T
MahdiandCursor 36eda8653e merge: bring Ant Design UI from dev into main
Keep the design refresh together with DISC, the sales copilot, and treasury behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 21:33:56 +03:30

1046 lines
38 KiB
Vue

<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import {
Affix as AAffix,
Alert as AAlert,
Button as AButton,
Card as ACard,
Col as ACol,
Collapse as ACollapse,
CollapsePanel as ACollapsePanel,
Descriptions as ADescriptions,
DescriptionsItem as ADescriptionsItem,
Divider as ADivider,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Input as AInput,
InputNumber as AInputNumber,
Modal as AModal,
Row as ARow,
Segmented as ASegmented,
Select as ASelect,
Space,
Space as ASpace,
Spin as ASpin,
Textarea as ATextarea,
Typography,
} from 'ant-design-vue';
import {
ArrowLeftOutlined,
CheckOutlined,
DeleteOutlined,
FileTextOutlined,
PlusOutlined,
UserAddOutlined,
} from '@ant-design/icons-vue';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import { canUseBehavior } from '@/lib/behavior-access';
import { toFaDigits } from '@/lib/jalali';
import { currencySuffix, formatMoney } from '@/lib/money';
import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
import EntityTimeline from '@/components/EntityTimeline.vue';
import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import MoneyInput from '@/components/MoneyInput.vue';
import UserMultiSelect from '@/components/UserMultiSelect.vue';
import PersonFormView from '@/views/PersonFormView.vue';
import type {
CompanyDto,
CreateDealRequest,
DealDto,
DealLineItemInput,
OrganizationDto,
OrgUserDto,
PaginatedResponse,
PersonDto,
PipelineBoardDto,
PipelineDto,
ProductDto,
} from '@mento/shared';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const ASpaceCompact = Space.Compact;
type LineDraft = {
key: string;
productId: string;
name: string;
quantity: string;
unitPrice: string;
};
type ContactKind = 'person' | 'company';
type DealHealth = 'normal' | 'stale' | 'rotten';
const props = withDefaults(
defineProps<{
embedded?: boolean;
dealId?: string;
initialPipelineId?: string;
initialStageId?: string;
}>(),
{ embedded: false },
);
const emit = defineEmits<{
saved: [];
cancel: [];
}>();
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const canBehavior = computed(() => canUseBehavior(auth.user));
const id = computed(() => {
if (props.embedded) return props.dealId ?? '';
return typeof route.params.id === 'string' ? route.params.id : '';
});
const isNew = computed(() => (props.embedded ? !props.dealId : route.name === 'deal-new'));
const pageTitle = computed(() => (isNew.value ? 'ایجاد معامله جدید' : 'ویرایش معامله'));
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const showOthers = ref(true);
const othersKeys = ref<string[]>(['others']);
const productsOpen = ref(false);
const contactKind = ref<ContactKind>('person');
const personQuery = ref('');
const personResults = ref<PersonDto[] | null>(null);
const personSearching = ref(false);
const newPersonOpen = ref(false);
let personSearchTimer: ReturnType<typeof setTimeout> | undefined;
let personSearchSeq = 0;
const people = ref<PersonDto[]>([]);
const companies = ref<CompanyDto[]>([]);
const products = ref<ProductDto[]>([]);
const pipelines = ref<PipelineDto[]>([]);
const stages = ref<PipelineBoardDto['stages']>([]);
const users = ref<OrgUserDto[]>([]);
const title = ref('');
const amount = ref('');
const currency = ref('IRR');
const enabledCurrencies = ref<string[]>(['IRR']);
const source = ref('');
const notes = ref('');
const tagsRaw = ref('');
const health = ref<DealHealth>('normal');
const expectedCloseAt = ref('');
const personId = ref('');
const companyId = ref('');
const stageId = ref('');
const pipelineId = ref('');
const ownerId = ref('');
const selectedAssigneeIds = ref<string[]>([]);
const lostReason = ref('');
const lostNote = ref('');
const dealCode = ref('');
const probability = ref('');
const probabilityNum = computed({
get: () => (probability.value === '' ? undefined : Number(probability.value)),
set: (v: number | string | null | undefined) => {
if (v == null || v === '' || Number.isNaN(Number(v))) probability.value = '';
else probability.value = String(v);
},
});
const sourceDetail = ref('');
const referrer = ref('');
const closedAt = ref('');
const createdByName = ref('');
const sourceRef = ref('');
const status = ref('');
const customFields = ref<Record<string, unknown>>({});
const lines = ref<LineDraft[]>([]);
/** تعداد اقلام ذخیره‌شده؛ برای حذف همهٔ اقلام باید آرایهٔ خالی فرستاده شود */
const savedLineCount = ref(0);
const nextDueDate = ref('');
const nextDueTime = ref('10:30');
const nextDuration = ref(30);
const selectedPerson = computed(() => people.value.find((p) => p.id === personId.value) ?? null);
const selectedCompany = computed(() => companies.value.find((c) => c.id === companyId.value) ?? null);
const selectedOwner = computed(() => users.value.find((u) => u.id === ownerId.value) ?? null);
const personOptions = computed(() => personResults.value ?? people.value);
const contactKindOptions = [
{ value: 'person' as const, label: 'شخص' },
{ value: 'company' as const, label: 'شرکت' },
];
const personSelectOptions = computed(() =>
personOptions.value.map((p) => ({
value: p.id,
label: p.company?.name ? `${p.fullName} · ${p.company.name}` : p.fullName,
})),
);
const companySelectOptions = computed(() =>
companies.value.map((c) => ({ value: c.id, label: c.name })),
);
const ownerSelectOptions = computed(() =>
users.value.map((u) => ({ value: u.id, label: u.fullName })),
);
const currencySelectOptions = computed(() =>
enabledCurrencies.value.map((code) => ({
value: code,
label: `${currencySuffix(code)} (${code})`,
})),
);
const pipelineSelectOptions = computed(() =>
pipelines.value.map((p) => ({ value: p.id, label: p.name })),
);
const stageSelectOptions = computed(() =>
stages.value.map((s) => ({ value: s.id, label: s.name })),
);
const productSelectOptions = computed(() =>
products.value.map((p) => ({ value: p.id, label: p.name })),
);
const healthSelectOptions = [
{ value: 'normal', label: 'عادی' },
{ value: 'stale', label: 'راکد' },
{ value: 'rotten', label: 'فاسد' },
];
const statusLabel = computed(() =>
status.value === 'won'
? 'برنده'
: status.value === 'lost'
? 'از دست‌رفته'
: status.value
? 'باز'
: '—',
);
function rememberPerson(p: PersonDto) {
if (!people.value.some((x) => x.id === p.id)) people.value = [p, ...people.value];
}
async function ensurePersonLoaded(pid: string) {
if (!pid || people.value.some((p) => p.id === pid)) return;
try {
const { data } = await api.get<PersonDto>(`/people/${pid}`);
rememberPerson(data);
} catch {
/* person may have been removed */
}
}
function pickPerson(p: PersonDto) {
rememberPerson(p);
personId.value = p.id;
if (p.company?.id) companyId.value = p.company.id;
}
function clearPerson() {
personId.value = '';
}
function openNewPerson() {
newPersonOpen.value = true;
}
function onPersonSearch(q: string) {
personQuery.value = q;
}
function onPersonSelect(value: unknown) {
const pid = typeof value === 'string' ? value : '';
if (!pid) {
clearPerson();
return;
}
const p = personOptions.value.find((x) => x.id === pid);
if (p) pickPerson(p);
else personId.value = pid;
}
function onPersonCreated(p?: PersonDto) {
newPersonOpen.value = false;
if (!p) return;
personQuery.value = '';
personResults.value = null;
contactKind.value = 'person';
pickPerson(p);
}
watch(personQuery, (raw) => {
if (personSearchTimer) clearTimeout(personSearchTimer);
const q = raw.trim();
if (!q) {
personResults.value = null;
personSearching.value = false;
return;
}
personSearching.value = true;
const seq = ++personSearchSeq;
personSearchTimer = setTimeout(async () => {
try {
const { data } = await api.get<PaginatedResponse<PersonDto>>('/people', {
params: { q, limit: 30 },
});
if (seq === personSearchSeq) personResults.value = data.items;
} catch {
if (seq === personSearchSeq) personResults.value = [];
} finally {
if (seq === personSearchSeq) personSearching.value = false;
}
}, 300);
});
const linesTotal = computed(() =>
lines.value.reduce((sum, line) => {
const q = Number(line.quantity);
const p = Number(line.unitPrice);
if (Number.isNaN(q) || Number.isNaN(p)) return sum;
return sum + q * p;
}, 0),
);
const productsLabel = computed(() =>
lines.value.length ? `${toFaDigits(lines.value.length)} محصول` : 'بدون محصول',
);
watch(personId, (value) => {
if (!value) return;
const person = people.value.find((p) => p.id === value);
if (person?.company?.id && !companyId.value) companyId.value = person.company.id;
});
function parseTags(raw: string) {
return raw
.split(/[,،#\n]+/)
.map((t) => t.trim())
.filter(Boolean);
}
function newLine(): LineDraft {
return {
key: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
productId: '',
name: '',
quantity: '1',
unitPrice: '',
};
}
function addLine() {
productsOpen.value = true;
lines.value.push(newLine());
}
function removeLine(key: string) {
lines.value = lines.value.filter((l) => l.key !== key);
}
function onPickProduct(line: LineDraft) {
const product = products.value.find((p) => p.id === line.productId);
if (!product) return;
line.name = product.name;
if (product.unitPrice != null) line.unitPrice = String(product.unitPrice);
}
function toLineInputs(): DealLineItemInput[] {
return lines.value
.filter((l) => l.name.trim() && l.quantity !== '' && l.unitPrice !== '')
.map((l) => ({
productId: l.productId || null,
name: l.name.trim(),
quantity: Number(l.quantity),
unitPrice: Number(l.unitPrice),
currency: currency.value || 'IRR',
}));
}
async function loadBoard(pid?: string) {
const { data } = await api.get<PipelineBoardDto>('/pipelines/board', {
params: pid ? { pipelineId: pid } : undefined,
});
stages.value = data.stages;
pipelineId.value = data.pipeline.id;
const keep = stages.value.some((s) => s.id === stageId.value);
if (!keep) {
const firstOpen = stages.value.find((s) => !s.isWon && !s.isLost);
stageId.value = firstOpen?.id ?? stages.value[0]?.id ?? '';
}
}
async function onPipelineChange(pid: string) {
pipelineId.value = pid;
await loadBoard(pid);
}
async function load() {
loading.value = true;
error.value = '';
try {
const queryPipelineId =
props.initialPipelineId ||
(typeof route.query.pipelineId === 'string' ? route.query.pipelineId : undefined);
const [peopleRes, companiesRes, pipelinesRes, productsRes, orgRes, usersRes] =
await Promise.all([
api.get<{ items: PersonDto[] }>('/people', { params: { limit: 100 } }),
api.get<{ items: CompanyDto[] }>('/companies', { params: { limit: 100 } }),
api.get<PipelineDto[]>('/pipelines'),
api.get<ProductDto[]>('/products', { params: { activeOnly: true } }),
api.get<OrganizationDto>('/organizations/current'),
api.get<OrgUserDto[]>('/users').catch(() => ({ data: [] as OrgUserDto[] })),
]);
people.value = peopleRes.data.items;
companies.value = companiesRes.data.items;
pipelines.value = pipelinesRes.data;
products.value = productsRes.data;
users.value = usersRes.data;
enabledCurrencies.value = orgRes.data.enabledCurrencies?.length
? orgRes.data.enabledCurrencies
: [orgRes.data.baseCurrency || 'IRR'];
if (!currency.value || !enabledCurrencies.value.includes(currency.value)) {
currency.value = orgRes.data.baseCurrency || 'IRR';
}
await loadBoard(queryPipelineId);
if (!isNew.value && id.value) {
const { data } = await api.get<DealDto>(`/deals/${id.value}`);
title.value = data.title;
amount.value = data.amount == null ? '' : String(data.amount);
currency.value = data.currency || currency.value;
source.value = data.source ?? '';
notes.value = data.notes ?? '';
tagsRaw.value = (data.tags ?? []).join('، ');
health.value = data.isRotten ? 'rotten' : data.isStale ? 'stale' : 'normal';
expectedCloseAt.value = data.expectedCloseAt
? data.expectedCloseAt.slice(0, 10)
: '';
personId.value = data.person?.id ?? '';
companyId.value = data.company?.id ?? '';
contactKind.value = data.person?.id || !data.company?.id ? 'person' : 'company';
if (personId.value) await ensurePersonLoaded(personId.value);
stageId.value = data.stageId;
ownerId.value = data.owner?.id ?? auth.user?.id ?? '';
selectedAssigneeIds.value = (data.assignees ?? []).map((a) => a.id);
lostReason.value = data.lostReason ?? '';
lostNote.value = data.lostNote ?? '';
dealCode.value = data.code ?? '';
probability.value = data.probability == null ? '' : String(data.probability);
sourceDetail.value = data.sourceDetail ?? '';
referrer.value = data.referrer ?? '';
closedAt.value = data.closedAt ? data.closedAt.slice(0, 10) : '';
createdByName.value = data.createdBy?.fullName ?? '';
sourceRef.value = data.importSource === 'didar' && data.sourceRef ? data.sourceRef : '';
status.value = data.status;
customFields.value = { ...(data.customFields ?? {}) };
lines.value = (data.lineItems ?? []).map((item) => ({
key: item.id,
productId: item.productId ?? '',
name: item.name,
quantity: String(item.quantity),
unitPrice: String(item.unitPrice),
}));
savedLineCount.value = lines.value.length;
productsOpen.value = lines.value.length > 0;
if (data.company && !companies.value.some((c) => c.id === data.company!.id)) {
companies.value = [{ id: data.company.id, name: data.company.name } as CompanyDto, ...companies.value];
}
if (data.pipelineId !== pipelineId.value) {
await loadBoard(data.pipelineId);
stageId.value = data.stageId;
}
} else {
ownerId.value = auth.user?.id ?? users.value[0]?.id ?? '';
selectedAssigneeIds.value = [];
if (typeof route.query.personId === 'string' && route.query.personId) {
personId.value = route.query.personId;
contactKind.value = 'person';
const person = people.value.find((p) => p.id === personId.value);
if (person?.company?.id) companyId.value = person.company.id;
}
if (typeof route.query.companyId === 'string' && route.query.companyId) {
companyId.value = route.query.companyId;
if (!personId.value) contactKind.value = 'company';
}
const incomingStage = props.initialStageId ||
(typeof route.query.stageId === 'string' ? route.query.stageId : '');
if (incomingStage && stages.value.some((s) => s.id === incomingStage)) {
stageId.value = incomingStage;
}
}
} catch {
error.value = 'بارگذاری فرم معامله ناموفق بود.';
} finally {
loading.value = false;
}
}
function payload(): CreateDealRequest {
const lineItems = toLineInputs();
const sendLineItems = lineItems.length > 0 || savedLineCount.value > 0;
return {
title: title.value,
amount:
lineItems.length > 0
? linesTotal.value
: amount.value === ''
? null
: Number(amount.value),
source: source.value || undefined,
sourceDetail: sourceDetail.value.trim() || null,
referrer: referrer.value.trim() || null,
code: dealCode.value.trim() || null,
probability: probability.value === '' ? null : Number(probability.value),
notes: notes.value || undefined,
tags: parseTags(tagsRaw.value),
isStale: health.value === 'stale',
isRotten: health.value === 'rotten',
expectedCloseAt: expectedCloseAt.value || null,
personId: personId.value || null,
companyId: companyId.value || null,
ownerId: ownerId.value || null,
assigneeIds: selectedAssigneeIds.value,
pipelineId: pipelineId.value || undefined,
stageId: stageId.value || undefined,
customFields: customFields.value,
currency: currency.value || 'IRR',
...(sendLineItems ? { lineItems } : {}),
};
}
async function createFollowUp(dealId: string) {
if (!nextDueDate.value) return;
const due = new Date(`${nextDueDate.value}T${nextDueTime.value || '10:30'}:00`);
if (Number.isNaN(due.getTime())) return;
await api.post('/activities', {
title: `پیگیری: ${title.value}`,
type: 'task',
dueAt: due.toISOString(),
durationMinutes: nextDuration.value || null,
personId: personId.value || null,
companyId: companyId.value || null,
dealId,
assigneeId: ownerId.value || null,
});
}
function validate() {
if (title.value.trim().length < 2) return 'عنوان معامله را بنویسید.';
return '';
}
function cancel() {
if (props.embedded) {
emit('cancel');
return;
}
void router.push({ name: 'pipeline' });
}
async function submit() {
const message = validate();
if (message) {
error.value = message;
return;
}
saving.value = true;
error.value = '';
try {
if (isNew.value) {
const { data } = await api.post<DealDto>('/deals', payload());
try {
await createFollowUp(data.id);
} catch {
/* deal saved; follow-up is optional */
}
if (props.embedded) {
emit('saved');
return;
}
await router.replace({ name: 'deal-detail', params: { id: data.id } });
} else {
const { pipelineId: _pipelineId, stageId: moveStageId, ...updateBody } = payload();
await api.patch(`/deals/${id.value}`, {
...updateBody,
lostReason: lostReason.value || null,
lostNote: lostNote.value.trim() || null,
closedAt: closedAt.value || null,
});
if (moveStageId) {
await api.patch(`/deals/${id.value}/move`, { stageId: moveStageId });
}
try {
await createFollowUp(id.value);
} catch {
/* deal saved; follow-up is optional */
}
if (props.embedded) {
emit('saved');
return;
}
await router.push({ name: 'pipeline' });
}
} catch (e) {
if (axios.isAxiosError(e)) {
if (!e.response) {
error.value = 'اتصال به سرور برقرار نشد.';
} else {
const msg = (e.response.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ذخیره ناموفق بود');
}
} else {
error.value = 'ذخیره ناموفق بود';
}
} finally {
saving.value = false;
}
}
watch(showOthers, (open) => {
othersKeys.value = open ? ['others'] : [];
});
watch(othersKeys, (keys) => {
showOthers.value = keys.includes('others');
});
onMounted(load);
</script>
<template>
<div dir="rtl">
<a-spin :spinning="loading">
<a-flex
v-if="!embedded"
justify="space-between"
align="center"
wrap="wrap"
:gap="12"
:style="{ marginBottom: '16px' }"
>
<div>
<a-space :size="8">
<RouterLink :to="{ name: 'pipeline' }" custom v-slot="{ navigate }">
<a-button type="text" @click="navigate">
<template #icon><ArrowLeftOutlined /></template>
</a-button>
</RouterLink>
<a-typography-title :level="3" :style="{ margin: 0 }">{{ pageTitle }}</a-typography-title>
</a-space>
<div>
<a-typography-text type="secondary">
<RouterLink :to="{ name: 'pipeline' }">مسیر فروش</RouterLink>
· {{ pageTitle }}
</a-typography-text>
</div>
</div>
<a-space wrap :size="8">
<RouterLink
v-if="!isNew && id"
:to="{ name: 'quote-new', query: { fromDealId: id } }"
custom
v-slot="{ navigate }"
>
<a-button @click="navigate">صدور پیش‌فاکتور</a-button>
</RouterLink>
<RouterLink
v-if="!isNew && id"
:to="{ name: 'invoice-new', query: { fromDealId: id } }"
custom
v-slot="{ navigate }"
>
<a-button @click="navigate">صدور فاکتور</a-button>
</RouterLink>
<RouterLink
v-if="!isNew && personId && canBehavior"
:to="{ name: 'person-detail', params: { id: personId }, query: { tab: 'copilot', dealId: id } }"
custom
v-slot="{ navigate }"
>
<a-button @click="navigate">کمک برای ادامه مذاکره</a-button>
</RouterLink>
<a-button :disabled="saving" @click="cancel">انصراف</a-button>
<a-button type="primary" :loading="saving" @click="submit">
<template #icon><CheckOutlined /></template>
{{ saving ? 'در حال ذخیره…' : 'ذخیره معامله' }}
</a-button>
</a-space>
</a-flex>
<a-alert v-if="error" type="error" show-icon :message="error" :style="{ marginBottom: '16px' }" />
<a-form layout="vertical" @finish="submit">
<a-row :gutter="[16, 16]">
<a-col :xs="24" :lg="embedded ? 24 : 17" :xl="embedded ? 24 : 18">
<a-card :bordered="false">
<a-divider orientation="right">اطلاعات اصلی</a-divider>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="نوع شخص / شرکت" required>
<a-segmented v-model:value="contactKind" block :options="contactKindOptions" />
</a-form-item>
</a-col>
<a-col v-show="contactKind === 'person'" :xs="24" :md="12">
<a-form-item label="شخص مرتبط" required>
<a-space direction="vertical" :style="{ width: '100%' }" :size="8">
<a-select
:value="personId || undefined"
show-search
allow-clear
:filter-option="false"
:options="personSelectOptions"
:loading="personSearching"
placeholder="جستجو و انتخاب شخص"
style="width: 100%"
@search="onPersonSearch"
@update:value="onPersonSelect"
/>
<a-button type="link" @click="openNewPerson">
<template #icon><UserAddOutlined /></template>
افزودن شخص جدید
</a-button>
</a-space>
</a-form-item>
</a-col>
<a-col v-show="contactKind === 'company'" :xs="24" :md="12">
<a-form-item label="شرکت مرتبط" required>
<a-select
v-model:value="companyId"
show-search
allow-clear
option-filter-prop="label"
:options="companySelectOptions"
placeholder="انتخاب شرکت"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<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-space-compact block style="width: 100%">
<MoneyInput
v-model="amount"
:decimals="0"
:readonly="lines.length > 0"
:placeholder="lines.length ? 'جمع ردیف‌ها' : 'مبلغ را وارد کنید'"
/>
<a-select
v-model:value="currency"
:options="currencySelectOptions"
:style="{ width: '120px' }"
/>
</a-space-compact>
<a-typography-text v-if="lines.length" type="secondary">
جمع ردیف‌ها: {{ formatMoney(linesTotal, currency) }}
</a-typography-text>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="پیش‌بینی تاریخ فروش">
<JalaliDateInput v-model="expectedCloseAt" placeholder="انتخاب تاریخ" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="فعالیت بعدی">
<JalaliDateInput v-model="nextDueDate" placeholder="تاریخ پیگیری" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="ساعت">
<JalaliDateInput v-model="nextDueTime" mode="time" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مدت (دقیقه)">
<a-input-number
v-model:value="nextDuration"
:min="5"
:step="5"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="مسئول انجام این معامله" required>
<a-select
v-model:value="ownerId"
show-search
option-filter-prop="label"
:options="ownerSelectOptions"
placeholder="انتخاب مسئول"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="مسئولان مشترک">
<UserMultiSelect v-model="selectedAssigneeIds" :users="users" />
<a-typography-text type="secondary">علاوه بر مسئول اصلی.</a-typography-text>
</a-form-item>
</a-col>
</a-row>
<a-divider orientation="right">سایر فیلدها و اطلاعات تکمیلی</a-divider>
<a-collapse
v-model:activeKey="othersKeys"
bordered
:style="{ borderRadius: '8px', overflow: 'hidden' }"
>
<a-collapse-panel key="others" header="نمایش فیلدهای تکمیلی">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="8">
<a-form-item label="واحد ارزی">
<a-select
v-model:value="currency"
:options="currencySelectOptions"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مسیر فروش">
<a-select
:value="pipelineId"
:options="pipelineSelectOptions"
style="width: 100%"
@update:value="(v) => onPipelineChange(String(v))"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مرحله" required>
<a-select
v-model:value="stageId"
:options="stageSelectOptions"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="وضعیت سلامت معامله">
<a-select
v-model:value="health"
:options="healthSelectOptions"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="شیوه آشنایی">
<a-input v-model:value="source" placeholder="وب‌سایت، معرفی، نمایشگاه…" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="شیوه آشنایی دلخواه">
<a-input v-model:value="sourceDetail" placeholder="توضیح آزاد" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="معرف">
<a-input v-model:value="referrer" placeholder="نام معرف" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="کد دستی معامله">
<a-input v-model:value="dealCode" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="احتمال موفقیت (درصد)">
<a-input-number
v-model:value="probabilityNum"
:min="0"
:max="100"
style="width: 100%"
/>
</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 v-if="!isNew" :xs="24" :md="12">
<a-form-item label="تاریخ انجام معامله">
<JalaliDateInput v-model="closedAt" placeholder="انتخاب تاریخ" />
</a-form-item>
</a-col>
<a-col v-if="!isNew" :xs="24" :md="12">
<a-form-item label="دلیل شکست">
<a-input
v-model:value="lostReason"
placeholder="علت از دست رفتن معامله…"
/>
</a-form-item>
</a-col>
<a-col v-if="!isNew" :xs="24" :md="12">
<a-form-item label="یادداشت شکست">
<a-input v-model:value="lostNote" />
</a-form-item>
</a-col>
<a-col v-if="!isNew && (createdByName || sourceRef)" :span="24">
<a-typography-text type="secondary">
<span v-if="createdByName">ثبت‌کننده: {{ createdByName }}</span>
<span v-if="createdByName && sourceRef"> · </span>
<span v-if="sourceRef">کد دیدار: {{ toFaDigits(sourceRef) }}</span>
</a-typography-text>
</a-col>
</a-row>
<a-divider orientation="right">محصولات / خدمات</a-divider>
<a-space wrap :size="8" :style="{ marginBottom: '12px' }">
<a-button @click="productsOpen = !productsOpen">
<template #icon><FileTextOutlined /></template>
{{ productsOpen ? 'پنهان کردن اقلام' : 'مدیریت اقلام معامله' }}
({{ productsLabel }})
</a-button>
<a-button v-if="productsOpen" type="primary" @click="addLine">
<template #icon><PlusOutlined /></template>
افزودن ردیف
</a-button>
</a-space>
<template v-if="productsOpen">
<a-row
v-for="line in lines"
:key="line.key"
:gutter="[8, 8]"
align="middle"
:style="{ marginBottom: '8px' }"
>
<a-col :xs="24" :md="6">
<a-select
v-model:value="line.productId"
allow-clear
:options="[{ value: '', label: 'محصول از کاتالوگ…' }, ...productSelectOptions]"
style="width: 100%"
@change="onPickProduct(line)"
/>
</a-col>
<a-col :xs="24" :md="6">
<a-input v-model:value="line.name" placeholder="نام ردیف" />
</a-col>
<a-col :xs="12" :md="4">
<a-input v-model:value="line.quantity" type="number" placeholder="تعداد" />
</a-col>
<a-col :xs="12" :md="5">
<MoneyInput v-model="line.unitPrice" :decimals="0" placeholder="قیمت واحد" />
</a-col>
<a-col :xs="24" :md="3">
<a-button danger block @click="removeLine(line.key)">
<template #icon><DeleteOutlined /></template>
</a-button>
</a-col>
</a-row>
<a-typography-text v-if="!lines.length" type="secondary">
می‌توانید فقط مبلغ کل را وارد کنید یا با «افزودن ردیف» اقلام را مشخص کنید.
</a-typography-text>
</template>
<CustomFieldsSection entity-type="deal" v-model="customFields" />
</a-collapse-panel>
</a-collapse>
<a-divider orientation="right">توضیحات و یادداشت</a-divider>
<a-form-item label="یادداشت">
<a-textarea
v-model:value="notes"
:rows="6"
placeholder="جزئیات معامله، توافق‌ها یا یادداشت مرتبط…"
/>
</a-form-item>
</a-card>
<RelatedTasksPanel
v-if="!isNew && id"
:style="{ marginTop: '16px' }"
:entity="{ type: 'deal', id, label: title }"
/>
<EntityTimeline
v-if="!isNew && id"
:style="{ marginTop: '16px' }"
entity-type="deal"
:entity-id="id"
/>
<a-space v-if="embedded" :style="{ marginTop: '16px' }" :size="8">
<a-button type="primary" :loading="saving" @click="submit">
{{ saving ? 'در حال ذخیره…' : 'ذخیره معامله' }}
</a-button>
<a-button :disabled="saving" @click="cancel">انصراف</a-button>
</a-space>
</a-col>
<a-col v-if="!embedded" :xs="24" :lg="7" :xl="6">
<a-affix :offset-top="24">
<a-card :bordered="false" title="خلاصه معامله">
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="عنوان">{{ title || '—' }}</a-descriptions-item>
<a-descriptions-item label="مبلغ">
{{
lines.length
? formatMoney(linesTotal, currency)
: amount
? formatMoney(Number(amount), currency)
: '—'
}}
</a-descriptions-item>
<a-descriptions-item label="مخاطب">
{{
contactKind === 'person'
? selectedPerson?.fullName || '—'
: selectedCompany?.name || '—'
}}
</a-descriptions-item>
<a-descriptions-item label="مسئول">
{{ selectedOwner?.fullName || '—' }}
</a-descriptions-item>
<a-descriptions-item label="مرحله">
{{ stages.find((s) => s.id === stageId)?.name || '—' }}
</a-descriptions-item>
<a-descriptions-item v-if="!isNew" label="وضعیت">
{{ statusLabel }}
</a-descriptions-item>
</a-descriptions>
<a-space
direction="vertical"
:style="{ width: '100%', marginTop: '16px' }"
:size="8"
>
<a-button type="primary" block :loading="saving" @click="submit">
{{ saving ? 'در حال ذخیره…' : 'ذخیره معامله' }}
</a-button>
<a-button block :disabled="saving" @click="cancel">انصراف</a-button>
</a-space>
</a-card>
</a-affix>
</a-col>
</a-row>
</a-form>
</a-spin>
<a-modal
v-model:open="newPersonOpen"
title="شخص جدید"
:footer="null"
destroy-on-close
width="720px"
@cancel="newPersonOpen = false"
>
<PersonFormView
v-if="newPersonOpen"
embedded
:initial-query="personQuery"
@saved="onPersonCreated"
@cancel="newPersonOpen = false"
/>
</a-modal>
</div>
</template>