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 { 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 { ModuleRoutes } from "@/components/module-routes";
import { ErrorPage, LoginForm } from "../pages"; import { ErrorPage, LoginForm } from "../pages";
import { modules } from "../register-modules"; // Aquí ca import { modules } from "../register-modules"; // Aquí ca
@ -33,24 +35,24 @@ export const getAppRouter = () => {
return createBrowserRouter( return createBrowserRouter(
createRoutesFromElements( createRoutesFromElements(
<Route path='/'> <Route path="/">
{/* Auth Layout */} {/* Auth Layout */}
<Route path='/auth'> <Route path="/auth">
<Route index element={<Navigate to='login' />} /> <Route element={<Navigate to="login" />} index />
<Route path='login' element={<LoginForm />} /> <Route element={<LoginForm />} path="login" />
<Route path='*' element={<ModuleRoutes modules={grouped.auth} params={params} />} /> <Route element={<ModuleRoutes modules={grouped.auth} params={params} />} path="*" />
</Route> </Route>
{/* App Layout */} {/* App Layout */}
<Route element={<AppLayout />}> <Route element={<AppLayout />}>
{/* Dynamic Module Routes */} {/* Dynamic Module Routes */}
<Route path='*' element={<ModuleRoutes modules={grouped.app} params={params} />} /> <Route element={<ModuleRoutes modules={grouped.app} params={params} />} path="*" />
{/* Main Layout */} {/* Main Layout */}
<Route path='/dashboard' element={<ErrorPage />} /> <Route element={<ErrorPage />} path="/dashboard" />
<Route path='/settings' element={<ErrorPage />} /> <Route element={<ErrorPage />} path="/settings" />
<Route path='/catalog' element={<ErrorPage />} /> <Route element={<ErrorPage />} path="/catalog" />
<Route path='/quotes' element={<ErrorPage />} /> <Route element={<ErrorPage />} path="/quotes" />
</Route> </Route>
</Route> </Route>
) )

View File

@ -1,4 +1,5 @@
export * from "./documents"; export * from "./documents";
export * from "./mappers";
export * from "./presenters"; export * from "./presenters";
export * from "./renderers"; export * from "./renderers";
export * from "./snapshot-builders"; 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>>; Partial<IBulkDomainMapper<TPersistence, TDomain>>;
/** /**
* * 👓 Mapper de Read Model (Persistencia Read Model/Proyección de Lectura)
* 👓 Mapper de Read Model (Persistencia DTO/Proyección de Lectura) * - Responsabilidad: transformar registros de persistencia en read models para lectura (listados, resúmenes, informes).
* - Responsabilidad: transformar registros de persistencia en DTOs para lectura (listados, resúmenes, informes).
* - No intenta reconstruir agregados ni validar value objects de dominio. * - 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[], raws: TPersistence[],
totalCount: number, totalCount: number,
params?: MapperParamsType params?: MapperParamsType
): Result<Collection<TDTO>, Error>; ): Result<Collection<TReadModel>, Error>;
} }

View File

@ -1,3 +1,2 @@
export * from "./errors"; export * from "./errors";
export * from "./repositories";
export * from "./value-objects"; 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 { Collection, Result, ResultCollection } from "@repo/rdx-utils";
import type { Model } from "sequelize"; import type { Model } from "sequelize";
import type { MapperParamsType } from "../../../../domain"; import type { MapperParamsType } from "../../../../application";
import type { ISequelizeDomainMapper } from "./sequelize-mapper.interface"; 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> export interface ISequelizeDomainMapper<TModel, TModelAttributes, TEntity>
extends DomainMapperWithBulk<TModel | TModelAttributes, TEntity> {} extends DomainMapperWithBulk<TModel | TModelAttributes, TEntity> {}

View File

@ -1,16 +1,16 @@
import { Collection, Result } from "@repo/rdx-utils"; import { Collection, Result } from "@repo/rdx-utils";
import type { Model } from "sequelize"; import type { Model } from "sequelize";
import type { MapperParamsType } from "../../../../domain"; import type { MapperParamsType } from "../../../../application";
import type { ISequelizeQueryMapper } from "./sequelize-mapper.interface"; import type { ISequelizeQueryMapper } from "./sequelize-mapper.interface";
export abstract class SequelizeQueryMapper<TModel extends Model, TEntity> export abstract class SequelizeQueryMapper<TModel extends Model, TEntity>
implements ISequelizeQueryMapper<TModel, 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[], raws: TModel[],
totalCount: number, totalCount: number,
params?: MapperParamsType params?: MapperParamsType
@ -23,7 +23,7 @@ export abstract class SequelizeQueryMapper<TModel extends Model, TEntity>
} }
const items = _source.map((value, index) => { 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) { if (result.isFailure) {
throw result.error; 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 "./di";
export * from "./dtos";
export * from "./mappers"; export * from "./mappers";
export * from "./models";
export * from "./repositories"; export * from "./repositories";
export * from "./services"; export * from "./services";
export * from "./snapshot-builders"; export * from "./snapshot-builders";

View File

@ -1,8 +1,8 @@
import type { MapperParamsType } from "@erp/core/api"; import type { MapperParamsType } from "@erp/core/api";
import type { Result } from "@repo/rdx-utils"; import type { Result } from "@repo/rdx-utils";
import type { IssuedInvoiceListDTO } from "../dtos"; import type { IssuedInvoiceSummary } from "../models";
export interface IIssuedInvoiceListMapper { export interface IIssuedInvoiceSummaryMapper {
mapToDTO(raw: unknown, params?: MapperParamsType): Result<IssuedInvoiceListDTO, Error>; 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, VerifactuRecord,
} from "../../../domain"; } from "../../../domain";
export type IssuedInvoiceListDTO = { export type IssuedInvoiceSummary = {
id: UniqueID; id: UniqueID;
companyId: 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 { Collection, Result } from "@repo/rdx-utils";
import type { IssuedInvoice } from "../../../domain"; import type { IssuedInvoice } from "../../../domain";
import type { IssuedInvoiceListDTO } from "../dtos"; import type { IssuedInvoiceSummary } from "../models";
export interface IIssuedInvoiceRepository { export interface IIssuedInvoiceRepository {
create(invoice: IssuedInvoice, transaction?: unknown): Promise<Result<void, Error>>; create(invoice: IssuedInvoice, transaction?: unknown): Promise<Result<void, Error>>;
@ -24,5 +24,5 @@ export interface IIssuedInvoiceRepository {
companyId: UniqueID, companyId: UniqueID,
criteria: Criteria, criteria: Criteria,
transaction: unknown 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 { Transaction } from "sequelize";
import type { IssuedInvoice } from "../../../domain"; import type { IssuedInvoice } from "../../../domain";
import type { IssuedInvoiceListDTO } from "../dtos"; import type { IssuedInvoiceSummary } from "../models";
import type { IIssuedInvoiceRepository } from "../repositories"; import type { IIssuedInvoiceRepository } from "../repositories";
export interface IIssuedInvoiceFinder { export interface IIssuedInvoiceFinder {
@ -24,7 +24,7 @@ export interface IIssuedInvoiceFinder {
companyId: UniqueID, companyId: UniqueID,
criteria: Criteria, criteria: Criteria,
transaction?: Transaction transaction?: Transaction
): Promise<Result<Collection<IssuedInvoiceListDTO>, Error>>; ): Promise<Result<Collection<IssuedInvoiceSummary>, Error>>;
} }
export class IssuedInvoiceFinder implements IIssuedInvoiceFinder { export class IssuedInvoiceFinder implements IIssuedInvoiceFinder {
@ -50,7 +50,7 @@ export class IssuedInvoiceFinder implements IIssuedInvoiceFinder {
companyId: UniqueID, companyId: UniqueID,
criteria: Criteria, criteria: Criteria,
transaction?: Transaction transaction?: Transaction
): Promise<Result<Collection<IssuedInvoiceListDTO>, Error>> { ): Promise<Result<Collection<IssuedInvoiceSummary>, Error>> {
return this.repository.findByCriteriaInCompany(companyId, criteria, transaction); return this.repository.findByCriteriaInCompany(companyId, criteria, transaction);
} }
} }

View File

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

View File

@ -1,7 +1,7 @@
import type { MapperParamsType } from "@erp/core/api"; import type { MapperParamsType } from "@erp/core/api";
import type { Result } from "@repo/rdx-utils"; import type { Result } from "@repo/rdx-utils";
import type { ProformaListDTO } from "../dtos"; import type { ProformaListDTO } from "../models";
export interface IProformaListMapper { export interface IProformaListMapper {
mapToDTO(raw: unknown, params?: MapperParamsType): Result<ProformaListDTO, Error>; 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 { Collection, Result } from "@repo/rdx-utils";
import type { InvoiceStatus, Proforma } from "../../../domain"; import type { InvoiceStatus, Proforma } from "../../../domain";
import type { ProformaListDTO } from "../dtos"; import type { ProformaListDTO } from "../models";
export interface IProformaRepository { export interface IProformaRepository {
create(proforma: Proforma, transaction?: unknown): Promise<Result<void, Error>>; 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 { Transaction } from "sequelize";
import type { Proforma } from "../../../domain"; import type { Proforma } from "../../../domain";
import type { ProformaListDTO } from "../dtos"; import type { ProformaListDTO } from "../models";
import type { IProformaRepository } from "../repositories"; import type { IProformaRepository } from "../repositories";
export interface IProformaFinder { export interface IProformaFinder {

View File

@ -1,7 +1,7 @@
import type { ISnapshotBuilder } from "@erp/core/api"; import type { ISnapshotBuilder } from "@erp/core/api";
import { maybeToEmptyString } from "@repo/rdx-ddd"; 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"; 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 // Method to get the complete list of line items
public get items(): CustomerInvoiceItems { public get items(): CustomerInvoiceItems {
return this._items; return this._items;
} }

View File

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

View File

@ -11,7 +11,10 @@ import {
} from "@repo/rdx-ddd"; } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils"; import { Maybe, Result } from "@repo/rdx-utils";
import type { IIssuedInvoiceListMapper, IssuedInvoiceListDTO } from "../../../../../../application"; import type {
IIssuedInvoiceSummaryMapper,
IssuedInvoiceSummary,
} from "../../../../../../application";
import { import {
InvoiceAmount, InvoiceAmount,
InvoiceNumber, InvoiceNumber,
@ -25,8 +28,8 @@ import { SequelizeIssuedInvoiceRecipientListMapper } from "./sequelize-issued-in
import { SequelizeVerifactuRecordListMapper } from "./sequelize-verifactu-record.list.mapper"; import { SequelizeVerifactuRecordListMapper } from "./sequelize-verifactu-record.list.mapper";
export class SequelizeIssuedInvoiceListMapper export class SequelizeIssuedInvoiceListMapper
extends SequelizeQueryMapper<CustomerInvoiceModel, IssuedInvoiceListDTO> extends SequelizeQueryMapper<CustomerInvoiceModel, IssuedInvoiceSummary>
implements IIssuedInvoiceListMapper implements IIssuedInvoiceSummaryMapper
{ {
private _recipientMapper: SequelizeIssuedInvoiceRecipientListMapper; private _recipientMapper: SequelizeIssuedInvoiceRecipientListMapper;
private _verifactuMapper: SequelizeVerifactuRecordListMapper; private _verifactuMapper: SequelizeVerifactuRecordListMapper;
@ -37,14 +40,14 @@ export class SequelizeIssuedInvoiceListMapper
this._verifactuMapper = new SequelizeVerifactuRecordListMapper(); this._verifactuMapper = new SequelizeVerifactuRecordListMapper();
} }
public mapToDTO( public mapToReadModel(
raw: CustomerInvoiceModel, raw: CustomerInvoiceModel,
params?: MapperParamsType params?: MapperParamsType
): Result<IssuedInvoiceListDTO, Error> { ): Result<IssuedInvoiceSummary, Error> {
const errors: ValidationErrorDetail[] = []; const errors: ValidationErrorDetail[] = [];
// 1) Valores escalares (atributos generales) // 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) // 2) Recipient (snapshot en la factura o include)
const recipientResult = this._recipientMapper.mapToDTO(raw, { 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 { const { errors } = params as {
errors: ValidationErrorDetail[]; 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 Collection, Result } from "@repo/rdx-utils";
import type { FindOptions, InferAttributes, OrderItem, Sequelize, Transaction } from "sequelize"; 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 type { IssuedInvoice } from "../../../../../domain";
import { import {
CustomerInvoiceItemModel, CustomerInvoiceItemModel,
@ -212,7 +212,7 @@ export class IssuedInvoiceRepository
criteria: Criteria, criteria: Criteria,
transaction: Transaction, transaction: Transaction,
options: FindOptions<InferAttributes<CustomerInvoiceModel>> = {} options: FindOptions<InferAttributes<CustomerInvoiceModel>> = {}
): Promise<Result<Collection<IssuedInvoiceListDTO>, Error>> { ): Promise<Result<Collection<IssuedInvoiceSummary>, Error>> {
const { CustomerModel } = this.database.models; const { CustomerModel } = this.database.models;
try { try {
@ -301,7 +301,7 @@ export class IssuedInvoiceRepository
}), }),
]); ]);
return this.listMapper.mapToDTOCollection(rows, count); return this.listMapper.mapToReadModelCollection(rows, count);
} catch (err: unknown) { } catch (err: unknown) {
return Result.fail(translateSequelizeError(err)); return Result.fail(translateSequelizeError(err));
} }

View File

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

View File

@ -368,10 +368,12 @@ export class ProformaRepository
? [options.include] ? [options.include]
: []; : [];
console.log(query.where);
query.where = { query.where = {
...query.where, ...query.where,
...(options.where ?? {}), ...(options.where ?? {}),
is_proforma: false, is_proforma: true,
company_id: companyId.toString(), company_id: companyId.toString(),
deleted_at: null, 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) { } catch (err: unknown) {
return Result.fail(translateSequelizeError(err)); return Result.fail(translateSequelizeError(err));
} }

View File

@ -2,29 +2,32 @@ import type { ModuleClientParams } from "@erp/core/client";
import { lazy } from "react"; import { lazy } from "react";
import { Outlet, type RouteObject } from "react-router-dom"; import { Outlet, type RouteObject } from "react-router-dom";
import { ProformaCreatePage } from "./proformas/create";
const ProformaLayout = lazy(() => const ProformaLayout = lazy(() =>
import("./proformas/ui").then((m) => ({ default: m.ProformaLayout })) import("./proformas/ui").then((m) => ({ default: m.ProformaLayout }))
); );
const IssuedInvoicesLayout = lazy(() =>
import("./issued-invoices/ui").then((m) => ({ default: m.IssuedInvoicesLayout }))
);
const ProformasListPage = lazy(() => const ProformasListPage = lazy(() =>
import("./proformas/list").then((m) => ({ default: m.ProformaListPage })) 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(() => const IssuedInvoiceListPage = lazy(() =>
import("./issued-invoices/list").then((m) => ({ default: m.IssuedInvoiceListPage })) 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[] => { export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[] => {
return [ return [
{ {
@ -37,7 +40,7 @@ export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[]
children: [ children: [
{ path: "", index: true, element: <ProformasListPage /> }, // index { path: "", index: true, element: <ProformasListPage /> }, // index
{ path: "list", element: <ProformasListPage /> }, { path: "list", element: <ProformasListPage /> },
//{ path: "create", element: <CustomerInvoiceAdd /> }, { path: "create", element: <ProformaCreatePage /> },
//{ path: ":id/edit", element: <InvoiceUpdatePage /> }, //{ 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 { import type {
IssuedInvoiceSummaryData, IssuedInvoiceSummaryData,
@ -26,14 +26,14 @@ export const IssuedInvoiceSummaryDtoAdapter = {
summaryDto.language_code summaryDto.language_code
), ),
discount_percentage: PercentageDTOHelper.toNumber(summaryDto.discount_percentage), /*discount_percentage: PercentageDTOHelper.toNumber(summaryDto.discount_percentage),
discount_percentage_fmt: PercentageDTOHelper.toNumericString( discount_percentage_fmt: PercentageDTOHelper.toNumericString(
summaryDto.discount_percentage summaryDto.discount_percentage
), ),*/
discount_amount: MoneyDTOHelper.toNumber(summaryDto.discount_amount), discount_amount: MoneyDTOHelper.toNumber(summaryDto.total_discount_amount),
discount_amount_fmt: formatCurrency( discount_amount_fmt: formatCurrency(
MoneyDTOHelper.toNumber(summaryDto.discount_amount), MoneyDTOHelper.toNumber(summaryDto.total_discount_amount),
Number(summaryDto.total_amount.scale || 2), Number(summaryDto.total_amount.scale || 2),
summaryDto.currency_code, summaryDto.currency_code,
summaryDto.language_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={ rightSlot={
<Button <Button
aria-label={t("pages.proformas.create.title")} aria-label={t("pages.proformas.create.title")}
className="hidden"
onClick={() => navigate("/proformas/create")} onClick={() => navigate("/proformas/create")}
> >
<PlusIcon aria-hidden className="mr-2 size-4" /> <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 { UniqueID } from "@repo/rdx-ddd";
import { Collection, Result } from "@repo/rdx-utils"; import { Collection, Result } from "@repo/rdx-utils";
import { Transaction } from "sequelize"; import { Transaction } from "sequelize";
import { Customer, CustomerPatchProps, CustomerProps, ICustomerRepository } from "../domain"; import { Customer, CustomerPatchProps, ICustomerProps, ICustomerRepository } from "../domain";
import { CustomerListDTO } from "../infrastructure"; import { CustomerListDTO } from "../infrastructure";
export class CustomerApplicationService { export class CustomerApplicationService {
@ -19,7 +19,7 @@ export class CustomerApplicationService {
*/ */
buildCustomerInCompany( buildCustomerInCompany(
companyId: UniqueID, companyId: UniqueID,
props: Omit<CustomerProps, "companyId">, props: Omit<ICustomerProps, "companyId">,
customerId?: UniqueID customerId?: UniqueID
): Result<Customer, Error> { ): Result<Customer, Error> {
return Customer.create({ ...props, companyId }, customerId); 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 "./di";
//export * from "./presenters"; export * from "./mappers";
export * from "./models";
export * from "./repositories";
export * from "./services";
export * from "./snapshot-builders";
export * from "./use-cases"; 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 type { Criteria } from "@repo/rdx-criteria/server";
import { UniqueID } from "@repo/rdx-ddd"; import type { UniqueID } from "@repo/rdx-ddd";
import { Collection, Result } from "@repo/rdx-utils"; import type { Collection, Result } from "@repo/rdx-utils";
import { CustomerListDTO } from "../../infrastructure/mappers";
import { Customer } from "../aggregates"; import type { Customer } from "../../domain/aggregates";
import type { CustomerSummary } from "../models";
/** /**
* Interfaz del repositorio para el agregado `Customer`. * Interfaz del repositorio para el agregado `Customer`.
@ -56,7 +57,7 @@ export interface ICustomerRepository {
companyId: UniqueID, companyId: UniqueID,
criteria: Criteria, criteria: Criteria,
transaction: unknown transaction: unknown
): Promise<Result<Collection<CustomerListDTO>, Error>>; ): Promise<Result<Collection<CustomerSummary>, Error>>;
/** /**
* Elimina un Customer por su ID, dentro de una empresa. * 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 { Collection, Result, isNullishOrEmpty } from "@repo/rdx-utils";
import type { CreateCustomerRequestDTO } from "../../../../common"; 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). * Convierte el DTO a las props validadas (CustomerProps).
@ -197,7 +197,7 @@ export function mapDTOToCreateCustomerProps(dto: CreateCustomerRequestDTO) {
errors errors
); );
const customerProps: Omit<CustomerProps, "companyId"> = { const customerProps: Omit<ICustomerProps, "companyId"> = {
status: status!, status: status!,
reference: reference!, reference: reference!,

View File

@ -1,11 +1,12 @@
import { IPresenterRegistry, ITransactionManager } from "@erp/core/api"; import type { ITransactionManager } from "@erp/core/api";
import { Criteria } from "@repo/rdx-criteria/server"; import type { Criteria } from "@repo/rdx-criteria/server";
import { UniqueID } from "@repo/rdx-ddd"; import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import { Transaction } from "sequelize"; import type { Transaction } from "sequelize";
import { ListCustomersResponseDTO } from "../../../common/dto";
import { CustomerApplicationService } from "../../application"; import type { ListCustomersResponseDTO } from "../../../common/dto";
import { ListCustomersPresenter } from "../presenters"; import type { ICustomerFinder } from "../services";
import type { ICustomerListItemSnapshotBuilder } from "../snapshot-builders/list";
type ListCustomersUseCaseInput = { type ListCustomersUseCaseInput = {
companyId: UniqueID; companyId: UniqueID;
@ -14,39 +15,42 @@ type ListCustomersUseCaseInput = {
export class ListCustomersUseCase { export class ListCustomersUseCase {
constructor( constructor(
private readonly service: CustomerApplicationService, private readonly finder: ICustomerFinder,
private readonly transactionManager: ITransactionManager, private readonly listItemSnapshotBuilder: ICustomerListItemSnapshotBuilder,
private readonly presenterRegistry: IPresenterRegistry private readonly transactionManager: ITransactionManager
) {} ) {}
public execute( public execute(
params: ListCustomersUseCaseInput params: ListCustomersUseCaseInput
): Promise<Result<ListCustomersResponseDTO, Error>> { ): Promise<Result<ListCustomersResponseDTO, Error>> {
const { criteria, companyId } = params; const { criteria, companyId } = params;
const presenter = this.presenterRegistry.getPresenter({
resource: "customer",
projection: "LIST",
}) as ListCustomersPresenter;
return this.transactionManager.complete(async (transaction: Transaction) => { return this.transactionManager.complete(async (transaction: Transaction) => {
try { try {
const result = await this.service.findCustomerByCriteriaInCompany( const result = await this.finder.findCustomersByCriteria(companyId, criteria, transaction);
companyId,
criteria,
transaction
);
if (result.isFailure) { if (result.isFailure) {
return Result.fail(result.error); return Result.fail(result.error);
} }
const customers = result.data; const customers = result.data;
const dto = presenter.toOutput({ const totalCustomers = customers.total();
customers,
criteria,
});
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) { } catch (error: unknown) {
return Result.fail(error as Error); 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"; import type { CustomerStatus } from "../value-objects";
export interface CustomerProps { export interface ICustomerProps {
companyId: UniqueID; companyId: UniqueID;
status: CustomerStatus; status: CustomerStatus;
reference: Maybe<Name>; reference: Maybe<Name>;
@ -49,7 +49,7 @@ export interface CustomerProps {
currencyCode: CurrencyCode; currencyCode: CurrencyCode;
} }
export type CustomerPatchProps = Partial<Omit<CustomerProps, "companyId" | "address">> & { export type CustomerPatchProps = Partial<Omit<ICustomerProps, "companyId" | "address">> & {
address?: PostalAddressPatchProps; address?: PostalAddressPatchProps;
}; };
@ -90,8 +90,12 @@ export interface ICustomer {
readonly currencyCode: CurrencyCode; readonly currencyCode: CurrencyCode;
} }
export class Customer extends AggregateRoot<CustomerProps> implements ICustomer { type CreateCustomerProps = ICustomerProps;
static create(props: CustomerProps, id?: UniqueID): Result<Customer, Error> { 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); const contact = new Customer(props, id);
// Reglas de negocio / validaciones // Reglas de negocio / validaciones
@ -105,12 +109,18 @@ export class Customer extends AggregateRoot<CustomerProps> implements ICustomer
return Result.ok(contact); 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> { public update(partialCustomer: CustomerPatchProps): Result<Customer, Error> {
const { address: partialAddress, ...rest } = partialCustomer; const { address: partialAddress, ...rest } = partialCustomer;
const updatedProps = { const updatedProps = {
...this.props, ...this.props,
...rest, ...rest,
} as CustomerProps; } as ICustomerProps;
if (partialAddress) { if (partialAddress) {
const updatedAddressOrError = this.address.update(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); return Customer.create(updatedProps, this.id);
} }
// Getters
public get isIndividual(): boolean { public get isIndividual(): boolean {
return !this.props.isCompany; 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 "./aggregates";
export * from "./errors"; export * from "./errors";
export * from "./repositories";
export * from "./value-objects"; export * from "./value-objects";

View File

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

View File

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

View File

@ -14,7 +14,7 @@ import {
import { CustomerApplicationService } from "../application/customer-application.service"; import { CustomerApplicationService } from "../application/customer-application.service";
import { CustomerFullPresenter, ListCustomersPresenter } from "../application/presenters"; import { CustomerFullPresenter, ListCustomersPresenter } from "../application/presenters";
import { CustomerDomainMapper, CustomerListMapper } from "./mappers"; import { CustomerDomainMapper, CustomerSummaryMapper } from "./mappers";
import { CustomerRepository } from "./sequelize"; import { CustomerRepository } from "./sequelize";
export type CustomerDeps = { export type CustomerDeps = {
@ -40,7 +40,7 @@ export function buildCustomerDependencies(params: ModuleParams): CustomerDeps {
const mapperRegistry = new InMemoryMapperRegistry(); const mapperRegistry = new InMemoryMapperRegistry();
mapperRegistry mapperRegistry
.registerDomainMapper({ resource: "customer" }, new CustomerDomainMapper()) .registerDomainMapper({ resource: "customer" }, new CustomerDomainMapper())
.registerQueryMapper({ resource: "customer", query: "LIST" }, new CustomerListMapper()); .registerQueryMapper({ resource: "customer", query: "LIST" }, new CustomerSummaryMapper());
// Repository & Services // Repository & Services
const repo = new CustomerRepository({ mapperRegistry, database }); 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, UpdateCustomerController,
} from "./controllers"; } from "./controllers";
export const customersRouter = (params: ModuleParams) => { export const customersRouter = (params: ModuleParams, deps: CustomerInternalDeps) => {
const { app, baseRoutePath, logger } = params as { const { app, baseRoutePath, logger } = params as {
app: Application; app: Application;
database: Sequelize; database: Sequelize;

View File

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

View File

@ -1,8 +1,4 @@
import { import { type MapperParamsType, SequelizeQueryMapper } from "@erp/core/api";
type ISequelizeQueryMapper,
type MapperParamsType,
SequelizeQueryMapper,
} from "@erp/core/api";
import { import {
City, City,
Country, Country,
@ -24,46 +20,20 @@ import {
extractOrPushError, extractOrPushError,
maybeFromNullableResult, maybeFromNullableResult,
} from "@repo/rdx-ddd"; } 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 { CustomerStatus } from "../../../domain";
import type { CustomerModel } from "../../sequelize"; import type { CustomerModel } from "../../sequelize";
export type CustomerListDTO = { export class CustomerSummaryMapper
id: UniqueID; extends SequelizeQueryMapper<CustomerModel, CustomerSummary>
companyId: UniqueID; implements ICustomerSummaryMapper
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
{ {
public mapToDTO(raw: CustomerModel, params?: MapperParamsType): Result<CustomerListDTO, Error> { public mapToReadModel(
raw: CustomerModel,
params?: MapperParamsType
): Result<CustomerSummary, Error> {
const errors: ValidationErrorDetail[] = []; const errors: ValidationErrorDetail[] = [];
// 1) Valores escalares (atributos generales) // 1) Valores escalares (atributos generales)
@ -217,7 +187,7 @@ export class CustomerListMapper
); );
} }
return Result.ok<CustomerListDTO>({ return Result.ok<CustomerSummary>({
id: customerId!, id: customerId!,
companyId: companyId!, companyId: companyId!,
status: status!, 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 Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server";
import type { UniqueID } from "@repo/rdx-ddd"; import type { UniqueID } from "@repo/rdx-ddd";
import { type Collection, Result } from "@repo/rdx-utils"; 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 { CustomerSummary, ICustomerRepository } from "../../../application";
import type { CustomerListDTO, ICustomerDomainMapper, ICustomerListMapper } from "../../mappers"; import type { Customer } from "../../../domain";
import type { ICustomerDomainMapper, ICustomerListMapper } from "../../mappers";
import { CustomerModel } from "../models/customer.model"; import { CustomerModel } from "../models/customer.model";
export class CustomerRepository export class CustomerRepository
extends SequelizeRepository<Customer> extends SequelizeRepository<Customer>
implements ICustomerRepository implements ICustomerRepository
{ {
constructor(
private readonly domainMapper: ICustomerDomainMapper,
private readonly listMapper: ICustomerListMapper,
database: Sequelize
) {
super({ database });
}
/** /**
* *
* Crea un nuevo cliente * Crea un nuevo cliente
@ -27,18 +36,15 @@ export class CustomerRepository
*/ */
async create(customer: Customer, transaction?: Transaction): Promise<Result<void, Error>> { async create(customer: Customer, transaction?: Transaction): Promise<Result<void, Error>> {
try { try {
const mapper: ICustomerDomainMapper = this._registry.getDomainMapper({ const dtoResult = this.domainMapper.mapToPersistence(customer);
resource: "customer",
});
const dto = mapper.mapToPersistence(customer);
if (dto.isFailure) { if (dtoResult.isFailure) {
return Result.fail(dto.error); return Result.fail(dtoResult.error);
} }
const { data } = dto; const dto = dtoResult.data;
await CustomerModel.create(data, { await CustomerModel.create(dto, {
include: [{ all: true }], include: [{ all: true }],
transaction, transaction,
}); });
@ -58,12 +64,9 @@ export class CustomerRepository
*/ */
async update(customer: Customer, transaction?: Transaction): Promise<Result<void, Error>> { async update(customer: Customer, transaction?: Transaction): Promise<Result<void, Error>> {
try { try {
const mapper: ICustomerDomainMapper = this._registry.getDomainMapper({ const dtoResult = this.domainMapper.mapToPersistence(customer);
resource: "customer",
});
const dto = mapper.mapToPersistence(customer);
const { id, ...updatePayload } = dto.data; const { id, ...updatePayload } = dtoResult.data;
const [affected] = await CustomerModel.update(updatePayload, { const [affected] = await CustomerModel.update(updatePayload, {
where: { id /*, version */ }, where: { id /*, version */ },
//fields: Object.keys(updatePayload), //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 companyId - Identificador UUID de la empresa a la que pertenece el cliente.
* @param criteria - Criterios de búsqueda. * @param criteria - Criterios de búsqueda.
* @param transaction - Transacción activa para la operación. * @param transaction - Transacción activa para la operación.
* @returns Result<Collection<Customer>, Error> * @returns Result<Collection<CustomerListDTO>, Error>
* *
* @see Criteria * @see Criteria
*/ */
@ -154,15 +157,10 @@ export class CustomerRepository
companyId: UniqueID, companyId: UniqueID,
criteria: Criteria, criteria: Criteria,
transaction?: Transaction transaction?: Transaction
): Promise<Result<Collection<CustomerListDTO>, Error>> { ): Promise<Result<Collection<CustomerSummary>, Error>> {
try { try {
const mapper: ICustomerListMapper = this._registry.getQueryMapper({ const criteriaConverter = new CriteriaToSequelizeConverter();
resource: "customer", const query = criteriaConverter.convert(criteria, {
query: "LIST",
});
const converter = new CriteriaToSequelizeConverter();
const query = converter.convert(criteria, {
searchableFields: [ searchableFields: [
"name", "name",
"trade_name", "trade_name",
@ -195,19 +193,7 @@ export class CustomerRepository
transaction, transaction,
}); });
/*const [rows, count] = await Promise.all([ return this.listMapper.mapToDTOCollection(rows, count);
CustomerModel.findAll({
...CustomerModel,
transaction,
}),
CustomerModel.count({
where: query.where,
distinct: true, // evita duplicados por LEFT JOIN
transaction,
}),
]);*/
return mapper.mapToDTOCollection(rows, count);
} catch (err: unknown) { } catch (err: unknown) {
return Result.fail(translateSequelizeError(err)); 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 { buildTextFilters } from "@erp/core/client";
import { LookupDialog } from "@repo/rdx-ui/components";
import { import {
Badge, Badge,
Button, Button,
@ -18,7 +15,10 @@ import {
} from "@repo/shadcn-ui/components"; } from "@repo/shadcn-ui/components";
import { Building, Calendar, Mail, MapPin, Phone, Plus, User } from "lucide-react"; import { Building, Calendar, Mail, MapPin, Phone, Plus, User } from "lucide-react";
import { useState } from "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"; import { useCustomerListQuery } from "../hooks";
type Customer = ListCustomersResponseDTO["items"][number]; type Customer = ListCustomersResponseDTO["items"][number];
@ -143,96 +143,96 @@ export const ClientSelectorModal = () => {
}; };
return ( return (
<div className='space-y-4 max-w-2xl'> <div className="space-y-4 max-w-2xl">
<div className='space-y-1'> <div className="space-y-1">
<Label>Cliente</Label> <Label>Cliente</Label>
<Button variant='outline' className='w-full justify-start' onClick={handleSelectClient}> <Button className="w-full justify-start" onClick={handleSelectClient} variant="outline">
<User className='h-4 w-4 mr-2' /> <User className="h-4 w-4 mr-2" />
{selectedCustomer ? selectedCustomer.name : "Seleccionar cliente"} {selectedCustomer ? selectedCustomer.name : "Seleccionar cliente"}
</Button> </Button>
</div> </div>
{selectedCustomer && ( {selectedCustomer && (
<Card> <Card>
<CardContent className='p-4 space-y-2'> <CardContent className="p-4 space-y-2">
<div className='flex items-center justify-between'> <div className="flex items-center justify-between">
<div className='flex items-center gap-2'> <div className="flex items-center gap-2">
<User className='h-6 w-6 text-primary' /> <User className="h-6 w-6 text-primary" />
<h3 className='font-semibold'>{selectedCustomer.name}</h3> <h3 className="font-semibold">{selectedCustomer.name}</h3>
<Badge <Badge
className="text-xs"
variant={selectedCustomer.status === "Activo" ? "default" : "secondary"} variant={selectedCustomer.status === "Activo" ? "default" : "secondary"}
className='text-xs'
> >
{selectedCustomer.status} {selectedCustomer.status}
</Badge> </Badge>
</div> </div>
<span className='text-sm text-muted-foreground'>{selectedCustomer.company}</span> <span className="text-sm text-muted-foreground">{selectedCustomer.company}</span>
</div> </div>
<p className='text-sm text-muted-foreground'>{selectedCustomer.email}</p> <p className="text-sm text-muted-foreground">{selectedCustomer.email}</p>
</CardContent> </CardContent>
</Card> </Card>
)} )}
<LookupDialog <LookupDialog
open={open} description="Busca un cliente por nombre, email o empresa"
onOpenChange={setOpen}
items={data?.items ?? []}
totalItems={data?.total_items ?? 0}
search={search}
onSearchChange={setSearch}
isLoading={isLoading}
isError={isError} isError={isError}
refetch={refetch} isLoading={isLoading}
title='Seleccionar cliente' items={data?.items ?? []}
description='Busca un cliente por nombre, email o empresa'
onSelect={(customer) => {
setSelectedCustomer(customer);
setOpen(false);
}}
onCreate={(e) => { onCreate={(e) => {
e.preventDefault(); e.preventDefault();
setOpen(true); setOpen(true);
console.log("Crear nuevo cliente"); console.log("Crear nuevo cliente");
}} }}
onOpenChange={setOpen}
onPageChange={setPageNumber}
onSearchChange={setSearch}
onSelect={(customer) => {
setSelectedCustomer(customer);
setOpen(false);
}}
open={open}
page={pageNumber} page={pageNumber}
perPage={pageSize} perPage={pageSize}
onPageChange={setPageNumber} refetch={refetch}
renderItem={() => null} // No se usa con DataTable
renderContainer={(items) => ( renderContainer={(items) => (
<DataTable <DataTable
columns={columns} columns={columns}
data={items} data={items}
highlightOnHover
noDataComponent="No se encontraron resultados"
onChangePage={(p) => setPageNumber(p)}
onRowClicked={(item) => { onRowClicked={(item) => {
setSelectedCustomer(item); setSelectedCustomer(item);
setOpen(false); setOpen(false);
}} }}
pagination pagination
paginationServer
paginationPerPage={pageSize} paginationPerPage={pageSize}
paginationServer
paginationTotalRows={data?.total_items ?? 0} paginationTotalRows={data?.total_items ?? 0}
onChangePage={(p) => setPageNumber(p)}
highlightOnHover
pointerOnHover 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}> <Dialog onOpenChange={setIsCreateOpen} open={isCreateOpen}>
<DialogContent className='max-w-md'> <DialogContent className="max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle className='flex items-center gap-2'> <DialogTitle className="flex items-center gap-2">
<Plus className='size-5' /> <Plus className="size-5" />
Nuevo Cliente Nuevo Cliente
</DialogTitle> </DialogTitle>
</DialogHeader> </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> <DialogFooter>
<Button variant='outline' onClick={() => setIsCreateOpen(false)}> <Button onClick={() => setIsCreateOpen(false)} variant="outline">
Cancelar Cancelar
</Button> </Button>
<Button> <Button>
<Plus className='h-4 w-4 mr-2' /> <Plus className="h-4 w-4 mr-2" />
Crear Crear
</Button> </Button>
</DialogFooter> </DialogFooter>
@ -246,33 +246,33 @@ export const ClientSelectorModal = () => {
const CustomerCard = ({ customer }: { customer: Customer }) => ( const CustomerCard = ({ customer }: { customer: Customer }) => (
<Card> <Card>
<CardContent className='p-4 space-y-2'> <CardContent className="p-4 space-y-2">
<div className='flex items-center gap-2'> <div className="flex items-center gap-2">
<User className='size-5' /> <User className="size-5" />
<span className='font-semibold'>{customer.name}</span> <span className="font-semibold">{customer.name}</span>
<Badge variant={customer.status === "Activo" ? "default" : "secondary"}> <Badge variant={customer.status === "Activo" ? "default" : "secondary"}>
{customer.status} {customer.status}
</Badge> </Badge>
</div> </div>
<div className='text-sm text-muted-foreground flex flex-col gap-1'> <div className="text-sm text-muted-foreground flex flex-col gap-1">
<div className='flex items-center gap-1'> <div className="flex items-center gap-1">
<Mail className='h-4 w-4' /> <Mail className="h-4 w-4" />
{customer.email} {customer.email}
</div> </div>
<div className='flex items-center gap-1'> <div className="flex items-center gap-1">
<Building className='h-4 w-4' /> <Building className="h-4 w-4" />
{customer.company} {customer.company}
</div> </div>
<div className='flex items-center gap-1'> <div className="flex items-center gap-1">
<Phone className='h-4 w-4' /> <Phone className="h-4 w-4" />
{customer.phone} {customer.phone}
</div> </div>
<div className='flex items-center gap-1'> <div className="flex items-center gap-1">
<MapPin className='h-4 w-4' /> <MapPin className="h-4 w-4" />
{customer.address} {customer.address}
</div> </div>
<div className='flex items-center gap-1'> <div className="flex items-center gap-1">
<Calendar className='h-4 w-4' /> <Calendar className="h-4 w-4" />
{new Date(customer.createdAt).toLocaleDateString("es-ES")} {new Date(customer.createdAt).toLocaleDateString("es-ES")}
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,4 @@
export * from "./use-create-customer-mutation"; export * from "./use-create-customer-mutation";
export * from "./use-customer-list-query";
export * from "./use-customer-query"; export * from "./use-customer-query";
export * from "./use-customers-context"; export * from "./use-customers-context";
export * from "./use-update-customer-mutation"; 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