feat(treasury): number documents and manage categories on their tab

Save a system tracking number on each income and expense, reuse the full contact dialogs for a new party, and create, rename, or delete categories only from the categories tab.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-04 13:11:09 +03:30
co-authored by Cursor
parent d71c2dbef4
commit 08a278e1c8
10 changed files with 361 additions and 256 deletions
+15 -1
View File
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Patch, Post, Query, StreamableFile, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, StreamableFile, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@@ -43,6 +43,20 @@ export class TreasuryController {
return this.treasury.createCategory(user, body);
}
@Patch('categories/:id')
updateCategory(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() body: { name?: string; isActive?: boolean },
) {
return this.treasury.updateCategory(user, id, body);
}
@Delete('categories/:id')
deleteCategory(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.treasury.deleteCategory(user, id);
}
@Get('accounts')
accounts(
@CurrentUser() user: Actor,
+85 -2
View File
@@ -20,6 +20,7 @@ import type {
} from '@mento/shared';
import { CompaniesService } from '../contacts/companies.service';
import { PeopleService } from '../contacts/people.service';
import { dateToJalali } from '../common/jalali';
import { PrismaService } from '../prisma/prisma.service';
import { LocalFileStorage } from '../storage/local-file.storage';
import { StorageQuotaService } from '../billing/storage-quota.service';
@@ -119,6 +120,7 @@ export class TreasuryService {
): Promise<FinancialCategoryDto> {
const name = input.name.trim();
if (name.length < 2) throw new BadRequestException('نام دسته‌بندی کوتاه است.');
try {
const row = await this.prisma.financialCategory.create({
data: {
organizationId: actor.organizationId,
@@ -132,6 +134,54 @@ export class TreasuryService {
kind: row.kind as FinancialCategoryDto['kind'],
isActive: row.isActive,
};
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new BadRequestException('این نام برای همین نوع دسته قبلاً ثبت شده است.');
}
throw e;
}
}
async updateCategory(
actor: Actor,
id: string,
input: { name?: string; isActive?: boolean },
): Promise<FinancialCategoryDto> {
const current = await this.prisma.financialCategory.findFirst({
where: { id, organizationId: actor.organizationId },
});
if (!current) throw new NotFoundException('دسته‌بندی پیدا نشد.');
const name = input.name == null ? current.name : input.name.trim();
if (name.length < 2) throw new BadRequestException('نام دسته‌بندی کوتاه است.');
try {
const row = await this.prisma.financialCategory.update({
where: { id: current.id },
data: {
name,
...(input.isActive == null ? {} : { isActive: input.isActive }),
},
});
return {
id: row.id,
name: row.name,
kind: row.kind as FinancialCategoryDto['kind'],
isActive: row.isActive,
};
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new BadRequestException('این نام برای همین نوع دسته قبلاً ثبت شده است.');
}
throw e;
}
}
async deleteCategory(actor: Actor, id: string): Promise<{ ok: true }> {
const current = await this.prisma.financialCategory.findFirst({
where: { id, organizationId: actor.organizationId },
});
if (!current) throw new NotFoundException('دسته‌بندی پیدا نشد.');
await this.prisma.financialCategory.delete({ where: { id: current.id } });
return { ok: true };
}
async listAccounts(
@@ -511,6 +561,13 @@ export class TreasuryService {
}
const bookedAt = startOfDay(input.bookedAt) ?? new Date();
const doc = await this.prisma.$transaction(async (tx) => {
const reference = await this.nextDocumentReference(
tx,
actor.organizationId,
input.kind,
input.bookedAt,
bookedAt,
);
const created = await tx.financialDocument.create({
data: {
organizationId: actor.organizationId,
@@ -528,7 +585,7 @@ export class TreasuryService {
accountId: input.accountId || null,
method: input.method || null,
status: settle.equals(0) ? 'open' : settle.equals(amount) ? 'settled' : 'partial',
reference: input.reference?.trim() || null,
reference,
notes: input.notes?.trim() || null,
createdById: actor.userId,
},
@@ -544,7 +601,7 @@ export class TreasuryService {
kind: input.kind === 'income' ? 'receipt' : 'payment',
occurredAt: startOfDay(input.settledAt ?? undefined) ?? bookedAt,
memo: title,
reference: input.reference?.trim() || null,
reference,
createdById: actor.userId,
},
});
@@ -557,6 +614,32 @@ export class TreasuryService {
return this.toDocumentDto(doc);
}
/** شماره پیگیری روزانه: DR-14050813-0001 برای درآمد و PD-… برای هزینه. */
private async nextDocumentReference(
tx: Prisma.TransactionClient,
organizationId: string,
kind: 'income' | 'expense',
bookedAtInput: string | undefined,
bookedAt: Date,
) {
const match = bookedAtInput?.match(/^(\d{4})-(\d{2})-(\d{2})/);
const when = match
? new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]))
: bookedAt;
const { jy, jm, jd } = dateToJalali(when);
const ymd = `${jy}${String(jm).padStart(2, '0')}${String(jd).padStart(2, '0')}`;
const prefix = `${kind === 'income' ? 'DR' : 'PD'}-${ymd}-`;
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${`finref:${organizationId}:${prefix}`})::bigint)`;
const latest = await tx.financialDocument.findFirst({
where: { organizationId, reference: { startsWith: prefix } },
orderBy: { reference: 'desc' },
select: { reference: true },
});
const current = Number(latest?.reference?.slice(prefix.length));
const next = Number.isFinite(current) && current > 0 ? current + 1 : 1;
return `${prefix}${String(next).padStart(4, '0')}`;
}
async searchParties(actor: Actor, q?: string): Promise<TreasuryPartySearchDto> {
const term = q?.trim() ?? '';
const contains = term ? { contains: term, mode: 'insensitive' as const } : undefined;
@@ -22,7 +22,7 @@ const links = computed(() => {
{ value: '/finance/recurring', label: 'تکرارشونده', section: 'treasury_recurring' },
{ value: '/finance/reports', label: 'گزارش', section: 'treasury_reports' },
{ value: '/finance/audit', label: 'سوابق', section: 'treasury_audit' },
{ value: '/finance/categories', label: 'دسته‌بندی‌ها', section: 'treasury_categories' },
{ value: '/finance/categories', label: 'دسته‌بندی', section: 'treasury_categories' },
];
return all.filter((link) => treasurySectionAllowed(role, permissions, link.section));
});
@@ -4,16 +4,15 @@ import axios from 'axios';
import {
Button as AButton,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Input as AInput,
Modal as AModal,
Select as ASelect,
Typography,
message,
} from 'ant-design-vue';
import type { FinancialPartyKind, TreasuryPartySearchDto } from '@mento/shared';
import type { CompanyDto, FinancialPartyKind, PersonDto, TreasuryPartySearchDto } from '@mento/shared';
import { UiModal } from '@/components/ui';
import { api } from '@/lib/api';
import CompanyFormView from '@/views/CompanyFormView.vue';
import PersonFormView from '@/views/PersonFormView.vue';
const VNodes = defineComponent({
props: { vnodes: { type: Object, required: true } },
@@ -36,12 +35,9 @@ const companies = ref<Choice[]>([]);
const users = ref<Choice[]>([]);
const labels = ref<Record<string, string>>({});
const loading = ref(false);
const selectOpen = ref(false);
const personOpen = ref(false);
const companyOpen = ref(false);
const personSaving = ref(false);
const companySaving = ref(false);
const personForm = ref({ firstName: '', lastName: '' });
const companyName = ref('');
const selectValue = computed(() => {
if (kind.value === 'other') return 'other';
@@ -112,57 +108,37 @@ function onChange(value: unknown) {
partyId.value = id;
}
function savePerson() {
const firstName = personForm.value.firstName.trim();
const lastName = personForm.value.lastName.trim();
if (`${firstName} ${lastName}`.trim().length < 2) {
message.warning('نام شخص را وارد کنید.');
return Promise.reject(new Error('invalid'));
}
personSaving.value = true;
return api
.post<{ id: string; name: string }>('/treasury/parties/person', { firstName, lastName })
.then(({ data }) => {
people.value = [{ id: data.id, name: data.name }, ...people.value.filter((row) => row.id !== data.id)];
labels.value[`person:${data.id}`] = data.name;
kind.value = 'person';
partyId.value = data.id;
personForm.value = { firstName: '', lastName: '' };
message.success('شخص در مشتریان ثبت شد.');
})
.catch((e: unknown) => {
if (axios.isAxiosError(e)) message.error(apiMessage(e));
return Promise.reject(e);
})
.finally(() => {
personSaving.value = false;
});
function onDropdown(open: boolean) {
selectOpen.value = open;
}
function saveCompany() {
const name = companyName.value.trim();
if (name.length < 2) {
message.warning('نام شرکت را وارد کنید.');
return Promise.reject(new Error('invalid'));
}
companySaving.value = true;
return api
.post<{ id: string; name: string }>('/treasury/parties/company', { name })
.then(({ data }) => {
companies.value = [{ id: data.id, name: data.name }, ...companies.value.filter((row) => row.id !== data.id)];
labels.value[`company:${data.id}`] = data.name;
function openPersonForm() {
selectOpen.value = false;
personOpen.value = true;
}
function openCompanyForm() {
selectOpen.value = false;
companyOpen.value = true;
}
function onPersonCreated(person?: PersonDto) {
personOpen.value = false;
if (!person?.id) return;
const name = person.fullName?.trim() || `${person.firstName} ${person.lastName}`.trim();
people.value = [{ id: person.id, name }, ...people.value.filter((row) => row.id !== person.id)];
labels.value[`person:${person.id}`] = name;
kind.value = 'person';
partyId.value = person.id;
}
function onCompanyCreated(company?: CompanyDto) {
companyOpen.value = false;
if (!company?.id) return;
companies.value = [{ id: company.id, name: company.name }, ...companies.value.filter((row) => row.id !== company.id)];
labels.value[`company:${company.id}`] = company.name;
kind.value = 'company';
partyId.value = data.id;
companyName.value = '';
message.success('شرکت در شرکت‌ها ثبت شد.');
})
.catch((e: unknown) => {
if (axios.isAxiosError(e)) message.error(apiMessage(e));
return Promise.reject(e);
})
.finally(() => {
companySaving.value = false;
});
partyId.value = company.id;
}
onMounted(() => {
@@ -173,7 +149,9 @@ onMounted(() => {
<template>
<a-flex vertical :gap="4">
<a-select
:open="selectOpen"
show-search
@dropdownVisibleChange="onDropdown"
allow-clear
:filter-option="false"
:loading="loading"
@@ -217,8 +195,8 @@ onMounted(() => {
<template #dropdownRender="{ menuNode }">
<v-nodes :vnodes="menuNode" />
<a-flex @mousedown.prevent>
<a-button type="link" @click="personOpen = true">شخص جدید</a-button>
<a-button type="link" @click="companyOpen = true">شرکت جدید</a-button>
<a-button type="link" @click="openPersonForm">شخص جدید</a-button>
<a-button type="link" @click="openCompanyForm">شرکت جدید</a-button>
</a-flex>
</template>
</a-select>
@@ -227,36 +205,21 @@ onMounted(() => {
</a-typography-text>
</a-flex>
<a-modal
v-model:open="personOpen"
title="شخص جدید"
ok-text="ثبت در مشتریان"
cancel-text="انصراف"
:confirm-loading="personSaving"
@ok="savePerson"
>
<a-form layout="vertical">
<a-form-item label="نام" required>
<a-input v-model:value="personForm.firstName" />
</a-form-item>
<a-form-item label="نام خانوادگی">
<a-input v-model:value="personForm.lastName" />
</a-form-item>
</a-form>
</a-modal>
<UiModal :open="personOpen" title="شخص جدید" @close="personOpen = false">
<PersonFormView
v-if="personOpen"
embedded
@saved="onPersonCreated"
@cancel="personOpen = false"
/>
</UiModal>
<a-modal
v-model:open="companyOpen"
title="شرکت جدید"
ok-text="ثبت در شرکت‌ها"
cancel-text="انصراف"
:confirm-loading="companySaving"
@ok="saveCompany"
>
<a-form layout="vertical">
<a-form-item label="نام شرکت" required>
<a-input v-model:value="companyName" />
</a-form-item>
</a-form>
</a-modal>
<UiModal :open="companyOpen" title="شرکت جدید" @close="companyOpen = false">
<CompanyFormView
v-if="companyOpen"
embedded
@saved="onCompanyCreated"
@cancel="companyOpen = false"
/>
</UiModal>
</template>
+2 -2
View File
@@ -369,8 +369,8 @@ export const router = createRouter({
component: () => import('@/views/treasury/TreasuryCategoriesView.vue'),
meta: {
roles: ['owner', 'admin'] as UserRole[],
treasuryTitle: 'دسته‌بندی‌های مالی',
treasuryDescription: 'دسته‌های پیش‌فرض با اولین بازدید ساخته می‌شوند.',
treasuryTitle: 'دسته‌بندی',
treasuryDescription: 'دسته‌های درآمد و هزینهٔ همین محیط؛ ساخته و ویرایش فقط از اینجا.',
},
},
],
+2 -2
View File
@@ -27,7 +27,7 @@ const props = withDefaults(
}>(),
{ embedded: false },
);
const emit = defineEmits<{ saved: []; cancel: [] }>();
const emit = defineEmits<{ saved: [company?: CompanyDto]; cancel: [] }>();
const route = useRoute();
const router = useRouter();
@@ -152,7 +152,7 @@ async function submit() {
if (isNew.value) {
const { data } = await api.post<CompanyDto>('/companies', payload());
if (props.embedded) {
emit('saved');
emit('saved', data);
return;
}
await router.replace({ name: 'company-detail', params: { id: data.id } });
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import axios from 'axios';
import {
Alert as AAlert,
@@ -13,20 +13,38 @@ import {
FormItem as AFormItem,
Input as AInput,
List as AList,
Modal as AModal,
Popconfirm as APopconfirm,
Row as ARow,
Select as ASelect,
Switch as ASwitch,
Tag as ATag,
Typography,
} from 'ant-design-vue';
import { api } from '@/lib/api';
import type { FinancialCategoryDto } from '@mento/shared';
const { Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option;
const rows = ref<FinancialCategoryDto[]>([]);
const form = ref({ name: '', kind: 'expense' as 'income' | 'expense' });
const form = ref({ name: '', kind: 'income' as 'income' | 'expense' });
const error = ref('');
const saving = ref(false);
const editOpen = ref(false);
const editSaving = ref(false);
const edit = ref({ id: '', name: '', isActive: true });
const incomeRows = computed(() => rows.value.filter((row) => row.kind === 'income'));
const expenseRows = computed(() => rows.value.filter((row) => row.kind === 'expense'));
function apiMessage(e: unknown) {
if (!axios.isAxiosError(e)) return 'ذخیره ناموفق بود.';
const msg = (e.response?.data as { message?: string | string[] })?.message;
return Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق بود.';
}
async function load() {
const { data } = await api.get<FinancialCategoryDto[]>('/treasury/categories');
@@ -35,6 +53,7 @@ async function load() {
async function add() {
error.value = '';
saving.value = true;
try {
await api.post('/treasury/categories', {
name: form.value.name,
@@ -43,23 +62,66 @@ async function add() {
form.value.name = '';
await load();
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'ثبت ناموفق بود.')
: 'ثبت ناموفق بود.';
error.value = apiMessage(e);
} finally {
saving.value = false;
}
}
function openEdit(item: FinancialCategoryDto) {
edit.value = { id: item.id, name: item.name, isActive: item.isActive };
editOpen.value = true;
}
async function remove(item: FinancialCategoryDto) {
error.value = '';
try {
await api.delete(`/treasury/categories/${item.id}`);
await load();
} catch (e) {
error.value = apiMessage(e);
}
}
function saveEdit() {
const name = edit.value.name.trim();
if (name.length < 2) {
error.value = 'نام دسته‌بندی کوتاه است.';
return Promise.reject(new Error('invalid'));
}
editSaving.value = true;
error.value = '';
return api
.patch(`/treasury/categories/${edit.value.id}`, {
name,
isActive: edit.value.isActive,
})
.then(() => load())
.catch((e: unknown) => {
error.value = apiMessage(e);
return Promise.reject(e);
})
.finally(() => {
editSaving.value = false;
});
}
onMounted(() => {
void load();
void load().catch(() => {
error.value = 'بارگذاری ناموفق بود.';
});
});
</script>
<template>
<a-flex vertical :gap="16">
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-typography-text type="secondary">
دسته‌ها فقط برای همین محیط هستند و در فرم درآمد و هزینه نمایش داده می‌شوند.
</a-typography-text>
<a-card :bordered="false">
<a-divider orientation="right">افزودن دسته</a-divider>
<a-divider orientation="right">دسته جدید</a-divider>
<a-form layout="vertical" :model="form" @finish="add">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
@@ -81,21 +143,54 @@ onMounted(() => {
</a-col>
<a-col :xs="24" :md="4">
<a-form-item label=" ">
<a-button type="primary" html-type="submit" block>افزودن</a-button>
<a-button type="primary" html-type="submit" block :loading="saving">افزودن</a-button>
</a-form-item>
</a-col>
</a-row>
</a-form>
<a-divider orientation="right">فهرست</a-divider>
<a-empty v-if="!rows.length" description="دسته‌ای ثبت نشده است." />
<a-list v-else item-layout="horizontal" :data-source="rows">
<a-divider orientation="right">درآمد</a-divider>
<a-empty v-if="!incomeRows.length" description="دسته درآمدی نیست." />
<a-list v-else item-layout="horizontal" :data-source="incomeRows">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a-tag :color="item.kind === 'income' ? 'success' : 'default'">
{{ item.kind === 'income' ? 'درآمد' : 'هزینه' }}
</a-tag>
<a-tag v-if="!item.isActive">غیرفعال</a-tag>
<a-button type="link" @click="openEdit(item)">ویرایش</a-button>
<a-popconfirm
title="این دسته حذف شود؟ سندهای قبلی بدون دسته می‌مانند."
ok-text="حذف"
cancel-text="انصراف"
:ok-button-props="{ danger: true }"
@confirm="remove(item)"
>
<a-button type="link" danger>حذف</a-button>
</a-popconfirm>
</template>
<a-list-item-meta>
<template #title>{{ item.name }}</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
<a-divider orientation="right">هزینه</a-divider>
<a-empty v-if="!expenseRows.length" description="دسته هزینه‌ای نیست." />
<a-list v-else item-layout="horizontal" :data-source="expenseRows">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a-tag v-if="!item.isActive">غیرفعال</a-tag>
<a-button type="link" @click="openEdit(item)">ویرایش</a-button>
<a-popconfirm
title="این دسته حذف شود؟ سندهای قبلی بدون دسته می‌مانند."
ok-text="حذف"
cancel-text="انصراف"
:ok-button-props="{ danger: true }"
@confirm="remove(item)"
>
<a-button type="link" danger>حذف</a-button>
</a-popconfirm>
</template>
<a-list-item-meta>
<template #title>{{ item.name }}</template>
@@ -104,5 +199,23 @@ onMounted(() => {
</template>
</a-list>
</a-card>
<a-modal
v-model:open="editOpen"
title="ویرایش دسته"
ok-text="ذخیره"
cancel-text="انصراف"
:confirm-loading="editSaving"
@ok="saveEdit"
>
<a-form layout="vertical">
<a-form-item label="نام" required>
<a-input v-model:value="edit.name" />
</a-form-item>
<a-form-item label="فعال در فرم‌ها">
<a-switch v-model:checked="edit.isActive" />
</a-form-item>
</a-form>
</a-modal>
</a-flex>
</template>
@@ -56,7 +56,6 @@ const form = ref({
accountId: '',
settleAmount: '',
method: 'transfer',
reference: '',
notes: '',
});
const settleMirror = useSettleDefault(form);
@@ -85,10 +84,11 @@ function apiMessage(e: unknown) {
function docLine(item: FinancialDocumentDto) {
const parts = [
item.reference ? `پیگیری ${item.reference}` : '',
item.partyName || 'بدون طرف حساب',
statusLabel[item.status] ?? item.status,
`مانده ${toman(item.remaining)}`,
];
].filter(Boolean);
if (item.categoryName) parts.push(item.categoryName);
return parts.join(' · ');
}
@@ -132,7 +132,7 @@ async function createDoc() {
error.value = 'برای سایر، مبلغ پرداخت‌شده باید با مبلغ کل یکی باشد.';
return;
}
await api.post('/treasury/documents', {
const { data } = await api.post<FinancialDocumentDto>('/treasury/documents', {
kind,
title: form.value.title,
amount,
@@ -144,7 +144,6 @@ async function createDoc() {
accountId: form.value.accountId || null,
settleAmount,
method: form.value.method,
reference: form.value.reference || null,
notes: form.value.notes || null,
});
settleMirror.reset();
@@ -157,10 +156,11 @@ async function createDoc() {
accountId: '',
settleAmount: '',
method: 'transfer',
reference: '',
notes: '',
};
ok.value = 'سند ثبت شد. اگر مبلغ دریافت/پرداخت صفر باشد موجودی حساب تغییر نمی‌کند.';
ok.value = data.reference
? `سند ثبت شد. شماره پیگیری: ${data.reference}`
: 'سند ثبت شد.';
await load();
} catch (e) {
error.value = apiMessage(e);
@@ -265,11 +265,6 @@ onMounted(() => {
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="پیگیری" name="reference">
<a-input v-model:value="form.reference" />
</a-form-item>
</a-col>
</a-row>
<a-button type="primary" html-type="submit" :loading="saving">ثبت</a-button>
</a-form>
@@ -285,7 +280,7 @@ onMounted(() => {
<a-input
v-model:value="q"
allow-clear
placeholder="جستجو…"
placeholder="عنوان یا شماره پیگیری"
@press-enter="load"
@change="load"
/>
@@ -15,7 +15,6 @@ import {
Grid,
Input as AInput,
List as AList,
Modal as AModal,
Row as ARow,
Segmented as ASegmented,
Select as ASelect,
@@ -34,8 +33,6 @@ import {
CreditCardOutlined,
FileTextOutlined,
InfoCircleOutlined,
NumberOutlined,
PlusOutlined,
TagOutlined,
} from '@ant-design/icons-vue';
import { api } from '@/lib/api';
@@ -75,9 +72,6 @@ const ok = ref('');
const saving = ref(false);
const q = ref('');
const formTab = ref<'main' | 'extra'>('main');
const categoryModalOpen = ref(false);
const newCategoryName = ref('');
const categorySaving = ref(false);
const mobileFormRef = ref<FormInstance>();
function emptyForm() {
@@ -90,7 +84,6 @@ function emptyForm() {
accountId: undefined as string | undefined,
settleAmount: '',
method: 'transfer',
reference: '',
notes: '',
bookedAt: formatLocalDateInput(new Date()),
};
@@ -146,10 +139,11 @@ function apiMessage(e: unknown) {
function docLine(item: FinancialDocumentDto) {
const parts = [
item.reference ? `پیگیری ${item.reference}` : '',
item.partyName || 'بدون طرف حساب',
statusLabel[item.status] ?? item.status,
`مانده ${toman(item.remaining)}`,
];
].filter(Boolean);
if (item.categoryName) parts.push(item.categoryName);
return parts.join(' · ');
}
@@ -199,7 +193,7 @@ async function createDoc() {
error.value = 'برای سایر، مبلغ دریافت‌شده باید با مبلغ کل یکی باشد.';
return;
}
await api.post('/treasury/documents', {
const { data } = await api.post<FinancialDocumentDto>('/treasury/documents', {
kind,
title: form.value.title,
amount,
@@ -211,12 +205,13 @@ async function createDoc() {
accountId: form.value.accountId || null,
settleAmount,
method: form.value.method,
reference: form.value.reference || null,
notes: form.value.notes || null,
bookedAt: form.value.bookedAt || undefined,
});
resetForm();
ok.value = 'سند ثبت شد. اگر مبلغ دریافت/پرداخت صفر باشد موجودی حساب تغییر نمی‌کند.';
ok.value = data.reference
? `سند ثبت شد. شماره پیگیری: ${data.reference}`
: 'سند ثبت شد.';
await load();
} catch (e) {
error.value = apiMessage(e);
@@ -245,30 +240,6 @@ async function voidDoc(id: string) {
}
}
async function createCategoryQuick() {
const name = newCategoryName.value.trim();
if (name.length < 2) {
error.value = 'نام دسته‌بندی کوتاه است.';
return;
}
categorySaving.value = true;
error.value = '';
try {
const { data } = await api.post<FinancialCategoryDto>('/treasury/categories', {
name,
kind,
});
await load();
form.value.categoryId = data.id;
newCategoryName.value = '';
categoryModalOpen.value = false;
} catch (e) {
error.value = apiMessage(e);
} finally {
categorySaving.value = false;
}
}
onMounted(() => {
void load().catch(() => {
error.value = 'بارگذاری ناموفق بود.';
@@ -425,21 +396,16 @@ onMounted(() => {
<span>دسته‌بندی درآمد</span>
</a-space>
</template>
<a-flex :gap="8">
<a-select show-search option-filter-prop="label"
v-model:value="form.categoryId"
allow-clear
placeholder="انتخاب دسته"
style="flex: 1; min-width: 0"
style="width: 100%"
>
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id" :label="cat.name">
{{ cat.name }}
</a-select-option>
</a-select>
<a-button type="primary" ghost @click="categoryModalOpen = true">
<template #icon><PlusOutlined /></template>
</a-button>
</a-flex>
</a-form-item>
<a-row :gutter="[12, 0]">
@@ -530,12 +496,6 @@ onMounted(() => {
</div>
<div v-show="formTab === 'extra'">
<a-form-item label="شماره پیگیری" name="reference">
<a-input v-model:value="form.reference" placeholder="شماره پیگیری بانکی یا مرجع">
<template #prefix><NumberOutlined /></template>
</a-input>
</a-form-item>
<a-form-item label="توضیحات" name="notes">
<a-textarea
v-model:value="form.notes"
@@ -556,7 +516,7 @@ onMounted(() => {
<a-input
v-model:value="q"
allow-clear
placeholder="جستجو…"
placeholder="عنوان یا شماره پیگیری"
@press-enter="load"
@change="load"
/>
@@ -709,11 +669,6 @@ onMounted(() => {
<JalaliDateInput v-model="form.bookedAt" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="پیگیری" name="reference">
<a-input v-model:value="form.reference" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="16">
<a-form-item label="توضیحات" name="notes">
<a-input v-model:value="form.notes" />
@@ -733,7 +688,7 @@ onMounted(() => {
<a-input
v-model:value="q"
allow-clear
placeholder="جستجو…"
placeholder="عنوان یا شماره پیگیری"
@press-enter="load"
@change="load"
/>
@@ -768,23 +723,5 @@ onMounted(() => {
</a-flex>
</template>
<a-modal
v-model:open="categoryModalOpen"
title="دسته جدید"
ok-text="افزودن"
cancel-text="انصراف"
:confirm-loading="categorySaving"
@ok="createCategoryQuick"
>
<a-form layout="vertical">
<a-form-item label="نام دسته" required>
<a-input
v-model:value="newCategoryName"
placeholder="مثلاً: فروش خدمات"
@press-enter="createCategoryQuick"
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
+1 -1
View File
@@ -843,7 +843,7 @@ export const ROLE_PERMISSION_ITEMS: Array<{
{ key: "treasury_recurring", label: "مالی — تکرارشونده" },
{ key: "treasury_reports", label: "مالی — گزارش" },
{ key: "treasury_audit", label: "مالی — سوابق" },
{ key: "treasury_categories", label: "مالی — دسته‌بندی‌ها" },
{ key: "treasury_categories", label: "مالی — دسته‌بندی" },
{ key: "behavior", label: "شخصیت‌شناسی و دستیار فروش" },
];