Facturas de cliente
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
@ -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 {
|
||||
@ -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,
|
||||
@ -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: "",
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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: "",
|
||||
},
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export * from "./proforma.full.presenter";
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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: "",
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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: "",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./domain";
|
||||
export * from "./queries";
|
||||
export * from "./reports";
|
||||
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export * from "./issued-invoice.list.presenter";
|
||||
@ -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}`,
|
||||
@ -0,0 +1 @@
|
||||
export * from "./proforma.list.presenter";
|
||||
@ -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}`,
|
||||
//},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
export * from "./issued-invoices";
|
||||
export * from "./proformas";
|
||||
@ -0,0 +1,3 @@
|
||||
export * from "./issued-invoice.report.presenter";
|
||||
export * from "./issued-invoice-items.report.presenter";
|
||||
export * from "./issued-invoice-taxes.report.presenter";
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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;
|
||||
};
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
export * from "./proforma.report.presenter";
|
||||
export * from "./proforma-items.report.presenter";
|
||||
export * from "./proforma-taxes.report.presenter";
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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, {});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export * from "./report-issued-invoice.use-case";
|
||||
@ -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 {
|
||||
@ -0,0 +1,2 @@
|
||||
export * from "./issued-invoice.report.html";
|
||||
export * from "./issued-invoice.report.pdf";
|
||||
@ -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);
|
||||
@ -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({
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 118 KiB |
@ -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>
|
||||
|
||||
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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()),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -8,3 +8,4 @@ export * from "./invoice-recipient";
|
||||
export * from "./item-amount";
|
||||
export * from "./item-discount";
|
||||
export * from "./item-quantity";
|
||||
export * from "./verifactu-status";
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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: {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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") {
|
||||
|
||||
@ -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") {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
export * from "./dependencies";
|
||||
export * from "./express";
|
||||
export * from "./mappers";
|
||||
export * from "./proformas-dependencies";
|
||||
export * from "./sequelize";
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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> {}
|
||||
|
||||
@ -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!
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
];
|
||||
|
||||
@ -55,7 +55,9 @@ export class CustomerInvoiceTaxModel extends Model<
|
||||
});
|
||||
}
|
||||
|
||||
static hooks(_database: Sequelize) {}
|
||||
static hooks(_database: Sequelize) {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
export default (database: Sequelize) => {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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(),
|
||||
})
|
||||
);
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 }) => (
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
export * from "./amount-input-field";
|
||||
export * from "./percentage-input-field";
|
||||
export * from "./quantity-input-field";
|
||||
@ -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.
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export * from "./ui";
|
||||
@ -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";
|
||||
@ -1 +0,0 @@
|
||||
export * from "./append-empty-row-button";
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
@ -1 +0,0 @@
|
||||
export * from "./items";
|
||||