Files
crm/apps/web/src/views/QuoteFormView.vue
T
MahdiandCursor 092767b6f2 Align older CRM pages with the Mento visual language.
Restyle list, form, settings, and secondary modules to match contacts/activities patterns with teal chrome, soft panels, and mobile layouts — without changing already-redesigned screens.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 02:29:41 +03:30

512 lines
13 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 } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { formatMoney as formatMoneyLib } from '@/lib/money';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import MoneyInput from '@/components/MoneyInput.vue';
import type {
CompanyDto,
CreateQuoteRequest,
DealLineItemInput,
OrganizationDto,
PaginatedResponse,
PersonDto,
ProductDto,
QuoteDto,
QuoteStatus,
} 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 === 'quote-new');
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const number = ref('');
const title = ref('پیش‌فاکتور');
const status = ref<QuoteStatus>('draft');
const notes = ref('');
const validUntil = ref('');
const currency = ref('IRR');
const enabledCurrencies = ref<string[]>(['IRR']);
const personId = ref('');
const companyId = ref('');
const dealId = ref('');
const people = ref<PersonDto[]>([]);
const companies = ref<CompanyDto[]>([]);
const products = ref<ProductDto[]>([]);
const lines = ref<LineDraft[]>([]);
const statusLabel: Record<QuoteStatus, string> = {
draft: 'پیش‌نویس',
sent: 'ارسال‌شده',
accepted: 'تأییدشده',
rejected: 'ردشده',
expired: 'منقضی',
};
const totalAmount = computed(() =>
toLineInputs().reduce((sum, l) => sum + l.quantity * l.unitPrice, 0),
);
function formatMoney(amount: number) {
return formatMoneyLib(amount, currency.value);
}
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 fromDealId =
typeof route.query.fromDealId === 'string'
? route.query.fromDealId
: typeof route.query.dealId === 'string'
? route.query.dealId
: '';
const [peopleRes, companiesRes, productsRes, orgRes] = await Promise.all([
api.get<PaginatedResponse<PersonDto>>('/people', { params: { limit: 100 } }),
api.get<PaginatedResponse<CompanyDto>>('/companies', { params: { limit: 100 } }),
api.get<ProductDto[]>('/products', { params: { activeOnly: true } }),
api.get<OrganizationDto>('/organizations/current'),
]);
people.value = peopleRes.data.items;
companies.value = companiesRes.data.items;
products.value = productsRes.data;
enabledCurrencies.value = orgRes.data.enabledCurrencies?.length
? orgRes.data.enabledCurrencies
: [orgRes.data.baseCurrency || 'IRR'];
currency.value = orgRes.data.baseCurrency || 'IRR';
if (!isNew.value && id.value) {
const { data } = await api.get<QuoteDto>(`/quotes/${id.value}`);
number.value = data.number;
title.value = data.title;
status.value = data.status as QuoteStatus;
notes.value = data.notes ?? '';
currency.value = data.currency || currency.value;
validUntil.value = data.validUntil ? data.validUntil.slice(0, 10) : '';
personId.value = data.person?.id ?? '';
companyId.value = data.company?.id ?? '';
dealId.value = data.deal?.id ?? '';
lines.value = (data.lineItems ?? []).map((item) => ({
key: item.id,
productId: item.productId ?? '',
name: item.name,
quantity: String(item.quantity),
unitPrice: String(item.unitPrice),
}));
} else if (fromDealId) {
dealId.value = fromDealId;
title.value = 'پیش‌فاکتور';
} else {
if (typeof route.query.personId === 'string' && route.query.personId) {
personId.value = route.query.personId;
const person = people.value.find((p) => p.id === personId.value);
if (person?.company?.id) companyId.value = person.company.id;
}
if (!lines.value.length) {
lines.value = [newLine()];
}
}
} catch {
error.value = 'بارگذاری فرم پیش‌فاکتور ناموفق بود.';
} finally {
loading.value = false;
}
}
function payload(): CreateQuoteRequest {
const fromDealId =
isNew.value && typeof route.query.fromDealId === 'string'
? route.query.fromDealId
: undefined;
return {
title: title.value.trim(),
status: status.value,
notes: notes.value || undefined,
validUntil: validUntil.value ? new Date(validUntil.value).toISOString() : null,
personId: personId.value || null,
companyId: companyId.value || null,
dealId: dealId.value || null,
fromDealId: fromDealId || null,
lineItems: toLineInputs(),
currency: currency.value || 'IRR',
};
}
async function save() {
if (!title.value.trim()) {
error.value = 'عنوان را وارد کنید.';
return;
}
saving.value = true;
error.value = '';
try {
if (isNew.value) {
const { data } = await api.post<QuoteDto>('/quotes', payload());
await router.replace({ name: 'quote-detail', params: { id: data.id } });
} else {
await api.patch(`/quotes/${id.value}`, payload());
await load();
}
} catch (e) {
if (axios.isAxiosError(e)) {
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;
await api.delete(`/quotes/${id.value}`);
await router.push({ name: 'quotes' });
}
function printQuote() {
window.print();
}
onMounted(load);
</script>
<template>
<section class="page">
<header class="page-head no-print">
<div class="head-copy">
<p class="crumb">
<RouterLink :to="{ name: 'quotes' }">پیش‌فاکتورها</RouterLink>
<span>›</span>
<strong>{{ isNew ? 'پیش‌فاکتور جدید' : number || 'پیش‌فاکتور' }}</strong>
</p>
<h1>{{ isNew ? 'پیش‌فاکتور جدید' : number || 'پیش‌فاکتور' }}</h1>
</div>
<div class="head-actions">
<button v-if="!isNew" class="btn ghost" type="button" @click="printQuote">چاپ</button>
<RouterLink
v-if="!isNew && id"
class="btn ghost"
:to="{ name: 'invoice-new', query: { fromQuoteId: id } }"
>
صدور فاکتور
</RouterLink>
</div>
</header>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<form v-else class="form" @submit.prevent="save">
<div class="print-banner">
<strong>منتو</strong>
<span v-if="number">{{ number }}</span>
</div>
<div class="grid">
<div class="field wide">
<label>عنوان</label>
<input v-model="title" required maxlength="200" />
</div>
<div class="field">
<label>وضعیت</label>
<select v-model="status">
<option v-for="(label, key) in statusLabel" :key="key" :value="key">{{ label }}</option>
</select>
</div>
<div class="field">
<label>اعتبار تا</label>
<JalaliDateInput v-model="validUntil" />
</div>
<div class="field">
<label>ارز</label>
<select v-model="currency">
<option v-for="code in enabledCurrencies" :key="code" :value="code">
{{ code }}
</option>
</select>
</div>
<div class="field">
<label>شخص</label>
<select 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>شرکت</label>
<select 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>یادداشت</label>
<textarea v-model="notes" rows="3" />
</div>
</div>
<div class="lines-block">
<div class="lines-head">
<h3>ردیف‌های پیش‌فاکتور</h3>
<button class="btn btn-ghost no-print" type="button" @click="addLine">افزودن ردیف</button>
</div>
<div v-for="line in lines" :key="line.key" class="line-row">
<select v-model="line.productId" class="no-print" @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="تعداد" />
<MoneyInput v-model="line.unitPrice" :decimals="0" placeholder="قیمت واحد" />
<button class="btn btn-ghost no-print" type="button" @click="removeLine(line.key)">حذف</button>
</div>
<p class="total"><strong>جمع:</strong> {{ formatMoney(totalAmount) }}</p>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div class="form-actions no-print">
<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: 1rem;
max-width: 860px;
min-width: 0;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.head-copy {
min-width: 0;
}
.crumb {
margin: 0 0 0.25rem;
display: flex;
align-items: center;
gap: 0.35rem;
color: #94a3b8;
font-size: 0.82rem;
}
.crumb a {
color: #0f766e;
font-weight: 700;
text-decoration: none;
}
.head-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
h1 {
margin: 0;
font-size: clamp(1.25rem, 2.5vw, 1.55rem);
font-weight: 800;
}
.btn.ghost {
display: inline-flex;
align-items: center;
background: #fff;
color: #0f766e;
border: 1px solid #99f6e4;
border-radius: 12px;
min-height: 42px;
padding: 0.55rem 1rem;
font: inherit;
font-weight: 700;
cursor: pointer;
text-decoration: none;
}
.form,
.lines-block {
display: grid;
gap: 0.85rem;
background: #fff;
border-radius: 18px;
padding: 1rem;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.field {
display: grid;
gap: 0.35rem;
}
.field.wide {
grid-column: 1 / -1;
}
label {
color: var(--muted);
font-size: 0.9rem;
}
input,
select,
textarea {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.7rem 0.85rem;
font: inherit;
background: #fff;
width: 100%;
}
.lines-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.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;
}
.total {
margin: 0;
text-align: start;
font-size: 1.05rem;
}
.form-actions {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}
.error {
color: #b42318;
}
.print-banner {
display: none;
}
@media (max-width: 700px) {
.grid,
.line-row {
grid-template-columns: 1fr;
}
}
@media print {
.no-print {
display: none !important;
}
.print-banner {
display: flex;
justify-content: space-between;
border-bottom: 1px solid #ccc;
padding-bottom: 0.5rem;
margin-bottom: 0.5rem;
}
.page {
max-width: none;
}
input,
select,
textarea {
border: 0;
padding: 0.2rem 0;
}
}
</style>