Facturas de cliente

This commit is contained in:
David Arranz 2025-11-17 18:20:18 +01:00
parent ec80d62d8c
commit 89e22de2bd
171 changed files with 2704 additions and 5943 deletions

View File

@ -1,6 +1,7 @@
import { ApplicationError } from "../errors"; import { ApplicationError } from "../errors";
import { IPresenterRegistry, PresenterKey } from "./presenter-registry.interface";
import { IPresenter } from "./presenter.interface"; import type { IPresenter } from "./presenter.interface";
import type { IPresenterRegistry, PresenterKey } from "./presenter-registry.interface";
export class InMemoryPresenterRegistry implements IPresenterRegistry { export class InMemoryPresenterRegistry implements IPresenterRegistry {
private registry: Map<string, IPresenter<any, any>> = new Map(); private registry: Map<string, IPresenter<any, any>> = new Map();
@ -16,7 +17,7 @@ export class InMemoryPresenterRegistry implements IPresenterRegistry {
* 🔹 Construye la clave única para el registro. * 🔹 Construye la clave única para el registro.
*/ */
private _buildKey(key: PresenterKey): string { private _buildKey(key: PresenterKey): string {
const { resource, projection, format, version, locale } = key; const { resource, projection, format, version, locale } = this._normalizeKey(key);
return [ return [
resource.toLowerCase(), resource.toLowerCase(),
projection.toLowerCase(), projection.toLowerCase(),
@ -30,12 +31,12 @@ export class InMemoryPresenterRegistry implements IPresenterRegistry {
key: PresenterKey, key: PresenterKey,
presenter: IPresenter<TSource, TOutput> presenter: IPresenter<TSource, TOutput>
): void { ): void {
const exactKey = this._buildKey(this._normalizeKey(key)); const exactKey = this._buildKey(key);
this.registry.set(exactKey, presenter); this.registry.set(exactKey, presenter);
} }
getPresenter<TSource, TOutput>(key: PresenterKey): IPresenter<TSource, TOutput> { getPresenter<TSource, TOutput>(key: PresenterKey): IPresenter<TSource, TOutput> {
const exactKey = this._buildKey(this._normalizeKey(key)); const exactKey = this._buildKey(key);
// 1) Intentar clave exacta // 1) Intentar clave exacta
if (this.registry.has(exactKey)) { if (this.registry.has(exactKey)) {
@ -86,7 +87,9 @@ export class InMemoryPresenterRegistry implements IPresenterRegistry {
registerPresenters( registerPresenters(
presenters: Array<{ key: PresenterKey; presenter: IPresenter<any, any> }> presenters: Array<{ key: PresenterKey; presenter: IPresenter<any, any> }>
): this { ): this {
presenters.forEach(({ key, presenter }) => this._registerPresenter(key, presenter)); for (const { key, presenter } of presenters) {
this._registerPresenter(key, presenter);
}
return this; return this;
} }
} }

View File

@ -1,5 +1,6 @@
import { RequestHandler } from "express"; import type { RequestHandler } from "express";
import { z } from "zod/v4"; import type { z } from "zod/v4";
import { InternalApiError, ValidationApiError } from "../errors"; import { InternalApiError, ValidationApiError } from "../errors";
import { ExpressController } from "../express-controller"; import { ExpressController } from "../express-controller";

View File

@ -6,6 +6,7 @@ import {
ValidationError as SequelizeValidationError, ValidationError as SequelizeValidationError,
UniqueConstraintError, UniqueConstraintError,
} from "sequelize"; } from "sequelize";
import { DuplicateEntityError, EntityNotFoundError } from "../../domain"; import { DuplicateEntityError, EntityNotFoundError } from "../../domain";
import { InfrastructureRepositoryError } from "../errors/infrastructure-repository-error"; import { InfrastructureRepositoryError } from "../errors/infrastructure-repository-error";
import { InfrastructureUnavailableError } from "../errors/infrastructure-unavailable-error"; import { InfrastructureUnavailableError } from "../errors/infrastructure-unavailable-error";
@ -17,7 +18,7 @@ import { InfrastructureUnavailableError } from "../errors/infrastructure-unavail
* 👉 Este traductor pertenece a la infraestructura (persistencia) * 👉 Este traductor pertenece a la infraestructura (persistencia)
*/ */
export function translateSequelizeError(err: unknown): Error { export function translateSequelizeError(err: unknown): Error {
console.log(err); console.error(err);
// 1) Duplicados (índices únicos) // 1) Duplicados (índices únicos)
if (err instanceof UniqueConstraintError) { if (err instanceof UniqueConstraintError) {

View File

@ -1,3 +1,2 @@
export * from "./customer-invoice-items.full.presenter"; export * from "./issued-invoices";
export * from "./customer-invoice.full.presenter"; export * from "./proformas";
export * from "./recipient-invoice.full.representer";

View File

@ -0,0 +1,4 @@
export * from "./issued-invoice.full.presenter";
export * from "./issued-invoice-items.full.presenter";
export * from "./issued-invoice-recipient.full.presenter";
export * from "./issued-invoice-verifactu.full.presenter";

View File

@ -3,17 +3,17 @@ import type { GetIssuedInvoiceByIdResponseDTO } from "@erp/customer-invoices/com
import { toEmptyString } from "@repo/rdx-ddd"; import { toEmptyString } from "@repo/rdx-ddd";
import type { ArrayElement } from "@repo/rdx-utils"; import type { ArrayElement } from "@repo/rdx-utils";
import type { CustomerInvoiceItem, CustomerInvoiceItems } from "../../../domain"; import type { CustomerInvoiceItem, CustomerInvoiceItems } from "../../../../domain";
type GetCustomerInvoiceItemByInvoiceIdResponseDTO = ArrayElement< type GetIssuedInvoiceItemByInvoiceIdResponseDTO = ArrayElement<
GetIssuedInvoiceByIdResponseDTO["items"] GetIssuedInvoiceByIdResponseDTO["items"]
>; >;
export class CustomerInvoiceItemsFullPresenter extends Presenter { export class IssuedInvoiceItemsFullPresenter extends Presenter {
private _mapItem( private _mapItem(
invoiceItem: CustomerInvoiceItem, invoiceItem: CustomerInvoiceItem,
index: number index: number
): GetCustomerInvoiceItemByInvoiceIdResponseDTO { ): GetIssuedInvoiceItemByInvoiceIdResponseDTO {
const allAmounts = invoiceItem.getAllAmounts(); const allAmounts = invoiceItem.getAllAmounts();
return { return {

View File

@ -1,13 +1,13 @@
import { Presenter } from "@erp/core/api"; import { Presenter } from "@erp/core/api";
import { DomainValidationError, toEmptyString } from "@repo/rdx-ddd"; import { DomainValidationError, toEmptyString } from "@repo/rdx-ddd";
import type { GetIssuedInvoiceByIdResponseDTO } from "../../../../common/dto"; import type { GetIssuedInvoiceByIdResponseDTO } from "../../../../../common/dto";
import type { CustomerInvoice, InvoiceRecipient } from "../../../domain"; import type { CustomerInvoice, InvoiceRecipient } from "../../../../domain";
type GetRecipientInvoiceByInvoiceIdResponseDTO = GetIssuedInvoiceByIdResponseDTO["recipient"]; type GetIssuedInvoiceRecipientByIdResponseDTO = GetIssuedInvoiceByIdResponseDTO["recipient"];
export class RecipientInvoiceFullPresenter extends Presenter { export class IssuedInvoiceRecipientFullPresenter extends Presenter {
toOutput(invoice: CustomerInvoice): GetRecipientInvoiceByInvoiceIdResponseDTO { toOutput(invoice: CustomerInvoice): GetIssuedInvoiceRecipientByIdResponseDTO {
if (!invoice.recipient) { if (!invoice.recipient) {
throw DomainValidationError.requiredValue("recipient", { throw DomainValidationError.requiredValue("recipient", {
cause: invoice, cause: invoice,

View File

@ -0,0 +1,30 @@
import { Presenter } from "@erp/core/api";
import { DomainValidationError } from "@repo/rdx-ddd";
import type { GetIssuedInvoiceByIdResponseDTO } from "../../../../../common/dto";
import type { CustomerInvoice } from "../../../../domain";
type GetIssuedInvoiceVerifactuByIdResponseDTO = GetIssuedInvoiceByIdResponseDTO["verifactu"];
export class IssuedInvoiceVerifactuFullPresenter extends Presenter {
toOutput(invoice: CustomerInvoice): GetIssuedInvoiceVerifactuByIdResponseDTO {
if (!invoice.verifactu) {
throw DomainValidationError.requiredValue("verifactu", {
cause: invoice,
});
}
return invoice.verifactu.match(
(verifactu) => ({
id: verifactu.id.toString(),
...verifactu.toObjectString(),
}),
() => ({
id: "",
status: "",
url: "",
qr_code: "",
})
);
}
}

View File

@ -1,29 +1,36 @@
import { Presenter } from "@erp/core/api"; import { Presenter } from "@erp/core/api";
import { toEmptyString } from "@repo/rdx-ddd"; import { toEmptyString } from "@repo/rdx-ddd";
import type { GetIssuedInvoiceByIdResponseDTO } from "../../../../common/dto"; import type { GetIssuedInvoiceByIdResponseDTO } from "../../../../../common/dto";
import type { CustomerInvoice } from "../../../domain"; import type { CustomerInvoice } from "../../../../domain";
import type { CustomerInvoiceItemsFullPresenter } from "./customer-invoice-items.full.presenter"; import type { IssuedInvoiceItemsFullPresenter } from "./issued-invoice-items.full.presenter";
import type { RecipientInvoiceFullPresenter } from "./recipient-invoice.full.representer"; import type { IssuedInvoiceRecipientFullPresenter } from "./issued-invoice-recipient.full.presenter";
import type { IssuedInvoiceVerifactuFullPresenter } from "./issued-invoice-verifactu.full.presenter";
export class CustomerInvoiceFullPresenter extends Presenter< export class IssuedInvoiceFullPresenter extends Presenter<
CustomerInvoice, CustomerInvoice,
GetIssuedInvoiceByIdResponseDTO GetIssuedInvoiceByIdResponseDTO
> { > {
toOutput(invoice: CustomerInvoice): GetIssuedInvoiceByIdResponseDTO { toOutput(invoice: CustomerInvoice): GetIssuedInvoiceByIdResponseDTO {
const itemsPresenter = this.presenterRegistry.getPresenter({ const itemsPresenter = this.presenterRegistry.getPresenter({
resource: "customer-invoice-items", resource: "issued-invoice-items",
projection: "FULL", projection: "FULL",
}) as CustomerInvoiceItemsFullPresenter; }) as IssuedInvoiceItemsFullPresenter;
const recipientPresenter = this.presenterRegistry.getPresenter({ const recipientPresenter = this.presenterRegistry.getPresenter({
resource: "recipient-invoice", resource: "issued-invoice-recipient",
projection: "FULL", projection: "FULL",
}) as RecipientInvoiceFullPresenter; }) as IssuedInvoiceRecipientFullPresenter;
const verifactuPresenter = this.presenterRegistry.getPresenter({
resource: "issued-invoice-verifactu",
projection: "FULL",
}) as IssuedInvoiceVerifactuFullPresenter;
const recipient = recipientPresenter.toOutput(invoice); const recipient = recipientPresenter.toOutput(invoice);
const items = itemsPresenter.toOutput(invoice.items); const items = itemsPresenter.toOutput(invoice.items);
const verifactu = verifactuPresenter.toOutput(invoice);
const allAmounts = invoice.getAllAmounts(); const allAmounts = invoice.getAllAmounts();
const payment = invoice.paymentMethod.match( const payment = invoice.paymentMethod.match(
@ -81,10 +88,12 @@ export class CustomerInvoiceFullPresenter extends Presenter<
taxes_amount: allAmounts.taxesAmount.toObjectString(), taxes_amount: allAmounts.taxesAmount.toObjectString(),
total_amount: allAmounts.totalAmount.toObjectString(), total_amount: allAmounts.totalAmount.toObjectString(),
verifactu,
items, items,
metadata: { metadata: {
entity: "customer-invoices", entity: "issued-invoices",
link: "", link: "",
}, },
}; };

View File

@ -0,0 +1 @@
export * from "./proforma.full.presenter";

View File

@ -0,0 +1,53 @@
import { Presenter } from "@erp/core/api";
import type { GetProformaByIdResponseDTO } from "@erp/customer-invoices/common";
import { toEmptyString } from "@repo/rdx-ddd";
import type { ArrayElement } from "@repo/rdx-utils";
import type { CustomerInvoiceItem, CustomerInvoiceItems } from "../../../../domain";
type GetProformaItemByIdResponseDTO = ArrayElement<GetProformaByIdResponseDTO["items"]>;
export class ProformaItemsFullPresenter extends Presenter {
private _mapItem(
proformaItem: CustomerInvoiceItem,
index: number
): GetProformaItemByIdResponseDTO {
const allAmounts = proformaItem.getAllAmounts();
return {
id: proformaItem.id.toPrimitive(),
is_valued: String(proformaItem.isValued),
position: String(index),
description: toEmptyString(proformaItem.description, (value) => value.toPrimitive()),
quantity: proformaItem.quantity.match(
(quantity) => quantity.toObjectString(),
() => ({ value: "", scale: "" })
),
unit_amount: proformaItem.unitAmount.match(
(unitAmount) => unitAmount.toObjectString(),
() => ({ value: "", scale: "", currency_code: "" })
),
subtotal_amount: allAmounts.subtotalAmount.toObjectString(),
discount_percentage: proformaItem.discountPercentage.match(
(discountPercentage) => discountPercentage.toObjectString(),
() => ({ value: "", scale: "" })
),
discount_amount: allAmounts.discountAmount.toObjectString(),
taxable_amount: allAmounts.taxableAmount.toObjectString(),
tax_codes: proformaItem.taxes.getCodesToString().split(","),
taxes_amount: allAmounts.taxesAmount.toObjectString(),
total_amount: allAmounts.totalAmount.toObjectString(),
};
}
toOutput(proformaItems: CustomerInvoiceItems): GetProformaByIdResponseDTO["items"] {
return proformaItems.map(this._mapItem);
}
}

View File

@ -0,0 +1,46 @@
import { Presenter } from "@erp/core/api";
import { DomainValidationError, toEmptyString } from "@repo/rdx-ddd";
import type { GetIssuedInvoiceByIdResponseDTO as GetProformaByIdResponseDTO } from "../../../../../common/dto";
import type { CustomerInvoice, InvoiceRecipient } from "../../../../domain";
type GetProformaRecipientByIdResponseDTO = GetProformaByIdResponseDTO["recipient"];
export class ProformaRecipientFullPresenter extends Presenter {
toOutput(proforma: CustomerInvoice): GetProformaRecipientByIdResponseDTO {
if (!proforma.recipient) {
throw DomainValidationError.requiredValue("recipient", {
cause: proforma,
});
}
return proforma.recipient.match(
(recipient: InvoiceRecipient) => {
return {
id: proforma.customerId.toString(),
name: recipient.name.toString(),
tin: recipient.tin.toString(),
street: toEmptyString(recipient.street, (value) => value.toString()),
street2: toEmptyString(recipient.street2, (value) => value.toString()),
city: toEmptyString(recipient.city, (value) => value.toString()),
province: toEmptyString(recipient.province, (value) => value.toString()),
postal_code: toEmptyString(recipient.postalCode, (value) => value.toString()),
country: toEmptyString(recipient.country, (value) => value.toString()),
};
},
() => {
return {
id: "",
name: "",
tin: "",
street: "",
street2: "",
city: "",
province: "",
postal_code: "",
country: "",
};
}
);
}
}

View File

@ -0,0 +1,89 @@
import { Presenter } from "@erp/core/api";
import { toEmptyString } from "@repo/rdx-ddd";
import type { GetProformaByIdResponseDTO } from "../../../../../common/dto";
import type { CustomerInvoice } from "../../../../domain";
import type { ProformaItemsFullPresenter } from "./proforma-items.full.presenter";
import type { ProformaRecipientFullPresenter } from "./proforma-recipient.full.presenter";
export class ProformaFullPresenter extends Presenter<CustomerInvoice, GetProformaByIdResponseDTO> {
toOutput(proforma: CustomerInvoice): GetProformaByIdResponseDTO {
const itemsPresenter = this.presenterRegistry.getPresenter({
resource: "proforma-items",
projection: "FULL",
}) as ProformaItemsFullPresenter;
const recipientPresenter = this.presenterRegistry.getPresenter({
resource: "proforma-recipient",
projection: "FULL",
}) as ProformaRecipientFullPresenter;
const recipient = recipientPresenter.toOutput(proforma);
const items = itemsPresenter.toOutput(proforma.items);
const allAmounts = proforma.getAllAmounts();
const payment = proforma.paymentMethod.match(
(payment) => {
const { id, payment_description } = payment.toObjectString();
return {
payment_id: id,
payment_description,
};
},
() => undefined
);
const invoiceTaxes = proforma.getTaxes().map((taxItem) => {
return {
tax_code: taxItem.tax.code,
taxable_amount: taxItem.taxableAmount.toObjectString(),
taxes_amount: taxItem.taxesAmount.toObjectString(),
};
});
return {
id: proforma.id.toString(),
company_id: proforma.companyId.toString(),
is_proforma: proforma.isProforma ? "true" : "false",
invoice_number: proforma.invoiceNumber.toString(),
status: proforma.status.toPrimitive(),
series: toEmptyString(proforma.series, (value) => value.toString()),
invoice_date: proforma.invoiceDate.toDateString(),
operation_date: toEmptyString(proforma.operationDate, (value) => value.toDateString()),
reference: toEmptyString(proforma.reference, (value) => value.toString()),
description: toEmptyString(proforma.description, (value) => value.toString()),
notes: toEmptyString(proforma.notes, (value) => value.toString()),
language_code: proforma.languageCode.toString(),
currency_code: proforma.currencyCode.toString(),
customer_id: proforma.customerId.toString(),
recipient,
taxes: invoiceTaxes,
payment_method: payment,
subtotal_amount: allAmounts.subtotalAmount.toObjectString(),
items_discount_amount: allAmounts.itemDiscountAmount.toObjectString(),
discount_percentage: proforma.discountPercentage.toObjectString(),
discount_amount: allAmounts.headerDiscountAmount.toObjectString(),
taxable_amount: allAmounts.taxableAmount.toObjectString(),
taxes_amount: allAmounts.taxesAmount.toObjectString(),
total_amount: allAmounts.totalAmount.toObjectString(),
items,
metadata: {
entity: "proforma",
link: "",
},
};
}
}

View File

@ -1,2 +1,3 @@
export * from "./domain"; export * from "./domain";
export * from "./queries"; export * from "./queries";
export * from "./reports";

View File

@ -1,63 +0,0 @@
import { DateHelper, MoneyDTOHelper, PercentageDTOHelper } from "@erp/core";
import { Presenter } from "@erp/core/api";
import type { GetIssuedInvoiceByIdResponseDTO } from "../../../../common/dto";
export class CustomerInvoiceReportPresenter extends Presenter<
GetIssuedInvoiceByIdResponseDTO,
unknown
> {
private _formatPaymentMethodDTO(
paymentMethod?: GetIssuedInvoiceByIdResponseDTO["payment_method"]
) {
if (!paymentMethod) {
return "";
}
return paymentMethod.payment_description ?? "";
}
toOutput(invoiceDTO: GetIssuedInvoiceByIdResponseDTO) {
const itemsPresenter = this.presenterRegistry.getPresenter({
resource: "customer-invoice-items",
projection: "REPORT",
format: "JSON",
});
const taxesPresenter = this.presenterRegistry.getPresenter({
resource: "customer-invoice-taxes",
projection: "REPORT",
format: "JSON",
});
const locale = invoiceDTO.language_code;
const itemsDTO = itemsPresenter.toOutput(invoiceDTO.items, {
locale,
});
const taxesDTO = taxesPresenter.toOutput(invoiceDTO.taxes, {
locale,
});
const moneyOptions = {
hideZeros: true,
minimumFractionDigits: 0,
};
return {
...invoiceDTO,
taxes: taxesDTO,
items: itemsDTO,
invoice_date: DateHelper.format(invoiceDTO.invoice_date, locale),
subtotal_amount: MoneyDTOHelper.format(invoiceDTO.subtotal_amount, locale, moneyOptions),
discount_percentage: PercentageDTOHelper.format(invoiceDTO.discount_percentage, locale),
discount_amount: MoneyDTOHelper.format(invoiceDTO.discount_amount, locale, moneyOptions),
taxable_amount: MoneyDTOHelper.format(invoiceDTO.taxable_amount, locale, moneyOptions),
taxes_amount: MoneyDTOHelper.format(invoiceDTO.taxes_amount, locale, moneyOptions),
total_amount: MoneyDTOHelper.format(invoiceDTO.total_amount, locale, moneyOptions),
payment_method: this._formatPaymentMethodDTO(invoiceDTO.payment_method),
};
}
}

View File

@ -1,4 +1,2 @@
export * from "./customer-invoice.report.presenter"; export * from "./issued-invoices";
export * from "./customer-invoice-items.report.presenter"; export * from "./proformas";
export * from "./customer-invoice-taxes.report.presenter";
export * from "./list-customer-invoices.presenter";

View File

@ -0,0 +1 @@
export * from "./issued-invoice.list.presenter";

View File

@ -3,13 +3,22 @@ import type { Criteria } from "@repo/rdx-criteria/server";
import { toEmptyString } from "@repo/rdx-ddd"; import { toEmptyString } from "@repo/rdx-ddd";
import type { ArrayElement, Collection } from "@repo/rdx-utils"; import type { ArrayElement, Collection } from "@repo/rdx-utils";
import type { ListIssuedInvoicesResponseDTO } from "../../../../common/dto"; import type { ListIssuedInvoicesResponseDTO } from "../../../../../common/dto";
import type { CustomerInvoiceListDTO } from "../../../infrastructure"; import type { CustomerInvoiceListDTO } from "../../../../infrastructure";
export class ListCustomerInvoicesPresenter extends Presenter { export class IssuedInvoiceListPresenter extends Presenter {
protected _mapInvoice(invoice: CustomerInvoiceListDTO) { protected _mapInvoice(invoice: CustomerInvoiceListDTO) {
const recipientDTO = invoice.recipient.toObjectString(); const recipientDTO = invoice.recipient.toObjectString();
const verifactuDTO = invoice.verifactu.match(
(verifactu) => verifactu.toObjectString(),
() => ({
status: "",
url: "",
qr_code: "",
})
);
const invoiceDTO: ArrayElement<ListIssuedInvoicesResponseDTO["items"]> = { const invoiceDTO: ArrayElement<ListIssuedInvoicesResponseDTO["items"]> = {
id: invoice.id.toString(), id: invoice.id.toString(),
company_id: invoice.companyId.toString(), company_id: invoice.companyId.toString(),
@ -39,8 +48,10 @@ export class ListCustomerInvoicesPresenter extends Presenter {
taxes_amount: invoice.taxesAmount.toObjectString(), taxes_amount: invoice.taxesAmount.toObjectString(),
total_amount: invoice.totalAmount.toObjectString(), total_amount: invoice.totalAmount.toObjectString(),
verifactu: verifactuDTO,
metadata: { metadata: {
entity: "customer-invoice", entity: "issued-invoice",
}, },
}; };
@ -48,22 +59,22 @@ export class ListCustomerInvoicesPresenter extends Presenter {
} }
toOutput(params: { toOutput(params: {
customerInvoices: Collection<CustomerInvoiceListDTO>; invoices: Collection<CustomerInvoiceListDTO>;
criteria: Criteria; criteria: Criteria;
}): ListIssuedInvoicesResponseDTO { }): ListIssuedInvoicesResponseDTO {
const { customerInvoices, criteria } = params; const { invoices, criteria } = params;
const invoices = customerInvoices.map((invoice) => this._mapInvoice(invoice)); const _invoices = invoices.map((invoice) => this._mapInvoice(invoice));
const totalItems = customerInvoices.total(); const _totalItems = invoices.total();
return { return {
page: criteria.pageNumber, page: criteria.pageNumber,
per_page: criteria.pageSize, per_page: criteria.pageSize,
total_pages: Math.ceil(totalItems / criteria.pageSize), total_pages: Math.ceil(_totalItems / criteria.pageSize),
total_items: totalItems, total_items: _totalItems,
items: invoices, items: _invoices,
metadata: { metadata: {
entity: "customer-invoices", entity: "issued-invoices",
criteria: criteria.toJSON(), criteria: criteria.toJSON(),
//links: { //links: {
// self: `/api/customer-invoices?page=${criteria.pageNumber}&per_page=${criteria.pageSize}`, // self: `/api/customer-invoices?page=${criteria.pageNumber}&per_page=${criteria.pageSize}`,

View File

@ -0,0 +1 @@
export * from "./proforma.list.presenter";

View File

@ -0,0 +1,76 @@
import { Presenter } from "@erp/core/api";
import type { Criteria } from "@repo/rdx-criteria/server";
import { toEmptyString } from "@repo/rdx-ddd";
import type { ArrayElement, Collection } from "@repo/rdx-utils";
import type { ListProformasResponseDTO } from "../../../../../common/dto";
import type { CustomerInvoiceListDTO } from "../../../../infrastructure";
export class ProformaListPresenter extends Presenter {
protected _mapProforma(proforma: CustomerInvoiceListDTO) {
const recipientDTO = proforma.recipient.toObjectString();
const invoiceDTO: ArrayElement<ListProformasResponseDTO["items"]> = {
id: proforma.id.toString(),
company_id: proforma.companyId.toString(),
is_proforma: proforma.isProforma,
customer_id: proforma.customerId.toString(),
invoice_number: proforma.invoiceNumber.toString(),
status: proforma.status.toPrimitive(),
series: toEmptyString(proforma.series, (value) => value.toString()),
invoice_date: proforma.invoiceDate.toDateString(),
operation_date: toEmptyString(proforma.operationDate, (value) => value.toDateString()),
reference: toEmptyString(proforma.reference, (value) => value.toString()),
description: toEmptyString(proforma.description, (value) => value.toString()),
recipient: recipientDTO,
language_code: proforma.languageCode.code,
currency_code: proforma.currencyCode.code,
taxes: proforma.taxes,
subtotal_amount: proforma.subtotalAmount.toObjectString(),
discount_percentage: proforma.discountPercentage.toObjectString(),
discount_amount: proforma.discountAmount.toObjectString(),
taxable_amount: proforma.taxableAmount.toObjectString(),
taxes_amount: proforma.taxesAmount.toObjectString(),
total_amount: proforma.totalAmount.toObjectString(),
metadata: {
entity: "proforma",
},
};
return invoiceDTO;
}
toOutput(params: {
proformas: Collection<CustomerInvoiceListDTO>;
criteria: Criteria;
}): ListProformasResponseDTO {
const { proformas, criteria } = params;
const _proformas = proformas.map((proforma) => this._mapProforma(proforma));
const _totalItems = proformas.total();
return {
page: criteria.pageNumber,
per_page: criteria.pageSize,
total_pages: Math.ceil(_totalItems / criteria.pageSize),
total_items: _totalItems,
items: _proformas,
metadata: {
entity: "proformas",
criteria: criteria.toJSON(),
//links: {
// self: `/api/customer-invoices?page=${criteria.pageNumber}&per_page=${criteria.pageSize}`,
// first: `/api/customer-invoices?page=1&per_page=${criteria.pageSize}`,
// last: `/api/customer-invoices?page=${Math.ceil(totalItems / criteria.pageSize)}&per_page=${criteria.pageSize}`,
//},
},
};
}
}

View File

@ -0,0 +1,2 @@
export * from "./issued-invoices";
export * from "./proformas";

View File

@ -0,0 +1,3 @@
export * from "./issued-invoice.report.presenter";
export * from "./issued-invoice-items.report.presenter";
export * from "./issued-invoice-taxes.report.presenter";

View File

@ -3,16 +3,13 @@ import { type IPresenterOutputParams, Presenter } from "@erp/core/api";
import type { GetIssuedInvoiceByIdResponseDTO } from "@erp/customer-invoices/common"; import type { GetIssuedInvoiceByIdResponseDTO } from "@erp/customer-invoices/common";
import type { ArrayElement } from "@repo/rdx-utils"; import type { ArrayElement } from "@repo/rdx-utils";
type CustomerInvoiceItemsDTO = GetIssuedInvoiceByIdResponseDTO["items"]; type IssuedInvoiceItemsDTO = GetIssuedInvoiceByIdResponseDTO["items"];
type CustomerInvoiceItemDTO = ArrayElement<CustomerInvoiceItemsDTO>; type IssuedInvoiceItemDTO = ArrayElement<IssuedInvoiceItemsDTO>;
export class CustomerInvoiceItemsReportPersenter extends Presenter< export class IssuedInvoiceItemsReportPresenter extends Presenter<IssuedInvoiceItemsDTO, unknown> {
CustomerInvoiceItemsDTO,
unknown
> {
private _locale!: string; private _locale!: string;
private _mapItem(invoiceItem: CustomerInvoiceItemDTO, _index: number) { private _mapItem(invoiceItem: IssuedInvoiceItemDTO, _index: number) {
const moneyOptions = { const moneyOptions = {
hideZeros: true, hideZeros: true,
minimumFractionDigits: 0, minimumFractionDigits: 0,
@ -48,14 +45,14 @@ export class CustomerInvoiceItemsReportPersenter extends Presenter<
}; };
} }
toOutput(invoiceItems: CustomerInvoiceItemsDTO, params: IPresenterOutputParams): unknown { toOutput(issuedInvoiceItems: IssuedInvoiceItemsDTO, params: IPresenterOutputParams): unknown {
const { locale } = params as { const { locale } = params as {
locale: string; locale: string;
}; };
this._locale = locale; this._locale = locale;
return invoiceItems.map((item, index) => { return issuedInvoiceItems.map((item, index) => {
return this._mapItem(item, index); return this._mapItem(item, index);
}); });
} }

View File

@ -3,17 +3,14 @@ import { type IPresenterOutputParams, Presenter } from "@erp/core/api";
import type { GetIssuedInvoiceByIdResponseDTO } from "@erp/customer-invoices/common"; import type { GetIssuedInvoiceByIdResponseDTO } from "@erp/customer-invoices/common";
import type { ArrayElement } from "@repo/rdx-utils"; import type { ArrayElement } from "@repo/rdx-utils";
type CustomerInvoiceTaxesDTO = GetIssuedInvoiceByIdResponseDTO["taxes"]; type IssuedInvoiceTaxesDTO = GetIssuedInvoiceByIdResponseDTO["taxes"];
type CustomerInvoiceTaxDTO = ArrayElement<CustomerInvoiceTaxesDTO>; type IssuedInvoiceTaxDTO = ArrayElement<IssuedInvoiceTaxesDTO>;
export class CustomerInvoiceTaxesReportPresenter extends Presenter< export class IssuedInvoiceTaxesReportPresenter extends Presenter<IssuedInvoiceTaxesDTO, unknown> {
CustomerInvoiceTaxesDTO,
unknown
> {
private _locale!: string; private _locale!: string;
private _taxCatalog!: JsonTaxCatalogProvider; private _taxCatalog!: JsonTaxCatalogProvider;
private _mapTax(taxItem: CustomerInvoiceTaxDTO) { private _mapTax(taxItem: IssuedInvoiceTaxDTO) {
const moneyOptions = { const moneyOptions = {
hideZeros: true, hideZeros: true,
minimumFractionDigits: 0, minimumFractionDigits: 0,
@ -29,7 +26,7 @@ export class CustomerInvoiceTaxesReportPresenter extends Presenter<
}; };
} }
toOutput(taxes: CustomerInvoiceTaxesDTO, params: IPresenterOutputParams): unknown { toOutput(taxes: IssuedInvoiceTaxesDTO, params: IPresenterOutputParams): unknown {
const { locale } = params as { const { locale } = params as {
locale: string; locale: string;
}; };

View File

@ -0,0 +1,71 @@
import { DateHelper, MoneyDTOHelper, PercentageDTOHelper } from "@erp/core";
import { Presenter } from "@erp/core/api";
import type { GetIssuedInvoiceByIdResponseDTO } from "../../../../../common/dto";
export class IssuedInvoiceReportPresenter extends Presenter<
GetIssuedInvoiceByIdResponseDTO,
unknown
> {
private _formatPaymentMethodDTO(
paymentMethod?: GetIssuedInvoiceByIdResponseDTO["payment_method"]
) {
if (!paymentMethod) {
return "";
}
return paymentMethod.payment_description ?? "";
}
toOutput(issuedInvoiceDTO: GetIssuedInvoiceByIdResponseDTO) {
const itemsPresenter = this.presenterRegistry.getPresenter({
resource: "issued-invoice-items",
projection: "REPORT",
format: "JSON",
});
const taxesPresenter = this.presenterRegistry.getPresenter({
resource: "issued-invoice-taxes",
projection: "REPORT",
format: "JSON",
});
const locale = issuedInvoiceDTO.language_code;
const itemsDTO = itemsPresenter.toOutput(issuedInvoiceDTO.items, {
locale,
});
const taxesDTO = taxesPresenter.toOutput(issuedInvoiceDTO.taxes, {
locale,
});
const moneyOptions = {
hideZeros: true,
minimumFractionDigits: 0,
};
return {
...issuedInvoiceDTO,
taxes: taxesDTO,
items: itemsDTO,
invoice_date: DateHelper.format(issuedInvoiceDTO.invoice_date, locale),
subtotal_amount: MoneyDTOHelper.format(
issuedInvoiceDTO.subtotal_amount,
locale,
moneyOptions
),
discount_percentage: PercentageDTOHelper.format(issuedInvoiceDTO.discount_percentage, locale),
discount_amount: MoneyDTOHelper.format(
issuedInvoiceDTO.discount_amount,
locale,
moneyOptions
),
taxable_amount: MoneyDTOHelper.format(issuedInvoiceDTO.taxable_amount, locale, moneyOptions),
taxes_amount: MoneyDTOHelper.format(issuedInvoiceDTO.taxes_amount, locale, moneyOptions),
total_amount: MoneyDTOHelper.format(issuedInvoiceDTO.total_amount, locale, moneyOptions),
payment_method: this._formatPaymentMethodDTO(issuedInvoiceDTO.payment_method),
};
}
}

View File

@ -0,0 +1,3 @@
export * from "./proforma.report.presenter";
export * from "./proforma-items.report.presenter";
export * from "./proforma-taxes.report.presenter";

View File

@ -0,0 +1,63 @@
import { MoneyDTOHelper, PercentageDTOHelper, QuantityDTOHelper } from "@erp/core";
import { type IPresenterOutputParams, Presenter } from "@erp/core/api";
import type { GetProformaByIdResponseDTO } from "@erp/customer-invoices/common";
import type { ArrayElement } from "@repo/rdx-utils";
type ProformaItemsDTO = GetProformaByIdResponseDTO["items"];
type ProformaItemDTO = ArrayElement<ProformaItemsDTO>;
export class ProformaItemsReportPresenter extends Presenter<ProformaItemsDTO, unknown> {
private _locale!: string;
private _mapItem(proformaItem: ProformaItemDTO, _index: number) {
const moneyOptions = {
hideZeros: true,
minimumFractionDigits: 0,
};
return {
...proformaItem,
quantity: QuantityDTOHelper.format(proformaItem.quantity, this._locale, {
minimumFractionDigits: 0,
}),
unit_amount: MoneyDTOHelper.format(proformaItem.unit_amount, this._locale, moneyOptions),
subtotal_amount: MoneyDTOHelper.format(
proformaItem.subtotal_amount,
this._locale,
moneyOptions
),
discount_percentage: PercentageDTOHelper.format(
proformaItem.discount_percentage,
this._locale,
{
minimumFractionDigits: 0,
}
),
discount_amount: MoneyDTOHelper.format(
proformaItem.discount_amount,
this._locale,
moneyOptions
),
taxable_amount: MoneyDTOHelper.format(
proformaItem.taxable_amount,
this._locale,
moneyOptions
),
taxes_amount: MoneyDTOHelper.format(proformaItem.taxes_amount, this._locale, moneyOptions),
total_amount: MoneyDTOHelper.format(proformaItem.total_amount, this._locale, moneyOptions),
};
}
toOutput(proformaItems: ProformaItemsDTO, params: IPresenterOutputParams): unknown {
const { locale } = params as {
locale: string;
};
this._locale = locale;
return proformaItems.map((item, index) => {
return this._mapItem(item, index);
});
}
}

View File

@ -0,0 +1,41 @@
import { type JsonTaxCatalogProvider, MoneyDTOHelper, SpainTaxCatalogProvider } from "@erp/core";
import { type IPresenterOutputParams, Presenter } from "@erp/core/api";
import type { GetProformaByIdResponseDTO } from "@erp/customer-invoices/common";
import type { ArrayElement } from "@repo/rdx-utils";
type ProformaTaxesDTO = GetProformaByIdResponseDTO["taxes"];
type ProformaTaxDTO = ArrayElement<ProformaTaxesDTO>;
export class ProformaTaxesReportPresenter extends Presenter<ProformaTaxesDTO, unknown> {
private _locale!: string;
private _taxCatalog!: JsonTaxCatalogProvider;
private _mapTax(taxItem: ProformaTaxDTO) {
const moneyOptions = {
hideZeros: true,
minimumFractionDigits: 0,
};
const taxCatalogItem = this._taxCatalog.findByCode(taxItem.tax_code);
return {
tax_code: taxItem.tax_code,
tax_name: taxCatalogItem.unwrap().name,
taxable_amount: MoneyDTOHelper.format(taxItem.taxable_amount, this._locale, moneyOptions),
taxes_amount: MoneyDTOHelper.format(taxItem.taxes_amount, this._locale, moneyOptions),
};
}
toOutput(taxes: ProformaTaxesDTO, params: IPresenterOutputParams): unknown {
const { locale } = params as {
locale: string;
};
this._locale = locale;
this._taxCatalog = SpainTaxCatalogProvider();
return taxes.map((item, _index) => {
return this._mapTax(item);
});
}
}

View File

@ -0,0 +1,58 @@
import { DateHelper, MoneyDTOHelper, PercentageDTOHelper } from "@erp/core";
import { Presenter } from "@erp/core/api";
import type { GetProformaByIdResponseDTO } from "../../../../../common/dto";
export class ProformaReportPresenter extends Presenter<GetProformaByIdResponseDTO, unknown> {
private _formatPaymentMethodDTO(paymentMethod?: GetProformaByIdResponseDTO["payment_method"]) {
if (!paymentMethod) {
return "";
}
return paymentMethod.payment_description ?? "";
}
toOutput(proformaDTO: GetProformaByIdResponseDTO) {
const itemsPresenter = this.presenterRegistry.getPresenter({
resource: "proforma-items",
projection: "REPORT",
format: "JSON",
});
const taxesPresenter = this.presenterRegistry.getPresenter({
resource: "proforma-taxes",
projection: "REPORT",
format: "JSON",
});
const locale = proformaDTO.language_code;
const itemsDTO = itemsPresenter.toOutput(proformaDTO.items, {
locale,
});
const taxesDTO = taxesPresenter.toOutput(proformaDTO.taxes, {
locale,
});
const moneyOptions = {
hideZeros: true,
minimumFractionDigits: 0,
};
return {
...proformaDTO,
taxes: taxesDTO,
items: itemsDTO,
invoice_date: DateHelper.format(proformaDTO.invoice_date, locale),
subtotal_amount: MoneyDTOHelper.format(proformaDTO.subtotal_amount, locale, moneyOptions),
discount_percentage: PercentageDTOHelper.format(proformaDTO.discount_percentage, locale),
discount_amount: MoneyDTOHelper.format(proformaDTO.discount_amount, locale, moneyOptions),
taxable_amount: MoneyDTOHelper.format(proformaDTO.taxable_amount, locale, moneyOptions),
taxes_amount: MoneyDTOHelper.format(proformaDTO.taxes_amount, locale, moneyOptions),
total_amount: MoneyDTOHelper.format(proformaDTO.total_amount, locale, moneyOptions),
payment_method: this._formatPaymentMethodDTO(proformaDTO.payment_method),
};
}
}

View File

@ -188,11 +188,7 @@ export class CustomerInvoiceApplicationService {
criteria: Criteria, criteria: Criteria,
transaction?: Transaction transaction?: Transaction
): Promise<Result<Collection<CustomerInvoiceListDTO>, Error>> { ): Promise<Result<Collection<CustomerInvoiceListDTO>, Error>> {
return this.repository.findByCriteriaInCompany(companyId, criteria, transaction, { return this.repository.findProformasByCriteriaInCompany(companyId, criteria, transaction, {});
where: {
is_proforma: true,
},
});
} }
/** /**
@ -208,11 +204,12 @@ export class CustomerInvoiceApplicationService {
criteria: Criteria, criteria: Criteria,
transaction?: Transaction transaction?: Transaction
): Promise<Result<Collection<CustomerInvoiceListDTO>, Error>> { ): Promise<Result<Collection<CustomerInvoiceListDTO>, Error>> {
return this.repository.findByCriteriaInCompany(companyId, criteria, transaction, { return this.repository.findIssuedInvoicesByCriteriaInCompany(
where: { companyId,
is_proforma: false, criteria,
}, transaction,
}); {}
);
} }
/** /**
@ -227,11 +224,12 @@ export class CustomerInvoiceApplicationService {
invoiceId: UniqueID, invoiceId: UniqueID,
transaction?: Transaction transaction?: Transaction
): Promise<Result<CustomerInvoice>> { ): Promise<Result<CustomerInvoice>> {
return await this.repository.getByIdInCompany(companyId, invoiceId, transaction, { return await this.repository.getIssuedInvoiceByIdInCompany(
where: { companyId,
is_proforma: false, invoiceId,
}, transaction,
}); {}
);
} }
/** /**
@ -246,11 +244,7 @@ export class CustomerInvoiceApplicationService {
proformaId: UniqueID, proformaId: UniqueID,
transaction?: Transaction transaction?: Transaction
): Promise<Result<CustomerInvoice>> { ): Promise<Result<CustomerInvoice>> {
return await this.repository.getByIdInCompany(companyId, proformaId, transaction, { return await this.repository.getProformaByIdInCompany(companyId, proformaId, transaction, {});
where: {
is_proforma: true,
},
});
} }
/** /**

View File

@ -2,7 +2,7 @@ import type { IPresenterRegistry, ITransactionManager } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd"; import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import type { CustomerInvoiceFullPresenter } from "../../presenters/domain"; import type { ProformaFullPresenter } from "../../presenters/domain";
import type { CustomerInvoiceApplicationService } from "../../services"; import type { CustomerInvoiceApplicationService } from "../../services";
type GetIssuedInvoiceUseCaseInput = { type GetIssuedInvoiceUseCaseInput = {
@ -27,9 +27,9 @@ export class GetIssuedInvoiceUseCase {
const invoiceId = idOrError.data; const invoiceId = idOrError.data;
const presenter = this.presenterRegistry.getPresenter({ const presenter = this.presenterRegistry.getPresenter({
resource: "customer-invoice", resource: "issued-invoice",
projection: "FULL", projection: "FULL",
}) as CustomerInvoiceFullPresenter; }) as ProformaFullPresenter;
return this.transactionManager.complete(async (transaction) => { return this.transactionManager.complete(async (transaction) => {
try { try {

View File

@ -1,3 +1,3 @@
export * from "./get-issued-invoice.use-case"; export * from "./get-issued-invoice.use-case";
export * from "./list-issued-invoices.use-case"; export * from "./list-issued-invoices.use-case";
export * from "./report-issued-invoice.use-case"; export * from "./report-issued-invoices";

View File

@ -5,7 +5,7 @@ import { Result } from "@repo/rdx-utils";
import type { Transaction } from "sequelize"; import type { Transaction } from "sequelize";
import type { ListIssuedInvoicesResponseDTO } from "../../../../common/dto"; import type { ListIssuedInvoicesResponseDTO } from "../../../../common/dto";
import type { ListCustomerInvoicesPresenter } from "../../presenters"; import type { IssuedInvoiceListPresenter } from "../../presenters";
import type { CustomerInvoiceApplicationService } from "../../services"; import type { CustomerInvoiceApplicationService } from "../../services";
type ListIssuedInvoicesUseCaseInput = { type ListIssuedInvoicesUseCaseInput = {
@ -25,9 +25,9 @@ export class ListIssuedInvoicesUseCase {
): Promise<Result<ListIssuedInvoicesResponseDTO, Error>> { ): Promise<Result<ListIssuedInvoicesResponseDTO, Error>> {
const { criteria, companyId } = params; const { criteria, companyId } = params;
const presenter = this.presenterRegistry.getPresenter({ const presenter = this.presenterRegistry.getPresenter({
resource: "customer-invoice", resource: "issued-invoice",
projection: "LIST", projection: "LIST",
}) as ListCustomerInvoicesPresenter; }) as IssuedInvoiceListPresenter;
return this.transactionManager.complete(async (transaction: Transaction) => { return this.transactionManager.complete(async (transaction: Transaction) => {
try { try {
@ -43,7 +43,7 @@ export class ListIssuedInvoicesUseCase {
const invoices = result.data; const invoices = result.data;
const dto = presenter.toOutput({ const dto = presenter.toOutput({
customerInvoices: invoices, invoices: invoices,
criteria, criteria,
}); });

View File

@ -0,0 +1 @@
export * from "./report-issued-invoice.use-case";

View File

@ -2,8 +2,9 @@ import type { IPresenterRegistry, ITransactionManager } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd"; import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import type { CustomerInvoiceApplicationService } from "../../services"; import type { CustomerInvoiceApplicationService } from "../../../services";
import type { CustomerInvoiceReportPDFPresenter } from "../proformas";
import type { IssuedInvoiceReportPDFPresenter } from "./reporter/issued-invoice.report.pdf";
type ReportIssuedInvoiceUseCaseInput = { type ReportIssuedInvoiceUseCaseInput = {
companyId: UniqueID; companyId: UniqueID;
@ -28,10 +29,10 @@ export class ReportIssuedInvoiceUseCase {
const invoiceId = idOrError.data; const invoiceId = idOrError.data;
const pdfPresenter = this.presenterRegistry.getPresenter({ const pdfPresenter = this.presenterRegistry.getPresenter({
resource: "customer-invoice", resource: "issued-invoice",
projection: "REPORT", projection: "REPORT",
format: "PDF", format: "PDF",
}) as CustomerInvoiceReportPDFPresenter; }) as IssuedInvoiceReportPDFPresenter;
return this.transactionManager.complete(async (transaction) => { return this.transactionManager.complete(async (transaction) => {
try { try {

View File

@ -0,0 +1,2 @@
export * from "./issued-invoice.report.html";
export * from "./issued-invoice.report.pdf";

View File

@ -7,8 +7,8 @@ import Handlebars from "handlebars";
import type { CustomerInvoice } from "../../../../../domain"; import type { CustomerInvoice } from "../../../../../domain";
import type { import type {
CustomerInvoiceFullPresenter, IssuedInvoiceFullPresenter,
CustomerInvoiceReportPresenter, IssuedInvoiceReportPresenter,
} from "../../../../presenters"; } from "../../../../presenters";
/** Helper para trabajar relativo al fichero actual (ESM) */ /** Helper para trabajar relativo al fichero actual (ESM) */
@ -23,26 +23,26 @@ export function fromHere(metaUrl: string) {
}; };
} }
export class CustomerInvoiceReportHTMLPresenter extends Presenter { export class IssuedInvoiceReportHTMLPresenter extends Presenter {
toOutput(customerInvoice: CustomerInvoice): string { toOutput(invoice: CustomerInvoice): string {
const dtoPresenter = this.presenterRegistry.getPresenter({ const dtoPresenter = this.presenterRegistry.getPresenter({
resource: "customer-invoice", resource: "issued-invoice",
projection: "FULL", projection: "FULL",
}) as CustomerInvoiceFullPresenter; }) as IssuedInvoiceFullPresenter;
const prePresenter = this.presenterRegistry.getPresenter({ const prePresenter = this.presenterRegistry.getPresenter({
resource: "customer-invoice", resource: "issued-invoice",
projection: "REPORT", projection: "REPORT",
format: "JSON", format: "JSON",
}) as CustomerInvoiceReportPresenter; }) as IssuedInvoiceReportPresenter;
const invoiceDTO = dtoPresenter.toOutput(customerInvoice); const invoiceDTO = dtoPresenter.toOutput(invoice);
const prettyDTO = prePresenter.toOutput(invoiceDTO); const prettyDTO = prePresenter.toOutput(invoiceDTO);
// Obtener y compilar la plantilla HTML // Obtener y compilar la plantilla HTML
const here = fromHere(import.meta.url); const here = fromHere(import.meta.url);
const templatePath = here.resolve("./templates/customer-invoice/template.hbs"); const templatePath = here.resolve("./templates/proforma/template.hbs");
const templateHtml = readFileSync(templatePath).toString(); const templateHtml = readFileSync(templatePath).toString();
const template = Handlebars.compile(templateHtml, {}); const template = Handlebars.compile(templateHtml, {});
return template(prettyDTO); return template(prettyDTO);

View File

@ -4,23 +4,23 @@ import report from "puppeteer-report";
import type { CustomerInvoice } from "../../../../../domain"; import type { CustomerInvoice } from "../../../../../domain";
import type { CustomerInvoiceReportHTMLPresenter } from "./customer-invoice.report.html"; import type { IssuedInvoiceReportHTMLPresenter } from "./issued-invoice.report.html";
// https://plnkr.co/edit/lWk6Yd?preview // https://plnkr.co/edit/lWk6Yd?preview
export class CustomerInvoiceReportPDFPresenter extends Presenter< export class IssuedInvoiceReportPDFPresenter extends Presenter<
CustomerInvoice, CustomerInvoice,
Promise<Buffer<ArrayBuffer>> Promise<Buffer<ArrayBuffer>>
> { > {
async toOutput(customerInvoice: CustomerInvoice): Promise<Buffer<ArrayBuffer>> { async toOutput(invoice: CustomerInvoice): Promise<Buffer<ArrayBuffer>> {
try { try {
const htmlPresenter = this.presenterRegistry.getPresenter({ const htmlPresenter = this.presenterRegistry.getPresenter({
resource: "customer-invoice", resource: "issued-invoice",
projection: "REPORT", projection: "REPORT",
format: "HTML", format: "HTML",
}) as CustomerInvoiceReportHTMLPresenter; }) as IssuedInvoiceReportHTMLPresenter;
const htmlData = htmlPresenter.toOutput(customerInvoice); const htmlData = htmlPresenter.toOutput(invoice);
// Generar el PDF con Puppeteer // Generar el PDF con Puppeteer
const browser = await puppeteer.launch({ const browser = await puppeteer.launch({

View File

@ -9,7 +9,7 @@ import { Result } from "@repo/rdx-utils";
import type { Transaction } from "sequelize"; import type { Transaction } from "sequelize";
import type { CreateProformaRequestDTO } from "../../../../../common"; import type { CreateProformaRequestDTO } from "../../../../../common";
import type { CustomerInvoiceFullPresenter } from "../../../presenters"; import type { ProformaFullPresenter } from "../../../presenters";
import type { CustomerInvoiceApplicationService } from "../../../services"; import type { CustomerInvoiceApplicationService } from "../../../services";
import { CreateCustomerInvoicePropsMapper } from "./map-dto-to-create-proforma-props"; import { CreateCustomerInvoicePropsMapper } from "./map-dto-to-create-proforma-props";
@ -30,9 +30,9 @@ export class CreateProformaUseCase {
public async execute(params: CreateProformaUseCaseInput) { public async execute(params: CreateProformaUseCaseInput) {
const { dto, companyId } = params; const { dto, companyId } = params;
const presenter = this.presenterRegistry.getPresenter({ const presenter = this.presenterRegistry.getPresenter({
resource: "customer-invoice", resource: "proforma",
projection: "FULL", projection: "FULL",
}) as CustomerInvoiceFullPresenter; }) as ProformaFullPresenter;
// 1) Mapear DTO → props de dominio // 1) Mapear DTO → props de dominio
const dtoMapper = new CreateCustomerInvoicePropsMapper({ taxCatalog: this.taxCatalog }); const dtoMapper = new CreateCustomerInvoicePropsMapper({ taxCatalog: this.taxCatalog });
@ -65,24 +65,24 @@ export class CreateProformaUseCase {
return Result.fail(buildResult.error); return Result.fail(buildResult.error);
} }
const newInvoice = buildResult.data; const newProforma = buildResult.data;
const existsGuard = await this.ensureNotExists(companyId, id, transaction); const existsGuard = await this.ensureNotExists(companyId, id, transaction);
if (existsGuard.isFailure) { if (existsGuard.isFailure) {
return Result.fail(existsGuard.error); return Result.fail(existsGuard.error);
} }
const saveResult = await this.service.createInvoiceInCompany( const saveResult = await this.service.createProformaInCompany(
companyId, companyId,
newInvoice, newProforma,
transaction transaction
); );
if (saveResult.isFailure) { if (saveResult.isFailure) {
return Result.fail(saveResult.error); return Result.fail(saveResult.error);
} }
const invoice = saveResult.data; const proforma = saveResult.data;
const dto = presenter.toOutput(invoice); const dto = presenter.toOutput(proforma);
return Result.ok(dto); return Result.ok(dto);
} catch (error: unknown) { } catch (error: unknown) {
@ -99,7 +99,7 @@ export class CreateProformaUseCase {
id: UniqueID, id: UniqueID,
transaction: Transaction transaction: Transaction
): Promise<Result<void, Error>> { ): Promise<Result<void, Error>> {
const existsResult = await this.service.existsByIdInCompany(companyId, id, transaction); const existsResult = await this.service.existsProformaByIdInCompany(companyId, id, transaction);
if (existsResult.isFailure) { if (existsResult.isFailure) {
return Result.fail(existsResult.error); return Result.fail(existsResult.error);
} }

View File

@ -2,7 +2,7 @@ import type { IPresenterRegistry, ITransactionManager } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd"; import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import type { CustomerInvoiceFullPresenter } from "../../presenters/domain"; import type { ProformaFullPresenter } from "../../presenters/domain";
import type { CustomerInvoiceApplicationService } from "../../services"; import type { CustomerInvoiceApplicationService } from "../../services";
type GetProformaUseCaseInput = { type GetProformaUseCaseInput = {
@ -27,9 +27,9 @@ export class GetProformaUseCase {
const proformaId = idOrError.data; const proformaId = idOrError.data;
const presenter = this.presenterRegistry.getPresenter({ const presenter = this.presenterRegistry.getPresenter({
resource: "customer-invoice", resource: "proforma",
projection: "FULL", projection: "FULL",
}) as CustomerInvoiceFullPresenter; }) as ProformaFullPresenter;
return this.transactionManager.complete(async (transaction) => { return this.transactionManager.complete(async (transaction) => {
try { try {

View File

@ -6,7 +6,7 @@ import {
IssueCustomerInvoiceDomainService, IssueCustomerInvoiceDomainService,
ProformaCustomerInvoiceDomainService, ProformaCustomerInvoiceDomainService,
} from "../../../domain"; } from "../../../domain";
import type { CustomerInvoiceFullPresenter } from "../../presenters"; import type { ProformaFullPresenter } from "../../presenters";
import type { CustomerInvoiceApplicationService } from "../../services"; import type { CustomerInvoiceApplicationService } from "../../services";
type IssueProformaUseCaseInput = { type IssueProformaUseCaseInput = {
@ -43,9 +43,9 @@ export class IssueProformaUseCase {
const proformaId = idOrError.data; const proformaId = idOrError.data;
const presenter = this.presenterRegistry.getPresenter({ const presenter = this.presenterRegistry.getPresenter({
resource: "customer-invoice", resource: "proforma",
projection: "FULL", projection: "FULL",
}) as CustomerInvoiceFullPresenter; }) as ProformaFullPresenter;
return this.transactionManager.complete(async (transaction) => { return this.transactionManager.complete(async (transaction) => {
try { try {

View File

@ -1,11 +1,11 @@
import type { IPresenterRegistry, ITransactionManager } from "@erp/core/api"; import type { IPresenterRegistry, ITransactionManager } from "@erp/core/api";
import type { ListProformasResponseDTO } from "@erp/customer-invoices/common";
import type { Criteria } from "@repo/rdx-criteria/server"; import type { Criteria } from "@repo/rdx-criteria/server";
import type { UniqueID } from "@repo/rdx-ddd"; import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import type { Transaction } from "sequelize"; import type { Transaction } from "sequelize";
import type { ListIssuedInvoicesResponseDTO } from "../../../../common/dto"; import type { ProformaListPresenter } from "../../presenters";
import type { ListCustomerInvoicesPresenter } from "../../presenters";
import type { CustomerInvoiceApplicationService } from "../../services"; import type { CustomerInvoiceApplicationService } from "../../services";
type ListProformasUseCaseInput = { type ListProformasUseCaseInput = {
@ -22,12 +22,12 @@ export class ListProformasUseCase {
public execute( public execute(
params: ListProformasUseCaseInput params: ListProformasUseCaseInput
): Promise<Result<ListIssuedInvoicesResponseDTO, Error>> { ): Promise<Result<ListProformasResponseDTO, Error>> {
const { criteria, companyId } = params; const { criteria, companyId } = params;
const presenter = this.presenterRegistry.getPresenter({ const presenter = this.presenterRegistry.getPresenter({
resource: "customer-invoice", resource: "proforma",
projection: "LIST", projection: "LIST",
}) as ListCustomerInvoicesPresenter; }) as ProformaListPresenter;
return this.transactionManager.complete(async (transaction: Transaction) => { return this.transactionManager.complete(async (transaction: Transaction) => {
try { try {
@ -43,7 +43,7 @@ export class ListProformasUseCase {
const proformas = result.data; const proformas = result.data;
const dto = presenter.toOutput({ const dto = presenter.toOutput({
customerInvoices: proformas, proformas,
criteria, criteria,
}); });

View File

@ -4,7 +4,7 @@ import { Result } from "@repo/rdx-utils";
import type { CustomerInvoiceApplicationService } from "../../../services/customer-invoice-application.service"; import type { CustomerInvoiceApplicationService } from "../../../services/customer-invoice-application.service";
import type { CustomerInvoiceReportPDFPresenter } from "./reporter"; import type { ProformaReportPDFPresenter } from "./reporter";
type ReportProformaUseCaseInput = { type ReportProformaUseCaseInput = {
companyId: UniqueID; companyId: UniqueID;
@ -29,10 +29,10 @@ export class ReportProformaUseCase {
const proformaId = idOrError.data; const proformaId = idOrError.data;
const pdfPresenter = this.presenterRegistry.getPresenter({ const pdfPresenter = this.presenterRegistry.getPresenter({
resource: "customer-invoice", resource: "proforma",
projection: "REPORT", projection: "REPORT",
format: "PDF", format: "PDF",
}) as CustomerInvoiceReportPDFPresenter; }) as ProformaReportPDFPresenter;
return this.transactionManager.complete(async (transaction) => { return this.transactionManager.complete(async (transaction) => {
try { try {

View File

@ -1,2 +1,2 @@
export * from "./customer-invoice.report.html"; export * from "./proforma.report.html";
export * from "./customer-invoice.report.pdf"; export * from "./proforma.report.pdf";

View File

@ -0,0 +1,47 @@
import { readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { Presenter } from "@erp/core/api";
import Handlebars from "handlebars";
import type { CustomerInvoice } from "../../../../../domain";
import type { ProformaFullPresenter, ProformaReportPresenter } from "../../../../presenters";
/** Helper para trabajar relativo al fichero actual (ESM) */
export function fromHere(metaUrl: string) {
const file = fileURLToPath(metaUrl);
const dir = dirname(file);
return {
file, // ruta absoluta al fichero actual
dir, // ruta absoluta al directorio actual
resolve: (...parts: string[]) => resolve(dir, ...parts),
join: (...parts: string[]) => join(dir, ...parts),
};
}
export class ProformaReportHTMLPresenter extends Presenter {
toOutput(proforma: CustomerInvoice): string {
const dtoPresenter = this.presenterRegistry.getPresenter({
resource: "proforma",
projection: "FULL",
}) as ProformaFullPresenter;
const prePresenter = this.presenterRegistry.getPresenter({
resource: "proforma",
projection: "REPORT",
format: "JSON",
}) as ProformaReportPresenter;
const invoiceDTO = dtoPresenter.toOutput(proforma);
const prettyDTO = prePresenter.toOutput(invoiceDTO);
// Obtener y compilar la plantilla HTML
const here = fromHere(import.meta.url);
const templatePath = here.resolve("./templates/proforma/template.hbs");
const templateHtml = readFileSync(templatePath).toString();
const template = Handlebars.compile(templateHtml, {});
return template(prettyDTO);
}
}

View File

@ -0,0 +1,69 @@
import { Presenter } from "@erp/core/api";
import puppeteer from "puppeteer";
import report from "puppeteer-report";
import type { CustomerInvoice } from "../../../../../domain";
import type { ProformaReportHTMLPresenter } from "./proforma.report.html";
// https://plnkr.co/edit/lWk6Yd?preview
export class ProformaReportPDFPresenter extends Presenter<
CustomerInvoice,
Promise<Buffer<ArrayBuffer>>
> {
async toOutput(proforma: CustomerInvoice): Promise<Buffer<ArrayBuffer>> {
try {
const htmlPresenter = this.presenterRegistry.getPresenter({
resource: "proforma",
projection: "REPORT",
format: "HTML",
}) as ProformaReportHTMLPresenter;
const htmlData = htmlPresenter.toOutput(proforma);
// Generar el PDF con Puppeteer
const browser = await puppeteer.launch({
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH,
headless: true,
args: [
"--disable-extensions",
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
],
});
const page = await browser.newPage();
const navigationPromise = page.waitForNavigation();
await page.setContent(htmlData, { waitUntil: "networkidle2" });
await navigationPromise;
const reportPDF = await report.pdfPage(page, {
format: "A4",
margin: {
bottom: "10mm",
left: "10mm",
right: "10mm",
top: "10mm",
},
landscape: false,
preferCSSPageSize: true,
omitBackground: false,
printBackground: true,
displayHeaderFooter: false,
headerTemplate: "<div />",
footerTemplate:
'<div style="text-align: center;width: 297mm;font-size: 10px;">Página <span style="margin-right: 1cm"><span class="pageNumber"></span> de <span class="totalPages"></span></span></div>',
});
await browser.close();
return Buffer.from(reportPDF);
} catch (err: unknown) {
console.error(err);
throw err as Error;
}
}
}

View File

@ -16,6 +16,7 @@
} }
header { header {
font-family: Tahoma, sans-serif;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
margin-bottom: 20px; margin-bottom: 20px;
@ -253,9 +254,16 @@
<footer id="footer" class="mt-4"> <footer id="footer" class="mt-4">
<aside> <aside>
<p class="text-center">Insc. en el Reg. Merc. de Madrid, Tomo 20.073, Libro 0, Folio 141, Sección 8, Hoja M-354212 <p class="text-center">Insc. en el Reg. Merc. de Madrid, Tomo 31.839, Libro 0, Folio 191, Sección 8, Hoja M-572991
| CIF: B83999441 - CIF: B86913910</p>
Rodax Software S.L.</p> <p class="text-left" style="font-size: 6pt;">Información en protección de datos<br />De conformidad con lo
dispuesto en el RGPD y LOPDGDD,
informamos que los datos personales serán tratados por
ALISO DESIGN S.L para cumplir con la obligación tributaria de emitir facturas. Podrá solicitar más información,
y ejercer sus derechos escribiendo a info@acanainteriorismo.com o mediante correo postal a la dirección CALLE LA
FUNDICION 27 POL. IND. SANTA ANA (28522) RIVAS-VACIAMADRID, MADRID. Para el ejercicio de sus derechos, en caso
de que sea necesario, se le solicitará documento que acredite su identidad. Si siente vulnerados sus derechos
puede presentar una reclamación ante la AEPD, en su web: www.aepd.es.</p>
</aside> </aside>
</footer> </footer>

View File

@ -5,7 +5,7 @@ import type { Transaction } from "sequelize";
import type { UpdateProformaByIdRequestDTO } from "../../../../../common"; import type { UpdateProformaByIdRequestDTO } from "../../../../../common";
import type { CustomerInvoicePatchProps } from "../../../../domain"; import type { CustomerInvoicePatchProps } from "../../../../domain";
import type { CustomerInvoiceFullPresenter } from "../../../presenters"; import type { ProformaFullPresenter } from "../../../presenters";
import type { CustomerInvoiceApplicationService } from "../../../services/customer-invoice-application.service"; import type { CustomerInvoiceApplicationService } from "../../../services/customer-invoice-application.service";
import { mapDTOToUpdateCustomerInvoicePatchProps } from "./map-dto-to-update-customer-invoice-props"; import { mapDTOToUpdateCustomerInvoicePatchProps } from "./map-dto-to-update-customer-invoice-props";
@ -33,9 +33,9 @@ export class UpdateProformaUseCase {
const invoiceId = idOrError.data; const invoiceId = idOrError.data;
const presenter = this.presenterRegistry.getPresenter({ const presenter = this.presenterRegistry.getPresenter({
resource: "customer-invoice", resource: "proforma",
projection: "FULL", projection: "FULL",
}) as CustomerInvoiceFullPresenter; }) as ProformaFullPresenter;
// Mapear DTO → props de dominio // Mapear DTO → props de dominio
const patchPropsResult = mapDTOToUpdateCustomerInvoicePatchProps(dto); const patchPropsResult = mapDTOToUpdateCustomerInvoicePatchProps(dto);

View File

@ -10,7 +10,12 @@ import {
} from "@repo/rdx-ddd"; } from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils"; import { type Maybe, Result } from "@repo/rdx-utils";
import { CustomerInvoiceItems, type InvoicePaymentMethod, type InvoiceTaxTotal } from "../entities"; import {
CustomerInvoiceItems,
type InvoicePaymentMethod,
type InvoiceTaxTotal,
type VerifactuRecord,
} from "../entities";
import { import {
type CustomerInvoiceNumber, type CustomerInvoiceNumber,
type CustomerInvoiceSerie, type CustomerInvoiceSerie,
@ -49,9 +54,7 @@ export interface CustomerInvoiceProps {
discountPercentage: Percentage; discountPercentage: Percentage;
/*verifactu_qr: string; verifactu: Maybe<VerifactuRecord>;
verifactu_url: string;
verifactu_status: string;*/
} }
export type CustomerInvoicePatchProps = Partial< export type CustomerInvoicePatchProps = Partial<
@ -212,6 +215,10 @@ export class CustomerInvoice
return this.props.currencyCode; return this.props.currencyCode;
} }
public get verifactu(): Maybe<VerifactuRecord> {
return this.props.verifactu;
}
public get discountPercentage(): Percentage { public get discountPercentage(): Percentage {
return this.props.discountPercentage; return this.props.discountPercentage;
} }

View File

@ -1,12 +1,19 @@
import { CurrencyCode, DomainEntity, LanguageCode, Percentage, UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { import {
CustomerInvoiceItemDescription, type CurrencyCode,
DomainEntity,
type LanguageCode,
type Percentage,
type UniqueID,
} from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
import {
type CustomerInvoiceItemDescription,
ItemAmount, ItemAmount,
ItemDiscount, ItemDiscount,
ItemQuantity, ItemQuantity,
} from "../../value-objects"; } from "../../value-objects";
import { ItemTaxes, ItemTaxTotal } from "../item-taxes"; import type { ItemTaxTotal, ItemTaxes } from "../item-taxes";
export interface CustomerInvoiceItemProps { export interface CustomerInvoiceItemProps {
description: Maybe<CustomerInvoiceItemDescription>; description: Maybe<CustomerInvoiceItemDescription>;

View File

@ -2,3 +2,4 @@ export * from "./customer-invoice-items";
export * from "./invoice-payment-method"; export * from "./invoice-payment-method";
export * from "./invoice-taxes"; export * from "./invoice-taxes";
export * from "./item-taxes"; export * from "./item-taxes";
export * from "./verifactu-record";

View File

@ -0,0 +1,50 @@
import { DomainEntity, type URLAddress, type UniqueID, toEmptyString } from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
import type { VerifactuRecordEstado } from "../value-objects";
export interface VerifactuRecordProps {
estado: VerifactuRecordEstado;
url: Maybe<URLAddress>;
qrCode: Maybe<string>;
}
export class VerifactuRecord extends DomainEntity<VerifactuRecordProps> {
public static create(props: VerifactuRecordProps, id?: UniqueID): Result<VerifactuRecord, Error> {
const record = new VerifactuRecord(props, id);
// Reglas de negocio / validaciones
// ...
// ...
return Result.ok(record);
}
get estado(): VerifactuRecordEstado {
return this.props.estado;
}
get url(): Maybe<URLAddress> {
return this.props.url;
}
get qrCode(): Maybe<string> {
return this.props.qrCode;
}
getProps(): VerifactuRecordProps {
return this.props;
}
toPrimitive() {
return this.getProps();
}
toObjectString() {
return {
status: this.estado.toString(),
url: toEmptyString(this.url, (value) => value.toString()),
qr_code: toEmptyString(this.qrCode, (value) => value.toString()),
};
}
}

View File

@ -41,10 +41,21 @@ export interface ICustomerInvoiceRepository {
): Promise<Result<boolean, Error>>; ): Promise<Result<boolean, Error>>;
/** /**
* Recupera una factura/proforma por su ID y companyId. * Recupera una proforma por su ID y companyId.
* Devuelve un `NotFoundError` si no se encuentra. * Devuelve un `NotFoundError` si no se encuentra.
*/ */
getByIdInCompany( getProformaByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction: unknown,
options: unknown
): Promise<Result<CustomerInvoice, Error>>;
/**
* Recupera una factura por su ID y companyId.
* Devuelve un `NotFoundError` si no se encuentra.
*/
getIssuedInvoiceByIdInCompany(
companyId: UniqueID, companyId: UniqueID,
id: UniqueID, id: UniqueID,
transaction: unknown, transaction: unknown,
@ -53,7 +64,7 @@ export interface ICustomerInvoiceRepository {
/** /**
* *
* Consulta facturas/proformas dentro de una empresa usando un * Consulta proformas dentro de una empresa usando un
* objeto Criteria (filtros, orden, paginación). * objeto Criteria (filtros, orden, paginación).
* El resultado está encapsulado en un objeto `Collection<T>`. * El resultado está encapsulado en un objeto `Collection<T>`.
* *
@ -65,7 +76,28 @@ export interface ICustomerInvoiceRepository {
* *
* @see Criteria * @see Criteria
*/ */
findByCriteriaInCompany( findProformasByCriteriaInCompany(
companyId: UniqueID,
criteria: Criteria,
transaction: unknown,
options: unknown
): Promise<Result<Collection<CustomerInvoiceListDTO>, Error>>;
/**
*
* Consulta facturas dentro de una empresa usando un
* objeto Criteria (filtros, orden, paginación).
* El resultado está encapsulado en un objeto `Collection<T>`.
*
* @param companyId - ID de la empresa.
* @param criteria - Criterios de búsqueda.
* @param transaction - Transacción activa para la operación.
* @param options - Opciones adicionales para la consulta (Sequelize FindOptions)
* @returns Result<Collection<CustomerInvoiceListDTO>, Error>
*
* @see Criteria
*/
findIssuedInvoicesByCriteriaInCompany(
companyId: UniqueID, companyId: UniqueID,
criteria: Criteria, criteria: Criteria,
transaction: unknown, transaction: unknown,

View File

@ -8,3 +8,4 @@ export * from "./invoice-recipient";
export * from "./item-amount"; export * from "./item-amount";
export * from "./item-discount"; export * from "./item-discount";
export * from "./item-quantity"; export * from "./item-quantity";
export * from "./verifactu-status";

View File

@ -16,6 +16,7 @@ export enum VERIFACTU_RECORD_STATUS {
RECHAZADO = "No registrado", // <- Registro rechazado por la AEAT RECHAZADO = "No registrado", // <- Registro rechazado por la AEAT
ERROR = "Error servidor AEAT", // <- Error en el servidor de la AEAT. Se intentará reenviar el registro de facturación de nuevo ERROR = "Error servidor AEAT", // <- Error en el servidor de la AEAT. Se intentará reenviar el registro de facturación de nuevo
} }
export class VerifactuRecordEstado extends ValueObject<IVerifactuRecordEstadoProps> { export class VerifactuRecordEstado extends ValueObject<IVerifactuRecordEstadoProps> {
private static readonly ALLOWED_STATUSES = [ private static readonly ALLOWED_STATUSES = [
"Pendiente", "Pendiente",
@ -30,17 +31,10 @@ export class VerifactuRecordEstado extends ValueObject<IVerifactuRecordEstadoPro
]; ];
private static readonly FIELD = "estado"; private static readonly FIELD = "estado";
private static readonly ERROR_CODE = "INVALID_RECORD_STATUS"; private static readonly ERROR_CODE = "INVALID_RECORD_STATUS";
/*
private static readonly TRANSITIONS: Record<string, string[]> = {
draft: [INVOICE_STATUS.SENT],
sent: [INVOICE_STATUS.APPROVED, INVOICE_STATUS.REJECTED],
approved: [INVOICE_STATUS.ISSUED],
rejected: [INVOICE_STATUS.DRAFT],
};
*/
static create(value: string): Result<VerifactuRecordEstado, Error> { static create(value: string): Result<VerifactuRecordEstado, Error> {
if (!VerifactuRecordEstado.ALLOWED_STATUSES.includes(value)) { if (!VerifactuRecordEstado.ALLOWED_STATUSES.includes(value)) {
const detail = `Estado de la factura no válido: ${value}`; const detail = `Estado de verifactu no válido: ${value}`;
return Result.fail( return Result.fail(
new DomainValidationError( new DomainValidationError(
VerifactuRecordEstado.ERROR_CODE, VerifactuRecordEstado.ERROR_CODE,
@ -53,6 +47,14 @@ export class VerifactuRecordEstado extends ValueObject<IVerifactuRecordEstadoPro
return Result.ok(new VerifactuRecordEstado({ value })); return Result.ok(new VerifactuRecordEstado({ value }));
} }
public static createPendiente(): VerifactuRecordEstado {
return new VerifactuRecordEstado({ value: VERIFACTU_RECORD_STATUS.PENDIENTE });
}
isPendiente(): boolean {
return this.props.value === VERIFACTU_RECORD_STATUS.PENDIENTE;
}
getProps(): string { getProps(): string {
return this.props.value; return this.props.value;
} }

View File

@ -2,7 +2,7 @@ import type { IModuleServer, ModuleParams } from "@erp/core/api";
import type { UniqueID } from "@repo/rdx-ddd"; import type { UniqueID } from "@repo/rdx-ddd";
import type { Transaction } from "sequelize"; import type { Transaction } from "sequelize";
import { buildCustomerInvoiceDependencies, models, proformasRouter } from "./infrastructure"; import { models, proformasRouter } from "./infrastructure";
import { issuedInvoicesRouter } from "./infrastructure/express/issued-invoices.routes"; import { issuedInvoicesRouter } from "./infrastructure/express/issued-invoices.routes";
export const customerInvoicesAPIModule: IModuleServer = { export const customerInvoicesAPIModule: IModuleServer = {
@ -27,8 +27,6 @@ export const customerInvoicesAPIModule: IModuleServer = {
label: this.name, label: this.name,
}); });
const deps = buildCustomerInvoiceDependencies(params);
return { return {
models, models,
services: { services: {

View File

@ -1,169 +0,0 @@
// modules/invoice/infrastructure/invoice-dependencies.factory.ts
import { JsonTaxCatalogProvider, SpainTaxCatalogProvider } from "@erp/core";
import type { IMapperRegistry, IPresenterRegistry, ModuleParams } from "@erp/core/api";
import {
InMemoryMapperRegistry,
InMemoryPresenterRegistry,
SequelizeTransactionManager,
} from "@erp/core/api";
import {
CreateCustomerInvoiceUseCase,
CustomerInvoiceApplicationService,
CustomerInvoiceFullPresenter,
CustomerInvoiceItemsFullPresenter,
CustomerInvoiceItemsReportPersenter,
CustomerInvoiceReportHTMLPresenter,
CustomerInvoiceReportPDFPresenter,
CustomerInvoiceReportPresenter,
GetCustomerInvoiceUseCase,
IssueCustomerInvoiceUseCase,
ListCustomerInvoicesPresenter,
ListCustomerInvoicesUseCase,
RecipientInvoiceFullPresenter,
ReportCustomerInvoiceUseCase,
UpdateCustomerInvoiceUseCase,
} from "../application";
import { CustomerInvoiceDomainMapper, CustomerInvoiceListMapper } from "./mappers";
import { CustomerInvoiceRepository } from "./sequelize";
import { SequelizeInvoiceNumberGenerator } from "./services";
export type CustomerInvoiceDeps = {
transactionManager: SequelizeTransactionManager;
mapperRegistry: IMapperRegistry;
presenterRegistry: IPresenterRegistry;
repo: CustomerInvoiceRepository;
service: CustomerInvoiceApplicationService;
catalogs: {
taxes: JsonTaxCatalogProvider;
};
build: {
list: () => ListCustomerInvoicesUseCase;
get: () => GetCustomerInvoiceUseCase;
create: () => CreateCustomerInvoiceUseCase;
update: () => UpdateCustomerInvoiceUseCase;
//delete: () => DeleteCustomerInvoiceUseCase;
report: () => ReportCustomerInvoiceUseCase;
issue: () => IssueCustomerInvoiceUseCase;
};
getService: (name: string) => any;
listServices: () => string[];
};
export function buildCustomerInvoiceDependencies(params: ModuleParams): CustomerInvoiceDeps {
const { database, listServices, getService } = params;
const transactionManager = new SequelizeTransactionManager(database);
const catalogs = { taxes: SpainTaxCatalogProvider() };
// Mapper Registry
const mapperRegistry = new InMemoryMapperRegistry();
mapperRegistry
.registerDomainMapper(
{ resource: "customer-invoice" },
new CustomerInvoiceDomainMapper({ taxCatalog: catalogs.taxes })
)
.registerQueryMappers([
{
key: { resource: "customer-invoice", query: "LIST" },
mapper: new CustomerInvoiceListMapper(),
},
]);
// Repository & Services
const repo = new CustomerInvoiceRepository({ mapperRegistry, database });
const numberGenerator = new SequelizeInvoiceNumberGenerator();
const service = new CustomerInvoiceApplicationService(repo, numberGenerator);
// Presenter Registry
const presenterRegistry = new InMemoryPresenterRegistry();
presenterRegistry.registerPresenters([
{
key: {
resource: "customer-invoice-items",
projection: "FULL",
},
presenter: new CustomerInvoiceItemsFullPresenter(presenterRegistry),
},
{
key: {
resource: "recipient-invoice",
projection: "FULL",
},
presenter: new RecipientInvoiceFullPresenter(presenterRegistry),
},
{
key: {
resource: "customer-invoice",
projection: "FULL",
},
presenter: new CustomerInvoiceFullPresenter(presenterRegistry),
},
{
key: {
resource: "customer-invoice",
projection: "LIST",
},
presenter: new ListCustomerInvoicesPresenter(presenterRegistry),
},
{
key: {
resource: "customer-invoice",
projection: "REPORT",
format: "JSON",
},
presenter: new CustomerInvoiceReportPresenter(presenterRegistry),
},
{
key: {
resource: "customer-invoice-items",
projection: "REPORT",
format: "JSON",
},
presenter: new CustomerInvoiceItemsReportPersenter(presenterRegistry),
},
{
key: {
resource: "customer-invoice",
projection: "REPORT",
format: "HTML",
},
presenter: new CustomerInvoiceReportHTMLPresenter(presenterRegistry),
},
{
key: {
resource: "customer-invoice",
projection: "REPORT",
format: "PDF",
},
presenter: new CustomerInvoiceReportPDFPresenter(presenterRegistry),
},
]);
return {
transactionManager,
repo,
mapperRegistry,
presenterRegistry,
service,
catalogs,
build: {
list: () => new ListCustomerInvoicesUseCase(service, transactionManager, presenterRegistry),
get: () => new GetCustomerInvoiceUseCase(service, transactionManager, presenterRegistry),
create: () =>
new CreateCustomerInvoiceUseCase(
service,
transactionManager,
presenterRegistry,
catalogs.taxes
),
update: () =>
new UpdateCustomerInvoiceUseCase(service, transactionManager, presenterRegistry),
// delete: () => new DeleteCustomerInvoiceUseCase(service, transactionManager),
report: () =>
new ReportCustomerInvoiceUseCase(service, transactionManager, presenterRegistry),
issue: () => new IssueCustomerInvoiceUseCase(service, transactionManager),
},
listServices,
getService,
};
}

View File

@ -9,7 +9,7 @@ import {
ListIssuedInvoicesRequestSchema, ListIssuedInvoicesRequestSchema,
ReportIssueInvoiceByIdRequestSchema, ReportIssueInvoiceByIdRequestSchema,
} from "../../../common/dto"; } from "../../../common/dto";
import { buildCustomerInvoiceDependencies } from "../dependencies"; import { buildIssuedInvoicesDependencies } from "../issued-invoices-dependencies";
import { import {
GetIssueInvoiceController, GetIssueInvoiceController,
@ -25,7 +25,7 @@ export const issuedInvoicesRouter = (params: ModuleParams) => {
logger: ILogger; logger: ILogger;
}; };
const deps = buildCustomerInvoiceDependencies(params); const deps = buildIssuedInvoicesDependencies(params);
const router: Router = Router({ mergeParams: true }); const router: Router = Router({ mergeParams: true });
if (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "production") { if (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "production") {

View File

@ -16,7 +16,7 @@ import {
UpdateProformaByIdParamsRequestSchema, UpdateProformaByIdParamsRequestSchema,
UpdateProformaByIdRequestSchema, UpdateProformaByIdRequestSchema,
} from "../../../common"; } from "../../../common";
import { buildCustomerInvoiceDependencies } from "../dependencies"; import { buildProformasDependencies } from "../proformas-dependencies";
import { import {
ChangeStatusProformaController, ChangeStatusProformaController,
@ -37,7 +37,7 @@ export const proformasRouter = (params: ModuleParams) => {
logger: ILogger; logger: ILogger;
}; };
const deps = buildCustomerInvoiceDependencies(params); const deps = buildProformasDependencies(params);
const router: Router = Router({ mergeParams: true }); const router: Router = Router({ mergeParams: true });
if (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "production") { if (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "production") {

View File

@ -1,4 +1,4 @@
export * from "./dependencies";
export * from "./express"; export * from "./express";
export * from "./mappers"; export * from "./mappers";
export * from "./proformas-dependencies";
export * from "./sequelize"; export * from "./sequelize";

View File

@ -0,0 +1,148 @@
// modules/invoice/infrastructure/invoice-dependencies.factory.ts
import { type JsonTaxCatalogProvider, SpainTaxCatalogProvider } from "@erp/core";
import type { IMapperRegistry, IPresenterRegistry, ModuleParams } from "@erp/core/api";
import {
InMemoryMapperRegistry,
InMemoryPresenterRegistry,
SequelizeTransactionManager,
} from "@erp/core/api";
import {
CustomerInvoiceApplicationService,
GetIssuedInvoiceUseCase,
IssuedInvoiceFullPresenter,
IssuedInvoiceItemsFullPresenter,
IssuedInvoiceItemsReportPresenter,
IssuedInvoiceListPresenter,
IssuedInvoiceRecipientFullPresenter,
IssuedInvoiceReportPresenter,
IssuedInvoiceTaxesReportPresenter,
IssuedInvoiceVerifactuFullPresenter,
ListIssuedInvoicesUseCase,
ReportIssuedInvoiceUseCase,
} from "../application";
import {
IssuedInvoiceReportHTMLPresenter,
IssuedInvoiceReportPDFPresenter,
} from "../application/use-cases/issued-invoices/report-issued-invoices/reporter";
import { CustomerInvoiceDomainMapper, CustomerInvoiceListMapper } from "./mappers";
import { CustomerInvoiceRepository } from "./sequelize";
import { SequelizeInvoiceNumberGenerator } from "./services";
export type IssuedInvoicesDeps = {
transactionManager: SequelizeTransactionManager;
mapperRegistry: IMapperRegistry;
presenterRegistry: IPresenterRegistry;
repo: CustomerInvoiceRepository;
appService: CustomerInvoiceApplicationService;
catalogs: {
taxes: JsonTaxCatalogProvider;
};
useCases: {
list_issued_invoices: () => ListIssuedInvoicesUseCase;
get_issued_invoice: () => GetIssuedInvoiceUseCase;
report_issued_invoice: () => ReportIssuedInvoiceUseCase;
};
};
export function buildIssuedInvoicesDependencies(params: ModuleParams): IssuedInvoicesDeps {
const { database } = params;
/** Dominio */
const catalogs = { taxes: SpainTaxCatalogProvider() };
/** Infraestructura */
const transactionManager = new SequelizeTransactionManager(database);
const mapperRegistry = new InMemoryMapperRegistry();
mapperRegistry
.registerDomainMapper(
{ resource: "customer-invoice" },
new CustomerInvoiceDomainMapper({ taxCatalog: catalogs.taxes })
)
.registerQueryMappers([
{
key: { resource: "customer-invoice", query: "LIST" },
mapper: new CustomerInvoiceListMapper(),
},
]);
// Repository & Services
const repository = new CustomerInvoiceRepository({ mapperRegistry, database });
const numberGenerator = new SequelizeInvoiceNumberGenerator();
/** Aplicación */
const appService = new CustomerInvoiceApplicationService(repository, numberGenerator);
// Presenter Registry
const presenterRegistry = new InMemoryPresenterRegistry();
presenterRegistry.registerPresenters([
// FULL
{
key: { resource: "issued-invoice-items", projection: "FULL" },
presenter: new IssuedInvoiceItemsFullPresenter(presenterRegistry),
},
{
key: { resource: "issued-invoice-recipient", projection: "FULL" },
presenter: new IssuedInvoiceRecipientFullPresenter(presenterRegistry),
},
{
key: { resource: "issued-invoice-verifactu", projection: "FULL" },
presenter: new IssuedInvoiceVerifactuFullPresenter(presenterRegistry),
},
{
key: { resource: "issued-invoice", projection: "FULL" },
presenter: new IssuedInvoiceFullPresenter(presenterRegistry),
},
// LIST
{
key: { resource: "issued-invoice", projection: "LIST" },
presenter: new IssuedInvoiceListPresenter(presenterRegistry),
},
// REPORT
{
key: { resource: "issued-invoice", projection: "REPORT", format: "JSON" },
presenter: new IssuedInvoiceReportPresenter(presenterRegistry),
},
{
key: { resource: "issued-invoice-taxes", projection: "REPORT", format: "JSON" },
presenter: new IssuedInvoiceTaxesReportPresenter(presenterRegistry),
},
{
key: { resource: "issued-invoice-items", projection: "REPORT", format: "JSON" },
presenter: new IssuedInvoiceItemsReportPresenter(presenterRegistry),
},
{
key: { resource: "issued-invoice", projection: "REPORT", format: "HTML" },
presenter: new IssuedInvoiceReportHTMLPresenter(presenterRegistry),
},
{
key: { resource: "issued-invoice", projection: "REPORT", format: "PDF" },
presenter: new IssuedInvoiceReportPDFPresenter(presenterRegistry),
},
]);
const useCases: IssuedInvoicesDeps["useCases"] = {
// Issue Invoices
list_issued_invoices: () =>
new ListIssuedInvoicesUseCase(appService, transactionManager, presenterRegistry),
get_issued_invoice: () =>
new GetIssuedInvoiceUseCase(appService, transactionManager, presenterRegistry),
report_issued_invoice: () =>
new ReportIssuedInvoiceUseCase(appService, transactionManager, presenterRegistry),
};
return {
transactionManager,
repo: repository,
mapperRegistry,
presenterRegistry,
appService,
catalogs,
useCases,
};
}

View File

@ -14,7 +14,7 @@ import {
extractOrPushError, extractOrPushError,
maybeFromNullableVO, maybeFromNullableVO,
} from "@repo/rdx-ddd"; } from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils"; import { Maybe, Result } from "@repo/rdx-utils";
import { import {
CustomerInvoiceNumber, CustomerInvoiceNumber,
@ -22,10 +22,12 @@ import {
CustomerInvoiceStatus, CustomerInvoiceStatus,
InvoiceAmount, InvoiceAmount,
type InvoiceRecipient, type InvoiceRecipient,
type VerifactuRecord,
} from "../../../domain"; } from "../../../domain";
import type { CustomerInvoiceModel } from "../../sequelize"; import type { CustomerInvoiceModel } from "../../sequelize";
import { InvoiceRecipientListMapper } from "./invoice-recipient.list.mapper"; import { InvoiceRecipientListMapper } from "./invoice-recipient.list.mapper";
import { VerifactuRecordListMapper } from "./verifactu-record.list.mapper";
export type CustomerInvoiceListDTO = { export type CustomerInvoiceListDTO = {
id: UniqueID; id: UniqueID;
@ -57,6 +59,8 @@ export type CustomerInvoiceListDTO = {
taxableAmount: InvoiceAmount; taxableAmount: InvoiceAmount;
taxesAmount: InvoiceAmount; taxesAmount: InvoiceAmount;
totalAmount: InvoiceAmount; totalAmount: InvoiceAmount;
verifactu: Maybe<VerifactuRecord>;
}; };
export interface ICustomerInvoiceListMapper export interface ICustomerInvoiceListMapper
@ -67,10 +71,12 @@ export class CustomerInvoiceListMapper
implements ICustomerInvoiceListMapper implements ICustomerInvoiceListMapper
{ {
private _recipientMapper: InvoiceRecipientListMapper; private _recipientMapper: InvoiceRecipientListMapper;
private _verifactuMapper: VerifactuRecordListMapper;
constructor() { constructor() {
super(); super();
this._recipientMapper = new InvoiceRecipientListMapper(); this._recipientMapper = new InvoiceRecipientListMapper();
this._verifactuMapper = new VerifactuRecordListMapper();
} }
public mapToDTO( public mapToDTO(
@ -99,6 +105,21 @@ export class CustomerInvoiceListMapper
// 3) Taxes // 3) Taxes
const taxes = raw.taxes.map((tax) => tax.tax_code).join(", "); const taxes = raw.taxes.map((tax) => tax.tax_code).join(", ");
// 4) Verifactu record
let verifactu: Maybe<VerifactuRecord> = Maybe.none();
if (raw.verifactu) {
const verifactuResult = this._verifactuMapper.mapToDTO(raw.verifactu, { errors, ...params });
if (verifactuResult.isFailure) {
errors.push({
path: "verifactu",
message: verifactuResult.error.message,
});
} else {
verifactu = Maybe.some(verifactuResult.data);
}
}
// 5) Si hubo errores de mapeo, devolvemos colección de validación // 5) Si hubo errores de mapeo, devolvemos colección de validación
if (errors.length > 0) { if (errors.length > 0) {
return Result.fail( return Result.fail(
@ -133,6 +154,8 @@ export class CustomerInvoiceListMapper
taxableAmount: attributes.taxableAmount!, taxableAmount: attributes.taxableAmount!,
taxesAmount: attributes.taxesAmount!, taxesAmount: attributes.taxesAmount!,
totalAmount: attributes.totalAmount!, totalAmount: attributes.totalAmount!,
verifactu,
}); });
} }

View File

@ -1,3 +1,8 @@
import {
type IQueryMapperWithBulk,
type MapperParamsType,
SequelizeQueryMapper,
} from "@erp/core/api";
import { import {
City, City,
Country, Country,
@ -6,17 +11,16 @@ import {
Province, Province,
Street, Street,
TINNumber, TINNumber,
ValidationErrorDetail, type ValidationErrorDetail,
extractOrPushError, extractOrPushError,
maybeFromNullableVO, maybeFromNullableVO,
} from "@repo/rdx-ddd"; } from "@repo/rdx-ddd";
import type { Result } from "@repo/rdx-utils";
import { IQueryMapperWithBulk, MapperParamsType, SequelizeQueryMapper } from "@erp/core/api";
import { Result } from "@repo/rdx-utils";
import { InvoiceRecipient } from "../../../domain"; import { InvoiceRecipient } from "../../../domain";
import { CustomerInvoiceModel } from "../../sequelize"; import type { CustomerInvoiceModel } from "../../sequelize";
import { CustomerInvoiceListDTO } from "./customer-invoice.list.mapper";
import type { CustomerInvoiceListDTO } from "./customer-invoice.list.mapper";
interface IInvoiceRecipientListMapper interface IInvoiceRecipientListMapper
extends IQueryMapperWithBulk<CustomerInvoiceModel, InvoiceRecipient> {} extends IQueryMapperWithBulk<CustomerInvoiceModel, InvoiceRecipient> {}

View File

@ -0,0 +1,64 @@
import {
type ISequelizeQueryMapper,
type MapperParamsType,
SequelizeQueryMapper,
} from "@erp/core/api";
import {
URLAddress,
UniqueID,
ValidationErrorCollection,
type ValidationErrorDetail,
extractOrPushError,
maybeFromNullableVO,
} from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { VerifactuRecord, VerifactuRecordEstado } from "../../../domain/";
import type { VerifactuRecordModel } from "../../sequelize";
export interface IVerifactuRecordListMapper
extends ISequelizeQueryMapper<VerifactuRecordModel, VerifactuRecord> {
//
}
export class VerifactuRecordListMapper
extends SequelizeQueryMapper<VerifactuRecordModel, VerifactuRecord>
implements IVerifactuRecordListMapper
{
public mapToDTO(
raw: VerifactuRecordModel,
params?: MapperParamsType
): Result<VerifactuRecord, Error> {
const errors: ValidationErrorDetail[] = [];
const recordId = extractOrPushError(UniqueID.create(raw.id), "id", errors);
const estado = extractOrPushError(VerifactuRecordEstado.create(raw.estado), "estado", errors);
const qr = extractOrPushError(
maybeFromNullableVO(raw.qr, (value) => Result.ok(String(value))),
"qr",
errors
);
const url = extractOrPushError(
maybeFromNullableVO(raw.url, (value) => URLAddress.create(value)),
"url",
errors
);
if (errors.length > 0) {
return Result.fail(
new ValidationErrorCollection("Verifactu record mapping failed [mapToDTO]", errors)
);
}
return VerifactuRecord.create(
{
estado: estado!,
qrCode: qr!,
url: url!,
},
recordId!
);
}
}

View File

@ -12,31 +12,30 @@ import {
ChangeStatusProformaUseCase, ChangeStatusProformaUseCase,
CreateProformaUseCase, CreateProformaUseCase,
CustomerInvoiceApplicationService, CustomerInvoiceApplicationService,
CustomerInvoiceFullPresenter,
CustomerInvoiceItemsFullPresenter,
CustomerInvoiceItemsReportPersenter,
CustomerInvoiceReportHTMLPresenter,
CustomerInvoiceReportPDFPresenter,
CustomerInvoiceReportPresenter,
CustomerInvoiceTaxesReportPresenter,
DeleteProformaUseCase, DeleteProformaUseCase,
GetIssuedInvoiceUseCase,
GetProformaUseCase, GetProformaUseCase,
IssueProformaUseCase, IssueProformaUseCase,
ListCustomerInvoicesPresenter,
ListIssuedInvoicesUseCase,
ListProformasUseCase, ListProformasUseCase,
RecipientInvoiceFullPresenter, ProformaFullPresenter,
ReportIssuedInvoiceUseCase, ProformaListPresenter,
ProformaReportHTMLPresenter,
ProformaReportPDFPresenter,
ReportProformaUseCase, ReportProformaUseCase,
UpdateProformaUseCase, UpdateProformaUseCase,
} from "../application"; } from "../application";
import { ProformaItemsFullPresenter } from "../application/presenters/domain/proformas/proforma-items.full.presenter";
import { ProformaRecipientFullPresenter } from "../application/presenters/domain/proformas/proforma-recipient.full.presenter";
import {
IssuedInvoiceTaxesReportPresenter,
ProformaItemsReportPresenter,
ProformaReportPresenter,
} from "../application/presenters/reports";
import { CustomerInvoiceDomainMapper, CustomerInvoiceListMapper } from "./mappers"; import { CustomerInvoiceDomainMapper, CustomerInvoiceListMapper } from "./mappers";
import { CustomerInvoiceRepository } from "./sequelize"; import { CustomerInvoiceRepository } from "./sequelize";
import { SequelizeInvoiceNumberGenerator } from "./services"; import { SequelizeInvoiceNumberGenerator } from "./services";
export type CustomerInvoiceDeps = { export type ProformasDeps = {
transactionManager: SequelizeTransactionManager; transactionManager: SequelizeTransactionManager;
mapperRegistry: IMapperRegistry; mapperRegistry: IMapperRegistry;
presenterRegistry: IPresenterRegistry; presenterRegistry: IPresenterRegistry;
@ -54,14 +53,10 @@ export type CustomerInvoiceDeps = {
report_proforma: () => ReportProformaUseCase; report_proforma: () => ReportProformaUseCase;
issue_proforma: () => IssueProformaUseCase; issue_proforma: () => IssueProformaUseCase;
changeStatus_proforma: () => ChangeStatusProformaUseCase; changeStatus_proforma: () => ChangeStatusProformaUseCase;
list_issued_invoices: () => ListIssuedInvoicesUseCase;
get_issued_invoice: () => GetIssuedInvoiceUseCase;
report_issued_invoice: () => ReportIssuedInvoiceUseCase;
}; };
}; };
export function buildCustomerInvoiceDependencies(params: ModuleParams): CustomerInvoiceDeps { export function buildProformasDependencies(params: ModuleParams): ProformasDeps {
const { database } = params; const { database } = params;
/** Dominio */ /** Dominio */
@ -93,45 +88,50 @@ export function buildCustomerInvoiceDependencies(params: ModuleParams): Customer
// Presenter Registry // Presenter Registry
const presenterRegistry = new InMemoryPresenterRegistry(); const presenterRegistry = new InMemoryPresenterRegistry();
presenterRegistry.registerPresenters([ presenterRegistry.registerPresenters([
// FULL
{ {
key: { resource: "customer-invoice-items", projection: "FULL" }, key: { resource: "proforma-items", projection: "FULL" },
presenter: new CustomerInvoiceItemsFullPresenter(presenterRegistry), presenter: new ProformaItemsFullPresenter(presenterRegistry),
}, },
{ {
key: { resource: "recipient-invoice", projection: "FULL" }, key: { resource: "proforma-recipient", projection: "FULL" },
presenter: new RecipientInvoiceFullPresenter(presenterRegistry), presenter: new ProformaRecipientFullPresenter(presenterRegistry),
}, },
{ {
key: { resource: "customer-invoice", projection: "FULL" }, key: { resource: "proforma", projection: "FULL" },
presenter: new CustomerInvoiceFullPresenter(presenterRegistry), presenter: new ProformaFullPresenter(presenterRegistry),
},
// LIST
{
key: { resource: "proforma", projection: "LIST" },
presenter: new ProformaListPresenter(presenterRegistry),
},
// REPORT
{
key: { resource: "proforma", projection: "REPORT", format: "JSON" },
presenter: new ProformaReportPresenter(presenterRegistry),
}, },
{ {
key: { resource: "customer-invoice", projection: "LIST" }, key: { resource: "proforma-taxes", projection: "REPORT", format: "JSON" },
presenter: new ListCustomerInvoicesPresenter(presenterRegistry), presenter: new IssuedInvoiceTaxesReportPresenter(presenterRegistry),
}, },
{ {
key: { resource: "customer-invoice", projection: "REPORT", format: "JSON" }, key: { resource: "proforma-items", projection: "REPORT", format: "JSON" },
presenter: new CustomerInvoiceReportPresenter(presenterRegistry), presenter: new ProformaItemsReportPresenter(presenterRegistry),
}, },
{ {
key: { resource: "customer-invoice-taxes", projection: "REPORT", format: "JSON" }, key: { resource: "proforma", projection: "REPORT", format: "HTML" },
presenter: new CustomerInvoiceTaxesReportPresenter(presenterRegistry), presenter: new ProformaReportHTMLPresenter(presenterRegistry),
}, },
{ {
key: { resource: "customer-invoice-items", projection: "REPORT", format: "JSON" }, key: { resource: "proforma", projection: "REPORT", format: "PDF" },
presenter: new CustomerInvoiceItemsReportPersenter(presenterRegistry), presenter: new ProformaReportPDFPresenter(presenterRegistry),
},
{
key: { resource: "customer-invoice", projection: "REPORT", format: "HTML" },
presenter: new CustomerInvoiceReportHTMLPresenter(presenterRegistry),
},
{
key: { resource: "customer-invoice", projection: "REPORT", format: "PDF" },
presenter: new CustomerInvoiceReportPDFPresenter(presenterRegistry),
}, },
]); ]);
const useCases: CustomerInvoiceDeps["useCases"] = { const useCases: ProformasDeps["useCases"] = {
// Proformas // Proformas
list_proformas: () => list_proformas: () =>
new ListProformasUseCase(appService, transactionManager, presenterRegistry), new ListProformasUseCase(appService, transactionManager, presenterRegistry),
@ -146,14 +146,6 @@ export function buildCustomerInvoiceDependencies(params: ModuleParams): Customer
issue_proforma: () => issue_proforma: () =>
new IssueProformaUseCase(appService, transactionManager, presenterRegistry), new IssueProformaUseCase(appService, transactionManager, presenterRegistry),
changeStatus_proforma: () => new ChangeStatusProformaUseCase(appService, transactionManager), changeStatus_proforma: () => new ChangeStatusProformaUseCase(appService, transactionManager),
// Issue Invoices
list_issued_invoices: () =>
new ListIssuedInvoicesUseCase(appService, transactionManager, presenterRegistry),
get_issued_invoice: () =>
new GetIssuedInvoiceUseCase(appService, transactionManager, presenterRegistry),
report_issued_invoice: () =>
new ReportIssuedInvoiceUseCase(appService, transactionManager, presenterRegistry),
}; };
return { return {

View File

@ -24,6 +24,7 @@ import { CustomerInvoiceModel } from "./models/customer-invoice.model";
import { CustomerInvoiceItemModel } from "./models/customer-invoice-item.model"; import { CustomerInvoiceItemModel } from "./models/customer-invoice-item.model";
import { CustomerInvoiceItemTaxModel } from "./models/customer-invoice-item-tax.model"; import { CustomerInvoiceItemTaxModel } from "./models/customer-invoice-item-tax.model";
import { CustomerInvoiceTaxModel } from "./models/customer-invoice-tax.model"; import { CustomerInvoiceTaxModel } from "./models/customer-invoice-tax.model";
import { VerifactuRecordModel } from "./models/verifactu-record.model";
export class CustomerInvoiceRepository export class CustomerInvoiceRepository
extends SequelizeRepository<CustomerInvoice> extends SequelizeRepository<CustomerInvoice>
@ -119,8 +120,6 @@ export class CustomerInvoiceRepository
transaction, transaction,
}); });
console.log(affectedCount);
if (affectedCount === 0) { if (affectedCount === 0) {
return Result.fail( return Result.fail(
new InfrastructureRepositoryError(`Invoice ${id} not found or concurrency issue`) new InfrastructureRepositoryError(`Invoice ${id} not found or concurrency issue`)
@ -193,15 +192,15 @@ export class CustomerInvoiceRepository
/** /**
* *
* Busca una factura por su identificador único. * Busca una proforma por su identificador único.
* *
* @param companyId - Identificador UUID de la empresa a la que pertenece la factura. * @param companyId - Identificador UUID de la empresa a la que pertenece la proforma.
* @param id - UUID de la factura. * @param id - UUID de la proforma.
* @param transaction - Transacción activa para la operación. * @param transaction - Transacción activa para la operación.
* @param options - Opciones adicionales para la consulta (Sequelize FindOptions) * @param options - Opciones adicionales para la consulta (Sequelize FindOptions)
* @returns Result<CustomerInvoice, Error> * @returns Result<CustomerInvoice, Error>
*/ */
async getByIdInCompany( async getProformaByIdInCompany(
companyId: UniqueID, companyId: UniqueID,
id: UniqueID, id: UniqueID,
transaction: Transaction, transaction: Transaction,
@ -230,9 +229,10 @@ export class CustomerInvoiceRepository
const mergedOptions: FindOptions<InferAttributes<CustomerInvoiceModel>> = { const mergedOptions: FindOptions<InferAttributes<CustomerInvoiceModel>> = {
...options, ...options,
where: { where: {
id: id.toString(),
company_id: companyId.toString(),
...(options.where ?? {}), ...(options.where ?? {}),
id: id.toString(),
is_proforma: true,
company_id: companyId.toString(),
}, },
order: [ order: [
...normalizedOrder, ...normalizedOrder,
@ -281,7 +281,96 @@ export class CustomerInvoiceRepository
/** /**
* *
* Consulta facturas usando un objeto Criteria (filtros, orden, paginación). * Busca una factura por su identificador único.
*
* @param companyId - Identificador UUID de la empresa a la que pertenece la factura.
* @param id - UUID de la factura.
* @param transaction - Transacción activa para la operación.
* @param options - Opciones adicionales para la consulta (Sequelize FindOptions)
* @returns Result<CustomerInvoice, Error>
*/
async getIssuedInvoiceByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction: Transaction,
options: FindOptions<InferAttributes<CustomerInvoiceModel>> = {}
): Promise<Result<CustomerInvoice, Error>> {
const { CustomerModel } = this._database.models;
try {
const mapper: ICustomerInvoiceDomainMapper = this._registry.getDomainMapper({
resource: "customer-invoice",
});
// Normalización defensiva de order/include
const normalizedOrder = Array.isArray(options.order)
? options.order
: options.order
? [options.order]
: [];
const normalizedInclude = Array.isArray(options.include)
? options.include
: options.include
? [options.include]
: [];
const mergedOptions: FindOptions<InferAttributes<CustomerInvoiceModel>> = {
...options,
where: {
...(options.where ?? {}),
id: id.toString(),
is_proforma: false,
company_id: companyId.toString(),
},
order: [
...normalizedOrder,
[{ model: CustomerInvoiceItemModel, as: "items" }, "position", "ASC"],
],
include: [
...normalizedInclude,
{
model: CustomerModel,
as: "current_customer",
required: false,
},
{
model: CustomerInvoiceItemModel,
as: "items",
required: false,
include: [
{
model: CustomerInvoiceItemTaxModel,
as: "taxes",
required: false,
},
],
},
{
model: CustomerInvoiceTaxModel,
as: "taxes",
required: false,
},
],
transaction,
};
const row = await CustomerInvoiceModel.findOne(mergedOptions);
if (!row) {
return Result.fail(new EntityNotFoundError("CustomerInvoice", "id", id.toString()));
}
const invoice = mapper.mapToDomain(row);
return invoice;
} catch (err: unknown) {
return Result.fail(translateSequelizeError(err));
}
}
/**
*
* Consulta proformas usando un objeto Criteria (filtros, orden, paginación).
* *
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente. * @param companyId - Identificador UUID de la empresa a la que pertenece el cliente.
* @param criteria - Criterios de búsqueda. * @param criteria - Criterios de búsqueda.
@ -290,7 +379,7 @@ export class CustomerInvoiceRepository
* *
* @see Criteria * @see Criteria
*/ */
public async findByCriteriaInCompany( public async findProformasByCriteriaInCompany(
companyId: UniqueID, companyId: UniqueID,
criteria: Criteria, criteria: Criteria,
transaction: Transaction, transaction: Transaction,
@ -331,9 +420,10 @@ export class CustomerInvoiceRepository
query.where = { query.where = {
...query.where, ...query.where,
...(options.where ?? {}),
is_proforma: true,
company_id: companyId.toString(), company_id: companyId.toString(),
deleted_at: null, deleted_at: null,
...(options.where ?? {}),
}; };
query.order = [...(query.order as OrderItem[]), ...normalizedOrder]; query.order = [...(query.order as OrderItem[]), ...normalizedOrder];
@ -389,6 +479,123 @@ export class CustomerInvoiceRepository
} }
} }
/**
*
* Consulta facturas usando un objeto Criteria (filtros, orden, paginación).
*
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente.
* @param criteria - Criterios de búsqueda.
* @param transaction - Transacción activa para la operación.
* @returns Result<CustomerInvoice[], Error>
*
* @see Criteria
*/
public async findIssuedInvoicesByCriteriaInCompany(
companyId: UniqueID,
criteria: Criteria,
transaction: Transaction,
options: FindOptions<InferAttributes<CustomerInvoiceModel>> = {}
): Promise<Result<Collection<CustomerInvoiceListDTO>, Error>> {
const { CustomerModel } = this._database.models;
try {
const mapper: ICustomerInvoiceListMapper = this._registry.getQueryMapper({
resource: "customer-invoice",
query: "LIST",
});
const converter = new CriteriaToSequelizeConverter();
const query = converter.convert(criteria, {
searchableFields: ["invoice_number", "reference", "description"],
mappings: {
reference: "CustomerInvoiceModel.reference",
},
allowedFields: ["invoice_date", "id", "created_at"],
enableFullText: true,
database: this._database,
strictMode: true, // fuerza error si ORDER BY no permitido
});
// Normalización defensiva de order/include
const normalizedOrder = Array.isArray(options.order)
? options.order
: options.order
? [options.order]
: [];
const normalizedInclude = Array.isArray(options.include)
? options.include
: options.include
? [options.include]
: [];
query.where = {
...query.where,
...(options.where ?? {}),
is_proforma: false,
company_id: companyId.toString(),
deleted_at: null,
};
query.order = [...(query.order as OrderItem[]), ...normalizedOrder];
query.include = [
...normalizedInclude,
{
model: VerifactuRecordModel,
as: "verifactu",
required: false,
attributes: ["id", "estado", "url", "uuid"],
},
{
model: CustomerModel,
as: "current_customer",
required: false, // false => LEFT JOIN
attributes: [
"name",
"trade_name",
"tin",
"street",
"street2",
"city",
"postal_code",
"province",
"country",
],
},
{
model: CustomerInvoiceTaxModel,
as: "taxes",
required: false,
separate: true, // => query aparte, devuelve siempre array
attributes: ["tax_id", "tax_code"],
},
];
// Reemplazar findAndCountAll por findAll + count (más control y mejor rendimiento)
/*const { rows, count } = await CustomerInvoiceModel.findAndCountAll({
...query,
transaction,
});*/
const [rows, count] = await Promise.all([
CustomerInvoiceModel.findAll({
...query,
transaction,
}),
CustomerInvoiceModel.count({
where: query.where,
distinct: true, // evita duplicados por LEFT JOIN
transaction,
}),
]);
return mapper.mapToDTOCollection(rows, count);
} catch (err: unknown) {
return Result.fail(translateSequelizeError(err));
}
}
/** /**
* *
* Elimina o marca como eliminada una proforma dentro de una empresa. * Elimina o marca como eliminada una proforma dentro de una empresa.

View File

@ -2,6 +2,7 @@ import customerInvoiceModelInit from "./models/customer-invoice.model";
import customerInvoiceItemModelInit from "./models/customer-invoice-item.model"; import customerInvoiceItemModelInit from "./models/customer-invoice-item.model";
import customerInvoiceItemTaxesModelInit from "./models/customer-invoice-item-tax.model"; import customerInvoiceItemTaxesModelInit from "./models/customer-invoice-item-tax.model";
import customerInvoiceTaxesModelInit from "./models/customer-invoice-tax.model"; import customerInvoiceTaxesModelInit from "./models/customer-invoice-tax.model";
import verifactuRecordModelInit from "./models/verifactu-record.model";
export * from "./customer-invoice.repository"; export * from "./customer-invoice.repository";
export * from "./models"; export * from "./models";
@ -13,4 +14,6 @@ export const models = [
customerInvoiceTaxesModelInit, customerInvoiceTaxesModelInit,
customerInvoiceItemTaxesModelInit, customerInvoiceItemTaxesModelInit,
verifactuRecordModelInit,
]; ];

View File

@ -55,7 +55,9 @@ export class CustomerInvoiceTaxModel extends Model<
}); });
} }
static hooks(_database: Sequelize) {} static hooks(_database: Sequelize) {
//
}
} }
export default (database: Sequelize) => { export default (database: Sequelize) => {

View File

@ -17,6 +17,7 @@ import type {
CustomerInvoiceTaxCreationAttributes, CustomerInvoiceTaxCreationAttributes,
CustomerInvoiceTaxModel, CustomerInvoiceTaxModel,
} from "./customer-invoice-tax.model"; } from "./customer-invoice-tax.model";
import type { VerifactuRecordModel } from "./verifactu-record.model";
export type CustomerInvoiceCreationAttributes = InferCreationAttributes< export type CustomerInvoiceCreationAttributes = InferCreationAttributes<
CustomerInvoiceModel, CustomerInvoiceModel,
@ -101,6 +102,7 @@ export class CustomerInvoiceModel extends Model<
declare items: NonAttribute<CustomerInvoiceItemModel[]>; declare items: NonAttribute<CustomerInvoiceItemModel[]>;
declare taxes: NonAttribute<CustomerInvoiceTaxModel[]>; declare taxes: NonAttribute<CustomerInvoiceTaxModel[]>;
declare current_customer: NonAttribute<CustomerModel>; declare current_customer: NonAttribute<CustomerModel>;
declare verifactu: NonAttribute<VerifactuRecordModel>;
static associate(database: Sequelize) { static associate(database: Sequelize) {
const models = database.models; const models = database.models;
@ -109,6 +111,7 @@ export class CustomerInvoiceModel extends Model<
"CustomerInvoiceItemModel", "CustomerInvoiceItemModel",
"CustomerModel", "CustomerModel",
"CustomerInvoiceTaxModel", "CustomerInvoiceTaxModel",
"VerifactuRecordModel",
]; ];
// Comprobamos que los modelos existan // Comprobamos que los modelos existan
@ -124,8 +127,14 @@ export class CustomerInvoiceModel extends Model<
CustomerInvoiceItemModel, CustomerInvoiceItemModel,
CustomerModel, CustomerModel,
CustomerInvoiceTaxModel, CustomerInvoiceTaxModel,
VerifactuRecordModel,
} = models; } = models;
CustomerInvoiceModel.hasOne(VerifactuRecordModel, {
as: "verifactu",
foreignKey: "invoice_id",
});
CustomerInvoiceModel.belongsTo(CustomerModel, { CustomerInvoiceModel.belongsTo(CustomerModel, {
as: "current_customer", as: "current_customer",
foreignKey: "customer_id", foreignKey: "customer_id",
@ -151,7 +160,9 @@ export class CustomerInvoiceModel extends Model<
}); });
} }
static hooks(_database: Sequelize) {} static hooks(_database: Sequelize) {
//
}
} }
export default (database: Sequelize) => { export default (database: Sequelize) => {

View File

@ -1,4 +1,5 @@
export * from "./customer-invoice-item-tax.model";
export * from "./customer-invoice-item.model";
export * from "./customer-invoice-tax.model";
export * from "./customer-invoice.model"; export * from "./customer-invoice.model";
export * from "./customer-invoice-item.model";
export * from "./customer-invoice-item-tax.model";
export * from "./customer-invoice-tax.model";
export * from "./verifactu-record.model";

View File

@ -1,9 +1,10 @@
import { DataTypes, InferAttributes, InferCreationAttributes, Model, Sequelize } from "sequelize"; import {
/*import { DataTypes,
CustomerInvoiceItemTaxCreationAttributes, type InferAttributes,
CustomerInvoiceItemTaxModel, type InferCreationAttributes,
} from "./customer-invoice-item-tax.model"; Model,
*/ type Sequelize,
} from "sequelize";
export type VerifactuRecordCreationAttributes = InferCreationAttributes<VerifactuRecordModel>; export type VerifactuRecordCreationAttributes = InferCreationAttributes<VerifactuRecordModel>;
@ -22,16 +23,30 @@ export class VerifactuRecordModel extends Model<
declare operacion: string; declare operacion: string;
static associate(database: Sequelize) { static associate(database: Sequelize) {
const { CustomerInvoiceModel } = database.models; const models = database.models;
const requiredModels = ["CustomerInvoiceModel"];
// Comprobamos que los modelos existan
for (const name of requiredModels) {
if (!models[name]) {
throw new Error(`[VerifactuRecordModel.associate] Missing model: ${name}`);
}
}
const { CustomerInvoiceModel } = models;
VerifactuRecordModel.belongsTo(CustomerInvoiceModel, { VerifactuRecordModel.belongsTo(CustomerInvoiceModel, {
as: "verifactu_records", as: "verifactu",
targetKey: "id", targetKey: "id",
foreignKey: "invoice_id", foreignKey: "invoice_id",
onDelete: "CASCADE", onDelete: "CASCADE",
onUpdate: "CASCADE", onUpdate: "CASCADE",
}); });
} }
static hooks(_database: Sequelize) {
//
}
} }
export default (database: Sequelize) => { export default (database: Sequelize) => {
@ -76,11 +91,18 @@ export default (database: Sequelize) => {
}, },
{ {
sequelize: database, sequelize: database,
modelName: "VerifactuRecordModel",
tableName: "verifactu_records", tableName: "verifactu_records",
underscored: true, underscored: true,
paranoid: true, // softs deletes
timestamps: true,
indexes: [], createdAt: "created_at",
updatedAt: "updated_at",
deletedAt: "deleted_at",
indexes: [{ name: "idx_invoice_id", fields: ["invoice_id"] }], // <- para relación con CustomerInvoiceModel
whereMergeStrategy: "and", // <- cómo tratar el merge de un scope whereMergeStrategy: "and", // <- cómo tratar el merge de un scope

View File

@ -56,6 +56,12 @@ export const GetIssuedInvoiceByIdResponseSchema = z.object({
taxes_amount: MoneySchema, taxes_amount: MoneySchema,
total_amount: MoneySchema, total_amount: MoneySchema,
verifactu: z.object({
status: z.string(),
url: z.string(),
qr_code: z.string(),
}),
items: z.array( items: z.array(
z.object({ z.object({
id: z.uuid(), id: z.uuid(),

View File

@ -47,6 +47,12 @@ export const ListIssuedInvoicesResponseSchema = createPaginatedListSchema(
taxes_amount: MoneySchema, taxes_amount: MoneySchema,
total_amount: MoneySchema, total_amount: MoneySchema,
verifactu: z.object({
status: z.string(),
url: z.string(),
qr_code: z.string(),
}),
metadata: MetadataSchema.optional(), metadata: MetadataSchema.optional(),
}) })
); );

View File

@ -35,6 +35,19 @@
"rejected": "Rejected", "rejected": "Rejected",
"issued": "Issued" "issued": "Issued"
} }
},
"issued_invoices": {
"status": {
"all": "Todos",
"pendiente": "Pendiente",
"aceptado_con_error": "Aceptado con error",
"incorrecto": "Incorrecto",
"duplicado": "Duplicado",
"anulado": "Anulado",
"factura_inexistente": "Factura inexistente",
"rechazado": "Rechazado",
"error": "Error"
}
} }
}, },
"pages": { "pages": {
@ -90,6 +103,10 @@
"title": "Customer invoices", "title": "Customer invoices",
"description": "List all customer invoices", "description": "List all customer invoices",
"grid_columns": { "grid_columns": {
"series_invoice_number": "Serie & #",
"verifactu_status": "Status",
"verifactu_qr_code": "QR Code",
"verifactu_url": "URL",
"invoice_number": "Inv. number", "invoice_number": "Inv. number",
"series": "Serie", "series": "Serie",
"reference": "Reference", "reference": "Reference",

View File

@ -34,6 +34,19 @@
"rejected": "Rechazadas", "rejected": "Rechazadas",
"issued": "Emitidas" "issued": "Emitidas"
} }
},
"issued_invoices": {
"status": {
"all": "All",
"pendiente": "Pendiente",
"aceptado_con_error": "Aceptado con error",
"incorrecto": "Incorrecto",
"duplicado": "Duplicado",
"anulado": "Anulado",
"factura_inexistente": "Factura inexistente",
"rechazado": "Rechazado",
"error": "Error"
}
} }
}, },
"pages": { "pages": {
@ -89,10 +102,14 @@
"title": "Facturas de cliente", "title": "Facturas de cliente",
"description": "Lista todas las facturas de cliente", "description": "Lista todas las facturas de cliente",
"grid_columns": { "grid_columns": {
"series_invoice_number": "Serie & #",
"verifactu_status": "Estado",
"verifactu_qr_code": "Código QR",
"verifactu_url": "URL",
"invoice_number": "Nº factura", "invoice_number": "Nº factura",
"series": "Serie", "series": "Serie",
"reference": "Reference", "reference": "Reference",
"invoice_date": "Fecha de proforma", "invoice_date": "Fecha de factura",
"operation_date": "Fecha de operación", "operation_date": "Fecha de operación",
"recipient": "Cliente", "recipient": "Cliente",
"recipient_tin": "NIF/CIF", "recipient_tin": "NIF/CIF",

View File

@ -49,14 +49,73 @@ export function useIssuedInvoicesGridColumns(
), ),
enableHiding: false, enableHiding: false,
enableSorting: false, enableSorting: false,
maxSize: 48, maxSize: 64,
size: 48, size: 64,
minSize: 48, minSize: 64,
meta: { meta: {
title: t("pages.issued_invoices.list.grid_columns.series_invoice_number"), title: t("pages.issued_invoices.list.grid_columns.series_invoice_number"),
}, },
}, },
{
id: "verifactu_status",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("pages.issued_invoices.list.grid_columns.verifactu_status")}
/>
),
accessorFn: (row) => row.verifactu.status, // para ordenar/buscar por nombre
enableHiding: false,
enableSorting: false,
size: 140,
minSize: 120,
meta: {
title: t("pages.issued_invoices.list.grid_columns.verifactu_status"),
},
},
{
id: "verifactu_qr_code",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("pages.issued_invoices.list.grid_columns.verifactu_qr_code")}
/>
),
accessorFn: (row) => row.verifactu.qr_code, // para ordenar/buscar por nombre
cell: ({ row }) => (
<div className="font-medium text-left">{row.original.verifactu.qr_code}</div>
),
enableHiding: false,
enableSorting: false,
size: 140,
minSize: 120,
meta: {
title: t("pages.issued_invoices.list.grid_columns.verifactu_qr_code"),
},
},
{
id: "verifactu_url",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("pages.issued_invoices.list.grid_columns.verifactu_url")}
/>
),
accessorFn: (row) => row.verifactu.url, // para ordenar/buscar por nombre
cell: ({ row }) => (
<div className="font-medium text-left">{row.original.verifactu.url}</div>
),
enableHiding: false,
enableSorting: false,
size: 140,
minSize: 120,
meta: {
title: t("pages.issued_invoices.list.grid_columns.verifactu_url"),
},
},
{ {
id: "recipient", id: "recipient",
header: ({ column }) => ( header: ({ column }) => (

View File

@ -1,5 +1,13 @@
import { SimpleSearchInput } from "@erp/core/components"; import { SimpleSearchInput } from "@erp/core/components";
import { DataTable, SkeletonDataTable } from "@repo/rdx-ui/components"; import { DataTable, SkeletonDataTable } from "@repo/rdx-ui/components";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@repo/shadcn-ui/components";
import { FilterIcon } from "lucide-react";
import { useTranslation } from "../../../../i18n"; import { useTranslation } from "../../../../i18n";
import type { IssuedInvoiceSummaryPageData } from "../../../schema/issued-invoice-summary.web.schema"; import type { IssuedInvoiceSummaryPageData } from "../../../schema/issued-invoice-summary.web.schema";
@ -52,6 +60,35 @@ export const IssuedInvoicesGrid = ({
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex flex-col sm:flex-row gap-4"> <div className="flex flex-col sm:flex-row gap-4">
<SimpleSearchInput loading={loading} onSearchChange={onSearchChange} /> <SimpleSearchInput loading={loading} onSearchChange={onSearchChange} />
<Select defaultValue="all">
<SelectTrigger className="w-full sm:w-48">
<FilterIcon aria-hidden className="mr-2 size-4" />
<SelectValue placeholder={t("filters.status")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("catalog.issued_invoices.status.all")}</SelectItem>
<SelectItem value="pendiente">
{t("catalog.issued_invoices.status.pendiente")}
</SelectItem>
<SelectItem value="aceptado_con_error">
{t("catalog.issued_invoices.status.aceptado_con_error")}
</SelectItem>
<SelectItem value="incorrecto">
{t("catalog.issued_invoices.status.incorrecto")}
</SelectItem>
<SelectItem value="duplicado">
{t("catalog.issued_invoices.status.duplicado")}
</SelectItem>
<SelectItem value="anulado">{t("catalog.issued_invoices.status.anulado")}</SelectItem>
<SelectItem value="factura_inexistente">
{t("catalog.issued_invoices.status.factura_inexistente")}
</SelectItem>
<SelectItem value="rechazado">
{t("catalog.issued_invoices.status.rechazado")}
</SelectItem>
<SelectItem value="error">{t("catalog.issued_invoices.status.error")}</SelectItem>
</SelectContent>
</Select>
</div> </div>
<DataTable <DataTable

View File

@ -12,7 +12,6 @@ import type { ComponentProps } from "react";
import { useFormContext, useWatch } from "react-hook-form"; import { useFormContext, useWatch } from "react-hook-form";
import { useTranslation } from "../../../../../i18n"; import { useTranslation } from "../../../../../i18n";
import { PercentageInputField } from "../../../../../shared/ui/";
import type { ProformaFormData } from "../../../../schema"; import type { ProformaFormData } from "../../../../schema";
import { useProformaContext } from "../../context"; import { useProformaContext } from "../../context";

View File

@ -0,0 +1,56 @@
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@repo/shadcn-ui/components";
import type { Control, FieldPath, FieldValues } from "react-hook-form";
import { AmountInput, type AmountInputProps } from "./amount-input";
type AmountInputFieldProps<T extends FieldValues> = {
inputId?: string;
control: Control<T>;
name: FieldPath<T>;
label?: string;
description?: string;
required?: boolean;
} & Omit<AmountInputProps, "value" | "onChange">;
export function AmountInputField<T extends FieldValues>({
inputId,
control,
name,
label,
description,
required = false,
...inputProps
}: AmountInputFieldProps<T>) {
return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem>
{label ? (
<FormLabel htmlFor={inputId}>
{label} {required ? <span aria-hidden="true">*</span> : null}
</FormLabel>
) : null}
<FormControl>
<AmountInput
id={inputId}
onChange={field.onChange}
value={field.value ?? ""}
{...inputProps}
/>
</FormControl>
{description ? <FormDescription>{description}</FormDescription> : null}
<FormMessage />
</FormItem>
)}
/>
);
}

View File

@ -0,0 +1,233 @@
import { formatCurrency } from "@erp/core";
import { useMoney } from "@erp/core/hooks";
import { Input } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import * as React from "react";
import {
type InputEmptyMode,
type InputReadOnlyMode,
findFocusableInCell,
focusAndSelect,
} from "./input-utils";
export type AmountInputProps = {
value: number | string; // "" → no mostrar nada; string puede venir con separadores
onChange: (next: number | string) => void;
readOnly?: boolean;
readOnlyMode?: InputReadOnlyMode; // default "textlike-input"
id?: string;
"aria-label"?: string;
step?: number; // ↑/↓; default 0.01
emptyMode?: InputEmptyMode; // cómo presentar vacío
emptyText?: string; // texto en vacío para value/placeholder
scale?: number; // decimales; default 2 (ej. 4 para unit_amount)
languageCode?: string; // p.ej. "es-ES"
currencyCode?: string; // p.ej. "EUR"
className?: string;
};
export function AmountInput({
value,
onChange,
readOnly = false,
readOnlyMode = "textlike-input",
id,
"aria-label": ariaLabel = "Amount",
emptyMode = "blank",
emptyText = "",
scale = 2,
languageCode = "es",
currencyCode = "EUR",
className,
...inputProps
}: AmountInputProps) {
// Hook de dinero para parseo/redondeo consistente con el resto de la app
const { parse, roundToScale } = useMoney({
locale: languageCode,
fallbackCurrency: currencyCode as any,
});
const [raw, setRaw] = React.useState<string>("");
const [focused, setFocused] = React.useState(false);
const formatCurrencyNumber = React.useCallback(
(n: number) => formatCurrency(n, scale, currencyCode, languageCode),
[languageCode, currencyCode, scale]
);
// Derivar texto visual desde prop `value`
const visualText = React.useMemo(() => {
if (value === "" || value == null) {
return emptyMode === "value" ? emptyText : "";
}
const numeric =
typeof value === "number"
? value
: (parse(String(value)) ??
Number(
String(value)
.replace(/[^\d.,-]/g, "")
.replace(/\./g, "")
.replace(",", ".")
));
if (!Number.isFinite(numeric)) return emptyMode === "value" ? emptyText : "";
const n = roundToScale(numeric, scale);
return formatCurrencyNumber(n);
}, [value, emptyMode, emptyText, parse, roundToScale, scale, formatCurrencyNumber]);
const isShowingEmptyValue = emptyMode === "value" && raw === emptyText;
// Sin foco → mantener visual
React.useEffect(() => {
if (!focused) setRaw(visualText);
}, [visualText, focused]);
const handleChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setRaw(e.currentTarget.value);
}, []);
const handleFocus = React.useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
setFocused(true);
// pasar de visual con símbolo → crudo
if (emptyMode === "value" && e.currentTarget.value === emptyText) {
setRaw("");
return;
}
const current =
parse(e.currentTarget.value) ??
(value === "" || value == null
? null
: typeof value === "number"
? value
: parse(String(value)));
setRaw(current !== null && current !== undefined ? String(current) : "");
},
[emptyMode, emptyText, parse, value]
);
const handleBlur = React.useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
setFocused(false);
const txt = e.currentTarget.value.trim();
if (txt === "" || isShowingEmptyValue) {
onChange("");
setRaw(emptyMode === "value" ? emptyText : "");
return;
}
const n = parse(txt);
if (n === null) {
onChange("");
setRaw(emptyMode === "value" ? emptyText : "");
return;
}
const rounded = roundToScale(n, scale);
onChange(rounded);
setRaw(formatCurrencyNumber(rounded)); // vuelve a visual con símbolo
},
[
isShowingEmptyValue,
onChange,
emptyMode,
emptyText,
parse,
roundToScale,
scale,
formatCurrencyNumber,
]
);
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLElement>) => {
if (readOnly) return;
const keys = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"];
if (!keys.includes(e.key)) return;
e.preventDefault();
const current = e.currentTarget as HTMLElement;
const rowIndex = Number(current.dataset.rowIndex);
const colIndex = Number(current.dataset.colIndex);
let nextRow = rowIndex;
let nextCol = colIndex;
switch (e.key) {
case "ArrowUp":
nextRow--;
break;
case "ArrowDown":
nextRow++;
break;
case "ArrowLeft":
nextCol--;
break;
case "ArrowRight":
nextCol++;
break;
}
const nextElement = findFocusableInCell(nextRow, nextCol);
console.log(nextElement);
if (nextElement) {
focusAndSelect(nextElement);
}
},
[readOnly]
);
const handleBlock = React.useCallback((e: React.SyntheticEvent<HTMLInputElement>) => {
e.preventDefault();
(e.target as HTMLInputElement).blur();
}, []);
if (readOnly && readOnlyMode === "textlike-input") {
return (
<Input
aria-label={ariaLabel}
className={cn(
"w-full bg-transparent p-0 text-right tabular-nums border-0 shadow-none",
"focus:outline-none focus:ring-0 [caret-color:transparent] cursor-default",
className
)}
id={id}
onFocus={handleBlock}
onKeyDown={(e) => e.preventDefault()}
onMouseDown={handleBlock}
readOnly
tabIndex={-1}
value={visualText}
{...inputProps}
/>
);
}
return (
<Input
aria-label={ariaLabel}
className={cn(
"w-full bg-transparent p-0 text-right tabular-nums h-8 px-1 shadow-none",
"border-none",
"focus:bg-background",
"focus-visible:border-ring focus-visible:ring-ring/20 focus-visible:ring-[2px]",
"hover:border hover:ring-ring/20 hover:ring-[2px]",
className
)}
id={id}
inputMode="decimal"
onBlur={handleBlur}
onChange={handleChange}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
pattern="[0-9]*[.,]?[0-9]*"
placeholder={
emptyMode === "placeholder" && (value === "" || value == null) ? emptyText : undefined
}
readOnly={readOnly}
value={raw}
{...inputProps}
/>
);
}

View File

@ -0,0 +1,3 @@
export * from "./amount-input-field";
export * from "./percentage-input-field";
export * from "./quantity-input-field";

View File

@ -1,14 +1,18 @@
export type InputEmptyMode = "blank" | "placeholder" | "value";
export type InputReadOnlyMode = "textlike-input" | "normal";
export type InputSuffixMap = { one: string; other: string; zero?: string };
// Selectores típicos de elementos que son editables o permite foco // Selectores típicos de elementos que son editables o permite foco
const FOCUSABLE_SELECTOR = [ const FOCUSABLE_SELECTOR = [
'[data-cell-focus]', // permite marcar manualmente el target dentro de la celda "[data-cell-focus]", // permite marcar manualmente el target dentro de la celda
'input:not([disabled])', "input:not([disabled])",
'textarea:not([disabled])', "textarea:not([disabled])",
'select:not([disabled])', "select:not([disabled])",
'[contenteditable="true"]', '[contenteditable="true"]',
'button:not([disabled])', "button:not([disabled])",
'a[href]', "a[href]",
'[tabindex]:not([tabindex="-1"])' '[tabindex]:not([tabindex="-1"])',
].join(','); ].join(",");
// Busca el elemento focuseable dentro de la "celda" destino. // Busca el elemento focuseable dentro de la "celda" destino.
// Puedes poner data-row-index / data-col-index en la propia celda <td> o en el control. // Puedes poner data-row-index / data-col-index en la propia celda <td> o en el control.
@ -16,9 +20,8 @@ const FOCUSABLE_SELECTOR = [
export function findFocusableInCell(row: number, col: number): HTMLElement | null { export function findFocusableInCell(row: number, col: number): HTMLElement | null {
// 1) ¿Hay un control que ya tenga los data-* directamente? // 1) ¿Hay un control que ya tenga los data-* directamente?
let el = let el = document.querySelector<HTMLElement>(
document.querySelector<HTMLElement>( `[data-row-index="${row}"][data-col-index="${col}"]${FOCUSABLE_SELECTOR.startsWith("[") ? "" : ""}`
`[data-row-index="${row}"][data-col-index="${col}"]${FOCUSABLE_SELECTOR.startsWith('[') ? '' : ''}`
); );
// Si lo anterior no funcionó o seleccionó un contenedor, intenta: // Si lo anterior no funcionó o seleccionó un contenedor, intenta:
@ -30,7 +33,9 @@ export function findFocusableInCell(row: number, col: number): HTMLElement | nul
if (!cell) return null; if (!cell) return null;
// 3) Dentro de la celda, busca el primer foco válido // 3) Dentro de la celda, busca el primer foco válido
el = cell.matches(FOCUSABLE_SELECTOR) ? cell : cell.querySelector<HTMLElement>(FOCUSABLE_SELECTOR); el = cell.matches(FOCUSABLE_SELECTOR)
? cell
: cell.querySelector<HTMLElement>(FOCUSABLE_SELECTOR);
} }
return el || null; return el || null;
@ -48,8 +53,8 @@ export function focusAndSelect(el: HTMLElement) {
// select() funciona en la mayoría; si es type="number", cae en setSelectionRange // select() funciona en la mayoría; si es type="number", cae en setSelectionRange
el.select?.(); el.select?.();
// Asegura selección completa si select() no aplica (p.ej. type="number") // Asegura selección completa si select() no aplica (p.ej. type="number")
if (typeof (el as any).setSelectionRange === 'function') { if (typeof (el as any).setSelectionRange === "function") {
const val = (el as any).value ?? ''; const val = (el as any).value ?? "";
(el as any).setSelectionRange(0, String(val).length); (el as any).setSelectionRange(0, String(val).length);
} }
} catch { } catch {

View File

@ -0,0 +1,56 @@
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@repo/shadcn-ui/components";
import type { Control, FieldPath, FieldValues } from "react-hook-form";
import { PercentageInput, type PercentageInputProps } from "./percentage-input";
type PercentageInputFieldProps<T extends FieldValues> = {
inputId?: string;
control: Control<T>;
name: FieldPath<T>;
label?: string;
description?: string;
required?: boolean;
} & Omit<PercentageInputProps, "value" | "onChange">;
export function PercentageInputField<T extends FieldValues>({
inputId,
control,
name,
label,
description,
required = false,
...inputProps
}: PercentageInputFieldProps<T>) {
return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem>
{label ? (
<FormLabel htmlFor={inputId}>
{label} {required ? <span aria-hidden="true">*</span> : null}
</FormLabel>
) : null}
<FormControl>
<PercentageInput
id={inputId}
onChange={field.onChange}
value={field.value}
{...inputProps}
/>
</FormControl>
{description ? <FormDescription>{description}</FormDescription> : null}
<FormMessage />
</FormItem>
)}
/>
);
}

View File

@ -0,0 +1,254 @@
import { Input } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import * as React from "react";
import {
type InputEmptyMode,
type InputReadOnlyMode,
findFocusableInCell,
focusAndSelect,
} from "./input-utils";
export type PercentageInputProps = {
value: number | "" | string; // "" → no mostrar nada; string puede venir con separadores
onChange: (next: number | "") => void;
readOnly?: boolean;
readOnlyMode?: InputReadOnlyMode; // default "textlike-input"
id?: string;
"aria-label"?: string;
step?: number; // ↑/↓; default 0.1
emptyMode?: InputEmptyMode; // cómo presentar vacío
emptyText?: string; // texto en vacío para value/placeholder
scale?: number; // decimales; default 2
min?: number; // default 0 (p. ej. descuentos)
max?: number; // default 100
showSuffix?: boolean; // “%” en visual; default true
locale?: string; // para formateo numérico
className?: string;
};
export function PercentageInput({
value,
onChange,
readOnly = false,
readOnlyMode = "textlike-input",
id,
"aria-label": ariaLabel = "Percentage",
step = 0.1,
emptyMode = "blank",
emptyText = "",
scale = 2,
min = 0,
max = 100,
showSuffix = true,
locale,
className,
...inputProps
}: PercentageInputProps) {
const stripNumberish = (s: string) => s.replace(/[^\d.,-]/g, "").trim();
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
const parseLocaleNumber = React.useCallback((raw: string): number | null => {
if (!raw) return null;
const s = stripNumberish(raw);
if (!s) return null;
const lastComma = s.lastIndexOf(",");
const lastDot = s.lastIndexOf(".");
let normalized = s;
if (lastComma > -1 && lastDot > -1) {
normalized =
lastComma > lastDot ? s.replace(/\./g, "").replace(",", ".") : s.replace(/,/g, "");
} else if (lastComma > -1) {
normalized = s.replace(",", ".");
}
const n = Number(normalized);
return Number.isFinite(n) ? n : null;
}, []);
const roundToScale = React.useCallback((n: number, sc: number) => {
const f = 10 ** sc;
return Math.round(n * f) / f;
}, []);
const clamp = React.useCallback((n: number) => Math.min(Math.max(n, min), max), [min, max]);
const [raw, setRaw] = React.useState<string>("");
const [focused, setFocused] = React.useState(false);
const formatVisual = React.useCallback(
(n: number) => {
const txt = new Intl.NumberFormat(locale ?? undefined, {
maximumFractionDigits: scale,
minimumFractionDigits: Number.isInteger(n) ? 0 : 0,
useGrouping: false,
}).format(n);
return showSuffix ? `${txt}%` : txt;
},
[locale, scale, showSuffix]
);
const visualText = React.useMemo(() => {
if (value === "" || value == null) {
return emptyMode === "value" ? emptyText : "";
}
const numeric = typeof value === "number" ? value : parseLocaleNumber(String(value));
if (!Number.isFinite(numeric as number)) return emptyMode === "value" ? emptyText : "";
const n = roundToScale(clamp(numeric as number), scale);
return formatVisual(n);
}, [value, emptyMode, emptyText, parseLocaleNumber, roundToScale, clamp, scale, formatVisual]);
const isShowingEmptyValue = emptyMode === "value" && raw === emptyText;
React.useEffect(() => {
if (!focused) setRaw(visualText);
}, [visualText, focused]);
const handleChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setRaw(e.currentTarget.value);
}, []);
const handleFocus = React.useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
setFocused(true);
if (emptyMode === "value" && e.currentTarget.value === emptyText) {
setRaw("");
return;
}
const n =
parseLocaleNumber(e.currentTarget.value) ??
(value === "" || value == null
? null
: typeof value === "number"
? value
: parseLocaleNumber(String(value)));
setRaw(n !== null && n !== undefined ? String(n) : "");
},
[emptyMode, emptyText, parseLocaleNumber, value]
);
const handleBlur = React.useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
setFocused(false);
const txt = e.currentTarget.value.trim().replace("%", "");
if (txt === "" || isShowingEmptyValue) {
onChange("");
setRaw(emptyMode === "value" ? emptyText : "");
return;
}
const parsed = parseLocaleNumber(txt);
if (parsed === null) {
onChange("");
setRaw(emptyMode === "value" ? emptyText : "");
return;
}
const rounded = roundToScale(clamp(parsed), scale);
onChange(rounded);
setRaw(formatVisual(rounded)); // vuelve a visual con %
},
[
isShowingEmptyValue,
onChange,
emptyMode,
emptyText,
parseLocaleNumber,
roundToScale,
clamp,
scale,
formatVisual,
]
);
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLElement>) => {
if (readOnly) return;
const keys = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"];
if (!keys.includes(e.key)) return;
e.preventDefault();
const current = e.currentTarget as HTMLElement;
const rowIndex = Number(current.dataset.rowIndex);
const colIndex = Number(current.dataset.colIndex);
let nextRow = rowIndex;
let nextCol = colIndex;
switch (e.key) {
case "ArrowUp":
nextRow--;
break;
case "ArrowDown":
nextRow++;
break;
case "ArrowLeft":
nextCol--;
break;
case "ArrowRight":
nextCol++;
break;
}
const nextElement = findFocusableInCell(nextRow, nextCol);
console.log(nextElement);
if (nextElement) {
focusAndSelect(nextElement);
}
},
[readOnly]
);
// Bloquear foco/edición en modo texto
const handleBlock = React.useCallback((e: React.SyntheticEvent<HTMLInputElement>) => {
e.preventDefault();
(e.target as HTMLInputElement).blur();
}, []);
if (readOnly && readOnlyMode === "textlike-input") {
return (
<Input
aria-label={ariaLabel}
className={cn(
"w-full bg-transparent p-0 text-right tabular-nums border-0 shadow-none",
"focus:outline-none focus:ring-0 [caret-color:transparent] cursor-default",
className
)}
id={id}
onFocus={handleBlock}
onKeyDown={(e) => e.preventDefault()}
onMouseDown={handleBlock}
readOnly
tabIndex={-1}
value={visualText}
{...inputProps}
/>
);
}
return (
<Input
aria-label={ariaLabel}
className={cn(
"w-full bg-transparent p-0 text-right tabular-nums h-8 px-1 shadow-none",
"border-none",
"focus:bg-background",
"focus-visible:border-ring focus-visible:ring-ring/20 focus-visible:ring-[2px]",
"hover:border hover:ring-ring/20 hover:ring-[2px]",
className
)}
id={id}
inputMode="decimal"
onBlur={handleBlur}
onChange={handleChange}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
pattern="[0-9]*[.,]?[0-9]*%?"
placeholder={
emptyMode === "placeholder" && (value === "" || value == null) ? emptyText : undefined
}
readOnly={readOnly}
value={raw}
{...inputProps}
/>
);
}

View File

@ -0,0 +1,56 @@
import type { CommonInputProps } from "@repo/rdx-ui/components";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@repo/shadcn-ui/components";
import type { Control, FieldPath, FieldValues } from "react-hook-form";
import { QuantityInput, type QuantityInputProps } from "./quantity-input";
type QuantityInputFieldProps<TFormValues extends FieldValues> = CommonInputProps & {
inputId?: string;
control: Control<TFormValues>;
name: FieldPath<TFormValues>;
label?: string;
description?: string;
required?: boolean;
} & Omit<QuantityInputProps, "value" | "onChange">;
export function QuantityInputField<TFormValues extends FieldValues>({
inputId,
control,
name,
label,
description,
required = false,
...inputProps
}: QuantityInputFieldProps<TFormValues>) {
return (
<FormField
control={control}
name={name}
render={({ field }) => {
const { value, onChange } = field;
return (
<FormItem>
{label ? (
<FormLabel htmlFor={inputId}>
{label} {required ? <span aria-hidden="true">*</span> : null}
</FormLabel>
) : null}
<FormControl>
<QuantityInput id={inputId} onChange={onChange} value={value} {...inputProps} />
</FormControl>
{description ? <FormDescription>{description}</FormDescription> : null}
<FormMessage />
</FormItem>
);
}}
/>
);
}

View File

@ -0,0 +1,269 @@
// QuantityNumberInput.tsx — valor primitivo (number | "" | string numérica)
// Comentarios en español. TS estricto.
import { useQuantity } from "@erp/core/hooks";
import { Input } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import * as React from "react";
import {
type InputEmptyMode,
type InputReadOnlyMode,
type InputSuffixMap,
findFocusableInCell,
focusAndSelect,
} from "./input-utils";
export type QuantityInputProps = {
value: number | "" | string; // "" → no mostrar nada; string puede venir con separadores
onChange: (next: number | "") => void;
readOnly?: boolean;
readOnlyMode?: InputReadOnlyMode;
id?: string;
"aria-label"?: string;
emptyMode?: InputEmptyMode; // cómo presentar vacío
emptyText?: string; // texto de vacío para value-mode/placeholder
scale?: number; // default 2
locale?: string; // para plural/sufijo y formateo
className?: string;
// Sufijo solo en visual, p.ej. {one:"caja", other:"cajas"}
displaySuffix?: InputSuffixMap | ((n: number) => string);
nbspBeforeSuffix?: boolean; // separador no rompible
};
export function QuantityInput({
value,
onChange,
readOnly = false,
readOnlyMode = "textlike-input",
id,
"aria-label": ariaLabel = "Quantity",
emptyMode = "blank",
emptyText = "",
scale = 2,
locale,
className,
displaySuffix,
nbspBeforeSuffix = true,
...inputProps
}: QuantityInputProps) {
const { parse, roundToScale } = useQuantity({ defaultScale: scale });
const [raw, setRaw] = React.useState<string>("");
const [focused, setFocused] = React.useState(false);
const plural = React.useMemo(() => new Intl.PluralRules(locale ?? undefined), [locale]);
const suffixFor = React.useCallback(
(n: number): string => {
if (!displaySuffix) return "";
if (typeof displaySuffix === "function") return displaySuffix(n);
const cat = plural.select(Math.abs(n));
if (n === 0 && displaySuffix.zero) return displaySuffix.zero;
return displaySuffix[cat as "one" | "other"] ?? displaySuffix.other;
},
[displaySuffix, plural]
);
const formatNumber = React.useCallback(
(n: number) => {
return new Intl.NumberFormat(locale ?? undefined, {
maximumFractionDigits: scale,
minimumFractionDigits: Number.isInteger(n) ? 0 : 0,
useGrouping: false,
}).format(n);
},
[locale, scale]
);
// Derivar texto visual desde prop `value`
const visualText = React.useMemo(() => {
if (value === "" || value === null || value === undefined) {
return emptyMode === "value" ? emptyText : "";
}
const numeric =
typeof value === "number"
? value
: (parse(String(value)) ?? Number(String(value).replaceAll(",", ""))); // tolera string numérico
if (!Number.isFinite(numeric)) return emptyMode === "value" ? emptyText : "";
const n = roundToScale(numeric, scale);
const numTxt = formatNumber(n);
const suf = suffixFor(n);
return suf ? `${numTxt}${nbspBeforeSuffix ? "\u00A0" : " "}${suf}` : numTxt;
}, [
value,
emptyMode,
emptyText,
parse,
roundToScale,
scale,
formatNumber,
suffixFor,
nbspBeforeSuffix,
]);
const isShowingEmptyValue = emptyMode === "value" && raw === emptyText;
// Sin foco → mantener visual
React.useEffect(() => {
if (!focused) setRaw(visualText);
}, [visualText, focused]);
const handleChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setRaw(e.currentTarget.value);
}, []);
const handleFocus = React.useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
setFocused(true);
if (emptyMode === "value" && e.currentTarget.value === emptyText) {
setRaw("");
return;
}
const n =
parse(e.currentTarget.value) ??
(value === "" || value == null
? null
: typeof value === "number"
? value
: parse(String(value)));
setRaw(n !== null && n !== undefined ? String(n) : "");
},
[emptyMode, emptyText, parse, value]
);
const handleBlur = React.useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
setFocused(false);
const txt = e.currentTarget.value.trim();
if (txt === "" || isShowingEmptyValue) {
onChange("");
setRaw(emptyMode === "value" ? emptyText : "");
return;
}
const n = parse(txt);
if (n === null) {
onChange("");
setRaw(emptyMode === "value" ? emptyText : "");
return;
}
const rounded = roundToScale(n, scale);
onChange(rounded);
const numTxt = formatNumber(rounded);
const suf = suffixFor(rounded);
setRaw(suf ? `${numTxt}${nbspBeforeSuffix ? "\u00A0" : " "}${suf}` : numTxt);
},
[
isShowingEmptyValue,
onChange,
emptyMode,
emptyText,
parse,
roundToScale,
scale,
formatNumber,
suffixFor,
nbspBeforeSuffix,
]
);
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLElement>) => {
if (readOnly) return;
const keys = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"];
if (!keys.includes(e.key)) return;
e.preventDefault();
const current = e.currentTarget as HTMLElement;
const rowIndex = Number(current.dataset.rowIndex);
const colIndex = Number(current.dataset.colIndex);
let nextRow = rowIndex;
let nextCol = colIndex;
switch (e.key) {
case "ArrowUp":
nextRow--;
break;
case "ArrowDown":
nextRow++;
break;
case "ArrowLeft":
nextCol--;
break;
case "ArrowRight":
nextCol++;
break;
}
const nextElement = findFocusableInCell(nextRow, nextCol);
console.log(nextElement);
if (nextElement) {
focusAndSelect(nextElement);
}
},
[readOnly]
);
// ── READ-ONLY como input que parece texto ───────────────────────────────
if (readOnly && readOnlyMode === "textlike-input") {
const handleBlockFocus = React.useCallback((e: React.SyntheticEvent<HTMLInputElement>) => {
e.preventDefault();
(e.target as HTMLInputElement).blur();
}, []);
const handleBlockKey = React.useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
e.preventDefault();
}, []);
return (
<Input
aria-label={ariaLabel}
className={cn(
"w-full bg-transparent p-0 text-right tabular-nums border-0 shadow-none",
"focus:outline-none focus:ring-0 [caret-color:transparent] cursor-default",
className
)}
id={id}
onFocus={handleBlockFocus}
onKeyDown={handleBlockKey}
onMouseDown={handleBlockFocus}
readOnly
tabIndex={-1}
value={visualText}
{...inputProps}
/>
);
}
// ── Editable / readOnly normal ──────────────────────────────────────────
return (
<Input
aria-label={ariaLabel}
className={cn(
"w-full bg-transparent p-0 text-right tabular-nums h-8 px-1 shadow-none",
"border-none",
"focus:bg-background",
"focus-visible:border-ring focus-visible:ring-ring/20 focus-visible:ring-[2px]",
"hover:border hover:ring-ring/20 hover:ring-[2px]",
className
)}
id={id}
inputMode="decimal"
onBlur={handleBlur}
onChange={handleChange}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
pattern="[0-9]*[.,]?[0-9]*"
placeholder={
emptyMode === "placeholder" && (value === "" || value == null) ? emptyText : undefined
}
readOnly={readOnly}
value={raw}
{...inputProps}
/>
);
}

View File

@ -1 +0,0 @@
export * from "./ui";

View File

@ -1,26 +0,0 @@
import { Button } from "@repo/shadcn-ui/components";
import { PlusCircleIcon } from "lucide-react";
import { type JSX, forwardRef } from "react";
import { useTranslation } from "../../../../i18n";
export interface AppendEmptyRowButtonProps extends React.ComponentProps<typeof Button> {
label?: string;
className?: string;
}
export const AppendEmptyRowButton = forwardRef<HTMLButtonElement, AppendEmptyRowButtonProps>(
({ label, className, ...props }: AppendEmptyRowButtonProps, ref): JSX.Element => {
const { t } = useTranslation();
const _label = label || t("common.append_empty_row");
return (
<Button ref={ref} type="button" variant="outline" {...props}>
<PlusCircleIcon className={_label ? "w-4 h-4 mr-2" : "w-4 h-4"} />
{_label && <>{_label}</>}
</Button>
);
}
);
AppendEmptyRowButton.displayName = "AppendEmptyRowButton";

View File

@ -1 +0,0 @@
export * from "./append-empty-row-button";

View File

@ -1,154 +0,0 @@
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import {
ArrowLeftIcon,
CopyIcon,
EyeIcon,
MoreHorizontalIcon,
RotateCcwIcon,
Trash2Icon,
} from "lucide-react";
import { useFormContext } from "react-hook-form";
import { CancelFormButton, CancelFormButtonProps } from "./cancel-form-button";
import { SubmitButtonProps, SubmitFormButton } from "./submit-form-button";
type Align = "start" | "center" | "end" | "between";
type GroupSubmitButtonProps = Omit<SubmitButtonProps, "isLoading" | "preventDoubleSubmit">;
export type FormCommitButtonGroupProps = {
className?: string;
align?: Align; // default "end"
gap?: string; // default "gap-2"
reverseOrderOnMobile?: boolean; // default true (Cancel debajo en móvil)
isLoading?: boolean;
disabled?: boolean;
preventDoubleSubmit?: boolean; // Evita múltiples submits mientras loading
cancel?: CancelFormButtonProps & { show?: boolean };
submit?: GroupSubmitButtonProps; // props directas a SubmitButton
onReset?: () => void;
onDelete?: () => void;
onPreview?: () => void;
onDuplicate?: () => void;
onBack?: () => void;
};
const alignToJustify: Record<Align, string> = {
start: "justify-start",
center: "justify-center",
end: "justify-end",
between: "justify-between",
};
export const FormCommitButtonGroup = ({
className,
align = "end",
gap = "gap-2",
reverseOrderOnMobile = true,
isLoading,
disabled = false,
preventDoubleSubmit = true,
cancel,
submit,
onReset,
onDelete,
onPreview,
onDuplicate,
onBack,
}: FormCommitButtonGroupProps) => {
const showCancel = cancel?.show ?? true;
const hasSecondaryActions = onReset || onPreview || onDuplicate || onBack || onDelete;
// ⛳️ RHF opcional: auto-detectar isSubmitting si no se pasó isLoading
let rhfIsSubmitting = false;
try {
const ctx = useFormContext();
rhfIsSubmitting = !!ctx?.formState?.isSubmitting;
} catch {
// No hay provider de RHF; ignorar
}
const busy = isLoading ?? rhfIsSubmitting;
const computedDisabled = !!(disabled || (preventDoubleSubmit && busy));
return (
<div
className={cn(
"flex",
reverseOrderOnMobile ? "flex-col-reverse sm:flex-row" : "flex-row",
alignToJustify[align],
gap,
className
)}
>
{submit && <SubmitFormButton {...submit} />}
{showCancel && <CancelFormButton {...cancel} />}
{/* Menú de acciones adicionales */}
{hasSecondaryActions && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='ghost' size='sm' disabled={computedDisabled} className='px-2'>
<MoreHorizontalIcon className='h-4 w-4' />
<span className='sr-only'>Más acciones</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-48'>
{onReset && (
<DropdownMenuItem
onClick={onReset}
disabled={computedDisabled}
className='text-muted-foreground'
>
<RotateCcwIcon className='mr-2 h-4 w-4' />
Deshacer cambios
</DropdownMenuItem>
)}
{onPreview && (
<DropdownMenuItem onClick={onPreview} className='text-muted-foreground'>
<EyeIcon className='mr-2 h-4 w-4' />
Vista previa
</DropdownMenuItem>
)}
{onDuplicate && (
<DropdownMenuItem onClick={onDuplicate} className='text-muted-foreground'>
<CopyIcon className='mr-2 h-4 w-4' />
Duplicar
</DropdownMenuItem>
)}
{onBack && (
<DropdownMenuItem onClick={onBack} className='text-muted-foreground'>
<ArrowLeftIcon className='mr-2 h-4 w-4' />
Volver
</DropdownMenuItem>
)}
{onDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={onDelete}
className='text-destructive focus:text-destructive'
>
<Trash2Icon className='mr-2 h-4 w-4' />
Eliminar
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
};

View File

@ -1,89 +0,0 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Separator,
} from "@repo/shadcn-ui/components";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "../../../i18n";
import { formatCurrency } from "../../../pages/create/utils";
export const CustomerInvoicePricesCard = () => {
const { t } = useTranslation();
const { register, formState, control, watch } = useFormContext();
/*const pricesWatch = useWatch({ control, name: ["subtotal_price", "discount", "tax"] });
const totals = calculateQuoteTotals(pricesWatch);
const subtotal_price = formatNumber(totals.subtotalPrice);
const discount_price = formatNumber(totals.discountPrice);
const tax_price = formatNumber(totals.taxesPrice);
const total_price = formatNumber(totals.totalPrice);*/
const currency_symbol = watch("currency");
return (
<Card>
<CardHeader>
<CardTitle>Impuestos y Totales</CardTitle>
<CardDescription>Configuración de impuestos y resumen de totales</CardDescription>
</CardHeader>
<CardContent className="flex flex-row items-end gap-2 p-4">
<div className="grid flex-1 h-16 grid-cols-1 auto-rows-max">
<div className="grid gap-1 font-semibold text-right text-muted-foreground">
<CardDescription className="text-sm">
{t("form_fields.subtotal_price.label")}
</CardDescription>
<CardTitle className="flex items-baseline justify-end text-2xl tabular-nums">
{formatCurrency(watch("subtotal_price.amount"), 2, watch("currency"))}
</CardTitle>
</div>
</div>
<Separator className="w-px h-16 mx-2" orientation="vertical" />
<div className="grid flex-1 h-16 grid-cols-2 gap-6 auto-rows-max">
<div className="grid gap-1 font-medium text-muted-foreground">
<CardDescription className="text-sm">{t("form_fields.discount.label")}</CardDescription>
</div>
<div className="grid gap-1 font-semibold text-muted-foreground">
<CardDescription className="text-sm text-right">
{t("form_fields.discount_price.label")}
</CardDescription>
<CardTitle className="flex items-baseline justify-end text-2xl tabular-nums">
{"-"} {formatCurrency(watch("discount_price.amount"), 2, watch("currency"))}
</CardTitle>
</div>
</div>
<Separator className="w-px h-16 mx-2" orientation="vertical" />
<div className="grid flex-1 h-16 grid-cols-2 gap-6 auto-rows-max">
<div className="grid gap-1 font-medium text-muted-foreground">
<CardDescription className="text-sm">{t("form_fields.tax.label")}</CardDescription>
</div>
<div className="grid gap-1 font-semibold text-muted-foreground">
<CardDescription className="text-sm text-right">
{t("form_fields.tax_price.label")}
</CardDescription>
<CardTitle className="flex items-baseline justify-end gap-1 text-2xl tabular-nums">
{formatCurrency(watch("tax_price.amount"), 2, watch("currency"))}
</CardTitle>
</div>
</div>{" "}
<Separator className="w-px h-16 mx-2" orientation="vertical" />
<div className="grid flex-1 h-16 grid-cols-1 auto-rows-max">
<div className="grid gap-0">
<CardDescription className="text-sm font-semibold text-right text-foreground">
{t("form_fields.total_price.label")}
</CardDescription>
<CardTitle className="flex items-baseline justify-end gap-1 text-3xl tabular-nums">
{formatCurrency(watch("total_price.amount"), 2, watch("currency"))}
</CardTitle>
</div>
</div>
</CardContent>
</Card>
);
};

View File

@ -1,614 +0,0 @@
[
{
"id": 1,
"header": "Cover page",
"type": "Cover page",
"status": "In Process",
"target": "18",
"limit": "5",
"reviewer": "Eddie Lake"
},
{
"id": 2,
"header": "Table of contents",
"type": "Table of contents",
"status": "Done",
"target": "29",
"limit": "24",
"reviewer": "Eddie Lake"
},
{
"id": 3,
"header": "Executive summary",
"type": "Narrative",
"status": "Done",
"target": "10",
"limit": "13",
"reviewer": "Eddie Lake"
},
{
"id": 4,
"header": "Technical approach",
"type": "Narrative",
"status": "Done",
"target": "27",
"limit": "23",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 5,
"header": "Design",
"type": "Narrative",
"status": "In Process",
"target": "2",
"limit": "16",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 6,
"header": "Capabilities",
"type": "Narrative",
"status": "In Process",
"target": "20",
"limit": "8",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 7,
"header": "Integration with existing systems",
"type": "Narrative",
"status": "In Process",
"target": "19",
"limit": "21",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 8,
"header": "Innovation and Advantages",
"type": "Narrative",
"status": "Done",
"target": "25",
"limit": "26",
"reviewer": "Assign reviewer"
},
{
"id": 9,
"header": "Overview of EMR's Innovative Solutions",
"type": "Technical content",
"status": "Done",
"target": "7",
"limit": "23",
"reviewer": "Assign reviewer"
},
{
"id": 10,
"header": "Advanced Algorithms and Machine Learning",
"type": "Narrative",
"status": "Done",
"target": "30",
"limit": "28",
"reviewer": "Assign reviewer"
},
{
"id": 11,
"header": "Adaptive Communication Protocols",
"type": "Narrative",
"status": "Done",
"target": "9",
"limit": "31",
"reviewer": "Assign reviewer"
},
{
"id": 12,
"header": "Advantages Over Current Technologies",
"type": "Narrative",
"status": "Done",
"target": "12",
"limit": "0",
"reviewer": "Assign reviewer"
},
{
"id": 13,
"header": "Past Performance",
"type": "Narrative",
"status": "Done",
"target": "22",
"limit": "33",
"reviewer": "Assign reviewer"
},
{
"id": 14,
"header": "Customer Feedback and Satisfaction Levels",
"type": "Narrative",
"status": "Done",
"target": "15",
"limit": "34",
"reviewer": "Assign reviewer"
},
{
"id": 15,
"header": "Implementation Challenges and Solutions",
"type": "Narrative",
"status": "Done",
"target": "3",
"limit": "35",
"reviewer": "Assign reviewer"
},
{
"id": 16,
"header": "Security Measures and Data Protection Policies",
"type": "Narrative",
"status": "In Process",
"target": "6",
"limit": "36",
"reviewer": "Assign reviewer"
},
{
"id": 17,
"header": "Scalability and Future Proofing",
"type": "Narrative",
"status": "Done",
"target": "4",
"limit": "37",
"reviewer": "Assign reviewer"
},
{
"id": 18,
"header": "Cost-Benefit Analysis",
"type": "Plain language",
"status": "Done",
"target": "14",
"limit": "38",
"reviewer": "Assign reviewer"
},
{
"id": 19,
"header": "User Training and Onboarding Experience",
"type": "Narrative",
"status": "Done",
"target": "17",
"limit": "39",
"reviewer": "Assign reviewer"
},
{
"id": 20,
"header": "Future Development Roadmap",
"type": "Narrative",
"status": "Done",
"target": "11",
"limit": "40",
"reviewer": "Assign reviewer"
},
{
"id": 21,
"header": "System Architecture Overview",
"type": "Technical content",
"status": "In Process",
"target": "24",
"limit": "18",
"reviewer": "Maya Johnson"
},
{
"id": 22,
"header": "Risk Management Plan",
"type": "Narrative",
"status": "Done",
"target": "15",
"limit": "22",
"reviewer": "Carlos Rodriguez"
},
{
"id": 23,
"header": "Compliance Documentation",
"type": "Legal",
"status": "In Process",
"target": "31",
"limit": "27",
"reviewer": "Sarah Chen"
},
{
"id": 24,
"header": "API Documentation",
"type": "Technical content",
"status": "Done",
"target": "8",
"limit": "12",
"reviewer": "Raj Patel"
},
{
"id": 25,
"header": "User Interface Mockups",
"type": "Visual",
"status": "In Process",
"target": "19",
"limit": "25",
"reviewer": "Leila Ahmadi"
},
{
"id": 26,
"header": "Database Schema",
"type": "Technical content",
"status": "Done",
"target": "22",
"limit": "20",
"reviewer": "Thomas Wilson"
},
{
"id": 27,
"header": "Testing Methodology",
"type": "Technical content",
"status": "In Process",
"target": "17",
"limit": "14",
"reviewer": "Assign reviewer"
},
{
"id": 28,
"header": "Deployment Strategy",
"type": "Narrative",
"status": "Done",
"target": "26",
"limit": "30",
"reviewer": "Eddie Lake"
},
{
"id": 29,
"header": "Budget Breakdown",
"type": "Financial",
"status": "In Process",
"target": "13",
"limit": "16",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 30,
"header": "Market Analysis",
"type": "Research",
"status": "Done",
"target": "29",
"limit": "32",
"reviewer": "Sophia Martinez"
},
{
"id": 31,
"header": "Competitor Comparison",
"type": "Research",
"status": "In Process",
"target": "21",
"limit": "19",
"reviewer": "Assign reviewer"
},
{
"id": 32,
"header": "Maintenance Plan",
"type": "Technical content",
"status": "Done",
"target": "16",
"limit": "23",
"reviewer": "Alex Thompson"
},
{
"id": 33,
"header": "User Personas",
"type": "Research",
"status": "In Process",
"target": "27",
"limit": "24",
"reviewer": "Nina Patel"
},
{
"id": 34,
"header": "Accessibility Compliance",
"type": "Legal",
"status": "Done",
"target": "18",
"limit": "21",
"reviewer": "Assign reviewer"
},
{
"id": 35,
"header": "Performance Metrics",
"type": "Technical content",
"status": "In Process",
"target": "23",
"limit": "26",
"reviewer": "David Kim"
},
{
"id": 36,
"header": "Disaster Recovery Plan",
"type": "Technical content",
"status": "Done",
"target": "14",
"limit": "17",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 37,
"header": "Third-party Integrations",
"type": "Technical content",
"status": "In Process",
"target": "25",
"limit": "28",
"reviewer": "Eddie Lake"
},
{
"id": 38,
"header": "User Feedback Summary",
"type": "Research",
"status": "Done",
"target": "20",
"limit": "15",
"reviewer": "Assign reviewer"
},
{
"id": 39,
"header": "Localization Strategy",
"type": "Narrative",
"status": "In Process",
"target": "12",
"limit": "19",
"reviewer": "Maria Garcia"
},
{
"id": 40,
"header": "Mobile Compatibility",
"type": "Technical content",
"status": "Done",
"target": "28",
"limit": "31",
"reviewer": "James Wilson"
},
{
"id": 41,
"header": "Data Migration Plan",
"type": "Technical content",
"status": "In Process",
"target": "19",
"limit": "22",
"reviewer": "Assign reviewer"
},
{
"id": 42,
"header": "Quality Assurance Protocols",
"type": "Technical content",
"status": "Done",
"target": "30",
"limit": "33",
"reviewer": "Priya Singh"
},
{
"id": 43,
"header": "Stakeholder Analysis",
"type": "Research",
"status": "In Process",
"target": "11",
"limit": "14",
"reviewer": "Eddie Lake"
},
{
"id": 44,
"header": "Environmental Impact Assessment",
"type": "Research",
"status": "Done",
"target": "24",
"limit": "27",
"reviewer": "Assign reviewer"
},
{
"id": 45,
"header": "Intellectual Property Rights",
"type": "Legal",
"status": "In Process",
"target": "17",
"limit": "20",
"reviewer": "Sarah Johnson"
},
{
"id": 46,
"header": "Customer Support Framework",
"type": "Narrative",
"status": "Done",
"target": "22",
"limit": "25",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 47,
"header": "Version Control Strategy",
"type": "Technical content",
"status": "In Process",
"target": "15",
"limit": "18",
"reviewer": "Assign reviewer"
},
{
"id": 48,
"header": "Continuous Integration Pipeline",
"type": "Technical content",
"status": "Done",
"target": "26",
"limit": "29",
"reviewer": "Michael Chen"
},
{
"id": 49,
"header": "Regulatory Compliance",
"type": "Legal",
"status": "In Process",
"target": "13",
"limit": "16",
"reviewer": "Assign reviewer"
},
{
"id": 50,
"header": "User Authentication System",
"type": "Technical content",
"status": "Done",
"target": "28",
"limit": "31",
"reviewer": "Eddie Lake"
},
{
"id": 51,
"header": "Data Analytics Framework",
"type": "Technical content",
"status": "In Process",
"target": "21",
"limit": "24",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 52,
"header": "Cloud Infrastructure",
"type": "Technical content",
"status": "Done",
"target": "16",
"limit": "19",
"reviewer": "Assign reviewer"
},
{
"id": 53,
"header": "Network Security Measures",
"type": "Technical content",
"status": "In Process",
"target": "29",
"limit": "32",
"reviewer": "Lisa Wong"
},
{
"id": 54,
"header": "Project Timeline",
"type": "Planning",
"status": "Done",
"target": "14",
"limit": "17",
"reviewer": "Eddie Lake"
},
{
"id": 55,
"header": "Resource Allocation",
"type": "Planning",
"status": "In Process",
"target": "27",
"limit": "30",
"reviewer": "Assign reviewer"
},
{
"id": 56,
"header": "Team Structure and Roles",
"type": "Planning",
"status": "Done",
"target": "20",
"limit": "23",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 57,
"header": "Communication Protocols",
"type": "Planning",
"status": "In Process",
"target": "15",
"limit": "18",
"reviewer": "Assign reviewer"
},
{
"id": 58,
"header": "Success Metrics",
"type": "Planning",
"status": "Done",
"target": "30",
"limit": "33",
"reviewer": "Eddie Lake"
},
{
"id": 59,
"header": "Internationalization Support",
"type": "Technical content",
"status": "In Process",
"target": "23",
"limit": "26",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 60,
"header": "Backup and Recovery Procedures",
"type": "Technical content",
"status": "Done",
"target": "18",
"limit": "21",
"reviewer": "Assign reviewer"
},
{
"id": 61,
"header": "Monitoring and Alerting System",
"type": "Technical content",
"status": "In Process",
"target": "25",
"limit": "28",
"reviewer": "Daniel Park"
},
{
"id": 62,
"header": "Code Review Guidelines",
"type": "Technical content",
"status": "Done",
"target": "12",
"limit": "15",
"reviewer": "Eddie Lake"
},
{
"id": 63,
"header": "Documentation Standards",
"type": "Technical content",
"status": "In Process",
"target": "27",
"limit": "30",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 64,
"header": "Release Management Process",
"type": "Planning",
"status": "Done",
"target": "22",
"limit": "25",
"reviewer": "Assign reviewer"
},
{
"id": 65,
"header": "Feature Prioritization Matrix",
"type": "Planning",
"status": "In Process",
"target": "19",
"limit": "22",
"reviewer": "Emma Davis"
},
{
"id": 66,
"header": "Technical Debt Assessment",
"type": "Technical content",
"status": "Done",
"target": "24",
"limit": "27",
"reviewer": "Eddie Lake"
},
{
"id": 67,
"header": "Capacity Planning",
"type": "Planning",
"status": "In Process",
"target": "21",
"limit": "24",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 68,
"header": "Service Level Agreements",
"type": "Legal",
"status": "Done",
"target": "26",
"limit": "29",
"reviewer": "Assign reviewer"
}
]

View File

@ -1 +0,0 @@
export * from "./items";

Some files were not shown because too many files have changed in this diff Show More