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