Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
David Arranz 2026-05-05 20:37:29 +02:00
parent dd15fec846
commit 0fc0717822
52 changed files with 627 additions and 983 deletions

View File

@ -38,6 +38,13 @@
"editor.defaultFormatter": "biomejs.biome" "editor.defaultFormatter": "biomejs.biome"
}, },
// Codex OpenAI
"codex.enableAutoSuggest": true,
"codex.contextAwareness": true,
"codex.cloudTasks": true,
"codex.panelPosition": "right",
"codex.maxContextLines": 1000,
// Biome // Biome
"biome.enabled": true, "biome.enabled": true,
"editor.defaultFormatter": "biomejs.biome", "editor.defaultFormatter": "biomejs.biome",

View File

@ -10,6 +10,11 @@
"ignoreUnknown": true, "ignoreUnknown": true,
"includes": [ "includes": [
"**", "**",
"!!**/supplier-invoices",
"!!**/suppliers",
"!!**/auth",
"!!**/rdx-criteria",
"!!**/shadcn-ui",
"!!**/node_modules", "!!**/node_modules",
"!!**/.next", "!!**/.next",
"!!**/dist", "!!**/dist",

View File

@ -1,4 +1,3 @@
import { useDebounce } from "@repo/rdx-ui/components";
import { import {
Button, Button,
InputGroup, InputGroup,
@ -9,6 +8,7 @@ import {
import { SearchIcon, XIcon } from "lucide-react"; import { SearchIcon, XIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useDebounce } from "../../hooks";
import { useTranslation } from "../../i18n"; import { useTranslation } from "../../i18n";
type SimpleSearchInputProps = { type SimpleSearchInputProps = {

View File

@ -1,4 +1,5 @@
export * from "./use-datasource"; export * from "./use-datasource";
export * from "./use-debounce";
export * from "./use-hook-form"; export * from "./use-hook-form";
export * from "./use-rhf-error-focus"; export * from "./use-rhf-error-focus";
export * from "./use-unsaved-changes-notifier"; export * from "./use-unsaved-changes-notifier";

View File

@ -0,0 +1,15 @@
import * as React from "react";
export function useDebounce<T>(value: T, delay?: number): T {
const [debouncedValue, setDebouncedValue] = React.useState<T>(value);
React.useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}

View File

@ -22,12 +22,6 @@ export interface IProformaRepository {
transaction: unknown transaction: unknown
): Promise<Result<Proforma, Error>>; ): Promise<Result<Proforma, Error>>;
getByFactuGESIdInCompany(
companyId: UniqueID,
factugesId: string,
transaction: unknown
): Promise<Result<Proforma, Error>>;
findByCriteriaInCompany( findByCriteriaInCompany(
companyId: UniqueID, companyId: UniqueID,
criteria: Criteria, criteria: Criteria,

View File

@ -1,2 +0,0 @@
export * from "./issue-customer-invoice-domain-service";
export * from "./proforma-customer-invoice-domain-service";

View File

@ -1,83 +0,0 @@
import { UniqueID, type UtcDate } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { CustomerInvoice } from "../aggregates";
import { VerifactuRecord } from "../common/entities";
import { type InvoiceNumber, InvoiceStatus, VerifactuRecordEstado } from "../common/value-objects";
import { EntityIsNotProformaError, ProformaCannotBeConvertedToInvoiceError } from "../errors";
import {
CustomerInvoiceIsProformaSpecification,
ProformaCanTranstionToIssuedSpecification,
} from "../specs";
/**
* Servicio de dominio que encapsula la lógica de emisión de factura definitiva desde una proforma.
*/
export class IssueCustomerInvoiceDomainService {
private readonly isProformaSpec = new CustomerInvoiceIsProformaSpecification();
private readonly isApprovedSpec = new ProformaCanTranstionToIssuedSpecification();
/**
* Convierte una proforma en factura definitiva.
*
* @param proforma - Entidad CustomerInvoice en estado proforma aprobada.
* @param params.issueNumber - Número de la nueva factura.
* @param params.issueDate - Fecha de emisión.
* @returns Result<CustomerInvoice, Error> - Nueva factura emitida o error de dominio.
*/
public async issueFromProforma(
proforma: CustomerInvoice,
params: {
issueNumber: InvoiceNumber;
issueDate: UtcDate;
}
): Promise<Result<CustomerInvoice, Error>> {
const { issueDate, issueNumber } = params;
/** 1. Validar que la entidad origen es una proforma */
if (!(await this.isProformaSpec.isSatisfiedBy(proforma))) {
return Result.fail(new EntityIsNotProformaError(proforma.id.toString()));
}
/** 2. Validar que la proforma puede emitirse */
if (!(await this.isApprovedSpec.isSatisfiedBy(proforma))) {
return Result.fail(new ProformaCannotBeConvertedToInvoiceError(proforma.id.toString()));
}
const verifactuRecordOrError = VerifactuRecord.create(
{
estado: VerifactuRecordEstado.createPendiente(),
qrCode: Maybe.none(),
url: Maybe.none(),
uuid: Maybe.none(),
operacion: Maybe.none(),
},
UniqueID.generateNewID()
);
if (verifactuRecordOrError.isFailure) {
return Result.fail(new ProformaCannotBeConvertedToInvoiceError(proforma.id.toString()));
}
const verifactuRecord = verifactuRecordOrError.data;
/** 3. Generar la nueva factura definitiva (inmutable) */
const proformaProps = proforma.getProps();
const newInvoiceOrError = CustomerInvoice.create({
...proformaProps,
isProforma: false,
proformaId: Maybe.some(proforma.id),
status: InvoiceStatus.issued(),
invoiceNumber: issueNumber,
invoiceDate: issueDate,
description: proformaProps.description.isNone() ? Maybe.some(".") : proformaProps.description,
verifactu: Maybe.some(verifactuRecord),
});
if (newInvoiceOrError.isFailure) {
return Result.fail(newInvoiceOrError.error);
}
return Result.ok(newInvoiceOrError.data);
}
}

View File

@ -1,66 +0,0 @@
import { Result } from "@repo/rdx-utils";
import { CustomerInvoice } from "../aggregates";
import { INVOICE_STATUS, InvoiceStatus } from "../common/value-objects";
import { EntityIsNotProformaError, InvalidProformaTransitionError } from "../errors";
import { CustomerInvoiceIsProformaSpecification } from "../specs";
/**
* Servicio de dominio que encapsula la lógica de emisión de factura definitiva desde una proforma.
*/
export class ProformaCustomerInvoiceDomainService {
/** Aplica la transición si está permitida según INVOICE_TRANSITIONS. */
async transition(
proforma: CustomerInvoice,
nextStatus: string
): Promise<Result<CustomerInvoice, Error>> {
// Validar que la entidad es una proforma
const isProformaSpec = new CustomerInvoiceIsProformaSpecification();
if (!(await isProformaSpec.isSatisfiedBy(proforma))) {
return Result.fail(new EntityIsNotProformaError(proforma.id.toString()));
}
const current = proforma.status.toString();
const allowed = proforma.canTransitionTo(nextStatus);
if (!allowed) {
return Result.fail(
new InvalidProformaTransitionError(current, nextStatus, proforma.id.toString())
);
}
// Validaciones adicionales de dominio, si las hubiera
// (por ejemplo, no aprobar si no hay líneas)
// new ProformaHasLinesSpecification().isSatisfiedBy(proforma)
return CustomerInvoice.create({
...proforma.getProps(),
status: InvoiceStatus.create(nextStatus).data,
});
}
/** Envía la proforma (draft → sent) */
async send(proforma: CustomerInvoice): Promise<Result<CustomerInvoice, Error>> {
return this.transition(proforma, INVOICE_STATUS.SENT);
}
/** Aprueba la proforma (sent → approved) */
async approve(proforma: CustomerInvoice): Promise<Result<CustomerInvoice, Error>> {
return this.transition(proforma, INVOICE_STATUS.APPROVED);
}
/** Rechaza la proforma (sent → rejected) */
async reject(proforma: CustomerInvoice): Promise<Result<CustomerInvoice, Error>> {
return this.transition(proforma, INVOICE_STATUS.REJECTED);
}
/** Reabre una proforma rechazada (rejected → draft) */
async reopen(proforma: CustomerInvoice): Promise<Result<CustomerInvoice, Error>> {
return this.transition(proforma, INVOICE_STATUS.DRAFT);
}
/** Marca la proforma como emitida (approved → issued) */
async markAsIssued(proforma: CustomerInvoice): Promise<Result<CustomerInvoice, Error>> {
return this.transition(proforma, INVOICE_STATUS.ISSUED);
}
}

View File

@ -119,9 +119,6 @@ export class CustomerInvoiceModel extends Model<
declare customer_postal_code: CreationOptional<string | null>; declare customer_postal_code: CreationOptional<string | null>;
declare customer_country: CreationOptional<string | null>; declare customer_country: CreationOptional<string | null>;
// FactuGES
declare factuges_id: CreationOptional<string | null>;
// Relaciones // Relaciones
declare items: NonAttribute<CustomerInvoiceItemModel[]>; declare items: NonAttribute<CustomerInvoiceItemModel[]>;
declare taxes: NonAttribute<CustomerInvoiceTaxModel[]>; declare taxes: NonAttribute<CustomerInvoiceTaxModel[]>;
@ -493,12 +490,6 @@ export default (database: Sequelize) => {
allowNull: true, allowNull: true,
defaultValue: null, defaultValue: null,
}, },
factuges_id: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: null,
},
}, },
{ {
sequelize: database, sequelize: database,
@ -529,8 +520,6 @@ export default (database: Sequelize) => {
{ name: "idx_invoice_company_id", fields: ["id", "company_id"], unique: true }, // <- para consulta get { name: "idx_invoice_company_id", fields: ["id", "company_id"], unique: true }, // <- para consulta get
{ name: "idx_invoice_factuges", fields: ["factuges_id"], unique: false }, // <- para el proceso python
{ name: "uq_invoice_proforma_id", fields: ["proforma_id"], unique: true }, // <- para asegurar que una proforma solo tenga una factura vinculada { name: "uq_invoice_proforma_id", fields: ["proforma_id"], unique: true }, // <- para asegurar que una proforma solo tenga una factura vinculada
// Para búsquedas simples => se hace con el "CriteriaToSequelizeConverter" // Para búsquedas simples => se hace con el "CriteriaToSequelizeConverter"

View File

@ -326,93 +326,6 @@ export class ProformaRepository
} }
} }
/**
*
* Busca una factura por su identificador único de FactuGES.
*
* @param companyId - Identificador UUID de la empresa a la que pertenece la factura.
* @param factugesId - ID de la factura en FactuGES.
* @param transaction - Transacción activa para la operación.
* @param options - Opciones adicionales para la consulta (Sequelize FindOptions)
* @returns Result<CustomerInvoice, Error>
*/
async getByFactuGESIdInCompany(
companyId: UniqueID,
factugesId: string,
transaction: Transaction,
options: FindOptions<InferAttributes<CustomerInvoiceModel>> = {}
): Promise<Result<Proforma, Error>> {
const { CustomerModel } = this.database.models;
try {
// 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 ?? {}),
factuges_id: factugesId,
is_proforma: true,
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,
},
{
model: CustomerInvoiceTaxModel,
as: "taxes",
required: false,
},
{
model: CustomerInvoiceModel,
as: "linked_invoice",
required: false,
attributes: ["id"],
},
],
transaction,
};
const row = await CustomerInvoiceModel.findOne(mergedOptions);
if (!row) {
return Result.fail(
new EntityNotFoundError("CustomerInvoice", "factuges_id", factugesId.toString())
);
}
const invoice = this.domainMapper.mapToDomain(row);
return invoice;
} catch (err: unknown) {
return Result.fail(translateSequelizeError(err));
}
}
/** /**
* *
* Consulta facturas usando un objeto Criteria (filtros, orden, paginación). * Consulta facturas usando un objeto Criteria (filtros, orden, paginación).

View File

@ -1,10 +1,9 @@
// src/modules/issued-invoices/hooks/use-proformas-list.ts // src/modules/issued-invoices/hooks/use-proformas-list.ts
import type { CriteriaDTO } from "@erp/core"; import type { CriteriaDTO } from "@erp/core";
import { useDebounce } from "@repo/rdx-ui/components"; import { useDebounce } from "@erp/core/hooks";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { IssuedInvoiceSummaryDtoAdapter } from "../../../adapters/issued-invoice-summary-dto.adapter";
import { useIssuedInvoicesQuery } from "../../../hooks"; import { useIssuedInvoicesQuery } from "../../../hooks";
export const useIssuedInvoicesList = () => { export const useIssuedInvoicesList = () => {
@ -17,7 +16,7 @@ export const useIssuedInvoicesList = () => {
const criteria = useMemo<CriteriaDTO>(() => { const criteria = useMemo<CriteriaDTO>(() => {
const baseFilters = const baseFilters =
status !== "all" ? [{ field: "status", operator: "CONTAINS", value: status }] : []; status === "all" ? [] : [{ field: "status", operator: "CONTAINS", value: status }];
return { return {
q: debouncedQ || "", q: debouncedQ || "",

View File

@ -1,4 +1,4 @@
import { useDebounce } from "@repo/rdx-ui/components"; import { useDebounce } from "@erp/core/hooks";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { import {

View File

@ -1,4 +1,4 @@
import { useDebounce } from "@repo/rdx-ui/components"; import { useDebounce } from "@erp/core/hooks";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { type ListCustomersByCriteriaParams, useCustomersListQuery } from "../../../../web/shared"; import { type ListCustomersByCriteriaParams, useCustomersListQuery } from "../../../../web/shared";

View File

@ -1,4 +1,4 @@
import { useDebounce } from "@repo/rdx-ui/components"; import { useDebounce } from "@erp/core/hooks";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { type ListCustomersByCriteriaParams, useCustomersListQuery } from "../../shared"; import { type ListCustomersByCriteriaParams, useCustomersListQuery } from "../../shared";

View File

@ -0,0 +1,9 @@
import type { IFactuGESProformaLinkRepository } from "../repositories";
import { FactuGESProformaFinder, type IFactuGESProformaFinder } from "../services";
export function buildFactuGESFinder(params: {
repository: IFactuGESProformaLinkRepository;
}): IFactuGESProformaFinder {
const { repository } = params;
return new FactuGESProformaFinder(repository);
}

View File

@ -0,0 +1,12 @@
import type { IFactuGESProformaLinkRepository } from "../repositories";
import { FactuGESProformaLinker, type IFactuGESProformaLinker } from "../services";
export const buildFactuGESLinker = (params: {
repository: IFactuGESProformaLinkRepository;
}): IFactuGESProformaLinker => {
const { repository } = params;
return new FactuGESProformaLinker({
repository,
});
};

View File

@ -1,29 +1,32 @@
import type { ICatalogs, ITransactionManager } from "@erp/core/api"; import type { ICatalogs, ITransactionManager } from "@erp/core/api";
import type { ProformaPublicServices } from "@erp/customer-invoices/api"; import type { ICustomerPublicServices } from "@erp/customers/api";
import type { CustomerPublicServices } from "@erp/customers/api";
import type { ICreateProformaFromFactugesInputMapper } from "../mappers"; import type { ICreateProformaFromFactugesInputMapper } from "../mappers";
import type { IFactuGESProformaFinder, IFactuGESProformaLinker } from "../services";
import { CreateProformaFromFactugesUseCase } from "../use-cases"; import { CreateProformaFromFactugesUseCase } from "../use-cases";
export function buildCreateProformaFromFactugesUseCase(deps: { export function buildCreateProformaFromFactugesUseCase(deps: {
linker: IFactuGESProformaLinker;
finder: IFactuGESProformaFinder;
publicServices: { publicServices: {
customerServices: CustomerPublicServices; customerServices: ICustomerPublicServices;
proformaServices: ProformaPublicServices;
}; };
dtoMapper: ICreateProformaFromFactugesInputMapper; dtoMapper: ICreateProformaFromFactugesInputMapper;
catalogs: ICatalogs; catalogs: ICatalogs;
transactionManager: ITransactionManager; transactionManager: ITransactionManager;
}) { }) {
const { const {
linker,
dtoMapper, dtoMapper,
transactionManager, transactionManager,
publicServices: { customerServices, proformaServices }, publicServices: { customerServices },
} = deps; } = deps;
const { taxCatalog } = deps.catalogs; const { taxCatalog } = deps.catalogs;
return new CreateProformaFromFactugesUseCase({ return new CreateProformaFromFactugesUseCase({
linker,
finder,
customerServices, customerServices,
proformaServices,
dtoMapper, dtoMapper,
taxCatalog, taxCatalog,
transactionManager, transactionManager,

View File

@ -1,2 +1,3 @@
export * from "./mappers"; export * from "./mappers";
export * from "./repositories";
export * from "./use-cases"; export * from "./use-cases";

View File

@ -0,0 +1,20 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Maybe, Result } from "@repo/rdx-utils";
import type { FactuGESProformaLink } from "../../domain";
export interface IFactuGESProformaLinkRepository {
save(link: FactuGESProformaLink, transaction: unknown): Promise<Result<void, Error>>;
findByFactuGESIdInCompany(
companyId: UniqueID,
factuGESId: string,
transaction?: unknown
): Promise<Result<Maybe<FactuGESProformaLink>, Error>>;
existsByFactuGESIdInCompany(
companyId: UniqueID,
factuGESId: string,
transaction?: unknown
): Promise<Result<boolean, Error>>;
}

View File

@ -0,0 +1 @@
export * from "./factuges-proforma-link-repository.interface";

View File

@ -0,0 +1,55 @@
import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { IFactuGESProformaLinkRepository } from "../repositories";
export interface IFactuGESProformaFinder {
existsProformaByFactuGESId(
companyId: UniqueID,
factuGESId: string,
transaction?: unknown
): Promise<Result<boolean, Error>>;
findProformaIdByFactuGESId(
companyId: UniqueID,
factuGESId: string,
transaction?: unknown
): Promise<Result<UniqueID, Error>>;
}
export class FactuGESProformaFinder implements IFactuGESProformaFinder {
constructor(private readonly repository: IFactuGESProformaLinkRepository) {}
async existsProformaByFactuGESId(
companyId: UniqueID,
factuGESId: string,
transaction?: unknown
): Promise<Result<boolean, Error>> {
return this.repository.existsByFactuGESIdInCompany(companyId, factuGESId, transaction);
}
async findProformaIdByFactuGESId(
companyId: UniqueID,
factuGESId: string,
transaction?: unknown
): Promise<Result<UniqueID, Error>> {
const result = await this.repository.findByFactuGESIdInCompany(
companyId,
factuGESId,
transaction
);
if (result.isFailure) {
return Result.fail(result.error);
}
const linkOrNone = result.data;
if (linkOrNone.isNone()) {
return Result.fail(new Error("Proforma not found"));
}
const link = linkOrNone.unwrap();
return Result.ok(link.proformaId);
}
}

View File

@ -0,0 +1,81 @@
// modules/factuges/src/api/application/services/factuges-proforma-link.creator.ts
import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import {
FactuGESProformaLink,
type IFactuGESProformaLinkCreateProps,
} from "../../domain/aggregates/factuges-proforma-link.aggregate";
import type { IFactuGESProformaLinkRepository } from "../repositories";
export interface IFactuGESProformaLinkerParams {
companyId: UniqueID;
proformaId: UniqueID;
factuGESId: string;
transaction: unknown;
}
export interface IFactuGESProformaLinker {
create(params: IFactuGESProformaLinkerParams): Promise<Result<FactuGESProformaLink, Error>>;
}
export class FactuGESProformaLinker implements IFactuGESProformaLinker {
constructor(
private readonly deps: {
repository: IFactuGESProformaLinkRepository;
}
) {}
/**
* Crea la relación entre una factura de FactuGES y una proforma nueva.
*
* La operación es idempotente por `(companyId, factuGESId)`: si el link ya existe
* con la misma `proformaId`, devuelve el existente. Si existe apuntando a otra
* proforma, falla para evitar sobrescrituras silenciosas.
*/
public async create(
params: IFactuGESProformaLinkerParams
): Promise<Result<FactuGESProformaLink, Error>> {
const { companyId, factuGESId, transaction } = params;
const existingLinkResult = await this.deps.repository.findByFactuGESIdInCompany(
companyId,
factuGESId,
transaction
);
if (existingLinkResult.isFailure) {
console.error("Error fetching link by FactuGES ID:", existingLinkResult.error);
return Result.fail(existingLinkResult.error);
}
const linkOrNone = existingLinkResult.data;
if (linkOrNone.isSome()) {
const actualLink = linkOrNone.unwrap();
if (actualLink.proformaId.toString() !== params.proformaId.toString()) {
return Result.fail(
new Error(
`FactuGES proforma link already exists for company "${params.companyId.toString()}" and FactuGES id "${params.factuGESId}".`
)
);
}
}
const createProps: IFactuGESProformaLinkCreateProps = {
companyId: params.companyId,
proformaId: params.proformaId,
factuGESId: params.factuGESId,
};
const linkResult = FactuGESProformaLink.create(createProps);
if (linkResult.isFailure) {
return Result.fail(linkResult.error);
}
await this.deps.repository.save(linkResult.data, transaction);
return Result.ok(linkResult.data);
}
}

