Files
crm/apps/web/src/views/InvoicesView.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

602 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.
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { formatMoney } from '@/lib/money';
import { toFaDigits } from '@/lib/jalali';
import UiIcon from '@/components/UiIcon.vue';
import type { InvoiceDto, InvoiceStatus, PaginatedResponse } from '@mento/shared';
const route = useRoute();
const router = useRouter();
const loading = ref(false);
const error = ref('');
const data = ref<PaginatedResponse<InvoiceDto> | null>(null);
const q = ref(typeof route.query.q === 'string' ? route.query.q : '');
const status = ref<InvoiceStatus | 'all'>(
typeof route.query.status === 'string' &&
['draft', 'issued', 'partially_paid', 'paid', 'canceled'].includes(route.query.status)
? (route.query.status as InvoiceStatus)
: 'all',
);
const filterOpen = ref(false);
const statusLabel: Record<InvoiceStatus, string> = {
draft: 'پیش‌نویس',
issued: 'صادرشده',
partially_paid: 'پرداخت جزئی',
paid: 'تسویه‌شده',
canceled: 'لغوشده',
};
const items = computed(() => data.value?.items ?? []);
const listEmpty = computed(() => !loading.value && !items.value.length);
const statusFilterLabel = computed(() =>
status.value === 'all' ? 'همه وضعیت‌ها' : statusLabel[status.value],
);
async function load() {
loading.value = true;
error.value = '';
filterOpen.value = false;
try {
const { data: res } = await api.get<PaginatedResponse<InvoiceDto>>('/invoices', {
params: {
q: q.value || undefined,
status: status.value === 'all' ? undefined : status.value,
dealId: typeof route.query.dealId === 'string' ? route.query.dealId : undefined,
limit: 50,
},
});
data.value = res;
} catch (e) {
if (axios.isAxiosError(e) && !e.response) {
error.value = 'اتصال به API برقرار نشد.';
} else {
error.value = 'بارگذاری فاکتورها ناموفق بود.';
}
} finally {
loading.value = false;
}
}
function onSearch() {
void router.replace({
name: 'invoices',
query: {
...route.query,
q: q.value.trim() || undefined,
status: status.value === 'all' ? undefined : status.value,
},
});
}
function setStatus(next: InvoiceStatus | 'all') {
status.value = next;
filterOpen.value = false;
onSearch();
}
watch(
() => [route.query.q, route.query.status, route.query.dealId],
() => {
q.value = typeof route.query.q === 'string' ? route.query.q : '';
status.value =
typeof route.query.status === 'string' &&
['draft', 'issued', 'partially_paid', 'paid', 'canceled'].includes(route.query.status)
? (route.query.status as InvoiceStatus)
: 'all';
void load();
},
);
onMounted(load);
</script>
<template>
<section class="page" @click="filterOpen = false">
<header class="page-head">
<div class="head-copy">
<h1>فاکتورها</h1>
<p class="lead">صدور فاکتور، ثبت پرداخت و پیگیری اقساط ساده.</p>
</div>
<RouterLink class="add-btn" :to="{ name: 'invoice-new', query: route.query }" aria-label="فاکتور جدید">
<span>+</span>
</RouterLink>
</header>
<div class="tools">
<form class="search" @submit.prevent="onSearch">
<UiIcon name="search" :size="18" />
<input v-model="q" type="search" placeholder="جستجو در عنوان یا شماره…" />
</form>
<div class="filter-wrap" @click.stop>
<button class="btn ghost filter-btn" type="button" @click="filterOpen = !filterOpen">
<UiIcon name="filter" :size="16" />
<span class="btn-full">{{ statusFilterLabel }}</span>
<span class="btn-short">وضعیت</span>
<UiIcon name="chevron" :size="14" class="chev" />
</button>
<div v-if="filterOpen" class="popover">
<button type="button" :class="{ on: status === 'all' }" @click="setStatus('all')">
همه وضعیت‌ها
</button>
<button
v-for="(label, key) in statusLabel"
:key="key"
type="button"
:class="{ on: status === key }"
@click="setStatus(key as InvoiceStatus)"
>
{{ label }}
</button>
</div>
</div>
</div>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<div v-else-if="listEmpty" class="empty-card">
<div class="art" aria-hidden="true">
<svg viewBox="0 0 168 148" fill="none">
<rect x="38" y="18" width="92" height="112" rx="16" fill="#E7F6F3" />
<rect x="50" y="32" width="68" height="84" rx="10" fill="#fff" />
<path d="M62 52h28" stroke="#14b8a6" stroke-width="4" stroke-linecap="round" />
<path d="M62 68h36" stroke="#99f6e4" stroke-width="4" stroke-linecap="round" />
<path d="M62 84h22" stroke="#99f6e4" stroke-width="4" stroke-linecap="round" />
<circle cx="118" cy="112" r="20" fill="#14b8a6" />
<path d="M118 104v16M110 112h16" stroke="#fff" stroke-width="3" stroke-linecap="round" />
</svg>
</div>
<h2>هنوز فاکتوری نیست</h2>
<p>اولین فاکتور را صادر کنید تا پرداخت‌ها و اقساط را اینجا ببینید.</p>
<RouterLink class="btn primary wide" :to="{ name: 'invoice-new', query: route.query }">
<span>+</span>
فاکتور جدید
</RouterLink>
</div>
<template v-else>
<div class="list-head">
<strong>{{ toFaDigits(items.length) }} فاکتور</strong>
</div>
<div class="table-wrap desktop-only">
<table class="data-table">
<thead>
<tr>
<th>عنوان</th>
<th>شماره</th>
<th>وضعیت</th>
<th>مشتری / معامله</th>
<th>مبلغ</th>
</tr>
</thead>
<tbody>
<tr v-for="invoice in items" :key="invoice.id">
<td>
<RouterLink class="title" :to="{ name: 'invoice-detail', params: { id: invoice.id } }">
{{ invoice.title }}
</RouterLink>
</td>
<td class="muted">{{ invoice.number }}</td>
<td>
<span class="badge" :class="invoice.status">
{{ statusLabel[invoice.status as InvoiceStatus] || invoice.status }}
</span>
</td>
<td class="muted">
<span v-if="invoice.person">{{ invoice.person.fullName }}</span>
<span v-if="invoice.person && invoice.deal"> · </span>
<span v-if="invoice.deal">{{ invoice.deal.title }}</span>
<span v-if="!invoice.person && !invoice.deal">—</span>
</td>
<td class="amounts">
<strong>{{ formatMoney(invoice.totalAmount, invoice.currency) }}</strong>
<span v-if="invoice.paidAmount > 0" class="paid">
وصول: {{ formatMoney(invoice.paidAmount, invoice.currency) }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
<div class="cards mobile-only">
<RouterLink
v-for="invoice in items"
:key="invoice.id"
class="m-card"
:to="{ name: 'invoice-detail', params: { id: invoice.id } }"
>
<div class="m-top">
<strong>{{ invoice.title }}</strong>
<span class="badge" :class="invoice.status">
{{ statusLabel[invoice.status as InvoiceStatus] || invoice.status }}
</span>
</div>
<p class="muted">{{ invoice.number }}</p>
<p v-if="invoice.person || invoice.deal" class="meta">
<span v-if="invoice.person">{{ invoice.person.fullName }}</span>
<span v-if="invoice.deal"> · {{ invoice.deal.title }}</span>
</p>
<div class="m-foot">
<strong>{{ formatMoney(invoice.totalAmount, invoice.currency) }}</strong>
<span v-if="invoice.paidAmount > 0" class="paid">
وصول: {{ formatMoney(invoice.paidAmount, invoice.currency) }}
</span>
</div>
</RouterLink>
</div>
</template>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
min-width: 0;
overflow-x: clip;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.head-copy {
min-width: 0;
}
h1 {
margin: 0 0 0.3rem;
font-size: clamp(1.45rem, 2.5vw, 1.85rem);
font-weight: 800;
}
.lead,
.muted,
.error,
.meta {
margin: 0;
}
.lead,
.muted,
.meta {
color: #64748b;
}
.error {
color: #b91c1c;
}
.add-btn {
width: 48px;
height: 48px;
border-radius: 999px;
background: #14b8a6;
color: #fff;
display: grid;
place-items: center;
text-decoration: none;
font-size: 1.6rem;
font-weight: 700;
box-shadow: 0 8px 18px rgba(20, 184, 166, 0.28);
flex-shrink: 0;
}
.tools {
display: flex;
gap: 0.55rem;
align-items: center;
}
.search {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 0.5rem;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 0 0.85rem;
color: #94a3b8;
}
.search input {
flex: 1;
min-width: 0;
border: 0;
background: none;
padding: 0.75rem 0;
font: inherit;
}
.filter-wrap {
position: relative;
flex-shrink: 0;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-height: 42px;
padding: 0.55rem 1rem;
border-radius: 12px;
font-weight: 700;
border: 1px solid transparent;
cursor: pointer;
font: inherit;
text-decoration: none;
}
.btn.primary {
background: #14b8a6;
color: #fff;
box-shadow: 0 8px 18px rgba(20, 184, 166, 0.24);
}
.btn.ghost {
background: #fff;
color: #0f766e;
border-color: #99f6e4;
}
.btn.wide {
width: min(100%, 320px);
}
.filter-btn .chev {
transform: rotate(90deg);
}
.btn-short {
display: none;
}
.popover {
position: absolute;
top: calc(100% + 6px);
inset-inline-end: 0;
min-width: 11rem;
background: #fff;
border-radius: 12px;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
padding: 0.35rem;
display: grid;
z-index: 8;
}
.popover button {
display: block;
width: 100%;
text-align: start;
border: 0;
background: none;
padding: 0.6rem 0.7rem;
border-radius: 8px;
font: inherit;
cursor: pointer;
color: #0f172a;
}
.popover button:hover,
.popover button.on {
background: #f0fdfa;
color: #0f766e;
}
.empty-card {
background: #fff;
border-radius: 22px;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.05);
padding: 2.4rem 1.2rem 2rem;
display: grid;
justify-items: center;
text-align: center;
gap: 0.7rem;
}
.art svg {
width: 148px;
height: auto;
display: block;
}
.empty-card h2 {
margin: 0.2rem 0 0;
font-size: 1.15rem;
}
.empty-card > p {
max-width: 22rem;
color: #94a3b8;
line-height: 1.8;
}
.list-head {
color: #0f766e;
}
.table-wrap {
background: #fff;
border-radius: 18px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.05);
overflow: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
min-width: 640px;
}
.data-table th,
.data-table td {
padding: 0.9rem 1rem;
text-align: start;
border-bottom: 1px solid #f1f5f9;
}
.data-table th {
font-size: 0.78rem;
color: #94a3b8;
font-weight: 700;
background: #f8fafc;
}
.title {
color: #0f172a;
font-weight: 700;
text-decoration: none;
}
.title:hover {
color: #0f766e;
}
.badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.55rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
background: #f1f5f9;
color: #475569;
white-space: nowrap;
}
.badge.draft {
background: #f1f5f9;
color: #64748b;
}
.badge.issued {
background: #ecfeff;
color: #0e7490;
}
.badge.partially_paid {
background: #fff7ed;
color: #c2410c;
}
.badge.paid {
background: #ecfdf5;
color: #047857;
}
.badge.canceled {
background: #fef2f2;
color: #b91c1c;
}
.amounts {
display: grid;
gap: 0.15rem;
white-space: nowrap;
}
.paid {
font-size: 0.8rem;
color: #64748b;
}
.desktop-only {
display: block;
}
.mobile-only {
display: none;
}
.cards {
display: grid;
gap: 0.65rem;
}
.m-card {
background: #fff;
border-radius: 16px;
padding: 0.95rem 1rem;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.05);
text-decoration: none;
color: inherit;
display: grid;
gap: 0.35rem;
min-width: 0;
}
.m-top {
display: flex;
justify-content: space-between;
gap: 0.6rem;
align-items: flex-start;
}
.m-top strong {
min-width: 0;
overflow-wrap: anywhere;
}
.m-foot {
display: flex;
flex-wrap: wrap;
gap: 0.4rem 0.75rem;
align-items: baseline;
margin-top: 0.25rem;
}
@media (max-width: 900px) {
.page-head {
align-items: center;
}
.tools {
flex-wrap: wrap;
}
.search {
flex: 1 1 100%;
}
.btn-full {
display: none;
}
.btn-short {
display: inline;
}
.desktop-only {
display: none;
}
.mobile-only {
display: grid;
}
}
@media (max-width: 520px) {
.add-btn {
width: 44px;
height: 44px;
}
.m-card {
padding: 0.85rem;
}
}
</style>