Keep the design refresh together with DISC, the sales copilot, and treasury behavior. Co-authored-by: Cursor <cursoragent@cursor.com>
1046 lines
38 KiB
Vue
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>
|