feat: gate treasury as an org module and group money inputs
Link optional invoice and cheque settlements to cash accounts, and let superadmin enable the finance module per organization. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
ALTER TABLE "Payment" ADD COLUMN "financialAccountId" TEXT;
|
||||
ALTER TABLE "Cheque" ADD COLUMN "financialAccountId" TEXT;
|
||||
ALTER TABLE "FinancialMovement" ADD COLUMN "paymentId" TEXT;
|
||||
ALTER TABLE "FinancialMovement" ADD COLUMN "chequeId" TEXT;
|
||||
CREATE INDEX "FinancialMovement_paymentId_idx" ON "FinancialMovement"("paymentId");
|
||||
CREATE INDEX "FinancialMovement_chequeId_idx" ON "FinancialMovement"("chequeId");
|
||||
|
||||
CREATE TABLE "FinancialAuditLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"entityType" TEXT NOT NULL,
|
||||
"entityId" TEXT NOT NULL,
|
||||
"action" TEXT NOT NULL,
|
||||
"before" JSONB,
|
||||
"after" JSONB,
|
||||
"actorUserId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "FinancialAuditLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
CREATE INDEX "FinancialAuditLog_organizationId_entityType_entityId_createdAt_idx" ON "FinancialAuditLog"("organizationId", "entityType", "entityId", "createdAt");
|
||||
|
||||
CREATE TABLE "FinancialAttachment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"documentId" TEXT NOT NULL,
|
||||
"fileName" TEXT NOT NULL,
|
||||
"mimeType" TEXT NOT NULL,
|
||||
"storageKey" TEXT NOT NULL,
|
||||
"createdById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "FinancialAttachment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
CREATE INDEX "FinancialAttachment_documentId_idx" ON "FinancialAttachment"("documentId");
|
||||
|
||||
ALTER TABLE "FinancialAuditLog" ADD CONSTRAINT "FinancialAuditLog_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialAttachment" ADD CONSTRAINT "FinancialAttachment_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "FinancialAttachment" ADD CONSTRAINT "FinancialAttachment_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "FinancialDocument"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -211,6 +211,8 @@ model Organization {
|
||||
financialPayrolls FinancialPayroll[]
|
||||
financialRecurringItems FinancialRecurringItem[]
|
||||
financialReconciliations FinancialReconciliation[]
|
||||
financialAuditLogs FinancialAuditLog[]
|
||||
financialAttachments FinancialAttachment[]
|
||||
wooCommerceConnections WooCommerceConnection[]
|
||||
montifyConnections MontifyConnection[]
|
||||
voipConnections VoipConnection[]
|
||||
@@ -1181,6 +1183,8 @@ model Payment {
|
||||
method PaymentMethod @default(transfer)
|
||||
paidAt DateTime @default(now())
|
||||
notes String?
|
||||
/// اگر پر باشد، این پرداخت یکبار در صندوق نشسته است
|
||||
financialAccountId String?
|
||||
installmentId String? @unique
|
||||
installment InvoiceInstallment? @relation(fields: [installmentId], references: [id], onDelete: SetNull)
|
||||
cheque Cheque?
|
||||
@@ -1840,6 +1844,8 @@ model Cheque {
|
||||
/// in = دریافتی، out = پرداختی
|
||||
direction String @default("in")
|
||||
reminderNotifiedAt DateTime?
|
||||
/// حساب صندوق برای لحظهٔ وصول؛ تا قبل از وصول موجودی را عوض نمیکند
|
||||
financialAccountId String?
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -2071,6 +2077,7 @@ model FinancialDocument {
|
||||
voidedAt DateTime?
|
||||
voidReason String?
|
||||
movements FinancialMovement[]
|
||||
attachments FinancialAttachment[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -2094,6 +2101,8 @@ model FinancialMovement {
|
||||
installment FinancialInstallment? @relation(fields: [installmentId], references: [id], onDelete: SetNull)
|
||||
payrollId String?
|
||||
payroll FinancialPayroll? @relation(fields: [payrollId], references: [id], onDelete: SetNull)
|
||||
paymentId String?
|
||||
chequeId String?
|
||||
/// in | out
|
||||
direction String
|
||||
amount Decimal @db.Decimal(18, 2)
|
||||
@@ -2113,6 +2122,38 @@ model FinancialMovement {
|
||||
@@index([receivableId])
|
||||
@@index([installmentId])
|
||||
@@index([payrollId])
|
||||
@@index([paymentId])
|
||||
@@index([chequeId])
|
||||
}
|
||||
|
||||
model FinancialAuditLog {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
entityType String
|
||||
entityId String
|
||||
action String
|
||||
before Json?
|
||||
after Json?
|
||||
actorUserId String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([organizationId, entityType, entityId, createdAt])
|
||||
}
|
||||
|
||||
model FinancialAttachment {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
documentId String
|
||||
document FinancialDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
fileName String
|
||||
mimeType String
|
||||
storageKey String
|
||||
createdById String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([documentId])
|
||||
}
|
||||
|
||||
model FinancialPayroll {
|
||||
|
||||
@@ -763,6 +763,7 @@ export class AuthService {
|
||||
organization: {
|
||||
select: { name: true, enabledModules: true, priorArchiveEnabledAt: true },
|
||||
},
|
||||
orgRole: { select: { permissions: true } },
|
||||
},
|
||||
});
|
||||
const role = membership?.role ?? user.role;
|
||||
@@ -790,6 +791,9 @@ export class AuthService {
|
||||
subscription: sub,
|
||||
isOrgOwner: role === 'owner',
|
||||
priorArchive: this.priorArchiveFor(membership),
|
||||
permissions: (Array.isArray(membership?.orgRole?.permissions)
|
||||
? membership.orgRole.permissions.filter((item) => typeof item === 'string')
|
||||
: []) as AuthUserDto['permissions'],
|
||||
...(viewAs ? { viewAs } : {}),
|
||||
});
|
||||
}
|
||||
@@ -897,6 +901,7 @@ export class AuthService {
|
||||
isOrgOwner?: boolean;
|
||||
priorArchive?: AuthUserDto['priorArchive'];
|
||||
viewAs?: AuthUserDto['viewAs'];
|
||||
permissions?: AuthUserDto['permissions'];
|
||||
}): AuthUserDto {
|
||||
return {
|
||||
id: user.id,
|
||||
@@ -912,6 +917,7 @@ export class AuthService {
|
||||
...(user.isOrgOwner !== undefined ? { isOrgOwner: user.isOrgOwner } : {}),
|
||||
...(user.priorArchive ? { priorArchive: user.priorArchive } : {}),
|
||||
...(user.viewAs ? { viewAs: user.viewAs } : {}),
|
||||
...(user.permissions ? { permissions: user.permissions } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,12 +12,18 @@ import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { type ScopedActor, ownerWhere, scopedOwnerIds } from '../common/record-scope';
|
||||
import { buildSearchWhere, ci } from '../common/search';
|
||||
import { TreasuryBridgeService } from '../treasury/treasury-bridge.service';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
|
||||
const CHEQUE_STATUSES = ['pending', 'cleared', 'bounced'] as const;
|
||||
|
||||
@Injectable()
|
||||
export class ChequesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly treasury: TreasuryBridgeService,
|
||||
private readonly billing: BillingService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
organizationId: string,
|
||||
@@ -92,9 +98,13 @@ export class ChequesService {
|
||||
? dto.status
|
||||
: 'pending',
|
||||
notes: dto.notes?.trim() ? dto.notes.trim() : null,
|
||||
financialAccountId: dto.financialAccountId || null,
|
||||
},
|
||||
include: this.include,
|
||||
});
|
||||
if (row.status === 'cleared' && row.financialAccountId && await this.treasuryEnabled(organizationId)) {
|
||||
await this.postCleared(row, actor.userId);
|
||||
}
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
@@ -106,7 +116,6 @@ export class ChequesService {
|
||||
): Promise<ChequeDto> {
|
||||
const existing = await this.prisma.cheque.findFirst({
|
||||
where: { id, organizationId, ...this.accessWhere(actor) },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('چک پیدا نشد');
|
||||
|
||||
@@ -123,15 +132,61 @@ export class ChequesService {
|
||||
if ('notes' in dto) {
|
||||
data.notes = dto.notes?.trim() ? dto.notes.trim() : null;
|
||||
}
|
||||
if (dto.financialAccountId) data.financialAccountId = dto.financialAccountId;
|
||||
const nextStatus = dto.status ?? existing.status;
|
||||
const nextAccount = dto.financialAccountId || existing.financialAccountId;
|
||||
const treasuryOn = await this.treasuryEnabled(organizationId);
|
||||
if (treasuryOn && existing.status !== 'cleared' && nextStatus === 'cleared' && !nextAccount) {
|
||||
throw new BadRequestException('برای وصول چک، حساب صندوق را انتخاب کنید.');
|
||||
}
|
||||
|
||||
const row = await this.prisma.cheque.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: this.include,
|
||||
});
|
||||
if (
|
||||
existing.status !== 'cleared' &&
|
||||
row.status === 'cleared' &&
|
||||
row.financialAccountId &&
|
||||
await this.treasuryEnabled(organizationId)
|
||||
) {
|
||||
await this.postCleared(row, actor.userId);
|
||||
}
|
||||
if (existing.status === 'cleared' && row.status !== 'cleared') {
|
||||
await this.treasury.voidCheque(row.id);
|
||||
if (row.paymentId) await this.treasury.voidPayment(row.paymentId);
|
||||
}
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
private async treasuryEnabled(organizationId: string) {
|
||||
try {
|
||||
await this.billing.requireModule(organizationId, 'treasury');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async postCleared(
|
||||
row: { id: string; organizationId: string; financialAccountId: string | null; amount: Prisma.Decimal; direction: string; number: string },
|
||||
actorUserId: string,
|
||||
) {
|
||||
if (!row.financialAccountId) return;
|
||||
await this.treasury.postCash({
|
||||
organizationId: row.organizationId,
|
||||
accountId: row.financialAccountId,
|
||||
amount: row.amount,
|
||||
direction: row.direction === 'out' ? 'out' : 'in',
|
||||
kind: row.direction === 'out' ? 'payment' : 'receipt',
|
||||
occurredAt: new Date(),
|
||||
memo: `وصول چک ${row.number}`,
|
||||
actorUserId,
|
||||
chequeId: row.id,
|
||||
});
|
||||
}
|
||||
|
||||
private accessWhere(
|
||||
actor: ScopedActor,
|
||||
): Prisma.ChequeWhereInput {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { TreasuryModule } from '../treasury/treasury.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { SmsService } from '../common/sms.service';
|
||||
import { ChequesService } from './cheques.service';
|
||||
@@ -8,7 +9,7 @@ import { CommissionsService } from './commissions.service';
|
||||
import { FinanceController } from './finance.controller';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, NotificationsModule],
|
||||
imports: [AuthModule, NotificationsModule, TreasuryModule],
|
||||
controllers: [FinanceController],
|
||||
providers: [ChequesService, ChequeRemindersService, CommissionsService, SmsService],
|
||||
exports: [ChequesService, CommissionsService],
|
||||
|
||||
@@ -2,12 +2,13 @@ import { Module } from '@nestjs/common';
|
||||
import { AutomationsModule } from '../automations/automations.module';
|
||||
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
|
||||
import { FinanceModule } from '../finance/finance.module';
|
||||
import { TreasuryModule } from '../treasury/treasury.module';
|
||||
import { InventoryModule } from '../inventory/inventory.module';
|
||||
import { InvoicesController } from './invoices.controller';
|
||||
import { InvoicesService } from './invoices.service';
|
||||
|
||||
@Module({
|
||||
imports: [AutomationsModule, MessageAutomationsModule, InventoryModule, FinanceModule],
|
||||
imports: [AutomationsModule, MessageAutomationsModule, InventoryModule, FinanceModule, TreasuryModule],
|
||||
controllers: [InvoicesController],
|
||||
providers: [InvoicesService],
|
||||
exports: [InvoicesService],
|
||||
|
||||
@@ -22,6 +22,7 @@ import { MessageAutomationsService } from '../message-automations/message-automa
|
||||
import { InventoryService } from '../inventory/inventory.service';
|
||||
import { CommissionsService } from '../finance/commissions.service';
|
||||
import { GamificationService } from '../gamification/gamification.service';
|
||||
import { TreasuryBridgeService } from '../treasury/treasury-bridge.service';
|
||||
|
||||
type InvoiceActor = Pick<Actor, 'userId' | 'role'>;
|
||||
|
||||
@@ -54,6 +55,7 @@ export class InvoicesService {
|
||||
private readonly inventory: InventoryService,
|
||||
private readonly commissions: CommissionsService,
|
||||
private readonly game: GamificationService,
|
||||
private readonly treasury: TreasuryBridgeService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
@@ -509,9 +511,16 @@ export class InvoicesService {
|
||||
? dto.cheque.status
|
||||
: 'pending',
|
||||
notes: emptyToNull(dto.cheque.notes ?? null),
|
||||
financialAccountId: dto.financialAccountId || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (dto.financialAccountId) {
|
||||
await tx.payment.update({
|
||||
where: { id: payment.id },
|
||||
data: { financialAccountId: dto.financialAccountId },
|
||||
});
|
||||
}
|
||||
if (installmentId) {
|
||||
await tx.invoiceInstallment.update({
|
||||
where: { id: installmentId },
|
||||
@@ -530,6 +539,25 @@ export class InvoicesService {
|
||||
return payment.id;
|
||||
});
|
||||
|
||||
const accountId = dto.financialAccountId || null;
|
||||
const chequePending = method === 'check' && dto.cheque?.status !== 'cleared';
|
||||
if (accountId && !chequePending) {
|
||||
const linkedCheque = method === 'check'
|
||||
? await this.prisma.cheque.findFirst({ where: { paymentId }, select: { id: true } })
|
||||
: null;
|
||||
await this.treasury.postCash({
|
||||
organizationId,
|
||||
accountId,
|
||||
amount: dto.amount,
|
||||
direction: 'in',
|
||||
kind: 'receipt',
|
||||
occurredAt: dto.paidAt ? new Date(dto.paidAt) : new Date(),
|
||||
memo: 'پرداخت فاکتور',
|
||||
actorUserId: actor.userId,
|
||||
paymentId,
|
||||
chequeId: linkedCheque?.id ?? null,
|
||||
});
|
||||
}
|
||||
await this.recomputeStatus(invoiceId);
|
||||
const row = await this.get(organizationId, invoiceId, actor);
|
||||
await this.inventory.syncInvoiceStock(organizationId, row.id);
|
||||
@@ -574,6 +602,7 @@ export class InvoicesService {
|
||||
});
|
||||
if (!payment) throw new NotFoundException('پرداخت پیدا نشد');
|
||||
|
||||
await this.treasury.voidPayment(paymentId);
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
if (payment.installmentId) {
|
||||
await tx.invoiceInstallment.update({
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
|
||||
@Injectable()
|
||||
export class TreasuryAccessGuard implements CanActivate {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const req = context.switchToHttp().getRequest<{ user?: Actor; originalUrl?: string; url?: string }>();
|
||||
const actor = req.user;
|
||||
if (!actor) throw new ForbiddenException('دسترسی مالی مجاز نیست.');
|
||||
const url = req.originalUrl || req.url || '';
|
||||
const payroll = url.includes('/treasury/payroll');
|
||||
if (actor.role === 'owner' || actor.role === 'admin') return true;
|
||||
const membership = await this.prisma.membership.findFirst({
|
||||
where: { userId: actor.userId, organizationId: actor.organizationId, isActive: true },
|
||||
select: { orgRole: { select: { permissions: true } } },
|
||||
});
|
||||
const permissions = Array.isArray(membership?.orgRole?.permissions)
|
||||
? membership.orgRole.permissions.map(String)
|
||||
: [];
|
||||
const allowed = payroll
|
||||
? permissions.includes('treasury_payroll')
|
||||
: permissions.includes('treasury') || permissions.includes('treasury_payroll');
|
||||
if (!allowed) throw new ForbiddenException('دسترسی مالی برای نقش شما مجاز نیست.');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class TreasuryBridgeService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async postCash(input: {
|
||||
organizationId: string;
|
||||
accountId: string;
|
||||
amount: Prisma.Decimal | number | string;
|
||||
direction: 'in' | 'out';
|
||||
kind: 'receipt' | 'payment';
|
||||
occurredAt: Date;
|
||||
memo: string;
|
||||
actorUserId?: string | null;
|
||||
paymentId?: string | null;
|
||||
chequeId?: string | null;
|
||||
}) {
|
||||
const account = await this.prisma.financialAccount.findFirst({
|
||||
where: { id: input.accountId, organizationId: input.organizationId, isActive: true },
|
||||
});
|
||||
if (!account) throw new BadRequestException('حساب صندوق پیدا نشد.');
|
||||
const amount = new Prisma.Decimal(input.amount);
|
||||
if (!amount.greaterThan(0)) return;
|
||||
if (input.paymentId) {
|
||||
const existing = await this.prisma.financialMovement.findFirst({
|
||||
where: { paymentId: input.paymentId, voidedAt: null },
|
||||
});
|
||||
if (existing) return;
|
||||
}
|
||||
if (input.chequeId) {
|
||||
const existing = await this.prisma.financialMovement.findFirst({
|
||||
where: { chequeId: input.chequeId, voidedAt: null },
|
||||
});
|
||||
if (existing) return;
|
||||
}
|
||||
if (input.direction === 'out') {
|
||||
const balance = await this.balance(account.id);
|
||||
if (balance.lessThan(amount)) {
|
||||
throw new BadRequestException('موجودی حساب برای این پرداخت کافی نیست.');
|
||||
}
|
||||
}
|
||||
await this.prisma.financialMovement.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
accountId: account.id,
|
||||
direction: input.direction,
|
||||
amount,
|
||||
kind: input.kind,
|
||||
occurredAt: input.occurredAt,
|
||||
memo: input.memo,
|
||||
paymentId: input.paymentId ?? null,
|
||||
chequeId: input.chequeId ?? null,
|
||||
createdById: input.actorUserId ?? null,
|
||||
},
|
||||
});
|
||||
await this.audit(input.organizationId, input.actorUserId, 'cash_post', input.paymentId || input.chequeId || account.id, null, {
|
||||
accountId: account.id,
|
||||
amount: amount.toFixed(2),
|
||||
direction: input.direction,
|
||||
});
|
||||
}
|
||||
|
||||
async voidPayment(paymentId: string) {
|
||||
await this.prisma.financialMovement.updateMany({
|
||||
where: { paymentId, voidedAt: null },
|
||||
data: { voidedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async voidCheque(chequeId: string) {
|
||||
await this.prisma.financialMovement.updateMany({
|
||||
where: { chequeId, voidedAt: null },
|
||||
data: { voidedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async audit(
|
||||
organizationId: string,
|
||||
actorUserId: string | null | undefined,
|
||||
action: string,
|
||||
entityId: string,
|
||||
before: unknown,
|
||||
after: unknown,
|
||||
entityType = 'treasury',
|
||||
) {
|
||||
await this.prisma.financialAuditLog.create({
|
||||
data: {
|
||||
organizationId,
|
||||
entityType,
|
||||
entityId,
|
||||
action,
|
||||
before: before == null ? undefined : (before as Prisma.InputJsonValue),
|
||||
after: after == null ? undefined : (after as Prisma.InputJsonValue),
|
||||
actorUserId: actorUserId ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async balance(accountId: string) {
|
||||
const rows = await this.prisma.financialMovement.findMany({
|
||||
where: { accountId, voidedAt: null },
|
||||
select: { direction: true, amount: true },
|
||||
});
|
||||
return rows.reduce(
|
||||
(sum, row) => (row.direction === 'in' ? sum.plus(row.amount) : sum.minus(row.amount)),
|
||||
new Prisma.Decimal(0),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, StreamableFile, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, 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';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { OrgModuleGuard } from '../billing/org-module.guard';
|
||||
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||
import { TreasuryAccessGuard } from './treasury-access.guard';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { TreasuryOpsService } from './treasury-ops.service';
|
||||
import { TreasuryReceivablesService } from './treasury-receivables.service';
|
||||
import { TreasuryService } from './treasury.service';
|
||||
|
||||
@Controller('treasury')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('owner', 'admin')
|
||||
@UseGuards(JwtAuthGuard, OrgModuleGuard, TreasuryAccessGuard)
|
||||
@RequireOrgModule('treasury')
|
||||
export class TreasuryController {
|
||||
constructor(
|
||||
private readonly treasury: TreasuryService,
|
||||
@@ -92,6 +95,16 @@ export class TreasuryController {
|
||||
return this.treasury.createDocument(user, body);
|
||||
}
|
||||
|
||||
@Post('documents/:id/attachments')
|
||||
@UseInterceptors(FileInterceptor('file', { storage: memoryStorage(), limits: { fileSize: 8_000_000 } }))
|
||||
addAttachment(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@UploadedFile() file: { originalname: string; mimetype: string; buffer: Buffer },
|
||||
) {
|
||||
return this.treasury.addAttachment(user, id, file);
|
||||
}
|
||||
|
||||
@Post('documents/:id/settle')
|
||||
settle(
|
||||
@CurrentUser() user: Actor,
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { TreasuryController } from './treasury.controller';
|
||||
import { TreasuryAccessGuard } from './treasury-access.guard';
|
||||
import { TreasuryBridgeService } from './treasury-bridge.service';
|
||||
import { TreasuryOpsService } from './treasury-ops.service';
|
||||
import { TreasuryReceivablesService } from './treasury-receivables.service';
|
||||
import { TreasuryService } from './treasury.service';
|
||||
@@ -9,7 +11,13 @@ import { TreasuryService } from './treasury.service';
|
||||
@Module({
|
||||
imports: [AuthModule, NotificationsModule],
|
||||
controllers: [TreasuryController],
|
||||
providers: [TreasuryService, TreasuryReceivablesService, TreasuryOpsService],
|
||||
exports: [TreasuryService],
|
||||
providers: [
|
||||
TreasuryService,
|
||||
TreasuryReceivablesService,
|
||||
TreasuryOpsService,
|
||||
TreasuryBridgeService,
|
||||
TreasuryAccessGuard,
|
||||
],
|
||||
exports: [TreasuryService, TreasuryBridgeService],
|
||||
})
|
||||
export class TreasuryModule {}
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
TreasuryDashboardDto,
|
||||
} from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { TreasuryReceivablesService } from './treasury-receivables.service';
|
||||
|
||||
@@ -58,6 +59,7 @@ export class TreasuryService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly receivables: TreasuryReceivablesService,
|
||||
private readonly files: LocalFileStorage,
|
||||
) {}
|
||||
|
||||
async ensureCategories(organizationId: string) {
|
||||
@@ -507,6 +509,16 @@ export class TreasuryService {
|
||||
data: { status, accountId: account.id },
|
||||
}),
|
||||
]);
|
||||
await this.prisma.financialAuditLog.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
entityType: 'document',
|
||||
entityId: doc.id,
|
||||
action: 'settle',
|
||||
after: { amount: amount.toFixed(2), accountId: account.id, status },
|
||||
actorUserId: actor.userId,
|
||||
},
|
||||
}).catch(() => undefined);
|
||||
if (status === 'settled') {
|
||||
await this.prisma.financialRecurringOccurrence.updateMany({
|
||||
where: { documentId: doc.id, status: 'pending' },
|
||||
@@ -543,6 +555,28 @@ export class TreasuryService {
|
||||
return this.toDocumentDto(fresh);
|
||||
}
|
||||
|
||||
async addAttachment(actor: Actor, documentId: string, file: { originalname: string; mimetype: string; buffer: Buffer }) {
|
||||
const doc = await this.prisma.financialDocument.findFirst({
|
||||
where: { id: documentId, organizationId: actor.organizationId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!doc) throw new NotFoundException('سند پیدا نشد.');
|
||||
const safeName = file.originalname.replace(/[^\w.\-\u0600-\u06FF]+/g, '_').slice(0, 80);
|
||||
const storageKey = `treasury/${actor.organizationId}/${doc.id}/${randomUUID()}-${safeName || 'file'}`;
|
||||
await this.files.save(storageKey, file.buffer);
|
||||
return this.prisma.financialAttachment.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
documentId: doc.id,
|
||||
fileName: file.originalname,
|
||||
mimeType: file.mimetype || 'application/octet-stream',
|
||||
storageKey,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
select: { id: true, fileName: true, createdAt: true },
|
||||
});
|
||||
}
|
||||
|
||||
async dashboard(
|
||||
actor: Actor,
|
||||
range?: { from?: string; to?: string },
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
const route = useRoute();
|
||||
const links = [
|
||||
const auth = useAuthStore();
|
||||
const canPayroll = computed(() =>
|
||||
auth.user?.role === 'owner'
|
||||
|| auth.user?.role === 'admin'
|
||||
|| Boolean(auth.user?.permissions?.includes('treasury_payroll')),
|
||||
);
|
||||
const links = computed(() => [
|
||||
{ to: '/finance', label: 'داشبورد', exact: true },
|
||||
{ to: '/finance/incomes', label: 'دریافتها و درآمدها' },
|
||||
{ to: '/finance/expenses', label: 'پرداختها و هزینهها' },
|
||||
{ to: '/finance/receivables', label: 'اقساط و مطالبات' },
|
||||
{ to: '/finance/accounts', label: 'صندوق و حسابها' },
|
||||
{ to: '/finance/payroll', label: 'حقوق' },
|
||||
...(canPayroll.value ? [{ to: '/finance/payroll', label: 'حقوق' }] : []),
|
||||
{ to: '/finance/recurring', label: 'تکرارشونده' },
|
||||
{ to: '/finance/reports', label: 'گزارش' },
|
||||
{ to: '/finance/categories', label: 'دستهبندیها' },
|
||||
];
|
||||
]);
|
||||
|
||||
function on(path: string, exact?: boolean) {
|
||||
if (exact) return route.path === path;
|
||||
|
||||
@@ -157,6 +157,11 @@ function cardAllowedForNav(card: {
|
||||
to?: string;
|
||||
}) {
|
||||
if (card.ownerOnly && auth.user?.role !== 'owner') return false;
|
||||
const canTreasury = auth.user?.role === 'owner'
|
||||
|| auth.user?.role === 'admin'
|
||||
|| Boolean(auth.user?.permissions?.includes('treasury'))
|
||||
|| Boolean(auth.user?.permissions?.includes('treasury_payroll'));
|
||||
if (card.to?.startsWith('/finance')) return canTreasury;
|
||||
if (card.admin && !canSettings.value && !canOpenTeamWhileViewing.value) return false;
|
||||
if (!moduleAllowed(card.module)) return false;
|
||||
if (
|
||||
|
||||
@@ -360,7 +360,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
desc: 'صندوق، درآمد و هزینه داخلی',
|
||||
icon: 'dollar',
|
||||
tint: 'mint',
|
||||
admin: true,
|
||||
module: 'treasury',
|
||||
},
|
||||
{
|
||||
to: '/reservations',
|
||||
|
||||
@@ -557,6 +557,10 @@ router.beforeEach((to) => {
|
||||
) {
|
||||
return { name: 'home' };
|
||||
}
|
||||
if (String(to.path).startsWith('/finance')) {
|
||||
const modules = auth.user?.enabledModules;
|
||||
if (modules && !modules.includes('treasury')) return { name: 'home' };
|
||||
}
|
||||
const roles = to.meta.roles as UserRole[] | undefined;
|
||||
if (roles?.length) {
|
||||
const role = auth.user?.role as UserRole | undefined;
|
||||
@@ -566,6 +570,12 @@ router.beforeEach((to) => {
|
||||
if (to.name === 'team-settings' && auth.user?.viewAs?.canOpenTeamSettings) {
|
||||
return true;
|
||||
}
|
||||
if (String(to.path).startsWith('/finance')) {
|
||||
const perms = auth.user?.permissions ?? [];
|
||||
const payroll = to.path.startsWith('/finance/payroll');
|
||||
if (payroll && perms.includes('treasury_payroll')) return true;
|
||||
if (!payroll && (perms.includes('treasury') || perms.includes('treasury_payroll'))) return true;
|
||||
}
|
||||
return { name: 'home' };
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -97,10 +97,46 @@ async function load() {
|
||||
|
||||
async function setStatus(row: ChequeDto, next: ChequeStatus) {
|
||||
if (row.status === next) return;
|
||||
let financialAccountId: string | undefined;
|
||||
if (next === 'cleared' && row.status !== 'cleared') {
|
||||
const accountsRes = await api
|
||||
.get<Array<{ id: string; name: string; isActive: boolean }>>('/treasury/accounts')
|
||||
.catch(() => null);
|
||||
if (!accountsRes) {
|
||||
savingId.value = row.id;
|
||||
try {
|
||||
const { data } = await api.patch<ChequeDto>(`/cheques/${row.id}`, { status: next });
|
||||
items.value = items.value.map((item) => (item.id === row.id ? data : item));
|
||||
} catch {
|
||||
error.value = 'تغییر وضعیت چک ناموفق بود.';
|
||||
} finally {
|
||||
savingId.value = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
const accounts = accountsRes.data;
|
||||
const active = accounts.filter((item) => item.isActive);
|
||||
if (!active.length) {
|
||||
error.value = 'برای وصول چک ابتدا یک حساب صندوق بسازید.';
|
||||
return;
|
||||
}
|
||||
if (active.length === 1) {
|
||||
financialAccountId = active[0]!.id;
|
||||
} else {
|
||||
const picked = window.prompt(`نام حساب صندوق را بنویسید: ${active.map((item) => item.name).join('، ')}`);
|
||||
const match = active.find((item) => item.name.trim() === picked?.trim());
|
||||
if (!match) {
|
||||
error.value = 'حساب صندوق انتخاب نشد.';
|
||||
return;
|
||||
}
|
||||
financialAccountId = match.id;
|
||||
}
|
||||
}
|
||||
savingId.value = row.id;
|
||||
try {
|
||||
const { data } = await api.patch<ChequeDto>(`/cheques/${row.id}`, {
|
||||
status: next,
|
||||
...(financialAccountId ? { financialAccountId } : {}),
|
||||
});
|
||||
items.value = items.value.map((item) => (item.id === row.id ? data : item));
|
||||
} catch {
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
CreateQuoteRequest,
|
||||
DealDto,
|
||||
DealLineItemInput,
|
||||
FinancialAccountDto,
|
||||
InvoiceDto,
|
||||
InvoiceInstallmentInput,
|
||||
InvoiceStatus,
|
||||
@@ -123,6 +124,8 @@ const lines = ref<LineDraft[]>([]);
|
||||
const installments = ref<InstallmentDraft[]>([]);
|
||||
const invoice = ref<InvoiceDto | null>(null);
|
||||
|
||||
const payAccountId = ref('');
|
||||
const financeAccounts = ref<FinancialAccountDto[]>([]);
|
||||
const payAmount = ref('');
|
||||
const payMethod = ref<PaymentMethod>('transfer');
|
||||
const payNotes = ref('');
|
||||
@@ -463,6 +466,8 @@ async function load() {
|
||||
error.value = '';
|
||||
mobileStep.value = 1;
|
||||
try {
|
||||
const accounts = await api.get<FinancialAccountDto[]>('/treasury/accounts').catch(() => null);
|
||||
financeAccounts.value = accounts?.data.filter((row) => row.isActive) ?? [];
|
||||
title.value = defaultTitle.value;
|
||||
status.value = 'draft';
|
||||
notes.value = '';
|
||||
@@ -740,6 +745,7 @@ async function submitPayment() {
|
||||
notes: payNotes.value || undefined,
|
||||
paidAt: payDate.value ? new Date(payDate.value).toISOString() : undefined,
|
||||
installmentId: payInstallmentId.value || null,
|
||||
financialAccountId: payAccountId.value || null,
|
||||
cheque:
|
||||
payMethod.value === 'check'
|
||||
? {
|
||||
@@ -1316,6 +1322,12 @@ onMounted(load);
|
||||
</option>
|
||||
</select>
|
||||
<input v-model="payNotes" placeholder="یادداشت پرداخت" />
|
||||
<select v-if="financeAccounts.length" v-model="payAccountId">
|
||||
<option value="">بدون اثر روی صندوق</option>
|
||||
<option v-for="account in financeAccounts" :key="account.id" :value="account.id">
|
||||
واریز به {{ account.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div v-if="payMethod === 'check'" class="cheque-fields">
|
||||
<input v-model="chequeNumber" placeholder="شماره چک" />
|
||||
<input v-model="chequeBank" placeholder="بانک" />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRoute } from 'vue-router';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialAccountDto, FinancialMovementDto } from '@mento/shared';
|
||||
import { UiButton, UiField, UiInput, UiPageHeader } from '@/components/ui';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
import { formatJalaliNumeric } from '@/lib/jalali';
|
||||
@@ -70,7 +71,7 @@ onMounted(async () => {
|
||||
<article><span>خروجی</span><strong>{{ toman(account.periodOut) }}</strong></article>
|
||||
</div>
|
||||
<div v-if="account" class="reconcile">
|
||||
<UiField label="موجودی واقعی"><UiInput v-model="stated" /></UiField>
|
||||
<UiField label="موجودی واقعی"><MoneyInput v-model="stated" :decimals="0" /></UiField>
|
||||
<UiField label="علت اصلاح"><UiInput v-model="reason" /></UiField>
|
||||
<UiButton variant="secondary" @click="reconcile">ثبت مغایرت</UiButton>
|
||||
<UiButton variant="secondary" @click="adjust('in')">اصلاح ورود</UiButton>
|
||||
|
||||
@@ -4,6 +4,7 @@ import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialAccountDto, FinancialAccountType } from '@mento/shared';
|
||||
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
|
||||
@@ -171,7 +172,7 @@ onMounted(() => {
|
||||
<UiField v-if="form.type === 'bank'" label="نام بانک"><UiInput v-model="form.bankName" /></UiField>
|
||||
<UiField label="شماره یا شناسه"><UiInput v-model="form.accountRef" /></UiField>
|
||||
<UiField label="مسئول"><UiInput v-model="form.responsibleName" /></UiField>
|
||||
<UiField label="موجودی اولیه"><UiInput v-model="form.openingBalance" /></UiField>
|
||||
<UiField label="موجودی اولیه"><MoneyInput v-model="form.openingBalance" :decimals="0" /></UiField>
|
||||
<UiField label="تاریخ شروع"><UiInput v-model="form.openingDate" type="date" /></UiField>
|
||||
<UiField label="توضیحات"><UiInput v-model="form.notes" /></UiField>
|
||||
</div>
|
||||
@@ -192,7 +193,7 @@ onMounted(() => {
|
||||
<option v-for="account in active" :key="`to-${account.id}`" :value="account.id">{{ account.name }}</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="مبلغ"><UiInput v-model="transfer.amount" /></UiField>
|
||||
<UiField label="مبلغ"><MoneyInput v-model="transfer.amount" :decimals="0" /></UiField>
|
||||
<UiField label="شرح"><UiInput v-model="transfer.memo" /></UiField>
|
||||
</div>
|
||||
<p class="muted">این انتقال درآمد یا هزینه نیست. اگر موجودی مبدأ کافی نباشد ثبت نمیشود.</p>
|
||||
|
||||
@@ -4,6 +4,7 @@ import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialAccountDto, FinancialCategoryDto, FinancialDocumentDto } from '@mento/shared';
|
||||
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
|
||||
@@ -90,6 +91,19 @@ async function createDoc() {
|
||||
}
|
||||
}
|
||||
|
||||
async function upload(id: string, event: Event) {
|
||||
const file = (event.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
try {
|
||||
await api.post(`/treasury/documents/${id}/attachments`, body);
|
||||
ok.value = 'پیوست ذخیره شد.';
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function voidDoc(id: string) {
|
||||
if (!window.confirm('این سند و گردش متصل به آن باطل شود؟')) return;
|
||||
try {
|
||||
@@ -120,7 +134,7 @@ onMounted(() => {
|
||||
<UiCard title="سند جدید">
|
||||
<div class="grid">
|
||||
<UiField label="عنوان"><UiInput v-model="form.title" /></UiField>
|
||||
<UiField label="مبلغ کل"><UiInput v-model="form.amount" /></UiField>
|
||||
<UiField label="مبلغ کل"><MoneyInput v-model="form.amount" :decimals="0" /></UiField>
|
||||
<UiField label="دستهبندی">
|
||||
<select v-model="form.categoryId" class="select">
|
||||
<option value="">بدون دسته</option>
|
||||
@@ -128,7 +142,7 @@ onMounted(() => {
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="طرف حساب"><UiInput v-model="form.partyName" /></UiField>
|
||||
<UiField :label="settleLabel"><UiInput v-model="form.settleAmount" /></UiField>
|
||||
<UiField :label="settleLabel"><MoneyInput v-model="form.settleAmount" :decimals="0" /></UiField>
|
||||
<UiField label="حساب">
|
||||
<select v-model="form.accountId" class="select">
|
||||
<option value="">انتخاب حساب</option>
|
||||
@@ -162,6 +176,10 @@ onMounted(() => {
|
||||
<div class="side">
|
||||
<b>{{ toman(row.amount) }}</b>
|
||||
<button v-if="row.status !== 'void'" type="button" @click="voidDoc(row.id)">ابطال</button>
|
||||
<label v-if="row.status !== 'void'" class="file">
|
||||
پیوست
|
||||
<input type="file" @change="upload(row.id, $event)" />
|
||||
</label>
|
||||
</div>
|
||||
</article>
|
||||
<p v-if="!rows.length" class="muted">سندی نیست.</p>
|
||||
@@ -177,7 +195,9 @@ onMounted(() => {
|
||||
.list article { display: flex; justify-content: space-between; gap: 0.75rem; background: #fff; border: 1px solid #e7eef2; border-radius: 14px; padding: 0.8rem 1rem; }
|
||||
.list small, .muted { color: #64748b; }
|
||||
.side { display: grid; justify-items: end; gap: 0.3rem; }
|
||||
.side button { border: 0; background: transparent; color: #b91c1c; cursor: pointer; font: inherit; }
|
||||
.side button, .file { border: 0; background: transparent; color: #b91c1c; cursor: pointer; font: inherit; }
|
||||
.file { color: #0f766e; font-weight: 700; }
|
||||
.file input { display: none; }
|
||||
.err { color: #b91c1c; font-weight: 700; }
|
||||
.ok { color: #0f766e; font-weight: 700; }
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,7 @@ import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialAccountDto, OrgUserDto } from '@mento/shared';
|
||||
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
|
||||
@@ -90,10 +91,10 @@ onMounted(() => {
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="دوره"><UiInput v-model="form.period" placeholder="۱۴۰۵-۰۷" /></UiField>
|
||||
<UiField label="پایه"><UiInput v-model="form.baseAmount" /></UiField>
|
||||
<UiField label="مزایا"><UiInput v-model="form.benefits" /></UiField>
|
||||
<UiField label="پاداش"><UiInput v-model="form.bonus" /></UiField>
|
||||
<UiField label="کسورات"><UiInput v-model="form.deductions" /></UiField>
|
||||
<UiField label="پایه"><MoneyInput v-model="form.baseAmount" :decimals="0" /></UiField>
|
||||
<UiField label="مزایا"><MoneyInput v-model="form.benefits" :decimals="0" /></UiField>
|
||||
<UiField label="پاداش"><MoneyInput v-model="form.bonus" :decimals="0" /></UiField>
|
||||
<UiField label="کسورات"><MoneyInput v-model="form.deductions" :decimals="0" /></UiField>
|
||||
</div>
|
||||
<UiButton @click="createRow">ثبت حقوق</UiButton>
|
||||
</UiCard>
|
||||
@@ -109,7 +110,7 @@ onMounted(() => {
|
||||
</article>
|
||||
<UiCard v-if="pay.id" title="پرداخت حقوق">
|
||||
<div class="grid">
|
||||
<UiField label="مبلغ"><UiInput v-model="pay.amount" /></UiField>
|
||||
<UiField label="مبلغ"><MoneyInput v-model="pay.amount" :decimals="0" /></UiField>
|
||||
<UiField label="حساب">
|
||||
<select v-model="pay.accountId" class="select">
|
||||
<option v-for="account in accounts" :key="account.id" :value="account.id">{{ account.name }}</option>
|
||||
|
||||
@@ -4,6 +4,7 @@ import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialAccountDto, FinancialReceivableDto } from '@mento/shared';
|
||||
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
import { formatJalaliNumeric } from '@/lib/jalali';
|
||||
@@ -121,8 +122,8 @@ onMounted(() => {
|
||||
<div class="grid">
|
||||
<UiField label="عنوان"><UiInput v-model="form.title" /></UiField>
|
||||
<UiField label="طرف حساب"><UiInput v-model="form.partyName" /></UiField>
|
||||
<UiField label="مبلغ کل"><UiInput v-model="form.totalAmount" /></UiField>
|
||||
<UiField label="پیشپرداخت"><UiInput v-model="form.prepaidAmount" /></UiField>
|
||||
<UiField label="مبلغ کل"><MoneyInput v-model="form.totalAmount" :decimals="0" /></UiField>
|
||||
<UiField label="پیشپرداخت"><MoneyInput v-model="form.prepaidAmount" :decimals="0" /></UiField>
|
||||
<UiField label="حساب پیشپرداخت">
|
||||
<select v-model="form.accountId" class="select">
|
||||
<option value="">انتخاب</option>
|
||||
@@ -141,7 +142,7 @@ onMounted(() => {
|
||||
<label class="check"><input v-model="custom" type="checkbox" /> اقساط نامساوی</label>
|
||||
<div v-if="custom" class="custom">
|
||||
<div v-for="(line, index) in customLines" :key="index" class="grid">
|
||||
<UiField label="مبلغ قسط"><UiInput v-model="line.amount" /></UiField>
|
||||
<UiField label="مبلغ قسط"><MoneyInput v-model="line.amount" :decimals="0" /></UiField>
|
||||
<UiField label="سررسید"><UiInput v-model="line.dueDate" type="date" /></UiField>
|
||||
</div>
|
||||
<UiButton variant="secondary" @click="customLines.push({ amount: '', dueDate: '' })">قسط دیگر</UiButton>
|
||||
@@ -191,7 +192,7 @@ onMounted(() => {
|
||||
|
||||
<UiCard v-if="collect.installmentId" title="دریافت قسط">
|
||||
<div class="grid">
|
||||
<UiField label="مبلغ"><UiInput v-model="collect.amount" /></UiField>
|
||||
<UiField label="مبلغ"><MoneyInput v-model="collect.amount" :decimals="0" /></UiField>
|
||||
<UiField label="حساب مقصد">
|
||||
<select v-model="collect.accountId" class="select">
|
||||
<option v-for="account in accounts" :key="account.id" :value="account.id">{{ account.name }}</option>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
import { formatJalaliNumeric } from '@/lib/jalali';
|
||||
@@ -51,7 +52,7 @@ onMounted(() => {
|
||||
<option value="income">درآمد</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="مبلغ"><UiInput v-model="form.amount" /></UiField>
|
||||
<UiField label="مبلغ"><MoneyInput v-model="form.amount" :decimals="0" /></UiField>
|
||||
<UiField label="دوره">
|
||||
<select v-model="form.interval" class="select">
|
||||
<option value="monthly">ماهانه</option>
|
||||
|
||||
Reference in New Issue
Block a user