Unify invoices and quotes UI with discount, tax, and credit notes.

Match list/form mockups on desktop and mobile, wire real APIs for billing fields, and merge proforma into the invoices section.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-05 17:53:50 +03:30
co-authored by Cursor
parent 7862b6f374
commit 2ece79b3d6
15 changed files with 3101 additions and 1995 deletions
+50
View File
@@ -66,6 +66,28 @@ export class CreateQuoteDto {
@MaxLength(4000)
notes?: string;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
discountAmount?: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
taxPercent?: number;
@IsOptional()
@IsString()
issuedAt?: string | null;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
validityDays?: number;
@IsOptional()
@IsString()
validUntil?: string | null;
@@ -115,6 +137,22 @@ export class UpdateQuoteDto {
@MaxLength(4000)
notes?: string | null;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
discountAmount?: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
taxPercent?: number;
@IsOptional()
@IsString()
issuedAt?: string | null;
@IsOptional()
@IsString()
validUntil?: string | null;
@@ -155,6 +193,18 @@ export class ListQuotesQueryDto {
@IsString()
personId?: string;
@IsOptional()
@IsString()
companyId?: string;
@IsOptional()
@IsString()
from?: string;
@IsOptional()
@IsString()
to?: string;
@IsOptional()
@Type(() => Number)
@IsNumber()
+3
View File
@@ -35,6 +35,9 @@ export class QuotesController {
status: query.status,
dealId: query.dealId,
personId: query.personId,
companyId: query.companyId,
from: query.from,
to: query.to,
page: query.page ?? 1,
limit: query.limit ?? 50,
});
+66 -3
View File
@@ -38,18 +38,24 @@ export class QuotesService {
status?: string;
dealId?: string;
personId?: string;
companyId?: string;
from?: string;
to?: string;
page?: number;
limit?: number;
},
): Promise<PaginatedResponse<QuoteDto>> {
const page = opts.page ?? 1;
const limit = opts.limit ?? 50;
const dateFilter = buildDateRangeFilter(opts.from, opts.to);
const where: Prisma.QuoteWhereInput = {
organizationId,
...ownerWhere(actor),
...(opts.status ? { status: opts.status as never } : {}),
...(opts.dealId ? { dealId: opts.dealId } : {}),
...(opts.personId ? { personId: opts.personId } : {}),
...(opts.companyId ? { companyId: opts.companyId } : {}),
...(dateFilter ? dateFilter : {}),
...(opts.q?.trim()
? {
OR: [
@@ -137,6 +143,19 @@ export class QuotesService {
await this.assertProducts(organizationId, lineItems);
}
const issuedAt = dto.issuedAt ? new Date(dto.issuedAt) : null;
let validUntil = dto.validUntil ? new Date(dto.validUntil) : null;
if (
dto.validityDays != null &&
Number.isFinite(dto.validityDays) &&
dto.validityDays > 0 &&
!validUntil
) {
const base = issuedAt ?? new Date();
validUntil = new Date(base);
validUntil.setDate(validUntil.getDate() + Math.floor(dto.validityDays));
}
const number = await this.nextNumber(organizationId);
const row = await this.prisma.quote.create({
data: {
@@ -146,7 +165,10 @@ export class QuotesService {
status: (dto.status as never) || 'draft',
currency,
notes,
validUntil: dto.validUntil ? new Date(dto.validUntil) : null,
discountAmount: new Prisma.Decimal(dto.discountAmount ?? 0),
taxPercent: new Prisma.Decimal(dto.taxPercent ?? 0),
issuedAt,
validUntil,
personId,
companyId,
dealId,
@@ -201,6 +223,15 @@ export class QuotesService {
data.currency = dto.currency.trim() || 'IRR';
}
if ('notes' in dto) data.notes = emptyToNull(dto.notes ?? null);
if (dto.discountAmount !== undefined) {
data.discountAmount = new Prisma.Decimal(dto.discountAmount);
}
if (dto.taxPercent !== undefined) {
data.taxPercent = new Prisma.Decimal(dto.taxPercent);
}
if ('issuedAt' in dto) {
data.issuedAt = dto.issuedAt ? new Date(dto.issuedAt) : null;
}
if ('validUntil' in dto) {
data.validUntil = dto.validUntil ? new Date(dto.validUntil) : null;
}
@@ -334,7 +365,13 @@ export class QuotesService {
lineTotal: quantity * unitPrice,
};
});
const totalAmount = lineItems.reduce((sum, item) => sum + item.lineTotal, 0);
const subtotal = lineItems.reduce((sum, item) => sum + item.lineTotal, 0);
const discountAmount = Number(row.discountAmount ?? 0);
const taxPercent = Number(row.taxPercent ?? 0);
const taxAmount = roundMoney(
Math.max(0, subtotal - discountAmount) * (taxPercent / 100),
);
const payableAmount = roundMoney(subtotal - discountAmount + taxAmount);
return {
id: row.id,
number: row.number,
@@ -342,8 +379,14 @@ export class QuotesService {
status: row.status,
currency: row.currency,
notes: row.notes,
discountAmount,
taxPercent,
issuedAt: row.issuedAt?.toISOString() ?? null,
validUntil: row.validUntil?.toISOString() ?? null,
totalAmount,
totalAmount: subtotal,
subtotal,
taxAmount,
payableAmount,
person: row.person
? {
id: row.person.id,
@@ -365,3 +408,23 @@ function emptyToNull(value?: string | null): string | null {
const trimmed = value.trim();
return trimmed.length ? trimmed : null;
}
function roundMoney(value: number): number {
return Math.round((value + Number.EPSILON) * 100) / 100;
}
function buildDateRangeFilter(
from?: string,
to?: string,
): Prisma.QuoteWhereInput | null {
if (!from && !to) return null;
const range: Prisma.DateTimeFilter = {};
if (from) range.gte = new Date(from);
if (to) range.lte = new Date(to);
return {
OR: [
{ issuedAt: range },
{ AND: [{ issuedAt: null }, { createdAt: range }] },
],
};
}