feat(treasury): show who still owes on the finance pages
Partial receipts and payments stay tied to a customer, company, or user, and the open balance is listed on the income and expense screens. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||||
@@ -295,6 +295,7 @@ model User {
|
|||||||
ownedInvoices Invoice[] @relation("InvoiceOwner")
|
ownedInvoices Invoice[] @relation("InvoiceOwner")
|
||||||
ownedPeople Person[] @relation("PersonOwner")
|
ownedPeople Person[] @relation("PersonOwner")
|
||||||
ownedCompanies Company[] @relation("CompanyOwner")
|
ownedCompanies Company[] @relation("CompanyOwner")
|
||||||
|
financialPartyDocuments FinancialDocument[] @relation("FinancialDocumentParty")
|
||||||
ownedProjectCards ProjectCard[] @relation("ProjectCardOwner")
|
ownedProjectCards ProjectCard[] @relation("ProjectCardOwner")
|
||||||
activities Activity[] @relation("ActivityAssignee")
|
activities Activity[] @relation("ActivityAssignee")
|
||||||
assignedActivities Activity[] @relation("ActivityAssignees")
|
assignedActivities Activity[] @relation("ActivityAssignees")
|
||||||
@@ -524,6 +525,7 @@ model Company {
|
|||||||
customFields Json @default("{}")
|
customFields Json @default("{}")
|
||||||
groupLinks CompanyGroupLink[]
|
groupLinks CompanyGroupLink[]
|
||||||
people Person[]
|
people Person[]
|
||||||
|
financialDocuments FinancialDocument[]
|
||||||
deals Deal[]
|
deals Deal[]
|
||||||
activities Activity[]
|
activities Activity[]
|
||||||
smartNotes Note[]
|
smartNotes Note[]
|
||||||
@@ -2330,6 +2332,12 @@ model FinancialDocument {
|
|||||||
category FinancialCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
category FinancialCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||||
personId String?
|
personId String?
|
||||||
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
|
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?
|
partyName String?
|
||||||
accountId String?
|
accountId String?
|
||||||
account FinancialAccount? @relation("DocumentAccount", fields: [accountId], references: [id], onDelete: SetNull)
|
account FinancialAccount? @relation("DocumentAccount", fields: [accountId], references: [id], onDelete: SetNull)
|
||||||
@@ -2350,6 +2358,8 @@ model FinancialDocument {
|
|||||||
@@index([organizationId, kind, bookedAt])
|
@@index([organizationId, kind, bookedAt])
|
||||||
@@index([organizationId, status])
|
@@index([organizationId, status])
|
||||||
@@index([personId])
|
@@index([personId])
|
||||||
|
@@index([companyId])
|
||||||
|
@@index([userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// گردش قطعی حساب. افتتاحیه، دریافت، پرداخت، انتقال.
|
/// گردش قطعی حساب. افتتاحیه، دریافت، پرداخت، انتقال.
|
||||||
|
|||||||
@@ -92,6 +92,29 @@ export class TreasuryController {
|
|||||||
return this.treasury.transfer(user, body);
|
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')
|
@Get('documents')
|
||||||
documents(
|
documents(
|
||||||
@CurrentUser() user: Actor,
|
@CurrentUser() user: Actor,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { ContactsModule } from '../contacts/contacts.module';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { TreasuryController } from './treasury.controller';
|
import { TreasuryController } from './treasury.controller';
|
||||||
import { TreasuryAccessGuard } from './treasury-access.guard';
|
import { TreasuryAccessGuard } from './treasury-access.guard';
|
||||||
@@ -9,7 +10,7 @@ import { TreasuryReceivablesService } from './treasury-receivables.service';
|
|||||||
import { TreasuryService } from './treasury.service';
|
import { TreasuryService } from './treasury.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, NotificationsModule],
|
imports: [AuthModule, NotificationsModule, ContactsModule],
|
||||||
controllers: [TreasuryController],
|
controllers: [TreasuryController],
|
||||||
providers: [
|
providers: [
|
||||||
TreasuryService,
|
TreasuryService,
|
||||||
|
|||||||
@@ -13,8 +13,13 @@ import type {
|
|||||||
FinancialCategoryDto,
|
FinancialCategoryDto,
|
||||||
FinancialDocumentDto,
|
FinancialDocumentDto,
|
||||||
FinancialMovementDto,
|
FinancialMovementDto,
|
||||||
|
FinancialPartyKind,
|
||||||
TreasuryDashboardDto,
|
TreasuryDashboardDto,
|
||||||
|
TreasuryPartyBalanceDto,
|
||||||
|
TreasuryPartySearchDto,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
|
import { CompaniesService } from '../contacts/companies.service';
|
||||||
|
import { PeopleService } from '../contacts/people.service';
|
||||||
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';
|
||||||
@@ -65,6 +70,8 @@ export class TreasuryService {
|
|||||||
private readonly receivables: TreasuryReceivablesService,
|
private readonly receivables: TreasuryReceivablesService,
|
||||||
private readonly files: LocalFileStorage,
|
private readonly files: LocalFileStorage,
|
||||||
private readonly storage: StorageQuotaService,
|
private readonly storage: StorageQuotaService,
|
||||||
|
private readonly people: PeopleService,
|
||||||
|
private readonly companies: CompaniesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async ensureCategories(organizationId: string) {
|
async ensureCategories(organizationId: string) {
|
||||||
@@ -462,7 +469,10 @@ export class TreasuryService {
|
|||||||
bookedAt?: string;
|
bookedAt?: string;
|
||||||
dueAt?: string | null;
|
dueAt?: string | null;
|
||||||
categoryId?: string | null;
|
categoryId?: string | null;
|
||||||
|
partyKind?: FinancialPartyKind | null;
|
||||||
personId?: string | null;
|
personId?: string | null;
|
||||||
|
companyId?: string | null;
|
||||||
|
userId?: string | null;
|
||||||
partyName?: string | null;
|
partyName?: string | null;
|
||||||
method?: string | null;
|
method?: string | null;
|
||||||
reference?: string | null;
|
reference?: string | null;
|
||||||
@@ -476,9 +486,13 @@ export class TreasuryService {
|
|||||||
const amount = dec(input.amount);
|
const amount = dec(input.amount);
|
||||||
if (title.length < 2) throw new BadRequestException('عنوان را وارد کنید.');
|
if (title.length < 2) throw new BadRequestException('عنوان را وارد کنید.');
|
||||||
if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ باید بزرگتر از صفر باشد.');
|
if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ باید بزرگتر از صفر باشد.');
|
||||||
|
const party = await this.resolveParty(actor, input);
|
||||||
const settle = input.settleAmount == null || input.settleAmount === ''
|
const settle = input.settleAmount == null || input.settleAmount === ''
|
||||||
? new Prisma.Decimal(0)
|
? new Prisma.Decimal(0)
|
||||||
: dec(input.settleAmount);
|
: dec(input.settleAmount);
|
||||||
|
if (party.partyKind === 'other' && !settle.equals(amount)) {
|
||||||
|
throw new BadRequestException('برای سایر، مبلغ دریافت یا پرداخت باید با مبلغ کل یکی باشد.');
|
||||||
|
}
|
||||||
if (settle.isNegative() || settle.greaterThan(amount)) {
|
if (settle.isNegative() || settle.greaterThan(amount)) {
|
||||||
throw new BadRequestException('مبلغ دریافت/پرداخت از مبلغ سند بیشتر است.');
|
throw new BadRequestException('مبلغ دریافت/پرداخت از مبلغ سند بیشتر است.');
|
||||||
}
|
}
|
||||||
@@ -506,8 +520,11 @@ export class TreasuryService {
|
|||||||
bookedAt,
|
bookedAt,
|
||||||
dueAt: input.dueAt ? startOfDay(input.dueAt) : null,
|
dueAt: input.dueAt ? startOfDay(input.dueAt) : null,
|
||||||
categoryId: input.categoryId || null,
|
categoryId: input.categoryId || null,
|
||||||
personId: input.personId || null,
|
personId: party.personId,
|
||||||
partyName: input.partyName?.trim() || null,
|
companyId: party.companyId,
|
||||||
|
userId: party.userId,
|
||||||
|
partyKind: party.partyKind,
|
||||||
|
partyName: party.partyName,
|
||||||
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',
|
||||||
@@ -540,6 +557,195 @@ export class TreasuryService {
|
|||||||
return this.toDocumentDto(doc);
|
return this.toDocumentDto(doc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async searchParties(actor: Actor, q?: string): Promise<TreasuryPartySearchDto> {
|
||||||
|
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<TreasuryPartyBalanceDto[]> {
|
||||||
|
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<string, {
|
||||||
|
key: string;
|
||||||
|
partyKind: FinancialPartyKind | null;
|
||||||
|
partyName: string;
|
||||||
|
amount: Prisma.Decimal;
|
||||||
|
settled: Prisma.Decimal;
|
||||||
|
remaining: Prisma.Decimal;
|
||||||
|
}>();
|
||||||
|
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(
|
async settleDocument(
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
id: string,
|
id: string,
|
||||||
@@ -948,7 +1154,10 @@ export class TreasuryService {
|
|||||||
bookedAt: Date;
|
bookedAt: Date;
|
||||||
dueAt: Date | null;
|
dueAt: Date | null;
|
||||||
categoryId: string | null;
|
categoryId: string | null;
|
||||||
|
partyKind?: string | null;
|
||||||
personId: string | null;
|
personId: string | null;
|
||||||
|
companyId?: string | null;
|
||||||
|
userId?: string | null;
|
||||||
partyName: string | null;
|
partyName: string | null;
|
||||||
accountId: string | null;
|
accountId: string | null;
|
||||||
method: string | null;
|
method: string | null;
|
||||||
@@ -975,7 +1184,10 @@ export class TreasuryService {
|
|||||||
dueAt: row.dueAt ? row.dueAt.toISOString() : null,
|
dueAt: row.dueAt ? row.dueAt.toISOString() : null,
|
||||||
categoryId: row.categoryId,
|
categoryId: row.categoryId,
|
||||||
categoryName: row.category?.name ?? null,
|
categoryName: row.category?.name ?? null,
|
||||||
|
partyKind: (row.partyKind as FinancialDocumentDto['partyKind']) ?? null,
|
||||||
personId: row.personId,
|
personId: row.personId,
|
||||||
|
companyId: row.companyId ?? null,
|
||||||
|
userId: row.userId ?? null,
|
||||||
partyName: row.partyName,
|
partyName: row.partyName,
|
||||||
accountId: row.accountId,
|
accountId: row.accountId,
|
||||||
accountName: row.account?.name ?? null,
|
accountName: row.account?.name ?? null,
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { Table as ATable } from 'ant-design-vue';
|
||||||
|
import type { TreasuryPartyBalanceDto } from '@mento/shared';
|
||||||
|
import { toman } from '@/views/treasury/treasury-format';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
rows: TreasuryPartyBalanceDto[];
|
||||||
|
settledLabel: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const kindLabel: Record<string, string> = {
|
||||||
|
person: 'مشتری',
|
||||||
|
company: 'شرکت',
|
||||||
|
user: 'کاربر',
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = computed(() => [
|
||||||
|
{ title: 'طرف حساب', dataIndex: 'partyName', key: 'partyName' },
|
||||||
|
{
|
||||||
|
title: 'نوع',
|
||||||
|
key: 'partyKind',
|
||||||
|
customRender: ({ record }: { record: TreasuryPartyBalanceDto }) =>
|
||||||
|
(record.partyKind && kindLabel[record.partyKind]) || 'ثبت قبلی',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'مبلغ کل',
|
||||||
|
key: 'amount',
|
||||||
|
customRender: ({ record }: { record: TreasuryPartyBalanceDto }) => toman(record.amount),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: props.settledLabel,
|
||||||
|
key: 'settledAmount',
|
||||||
|
customRender: ({ record }: { record: TreasuryPartyBalanceDto }) => toman(record.settledAmount),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'مانده',
|
||||||
|
key: 'remaining',
|
||||||
|
customRender: ({ record }: { record: TreasuryPartyBalanceDto }) => toman(record.remaining),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-table
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="rows"
|
||||||
|
:pagination="false"
|
||||||
|
row-key="key"
|
||||||
|
:locale="{ emptyText: 'ماندهای نیست.' }"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, defineComponent, onMounted, ref } from 'vue';
|
||||||
|
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 { api } from '@/lib/api';
|
||||||
|
|
||||||
|
const VNodes = defineComponent({
|
||||||
|
props: { vnodes: { type: Object, required: true } },
|
||||||
|
render() {
|
||||||
|
return this.vnodes;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const ASelectOption = ASelect.Option;
|
||||||
|
const ASelectOptGroup = ASelect.OptGroup;
|
||||||
|
const { Text: ATypographyText } = Typography;
|
||||||
|
|
||||||
|
const kind = defineModel<FinancialPartyKind | undefined>('kind');
|
||||||
|
const partyId = defineModel<string | undefined>('partyId');
|
||||||
|
|
||||||
|
type Choice = { id: string; name: string };
|
||||||
|
|
||||||
|
const people = ref<Choice[]>([]);
|
||||||
|
const companies = ref<Choice[]>([]);
|
||||||
|
const users = ref<Choice[]>([]);
|
||||||
|
const labels = ref<Record<string, string>>({});
|
||||||
|
const loading = 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';
|
||||||
|
if (kind.value && partyId.value) return `${kind.value}:${partyId.value}`;
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
function withCurrent(list: Choice[], group: 'person' | 'company' | 'user') {
|
||||||
|
if (kind.value !== group || !partyId.value) return list;
|
||||||
|
if (list.some((row) => row.id === partyId.value)) return list;
|
||||||
|
const name = labels.value[`${group}:${partyId.value}`] || 'انتخابشده';
|
||||||
|
return [{ id: partyId.value, name }, ...list];
|
||||||
|
}
|
||||||
|
|
||||||
|
const peopleOptions = computed(() => withCurrent(people.value, 'person'));
|
||||||
|
const companyOptions = computed(() => withCurrent(companies.value, 'company'));
|
||||||
|
const userOptions = computed(() => withCurrent(users.value, 'user'));
|
||||||
|
|
||||||
|
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 || 'ثبت ناموفق بود.';
|
||||||
|
}
|
||||||
|
|
||||||
|
let timer = 0;
|
||||||
|
function onSearch(value: string) {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
timer = window.setTimeout(() => {
|
||||||
|
void load(value);
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(q = '') {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<TreasuryPartySearchDto>('/treasury/parties', {
|
||||||
|
params: { q: q.trim() || undefined },
|
||||||
|
});
|
||||||
|
people.value = data.people;
|
||||||
|
companies.value = data.companies;
|
||||||
|
users.value = data.users;
|
||||||
|
for (const row of data.people) labels.value[`person:${row.id}`] = row.name;
|
||||||
|
for (const row of data.companies) labels.value[`company:${row.id}`] = row.name;
|
||||||
|
for (const row of data.users) labels.value[`user:${row.id}`] = row.name;
|
||||||
|
} catch (e) {
|
||||||
|
if (!(axios.isAxiosError(e) && e.response?.status === 404)) message.error(apiMessage(e));
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChange(value: unknown) {
|
||||||
|
if (typeof value !== 'string' || !value) {
|
||||||
|
kind.value = undefined;
|
||||||
|
partyId.value = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value === 'other') {
|
||||||
|
kind.value = 'other';
|
||||||
|
partyId.value = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const splitAt = value.indexOf(':');
|
||||||
|
const nextKind = value.slice(0, splitAt) as FinancialPartyKind;
|
||||||
|
const id = value.slice(splitAt + 1);
|
||||||
|
if (nextKind !== 'person' && nextKind !== 'company' && nextKind !== 'user') return;
|
||||||
|
kind.value = nextKind;
|
||||||
|
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 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void load();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-flex vertical :gap="4">
|
||||||
|
<a-select
|
||||||
|
show-search
|
||||||
|
allow-clear
|
||||||
|
:filter-option="false"
|
||||||
|
:loading="loading"
|
||||||
|
:value="selectValue"
|
||||||
|
placeholder="مشتری، شرکت، کاربر یا سایر"
|
||||||
|
style="width: 100%"
|
||||||
|
@search="onSearch"
|
||||||
|
@change="onChange"
|
||||||
|
>
|
||||||
|
<a-select-option value="other" label="سایر">سایر</a-select-option>
|
||||||
|
<a-select-opt-group v-if="peopleOptions.length" label="مشتریان">
|
||||||
|
<a-select-option
|
||||||
|
v-for="row in peopleOptions"
|
||||||
|
:key="`person:${row.id}`"
|
||||||
|
:value="`person:${row.id}`"
|
||||||
|
:label="row.name"
|
||||||
|
>
|
||||||
|
{{ row.name }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select-opt-group>
|
||||||
|
<a-select-opt-group v-if="companyOptions.length" label="شرکتها">
|
||||||
|
<a-select-option
|
||||||
|
v-for="row in companyOptions"
|
||||||
|
:key="`company:${row.id}`"
|
||||||
|
:value="`company:${row.id}`"
|
||||||
|
:label="row.name"
|
||||||
|
>
|
||||||
|
{{ row.name }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select-opt-group>
|
||||||
|
<a-select-opt-group v-if="userOptions.length" label="کاربران">
|
||||||
|
<a-select-option
|
||||||
|
v-for="row in userOptions"
|
||||||
|
:key="`user:${row.id}`"
|
||||||
|
:value="`user:${row.id}`"
|
||||||
|
:label="row.name"
|
||||||
|
>
|
||||||
|
{{ row.name }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select-opt-group>
|
||||||
|
<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-flex>
|
||||||
|
</template>
|
||||||
|
</a-select>
|
||||||
|
<a-typography-text v-if="kind === 'other'" type="secondary">
|
||||||
|
برای سایر، مبلغ دریافت یا پرداخت با مبلغ کل یکی است و مانده نمیماند.
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -19,8 +19,17 @@ import {
|
|||||||
message,
|
message,
|
||||||
} from 'ant-design-vue';
|
} from 'ant-design-vue';
|
||||||
import { api } from '@/lib/api';
|
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 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 { toman } from './treasury-format';
|
||||||
import { parseMoneyNumber } from '@/lib/money';
|
import { parseMoneyNumber } from '@/lib/money';
|
||||||
|
|
||||||
@@ -31,6 +40,7 @@ const ASelectOption = ASelect.Option;
|
|||||||
|
|
||||||
const kind = 'expense' as const;
|
const kind = 'expense' as const;
|
||||||
const rows = ref<FinancialDocumentDto[]>([]);
|
const rows = ref<FinancialDocumentDto[]>([]);
|
||||||
|
const balances = ref<TreasuryPartyBalanceDto[]>([]);
|
||||||
const categories = ref<FinancialCategoryDto[]>([]);
|
const categories = ref<FinancialCategoryDto[]>([]);
|
||||||
const accounts = ref<FinancialAccountDto[]>([]);
|
const accounts = ref<FinancialAccountDto[]>([]);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
@@ -41,13 +51,15 @@ const form = ref({
|
|||||||
title: '',
|
title: '',
|
||||||
amount: '',
|
amount: '',
|
||||||
categoryId: '',
|
categoryId: '',
|
||||||
partyName: '',
|
partyKind: undefined as FinancialPartyKind | undefined,
|
||||||
|
partyId: undefined as string | undefined,
|
||||||
accountId: '',
|
accountId: '',
|
||||||
settleAmount: '',
|
settleAmount: '',
|
||||||
method: 'transfer',
|
method: 'transfer',
|
||||||
reference: '',
|
reference: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
});
|
});
|
||||||
|
const settleMirror = useSettleDefault(form);
|
||||||
|
|
||||||
const settleLabel = 'مبلغ پرداختشده';
|
const settleLabel = 'مبلغ پرداختشده';
|
||||||
const statusLabel: Record<string, string> = {
|
const statusLabel: Record<string, string> = {
|
||||||
@@ -71,13 +83,28 @@ function apiMessage(e: unknown) {
|
|||||||
return Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق بود.';
|
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() {
|
async function load() {
|
||||||
const [docs, cats, acc] = await Promise.all([
|
const [docs, cats, acc, bals] = await Promise.all([
|
||||||
api.get<FinancialDocumentDto[]>('/treasury/documents', { params: { kind, q: q.value || undefined } }),
|
api.get<FinancialDocumentDto[]>('/treasury/documents', { params: { kind, q: q.value || undefined } }),
|
||||||
api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }),
|
api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }),
|
||||||
api.get<FinancialAccountDto[]>('/treasury/accounts'),
|
api.get<FinancialAccountDto[]>('/treasury/accounts'),
|
||||||
|
api.get<TreasuryPartyBalanceDto[]>('/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;
|
rows.value = docs.data;
|
||||||
|
balances.value = bals.data;
|
||||||
categories.value = cats.data.filter((row) => row.isActive);
|
categories.value = cats.data.filter((row) => row.isActive);
|
||||||
accounts.value = acc.data.filter((row) => row.isActive);
|
accounts.value = acc.data.filter((row) => row.isActive);
|
||||||
}
|
}
|
||||||
@@ -96,24 +123,37 @@ async function createDoc() {
|
|||||||
error.value = 'مبلغ کل معتبر نیست.';
|
error.value = 'مبلغ کل معتبر نیست.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!form.value.partyKind || (form.value.partyKind !== 'other' && !form.value.partyId)) {
|
||||||
|
error.value = 'طرف حساب را انتخاب کنید.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
|
const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
|
||||||
|
if (form.value.partyKind === 'other' && settleAmount !== amount) {
|
||||||
|
error.value = 'برای سایر، مبلغ پرداختشده باید با مبلغ کل یکی باشد.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
await api.post('/treasury/documents', {
|
await api.post('/treasury/documents', {
|
||||||
kind,
|
kind,
|
||||||
title: form.value.title,
|
title: form.value.title,
|
||||||
amount,
|
amount,
|
||||||
categoryId: form.value.categoryId || null,
|
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,
|
accountId: form.value.accountId || null,
|
||||||
settleAmount,
|
settleAmount,
|
||||||
method: form.value.method,
|
method: form.value.method,
|
||||||
reference: form.value.reference || null,
|
reference: form.value.reference || null,
|
||||||
notes: form.value.notes || null,
|
notes: form.value.notes || null,
|
||||||
});
|
});
|
||||||
|
settleMirror.reset();
|
||||||
form.value = {
|
form.value = {
|
||||||
title: '',
|
title: '',
|
||||||
amount: '',
|
amount: '',
|
||||||
categoryId: '',
|
categoryId: '',
|
||||||
partyName: '',
|
partyKind: undefined,
|
||||||
|
partyId: undefined,
|
||||||
accountId: '',
|
accountId: '',
|
||||||
settleAmount: '',
|
settleAmount: '',
|
||||||
method: 'transfer',
|
method: 'transfer',
|
||||||
@@ -180,7 +220,7 @@ onMounted(() => {
|
|||||||
</a-col>
|
</a-col>
|
||||||
<a-col :xs="24" :md="8">
|
<a-col :xs="24" :md="8">
|
||||||
<a-form-item :label="settleLabel" name="settleAmount">
|
<a-form-item :label="settleLabel" name="settleAmount">
|
||||||
<MoneyInput v-model="form.settleAmount" :decimals="0" />
|
<MoneyInput v-model="form.settleAmount" :decimals="0" :disabled="form.partyKind === 'other'" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :xs="24" :md="8">
|
<a-col :xs="24" :md="8">
|
||||||
@@ -198,8 +238,8 @@ onMounted(() => {
|
|||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :xs="24" :md="8">
|
<a-col :xs="24" :md="8">
|
||||||
<a-form-item label="طرف حساب" name="partyName">
|
<a-form-item label="طرف حساب" name="partyKind">
|
||||||
<a-input v-model:value="form.partyName" />
|
<TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :xs="24" :md="8">
|
<a-col :xs="24" :md="8">
|
||||||
@@ -234,6 +274,9 @@ onMounted(() => {
|
|||||||
<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>
|
||||||
|
|
||||||
|
<a-divider orientation="right">مانده پرداختنی</a-divider>
|
||||||
|
<TreasuryPartyBalanceTable :rows="balances" settled-label="پرداختشده" />
|
||||||
|
|
||||||
<a-divider orientation="right">فهرست اسناد</a-divider>
|
<a-divider orientation="right">فهرست اسناد</a-divider>
|
||||||
<a-form layout="vertical">
|
<a-form layout="vertical">
|
||||||
<a-row :gutter="[16, 0]">
|
<a-row :gutter="[16, 0]">
|
||||||
@@ -268,11 +311,7 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
<a-list-item-meta>
|
<a-list-item-meta>
|
||||||
<template #title>{{ item.title }}</template>
|
<template #title>{{ item.title }}</template>
|
||||||
<template #description>
|
<template #description>{{ docLine(item) }}</template>
|
||||||
{{ statusLabel[item.status] }}
|
|
||||||
· مانده {{ toman(item.remaining) }}
|
|
||||||
<template v-if="item.categoryName"> · {{ item.categoryName }}</template>
|
|
||||||
</template>
|
|
||||||
</a-list-item-meta>
|
</a-list-item-meta>
|
||||||
</a-list-item>
|
</a-list-item>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ import {
|
|||||||
NumberOutlined,
|
NumberOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
TagOutlined,
|
TagOutlined,
|
||||||
UserOutlined,
|
|
||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { formatLocalDateInput } from '@/lib/jalali';
|
import { formatLocalDateInput } from '@/lib/jalali';
|
||||||
@@ -45,10 +44,15 @@ import type {
|
|||||||
FinancialAccountDto,
|
FinancialAccountDto,
|
||||||
FinancialCategoryDto,
|
FinancialCategoryDto,
|
||||||
FinancialDocumentDto,
|
FinancialDocumentDto,
|
||||||
|
FinancialPartyKind,
|
||||||
TreasuryDashboardDto,
|
TreasuryDashboardDto,
|
||||||
|
TreasuryPartyBalanceDto,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||||
import MoneyInput from '@/components/MoneyInput.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 { toman } from './treasury-format';
|
||||||
import { parseMoneyNumber } from '@/lib/money';
|
import { parseMoneyNumber } from '@/lib/money';
|
||||||
|
|
||||||
@@ -62,6 +66,7 @@ const isMobile = computed(() => !screens.value.md);
|
|||||||
|
|
||||||
const kind = 'income' as const;
|
const kind = 'income' as const;
|
||||||
const rows = ref<FinancialDocumentDto[]>([]);
|
const rows = ref<FinancialDocumentDto[]>([]);
|
||||||
|
const balances = ref<TreasuryPartyBalanceDto[]>([]);
|
||||||
const categories = ref<FinancialCategoryDto[]>([]);
|
const categories = ref<FinancialCategoryDto[]>([]);
|
||||||
const accounts = ref<FinancialAccountDto[]>([]);
|
const accounts = ref<FinancialAccountDto[]>([]);
|
||||||
const dashboard = ref<TreasuryDashboardDto | null>(null);
|
const dashboard = ref<TreasuryDashboardDto | null>(null);
|
||||||
@@ -80,7 +85,8 @@ function emptyForm() {
|
|||||||
title: '',
|
title: '',
|
||||||
amount: '',
|
amount: '',
|
||||||
categoryId: undefined as string | undefined,
|
categoryId: undefined as string | undefined,
|
||||||
partyName: '',
|
partyKind: undefined as FinancialPartyKind | undefined,
|
||||||
|
partyId: undefined as string | undefined,
|
||||||
accountId: undefined as string | undefined,
|
accountId: undefined as string | undefined,
|
||||||
settleAmount: '',
|
settleAmount: '',
|
||||||
method: 'transfer',
|
method: 'transfer',
|
||||||
@@ -91,6 +97,7 @@ function emptyForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const form = ref(emptyForm());
|
const form = ref(emptyForm());
|
||||||
|
const settleMirror = useSettleDefault(form);
|
||||||
|
|
||||||
const settleLabel = 'مبلغ دریافتشده';
|
const settleLabel = 'مبلغ دریافتشده';
|
||||||
const statusLabel: Record<string, string> = {
|
const statusLabel: Record<string, string> = {
|
||||||
@@ -137,20 +144,36 @@ function apiMessage(e: unknown) {
|
|||||||
return Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق بود.';
|
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() {
|
async function load() {
|
||||||
const [docs, cats, acc, dash] = await Promise.all([
|
const [docs, cats, acc, dash, bals] = await Promise.all([
|
||||||
api.get<FinancialDocumentDto[]>('/treasury/documents', { params: { kind, q: q.value || undefined } }),
|
api.get<FinancialDocumentDto[]>('/treasury/documents', { params: { kind, q: q.value || undefined } }),
|
||||||
api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }),
|
api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }),
|
||||||
api.get<FinancialAccountDto[]>('/treasury/accounts'),
|
api.get<FinancialAccountDto[]>('/treasury/accounts'),
|
||||||
api.get<TreasuryDashboardDto>('/treasury/dashboard').catch(() => null),
|
api.get<TreasuryDashboardDto>('/treasury/dashboard').catch(() => null),
|
||||||
|
api.get<TreasuryPartyBalanceDto[]>('/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;
|
rows.value = docs.data;
|
||||||
|
balances.value = bals.data;
|
||||||
categories.value = cats.data.filter((row) => row.isActive);
|
categories.value = cats.data.filter((row) => row.isActive);
|
||||||
accounts.value = acc.data.filter((row) => row.isActive);
|
accounts.value = acc.data.filter((row) => row.isActive);
|
||||||
if (dash) dashboard.value = dash.data;
|
if (dash) dashboard.value = dash.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
|
settleMirror.reset();
|
||||||
form.value = emptyForm();
|
form.value = emptyForm();
|
||||||
formTab.value = 'main';
|
formTab.value = 'main';
|
||||||
error.value = '';
|
error.value = '';
|
||||||
@@ -167,13 +190,24 @@ async function createDoc() {
|
|||||||
error.value = 'مبلغ کل معتبر نیست.';
|
error.value = 'مبلغ کل معتبر نیست.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!form.value.partyKind || (form.value.partyKind !== 'other' && !form.value.partyId)) {
|
||||||
|
error.value = 'طرف حساب را انتخاب کنید.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
|
const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
|
||||||
|
if (form.value.partyKind === 'other' && settleAmount !== amount) {
|
||||||
|
error.value = 'برای سایر، مبلغ دریافتشده باید با مبلغ کل یکی باشد.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
await api.post('/treasury/documents', {
|
await api.post('/treasury/documents', {
|
||||||
kind,
|
kind,
|
||||||
title: form.value.title,
|
title: form.value.title,
|
||||||
amount,
|
amount,
|
||||||
categoryId: form.value.categoryId || null,
|
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,
|
accountId: form.value.accountId || null,
|
||||||
settleAmount,
|
settleAmount,
|
||||||
method: form.value.method,
|
method: form.value.method,
|
||||||
@@ -358,10 +392,8 @@ onMounted(() => {
|
|||||||
|
|
||||||
<a-row :gutter="[12, 0]">
|
<a-row :gutter="[12, 0]">
|
||||||
<a-col :span="12">
|
<a-col :span="12">
|
||||||
<a-form-item label="مشتری / طرف حساب" name="partyName">
|
<a-form-item label="طرف حساب" name="partyKind">
|
||||||
<a-input v-model:value="form.partyName" placeholder="نام طرف حساب">
|
<TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
|
||||||
<template #prefix><UserOutlined /></template>
|
|
||||||
</a-input>
|
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="12">
|
<a-col :span="12">
|
||||||
@@ -426,7 +458,12 @@ onMounted(() => {
|
|||||||
name="settleAmount"
|
name="settleAmount"
|
||||||
:rules="[{ required: true, message: 'مبلغ دریافتشده را وارد کنید' }]"
|
:rules="[{ required: true, message: 'مبلغ دریافتشده را وارد کنید' }]"
|
||||||
>
|
>
|
||||||
<MoneyInput v-model="form.settleAmount" :decimals="0" placeholder="۰" />
|
<MoneyInput
|
||||||
|
v-model="form.settleAmount"
|
||||||
|
:decimals="0"
|
||||||
|
:disabled="form.partyKind === 'other'"
|
||||||
|
placeholder="۰"
|
||||||
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
@@ -511,6 +548,8 @@ onMounted(() => {
|
|||||||
</a-card>
|
</a-card>
|
||||||
|
|
||||||
<a-card :bordered="false">
|
<a-card :bordered="false">
|
||||||
|
<a-divider orientation="right">مانده دریافتنی</a-divider>
|
||||||
|
<TreasuryPartyBalanceTable :rows="balances" settled-label="دریافتشده" />
|
||||||
<a-divider orientation="right">فهرست اسناد</a-divider>
|
<a-divider orientation="right">فهرست اسناد</a-divider>
|
||||||
<a-form layout="vertical">
|
<a-form layout="vertical">
|
||||||
<a-form-item label="جستجو">
|
<a-form-item label="جستجو">
|
||||||
@@ -540,11 +579,7 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
<a-list-item-meta>
|
<a-list-item-meta>
|
||||||
<template #title>{{ item.title }}</template>
|
<template #title>{{ item.title }}</template>
|
||||||
<template #description>
|
<template #description>{{ docLine(item) }}</template>
|
||||||
{{ statusLabel[item.status] }}
|
|
||||||
· مانده {{ toman(item.remaining) }}
|
|
||||||
<template v-if="item.categoryName"> · {{ item.categoryName }}</template>
|
|
||||||
</template>
|
|
||||||
</a-list-item-meta>
|
</a-list-item-meta>
|
||||||
</a-list-item>
|
</a-list-item>
|
||||||
</template>
|
</template>
|
||||||
@@ -614,7 +649,7 @@ onMounted(() => {
|
|||||||
</a-col>
|
</a-col>
|
||||||
<a-col :xs="24" :md="8">
|
<a-col :xs="24" :md="8">
|
||||||
<a-form-item :label="settleLabel" name="settleAmount">
|
<a-form-item :label="settleLabel" name="settleAmount">
|
||||||
<MoneyInput v-model="form.settleAmount" :decimals="0" />
|
<MoneyInput v-model="form.settleAmount" :decimals="0" :disabled="form.partyKind === 'other'" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :xs="24" :md="8">
|
<a-col :xs="24" :md="8">
|
||||||
@@ -632,8 +667,8 @@ onMounted(() => {
|
|||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :xs="24" :md="8">
|
<a-col :xs="24" :md="8">
|
||||||
<a-form-item label="طرف حساب" name="partyName">
|
<a-form-item label="طرف حساب" name="partyKind">
|
||||||
<a-input v-model:value="form.partyName" />
|
<TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :xs="24" :md="8">
|
<a-col :xs="24" :md="8">
|
||||||
@@ -688,6 +723,8 @@ onMounted(() => {
|
|||||||
<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>
|
||||||
|
|
||||||
|
<a-divider orientation="right">مانده دریافتنی</a-divider>
|
||||||
|
<TreasuryPartyBalanceTable :rows="balances" settled-label="دریافتشده" />
|
||||||
<a-divider orientation="right">فهرست اسناد</a-divider>
|
<a-divider orientation="right">فهرست اسناد</a-divider>
|
||||||
<a-form layout="vertical">
|
<a-form layout="vertical">
|
||||||
<a-row :gutter="[16, 0]">
|
<a-row :gutter="[16, 0]">
|
||||||
@@ -722,11 +759,7 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
<a-list-item-meta>
|
<a-list-item-meta>
|
||||||
<template #title>{{ item.title }}</template>
|
<template #title>{{ item.title }}</template>
|
||||||
<template #description>
|
<template #description>{{ docLine(item) }}</template>
|
||||||
{{ statusLabel[item.status] }}
|
|
||||||
· مانده {{ toman(item.remaining) }}
|
|
||||||
<template v-if="item.categoryName"> · {{ item.categoryName }}</template>
|
|
||||||
</template>
|
|
||||||
</a-list-item-meta>
|
</a-list-item-meta>
|
||||||
</a-list-item>
|
</a-list-item>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -6461,6 +6461,7 @@ export interface RoutineUserSettingsDto {
|
|||||||
export type FinancialAccountType = "bank" | "cash" | "petty";
|
export type FinancialAccountType = "bank" | "cash" | "petty";
|
||||||
export type FinancialDocKind = "income" | "expense";
|
export type FinancialDocKind = "income" | "expense";
|
||||||
export type FinancialDocStatus = "open" | "partial" | "settled" | "void";
|
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 type FinancialPayMethod = "cash" | "card_to_card" | "pos" | "transfer" | "other";
|
||||||
|
|
||||||
export interface FinancialAccountDto {
|
export interface FinancialAccountDto {
|
||||||
@@ -6499,7 +6500,10 @@ export interface FinancialDocumentDto {
|
|||||||
dueAt: string | null;
|
dueAt: string | null;
|
||||||
categoryId: string | null;
|
categoryId: string | null;
|
||||||
categoryName: string | null;
|
categoryName: string | null;
|
||||||
|
partyKind: FinancialPartyKind | null;
|
||||||
personId: string | null;
|
personId: string | null;
|
||||||
|
companyId: string | null;
|
||||||
|
userId: string | null;
|
||||||
partyName: string | null;
|
partyName: string | null;
|
||||||
accountId: string | null;
|
accountId: string | null;
|
||||||
accountName: string | null;
|
accountName: string | null;
|
||||||
@@ -6552,6 +6556,26 @@ export interface FinancialReceivableDto {
|
|||||||
createdAt: string;
|
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 {
|
export interface TreasuryDashboardDto {
|
||||||
bookedIncome: string;
|
bookedIncome: string;
|
||||||
actualReceipts: string;
|
actualReceipts: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user