Files
CRM_Montzar/apps/web/src/views/DealFormView.vue
T

1547 lines
42 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.
This file contains Unicode characters that might be confused with other characters. 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, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
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 UiIcon from '@/components/UiIcon.vue';
import UserAvatar from '@/components/UserAvatar.vue';
import UserMultiSelect from '@/components/UserMultiSelect.vue';
import { UiButton, UiField, UiInput, UiPageHeader, UiSelect } from '@/components/ui';
import type {
CompanyDto,
CreateDealRequest,
DealDto,
DealLineItemInput,
OrganizationDto,
OrgUserDto,
PersonDto,
PipelineBoardDto,
PipelineDto,
ProductDto,
} from '@mento/shared';
type LineDraft = {
key: string;
productId: string;
name: string;
quantity: string;
unitPrice: string;
};
type MenuKey = 'person' | 'company' | 'owner' | 'currency' | '';
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 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 tab = ref<'main' | 'notes'>('main');
const showOthers = ref(true);
const productsOpen = ref(false);
const openMenu = ref<MenuKey>('');
const contactKind = ref<ContactKind>('person');
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 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 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 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)} محصول` : 'بدون محصول',
);
const currencyLabel = computed(() => currencySuffix(currency.value));
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',
}));
}
function bumpDuration(delta: number) {
nextDuration.value = Math.max(5, nextDuration.value + delta);
}
function toggleMenu(key: MenuKey) {
openMenu.value = openMenu.value === key ? '' : key;
}
function closeMenus() {
openMenu.value = '';
}
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';
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),
}));
productsOpen.value = lines.value.length > 0;
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();
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',
lineItems,
};
}
async function createFollowUp(dealId: string) {
if (!isNew.value || !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;
tab.value = 'main';
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 {
await api.patch(`/deals/${id.value}`, {
...payload(),
lostReason: lostReason.value || null,
lostNote: lostNote.value.trim() || null,
closedAt: closedAt.value || null,
});
if (stageId.value) {
await api.patch(`/deals/${id.value}/move`, { stageId: stageId.value });
}
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;
}
}
onMounted(load);
</script>
<template>
<section class="page" :class="{ embedded }" @click="closeMenus">
<UiPageHeader
v-if="!embedded"
:title="pageTitle"
:back-to="{ name: 'pipeline' }"
>
<template #lead>
<span class="crumb">
<RouterLink :to="{ name: 'pipeline' }">کاریز فروش</RouterLink>
<span>›</span>
<strong>{{ pageTitle }}</strong>
</span>
</template>
<template #actions>
<RouterLink
v-if="!isNew && id"
:to="{ name: 'quote-new', query: { fromDealId: id } }"
>
<UiButton variant="outline">صدور پیش‌فاکتور</UiButton>
</RouterLink>
<RouterLink
v-if="!isNew && id"
:to="{ name: 'invoice-new', query: { fromDealId: id } }"
>
<UiButton variant="outline">صدور فاکتور</UiButton>
</RouterLink>
<UiButton variant="primary" :disabled="saving" @click="submit">
<UiIcon name="check" :size="16" />
{{ saving ? 'در حال ذخیره…' : 'ذخیره معامله' }}
</UiButton>
<UiButton variant="ghost" :disabled="saving" @click="cancel">
انصراف
</UiButton>
</template>
</UiPageHeader>
<header v-if="!embedded" class="mobile-head">
<button class="icon-btn" type="button" aria-label="ذخیره" :disabled="saving" @click="submit">
<UiIcon name="check-plain" :size="22" />
</button>
<h1>{{ pageTitle }}</h1>
<button class="icon-btn" type="button" aria-label="بستن" @click="cancel">
<UiIcon name="x" :size="20" />
</button>
</header>
<nav class="tabs">
<button type="button" :class="{ on: tab === 'main' }" @click="tab = 'main'">اطلاعات اصلی</button>
<button type="button" :class="{ on: tab === 'notes' }" @click="tab = 'notes'">توضیحات و یادداشت</button>
</nav>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<form v-else class="form" @submit.prevent="submit">
<div v-show="tab === 'main'" class="stack">
<section class="card main-card">
<h2 class="sec">
<i class="dot" />
اطلاعات اصلی
</h2>
<div class="field kind-field">
<label>نوع شخص / شرکت <i>*</i></label>
<div class="segment">
<button
type="button"
:class="{ on: contactKind === 'person' }"
@click="contactKind = 'person'"
>
<UiIcon name="user" :size="16" />
شخص
</button>
<button
type="button"
:class="{ on: contactKind === 'company' }"
@click="contactKind = 'company'"
>
<UiIcon name="building" :size="16" />
شرکت
</button>
</div>
</div>
<div class="pair contacts">
<div v-show="contactKind === 'person'" class="field">
<label>شخص مرتبط <i>*</i></label>
<button class="picker" type="button" @click.stop="toggleMenu('person')">
<UserAvatar :name="selectedPerson?.fullName || 'شخص'" src="" :size="32" />
<span :class="{ ghost: !selectedPerson }">
{{ selectedPerson?.fullName || 'انتخاب کنید' }}
</span>
<UiIcon name="chevron" :size="16" />
</button>
<ul v-if="openMenu === 'person'" class="menu">
<li>
<button type="button" @click="personId = ''; openMenu = ''">بدون شخص</button>
</li>
<li v-for="p in people" :key="p.id">
<button type="button" @click="personId = p.id; openMenu = ''">
<UserAvatar :name="p.fullName" src="" :size="28" />
<span class="who">
<b>{{ p.fullName }}</b>
<small>{{ p.company?.name || '' }}</small>
</span>
</button>
</li>
</ul>
</div>
<div v-show="contactKind === 'company'" class="field desktop-also">
<label>شرکت مرتبط <i>*</i></label>
<button class="picker" type="button" @click.stop="toggleMenu('company')">
<span class="lead-ico">
<UiIcon name="building" :size="16" />
</span>
<span :class="{ ghost: !selectedCompany }">
{{ selectedCompany?.name || 'انتخاب کنید' }}
</span>
<UiIcon name="chevron" :size="16" />
</button>
<ul v-if="openMenu === 'company'" class="menu">
<li>
<button type="button" @click="companyId = ''; openMenu = ''">بدون شرکت</button>
</li>
<li v-for="c in companies" :key="c.id">
<button type="button" @click="companyId = c.id; openMenu = ''">
<span class="lead-ico">
<UiIcon name="building" :size="14" />
</span>
{{ c.name }}
</button>
</li>
</ul>
</div>
</div>
<div class="field">
<label for="title">عنوان معامله <i>*</i></label>
<div class="control">
<input
id="title"
v-model="title"
required
minlength="2"
placeholder="مثال: فروش نرم‌افزار حسابداری منتک"
/>
<UiIcon name="briefcase" :size="18" />
</div>
</div>
<div class="pair">
<div class="field">
<label for="amount">پیش‌بینی مبلغ فروش به مشتری</label>
<div class="amount">
<MoneyInput
id="amount"
v-model="amount"
:decimals="0"
:readonly="lines.length > 0"
:placeholder="lines.length ? 'جمع ردیف‌ها' : 'مبلغ را وارد کنید'"
/>
<button class="cur-chip" type="button" @click.stop="toggleMenu('currency')">
{{ currencyLabel }}
<UiIcon name="chevron" :size="14" />
</button>
<ul v-if="openMenu === 'currency'" class="menu nested">
<li v-for="code in enabledCurrencies" :key="code">
<button type="button" @click="currency = code; openMenu = ''">
{{ currencySuffix(code) }}
</button>
</li>
</ul>
</div>
<small v-if="lines.length" class="hint">
جمع ردیف‌ها: {{ formatMoney(linesTotal, currency) }}
</small>
</div>
<div class="field">
<label>پیش‌بینی تاریخ فروش به مشتری</label>
<JalaliDateInput v-model="expectedCloseAt" placeholder="انتخاب تاریخ" />
</div>
</div>
<div class="schedule">
<div class="field next-field">
<label>فعالیت بعدی</label>
<JalaliDateInput v-model="nextDueDate" placeholder="انتخاب تاریخ پیگیری" />
</div>
<div class="pair follow-up">
<div class="field">
<label>ساعت <i>*</i></label>
<JalaliDateInput v-model="nextDueTime" mode="time" />
</div>
<div class="field">
<label>مدت فعالیت (دقیقه) <i>*</i></label>
<div class="stepper">
<button type="button" aria-label="کاهش" @click="bumpDuration(-5)">
<UiIcon name="minus" :size="16" />
</button>
<strong>{{ toFaDigits(nextDuration) }}</strong>
<button type="button" aria-label="افزایش" @click="bumpDuration(5)">
<UiIcon name="plus" :size="16" />
</button>
</div>
</div>
</div>
<div class="field owner-field">
<label>مسئول انجام این معامله <i>*</i></label>
<button class="picker" type="button" @click.stop="toggleMenu('owner')">
<UserAvatar :name="selectedOwner?.fullName || 'مسئول'" :user-id="selectedOwner?.id" :size="32" />
<span :class="{ ghost: !selectedOwner }">
{{ selectedOwner?.fullName || 'انتخاب کنید' }}
</span>
<UiIcon name="chevron" :size="16" />
</button>
<ul v-if="openMenu === 'owner'" class="menu">
<li v-for="user in users" :key="user.id">
<button type="button" @click="ownerId = user.id; openMenu = ''">
<UserAvatar :name="user.fullName" :user-id="user.id" :has-avatar="user.hasAvatar" :size="28" />
{{ user.fullName }}
</button>
</li>
</ul>
</div>
<div class="field">
<label>مسئولان مشترک</label>
<UserMultiSelect v-model="selectedAssigneeIds" :users="users" />
<small class="hint">علاوه بر مسئول اصلی.</small>
</div>
</div>
</section>
<section class="card extras">
<button class="extras-toggle" type="button" @click="showOthers = !showOthers">
<span class="extras-toggle-title">
<UiIcon name="layers" :size="18" />
سایر فیلدها و اطلاعات تکمیلی
</span>
<UiIcon name="chevron" :size="16" :class="{ open: showOthers }" />
</button>
<div v-if="showOthers" class="extras-body">
<div class="crm-form-grid-2">
<UiField label="واحد ارزی">
<UiSelect v-model="currency">
<option v-for="code in enabledCurrencies" :key="code" :value="code">
{{ currencySuffix(code) }} ({{ code }})
</option>
</UiSelect>
</UiField>
<UiField label="کاریز فروش">
<UiSelect :model-value="pipelineId" @update:model-value="onPipelineChange($event)">
<option v-for="p in pipelines" :key="p.id" :value="p.id">{{ p.name }}</option>
</UiSelect>
</UiField>
<UiField label="مرحله کاریز" required>
<UiSelect v-model="stageId" required>
<option v-for="s in stages" :key="s.id" :value="s.id">{{ s.name }}</option>
</UiSelect>
</UiField>
<UiField label="وضعیت سلامت معامله">
<UiSelect v-model="health">
<option value="normal">عادی</option>
<option value="stale">راکد</option>
<option value="rotten">فاسد</option>
</UiSelect>
</UiField>
<UiField label="شیوه آشنایی">
<UiInput v-model="source" placeholder="مثال: وب‌سایت، معرفی، نمایشگاه…" />
</UiField>
<UiField label="شیوه آشنایی دلخواه">
<UiInput v-model="sourceDetail" placeholder="توضیح آزاد" />
</UiField>
<UiField label="معرف">
<UiInput v-model="referrer" placeholder="نام معرف" />
</UiField>
<UiField label="کد دستی معامله">
<UiInput v-model="dealCode" />
</UiField>
<UiField label="احتمال موفقیت (درصد)">
<UiInput v-model="probability" type="number" min="0" max="100" />
</UiField>
<UiField label="برچسب‌ها">
<UiInput v-model="tagsRaw" placeholder="سازمانی، فوری (با کاما جدا کنید)" />
</UiField>
<UiField v-if="!isNew" label="تاریخ انجام معامله">
<JalaliDateInput v-model="closedAt" placeholder="انتخاب تاریخ" />
</UiField>
<UiField v-if="!isNew" label="دلیل شکست (در صورت باخت معامله)" class="crm-col-span-full">
<UiInput id="lostReason" v-model="lostReason" placeholder="علت از دست رفتن معامله را بنویسید…" />
</UiField>
<UiField v-if="!isNew" label="یادداشت شکست" class="crm-col-span-full">
<UiInput v-model="lostNote" />
</UiField>
<p v-if="!isNew && (createdByName || sourceRef)" class="hint crm-col-span-full">
<span v-if="createdByName">ثبت‌کننده: {{ createdByName }}</span>
<span v-if="createdByName && sourceRef"> · </span>
<span v-if="sourceRef">کد دیدار: {{ toFaDigits(sourceRef) }}</span>
</p>
</div>
<!-- بخش محصولات / خدمات -->
<div class="products-section">
<div class="products-head">
<div class="products-head-info">
<span class="sec-title">محصولات / خدمات</span>
<span class="lines-count-badge">{{ productsLabel }}</span>
</div>
<div class="products-head-actions">
<UiButton
variant="outline"
size="sm"
type="button"
@click="productsOpen = !productsOpen"
>
<UiIcon name="box" :size="14" />
{{ productsOpen ? 'پنهان کردن اقلام' : 'مدیریت اقلام معامله' }}
</UiButton>
<UiButton
v-if="productsOpen"
variant="primary"
size="sm"
type="button"
@click="addLine"
>
<UiIcon name="plus" :size="14" />
افزودن ردیف
</UiButton>
</div>
</div>
<div v-if="productsOpen" class="lines-block">
<div v-if="lines.length" class="lines-table">
<div class="lines-table-head">
<span>محصول از کاتالوگ</span>
<span>نام ردیف</span>
<span>تعداد</span>
<span>قیمت واحد ({{ currencyLabel }})</span>
<span>عملیات</span>
</div>
<div v-for="line in lines" :key="line.key" class="line-row">
<UiSelect v-model="line.productId" @change="onPickProduct(line)">
<option value="">محصول از کاتالوگ…</option>
<option v-for="p in products" :key="p.id" :value="p.id">{{ p.name }}</option>
</UiSelect>
<UiInput v-model="line.name" placeholder="نام ردیف" />
<UiInput v-model="line.quantity" type="number" min="0.01" step="0.01" placeholder="تعداد" />
<MoneyInput v-model="line.unitPrice" :decimals="0" placeholder="قیمت واحد" />
<button class="line-delete-btn" type="button" title="حذف ردیف" @click="removeLine(line.key)">
<UiIcon name="trash" :size="16" />
</button>
</div>
</div>
<p v-else class="lines-empty-hint">
می‌توانید فقط مبلغ کل را در فیلد مبلغ وارد کنید یا با کلیک روی «افزودن ردیف»، اقلام را از کاتالوگ محصول مشخص فرمایید.
</p>
</div>
</div>
<CustomFieldsSection entity-type="deal" v-model="customFields" />
</div>
</section>
</div>
<div class="card note-card" :class="{ off: tab !== 'notes' }">
<label for="notes">توضیحات و یادداشت</label>
<textarea
id="notes"
v-model="notes"
rows="8"
placeholder="جزئیات معامله، توافق‌ها یا یادداشت مرتبط را بنویسید…"
/>
</div>
<RelatedTasksPanel
v-if="!isNew && id"
class="timeline"
:class="{ hide: tab !== 'main' }"
:entity="{ type: 'deal', id, label: title }"
/>
<EntityTimeline
v-if="!isNew && id"
class="timeline"
:class="{ hide: tab !== 'main' }"
entity-type="deal"
:entity-id="id"
/>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="status" class="muted">
وضعیت: {{ status === 'won' ? 'برنده' : status === 'lost' ? 'از دست‌رفته' : 'باز' }}
</p>
</form>
<footer v-if="!embedded" class="mobile-save">
<button class="btn outline" type="button" :disabled="saving" @click="cancel">انصراف</button>
<button class="btn primary" type="button" :disabled="saving || loading" @click="submit">
{{ saving ? 'در حال ذخیره…' : 'ذخیره معامله' }}
</button>
</footer>
<!-- Embedded Modal Actions (Fixed at bottom) -->
<div v-if="embedded" class="embed-actions">
<UiButton variant="primary" :disabled="saving" @click="submit">
{{ saving ? 'در حال ذخیره…' : 'ذخیره معامله' }}
</UiButton>
<UiButton variant="ghost" :disabled="saving" @click="cancel">
انصراف
</UiButton>
</div>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 0.85rem;
width: 100%;
max-width: 100%;
min-width: 0;
padding-bottom: 5rem;
}
.page.embedded {
padding-bottom: 0;
}
.embed-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.desktop-head {
display: none;
justify-content: space-between;
gap: 1rem;
align-items: center;
}
.title-block {
display: flex;
align-items: center;
gap: 0.75rem;
}
.doc-ico {
width: 44px;
height: 44px;
border-radius: var(--radius);
display: grid;
place-items: center;
background: #ecfdf5;
color: #0f766e;
}
h1 {
margin: 0;
font-size: 1.35rem;
}
.crumb {
margin: 0.2rem 0 0;
display: flex;
align-items: center;
gap: 0.35rem;
color: #94a3b8;
font-size: 0.82rem;
}
.crumb a {
color: #0f766e;
font-weight: 700;
}
.desk-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.mobile-head {
display: grid;
grid-template-columns: 40px 1fr 40px;
align-items: center;
}
.mobile-head h1 {
text-align: center;
font-size: 1.05rem;
}
.icon-btn {
width: 40px;
height: 40px;
border: 0;
background: none;
color: #0f766e;
display: grid;
place-items: center;
cursor: pointer;
}
.tabs {
display: flex;
gap: 1.2rem;
border-bottom: 1px solid #e2e8f0;
}
.tabs button {
border: 0;
background: none;
padding: 0.55rem 0;
font: inherit;
color: #94a3b8;
cursor: pointer;
}
.tabs .on {
color: #0f766e;
font-weight: 600;
box-shadow: inset 0 -2px 0 #14b8a6;
}
.form,
.stack {
display: grid;
gap: 0.75rem;
min-width: 0;
}
.card {
position: relative;
background: #fff;
border-radius: var(--radius);
padding: 0.95rem;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
min-width: 0;
}
.sec {
display: none;
align-items: center;
gap: 0.45rem;
margin: 0 0 0.9rem;
font-size: 0.95rem;
color: #0f172a;
}
.dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: #14b8a6;
}
.kind-field {
display: grid;
}
.field {
display: grid;
gap: 0.4rem;
min-width: 0;
position: relative;
}
.field label {
margin: 0;
color: #64748b;
font-size: 0.82rem;
}
.field i {
color: #dc2626;
font-style: normal;
}
.pair,
.schedule {
display: grid;
gap: 0.85rem;
}
.field :deep(.trigger) {
min-height: var(--control-h);
height: var(--control-h);
}
.control,
.amount,
.picker {
width: 100%;
min-height: var(--control-h);
height: var(--control-h);
border: 1px solid #e2e8f0;
border-radius: var(--radius);
background: #fff;
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.35rem 0.7rem;
}
.control input,
.amount input {
flex: 1;
min-width: 0;
border: 0;
background: transparent;
font: inherit;
padding: 0.4rem 0;
}
.control :deep(.ico),
.picker :deep(.ico:last-child) {
color: #94a3b8;
flex-shrink: 0;
margin-inline-start: auto;
}
.picker :deep(.ico:last-child) {
transform: rotate(90deg);
}
.lead-ico {
width: 32px;
height: 32px;
border-radius: 999px;
display: grid;
place-items: center;
background: #ecfdf5;
color: #0d9488;
flex-shrink: 0;
}
.picker {
font: inherit;
text-align: start;
cursor: pointer;
}
.picker .ghost {
color: #94a3b8;
font-weight: 500;
}
.amount {
position: relative;
padding-inline-end: 0.4rem;
}
.cur-chip {
display: inline-flex;
align-items: center;
gap: 0.15rem;
border: 0;
background: #f8fafc;
color: #0f766e;
border-radius: var(--radius);
padding: 0.35rem 0.55rem;
font: inherit;
font-weight: 700;
cursor: pointer;
flex-shrink: 0;
}
.cur-chip :deep(.ico) {
transform: rotate(90deg);
color: #94a3b8;
}
.segment {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.45rem;
padding: 0.2rem;
border-radius: var(--radius);
background: #f8fafc;
}
.segment button {
min-height: calc(var(--control-h) - 4px);
height: calc(var(--control-h) - 4px);
border: 0;
border-radius: var(--radius);
background: transparent;
color: #64748b;
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
font: inherit;
cursor: pointer;
}
.segment .on {
background: #ecfdf5;
color: #0f766e;
font-weight: 600;
}
.menu {
position: absolute;
inset-inline: 0;
top: calc(100% + 4px);
z-index: 6;
margin: 0;
padding: 0.35rem;
list-style: none;
background: #fff;
border-radius: var(--radius);
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
max-height: 16rem;
overflow: auto;
}
.menu.nested {
inset-inline: auto 0;
min-width: 8rem;
}
.menu button {
width: 100%;
display: flex;
align-items: center;
gap: 0.55rem;
border: 0;
background: none;
padding: 0.55rem 0.5rem;
border-radius: var(--radius);
font: inherit;
text-align: start;
cursor: pointer;
}
.menu button:hover {
background: #f8fafc;
}
.who {
display: grid;
min-width: 0;
text-align: start;
}
.who b {
font-weight: 600;
}
.who small {
color: #94a3b8;
font-size: 0.75rem;
}
.stepper {
display: flex;
align-items: center;
justify-content: space-between;
min-height: var(--control-h);
height: var(--control-h);
border: 1px solid #e2e8f0;
border-radius: var(--radius);
padding: 0.15rem;
}
.stepper button {
width: 28px;
height: 28px;
border: 0;
border-radius: var(--radius);
background: #f0fdfa;
color: #0f766e;
display: grid;
place-items: center;
cursor: pointer;
}
.extras-toggle {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
border: 1px solid var(--line);
background: var(--surface-subtle);
border-radius: var(--radius, 10px);
padding: 0.75rem 1rem;
font: inherit;
font-weight: 600;
color: var(--ink);
cursor: pointer;
transition: all var(--transition-fast);
}
.extras-toggle:hover {
background: var(--surface-hover);
border-color: var(--line-strong);
}
.extras-toggle-title {
display: flex;
align-items: center;
gap: var(--sp-2);
font-size: var(--fs-base);
}
.extras-toggle :deep(.ico) {
color: var(--muted);
transition: transform var(--transition-fast);
}
.extras-toggle :deep(.ico.open) {
transform: rotate(180deg);
}
.extras-body {
display: flex;
flex-direction: column;
gap: var(--sp-5);
margin-top: var(--sp-4);
width: 100%;
}
.products-section {
display: flex;
flex-direction: column;
gap: var(--sp-3);
padding-top: var(--sp-4);
border-top: 1px dashed var(--line);
width: 100%;
}
.products-head {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--sp-2);
}
.products-head-info {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.sec-title {
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
color: var(--ink-secondary);
}
.lines-count-badge {
font-size: var(--fs-xs);
color: var(--muted);
background: var(--surface-subtle);
border: 1px solid var(--line);
padding: 2px 8px;
border-radius: 999px;
}
.products-head-actions {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.lines-block {
display: flex;
flex-direction: column;
gap: var(--sp-3);
}
.lines-table {
display: flex;
flex-direction: column;
gap: var(--sp-2);
width: 100%;
}
.lines-table-head {
display: grid;
grid-template-columns: 1.5fr 1.5fr 0.8fr 1.2fr 40px;
gap: var(--sp-2);
font-size: var(--fs-xs);
font-weight: var(--fw-medium);
color: var(--muted);
padding: 0 4px;
}
.line-row {
display: grid;
grid-template-columns: 1.5fr 1.5fr 0.8fr 1.2fr 40px;
gap: var(--sp-2);
align-items: center;
width: 100%;
}
.line-delete-btn {
width: 36px;
height: 36px;
border: 1px solid var(--line);
border-radius: var(--radius, 10px);
background: var(--surface);
color: var(--danger);
display: grid;
place-items: center;
cursor: pointer;
transition: all var(--transition-fast);
}
.line-delete-btn:hover {
background: #fef2f2;
border-color: #fecaca;
}
.lines-empty-hint {
margin: 0;
padding: var(--sp-3);
background: var(--surface-subtle);
border: 1px dashed var(--line);
border-radius: var(--radius, 10px);
color: var(--muted);
font-size: var(--fs-xs);
text-align: center;
}
@media (max-width: 768px) {
.lines-table-head {
display: none;
}
.line-row {
grid-template-columns: 1fr;
background: var(--surface-subtle);
padding: var(--sp-3);
border: 1px solid var(--line);
border-radius: var(--radius, 10px);
gap: var(--sp-2);
}
.line-delete-btn {
width: 100%;
}
}
.note-card {
display: grid;
gap: 0.45rem;
}
.hint {
color: #64748b;
font-size: 0.8rem;
}
.note-card.off,
.timeline.hide {
display: none;
}
.note-card textarea {
min-height: 12rem;
width: 100%;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
padding: 0.75rem 0.85rem;
font: inherit;
resize: vertical;
}
.hint,
.muted,
.error {
margin: 0;
}
.hint,
.muted {
color: #64748b;
font-size: 0.85rem;
}
.error {
color: #b91c1c;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-height: var(--control-h);
border-radius: var(--radius);
padding: 0 0.95rem;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.btn.primary {
border: 0;
background: #14b8a6;
color: #fff;
}
.btn.outline {
border: 1.5px solid #14b8a6;
background: #fff;
color: #0f766e;
}
.btn.cancel {
border: 1.5px solid #e2e8f0;
background: #fff;
color: #64748b;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.mobile-save {
position: fixed;
inset-inline: 0.85rem;
bottom: calc(0.55rem + var(--mobile-tabbar-h));
z-index: 8;
display: grid;
grid-template-columns: 1fr 1.2fr;
gap: 0.55rem;
}
.mobile-save .btn {
width: 100%;
min-height: var(--control-h);
height: var(--control-h);
border-radius: var(--radius);
}
@media (min-width: 960px) {
.page {
padding-bottom: 1rem;
gap: 1rem;
}
.desktop-head {
display: flex;
}
.mobile-head,
.tabs,
.kind-field,
.follow-up,
.mobile-save {
display: none;
}
.note-card.off,
.timeline.hide {
display: grid;
}
.sec {
display: flex;
}
.main-card,
.extras {
padding: 1.15rem 1.2rem;
}
.pair {
grid-template-columns: 1fr 1fr;
}
.schedule {
grid-template-columns: 1fr 1fr;
}
.next-field {
grid-column: 1;
grid-row: 1;
}
.owner-field {
grid-column: 2;
grid-row: 1;
}
.contacts .field {
display: grid !important;
}
.next-field {
display: grid;
}
.note-card {
display: grid;
}
}
</style>