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:
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 }] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user