934 lines
36 KiB
Vue
934 lines
36 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, ref, watch } from 'vue';
|
||
import { useEscClose } from '@/lib/modal-stack';
|
||
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 {
|
||
UiButton,
|
||
UiDropdownMenu,
|
||
UiModal,
|
||
UiPageHeader,
|
||
UiPagination,
|
||
UiEmptyState,
|
||
} from '@/components/ui';
|
||
import UiIcon from '@/components/UiIcon.vue';
|
||
import InvoiceFormView from '@/views/InvoiceFormView.vue';
|
||
import type {
|
||
InvoiceDto,
|
||
PaginatedResponse,
|
||
PersonDto,
|
||
QuoteDto,
|
||
} from '@mento/shared';
|
||
|
||
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 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);
|
||
useEscClose(() => filterSheet.value, () => { filterSheet.value = 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 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);
|
||
</script>
|
||
|
||
<template>
|
||
<section class="page" @click="closeMenus">
|
||
<UiPageHeader
|
||
title="فاکتور ها"
|
||
lead="مدیریت فاکتورهای فروش، پیش فاکتور و خدمات"
|
||
>
|
||
<template #actions>
|
||
<div class="head-actions" @click.stop>
|
||
<div class="add-split desk-only">
|
||
<UiButton type="button" @click="invoiceModal = { kind: 'invoice' }; addOpen = false">
|
||
<UiIcon name="plus" :size="16" />
|
||
افزودن فاکتور
|
||
</UiButton>
|
||
<button type="button" class="btn primary split" aria-label="گزینههای افزودن" @click="addOpen = !addOpen">
|
||
<UiIcon name="chevron" :size="14" />
|
||
</button>
|
||
<div v-if="addOpen" class="menu">
|
||
<button type="button" @click="invoiceModal = { kind: 'invoice' }; addOpen = false">فاکتور جدید</button>
|
||
<button type="button" @click="invoiceModal = { kind: 'quote' }; addOpen = false">پیش فاکتور جدید</button>
|
||
<button type="button" @click="invoiceModal = { kind: 'credit' }; addOpen = false">فاکتور برگشتی</button>
|
||
</div>
|
||
</div>
|
||
<UiButton class="mob-only" type="button" @click="invoiceModal = { kind: 'invoice' }">
|
||
<UiIcon name="plus" :size="16" />
|
||
افزودن
|
||
</UiButton>
|
||
</div>
|
||
</template>
|
||
</UiPageHeader>
|
||
|
||
<!-- Desktop underline tabs -->
|
||
<div class="tabs desk-only">
|
||
<button type="button" :class="{ on: docTab === 'all' }" @click="setDocTab('all')">
|
||
همه
|
||
<em>{{ toFaDigits(counts.all) }}</em>
|
||
</button>
|
||
<button type="button" :class="{ on: docTab === 'invoice' }" @click="setDocTab('invoice')">
|
||
فاکتور
|
||
<em>{{ toFaDigits(counts.invoice) }}</em>
|
||
</button>
|
||
<button type="button" :class="{ on: docTab === 'quote' }" @click="setDocTab('quote')">
|
||
پیش فاکتور
|
||
<em>{{ toFaDigits(counts.quote) }}</em>
|
||
</button>
|
||
<button type="button" :class="{ on: docTab === 'credit' }" @click="setDocTab('credit')">
|
||
فاکتور برگشتی
|
||
<em>{{ toFaDigits(counts.credit) }}</em>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="toolbar">
|
||
<label class="search">
|
||
<UiIcon name="search" :size="18" />
|
||
<input
|
||
v-model="q"
|
||
type="search"
|
||
class="desk-ph"
|
||
placeholder="جستجو در شماره، مشتری، شرح ..."
|
||
@input="page = 1"
|
||
/>
|
||
<input
|
||
v-model="q"
|
||
type="search"
|
||
class="mob-ph"
|
||
placeholder="جستجو در شماره، مشتری، مبلغ ..."
|
||
@input="page = 1"
|
||
/>
|
||
</label>
|
||
<div class="filters desk-only">
|
||
<div class="filter-chip">
|
||
<UiIcon name="calendar" :size="16" />
|
||
<JalaliDateInput v-model="from" placeholder="از تاریخ" />
|
||
<span class="sep">–</span>
|
||
<JalaliDateInput v-model="to" placeholder="تا تاریخ" />
|
||
</div>
|
||
<label class="filter-chip">
|
||
<UiIcon name="flag" :size="16" />
|
||
<select v-model="payFilter" @change="page = 1">
|
||
<option value="all">وضعیت</option>
|
||
<option value="paid">پرداخت شده</option>
|
||
<option value="unpaid">پرداخت نشده</option>
|
||
<option value="pending">در انتظار تایید</option>
|
||
<option value="expired">منقضی شده</option>
|
||
</select>
|
||
</label>
|
||
<label class="filter-chip">
|
||
<UiIcon name="user" :size="16" />
|
||
<select v-model="personId" @change="page = 1">
|
||
<option value="">مشتری</option>
|
||
<option v-for="p in people" :key="p.id" :value="p.id">
|
||
{{ p.fullName || `${p.firstName} ${p.lastName}` }}
|
||
</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<button type="button" class="ghost-btn more-filters" @click.stop="filterSheet = true">
|
||
<UiIcon name="filter" :size="16" />
|
||
<span class="desk-only">فیلترهای بیشتر</span>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Mobile pill tabs -->
|
||
<div class="pill-tabs mob-only">
|
||
<button type="button" :class="{ on: docTab === 'all' }" @click="setDocTab('all')">
|
||
همه {{ toFaDigits(counts.all) }}
|
||
</button>
|
||
<button type="button" :class="{ on: docTab === 'invoice' }" @click="setDocTab('invoice')">
|
||
فاکتور {{ toFaDigits(counts.invoice) }}
|
||
</button>
|
||
<button type="button" :class="{ on: docTab === 'quote' }" @click="setDocTab('quote')">
|
||
پیش فاکتور {{ toFaDigits(counts.quote) }}
|
||
</button>
|
||
<button type="button" :class="{ on: docTab === 'credit' }" @click="setDocTab('credit')">
|
||
فاکتور برگشتی {{ toFaDigits(counts.credit) }}
|
||
</button>
|
||
</div>
|
||
|
||
<p v-if="error" class="error">{{ error }}</p>
|
||
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
|
||
|
||
<template v-else>
|
||
<div class="tc-row desk">
|
||
<TableColumnsMenu table-key="invoices" align="end" />
|
||
</div>
|
||
<div class="table-wrap desk">
|
||
<table v-table-cols="'invoices'">
|
||
<thead>
|
||
<tr>
|
||
<th class="check">
|
||
<input
|
||
type="checkbox"
|
||
:checked="pageItems.length > 0 && pageItems.every((r) => selected.has(r.id))"
|
||
@change="toggleSelectAll"
|
||
/>
|
||
</th>
|
||
<th class="num-col">#</th>
|
||
<th>شماره فاکتور</th>
|
||
<th>تاریخ</th>
|
||
<th>مشتری</th>
|
||
<th>نوع</th>
|
||
<th>وضعیت</th>
|
||
<th>مبلغ کل</th>
|
||
<th>ارز</th>
|
||
<th>مهلت پرداخت</th>
|
||
<th class="ops">عملیات</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="(r, idx) in pageItems" :key="r.id">
|
||
<td class="check">
|
||
<input
|
||
type="checkbox"
|
||
:checked="selected.has(r.id)"
|
||
@change="toggleSelect(r.id)"
|
||
/>
|
||
</td>
|
||
<td class="num-col">{{ toFaDigits((page - 1) * pageSize + idx + 1) }}</td>
|
||
<td class="mono" dir="ltr">{{ r.number }}</td>
|
||
<td>{{ formatDate(r.date) }}</td>
|
||
<td>
|
||
<div class="cust">
|
||
<span class="avatar">{{ r.customerName.slice(0, 1) }}</span>
|
||
<div>
|
||
<strong>{{ r.customerName }}</strong>
|
||
<small v-if="r.personName">{{ r.personName }}</small>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td><span class="type" :class="r.doc">{{ docLabel(r.doc) }}</span></td>
|
||
<td>
|
||
<span class="status" :class="r.statusKey">
|
||
<UiIcon :name="statusIcon(r.statusKey)" :size="13" />
|
||
{{ r.statusLabel }}
|
||
</span>
|
||
</td>
|
||
<td class="amount" :class="{ neg: r.amount < 0 }" dir="ltr">
|
||
{{ money(r.amount, r.currency) }}
|
||
</td>
|
||
<td>{{ r.currency }}</td>
|
||
<td :class="['due', r.statusKey]">{{ formatDate(r.dueOrValid) }}</td>
|
||
<td class="ops" @click.stop>
|
||
<span class="grip" aria-hidden="true"><UiIcon name="grip" :size="14" /></span>
|
||
<RouterLink class="icon-btn sm" :to="r.href" title="مشاهده">
|
||
<UiIcon name="document" :size="15" />
|
||
</RouterLink>
|
||
<RouterLink class="icon-btn sm" :to="r.href" title="ویرایش">
|
||
<UiIcon name="pencil" :size="15" />
|
||
</RouterLink>
|
||
<UiDropdownMenu
|
||
:open="menuId === r.id"
|
||
align="end"
|
||
@update:open="(v) => (menuId = v ? r.id : null)"
|
||
>
|
||
<template #trigger="{ toggle }">
|
||
<button type="button" class="icon-btn sm" @click.stop="toggle">
|
||
<UiIcon name="dots-v" :size="15" />
|
||
</button>
|
||
</template>
|
||
<RouterLink :to="r.href">باز کردن</RouterLink>
|
||
</UiDropdownMenu>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<p v-if="!pageItems.length" class="empty">سندی یافت نشد.</p>
|
||
</div>
|
||
|
||
<div class="mob-list">
|
||
<article v-for="r in pageItems" :key="r.id" class="mob-card" @click.stop>
|
||
<div class="mob-card-top">
|
||
<RouterLink class="mob-info" :to="r.href">
|
||
<strong>{{ r.customerName }}</strong>
|
||
<span class="mono" dir="ltr">{{ r.number }}</span>
|
||
<span class="muted">{{ formatDate(r.date) }}</span>
|
||
</RouterLink>
|
||
<span class="status" :class="r.statusKey">
|
||
<UiIcon :name="statusIcon(r.statusKey)" :size="12" />
|
||
{{ r.statusLabel }}
|
||
</span>
|
||
<div class="mob-aside">
|
||
<UiDropdownMenu
|
||
:open="menuId === r.id"
|
||
align="end"
|
||
@update:open="(v) => (menuId = v ? r.id : null)"
|
||
>
|
||
<template #trigger="{ toggle }">
|
||
<button type="button" class="icon-btn sm ghost" @click.stop="toggle">
|
||
<UiIcon name="dots-v" :size="16" />
|
||
</button>
|
||
</template>
|
||
<RouterLink :to="r.href">باز کردن</RouterLink>
|
||
</UiDropdownMenu>
|
||
<span class="avatar">{{ r.customerName.slice(0, 1) }}</span>
|
||
</div>
|
||
</div>
|
||
<div class="mob-card-bottom">
|
||
<em :class="{ neg: r.amount < 0 }">{{ money(r.amount, r.currency) }}</em>
|
||
</div>
|
||
</article>
|
||
<UiEmptyState
|
||
v-if="!pageItems.length"
|
||
title="سندی یافت نشد."
|
||
description="فاکتور، پیشفاکتور یا سند جدیدی ثبت کنید."
|
||
/>
|
||
</div>
|
||
|
||
<UiPagination
|
||
v-if="filtered.length"
|
||
:page="page"
|
||
:total-pages="pageCount"
|
||
:total-items="filtered.length"
|
||
:page-size="pageSize"
|
||
:page-size-options="PAGE_SIZE_OPTIONS"
|
||
@update:page="page = $event"
|
||
@update:page-size="pageSize = $event"
|
||
/>
|
||
</template>
|
||
|
||
<UiModal
|
||
:open="Boolean(invoiceModal)"
|
||
:title="
|
||
invoiceModal?.kind === 'quote'
|
||
? 'پیش فاکتور جدید'
|
||
: invoiceModal?.kind === 'credit'
|
||
? 'فاکتور برگشتی'
|
||
: 'فاکتور جدید'
|
||
"
|
||
@close="invoiceModal = null"
|
||
>
|
||
<InvoiceFormView
|
||
v-if="invoiceModal"
|
||
embedded
|
||
:initial-kind="invoiceModal.kind"
|
||
@saved="invoiceModal = null; void load()"
|
||
@cancel="invoiceModal = null"
|
||
/>
|
||
</UiModal>
|
||
|
||
<div v-if="filterSheet" class="sheet-backdrop">
|
||
<div class="sheet" role="dialog" aria-modal="true" aria-label="فیلترها">
|
||
<header>
|
||
<button type="button" class="icon-btn" aria-label="بستن (Esc)" title="بستن (Esc)" @click="filterSheet = false">
|
||
<UiIcon name="x" :size="18" />
|
||
</button>
|
||
<button type="button" class="link" @click="clearFilters">حذف همه</button>
|
||
<strong>فیلترها</strong>
|
||
</header>
|
||
<div class="sheet-body">
|
||
<p class="lbl">نوع سند</p>
|
||
<div class="pill-row">
|
||
<button type="button" :class="{ on: docTab === 'all' }" @click="docTab = 'all'">همه</button>
|
||
<button type="button" :class="{ on: docTab === 'invoice' }" @click="docTab = 'invoice'">فاکتور</button>
|
||
<button type="button" :class="{ on: docTab === 'quote' }" @click="docTab = 'quote'">پیش فاکتور</button>
|
||
<button type="button" :class="{ on: docTab === 'credit' }" @click="docTab = 'credit'">فاکتور برگشتی</button>
|
||
</div>
|
||
<p class="lbl">وضعیت پرداخت</p>
|
||
<div class="pill-row">
|
||
<button type="button" :class="{ on: payFilter === 'all' }" @click="payFilter = 'all'">همه</button>
|
||
<button type="button" :class="{ on: payFilter === 'paid' }" @click="payFilter = 'paid'">پرداخت شده</button>
|
||
<button type="button" :class="{ on: payFilter === 'unpaid' }" @click="payFilter = 'unpaid'">پرداخت نشده</button>
|
||
<button type="button" :class="{ on: payFilter === 'pending' }" @click="payFilter = 'pending'">در انتظار تایید</button>
|
||
<button type="button" :class="{ on: payFilter === 'expired' }" @click="payFilter = 'expired'">منقضی شده</button>
|
||
</div>
|
||
<label class="field">
|
||
<span>مشتری</span>
|
||
<div class="control">
|
||
<UiIcon name="user" :size="16" />
|
||
<select v-model="personId">
|
||
<option value="">جستجو و انتخاب مشتری ...</option>
|
||
<option v-for="p in people" :key="p.id" :value="p.id">
|
||
{{ p.fullName || `${p.firstName} ${p.lastName}` }}
|
||
</option>
|
||
</select>
|
||
</div>
|
||
</label>
|
||
<p class="lbl">بازه تاریخ</p>
|
||
<div class="row-2">
|
||
<label class="field"><span>از</span><JalaliDateInput v-model="from" /></label>
|
||
<label class="field"><span>تا</span><JalaliDateInput v-model="to" /></label>
|
||
</div>
|
||
<p class="lbl">بازه مبلغ (ریال)</p>
|
||
<div class="row-2">
|
||
<label class="field"><span>حداقل مبلغ</span><input v-model="amountMin" type="number" min="0" dir="ltr" placeholder="حداقل مبلغ" /></label>
|
||
<label class="field"><span>حداکثر مبلغ</span><input v-model="amountMax" type="number" min="0" dir="ltr" placeholder="حداکثر مبلغ" /></label>
|
||
</div>
|
||
</div>
|
||
<footer>
|
||
<button type="button" class="btn ghost" @click="clearFilters">پاک کردن</button>
|
||
<button type="button" class="btn primary" @click="applyFilters">اعمال فیلتر</button>
|
||
</footer>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.page {
|
||
--teal: #0d9488;
|
||
--teal-soft: #f0fdfa;
|
||
display: grid;
|
||
gap: 0.9rem;
|
||
position: relative;
|
||
width: 100%;
|
||
max-width: 100%;
|
||
}
|
||
.page-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 1rem;
|
||
align-items: flex-start;
|
||
}
|
||
.title-row { display: flex; gap: 0.75rem; align-items: flex-start; }
|
||
.title-ico {
|
||
width: 44px; height: 44px; border-radius: var(--radius); display: grid; place-items: center;
|
||
background: var(--teal-soft); color: var(--teal);
|
||
}
|
||
h1 { margin: 0; font-size: 1.5rem; font-weight: 600; }
|
||
.lead { margin: 0.2rem 0 0; color: var(--muted); font-size: 0.9rem; }
|
||
.head-actions { position: relative; }
|
||
.add-split { display: flex; position: relative; }
|
||
.btn {
|
||
border: 0; border-radius: var(--radius); min-height: var(--control-h); height: var(--control-h);
|
||
padding: 0 0.95rem; font-weight: 700; box-sizing: border-box;
|
||
display: inline-flex; align-items: center; gap: 0.35rem; cursor: pointer; text-decoration: none;
|
||
}
|
||
.btn.primary { background: var(--teal); color: #fff; }
|
||
.btn.split {
|
||
border-radius: var(--radius) 0 0 var(--radius); padding-inline: 0.55rem;
|
||
border-inline-start: 1px solid rgba(255,255,255,.25);
|
||
}
|
||
.add-split .btn.primary:first-child { border-radius: 0 var(--radius) var(--radius) 0; }
|
||
.btn.ghost, .ghost-btn, .icon-btn {
|
||
border: 1px solid var(--line); background: var(--surface); color: var(--text);
|
||
border-radius: var(--radius); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; gap: 0.35rem;
|
||
}
|
||
.ghost-btn { min-height: var(--control-h); height: var(--control-h); padding: 0 0.8rem; font-weight: 600; box-sizing: border-box; }
|
||
.icon-btn { width: var(--control-h); height: var(--control-h); position: relative; }
|
||
.icon-btn.sm { width: 32px; height: 32px; }
|
||
.icon-btn.ghost { border: 0; background: transparent; color: #94a3b8; }
|
||
.add-doc { color: var(--teal); }
|
||
.plus-badge {
|
||
position: absolute; bottom: 6px; left: 6px;
|
||
background: var(--teal); color: #fff; border-radius: var(--radius); padding: 1px;
|
||
}
|
||
.menu {
|
||
position: absolute; top: calc(100% + 6px); left: 0; z-index: 30; min-width: 170px;
|
||
background: #fff; border: 1px solid var(--line); border-radius: var(--radius);
|
||
box-shadow: 0 12px 28px rgba(15,23,42,.12); padding: 0.35rem; display: grid;
|
||
}
|
||
.menu.left { left: auto; right: 0; }
|
||
.menu a,
|
||
.menu button {
|
||
display: block; width: 100%;
|
||
border: 0; background: transparent; text-align: right; padding: 0.55rem 0.7rem;
|
||
border-radius: var(--radius); color: var(--text); text-decoration: none; cursor: pointer;
|
||
}
|
||
.menu a:hover,
|
||
.menu button:hover { background: var(--teal-soft); color: var(--teal); }
|
||
|
||
.tabs {
|
||
display: flex; gap: 0.35rem; overflow-x: auto; border-bottom: 1px solid var(--line);
|
||
}
|
||
.tabs button {
|
||
border: 0; background: transparent; padding: 0.65rem 0.85rem; cursor: pointer;
|
||
color: #64748b; font-weight: 700; display: inline-flex; gap: 0.4rem; align-items: center;
|
||
border-bottom: 2px solid transparent; white-space: nowrap;
|
||
}
|
||
.tabs button.on { color: var(--teal); border-bottom-color: var(--teal); }
|
||
.tabs em {
|
||
font-style: normal; font-size: 0.75rem; background: #f1f5f9; color: #64748b;
|
||
border-radius: 999px; padding: 0.1rem 0.45rem;
|
||
}
|
||
.tabs button.on em { background: var(--teal-soft); color: var(--teal); }
|
||
|
||
.pill-tabs {
|
||
display: none; gap: 0.45rem; overflow-x: auto; padding-bottom: 0.15rem;
|
||
}
|
||
.pill-tabs button {
|
||
flex: 0 0 auto; border: 0; border-radius: 999px; padding: 0.45rem 0.85rem;
|
||
background: #e0f2fe; color: #0369a1; font-weight: 700; cursor: pointer; white-space: nowrap;
|
||
}
|
||
.pill-tabs button.on { background: var(--teal); color: #fff; }
|
||
|
||
.toolbar { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
|
||
.search {
|
||
flex: 1 1 220px; max-width: 380px; display: flex; align-items: center; gap: 0.45rem;
|
||
border: 1px solid var(--line); border-radius: var(--radius); min-height: var(--control-h); height: var(--control-h);
|
||
padding: 0 0.75rem; background: #fff; color: var(--muted); box-sizing: border-box;
|
||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||
}
|
||
.search:focus-within {
|
||
border-color: var(--accent);
|
||
box-shadow: 0 0 0 2px var(--accent-soft);
|
||
}
|
||
.search input { border: 0; outline: 0; width: 100%; background: transparent; color: var(--ink); font-size: var(--fs-sm); padding: 0; }
|
||
.search .mob-ph { display: none; }
|
||
.filters { display: flex; flex-wrap: wrap; gap: 0.45rem; align-items: center; }
|
||
.filter-chip {
|
||
display: inline-flex; align-items: center; gap: 0.3rem; flex-wrap: nowrap;
|
||
border: 1px solid var(--line); border-radius: var(--radius); padding: 0 0.55rem;
|
||
background: #fff; color: var(--muted); min-height: var(--control-h); height: var(--control-h);
|
||
box-sizing: border-box; white-space: nowrap; font-size: var(--fs-sm);
|
||
transition: border-color var(--transition-fast);
|
||
}
|
||
.filter-chip:focus-within {
|
||
border-color: var(--accent);
|
||
}
|
||
.filter-chip select, .filter-chip :deep(button), .filter-chip :deep(input) {
|
||
border: 0; background: transparent; outline: 0; min-height: 0; height: 32px; color: var(--ink); font-size: var(--fs-sm);
|
||
}
|
||
.filter-chip :deep(.wrap) { width: auto; flex: 0 1 auto; }
|
||
.filter-chip :deep(.trigger) {
|
||
width: auto; min-width: 5.4rem; height: 32px; min-height: 32px;
|
||
padding: 0 0.2rem; border: 0; box-shadow: none;
|
||
}
|
||
.filter-chip :deep(.trigger span) { white-space: nowrap; }
|
||
.filter-chip :deep(.trigger .ico) { display: none; }
|
||
.filter-chip .sep { color: #cbd5e1; flex-shrink: 0; }
|
||
|
||
.table-wrap {
|
||
background: #fff; border: 1px solid #eef2f7; border-radius: var(--radius); overflow: auto;
|
||
box-shadow: 0 8px 24px rgba(15,23,42,.04);
|
||
}
|
||
table { width: 100%; border-collapse: collapse; min-width: 980px; }
|
||
th, td { padding: 0.85rem 0.7rem; border-bottom: 1px solid #eef2f7; text-align: right; font-size: 0.9rem; vertical-align: middle; }
|
||
th { background: #f8fafc; color: #64748b; font-weight: 700; white-space: nowrap; }
|
||
tbody tr:nth-child(even) { background: #fcfdff; }
|
||
.check { width: 42px; }
|
||
.mono { font-family: ui-monospace, monospace; direction: ltr; text-align: left; }
|
||
.cust { display: flex; gap: 0.55rem; align-items: center; }
|
||
.avatar {
|
||
width: 36px; height: 36px; border-radius: 999px; background: var(--teal-soft); color: var(--teal);
|
||
display: grid; place-items: center; font-weight: 600; flex-shrink: 0;
|
||
}
|
||
.cust strong { display: block; }
|
||
.cust small { color: var(--muted); }
|
||
.type {
|
||
display: inline-flex; padding: 0.2rem 0.55rem; border-radius: 999px; font-size: 0.78rem; font-weight: 700;
|
||
}
|
||
.type.invoice { background: #ccfbf1; color: #0f766e; }
|
||
.type.quote { background: #e0f2fe; color: #0369a1; }
|
||
.type.credit { background: #fee2e2; color: #b91c1c; }
|
||
.status {
|
||
display: inline-flex; align-items: center; gap: 0.3rem; padding: 0.22rem 0.55rem;
|
||
border-radius: 999px; font-size: 0.78rem; font-weight: 700; white-space: nowrap;
|
||
}
|
||
.status.paid { background: #dcfce7; color: #15803d; }
|
||
.status.unpaid { background: #fee2e2; color: #b91c1c; }
|
||
.status.pending { background: #fef3c7; color: #b45309; }
|
||
.status.expired { background: #f1f5f9; color: #64748b; }
|
||
.status.quote { background: #e0f2fe; color: #0369a1; }
|
||
.amount { font-weight: 700; }
|
||
.amount.neg, .neg { color: #b91c1c; }
|
||
.due.paid { color: #15803d; }
|
||
.due.pending { color: #b45309; }
|
||
.due.unpaid { color: #b91c1c; }
|
||
.ops { position: relative; white-space: nowrap; width: 140px; }
|
||
.grip { display: inline-flex; color: #cbd5e1; margin-inline-end: 0.15rem; vertical-align: middle; }
|
||
|
||
.mob-list { display: none; gap: 0.65rem; }
|
||
.mob-card {
|
||
position: relative;
|
||
background: #fff; border: 1px solid #eef2f7; border-radius: var(--radius); padding: 0.9rem;
|
||
box-shadow: 0 6px 16px rgba(15,23,42,.04);
|
||
display: grid; gap: 0.65rem;
|
||
}
|
||
.mob-card-top {
|
||
display: grid;
|
||
grid-template-columns: 1fr auto auto;
|
||
gap: 0.55rem;
|
||
align-items: start;
|
||
}
|
||
.mob-info { text-decoration: none; color: inherit; display: grid; gap: 0.12rem; min-width: 0; }
|
||
.mob-info .mono, .mob-info .muted { font-size: 0.8rem; color: var(--muted); }
|
||
.mob-aside { display: grid; justify-items: center; gap: 0.35rem; }
|
||
.mob-card-bottom { display: flex; justify-content: flex-start; }
|
||
.mob-card-bottom em { font-style: normal; font-weight: 600; font-size: 0.92rem; }
|
||
|
||
.pager {
|
||
display: flex; flex-wrap: wrap; justify-content: space-between; gap: 0.75rem; align-items: center;
|
||
}
|
||
.pager-meta { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center; }
|
||
.page-size {
|
||
display: inline-flex; align-items: center; gap: 0.35rem; color: var(--muted); font-size: 0.88rem;
|
||
}
|
||
.page-size select {
|
||
border: 1px solid var(--line); border-radius: var(--radius); padding: 0.3rem 0.5rem; background: #fff;
|
||
}
|
||
.pages { display: flex; gap: 0.3rem; }
|
||
.page-btn {
|
||
min-width: 34px; height: 34px; border-radius: var(--radius); border: 1px solid var(--line);
|
||
background: #fff; cursor: pointer;
|
||
}
|
||
.page-btn.on { background: var(--teal); color: #fff; border-color: transparent; }
|
||
|
||
.fab {
|
||
display: none; position: fixed; left: 1rem; right: 1rem; bottom: calc(1rem + var(--mobile-tabbar-h)); z-index: 40;
|
||
background: var(--teal); color: #fff; text-align: center; text-decoration: none;
|
||
border-radius: var(--radius); padding: 0.9rem; font-weight: 600;
|
||
box-shadow: 0 12px 28px rgba(13,148,136,.35);
|
||
}
|
||
|
||
.sheet-backdrop {
|
||
position: fixed; inset: 0; background: rgba(15,23,42,.45); z-index: 50;
|
||
display: grid; align-items: end;
|
||
}
|
||
.sheet {
|
||
background: #fff; border-radius: var(--radius, 10px) var(--radius, 10px) 0 0; padding: 16px; max-height: 85vh; overflow: auto;
|
||
}
|
||
.sheet header {
|
||
display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem;
|
||
}
|
||
.sheet header strong { justify-self: end; font-size: 16px; font-weight: 500; color: var(--ink); }
|
||
.link { border: 0; background: transparent; color: var(--teal); font-weight: 700; cursor: pointer; }
|
||
.lbl { margin: 0.85rem 0 0.4rem; font-weight: 700; color: #334155; }
|
||
.pill-row { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
||
.pill-row button {
|
||
border: 1px solid var(--line); background: #f8fafc; border-radius: 999px; padding: 0.4rem 0.7rem;
|
||
cursor: pointer; font-weight: 600;
|
||
}
|
||
.pill-row button.on { background: var(--teal); color: #fff; border-color: transparent; }
|
||
.field { display: grid; gap: 0.3rem; color: var(--muted); font-size: 0.85rem; margin-top: 0.35rem; }
|
||
.field input, .field select, .control select {
|
||
border: 1px solid var(--line); border-radius: var(--radius); padding: 0.55rem 0.7rem; background: #fff; color: var(--text);
|
||
}
|
||
.control { position: relative; display: flex; align-items: center; }
|
||
.control :deep(.ico) {
|
||
position: absolute; inset-inline-start: 0.7rem; color: #94a3b8; pointer-events: none;
|
||
}
|
||
.control select { width: 100%; padding-inline-start: 2.2rem; }
|
||
.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.55rem; }
|
||
.sheet footer { display: grid; grid-template-columns: 1fr 1fr; gap: 0.55rem; margin-top: 1rem; }
|
||
.empty, .muted, .error { font-size: 0.9rem; }
|
||
.error { color: #b91c1c; }
|
||
.muted, .empty { color: var(--muted); }
|
||
.mob-only { display: none; }
|
||
|
||
@media (max-width: 900px) {
|
||
.desk, .desk-only { display: none !important; }
|
||
.mob-list, .mob-only, .fab, .pill-tabs { display: flex; }
|
||
.mob-list { display: grid; }
|
||
.search .desk-ph { display: none; }
|
||
.search .mob-ph { display: block; }
|
||
.toolbar { flex-wrap: nowrap; }
|
||
.more-filters {
|
||
width: var(--control-h); height: var(--control-h); padding: 0; flex-shrink: 0; border-radius: var(--radius);
|
||
}
|
||
.page { padding-bottom: 6rem; }
|
||
}
|
||
</style>
|