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>
827 lines
24 KiB
Vue
827 lines
24 KiB
Vue
<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>
|