From 2ece79b3d69212375179ad1f44ae3feb2f0a0193 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sat, 5 Sep 2026 17:53:50 +0330 Subject: [PATCH] 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 --- .../migration.sql | 9 + apps/api/prisma/schema.prisma | 6 + apps/api/src/invoices/dto.ts | 60 + apps/api/src/invoices/invoices.controller.ts | 6 + apps/api/src/invoices/invoices.service.ts | 95 +- apps/api/src/quotes/dto.ts | 50 + apps/api/src/quotes/quotes.controller.ts | 3 + apps/api/src/quotes/quotes.service.ts | 69 +- apps/web/src/components/UiIcon.vue | 24 +- apps/web/src/layouts/AppLayout.vue | 7 +- apps/web/src/views/InvoiceFormView.vue | 2387 ++++++++++++++--- apps/web/src/views/InvoicesView.vue | 1271 +++++---- apps/web/src/views/QuoteFormView.vue | 509 +--- apps/web/src/views/QuotesView.vue | 575 +--- packages/shared/src/index.ts | 25 + 15 files changed, 3101 insertions(+), 1995 deletions(-) create mode 100644 apps/api/prisma/migrations/20260905160000_billing_discount_tax_kind/migration.sql diff --git a/apps/api/prisma/migrations/20260905160000_billing_discount_tax_kind/migration.sql b/apps/api/prisma/migrations/20260905160000_billing_discount_tax_kind/migration.sql new file mode 100644 index 0000000..3e6c1b2 --- /dev/null +++ b/apps/api/prisma/migrations/20260905160000_billing_discount_tax_kind/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable Quote +ALTER TABLE "Quote" ADD COLUMN IF NOT EXISTS "discountAmount" DECIMAL(18,2) NOT NULL DEFAULT 0; +ALTER TABLE "Quote" ADD COLUMN IF NOT EXISTS "taxPercent" DECIMAL(8,2) NOT NULL DEFAULT 0; +ALTER TABLE "Quote" ADD COLUMN IF NOT EXISTS "issuedAt" TIMESTAMP(3); + +-- AlterTable Invoice +ALTER TABLE "Invoice" ADD COLUMN IF NOT EXISTS "kind" TEXT NOT NULL DEFAULT 'invoice'; +ALTER TABLE "Invoice" ADD COLUMN IF NOT EXISTS "discountAmount" DECIMAL(18,2) NOT NULL DEFAULT 0; +ALTER TABLE "Invoice" ADD COLUMN IF NOT EXISTS "taxPercent" DECIMAL(8,2) NOT NULL DEFAULT 0; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index cd04758..1779ba0 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -614,6 +614,9 @@ model Quote { status QuoteStatus @default(draft) currency String @default("IRR") notes String? + discountAmount Decimal @default(0) @db.Decimal(18, 2) + taxPercent Decimal @default(0) @db.Decimal(8, 2) + issuedAt DateTime? validUntil DateTime? personId String? person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) @@ -661,8 +664,11 @@ model Invoice { number String title String status InvoiceStatus @default(draft) + kind String @default("invoice") // invoice | credit currency String @default("IRR") notes String? + discountAmount Decimal @default(0) @db.Decimal(18, 2) + taxPercent Decimal @default(0) @db.Decimal(8, 2) issuedAt DateTime? dueDate DateTime? personId String? diff --git a/apps/api/src/invoices/dto.ts b/apps/api/src/invoices/dto.ts index 4aae033..862f0ad 100644 --- a/apps/api/src/invoices/dto.ts +++ b/apps/api/src/invoices/dto.ts @@ -20,6 +20,8 @@ const INVOICE_STATUSES = [ 'canceled', ] as const; +const INVOICE_KINDS = ['invoice', 'credit'] as const; + const PAYMENT_METHODS = [ 'cash', 'transfer', @@ -79,6 +81,10 @@ export class CreateInvoiceDto { @IsIn(INVOICE_STATUSES) status?: (typeof INVOICE_STATUSES)[number]; + @IsOptional() + @IsIn(INVOICE_KINDS) + kind?: (typeof INVOICE_KINDS)[number]; + @IsOptional() @IsString() @MaxLength(8) @@ -89,6 +95,18 @@ export class CreateInvoiceDto { @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; @@ -146,6 +164,10 @@ export class UpdateInvoiceDto { @IsIn(INVOICE_STATUSES) status?: (typeof INVOICE_STATUSES)[number]; + @IsOptional() + @IsIn(INVOICE_KINDS) + kind?: (typeof INVOICE_KINDS)[number]; + @IsOptional() @IsString() @MaxLength(8) @@ -156,6 +178,18 @@ export class UpdateInvoiceDto { @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; @@ -231,6 +265,10 @@ export class ListInvoicesQueryDto { @IsIn(INVOICE_STATUSES) status?: (typeof INVOICE_STATUSES)[number]; + @IsOptional() + @IsIn(INVOICE_KINDS) + kind?: (typeof INVOICE_KINDS)[number]; + @IsOptional() @IsString() dealId?: string; @@ -239,10 +277,32 @@ export class ListInvoicesQueryDto { @IsString() personId?: string; + @IsOptional() + @IsString() + companyId?: string; + @IsOptional() @IsString() quoteId?: string; + @IsOptional() + @IsString() + from?: string; + + @IsOptional() + @IsString() + to?: string; + + @IsOptional() + @Type(() => Number) + @IsNumber() + amountMin?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber() + amountMax?: number; + @IsOptional() @Type(() => Number) @IsNumber() diff --git a/apps/api/src/invoices/invoices.controller.ts b/apps/api/src/invoices/invoices.controller.ts index 8b2c19b..7fc32ce 100644 --- a/apps/api/src/invoices/invoices.controller.ts +++ b/apps/api/src/invoices/invoices.controller.ts @@ -34,9 +34,15 @@ export class InvoicesController { return this.invoicesService.list(user.organizationId, user, { q: query.q, status: query.status, + kind: query.kind, dealId: query.dealId, personId: query.personId, + companyId: query.companyId, quoteId: query.quoteId, + from: query.from, + to: query.to, + amountMin: query.amountMin, + amountMax: query.amountMax, page: query.page ?? 1, limit: query.limit ?? 50, }); diff --git a/apps/api/src/invoices/invoices.service.ts b/apps/api/src/invoices/invoices.service.ts index b9e22b6..8cb8be1 100644 --- a/apps/api/src/invoices/invoices.service.ts +++ b/apps/api/src/invoices/invoices.service.ts @@ -45,22 +45,32 @@ export class InvoicesService { opts: { q?: string; status?: string; + kind?: string; dealId?: string; personId?: string; + companyId?: string; quoteId?: string; + from?: string; + to?: string; + amountMin?: number; + amountMax?: number; page?: number; limit?: number; }, ): Promise> { const page = opts.page ?? 1; const limit = opts.limit ?? 50; + const dateFilter = buildDateRangeFilter(opts.from, opts.to); const where: Prisma.InvoiceWhereInput = { organizationId, ...ownerWhere(actor), ...(opts.status ? { status: opts.status as never } : {}), + ...(opts.kind ? { kind: opts.kind } : {}), ...(opts.dealId ? { dealId: opts.dealId } : {}), ...(opts.personId ? { personId: opts.personId } : {}), + ...(opts.companyId ? { companyId: opts.companyId } : {}), ...(opts.quoteId ? { quoteId: opts.quoteId } : {}), + ...(dateFilter ? dateFilter : {}), ...(opts.q?.trim() ? { OR: [ @@ -72,6 +82,28 @@ export class InvoicesService { : {}), }; + const hasAmountFilter = + opts.amountMin != null || opts.amountMax != null; + + if (hasAmountFilter) { + const rows = await this.prisma.invoice.findMany({ + where, + include, + orderBy: { updatedAt: 'desc' }, + }); + const filtered = rows + .map((row) => this.toDto(row)) + .filter((dto) => { + const amount = Math.abs(dto.payableAmount); + if (opts.amountMin != null && amount < opts.amountMin) return false; + if (opts.amountMax != null && amount > opts.amountMax) return false; + return true; + }); + const total = filtered.length; + const items = filtered.slice((page - 1) * limit, page * limit); + return { items, total, page, limit }; + } + const [rows, total] = await this.prisma.$transaction([ this.prisma.invoice.findMany({ where, @@ -191,6 +223,7 @@ export class InvoicesService { const number = await this.nextNumber(organizationId); const installments = dto.installments ?? []; + const kind = dto.kind === 'credit' ? 'credit' : 'invoice'; const row = await this.prisma.invoice.create({ data: { @@ -198,8 +231,11 @@ export class InvoicesService { number, title, status, + kind, currency, notes, + discountAmount: new Prisma.Decimal(dto.discountAmount ?? 0), + taxPercent: new Prisma.Decimal(dto.taxPercent ?? 0), issuedAt, dueDate, personId, @@ -268,6 +304,15 @@ export class InvoicesService { data.currency = dto.currency.trim() || 'IRR'; } if ('notes' in dto) data.notes = emptyToNull(dto.notes ?? null); + if (dto.kind !== undefined) { + data.kind = dto.kind === 'credit' ? 'credit' : 'invoice'; + } + 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; } @@ -439,10 +484,18 @@ export class InvoicesService { return; } - const total = invoice.lineItems.reduce( + 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 total = Math.abs( + roundMoney(subtotal - discountAmount + taxAmount), + ); const paid = invoice.payments.reduce( (sum, item) => sum + Number(item.amount), 0, @@ -580,7 +633,15 @@ export class InvoicesService { 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 signedPayable = roundMoney(subtotal - discountAmount + taxAmount); + const kind = row.kind === 'credit' ? 'credit' : 'invoice'; + const payableAmount = kind === 'credit' ? -signedPayable : signedPayable; const payments: PaymentDto[] = row.payments.map((p) => ({ id: p.id, amount: Number(p.amount), @@ -608,13 +669,19 @@ export class InvoicesService { number: row.number, title: row.title, status: row.status, + kind, currency: row.currency, notes: row.notes, + discountAmount, + taxPercent, issuedAt: row.issuedAt?.toISOString() ?? null, dueDate: row.dueDate?.toISOString() ?? null, - totalAmount, + totalAmount: subtotal, + subtotal, + taxAmount, + payableAmount, paidAmount, - remainingAmount: Math.max(0, totalAmount - paidAmount), + remainingAmount: Math.max(0, Math.abs(payableAmount) - paidAmount), person: row.person ? { id: row.person.id, @@ -639,3 +706,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.InvoiceWhereInput | 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 }] }, + ], + }; +} diff --git a/apps/api/src/quotes/dto.ts b/apps/api/src/quotes/dto.ts index f3236b3..fccd7de 100644 --- a/apps/api/src/quotes/dto.ts +++ b/apps/api/src/quotes/dto.ts @@ -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() diff --git a/apps/api/src/quotes/quotes.controller.ts b/apps/api/src/quotes/quotes.controller.ts index 9d21601..ad86f98 100644 --- a/apps/api/src/quotes/quotes.controller.ts +++ b/apps/api/src/quotes/quotes.controller.ts @@ -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, }); diff --git a/apps/api/src/quotes/quotes.service.ts b/apps/api/src/quotes/quotes.service.ts index 602eeed..56240e0 100644 --- a/apps/api/src/quotes/quotes.service.ts +++ b/apps/api/src/quotes/quotes.service.ts @@ -38,18 +38,24 @@ export class QuotesService { status?: string; dealId?: string; personId?: string; + companyId?: string; + from?: string; + to?: string; page?: number; limit?: number; }, ): Promise> { 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 }] }, + ], + }; +} diff --git a/apps/web/src/components/UiIcon.vue b/apps/web/src/components/UiIcon.vue index fdad504..e330c84 100644 --- a/apps/web/src/components/UiIcon.vue +++ b/apps/web/src/components/UiIcon.vue @@ -50,7 +50,10 @@ defineProps<{ | 'target' | 'pencil' | 'grid' - | 'list'; + | 'list' + | 'eye' + | 'truck' + | 'grip'; size?: number; }>(); @@ -289,6 +292,25 @@ defineProps<{ + + + diff --git a/apps/web/src/layouts/AppLayout.vue b/apps/web/src/layouts/AppLayout.vue index ef00e69..7556202 100644 --- a/apps/web/src/layouts/AppLayout.vue +++ b/apps/web/src/layouts/AppLayout.vue @@ -283,8 +283,11 @@ onUnmounted(() => {