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

529 lines
16 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
import EntityTimeline from '@/components/EntityTimeline.vue';
import { formatMoney } from '@/lib/money';
import type {
CompanyDto,
CreateDealRequest,
DealDto,
DealLineItemInput,
OrganizationDto,
PersonDto,
PipelineBoardDto,
ProductDto,
} from '@mento/shared';
type LineDraft = {
key: string;
productId: string;
name: string;
quantity: string;
unitPrice: string;
};
const route = useRoute();
const router = useRouter();
const id = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
const isNew = computed(() => route.name === 'deal-new');
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const people = ref<PersonDto[]>([]);
const companies = ref<CompanyDto[]>([]);
const products = ref<ProductDto[]>([]);
const stages = ref<PipelineBoardDto['stages']>([]);
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 isStale = ref(false);
const isRotten = ref(false);
const expectedCloseAt = ref('');
const personId = ref('');
const companyId = ref('');
const stageId = ref('');
const pipelineId = ref('');
const lostReason = ref('');
const status = ref('');
const customFields = ref<Record<string, unknown>>({});
const lines = ref<LineDraft[]>([]);
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),
);
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() {
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 load() {
loading.value = true;
error.value = '';
try {
const queryPipelineId =
typeof route.query.pipelineId === 'string' ? route.query.pipelineId : undefined;
const [peopleRes, companiesRes, boardRes, productsRes, orgRes] =
await Promise.all([
api.get<{ items: PersonDto[] }>('/people', { params: { limit: 100 } }),
api.get<{ items: CompanyDto[] }>('/companies', { params: { limit: 100 } }),
api.get<PipelineBoardDto>('/pipelines/board', {
params: queryPipelineId ? { pipelineId: queryPipelineId } : undefined,
}),
api.get<ProductDto[]>('/products', { params: { activeOnly: true } }),
api.get<OrganizationDto>('/organizations/current'),
]);
people.value = peopleRes.data.items;
companies.value = companiesRes.data.items;
stages.value = boardRes.data.stages;
pipelineId.value = boardRes.data.pipeline.id;
products.value = productsRes.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';
}
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('، ');
isStale.value = data.isStale;
isRotten.value = data.isRotten;
expectedCloseAt.value = data.expectedCloseAt
? data.expectedCloseAt.slice(0, 10)
: '';
personId.value = data.person?.id ?? '';
companyId.value = data.company?.id ?? '';
stageId.value = data.stageId;
pipelineId.value = data.pipelineId;
lostReason.value = data.lostReason ?? '';
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),
}));
} else {
const firstOpen = stages.value.find((s) => !s.isWon && !s.isLost);
stageId.value = firstOpen?.id ?? stages.value[0]?.id ?? '';
}
} 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,
notes: notes.value || undefined,
tags: parseTags(tagsRaw.value),
isStale: isStale.value,
isRotten: isRotten.value,
expectedCloseAt: expectedCloseAt.value || null,
personId: personId.value || null,
companyId: companyId.value || null,
pipelineId: pipelineId.value || undefined,
stageId: stageId.value || undefined,
customFields: customFields.value,
currency: currency.value || 'IRR',
lineItems,
};
}
async function submit() {
saving.value = true;
error.value = '';
try {
if (isNew.value) {
const { data } = await api.post<DealDto>('/deals', payload());
await router.replace({ name: 'deal-detail', params: { id: data.id } });
} else {
await api.patch(`/deals/${id.value}`, {
...payload(),
lostReason: lostReason.value || null,
});
if (stageId.value) {
await api.patch(`/deals/${id.value}/move`, { stageId: stageId.value });
}
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;
}
}
async function remove() {
if (!id.value || !confirm('این معامله حذف شود؟')) return;
try {
await api.delete(`/deals/${id.value}`);
await router.push({ name: 'pipeline' });
} catch {
error.value = 'حذف ناموفق بود';
}
}
onMounted(load);
</script>
<template>
<section class="page">
<header class="page-head">
<div>
<p class="eyebrow">معامله</p>
<h1>{{ isNew ? 'معامله جدید' : 'ویرایش معامله' }}</h1>
<p v-if="status" class="lead">وضعیت: {{ status === 'won' ? 'برنده' : status === 'lost' ? 'از دست‌رفته' : 'باز' }}</p>
</div>
<div class="head-actions">
<RouterLink
v-if="!isNew && id"
class="btn btn-ghost"
:to="{ name: 'quote-new', query: { fromDealId: id } }"
>
صدور پیش‌فاکتور
</RouterLink>
<RouterLink
v-if="!isNew && id"
class="btn btn-ghost"
:to="{ name: 'invoice-new', query: { fromDealId: id } }"
>
صدور فاکتور
</RouterLink>
<RouterLink class="btn btn-ghost" :to="{ name: 'pipeline' }">بازگشت به کاریز</RouterLink>
</div>
</header>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<form v-else class="form-card" @submit.prevent="submit">
<div class="grid">
<div class="field wide">
<label for="title">عنوان معامله</label>
<input id="title" v-model="title" required minlength="2" placeholder="مثلاً فروش بسته سازمانی" />
</div>
<div class="field">
<label for="amount">مبلغ</label>
<input
id="amount"
v-model="amount"
type="number"
min="0"
step="1"
:readonly="lines.length > 0"
:placeholder="lines.length ? 'جمع ردیف‌ها' : undefined"
/>
<small v-if="lines.length" class="hint">
جمع ردیف‌ها: {{ formatMoney(linesTotal, currency) }}
</small>
</div>
<div class="field">
<label for="currency">ارز</label>
<select id="currency" v-model="currency">
<option v-for="code in enabledCurrencies" :key="code" :value="code">
{{ code }}
</option>
</select>
</div>
<div class="field">
<label for="source">منبع آشنایی</label>
<input id="source" v-model="source" placeholder="وب‌سایت، معرفی، نمایشگاه…" />
</div>
<div class="field">
<label for="expectedCloseAt">تاریخ بسته‌شدن مورد انتظار</label>
<input id="expectedCloseAt" v-model="expectedCloseAt" type="date" />
</div>
<div class="field wide">
<label for="tags">برچسب‌ها (با کاما جدا کنید)</label>
<input id="tags" v-model="tagsRaw" placeholder="مثلاً سازمانی، فوری، نمایشگاه" />
</div>
<div class="field">
<label class="check">
<input v-model="isStale" type="checkbox" />
راکد (مدتی پیگیری نشده)
</label>
</div>
<div class="field">
<label class="check">
<input v-model="isRotten" type="checkbox" />
فاسد (احتمال موفقیت خیلی کم)
</label>
</div>
<div class="field">
<label for="personId">شخص</label>
<select id="personId" v-model="personId">
<option value="">بدون شخص</option>
<option v-for="p in people" :key="p.id" :value="p.id">{{ p.fullName }}</option>
</select>
</div>
<div class="field">
<label for="companyId">شرکت</label>
<select id="companyId" v-model="companyId">
<option value="">بدون شرکت</option>
<option v-for="c in companies" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
</div>
<div class="field wide">
<label for="stageId">مرحله کاریز</label>
<select id="stageId" v-model="stageId" required>
<option v-for="s in stages" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
</div>
<div v-if="!isNew" class="field wide">
<label for="lostReason">دلیل شکست (اگر از دست رفت)</label>
<input id="lostReason" v-model="lostReason" />
</div>
<div class="field wide">
<label for="notes">یادداشت</label>
<textarea id="notes" v-model="notes" rows="4" />
</div>
</div>
<div class="lines-block">
<div class="lines-head">
<h3>محصولات / خدمات روی معامله</h3>
<button class="btn btn-ghost" type="button" @click="addLine">افزودن ردیف</button>
</div>
<div v-for="line in lines" :key="line.key" class="line-row">
<select 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>
</select>
<input v-model="line.name" placeholder="نام ردیف" required />
<input v-model="line.quantity" type="number" min="0.01" step="0.01" placeholder="تعداد" />
<input v-model="line.unitPrice" type="number" min="0" step="1" placeholder="قیمت واحد" />
<button class="btn btn-ghost" type="button" @click="removeLine(line.key)">حذف</button>
</div>
<p v-if="!lines.length" class="muted">
اختیاری — می‌توانید فقط مبلغ کل را وارد کنید یا از کاتالوگ محصول ردیف اضافه کنید.
</p>
</div>
<CustomFieldsSection entity-type="deal" v-model="customFields" />
<EntityTimeline
v-if="!isNew && id"
entity-type="deal"
:entity-id="id"
/>
<p v-if="error" class="error">{{ error }}</p>
<div class="form-actions">
<button class="btn" type="submit" :disabled="saving">
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
</button>
<button
v-if="!isNew"
class="btn btn-danger"
type="button"
:disabled="saving"
@click="remove"
>
حذف
</button>
</div>
</form>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1.1rem;
max-width: 820px;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
flex-wrap: wrap;
}
.head-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.eyebrow {
margin: 0;
color: var(--accent);
font-weight: 700;
}
h1 {
margin: 0.2rem 0 0;
font-size: 1.7rem;
}
.lead,
.muted {
color: var(--muted);
}
.form-card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.2rem;
display: grid;
gap: 1rem;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem;
}
.wide {
grid-column: 1 / -1;
}
.check {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 1.7rem;
font-weight: 500;
}
.hint {
display: block;
margin-top: 0.35rem;
color: var(--muted);
font-size: 0.85rem;
}
.lines-block {
display: grid;
gap: 0.7rem;
padding-top: 0.5rem;
border-top: 1px dashed var(--line);
}
.lines-head {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: center;
}
.lines-head h3 {
margin: 0;
font-size: 1rem;
}
.line-row {
display: grid;
grid-template-columns: 1.2fr 1.4fr 0.7fr 0.9fr auto;
gap: 0.45rem;
align-items: center;
}
.line-row input,
.line-row select {
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.65rem 0.75rem;
background: #fff;
font: inherit;
width: 100%;
}
.form-actions {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}
@media (max-width: 700px) {
.grid,
.line-row {
grid-template-columns: 1fr;
}
}
</style>