View File

@ -0,0 +1,2 @@
export * from "./factuges-proforma-finder";
export * from "./factuges-proforma-linker";

View File

@ -28,6 +28,7 @@ import type { Transaction } from "sequelize";
import type { CreateProformaFromFactugesRequestDTO } from "../../../common"; import type { CreateProformaFromFactugesRequestDTO } from "../../../common";
import type { FactugesProformaPayload, ICreateProformaFromFactugesInputMapper } from "../mappers"; import type { FactugesProformaPayload, ICreateProformaFromFactugesInputMapper } from "../mappers";
import type { IFactuGESProformaFinder, IFactuGESProformaLinker } from "../services";
import paymentsCatalog from "./payments.json"; import paymentsCatalog from "./payments.json";
@ -43,6 +44,8 @@ type CreateProformaFromFactugesUseCaseInput = {
}; };
type CreateProformaFromFactugesUseCaseDeps = { type CreateProformaFromFactugesUseCaseDeps = {
linker: IFactuGESProformaLinker;
finder: IFactuGESProformaFinder;
customerServices: ICustomerPublicServices; customerServices: ICustomerPublicServices;
proformaServices: IProformaPublicServices; proformaServices: IProformaPublicServices;
dtoMapper: ICreateProformaFromFactugesInputMapper; dtoMapper: ICreateProformaFromFactugesInputMapper;
@ -54,12 +57,16 @@ type CreateProformaProps = Parameters<IProformaPublicServices["createProforma"]>
export class CreateProformaFromFactugesUseCase { export class CreateProformaFromFactugesUseCase {
private readonly dtoMapper: ICreateProformaFromFactugesInputMapper; private readonly dtoMapper: ICreateProformaFromFactugesInputMapper;
private readonly linker: IFactuGESProformaLinker;
private readonly finder: IFactuGESProformaFinder;
private readonly customerServices: ICustomerPublicServices; private readonly customerServices: ICustomerPublicServices;
private readonly proformaServices: IProformaPublicServices; private readonly proformaServices: IProformaPublicServices;
private readonly taxCatalog: JsonTaxCatalogProvider; private readonly taxCatalog: JsonTaxCatalogProvider;
private readonly transactionManager: ITransactionManager; private readonly transactionManager: ITransactionManager;
constructor(deps: CreateProformaFromFactugesUseCaseDeps) { constructor(deps: CreateProformaFromFactugesUseCaseDeps) {
this.linker = deps.linker;
this.finder = deps.finder;
this.customerServices = deps.customerServices; this.customerServices = deps.customerServices;
this.proformaServices = deps.proformaServices; this.proformaServices = deps.proformaServices;
this.dtoMapper = deps.dtoMapper; this.dtoMapper = deps.dtoMapper;
@ -80,17 +87,16 @@ export class CreateProformaFromFactugesUseCase {
mappedPropsResult.data; mappedPropsResult.data;
// 2) Comprobar si la proforma ya existe (idempotencia) // 2) Comprobar si la proforma ya existe (idempotencia)
const existingProformaResult = await this.proformaServices.getProformaByFactuGESId( const proformaIdResult = await this.finder.findProformaIdByFactuGESId(
proformaDraft.factugesID, companyId,
{ companyId, transaction: null } proformaDraft.factugesID
); );
if (existingProformaResult.isSuccess) { if (proformaIdResult.isSuccess) {
const existingProforma = existingProformaResult.data; const existingProforma = proformaIdResult.data;
return Result.ok({ return Result.ok({
customer_id: existingProforma.customerId.toString(), proforma_id: existingProforma.toString(),
proforma_id: existingProforma.id.toString(),
}); });
} }
@ -147,9 +153,18 @@ export class CreateProformaFromFactugesUseCase {
return Result.fail(createResult.error); return Result.fail(createResult.error);
} }
// Valida que los datos de entrada coincidan con el snapshot // Guardar la relación entre la proforma generada y la factura de FactuGES
const proforma = createResult.data; await this.linker.create({
const validationResult = this.validateDraftAgainstProforma(proformaDraft, proforma); companyId,
factuGESId: proformaDraft.factugesID,
proformaId: createResult.data.id,
transaction,
});
// Validación extra: los datos de entrada deben coincidir con el snapshot
const newProforma = createResult.data;
const validationResult = this.validateDraftAgainstProforma(proformaDraft, newProforma);
if (validationResult.isFailure) { if (validationResult.isFailure) {
return Result.fail(validationResult.error); return Result.fail(validationResult.error);
} }
@ -169,7 +184,6 @@ export class CreateProformaFromFactugesUseCase {
const snapshot = readResult.data; const snapshot = readResult.data;
const result = { const result = {
customer_id: customer.id.toString(),
proforma_id: snapshot.id.toString(), proforma_id: snapshot.id.toString(),
}; };

View File

@ -0,0 +1,61 @@
import { AggregateRoot, type UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
export interface IFactuGESProformaLinkCreateProps {
companyId: UniqueID;
proformaId: UniqueID;
factuGESId: string;
}
export interface IFactuGESProformaLink {
companyId: UniqueID;
proformaId: UniqueID;
factuGESId: string;
}
export type FactuGESProformaLinkInternalProps = IFactuGESProformaLinkCreateProps;
export class FactuGESProformaLink
extends AggregateRoot<FactuGESProformaLinkInternalProps>
implements IFactuGESProformaLink
{
// Creación funcional
static create(
props: IFactuGESProformaLinkCreateProps,
id?: UniqueID
): Result<FactuGESProformaLink, Error> {
const validationResult = FactuGESProformaLink.validateCreateProps(props);
if (validationResult.isFailure) {
return Result.fail(validationResult.error);
}
const link = new FactuGESProformaLink(props, id);
return Result.ok(link);
}
private static validateCreateProps(
props: IFactuGESProformaLinkCreateProps | FactuGESProformaLinkInternalProps
): Result<void, Error> {
return Result.ok();
}
// Rehidratación desde persistencia
static rehydrate(props: FactuGESProformaLinkInternalProps, id: UniqueID): FactuGESProformaLink {
return new FactuGESProformaLink(props, id);
}
// Getters
public get companyId(): UniqueID {
return this.props.companyId;
}
public get proformaId(): UniqueID {
return this.props.proformaId;
}
public get factuGESId(): string {
return this.props.factuGESId;
}
}

View File

@ -0,0 +1 @@
export * from "./factuges-proforma-link.aggregate";

View File

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

View File

@ -0,0 +1,14 @@
import { SequelizeFactuGESProformaLinkDomainMapper } from "../persistence";
export interface IFactuGESPersistenceMappers {
domainMapper: SequelizeFactuGESProformaLinkDomainMapper;
}
export const buildFactuGESPersistenceMappers = (): IFactuGESPersistenceMappers => {
// Mappers para el repositorio
const domainMapper = new SequelizeFactuGESProformaLinkDomainMapper();
return {
domainMapper,
};
};

View File

@ -0,0 +1,14 @@
import type { Sequelize } from "sequelize";
import { FactuGESProformaLinkRepository } from "../persistence";
import type { IFactuGESPersistenceMappers } from "./factuges-persistence-mappers.di";
export const buildFactugesRepository = (params: {
database: Sequelize;
mappers: IFactuGESPersistenceMappers;
}) => {
const { database, mappers } = params;
return new FactuGESProformaLinkRepository(mappers.domainMapper, database);
};

View File

@ -6,8 +6,13 @@ import {
buildCreateProformaFromFactugesUseCase, buildCreateProformaFromFactugesUseCase,
buildFactugesInputMappers, buildFactugesInputMappers,
} from "../../application/di"; } from "../../application/di";
import { buildFactuGESFinder } from "../../application/di/factuges-finder.di";
import { buildFactuGESLinker } from "../../application/di/factuges-linker.di";
import type { CreateProformaFromFactugesUseCase } from "../../application/use-cases"; import type { CreateProformaFromFactugesUseCase } from "../../application/use-cases";
import { buildFactuGESPersistenceMappers } from "./factuges-persistence-mappers.di";
import { buildFactugesRepository } from "./factuges-repositories.di";
export type FactugesInternalDeps = { export type FactugesInternalDeps = {
useCases: { useCases: {
createProforma: (publicServices: { createProforma: (publicServices: {
@ -22,19 +27,24 @@ export function buildFactugesDependencies(params: SetupParams): FactugesInternal
// Infrastructure // Infrastructure
const transactionManager = buildTransactionManager(database); const transactionManager = buildTransactionManager(database);
const catalogs = buildCatalogs(); const catalogs = buildCatalogs();
const inputMappers = buildFactugesInputMappers(catalogs);
const persistenceMappers = buildFactuGESPersistenceMappers();
const repository = buildFactugesRepository({ database, mappers: persistenceMappers });
// Application helpers // Application helpers
const inputMappers = buildFactugesInputMappers(catalogs); const finder = buildFactuGESFinder({ repository });
const linker = buildFactuGESLinker({ repository });
// Internal use cases (factories) // Internal use cases (factories)
return { return {
useCases: { useCases: {
createProforma: (publicServices: { createProforma: (publicServices: { customerServices: ICustomerPublicServices }) =>
customerServices: ICustomerPublicServices;
proformaServices: IProformaPublicServices;
}) =>
buildCreateProformaFromFactugesUseCase({ buildCreateProformaFromFactugesUseCase({
linker,
finder,
dtoMapper: inputMappers.createInputMapper, dtoMapper: inputMappers.createInputMapper,
publicServices, publicServices,
catalogs, catalogs,

View File

@ -1 +1,2 @@
export * from "./express"; export * from "./express";
export * from "./persistence";

View File

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

View File

@ -0,0 +1,8 @@
import factugesCustomerInvoiceModelInit from "./models/factuges-customer-invoice.model";
export * from "./mappers";
export * from "./models";
export * from "./repositories";
// Array de inicializadores para que registerModels() lo use
export const models = [factugesCustomerInvoiceModelInit];

View File

@ -0,0 +1 @@
export * from "./sequelize-factuges-proforma-link-domain.mapper";

View File

@ -0,0 +1,76 @@
// modules/factuges/src/api/infrastructure/persistence/sequelize/mappers/domain/sequelize-factuges-proforma-link-domain.mapper.ts
import { type MapperParamsType, SequelizeDomainMapper } from "@erp/core/api";
import {
UniqueID,
ValidationErrorCollection,
type ValidationErrorDetail,
extractOrPushError,
} from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import {
FactuGESProformaLink,
type FactuGESProformaLinkInternalProps,
} from "../../../../../domain";
import type {
FactuGESCustomerInvoiceLinkCreationAttributes,
FactuGESCustomerInvoiceLinkModel,
} from "../../models/factuges-customer-invoice.model";
export class SequelizeFactuGESProformaLinkDomainMapper extends SequelizeDomainMapper<
FactuGESCustomerInvoiceLinkModel,
FactuGESCustomerInvoiceLinkCreationAttributes,
FactuGESProformaLink
> {
public mapToDomain(
source: FactuGESCustomerInvoiceLinkModel,
_params?: MapperParamsType
): Result<FactuGESProformaLink, Error> {
try {
const errors: ValidationErrorDetail[] = [];
const companyId = extractOrPushError(
UniqueID.create(source.company_id),
"company_id",
errors
);
const proformaId = extractOrPushError(
UniqueID.create(source.invoice_id),
"invoice_id",
errors
);
if (errors.length > 0) {
return Result.fail(
new ValidationErrorCollection("FactuGES proforma link props mapping failed", errors)
);
}
const props: FactuGESProformaLinkInternalProps = {
companyId: companyId!,
proformaId: proformaId!,
factuGESId: source.factuges_id,
};
const link = FactuGESProformaLink.rehydrate(props, proformaId!);
return Result.ok(link);
} catch (err: unknown) {
return Result.fail(err as Error);
}
}
public mapToPersistence(
source: FactuGESProformaLink,
_params?: MapperParamsType
): Result<FactuGESCustomerInvoiceLinkCreationAttributes, Error> {
const values: Partial<FactuGESCustomerInvoiceLinkCreationAttributes> = {
company_id: source.companyId.toPrimitive(),
invoice_id: source.proformaId.toPrimitive(),
factuges_id: source.factuGESId,
};
return Result.ok(values as FactuGESCustomerInvoiceLinkCreationAttributes);
}
}

View File

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

View File

@ -0,0 +1,66 @@
import {
DataTypes,
type InferAttributes,
type InferCreationAttributes,
Model,
type Sequelize,
} from "sequelize";
export type FactuGESCustomerInvoiceLinkCreationAttributes = InferCreationAttributes<
FactuGESCustomerInvoiceLinkModel,
{}
> & {};
export class FactuGESCustomerInvoiceLinkModel extends Model<
InferAttributes<FactuGESCustomerInvoiceLinkModel>,
InferCreationAttributes<FactuGESCustomerInvoiceLinkModel, {}>
> {
declare company_id: string;
declare invoice_id: string;
declare factuges_id: string;
static associate(_database: Sequelize) {}
static hooks(_database: Sequelize) {}
}
export default (database: Sequelize) => {
FactuGESCustomerInvoiceLinkModel.init(
{
company_id: {
type: DataTypes.UUID,
allowNull: false,
},
invoice_id: {
type: DataTypes.UUID,
primaryKey: true,
},
factuges_id: {
type: DataTypes.STRING,
allowNull: false,
},
},
{
sequelize: database,
modelName: "FactuGESCustomerInvoiceLinkModel",
tableName: "factuges_customer_invoice_links",
underscored: true,
paranoid: false, // no softs deletes
timestamps: true,
createdAt: "created_at",
updatedAt: "updated_at",
deletedAt: "deleted_at",
indexes: [],
whereMergeStrategy: "and", // <- cómo tratar el merge de un scope
defaultScope: {},
scopes: {},
}
);
return FactuGESCustomerInvoiceLinkModel;
};

View File

@ -0,0 +1 @@
export * from "./factuges-customer-invoice.model";

View File

@ -0,0 +1,100 @@
import { SequelizeRepository, translateSequelizeError } from "@erp/core/api";
import type { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { Sequelize, Transaction } from "sequelize";
import type { IFactuGESProformaLinkRepository } from "../../../../application";
import type { FactuGESProformaLink } from "../../../../domain";
import type { SequelizeFactuGESProformaLinkDomainMapper } from "../mappers";
import { FactuGESCustomerInvoiceLinkModel } from "../models";
export class FactuGESProformaLinkRepository
extends SequelizeRepository<FactuGESProformaLink>
implements IFactuGESProformaLinkRepository
{
constructor(
private readonly domainMapper: SequelizeFactuGESProformaLinkDomainMapper,
database: Sequelize
) {
super({ database });
}
/**
* Persiste una relación entre factura legacy FactuGES y proforma.
*
* La unicidad debe garantizarse también en base de datos mediante índice único
* sobre `(company_id, factuges_id)`.
*/
public async save(
link: FactuGESProformaLink,
transaction?: Transaction
): Promise<Result<void, Error>> {
try {
const dtoResult = this.domainMapper.mapToPersistence(link);
if (dtoResult.isFailure) {
return Result.fail(dtoResult.error);
}
const dto = dtoResult.data;
await FactuGESCustomerInvoiceLinkModel.create(dto, { transaction });
return Result.ok();
} catch (error: unknown) {
return Result.fail(translateSequelizeError(error));
}
}
/**
* Busca el link asociado a una factura de FactuGES dentro de una company.
*/
public async findByFactuGESIdInCompany(
companyId: UniqueID,
factuGESId: string,
transaction?: Transaction
): Promise<Result<Maybe<FactuGESProformaLink>, Error>> {
try {
const row = await FactuGESCustomerInvoiceLinkModel.findOne({
where: {
company_id: companyId.toString(),
factuges_id: factuGESId,
},
transaction,
});
if (!row) {
return Result.ok(Maybe.none());
}
const linkResult = this.domainMapper.mapToDomain(row);
return linkResult.isFailure
? Result.fail(linkResult.error)
: Result.ok(Maybe.some(linkResult.data));
} catch (error: unknown) {
throw translateSequelizeError(error);
}
}
/**
* Comprueba existencia del link por factura legacy dentro de una company.
*/
public async existsByFactuGESIdInCompany(
companyId: UniqueID,
factuGESId: string,
transaction?: Transaction
): Promise<Result<boolean, Error>> {
try {
const count = await FactuGESCustomerInvoiceLinkModel.count({
where: {
company_id: companyId.toString(),
factuges_id: factuGESId,
},
transaction,
});
return Result.ok(Boolean(count > 0));
} catch (error: unknown) {
return Result.fail(translateSequelizeError(error));
}
}
}

View File

@ -0,0 +1 @@
export * from "./factuges-proforma-link.repository";

View File

@ -6,8 +6,6 @@
"sideEffects": false, "sideEffects": false,
"scripts": { "scripts": {
"typecheck": "tsc -p tsconfig.json --noEmit", "typecheck": "tsc -p tsconfig.json --noEmit",
"check": "biome check .",
"lint": "biome lint .",
"clean": "rimraf .turbo node_modules dist" "clean": "rimraf .turbo node_modules dist"
}, },
"exports": { "exports": {

View File

@ -1,4 +1,4 @@
import { UniqueID } from "../value-objects/unique-id"; import type { UniqueID } from "../value-objects/unique-id";
export interface IDomainEvent { export interface IDomainEvent {
eventName: string; // Nombre del evento eventName: string; // Nombre del evento

View File

@ -1,10 +1,11 @@
// https://khalilstemmler.com/articles/typescript-domain-driven-design/chain-business-logic-domain-events/ // https://khalilstemmler.com/articles/typescript-domain-driven-design/chain-business-logic-domain-events/
import { AggregateRoot } from "../aggregate-root"; import type { AggregateRoot } from "../aggregate-root";
import { UniqueID } from "../value-objects"; import type { UniqueID } from "../value-objects";
import { IDomainEvent } from "./domain-event.interface";
// biome-ignore lint/complexity/noStaticOnlyClass: <explanation> import type { IDomainEvent } from "./domain-event.interface";
// biome-ignore lint/complexity/noStaticOnlyClass: <pendiente de revisar>
export class DomainEvents { export class DomainEvents {
private static handlersMap: { [key: string]: Array<(event: IDomainEvent) => void> } = {}; private static handlersMap: { [key: string]: Array<(event: IDomainEvent) => void> } = {};
private static markedAggregates: AggregateRoot<any>[] = []; private static markedAggregates: AggregateRoot<any>[] = [];
@ -33,7 +34,9 @@ export class DomainEvents {
*/ */
private static dispatchAggregateEvents(aggregate: AggregateRoot<any>): void { private static dispatchAggregateEvents(aggregate: AggregateRoot<any>): void {
aggregate.domainEvents.forEach((event: IDomainEvent) => DomainEvents.dispatch(event)); for (const event of aggregate.domainEvents) {
DomainEvents.dispatch(event);
}
} }
/** /**
@ -91,10 +94,10 @@ export class DomainEvents {
*/ */
public static register(callback: (event: IDomainEvent) => void, eventClassName: string): void { public static register(callback: (event: IDomainEvent) => void, eventClassName: string): void {
if (!Object.prototype.hasOwnProperty.call(DomainEvents.handlersMap, eventClassName)) { if (!Object.hasOwn(DomainEvents.handlersMap, eventClassName)) {
DomainEvents.handlersMap[eventClassName] = []; DomainEvents.handlersMap[eventClassName] = [];
} }
DomainEvents.handlersMap[eventClassName].push(callback); DomainEvents.handlersMap[eventClassName]!.push(callback);
} }
/** /**
@ -127,7 +130,7 @@ export class DomainEvents {
const eventClassName: string = event.constructor.name; const eventClassName: string = event.constructor.name;
if (Object.hasOwn(DomainEvents.handlersMap, eventClassName)) { if (Object.hasOwn(DomainEvents.handlersMap, eventClassName)) {
const handlers: any[] = DomainEvents.handlersMap[eventClassName]; const handlers = DomainEvents.handlersMap[eventClassName]!;
for (const handler of handlers) { for (const handler of handlers) {
handler(event); handler(event);
} }

View File

@ -1,4 +1,4 @@
import { ILogger } from "../types"; import type { ILogger } from "../types";
export class SentryLogger implements ILogger { export class SentryLogger implements ILogger {
// biome-ignore lint/complexity/noUselessConstructor: <explanation> // biome-ignore lint/complexity/noUselessConstructor: <explanation>

View File

@ -1,6 +1,7 @@
import rTracer from "cls-rtracer"; import rTracer from "cls-rtracer";
import { createLogger, format, transports } from "winston"; import { createLogger, format, transports } from "winston";
import { ILogger } from "../types";
import type { ILogger } from "../types";
const winston = createLogger({ const winston = createLogger({
level: "info", level: "info",

View File

@ -1,65 +0,0 @@
// DatePickerField.tsx
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import type { Control, FieldPath, FieldValues } from "react-hook-form";
import { useTranslation } from "../../locales/i18n.ts";
type NumberFieldProps<TFormValues extends FieldValues> = {
control: Control<TFormValues>;
name: FieldPath<TFormValues>;
label: string;
placeholder?: string;
description?: string;
disabled?: boolean;
required?: boolean;
readOnly?: boolean;
className?: string;
};
export function NumberField<TFormValues extends FieldValues>({
control,
name,
label,
placeholder,
description,
disabled = false,
required = false,
readOnly = false,
className,
}: NumberFieldProps<TFormValues>) {
const { t } = useTranslation();
const isDisabled = disabled || readOnly;
return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem className={cn("space-y-0", className)}>
<div className='flex justify-between items-center'>
<FormLabel className='m-0'>{label}</FormLabel>
{required && <span className='text-xs text-destructive'>{t("common.required")}</span>}
</div>
<FormControl>
<Input disabled={isDisabled} placeholder={placeholder} {...field} />
</FormControl>
<FormDescription className={cn("text-xs truncate", !description && "invisible")}>
{description || "\u00A0"}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
);
}

View File

@ -13,7 +13,6 @@ export * from "./loading-overlay/index.ts";
export * from "./logo-verifactu.tsx"; export * from "./logo-verifactu.tsx";
export * from "./lookup-dialog/index.ts"; export * from "./lookup-dialog/index.ts";
export * from "./multi-select.tsx"; export * from "./multi-select.tsx";
export * from "./multiple-selector.tsx";
export * from "./right-panel/index.ts"; export * from "./right-panel/index.ts";
export * from "./scroll-to-top.tsx"; export * from "./scroll-to-top.tsx";
export * from "./tailwind-indicator.tsx"; export * from "./tailwind-indicator.tsx";

View File

@ -1,616 +0,0 @@
// https://shadcnui-expansions.typeart.cc/docs/multiple-selector
import { Badge, Command, CommandGroup, CommandItem, CommandList } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { Command as CommandPrimitive, useCommandState } from "cmdk";
import { ChevronDownIcon, X } from "lucide-react";
import * as React from "react";
import { forwardRef, useEffect } from "react";
export interface MultipleSelectorOption {
value: string;
label: string;
disable?: boolean;
/** fixed option that can't be removed. */
fixed?: boolean;
/** Group the options by providing key. */
[key: string]: string | boolean | undefined;
}
interface GroupOption {
[key: string]: MultipleSelectorOption[];
}
interface MultipleSelectorProps {
value?: MultipleSelectorOption[];
defaultOptions?: MultipleSelectorOption[];
/** manually controlled options */
options?: MultipleSelectorOption[];
placeholder?: string;
/** Loading component. */
loadingIndicator?: React.ReactNode;
/** Empty component. */
emptyIndicator?: React.ReactNode;
/** Debounce time for async search. Only work with `onSearch`. */
delay?: number;
/**
* Only work with `onSearch` prop. Trigger search when `onFocus`.
* For example, when user click on the input, it will trigger the search to get initial options.
**/
triggerSearchOnFocus?: boolean;
/** async search */
onSearch?: (value: string) => Promise<MultipleSelectorOption[]>;
/**
* sync search. This search will not showing loadingIndicator.
* The rest props are the same as async search.
* i.e.: creatable, groupBy, delay.
**/
onSearchSync?: (value: string) => MultipleSelectorOption[];
onChange?: (options: MultipleSelectorOption[]) => void;
/** Limit the maximum number of selected options. */
maxSelected?: number;
/** When the number of selected options exceeds the limit, the onMaxSelected will be called. */
onMaxSelected?: (maxLimit: number) => void;
/** Hide the placeholder when there are options selected. */
hidePlaceholderWhenSelected?: boolean;
disabled?: boolean;
/** Group the options base on provided key. */
groupBy?: string;
className?: string;
badgeClassName?: string;
/**
* First item selected is a default behavior by cmdk. That is why the default is true.
* This is a workaround solution by add a dummy item.
*
* @reference: https://github.com/pacocoursey/cmdk/issues/171
*/
selectFirstItem?: boolean;
/** Allow user to create option when there is no option matched. */
creatable?: boolean;
/** Props of `Command` */
commandProps?: React.ComponentPropsWithoutRef<typeof Command>;
/** Props of `CommandInput` */
inputProps?: Omit<
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>,
"value" | "placeholder" | "disabled"
>;
/** hide the clear all button. */
hideClearAllButton?: boolean;
}
export interface MultipleSelectorRef {
selectedValue: MultipleSelectorOption[];
input: HTMLInputElement;
focus: () => void;
reset: () => void;
}
export function useDebounce<T>(value: T, delay?: number): T {
const [debouncedValue, setDebouncedValue] = React.useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}
function transToGroupOption(options: MultipleSelectorOption[], groupBy?: string) {
if (options.length === 0) {
return {};
}
if (!groupBy) {
return {
"": options,
};
}
const groupOption: GroupOption = {};
options.forEach((option) => {
const key = (option[groupBy] as string) || "";
if (!groupOption[key]) {
groupOption[key] = [];
}
groupOption[key].push(option);
});
return groupOption;
}
function removePickedOption(groupOption: GroupOption, picked: MultipleSelectorOption[]) {
const cloneOption = JSON.parse(JSON.stringify(groupOption)) as GroupOption;
for (const [key, value] of Object.entries(cloneOption)) {
cloneOption[key] = value.filter((val) => !picked.find((p) => p.value === val.value));
}
return cloneOption;
}
function isOptionsExist(groupOption: GroupOption, targetOption: MultipleSelectorOption[]) {
for (const [, value] of Object.entries(groupOption)) {
if (value.some((option) => targetOption.find((p) => p.value === option.value))) {
return true;
}
}
return false;
}
/**
* The `CommandEmpty` of shadcn/ui will cause the cmdk empty not rendering correctly.
* So we create one and copy the `Empty` implementation from `cmdk`.
*
* @reference: https://github.com/hsuanyi-chou/shadcn-ui-expansions/issues/34#issuecomment-1949561607
**/
const CommandEmpty = forwardRef<
HTMLDivElement,
React.ComponentProps<typeof CommandPrimitive.Empty>
>(({ className, ...props }, forwardedRef) => {
const render = useCommandState((state) => state.filtered.count === 0);
if (!render) return null;
return (
<div
ref={forwardedRef}
className={cn("py-6 text-center text-sm", className)}
cmdk-empty=''
role='presentation'
{...props}
/>
);
});
CommandEmpty.displayName = "CommandEmpty";
export const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorProps>(
(
{
value,
onChange,
placeholder,
defaultOptions: arrayDefaultOptions = [],
options: arrayOptions,
delay,
onSearch,
onSearchSync,
loadingIndicator,
emptyIndicator,
maxSelected = Number.MAX_SAFE_INTEGER,
onMaxSelected,
hidePlaceholderWhenSelected,
disabled,
groupBy,
className,
badgeClassName,
selectFirstItem = true,
creatable = false,
triggerSearchOnFocus = false,
commandProps,
inputProps,
hideClearAllButton = false,
}: MultipleSelectorProps,
ref: React.Ref<MultipleSelectorRef>
) => {
const inputRef = React.useRef<HTMLInputElement>(null);
const [open, setOpen] = React.useState(false);
const [onScrollbar, setOnScrollbar] = React.useState(false);
const [isLoading, setIsLoading] = React.useState(false);
const dropdownRef = React.useRef<HTMLDivElement>(null); // Added this
const [selected, setSelected] = React.useState<MultipleSelectorOption[]>(value || []);
const [options, setOptions] = React.useState<GroupOption>(
transToGroupOption(arrayDefaultOptions, groupBy)
);
const [inputValue, setInputValue] = React.useState("");
const debouncedSearchTerm = useDebounce(inputValue, delay || 500);
React.useImperativeHandle(
ref,
() => ({
selectedValue: [...selected],
input: inputRef.current as HTMLInputElement,
focus: () => inputRef?.current?.focus(),
reset: () => setSelected([]),
}),
[selected]
);
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
inputRef.current &&
!inputRef.current.contains(event.target as Node)
) {
setOpen(false);
inputRef.current.blur();
}
};
const handleUnselect = React.useCallback(
(option: MultipleSelectorOption) => {
const newOptions = selected.filter((s) => s.value !== option.value);
setSelected(newOptions);
onChange?.(newOptions);
},
[onChange, selected]
);
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
const input = inputRef.current;
if (input) {
if (e.key === "Delete" || e.key === "Backspace") {
if (input.value === "" && selected.length > 0) {
const lastSelectOption = selected[selected.length - 1];
// If there is a last item and it is not fixed, we can remove it.
if (lastSelectOption && !lastSelectOption.fixed) {
handleUnselect(lastSelectOption);
}
}
}
// This is not a default behavior of the <input /> field
if (e.key === "Escape") {
input.blur();
}
}
},
[handleUnselect, selected]
);
useEffect(() => {
if (open) {
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("touchend", handleClickOutside);
} else {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("touchend", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("touchend", handleClickOutside);
};
}, [open]);
useEffect(() => {
if (value) {
setSelected(value);
}
}, [value]);
useEffect(() => {
/** If `onSearch` is provided, do not trigger options updated. */
if (!arrayOptions || onSearch) {
return;
}
const newOption = transToGroupOption(arrayOptions || [], groupBy);
if (JSON.stringify(newOption) !== JSON.stringify(options)) {
setOptions(newOption);
}
}, [arrayDefaultOptions, arrayOptions, groupBy, onSearch, options]);
useEffect(() => {
/** sync search */
const doSearchSync = () => {
const res = onSearchSync?.(debouncedSearchTerm);
setOptions(transToGroupOption(res || [], groupBy));
};
const exec = async () => {
if (!(onSearchSync && open)) return;
if (triggerSearchOnFocus) {
doSearchSync();
}
if (debouncedSearchTerm) {
doSearchSync();
}
};
void exec();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]);
useEffect(() => {
/** async search */
const doSearch = async () => {
setIsLoading(true);
const res = await onSearch?.(debouncedSearchTerm);
setOptions(transToGroupOption(res || [], groupBy));
setIsLoading(false);
};
const exec = async () => {
if (!(onSearch && open)) return;
if (triggerSearchOnFocus) {
await doSearch();
}
if (debouncedSearchTerm) {
await doSearch();
}
};
void exec();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]);
const CreatableItem = () => {
if (!creatable) return undefined;
if (
isOptionsExist(options, [{ value: inputValue, label: inputValue }]) ||
selected.find((s) => s.value === inputValue)
) {
return undefined;
}
const Item = (
<CommandItem
value={inputValue}
className='cursor-pointer'
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={(value: string) => {
if (selected.length >= maxSelected) {
onMaxSelected?.(selected.length);
return;
}
setInputValue("");
const newOptions = [...selected, { value, label: value }];
setSelected(newOptions);
onChange?.(newOptions);
}}
>
{`Create "${inputValue}"`}
</CommandItem>
);
// For normal creatable
if (!onSearch && inputValue.length > 0) {
return Item;
}
// For async search creatable. avoid showing creatable item before loading at first.
if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) {
return Item;
}
return undefined;
};
const EmptyItem = React.useCallback(() => {
if (!emptyIndicator) return undefined;
// For async search that showing emptyIndicator
if (onSearch && !creatable && Object.keys(options).length === 0) {
return (
<CommandItem value='-' disabled>
{emptyIndicator}
</CommandItem>
);
}
return <CommandEmpty>{emptyIndicator}</CommandEmpty>;
}, [creatable, emptyIndicator, onSearch, options]);
const selectables = React.useMemo<GroupOption>(
() => removePickedOption(options, selected),
[options, selected]
);
/** Avoid Creatable Selector freezing or lagging when paste a long string. */
const commandFilter = React.useCallback(() => {
if (commandProps?.filter) {
return commandProps.filter;
}
if (creatable) {
return (value: string, search: string) => {
return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1;
};
}
// Using default filter in `cmdk`. We don't have to provide it.
return undefined;
}, [creatable, commandProps?.filter]);
return (
<Command
ref={dropdownRef}
{...commandProps}
onKeyDown={(e) => {
handleKeyDown(e);
commandProps?.onKeyDown?.(e);
}}
className={cn("h-auto overflow-visible bg-transparent", commandProps?.className)}
shouldFilter={
commandProps?.shouldFilter === undefined ? !onSearch : commandProps.shouldFilter
} // When onSearch is provided, we don't want to filter the options. You can still override it.
filter={commandFilter()}
>
<div
className={cn(
"flex items-start justify-between rounded-md border border-input px-3 py-2 text-base ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 md:text-sm",
{
"cursor-text": !disabled && selected.length !== 0,
},
className
)}
onClick={() => {
if (disabled) return;
inputRef?.current?.focus();
}}
onKeyDown={(e) => {
if ((e.key === "Enter" || e.key === " ") && !disabled) {
inputRef?.current?.focus();
}
}}
>
<div className='relative flex flex-wrap gap-1'>
{selected.map((option) => {
return (
<Badge
key={option.value}
className={cn(
"data-[disabled]:bg-muted-foreground data-[disabled]:text-muted data-[disabled]:hover:bg-muted-foreground",
"data-[fixed]:bg-muted-foreground data-[fixed]:text-muted data-[fixed]:hover:bg-muted-foreground",
badgeClassName
)}
data-fixed={option.fixed}
data-disabled={disabled || undefined}
>
{option.label}
<button
type='button'
className={cn(
"ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2",
(disabled || option.fixed) && "hidden"
)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleUnselect(option);
}
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={() => handleUnselect(option)}
>
<X className='h-3 w-3 text-muted-foreground hover:text-foreground' />
</button>
</Badge>
);
})}
{/* Avoid having the "Search" Icon */}
<CommandPrimitive.Input
{...inputProps}
ref={inputRef}
value={inputValue}
disabled={disabled}
onValueChange={(value) => {
setInputValue(value);
inputProps?.onValueChange?.(value);
}}
onBlur={(event) => {
if (!onScrollbar) {
setOpen(false);
}
inputProps?.onBlur?.(event);
}}
onFocus={(event) => {
setOpen(true);
inputProps?.onFocus?.(event);
}}
placeholder={hidePlaceholderWhenSelected && selected.length !== 0 ? "" : placeholder}
className={cn(
"flex-1 self-baseline bg-transparent outline-none placeholder:text-muted-foreground",
{
"w-full": hidePlaceholderWhenSelected,
"ml-1": selected.length !== 0,
},
inputProps?.className
)}
/>
</div>
<button
type='button'
onClick={() => {
setSelected(selected.filter((s) => s.fixed));
onChange?.(selected.filter((s) => s.fixed));
}}
className={cn(
"size-5",
(hideClearAllButton ||
disabled ||
selected.length < 1 ||
selected.filter((s) => s.fixed).length === selected.length) &&
"hidden"
)}
>
<X />
</button>
<ChevronDownIcon
className={cn(
"size-5 text-muted-foreground/50",
(hideClearAllButton ||
disabled ||
selected.length >= 1 ||
selected.filter((s) => s.fixed).length !== selected.length) &&
"hidden"
)}
/>
</div>
<div className='relative'>
{open && (
<CommandList
className='absolute top-1 z-auto w-full rounded-md border bg-popover text-popover-foreground shadow-md outline-none animate-in'
onMouseLeave={() => {
setOnScrollbar(false);
}}
onMouseEnter={() => {
setOnScrollbar(true);
}}
onMouseUp={() => {
inputRef?.current?.focus();
}}
>
{isLoading ? (
<>{loadingIndicator}</>
) : (
<>
{EmptyItem()}
{CreatableItem()}
{!selectFirstItem && <CommandItem value='-' className='hidden' />}
{Object.entries(selectables).map(([key, dropdowns]) => (
<CommandGroup key={key} heading={key} className='h-full overflow-auto'>
{dropdowns.map((option) => {
return (
<CommandItem
key={option.value}
value={option.label}
disabled={option.disable}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={() => {
if (selected.length >= maxSelected) {
onMaxSelected?.(selected.length);
return;
}
setInputValue("");
const newOptions = [...selected, option];
setSelected(newOptions);
onChange?.(newOptions);
}}
className={cn(
"cursor-pointer",
option.disable && "cursor-default text-muted-foreground"
)}
>
{option.label}
</CommandItem>
);
})}
</CommandGroup>
))}
</>
)}
</CommandList>
)}
</div>
</Command>
);
}
);
MultipleSelector.displayName = "MultipleSelector";

View File

@ -14,7 +14,7 @@ function formatDate(
options?: Intl.DateTimeFormatOptions options?: Intl.DateTimeFormatOptions
): string { ): string {
const date = normalizeToDate(input); const date = normalizeToDate(input);
if (!date || isNaN(date.getTime())) return ""; if (!date || Number.isNaN(date.getTime())) return "";
// Por defecto, formato corto y consistente. // Por defecto, formato corto y consistente.
const fmt = new Intl.DateTimeFormat(locale, { const fmt = new Intl.DateTimeFormat(locale, {

View File

@ -4,7 +4,6 @@ import { Result } from "./result";
export type TRuleValidatorResult<T> = Result<T, ValidationError>; export type TRuleValidatorResult<T> = Result<T, ValidationError>;
// biome-ignore lint/complexity/noStaticOnlyClass: <explanation>
export class RuleValidator { export class RuleValidator {
public static readonly RULE_NOT_NULL_OR_UNDEFINED = Joi.any() public static readonly RULE_NOT_NULL_OR_UNDEFINED = Joi.any()
.required() // <- undefined .required() // <- undefined

View File

@ -16,8 +16,6 @@
] ]
}, },
"scripts": { "scripts": {
"check": "biome check .",
"lint": "biome lint .",
"ui:add": "pnpm dlx shadcn@latest add" "ui:add": "pnpm dlx shadcn@latest add"
}, },
"peerDependencies": { "peerDependencies": {