This commit is contained in:
David Arranz 2026-03-07 19:27:23 +01:00
parent bba38e67f2
commit bf9ed99a90
135 changed files with 1988 additions and 1083 deletions

View File

@ -1,7 +1,9 @@
import { IModuleClient } from "@erp/core/client";
import type { IModuleClient } from "@erp/core/client";
import { AppLayout } from "@repo/rdx-ui/components";
import { createBrowserRouter, createRoutesFromElements, Navigate, Route } from "react-router-dom";
import { Navigate, Route, createBrowserRouter, createRoutesFromElements } from "react-router-dom";
import { ModuleRoutes } from "@/components/module-routes";
import { ErrorPage, LoginForm } from "../pages";
import { modules } from "../register-modules"; // Aquí ca
@ -33,24 +35,24 @@ export const getAppRouter = () => {
return createBrowserRouter(
createRoutesFromElements(
<Route path='/'>
<Route path="/">
{/* Auth Layout */}
<Route path='/auth'>
<Route index element={<Navigate to='login' />} />
<Route path='login' element={<LoginForm />} />
<Route path='*' element={<ModuleRoutes modules={grouped.auth} params={params} />} />
<Route path="/auth">
<Route element={<Navigate to="login" />} index />
<Route element={<LoginForm />} path="login" />
<Route element={<ModuleRoutes modules={grouped.auth} params={params} />} path="*" />
</Route>
{/* App Layout */}
<Route element={<AppLayout />}>
{/* Dynamic Module Routes */}
<Route path='*' element={<ModuleRoutes modules={grouped.app} params={params} />} />
<Route element={<ModuleRoutes modules={grouped.app} params={params} />} path="*" />
{/* Main Layout */}
<Route path='/dashboard' element={<ErrorPage />} />
<Route path='/settings' element={<ErrorPage />} />
<Route path='/catalog' element={<ErrorPage />} />
<Route path='/quotes' element={<ErrorPage />} />
<Route element={<ErrorPage />} path="/dashboard" />
<Route element={<ErrorPage />} path="/settings" />
<Route element={<ErrorPage />} path="/catalog" />
<Route element={<ErrorPage />} path="/quotes" />
</Route>
</Route>
)

View File

@ -1,4 +1,5 @@
export * from "./documents";
export * from "./mappers";
export * from "./presenters";
export * from "./renderers";
export * from "./snapshot-builders";

View File

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

View File

@ -56,23 +56,21 @@ export type DomainMapperWithBulk<TPersistence, TDomain> = IDomainMapper<TPersist
Partial<IBulkDomainMapper<TPersistence, TDomain>>;
/**
*
* 👓 Mapper de Read Model (Persistencia DTO/Proyección de Lectura)
* - Responsabilidad: transformar registros de persistencia en DTOs para lectura (listados, resúmenes, informes).
* 👓 Mapper de Read Model (Persistencia Read Model/Proyección de Lectura)
* - Responsabilidad: transformar registros de persistencia en read models para lectura (listados, resúmenes, informes).
* - No intenta reconstruir agregados ni validar value objects de dominio.
**/
export interface IQueryMapperWithBulk<TPersistence, TDTO> {
export interface IQueryMapperWithBulk<TPersistence, TReadModel> {
/**
* Convierte un registro crudo en un DTO de lectura.
* Convierte un registro crudo en un read model de lectura.
*/
mapToDTO(raw: TPersistence, params?: MapperParamsType): Result<TDTO, Error>;
mapToReadModel(raw: TPersistence, params?: MapperParamsType): Result<TReadModel, Error>;
/**
* Convierte múltiples registros crudos en una Collection de DTOs de lectura.
* Convierte múltiples registros crudos en una Collection de read models de lectura.
*/
mapToDTOCollection(
mapToReadModelCollection(
raws: TPersistence[],
totalCount: number,
params?: MapperParamsType
): Result<Collection<TDTO>, Error>;
): Result<Collection<TReadModel>, Error>;
}

View File

@ -1,3 +1,2 @@
export * from "./errors";
export * from "./repositories";
export * from "./value-objects";

View File

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

View File

@ -1,7 +1,7 @@
import { Collection, Result, ResultCollection } from "@repo/rdx-utils";
import type { Model } from "sequelize";
import type { MapperParamsType } from "../../../../domain";
import type { MapperParamsType } from "../../../../application";
import type { ISequelizeDomainMapper } from "./sequelize-mapper.interface";

View File

@ -1,4 +1,4 @@
import type { DomainMapperWithBulk, IQueryMapperWithBulk } from "../../../../domain";
import type { DomainMapperWithBulk, IQueryMapperWithBulk } from "@erp/core/api/application";
export interface ISequelizeDomainMapper<TModel, TModelAttributes, TEntity>
extends DomainMapperWithBulk<TModel | TModelAttributes, TEntity> {}

View File

@ -1,16 +1,16 @@
import { Collection, Result } from "@repo/rdx-utils";
import type { Model } from "sequelize";
import type { MapperParamsType } from "../../../../domain";
import type { MapperParamsType } from "../../../../application";
import type { ISequelizeQueryMapper } from "./sequelize-mapper.interface";
export abstract class SequelizeQueryMapper<TModel extends Model, TEntity>
implements ISequelizeQueryMapper<TModel, TEntity>
{
public abstract mapToDTO(raw: TModel, params?: MapperParamsType): Result<TEntity, Error>;
public abstract mapToReadModel(raw: TModel, params?: MapperParamsType): Result<TEntity, Error>;
public mapToDTOCollection(
public mapToReadModelCollection(
raws: TModel[],
totalCount: number,
params?: MapperParamsType
@ -23,7 +23,7 @@ export abstract class SequelizeQueryMapper<TModel extends Model, TEntity>
}
const items = _source.map((value, index) => {
const result = this.mapToDTO(value as TModel, { index, ...params });
const result = this.mapToReadModel(value as TModel, { index, ...params });
if (result.isFailure) {
throw result.error;
}

View File

@ -1 +0,0 @@
export * from "./issued-invoice-list.dto";

View File

@ -1,7 +1,6 @@
export * from "./application-models";
export * from "./di";
export * from "./dtos";
export * from "./mappers";
export * from "./models";
export * from "./repositories";
export * from "./services";
export * from "./snapshot-builders";

View File

@ -1,8 +1,8 @@
import type { MapperParamsType } from "@erp/core/api";
import type { Result } from "@repo/rdx-utils";
import type { IssuedInvoiceListDTO } from "../dtos";
import type { IssuedInvoiceSummary } from "../models";
export interface IIssuedInvoiceListMapper {
mapToDTO(raw: unknown, params?: MapperParamsType): Result<IssuedInvoiceListDTO, Error>;
export interface IIssuedInvoiceSummaryMapper {
mapToDTO(raw: unknown, params?: MapperParamsType): Result<IssuedInvoiceSummary, Error>;
}

View File

@ -0,0 +1 @@
export * from "./issued-invoice-summary";

View File

@ -10,7 +10,7 @@ import type {
VerifactuRecord,
} from "../../../domain";
export type IssuedInvoiceListDTO = {
export type IssuedInvoiceSummary = {
id: UniqueID;
companyId: UniqueID;

View File

@ -3,7 +3,7 @@ import type { UniqueID } from "@repo/rdx-ddd";
import type { Collection, Result } from "@repo/rdx-utils";
import type { IssuedInvoice } from "../../../domain";
import type { IssuedInvoiceListDTO } from "../dtos";
import type { IssuedInvoiceSummary } from "../models";
export interface IIssuedInvoiceRepository {
create(invoice: IssuedInvoice, transaction?: unknown): Promise<Result<void, Error>>;
@ -24,5 +24,5 @@ export interface IIssuedInvoiceRepository {
companyId: UniqueID,
criteria: Criteria,
transaction: unknown
): Promise<Result<Collection<IssuedInvoiceListDTO>, Error>>;
): Promise<Result<Collection<IssuedInvoiceSummary>, Error>>;
}

View File

@ -4,7 +4,7 @@ import type { Collection, Result } from "@repo/rdx-utils";
import type { Transaction } from "sequelize";
import type { IssuedInvoice } from "../../../domain";
import type { IssuedInvoiceListDTO } from "../dtos";
import type { IssuedInvoiceSummary } from "../models";
import type { IIssuedInvoiceRepository } from "../repositories";
export interface IIssuedInvoiceFinder {
@ -24,7 +24,7 @@ export interface IIssuedInvoiceFinder {
companyId: UniqueID,
criteria: Criteria,
transaction?: Transaction
): Promise<Result<Collection<IssuedInvoiceListDTO>, Error>>;
): Promise<Result<Collection<IssuedInvoiceSummary>, Error>>;
}
export class IssuedInvoiceFinder implements IIssuedInvoiceFinder {
@ -50,7 +50,7 @@ export class IssuedInvoiceFinder implements IIssuedInvoiceFinder {
companyId: UniqueID,
criteria: Criteria,
transaction?: Transaction
): Promise<Result<Collection<IssuedInvoiceListDTO>, Error>> {
): Promise<Result<Collection<IssuedInvoiceSummary>, Error>> {
return this.repository.findByCriteriaInCompany(companyId, criteria, transaction);
}
}

View File

@ -1,15 +1,15 @@
import type { ISnapshotBuilder } from "@erp/core/api";
import { maybeToEmptyString } from "@repo/rdx-ddd";
import type { IssuedInvoiceListDTO } from "../../dtos";
import type { IssuedInvoiceSummary } from "../../models";
import type { IIssuedInvoiceListItemSnapshot } from "./issued-invoice-list-item-snapshot.interface";
export interface IIssuedInvoiceListItemSnapshotBuilder
extends ISnapshotBuilder<IssuedInvoiceListDTO, IIssuedInvoiceListItemSnapshot> {}
extends ISnapshotBuilder<IssuedInvoiceSummary, IIssuedInvoiceListItemSnapshot> {}
export class IssuedInvoiceListItemSnapshotBuilder implements IIssuedInvoiceListItemSnapshotBuilder {
toOutput(invoice: IssuedInvoiceListDTO): IIssuedInvoiceListItemSnapshot {
toOutput(invoice: IssuedInvoiceSummary): IIssuedInvoiceListItemSnapshot {
const recipient = invoice.recipient.toObjectString();
const verifactu = invoice.verifactu.match(

View File

@ -1 +0,0 @@
export * from "./proforma-list.dto";

View File

@ -1,7 +1,6 @@
export * from "./application-models";
export * from "./di";
export * from "./dtos";
export * from "./mappers";
export * from "./models";
export * from "./repositories";
export * from "./services";
export * from "./snapshot-builders";

View File

@ -1,7 +1,7 @@
import type { MapperParamsType } from "@erp/core/api";
import type { Result } from "@repo/rdx-utils";
import type { ProformaListDTO } from "../dtos";
import type { ProformaListDTO } from "../models";
export interface IProformaListMapper {
mapToDTO(raw: unknown, params?: MapperParamsType): Result<ProformaListDTO, Error>;

View File

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

View File

@ -3,7 +3,7 @@ import type { UniqueID } from "@repo/rdx-ddd";
import type { Collection, Result } from "@repo/rdx-utils";
import type { InvoiceStatus, Proforma } from "../../../domain";
import type { ProformaListDTO } from "../dtos";
import type { ProformaListDTO } from "../models";
export interface IProformaRepository {
create(proforma: Proforma, transaction?: unknown): Promise<Result<void, Error>>;

View File

@ -4,7 +4,7 @@ import type { Collection, Result } from "@repo/rdx-utils";
import type { Transaction } from "sequelize";
import type { Proforma } from "../../../domain";
import type { ProformaListDTO } from "../dtos";
import type { ProformaListDTO } from "../models";
import type { IProformaRepository } from "../repositories";
export interface IProformaFinder {

View File

@ -1,7 +1,7 @@
import type { ISnapshotBuilder } from "@erp/core/api";
import { maybeToEmptyString } from "@repo/rdx-ddd";
import type { ProformaListDTO } from "../../dtos";
import type { ProformaListDTO } from "../../models";
import type { IProformaListItemSnapshot } from "./proforma-list-item-snapshot.interface";

View File

@ -192,6 +192,7 @@ export class CustomerInvoice
}
// Method to get the complete list of line items
public get items(): CustomerInvoiceItems {
return this._items;
}

View File

@ -14,7 +14,7 @@ import {
} from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { IssuedInvoiceListDTO } from "../../../../../../application";
import type { IssuedInvoiceSummary } from "../../../../../../application";
import { InvoiceRecipient } from "../../../../../../domain";
import type { CustomerInvoiceModel } from "../../../../../common";
@ -32,7 +32,7 @@ export class SequelizeIssuedInvoiceRecipientListMapper extends SequelizeQueryMap
const { errors, attributes } = params as {
errors: ValidationErrorDetail[];
attributes: Partial<IssuedInvoiceListDTO>;
attributes: Partial<IssuedInvoiceSummary>;
};
const { isProforma } = attributes;

View File

@ -11,7 +11,10 @@ import {
} from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { IIssuedInvoiceListMapper, IssuedInvoiceListDTO } from "../../../../../../application";
import type {
IIssuedInvoiceSummaryMapper,
IssuedInvoiceSummary,
} from "../../../../../../application";
import {
InvoiceAmount,
InvoiceNumber,
@ -25,8 +28,8 @@ import { SequelizeIssuedInvoiceRecipientListMapper } from "./sequelize-issued-in
import { SequelizeVerifactuRecordListMapper } from "./sequelize-verifactu-record.list.mapper";
export class SequelizeIssuedInvoiceListMapper
extends SequelizeQueryMapper<CustomerInvoiceModel, IssuedInvoiceListDTO>
implements IIssuedInvoiceListMapper
extends SequelizeQueryMapper<CustomerInvoiceModel, IssuedInvoiceSummary>
implements IIssuedInvoiceSummaryMapper
{
private _recipientMapper: SequelizeIssuedInvoiceRecipientListMapper;
private _verifactuMapper: SequelizeVerifactuRecordListMapper;
@ -37,14 +40,14 @@ export class SequelizeIssuedInvoiceListMapper
this._verifactuMapper = new SequelizeVerifactuRecordListMapper();
}
public mapToDTO(
public mapToReadModel(
raw: CustomerInvoiceModel,
params?: MapperParamsType
): Result<IssuedInvoiceListDTO, Error> {
): Result<IssuedInvoiceSummary, Error> {
const errors: ValidationErrorDetail[] = [];
// 1) Valores escalares (atributos generales)
const attributes = this.mapAttributesToDTO(raw, { errors, ...params });
const attributes = this._mapAttributesToReadModel(raw, { errors, ...params });
// 2) Recipient (snapshot en la factura o include)
const recipientResult = this._recipientMapper.mapToDTO(raw, {
@ -111,7 +114,7 @@ export class SequelizeIssuedInvoiceListMapper
});
}
private mapAttributesToDTO(raw: CustomerInvoiceModel, params?: MapperParamsType) {
private _mapAttributesToReadModel(raw: CustomerInvoiceModel, params?: MapperParamsType) {
const { errors } = params as {
errors: ValidationErrorDetail[];
};

View File

@ -4,7 +4,7 @@ import type { UniqueID } from "@repo/rdx-ddd";
import { type Collection, Result } from "@repo/rdx-utils";
import type { FindOptions, InferAttributes, OrderItem, Sequelize, Transaction } from "sequelize";
import type { IIssuedInvoiceRepository, IssuedInvoiceListDTO } from "../../../../../application";
import type { IIssuedInvoiceRepository, IssuedInvoiceSummary } from "../../../../../application";
import type { IssuedInvoice } from "../../../../../domain";
import {
CustomerInvoiceItemModel,
@ -212,7 +212,7 @@ export class IssuedInvoiceRepository
criteria: Criteria,
transaction: Transaction,
options: FindOptions<InferAttributes<CustomerInvoiceModel>> = {}
): Promise<Result<Collection<IssuedInvoiceListDTO>, Error>> {
): Promise<Result<Collection<IssuedInvoiceSummary>, Error>> {
const { CustomerModel } = this.database.models;
try {
@ -301,7 +301,7 @@ export class IssuedInvoiceRepository
}),
]);
return this.listMapper.mapToDTOCollection(rows, count);
return this.listMapper.mapToReadModelCollection(rows, count);
} catch (err: unknown) {
return Result.fail(translateSequelizeError(err));
}

View File

@ -2,23 +2,23 @@ import type { ProformasInternalDeps } from "./proformas.di";
export type ProformasServicesDeps = {
services: {
listIssuedInvoices: (filters: unknown, context: unknown) => null;
getIssuedInvoiceById: (id: unknown, context: unknown) => null;
generateIssuedInvoiceReport: (id: unknown, options: unknown, context: unknown) => null;
listProformas: (filters: unknown, context: unknown) => null;
getProformaById: (id: unknown, context: unknown) => null;
generateProformaReport: (id: unknown, options: unknown, context: unknown) => null;
};
};
export function buildProformaServices(deps: ProformasInternalDeps): ProformasServicesDeps {
return {
services: {
listIssuedInvoices: (filters, context) => null,
//internal.useCases.listIssuedInvoices().execute(filters, context),
listProformas: (filters, context) => null,
//internal.useCases.listProformas().execute(filters, context),
getIssuedInvoiceById: (id, context) => null,
//internal.useCases.getIssuedInvoiceById().execute(id, context),
getProformaById: (id, context) => null,
//internal.useCases.getProformaById().execute(id, context),
generateIssuedInvoiceReport: (id, options, context) => null,
//internal.useCases.reportIssuedInvoice().execute(id, options, context),
generateProformaReport: (id, options, context) => null,
//internal.useCases.reportProforma().execute(id, options, context),
},
};
}

View File

@ -368,10 +368,12 @@ export class ProformaRepository
? [options.include]
: [];
console.log(query.where);
query.where = {
...query.where,
...(options.where ?? {}),
is_proforma: false,
is_proforma: true,
company_id: companyId.toString(),
deleted_at: null,
};
@ -422,7 +424,7 @@ export class ProformaRepository
}),
]);
return this.listMapper.mapToDTOCollection(rows, count);
return this.listMapper.mapToReadModelCollection(rows, count);
} catch (err: unknown) {
return Result.fail(translateSequelizeError(err));
}

View File

@ -2,29 +2,32 @@ import type { ModuleClientParams } from "@erp/core/client";
import { lazy } from "react";
import { Outlet, type RouteObject } from "react-router-dom";
import { ProformaCreatePage } from "./proformas/create";
const ProformaLayout = lazy(() =>
import("./proformas/ui").then((m) => ({ default: m.ProformaLayout }))
);
const IssuedInvoicesLayout = lazy(() =>
import("./issued-invoices/ui").then((m) => ({ default: m.IssuedInvoicesLayout }))
);
const ProformasListPage = lazy(() =>
import("./proformas/list").then((m) => ({ default: m.ProformaListPage }))
);
const ProformasCreatePage = lazy(() =>
import("./proformas/create").then((m) => ({ default: m.ProformaCreatePage }))
);
/*const InvoiceUpdatePage = lazy(() =>
import("./pages").then((m) => ({ default: m.InvoiceUpdatePage }))
);*/
const IssuedInvoicesLayout = lazy(() =>
import("./issued-invoices/ui").then((m) => ({ default: m.IssuedInvoicesLayout }))
);
const IssuedInvoiceListPage = lazy(() =>
import("./issued-invoices/list").then((m) => ({ default: m.IssuedInvoiceListPage }))
);
/*const CustomerInvoiceAdd = lazy(() =>
import("./pages").then((m) => ({ default: m.CustomerInvoiceCreate }))
);
const InvoiceUpdatePage = lazy(() =>
import("./pages").then((m) => ({ default: m.InvoiceUpdatePage }))
);*/
export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[] => {
return [
{
@ -37,7 +40,7 @@ export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[]
children: [
{ path: "", index: true, element: <ProformasListPage /> }, // index
{ path: "list", element: <ProformasListPage /> },
//{ path: "create", element: <CustomerInvoiceAdd /> },
{ path: "create", element: <ProformaCreatePage /> },
//{ path: ":id/edit", element: <InvoiceUpdatePage /> },
],
},

View File

@ -1,4 +1,4 @@
import { MoneyDTOHelper, PercentageDTOHelper, formatCurrency } from "@erp/core";
import { MoneyDTOHelper, formatCurrency } from "@erp/core";
import type {
IssuedInvoiceSummaryData,
@ -26,14 +26,14 @@ export const IssuedInvoiceSummaryDtoAdapter = {
summaryDto.language_code
),
discount_percentage: PercentageDTOHelper.toNumber(summaryDto.discount_percentage),
/*discount_percentage: PercentageDTOHelper.toNumber(summaryDto.discount_percentage),
discount_percentage_fmt: PercentageDTOHelper.toNumericString(
summaryDto.discount_percentage
),
),*/
discount_amount: MoneyDTOHelper.toNumber(summaryDto.discount_amount),
discount_amount: MoneyDTOHelper.toNumber(summaryDto.total_discount_amount),
discount_amount_fmt: formatCurrency(
MoneyDTOHelper.toNumber(summaryDto.discount_amount),
MoneyDTOHelper.toNumber(summaryDto.total_discount_amount),
Number(summaryDto.total_amount.scale || 2),
summaryDto.currency_code,
summaryDto.language_code

View File

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

View File

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

View File

@ -0,0 +1 @@
export * from "./proforma-create-page";

View File

@ -0,0 +1,47 @@
import { PageHeader } from "@erp/core/components";
import { AppContent, AppHeader } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { PlusIcon } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../../i18n";
export const ProformaCreatePage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
return (
<>
<AppHeader>
<PageHeader
description={t("pages.proformas.list.description")}
rightSlot={
<Button
aria-label={t("pages.proformas.create.title")}
onClick={() => navigate("/proformas/create")}
>
<PlusIcon aria-hidden className="mr-2 size-4" />
{t("pages.proformas.create.title")}
</Button>
}
title={t("pages.proformas.list.title")}
/>
</AppHeader>
<AppContent>
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">{t("pages.create.title")}</h2>
<p className="text-muted-foreground">{t("pages.create.description")}</p>
</div>
<div className="flex items-center justify-end mb-4">
<Button className="cursor-pointer" onClick={() => navigate("/customer-invoices/list")}>
{t("pages.create.back_to_list")}
</Button>
</div>
</div>
<div className="flex flex-1 flex-col gap-4 p-4">hola</div>
</AppContent>
</>
);
};

View File

@ -63,7 +63,6 @@ export const ProformaListPage = () => {
rightSlot={
<Button
aria-label={t("pages.proformas.create.title")}
className="hidden"
onClick={() => navigate("/proformas/create")}
>
<PlusIcon aria-hidden className="mr-2 size-4" />

View File

@ -3,7 +3,7 @@ import { Criteria } from "@repo/rdx-criteria/server";
import { UniqueID } from "@repo/rdx-ddd";
import { Collection, Result } from "@repo/rdx-utils";
import { Transaction } from "sequelize";
import { Customer, CustomerPatchProps, CustomerProps, ICustomerRepository } from "../domain";
import { Customer, CustomerPatchProps, ICustomerProps, ICustomerRepository } from "../domain";
import { CustomerListDTO } from "../infrastructure";
export class CustomerApplicationService {
@ -19,7 +19,7 @@ export class CustomerApplicationService {
*/
buildCustomerInCompany(
companyId: UniqueID,
props: Omit<CustomerProps, "companyId">,
props: Omit<ICustomerProps, "companyId">,
customerId?: UniqueID
): Result<Customer, Error> {
return Customer.create({ ...props, companyId }, customerId);

View File

@ -0,0 +1,6 @@
import type { IProformaRepository } from "../repositories";
import { type IProformaFinder, ProformaFinder } from "../services";
export function buildProformaFinder(repository: IProformaRepository): IProformaFinder {
return new ProformaFinder(repository);
}

View File

@ -0,0 +1,41 @@
// application/issued-invoices/di/snapshot-builders.di.ts
import {
CustomerFullSnapshotBuilder,
CustomerItemReportSnapshotBuilder,
CustomerItemsFullSnapshotBuilder,
CustomerListItemSnapshotBuilder,
CustomerRecipientFullSnapshotBuilder,
CustomerReportSnapshotBuilder,
CustomerTaxReportSnapshotBuilder,
CustomerTaxesFullSnapshotBuilder,
} from "../snapshot-builders";
export function buildCustomerSnapshotBuilders() {
const itemsBuilder = new CustomerItemsFullSnapshotBuilder();
const taxesBuilder = new CustomerTaxesFullSnapshotBuilder();
const recipientBuilder = new CustomerRecipientFullSnapshotBuilder();
const fullSnapshotBuilder = new CustomerFullSnapshotBuilder(
itemsBuilder,
recipientBuilder,
taxesBuilder
);
const listSnapshotBuilder = new CustomerListItemSnapshotBuilder();
const itemsReportBuilder = new CustomerItemReportSnapshotBuilder();
const taxesReportBuilder = new CustomerTaxReportSnapshotBuilder();
const reportSnapshotBuilder = new CustomerReportSnapshotBuilder(
itemsReportBuilder,
taxesReportBuilder
);
return {
full: fullSnapshotBuilder,
list: listSnapshotBuilder,
report: reportSnapshotBuilder,
};
}

View File

@ -0,0 +1,83 @@
import type { ITransactionManager } from "@erp/core/api";
import type { ICreateCustomerInputMapper } from "../mappers";
import type {
ICustomerCreator,
ICustomerFinder,
CustomerDocumentGeneratorService,
} from "../services";
import type {
ICustomerListItemSnapshotBuilder,
ICustomerReportSnapshotBuilder,
} from "../snapshot-builders";
import type { ICustomerFullSnapshotBuilder } from "../snapshot-builders/full";
import { GetCustomerByIdUseCase, ListCustomersUseCase, ReportCustomerUseCase } from "../use-cases";
import { CreateCustomerUseCase } from "../use-cases/create-customer";
export function buildGetCustomerByIdUseCase(deps: {
finder: ICustomerFinder;
fullSnapshotBuilder: ICustomerFullSnapshotBuilder;
transactionManager: ITransactionManager;
}) {
return new GetCustomerByIdUseCase(deps.finder, deps.fullSnapshotBuilder, deps.transactionManager);
}
export function buildListCustomersUseCase(deps: {
finder: ICustomerFinder;
itemSnapshotBuilder: ICustomerListItemSnapshotBuilder;
transactionManager: ITransactionManager;
}) {
return new ListCustomersUseCase(deps.finder, deps.itemSnapshotBuilder, deps.transactionManager);
}
export function buildReportCustomerUseCase(deps: {
finder: ICustomerFinder;
fullSnapshotBuilder: ICustomerFullSnapshotBuilder;
reportSnapshotBuilder: ICustomerReportSnapshotBuilder;
documentService: CustomerDocumentGeneratorService;
transactionManager: ITransactionManager;
}) {
return new ReportCustomerUseCase(
deps.finder,
deps.fullSnapshotBuilder,
deps.reportSnapshotBuilder,
deps.documentService,
deps.transactionManager
);
}
export function buildCreateCustomerUseCase(deps: {
creator: ICustomerCreator;
dtoMapper: ICreateCustomerInputMapper;
fullSnapshotBuilder: ICustomerFullSnapshotBuilder;
transactionManager: ITransactionManager;
}) {
return new CreateCustomerUseCase({
dtoMapper: deps.dtoMapper,
creator: deps.creator,
fullSnapshotBuilder: deps.fullSnapshotBuilder,
transactionManager: deps.transactionManager,
});
}
/*export function buildUpdateCustomerUseCase(deps: {
finder: ICustomerFinder;
fullSnapshotBuilder: ICustomerFullSnapshotBuilder;
}) {
return new UpdateCustomerUseCase(deps.finder, deps.fullSnapshotBuilder);
}
export function buildDeleteCustomerUseCase(deps: { finder: ICustomerFinder }) {
return new DeleteCustomerUseCase(deps.finder);
}
export function buildIssueCustomerUseCase(deps: { finder: ICustomerFinder }) {
return new IssueCustomerUseCase(deps.finder);
}
export function buildChangeStatusCustomerUseCase(deps: {
finder: ICustomerFinder;
transactionManager: ITransactionManager;
}) {
return new ChangeStatusCustomerUseCase(deps.finder, deps.transactionManager);
}*/

View File

@ -0,0 +1,5 @@
export * from "./customer-creator.di";
export * from "./customer-finder.di";
export * from "./customer-input-mappers.di";
export * from "./customer-snapshot-builders.di";
export * from "./customer-use-cases.di";

View File

@ -1,3 +1,7 @@
//export * from "./customer-application.service";
//export * from "./presenters";
export * from "./di";
export * from "./mappers";
export * from "./models";
export * from "./repositories";
export * from "./services";
export * from "./snapshot-builders";
export * from "./use-cases";

View File

@ -0,0 +1,7 @@
import type { DomainMapperWithBulk } from "@erp/core/api";
import type { Customer } from "../../domain";
import type { CustomerCreationAttributes, CustomerModel } from "../../infrastructure";
export interface ICustomerDomainMapper
extends DomainMapperWithBulk<CustomerModel | CustomerCreationAttributes, Customer> {}

View File

@ -0,0 +1,7 @@
import type { IQueryMapperWithBulk } from "@erp/core/api";
import type { CustomerModel } from "../../infrastructure";
import type { CustomerSummary } from "../models";
export interface ICustomerSummaryMapper
extends IQueryMapperWithBulk<CustomerModel, CustomerSummary> {}

View File

@ -0,0 +1,2 @@
export * from "./customer-domain-mapper.interface";
export * from "./customer-summary-mapper.interface";

View File

@ -0,0 +1,41 @@
import type {
CurrencyCode,
EmailAddress,
LanguageCode,
Name,
PhoneNumber,
PostalAddress,
TINNumber,
URLAddress,
UniqueID,
} from "@repo/rdx-ddd";
import type { Maybe } from "@repo/rdx-utils";
import type { CustomerStatus } from "../../domain";
export type CustomerSummary = {
id: UniqueID;
companyId: UniqueID;
status: CustomerStatus;
reference: Maybe<Name>;
isCompany: boolean;
name: Name;
tradeName: Maybe<Name>;
tin: Maybe<TINNumber>;
address: PostalAddress;
emailPrimary: Maybe<EmailAddress>;
emailSecondary: Maybe<EmailAddress>;
phonePrimary: Maybe<PhoneNumber>;
phoneSecondary: Maybe<PhoneNumber>;
mobilePrimary: Maybe<PhoneNumber>;
mobileSecondary: Maybe<PhoneNumber>;
fax: Maybe<PhoneNumber>;
website: Maybe<URLAddress>;
languageCode: LanguageCode;
currencyCode: CurrencyCode;
};

View File

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

View File

@ -1,8 +1,9 @@
import { Criteria } from "@repo/rdx-criteria/server";
import { UniqueID } from "@repo/rdx-ddd";
import { Collection, Result } from "@repo/rdx-utils";
import { CustomerListDTO } from "../../infrastructure/mappers";
import { Customer } from "../aggregates";
import type { Criteria } from "@repo/rdx-criteria/server";
import type { UniqueID } from "@repo/rdx-ddd";
import type { Collection, Result } from "@repo/rdx-utils";
import type { Customer } from "../../domain/aggregates";
import type { CustomerSummary } from "../models";
/**
* Interfaz del repositorio para el agregado `Customer`.
@ -56,7 +57,7 @@ export interface ICustomerRepository {
companyId: UniqueID,
criteria: Criteria,
transaction: unknown
): Promise<Result<Collection<CustomerListDTO>, Error>>;
): Promise<Result<Collection<CustomerSummary>, Error>>;
/**
* Elimina un Customer por su ID, dentro de una empresa.

View File

@ -0,0 +1,56 @@
import type { Criteria } from "@repo/rdx-criteria/server";
import type { UniqueID } from "@repo/rdx-ddd";
import type { Collection, Result } from "@repo/rdx-utils";
import type { Transaction } from "sequelize";
import type { Customer } from "../../domain";
import type { CustomerListDTO } from "../dtos";
import type { ICustomerRepository } from "../repositories";
export interface ICustomerFinder {
findCustomerById(
companyId: UniqueID,
invoiceId: UniqueID,
transaction?: Transaction
): Promise<Result<Customer, Error>>;
customerExists(
companyId: UniqueID,
invoiceId: UniqueID,
transaction?: Transaction
): Promise<Result<boolean, Error>>;
findCustomersByCriteria(
companyId: UniqueID,
criteria: Criteria,
transaction?: Transaction
): Promise<Result<Collection<CustomerListDTO>, Error>>;
}
export class CustomerFinder implements ICustomerFinder {
constructor(private readonly repository: ICustomerRepository) {}
async findCustomerById(
companyId: UniqueID,
customerId: UniqueID,
transaction?: Transaction
): Promise<Result<Customer, Error>> {
return this.repository.getByIdInCompany(companyId, customerId, transaction);
}
async customerExists(
companyId: UniqueID,
customerId: UniqueID,
transaction?: Transaction
): Promise<Result<boolean, Error>> {
return this.repository.existsByIdInCompany(companyId, customerId, transaction);
}
async findCustomersByCriteria(
companyId: UniqueID,
criteria: Criteria,
transaction?: Transaction
): Promise<Result<Collection<CustomerListDTO>, Error>> {
return this.repository.findByCriteriaInCompany(companyId, criteria, transaction);
}
}

View File

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

View File

@ -0,0 +1,3 @@
//export * from "./full";
export * from "./list";
//export * from "./report";

View File

@ -0,0 +1,14 @@
import type { ISnapshotBuilder } from "@erp/core/api";
import type { CustomerListDTO } from "../../dtos";
import type { ICustomerListItemSnapshot } from "./customer-list-item-snapshot.interface";
export interface ICustomerListItemSnapshotBuilder
extends ISnapshotBuilder<CustomerListDTO, ICustomerListItemSnapshot> {}
export class CustomerListItemSnapshotBuilder implements ICustomerListItemSnapshotBuilder {
toOutput(customer: CustomerListDTO): ICustomerListItemSnapshot {
return {};
}
}

View File

@ -0,0 +1 @@
export type ICustomerListItemSnapshot = {};

View File

@ -0,0 +1,2 @@
export * from "./customer-list-item-snapshot.interface";
export * from "./customer-list-item-snapshot-builder";

View File

@ -24,7 +24,7 @@ import {
import { Collection, Result, isNullishOrEmpty } from "@repo/rdx-utils";
import type { CreateCustomerRequestDTO } from "../../../../common";
import { type CustomerProps, CustomerStatus } from "../../../domain";
import { type ICustomerProps, CustomerStatus } from "../../../domain";
/**
* Convierte el DTO a las props validadas (CustomerProps).
@ -197,7 +197,7 @@ export function mapDTOToCreateCustomerProps(dto: CreateCustomerRequestDTO) {
errors
);
const customerProps: Omit<CustomerProps, "companyId"> = {
const customerProps: Omit<ICustomerProps, "companyId"> = {
status: status!,
reference: reference!,

View File

@ -1,11 +1,12 @@
import { IPresenterRegistry, ITransactionManager } from "@erp/core/api";
import { Criteria } from "@repo/rdx-criteria/server";
import { UniqueID } from "@repo/rdx-ddd";
import type { ITransactionManager } from "@erp/core/api";
import type { Criteria } from "@repo/rdx-criteria/server";
import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { Transaction } from "sequelize";
import { ListCustomersResponseDTO } from "../../../common/dto";
import { CustomerApplicationService } from "../../application";
import { ListCustomersPresenter } from "../presenters";
import type { Transaction } from "sequelize";
import type { ListCustomersResponseDTO } from "../../../common/dto";
import type { ICustomerFinder } from "../services";
import type { ICustomerListItemSnapshotBuilder } from "../snapshot-builders/list";
type ListCustomersUseCaseInput = {
companyId: UniqueID;
@ -14,39 +15,42 @@ type ListCustomersUseCaseInput = {
export class ListCustomersUseCase {
constructor(
private readonly service: CustomerApplicationService,
private readonly transactionManager: ITransactionManager,
private readonly presenterRegistry: IPresenterRegistry
private readonly finder: ICustomerFinder,
private readonly listItemSnapshotBuilder: ICustomerListItemSnapshotBuilder,
private readonly transactionManager: ITransactionManager
) {}
public execute(
params: ListCustomersUseCaseInput
): Promise<Result<ListCustomersResponseDTO, Error>> {
const { criteria, companyId } = params;
const presenter = this.presenterRegistry.getPresenter({
resource: "customer",
projection: "LIST",
}) as ListCustomersPresenter;
return this.transactionManager.complete(async (transaction: Transaction) => {
try {
const result = await this.service.findCustomerByCriteriaInCompany(
companyId,
criteria,
transaction
);
const result = await this.finder.findCustomersByCriteria(companyId, criteria, transaction);
if (result.isFailure) {
return Result.fail(result.error);
}
const customers = result.data;
const dto = presenter.toOutput({
customers,
criteria,
});
const totalCustomers = customers.total();
return Result.ok(dto);
const items = customers.map((item) => this.listItemSnapshotBuilder.toOutput(item));
const snapshot = {
page: criteria.pageNumber,
per_page: criteria.pageSize,
total_pages: Math.ceil(totalCustomers / criteria.pageSize),
total_items: totalCustomers,
items: items,
metadata: {
entity: "customers",
criteria: criteria.toJSON(),
},
};
return Result.ok(snapshot);
} catch (error: unknown) {
return Result.fail(error as Error);
}

View File

@ -17,7 +17,7 @@ import { type Collection, type Maybe, Result } from "@repo/rdx-utils";
import type { CustomerStatus } from "../value-objects";
export interface CustomerProps {
export interface ICustomerProps {
companyId: UniqueID;
status: CustomerStatus;
reference: Maybe<Name>;
@ -49,7 +49,7 @@ export interface CustomerProps {
currencyCode: CurrencyCode;
}
export type CustomerPatchProps = Partial<Omit<CustomerProps, "companyId" | "address">> & {
export type CustomerPatchProps = Partial<Omit<ICustomerProps, "companyId" | "address">> & {
address?: PostalAddressPatchProps;
};
@ -90,8 +90,12 @@ export interface ICustomer {
readonly currencyCode: CurrencyCode;
}
export class Customer extends AggregateRoot<CustomerProps> implements ICustomer {
static create(props: CustomerProps, id?: UniqueID): Result<Customer, Error> {
type CreateCustomerProps = ICustomerProps;
type InternalCustomerProps = ICustomerProps;
export class Customer extends AggregateRoot<InternalCustomerProps> implements ICustomer {
static create(props: CreateCustomerProps, id?: UniqueID): Result<Customer, Error> {
const contact = new Customer(props, id);
// Reglas de negocio / validaciones
@ -105,12 +109,18 @@ export class Customer extends AggregateRoot<CustomerProps> implements ICustomer
return Result.ok(contact);
}
// Rehidratación desde persistencia
static rehydrate(props: InternalCustomerProps, id: UniqueID): Customer {
return new Customer(props, id);
}
public update(partialCustomer: CustomerPatchProps): Result<Customer, Error> {
const { address: partialAddress, ...rest } = partialCustomer;
const updatedProps = {
...this.props,
...rest,
} as CustomerProps;
} as ICustomerProps;
if (partialAddress) {
const updatedAddressOrError = this.address.update(partialAddress);
@ -124,6 +134,8 @@ export class Customer extends AggregateRoot<CustomerProps> implements ICustomer
return Customer.create(updatedProps, this.id);
}
// Getters
public get isIndividual(): boolean {
return !this.props.isCompany;
}

View File

@ -1 +1 @@
export * from "./customer";
export * from "./customer.aggregate";

View File

@ -1,4 +1,3 @@
export * from "./aggregates";
export * from "./errors";
export * from "./repositories";
export * from "./value-objects";

View File

@ -1,4 +1,4 @@
export * from "./customer-address-type";
export * from "./customer-number";
export * from "./customer-serie";
export * from "./customer-status";
export * from "./customer-address-type.vo";
export * from "./customer-number.vo";
export * from "./customer-serie.vo";
export * from "./customer-status.vo";

View File

@ -1,6 +1,6 @@
import type { IModuleServer } from "@erp/core/api";
import { models } from "./infrastructure";
import { customersRouter, models } from "./infrastructure";
export * from "./infrastructure/sequelize";
@ -19,25 +19,29 @@ export const customersAPIModule: IModuleServer = {
async setup(params) {
const { env: ENV, app, database, baseRoutePath: API_BASE_PATH, logger } = params;
// 1) Dominio interno
//const customerInternalDeps = buildCustomerDependencies(params);
// 2) Servicios públicos (Application Services)
//const customerServices = buildCustomerServices(customerInternalDeps);
logger.info("🚀 Customers module dependencies registered", {
label: this.name,
});
return {
// Modelos Sequelize del módulo
models,
// Servicios expuestos a otros módulos
services: {
customers: {
/*...*/
},
//customers: customerServices,
},
// Implementación privada del módulo
internal: {
customers: {
/*...*/
},
//customers: customerInternalDeps,
},
};
},
@ -56,7 +60,7 @@ export const customersAPIModule: IModuleServer = {
const customersInternalDeps = getInternal("customers", "customers");
// Registro de rutas HTTP
//customersRouter(params, customersInternalDeps);
customersRouter(params, customersInternalDeps);
logger.info("🚀 Customers module started", {
label: this.name,

View File

@ -14,7 +14,7 @@ import {
import { CustomerApplicationService } from "../application/customer-application.service";
import { CustomerFullPresenter, ListCustomersPresenter } from "../application/presenters";
import { CustomerDomainMapper, CustomerListMapper } from "./mappers";
import { CustomerDomainMapper, CustomerSummaryMapper } from "./mappers";
import { CustomerRepository } from "./sequelize";
export type CustomerDeps = {
@ -40,7 +40,7 @@ export function buildCustomerDependencies(params: ModuleParams): CustomerDeps {
const mapperRegistry = new InMemoryMapperRegistry();
mapperRegistry
.registerDomainMapper({ resource: "customer" }, new CustomerDomainMapper())
.registerQueryMapper({ resource: "customer", query: "LIST" }, new CustomerListMapper());
.registerQueryMapper({ resource: "customer", query: "LIST" }, new CustomerSummaryMapper());
// Repository & Services
const repo = new CustomerRepository({ mapperRegistry, database });

View File

@ -0,0 +1,36 @@
import {
CreateCustomerInputMapper,
type ICatalogs,
type ICustomerDomainMapper,
type ICustomerListMapper,
} from "../../../application";
import { SequelizeCustomerDomainMapper, SequelizeCustomerListMapper } from "../persistence";
export interface ICustomerPersistenceMappers {
domainMapper: ICustomerDomainMapper;
listMapper: ICustomerListMapper;
createMapper: CreateCustomerInputMapper;
}
export const buildCustomerPersistenceMappers = (
catalogs: ICatalogs
): ICustomerPersistenceMappers => {
const { taxCatalog } = catalogs;
// Mappers para el repositorio
const domainMapper = new SequelizeCustomerDomainMapper({
taxCatalog,
});
const listMapper = new SequelizeCustomerListMapper();
// Mappers el DTO a las props validadas (CustomerProps) y luego construir agregado
const createMapper = new CreateCustomerInputMapper({ taxCatalog });
return {
domainMapper,
listMapper,
createMapper,
};
};

View File

@ -0,0 +1,14 @@
import type { Sequelize } from "sequelize";
import { CustomerRepository } from "../persistence";
import type { ICustomerPersistenceMappers } from "./customer-persistence-mappers.di";
export const buildCustomerRepository = (params: {
database: Sequelize;
mappers: ICustomerPersistenceMappers;
}) => {
const { database, mappers } = params;
return new CustomerRepository(mappers.domainMapper, mappers.listMapper, database);
};

View File

@ -0,0 +1,73 @@
import { type ModuleParams, buildCatalogs, buildTransactionManager } from "@erp/core/api";
export type CustomersInternalDeps = {
useCases: {
listCustomers: () => ListCustomersUseCase;
getCustomerById: () => GetCustomerByIdUseCase;
reportCustomer: () => ReportCustomerUseCase;
createCustomer: () => CreateCustomerUseCase;
/*
updateCustomer: () => UpdateCustomerUseCase;
deleteCustomer: () => DeleteCustomerUseCase;
issueCustomer: () => IssueCustomerUseCase;
changeStatusCustomer: () => ChangeStatusCustomerUseCase;*/
};
};
export function buildCustomersDependencies(params: ModuleParams): CustomersInternalDeps {
const { database } = params;
// Infrastructure
const transactionManager = buildTransactionManager(database);
const catalogs = buildCatalogs();
const persistenceMappers = buildCustomerPersistenceMappers(catalogs);
const repository = buildCustomerRepository({ database, mappers: persistenceMappers });
const numberService = buildCustomerNumberGenerator();
// Application helpers
const inputMappers = buildCustomerInputMappers(catalogs);
const finder = buildCustomerFinder(repository);
const creator = buildCustomerCreator({ numberService, repository });
const snapshotBuilders = buildCustomersnapshotBuilders();
const documentGeneratorPipeline = buildCustomerDocumentService(params);
// Internal use cases (factories)
return {
useCases: {
listCustomers: () =>
buildListCustomersUseCase({
finder,
itemSnapshotBuilder: snapshotBuilders.list,
transactionManager,
}),
getCustomerById: () =>
buildGetCustomerByIdUseCase({
finder,
fullSnapshotBuilder: snapshotBuilders.full,
transactionManager,
}),
reportCustomer: () =>
buildReportCustomerUseCase({
finder,
fullSnapshotBuilder: snapshotBuilders.full,
reportSnapshotBuilder: snapshotBuilders.report,
documentService: documentGeneratorPipeline,
transactionManager,
}),
createCustomer: () =>
buildCreateCustomerUseCase({
creator,
dtoMapper: inputMappers.createInputMapper,
fullSnapshotBuilder: snapshotBuilders.full,
transactionManager,
}),
},
};
}

View File

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

View File

@ -25,7 +25,7 @@ import {
UpdateCustomerController,
} from "./controllers";
export const customersRouter = (params: ModuleParams) => {
export const customersRouter = (params: ModuleParams, deps: CustomerInternalDeps) => {
const { app, baseRoutePath, logger } = params as {
app: Application;
database: Sequelize;

View File

@ -1,8 +1,5 @@
import {
type ISequelizeDomainMapper,
type MapperParamsType,
SequelizeDomainMapper,
} from "@erp/core/api";
import { type MapperParamsType, SequelizeDomainMapper } from "@erp/core/api";
import type { ICustomerDomainMapper } from "@erp/customers/api/application";
import {
City,
Country,
@ -16,7 +13,7 @@ import {
Province,
Street,
TINNumber,
TaxCode,
type TaxCode,
TextValue,
URLAddress,
UniqueID,
@ -26,14 +23,11 @@ import {
maybeFromNullableResult,
maybeToNullable,
} from "@repo/rdx-ddd";
import { Collection, Result, isNullishOrEmpty } from "@repo/rdx-utils";
import { Collection, Result } from "@repo/rdx-utils";
import { Customer, type CustomerProps, CustomerStatus } from "../../../domain";
import { Customer, CustomerStatus, type ICustomerProps } from "../../../domain";
import type { CustomerCreationAttributes, CustomerModel } from "../../sequelize";
export interface ICustomerDomainMapper
extends ISequelizeDomainMapper<CustomerModel, CustomerCreationAttributes, Customer> {}
export class CustomerDomainMapper
extends SequelizeDomainMapper<CustomerModel, CustomerCreationAttributes, Customer>
implements ICustomerDomainMapper
@ -175,14 +169,14 @@ export class CustomerDomainMapper
// source.default_taxes is stored as a comma-separated string
const defaultTaxes = new Collection<TaxCode>();
if (!isNullishOrEmpty(source.default_taxes)) {
/*if (!isNullishOrEmpty(source.default_taxes)) {
source.default_taxes!.split(",").map((taxCode, index) => {
const tax = extractOrPushError(TaxCode.create(taxCode), `default_taxes.${index}`, errors);
if (tax) {
defaultTaxes.add(tax!);
}
});
}
}*/
// Now, create the PostalAddress VO
const postalAddressProps = {
@ -205,7 +199,7 @@ export class CustomerDomainMapper
return Result.fail(new ValidationErrorCollection("Customer props mapping failed", errors));
}
const customerProps: CustomerProps = {
const customerProps: ICustomerProps = {
companyId: companyId!,
status: status!,
reference: reference!,

View File

@ -1,8 +1,4 @@
import {
type ISequelizeQueryMapper,
type MapperParamsType,
SequelizeQueryMapper,
} from "@erp/core/api";
import { type MapperParamsType, SequelizeQueryMapper } from "@erp/core/api";
import {
City,
Country,
@ -24,46 +20,20 @@ import {
extractOrPushError,
maybeFromNullableResult,
} from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
import { Result } from "@repo/rdx-utils";
import type { CustomerSummary, ICustomerSummaryMapper } from "../../../application";
import { CustomerStatus } from "../../../domain";
import type { CustomerModel } from "../../sequelize";
export type CustomerListDTO = {
id: UniqueID;
companyId: UniqueID;
status: CustomerStatus;
reference: Maybe<Name>;
isCompany: boolean;
name: Name;
tradeName: Maybe<Name>;
tin: Maybe<TINNumber>;
address: PostalAddress;
emailPrimary: Maybe<EmailAddress>;
emailSecondary: Maybe<EmailAddress>;
phonePrimary: Maybe<PhoneNumber>;
phoneSecondary: Maybe<PhoneNumber>;
mobilePrimary: Maybe<PhoneNumber>;
mobileSecondary: Maybe<PhoneNumber>;
fax: Maybe<PhoneNumber>;
website: Maybe<URLAddress>;
languageCode: LanguageCode;
currencyCode: CurrencyCode;
};
export interface ICustomerListMapper
extends ISequelizeQueryMapper<CustomerModel, CustomerListDTO> {}
export class CustomerListMapper
extends SequelizeQueryMapper<CustomerModel, CustomerListDTO>
implements ICustomerListMapper
export class CustomerSummaryMapper
extends SequelizeQueryMapper<CustomerModel, CustomerSummary>
implements ICustomerSummaryMapper
{
public mapToDTO(raw: CustomerModel, params?: MapperParamsType): Result<CustomerListDTO, Error> {
public mapToReadModel(
raw: CustomerModel,
params?: MapperParamsType
): Result<CustomerSummary, Error> {
const errors: ValidationErrorDetail[] = [];
// 1) Valores escalares (atributos generales)
@ -217,7 +187,7 @@ export class CustomerListMapper
);
}
return Result.ok<CustomerListDTO>({
return Result.ok<CustomerSummary>({
id: customerId!,
companyId: companyId!,
status: status!,

View File

@ -1 +1 @@
export * from "./customer.list.mapper";
export * from "./customer-summary.mapper";

View File

@ -7,16 +7,25 @@ import {
import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server";
import type { UniqueID } from "@repo/rdx-ddd";
import { type Collection, Result } from "@repo/rdx-utils";
import type { Transaction } from "sequelize";
import type { Sequelize, Transaction } from "sequelize";
import type { Customer, ICustomerRepository } from "../../../domain";
import type { CustomerListDTO, ICustomerDomainMapper, ICustomerListMapper } from "../../mappers";
import type { CustomerSummary, ICustomerRepository } from "../../../application";
import type { Customer } from "../../../domain";
import type { ICustomerDomainMapper, ICustomerListMapper } from "../../mappers";
import { CustomerModel } from "../models/customer.model";
export class CustomerRepository
extends SequelizeRepository<Customer>
implements ICustomerRepository
{
constructor(
private readonly domainMapper: ICustomerDomainMapper,
private readonly listMapper: ICustomerListMapper,
database: Sequelize
) {
super({ database });
}
/**
*
* Crea un nuevo cliente
@ -27,18 +36,15 @@ export class CustomerRepository
*/
async create(customer: Customer, transaction?: Transaction): Promise<Result<void, Error>> {
try {
const mapper: ICustomerDomainMapper = this._registry.getDomainMapper({
resource: "customer",
});
const dto = mapper.mapToPersistence(customer);
const dtoResult = this.domainMapper.mapToPersistence(customer);
if (dto.isFailure) {
return Result.fail(dto.error);
if (dtoResult.isFailure) {
return Result.fail(dtoResult.error);
}
const { data } = dto;
const dto = dtoResult.data;
await CustomerModel.create(data, {
await CustomerModel.create(dto, {
include: [{ all: true }],
transaction,
});
@ -58,12 +64,9 @@ export class CustomerRepository
*/
async update(customer: Customer, transaction?: Transaction): Promise<Result<void, Error>> {
try {
const mapper: ICustomerDomainMapper = this._registry.getDomainMapper({
resource: "customer",
});
const dto = mapper.mapToPersistence(customer);
const dtoResult = this.domainMapper.mapToPersistence(customer);
const { id, ...updatePayload } = dto.data;
const { id, ...updatePayload } = dtoResult.data;
const [affected] = await CustomerModel.update(updatePayload, {
where: { id /*, version */ },
//fields: Object.keys(updatePayload),
@ -146,7 +149,7 @@ export class CustomerRepository
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente.
* @param criteria - Criterios de búsqueda.
* @param transaction - Transacción activa para la operación.
* @returns Result<Collection<Customer>, Error>
* @returns Result<Collection<CustomerListDTO>, Error>
*
* @see Criteria
*/
@ -154,15 +157,10 @@ export class CustomerRepository
companyId: UniqueID,
criteria: Criteria,
transaction?: Transaction
): Promise<Result<Collection<CustomerListDTO>, Error>> {
): Promise<Result<Collection<CustomerSummary>, Error>> {
try {
const mapper: ICustomerListMapper = this._registry.getQueryMapper({
resource: "customer",
query: "LIST",
});
const converter = new CriteriaToSequelizeConverter();
const query = converter.convert(criteria, {
const criteriaConverter = new CriteriaToSequelizeConverter();
const query = criteriaConverter.convert(criteria, {
searchableFields: [
"name",
"trade_name",
@ -195,19 +193,7 @@ export class CustomerRepository
transaction,
});
/*const [rows, count] = await Promise.all([
CustomerModel.findAll({
...CustomerModel,
transaction,
}),
CustomerModel.count({
where: query.where,
distinct: true, // evita duplicados por LEFT JOIN
transaction,
}),
]);*/
return mapper.mapToDTOCollection(rows, count);
return this.listMapper.mapToDTOCollection(rows, count);
} catch (err: unknown) {
return Result.fail(translateSequelizeError(err));
}

View File

@ -1,8 +1,5 @@
import { LookupDialog } from "@repo/rdx-ui/components";
import DataTable, { TableColumn } from "react-data-table-component";
import { useDebounce } from "use-debounce";
import { buildTextFilters } from "@erp/core/client";
import { LookupDialog } from "@repo/rdx-ui/components";
import {
Badge,
Button,
@ -18,7 +15,10 @@ import {
} from "@repo/shadcn-ui/components";
import { Building, Calendar, Mail, MapPin, Phone, Plus, User } from "lucide-react";
import { useState } from "react";
import { ListCustomersResponseDTO } from "../../common";
import DataTable, { type TableColumn } from "react-data-table-component";
import { useDebounce } from "use-debounce";
import type { ListCustomersResponseDTO } from "../../common";
import { useCustomerListQuery } from "../hooks";
type Customer = ListCustomersResponseDTO["items"][number];
@ -143,96 +143,96 @@ export const ClientSelectorModal = () => {
};
return (
<div className='space-y-4 max-w-2xl'>
<div className='space-y-1'>
<div className="space-y-4 max-w-2xl">
<div className="space-y-1">
<Label>Cliente</Label>
<Button variant='outline' className='w-full justify-start' onClick={handleSelectClient}>
<User className='h-4 w-4 mr-2' />
<Button className="w-full justify-start" onClick={handleSelectClient} variant="outline">
<User className="h-4 w-4 mr-2" />
{selectedCustomer ? selectedCustomer.name : "Seleccionar cliente"}
</Button>
</div>
{selectedCustomer && (
<Card>
<CardContent className='p-4 space-y-2'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<User className='h-6 w-6 text-primary' />
<h3 className='font-semibold'>{selectedCustomer.name}</h3>
<CardContent className="p-4 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<User className="h-6 w-6 text-primary" />
<h3 className="font-semibold">{selectedCustomer.name}</h3>
<Badge
className="text-xs"
variant={selectedCustomer.status === "Activo" ? "default" : "secondary"}
className='text-xs'
>
{selectedCustomer.status}
</Badge>
</div>
<span className='text-sm text-muted-foreground'>{selectedCustomer.company}</span>
<span className="text-sm text-muted-foreground">{selectedCustomer.company}</span>
</div>
<p className='text-sm text-muted-foreground'>{selectedCustomer.email}</p>
<p className="text-sm text-muted-foreground">{selectedCustomer.email}</p>
</CardContent>
</Card>
)}
<LookupDialog
open={open}
onOpenChange={setOpen}
items={data?.items ?? []}
totalItems={data?.total_items ?? 0}
search={search}
onSearchChange={setSearch}
isLoading={isLoading}
description="Busca un cliente por nombre, email o empresa"
isError={isError}
refetch={refetch}
title='Seleccionar cliente'
description='Busca un cliente por nombre, email o empresa'
onSelect={(customer) => {
setSelectedCustomer(customer);
setOpen(false);
}}
isLoading={isLoading}
items={data?.items ?? []}
onCreate={(e) => {
e.preventDefault();
setOpen(true);
console.log("Crear nuevo cliente");
}}
onOpenChange={setOpen}
onPageChange={setPageNumber}
onSearchChange={setSearch}
onSelect={(customer) => {
setSelectedCustomer(customer);
setOpen(false);
}}
open={open}
page={pageNumber}
perPage={pageSize}
onPageChange={setPageNumber}
renderItem={() => null} // No se usa con DataTable
refetch={refetch}
renderContainer={(items) => (
<DataTable
columns={columns}
data={items}
highlightOnHover
noDataComponent="No se encontraron resultados"
onChangePage={(p) => setPageNumber(p)}
onRowClicked={(item) => {
setSelectedCustomer(item);
setOpen(false);
}}
pagination
paginationServer
paginationPerPage={pageSize}
paginationServer
paginationTotalRows={data?.total_items ?? 0}
onChangePage={(p) => setPageNumber(p)}
highlightOnHover
pointerOnHover
noDataComponent='No se encontraron resultados'
/>
)}
renderItem={() => null}
search={search}
title="Seleccionar cliente" // No se usa con DataTable
totalItems={data?.total_items ?? 0}
/>
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogContent className='max-w-md'>
<Dialog onOpenChange={setIsCreateOpen} open={isCreateOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<Plus className='size-5' />
<DialogTitle className="flex items-center gap-2">
<Plus className="size-5" />
Nuevo Cliente
</DialogTitle>
</DialogHeader>
<p className='text-muted-foreground text-sm mb-4'>Formulario de creación pendiente</p>
<p className="text-muted-foreground text-sm mb-4">Formulario de creación pendiente</p>
<DialogFooter>
<Button variant='outline' onClick={() => setIsCreateOpen(false)}>
<Button onClick={() => setIsCreateOpen(false)} variant="outline">
Cancelar
</Button>
<Button>
<Plus className='h-4 w-4 mr-2' />
<Plus className="h-4 w-4 mr-2" />
Crear
</Button>
</DialogFooter>
@ -246,33 +246,33 @@ export const ClientSelectorModal = () => {
const CustomerCard = ({ customer }: { customer: Customer }) => (
<Card>
<CardContent className='p-4 space-y-2'>
<div className='flex items-center gap-2'>
<User className='size-5' />
<span className='font-semibold'>{customer.name}</span>
<CardContent className="p-4 space-y-2">
<div className="flex items-center gap-2">
<User className="size-5" />
<span className="font-semibold">{customer.name}</span>
<Badge variant={customer.status === "Activo" ? "default" : "secondary"}>
{customer.status}
</Badge>
</div>
<div className='text-sm text-muted-foreground flex flex-col gap-1'>
<div className='flex items-center gap-1'>
<Mail className='h-4 w-4' />
<div className="text-sm text-muted-foreground flex flex-col gap-1">
<div className="flex items-center gap-1">
<Mail className="h-4 w-4" />
{customer.email}
</div>
<div className='flex items-center gap-1'>
<Building className='h-4 w-4' />
<div className="flex items-center gap-1">
<Building className="h-4 w-4" />
{customer.company}
</div>
<div className='flex items-center gap-1'>
<Phone className='h-4 w-4' />
<div className="flex items-center gap-1">
<Phone className="h-4 w-4" />
{customer.phone}
</div>
<div className='flex items-center gap-1'>
<MapPin className='h-4 w-4' />
<div className="flex items-center gap-1">
<MapPin className="h-4 w-4" />
{customer.address}
</div>
<div className='flex items-center gap-1'>
<Calendar className='h-4 w-4' />
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{new Date(customer.createdAt).toLocaleDateString("es-ES")}
</div>
</div>

View File

@ -1,19 +1,9 @@
import {
Button, Item,
ItemContent,
ItemFooter,
ItemTitle
} from "@repo/shadcn-ui/components";
import { cn } from '@repo/shadcn-ui/lib/utils';
import {
EyeIcon,
MapPinIcon,
RefreshCwIcon,
UserPlusIcon
} from "lucide-react";
import React, { useMemo } from 'react';
import { CustomerSummary } from "../../schemas";
import { Button, Item, ItemContent, ItemFooter, ItemTitle } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { EyeIcon, MapPinIcon, RefreshCwIcon, UserPlusIcon } from "lucide-react";
import React, { useMemo } from "react";
import type { CustomerSummary } from "../../schemas";
interface CustomerCardProps {
customer: CustomerSummary;
@ -57,42 +47,38 @@ export const CustomerCard = ({
const address = useMemo(() => buildAddress(customer), [customer]);
return (
<Item variant="outline" className={className}>
<Item className={className} variant="outline">
<ItemContent>
<ItemTitle className="flex items-start gap-2 w-full justify-between">
<span className="grow text-balance">{customer.name}</span>
{/* Eye solo si onViewCustomer existe */}
{onViewCustomer && (
<Button
type='button'
variant='ghost'
size='sm'
className='cursor-pointer'
aria-label="Ver ficha completa del cliente"
className="cursor-pointer"
onClick={onViewCustomer}
aria-label='Ver ficha completa del cliente'
size="sm"
type="button"
variant="ghost"
>
<EyeIcon className='size-4 text-muted-foreground' />
<EyeIcon className="size-4 text-muted-foreground" />
</Button>
)}
</ItemTitle>
<div
data-slot="item-description"
className={cn(
"text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
"text-sm text-muted-foreground"
)}>
{/* TIN en su propia línea si existe */}
{customer.tin && (
<div className="font-mono tabular-nums">{customer.tin}</div>
)}
data-slot="item-description"
>
{/* TIN en su propia línea si existe */}
{customer.tin && <div className="font-mono tabular-nums">{customer.tin}</div>}
{/* Dirección */}
{address.has ? (
<address
className="not-italic mt-1 text-pretty"
aria-label={address.full}
>
<address aria-label={address.full} className="not-italic mt-1 text-pretty">
{/* Desktop/tablet: compacto en una línea (o dos por wrap natural) */}
<div className="hidden sm:flex items-center gap-1 flex-wrap">
<MapPinIcon aria-hidden className="size-3.5 translate-y-[1px]" />
@ -101,7 +87,9 @@ export const CustomerCard = ({
<React.Fragment key={i}>
<span>{part}</span>
{i < address.stack.length - 1 && (
<span aria-hidden className="mx-1">·</span>
<span aria-hidden className="mx-1">
·
</span>
)}
</React.Fragment>
))}
@ -129,29 +117,29 @@ export const CustomerCard = ({
<ItemFooter className="flex-wrap gap-2">
{onChangeCustomer && (
<Button
type='button'
variant='outline'
size='sm'
className="flex-1 min-w-36 gap-2 cursor-pointer"
onClick={onChangeCustomer}
className='flex-1 min-w-36 gap-2 cursor-pointer'
size="sm"
type="button"
variant="outline"
>
<RefreshCwIcon className='size-4' />
<span className='text-sm text-muted-foreground'>Cambiar de cliente</span>
<RefreshCwIcon className="size-4" />
<span className="text-sm text-muted-foreground">Cambiar de cliente</span>
</Button>
)}
{onAddNewCustomer && (
<Button
type='button'
variant='outline'
size='sm'
className="flex-1 min-w-36 gap-2 cursor-pointer"
onClick={onAddNewCustomer}
className='flex-1 min-w-36 gap-2 cursor-pointer'
size="sm"
type="button"
variant="outline"
>
<UserPlusIcon className='size-4' />
<span className='text-sm text-muted-foreground'>Nuevo cliente</span>
<UserPlusIcon className="size-4" />
<span className="text-sm text-muted-foreground">Nuevo cliente</span>
</Button>
)}
</ItemFooter>
</Item>
);
};
};

View File

@ -1,4 +1,3 @@
import { UnsavedChangesProvider, useUnsavedChangesContext } from "@erp/core/hooks";
import {
Button,
@ -10,12 +9,12 @@ import {
DialogTitle,
} from "@repo/shadcn-ui/components";
import { Plus } from "lucide-react";
import { useCallback, useId } from 'react';
import { useTranslation } from "../../i18n";
import { useCustomerCreateController } from '../../pages/create/use-customer-create-controller';
import { CustomerFormData } from "../../schemas";
import { CustomerEditForm } from '../editor';
import { useCallback, useId } from "react";
import { useTranslation } from "../../i18n";
import { useCustomerCreateController } from "../../pages/create/use-customer-create-controller";
import type { CustomerFormData } from "../../schemas";
import { CustomerEditForm } from "../editor";
type CustomerCreateModalProps = {
open: boolean;
@ -25,62 +24,61 @@ type CustomerCreateModalProps = {
onSubmit: () => void; // ← mantenemos tu firma (no se usa directamente aquí)
};
export function CustomerCreateModal({
open,
onOpenChange,
}: CustomerCreateModalProps) {
export function CustomerCreateModal({ open, onOpenChange }: CustomerCreateModalProps) {
const { t } = useTranslation();
const formId = useId();
const { requestConfirm } = useUnsavedChangesContext();
const {
form, isCreating, isCreateError, createError,
handleSubmit, handleError, FormProvider
} = useCustomerCreateController();
const { form, isCreating, isCreateError, createError, handleSubmit, handleError, FormProvider } =
useCustomerCreateController();
const { isDirty } = form.formState;
const guardClose = useCallback(async (nextOpen: boolean) => {
if (nextOpen) return onOpenChange(true);
const guardClose = useCallback(
async (nextOpen: boolean) => {
if (nextOpen) return onOpenChange(true);
if (isCreating) return;
if (isCreating) return;
if (!isDirty) {
return onOpenChange(false);
}
if (!isDirty) {
return onOpenChange(false);
}
if (await requestConfirm()) {
return onOpenChange(false);
}
}, [requestConfirm, isCreating, onOpenChange, isDirty]);
if (await requestConfirm()) {
return onOpenChange(false);
}
},
[requestConfirm, isCreating, onOpenChange, isDirty]
);
const handleFormSubmit = (data: CustomerFormData) => handleSubmit(data /*, () => onOpenChange(false)*/);
const handleFormSubmit = (data: CustomerFormData) =>
handleSubmit(data /*, () => onOpenChange(false)*/);
return (
<UnsavedChangesProvider isDirty={isDirty}>
<Dialog open={open} onOpenChange={guardClose}>
<Dialog onOpenChange={guardClose} open={open}>
<DialogContent className="bg-card border-border p-0 max-w-[calc(100vw-2rem)] sm:[calc(max-w-3xl-2rem)] h-[calc(100dvh-2rem)]">
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<DialogTitle className="flex items-center gap-2">
<Plus className="size-5" /> {t("pages.create.title")}
</DialogTitle>
<DialogDescription className='text-left'>{t("pages.create.description")}</DialogDescription>
<DialogDescription className="text-left">
{t("pages.create.description")}
</DialogDescription>
</DialogHeader>
<div className="overflow-y-auto h:[calc(100%-8rem)]">
<FormProvider {...form}>
<CustomerEditForm
formId={formId}
onSubmit={handleFormSubmit}
onError={handleError}
className="max-w-none"
formId={formId}
onError={handleError}
onSubmit={handleFormSubmit}
/>
{isCreateError && (
<p role="alert" className="mt-3 text-sm text-destructive">
<p className="mt-3 text-sm text-destructive" role="alert">
{(createError as Error)?.message}
</p>
)}
@ -88,16 +86,26 @@ export function CustomerCreateModal({
</div>
<DialogFooter className="px-6 py-4 border-t bg-card">
<Button type="button" form={formId} variant="outline" className='cursor-pointer' onClick={() => guardClose(false)} disabled={isCreating}>
{t('common.cancel', "Cancelar")}
<Button
className="cursor-pointer"
disabled={isCreating}
form={formId}
onClick={() => guardClose(false)}
type="button"
variant="outline"
>
{t("common.cancel", "Cancelar")}
</Button>
<Button type="submit" form={formId} disabled={isCreating} className='cursor-pointer'>
{isCreating ? <span aria-live="polite">{t('common.saving', "Guardando")}</span> : <span>{t('common.save', "Guardar")}</span>}
<Button className="cursor-pointer" disabled={isCreating} form={formId} type="submit">
{isCreating ? (
<span aria-live="polite">{t("common.saving", "Guardando")}</span>
) : (
<span>{t("common.save", "Guardar")}</span>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</UnsavedChangesProvider>
);
}
}

View File

@ -1,8 +1,9 @@
import { Field, FieldLabel } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { type Control, Controller, type FieldPath, type FieldValues } from "react-hook-form";
import type { CustomerSummary } from "../../schemas";
import { cn } from '@repo/shadcn-ui/lib/utils';
import { Control, Controller, FieldPath, FieldValues } from "react-hook-form";
import { CustomerSummary } from '../../schemas';
import { CustomerModalSelector } from "./customer-modal-selector";
type CustomerModalSelectorFieldProps<TFormValues extends FieldValues> = {
@ -12,7 +13,7 @@ type CustomerModalSelectorFieldProps<TFormValues extends FieldValues> = {
label?: string;
description?: string;
orientation?: "vertical" | "horizontal" | "responsive",
orientation?: "vertical" | "horizontal" | "responsive";
disabled?: boolean;
required?: boolean;
@ -28,8 +29,7 @@ export function CustomerModalSelectorField<TFormValues extends FieldValues>({
label,
description,
orientation = 'vertical',
orientation = "vertical",
disabled = false,
required = false,
@ -49,25 +49,25 @@ export function CustomerModalSelectorField<TFormValues extends FieldValues>({
return (
<Field
className={cn("gap-1", className)}
data-invalid={fieldState.invalid}
orientation={orientation}
className={cn("gap-1", className)}
>
{label && (
<FieldLabel className='text-xs text-muted-foreground text-nowrap' htmlFor={name}>
<FieldLabel className="text-xs text-muted-foreground text-nowrap" htmlFor={name}>
{label}
</FieldLabel>
)}
<CustomerModalSelector
value={value}
onValueChange={onChange}
disabled={isDisabled}
readOnly={isReadOnly}
initialCustomer={initiaCustomer as CustomerSummary}
onValueChange={onChange}
readOnly={isReadOnly}
value={value}
/>
</Field>
);
}}
/>
);
}
}

View File

@ -1,11 +1,17 @@
import { useEffect, useId, useMemo, useState } from "react";
import { useCustomerListQuery } from "../../hooks";
import { CustomerFormData, CustomerSummary, defaultCustomerFormData } from "../../schemas";
import {
type CustomerFormData,
type CustomerSummary,
defaultCustomerFormData,
} from "../../schemas";
import { CustomerCard } from "./customer-card";
import { CustomerCreateModal } from './customer-create-modal';
import { CustomerCreateModal } from "./customer-create-modal";
import { CustomerEmptyCard } from "./customer-empty-card";
import { CustomerSearchDialog } from "./customer-search-dialog";
import { CustomerViewDialog } from './customer-view-dialog';
import { CustomerViewDialog } from "./customer-view-dialog";
// Debounce pequeño y tipado
function useDebouncedValue<T>(value: T, delay = 300) {
@ -17,7 +23,6 @@ function useDebouncedValue<T>(value: T, delay = 300) {
return debounced;
}
type CustomerModalSelectorProps = {
value?: string;
onValueChange?: (id: string) => void;
@ -25,7 +30,7 @@ type CustomerModalSelectorProps = {
readOnly?: boolean; // Ver ficha, pero no cambiar/crear
initialCustomer?: CustomerSummary;
className?: string;
}
};
export const CustomerModalSelector = ({
value,
@ -35,7 +40,6 @@ export const CustomerModalSelector = ({
initialCustomer,
className,
}: CustomerModalSelectorProps) => {
const dialogId = useId();
const [showSearch, setShowSearch] = useState(false);
@ -68,32 +72,28 @@ export const CustomerModalSelector = ({
isLoading,
isError,
error,
} = useCustomerListQuery(
{
enabled: showSearch, // <- evita llamadas innecesarias
criteria
}
);
} = useCustomerListQuery({
enabled: showSearch, // <- evita llamadas innecesarias
criteria,
});
// Combinar locales optimistas + remotos
const customers: CustomerSummary[] = useMemo(() => {
const remoteCustomers = remoteCustomersPage ? remoteCustomersPage.items : []
const remoteCustomers = remoteCustomersPage ? remoteCustomersPage.items : [];
const byId = new Map<string, CustomerSummary>();
[...localCreated, ...remoteCustomers].forEach((c) => byId.set(c.id, c as CustomerSummary));
return Array.from(byId.values());
}, [localCreated, remoteCustomersPage]);
// Sync con value e initialCustomer
useEffect(() => {
const found = customers.find((c) => c.id === value) ?? initialCustomer ?? null;
setSelected(found ?? null);
}, [value, customers, initialCustomer]);
// Crear cliente (optimista) mapeando desde CustomerDraft -> CustomerSummary
const handleCreate = () => {
if (!newClient.name || !newClient.email_primary) return;
if (!(newClient.name && newClient.email_primary)) return;
const newCustomer: CustomerSummary = defaultCustomerFormData as CustomerSummary;
@ -104,8 +104,8 @@ export const CustomerModalSelector = ({
};
// Handlers de tarjeta según modo
const canChange = !disabled && !readOnly;
const canCreate = !disabled && !readOnly;
const canChange = !(disabled || readOnly);
const canCreate = !(disabled || readOnly);
const canView = !!selected && !disabled;
return (
@ -115,63 +115,65 @@ export const CustomerModalSelector = ({
<CustomerCard
className={className}
customer={selected}
onViewCustomer={canView ? () => setShowView(true) : undefined}
onChangeCustomer={canChange ? () => setShowSearch(true) : undefined}
onAddNewCustomer={canCreate ? () => setShowNewForm(true) : undefined}
onChangeCustomer={canChange ? () => setShowSearch(true) : undefined}
onViewCustomer={canView ? () => setShowView(true) : undefined}
/>
) : (
<CustomerEmptyCard
className={className}
onClick={!disabled && !readOnly ? () => setShowSearch(true) : undefined}
onKeyDown={
!disabled && !readOnly
? (e) => {
if (e.key === "Enter" || e.key === " ") setShowSearch(true);
}
: undefined
}
aria-haspopup="dialog"
aria-controls={dialogId}
aria-disabled={disabled || readOnly}
aria-haspopup="dialog"
className={className}
onClick={disabled || readOnly ? undefined : () => setShowSearch(true)}
onKeyDown={
disabled || readOnly
? undefined
: (e) => {
if (e.key === "Enter" || e.key === " ") setShowSearch(true);
}
}
/>
)}
</div>
<CustomerSearchDialog
open={showSearch}
onOpenChange={setShowSearch}
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
customers={customers}
selectedClient={selected}
errorMessage={
isError ? ((error as Error)?.message ?? "Error al cargar clientes") : undefined
}
isError={isError}
isLoading={isLoading}
onCreateClient={(name) => {
setNewClient((prev) => ({ ...prev, name: name ?? "" }));
setShowNewForm(true);
}}
onOpenChange={setShowSearch}
onSearchQueryChange={setSearchQuery}
onSelectClient={(c) => {
setSelected(c);
onValueChange?.(c.id);
setShowSearch(false);
}}
onCreateClient={(name) => {
setNewClient((prev) => ({ ...prev, name: name ?? "" }));
setShowNewForm(true);
}}
isLoading={isLoading}
isError={isError}
errorMessage={isError ? ((error as Error)?.message ?? "Error al cargar clientes") : undefined}
open={showSearch}
searchQuery={searchQuery}
selectedClient={selected}
/>
<CustomerViewDialog
customerId={selected?.id ?? null}
open={showView}
onOpenChange={setShowView}
open={showView}
/>
{/* Diálogo de alta rápida */}
<CustomerCreateModal
open={showNewForm}
onOpenChange={setShowNewForm}
client={newClient}
onChange={setNewClient}
onOpenChange={setShowNewForm}
onSubmit={handleCreate}
open={showNewForm}
/>
</>
);
};
};

View File

@ -24,7 +24,8 @@ import {
UserIcon,
UserPlusIcon,
} from "lucide-react";
import { CustomerSummary } from "../../schemas";
import type { CustomerSummary } from "../../schemas";
interface CustomerSearchDialogProps {
open: boolean;
@ -53,45 +54,45 @@ export const CustomerSearchDialog = ({
isError,
errorMessage,
}: CustomerSearchDialogProps) => {
const isEmpty = !isLoading && !isError && customers && customers.length === 0;
const isEmpty = !(isLoading || isError) && customers && customers.length === 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-2xl bg-card border-border p-0'>
<DialogHeader className='px-6 pt-6 pb-4'>
<DialogTitle className='flex items-center justify-between'>
<span className='flex items-center gap-2'>
<User className='size-5' />
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="sm:max-w-2xl bg-card border-border p-0">
<DialogHeader className="px-6 pt-6 pb-4">
<DialogTitle className="flex items-center justify-between">
<span className="flex items-center gap-2">
<User className="size-5" />
Seleccionar Cliente
</span>
</DialogTitle>
<DialogDescription>Busca un cliente existente o crea uno nuevo.</DialogDescription>
</DialogHeader>
<div className='px-6 pb-3'>
<Command className='border rounded-lg' shouldFilter={false}>
<div className="px-6 pb-3">
<Command className="border rounded-lg" shouldFilter={false}>
<CommandInput
autoFocus
onValueChange={onSearchQueryChange}
placeholder="Buscar cliente..."
value={searchQuery}
onValueChange={onSearchQueryChange}
/>
<CommandList className='max-h-[600px]'>
<CommandList className="max-h-[600px]">
<CommandEmpty>
<div className='flex flex-col items-center gap-2 py-6 text-sm'>
<User className='size-8 text-muted-foreground/50' />
<div className="flex flex-col items-center gap-2 py-6 text-sm">
<User className="size-8 text-muted-foreground/50" />
{isLoading && <p>Cargando</p>}
{isError && <p className='text-destructive'>{errorMessage}</p>}
{!isLoading && !isError && (
{isError && <p className="text-destructive">{errorMessage}</p>}
{!(isLoading || isError) && (
<>
<p>No se encontraron clientes</p>
{searchQuery && (
<Button
className="cursor-pointer"
onClick={() => onCreateClient(searchQuery)}
className='cursor-pointer'
>
<UserPlusIcon className='mr-2 size-4' />
<UserPlusIcon className="mr-2 size-4" />
Crear cliente "{searchQuery}"
</Button>
)}
@ -104,38 +105,38 @@ export const CustomerSearchDialog = ({
{customers.map((customer) => {
return (
<CommandItem
className="flex items-center gap-x-4 py-5 cursor-pointer"
key={customer.id}
value={customer.id}
onSelect={() => onSelectClient(customer)}
className='flex items-center gap-x-4 py-5 cursor-pointer'
value={customer.id}
>
<div className='flex size-12 items-center justify-center rounded-full bg-primary/10'>
<UserIcon className='size-8 stroke-1 text-primary' />
<div className="flex size-12 items-center justify-center rounded-full bg-primary/10">
<UserIcon className="size-8 stroke-1 text-primary" />
</div>
<div className='flex-1 space-y-1 min-w-0'>
<div className='flex items-center gap-2'>
<span className='text-sm font-semibold'>{customer.name}</span>
<div className="flex-1 space-y-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold">{customer.name}</span>
{customer.trade_name && (
<Badge variant='secondary' className='text-sm'>
<Badge className="text-sm" variant="secondary">
{customer.trade_name}
</Badge>
)}
</div>
<div className='flex items-center gap-6 text-sm font-medium text-muted-foreground'>
<div className="flex items-center gap-6 text-sm font-medium text-muted-foreground">
{customer.tin && (
<div className='flex items-center gap-2 text-sm text-muted-foreground'>
<CreditCardIcon className='h-4 w-4 shrink-0' />
<span className='font-medium'>{customer.tin}</span>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CreditCardIcon className="h-4 w-4 shrink-0" />
<span className="font-medium">{customer.tin}</span>
</div>
)}
{customer.email_primary && (
<span className='flex items-center gap-1'>
<MailIcon className='size-4' /> {customer.email_primary}
<span className="flex items-center gap-1">
<MailIcon className="size-4" /> {customer.email_primary}
</span>
)}
{customer.mobile_primary && (
<span className='flex items-center gap-1'>
<SmartphoneIcon className='size-4' /> {customer.mobile_primary}
<span className="flex items-center gap-1">
<SmartphoneIcon className="size-4" /> {customer.mobile_primary}
</span>
)}
</div>
@ -153,12 +154,12 @@ export const CustomerSearchDialog = ({
</CommandList>
</Command>
</div>
<DialogFooter className='sm:justify-center px-6 pb-6'>
<DialogFooter className="sm:justify-center px-6 pb-6">
<Button
className="cursor-pointer text-center"
onClick={() => onCreateClient(searchQuery)}
className='cursor-pointer text-center'
>
<UserPlusIcon className='mr-2 size-4' />
<UserPlusIcon className="mr-2 size-4" />
Añadir nuevo cliente
</Button>
</DialogFooter>

View File

@ -1,209 +1,224 @@
import {
Badge, Button, Card, CardContent, CardHeader, CardTitle,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
Badge,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@repo/shadcn-ui/components";
import { Banknote, FileText, Languages, Mail, MapPin, Phone, Smartphone, X } from "lucide-react";
// CustomerViewDialog.tsx
import { useCustomerQuery } from "../../hooks";
type CustomerViewDialogProps = {
customerId: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const CustomerViewDialog = ({
customerId,
open,
onOpenChange,
}: CustomerViewDialogProps) => {
const {
data: customer,
isLoading,
isError,
error,
} = useCustomerQuery(customerId ?? "", { enabled: open && !!customerId });
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-3xl bg-card border-border p-0">
<DialogHeader className="px-6 pt-6">
<DialogTitle id="customer-view-title" className="flex items-center justify-between">
<span className="text-balance">
{customer?.name ?? "Cliente"}
{customer?.trade_name && (
<span className="ml-2 text-muted-foreground">({customer.trade_name})</span>
)}
</span>
<Button
type="button"
size="icon"
variant="ghost"
className="cursor-pointer"
onClick={() => onOpenChange(false)}
aria-label="Cerrar"
>
<X className="size-4" />
</Button>
</DialogTitle>
<DialogDescription className="px-0">
{customer?.tin ? (
<Badge variant="secondary" className="ml-0 font-mono">{customer.tin}</Badge>
) : (
<span className="text-muted-foreground">Ficha del cliente</span>
)}
</DialogDescription>
</DialogHeader>
<div className="px-6 pb-6">
{isLoading && <p role="status" aria-live="polite">Cargando</p>}
{isError && (
<p role="alert" className="text-destructive">
{(error as Error)?.message ?? "No se pudo cargar el cliente"}
</p>
)}
{!isLoading && !isError && customer && (
<div className="grid gap-6 md:grid-cols-2 max-h-[70vh] overflow-y-auto pr-1">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<FileText className="size-5 text-primary" />
Información Básica
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<dt className="text-sm text-muted-foreground">Nombre</dt>
<dd className="mt-1">{customer.name}</dd>
</div>
{customer.reference && (
<div>
<dt className="text-sm text-muted-foreground">Referencia</dt>
<dd className="mt-1 font-mono">{customer.reference}</dd>
</div>
)}
{customer.legal_record && (
<div>
<dt className="text-sm text-muted-foreground">Registro Legal</dt>
<dd className="mt-1">{customer.legal_record}</dd>
</div>
)}
{!!customer.default_taxes?.length && (
<div>
<dt className="text-sm text-muted-foreground">Impuestos por defecto</dt>
<dd className="mt-1 flex flex-wrap gap-1">
{customer.default_taxes.map((tax: string) => (
<Badge key={tax} variant="secondary">{tax}</Badge>
))}
</dd>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<MapPin className="size-5 text-primary" />
Dirección
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<dt className="text-sm text-muted-foreground">Calle</dt>
<dd className="mt-1">
{customer.street}
{customer.street2 && (<><br />{customer.street2}</>)}
</dd>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm text-muted-foreground">Ciudad</dt>
<dd className="mt-1">{customer.city}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Código Postal</dt>
<dd className="mt-1">{customer.postal_code}</dd>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm text-muted-foreground">Provincia</dt>
<dd className="mt-1">{customer.province}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">País</dt>
<dd className="mt-1">{customer.country}</dd>
</div>
</div>
</CardContent>
</Card>
<Card className="md:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Mail className="size-5 text-primary" />
Contacto y Preferencias
</CardTitle>
</CardHeader>
<CardContent className="grid gap-6 md:grid-cols-2">
<div className="space-y-3">
{customer.email_primary && (
<div className="flex items-start gap-3">
<Mail className="mt-0.5 size-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm text-muted-foreground">Email</dt>
<dd className="mt-1">{customer.email_primary}</dd>
</div>
</div>
)}
{customer.mobile_primary && (
<div className="flex items-start gap-3">
<Smartphone className="mt-0.5 size-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm text-muted-foreground">Móvil</dt>
<dd className="mt-1">{customer.mobile_primary}</dd>
</div>
</div>
)}
{customer.phone_primary && (
<div className="flex items-start gap-3">
<Phone className="mt-0.5 size-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm text-muted-foreground">Teléfono</dt>
<dd className="mt-1">{customer.phone_primary}</dd>
</div>
</div>
)}
</div>
<div className="space-y-3">
<div className="flex items-start gap-3">
<Languages className="mt-0.5 size-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm text-muted-foreground">Idioma</dt>
<dd className="mt-1">{customer.language_code}</dd>
</div>
</div>
<div className="flex items-start gap-3">
<Banknote className="mt-0.5 size-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm text-muted-foreground">Moneda</dt>
<dd className="mt-1">{customer.currency_code}</dd>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
customerId: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
};
export const CustomerViewDialog = ({ customerId, open, onOpenChange }: CustomerViewDialogProps) => {
const {
data: customer,
isLoading,
isError,
error,
} = useCustomerQuery(customerId ?? "", { enabled: open && !!customerId });
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="sm:max-w-3xl bg-card border-border p-0">
<DialogHeader className="px-6 pt-6">
<DialogTitle className="flex items-center justify-between" id="customer-view-title">
<span className="text-balance">
{customer?.name ?? "Cliente"}
{customer?.trade_name && (
<span className="ml-2 text-muted-foreground">({customer.trade_name})</span>
)}
</span>
<Button
aria-label="Cerrar"
className="cursor-pointer"
onClick={() => onOpenChange(false)}
size="icon"
type="button"
variant="ghost"
>
<X className="size-4" />
</Button>
</DialogTitle>
<DialogDescription className="px-0">
{customer?.tin ? (
<Badge className="ml-0 font-mono" variant="secondary">
{customer.tin}
</Badge>
) : (
<span className="text-muted-foreground">Ficha del cliente</span>
)}
</DialogDescription>
</DialogHeader>
<div className="px-6 pb-6">
{isLoading && (
<p aria-live="polite" role="status">
Cargando
</p>
)}
{isError && (
<p className="text-destructive" role="alert">
{(error as Error)?.message ?? "No se pudo cargar el cliente"}
</p>
)}
{!(isLoading || isError) && customer && (
<div className="grid gap-6 md:grid-cols-2 max-h-[70vh] overflow-y-auto pr-1">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<FileText className="size-5 text-primary" />
Información Básica
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<dt className="text-sm text-muted-foreground">Nombre</dt>
<dd className="mt-1">{customer.name}</dd>
</div>
{customer.reference && (
<div>
<dt className="text-sm text-muted-foreground">Referencia</dt>
<dd className="mt-1 font-mono">{customer.reference}</dd>
</div>
)}
{customer.legal_record && (
<div>
<dt className="text-sm text-muted-foreground">Registro Legal</dt>
<dd className="mt-1">{customer.legal_record}</dd>
</div>
)}
{!!customer.default_taxes?.length && (
<div>
<dt className="text-sm text-muted-foreground">Impuestos por defecto</dt>
<dd className="mt-1 flex flex-wrap gap-1">
{customer.default_taxes.map((tax: string) => (
<Badge key={tax} variant="secondary">
{tax}
</Badge>
))}
</dd>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<MapPin className="size-5 text-primary" />
Dirección
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<dt className="text-sm text-muted-foreground">Calle</dt>
<dd className="mt-1">
{customer.street}
{customer.street2 && (
<>
<br />
{customer.street2}
</>
)}
</dd>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm text-muted-foreground">Ciudad</dt>
<dd className="mt-1">{customer.city}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Código Postal</dt>
<dd className="mt-1">{customer.postal_code}</dd>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm text-muted-foreground">Provincia</dt>
<dd className="mt-1">{customer.province}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">País</dt>
<dd className="mt-1">{customer.country}</dd>
</div>
</div>
</CardContent>
</Card>
<Card className="md:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Mail className="size-5 text-primary" />
Contacto y Preferencias
</CardTitle>
</CardHeader>
<CardContent className="grid gap-6 md:grid-cols-2">
<div className="space-y-3">
{customer.email_primary && (
<div className="flex items-start gap-3">
<Mail className="mt-0.5 size-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm text-muted-foreground">Email</dt>
<dd className="mt-1">{customer.email_primary}</dd>
</div>
</div>
)}
{customer.mobile_primary && (
<div className="flex items-start gap-3">
<Smartphone className="mt-0.5 size-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm text-muted-foreground">Móvil</dt>
<dd className="mt-1">{customer.mobile_primary}</dd>
</div>
</div>
)}
{customer.phone_primary && (
<div className="flex items-start gap-3">
<Phone className="mt-0.5 size-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm text-muted-foreground">Teléfono</dt>
<dd className="mt-1">{customer.phone_primary}</dd>
</div>
</div>
)}
</div>
<div className="space-y-3">
<div className="flex items-start gap-3">
<Languages className="mt-0.5 size-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm text-muted-foreground">Idioma</dt>
<dd className="mt-1">{customer.language_code}</dd>
</div>
</div>
<div className="flex items-start gap-3">
<Banknote className="mt-0.5 size-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm text-muted-foreground">Moneda</dt>
<dd className="mt-1">{customer.currency_code}</dd>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -1,7 +0,0 @@
import type { PropsWithChildren } from "react";
import { CustomersProvider } from "../context";
export const CustomersLayout = ({ children }: PropsWithChildren) => {
return <CustomersProvider>{children}</CustomersProvider>;
};

View File

@ -1,18 +1,24 @@
import { Field, FieldDescription, FieldGroup, FieldLegend, FieldSet } from '@repo/shadcn-ui/components';
import { SelectField } from "@repo/rdx-ui/components";
import {
Field,
FieldDescription,
FieldGroup,
FieldLegend,
FieldSet,
} from "@repo/shadcn-ui/components";
import { useFormContext } from "react-hook-form";
import { CURRENCY_OPTIONS, LANGUAGE_OPTIONS } from "../../constants";
import { useTranslation } from "../../i18n";
import { CustomerFormData } from "../../schemas";
import type { CustomerFormData } from "../../schemas";
interface CustomerAdditionalConfigFieldsProps {
className?: string;
}
export const CustomerAdditionalConfigFields = ({
className, ...props
className,
...props
}: CustomerAdditionalConfigFieldsProps) => {
const { t } = useTranslation();
const { control } = useFormContext<CustomerFormData>();
@ -21,28 +27,28 @@ export const CustomerAdditionalConfigFields = ({
<FieldSet className={className} {...props}>
<FieldLegend>{t("form_groups.preferences.title")}</FieldLegend>
<FieldDescription>{t("form_groups.preferences.description")}</FieldDescription>
<FieldGroup className='grid grid-cols-1 gap-x-6 lg:grid-cols-4'>
<Field className='lg:col-span-2'>
<FieldGroup className="grid grid-cols-1 gap-x-6 lg:grid-cols-4">
<Field className="lg:col-span-2">
<SelectField
control={control}
name='language_code'
required
label={t("form_fields.language_code.label")}
placeholder={t("form_fields.language_code.placeholder")}
description={t("form_fields.language_code.description")}
items={[...LANGUAGE_OPTIONS]}
label={t("form_fields.language_code.label")}
name="language_code"
placeholder={t("form_fields.language_code.placeholder")}
required
/>
</Field>
<Field className='lg:col-span-2'>
<Field className="lg:col-span-2">
<SelectField
className='lg:col-span-2'
className="lg:col-span-2"
control={control}
name='currency_code'
required
label={t("form_fields.currency_code.label")}
placeholder={t("form_fields.currency_code.placeholder")}
description={t("form_fields.currency_code.description")}
items={[...CURRENCY_OPTIONS]}
label={t("form_fields.currency_code.label")}
name="currency_code"
placeholder={t("form_fields.currency_code.placeholder")}
required
/>
</Field>
</FieldGroup>

View File

@ -1,12 +1,16 @@
import { SelectField, TextField } from "@repo/rdx-ui/components";
import {
SelectField,
TextField
} from "@repo/rdx-ui/components";
import { Field, FieldDescription, FieldGroup, FieldLegend, FieldSet } from '@repo/shadcn-ui/components';
Field,
FieldDescription,
FieldGroup,
FieldLegend,
FieldSet,
} from "@repo/shadcn-ui/components";
import { useFormContext } from "react-hook-form";
import { COUNTRY_OPTIONS } from "../../constants";
import { useTranslation } from "../../i18n";
import { CustomerFormData } from "../../schemas";
import type { CustomerFormData } from "../../schemas";
interface CustomerAddressFieldsProps {
className?: string;
@ -20,58 +24,58 @@ export const CustomerAddressFields = ({ className, ...props }: CustomerAddressFi
<FieldSet className={className} {...props}>
<FieldLegend>{t("form_groups.address.title")}</FieldLegend>
<FieldDescription>{t("form_groups.address.description")}</FieldDescription>
<FieldGroup className='grid grid-cols-1 gap-x-6 lg:grid-cols-4'>
<FieldGroup className="grid grid-cols-1 gap-x-6 lg:grid-cols-4">
<TextField
className='lg:col-span-2'
className="lg:col-span-2"
control={control}
name='street'
label={t("form_fields.street.label")}
placeholder={t("form_fields.street.placeholder")}
description={t("form_fields.street.description")}
label={t("form_fields.street.label")}
name="street"
placeholder={t("form_fields.street.placeholder")}
/>
<TextField
className='lg:col-span-2'
className="lg:col-span-2"
control={control}
name='street2'
label={t("form_fields.street2.label")}
placeholder={t("form_fields.street2.placeholder")}
description={t("form_fields.street2.description")}
label={t("form_fields.street2.label")}
name="street2"
placeholder={t("form_fields.street2.placeholder")}
/>
<TextField
className='lg:col-span-2'
className="lg:col-span-2"
control={control}
name='city'
label={t("form_fields.city.label")}
placeholder={t("form_fields.city.placeholder")}
description={t("form_fields.city.description")}
label={t("form_fields.city.label")}
name="city"
placeholder={t("form_fields.city.placeholder")}
/>
<TextField
control={control}
name='postal_code'
label={t("form_fields.postal_code.label")}
placeholder={t("form_fields.postal_code.placeholder")}
description={t("form_fields.postal_code.description")}
label={t("form_fields.postal_code.label")}
name="postal_code"
placeholder={t("form_fields.postal_code.placeholder")}
/>
<Field className='lg:col-span-2 lg:col-start-1'>
<Field className="lg:col-span-2 lg:col-start-1">
<TextField
control={control}
name='province'
label={t("form_fields.province.label")}
placeholder={t("form_fields.province.placeholder")}
description={t("form_fields.province.description")}
label={t("form_fields.province.label")}
name="province"
placeholder={t("form_fields.province.placeholder")}
/>
</Field>
<Field className='lg:col-span-2'>
<Field className="lg:col-span-2">
<SelectField
control={control}
name='country'
required
label={t("form_fields.country.label")}
placeholder={t("form_fields.country.placeholder")}
description={t("form_fields.country.description")}
items={[...COUNTRY_OPTIONS]}
label={t("form_fields.country.label")}
name="country"
placeholder={t("form_fields.country.placeholder")}
required
/>
</Field>
</FieldGroup>

View File

@ -1,6 +1,7 @@
// components/CustomerSkeleton.tsx
import { AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { useTranslation } from "../../i18n";
export const CustomerEditorSkeleton = () => {
@ -8,24 +9,24 @@ export const CustomerEditorSkeleton = () => {
return (
<>
<AppContent>
<div className='flex items-center justify-between'>
<div className='space-y-2' aria-hidden='true'>
<div className='h-7 w-64 rounded-md bg-muted animate-pulse' />
<div className='h-5 w-96 rounded-md bg-muted animate-pulse' />
<div className="flex items-center justify-between">
<div aria-hidden="true" className="space-y-2">
<div className="h-7 w-64 rounded-md bg-muted animate-pulse" />
<div className="h-5 w-96 rounded-md bg-muted animate-pulse" />
</div>
<div className='flex items-center gap-2'>
<div className="flex items-center gap-2">
<BackHistoryButton />
<Button disabled aria-busy>
<Button aria-busy disabled>
{t("pages.update.submit")}
</Button>
</div>
</div>
<div className='mt-6 grid gap-4' aria-hidden='true'>
<div className='h-10 w-full rounded-md bg-muted animate-pulse' />
<div className='h-10 w-full rounded-md bg-muted animate-pulse' />
<div className='h-28 w-full rounded-md bg-muted animate-pulse' />
<div aria-hidden="true" className="mt-6 grid gap-4">
<div className="h-10 w-full rounded-md bg-muted animate-pulse" />
<div className="h-10 w-full rounded-md bg-muted animate-pulse" />
<div className="h-28 w-full rounded-md bg-muted animate-pulse" />
</div>
<span className='sr-only'>{t("pages.update.loading", "Cargando cliente...")}</span>
<span className="sr-only">{t("pages.update.loading", "Cargando cliente...")}</span>
</AppContent>
</>
);

View File

@ -1,7 +1,5 @@
export * from "./client-selector-modal";
export * from "./customer-modal-selector";
export * from "./customer-status-badge";
export * from "./customers-layout";
export * from "./editor";
export * from "./error-alert";
export * from "./not-found-card";

View File

@ -3,31 +3,29 @@ import { lazy } from "react";
import { Outlet, type RouteObject } from "react-router-dom";
// Lazy load components
const CustomersLayout = lazy(() =>
import("./components").then((m) => ({ default: m.CustomersLayout }))
);
const CustomerLayout = lazy(() => import("./ui").then((m) => ({ default: m.CustomerLayout })));
const CustomersList = lazy(() => import("./list").then((m) => ({ default: m.CustomerListPage })));
const CustomersList = lazy(() => import("./pages").then((m) => ({ default: m.CustomersListPage })));
const CustomerView = lazy(() => import("./pages").then((m) => ({ default: m.CustomerViewPage })));
const CustomerAdd = lazy(() => import("./pages").then((m) => ({ default: m.CustomerCreatePage })));
const CustomerUpdate = lazy(() =>
//const CustomerView = lazy(() => import("./pages").then((m) => ({ default: m.CustomerViewPage })));
//const CustomerAdd = lazy(() => import("./pages").then((m) => ({ default: m.CustomerCreatePage })));
/*const CustomerUpdate = lazy(() =>
import("./pages").then((m) => ({ default: m.CustomerUpdatePage }))
);
);*/
export const CustomerRoutes = (params: ModuleClientParams): RouteObject[] => {
return [
{
path: "customers",
element: (
<CustomersLayout>
<CustomerLayout>
<Outlet context={params} />
</CustomersLayout>
</CustomerLayout>
),
children: [
{ path: "", index: true, element: <CustomersList /> }, // index
{ path: "list", element: <CustomersList /> },
//{ path: "create", element: <CustomerAdd /> },
{ path: ":id", element: <CustomerView /> },
//{ path: ":id", element: <CustomerView /> },
//{ path: ":id/edit", element: <CustomerUpdate /> },
//

View File

@ -1,5 +1,4 @@
export * from "./use-create-customer-mutation";
export * from "./use-customer-list-query";
export * from "./use-customer-query";
export * from "./use-customers-context";
export * from "./use-update-customer-mutation";

View File

@ -0,0 +1,12 @@
import type { CustomerSummaryPage, CustomerSummaryPageData } from "../../types";
/**
* Convierte el DTO completo de API a datos numéricos para el formulario.
*/
export const CustomerSummaryDtoAdapter = {
fromDto(pageDto: CustomerSummaryPage, context?: unknown): CustomerSummaryPageData {
return {
...pageDto,
};
},
};

View File

@ -0,0 +1 @@
export * from "./customer-summary-dto.adapter";

View File

@ -0,0 +1,18 @@
import type { CriteriaDTO } from "@erp/core";
import type { IDataSource } from "@erp/core/client";
import type { CustomerSummaryPage } from "../../types";
export async function getCustomerListApi(
dataSource: IDataSource,
signal: AbortSignal,
criteria: CriteriaDTO
) {
const response = dataSource.getList<CustomerSummaryPage>("customers", {
signal,
...criteria,
});
//return mapIssuedInvoiceList(raw);
return response;
}

View File

@ -0,0 +1 @@
export * from "./get-customer-list.api";

View File

@ -0,0 +1 @@
export * from "./use-customer-list-page.controller";

View File

@ -0,0 +1,9 @@
import { useCustomerListController } from "./use-customer-list.controller";
export function useCustomerListPageController() {
const listCtrl = useCustomerListController();
return {
listCtrl,
};
}

Some files were not shown because too many files have changed in this diff Show More