diff --git a/apps/api/prisma/migrations/20261104140000_financial_document_party/migration.sql b/apps/api/prisma/migrations/20261104140000_financial_document_party/migration.sql new file mode 100644 index 0000000..6c9da38 --- /dev/null +++ b/apps/api/prisma/migrations/20261104140000_financial_document_party/migration.sql @@ -0,0 +1,9 @@ +ALTER TABLE "FinancialDocument" ADD COLUMN "companyId" TEXT; +ALTER TABLE "FinancialDocument" ADD COLUMN "userId" TEXT; +ALTER TABLE "FinancialDocument" ADD COLUMN "partyKind" TEXT; + +CREATE INDEX "FinancialDocument_companyId_idx" ON "FinancialDocument"("companyId"); +CREATE INDEX "FinancialDocument_userId_idx" ON "FinancialDocument"("userId"); + +ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "FinancialDocument" ADD CONSTRAINT "FinancialDocument_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 3d135b0..76bb1ef 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -295,6 +295,7 @@ model User { ownedInvoices Invoice[] @relation("InvoiceOwner") ownedPeople Person[] @relation("PersonOwner") ownedCompanies Company[] @relation("CompanyOwner") + financialPartyDocuments FinancialDocument[] @relation("FinancialDocumentParty") ownedProjectCards ProjectCard[] @relation("ProjectCardOwner") activities Activity[] @relation("ActivityAssignee") assignedActivities Activity[] @relation("ActivityAssignees") @@ -524,6 +525,7 @@ model Company { customFields Json @default("{}") groupLinks CompanyGroupLink[] people Person[] + financialDocuments FinancialDocument[] deals Deal[] activities Activity[] smartNotes Note[] @@ -2330,6 +2332,12 @@ model FinancialDocument { category FinancialCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull) personId String? person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) + companyId String? + company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull) + userId String? + partyUser User? @relation("FinancialDocumentParty", fields: [userId], references: [id], onDelete: SetNull) + /// person | company | user | other + partyKind String? partyName String? accountId String? account FinancialAccount? @relation("DocumentAccount", fields: [accountId], references: [id], onDelete: SetNull) @@ -2350,6 +2358,8 @@ model FinancialDocument { @@index([organizationId, kind, bookedAt]) @@index([organizationId, status]) @@index([personId]) + @@index([companyId]) + @@index([userId]) } /// گردش قطعی حساب. افتتاحیه، دریافت، پرداخت، انتقال. diff --git a/apps/api/src/treasury/treasury.controller.ts b/apps/api/src/treasury/treasury.controller.ts index 5cfe7b3..4d96f27 100644 --- a/apps/api/src/treasury/treasury.controller.ts +++ b/apps/api/src/treasury/treasury.controller.ts @@ -92,6 +92,29 @@ export class TreasuryController { return this.treasury.transfer(user, body); } + @Get('parties') + parties(@CurrentUser() user: Actor, @Query('q') q?: string) { + return this.treasury.searchParties(user, q); + } + + @Post('parties/person') + createPartyPerson( + @CurrentUser() user: Actor, + @Body() body: { firstName?: string; lastName?: string }, + ) { + return this.treasury.createPartyPerson(user, body); + } + + @Post('parties/company') + createPartyCompany(@CurrentUser() user: Actor, @Body() body: { name?: string }) { + return this.treasury.createPartyCompany(user, body); + } + + @Get('party-balances') + partyBalances(@CurrentUser() user: Actor, @Query('kind') kind?: string) { + return this.treasury.partyBalances(user, kind === 'expense' ? 'expense' : 'income'); + } + @Get('documents') documents( @CurrentUser() user: Actor, diff --git a/apps/api/src/treasury/treasury.module.ts b/apps/api/src/treasury/treasury.module.ts index c48cca6..f9a5fa2 100644 --- a/apps/api/src/treasury/treasury.module.ts +++ b/apps/api/src/treasury/treasury.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; +import { ContactsModule } from '../contacts/contacts.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { TreasuryController } from './treasury.controller'; import { TreasuryAccessGuard } from './treasury-access.guard'; @@ -9,7 +10,7 @@ import { TreasuryReceivablesService } from './treasury-receivables.service'; import { TreasuryService } from './treasury.service'; @Module({ - imports: [AuthModule, NotificationsModule], + imports: [AuthModule, NotificationsModule, ContactsModule], controllers: [TreasuryController], providers: [ TreasuryService, diff --git a/apps/api/src/treasury/treasury.service.ts b/apps/api/src/treasury/treasury.service.ts index c546a92..5fde7ab 100644 --- a/apps/api/src/treasury/treasury.service.ts +++ b/apps/api/src/treasury/treasury.service.ts @@ -13,8 +13,13 @@ import type { FinancialCategoryDto, FinancialDocumentDto, FinancialMovementDto, + FinancialPartyKind, TreasuryDashboardDto, + TreasuryPartyBalanceDto, + TreasuryPartySearchDto, } from '@mento/shared'; +import { CompaniesService } from '../contacts/companies.service'; +import { PeopleService } from '../contacts/people.service'; import { PrismaService } from '../prisma/prisma.service'; import { LocalFileStorage } from '../storage/local-file.storage'; import { StorageQuotaService } from '../billing/storage-quota.service'; @@ -65,6 +70,8 @@ export class TreasuryService { private readonly receivables: TreasuryReceivablesService, private readonly files: LocalFileStorage, private readonly storage: StorageQuotaService, + private readonly people: PeopleService, + private readonly companies: CompaniesService, ) {} async ensureCategories(organizationId: string) { @@ -462,7 +469,10 @@ export class TreasuryService { bookedAt?: string; dueAt?: string | null; categoryId?: string | null; + partyKind?: FinancialPartyKind | null; personId?: string | null; + companyId?: string | null; + userId?: string | null; partyName?: string | null; method?: string | null; reference?: string | null; @@ -476,9 +486,13 @@ export class TreasuryService { const amount = dec(input.amount); if (title.length < 2) throw new BadRequestException('عنوان را وارد کنید.'); if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ باید بزرگ‌تر از صفر باشد.'); + const party = await this.resolveParty(actor, input); const settle = input.settleAmount == null || input.settleAmount === '' ? new Prisma.Decimal(0) : dec(input.settleAmount); + if (party.partyKind === 'other' && !settle.equals(amount)) { + throw new BadRequestException('برای سایر، مبلغ دریافت یا پرداخت باید با مبلغ کل یکی باشد.'); + } if (settle.isNegative() || settle.greaterThan(amount)) { throw new BadRequestException('مبلغ دریافت/پرداخت از مبلغ سند بیشتر است.'); } @@ -506,8 +520,11 @@ export class TreasuryService { bookedAt, dueAt: input.dueAt ? startOfDay(input.dueAt) : null, categoryId: input.categoryId || null, - personId: input.personId || null, - partyName: input.partyName?.trim() || null, + personId: party.personId, + companyId: party.companyId, + userId: party.userId, + partyKind: party.partyKind, + partyName: party.partyName, accountId: input.accountId || null, method: input.method || null, status: settle.equals(0) ? 'open' : settle.equals(amount) ? 'settled' : 'partial', @@ -540,6 +557,195 @@ export class TreasuryService { return this.toDocumentDto(doc); } + async searchParties(actor: Actor, q?: string): Promise { + const term = q?.trim() ?? ''; + const contains = term ? { contains: term, mode: 'insensitive' as const } : undefined; + const [people, companies, users] = await Promise.all([ + this.prisma.person.findMany({ + where: { + organizationId: actor.organizationId, + ...(contains ? { OR: [{ firstName: contains }, { lastName: contains }] } : {}), + }, + select: { id: true, firstName: true, lastName: true }, + orderBy: { updatedAt: 'desc' }, + take: 20, + }), + this.prisma.company.findMany({ + where: { + organizationId: actor.organizationId, + ...(contains ? { name: contains } : {}), + }, + select: { id: true, name: true }, + orderBy: { updatedAt: 'desc' }, + take: 20, + }), + this.prisma.user.findMany({ + where: { + organizationId: actor.organizationId, + isActive: true, + ...(contains ? { fullName: contains } : {}), + }, + select: { id: true, fullName: true }, + orderBy: { fullName: 'asc' }, + take: 20, + }), + ]); + return { + people: people.map((row) => ({ + id: row.id, + name: `${row.firstName} ${row.lastName}`.trim() || 'مشتری', + })), + companies, + users: users.map((row) => ({ id: row.id, name: row.fullName })), + }; + } + + async createPartyPerson(actor: Actor, input: { firstName?: string; lastName?: string }) { + const firstName = input.firstName?.trim() ?? ''; + const lastName = input.lastName?.trim() ?? ''; + if (`${firstName} ${lastName}`.trim().length < 2) { + throw new BadRequestException('نام شخص را وارد کنید.'); + } + const person = await this.people.create(actor, { firstName, lastName }); + return { id: person.id, name: person.fullName, kind: 'person' as const }; + } + + async createPartyCompany(actor: Actor, input: { name?: string }) { + const name = input.name?.trim() ?? ''; + if (name.length < 2) throw new BadRequestException('نام شرکت را وارد کنید.'); + const company = await this.companies.create(actor, { name }); + return { id: company.id, name: company.name, kind: 'company' as const }; + } + + async partyBalances(actor: Actor, kind: 'income' | 'expense'): Promise { + const scope = await this.accountScope(actor); + const rows = await this.prisma.financialDocument.findMany({ + where: { + organizationId: actor.organizationId, + kind, + voidedAt: null, + status: { in: ['open', 'partial'] }, + ...(scope ? { OR: [{ accountId: null }, { accountId: { in: scope } }] } : {}), + }, + select: { + amount: true, + partyKind: true, + personId: true, + companyId: true, + userId: true, + partyName: true, + movements: { select: { amount: true, voidedAt: true } }, + }, + }); + const buckets = new Map(); + for (const row of rows) { + if (row.partyKind === 'other') continue; + const settled = this.settledOf(row.movements); + const rawRemaining = row.amount.minus(settled); + if (!rawRemaining.greaterThan(0)) continue; + const linkedId = row.personId || row.companyId || row.userId; + const partyKind = row.partyKind === 'person' || row.partyKind === 'company' || row.partyKind === 'user' + ? row.partyKind + : null; + const key = partyKind && linkedId + ? `${partyKind}:${linkedId}` + : `legacy:${(row.partyName || '').trim() || 'بدون طرف حساب'}`; + const current = buckets.get(key) ?? { + key, + partyKind, + partyName: row.partyName?.trim() || 'بدون طرف حساب', + amount: new Prisma.Decimal(0), + settled: new Prisma.Decimal(0), + remaining: new Prisma.Decimal(0), + }; + current.amount = current.amount.plus(row.amount); + current.settled = current.settled.plus(settled); + current.remaining = current.remaining.plus(rawRemaining); + buckets.set(key, current); + } + return [...buckets.values()] + .sort((a, b) => b.remaining.comparedTo(a.remaining)) + .map((row) => ({ + key: row.key, + partyKind: row.partyKind, + partyName: row.partyName, + amount: money(row.amount), + settledAmount: money(row.settled), + remaining: money(row.remaining), + })); + } + + private async resolveParty( + actor: Actor, + input: { + partyKind?: FinancialPartyKind | null; + personId?: string | null; + companyId?: string | null; + userId?: string | null; + }, + ) { + const kind = input.partyKind; + if (kind === 'other') { + return { partyKind: 'other' as const, personId: null, companyId: null, userId: null, partyName: 'سایر' }; + } + if (kind === 'person') { + const id = input.personId?.trim(); + if (!id) throw new BadRequestException('مشتری را انتخاب کنید.'); + const person = await this.prisma.person.findFirst({ + where: { id, organizationId: actor.organizationId }, + select: { id: true, firstName: true, lastName: true }, + }); + if (!person) throw new BadRequestException('مشتری پیدا نشد.'); + return { + partyKind: 'person' as const, + personId: person.id, + companyId: null, + userId: null, + partyName: `${person.firstName} ${person.lastName}`.trim() || 'مشتری', + }; + } + if (kind === 'company') { + const id = input.companyId?.trim(); + if (!id) throw new BadRequestException('شرکت را انتخاب کنید.'); + const company = await this.prisma.company.findFirst({ + where: { id, organizationId: actor.organizationId }, + select: { id: true, name: true }, + }); + if (!company) throw new BadRequestException('شرکت پیدا نشد.'); + return { + partyKind: 'company' as const, + personId: null, + companyId: company.id, + userId: null, + partyName: company.name, + }; + } + if (kind === 'user') { + const id = input.userId?.trim(); + if (!id) throw new BadRequestException('کاربر را انتخاب کنید.'); + const user = await this.prisma.user.findFirst({ + where: { id, organizationId: actor.organizationId }, + select: { id: true, fullName: true }, + }); + if (!user) throw new BadRequestException('کاربر پیدا نشد.'); + return { + partyKind: 'user' as const, + personId: null, + companyId: null, + userId: user.id, + partyName: user.fullName, + }; + } + throw new BadRequestException('طرف حساب را انتخاب کنید.'); + } + async settleDocument( actor: Actor, id: string, @@ -948,7 +1154,10 @@ export class TreasuryService { bookedAt: Date; dueAt: Date | null; categoryId: string | null; + partyKind?: string | null; personId: string | null; + companyId?: string | null; + userId?: string | null; partyName: string | null; accountId: string | null; method: string | null; @@ -975,7 +1184,10 @@ export class TreasuryService { dueAt: row.dueAt ? row.dueAt.toISOString() : null, categoryId: row.categoryId, categoryName: row.category?.name ?? null, + partyKind: (row.partyKind as FinancialDocumentDto['partyKind']) ?? null, personId: row.personId, + companyId: row.companyId ?? null, + userId: row.userId ?? null, partyName: row.partyName, accountId: row.accountId, accountName: row.account?.name ?? null, diff --git a/apps/web/src/components/treasury/TreasuryPartyBalanceTable.vue b/apps/web/src/components/treasury/TreasuryPartyBalanceTable.vue new file mode 100644 index 0000000..4dd929b --- /dev/null +++ b/apps/web/src/components/treasury/TreasuryPartyBalanceTable.vue @@ -0,0 +1,52 @@ + + + diff --git a/apps/web/src/components/treasury/TreasuryPartySelect.vue b/apps/web/src/components/treasury/TreasuryPartySelect.vue new file mode 100644 index 0000000..3c39dcb --- /dev/null +++ b/apps/web/src/components/treasury/TreasuryPartySelect.vue @@ -0,0 +1,262 @@ + + + diff --git a/apps/web/src/components/treasury/useSettleDefault.ts b/apps/web/src/components/treasury/useSettleDefault.ts new file mode 100644 index 0000000..6f92a11 --- /dev/null +++ b/apps/web/src/components/treasury/useSettleDefault.ts @@ -0,0 +1,52 @@ +import { ref, watch, type Ref } from 'vue'; + +/** مبلغ دریافت/پرداخت را با مبلغ کل یکی می‌کند تا کاربر خودش آن را عوض کند. */ +export function useSettleDefault( + form: Ref<{ amount: string; settleAmount: string; partyKind?: string }>, +) { + const dirty = ref(false); + let mirroring = false; + + function locked() { + return form.value.partyKind === 'other'; + } + + function copy() { + mirroring = true; + form.value.settleAmount = form.value.amount; + mirroring = false; + } + + watch( + () => form.value.amount, + () => { + if (locked() || !dirty.value) copy(); + }, + { flush: 'sync' }, + ); + + watch( + () => form.value.settleAmount, + () => { + if (!mirroring && !locked()) dirty.value = true; + }, + { flush: 'sync' }, + ); + + watch( + () => form.value.partyKind, + (kind) => { + if (kind === 'other') { + dirty.value = false; + copy(); + } + }, + { flush: 'sync' }, + ); + + return { + reset() { + dirty.value = false; + }, + }; +} diff --git a/apps/web/src/views/treasury/TreasuryExpensesView.vue b/apps/web/src/views/treasury/TreasuryExpensesView.vue index 6f444b9..45d6fe1 100644 --- a/apps/web/src/views/treasury/TreasuryExpensesView.vue +++ b/apps/web/src/views/treasury/TreasuryExpensesView.vue @@ -19,8 +19,17 @@ import { message, } from 'ant-design-vue'; import { api } from '@/lib/api'; -import type { FinancialAccountDto, FinancialCategoryDto, FinancialDocumentDto } from '@mento/shared'; +import type { + FinancialAccountDto, + FinancialCategoryDto, + FinancialDocumentDto, + FinancialPartyKind, + TreasuryPartyBalanceDto, +} from '@mento/shared'; import MoneyInput from '@/components/MoneyInput.vue'; +import TreasuryPartyBalanceTable from '@/components/treasury/TreasuryPartyBalanceTable.vue'; +import TreasuryPartySelect from '@/components/treasury/TreasuryPartySelect.vue'; +import { useSettleDefault } from '@/components/treasury/useSettleDefault'; import { toman } from './treasury-format'; import { parseMoneyNumber } from '@/lib/money'; @@ -31,6 +40,7 @@ const ASelectOption = ASelect.Option; const kind = 'expense' as const; const rows = ref([]); +const balances = ref([]); const categories = ref([]); const accounts = ref([]); const error = ref(''); @@ -41,13 +51,15 @@ const form = ref({ title: '', amount: '', categoryId: '', - partyName: '', + partyKind: undefined as FinancialPartyKind | undefined, + partyId: undefined as string | undefined, accountId: '', settleAmount: '', method: 'transfer', reference: '', notes: '', }); +const settleMirror = useSettleDefault(form); const settleLabel = 'مبلغ پرداخت‌شده'; const statusLabel: Record = { @@ -71,13 +83,28 @@ function apiMessage(e: unknown) { return Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق بود.'; } +function docLine(item: FinancialDocumentDto) { + const parts = [ + item.partyName || 'بدون طرف حساب', + statusLabel[item.status] ?? item.status, + `مانده ${toman(item.remaining)}`, + ]; + if (item.categoryName) parts.push(item.categoryName); + return parts.join(' · '); +} + async function load() { - const [docs, cats, acc] = await Promise.all([ + const [docs, cats, acc, bals] = await Promise.all([ api.get('/treasury/documents', { params: { kind, q: q.value || undefined } }), api.get('/treasury/categories', { params: { kind } }), api.get('/treasury/accounts'), + api.get('/treasury/party-balances', { params: { kind } }).catch((e: unknown) => { + if (axios.isAxiosError(e) && e.response?.status === 404) return { data: [] as TreasuryPartyBalanceDto[] }; + throw e; + }), ]); rows.value = docs.data; + balances.value = bals.data; categories.value = cats.data.filter((row) => row.isActive); accounts.value = acc.data.filter((row) => row.isActive); } @@ -96,24 +123,37 @@ async function createDoc() { error.value = 'مبلغ کل معتبر نیست.'; return; } + if (!form.value.partyKind || (form.value.partyKind !== 'other' && !form.value.partyId)) { + error.value = 'طرف حساب را انتخاب کنید.'; + return; + } const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0; + if (form.value.partyKind === 'other' && settleAmount !== amount) { + error.value = 'برای سایر، مبلغ پرداخت‌شده باید با مبلغ کل یکی باشد.'; + return; + } await api.post('/treasury/documents', { kind, title: form.value.title, amount, categoryId: form.value.categoryId || null, - partyName: form.value.partyName || null, + partyKind: form.value.partyKind, + personId: form.value.partyKind === 'person' ? form.value.partyId : null, + companyId: form.value.partyKind === 'company' ? form.value.partyId : null, + userId: form.value.partyKind === 'user' ? form.value.partyId : null, accountId: form.value.accountId || null, settleAmount, method: form.value.method, reference: form.value.reference || null, notes: form.value.notes || null, }); + settleMirror.reset(); form.value = { title: '', amount: '', categoryId: '', - partyName: '', + partyKind: undefined, + partyId: undefined, accountId: '', settleAmount: '', method: 'transfer', @@ -180,7 +220,7 @@ onMounted(() => { - + @@ -198,8 +238,8 @@ onMounted(() => { - - + + @@ -234,6 +274,9 @@ onMounted(() => { ثبت + مانده پرداختنی + + فهرست اسناد @@ -268,11 +311,7 @@ onMounted(() => { - + diff --git a/apps/web/src/views/treasury/TreasuryIncomesView.vue b/apps/web/src/views/treasury/TreasuryIncomesView.vue index 01e5606..5e502f2 100644 --- a/apps/web/src/views/treasury/TreasuryIncomesView.vue +++ b/apps/web/src/views/treasury/TreasuryIncomesView.vue @@ -37,7 +37,6 @@ import { NumberOutlined, PlusOutlined, TagOutlined, - UserOutlined, } from '@ant-design/icons-vue'; import { api } from '@/lib/api'; import { formatLocalDateInput } from '@/lib/jalali'; @@ -45,10 +44,15 @@ import type { FinancialAccountDto, FinancialCategoryDto, FinancialDocumentDto, + FinancialPartyKind, TreasuryDashboardDto, + TreasuryPartyBalanceDto, } from '@mento/shared'; import JalaliDateInput from '@/components/JalaliDateInput.vue'; import MoneyInput from '@/components/MoneyInput.vue'; +import TreasuryPartyBalanceTable from '@/components/treasury/TreasuryPartyBalanceTable.vue'; +import TreasuryPartySelect from '@/components/treasury/TreasuryPartySelect.vue'; +import { useSettleDefault } from '@/components/treasury/useSettleDefault'; import { toman } from './treasury-format'; import { parseMoneyNumber } from '@/lib/money'; @@ -62,6 +66,7 @@ const isMobile = computed(() => !screens.value.md); const kind = 'income' as const; const rows = ref([]); +const balances = ref([]); const categories = ref([]); const accounts = ref([]); const dashboard = ref(null); @@ -80,7 +85,8 @@ function emptyForm() { title: '', amount: '', categoryId: undefined as string | undefined, - partyName: '', + partyKind: undefined as FinancialPartyKind | undefined, + partyId: undefined as string | undefined, accountId: undefined as string | undefined, settleAmount: '', method: 'transfer', @@ -91,6 +97,7 @@ function emptyForm() { } const form = ref(emptyForm()); +const settleMirror = useSettleDefault(form); const settleLabel = 'مبلغ دریافت‌شده'; const statusLabel: Record = { @@ -137,20 +144,36 @@ function apiMessage(e: unknown) { return Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق بود.'; } +function docLine(item: FinancialDocumentDto) { + const parts = [ + item.partyName || 'بدون طرف حساب', + statusLabel[item.status] ?? item.status, + `مانده ${toman(item.remaining)}`, + ]; + if (item.categoryName) parts.push(item.categoryName); + return parts.join(' · '); +} + async function load() { - const [docs, cats, acc, dash] = await Promise.all([ + const [docs, cats, acc, dash, bals] = await Promise.all([ api.get('/treasury/documents', { params: { kind, q: q.value || undefined } }), api.get('/treasury/categories', { params: { kind } }), api.get('/treasury/accounts'), api.get('/treasury/dashboard').catch(() => null), + api.get('/treasury/party-balances', { params: { kind } }).catch((e: unknown) => { + if (axios.isAxiosError(e) && e.response?.status === 404) return { data: [] as TreasuryPartyBalanceDto[] }; + throw e; + }), ]); rows.value = docs.data; + balances.value = bals.data; categories.value = cats.data.filter((row) => row.isActive); accounts.value = acc.data.filter((row) => row.isActive); if (dash) dashboard.value = dash.data; } function resetForm() { + settleMirror.reset(); form.value = emptyForm(); formTab.value = 'main'; error.value = ''; @@ -167,13 +190,24 @@ async function createDoc() { error.value = 'مبلغ کل معتبر نیست.'; return; } + if (!form.value.partyKind || (form.value.partyKind !== 'other' && !form.value.partyId)) { + error.value = 'طرف حساب را انتخاب کنید.'; + return; + } const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0; + if (form.value.partyKind === 'other' && settleAmount !== amount) { + error.value = 'برای سایر، مبلغ دریافت‌شده باید با مبلغ کل یکی باشد.'; + return; + } await api.post('/treasury/documents', { kind, title: form.value.title, amount, categoryId: form.value.categoryId || null, - partyName: form.value.partyName || null, + partyKind: form.value.partyKind, + personId: form.value.partyKind === 'person' ? form.value.partyId : null, + companyId: form.value.partyKind === 'company' ? form.value.partyId : null, + userId: form.value.partyKind === 'user' ? form.value.partyId : null, accountId: form.value.accountId || null, settleAmount, method: form.value.method, @@ -358,10 +392,8 @@ onMounted(() => { - - - - + + @@ -426,7 +458,12 @@ onMounted(() => { name="settleAmount" :rules="[{ required: true, message: 'مبلغ دریافت‌شده را وارد کنید' }]" > - + @@ -511,6 +548,8 @@ onMounted(() => { + مانده دریافتنی + فهرست اسناد @@ -540,11 +579,7 @@ onMounted(() => { - + @@ -614,7 +649,7 @@ onMounted(() => { - + @@ -632,8 +667,8 @@ onMounted(() => { - - + + @@ -688,6 +723,8 @@ onMounted(() => { ثبت + مانده دریافتنی + فهرست اسناد @@ -722,11 +759,7 @@ onMounted(() => { - + diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d534472..4e886d6 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -6461,6 +6461,7 @@ export interface RoutineUserSettingsDto { export type FinancialAccountType = "bank" | "cash" | "petty"; export type FinancialDocKind = "income" | "expense"; export type FinancialDocStatus = "open" | "partial" | "settled" | "void"; +export type FinancialPartyKind = "person" | "company" | "user" | "other"; export type FinancialPayMethod = "cash" | "card_to_card" | "pos" | "transfer" | "other"; export interface FinancialAccountDto { @@ -6499,7 +6500,10 @@ export interface FinancialDocumentDto { dueAt: string | null; categoryId: string | null; categoryName: string | null; + partyKind: FinancialPartyKind | null; personId: string | null; + companyId: string | null; + userId: string | null; partyName: string | null; accountId: string | null; accountName: string | null; @@ -6552,6 +6556,26 @@ export interface FinancialReceivableDto { createdAt: string; } +export interface TreasuryPartyChoiceDto { + id: string; + name: string; +} + +export interface TreasuryPartySearchDto { + people: TreasuryPartyChoiceDto[]; + companies: TreasuryPartyChoiceDto[]; + users: TreasuryPartyChoiceDto[]; +} + +export interface TreasuryPartyBalanceDto { + key: string; + partyKind: FinancialPartyKind | null; + partyName: string; + amount: string; + settledAmount: string; + remaining: string; +} + export interface TreasuryDashboardDto { bookedIncome: string; actualReceipts: string;