Add salesperson commissions on issued invoices and a cheque register.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-11 01:09:59 +03:30
co-authored by Cursor
parent 535c428270
commit 9a991fc1f1
22 changed files with 1238 additions and 11 deletions
@@ -0,0 +1,75 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "commissionPercent" DECIMAL(8,2) NOT NULL DEFAULT 0;
-- AlterTable
ALTER TABLE "Invoice" ADD COLUMN "commissionPercent" DECIMAL(8,2),
ADD COLUMN "commissionFixedAmount" DECIMAL(18,2);
-- CreateTable
CREATE TABLE "CommissionEntry" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"invoiceId" TEXT NOT NULL,
"percent" DECIMAL(8,2) NOT NULL,
"amount" DECIMAL(18,2) NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'IRR',
"status" TEXT NOT NULL DEFAULT 'posted',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CommissionEntry_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Cheque" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"paymentId" TEXT NOT NULL,
"invoiceId" TEXT NOT NULL,
"number" TEXT NOT NULL,
"bank" TEXT NOT NULL,
"dueDate" TIMESTAMP(3) NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Cheque_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "CommissionEntry_invoiceId_key" ON "CommissionEntry"("invoiceId");
-- CreateIndex
CREATE INDEX "CommissionEntry_organizationId_userId_createdAt_idx" ON "CommissionEntry"("organizationId", "userId", "createdAt");
-- CreateIndex
CREATE INDEX "CommissionEntry_status_idx" ON "CommissionEntry"("status");
-- CreateIndex
CREATE UNIQUE INDEX "Cheque_paymentId_key" ON "Cheque"("paymentId");
-- CreateIndex
CREATE INDEX "Cheque_organizationId_status_dueDate_idx" ON "Cheque"("organizationId", "status", "dueDate");
-- CreateIndex
CREATE INDEX "Cheque_invoiceId_idx" ON "Cheque"("invoiceId");
-- AddForeignKey
ALTER TABLE "CommissionEntry" ADD CONSTRAINT "CommissionEntry_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CommissionEntry" ADD CONSTRAINT "CommissionEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CommissionEntry" ADD CONSTRAINT "CommissionEntry_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "Invoice"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Cheque" ADD CONSTRAINT "Cheque_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Cheque" ADD CONSTRAINT "Cheque_paymentId_fkey" FOREIGN KEY ("paymentId") REFERENCES "Payment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Cheque" ADD CONSTRAINT "Cheque_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "Invoice"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+52
View File
@@ -183,6 +183,8 @@ model Organization {
warehouses Warehouse[]
stockMovements StockMovement[]
partyOpenings PartyOpeningBalance[]
commissionEntries CommissionEntry[]
cheques Cheque[]
}
model User {
@@ -209,6 +211,8 @@ model User {
noteAttachments NoteAttachment[] @relation("NoteAttachmentUploader")
notifications Notification[]
salesGoals SalesGoal[] @relation("SalesGoalUser")
commissionPercent Decimal @default(0) @db.Decimal(8, 2)
commissionEntries CommissionEntry[]
assignedTickets Ticket[] @relation("TicketAssignee")
reportedTickets Ticket[] @relation("TicketReporter")
ticketMessages TicketMessage[] @relation("TicketMessageAuthor")
@@ -731,6 +735,10 @@ model Invoice {
payments Payment[]
installments InvoiceInstallment[]
stockMovements StockMovement[]
commissionPercent Decimal? @db.Decimal(8, 2)
commissionFixedAmount Decimal? @db.Decimal(18, 2)
commissionEntry CommissionEntry?
cheques Cheque[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -772,6 +780,7 @@ model Payment {
notes String?
installmentId String? @unique
installment InvoiceInstallment? @relation(fields: [installmentId], references: [id], onDelete: SetNull)
cheque Cheque?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -1254,6 +1263,49 @@ model StockMovement {
@@index([warehouseId])
}
/// پورسانت کارشناس بعد از صدور فاکتور
model CommissionEntry {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
invoiceId String @unique
invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade)
percent Decimal @db.Decimal(8, 2)
amount Decimal @db.Decimal(18, 2)
currency String @default("IRR")
/// posted | void
status String @default("posted")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId, userId, createdAt])
@@index([status])
}
/// چک دریافتی وصل به پرداخت فاکتور
model Cheque {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
paymentId String @unique
payment Payment @relation(fields: [paymentId], references: [id], onDelete: Cascade)
invoiceId String
invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade)
number String
bank String
dueDate DateTime
/// pending | cleared | bounced
status String @default("pending")
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId, status, dueDate])
@@index([invoiceId])
}
/// مانده اول دوره طرف‌حساب (مثبت = بدهکار ما)
model PartyOpeningBalance {
id String @id @default(cuid())
+2
View File
@@ -28,6 +28,7 @@ import { AiModule } from './ai/ai.module';
import { MemoryModule } from './memory/memory.module';
import { TahesabModule } from './integrations/tahesab/tahesab.module';
import { InventoryModule } from './inventory/inventory.module';
import { FinanceModule } from './finance/finance.module';
@Module({
imports: [
@@ -53,6 +54,7 @@ import { InventoryModule } from './inventory/inventory.module';
QuotesModule,
InvoicesModule,
InventoryModule,
FinanceModule,
SalesModule,
TicketsModule,
FaqModule,
+120
View File
@@ -0,0 +1,120 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { ChequeDto, UpdateChequeRequest } from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { type Actor, ownerWhere } from '../common/record-scope';
const CHEQUE_STATUSES = ['pending', 'cleared', 'bounced'] as const;
@Injectable()
export class ChequesService {
constructor(private readonly prisma: PrismaService) {}
async list(
organizationId: string,
actor: Pick<Actor, 'userId' | 'role'>,
opts?: { status?: string; q?: string },
): Promise<ChequeDto[]> {
const rows = await this.prisma.cheque.findMany({
where: {
organizationId,
invoice: ownerWhere(actor),
...(opts?.status ? { status: opts.status } : {}),
...(opts?.q?.trim()
? {
OR: [
{ number: { contains: opts.q.trim(), mode: 'insensitive' } },
{ bank: { contains: opts.q.trim(), mode: 'insensitive' } },
{
invoice: {
number: { contains: opts.q.trim(), mode: 'insensitive' },
},
},
],
}
: {}),
},
include: this.include,
orderBy: [{ dueDate: 'asc' }, { createdAt: 'desc' }],
take: 300,
});
return rows.map((row) => this.toDto(row));
}
async update(
organizationId: string,
id: string,
actor: Pick<Actor, 'userId' | 'role'>,
dto: UpdateChequeRequest,
): Promise<ChequeDto> {
const existing = await this.prisma.cheque.findFirst({
where: { id, organizationId, invoice: ownerWhere(actor) },
select: { id: true },
});
if (!existing) throw new NotFoundException('چک پیدا نشد');
const data: Prisma.ChequeUncheckedUpdateInput = {};
if (dto.number !== undefined) data.number = dto.number.trim();
if (dto.bank !== undefined) data.bank = dto.bank.trim();
if (dto.dueDate !== undefined) data.dueDate = new Date(dto.dueDate);
if (dto.status !== undefined) {
if (!CHEQUE_STATUSES.includes(dto.status as (typeof CHEQUE_STATUSES)[number])) {
throw new BadRequestException('وضعیت چک معتبر نیست');
}
data.status = dto.status;
}
if ('notes' in dto) {
data.notes = dto.notes?.trim() ? dto.notes.trim() : null;
}
const row = await this.prisma.cheque.update({
where: { id },
data,
include: this.include,
});
return this.toDto(row);
}
private include = {
payment: { select: { id: true, amount: true, currency: true, paidAt: true } },
invoice: {
select: {
id: true,
number: true,
title: true,
person: { select: { firstName: true, lastName: true } },
company: { select: { name: true } },
},
},
} satisfies Prisma.ChequeInclude;
private toDto(
row: Prisma.ChequeGetPayload<{ include: ChequesService['include'] }>,
): ChequeDto {
const personName = row.invoice.person
? `${row.invoice.person.firstName} ${row.invoice.person.lastName}`.trim()
: row.invoice.company?.name ?? null;
return {
id: row.id,
paymentId: row.paymentId,
invoiceId: row.invoiceId,
invoiceNumber: row.invoice.number,
invoiceTitle: row.invoice.title,
personName,
amount: Number(row.payment.amount),
currency: row.payment.currency,
number: row.number,
bank: row.bank,
dueDate: row.dueDate.toISOString(),
status: row.status,
notes: row.notes,
paidAt: row.payment.paidAt.toISOString(),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
+150
View File
@@ -0,0 +1,150 @@
import { Injectable } from '@nestjs/common';
import type { CommissionEntryDto } from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope';
function roundMoney(value: number): number {
return Math.round((value + Number.EPSILON) * 100) / 100;
}
@Injectable()
export class CommissionsService {
constructor(private readonly prisma: PrismaService) {}
async list(
organizationId: string,
actor: Pick<Actor, 'userId' | 'role'>,
): Promise<CommissionEntryDto[]> {
const rows = await this.prisma.commissionEntry.findMany({
where: {
organizationId,
...this.userScope(actor),
},
include: {
user: { select: { id: true, fullName: true } },
invoice: { select: { id: true, number: true, title: true, ownerId: true } },
},
orderBy: { createdAt: 'desc' },
take: 200,
});
return rows
.filter((row) => this.canSeeInvoice(actor, row.invoice.ownerId))
.map((row) => this.toDto(row));
}
async syncInvoice(organizationId: string, invoiceId: string) {
const invoice = await this.prisma.invoice.findFirst({
where: { id: invoiceId, organizationId },
include: {
owner: { select: { id: true, commissionPercent: true } },
lineItems: true,
commissionEntry: true,
},
});
if (!invoice) return;
const shouldPost =
invoice.kind !== 'credit' &&
invoice.status !== 'draft' &&
invoice.status !== 'canceled' &&
Boolean(invoice.ownerId);
if (!shouldPost) {
if (invoice.commissionEntry) {
await this.prisma.commissionEntry.update({
where: { id: invoice.commissionEntry.id },
data: { status: 'void', amount: 0 },
});
}
return;
}
const subtotal = invoice.lineItems.reduce(
(sum, item) => sum + Number(item.quantity) * Number(item.unitPrice),
0,
);
const discountAmount = Number(invoice.discountAmount ?? 0);
const taxPercent = Number(invoice.taxPercent ?? 0);
const taxAmount = roundMoney(
Math.max(0, subtotal - discountAmount) * (taxPercent / 100),
);
const payable = Math.abs(
roundMoney(subtotal - discountAmount + taxAmount),
);
const fixed = invoice.commissionFixedAmount;
const overridePercent = invoice.commissionPercent;
const defaultPercent = Number(invoice.owner?.commissionPercent ?? 0);
const percent =
fixed != null
? payable > 0
? roundMoney((Number(fixed) / payable) * 100)
: 0
: Number(overridePercent ?? defaultPercent);
const amount =
fixed != null ? roundMoney(Number(fixed)) : roundMoney((payable * percent) / 100);
await this.prisma.commissionEntry.upsert({
where: { invoiceId: invoice.id },
create: {
organizationId,
userId: invoice.ownerId!,
invoiceId: invoice.id,
percent: new Prisma.Decimal(percent),
amount: new Prisma.Decimal(amount),
currency: invoice.currency,
status: 'posted',
},
update: {
userId: invoice.ownerId!,
percent: new Prisma.Decimal(percent),
amount: new Prisma.Decimal(amount),
currency: invoice.currency,
status: 'posted',
},
});
}
private userScope(
actor: Pick<Actor, 'userId' | 'role'>,
): Prisma.CommissionEntryWhereInput {
if (actor.role === 'owner' || actor.role === 'admin') return {};
return { userId: actor.userId };
}
private canSeeInvoice(
actor: Pick<Actor, 'userId' | 'role'>,
ownerId: string | null,
) {
if (actor.role === 'owner' || actor.role === 'admin') return true;
return ownerId === actor.userId;
}
private toDto(row: {
id: string;
userId: string;
invoiceId: string;
percent: Prisma.Decimal;
amount: Prisma.Decimal;
currency: string;
status: string;
createdAt: Date;
user: { fullName: string };
invoice: { number: string; title: string };
}): CommissionEntryDto {
return {
id: row.id,
userId: row.userId,
userName: row.user.fullName,
invoiceId: row.invoiceId,
invoiceNumber: row.invoice.number,
invoiceTitle: row.invoice.title,
percent: Number(row.percent),
amount: Number(row.amount),
currency: row.currency,
status: row.status,
createdAt: row.createdAt.toISOString(),
};
}
}
+28
View File
@@ -0,0 +1,28 @@
import { IsIn, IsOptional, IsString, MaxLength } from 'class-validator';
const CHEQUE_STATUSES = ['pending', 'cleared', 'bounced'] as const;
export class UpdateChequeDto {
@IsOptional()
@IsString()
@MaxLength(80)
number?: string;
@IsOptional()
@IsString()
@MaxLength(120)
bank?: string;
@IsOptional()
@IsString()
dueDate?: string;
@IsOptional()
@IsIn(CHEQUE_STATUSES)
status?: (typeof CHEQUE_STATUSES)[number];
@IsOptional()
@IsString()
@MaxLength(2000)
notes?: string | null;
}
@@ -0,0 +1,40 @@
import { Body, Controller, Get, Param, Patch, Query, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { UpdateChequeDto } from './dto';
import { ChequesService } from './cheques.service';
import { CommissionsService } from './commissions.service';
type AuthUser = { organizationId: string; userId: string; role: string };
@Controller()
@UseGuards(JwtAuthGuard)
export class FinanceController {
constructor(
private readonly cheques: ChequesService,
private readonly commissions: CommissionsService,
) {}
@Get('cheques')
listCheques(
@CurrentUser() user: AuthUser,
@Query('status') status?: string,
@Query('q') q?: string,
) {
return this.cheques.list(user.organizationId, user, { status, q });
}
@Patch('cheques/:id')
updateCheque(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() dto: UpdateChequeDto,
) {
return this.cheques.update(user.organizationId, id, user, dto);
}
@Get('commissions')
listCommissions(@CurrentUser() user: AuthUser) {
return this.commissions.list(user.organizationId, user);
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { ChequesService } from './cheques.service';
import { CommissionsService } from './commissions.service';
import { FinanceController } from './finance.controller';
@Module({
imports: [AuthModule],
controllers: [FinanceController],
providers: [ChequesService, CommissionsService],
exports: [ChequesService, CommissionsService],
})
export class FinanceModule {}
+53
View File
@@ -151,6 +151,18 @@ export class CreateInvoiceDto {
@ValidateNested({ each: true })
@Type(() => InvoiceInstallmentInputDto)
installments?: InvoiceInstallmentInputDto[];
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
commissionPercent?: number | null;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
commissionFixedAmount?: number | null;
}
export class UpdateInvoiceDto {
@@ -225,6 +237,42 @@ export class UpdateInvoiceDto {
@ValidateNested({ each: true })
@Type(() => InvoiceInstallmentInputDto)
installments?: InvoiceInstallmentInputDto[];
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
commissionPercent?: number | null;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
commissionFixedAmount?: number | null;
}
export class ChequeInputDto {
@IsString()
@MinLength(1)
@MaxLength(80)
number!: string;
@IsString()
@MinLength(1)
@MaxLength(120)
bank!: string;
@IsString()
dueDate!: string;
@IsOptional()
@IsIn(['pending', 'cleared', 'bounced'])
status?: 'pending' | 'cleared' | 'bounced';
@IsOptional()
@IsString()
@MaxLength(2000)
notes?: string;
}
export class CreatePaymentDto {
@@ -254,6 +302,11 @@ export class CreatePaymentDto {
@IsOptional()
@IsString()
installmentId?: string | null;
@IsOptional()
@ValidateNested()
@Type(() => ChequeInputDto)
cheque?: ChequeInputDto;
}
export class ListInvoicesQueryDto {
+2 -1
View File
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { AutomationsModule } from '../automations/automations.module';
import { FinanceModule } from '../finance/finance.module';
import { InventoryModule } from '../inventory/inventory.module';
import { InvoicesController } from './invoices.controller';
import { InvoicesService } from './invoices.service';
@Module({
imports: [AutomationsModule, InventoryModule],
imports: [AutomationsModule, InventoryModule, FinanceModule],
controllers: [InvoicesController],
providers: [InvoicesService],
exports: [InvoicesService],
+101 -6
View File
@@ -18,6 +18,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { type Actor, ownerWhere } from '../common/record-scope';
import { AutomationsService } from '../automations/automations.service';
import { InventoryService } from '../inventory/inventory.service';
import { CommissionsService } from '../finance/commissions.service';
type InvoiceActor = Pick<Actor, 'userId' | 'role'>;
@@ -28,7 +29,11 @@ const include = {
quote: { select: { id: true, number: true, title: true } },
owner: { select: { id: true, fullName: true } },
lineItems: { orderBy: { position: 'asc' as const } },
payments: { orderBy: { paidAt: 'desc' as const } },
payments: {
orderBy: { paidAt: 'desc' as const },
include: { cheque: true },
},
commissionEntry: true,
installments: {
orderBy: { position: 'asc' as const },
include: { payment: { select: { id: true } } },
@@ -43,6 +48,7 @@ export class InvoicesService {
private readonly prisma: PrismaService,
private readonly automations: AutomationsService,
private readonly inventory: InventoryService,
private readonly commissions: CommissionsService,
) {}
async list(
@@ -249,6 +255,14 @@ export class InvoicesService {
dealId,
quoteId,
ownerId: userId,
commissionPercent:
dto.commissionPercent != null
? new Prisma.Decimal(dto.commissionPercent)
: null,
commissionFixedAmount:
dto.commissionFixedAmount != null
? new Prisma.Decimal(dto.commissionFixedAmount)
: null,
...(lineItems.length
? {
lineItems: {
@@ -270,8 +284,13 @@ export class InvoicesService {
},
include,
});
const created = this.toDto(row);
await this.inventory.syncInvoiceStock(organizationId, created.id);
await this.inventory.syncInvoiceStock(organizationId, row.id);
await this.commissions.syncInvoice(organizationId, row.id);
const fresh = await this.prisma.invoice.findFirst({
where: { id: row.id },
include,
});
const created = this.toDto(fresh ?? row);
if (created.status !== 'draft' && created.status !== 'canceled') {
await this.fireIssued(organizationId, created);
}
@@ -334,6 +353,18 @@ export class InvoicesService {
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
if ('quoteId' in dto) data.quoteId = emptyToNull(dto.quoteId ?? null);
if ('commissionPercent' in dto) {
data.commissionPercent =
dto.commissionPercent == null
? null
: new Prisma.Decimal(dto.commissionPercent);
}
if ('commissionFixedAmount' in dto) {
data.commissionFixedAmount =
dto.commissionFixedAmount == null
? null
: new Prisma.Decimal(dto.commissionFixedAmount);
}
if (
dto.status &&
@@ -387,6 +418,7 @@ export class InvoicesService {
const row = await this.get(organizationId, id, actor);
await this.inventory.syncInvoiceStock(organizationId, row.id);
await this.commissions.syncInvoice(organizationId, row.id);
const leftDraft =
existing.status === 'draft' &&
row.status !== 'draft' &&
@@ -414,13 +446,23 @@ export class InvoicesService {
): Promise<InvoiceDto> {
const invoice = await this.prisma.invoice.findFirst({
where: { id: invoiceId, organizationId, ...ownerWhere(actor) },
select: { id: true, status: true, currency: true },
select: { id: true, status: true, currency: true, organizationId: true },
});
if (!invoice) throw new NotFoundException('فاکتور پیدا نشد');
if (invoice.status === 'canceled') {
throw new BadRequestException('روی فاکتور لغوشده نمی‌توان پرداخت ثبت کرد');
}
const method = (dto.method as string) || 'transfer';
if (method === 'check') {
const cheque = dto.cheque;
if (!cheque?.number?.trim() || !cheque.bank?.trim() || !cheque.dueDate) {
throw new BadRequestException(
'برای پرداخت چکی شماره، بانک و تاریخ سررسید لازم است',
);
}
}
const installmentId = emptyToNull(dto.installmentId ?? null);
if (installmentId) {
const installment = await this.prisma.invoiceInstallment.findFirst({
@@ -434,17 +476,33 @@ export class InvoicesService {
}
await this.prisma.$transaction(async (tx) => {
await tx.payment.create({
const payment = await tx.payment.create({
data: {
invoiceId,
amount: new Prisma.Decimal(dto.amount),
currency: (dto.currency ?? invoice.currency).trim() || 'IRR',
method: (dto.method as never) || 'transfer',
method: method as never,
paidAt: dto.paidAt ? new Date(dto.paidAt) : new Date(),
notes: emptyToNull(dto.notes ?? null),
installmentId,
},
});
if (method === 'check' && dto.cheque) {
await tx.cheque.create({
data: {
organizationId: invoice.organizationId,
paymentId: payment.id,
invoiceId,
number: dto.cheque.number.trim(),
bank: dto.cheque.bank.trim(),
dueDate: new Date(dto.cheque.dueDate),
status: dto.cheque.status === 'cleared' || dto.cheque.status === 'bounced'
? dto.cheque.status
: 'pending',
notes: emptyToNull(dto.cheque.notes ?? null),
},
});
}
if (installmentId) {
await tx.invoiceInstallment.update({
where: { id: installmentId },
@@ -465,6 +523,7 @@ export class InvoicesService {
await this.recomputeStatus(invoiceId);
const row = await this.get(organizationId, invoiceId, actor);
await this.inventory.syncInvoiceStock(organizationId, row.id);
await this.commissions.syncInvoice(organizationId, row.id);
if (invoice.status === 'draft') {
await this.fireIssued(organizationId, row);
}
@@ -504,6 +563,7 @@ export class InvoicesService {
});
await this.recomputeStatus(invoiceId);
await this.commissions.syncInvoice(organizationId, invoiceId);
return this.get(organizationId, invoiceId, actor);
}
@@ -698,6 +758,28 @@ export class InvoicesService {
paidAt: p.paidAt.toISOString(),
notes: p.notes,
installmentId: p.installmentId,
cheque: p.cheque
? {
id: p.cheque.id,
paymentId: p.id,
invoiceId: row.id,
invoiceNumber: row.number,
invoiceTitle: row.title,
personName: row.person
? `${row.person.firstName} ${row.person.lastName}`.trim()
: row.company?.name ?? null,
amount: Number(p.amount),
currency: p.currency,
number: p.cheque.number,
bank: p.cheque.bank,
dueDate: p.cheque.dueDate.toISOString(),
status: p.cheque.status,
notes: p.cheque.notes,
paidAt: p.paidAt.toISOString(),
createdAt: p.cheque.createdAt.toISOString(),
updatedAt: p.cheque.updatedAt.toISOString(),
}
: null,
createdAt: p.createdAt.toISOString(),
}));
const paidAmount = payments.reduce((sum, p) => sum + p.amount, 0);
@@ -743,6 +825,19 @@ export class InvoicesService {
lineItems,
payments,
installments,
commissionPercent:
row.commissionPercent == null ? null : Number(row.commissionPercent),
commissionFixedAmount:
row.commissionFixedAmount == null
? null
: Number(row.commissionFixedAmount),
commission: row.commissionEntry
? {
percent: Number(row.commissionEntry.percent),
amount: Number(row.commissionEntry.amount),
status: row.commissionEntry.status,
}
: null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
+12
View File
@@ -1,8 +1,12 @@
import { Type } from 'class-transformer';
import {
IsEmail,
IsIn,
IsNumber,
IsString,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';
@@ -31,3 +35,11 @@ export class UpdateUserRoleDto {
@IsIn(ALL_ROLES)
role!: (typeof ALL_ROLES)[number];
}
export class UpdateUserCommissionDto {
@Type(() => Number)
@IsNumber()
@Min(0)
@Max(100)
commissionPercent!: number;
}
+16 -1
View File
@@ -12,7 +12,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { Roles } from '../auth/roles.decorator';
import { RolesGuard } from '../auth/roles.guard';
import { InviteUserDto, UpdateUserRoleDto } from './dto';
import { InviteUserDto, UpdateUserCommissionDto, UpdateUserRoleDto } from './dto';
import { UsersService } from './users.service';
@Controller('users')
@@ -34,6 +34,21 @@ export class UsersController {
return this.usersService.invite(user.organizationId, user.role, dto);
}
@Patch(':id/commission')
@Roles('owner', 'admin')
updateCommission(
@CurrentUser() user: { organizationId: string; role: UserRole },
@Param('id') id: string,
@Body() dto: UpdateUserCommissionDto,
) {
return this.usersService.updateCommission(
user.organizationId,
user.role,
id,
dto.commissionPercent,
);
}
@Patch(':id/role')
@Roles('owner', 'admin')
updateRole(
+26
View File
@@ -7,6 +7,7 @@ import {
} from '@nestjs/common';
import type { InviteUserRequest, OrgUserDto, UserRole } from '@mento/shared';
import * as bcrypt from 'bcryptjs';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
@@ -56,6 +57,29 @@ export class UsersService {
return this.toDto(row);
}
async updateCommission(
organizationId: string,
actorRole: UserRole,
targetId: string,
percent: number,
): Promise<OrgUserDto> {
if (actorRole !== 'owner' && actorRole !== 'admin') {
throw new ForbiddenException('فقط مالک یا مدیر می‌تواند پورسانت را تعیین کند');
}
if (percent < 0 || percent > 100) {
throw new BadRequestException('درصد پورسانت باید بین ۰ تا ۱۰۰ باشد');
}
const target = await this.prisma.user.findFirst({
where: { id: targetId, organizationId },
});
if (!target) throw new NotFoundException('کاربر پیدا نشد');
const row = await this.prisma.user.update({
where: { id: target.id },
data: { commissionPercent: new Prisma.Decimal(percent) },
});
return this.toDto(row);
}
async updateRole(
organizationId: string,
actor: { userId: string; role: UserRole },
@@ -100,6 +124,7 @@ export class UsersService {
email: string;
fullName: string;
role: UserRole;
commissionPercent?: { toString(): string } | number;
createdAt: Date;
}): OrgUserDto {
return {
@@ -107,6 +132,7 @@ export class UsersService {
email: row.email,
fullName: row.fullName,
role: row.role,
commissionPercent: Number(row.commissionPercent ?? 0),
createdAt: row.createdAt.toISOString(),
};
}
+7 -1
View File
@@ -36,7 +36,7 @@ const settingsOpen = ref(false);
const globalSearch = ref('');
const activityBadge = ref(0);
const salesPaths = ['/quotes', '/invoices', '/inventory', '/sales/forecast', '/reports', '/notes', '/tickets'];
const salesPaths = ['/quotes', '/invoices', '/inventory', '/cheques', '/commissions', '/sales/forecast', '/reports', '/notes', '/tickets'];
const morePaths = ['/activities/calendar', '/projects', '/project-cards', '/loyalty'];
const settingsPaths = [
'/settings/team',
@@ -64,6 +64,8 @@ const extraActive = computed(() =>
'/quotes',
'/invoices',
'/inventory',
'/cheques',
'/commissions',
'/sales/forecast',
'/notes',
'/tickets',
@@ -392,6 +394,8 @@ onUnmounted(() => {
@click="closeMenu"
>فاکتورها</RouterLink>
<RouterLink to="/inventory" @click="closeMenu">انبار</RouterLink>
<RouterLink to="/cheques" @click="closeMenu">چک‌ها</RouterLink>
<RouterLink to="/commissions" @click="closeMenu">پورسانت</RouterLink>
<RouterLink to="/sales/forecast" @click="closeMenu">هدف و پیش‌بینی</RouterLink>
<RouterLink to="/notes" @click="closeMenu">یادداشت‌ها</RouterLink>
<RouterLink to="/tickets" @click="closeMenu">تیکت‌ها</RouterLink>
@@ -480,6 +484,8 @@ onUnmounted(() => {
@click="closeMenu"
>فاکتورها</RouterLink>
<RouterLink to="/inventory" @click="closeMenu">انبار</RouterLink>
<RouterLink to="/cheques" @click="closeMenu">چک‌ها</RouterLink>
<RouterLink to="/commissions" @click="closeMenu">پورسانت</RouterLink>
<RouterLink to="/sales/forecast" @click="closeMenu">هدف و پیش‌بینی</RouterLink>
<RouterLink to="/notes" @click="closeMenu">یادداشت‌ها</RouterLink>
<RouterLink to="/tickets" @click="closeMenu">تیکت‌ها</RouterLink>
+10
View File
@@ -128,6 +128,16 @@ export const router = createRouter({
name: 'inventory',
component: () => import('@/views/InventoryView.vue'),
},
{
path: 'cheques',
name: 'cheques',
component: () => import('@/views/ChequesView.vue'),
},
{
path: 'commissions',
name: 'commissions',
component: () => import('@/views/CommissionsView.vue'),
},
{
path: 'pipeline',
name: 'pipeline',
+152
View File
@@ -0,0 +1,152 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { RouterLink } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { formatMoney } from '@/lib/money';
import { formatJalaliNumeric } from '@/lib/jalali';
import UiButton from '@/components/ui/UiButton.vue';
import type { ChequeDto, ChequeStatus } from '@mento/shared';
const loading = ref(false);
const error = ref('');
const items = ref<ChequeDto[]>([]);
const status = ref('');
const q = ref('');
const savingId = ref('');
const statusLabel: Record<ChequeStatus, string> = {
pending: 'در جریان',
cleared: 'وصول شد',
bounced: 'برگشت',
};
const filtered = computed(() => items.value);
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<ChequeDto[]>('/cheques', {
params: { status: status.value || undefined, q: q.value || undefined },
});
items.value = data;
} catch (e) {
error.value = axios.isAxiosError(e)
? 'بارگذاری چک‌ها ناموفق بود.'
: 'بارگذاری چک‌ها ناموفق بود.';
} finally {
loading.value = false;
}
}
async function setStatus(row: ChequeDto, next: ChequeStatus) {
if (row.status === next) return;
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 = '';
}
}
onMounted(load);
</script>
<template>
<section class="page" dir="rtl">
<header class="head">
<div>
<h1>چک‌ها</h1>
<p class="sub">دفتر چک‌های دریافتی وصل به فاکتور؛ وضعیت فقط برای پیگیری است</p>
</div>
<UiButton variant="ghost" type="button" :disabled="loading" @click="load">
بروزرسانی
</UiButton>
</header>
<div class="toolbar">
<input v-model="q" type="search" placeholder="شماره چک، بانک یا فاکتور" @keyup.enter="load" />
<select v-model="status" @change="load">
<option value="">همه وضعیت‌ها</option>
<option value="pending">در جریان</option>
<option value="cleared">وصول شد</option>
<option value="bounced">برگشت</option>
</select>
<UiButton type="button" @click="load">اعمال</UiButton>
</div>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading && !items.length" class="muted">در حال بارگذاری…</p>
<p v-else-if="!filtered.length" class="muted">چکی ثبت نشده.</p>
<div v-else class="table-wrap">
<table>
<thead>
<tr>
<th>شماره</th>
<th>بانک</th>
<th>سررسید</th>
<th>مبلغ</th>
<th>فاکتور</th>
<th>طرف‌حساب</th>
<th>وضعیت</th>
</tr>
</thead>
<tbody>
<tr v-for="row in filtered" :key="row.id">
<td>{{ row.number }}</td>
<td>{{ row.bank }}</td>
<td>{{ formatJalaliNumeric(new Date(row.dueDate)) }}</td>
<td>{{ formatMoney(row.amount, row.currency) }}</td>
<td>
<RouterLink :to="{ name: 'invoice-detail', params: { id: row.invoiceId } }">
{{ row.invoiceNumber }}
</RouterLink>
</td>
<td>{{ row.personName || '—' }}</td>
<td>
<select
:value="row.status"
:disabled="savingId === row.id"
@change="setStatus(row, ($event.target as HTMLSelectElement).value as ChequeStatus)"
>
<option value="pending">{{ statusLabel.pending }}</option>
<option value="cleared">{{ statusLabel.cleared }}</option>
<option value="bounced">{{ statusLabel.bounced }}</option>
</select>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</template>
<style scoped>
.page { display: grid; gap: 1rem; }
.head { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; }
h1 { margin: 0; font-size: 1.25rem; }
.sub, .muted { margin: 0.25rem 0 0; color: var(--muted); font-size: 0.88rem; }
.error { color: var(--danger, #dc2626); font-weight: 600; }
.toolbar {
display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center;
}
.toolbar input, .toolbar select {
min-height: var(--control-h); border: 1px solid var(--line); border-radius: var(--radius);
padding: 0 0.75rem; background: #fff;
}
.table-wrap { overflow: auto; background: #fff; border: 1px solid var(--line); border-radius: var(--radius); }
table { width: 100%; border-collapse: collapse; min-width: 720px; }
th, td { padding: 0.7rem 0.65rem; border-bottom: 1px solid #eef2f7; text-align: right; }
th { background: #f8fafc; color: #64748b; font-size: 0.82rem; }
select {
min-height: var(--control-h); border: 1px solid var(--line); border-radius: var(--radius);
padding: 0 0.55rem; background: #fff;
}
a { color: #0f766e; font-weight: 700; }
</style>
+104
View File
@@ -0,0 +1,104 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { RouterLink } from 'vue-router';
import { api } from '@/lib/api';
import { formatMoney } from '@/lib/money';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import UiButton from '@/components/ui/UiButton.vue';
import type { CommissionEntryDto } from '@mento/shared';
const loading = ref(false);
const error = ref('');
const items = ref<CommissionEntryDto[]>([]);
const posted = computed(() => items.value.filter((r) => r.status === 'posted'));
const total = computed(() => posted.value.reduce((sum, r) => sum + r.amount, 0));
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<CommissionEntryDto[]>('/commissions');
items.value = data;
} catch {
error.value = 'بارگذاری پورسانت‌ها ناموفق بود.';
} finally {
loading.value = false;
}
}
onMounted(load);
</script>
<template>
<section class="page" dir="rtl">
<header class="head">
<div>
<h1>پورسانت فروش</h1>
<p class="sub">با صدور هر فاکتور برای کارشناس مالک فاکتور ثبت می‌شود</p>
</div>
<UiButton variant="ghost" type="button" :disabled="loading" @click="load">
بروزرسانی
</UiButton>
</header>
<article class="kpi">
<span>جمع پورسانت ثبت‌شده</span>
<strong>{{ formatMoney(total) }}</strong>
<small>{{ toFaDigits(String(posted.length)) }} فاکتور</small>
</article>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading && !items.length" class="muted">در حال بارگذاری…</p>
<p v-else-if="!items.length" class="muted">هنوز پورسانتی ثبت نشده.</p>
<div v-else class="table-wrap">
<table>
<thead>
<tr>
<th>کارشناس</th>
<th>فاکتور</th>
<th>درصد</th>
<th>مبلغ</th>
<th>وضعیت</th>
<th>تاریخ</th>
</tr>
</thead>
<tbody>
<tr v-for="row in items" :key="row.id">
<td>{{ row.userName }}</td>
<td>
<RouterLink :to="{ name: 'invoice-detail', params: { id: row.invoiceId } }">
{{ row.invoiceNumber }}
</RouterLink>
<small>{{ row.invoiceTitle }}</small>
</td>
<td>{{ toFaDigits(String(row.percent)) }}٪</td>
<td>{{ formatMoney(row.amount, row.currency) }}</td>
<td>{{ row.status === 'posted' ? 'ثبت‌شده' : 'باطل' }}</td>
<td>{{ formatJalaliNumeric(new Date(row.createdAt)) }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</template>
<style scoped>
.page { display: grid; gap: 1rem; }
.head { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; }
h1 { margin: 0; font-size: 1.25rem; }
.sub, .muted { margin: 0.25rem 0 0; color: var(--muted); font-size: 0.88rem; }
.error { color: var(--danger, #dc2626); font-weight: 600; }
.kpi {
background: #fff; border: 1px solid var(--line); border-radius: var(--radius);
padding: 1rem 1.1rem; width: fit-content; min-width: 16rem;
}
.kpi span, .kpi small { display: block; color: var(--muted); font-size: 0.8rem; }
.kpi strong { display: block; margin: 0.25rem 0; font-size: 1.15rem; }
.table-wrap { overflow: auto; background: #fff; border: 1px solid var(--line); border-radius: var(--radius); }
table { width: 100%; border-collapse: collapse; min-width: 720px; }
th, td { padding: 0.7rem 0.65rem; border-bottom: 1px solid #eef2f7; text-align: right; vertical-align: top; }
th { background: #f8fafc; color: #64748b; font-size: 0.82rem; }
td small { display: block; color: var(--muted); font-size: 0.78rem; }
a { color: #0f766e; font-weight: 700; }
</style>
+153
View File
@@ -14,6 +14,7 @@ import MoneyInput from '@/components/MoneyInput.vue';
import UiIcon from '@/components/UiIcon.vue';
import BrandName from '@/components/brand/BrandName.vue';
import { brand } from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
import type {
CreateInvoiceRequest,
CreatePaymentRequest,
@@ -24,6 +25,7 @@ import type {
InvoiceInstallmentInput,
InvoiceStatus,
OrganizationDto,
OrgUserDto,
PaginatedResponse,
PaymentMethod,
PersonDto,
@@ -125,6 +127,15 @@ const payMethod = ref<PaymentMethod>('transfer');
const payNotes = ref('');
const payInstallmentId = ref('');
const payDate = ref(formatLocalDateInput(new Date()));
const chequeNumber = ref('');
const chequeBank = ref('');
const chequeDue = ref('');
const chequeStatus = ref<'pending' | 'cleared' | 'bounced'>('pending');
const commissionMode = ref<'percent' | 'amount'>('percent');
const commissionPercent = ref('');
const commissionFixedAmount = ref('');
const defaultCommissionPercent = ref(0);
const auth = useAuthStore();
const invoiceStatusLabel: Record<InvoiceStatus, string> = {
draft: 'پیش‌نویس',
@@ -249,6 +260,16 @@ const payableAmount = computed(() =>
docMode.value === 'credit' ? -payableSigned.value : payableSigned.value,
);
const commissionPreview = computed(() => {
const base = Math.abs(payableAmount.value);
if (commissionMode.value === 'amount') {
const n = Number(commissionFixedAmount.value);
return Number.isFinite(n) ? n : 0;
}
const n = Number(commissionPercent.value || defaultCommissionPercent.value);
return Number.isFinite(n) ? Math.round((base * n) / 100) : 0;
});
function formatMoney(amount: number) {
return formatMoneyLib(amount, currency.value);
}
@@ -461,6 +482,9 @@ async function load() {
loadedInvoiceKind.value = null;
installments.value = [];
lines.value = [];
commissionMode.value = 'percent';
commissionPercent.value = '';
commissionFixedAmount.value = '';
validityMode.value = 'days';
validityDays.value = 30;
@@ -473,6 +497,16 @@ async function load() {
]);
people.value = peopleRes.data.items;
products.value = productsRes.data;
try {
const { data: team } = await api.get<OrgUserDto[]>('/users');
const me = team.find((u) => u.id === auth.user?.id);
defaultCommissionPercent.value = Number(me?.commissionPercent ?? 0);
if (!commissionPercent.value && defaultCommissionPercent.value) {
commissionPercent.value = String(defaultCommissionPercent.value);
}
} catch {
/* optional */
}
enabledCurrencies.value = orgRes.data.enabledCurrencies?.length
? orgRes.data.enabledCurrencies
: [orgRes.data.baseCurrency || 'IRR'];
@@ -525,6 +559,18 @@ async function load() {
if (data.remainingAmount > 0) {
payAmount.value = String(data.remainingAmount);
}
if (data.commissionFixedAmount != null) {
commissionMode.value = 'amount';
commissionFixedAmount.value = String(data.commissionFixedAmount);
commissionPercent.value = String(
data.commissionPercent ?? defaultCommissionPercent.value,
);
} else {
commissionMode.value = 'percent';
commissionPercent.value = String(
data.commissionPercent ?? defaultCommissionPercent.value,
);
}
}
if (!lines.value.length) lines.value = [newLine()];
} else {
@@ -570,6 +616,18 @@ function invoicePayload(): CreateInvoiceRequest {
lineItems: toLineInputs(),
installments: toInstallmentInputs(),
currency: currency.value || 'IRR',
commissionPercent:
commissionMode.value === 'percent'
? commissionPercent.value === ''
? null
: Number(commissionPercent.value)
: null,
commissionFixedAmount:
commissionMode.value === 'amount'
? commissionFixedAmount.value === ''
? null
: Number(commissionFixedAmount.value)
: null,
};
}
@@ -671,6 +729,10 @@ async function submitPayment() {
error.value = 'مبلغ پرداخت را وارد کنید.';
return;
}
if (payMethod.value === 'check' && (!chequeNumber.value.trim() || !chequeBank.value.trim() || !chequeDue.value)) {
error.value = 'شماره، بانک و سررسید چک را وارد کنید.';
return;
}
paying.value = true;
error.value = '';
try {
@@ -680,6 +742,15 @@ async function submitPayment() {
notes: payNotes.value || undefined,
paidAt: payDate.value ? new Date(payDate.value).toISOString() : undefined,
installmentId: payInstallmentId.value || null,
cheque:
payMethod.value === 'check'
? {
number: chequeNumber.value.trim(),
bank: chequeBank.value.trim(),
dueDate: new Date(chequeDue.value).toISOString(),
status: chequeStatus.value,
}
: undefined,
};
const { data } = await api.post<InvoiceDto>(
`/invoices/${id.value}/payments`,
@@ -688,6 +759,10 @@ async function submitPayment() {
invoice.value = data;
payNotes.value = '';
payInstallmentId.value = '';
chequeNumber.value = '';
chequeBank.value = '';
chequeDue.value = '';
chequeStatus.value = 'pending';
await load();
} catch (e) {
error.value = axiosMessage(e, 'ثبت پرداخت ناموفق بود');
@@ -1128,6 +1203,45 @@ onMounted(load);
<span>مبلغ قابل پرداخت</span>
<strong>{{ formatMoney(payableAmount) }}</strong>
</div>
<div v-if="docMode === 'invoice'" class="commission-box">
<div class="days-row">
<button
type="button"
:class="{ on: commissionMode === 'percent' }"
@click="commissionMode = 'percent'"
>
درصد
</button>
<button
type="button"
:class="{ on: commissionMode === 'amount' }"
@click="commissionMode = 'amount'"
>
مبلغ ثابت
</button>
</div>
<label v-if="commissionMode === 'percent'" class="field">
<span>پورسانت کارشناس (%)</span>
<input
v-model="commissionPercent"
type="number"
min="0"
max="100"
step="0.1"
:placeholder="String(defaultCommissionPercent || 0)"
/>
</label>
<label v-else class="field">
<span>مبلغ پورسانت</span>
<MoneyInput v-model="commissionFixedAmount" :decimals="0" />
</label>
<p class="muted-sm">
برآورد پورسانت: {{ formatMoney(commissionPreview) }}
<template v-if="invoice?.commission?.status === 'posted'">
· ثبت‌شده {{ formatMoney(invoice.commission.amount) }}
</template>
</p>
</div>
</div>
</section>
@@ -1210,6 +1324,16 @@ onMounted(load);
</option>
</select>
<input v-model="payNotes" placeholder="یادداشت پرداخت" />
<div v-if="payMethod === 'check'" class="cheque-fields">
<input v-model="chequeNumber" placeholder="شماره چک" />
<input v-model="chequeBank" placeholder="بانک" />
<JalaliDateInput v-model="chequeDue" placeholder="سررسید چک" />
<select v-model="chequeStatus">
<option value="pending">در جریان</option>
<option value="cleared">وصول شد</option>
<option value="bounced">برگشت</option>
</select>
</div>
<button
class="btn primary"
type="button"
@@ -1230,6 +1354,9 @@ onMounted(load);
<strong>{{ formatMoney(p.amount) }}</strong>
<span class="muted">
· {{ methodLabel[p.method as PaymentMethod] || p.method }}
<template v-if="p.cheque">
· چک {{ p.cheque.number }} / {{ p.cheque.bank }}
</template>
· {{ p.paidAt.slice(0, 10) }}
</span>
</div>
@@ -1547,6 +1674,19 @@ onMounted(load);
<span>مبلغ قابل پرداخت</span>
<strong>{{ formatMoney(payableAmount) }}</strong>
</div>
<div v-if="docMode === 'invoice'" class="commission-box">
<label class="field">
<span>پورسانت کارشناس (%)</span>
<input
v-model="commissionPercent"
type="number"
min="0"
max="100"
step="0.1"
/>
</label>
<p class="muted-sm">برآورد: {{ formatMoney(commissionPreview) }}</p>
</div>
</div>
</section>
@@ -2366,6 +2506,19 @@ h1 {
gap: 0.5rem;
}
.cheque-fields,
.commission-box {
grid-column: 1 / -1;
display: grid;
gap: 0.45rem;
}
.commission-box {
margin-top: 0.45rem;
padding-top: 0.55rem;
border-top: 1px dashed var(--line);
}
.pay-list {
display: grid;
gap: 0.45rem;
+49 -1
View File
@@ -131,6 +131,24 @@ async function invite() {
}
}
async function changeCommission(user: OrgUserDto, raw: string) {
if (!canInvite.value) return;
const next = Number(raw);
if (!Number.isFinite(next) || next === Number(user.commissionPercent ?? 0)) return;
error.value = '';
try {
await api.patch(`/users/${user.id}/commission`, { commissionPercent: next });
await load();
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ذخیره پورسانت ناموفق بود');
} else {
error.value = 'ذخیره پورسانت ناموفق بود';
}
}
}
async function changeRole(user: OrgUserDto, next: string) {
const nextRole = next as UserRole;
if (nextRole === user.role) return;
@@ -157,7 +175,7 @@ onMounted(load);
<div class="head-copy">
<h1>تیم و نقش‌ها</h1>
<p class="lead">
زمینه فعالیت سازمان و کاربران را اینجا مدیریت کنید. تنظیمات سازمانی فقط برای مالک و مدیر است.
زمینه فعالیت سازمان، نقش‌ها و درصد پیش‌فرض پورسانت هر کارشناس را اینجا مدیریت کنید.
</p>
</div>
</header>
@@ -224,6 +242,18 @@ onMounted(load);
<p class="muted">{{ user.email }}</p>
</div>
<div class="role-cell">
<label class="comm-field">
<span>پورسانت %</span>
<input
type="number"
min="0"
max="100"
step="0.1"
:value="user.commissionPercent ?? 0"
:disabled="!canInvite"
@change="changeCommission(user, ($event.target as HTMLInputElement).value)"
/>
</label>
<template v-if="canInvite">
<select
:value="user.role"
@@ -373,6 +403,24 @@ select {
margin: 0.2rem 0 0;
}
.role-cell {
display: flex;
align-items: center;
gap: 0.55rem;
}
.comm-field {
display: grid;
gap: 0.15rem;
font-size: 0.75rem;
color: #64748b;
}
.comm-field input {
width: 4.8rem;
padding: 0 0.45rem;
}
.role-pill {
background: #f0fdfa;
color: #0f766e;
+4 -1
View File
@@ -1,7 +1,7 @@
# وضعیت پیشرفت منتکت (Montact CRM)
سند زندهٔ کارها — با هر قابلیت جدید همین فایل به‌روز می‌شود.
آخرین به‌روزرسانی: ۱۹ شهریور ۱۴۰۵
آخرین به‌روزرسانی: ۲۰ شهریور ۱۴۰۵
**راهنما:**
- `[x]` انجام شده
@@ -14,6 +14,7 @@
| تاریخ | مورد |
|---|---|
| ۱۴۰۵/۶/۲۰ | پورسانت کارشناس (درصد پیش‌فرض + تغییر روی فاکتور) و دفتر چک وصل به پرداخت |
| ۱۴۰۵/۶/۱۹ | انبار داخلی + مانده طرف‌حساب وقتی ته‌حساب وصل نیست؛ خروج کالا با صدور فاکتور |
| ۱۴۰۵/۶/۱۴ | هم‌تراز کردن ظاهر صفحات قدیمی با زبان بصری منتکت (لیست/فرم/تنظیمات/گزارش) + جداکننده هزارگان روی فیلدهای مبلغ |
| ۱۴۰۵/۶/۱۴ | یادآور فعالیت: کانال اعلان داخل‌برنامه و/یا پیامک |
@@ -97,6 +98,8 @@
| [x] | باشگاه مشتریان | امتیاز و سطوح |
| [x] | اتصال ته‌حساب | چند اتصال DB، لینک شخص، مانده، اسناد، ثبت سند (طلا/جواهر) |
| [x] | انبار و مانده داخلی | وقتی ته‌حساب نیست: موجودی کالا، ورود/اصلاح، خروج با صدور فاکتور، مانده شخص |
| [x] | پورسانت فروش | درصد پیش‌فرض کارشناس در تنظیمات تیم؛ درصد یا مبلغ روی فاکتور؛ ثبت با صدور |
| [x] | دفتر چک | شماره، بانک، سررسید، وضعیت؛ وصل به پرداخت فاکتور؛ وصول فاکتور همان لحظه ثبت |
---
+69
View File
@@ -42,9 +42,14 @@ export interface OrgUserDto {
email: string;
fullName: string;
role: UserRole;
commissionPercent?: number;
createdAt: string;
}
export interface UpdateUserCommissionRequest {
commissionPercent: number;
}
export interface InviteUserRequest {
email: string;
fullName: string;
@@ -1131,6 +1136,43 @@ export interface InvoiceLineItemDto {
lineTotal: number;
}
export type ChequeStatus = "pending" | "cleared" | "bounced";
export interface ChequeDto {
id: string;
paymentId: string;
invoiceId: string;
invoiceNumber: string;
invoiceTitle: string;
personName: string | null;
amount: number;
currency: string;
number: string;
bank: string;
dueDate: string;
status: ChequeStatus | string;
notes: string | null;
paidAt: string;
createdAt: string;
updatedAt: string;
}
export interface ChequeInput {
number: string;
bank: string;
dueDate: string;
status?: ChequeStatus | string;
notes?: string;
}
export interface UpdateChequeRequest {
number?: string;
bank?: string;
dueDate?: string;
status?: ChequeStatus | string;
notes?: string | null;
}
export interface PaymentDto {
id: string;
amount: number;
@@ -1139,6 +1181,21 @@ export interface PaymentDto {
paidAt: string;
notes: string | null;
installmentId: string | null;
cheque?: ChequeDto | null;
createdAt: string;
}
export interface CommissionEntryDto {
id: string;
userId: string;
userName: string;
invoiceId: string;
invoiceNumber: string;
invoiceTitle: string;
percent: number;
amount: number;
currency: string;
status: "posted" | "void" | string;
createdAt: string;
}
@@ -1179,6 +1236,13 @@ export interface InvoiceDto {
lineItems: InvoiceLineItemDto[];
payments: PaymentDto[];
installments: InvoiceInstallmentDto[];
commissionPercent?: number | null;
commissionFixedAmount?: number | null;
commission?: {
percent: number;
amount: number;
status: string;
} | null;
createdAt: string;
updatedAt: string;
}
@@ -1207,6 +1271,8 @@ export interface CreateInvoiceRequest {
fromQuoteId?: string | null;
lineItems?: DealLineItemInput[];
installments?: InvoiceInstallmentInput[];
commissionPercent?: number | null;
commissionFixedAmount?: number | null;
}
export interface UpdateInvoiceRequest {
@@ -1225,6 +1291,8 @@ export interface UpdateInvoiceRequest {
quoteId?: string | null;
lineItems?: DealLineItemInput[];
installments?: InvoiceInstallmentInput[];
commissionPercent?: number | null;
commissionFixedAmount?: number | null;
}
export interface CreatePaymentRequest {
@@ -1234,6 +1302,7 @@ export interface CreatePaymentRequest {
paidAt?: string;
notes?: string;
installmentId?: string | null;
cheque?: ChequeInput;
}
export type SalesGoalType =