.
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
parent
dd15fec846
commit
0fc0717822
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
15
modules/core/src/web/hooks/use-debounce.ts
Normal file
15
modules/core/src/web/hooks/use-debounce.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -1,2 +0,0 @@
|
|||||||
export * from "./issue-customer-invoice-domain-service";
|
|
||||||
export * from "./proforma-customer-invoice-domain-service";
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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"
|
||||||
|
|||||||
@ -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).
|
||||||
|
|||||||
@ -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 || "",
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -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,
|
||||||
|
|||||||
@ -1,2 +1,3 @@
|
|||||||
export * from "./mappers";
|
export * from "./mappers";
|
||||||
|
export * from "./repositories";
|
||||||
export * from "./use-cases";
|
export * from "./use-cases";
|
||||||
|
|||||||
@ -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>>;
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./factuges-proforma-link-repository.interface";
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
2
modules/factuges/src/api/application/services/index.ts
Normal file
2
modules/factuges/src/api/application/services/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./factuges-proforma-finder";
|
||||||
|
export * from "./factuges-proforma-linker";
|
||||||
@ -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(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
modules/factuges/src/api/domain/aggregates/index.ts
Normal file
1
modules/factuges/src/api/domain/aggregates/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./factuges-proforma-link.aggregate";
|
||||||
1
modules/factuges/src/api/domain/index.ts
Normal file
1
modules/factuges/src/api/domain/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./aggregates";
|
||||||
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -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);
|
||||||
|
};
|
||||||
@ -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,
|
||||||
|
|||||||
@ -1 +1,2 @@
|
|||||||
export * from "./express";
|
export * from "./express";
|
||||||
|
export * from "./persistence";
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./sequelize";
|
||||||
@ -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];
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./sequelize-factuges-proforma-link-domain.mapper";
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./domain";
|
||||||
@ -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;
|
||||||
|
};
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./factuges-customer-invoice.model";
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./factuges-proforma-link.repository";
|
||||||
@ -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": {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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";
|
||||||
|
|||||||
@ -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";
|
|
||||||
@ -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, {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user