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 { 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 {
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.
*/
private _buildKey(key: PresenterKey): string {
const { resource, projection, format, version, locale } = key;
const { resource, projection, format, version, locale } = this._normalizeKey(key);
return [
resource.toLowerCase(),
projection.toLowerCase(),
@ -30,12 +31,12 @@ export class InMemoryPresenterRegistry implements IPresenterRegistry {
key: PresenterKey,
presenter: IPresenter<TSource, TOutput>
): void {
const exactKey = this._buildKey(this._normalizeKey(key));
const exactKey = this._buildKey(key);
this.registry.set(exactKey, presenter);
}
getPresenter<TSource, TOutput>(key: PresenterKey): IPresenter<TSource, TOutput> {
const exactKey = this._buildKey(this._normalizeKey(key));
const exactKey = this._buildKey(key);
// 1) Intentar clave exacta
if (this.registry.has(exactKey)) {
@ -86,7 +87,9 @@ export class InMemoryPresenterRegistry implements IPresenterRegistry {
registerPresenters(
presenters: Array<{ key: PresenterKey; presenter: IPresenter<any, any> }>
): this {
presenters.forEach(({ key, presenter }) => this._registerPresenter(key, presenter));
for (const { key, presenter } of presenters) {
this._registerPresenter(key, presenter);
}
return this;
}
}

View File

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

View File

@ -6,6 +6,7 @@ import {
ValidationError as SequelizeValidationError,
UniqueConstraintError,
} from "sequelize";
import { DuplicateEntityError, EntityNotFoundError } from "../../domain";
import { InfrastructureRepositoryError } from "../errors/infrastructure-repository-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)
*/
export function translateSequelizeError(err: unknown): Error {
console.log(err);
console.error(err);
// 1) Duplicados (índices únicos)
if (err instanceof UniqueConstraintError) {

View File

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

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 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"]
>;
export class CustomerInvoiceItemsFullPresenter extends Presenter {
export class IssuedInvoiceItemsFullPresenter extends Presenter {
private _mapItem(
invoiceItem: CustomerInvoiceItem,
index: number
): GetCustomerInvoiceItemByInvoiceIdResponseDTO {
): GetIssuedInvoiceItemByInvoiceIdResponseDTO {
const allAmounts = invoiceItem.getAllAmounts();
return {

View File

@ -1,13 +1,13 @@
import { Presenter } from "@erp/core/api";
import { DomainValidationError, toEmptyString } from "@repo/rdx-ddd";
import type { GetIssuedInvoiceByIdResponseDTO } from "../../../../common/dto";
import type { CustomerInvoice, InvoiceRecipient } from "../../../domain";
import type { GetIssuedInvoiceByIdResponseDTO } from "../../../../../common/dto";
import type { CustomerInvoice, InvoiceRecipient } from "../../../../domain";
type GetRecipientInvoiceByInvoiceIdResponseDTO = GetIssuedInvoiceByIdResponseDTO["recipient"];
type GetIssuedInvoiceRecipientByIdResponseDTO = GetIssuedInvoiceByIdResponseDTO["recipient"];
export class RecipientInvoiceFullPresenter extends Presenter {
toOutput(invoice: CustomerInvoice): GetRecipientInvoiceByInvoiceIdResponseDTO {
export class IssuedInvoiceRecipientFullPresenter extends Presenter {
toOutput(invoice: CustomerInvoice): GetIssuedInvoiceRecipientByIdResponseDTO {
if (!invoice.recipient) {
throw DomainValidationError.requiredValue("recipient", {
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 { toEmptyString } from "@repo/rdx-ddd";
import type { GetIssuedInvoiceByIdResponseDTO } from "../../../../common/dto";
import type { CustomerInvoice } from "../../../domain";
import type { GetIssuedInvoiceByIdResponseDTO } from "../../../../../common/dto";
import type { CustomerInvoice } from "../../../../domain";
import type { CustomerInvoiceItemsFullPresenter } from "./customer-invoice-items.full.presenter";
import type { RecipientInvoiceFullPresenter } from "./recipient-invoice.full.representer";
import type { IssuedInvoiceItemsFullPresenter } from "./issued-invoice-items.full.presenter";
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,
GetIssuedInvoiceByIdResponseDTO
> {
toOutput(invoice: CustomerInvoice): GetIssuedInvoiceByIdResponseDTO {
const itemsPresenter = this.presenterRegistry.getPresenter({
resource: "customer-invoice-items",
resource: "issued-invoice-items",
projection: "FULL",
}) as CustomerInvoiceItemsFullPresenter;
}) as IssuedInvoiceItemsFullPresenter;
const recipientPresenter = this.presenterRegistry.getPresenter({
resource: "recipient-invoice",
resource: "issued-invoice-recipient",
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 items = itemsPresenter.toOutput(invoice.items);
const verifactu = verifactuPresenter.toOutput(invoice);
const allAmounts = invoice.getAllAmounts();
const payment = invoice.paymentMethod.match(
@ -81,10 +88,12 @@ export class CustomerInvoiceFullPresenter extends Presenter<
taxes_amount: allAmounts.taxesAmount.toObjectString(),
total_amount: allAmounts.totalAmount.toObjectString(),
verifactu,
items,
metadata: {
entity: "customer-invoices",
entity: "issued-invoices",
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 "./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 "./customer-invoice-items.report.presenter";
export * from "./customer-invoice-taxes.report.presenter";
export * from "./list-customer-invoices.presenter";
export * from "./issued-invoices";
export * from "./proformas";

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 type { ArrayElement, Collection } from "@repo/rdx-utils";
import type { ListIssuedInvoicesResponseDTO } from "../../../../common/dto";
import type { CustomerInvoiceListDTO } from "../../../infrastructure";
import type { ListIssuedInvoicesResponseDTO } from "../../../../../common/dto";
import type { CustomerInvoiceListDTO } from "../../../../infrastructure";
export class ListCustomerInvoicesPresenter extends Presenter {
export class IssuedInvoiceListPresenter extends Presenter {
protected _mapInvoice(invoice: CustomerInvoiceListDTO) {
const recipientDTO = invoice.recipient.toObjectString();
const verifactuDTO = invoice.verifactu.match(
(verifactu) => verifactu.toObjectString(),
() => ({
status: "",
url: "",
qr_code: "",
})
);
const invoiceDTO: ArrayElement<ListIssuedInvoicesResponseDTO["items"]> = {
id: invoice.id.toString(),
company_id: invoice.companyId.toString(),
@ -39,8 +48,10 @@ export class ListCustomerInvoicesPresenter extends Presenter {
taxes_amount: invoice.taxesAmount.toObjectString(),
total_amount: invoice.totalAmount.toObjectString(),
verifactu: verifactuDTO,
metadata: {
entity: "customer-invoice",
entity: "issued-invoice",
},
};
@ -48,22 +59,22 @@ export class ListCustomerInvoicesPresenter extends Presenter {
}
toOutput(params: {
customerInvoices: Collection<CustomerInvoiceListDTO>;
invoices: Collection<CustomerInvoiceListDTO>;
criteria: Criteria;
}): ListIssuedInvoicesResponseDTO {
const { customerInvoices, criteria } = params;
const { invoices, criteria } = params;
const invoices = customerInvoices.map((invoice) => this._mapInvoice(invoice));
const totalItems = customerInvoices.total();
const _invoices = invoices.map((invoice) => this._mapInvoice(invoice));
const _totalItems = invoices.total();
return {
page: criteria.pageNumber,
per_page: criteria.pageSize,
total_pages: Math.ceil(totalItems / criteria.pageSize),
total_items: totalItems,
items: invoices,
total_pages: Math.ceil(_totalItems / criteria.pageSize),
total_items: _totalItems,
items: _invoices,
metadata: {
entity: "customer-invoices",
entity: "issued-invoices",
criteria: criteria.toJSON(),
//links: {
// 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 { ArrayElement } from "@repo/rdx-utils";
type CustomerInvoiceItemsDTO = GetIssuedInvoiceByIdResponseDTO["items"];
type CustomerInvoiceItemDTO = ArrayElement<CustomerInvoiceItemsDTO>;
type IssuedInvoiceItemsDTO = GetIssuedInvoiceByIdResponseDTO["items"];
type IssuedInvoiceItemDTO = ArrayElement<IssuedInvoiceItemsDTO>;
export class CustomerInvoiceItemsReportPersenter extends Presenter<
CustomerInvoiceItemsDTO,
unknown
> {
export class IssuedInvoiceItemsReportPresenter extends Presenter<IssuedInvoiceItemsDTO, unknown> {
private _locale!: string;
private _mapItem(invoiceItem: CustomerInvoiceItemDTO, _index: number) {
private _mapItem(invoiceItem: IssuedInvoiceItemDTO, _index: number) {
const moneyOptions = {
hideZeros: true,
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 {
locale: string;
};
this._locale = locale;
return invoiceItems.map((item, index) => {
return issuedInvoiceItems.map((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 { ArrayElement } from "@repo/rdx-utils";
type CustomerInvoiceTaxesDTO = GetIssuedInvoiceByIdResponseDTO["taxes"];
type CustomerInvoiceTaxDTO = ArrayElement<CustomerInvoiceTaxesDTO>;
type IssuedInvoiceTaxesDTO = GetIssuedInvoiceByIdResponseDTO["taxes"];
type IssuedInvoiceTaxDTO = ArrayElement<IssuedInvoiceTaxesDTO>;
export class CustomerInvoiceTaxesReportPresenter extends Presenter<
CustomerInvoiceTaxesDTO,
unknown
> {
export class IssuedInvoiceTaxesReportPresenter extends Presenter<IssuedInvoiceTaxesDTO, unknown> {
private _locale!: string;
private _taxCatalog!: JsonTaxCatalogProvider;
private _mapTax(taxItem: CustomerInvoiceTaxDTO) {
private _mapTax(taxItem: IssuedInvoiceTaxDTO) {
const moneyOptions = {
hideZeros: true,
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 {
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,
transaction?: Transaction
): Promise<Result<Collection<CustomerInvoiceListDTO>, Error>> {
return this.repository.findByCriteriaInCompany(companyId, criteria, transaction, {
where: {
is_proforma: true,
},
});
return this.repository.findProformasByCriteriaInCompany(companyId, criteria, transaction, {});
}
/**
@ -208,11 +204,12 @@ export class CustomerInvoiceApplicationService {
criteria: Criteria,
transaction?: Transaction
): Promise<Result<Collection<CustomerInvoiceListDTO>, Error>> {
return this.repository.findByCriteriaInCompany(companyId, criteria, transaction, {
where: {
is_proforma: false,
},
});
return this.repository.findIssuedInvoicesByCriteriaInCompany(
companyId,
criteria,
transaction,
{}
);
}
/**
@ -227,11 +224,12 @@ export class CustomerInvoiceApplicationService {
invoiceId: UniqueID,
transaction?: Transaction
): Promise<Result<CustomerInvoice>> {
return await this.repository.getByIdInCompany(companyId, invoiceId, transaction, {
where: {
is_proforma: false,
},
});
return await this.repository.getIssuedInvoiceByIdInCompany(
companyId,
invoiceId,
transaction,
{}
);
}
/**
@ -246,11 +244,7 @@ export class CustomerInvoiceApplicationService {
proformaId: UniqueID,
transaction?: Transaction
): Promise<Result<CustomerInvoice>> {
return await this.repository.getByIdInCompany(companyId, proformaId, transaction, {
where: {
is_proforma: true,
},
});
return await this.repository.getProformaByIdInCompany(companyId, proformaId, transaction, {});
}
/**

View File

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

View File

@ -1,3 +1,3 @@
export * from "./get-issued-invoice.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 { ListIssuedInvoicesResponseDTO } from "../../../../common/dto";
import type { ListCustomerInvoicesPresenter } from "../../presenters";
import type { IssuedInvoiceListPresenter } from "../../presenters";
import type { CustomerInvoiceApplicationService } from "../../services";
type ListIssuedInvoicesUseCaseInput = {
@ -25,9 +25,9 @@ export class ListIssuedInvoicesUseCase {
): Promise<Result<ListIssuedInvoicesResponseDTO, Error>> {
const { criteria, companyId } = params;
const presenter = this.presenterRegistry.getPresenter({
resource: "customer-invoice",
resource: "issued-invoice",
projection: "LIST",
}) as ListCustomerInvoicesPresenter;
}) as IssuedInvoiceListPresenter;
return this.transactionManager.complete(async (transaction: Transaction) => {
try {
@ -43,7 +43,7 @@ export class ListIssuedInvoicesUseCase {
const invoices = result.data;
const dto = presenter.toOutput({
customerInvoices: invoices,
invoices: invoices,
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 { Result } from "@repo/rdx-utils";
import type { CustomerInvoiceApplicationService } from "../../services";
import type { CustomerInvoiceReportPDFPresenter } from "../proformas";
import type { CustomerInvoiceApplicationService } from "../../../services";
import type { IssuedInvoiceReportPDFPresenter } from "./reporter/issued-invoice.report.pdf";
type ReportIssuedInvoiceUseCaseInput = {
companyId: UniqueID;
@ -28,10 +29,10 @@ export class ReportIssuedInvoiceUseCase {
const invoiceId = idOrError.data;
const pdfPresenter = this.presenterRegistry.getPresenter({
resource: "customer-invoice",
resource: "issued-invoice",
projection: "REPORT",
format: "PDF",
}) as CustomerInvoiceReportPDFPresenter;
}) as IssuedInvoiceReportPDFPresenter;
return this.transactionManager.complete(async (transaction) => {
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 {
CustomerInvoiceFullPresenter,
CustomerInvoiceReportPresenter,
IssuedInvoiceFullPresenter,
IssuedInvoiceReportPresenter,
} from "../../../../presenters";
/** Helper para trabajar relativo al fichero actual (ESM) */
@ -23,26 +23,26 @@ export function fromHere(metaUrl: string) {
};
}
export class CustomerInvoiceReportHTMLPresenter extends Presenter {
toOutput(customerInvoice: CustomerInvoice): string {
export class IssuedInvoiceReportHTMLPresenter extends Presenter {
toOutput(invoice: CustomerInvoice): string {
const dtoPresenter = this.presenterRegistry.getPresenter({
resource: "customer-invoice",
resource: "issued-invoice",
projection: "FULL",
}) as CustomerInvoiceFullPresenter;
}) as IssuedInvoiceFullPresenter;
const prePresenter = this.presenterRegistry.getPresenter({
resource: "customer-invoice",
resource: "issued-invoice",
projection: "REPORT",
format: "JSON",
}) as CustomerInvoiceReportPresenter;
}) as IssuedInvoiceReportPresenter;
const invoiceDTO = dtoPresenter.toOutput(customerInvoice);
const invoiceDTO = dtoPresenter.toOutput(invoice);
const prettyDTO = prePresenter.toOutput(invoiceDTO);
// Obtener y compilar la plantilla HTML
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 template = Handlebars.compile(templateHtml, {});
return template(prettyDTO);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,2 +1,2 @@
export * from "./customer-invoice.report.html";
export * from "./customer-invoice.report.pdf";
export * from "./proforma.report.html";
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 {
font-family: Tahoma, sans-serif;
display: flex;
justify-content: space-between;
margin-bottom: 20px;
@ -253,9 +254,16 @@
<footer id="footer" class="mt-4">
<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
| CIF: B83999441 -
Rodax Software S.L.</p>
<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: B86913910</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>
</footer>

View File

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

View File

@ -10,7 +10,12 @@ import {
} from "@repo/rdx-ddd";
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 {
type CustomerInvoiceNumber,
type CustomerInvoiceSerie,
@ -49,9 +54,7 @@ export interface CustomerInvoiceProps {
discountPercentage: Percentage;
/*verifactu_qr: string;
verifactu_url: string;
verifactu_status: string;*/
verifactu: Maybe<VerifactuRecord>;
}
export type CustomerInvoicePatchProps = Partial<
@ -212,6 +215,10 @@ export class CustomerInvoice
return this.props.currencyCode;
}
public get verifactu(): Maybe<VerifactuRecord> {
return this.props.verifactu;
}
public get discountPercentage(): Percentage {
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 {
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,
ItemDiscount,
ItemQuantity,
} from "../../value-objects";
import { ItemTaxes, ItemTaxTotal } from "../item-taxes";
import type { ItemTaxTotal, ItemTaxes } from "../item-taxes";
export interface CustomerInvoiceItemProps {
description: Maybe<CustomerInvoiceItemDescription>;

View File

@ -2,3 +2,4 @@ export * from "./customer-invoice-items";
export * from "./invoice-payment-method";
export * from "./invoice-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>>;
/**
* Recupera una factura/proforma por su ID y companyId.
* Recupera una proforma por su ID y companyId.
* 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,
id: UniqueID,
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).
* El resultado está encapsulado en un objeto `Collection<T>`.
*
@ -65,7 +76,28 @@ export interface ICustomerInvoiceRepository {
*
* @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,
criteria: Criteria,
transaction: unknown,

View File

@ -8,3 +8,4 @@ export * from "./invoice-recipient";
export * from "./item-amount";
export * from "./item-discount";
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
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> {
private static readonly ALLOWED_STATUSES = [
"Pendiente",
@ -30,17 +31,10 @@ export class VerifactuRecordEstado extends ValueObject<IVerifactuRecordEstadoPro
];
private static readonly FIELD = "estado";
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> {
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(
new DomainValidationError(
VerifactuRecordEstado.ERROR_CODE,
@ -53,6 +47,14 @@ export class VerifactuRecordEstado extends ValueObject<IVerifactuRecordEstadoPro
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 {
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 { Transaction } from "sequelize";
import { buildCustomerInvoiceDependencies, models, proformasRouter } from "./infrastructure";
import { models, proformasRouter } from "./infrastructure";
import { issuedInvoicesRouter } from "./infrastructure/express/issued-invoices.routes";
export const customerInvoicesAPIModule: IModuleServer = {
@ -27,8 +27,6 @@ export const customerInvoicesAPIModule: IModuleServer = {
label: this.name,
});
const deps = buildCustomerInvoiceDependencies(params);
return {
models,
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,
ReportIssueInvoiceByIdRequestSchema,
} from "../../../common/dto";
import { buildCustomerInvoiceDependencies } from "../dependencies";
import { buildIssuedInvoicesDependencies } from "../issued-invoices-dependencies";
import {
GetIssueInvoiceController,
@ -25,7 +25,7 @@ export const issuedInvoicesRouter = (params: ModuleParams) => {
logger: ILogger;
};
const deps = buildCustomerInvoiceDependencies(params);
const deps = buildIssuedInvoicesDependencies(params);
const router: Router = Router({ mergeParams: true });
if (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "production") {

View File

@ -16,7 +16,7 @@ import {
UpdateProformaByIdParamsRequestSchema,
UpdateProformaByIdRequestSchema,
} from "../../../common";
import { buildCustomerInvoiceDependencies } from "../dependencies";
import { buildProformasDependencies } from "../proformas-dependencies";
import {
ChangeStatusProformaController,
@ -37,7 +37,7 @@ export const proformasRouter = (params: ModuleParams) => {
logger: ILogger;
};
const deps = buildCustomerInvoiceDependencies(params);
const deps = buildProformasDependencies(params);
const router: Router = Router({ mergeParams: true });
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 "./mappers";
export * from "./proformas-dependencies";
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,
maybeFromNullableVO,
} from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
import { Maybe, Result } from "@repo/rdx-utils";
import {
CustomerInvoiceNumber,
@ -22,10 +22,12 @@ import {
CustomerInvoiceStatus,
InvoiceAmount,
type InvoiceRecipient,
type VerifactuRecord,
} from "../../../domain";
import type { CustomerInvoiceModel } from "../../sequelize";
import { InvoiceRecipientListMapper } from "./invoice-recipient.list.mapper";
import { VerifactuRecordListMapper } from "./verifactu-record.list.mapper";
export type CustomerInvoiceListDTO = {
id: UniqueID;
@ -57,6 +59,8 @@ export type CustomerInvoiceListDTO = {
taxableAmount: InvoiceAmount;
taxesAmount: InvoiceAmount;
totalAmount: InvoiceAmount;
verifactu: Maybe<VerifactuRecord>;
};
export interface ICustomerInvoiceListMapper
@ -67,10 +71,12 @@ export class CustomerInvoiceListMapper
implements ICustomerInvoiceListMapper
{
private _recipientMapper: InvoiceRecipientListMapper;
private _verifactuMapper: VerifactuRecordListMapper;
constructor() {
super();
this._recipientMapper = new InvoiceRecipientListMapper();
this._verifactuMapper = new VerifactuRecordListMapper();
}
public mapToDTO(
@ -99,6 +105,21 @@ export class CustomerInvoiceListMapper
// 3) Taxes
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
if (errors.length > 0) {
return Result.fail(
@ -133,6 +154,8 @@ export class CustomerInvoiceListMapper
taxableAmount: attributes.taxableAmount!,
taxesAmount: attributes.taxesAmount!,
totalAmount: attributes.totalAmount!,
verifactu,
});
}

View File

@ -1,3 +1,8 @@
import {
type IQueryMapperWithBulk,
type MapperParamsType,
SequelizeQueryMapper,
} from "@erp/core/api";
import {
City,
Country,
@ -6,17 +11,16 @@ import {
Province,
Street,
TINNumber,
ValidationErrorDetail,
type ValidationErrorDetail,
extractOrPushError,
maybeFromNullableVO,
} 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 { CustomerInvoiceModel } from "../../sequelize";
import { CustomerInvoiceListDTO } from "./customer-invoice.list.mapper";
import type { CustomerInvoiceModel } from "../../sequelize";
import type { CustomerInvoiceListDTO } from "./customer-invoice.list.mapper";
interface IInvoiceRecipientListMapper
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,
CreateProformaUseCase,
CustomerInvoiceApplicationService,
CustomerInvoiceFullPresenter,
CustomerInvoiceItemsFullPresenter,
CustomerInvoiceItemsReportPersenter,
CustomerInvoiceReportHTMLPresenter,
CustomerInvoiceReportPDFPresenter,
CustomerInvoiceReportPresenter,
CustomerInvoiceTaxesReportPresenter,
DeleteProformaUseCase,
GetIssuedInvoiceUseCase,
GetProformaUseCase,
IssueProformaUseCase,
ListCustomerInvoicesPresenter,
ListIssuedInvoicesUseCase,
ListProformasUseCase,
RecipientInvoiceFullPresenter,
ReportIssuedInvoiceUseCase,
ProformaFullPresenter,
ProformaListPresenter,
ProformaReportHTMLPresenter,
ProformaReportPDFPresenter,
ReportProformaUseCase,
UpdateProformaUseCase,
} 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 { CustomerInvoiceRepository } from "./sequelize";
import { SequelizeInvoiceNumberGenerator } from "./services";
export type CustomerInvoiceDeps = {
export type ProformasDeps = {
transactionManager: SequelizeTransactionManager;
mapperRegistry: IMapperRegistry;
presenterRegistry: IPresenterRegistry;
@ -54,14 +53,10 @@ export type CustomerInvoiceDeps = {
report_proforma: () => ReportProformaUseCase;
issue_proforma: () => IssueProformaUseCase;
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;
/** Dominio */
@ -93,45 +88,50 @@ export function buildCustomerInvoiceDependencies(params: ModuleParams): Customer
// Presenter Registry
const presenterRegistry = new InMemoryPresenterRegistry();
presenterRegistry.registerPresenters([
// FULL
{
key: { resource: "customer-invoice-items", projection: "FULL" },
presenter: new CustomerInvoiceItemsFullPresenter(presenterRegistry),
key: { resource: "proforma-items", projection: "FULL" },
presenter: new ProformaItemsFullPresenter(presenterRegistry),
},
{
key: { resource: "recipient-invoice", projection: "FULL" },
presenter: new RecipientInvoiceFullPresenter(presenterRegistry),
key: { resource: "proforma-recipient", projection: "FULL" },
presenter: new ProformaRecipientFullPresenter(presenterRegistry),
},
{
key: { resource: "customer-invoice", projection: "FULL" },
presenter: new CustomerInvoiceFullPresenter(presenterRegistry),
key: { resource: "proforma", projection: "FULL" },
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" },
presenter: new ListCustomerInvoicesPresenter(presenterRegistry),
key: { resource: "proforma-taxes", projection: "REPORT", format: "JSON" },
presenter: new IssuedInvoiceTaxesReportPresenter(presenterRegistry),
},
{
key: { resource: "customer-invoice", projection: "REPORT", format: "JSON" },
presenter: new CustomerInvoiceReportPresenter(presenterRegistry),
key: { resource: "proforma-items", projection: "REPORT", format: "JSON" },
presenter: new ProformaItemsReportPresenter(presenterRegistry),
},
{
key: { resource: "customer-invoice-taxes", projection: "REPORT", format: "JSON" },
presenter: new CustomerInvoiceTaxesReportPresenter(presenterRegistry),
key: { resource: "proforma", projection: "REPORT", format: "HTML" },
presenter: new ProformaReportHTMLPresenter(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),
key: { resource: "proforma", projection: "REPORT", format: "PDF" },
presenter: new ProformaReportPDFPresenter(presenterRegistry),
},
]);
const useCases: CustomerInvoiceDeps["useCases"] = {
const useCases: ProformasDeps["useCases"] = {
// Proformas
list_proformas: () =>
new ListProformasUseCase(appService, transactionManager, presenterRegistry),
@ -146,14 +146,6 @@ export function buildCustomerInvoiceDependencies(params: ModuleParams): Customer
issue_proforma: () =>
new IssueProformaUseCase(appService, transactionManager, presenterRegistry),
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 {

View File

@ -24,6 +24,7 @@ import { CustomerInvoiceModel } from "./models/customer-invoice.model";
import { CustomerInvoiceItemModel } from "./models/customer-invoice-item.model";
import { CustomerInvoiceItemTaxModel } from "./models/customer-invoice-item-tax.model";
import { CustomerInvoiceTaxModel } from "./models/customer-invoice-tax.model";
import { VerifactuRecordModel } from "./models/verifactu-record.model";
export class CustomerInvoiceRepository
extends SequelizeRepository<CustomerInvoice>
@ -119,8 +120,6 @@ export class CustomerInvoiceRepository
transaction,
});
console.log(affectedCount);
if (affectedCount === 0) {
return Result.fail(
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 id - UUID de la factura.
* @param companyId - Identificador UUID de la empresa a la que pertenece la proforma.
* @param id - UUID de la proforma.
* @param transaction - Transacción activa para la operación.
* @param options - Opciones adicionales para la consulta (Sequelize FindOptions)
* @returns Result<CustomerInvoice, Error>
*/
async getByIdInCompany(
async getProformaByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction: Transaction,
@ -230,9 +229,10 @@ export class CustomerInvoiceRepository
const mergedOptions: FindOptions<InferAttributes<CustomerInvoiceModel>> = {
...options,
where: {
id: id.toString(),
company_id: companyId.toString(),
...(options.where ?? {}),
id: id.toString(),
is_proforma: true,
company_id: companyId.toString(),
},
order: [
...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 criteria - Criterios de búsqueda.
@ -290,7 +379,7 @@ export class CustomerInvoiceRepository
*
* @see Criteria
*/
public async findByCriteriaInCompany(
public async findProformasByCriteriaInCompany(
companyId: UniqueID,
criteria: Criteria,
transaction: Transaction,
@ -331,9 +420,10 @@ export class CustomerInvoiceRepository
query.where = {
...query.where,
...(options.where ?? {}),
is_proforma: true,
company_id: companyId.toString(),
deleted_at: null,
...(options.where ?? {}),
};
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.

View File

@ -2,6 +2,7 @@ import customerInvoiceModelInit from "./models/customer-invoice.model";
import customerInvoiceItemModelInit from "./models/customer-invoice-item.model";
import customerInvoiceItemTaxesModelInit from "./models/customer-invoice-item-tax.model";
import customerInvoiceTaxesModelInit from "./models/customer-invoice-tax.model";
import verifactuRecordModelInit from "./models/verifactu-record.model";
export * from "./customer-invoice.repository";
export * from "./models";
@ -13,4 +14,6 @@ export const models = [
customerInvoiceTaxesModelInit,
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) => {

View File

@ -17,6 +17,7 @@ import type {
CustomerInvoiceTaxCreationAttributes,
CustomerInvoiceTaxModel,
} from "./customer-invoice-tax.model";
import type { VerifactuRecordModel } from "./verifactu-record.model";
export type CustomerInvoiceCreationAttributes = InferCreationAttributes<
CustomerInvoiceModel,
@ -101,6 +102,7 @@ export class CustomerInvoiceModel extends Model<
declare items: NonAttribute<CustomerInvoiceItemModel[]>;
declare taxes: NonAttribute<CustomerInvoiceTaxModel[]>;
declare current_customer: NonAttribute<CustomerModel>;
declare verifactu: NonAttribute<VerifactuRecordModel>;
static associate(database: Sequelize) {
const models = database.models;
@ -109,6 +111,7 @@ export class CustomerInvoiceModel extends Model<
"CustomerInvoiceItemModel",
"CustomerModel",
"CustomerInvoiceTaxModel",
"VerifactuRecordModel",
];
// Comprobamos que los modelos existan
@ -124,8 +127,14 @@ export class CustomerInvoiceModel extends Model<
CustomerInvoiceItemModel,
CustomerModel,
CustomerInvoiceTaxModel,
VerifactuRecordModel,
} = models;
CustomerInvoiceModel.hasOne(VerifactuRecordModel, {
as: "verifactu",
foreignKey: "invoice_id",
});
CustomerInvoiceModel.belongsTo(CustomerModel, {
as: "current_customer",
foreignKey: "customer_id",
@ -151,7 +160,9 @@ export class CustomerInvoiceModel extends Model<
});
}
static hooks(_database: Sequelize) {}
static hooks(_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-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 {
CustomerInvoiceItemTaxCreationAttributes,
CustomerInvoiceItemTaxModel,
} from "./customer-invoice-item-tax.model";
*/
import {
DataTypes,
type InferAttributes,
type InferCreationAttributes,
Model,
type Sequelize,
} from "sequelize";
export type VerifactuRecordCreationAttributes = InferCreationAttributes<VerifactuRecordModel>;
@ -22,16 +23,30 @@ export class VerifactuRecordModel extends Model<
declare operacion: string;
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, {
as: "verifactu_records",
as: "verifactu",
targetKey: "id",
foreignKey: "invoice_id",
onDelete: "CASCADE",
onUpdate: "CASCADE",
});
}
static hooks(_database: Sequelize) {
//
}
}
export default (database: Sequelize) => {
@ -76,11 +91,18 @@ export default (database: Sequelize) => {
},
{
sequelize: database,
modelName: "VerifactuRecordModel",
tableName: "verifactu_records",
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

View File

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

View File

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

View File

@ -35,6 +35,19 @@
"rejected": "Rejected",
"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": {
@ -90,6 +103,10 @@
"title": "Customer invoices",
"description": "List all customer invoices",
"grid_columns": {
"series_invoice_number": "Serie & #",
"verifactu_status": "Status",
"verifactu_qr_code": "QR Code",
"verifactu_url": "URL",
"invoice_number": "Inv. number",
"series": "Serie",
"reference": "Reference",

View File

@ -34,6 +34,19 @@
"rejected": "Rechazadas",
"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": {
@ -89,10 +102,14 @@
"title": "Facturas de cliente",
"description": "Lista todas las facturas de cliente",
"grid_columns": {
"series_invoice_number": "Serie & #",
"verifactu_status": "Estado",
"verifactu_qr_code": "Código QR",
"verifactu_url": "URL",
"invoice_number": "Nº factura",
"series": "Serie",
"reference": "Reference",
"invoice_date": "Fecha de proforma",
"invoice_date": "Fecha de factura",
"operation_date": "Fecha de operación",
"recipient": "Cliente",
"recipient_tin": "NIF/CIF",

View File

@ -49,14 +49,73 @@ export function useIssuedInvoicesGridColumns(
),
enableHiding: false,
enableSorting: false,
maxSize: 48,
size: 48,
minSize: 48,
maxSize: 64,
size: 64,
minSize: 64,
meta: {
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",
header: ({ column }) => (

View File

@ -1,5 +1,13 @@
import { SimpleSearchInput } from "@erp/core/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 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 sm:flex-row gap-4">
<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>
<DataTable

View File

@ -12,7 +12,6 @@ import type { ComponentProps } from "react";
import { useFormContext, useWatch } from "react-hook-form";
import { useTranslation } from "../../../../../i18n";
import { PercentageInputField } from "../../../../../shared/ui/";
import type { ProformaFormData } from "../../../../schema";
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
const FOCUSABLE_SELECTOR = [
'[data-cell-focus]', // permite marcar manualmente el target dentro de la celda
'input:not([disabled])',
'textarea:not([disabled])',
'select:not([disabled])',
"[data-cell-focus]", // permite marcar manualmente el target dentro de la celda
"input:not([disabled])",
"textarea:not([disabled])",
"select:not([disabled])",
'[contenteditable="true"]',
'button:not([disabled])',
'a[href]',
'[tabindex]:not([tabindex="-1"])'
].join(',');
"button:not([disabled])",
"a[href]",
'[tabindex]:not([tabindex="-1"])',
].join(",");
// 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.
@ -16,10 +20,9 @@ const FOCUSABLE_SELECTOR = [
export function findFocusableInCell(row: number, col: number): HTMLElement | null {
// 1) ¿Hay un control que ya tenga los data-* directamente?
let el =
document.querySelector<HTMLElement>(
`[data-row-index="${row}"][data-col-index="${col}"]${FOCUSABLE_SELECTOR.startsWith('[') ? '' : ''}`
);
let el = document.querySelector<HTMLElement>(
`[data-row-index="${row}"][data-col-index="${col}"]${FOCUSABLE_SELECTOR.startsWith("[") ? "" : ""}`
);
// Si lo anterior no funcionó o seleccionó un contenedor, intenta:
if (!el) {
@ -30,7 +33,9 @@ export function findFocusableInCell(row: number, col: number): HTMLElement | nul
if (!cell) return null;
// 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;
@ -48,8 +53,8 @@ export function focusAndSelect(el: HTMLElement) {
// select() funciona en la mayoría; si es type="number", cae en setSelectionRange
el.select?.();
// Asegura selección completa si select() no aplica (p.ej. type="number")
if (typeof (el as any).setSelectionRange === 'function') {
const val = (el as any).value ?? '';
if (typeof (el as any).setSelectionRange === "function") {
const val = (el as any).value ?? "";
(el as any).setSelectionRange(0, String(val).length);
}
} catch {
@ -65,4 +70,4 @@ export function focusAndSelect(el: HTMLElement) {
}
// Para select/button/otros focuseables no hacemos selección de texto.
});
}
}

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