Files
CRM_Montzar/apps/web/src/views/InvoicesView.vue
T
MahdiandCursor 4abab55a6b feat(web): migrate CRM screens to Ant Design Vue with central theme
Adopt ant-design-vue and a shared antdTheme for leads, pipeline, invoices, and shared form controls under strict single-page governance.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 19:39:59 +03:30

827 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
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 { matchesSearch } from '@/lib/search';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import { PAGE_SIZE_OPTIONS, usePageSize } from '@/lib/pageSize';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { formatMoney } from '@/lib/money';
import {
formatJalaliNumeric,
toFaDigits,
} from '@/lib/jalali';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import InvoiceFormView from '@/views/InvoiceFormView.vue';
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
import {
Alert as AAlert,
Avatar as AAvatar,
Button as AButton,
Card as ACard,
Col as ACol,
Drawer as ADrawer,
Dropdown as ADropdown,
Empty as AEmpty,
Flex as AFlex,
FormItem as AFormItem,
Input as AInput,
InputNumber as AInputNumber,
Menu as AMenu,
Modal as AModal,
Pagination as APagination,
Row as ARow,
Select as ASelect,
Space as ASpace,
Spin as ASpin,
Table as ATable,
Tag as ATag,
Typography,
} from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
import {
DownOutlined,
EyeOutlined,
FilterOutlined,
MoreOutlined,
PlusOutlined,
SearchOutlined,
} from '@ant-design/icons-vue';
import type {
InvoiceDto,
PaginatedResponse,
PersonDto,
QuoteDto,
} from '@mento/shared';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const AMenuItem = AMenu.Item;
type DocTab = 'all' | 'invoice' | 'quote' | 'credit';
type PayFilter = 'all' | 'paid' | 'unpaid' | 'pending' | 'expired';
type BillingRow = {
id: string;
entityId: string;
personId: string | null;
companyId: string | null;
doc: 'invoice' | 'quote' | 'credit';
number: string;
date: string;
dueOrValid: string | null;
customerName: string;
personName: string;
amount: number;
currency: string;
statusKey: PayFilter | 'quote';
statusLabel: string;
href: { name: string; params: { id: string } };
};
const INVOICE_COL_LABELS = [
'شماره فاکتور',
'تاریخ',
'مشتری',
'نوع',
'وضعیت',
'مبلغ کل',
'ارز',
'مهلت پرداخت',
'عملیات',
] as const;
registerTableColumns('invoices', [...INVOICE_COL_LABELS]);
const tableCols = useTableColumns(() => 'invoices');
const route = useRoute();
const router = useRouter();
const loading = ref(false);
const error = ref('');
const invoices = ref<InvoiceDto[]>([]);
const quotes = ref<QuoteDto[]>([]);
const people = ref<PersonDto[]>([]);
const q = ref(typeof route.query.q === 'string' ? route.query.q : '');
const docTab = ref<DocTab>(
route.query.doc === 'quote'
? 'quote'
: route.query.doc === 'credit'
? 'credit'
: route.query.doc === 'invoice'
? 'invoice'
: 'all',
);
const payFilter = ref<PayFilter>('all');
const personId = ref(
typeof route.query.personId === 'string' ? route.query.personId : '',
);
const from = ref(typeof route.query.from === 'string' ? route.query.from : '');
const to = ref(typeof route.query.to === 'string' ? route.query.to : '');
const amountMin = ref('');
const amountMax = ref('');
const page = ref(1);
const pageSize = usePageSize('invoices', 10);
const selected = ref<Set<string>>(new Set());
const menuId = ref<string | null>(null);
const filterSheet = ref(false);
const addOpen = ref(false);
const invoiceModal = ref<{ kind: 'invoice' | 'credit' | 'quote' } | null>(null);
function mapInvoiceStatus(inv: InvoiceDto): { key: PayFilter; label: string } {
if (inv.status === 'paid') return { key: 'paid', label: 'پرداخت شده' };
if (inv.status === 'draft') return { key: 'pending', label: 'در انتظار تایید' };
if (inv.status === 'canceled') return { key: 'expired', label: 'لغو شده' };
if (inv.status === 'partially_paid') return { key: 'unpaid', label: 'پرداخت جزئی' };
return { key: 'unpaid', label: 'پرداخت نشده' };
}
function mapQuoteStatus(qt: QuoteDto): { key: PayFilter | 'quote'; label: string } {
if (qt.status === 'expired') return { key: 'expired', label: 'منقضی شده' };
if (qt.status === 'sent') return { key: 'pending', label: 'در انتظار تایید' };
if (qt.status === 'accepted') return { key: 'paid', label: 'تأیید شده' };
if (qt.status === 'rejected') return { key: 'unpaid', label: 'رد شده' };
return { key: 'quote', label: 'پیش فاکتور' };
}
function customerOf(row: {
company?: { id?: string; name: string } | null;
person?: {
id?: string;
firstName?: string;
lastName?: string;
fullName?: string;
} | null;
}) {
const company = row.company?.name?.trim() || '';
const person = row.person
? (
row.person.fullName ||
`${row.person.firstName ?? ''} ${row.person.lastName ?? ''}`
).trim()
: '';
return {
customerName: company || person || 'بدون مشتری',
personName: company && person ? person : '',
personId: row.person?.id ?? null,
companyId: row.company?.id ?? null,
};
}
const rows = computed<BillingRow[]>(() => {
const invRows: BillingRow[] = invoices.value.map((inv) => {
const st = mapInvoiceStatus(inv);
const cust = customerOf(inv);
const kind = inv.kind === 'credit' ? 'credit' : 'invoice';
return {
id: `inv:${inv.id}`,
entityId: inv.id,
personId: cust.personId,
companyId: cust.companyId,
doc: kind,
number: inv.number,
date: inv.issuedAt || inv.createdAt,
dueOrValid: inv.dueDate,
customerName: cust.customerName,
personName: cust.personName,
amount: inv.payableAmount ?? inv.totalAmount,
currency: inv.currency,
statusKey: st.key,
statusLabel: st.label,
href: { name: 'invoice-detail', params: { id: inv.id } },
};
});
const quoteRows: BillingRow[] = quotes.value.map((qt) => {
const st = mapQuoteStatus(qt);
const cust = customerOf(qt);
return {
id: `qt:${qt.id}`,
entityId: qt.id,
personId: cust.personId,
companyId: cust.companyId,
doc: 'quote',
number: qt.number,
date: qt.issuedAt || qt.createdAt,
dueOrValid: qt.validUntil,
customerName: cust.customerName,
personName: cust.personName,
amount: qt.payableAmount ?? qt.totalAmount,
currency: qt.currency,
statusKey: st.key,
statusLabel: st.label,
href: { name: 'quote-detail', params: { id: qt.id } },
};
});
return [...invRows, ...quoteRows].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
);
});
const counts = computed(() => ({
all: rows.value.length,
invoice: rows.value.filter((r) => r.doc === 'invoice').length,
quote: rows.value.filter((r) => r.doc === 'quote').length,
credit: rows.value.filter((r) => r.doc === 'credit').length,
}));
const filtered = computed(() => {
let list = rows.value;
if (docTab.value !== 'all') list = list.filter((r) => r.doc === docTab.value);
if (payFilter.value !== 'all') {
list = list.filter((r) => r.statusKey === payFilter.value);
}
if (personId.value) {
list = list.filter((r) => r.personId === personId.value);
}
if (from.value) {
const t = new Date(from.value).getTime();
list = list.filter((r) => new Date(r.date).getTime() >= t);
}
if (to.value) {
const t = new Date(to.value).getTime() + 86400000 - 1;
list = list.filter((r) => new Date(r.date).getTime() <= t);
}
const amin = Number(amountMin.value);
const amax = Number(amountMax.value);
if (Number.isFinite(amin) && amountMin.value !== '') {
list = list.filter((r) => Math.abs(r.amount) >= amin);
}
if (Number.isFinite(amax) && amountMax.value !== '') {
list = list.filter((r) => Math.abs(r.amount) <= amax);
}
if (q.value.trim()) {
list = list.filter((r) =>
matchesSearch(q.value, [r.number, r.customerName, r.personName, r.statusLabel, String(r.amount)]),
);
}
return list;
});
const pageCount = computed(() =>
Math.max(1, Math.ceil(filtered.value.length / pageSize.value)),
);
const pageItems = computed(() => {
const start = (page.value - 1) * pageSize.value;
return filtered.value.slice(start, start + pageSize.value);
});
function docLabel(doc: BillingRow['doc']) {
if (doc === 'quote') return 'پیش فاکتور';
if (doc === 'credit') return 'فاکتور برگشتی';
return 'فاکتور';
}
function statusIcon(key: BillingRow['statusKey']) {
if (key === 'paid') return 'check' as const;
if (key === 'pending') return 'clock' as const;
if (key === 'unpaid') return 'warning' as const;
if (key === 'expired') return 'minus' as const;
return 'document' as const;
}
function statusColor(key: BillingRow['statusKey']) {
const icon = statusIcon(key);
if (icon === 'check') return 'success';
if (icon === 'clock') return 'processing';
if (icon === 'warning') return 'warning';
if (icon === 'minus') return 'error';
return 'default';
}
function formatDate(iso: string | null | undefined) {
if (!iso) return '—';
try {
return formatJalaliNumeric(new Date(iso));
} catch {
return '—';
}
}
function money(n: number, currency: string) {
return formatMoney(n, currency);
}
function closeMenus() {
menuId.value = null;
addOpen.value = false;
}
function setDocTab(tab: DocTab) {
docTab.value = tab;
page.value = 1;
void router.replace({
name: 'invoices',
query: {
...route.query,
doc: tab === 'all' ? undefined : tab,
},
});
}
function clearFilters() {
payFilter.value = 'all';
personId.value = '';
from.value = '';
to.value = '';
amountMin.value = '';
amountMax.value = '';
q.value = '';
page.value = 1;
filterSheet.value = false;
}
function applyFilters() {
page.value = 1;
filterSheet.value = false;
}
function toggleSelectAll() {
const next = new Set(selected.value);
if (pageItems.value.every((r) => next.has(r.id))) {
for (const r of pageItems.value) next.delete(r.id);
} else {
for (const r of pageItems.value) next.add(r.id);
}
selected.value = next;
}
function toggleSelect(id: string) {
const next = new Set(selected.value);
if (next.has(id)) next.delete(id);
else next.add(id);
selected.value = next;
}
async function load() {
loading.value = true;
error.value = '';
closeMenus();
try {
const [invRes, quoteRes, peopleRes] = await Promise.all([
api.get<PaginatedResponse<InvoiceDto>>('/invoices', { params: { limit: 100 } }),
api.get<PaginatedResponse<QuoteDto>>('/quotes', { params: { limit: 100 } }),
api.get<PaginatedResponse<PersonDto>>('/people', { params: { limit: 100 } }),
]);
invoices.value = invRes.data.items;
quotes.value = quoteRes.data.items;
people.value = peopleRes.data.items;
} catch (e) {
error.value = axios.isAxiosError(e)
? String(
(e.response?.data as { message?: string })?.message ??
'بارگذاری اسناد ناموفق بود.',
)
: 'بارگذاری اسناد ناموفق بود.';
} finally {
loading.value = false;
}
}
watch([docTab, pageSize], () => {
page.value = 1;
});
watch(
() => route.query.doc,
(doc) => {
if (doc === 'quote' || doc === 'credit' || doc === 'invoice') docTab.value = doc;
else if (!doc) docTab.value = 'all';
},
);
onMounted(load);
const docTabOptions = computed(() => [
{ value: 'all', label: `همه (${toFaDigits(counts.value.all)})` },
{ value: 'invoice', label: `فاکتور (${toFaDigits(counts.value.invoice)})` },
{ value: 'quote', label: `پیش فاکتور (${toFaDigits(counts.value.quote)})` },
{ value: 'credit', label: `فاکتور برگشتی (${toFaDigits(counts.value.credit)})` },
]);
const payFilterOptions = [
{ value: 'all', label: 'همه وضعیت‌ها' },
{ value: 'paid', label: 'پرداخت شده' },
{ value: 'unpaid', label: 'پرداخت نشده' },
{ value: 'pending', label: 'در انتظار تایید' },
{ value: 'expired', label: 'منقضی شده' },
];
const personOptions = computed(() =>
people.value.map((p) => ({
value: p.id,
label: p.fullName || `${p.firstName} ${p.lastName}`,
})),
);
const modalTitle = computed(() => {
if (invoiceModal.value?.kind === 'quote') return 'پیش فاکتور جدید';
if (invoiceModal.value?.kind === 'credit') return 'فاکتور برگشتی';
return 'فاکتور جدید';
});
const selectedKeys = computed({
get: () => [...selected.value],
set: (keys: string[]) => {
selected.value = new Set(keys);
},
});
const columns = computed<TableColumnsType<BillingRow>>(() => {
const all: TableColumnsType<BillingRow> = [
{
title: '#',
key: 'index',
width: 56,
customRender: ({ index }) =>
toFaDigits((page.value - 1) * pageSize.value + index + 1),
},
{
title: 'شماره فاکتور',
dataIndex: 'number',
key: 'شماره فاکتور',
customRender: ({ text }) => ({
children: text,
props: { dir: 'ltr' },
}),
},
{
title: 'تاریخ',
key: 'تاریخ',
customRender: ({ record }) => formatDate(record.date),
},
{
title: 'مشتری',
key: 'مشتری',
},
{
title: 'نوع',
key: 'نوع',
customRender: ({ record }) => docLabel(record.doc),
},
{
title: 'وضعیت',
key: 'وضعیت',
},
{
title: 'مبلغ کل',
key: 'مبلغ کل',
},
{
title: 'ارز',
dataIndex: 'currency',
key: 'ارز',
},
{
title: 'مهلت پرداخت',
key: 'مهلت پرداخت',
customRender: ({ record }) => formatDate(record.dueOrValid),
},
{
title: 'عملیات',
key: 'عملیات',
fixed: 'left',
},
];
return all.filter((col) => {
const key = String(col.key ?? '');
if (key === 'index') return true;
if (!INVOICE_COL_LABELS.includes(key as (typeof INVOICE_COL_LABELS)[number])) {
return true;
}
return tableCols.isVisible(key);
});
});
function onAddMenu({ key }: { key: string | number }) {
const kind = String(key) as 'invoice' | 'quote' | 'credit';
invoiceModal.value = { kind };
addOpen.value = false;
}
function onRowMenu({ key }: { key: string | number }, row: BillingRow) {
if (String(key) === 'open') {
void router.push(row.href);
}
menuId.value = null;
}
function onDocTabChange(value: unknown) {
setDocTab(String(value ?? 'all') as DocTab);
}
function onPersonFilterChange(value: unknown) {
personId.value = value == null ? '' : String(value);
page.value = 1;
}
function onAmountMinChange(value: number | string | null) {
amountMin.value = value == null ? '' : String(value);
}
function onAmountMaxChange(value: number | string | null) {
amountMax.value = value == null ? '' : String(value);
}
function onTableSelectChange(keys: (string | number)[]) {
const next = keys.map(String);
const prev = selected.value;
if (Math.abs(next.length - prev.size) === 1) {
const nextSet = new Set(next);
for (const id of nextSet) {
if (!prev.has(id)) {
toggleSelect(id);
return;
}
}
for (const id of prev) {
if (!nextSet.has(id)) {
toggleSelect(id);
return;
}
}
}
selectedKeys.value = next;
}
defineExpose({ toggleSelect, pageCount, statusIcon });
</script>
<template>
<div @click="closeMenus">
<a-flex
justify="space-between"
align="center"
wrap="wrap"
:gap="12"
:style="{ marginBottom: '16px' }"
>
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">فاکتور ها</a-typography-title>
<a-typography-text type="secondary">
مدیریت فاکتورهای فروش، پیش فاکتور و خدمات
</a-typography-text>
</div>
<a-space :size="8" @click.stop>
<a-dropdown v-model:open="addOpen">
<a-button type="primary">
<template #icon><PlusOutlined /></template>
افزودن فاکتور
<DownOutlined />
</a-button>
<template #overlay>
<a-menu @click="onAddMenu">
<a-menu-item key="invoice">فاکتور جدید</a-menu-item>
<a-menu-item key="quote">پیش فاکتور جدید</a-menu-item>
<a-menu-item key="credit">فاکتور برگشتی</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</a-space>
</a-flex>
<a-card :bordered="false">
<a-row :gutter="[16, 12]">
<a-col :span="24">
<a-select
:value="docTab"
:options="docTabOptions"
style="width: 100%; max-width: 420px"
@update:value="onDocTabChange"
/>
</a-col>
<a-col :xs="24" :md="12" :lg="8">
<a-input
v-model:value="q"
allow-clear
placeholder="جستجو در شماره، مشتری، شرح ..."
@update:value="page = 1"
>
<template #prefix><SearchOutlined /></template>
</a-input>
</a-col>
<a-col :xs="24" :md="12" :lg="8">
<a-flex :gap="8" align="center">
<JalaliDateInput v-model="from" placeholder="از تاریخ" />
<a-typography-text type="secondary">–</a-typography-text>
<JalaliDateInput v-model="to" placeholder="تا تاریخ" />
</a-flex>
</a-col>
<a-col :xs="24" :md="12" :lg="4">
<a-select
v-model:value="payFilter"
:options="payFilterOptions"
style="width: 100%"
@change="page = 1"
/>
</a-col>
<a-col :xs="24" :md="12" :lg="4">
<a-select
:value="personId || undefined"
allow-clear
show-search
option-filter-prop="label"
:options="personOptions"
placeholder="مشتری"
style="width: 100%"
@update:value="onPersonFilterChange"
/>
</a-col>
<a-col :span="24">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<a-button @click="filterSheet = true">
<template #icon><FilterOutlined /></template>
فیلترهای بیشتر
</a-button>
<TableColumnsMenu table-key="invoices" align="end" />
</a-flex>
</a-col>
</a-row>
<a-alert
v-if="error"
type="error"
show-icon
:message="error"
:style="{ marginTop: '16px' }"
/>
<a-spin :spinning="loading">
<a-table
v-if="!error"
:columns="columns"
:data-source="pageItems"
:pagination="false"
row-key="id"
:row-selection="{
selectedRowKeys: selectedKeys,
onChange: onTableSelectChange,
onSelectAll: toggleSelectAll,
}"
:style="{ marginTop: '16px' }"
:scroll="{ x: 'max-content' }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'مشتری'">
<a-space :size="8">
<a-avatar>{{ record.customerName.slice(0, 1) }}</a-avatar>
<div>
<div>{{ record.customerName }}</div>
<a-typography-text v-if="record.personName" type="secondary">
{{ record.personName }}
</a-typography-text>
</div>
</a-space>
</template>
<template v-else-if="column.key === 'وضعیت'">
<a-tag :color="statusColor(record.statusKey)" :bordered="false">
{{ record.statusLabel }}
</a-tag>
</template>
<template v-else-if="column.key === 'مبلغ کل'">
<span dir="ltr">{{ money(record.amount, record.currency) }}</span>
</template>
<template v-else-if="column.key === 'عملیات'">
<a-space :size="4" @click.stop>
<RouterLink :to="record.href">
<a-button type="text">
<template #icon><EyeOutlined /></template>
</a-button>
</RouterLink>
<a-dropdown
:open="menuId === record.id"
@update:open="(v) => (menuId = v ? record.id : null)"
>
<a-button type="text">
<template #icon><MoreOutlined /></template>
</a-button>
<template #overlay>
<a-menu @click="(info) => onRowMenu(info, record as BillingRow)">
<a-menu-item key="open">باز کردن</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</a-space>
</template>
</template>
</a-table>
<a-empty
v-if="!loading && !pageItems.length && !error"
description="سندی یافت نشد."
:style="{ marginTop: '24px' }"
/>
<a-flex
v-if="filtered.length"
justify="flex-end"
:style="{ marginTop: '16px' }"
>
<a-pagination
v-model:current="page"
v-model:page-size="pageSize"
:total="filtered.length"
:show-size-changer="true"
:page-size-options="PAGE_SIZE_OPTIONS.map(String)"
:show-total="(total) => `${toFaDigits(total)} مورد · ${toFaDigits(pageCount)} صفحه`"
/>
</a-flex>
</a-spin>
</a-card>
<a-modal
:open="Boolean(invoiceModal)"
:title="modalTitle"
:footer="null"
width="960px"
destroy-on-close
@cancel="invoiceModal = null"
>
<InvoiceFormView
v-if="invoiceModal"
embedded
:initial-kind="invoiceModal.kind"
@saved="invoiceModal = null; void load()"
@cancel="invoiceModal = null"
/>
</a-modal>
<a-drawer
v-model:open="filterSheet"
title="فیلترها"
placement="bottom"
:height="'80%'"
>
<a-row :gutter="[16, 0]">
<a-col :span="24">
<a-form-item label="نوع سند">
<a-select
:value="docTab"
:options="docTabOptions"
style="width: 100%"
@update:value="onDocTabChange"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="وضعیت پرداخت">
<a-select
v-model:value="payFilter"
:options="payFilterOptions"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="مشتری">
<a-select
:value="personId || undefined"
allow-clear
show-search
option-filter-prop="label"
:options="personOptions"
placeholder="جستجو و انتخاب مشتری ..."
style="width: 100%"
@update:value="onPersonFilterChange"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="از تاریخ">
<JalaliDateInput v-model="from" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="تا تاریخ">
<JalaliDateInput v-model="to" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="حداقل مبلغ">
<a-input-number
:value="amountMin === '' ? undefined : Number(amountMin)"
:min="0"
placeholder="حداقل مبلغ"
style="width: 100%"
@update:value="onAmountMinChange"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="حداکثر مبلغ">
<a-input-number
:value="amountMax === '' ? undefined : Number(amountMax)"
:min="0"
placeholder="حداکثر مبلغ"
style="width: 100%"
@update:value="onAmountMaxChange"
/>
</a-form-item>
</a-col>
</a-row>
<a-space :size="8" :style="{ marginTop: '8px' }">
<a-button @click="clearFilters">پاک کردن</a-button>
<a-button type="primary" @click="applyFilters">اعمال فیلتر</a-button>
</a-space>
</a-drawer>
</div>
</template>