Facturas de cliente
@ -1,6 +1,7 @@
|
|||||||
import { ApplicationError } from "../errors";
|
import { ApplicationError } from "../errors";
|
||||||
import { IPresenterRegistry, PresenterKey } from "./presenter-registry.interface";
|
|
||||||
import { IPresenter } from "./presenter.interface";
|
import type { IPresenter } from "./presenter.interface";
|
||||||
|
import type { IPresenterRegistry, PresenterKey } from "./presenter-registry.interface";
|
||||||
|
|
||||||
export class InMemoryPresenterRegistry implements IPresenterRegistry {
|
export class InMemoryPresenterRegistry implements IPresenterRegistry {
|
||||||
private registry: Map<string, IPresenter<any, any>> = new Map();
|
private registry: Map<string, IPresenter<any, any>> = new Map();
|
||||||
@ -16,7 +17,7 @@ export class InMemoryPresenterRegistry implements IPresenterRegistry {
|
|||||||
* 🔹 Construye la clave única para el registro.
|
* 🔹 Construye la clave única para el registro.
|
||||||
*/
|
*/
|
||||||
private _buildKey(key: PresenterKey): string {
|
private _buildKey(key: PresenterKey): string {
|
||||||
const { resource, projection, format, version, locale } = key;
|
const { resource, projection, format, version, locale } = this._normalizeKey(key);
|
||||||
return [
|
return [
|
||||||
resource.toLowerCase(),
|
resource.toLowerCase(),
|
||||||
projection.toLowerCase(),
|
projection.toLowerCase(),
|
||||||
@ -30,12 +31,12 @@ export class InMemoryPresenterRegistry implements IPresenterRegistry {
|
|||||||
key: PresenterKey,
|
key: PresenterKey,
|
||||||
presenter: IPresenter<TSource, TOutput>
|
presenter: IPresenter<TSource, TOutput>
|
||||||
): void {
|
): void {
|
||||||
const exactKey = this._buildKey(this._normalizeKey(key));
|
const exactKey = this._buildKey(key);
|
||||||
this.registry.set(exactKey, presenter);
|
this.registry.set(exactKey, presenter);
|
||||||
}
|
}
|
||||||
|
|
||||||
getPresenter<TSource, TOutput>(key: PresenterKey): IPresenter<TSource, TOutput> {
|
getPresenter<TSource, TOutput>(key: PresenterKey): IPresenter<TSource, TOutput> {
|
||||||
const exactKey = this._buildKey(this._normalizeKey(key));
|
const exactKey = this._buildKey(key);
|
||||||
|
|
||||||
// 1) Intentar clave exacta
|
// 1) Intentar clave exacta
|
||||||
if (this.registry.has(exactKey)) {
|
if (this.registry.has(exactKey)) {
|
||||||
@ -86,7 +87,9 @@ export class InMemoryPresenterRegistry implements IPresenterRegistry {
|
|||||||
registerPresenters(
|
registerPresenters(
|
||||||
presenters: Array<{ key: PresenterKey; presenter: IPresenter<any, any> }>
|
presenters: Array<{ key: PresenterKey; presenter: IPresenter<any, any> }>
|
||||||
): this {
|
): this {
|
||||||
presenters.forEach(({ key, presenter }) => this._registerPresenter(key, presenter));
|
for (const { key, presenter } of presenters) {
|
||||||
|
this._registerPresenter(key, presenter);
|
||||||
|
}
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { RequestHandler } from "express";
|
import type { RequestHandler } from "express";
|
||||||
import { z } from "zod/v4";
|
import type { z } from "zod/v4";
|
||||||
|
|
||||||
import { InternalApiError, ValidationApiError } from "../errors";
|
import { InternalApiError, ValidationApiError } from "../errors";
|
||||||
import { ExpressController } from "../express-controller";
|
import { ExpressController } from "../express-controller";
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
ValidationError as SequelizeValidationError,
|
ValidationError as SequelizeValidationError,
|
||||||
UniqueConstraintError,
|
UniqueConstraintError,
|
||||||
} from "sequelize";
|
} from "sequelize";
|
||||||
|
|
||||||
import { DuplicateEntityError, EntityNotFoundError } from "../../domain";
|
import { DuplicateEntityError, EntityNotFoundError } from "../../domain";
|
||||||
import { InfrastructureRepositoryError } from "../errors/infrastructure-repository-error";
|
import { InfrastructureRepositoryError } from "../errors/infrastructure-repository-error";
|
||||||
import { InfrastructureUnavailableError } from "../errors/infrastructure-unavailable-error";
|
import { InfrastructureUnavailableError } from "../errors/infrastructure-unavailable-error";
|
||||||
@ -17,7 +18,7 @@ import { InfrastructureUnavailableError } from "../errors/infrastructure-unavail
|
|||||||
* 👉 Este traductor pertenece a la infraestructura (persistencia)
|
* 👉 Este traductor pertenece a la infraestructura (persistencia)
|
||||||
*/
|
*/
|
||||||
export function translateSequelizeError(err: unknown): Error {
|
export function translateSequelizeError(err: unknown): Error {
|
||||||
console.log(err);
|
console.error(err);
|
||||||
|
|
||||||
// 1) Duplicados (índices únicos)
|
// 1) Duplicados (índices únicos)
|
||||||
if (err instanceof UniqueConstraintError) {
|
if (err instanceof UniqueConstraintError) {
|
||||||
|
|||||||
@ -1,3 +1,2 @@
|
|||||||
export * from "./customer-invoice-items.full.presenter";
|
export * from "./issued-invoices";
|
||||||
export * from "./customer-invoice.full.presenter";
|
export * from "./proformas";
|
||||||
export * from "./recipient-invoice.full.representer";
|
|
||||||
|
|||||||
@ -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 { toEmptyString } from "@repo/rdx-ddd";
|
||||||
import type { ArrayElement } from "@repo/rdx-utils";
|
import type { ArrayElement } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import type { CustomerInvoiceItem, CustomerInvoiceItems } from "../../../domain";
|
import type { CustomerInvoiceItem, CustomerInvoiceItems } from "../../../../domain";
|
||||||
|
|
||||||
type GetCustomerInvoiceItemByInvoiceIdResponseDTO = ArrayElement<
|
type GetIssuedInvoiceItemByInvoiceIdResponseDTO = ArrayElement<
|
||||||
GetIssuedInvoiceByIdResponseDTO["items"]
|
GetIssuedInvoiceByIdResponseDTO["items"]
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export class CustomerInvoiceItemsFullPresenter extends Presenter {
|
export class IssuedInvoiceItemsFullPresenter extends Presenter {
|
||||||
private _mapItem(
|
private _mapItem(
|
||||||
invoiceItem: CustomerInvoiceItem,
|
invoiceItem: CustomerInvoiceItem,
|
||||||
index: number
|
index: number
|
||||||
): GetCustomerInvoiceItemByInvoiceIdResponseDTO {
|
): GetIssuedInvoiceItemByInvoiceIdResponseDTO {
|
||||||
const allAmounts = invoiceItem.getAllAmounts();
|
const allAmounts = invoiceItem.getAllAmounts();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -1,13 +1,13 @@
|
|||||||
import { Presenter } from "@erp/core/api";
|
import { Presenter } from "@erp/core/api";
|
||||||
import { DomainValidationError, toEmptyString } from "@repo/rdx-ddd";
|
import { DomainValidationError, toEmptyString } from "@repo/rdx-ddd";
|
||||||
|
|
||||||
import type { GetIssuedInvoiceByIdResponseDTO } from "../../../../common/dto";
|
import type { GetIssuedInvoiceByIdResponseDTO } from "../../../../../common/dto";
|
||||||
import type { CustomerInvoice, InvoiceRecipient } from "../../../domain";
|
import type { CustomerInvoice, InvoiceRecipient } from "../../../../domain";
|
||||||
|
|
||||||
type GetRecipientInvoiceByInvoiceIdResponseDTO = GetIssuedInvoiceByIdResponseDTO["recipient"];
|
type GetIssuedInvoiceRecipientByIdResponseDTO = GetIssuedInvoiceByIdResponseDTO["recipient"];
|
||||||
|
|
||||||
export class RecipientInvoiceFullPresenter extends Presenter {
|
export class IssuedInvoiceRecipientFullPresenter extends Presenter {
|
||||||
toOutput(invoice: CustomerInvoice): GetRecipientInvoiceByInvoiceIdResponseDTO {
|
toOutput(invoice: CustomerInvoice): GetIssuedInvoiceRecipientByIdResponseDTO {
|
||||||
if (!invoice.recipient) {
|
if (!invoice.recipient) {
|
||||||
throw DomainValidationError.requiredValue("recipient", {
|
throw DomainValidationError.requiredValue("recipient", {
|
||||||
cause: invoice,
|
cause: invoice,
|
||||||
@ -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 { Presenter } from "@erp/core/api";
|
||||||
import { toEmptyString } from "@repo/rdx-ddd";
|
import { toEmptyString } from "@repo/rdx-ddd";
|
||||||
|
|
||||||
import type { GetIssuedInvoiceByIdResponseDTO } from "../../../../common/dto";
|
import type { GetIssuedInvoiceByIdResponseDTO } from "../../../../../common/dto";
|
||||||
import type { CustomerInvoice } from "../../../domain";
|
import type { CustomerInvoice } from "../../../../domain";
|
||||||
|
|
||||||
import type { CustomerInvoiceItemsFullPresenter } from "./customer-invoice-items.full.presenter";
|
import type { IssuedInvoiceItemsFullPresenter } from "./issued-invoice-items.full.presenter";
|
||||||
import type { RecipientInvoiceFullPresenter } from "./recipient-invoice.full.representer";
|
import type { IssuedInvoiceRecipientFullPresenter } from "./issued-invoice-recipient.full.presenter";
|
||||||
|
import type { IssuedInvoiceVerifactuFullPresenter } from "./issued-invoice-verifactu.full.presenter";
|
||||||
|
|
||||||
export class CustomerInvoiceFullPresenter extends Presenter<
|
export class IssuedInvoiceFullPresenter extends Presenter<
|
||||||
CustomerInvoice,
|
CustomerInvoice,
|
||||||
GetIssuedInvoiceByIdResponseDTO
|
GetIssuedInvoiceByIdResponseDTO
|
||||||
> {
|
> {
|
||||||
toOutput(invoice: CustomerInvoice): GetIssuedInvoiceByIdResponseDTO {
|
toOutput(invoice: CustomerInvoice): GetIssuedInvoiceByIdResponseDTO {
|
||||||
const itemsPresenter = this.presenterRegistry.getPresenter({
|
const itemsPresenter = this.presenterRegistry.getPresenter({
|
||||||
resource: "customer-invoice-items",
|
resource: "issued-invoice-items",
|
||||||
projection: "FULL",
|
projection: "FULL",
|
||||||
}) as CustomerInvoiceItemsFullPresenter;
|
}) as IssuedInvoiceItemsFullPresenter;
|
||||||
|
|
||||||
const recipientPresenter = this.presenterRegistry.getPresenter({
|
const recipientPresenter = this.presenterRegistry.getPresenter({
|
||||||
resource: "recipient-invoice",
|
resource: "issued-invoice-recipient",
|
||||||
projection: "FULL",
|
projection: "FULL",
|
||||||
}) as RecipientInvoiceFullPresenter;
|
}) as IssuedInvoiceRecipientFullPresenter;
|
||||||
|
|
||||||
|
const verifactuPresenter = this.presenterRegistry.getPresenter({
|
||||||
|
resource: "issued-invoice-verifactu",
|
||||||
|
projection: "FULL",
|
||||||
|
}) as IssuedInvoiceVerifactuFullPresenter;
|
||||||
|
|
||||||
const recipient = recipientPresenter.toOutput(invoice);
|
const recipient = recipientPresenter.toOutput(invoice);
|
||||||
const items = itemsPresenter.toOutput(invoice.items);
|
const items = itemsPresenter.toOutput(invoice.items);
|
||||||
|
const verifactu = verifactuPresenter.toOutput(invoice);
|
||||||
const allAmounts = invoice.getAllAmounts();
|
const allAmounts = invoice.getAllAmounts();
|
||||||
|
|
||||||
const payment = invoice.paymentMethod.match(
|
const payment = invoice.paymentMethod.match(
|
||||||
@ -81,10 +88,12 @@ export class CustomerInvoiceFullPresenter extends Presenter<
|
|||||||
taxes_amount: allAmounts.taxesAmount.toObjectString(),
|
taxes_amount: allAmounts.taxesAmount.toObjectString(),
|
||||||
total_amount: allAmounts.totalAmount.toObjectString(),
|
total_amount: allAmounts.totalAmount.toObjectString(),
|
||||||
|
|
||||||
|
verifactu,
|
||||||
|
|
||||||
items,
|
items,
|
||||||
|
|
||||||
metadata: {
|
metadata: {
|
||||||
entity: "customer-invoices",
|
entity: "issued-invoices",
|
||||||
link: "",
|
link: "",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -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 "./domain";
|
||||||
export * from "./queries";
|
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 "./issued-invoices";
|
||||||
export * from "./customer-invoice-items.report.presenter";
|
export * from "./proformas";
|
||||||
export * from "./customer-invoice-taxes.report.presenter";
|
|
||||||
export * from "./list-customer-invoices.presenter";
|
|
||||||
|
|||||||
@ -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 { toEmptyString } from "@repo/rdx-ddd";
|
||||||
import type { ArrayElement, Collection } from "@repo/rdx-utils";
|
import type { ArrayElement, Collection } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import type { ListIssuedInvoicesResponseDTO } from "../../../../common/dto";
|
import type { ListIssuedInvoicesResponseDTO } from "../../../../../common/dto";
|
||||||
import type { CustomerInvoiceListDTO } from "../../../infrastructure";
|
import type { CustomerInvoiceListDTO } from "../../../../infrastructure";
|
||||||
|
|
||||||
export class ListCustomerInvoicesPresenter extends Presenter {
|
export class IssuedInvoiceListPresenter extends Presenter {
|
||||||
protected _mapInvoice(invoice: CustomerInvoiceListDTO) {
|
protected _mapInvoice(invoice: CustomerInvoiceListDTO) {
|
||||||
const recipientDTO = invoice.recipient.toObjectString();
|
const recipientDTO = invoice.recipient.toObjectString();
|
||||||
|
|
||||||
|
const verifactuDTO = invoice.verifactu.match(
|
||||||
|
(verifactu) => verifactu.toObjectString(),
|
||||||
|
() => ({
|
||||||
|
status: "",
|
||||||
|
url: "",
|
||||||
|
qr_code: "",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const invoiceDTO: ArrayElement<ListIssuedInvoicesResponseDTO["items"]> = {
|
const invoiceDTO: ArrayElement<ListIssuedInvoicesResponseDTO["items"]> = {
|
||||||
id: invoice.id.toString(),
|
id: invoice.id.toString(),
|
||||||
company_id: invoice.companyId.toString(),
|
company_id: invoice.companyId.toString(),
|
||||||
@ -39,8 +48,10 @@ export class ListCustomerInvoicesPresenter extends Presenter {
|
|||||||
taxes_amount: invoice.taxesAmount.toObjectString(),
|
taxes_amount: invoice.taxesAmount.toObjectString(),
|
||||||
total_amount: invoice.totalAmount.toObjectString(),
|
total_amount: invoice.totalAmount.toObjectString(),
|
||||||
|
|
||||||
|
verifactu: verifactuDTO,
|
||||||
|
|
||||||
metadata: {
|
metadata: {
|
||||||
entity: "customer-invoice",
|
entity: "issued-invoice",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -48,22 +59,22 @@ export class ListCustomerInvoicesPresenter extends Presenter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toOutput(params: {
|
toOutput(params: {
|
||||||
customerInvoices: Collection<CustomerInvoiceListDTO>;
|
invoices: Collection<CustomerInvoiceListDTO>;
|
||||||
criteria: Criteria;
|
criteria: Criteria;
|
||||||
}): ListIssuedInvoicesResponseDTO {
|
}): ListIssuedInvoicesResponseDTO {
|
||||||
const { customerInvoices, criteria } = params;
|
const { invoices, criteria } = params;
|
||||||
|
|
||||||
const invoices = customerInvoices.map((invoice) => this._mapInvoice(invoice));
|
const _invoices = invoices.map((invoice) => this._mapInvoice(invoice));
|
||||||
const totalItems = customerInvoices.total();
|
const _totalItems = invoices.total();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
page: criteria.pageNumber,
|
page: criteria.pageNumber,
|
||||||
per_page: criteria.pageSize,
|
per_page: criteria.pageSize,
|
||||||
total_pages: Math.ceil(totalItems / criteria.pageSize),
|
total_pages: Math.ceil(_totalItems / criteria.pageSize),
|
||||||
total_items: totalItems,
|
total_items: _totalItems,
|
||||||
items: invoices,
|
items: _invoices,
|
||||||
metadata: {
|
metadata: {
|
||||||
entity: "customer-invoices",
|
entity: "issued-invoices",
|
||||||
criteria: criteria.toJSON(),
|
criteria: criteria.toJSON(),
|
||||||
//links: {
|
//links: {
|
||||||
// self: `/api/customer-invoices?page=${criteria.pageNumber}&per_page=${criteria.pageSize}`,
|
// self: `/api/customer-invoices?page=${criteria.pageNumber}&per_page=${criteria.pageSize}`,
|
||||||
@ -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 { GetIssuedInvoiceByIdResponseDTO } from "@erp/customer-invoices/common";
|
||||||
import type { ArrayElement } from "@repo/rdx-utils";
|
import type { ArrayElement } from "@repo/rdx-utils";
|
||||||
|
|
||||||
type CustomerInvoiceItemsDTO = GetIssuedInvoiceByIdResponseDTO["items"];
|
type IssuedInvoiceItemsDTO = GetIssuedInvoiceByIdResponseDTO["items"];
|
||||||
type CustomerInvoiceItemDTO = ArrayElement<CustomerInvoiceItemsDTO>;
|
type IssuedInvoiceItemDTO = ArrayElement<IssuedInvoiceItemsDTO>;
|
||||||
|
|
||||||
export class CustomerInvoiceItemsReportPersenter extends Presenter<
|
export class IssuedInvoiceItemsReportPresenter extends Presenter<IssuedInvoiceItemsDTO, unknown> {
|
||||||
CustomerInvoiceItemsDTO,
|
|
||||||
unknown
|
|
||||||
> {
|
|
||||||
private _locale!: string;
|
private _locale!: string;
|
||||||
|
|
||||||
private _mapItem(invoiceItem: CustomerInvoiceItemDTO, _index: number) {
|
private _mapItem(invoiceItem: IssuedInvoiceItemDTO, _index: number) {
|
||||||
const moneyOptions = {
|
const moneyOptions = {
|
||||||
hideZeros: true,
|
hideZeros: true,
|
||||||
minimumFractionDigits: 0,
|
minimumFractionDigits: 0,
|
||||||
@ -48,14 +45,14 @@ export class CustomerInvoiceItemsReportPersenter extends Presenter<
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
toOutput(invoiceItems: CustomerInvoiceItemsDTO, params: IPresenterOutputParams): unknown {
|
toOutput(issuedInvoiceItems: IssuedInvoiceItemsDTO, params: IPresenterOutputParams): unknown {
|
||||||
const { locale } = params as {
|
const { locale } = params as {
|
||||||
locale: string;
|
locale: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
this._locale = locale;
|
this._locale = locale;
|
||||||
|
|
||||||
return invoiceItems.map((item, index) => {
|
return issuedInvoiceItems.map((item, index) => {
|
||||||
return this._mapItem(item, index);
|
return this._mapItem(item, index);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -3,17 +3,14 @@ import { type IPresenterOutputParams, Presenter } from "@erp/core/api";
|
|||||||
import type { GetIssuedInvoiceByIdResponseDTO } from "@erp/customer-invoices/common";
|
import type { GetIssuedInvoiceByIdResponseDTO } from "@erp/customer-invoices/common";
|
||||||
import type { ArrayElement } from "@repo/rdx-utils";
|
import type { ArrayElement } from "@repo/rdx-utils";
|
||||||
|
|
||||||
type CustomerInvoiceTaxesDTO = GetIssuedInvoiceByIdResponseDTO["taxes"];
|
type IssuedInvoiceTaxesDTO = GetIssuedInvoiceByIdResponseDTO["taxes"];
|
||||||
type CustomerInvoiceTaxDTO = ArrayElement<CustomerInvoiceTaxesDTO>;
|
type IssuedInvoiceTaxDTO = ArrayElement<IssuedInvoiceTaxesDTO>;
|
||||||
|
|
||||||
export class CustomerInvoiceTaxesReportPresenter extends Presenter<
|
export class IssuedInvoiceTaxesReportPresenter extends Presenter<IssuedInvoiceTaxesDTO, unknown> {
|
||||||
CustomerInvoiceTaxesDTO,
|
|
||||||
unknown
|
|
||||||
> {
|
|
||||||
private _locale!: string;
|
private _locale!: string;
|
||||||
private _taxCatalog!: JsonTaxCatalogProvider;
|
private _taxCatalog!: JsonTaxCatalogProvider;
|
||||||
|
|
||||||
private _mapTax(taxItem: CustomerInvoiceTaxDTO) {
|
private _mapTax(taxItem: IssuedInvoiceTaxDTO) {
|
||||||
const moneyOptions = {
|
const moneyOptions = {
|
||||||
hideZeros: true,
|
hideZeros: true,
|
||||||
minimumFractionDigits: 0,
|
minimumFractionDigits: 0,
|
||||||
@ -29,7 +26,7 @@ export class CustomerInvoiceTaxesReportPresenter extends Presenter<
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
toOutput(taxes: CustomerInvoiceTaxesDTO, params: IPresenterOutputParams): unknown {
|
toOutput(taxes: IssuedInvoiceTaxesDTO, params: IPresenterOutputParams): unknown {
|
||||||
const { locale } = params as {
|
const { locale } = params as {
|
||||||
locale: string;
|
locale: string;
|
||||||
};
|
};
|
||||||
@ -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,
|
criteria: Criteria,
|
||||||
transaction?: Transaction
|
transaction?: Transaction
|
||||||
): Promise<Result<Collection<CustomerInvoiceListDTO>, Error>> {
|
): Promise<Result<Collection<CustomerInvoiceListDTO>, Error>> {
|
||||||
return this.repository.findByCriteriaInCompany(companyId, criteria, transaction, {
|
return this.repository.findProformasByCriteriaInCompany(companyId, criteria, transaction, {});
|
||||||
where: {
|
|
||||||
is_proforma: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -208,11 +204,12 @@ export class CustomerInvoiceApplicationService {
|
|||||||
criteria: Criteria,
|
criteria: Criteria,
|
||||||
transaction?: Transaction
|
transaction?: Transaction
|
||||||
): Promise<Result<Collection<CustomerInvoiceListDTO>, Error>> {
|
): Promise<Result<Collection<CustomerInvoiceListDTO>, Error>> {
|
||||||
return this.repository.findByCriteriaInCompany(companyId, criteria, transaction, {
|
return this.repository.findIssuedInvoicesByCriteriaInCompany(
|
||||||
where: {
|
companyId,
|
||||||
is_proforma: false,
|
criteria,
|
||||||
},
|
transaction,
|
||||||
});
|
{}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -227,11 +224,12 @@ export class CustomerInvoiceApplicationService {
|
|||||||
invoiceId: UniqueID,
|
invoiceId: UniqueID,
|
||||||
transaction?: Transaction
|
transaction?: Transaction
|
||||||
): Promise<Result<CustomerInvoice>> {
|
): Promise<Result<CustomerInvoice>> {
|
||||||
return await this.repository.getByIdInCompany(companyId, invoiceId, transaction, {
|
return await this.repository.getIssuedInvoiceByIdInCompany(
|
||||||
where: {
|
companyId,
|
||||||
is_proforma: false,
|
invoiceId,
|
||||||
},
|
transaction,
|
||||||
});
|
{}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -246,11 +244,7 @@ export class CustomerInvoiceApplicationService {
|
|||||||
proformaId: UniqueID,
|
proformaId: UniqueID,
|
||||||
transaction?: Transaction
|
transaction?: Transaction
|
||||||
): Promise<Result<CustomerInvoice>> {
|
): Promise<Result<CustomerInvoice>> {
|
||||||
return await this.repository.getByIdInCompany(companyId, proformaId, transaction, {
|
return await this.repository.getProformaByIdInCompany(companyId, proformaId, transaction, {});
|
||||||
where: {
|
|
||||||
is_proforma: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import type { IPresenterRegistry, ITransactionManager } from "@erp/core/api";
|
|||||||
import { UniqueID } from "@repo/rdx-ddd";
|
import { UniqueID } from "@repo/rdx-ddd";
|
||||||
import { Result } from "@repo/rdx-utils";
|
import { Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import type { CustomerInvoiceFullPresenter } from "../../presenters/domain";
|
import type { ProformaFullPresenter } from "../../presenters/domain";
|
||||||
import type { CustomerInvoiceApplicationService } from "../../services";
|
import type { CustomerInvoiceApplicationService } from "../../services";
|
||||||
|
|
||||||
type GetIssuedInvoiceUseCaseInput = {
|
type GetIssuedInvoiceUseCaseInput = {
|
||||||
@ -27,9 +27,9 @@ export class GetIssuedInvoiceUseCase {
|
|||||||
|
|
||||||
const invoiceId = idOrError.data;
|
const invoiceId = idOrError.data;
|
||||||
const presenter = this.presenterRegistry.getPresenter({
|
const presenter = this.presenterRegistry.getPresenter({
|
||||||
resource: "customer-invoice",
|
resource: "issued-invoice",
|
||||||
projection: "FULL",
|
projection: "FULL",
|
||||||
}) as CustomerInvoiceFullPresenter;
|
}) as ProformaFullPresenter;
|
||||||
|
|
||||||
return this.transactionManager.complete(async (transaction) => {
|
return this.transactionManager.complete(async (transaction) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
export * from "./get-issued-invoice.use-case";
|
export * from "./get-issued-invoice.use-case";
|
||||||
export * from "./list-issued-invoices.use-case";
|
export * from "./list-issued-invoices.use-case";
|
||||||
export * from "./report-issued-invoice.use-case";
|
export * from "./report-issued-invoices";
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { Result } from "@repo/rdx-utils";
|
|||||||
import type { Transaction } from "sequelize";
|
import type { Transaction } from "sequelize";
|
||||||
|
|
||||||
import type { ListIssuedInvoicesResponseDTO } from "../../../../common/dto";
|
import type { ListIssuedInvoicesResponseDTO } from "../../../../common/dto";
|
||||||
import type { ListCustomerInvoicesPresenter } from "../../presenters";
|
import type { IssuedInvoiceListPresenter } from "../../presenters";
|
||||||
import type { CustomerInvoiceApplicationService } from "../../services";
|
import type { CustomerInvoiceApplicationService } from "../../services";
|
||||||
|
|
||||||
type ListIssuedInvoicesUseCaseInput = {
|
type ListIssuedInvoicesUseCaseInput = {
|
||||||
@ -25,9 +25,9 @@ export class ListIssuedInvoicesUseCase {
|
|||||||
): Promise<Result<ListIssuedInvoicesResponseDTO, Error>> {
|
): Promise<Result<ListIssuedInvoicesResponseDTO, Error>> {
|
||||||
const { criteria, companyId } = params;
|
const { criteria, companyId } = params;
|
||||||
const presenter = this.presenterRegistry.getPresenter({
|
const presenter = this.presenterRegistry.getPresenter({
|
||||||
resource: "customer-invoice",
|
resource: "issued-invoice",
|
||||||
projection: "LIST",
|
projection: "LIST",
|
||||||
}) as ListCustomerInvoicesPresenter;
|
}) as IssuedInvoiceListPresenter;
|
||||||
|
|
||||||
return this.transactionManager.complete(async (transaction: Transaction) => {
|
return this.transactionManager.complete(async (transaction: Transaction) => {
|
||||||
try {
|
try {
|
||||||
@ -43,7 +43,7 @@ export class ListIssuedInvoicesUseCase {
|
|||||||
|
|
||||||
const invoices = result.data;
|
const invoices = result.data;
|
||||||
const dto = presenter.toOutput({
|
const dto = presenter.toOutput({
|
||||||
customerInvoices: invoices,
|
invoices: invoices,
|
||||||
criteria,
|
criteria,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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 { UniqueID } from "@repo/rdx-ddd";
|
||||||
import { Result } from "@repo/rdx-utils";
|
import { Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import type { CustomerInvoiceApplicationService } from "../../services";
|
import type { CustomerInvoiceApplicationService } from "../../../services";
|
||||||
import type { CustomerInvoiceReportPDFPresenter } from "../proformas";
|
|
||||||
|
import type { IssuedInvoiceReportPDFPresenter } from "./reporter/issued-invoice.report.pdf";
|
||||||
|
|
||||||
type ReportIssuedInvoiceUseCaseInput = {
|
type ReportIssuedInvoiceUseCaseInput = {
|
||||||
companyId: UniqueID;
|
companyId: UniqueID;
|
||||||
@ -28,10 +29,10 @@ export class ReportIssuedInvoiceUseCase {
|
|||||||
|
|
||||||
const invoiceId = idOrError.data;
|
const invoiceId = idOrError.data;
|
||||||
const pdfPresenter = this.presenterRegistry.getPresenter({
|
const pdfPresenter = this.presenterRegistry.getPresenter({
|
||||||
resource: "customer-invoice",
|
resource: "issued-invoice",
|
||||||
projection: "REPORT",
|
projection: "REPORT",
|
||||||
format: "PDF",
|
format: "PDF",
|
||||||
}) as CustomerInvoiceReportPDFPresenter;
|
}) as IssuedInvoiceReportPDFPresenter;
|
||||||
|
|
||||||
return this.transactionManager.complete(async (transaction) => {
|
return this.transactionManager.complete(async (transaction) => {
|
||||||
try {
|
try {
|
||||||
@ -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 { CustomerInvoice } from "../../../../../domain";
|
||||||
import type {
|
import type {
|
||||||
CustomerInvoiceFullPresenter,
|
IssuedInvoiceFullPresenter,
|
||||||
CustomerInvoiceReportPresenter,
|
IssuedInvoiceReportPresenter,
|
||||||
} from "../../../../presenters";
|
} from "../../../../presenters";
|
||||||
|
|
||||||
/** Helper para trabajar relativo al fichero actual (ESM) */
|
/** Helper para trabajar relativo al fichero actual (ESM) */
|
||||||
@ -23,26 +23,26 @@ export function fromHere(metaUrl: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CustomerInvoiceReportHTMLPresenter extends Presenter {
|
export class IssuedInvoiceReportHTMLPresenter extends Presenter {
|
||||||
toOutput(customerInvoice: CustomerInvoice): string {
|
toOutput(invoice: CustomerInvoice): string {
|
||||||
const dtoPresenter = this.presenterRegistry.getPresenter({
|
const dtoPresenter = this.presenterRegistry.getPresenter({
|
||||||
resource: "customer-invoice",
|
resource: "issued-invoice",
|
||||||
projection: "FULL",
|
projection: "FULL",
|
||||||
}) as CustomerInvoiceFullPresenter;
|
}) as IssuedInvoiceFullPresenter;
|
||||||
|
|
||||||
const prePresenter = this.presenterRegistry.getPresenter({
|
const prePresenter = this.presenterRegistry.getPresenter({
|
||||||
resource: "customer-invoice",
|
resource: "issued-invoice",
|
||||||
projection: "REPORT",
|
projection: "REPORT",
|
||||||
format: "JSON",
|
format: "JSON",
|
||||||
}) as CustomerInvoiceReportPresenter;
|
}) as IssuedInvoiceReportPresenter;
|
||||||
|
|
||||||
const invoiceDTO = dtoPresenter.toOutput(customerInvoice);
|
const invoiceDTO = dtoPresenter.toOutput(invoice);
|
||||||
const prettyDTO = prePresenter.toOutput(invoiceDTO);
|
const prettyDTO = prePresenter.toOutput(invoiceDTO);
|
||||||
|
|
||||||
// Obtener y compilar la plantilla HTML
|
// Obtener y compilar la plantilla HTML
|
||||||
const here = fromHere(import.meta.url);
|
const here = fromHere(import.meta.url);
|
||||||
|
|
||||||
const templatePath = here.resolve("./templates/customer-invoice/template.hbs");
|
const templatePath = here.resolve("./templates/proforma/template.hbs");
|
||||||
const templateHtml = readFileSync(templatePath).toString();
|
const templateHtml = readFileSync(templatePath).toString();
|
||||||
const template = Handlebars.compile(templateHtml, {});
|
const template = Handlebars.compile(templateHtml, {});
|
||||||
return template(prettyDTO);
|
return template(prettyDTO);
|
||||||
@ -4,23 +4,23 @@ import report from "puppeteer-report";
|
|||||||
|
|
||||||
import type { CustomerInvoice } from "../../../../../domain";
|
import type { CustomerInvoice } from "../../../../../domain";
|
||||||
|
|
||||||
import type { CustomerInvoiceReportHTMLPresenter } from "./customer-invoice.report.html";
|
import type { IssuedInvoiceReportHTMLPresenter } from "./issued-invoice.report.html";
|
||||||
|
|
||||||
// https://plnkr.co/edit/lWk6Yd?preview
|
// https://plnkr.co/edit/lWk6Yd?preview
|
||||||
|
|
||||||
export class CustomerInvoiceReportPDFPresenter extends Presenter<
|
export class IssuedInvoiceReportPDFPresenter extends Presenter<
|
||||||
CustomerInvoice,
|
CustomerInvoice,
|
||||||
Promise<Buffer<ArrayBuffer>>
|
Promise<Buffer<ArrayBuffer>>
|
||||||
> {
|
> {
|
||||||
async toOutput(customerInvoice: CustomerInvoice): Promise<Buffer<ArrayBuffer>> {
|
async toOutput(invoice: CustomerInvoice): Promise<Buffer<ArrayBuffer>> {
|
||||||
try {
|
try {
|
||||||
const htmlPresenter = this.presenterRegistry.getPresenter({
|
const htmlPresenter = this.presenterRegistry.getPresenter({
|
||||||
resource: "customer-invoice",
|
resource: "issued-invoice",
|
||||||
projection: "REPORT",
|
projection: "REPORT",
|
||||||
format: "HTML",
|
format: "HTML",
|
||||||
}) as CustomerInvoiceReportHTMLPresenter;
|
}) as IssuedInvoiceReportHTMLPresenter;
|
||||||
|
|
||||||
const htmlData = htmlPresenter.toOutput(customerInvoice);
|
const htmlData = htmlPresenter.toOutput(invoice);
|
||||||
|
|
||||||
// Generar el PDF con Puppeteer
|
// Generar el PDF con Puppeteer
|
||||||
const browser = await puppeteer.launch({
|
const browser = await puppeteer.launch({
|
||||||
@ -9,7 +9,7 @@ import { Result } from "@repo/rdx-utils";
|
|||||||
import type { Transaction } from "sequelize";
|
import type { Transaction } from "sequelize";
|
||||||
|
|
||||||
import type { CreateProformaRequestDTO } from "../../../../../common";
|
import type { CreateProformaRequestDTO } from "../../../../../common";
|
||||||
import type { CustomerInvoiceFullPresenter } from "../../../presenters";
|
import type { ProformaFullPresenter } from "../../../presenters";
|
||||||
import type { CustomerInvoiceApplicationService } from "../../../services";
|
import type { CustomerInvoiceApplicationService } from "../../../services";
|
||||||
|
|
||||||
import { CreateCustomerInvoicePropsMapper } from "./map-dto-to-create-proforma-props";
|
import { CreateCustomerInvoicePropsMapper } from "./map-dto-to-create-proforma-props";
|
||||||
@ -30,9 +30,9 @@ export class CreateProformaUseCase {
|
|||||||
public async execute(params: CreateProformaUseCaseInput) {
|
public async execute(params: CreateProformaUseCaseInput) {
|
||||||
const { dto, companyId } = params;
|
const { dto, companyId } = params;
|
||||||
const presenter = this.presenterRegistry.getPresenter({
|
const presenter = this.presenterRegistry.getPresenter({
|
||||||
resource: "customer-invoice",
|
resource: "proforma",
|
||||||
projection: "FULL",
|
projection: "FULL",
|
||||||
}) as CustomerInvoiceFullPresenter;
|
}) as ProformaFullPresenter;
|
||||||
|
|
||||||
// 1) Mapear DTO → props de dominio
|
// 1) Mapear DTO → props de dominio
|
||||||
const dtoMapper = new CreateCustomerInvoicePropsMapper({ taxCatalog: this.taxCatalog });
|
const dtoMapper = new CreateCustomerInvoicePropsMapper({ taxCatalog: this.taxCatalog });
|
||||||
@ -65,24 +65,24 @@ export class CreateProformaUseCase {
|
|||||||
return Result.fail(buildResult.error);
|
return Result.fail(buildResult.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newInvoice = buildResult.data;
|
const newProforma = buildResult.data;
|
||||||
|
|
||||||
const existsGuard = await this.ensureNotExists(companyId, id, transaction);
|
const existsGuard = await this.ensureNotExists(companyId, id, transaction);
|
||||||
if (existsGuard.isFailure) {
|
if (existsGuard.isFailure) {
|
||||||
return Result.fail(existsGuard.error);
|
return Result.fail(existsGuard.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveResult = await this.service.createInvoiceInCompany(
|
const saveResult = await this.service.createProformaInCompany(
|
||||||
companyId,
|
companyId,
|
||||||
newInvoice,
|
newProforma,
|
||||||
transaction
|
transaction
|
||||||
);
|
);
|
||||||
if (saveResult.isFailure) {
|
if (saveResult.isFailure) {
|
||||||
return Result.fail(saveResult.error);
|
return Result.fail(saveResult.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const invoice = saveResult.data;
|
const proforma = saveResult.data;
|
||||||
const dto = presenter.toOutput(invoice);
|
const dto = presenter.toOutput(proforma);
|
||||||
|
|
||||||
return Result.ok(dto);
|
return Result.ok(dto);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@ -99,7 +99,7 @@ export class CreateProformaUseCase {
|
|||||||
id: UniqueID,
|
id: UniqueID,
|
||||||
transaction: Transaction
|
transaction: Transaction
|
||||||
): Promise<Result<void, Error>> {
|
): Promise<Result<void, Error>> {
|
||||||
const existsResult = await this.service.existsByIdInCompany(companyId, id, transaction);
|
const existsResult = await this.service.existsProformaByIdInCompany(companyId, id, transaction);
|
||||||
if (existsResult.isFailure) {
|
if (existsResult.isFailure) {
|
||||||
return Result.fail(existsResult.error);
|
return Result.fail(existsResult.error);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import type { IPresenterRegistry, ITransactionManager } from "@erp/core/api";
|
|||||||
import { UniqueID } from "@repo/rdx-ddd";
|
import { UniqueID } from "@repo/rdx-ddd";
|
||||||
import { Result } from "@repo/rdx-utils";
|
import { Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import type { CustomerInvoiceFullPresenter } from "../../presenters/domain";
|
import type { ProformaFullPresenter } from "../../presenters/domain";
|
||||||
import type { CustomerInvoiceApplicationService } from "../../services";
|
import type { CustomerInvoiceApplicationService } from "../../services";
|
||||||
|
|
||||||
type GetProformaUseCaseInput = {
|
type GetProformaUseCaseInput = {
|
||||||
@ -27,9 +27,9 @@ export class GetProformaUseCase {
|
|||||||
|
|
||||||
const proformaId = idOrError.data;
|
const proformaId = idOrError.data;
|
||||||
const presenter = this.presenterRegistry.getPresenter({
|
const presenter = this.presenterRegistry.getPresenter({
|
||||||
resource: "customer-invoice",
|
resource: "proforma",
|
||||||
projection: "FULL",
|
projection: "FULL",
|
||||||
}) as CustomerInvoiceFullPresenter;
|
}) as ProformaFullPresenter;
|
||||||
|
|
||||||
return this.transactionManager.complete(async (transaction) => {
|
return this.transactionManager.complete(async (transaction) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import {
|
|||||||
IssueCustomerInvoiceDomainService,
|
IssueCustomerInvoiceDomainService,
|
||||||
ProformaCustomerInvoiceDomainService,
|
ProformaCustomerInvoiceDomainService,
|
||||||
} from "../../../domain";
|
} from "../../../domain";
|
||||||
import type { CustomerInvoiceFullPresenter } from "../../presenters";
|
import type { ProformaFullPresenter } from "../../presenters";
|
||||||
import type { CustomerInvoiceApplicationService } from "../../services";
|
import type { CustomerInvoiceApplicationService } from "../../services";
|
||||||
|
|
||||||
type IssueProformaUseCaseInput = {
|
type IssueProformaUseCaseInput = {
|
||||||
@ -43,9 +43,9 @@ export class IssueProformaUseCase {
|
|||||||
|
|
||||||
const proformaId = idOrError.data;
|
const proformaId = idOrError.data;
|
||||||
const presenter = this.presenterRegistry.getPresenter({
|
const presenter = this.presenterRegistry.getPresenter({
|
||||||
resource: "customer-invoice",
|
resource: "proforma",
|
||||||
projection: "FULL",
|
projection: "FULL",
|
||||||
}) as CustomerInvoiceFullPresenter;
|
}) as ProformaFullPresenter;
|
||||||
|
|
||||||
return this.transactionManager.complete(async (transaction) => {
|
return this.transactionManager.complete(async (transaction) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import type { IPresenterRegistry, ITransactionManager } from "@erp/core/api";
|
import type { IPresenterRegistry, ITransactionManager } from "@erp/core/api";
|
||||||
|
import type { ListProformasResponseDTO } from "@erp/customer-invoices/common";
|
||||||
import type { Criteria } from "@repo/rdx-criteria/server";
|
import type { Criteria } from "@repo/rdx-criteria/server";
|
||||||
import type { UniqueID } from "@repo/rdx-ddd";
|
import type { UniqueID } from "@repo/rdx-ddd";
|
||||||
import { Result } from "@repo/rdx-utils";
|
import { Result } from "@repo/rdx-utils";
|
||||||
import type { Transaction } from "sequelize";
|
import type { Transaction } from "sequelize";
|
||||||
|
|
||||||
import type { ListIssuedInvoicesResponseDTO } from "../../../../common/dto";
|
import type { ProformaListPresenter } from "../../presenters";
|
||||||
import type { ListCustomerInvoicesPresenter } from "../../presenters";
|
|
||||||
import type { CustomerInvoiceApplicationService } from "../../services";
|
import type { CustomerInvoiceApplicationService } from "../../services";
|
||||||
|
|
||||||
type ListProformasUseCaseInput = {
|
type ListProformasUseCaseInput = {
|
||||||
@ -22,12 +22,12 @@ export class ListProformasUseCase {
|
|||||||
|
|
||||||
public execute(
|
public execute(
|
||||||
params: ListProformasUseCaseInput
|
params: ListProformasUseCaseInput
|
||||||
): Promise<Result<ListIssuedInvoicesResponseDTO, Error>> {
|
): Promise<Result<ListProformasResponseDTO, Error>> {
|
||||||
const { criteria, companyId } = params;
|
const { criteria, companyId } = params;
|
||||||
const presenter = this.presenterRegistry.getPresenter({
|
const presenter = this.presenterRegistry.getPresenter({
|
||||||
resource: "customer-invoice",
|
resource: "proforma",
|
||||||
projection: "LIST",
|
projection: "LIST",
|
||||||
}) as ListCustomerInvoicesPresenter;
|
}) as ProformaListPresenter;
|
||||||
|
|
||||||
return this.transactionManager.complete(async (transaction: Transaction) => {
|
return this.transactionManager.complete(async (transaction: Transaction) => {
|
||||||
try {
|
try {
|
||||||
@ -43,7 +43,7 @@ export class ListProformasUseCase {
|
|||||||
|
|
||||||
const proformas = result.data;
|
const proformas = result.data;
|
||||||
const dto = presenter.toOutput({
|
const dto = presenter.toOutput({
|
||||||
customerInvoices: proformas,
|
proformas,
|
||||||
criteria,
|
criteria,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { Result } from "@repo/rdx-utils";
|
|||||||
|
|
||||||
import type { CustomerInvoiceApplicationService } from "../../../services/customer-invoice-application.service";
|
import type { CustomerInvoiceApplicationService } from "../../../services/customer-invoice-application.service";
|
||||||
|
|
||||||
import type { CustomerInvoiceReportPDFPresenter } from "./reporter";
|
import type { ProformaReportPDFPresenter } from "./reporter";
|
||||||
|
|
||||||
type ReportProformaUseCaseInput = {
|
type ReportProformaUseCaseInput = {
|
||||||
companyId: UniqueID;
|
companyId: UniqueID;
|
||||||
@ -29,10 +29,10 @@ export class ReportProformaUseCase {
|
|||||||
|
|
||||||
const proformaId = idOrError.data;
|
const proformaId = idOrError.data;
|
||||||
const pdfPresenter = this.presenterRegistry.getPresenter({
|
const pdfPresenter = this.presenterRegistry.getPresenter({
|
||||||
resource: "customer-invoice",
|
resource: "proforma",
|
||||||
projection: "REPORT",
|
projection: "REPORT",
|
||||||
format: "PDF",
|
format: "PDF",
|
||||||
}) as CustomerInvoiceReportPDFPresenter;
|
}) as ProformaReportPDFPresenter;
|
||||||
|
|
||||||
return this.transactionManager.complete(async (transaction) => {
|
return this.transactionManager.complete(async (transaction) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
export * from "./customer-invoice.report.html";
|
export * from "./proforma.report.html";
|
||||||
export * from "./customer-invoice.report.pdf";
|
export * from "./proforma.report.pdf";
|
||||||
|
|||||||
@ -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 {
|
header {
|
||||||
|
font-family: Tahoma, sans-serif;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
@ -253,9 +254,16 @@
|
|||||||
|
|
||||||
<footer id="footer" class="mt-4">
|
<footer id="footer" class="mt-4">
|
||||||
<aside>
|
<aside>
|
||||||
<p class="text-center">Insc. en el Reg. Merc. de Madrid, Tomo 20.073, Libro 0, Folio 141, Sección 8, Hoja M-354212
|
<p class="text-center">Insc. en el Reg. Merc. de Madrid, Tomo 31.839, Libro 0, Folio 191, Sección 8, Hoja M-572991
|
||||||
| CIF: B83999441 -
|
CIF: B86913910</p>
|
||||||
Rodax Software S.L.</p>
|
<p class="text-left" style="font-size: 6pt;">Información en protección de datos<br />De conformidad con lo
|
||||||
|
dispuesto en el RGPD y LOPDGDD,
|
||||||
|
informamos que los datos personales serán tratados por
|
||||||
|
ALISO DESIGN S.L para cumplir con la obligación tributaria de emitir facturas. Podrá solicitar más información,
|
||||||
|
y ejercer sus derechos escribiendo a info@acanainteriorismo.com o mediante correo postal a la dirección CALLE LA
|
||||||
|
FUNDICION 27 POL. IND. SANTA ANA (28522) RIVAS-VACIAMADRID, MADRID. Para el ejercicio de sus derechos, en caso
|
||||||
|
de que sea necesario, se le solicitará documento que acredite su identidad. Si siente vulnerados sus derechos
|
||||||
|
puede presentar una reclamación ante la AEPD, en su web: www.aepd.es.</p>
|
||||||
</aside>
|
</aside>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
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 { UpdateProformaByIdRequestDTO } from "../../../../../common";
|
||||||
import type { CustomerInvoicePatchProps } from "../../../../domain";
|
import type { CustomerInvoicePatchProps } from "../../../../domain";
|
||||||
import type { CustomerInvoiceFullPresenter } from "../../../presenters";
|
import type { ProformaFullPresenter } from "../../../presenters";
|
||||||
import type { CustomerInvoiceApplicationService } from "../../../services/customer-invoice-application.service";
|
import type { CustomerInvoiceApplicationService } from "../../../services/customer-invoice-application.service";
|
||||||
|
|
||||||
import { mapDTOToUpdateCustomerInvoicePatchProps } from "./map-dto-to-update-customer-invoice-props";
|
import { mapDTOToUpdateCustomerInvoicePatchProps } from "./map-dto-to-update-customer-invoice-props";
|
||||||
@ -33,9 +33,9 @@ export class UpdateProformaUseCase {
|
|||||||
|
|
||||||
const invoiceId = idOrError.data;
|
const invoiceId = idOrError.data;
|
||||||
const presenter = this.presenterRegistry.getPresenter({
|
const presenter = this.presenterRegistry.getPresenter({
|
||||||
resource: "customer-invoice",
|
resource: "proforma",
|
||||||
projection: "FULL",
|
projection: "FULL",
|
||||||
}) as CustomerInvoiceFullPresenter;
|
}) as ProformaFullPresenter;
|
||||||
|
|
||||||
// Mapear DTO → props de dominio
|
// Mapear DTO → props de dominio
|
||||||
const patchPropsResult = mapDTOToUpdateCustomerInvoicePatchProps(dto);
|
const patchPropsResult = mapDTOToUpdateCustomerInvoicePatchProps(dto);
|
||||||
|
|||||||
@ -10,7 +10,12 @@ import {
|
|||||||
} from "@repo/rdx-ddd";
|
} from "@repo/rdx-ddd";
|
||||||
import { type Maybe, Result } from "@repo/rdx-utils";
|
import { type Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import { CustomerInvoiceItems, type InvoicePaymentMethod, type InvoiceTaxTotal } from "../entities";
|
import {
|
||||||
|
CustomerInvoiceItems,
|
||||||
|
type InvoicePaymentMethod,
|
||||||
|
type InvoiceTaxTotal,
|
||||||
|
type VerifactuRecord,
|
||||||
|
} from "../entities";
|
||||||
import {
|
import {
|
||||||
type CustomerInvoiceNumber,
|
type CustomerInvoiceNumber,
|
||||||
type CustomerInvoiceSerie,
|
type CustomerInvoiceSerie,
|
||||||
@ -49,9 +54,7 @@ export interface CustomerInvoiceProps {
|
|||||||
|
|
||||||
discountPercentage: Percentage;
|
discountPercentage: Percentage;
|
||||||
|
|
||||||
/*verifactu_qr: string;
|
verifactu: Maybe<VerifactuRecord>;
|
||||||
verifactu_url: string;
|
|
||||||
verifactu_status: string;*/
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CustomerInvoicePatchProps = Partial<
|
export type CustomerInvoicePatchProps = Partial<
|
||||||
@ -212,6 +215,10 @@ export class CustomerInvoice
|
|||||||
return this.props.currencyCode;
|
return this.props.currencyCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get verifactu(): Maybe<VerifactuRecord> {
|
||||||
|
return this.props.verifactu;
|
||||||
|
}
|
||||||
|
|
||||||
public get discountPercentage(): Percentage {
|
public get discountPercentage(): Percentage {
|
||||||
return this.props.discountPercentage;
|
return this.props.discountPercentage;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,19 @@
|
|||||||
import { CurrencyCode, DomainEntity, LanguageCode, Percentage, UniqueID } from "@repo/rdx-ddd";
|
|
||||||
import { Maybe, Result } from "@repo/rdx-utils";
|
|
||||||
import {
|
import {
|
||||||
CustomerInvoiceItemDescription,
|
type CurrencyCode,
|
||||||
|
DomainEntity,
|
||||||
|
type LanguageCode,
|
||||||
|
type Percentage,
|
||||||
|
type UniqueID,
|
||||||
|
} from "@repo/rdx-ddd";
|
||||||
|
import { type Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type CustomerInvoiceItemDescription,
|
||||||
ItemAmount,
|
ItemAmount,
|
||||||
ItemDiscount,
|
ItemDiscount,
|
||||||
ItemQuantity,
|
ItemQuantity,
|
||||||
} from "../../value-objects";
|
} from "../../value-objects";
|
||||||
import { ItemTaxes, ItemTaxTotal } from "../item-taxes";
|
import type { ItemTaxTotal, ItemTaxes } from "../item-taxes";
|
||||||
|
|
||||||
export interface CustomerInvoiceItemProps {
|
export interface CustomerInvoiceItemProps {
|
||||||
description: Maybe<CustomerInvoiceItemDescription>;
|
description: Maybe<CustomerInvoiceItemDescription>;
|
||||||
|
|||||||
@ -2,3 +2,4 @@ export * from "./customer-invoice-items";
|
|||||||
export * from "./invoice-payment-method";
|
export * from "./invoice-payment-method";
|
||||||
export * from "./invoice-taxes";
|
export * from "./invoice-taxes";
|
||||||
export * from "./item-taxes";
|
export * from "./item-taxes";
|
||||||
|
export * from "./verifactu-record";
|
||||||
|
|||||||
@ -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>>;
|
): Promise<Result<boolean, Error>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recupera una factura/proforma por su ID y companyId.
|
* Recupera una proforma por su ID y companyId.
|
||||||
* Devuelve un `NotFoundError` si no se encuentra.
|
* Devuelve un `NotFoundError` si no se encuentra.
|
||||||
*/
|
*/
|
||||||
getByIdInCompany(
|
getProformaByIdInCompany(
|
||||||
|
companyId: UniqueID,
|
||||||
|
id: UniqueID,
|
||||||
|
transaction: unknown,
|
||||||
|
options: unknown
|
||||||
|
): Promise<Result<CustomerInvoice, Error>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recupera una factura por su ID y companyId.
|
||||||
|
* Devuelve un `NotFoundError` si no se encuentra.
|
||||||
|
*/
|
||||||
|
getIssuedInvoiceByIdInCompany(
|
||||||
companyId: UniqueID,
|
companyId: UniqueID,
|
||||||
id: UniqueID,
|
id: UniqueID,
|
||||||
transaction: unknown,
|
transaction: unknown,
|
||||||
@ -53,7 +64,7 @@ export interface ICustomerInvoiceRepository {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* Consulta facturas/proformas dentro de una empresa usando un
|
* Consulta proformas dentro de una empresa usando un
|
||||||
* objeto Criteria (filtros, orden, paginación).
|
* objeto Criteria (filtros, orden, paginación).
|
||||||
* El resultado está encapsulado en un objeto `Collection<T>`.
|
* El resultado está encapsulado en un objeto `Collection<T>`.
|
||||||
*
|
*
|
||||||
@ -65,7 +76,28 @@ export interface ICustomerInvoiceRepository {
|
|||||||
*
|
*
|
||||||
* @see Criteria
|
* @see Criteria
|
||||||
*/
|
*/
|
||||||
findByCriteriaInCompany(
|
findProformasByCriteriaInCompany(
|
||||||
|
companyId: UniqueID,
|
||||||
|
criteria: Criteria,
|
||||||
|
transaction: unknown,
|
||||||
|
options: unknown
|
||||||
|
): Promise<Result<Collection<CustomerInvoiceListDTO>, Error>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* Consulta facturas dentro de una empresa usando un
|
||||||
|
* objeto Criteria (filtros, orden, paginación).
|
||||||
|
* El resultado está encapsulado en un objeto `Collection<T>`.
|
||||||
|
*
|
||||||
|
* @param companyId - ID de la empresa.
|
||||||
|
* @param criteria - Criterios de búsqueda.
|
||||||
|
* @param transaction - Transacción activa para la operación.
|
||||||
|
* @param options - Opciones adicionales para la consulta (Sequelize FindOptions)
|
||||||
|
* @returns Result<Collection<CustomerInvoiceListDTO>, Error>
|
||||||
|
*
|
||||||
|
* @see Criteria
|
||||||
|
*/
|
||||||
|
findIssuedInvoicesByCriteriaInCompany(
|
||||||
companyId: UniqueID,
|
companyId: UniqueID,
|
||||||
criteria: Criteria,
|
criteria: Criteria,
|
||||||
transaction: unknown,
|
transaction: unknown,
|
||||||
|
|||||||
@ -8,3 +8,4 @@ export * from "./invoice-recipient";
|
|||||||
export * from "./item-amount";
|
export * from "./item-amount";
|
||||||
export * from "./item-discount";
|
export * from "./item-discount";
|
||||||
export * from "./item-quantity";
|
export * from "./item-quantity";
|
||||||
|
export * from "./verifactu-status";
|
||||||
|
|||||||
@ -16,6 +16,7 @@ export enum VERIFACTU_RECORD_STATUS {
|
|||||||
RECHAZADO = "No registrado", // <- Registro rechazado por la AEAT
|
RECHAZADO = "No registrado", // <- Registro rechazado por la AEAT
|
||||||
ERROR = "Error servidor AEAT", // <- Error en el servidor de la AEAT. Se intentará reenviar el registro de facturación de nuevo
|
ERROR = "Error servidor AEAT", // <- Error en el servidor de la AEAT. Se intentará reenviar el registro de facturación de nuevo
|
||||||
}
|
}
|
||||||
|
|
||||||
export class VerifactuRecordEstado extends ValueObject<IVerifactuRecordEstadoProps> {
|
export class VerifactuRecordEstado extends ValueObject<IVerifactuRecordEstadoProps> {
|
||||||
private static readonly ALLOWED_STATUSES = [
|
private static readonly ALLOWED_STATUSES = [
|
||||||
"Pendiente",
|
"Pendiente",
|
||||||
@ -30,17 +31,10 @@ export class VerifactuRecordEstado extends ValueObject<IVerifactuRecordEstadoPro
|
|||||||
];
|
];
|
||||||
private static readonly FIELD = "estado";
|
private static readonly FIELD = "estado";
|
||||||
private static readonly ERROR_CODE = "INVALID_RECORD_STATUS";
|
private static readonly ERROR_CODE = "INVALID_RECORD_STATUS";
|
||||||
/*
|
|
||||||
private static readonly TRANSITIONS: Record<string, string[]> = {
|
|
||||||
draft: [INVOICE_STATUS.SENT],
|
|
||||||
sent: [INVOICE_STATUS.APPROVED, INVOICE_STATUS.REJECTED],
|
|
||||||
approved: [INVOICE_STATUS.ISSUED],
|
|
||||||
rejected: [INVOICE_STATUS.DRAFT],
|
|
||||||
};
|
|
||||||
*/
|
|
||||||
static create(value: string): Result<VerifactuRecordEstado, Error> {
|
static create(value: string): Result<VerifactuRecordEstado, Error> {
|
||||||
if (!VerifactuRecordEstado.ALLOWED_STATUSES.includes(value)) {
|
if (!VerifactuRecordEstado.ALLOWED_STATUSES.includes(value)) {
|
||||||
const detail = `Estado de la factura no válido: ${value}`;
|
const detail = `Estado de verifactu no válido: ${value}`;
|
||||||
return Result.fail(
|
return Result.fail(
|
||||||
new DomainValidationError(
|
new DomainValidationError(
|
||||||
VerifactuRecordEstado.ERROR_CODE,
|
VerifactuRecordEstado.ERROR_CODE,
|
||||||
@ -53,6 +47,14 @@ export class VerifactuRecordEstado extends ValueObject<IVerifactuRecordEstadoPro
|
|||||||
return Result.ok(new VerifactuRecordEstado({ value }));
|
return Result.ok(new VerifactuRecordEstado({ value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static createPendiente(): VerifactuRecordEstado {
|
||||||
|
return new VerifactuRecordEstado({ value: VERIFACTU_RECORD_STATUS.PENDIENTE });
|
||||||
|
}
|
||||||
|
|
||||||
|
isPendiente(): boolean {
|
||||||
|
return this.props.value === VERIFACTU_RECORD_STATUS.PENDIENTE;
|
||||||
|
}
|
||||||
|
|
||||||
getProps(): string {
|
getProps(): string {
|
||||||
return this.props.value;
|
return this.props.value;
|
||||||
}
|
}
|
||||||
@ -2,7 +2,7 @@ import type { IModuleServer, ModuleParams } from "@erp/core/api";
|
|||||||
import type { UniqueID } from "@repo/rdx-ddd";
|
import type { UniqueID } from "@repo/rdx-ddd";
|
||||||
import type { Transaction } from "sequelize";
|
import type { Transaction } from "sequelize";
|
||||||
|
|
||||||
import { buildCustomerInvoiceDependencies, models, proformasRouter } from "./infrastructure";
|
import { models, proformasRouter } from "./infrastructure";
|
||||||
import { issuedInvoicesRouter } from "./infrastructure/express/issued-invoices.routes";
|
import { issuedInvoicesRouter } from "./infrastructure/express/issued-invoices.routes";
|
||||||
|
|
||||||
export const customerInvoicesAPIModule: IModuleServer = {
|
export const customerInvoicesAPIModule: IModuleServer = {
|
||||||
@ -27,8 +27,6 @@ export const customerInvoicesAPIModule: IModuleServer = {
|
|||||||
label: this.name,
|
label: this.name,
|
||||||
});
|
});
|
||||||
|
|
||||||
const deps = buildCustomerInvoiceDependencies(params);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
models,
|
models,
|
||||||
services: {
|
services: {
|
||||||
|
|||||||
@ -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,
|
ListIssuedInvoicesRequestSchema,
|
||||||
ReportIssueInvoiceByIdRequestSchema,
|
ReportIssueInvoiceByIdRequestSchema,
|
||||||
} from "../../../common/dto";
|
} from "../../../common/dto";
|
||||||
import { buildCustomerInvoiceDependencies } from "../dependencies";
|
import { buildIssuedInvoicesDependencies } from "../issued-invoices-dependencies";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
GetIssueInvoiceController,
|
GetIssueInvoiceController,
|
||||||
@ -25,7 +25,7 @@ export const issuedInvoicesRouter = (params: ModuleParams) => {
|
|||||||
logger: ILogger;
|
logger: ILogger;
|
||||||
};
|
};
|
||||||
|
|
||||||
const deps = buildCustomerInvoiceDependencies(params);
|
const deps = buildIssuedInvoicesDependencies(params);
|
||||||
|
|
||||||
const router: Router = Router({ mergeParams: true });
|
const router: Router = Router({ mergeParams: true });
|
||||||
if (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "production") {
|
if (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "production") {
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import {
|
|||||||
UpdateProformaByIdParamsRequestSchema,
|
UpdateProformaByIdParamsRequestSchema,
|
||||||
UpdateProformaByIdRequestSchema,
|
UpdateProformaByIdRequestSchema,
|
||||||
} from "../../../common";
|
} from "../../../common";
|
||||||
import { buildCustomerInvoiceDependencies } from "../dependencies";
|
import { buildProformasDependencies } from "../proformas-dependencies";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ChangeStatusProformaController,
|
ChangeStatusProformaController,
|
||||||
@ -37,7 +37,7 @@ export const proformasRouter = (params: ModuleParams) => {
|
|||||||
logger: ILogger;
|
logger: ILogger;
|
||||||
};
|
};
|
||||||
|
|
||||||
const deps = buildCustomerInvoiceDependencies(params);
|
const deps = buildProformasDependencies(params);
|
||||||
|
|
||||||
const router: Router = Router({ mergeParams: true });
|
const router: Router = Router({ mergeParams: true });
|
||||||
if (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "production") {
|
if (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "production") {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
export * from "./dependencies";
|
|
||||||
export * from "./express";
|
export * from "./express";
|
||||||
export * from "./mappers";
|
export * from "./mappers";
|
||||||
|
export * from "./proformas-dependencies";
|
||||||
export * from "./sequelize";
|
export * from "./sequelize";
|
||||||
|
|||||||
@ -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,
|
extractOrPushError,
|
||||||
maybeFromNullableVO,
|
maybeFromNullableVO,
|
||||||
} from "@repo/rdx-ddd";
|
} from "@repo/rdx-ddd";
|
||||||
import { type Maybe, Result } from "@repo/rdx-utils";
|
import { Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CustomerInvoiceNumber,
|
CustomerInvoiceNumber,
|
||||||
@ -22,10 +22,12 @@ import {
|
|||||||
CustomerInvoiceStatus,
|
CustomerInvoiceStatus,
|
||||||
InvoiceAmount,
|
InvoiceAmount,
|
||||||
type InvoiceRecipient,
|
type InvoiceRecipient,
|
||||||
|
type VerifactuRecord,
|
||||||
} from "../../../domain";
|
} from "../../../domain";
|
||||||
import type { CustomerInvoiceModel } from "../../sequelize";
|
import type { CustomerInvoiceModel } from "../../sequelize";
|
||||||
|
|
||||||
import { InvoiceRecipientListMapper } from "./invoice-recipient.list.mapper";
|
import { InvoiceRecipientListMapper } from "./invoice-recipient.list.mapper";
|
||||||
|
import { VerifactuRecordListMapper } from "./verifactu-record.list.mapper";
|
||||||
|
|
||||||
export type CustomerInvoiceListDTO = {
|
export type CustomerInvoiceListDTO = {
|
||||||
id: UniqueID;
|
id: UniqueID;
|
||||||
@ -57,6 +59,8 @@ export type CustomerInvoiceListDTO = {
|
|||||||
taxableAmount: InvoiceAmount;
|
taxableAmount: InvoiceAmount;
|
||||||
taxesAmount: InvoiceAmount;
|
taxesAmount: InvoiceAmount;
|
||||||
totalAmount: InvoiceAmount;
|
totalAmount: InvoiceAmount;
|
||||||
|
|
||||||
|
verifactu: Maybe<VerifactuRecord>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ICustomerInvoiceListMapper
|
export interface ICustomerInvoiceListMapper
|
||||||
@ -67,10 +71,12 @@ export class CustomerInvoiceListMapper
|
|||||||
implements ICustomerInvoiceListMapper
|
implements ICustomerInvoiceListMapper
|
||||||
{
|
{
|
||||||
private _recipientMapper: InvoiceRecipientListMapper;
|
private _recipientMapper: InvoiceRecipientListMapper;
|
||||||
|
private _verifactuMapper: VerifactuRecordListMapper;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this._recipientMapper = new InvoiceRecipientListMapper();
|
this._recipientMapper = new InvoiceRecipientListMapper();
|
||||||
|
this._verifactuMapper = new VerifactuRecordListMapper();
|
||||||
}
|
}
|
||||||
|
|
||||||
public mapToDTO(
|
public mapToDTO(
|
||||||
@ -99,6 +105,21 @@ export class CustomerInvoiceListMapper
|
|||||||
// 3) Taxes
|
// 3) Taxes
|
||||||
const taxes = raw.taxes.map((tax) => tax.tax_code).join(", ");
|
const taxes = raw.taxes.map((tax) => tax.tax_code).join(", ");
|
||||||
|
|
||||||
|
// 4) Verifactu record
|
||||||
|
let verifactu: Maybe<VerifactuRecord> = Maybe.none();
|
||||||
|
if (raw.verifactu) {
|
||||||
|
const verifactuResult = this._verifactuMapper.mapToDTO(raw.verifactu, { errors, ...params });
|
||||||
|
|
||||||
|
if (verifactuResult.isFailure) {
|
||||||
|
errors.push({
|
||||||
|
path: "verifactu",
|
||||||
|
message: verifactuResult.error.message,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
verifactu = Maybe.some(verifactuResult.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 5) Si hubo errores de mapeo, devolvemos colección de validación
|
// 5) Si hubo errores de mapeo, devolvemos colección de validación
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
return Result.fail(
|
return Result.fail(
|
||||||
@ -133,6 +154,8 @@ export class CustomerInvoiceListMapper
|
|||||||
taxableAmount: attributes.taxableAmount!,
|
taxableAmount: attributes.taxableAmount!,
|
||||||
taxesAmount: attributes.taxesAmount!,
|
taxesAmount: attributes.taxesAmount!,
|
||||||
totalAmount: attributes.totalAmount!,
|
totalAmount: attributes.totalAmount!,
|
||||||
|
|
||||||
|
verifactu,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,8 @@
|
|||||||
|
import {
|
||||||
|
type IQueryMapperWithBulk,
|
||||||
|
type MapperParamsType,
|
||||||
|
SequelizeQueryMapper,
|
||||||
|
} from "@erp/core/api";
|
||||||
import {
|
import {
|
||||||
City,
|
City,
|
||||||
Country,
|
Country,
|
||||||
@ -6,17 +11,16 @@ import {
|
|||||||
Province,
|
Province,
|
||||||
Street,
|
Street,
|
||||||
TINNumber,
|
TINNumber,
|
||||||
ValidationErrorDetail,
|
type ValidationErrorDetail,
|
||||||
extractOrPushError,
|
extractOrPushError,
|
||||||
maybeFromNullableVO,
|
maybeFromNullableVO,
|
||||||
} from "@repo/rdx-ddd";
|
} from "@repo/rdx-ddd";
|
||||||
|
import type { Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import { IQueryMapperWithBulk, MapperParamsType, SequelizeQueryMapper } from "@erp/core/api";
|
|
||||||
|
|
||||||
import { Result } from "@repo/rdx-utils";
|
|
||||||
import { InvoiceRecipient } from "../../../domain";
|
import { InvoiceRecipient } from "../../../domain";
|
||||||
import { CustomerInvoiceModel } from "../../sequelize";
|
import type { CustomerInvoiceModel } from "../../sequelize";
|
||||||
import { CustomerInvoiceListDTO } from "./customer-invoice.list.mapper";
|
|
||||||
|
import type { CustomerInvoiceListDTO } from "./customer-invoice.list.mapper";
|
||||||
|
|
||||||
interface IInvoiceRecipientListMapper
|
interface IInvoiceRecipientListMapper
|
||||||
extends IQueryMapperWithBulk<CustomerInvoiceModel, InvoiceRecipient> {}
|
extends IQueryMapperWithBulk<CustomerInvoiceModel, InvoiceRecipient> {}
|
||||||
|
|||||||
@ -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,
|
ChangeStatusProformaUseCase,
|
||||||
CreateProformaUseCase,
|
CreateProformaUseCase,
|
||||||
CustomerInvoiceApplicationService,
|
CustomerInvoiceApplicationService,
|
||||||
CustomerInvoiceFullPresenter,
|
|
||||||
CustomerInvoiceItemsFullPresenter,
|
|
||||||
CustomerInvoiceItemsReportPersenter,
|
|
||||||
CustomerInvoiceReportHTMLPresenter,
|
|
||||||
CustomerInvoiceReportPDFPresenter,
|
|
||||||
CustomerInvoiceReportPresenter,
|
|
||||||
CustomerInvoiceTaxesReportPresenter,
|
|
||||||
DeleteProformaUseCase,
|
DeleteProformaUseCase,
|
||||||
GetIssuedInvoiceUseCase,
|
|
||||||
GetProformaUseCase,
|
GetProformaUseCase,
|
||||||
IssueProformaUseCase,
|
IssueProformaUseCase,
|
||||||
ListCustomerInvoicesPresenter,
|
|
||||||
ListIssuedInvoicesUseCase,
|
|
||||||
ListProformasUseCase,
|
ListProformasUseCase,
|
||||||
RecipientInvoiceFullPresenter,
|
ProformaFullPresenter,
|
||||||
ReportIssuedInvoiceUseCase,
|
ProformaListPresenter,
|
||||||
|
ProformaReportHTMLPresenter,
|
||||||
|
ProformaReportPDFPresenter,
|
||||||
ReportProformaUseCase,
|
ReportProformaUseCase,
|
||||||
UpdateProformaUseCase,
|
UpdateProformaUseCase,
|
||||||
} from "../application";
|
} from "../application";
|
||||||
|
import { ProformaItemsFullPresenter } from "../application/presenters/domain/proformas/proforma-items.full.presenter";
|
||||||
|
import { ProformaRecipientFullPresenter } from "../application/presenters/domain/proformas/proforma-recipient.full.presenter";
|
||||||
|
import {
|
||||||
|
IssuedInvoiceTaxesReportPresenter,
|
||||||
|
ProformaItemsReportPresenter,
|
||||||
|
ProformaReportPresenter,
|
||||||
|
} from "../application/presenters/reports";
|
||||||
|
|
||||||
import { CustomerInvoiceDomainMapper, CustomerInvoiceListMapper } from "./mappers";
|
import { CustomerInvoiceDomainMapper, CustomerInvoiceListMapper } from "./mappers";
|
||||||
import { CustomerInvoiceRepository } from "./sequelize";
|
import { CustomerInvoiceRepository } from "./sequelize";
|
||||||
import { SequelizeInvoiceNumberGenerator } from "./services";
|
import { SequelizeInvoiceNumberGenerator } from "./services";
|
||||||
|
|
||||||
export type CustomerInvoiceDeps = {
|
export type ProformasDeps = {
|
||||||
transactionManager: SequelizeTransactionManager;
|
transactionManager: SequelizeTransactionManager;
|
||||||
mapperRegistry: IMapperRegistry;
|
mapperRegistry: IMapperRegistry;
|
||||||
presenterRegistry: IPresenterRegistry;
|
presenterRegistry: IPresenterRegistry;
|
||||||
@ -54,14 +53,10 @@ export type CustomerInvoiceDeps = {
|
|||||||
report_proforma: () => ReportProformaUseCase;
|
report_proforma: () => ReportProformaUseCase;
|
||||||
issue_proforma: () => IssueProformaUseCase;
|
issue_proforma: () => IssueProformaUseCase;
|
||||||
changeStatus_proforma: () => ChangeStatusProformaUseCase;
|
changeStatus_proforma: () => ChangeStatusProformaUseCase;
|
||||||
|
|
||||||
list_issued_invoices: () => ListIssuedInvoicesUseCase;
|
|
||||||
get_issued_invoice: () => GetIssuedInvoiceUseCase;
|
|
||||||
report_issued_invoice: () => ReportIssuedInvoiceUseCase;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function buildCustomerInvoiceDependencies(params: ModuleParams): CustomerInvoiceDeps {
|
export function buildProformasDependencies(params: ModuleParams): ProformasDeps {
|
||||||
const { database } = params;
|
const { database } = params;
|
||||||
|
|
||||||
/** Dominio */
|
/** Dominio */
|
||||||
@ -93,45 +88,50 @@ export function buildCustomerInvoiceDependencies(params: ModuleParams): Customer
|
|||||||
// Presenter Registry
|
// Presenter Registry
|
||||||
const presenterRegistry = new InMemoryPresenterRegistry();
|
const presenterRegistry = new InMemoryPresenterRegistry();
|
||||||
presenterRegistry.registerPresenters([
|
presenterRegistry.registerPresenters([
|
||||||
|
// FULL
|
||||||
{
|
{
|
||||||
key: { resource: "customer-invoice-items", projection: "FULL" },
|
key: { resource: "proforma-items", projection: "FULL" },
|
||||||
presenter: new CustomerInvoiceItemsFullPresenter(presenterRegistry),
|
presenter: new ProformaItemsFullPresenter(presenterRegistry),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: { resource: "recipient-invoice", projection: "FULL" },
|
key: { resource: "proforma-recipient", projection: "FULL" },
|
||||||
presenter: new RecipientInvoiceFullPresenter(presenterRegistry),
|
presenter: new ProformaRecipientFullPresenter(presenterRegistry),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: { resource: "customer-invoice", projection: "FULL" },
|
key: { resource: "proforma", projection: "FULL" },
|
||||||
presenter: new CustomerInvoiceFullPresenter(presenterRegistry),
|
presenter: new ProformaFullPresenter(presenterRegistry),
|
||||||
|
},
|
||||||
|
|
||||||
|
// LIST
|
||||||
|
{
|
||||||
|
key: { resource: "proforma", projection: "LIST" },
|
||||||
|
presenter: new ProformaListPresenter(presenterRegistry),
|
||||||
|
},
|
||||||
|
|
||||||
|
// REPORT
|
||||||
|
{
|
||||||
|
key: { resource: "proforma", projection: "REPORT", format: "JSON" },
|
||||||
|
presenter: new ProformaReportPresenter(presenterRegistry),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: { resource: "customer-invoice", projection: "LIST" },
|
key: { resource: "proforma-taxes", projection: "REPORT", format: "JSON" },
|
||||||
presenter: new ListCustomerInvoicesPresenter(presenterRegistry),
|
presenter: new IssuedInvoiceTaxesReportPresenter(presenterRegistry),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: { resource: "customer-invoice", projection: "REPORT", format: "JSON" },
|
key: { resource: "proforma-items", projection: "REPORT", format: "JSON" },
|
||||||
presenter: new CustomerInvoiceReportPresenter(presenterRegistry),
|
presenter: new ProformaItemsReportPresenter(presenterRegistry),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: { resource: "customer-invoice-taxes", projection: "REPORT", format: "JSON" },
|
key: { resource: "proforma", projection: "REPORT", format: "HTML" },
|
||||||
presenter: new CustomerInvoiceTaxesReportPresenter(presenterRegistry),
|
presenter: new ProformaReportHTMLPresenter(presenterRegistry),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: { resource: "customer-invoice-items", projection: "REPORT", format: "JSON" },
|
key: { resource: "proforma", projection: "REPORT", format: "PDF" },
|
||||||
presenter: new CustomerInvoiceItemsReportPersenter(presenterRegistry),
|
presenter: new ProformaReportPDFPresenter(presenterRegistry),
|
||||||
},
|
|
||||||
{
|
|
||||||
key: { resource: "customer-invoice", projection: "REPORT", format: "HTML" },
|
|
||||||
presenter: new CustomerInvoiceReportHTMLPresenter(presenterRegistry),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: { resource: "customer-invoice", projection: "REPORT", format: "PDF" },
|
|
||||||
presenter: new CustomerInvoiceReportPDFPresenter(presenterRegistry),
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const useCases: CustomerInvoiceDeps["useCases"] = {
|
const useCases: ProformasDeps["useCases"] = {
|
||||||
// Proformas
|
// Proformas
|
||||||
list_proformas: () =>
|
list_proformas: () =>
|
||||||
new ListProformasUseCase(appService, transactionManager, presenterRegistry),
|
new ListProformasUseCase(appService, transactionManager, presenterRegistry),
|
||||||
@ -146,14 +146,6 @@ export function buildCustomerInvoiceDependencies(params: ModuleParams): Customer
|
|||||||
issue_proforma: () =>
|
issue_proforma: () =>
|
||||||
new IssueProformaUseCase(appService, transactionManager, presenterRegistry),
|
new IssueProformaUseCase(appService, transactionManager, presenterRegistry),
|
||||||
changeStatus_proforma: () => new ChangeStatusProformaUseCase(appService, transactionManager),
|
changeStatus_proforma: () => new ChangeStatusProformaUseCase(appService, transactionManager),
|
||||||
|
|
||||||
// Issue Invoices
|
|
||||||
list_issued_invoices: () =>
|
|
||||||
new ListIssuedInvoicesUseCase(appService, transactionManager, presenterRegistry),
|
|
||||||
get_issued_invoice: () =>
|
|
||||||
new GetIssuedInvoiceUseCase(appService, transactionManager, presenterRegistry),
|
|
||||||
report_issued_invoice: () =>
|
|
||||||
new ReportIssuedInvoiceUseCase(appService, transactionManager, presenterRegistry),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -24,6 +24,7 @@ import { CustomerInvoiceModel } from "./models/customer-invoice.model";
|
|||||||
import { CustomerInvoiceItemModel } from "./models/customer-invoice-item.model";
|
import { CustomerInvoiceItemModel } from "./models/customer-invoice-item.model";
|
||||||
import { CustomerInvoiceItemTaxModel } from "./models/customer-invoice-item-tax.model";
|
import { CustomerInvoiceItemTaxModel } from "./models/customer-invoice-item-tax.model";
|
||||||
import { CustomerInvoiceTaxModel } from "./models/customer-invoice-tax.model";
|
import { CustomerInvoiceTaxModel } from "./models/customer-invoice-tax.model";
|
||||||
|
import { VerifactuRecordModel } from "./models/verifactu-record.model";
|
||||||
|
|
||||||
export class CustomerInvoiceRepository
|
export class CustomerInvoiceRepository
|
||||||
extends SequelizeRepository<CustomerInvoice>
|
extends SequelizeRepository<CustomerInvoice>
|
||||||
@ -119,8 +120,6 @@ export class CustomerInvoiceRepository
|
|||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(affectedCount);
|
|
||||||
|
|
||||||
if (affectedCount === 0) {
|
if (affectedCount === 0) {
|
||||||
return Result.fail(
|
return Result.fail(
|
||||||
new InfrastructureRepositoryError(`Invoice ${id} not found or concurrency issue`)
|
new InfrastructureRepositoryError(`Invoice ${id} not found or concurrency issue`)
|
||||||
@ -193,15 +192,15 @@ export class CustomerInvoiceRepository
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* Busca una factura por su identificador único.
|
* Busca una proforma por su identificador único.
|
||||||
*
|
*
|
||||||
* @param companyId - Identificador UUID de la empresa a la que pertenece la factura.
|
* @param companyId - Identificador UUID de la empresa a la que pertenece la proforma.
|
||||||
* @param id - UUID de la factura.
|
* @param id - UUID de la proforma.
|
||||||
* @param transaction - Transacción activa para la operación.
|
* @param transaction - Transacción activa para la operación.
|
||||||
* @param options - Opciones adicionales para la consulta (Sequelize FindOptions)
|
* @param options - Opciones adicionales para la consulta (Sequelize FindOptions)
|
||||||
* @returns Result<CustomerInvoice, Error>
|
* @returns Result<CustomerInvoice, Error>
|
||||||
*/
|
*/
|
||||||
async getByIdInCompany(
|
async getProformaByIdInCompany(
|
||||||
companyId: UniqueID,
|
companyId: UniqueID,
|
||||||
id: UniqueID,
|
id: UniqueID,
|
||||||
transaction: Transaction,
|
transaction: Transaction,
|
||||||
@ -230,9 +229,10 @@ export class CustomerInvoiceRepository
|
|||||||
const mergedOptions: FindOptions<InferAttributes<CustomerInvoiceModel>> = {
|
const mergedOptions: FindOptions<InferAttributes<CustomerInvoiceModel>> = {
|
||||||
...options,
|
...options,
|
||||||
where: {
|
where: {
|
||||||
id: id.toString(),
|
|
||||||
company_id: companyId.toString(),
|
|
||||||
...(options.where ?? {}),
|
...(options.where ?? {}),
|
||||||
|
id: id.toString(),
|
||||||
|
is_proforma: true,
|
||||||
|
company_id: companyId.toString(),
|
||||||
},
|
},
|
||||||
order: [
|
order: [
|
||||||
...normalizedOrder,
|
...normalizedOrder,
|
||||||
@ -281,7 +281,96 @@ export class CustomerInvoiceRepository
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* Consulta facturas usando un objeto Criteria (filtros, orden, paginación).
|
* Busca una factura por su identificador único.
|
||||||
|
*
|
||||||
|
* @param companyId - Identificador UUID de la empresa a la que pertenece la factura.
|
||||||
|
* @param id - UUID de la factura.
|
||||||
|
* @param transaction - Transacción activa para la operación.
|
||||||
|
* @param options - Opciones adicionales para la consulta (Sequelize FindOptions)
|
||||||
|
* @returns Result<CustomerInvoice, Error>
|
||||||
|
*/
|
||||||
|
async getIssuedInvoiceByIdInCompany(
|
||||||
|
companyId: UniqueID,
|
||||||
|
id: UniqueID,
|
||||||
|
transaction: Transaction,
|
||||||
|
options: FindOptions<InferAttributes<CustomerInvoiceModel>> = {}
|
||||||
|
): Promise<Result<CustomerInvoice, Error>> {
|
||||||
|
const { CustomerModel } = this._database.models;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mapper: ICustomerInvoiceDomainMapper = this._registry.getDomainMapper({
|
||||||
|
resource: "customer-invoice",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Normalización defensiva de order/include
|
||||||
|
const normalizedOrder = Array.isArray(options.order)
|
||||||
|
? options.order
|
||||||
|
: options.order
|
||||||
|
? [options.order]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const normalizedInclude = Array.isArray(options.include)
|
||||||
|
? options.include
|
||||||
|
: options.include
|
||||||
|
? [options.include]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const mergedOptions: FindOptions<InferAttributes<CustomerInvoiceModel>> = {
|
||||||
|
...options,
|
||||||
|
where: {
|
||||||
|
...(options.where ?? {}),
|
||||||
|
id: id.toString(),
|
||||||
|
is_proforma: false,
|
||||||
|
company_id: companyId.toString(),
|
||||||
|
},
|
||||||
|
order: [
|
||||||
|
...normalizedOrder,
|
||||||
|
[{ model: CustomerInvoiceItemModel, as: "items" }, "position", "ASC"],
|
||||||
|
],
|
||||||
|
include: [
|
||||||
|
...normalizedInclude,
|
||||||
|
{
|
||||||
|
model: CustomerModel,
|
||||||
|
as: "current_customer",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: CustomerInvoiceItemModel,
|
||||||
|
as: "items",
|
||||||
|
required: false,
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: CustomerInvoiceItemTaxModel,
|
||||||
|
as: "taxes",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: CustomerInvoiceTaxModel,
|
||||||
|
as: "taxes",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
transaction,
|
||||||
|
};
|
||||||
|
|
||||||
|
const row = await CustomerInvoiceModel.findOne(mergedOptions);
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return Result.fail(new EntityNotFoundError("CustomerInvoice", "id", id.toString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoice = mapper.mapToDomain(row);
|
||||||
|
return invoice;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return Result.fail(translateSequelizeError(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* Consulta proformas usando un objeto Criteria (filtros, orden, paginación).
|
||||||
*
|
*
|
||||||
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente.
|
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente.
|
||||||
* @param criteria - Criterios de búsqueda.
|
* @param criteria - Criterios de búsqueda.
|
||||||
@ -290,7 +379,7 @@ export class CustomerInvoiceRepository
|
|||||||
*
|
*
|
||||||
* @see Criteria
|
* @see Criteria
|
||||||
*/
|
*/
|
||||||
public async findByCriteriaInCompany(
|
public async findProformasByCriteriaInCompany(
|
||||||
companyId: UniqueID,
|
companyId: UniqueID,
|
||||||
criteria: Criteria,
|
criteria: Criteria,
|
||||||
transaction: Transaction,
|
transaction: Transaction,
|
||||||
@ -331,9 +420,10 @@ export class CustomerInvoiceRepository
|
|||||||
|
|
||||||
query.where = {
|
query.where = {
|
||||||
...query.where,
|
...query.where,
|
||||||
|
...(options.where ?? {}),
|
||||||
|
is_proforma: true,
|
||||||
company_id: companyId.toString(),
|
company_id: companyId.toString(),
|
||||||
deleted_at: null,
|
deleted_at: null,
|
||||||
...(options.where ?? {}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
query.order = [...(query.order as OrderItem[]), ...normalizedOrder];
|
query.order = [...(query.order as OrderItem[]), ...normalizedOrder];
|
||||||
@ -389,6 +479,123 @@ export class CustomerInvoiceRepository
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* Consulta facturas usando un objeto Criteria (filtros, orden, paginación).
|
||||||
|
*
|
||||||
|
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente.
|
||||||
|
* @param criteria - Criterios de búsqueda.
|
||||||
|
* @param transaction - Transacción activa para la operación.
|
||||||
|
* @returns Result<CustomerInvoice[], Error>
|
||||||
|
*
|
||||||
|
* @see Criteria
|
||||||
|
*/
|
||||||
|
public async findIssuedInvoicesByCriteriaInCompany(
|
||||||
|
companyId: UniqueID,
|
||||||
|
criteria: Criteria,
|
||||||
|
transaction: Transaction,
|
||||||
|
options: FindOptions<InferAttributes<CustomerInvoiceModel>> = {}
|
||||||
|
): Promise<Result<Collection<CustomerInvoiceListDTO>, Error>> {
|
||||||
|
const { CustomerModel } = this._database.models;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mapper: ICustomerInvoiceListMapper = this._registry.getQueryMapper({
|
||||||
|
resource: "customer-invoice",
|
||||||
|
query: "LIST",
|
||||||
|
});
|
||||||
|
|
||||||
|
const converter = new CriteriaToSequelizeConverter();
|
||||||
|
const query = converter.convert(criteria, {
|
||||||
|
searchableFields: ["invoice_number", "reference", "description"],
|
||||||
|
mappings: {
|
||||||
|
reference: "CustomerInvoiceModel.reference",
|
||||||
|
},
|
||||||
|
allowedFields: ["invoice_date", "id", "created_at"],
|
||||||
|
enableFullText: true,
|
||||||
|
database: this._database,
|
||||||
|
strictMode: true, // fuerza error si ORDER BY no permitido
|
||||||
|
});
|
||||||
|
|
||||||
|
// Normalización defensiva de order/include
|
||||||
|
const normalizedOrder = Array.isArray(options.order)
|
||||||
|
? options.order
|
||||||
|
: options.order
|
||||||
|
? [options.order]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const normalizedInclude = Array.isArray(options.include)
|
||||||
|
? options.include
|
||||||
|
: options.include
|
||||||
|
? [options.include]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
query.where = {
|
||||||
|
...query.where,
|
||||||
|
...(options.where ?? {}),
|
||||||
|
is_proforma: false,
|
||||||
|
company_id: companyId.toString(),
|
||||||
|
deleted_at: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
query.order = [...(query.order as OrderItem[]), ...normalizedOrder];
|
||||||
|
|
||||||
|
query.include = [
|
||||||
|
...normalizedInclude,
|
||||||
|
{
|
||||||
|
model: VerifactuRecordModel,
|
||||||
|
as: "verifactu",
|
||||||
|
required: false,
|
||||||
|
attributes: ["id", "estado", "url", "uuid"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: CustomerModel,
|
||||||
|
as: "current_customer",
|
||||||
|
required: false, // false => LEFT JOIN
|
||||||
|
attributes: [
|
||||||
|
"name",
|
||||||
|
"trade_name",
|
||||||
|
"tin",
|
||||||
|
"street",
|
||||||
|
"street2",
|
||||||
|
"city",
|
||||||
|
"postal_code",
|
||||||
|
"province",
|
||||||
|
"country",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: CustomerInvoiceTaxModel,
|
||||||
|
as: "taxes",
|
||||||
|
required: false,
|
||||||
|
separate: true, // => query aparte, devuelve siempre array
|
||||||
|
attributes: ["tax_id", "tax_code"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Reemplazar findAndCountAll por findAll + count (más control y mejor rendimiento)
|
||||||
|
/*const { rows, count } = await CustomerInvoiceModel.findAndCountAll({
|
||||||
|
...query,
|
||||||
|
transaction,
|
||||||
|
});*/
|
||||||
|
|
||||||
|
const [rows, count] = await Promise.all([
|
||||||
|
CustomerInvoiceModel.findAll({
|
||||||
|
...query,
|
||||||
|
transaction,
|
||||||
|
}),
|
||||||
|
CustomerInvoiceModel.count({
|
||||||
|
where: query.where,
|
||||||
|
distinct: true, // evita duplicados por LEFT JOIN
|
||||||
|
transaction,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return mapper.mapToDTOCollection(rows, count);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return Result.fail(translateSequelizeError(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* Elimina o marca como eliminada una proforma dentro de una empresa.
|
* Elimina o marca como eliminada una proforma dentro de una empresa.
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import customerInvoiceModelInit from "./models/customer-invoice.model";
|
|||||||
import customerInvoiceItemModelInit from "./models/customer-invoice-item.model";
|
import customerInvoiceItemModelInit from "./models/customer-invoice-item.model";
|
||||||
import customerInvoiceItemTaxesModelInit from "./models/customer-invoice-item-tax.model";
|
import customerInvoiceItemTaxesModelInit from "./models/customer-invoice-item-tax.model";
|
||||||
import customerInvoiceTaxesModelInit from "./models/customer-invoice-tax.model";
|
import customerInvoiceTaxesModelInit from "./models/customer-invoice-tax.model";
|
||||||
|
import verifactuRecordModelInit from "./models/verifactu-record.model";
|
||||||
|
|
||||||
export * from "./customer-invoice.repository";
|
export * from "./customer-invoice.repository";
|
||||||
export * from "./models";
|
export * from "./models";
|
||||||
@ -13,4 +14,6 @@ export const models = [
|
|||||||
|
|
||||||
customerInvoiceTaxesModelInit,
|
customerInvoiceTaxesModelInit,
|
||||||
customerInvoiceItemTaxesModelInit,
|
customerInvoiceItemTaxesModelInit,
|
||||||
|
|
||||||
|
verifactuRecordModelInit,
|
||||||
];
|
];
|
||||||
|
|||||||
@ -55,7 +55,9 @@ export class CustomerInvoiceTaxModel extends Model<
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
static hooks(_database: Sequelize) {}
|
static hooks(_database: Sequelize) {
|
||||||
|
//
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default (database: Sequelize) => {
|
export default (database: Sequelize) => {
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import type {
|
|||||||
CustomerInvoiceTaxCreationAttributes,
|
CustomerInvoiceTaxCreationAttributes,
|
||||||
CustomerInvoiceTaxModel,
|
CustomerInvoiceTaxModel,
|
||||||
} from "./customer-invoice-tax.model";
|
} from "./customer-invoice-tax.model";
|
||||||
|
import type { VerifactuRecordModel } from "./verifactu-record.model";
|
||||||
|
|
||||||
export type CustomerInvoiceCreationAttributes = InferCreationAttributes<
|
export type CustomerInvoiceCreationAttributes = InferCreationAttributes<
|
||||||
CustomerInvoiceModel,
|
CustomerInvoiceModel,
|
||||||
@ -101,6 +102,7 @@ export class CustomerInvoiceModel extends Model<
|
|||||||
declare items: NonAttribute<CustomerInvoiceItemModel[]>;
|
declare items: NonAttribute<CustomerInvoiceItemModel[]>;
|
||||||
declare taxes: NonAttribute<CustomerInvoiceTaxModel[]>;
|
declare taxes: NonAttribute<CustomerInvoiceTaxModel[]>;
|
||||||
declare current_customer: NonAttribute<CustomerModel>;
|
declare current_customer: NonAttribute<CustomerModel>;
|
||||||
|
declare verifactu: NonAttribute<VerifactuRecordModel>;
|
||||||
|
|
||||||
static associate(database: Sequelize) {
|
static associate(database: Sequelize) {
|
||||||
const models = database.models;
|
const models = database.models;
|
||||||
@ -109,6 +111,7 @@ export class CustomerInvoiceModel extends Model<
|
|||||||
"CustomerInvoiceItemModel",
|
"CustomerInvoiceItemModel",
|
||||||
"CustomerModel",
|
"CustomerModel",
|
||||||
"CustomerInvoiceTaxModel",
|
"CustomerInvoiceTaxModel",
|
||||||
|
"VerifactuRecordModel",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Comprobamos que los modelos existan
|
// Comprobamos que los modelos existan
|
||||||
@ -124,8 +127,14 @@ export class CustomerInvoiceModel extends Model<
|
|||||||
CustomerInvoiceItemModel,
|
CustomerInvoiceItemModel,
|
||||||
CustomerModel,
|
CustomerModel,
|
||||||
CustomerInvoiceTaxModel,
|
CustomerInvoiceTaxModel,
|
||||||
|
VerifactuRecordModel,
|
||||||
} = models;
|
} = models;
|
||||||
|
|
||||||
|
CustomerInvoiceModel.hasOne(VerifactuRecordModel, {
|
||||||
|
as: "verifactu",
|
||||||
|
foreignKey: "invoice_id",
|
||||||
|
});
|
||||||
|
|
||||||
CustomerInvoiceModel.belongsTo(CustomerModel, {
|
CustomerInvoiceModel.belongsTo(CustomerModel, {
|
||||||
as: "current_customer",
|
as: "current_customer",
|
||||||
foreignKey: "customer_id",
|
foreignKey: "customer_id",
|
||||||
@ -151,7 +160,9 @@ export class CustomerInvoiceModel extends Model<
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
static hooks(_database: Sequelize) {}
|
static hooks(_database: Sequelize) {
|
||||||
|
//
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default (database: Sequelize) => {
|
export default (database: Sequelize) => {
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
export * from "./customer-invoice-item-tax.model";
|
|
||||||
export * from "./customer-invoice-item.model";
|
|
||||||
export * from "./customer-invoice-tax.model";
|
|
||||||
export * from "./customer-invoice.model";
|
export * from "./customer-invoice.model";
|
||||||
|
export * from "./customer-invoice-item.model";
|
||||||
|
export * from "./customer-invoice-item-tax.model";
|
||||||
|
export * from "./customer-invoice-tax.model";
|
||||||
|
export * from "./verifactu-record.model";
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import { DataTypes, InferAttributes, InferCreationAttributes, Model, Sequelize } from "sequelize";
|
import {
|
||||||
/*import {
|
DataTypes,
|
||||||
CustomerInvoiceItemTaxCreationAttributes,
|
type InferAttributes,
|
||||||
CustomerInvoiceItemTaxModel,
|
type InferCreationAttributes,
|
||||||
} from "./customer-invoice-item-tax.model";
|
Model,
|
||||||
*/
|
type Sequelize,
|
||||||
|
} from "sequelize";
|
||||||
|
|
||||||
export type VerifactuRecordCreationAttributes = InferCreationAttributes<VerifactuRecordModel>;
|
export type VerifactuRecordCreationAttributes = InferCreationAttributes<VerifactuRecordModel>;
|
||||||
|
|
||||||
@ -22,16 +23,30 @@ export class VerifactuRecordModel extends Model<
|
|||||||
declare operacion: string;
|
declare operacion: string;
|
||||||
|
|
||||||
static associate(database: Sequelize) {
|
static associate(database: Sequelize) {
|
||||||
const { CustomerInvoiceModel } = database.models;
|
const models = database.models;
|
||||||
|
const requiredModels = ["CustomerInvoiceModel"];
|
||||||
|
|
||||||
|
// Comprobamos que los modelos existan
|
||||||
|
for (const name of requiredModels) {
|
||||||
|
if (!models[name]) {
|
||||||
|
throw new Error(`[VerifactuRecordModel.associate] Missing model: ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { CustomerInvoiceModel } = models;
|
||||||
|
|
||||||
VerifactuRecordModel.belongsTo(CustomerInvoiceModel, {
|
VerifactuRecordModel.belongsTo(CustomerInvoiceModel, {
|
||||||
as: "verifactu_records",
|
as: "verifactu",
|
||||||
targetKey: "id",
|
targetKey: "id",
|
||||||
foreignKey: "invoice_id",
|
foreignKey: "invoice_id",
|
||||||
onDelete: "CASCADE",
|
onDelete: "CASCADE",
|
||||||
onUpdate: "CASCADE",
|
onUpdate: "CASCADE",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static hooks(_database: Sequelize) {
|
||||||
|
//
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default (database: Sequelize) => {
|
export default (database: Sequelize) => {
|
||||||
@ -76,11 +91,18 @@ export default (database: Sequelize) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
sequelize: database,
|
sequelize: database,
|
||||||
|
modelName: "VerifactuRecordModel",
|
||||||
tableName: "verifactu_records",
|
tableName: "verifactu_records",
|
||||||
|
|
||||||
underscored: true,
|
underscored: true,
|
||||||
|
paranoid: true, // softs deletes
|
||||||
|
timestamps: true,
|
||||||
|
|
||||||
indexes: [],
|
createdAt: "created_at",
|
||||||
|
updatedAt: "updated_at",
|
||||||
|
deletedAt: "deleted_at",
|
||||||
|
|
||||||
|
indexes: [{ name: "idx_invoice_id", fields: ["invoice_id"] }], // <- para relación con CustomerInvoiceModel
|
||||||
|
|
||||||
whereMergeStrategy: "and", // <- cómo tratar el merge de un scope
|
whereMergeStrategy: "and", // <- cómo tratar el merge de un scope
|
||||||
|
|
||||||
@ -56,6 +56,12 @@ export const GetIssuedInvoiceByIdResponseSchema = z.object({
|
|||||||
taxes_amount: MoneySchema,
|
taxes_amount: MoneySchema,
|
||||||
total_amount: MoneySchema,
|
total_amount: MoneySchema,
|
||||||
|
|
||||||
|
verifactu: z.object({
|
||||||
|
status: z.string(),
|
||||||
|
url: z.string(),
|
||||||
|
qr_code: z.string(),
|
||||||
|
}),
|
||||||
|
|
||||||
items: z.array(
|
items: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.uuid(),
|
id: z.uuid(),
|
||||||
|
|||||||
@ -47,6 +47,12 @@ export const ListIssuedInvoicesResponseSchema = createPaginatedListSchema(
|
|||||||
taxes_amount: MoneySchema,
|
taxes_amount: MoneySchema,
|
||||||
total_amount: MoneySchema,
|
total_amount: MoneySchema,
|
||||||
|
|
||||||
|
verifactu: z.object({
|
||||||
|
status: z.string(),
|
||||||
|
url: z.string(),
|
||||||
|
qr_code: z.string(),
|
||||||
|
}),
|
||||||
|
|
||||||
metadata: MetadataSchema.optional(),
|
metadata: MetadataSchema.optional(),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@ -35,6 +35,19 @@
|
|||||||
"rejected": "Rejected",
|
"rejected": "Rejected",
|
||||||
"issued": "Issued"
|
"issued": "Issued"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"issued_invoices": {
|
||||||
|
"status": {
|
||||||
|
"all": "Todos",
|
||||||
|
"pendiente": "Pendiente",
|
||||||
|
"aceptado_con_error": "Aceptado con error",
|
||||||
|
"incorrecto": "Incorrecto",
|
||||||
|
"duplicado": "Duplicado",
|
||||||
|
"anulado": "Anulado",
|
||||||
|
"factura_inexistente": "Factura inexistente",
|
||||||
|
"rechazado": "Rechazado",
|
||||||
|
"error": "Error"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
@ -90,6 +103,10 @@
|
|||||||
"title": "Customer invoices",
|
"title": "Customer invoices",
|
||||||
"description": "List all customer invoices",
|
"description": "List all customer invoices",
|
||||||
"grid_columns": {
|
"grid_columns": {
|
||||||
|
"series_invoice_number": "Serie & #",
|
||||||
|
"verifactu_status": "Status",
|
||||||
|
"verifactu_qr_code": "QR Code",
|
||||||
|
"verifactu_url": "URL",
|
||||||
"invoice_number": "Inv. number",
|
"invoice_number": "Inv. number",
|
||||||
"series": "Serie",
|
"series": "Serie",
|
||||||
"reference": "Reference",
|
"reference": "Reference",
|
||||||
|
|||||||
@ -34,6 +34,19 @@
|
|||||||
"rejected": "Rechazadas",
|
"rejected": "Rechazadas",
|
||||||
"issued": "Emitidas"
|
"issued": "Emitidas"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"issued_invoices": {
|
||||||
|
"status": {
|
||||||
|
"all": "All",
|
||||||
|
"pendiente": "Pendiente",
|
||||||
|
"aceptado_con_error": "Aceptado con error",
|
||||||
|
"incorrecto": "Incorrecto",
|
||||||
|
"duplicado": "Duplicado",
|
||||||
|
"anulado": "Anulado",
|
||||||
|
"factura_inexistente": "Factura inexistente",
|
||||||
|
"rechazado": "Rechazado",
|
||||||
|
"error": "Error"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
@ -89,10 +102,14 @@
|
|||||||
"title": "Facturas de cliente",
|
"title": "Facturas de cliente",
|
||||||
"description": "Lista todas las facturas de cliente",
|
"description": "Lista todas las facturas de cliente",
|
||||||
"grid_columns": {
|
"grid_columns": {
|
||||||
|
"series_invoice_number": "Serie & #",
|
||||||
|
"verifactu_status": "Estado",
|
||||||
|
"verifactu_qr_code": "Código QR",
|
||||||
|
"verifactu_url": "URL",
|
||||||
"invoice_number": "Nº factura",
|
"invoice_number": "Nº factura",
|
||||||
"series": "Serie",
|
"series": "Serie",
|
||||||
"reference": "Reference",
|
"reference": "Reference",
|
||||||
"invoice_date": "Fecha de proforma",
|
"invoice_date": "Fecha de factura",
|
||||||
"operation_date": "Fecha de operación",
|
"operation_date": "Fecha de operación",
|
||||||
"recipient": "Cliente",
|
"recipient": "Cliente",
|
||||||
"recipient_tin": "NIF/CIF",
|
"recipient_tin": "NIF/CIF",
|
||||||
|
|||||||
@ -49,14 +49,73 @@ export function useIssuedInvoicesGridColumns(
|
|||||||
),
|
),
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
maxSize: 48,
|
maxSize: 64,
|
||||||
size: 48,
|
size: 64,
|
||||||
minSize: 48,
|
minSize: 64,
|
||||||
meta: {
|
meta: {
|
||||||
title: t("pages.issued_invoices.list.grid_columns.series_invoice_number"),
|
title: t("pages.issued_invoices.list.grid_columns.series_invoice_number"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "verifactu_status",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader
|
||||||
|
className="text-left"
|
||||||
|
column={column}
|
||||||
|
title={t("pages.issued_invoices.list.grid_columns.verifactu_status")}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
accessorFn: (row) => row.verifactu.status, // para ordenar/buscar por nombre
|
||||||
|
enableHiding: false,
|
||||||
|
enableSorting: false,
|
||||||
|
size: 140,
|
||||||
|
minSize: 120,
|
||||||
|
meta: {
|
||||||
|
title: t("pages.issued_invoices.list.grid_columns.verifactu_status"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "verifactu_qr_code",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader
|
||||||
|
className="text-left"
|
||||||
|
column={column}
|
||||||
|
title={t("pages.issued_invoices.list.grid_columns.verifactu_qr_code")}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
accessorFn: (row) => row.verifactu.qr_code, // para ordenar/buscar por nombre
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="font-medium text-left">{row.original.verifactu.qr_code}</div>
|
||||||
|
),
|
||||||
|
enableHiding: false,
|
||||||
|
enableSorting: false,
|
||||||
|
size: 140,
|
||||||
|
minSize: 120,
|
||||||
|
meta: {
|
||||||
|
title: t("pages.issued_invoices.list.grid_columns.verifactu_qr_code"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "verifactu_url",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader
|
||||||
|
className="text-left"
|
||||||
|
column={column}
|
||||||
|
title={t("pages.issued_invoices.list.grid_columns.verifactu_url")}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
accessorFn: (row) => row.verifactu.url, // para ordenar/buscar por nombre
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="font-medium text-left">{row.original.verifactu.url}</div>
|
||||||
|
),
|
||||||
|
enableHiding: false,
|
||||||
|
enableSorting: false,
|
||||||
|
size: 140,
|
||||||
|
minSize: 120,
|
||||||
|
meta: {
|
||||||
|
title: t("pages.issued_invoices.list.grid_columns.verifactu_url"),
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "recipient",
|
id: "recipient",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
|
|||||||
@ -1,5 +1,13 @@
|
|||||||
import { SimpleSearchInput } from "@erp/core/components";
|
import { SimpleSearchInput } from "@erp/core/components";
|
||||||
import { DataTable, SkeletonDataTable } from "@repo/rdx-ui/components";
|
import { DataTable, SkeletonDataTable } from "@repo/rdx-ui/components";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@repo/shadcn-ui/components";
|
||||||
|
import { FilterIcon } from "lucide-react";
|
||||||
|
|
||||||
import { useTranslation } from "../../../../i18n";
|
import { useTranslation } from "../../../../i18n";
|
||||||
import type { IssuedInvoiceSummaryPageData } from "../../../schema/issued-invoice-summary.web.schema";
|
import type { IssuedInvoiceSummaryPageData } from "../../../schema/issued-invoice-summary.web.schema";
|
||||||
@ -52,6 +60,35 @@ export const IssuedInvoicesGrid = ({
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
<SimpleSearchInput loading={loading} onSearchChange={onSearchChange} />
|
<SimpleSearchInput loading={loading} onSearchChange={onSearchChange} />
|
||||||
|
<Select defaultValue="all">
|
||||||
|
<SelectTrigger className="w-full sm:w-48">
|
||||||
|
<FilterIcon aria-hidden className="mr-2 size-4" />
|
||||||
|
<SelectValue placeholder={t("filters.status")} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">{t("catalog.issued_invoices.status.all")}</SelectItem>
|
||||||
|
<SelectItem value="pendiente">
|
||||||
|
{t("catalog.issued_invoices.status.pendiente")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="aceptado_con_error">
|
||||||
|
{t("catalog.issued_invoices.status.aceptado_con_error")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="incorrecto">
|
||||||
|
{t("catalog.issued_invoices.status.incorrecto")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="duplicado">
|
||||||
|
{t("catalog.issued_invoices.status.duplicado")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="anulado">{t("catalog.issued_invoices.status.anulado")}</SelectItem>
|
||||||
|
<SelectItem value="factura_inexistente">
|
||||||
|
{t("catalog.issued_invoices.status.factura_inexistente")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="rechazado">
|
||||||
|
{t("catalog.issued_invoices.status.rechazado")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="error">{t("catalog.issued_invoices.status.error")}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
|
|||||||
@ -12,7 +12,6 @@ import type { ComponentProps } from "react";
|
|||||||
import { useFormContext, useWatch } from "react-hook-form";
|
import { useFormContext, useWatch } from "react-hook-form";
|
||||||
|
|
||||||
import { useTranslation } from "../../../../../i18n";
|
import { useTranslation } from "../../../../../i18n";
|
||||||
import { PercentageInputField } from "../../../../../shared/ui/";
|
|
||||||
import type { ProformaFormData } from "../../../../schema";
|
import type { ProformaFormData } from "../../../../schema";
|
||||||
import { useProformaContext } from "../../context";
|
import { useProformaContext } from "../../context";
|
||||||
|
|
||||||
|
|||||||
@ -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
|
// Selectores típicos de elementos que son editables o permite foco
|
||||||
const FOCUSABLE_SELECTOR = [
|
const FOCUSABLE_SELECTOR = [
|
||||||
'[data-cell-focus]', // permite marcar manualmente el target dentro de la celda
|
"[data-cell-focus]", // permite marcar manualmente el target dentro de la celda
|
||||||
'input:not([disabled])',
|
"input:not([disabled])",
|
||||||
'textarea:not([disabled])',
|
"textarea:not([disabled])",
|
||||||
'select:not([disabled])',
|
"select:not([disabled])",
|
||||||
'[contenteditable="true"]',
|
'[contenteditable="true"]',
|
||||||
'button:not([disabled])',
|
"button:not([disabled])",
|
||||||
'a[href]',
|
"a[href]",
|
||||||
'[tabindex]:not([tabindex="-1"])'
|
'[tabindex]:not([tabindex="-1"])',
|
||||||
].join(',');
|
].join(",");
|
||||||
|
|
||||||
// Busca el elemento focuseable dentro de la "celda" destino.
|
// Busca el elemento focuseable dentro de la "celda" destino.
|
||||||
// Puedes poner data-row-index / data-col-index en la propia celda <td> o en el control.
|
// Puedes poner data-row-index / data-col-index en la propia celda <td> o en el control.
|
||||||
@ -16,9 +20,8 @@ const FOCUSABLE_SELECTOR = [
|
|||||||
|
|
||||||
export function findFocusableInCell(row: number, col: number): HTMLElement | null {
|
export function findFocusableInCell(row: number, col: number): HTMLElement | null {
|
||||||
// 1) ¿Hay un control que ya tenga los data-* directamente?
|
// 1) ¿Hay un control que ya tenga los data-* directamente?
|
||||||
let el =
|
let el = document.querySelector<HTMLElement>(
|
||||||
document.querySelector<HTMLElement>(
|
`[data-row-index="${row}"][data-col-index="${col}"]${FOCUSABLE_SELECTOR.startsWith("[") ? "" : ""}`
|
||||||
`[data-row-index="${row}"][data-col-index="${col}"]${FOCUSABLE_SELECTOR.startsWith('[') ? '' : ''}`
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Si lo anterior no funcionó o seleccionó un contenedor, intenta:
|
// Si lo anterior no funcionó o seleccionó un contenedor, intenta:
|
||||||
@ -30,7 +33,9 @@ export function findFocusableInCell(row: number, col: number): HTMLElement | nul
|
|||||||
if (!cell) return null;
|
if (!cell) return null;
|
||||||
|
|
||||||
// 3) Dentro de la celda, busca el primer foco válido
|
// 3) Dentro de la celda, busca el primer foco válido
|
||||||
el = cell.matches(FOCUSABLE_SELECTOR) ? cell : cell.querySelector<HTMLElement>(FOCUSABLE_SELECTOR);
|
el = cell.matches(FOCUSABLE_SELECTOR)
|
||||||
|
? cell
|
||||||
|
: cell.querySelector<HTMLElement>(FOCUSABLE_SELECTOR);
|
||||||
}
|
}
|
||||||
|
|
||||||
return el || null;
|
return el || null;
|
||||||
@ -48,8 +53,8 @@ export function focusAndSelect(el: HTMLElement) {
|
|||||||
// select() funciona en la mayoría; si es type="number", cae en setSelectionRange
|
// select() funciona en la mayoría; si es type="number", cae en setSelectionRange
|
||||||
el.select?.();
|
el.select?.();
|
||||||
// Asegura selección completa si select() no aplica (p.ej. type="number")
|
// Asegura selección completa si select() no aplica (p.ej. type="number")
|
||||||
if (typeof (el as any).setSelectionRange === 'function') {
|
if (typeof (el as any).setSelectionRange === "function") {
|
||||||
const val = (el as any).value ?? '';
|
const val = (el as any).value ?? "";
|
||||||
(el as any).setSelectionRange(0, String(val).length);
|
(el as any).setSelectionRange(0, String(val).length);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@ -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";
|
|
||||||