From 08a278e1c8ec3e2712931080361f6ccf759aef31 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Wed, 4 Nov 2026 13:11:09 +0330 Subject: [PATCH] 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 --- apps/api/src/treasury/treasury.controller.ts | 16 +- apps/api/src/treasury/treasury.service.ts | 111 +++++++-- .../src/components/treasury/TreasuryNav.vue | 2 +- .../treasury/TreasuryPartySelect.vue | 145 +++++------- apps/web/src/router/index.ts | 4 +- apps/web/src/views/CompanyFormView.vue | 4 +- .../views/treasury/TreasuryCategoriesView.vue | 215 +++++++++++++----- .../views/treasury/TreasuryExpensesView.vue | 19 +- .../views/treasury/TreasuryIncomesView.vue | 99 ++------ packages/shared/src/index.ts | 2 +- 10 files changed, 361 insertions(+), 256 deletions(-) diff --git a/apps/api/src/treasury/treasury.controller.ts b/apps/api/src/treasury/treasury.controller.ts index 4d96f27..c627132 100644 --- a/apps/api/src/treasury/treasury.controller.ts +++ b/apps/api/src/treasury/treasury.controller.ts @@ -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, diff --git a/apps/api/src/treasury/treasury.service.ts b/apps/api/src/treasury/treasury.service.ts index 5fde7ab..b83c03e 100644 --- a/apps/api/src/treasury/treasury.service.ts +++ b/apps/api/src/treasury/treasury.service.ts @@ -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,19 +120,68 @@ export class TreasuryService { ): Promise { const name = input.name.trim(); if (name.length < 2) throw new BadRequestException('نام دسته‌بندی کوتاه است.'); - const row = await this.prisma.financialCategory.create({ - data: { - organizationId: actor.organizationId, - name, - kind: input.kind, - }, + try { + const row = await this.prisma.financialCategory.create({ + data: { + organizationId: actor.organizationId, + 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 { + const current = await this.prisma.financialCategory.findFirst({ + where: { id, organizationId: actor.organizationId }, }); - return { - id: row.id, - name: row.name, - kind: row.kind as FinancialCategoryDto['kind'], - isActive: row.isActive, - }; + 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 { const term = q?.trim() ?? ''; const contains = term ? { contains: term, mode: 'insensitive' as const } : undefined; diff --git a/apps/web/src/components/treasury/TreasuryNav.vue b/apps/web/src/components/treasury/TreasuryNav.vue index cf89e5a..2fb2bb7 100644 --- a/apps/web/src/components/treasury/TreasuryNav.vue +++ b/apps/web/src/components/treasury/TreasuryNav.vue @@ -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)); }); diff --git a/apps/web/src/components/treasury/TreasuryPartySelect.vue b/apps/web/src/components/treasury/TreasuryPartySelect.vue index 3c39dcb..23fabef 100644 --- a/apps/web/src/components/treasury/TreasuryPartySelect.vue +++ b/apps/web/src/components/treasury/TreasuryPartySelect.vue @@ -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([]); const users = ref([]); const labels = ref>({}); 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; - 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; - }); +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 = company.id; } onMounted(() => { @@ -173,7 +149,9 @@ onMounted(() => { diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index 1530816..5a271fa 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -369,8 +369,8 @@ export const router = createRouter({ component: () => import('@/views/treasury/TreasuryCategoriesView.vue'), meta: { roles: ['owner', 'admin'] as UserRole[], - treasuryTitle: 'دسته‌بندی‌های مالی', - treasuryDescription: 'دسته‌های پیش‌فرض با اولین بازدید ساخته می‌شوند.', + treasuryTitle: 'دسته‌بندی', + treasuryDescription: 'دسته‌های درآمد و هزینهٔ همین محیط؛ ساخته و ویرایش فقط از اینجا.', }, }, ], diff --git a/apps/web/src/views/CompanyFormView.vue b/apps/web/src/views/CompanyFormView.vue index e5833c2..adfd432 100644 --- a/apps/web/src/views/CompanyFormView.vue +++ b/apps/web/src/views/CompanyFormView.vue @@ -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('/companies', payload()); if (props.embedded) { - emit('saved'); + emit('saved', data); return; } await router.replace({ name: 'company-detail', params: { id: data.id } }); diff --git a/apps/web/src/views/treasury/TreasuryCategoriesView.vue b/apps/web/src/views/treasury/TreasuryCategoriesView.vue index 20dadc1..26864fd 100644 --- a/apps/web/src/views/treasury/TreasuryCategoriesView.vue +++ b/apps/web/src/views/treasury/TreasuryCategoriesView.vue @@ -1,5 +1,5 @@ diff --git a/apps/web/src/views/treasury/TreasuryExpensesView.vue b/apps/web/src/views/treasury/TreasuryExpensesView.vue index 45d6fe1..2560b11 100644 --- a/apps/web/src/views/treasury/TreasuryExpensesView.vue +++ b/apps/web/src/views/treasury/TreasuryExpensesView.vue @@ -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('/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(() => { - - - - - ثبت @@ -285,7 +280,7 @@ onMounted(() => { diff --git a/apps/web/src/views/treasury/TreasuryIncomesView.vue b/apps/web/src/views/treasury/TreasuryIncomesView.vue index 5e502f2..c961c76 100644 --- a/apps/web/src/views/treasury/TreasuryIncomesView.vue +++ b/apps/web/src/views/treasury/TreasuryIncomesView.vue @@ -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(); 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('/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('/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(() => { دسته‌بندی درآمد - - - - {{ cat.name }} - - - - - - + + + {{ cat.name }} + + @@ -530,12 +496,6 @@ onMounted(() => {
- - - - - - { @@ -709,11 +669,6 @@ onMounted(() => { - - - - - @@ -733,7 +688,7 @@ onMounted(() => { @@ -768,23 +723,5 @@ onMounted(() => { - - - - - - -
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 4e886d6..3819db9 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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: "شخصیت‌شناسی و دستیار فروش" }, ];