This commit is contained in:
David Arranz 2026-05-05 13:34:36 +02:00
parent 92faca9bfa
commit fa913627e2
162 changed files with 61434 additions and 2725 deletions

View File

@ -5,12 +5,6 @@
// Enable Font Ligatures
"editor.fontLigatures": true,
// Lint
"eslint.workingDirectories": [{ "mode": "auto" }],
"eslint.run": "onType",
"eslint.validate": ["javascript", "typescript", "typescriptreact"],
"eslint.lintTask.enable": true,
// Javascript and TypeScript settings
"js/ts.suggest.enabled": true,
"js/ts.suggest.autoImports": true,

12
.vscode/tasks.json vendored Normal file
View File

@ -0,0 +1,12 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "biome: check workspace",
"type": "shell",
"command": "pnpm biome check .",
"group": "test",
"problemMatcher": []
}
]
}

View File

@ -1,6 +1,6 @@
{
"name": "@erp/factuges-server",
"version": "0.6.4",
"version": "0.6.5",
"private": true,
"scripts": {
"build": "tsup src/index.ts --config tsup.config.ts",
@ -8,7 +8,8 @@
"dev": "node --import=tsx --watch src/index.ts",
"clean": "rimraf .turbo node_modules dist",
"typecheck": "tsc --noEmit",
"lint": "biome lint --fix",
"check": "biome check .",
"lint": "biome lint .",
"format": "biome format --write"
},
"devDependencies": {

View File

@ -45,6 +45,7 @@ const serverStop = (server: http.Server) => {
logger.warn("⚡️ Shutting down server");
// Tras el timeout, destruimos sockets vivos y resolvemos
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: <No merece la pena refactorizar>
const killer = setTimeout(() => {
try {
const sockets = Object.values(currentState.connections) as any[];
@ -229,6 +230,7 @@ process.on("uncaughtException", async (error: Error) => {
logger.info("✅ Server is READY (readiness=true)");
logger.info(`startup_duration_ms=${DateTime.now().diff(currentState.launchedAt).toMillis()}`);
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: <No merece la pena refactorizar>
server.listen(currentState.port, "0.0.0.0", () => {
server.emit("listening");

View File

@ -1,10 +1,8 @@
// apps/server/src/lib/modules/model-loader.ts
import type { ModuleParams } from "@erp/core/api";
import { logger } from "../logger";
/**
Tipos mínimos para evitar acoplarse a una versión concreta de Sequelize
*/
type SequelizeLike = {
sync: (options?: any) => Promise<void>;
models: Record<string, unknown>;
@ -12,51 +10,44 @@ type SequelizeLike = {
type ModelStatic = {
name: string;
// Algunas implementaciones añaden associate(sequelize) para definir relaciones
associate?: (sequelize: SequelizeLike) => void;
};
export type ModelInitializer = (sequelize: SequelizeLike) => ModelStatic;
/**
Estructuras internas (no exportadas) con trazabilidad de módulo.
*/
type InitializerEntry = { init: ModelInitializer; moduleName: string };
type RegisteredModelEntry = {
model: ModelStatic;
moduleName: string;
};
const allModelInitializers: InitializerEntry[] = [];
const registeredModels: Map<string, { model: ModelStatic; moduleName: string }> = new Map();
const registeredModels: Map<string, RegisteredModelEntry> = new Map();
let modelsInitialized = false;
/**
🔹 Registra inicializadores de modelos de un módulo
models: lista de funciones que definen modelos (sequelize) => ModelStatic
ctx.moduleName: para trazabilidad y detección de colisiones
*/
export const registerModels = (
models: ModelInitializer[],
_params?: ModuleParams,
ctx?: { moduleName: string }
) => {
const moduleName = ctx?.moduleName ?? "unknown";
if (!Array.isArray(models) || models.length === 0) {
logger.warn(`No models provided by module "${moduleName}"`, { label: "registerModels" });
return;
}
for (const init of models) {
// Guardamos con el módulo que los aporta (para logs/errores)
allModelInitializers.push({ init, moduleName });
}
logger.info(`📦 ${models.length} model initializer(s) enqueued from "${moduleName}"`, {
logger.info(`${models.length} model initializer(s) enqueued from "${moduleName}"`, {
label: "registerModels",
});
};
/**
🔹 Inicializa todos los modelos registrados y configura asociaciones.
Detecta colisiones por nombre de modelo entre módulos.
Controla el modo de sincronización por ENV.
*/
export const initModels = async (params: ModuleParams) => {
if (modelsInitialized) {
logger.warn("Models already initialized. Skipping initModels()", { label: "initModels" });
@ -65,91 +56,150 @@ export const initModels = async (params: ModuleParams) => {
logger.info("Init models...", { label: "initModels" });
const { database } = params as { database: SequelizeLike };
const database = getDatabaseOrThrow(params);
try {
defineRegisteredModels(database);
associateRegisteredModels(database);
await syncDatabase(database, getDatabaseSyncMode(params));
modelsInitialized = true;
} catch (error) {
registeredModels.clear();
throw error;
}
};
const getDatabaseOrThrow = (params: ModuleParams): SequelizeLike => {
const { database } = params as { database?: SequelizeLike };
if (!database) {
const error = new Error("❌ Database not found.");
const error = new Error("Database not found.");
logger.error(error.message, { label: "initModels" });
throw error;
}
// 1) Definir modelos (y detectar colisiones)
for (const { init, moduleName } of allModelInitializers) {
try {
const model = init(database);
if (!model || typeof model.name !== "string" || !model.name) {
throw new Error(`Invalid model initializer: missing or empty "name"`);
}
return database;
};
if (registeredModels.has(model.name)) {
const existing = registeredModels.get(model.name)!;
throw new Error(
`Model name collision: "${model.name}" from module "${moduleName}" conflicts with existing model from "${existing.moduleName}"`
);
}
registeredModels.set(model.name, { model, moduleName });
logger.info(`🔸 Model "${model.name}" registered (sequelize)`, {
label: "registerModel",
module: moduleName,
});
} catch (err: any) {
// Agregamos contexto del módulo que aportó el initializer
logger.error(`⛔️ Failed to define model (module="${moduleName}") : ${err?.message ?? err}`, {
label: "initModels",
stack: err?.stack,
});
throw err;
}
}
// 2) Configurar asociaciones
for (const { model, moduleName } of registeredModels.values()) {
if (typeof model.associate === "function") {
try {
model.associate(database);
} catch (err: any) {
logger.error(
`⛔️ Failed to associate model "${model.name}" (module="${moduleName}") : ${err?.message ?? err}`,
{ label: "initModels", stack: err?.stack }
);
throw err;
}
}
}
// 3) Sincronizar base de datos (según modo)
const syncModeEnv = params.config.database.syncMode; // "none" | "alter" | "force"
logger.info(` Database sync mode ${syncModeEnv}`, {
label: "initModels",
});
try {
if (syncModeEnv === "none") {
logger.info("✔️ Database sync skipped (mode=none)", { label: "initModels" });
} else if (syncModeEnv === "alter") {
await database.sync({ force: false, alter: true });
logger.info("✔️ Database synchronized successfully (mode=alter).", { label: "initModels" });
} else if (syncModeEnv === "force") {
await database.sync({ force: true, alter: false });
logger.warn("⚠️ Database synchronized with FORCE (mode=force).", { label: "initModels" });
} else {
logger.warn(`⚠️ Unknown DB sync mode "${String(syncModeEnv)}" → skipping`, {
label: "initModels",
});
}
} catch (err) {
const error = err as Error;
logger.error("❌ Error synchronizing database:", { error, label: "initModels" });
throw error;
} finally {
modelsInitialized = true;
const defineRegisteredModels = (database: SequelizeLike): void => {
for (const entry of allModelInitializers) {
defineModel(database, entry);
}
};
const defineModel = (database: SequelizeLike, entry: InitializerEntry): void => {
const { init, moduleName } = entry;
try {
const model = init(database);
assertValidModel(model);
assertModelNameIsAvailable(model.name, moduleName);
registeredModels.set(model.name, { model, moduleName });
logger.info(`Model "${model.name}" registered (sequelize)`, {
label: "registerModel",
module: moduleName,
});
} catch (err) {
const error = err as Error;
logger.error(`Failed to define model (module="${moduleName}") : ${error.message}`, {
label: "initModels",
stack: error.stack,
});
throw error;
}
};
const assertValidModel = (model: ModelStatic): void => {
if (!model || typeof model.name !== "string" || !model.name) {
throw new Error('Invalid model initializer: missing or empty "name"');
}
};
const assertModelNameIsAvailable = (modelName: string, moduleName: string): void => {
const existing = registeredModels.get(modelName);
if (!existing) {
return;
}
throw new Error(
`Model name collision: "${modelName}" from module "${moduleName}" conflicts with existing model from "${existing.moduleName}"`
);
};
const associateRegisteredModels = (database: SequelizeLike): void => {
for (const entry of registeredModels.values()) {
associateModel(database, entry);
}
};
const associateModel = (database: SequelizeLike, entry: RegisteredModelEntry): void => {
const { model, moduleName } = entry;
if (typeof model.associate !== "function") {
return;
}
try {
model.associate(database);
} catch (err) {
const error = err as Error;
logger.error(
`Failed to associate model "${model.name}" (module="${moduleName}") : ${error.message}`,
{ label: "initModels", stack: error.stack }
);
throw error;
}
};
const getDatabaseSyncMode = (params: ModuleParams): string => {
return params.config.database.syncMode;
};
const syncDatabase = async (database: SequelizeLike, syncMode: string): Promise<void> => {
logger.info(`Database sync mode ${syncMode}`, { label: "initModels" });
try {
if (syncMode === "none") {
logger.info("Database sync skipped (mode=none)", { label: "initModels" });
return;
}
if (syncMode === "alter") {
await database.sync({ force: false, alter: true });
logger.info("Database synchronized successfully (mode=alter).", { label: "initModels" });
return;
}
if (syncMode === "force") {
await database.sync({ force: true, alter: false });
logger.warn("Database synchronized with FORCE (mode=force).", { label: "initModels" });
return;
}
logger.warn(`Unknown DB sync mode "${String(syncMode)}" → skipping`, {
label: "initModels",
});
} catch (err) {
const error = err as Error;
logger.error("Error synchronizing database:", {
error,
label: "initModels",
});
throw error;
}
};
/**
🔹 Utilidades opcionales (DX/tests)
*/
export const getRegisteredModels = () =>
Array.from(registeredModels.entries()).map(([name, { moduleName }]) => ({
name,
@ -160,5 +210,6 @@ export const resetModelsRegistry = () => {
allModelInitializers.length = 0;
registeredModels.clear();
modelsInitialized = false;
logger.info("Model registry has been reset.", { label: "initModels" });
};

View File

@ -249,6 +249,7 @@ async function warmupModules(params: ModuleParams) {
const pkg = registeredModules.get(name);
if (!pkg?.warmup) continue;
// biome-ignore lint/suspicious/noExplicitAny: <Para que funione>
const maybeWarmup = (pkg as any).warmup as undefined | ((p: any) => Promise<void> | void);
if (typeof maybeWarmup !== "function") continue;
@ -313,8 +314,10 @@ async function withPhase<T>(
durationMs: duration,
});
return out;
} catch (error: any) {
} catch (err: unknown) {
const duration = Date.now() - startedAt;
const error = err as Error;
logger.error(
`⛔️ Module "${moduleName}" failed at phase="${phase}" after ${duration}ms: ${error?.message ?? error}`,
{
@ -326,11 +329,11 @@ async function withPhase<T>(
}
);
// Re-lanzamos con contexto preservado
const err = new Error(
const newError = new Error(
`[module=${moduleName}] phase=${phase} failed: ${error?.message ?? "Unknown error"}`
);
(err as any).cause = error;
throw err;
newError.cause = error;
throw newError;
}
}

View File

@ -3,7 +3,7 @@ const services: Record<string, unknown> = {};
/**
* Registra un objeto de servicio (API) bajo un nombre.
*/
export function registerService(name: string, api: any) {
export function registerService(name: string, api: unknown) {
if (services[name]) {
throw new Error(`❌ Servicio "${name}" ya fue registrado.`);
}
@ -21,7 +21,7 @@ export function registerService(name: string, api: any) {
*
* getService("self:repository")
*/
export function getServiceScoped<T = any>(
export function getServiceScoped<T = unknown>(
requesterModule: string,
allowedDeps: readonly string[],
name: string
@ -45,7 +45,7 @@ export function getServiceScoped<T = any>(
/**
* Recupera un servicio registrado, con tipado opcional.
*/
function getService<T = any>(name: string): T {
function getService<T = unknown>(name: string): T {
const service = services[name];
if (!service) {
throw new Error(`❌ Servicio "${name}" no encontrado.`);

View File

@ -1,20 +1 @@
export * from "./v1.routes";
import { Router } from "express";
export const v1Routes = (): Router => {
const routes = Router({ mergeParams: true });
routes.get("/hello", (req, res) => {
res.send("Hello world!");
});
//authRouter(routes);
//usersRouter(routes);
//accountsRouter(routes);
// Sales
//invoicesRouter(routes);
return routes;
};

View File

@ -3,7 +3,7 @@ import { Router } from "express";
export const v1Routes = (): Router => {
const routes = Router({ mergeParams: true });
routes.get("/hello", (req, res) => {
routes.get("/hello", (_req, res) => {
res.send("Hello world!");
});

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html>
<html lang="es">
<head>
<meta charset="UTF-8" />

View File

@ -1,7 +1,7 @@
{
"name": "@erp/factuges-web",
"private": true,
"version": "0.6.4",
"version": "0.6.5",
"type": "module",
"scripts": {
"dev": "vite --host --clearScreen false",
@ -11,7 +11,8 @@
"preview": "vite preview --host --debug --mode production",
"clean": "rm -rf dist && rm -rf node_modules && rm -rf .turbo",
"check:deps": "pnpm exec depcheck",
"lint": "biome lint --fix",
"check": "biome check .",
"lint": "biome lint .",
"format": "biome format --write"
},
"devDependencies": {

View File

@ -12,6 +12,7 @@ interface WarpIfProtectedProps {
isProtected: boolean;
}
// biome-ignore lint/correctness/noUnusedVariables: <posible uso futuro>
const WarpIfProtected = ({ component, isProtected }: WarpIfProtectedProps) => {
return isProtected ? <>{component}</> : component;
};

View File

@ -14,6 +14,7 @@ type ThemeProviderState = {
setTheme: (theme: Theme) => void;
};
// biome-ignore lint/suspicious/noExplicitAny: <Es para que funcione>
const secureLocalStorage = (secureLocalStorageImport as any)?.default ?? secureLocalStorageImport;
const initialState: ThemeProviderState = {

View File

@ -5,6 +5,7 @@ import secureLocalStorageImport from "react-secure-storage";
* Este archivo puede evolucionar a un AuthService más completo en el futuro.
*/
// biome-ignore lint/suspicious/noExplicitAny: <Es para que funcione>
const secureLocalStorage = (secureLocalStorageImport as any)?.default ?? secureLocalStorageImport;
/**

View File

@ -23,7 +23,7 @@ export const useAppTranslation = () => {
const base = useI18NextTranslation();
const { i18n } = base;
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
// biome-ignore lint/correctness/useExhaustiveDependencies: <Revisar si cambia i18n>
useEffect(() => {
// idempotente: solo añade si faltan bundles
ensureAppTranslations();

View File

@ -22,31 +22,31 @@ export function LoginForm({ className, ...props }: React.ComponentProps<"div">)
<form>
<FieldGroup>
<Field>
<FieldLabel htmlFor='email'>Email</FieldLabel>
<Input id='email' type='email' placeholder='m@example.com' required />
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input id="email" placeholder="m@example.com" required type="email" />
</Field>
<Field>
<div className='flex items-center'>
<FieldLabel htmlFor='password'>Password</FieldLabel>
<div className="flex items-center">
<FieldLabel htmlFor="password">Password</FieldLabel>
<a
// biome-ignore lint/a11y/useValidAnchor: <explanation>
href='#'
className='ml-auto inline-block text-sm underline-offset-4 hover:underline'
className="ml-auto inline-block text-sm underline-offset-4 hover:underline"
{ /* biome-ignore lint/a11y/useValidAnchor: <para que funcione> */ }
href="#"
>
Forgot your password?
</a>
</div>
<Input id='password' type='password' required />
<Input id="password" required type="password" />
</Field>
<Field>
<Button type='submit'>Login</Button>
<Button variant='outline' type='button'>
<Button type="submit">Login</Button>
<Button type="button" variant="outline">
Login with Google
</Button>
<FieldDescription className='text-center'>
<FieldDescription className="text-center">
Don&apos;t have an account?{" "}
{/** biome-ignore lint/a11y/useValidAnchor: <explanation> */}
<a href='#'>Sign up</a>
{/** biome-ignore lint/a11y/useValidAnchor: <para que funcione> */}
<a href="#">Sign up</a>
</FieldDescription>
</Field>
</FieldGroup>

View File

@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.1/schema.json",
"$schema": "https://biomejs.dev/schemas/2.4.11/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
@ -9,27 +9,19 @@
"files": {
"ignoreUnknown": true,
"includes": [
"**/*.js",
"**/*.jsx",
"**/*.ts",
"**/*.tsx",
"**/*.json",
"**/*.css",
"**/*.scss"
],
"experimentalScannerIgnores": [
"**/node_modules/**",
"**/.next/**",
"**/dist/**",
"**/build/**",
"**/coverage/**",
"**/.turbo/**",
"**/out/**",
"**/.env*",
"**/public/**",
"**/*.d.ts",
"**/storybook-static/**",
"**/.vercel/**"
"**",
"!!**/node_modules",
"!!**/.next",
"!!**/dist",
"!!**/build",
"!!**/coverage",
"!!**/.turbo",
"!!**/out",
"!!**/storybook-static",
"!!**/.vercel",
"!**/.env*",
"!**/public",
"!**/*.d.ts"
]
},
"formatter": {
@ -164,6 +156,7 @@
"noUnreachableSuper": "error",
"noUnsafeFinally": "error",
"noUnsafeOptionalChaining": "error",
"noUnusedFunctionParameters": "warn",
"noUnusedLabels": "error",
"noUnusedVariables": "warn",
"useExhaustiveDependencies": "info",

View File

@ -1,6 +1,6 @@
{
"name": "@erp/auth",
"version": "0.6.4",
"version": "0.6.5",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -1,5 +1,3 @@
import { IPercentageDTO } from "@erp/core";
export interface IGetProfileResponseDTO {
id: string;
name: string;

View File

@ -1,11 +1,13 @@
{
"name": "@erp/core",
"version": "0.6.4",
"version": "0.6.5",
"private": true,
"type": "module",
"sideEffects": false,
"scripts": {
"typecheck": "tsc -p tsconfig.json --noEmit",
"check": "biome check .",
"lint": "biome lint .",
"clean": "rimraf .turbo node_modules dist"
},
"exports": {

View File

@ -68,7 +68,9 @@ export class InMemoryMapperRegistry implements IMapperRegistry {
}
registerQueryMappers(mappers: Array<{ key: MapperQueryKey; mapper: any }>): this {
mappers.forEach(({ key, mapper }) => this.registerQueryMapper(key, mapper));
for (const { key, mapper } of mappers) {
this.registerQueryMapper(key, mapper);
}
return this;
}
}

View File

@ -1,4 +1,4 @@
import { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from "axios";
import type { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from "axios";
/**
* Configura interceptores para una instancia de Axios.

View File

@ -1,6 +1,7 @@
import { ReactNode } from "react";
import { RouteObject } from "react-router-dom";
import { ModuleMetadata } from "../../../common";
import type { ReactNode } from "react";
import type { RouteObject } from "react-router-dom";
import type { ModuleMetadata } from "../../../common";
export type ModuleClientParams = { [key: string]: any };

View File

@ -1,11 +1,13 @@
{
"name": "@erp/customer-invoices",
"version": "0.6.4",
"version": "0.6.5",
"private": true,
"type": "module",
"sideEffects": false,
"scripts": {
"typecheck": "tsc -p tsconfig.json --noEmit",
"check": "biome check .",
"lint": "biome lint .",
"clean": "rimraf .turbo node_modules dist"
},
"exports": {

View File

@ -1,8 +1,12 @@
import type { ICatalogs } from "@erp/core/api";
import {
type IProformaToIssuedInvoiceConverter,
ProformaToIssuedInvoiceConverter,
} from "../services";
export function buildProformaToIssuedInvoicePropsConverter(): IProformaToIssuedInvoiceConverter {
return new ProformaToIssuedInvoiceConverter();
export function buildProformaToIssuedInvoicePropsConverter(
catalogs: ICatalogs
): IProformaToIssuedInvoiceConverter {
return new ProformaToIssuedInvoiceConverter(catalogs);
}

View File

@ -1,8 +1,13 @@
import { UtcDate } from "@repo/rdx-ddd";
// modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.ts
import type { JsonPaymentCatalogProvider } from "@erp/core";
import type { ICatalogs } from "@erp/core/api";
import { DomainError, UtcDate } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import {
type IIssuedInvoiceCreateProps,
InvoicePaymentMethod,
InvoiceStatus,
IssuedInvoiceItem,
IssuedInvoiceTax,
@ -14,87 +19,42 @@ export interface IProformaToIssuedInvoiceConverter {
toCreateProps(proforma: Proforma): Result<IIssuedInvoiceCreateProps, Error>;
}
/**
* Se encarga de generar las props de una Issued Invoice
* a partir de una Proforma en estado "issued".
* Ya se ha validado la proforma con las condiciones necesarias
* para generar la issued invoice.
*/
export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoiceConverter {
private readonly paymentCatalog: JsonPaymentCatalogProvider;
constructor(catalogs: ICatalogs) {
this.paymentCatalog = catalogs.paymentCatalog;
}
public toCreateProps(proforma: Proforma): Result<IIssuedInvoiceCreateProps, Error> {
const itemsOrResult = this.resolveItems(proforma);
if (itemsOrResult.isFailure) {
return Result.fail(itemsOrResult.error);
}
const taxesOrResult = this.resolveTaxes(proforma);
if (taxesOrResult.isFailure) {
return Result.fail(taxesOrResult.error);
}
const paymentOrResult = this.resolvePayment(proforma);
if (paymentOrResult.isFailure) {
return Result.fail(paymentOrResult.error);
}
const proformaTotals = proforma.totals();
const taxTotals = proforma.taxes();
const issuedItems: IssuedInvoiceItem[] = [];
for (const item of proforma.items.getAll()) {
const itemTotals = item.totals();
const itemOrResult = IssuedInvoiceItem.create({
description: item.description,
quantity: item.quantity,
unitAmount: item.unitAmount,
currencyCode: proforma.currencyCode,
languageCode: proforma.languageCode,
itemDiscountPercentage: item.itemDiscountPercentage,
itemDiscountAmount: itemTotals.itemDiscountAmount,
globalDiscountPercentage: item.globalDiscountPercentage,
globalDiscountAmount: itemTotals.globalDiscountAmount,
totalDiscountAmount: itemTotals.totalDiscountAmount,
ivaCode: item.ivaCode(),
ivaPercentage: item.ivaPercentage(),
ivaAmount: itemTotals.ivaAmount,
recCode: item.recCode(),
recPercentage: item.recPercentage(),
recAmount: itemTotals.recAmount,
retentionCode: item.retentionCode(),
retentionPercentage: item.retentionPercentage(),
retentionAmount: itemTotals.recAmount,
subtotalAmount: itemTotals.subtotalAmount,
taxableAmount: itemTotals.taxableAmount,
taxesAmount: itemTotals.taxesAmount,
totalAmount: itemTotals.totalAmount,
});
if (itemOrResult.isFailure) {
return Result.fail(itemOrResult.error);
}
issuedItems.push(itemOrResult.data);
}
const issuedTaxes: IssuedInvoiceTax[] = [];
for (const tax of taxTotals.getAll()) {
if (tax.ivaCode.isNone()) {
return Result.fail(new Error("IVA code is required"));
}
const taxOrResult = IssuedInvoiceTax.create({
taxableAmount: tax.taxableAmount,
ivaCode: tax.ivaCode.unwrap(),
ivaPercentage: tax.ivaPercentage.unwrap(),
ivaAmount: tax.ivaAmount,
recCode: tax.recCode,
recPercentage: tax.recPercentage,
recAmount: tax.recAmount,
retentionCode: tax.retentionCode,
retentionAmount: tax.retentionAmount,
retentionPercentage: tax.retentionPercentage,
taxesAmount: tax.taxesAmount,
});
if (taxOrResult.isFailure) {
return Result.fail(taxOrResult.error);
}
issuedTaxes.push(taxOrResult.data);
}
const issuedInvoiceProps: IIssuedInvoiceCreateProps = {
return Result.ok({
companyId: proforma.companyId,
status: InvoiceStatus.issued(),
@ -103,7 +63,8 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic
invoiceNumber: proforma.invoiceNumber,
invoiceDate: UtcDate.today(), // La fecha de la factura es la fecha de emisión, no la de la proforma
// La fecha de factura debe reflejar la emisión, no la fecha original de la proforma.
invoiceDate: UtcDate.today(),
operationDate: proforma.operationDate,
description: proforma.description.getOrUndefined()!,
@ -113,17 +74,17 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic
notes: proforma.notes,
reference: proforma.reference,
paymentMethod: proforma.paymentMethod,
paymentMethod: paymentOrResult.data,
customerId: proforma.customerId,
recipient: proforma.recipient.getOrUndefined()!,
items: issuedItems,
items: itemsOrResult.data,
taxes: IssuedInvoiceTaxes.create({
currencyCode: proforma.currencyCode,
languageCode: proforma.languageCode,
taxes: issuedTaxes,
taxes: taxesOrResult.data,
}),
subtotalAmount: proformaTotals.subtotalAmount,
@ -143,8 +104,127 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic
totalAmount: proformaTotals.totalAmount,
verifactu: Maybe.none(),
};
});
}
return Result.ok(issuedInvoiceProps);
private resolveItems(proforma: Proforma): Result<IssuedInvoiceItem[], Error> {
const issuedItems: IssuedInvoiceItem[] = [];
for (const item of proforma.items.getAll()) {
const itemOrResult = this.resolveItem(proforma, item);
if (itemOrResult.isFailure) {
return Result.fail(itemOrResult.error);
}
issuedItems.push(itemOrResult.data);
}
return Result.ok(issuedItems);
}
private resolveItem(
proforma: Proforma,
item: ReturnType<Proforma["items"]["getAll"]>[number]
): Result<IssuedInvoiceItem, Error> {
const itemTotals = item.totals();
return IssuedInvoiceItem.create({
description: item.description,
quantity: item.quantity,
unitAmount: item.unitAmount,
currencyCode: proforma.currencyCode,
languageCode: proforma.languageCode,
itemDiscountPercentage: item.itemDiscountPercentage,
itemDiscountAmount: itemTotals.itemDiscountAmount,
globalDiscountPercentage: item.globalDiscountPercentage,
globalDiscountAmount: itemTotals.globalDiscountAmount,
totalDiscountAmount: itemTotals.totalDiscountAmount,
ivaCode: item.ivaCode(),
ivaPercentage: item.ivaPercentage(),
ivaAmount: itemTotals.ivaAmount,
recCode: item.recCode(),
recPercentage: item.recPercentage(),
recAmount: itemTotals.recAmount,
retentionCode: item.retentionCode(),
retentionPercentage: item.retentionPercentage(),
retentionAmount: itemTotals.retentionAmount,
subtotalAmount: itemTotals.subtotalAmount,
taxableAmount: itemTotals.taxableAmount,
taxesAmount: itemTotals.taxesAmount,
totalAmount: itemTotals.totalAmount,
});
}
private resolveTaxes(proforma: Proforma): Result<IssuedInvoiceTax[], Error> {
const issuedTaxes: IssuedInvoiceTax[] = [];
for (const tax of proforma.taxes().getAll()) {
const taxOrResult = this.resolveTax(tax);
if (taxOrResult.isFailure) {
return Result.fail(taxOrResult.error);
}
issuedTaxes.push(taxOrResult.data);
}
return Result.ok(issuedTaxes);
}
private resolveTax(
tax: ReturnType<ReturnType<Proforma["taxes"]>["getAll"]>[number]
): Result<IssuedInvoiceTax, Error> {
if (tax.ivaCode.isNone()) {
return Result.fail(new Error("IVA code is required"));
}
return IssuedInvoiceTax.create({
taxableAmount: tax.taxableAmount,
ivaCode: tax.ivaCode.unwrap(),
ivaPercentage: tax.ivaPercentage.unwrap(),
ivaAmount: tax.ivaAmount,
recCode: tax.recCode,
recPercentage: tax.recPercentage,
recAmount: tax.recAmount,
retentionCode: tax.retentionCode,
retentionAmount: tax.retentionAmount,
retentionPercentage: tax.retentionPercentage,
taxesAmount: tax.taxesAmount,
});
}
private resolvePayment(proforma: Proforma): Result<InvoicePaymentMethod, Error> {
const paymentId = proforma.paymentMethodId.unwrap();
const existingPaymentResult = this.paymentCatalog.findById(paymentId.toString());
if (existingPaymentResult.isNone()) {
return Result.fail(
new DomainError("Missing payment method [ProformaToIssuedInvoiceConverter]")
);
}
const paymentMethodOrError = InvoicePaymentMethod.create(
{
paymentDescription: existingPaymentResult.unwrap().description,
},
paymentId
);
if (paymentMethodOrError.isFailure) {
return Result.fail(paymentMethodOrError.error);
}
return Result.ok(paymentMethodOrError.data);
}
}

View File

@ -26,10 +26,10 @@ export class IssuedInvoiceFullSnapshotBuilder implements IIssuedInvoiceFullSnaps
const verifactu = this.verifactuBuilder.toOutput(invoice);
const taxes = this.taxesBuilder.toOutput(invoice.taxes);
const payment_method = maybeToNullable(invoice.paymentMethod, (p) => ({
payment_id: p.id.toString(),
payment_description: p.paymentDescription.toString(),
}));
const payment_method = {
payment_id: invoice.paymentMethod.id.toString(),
payment_description: invoice.paymentMethod.paymentDescription.toString(),
};
return {
id: invoice.id.toString(),

View File

@ -5,15 +5,3 @@
import type { IssuedInvoiceRecipientSummaryDTO } from "../../../../../common/dto";
export type IIssuedInvoiceRecipientFullSnapshot = IssuedInvoiceRecipientSummaryDTO;
interface IIssuedInvoiceRecipientFullSnapshot2 {
id: string | null;
name: string | null;
tin: string | null;
street: string | null;
street2: string | null;
city: string | null;
province: string | null;
postal_code: string | null;
country: string | null;
}

View File

@ -11,7 +11,7 @@ export interface IIssuedInvoiceTaxesFullSnapshotBuilder
export class IssuedInvoiceTaxesFullSnapshotBuilder
implements IIssuedInvoiceTaxesFullSnapshotBuilder
{
private mapItem(invoiceTax: IssuedInvoiceTax, index: number): IIssuedInvoiceTaxFullSnapshot {
private mapItem(invoiceTax: IssuedInvoiceTax, _index: number): IIssuedInvoiceTaxFullSnapshot {
return {
taxable_amount: invoiceTax.taxableAmount.toObjectString(),

View File

@ -11,11 +11,11 @@ export interface IProformaInputMappers {
updateInputMapper: UpdateProformaInputMapper;
}
export const buildProformaInputMappers = (catalogs: ICatalogs): IProformaInputMappers => {
const { taxCatalog } = catalogs;
export const buildProformaInputMappers = (_catalogs: ICatalogs): IProformaInputMappers => {
//const { taxCatalog } = catalogs;
// Mappers el DTO a las props validadas (ProformaProps) y luego construir agregado
const createInputMapper = new CreateProformaInputMapper({ taxCatalog });
const createInputMapper = new CreateProformaInputMapper();
const updateInputMapper = new UpdateProformaInputMapper();
return {

View File

@ -49,7 +49,7 @@ export interface IUpdateProformaInputMapper {
export class UpdateProformaInputMapper implements IUpdateProformaInputMapper {
public map(
dto: UpdateProformaByIdRequestDTO,
params: { companyId: UniqueID }
_params: { companyId: UniqueID }
): Result<ProformaPatchProps> {
try {
const errors: ValidationErrorDetail[] = [];

View File

@ -1,7 +1,7 @@
import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { Proforma, type ProformaCreateProps } from "../../../domain";
import { type IProformaCreateProps, Proforma } from "../../../domain";
import type { IProformaRepository } from "../repositories";
import type { IProformaNumberGenerator } from "./proforma-number-generator.interface";
@ -9,7 +9,7 @@ import type { IProformaNumberGenerator } from "./proforma-number-generator.inter
export interface IProformaCreatorParams {
companyId: UniqueID;
id: UniqueID;
props: Omit<ProformaCreateProps, "invoiceNumber">;
props: Omit<IProformaCreateProps, "invoiceNumber">;
transaction: unknown;
}

View File

@ -1,6 +1,6 @@
import type { DocumentGenerationService } from "@erp/core/api";
import type { ProformaReportSnapshot } from "../application-models";
import type { ProformaReportSnapshot } from "../snapshot-builders";
export interface ProformaDocumentGeneratorService
extends DocumentGenerationService<ProformaReportSnapshot> {}

View File

@ -1,6 +1,6 @@
import type { IDocumentMetadata, IDocumentMetadataFactory } from "@erp/core/api";
import type { ProformaReportSnapshot } from "../application-models";
import type { ProformaReportSnapshot } from "../snapshot-builders";
/**
* Construye los metadatos del documento PDF de una factura emitida.

View File

@ -1,6 +1,6 @@
import type { IDocumentProperties, IDocumentPropertiesFactory } from "@erp/core/api";
import type { ProformaReportSnapshot } from "../application-models";
import type { ProformaReportSnapshot } from "../snapshot-builders";
/**
* Construye los metadatos del documento PDF de una factura emitida.

View File

@ -9,7 +9,7 @@ export interface IProformaTaxesFullSnapshotBuilder
extends ISnapshotBuilder<Collection<IProformaTaxTotals>, TaxesBreakdownDTO[]> {}
export class ProformaTaxesFullSnapshotBuilder implements IProformaTaxesFullSnapshotBuilder {
private mapItem(proformaTax: IProformaTaxTotals, index: number): TaxesBreakdownDTO {
private mapItem(proformaTax: IProformaTaxTotals, _index: number): TaxesBreakdownDTO {
return {
taxable_amount: proformaTax.taxableAmount.toObjectString(),

View File

@ -50,7 +50,7 @@ export interface IIssuedInvoiceCreateProps {
languageCode: LanguageCode;
currencyCode: CurrencyCode;
paymentMethod: Maybe<InvoicePaymentMethod>;
paymentMethod: InvoicePaymentMethod;
items: IIssuedInvoiceItemCreateProps[];
taxes: IssuedInvoiceTaxes;
@ -218,7 +218,7 @@ export class IssuedInvoice
return this.props.recipient;
}
public get paymentMethod(): Maybe<InvoicePaymentMethod> {
public get paymentMethod(): InvoicePaymentMethod {
return this.props.paymentMethod;
}
@ -288,6 +288,6 @@ export class IssuedInvoice
}
public get hasPaymentMethod() {
return this.paymentMethod.isSome();
return Boolean(this.paymentMethod.id);
}
}

View File

@ -297,6 +297,10 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> implements IP
}
public issue(): Result<void, Error> {
// Antes de cambiar el estado de la proforma,
// comprobamos que se cumplen las condiciones
// necesarias.
if (!this.props.status.canTransitionTo("issued")) {
return Result.fail(
new DomainValidationError(

View File

@ -61,7 +61,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
const inputMappers = buildProformaInputMappers(catalogs);
const finder = buildProformaFinder(repository);
const creator = buildProformaCreator({ numberService: proformaNumberService, repository });
const proformaToIssuedInvoiceConverter = buildProformaToIssuedInvoicePropsConverter();
const proformaToIssuedInvoiceConverter = buildProformaToIssuedInvoicePropsConverter(catalogs);
const issuer = buildProformaIssuer({
proformaConverter: proformaToIssuedInvoiceConverter,

View File

@ -1,109 +0,0 @@
import { TaxItemType } from "@erp/core";
import type { Dinero } from "dinero.js";
import { InvoiceItemTaxSummary } from "./calculate-invoice-item-amounts";
import { toDinero } from "./calculate-utils";
export interface InvoiceItemsTotalsInput {
subtotal_amount: number;
discount_amount: number;
taxable_amount: number;
taxes_amount: number;
taxes_summary: InvoiceItemTaxSummary[];
total_amount: number;
}
export interface InvoiceHeaderCalcResult {
subtotal_amount: number;
items_discount_amount: number;
discount_amount: number;
taxable_amount: number;
taxes_summary: InvoiceItemTaxSummary[];
taxes_amount: number;
total_amount: number;
}
const toNum = (d: Dinero.Dinero) => d.toUnit();
/**
* Agrega los importes de todas las líneas y recalcula los totales generales
* con precisión financiera (sumas exactas en céntimos).
*/
export function calculateInvoiceHeaderAmounts(
items: InvoiceItemsTotalsInput[],
discount_percentage: number,
currency: string
): InvoiceHeaderCalcResult {
const defaultScale = 2;
let items_subtotal = toDinero(0, defaultScale, currency);
let items_discount = toDinero(0, defaultScale, currency);
let items_taxable = toDinero(0, defaultScale, currency);
let items_taxes = toDinero(0, defaultScale, currency);
let items_total = toDinero(0, defaultScale, currency);
const items_taxes_summary: InvoiceItemTaxSummary[] = [];
for (const item of items) {
items_subtotal = items_subtotal.add(toDinero(item.subtotal_amount, defaultScale, currency));
items_discount = items_discount.add(toDinero(item.discount_amount, defaultScale, currency));
items_taxable = items_taxable.add(toDinero(item.taxable_amount, defaultScale, currency));
items_taxes = items_taxes.add(toDinero(item.taxes_amount, defaultScale, currency));
items_total = items_total.add(toDinero(item.total_amount, defaultScale, currency));
items_taxes_summary.push(...item.taxes_summary);
}
// Descuento = subtotal × (item_pct / 100)
const discount_amount = items_taxable.percentage(discount_percentage);
return {
subtotal_amount: toNum(items_subtotal),
items_discount_amount: toNum(items_discount),
discount_amount: toNum(discount_amount),
taxable_amount: toNum(items_taxable),
taxes_amount: toNum(items_taxes),
total_amount: toNum(items_total),
taxes_summary: calculateTaxesSummary(items_taxes_summary, currency),
};
}
function calculateTaxesSummary(
items_summary: InvoiceItemTaxSummary[],
currency: string
): InvoiceItemTaxSummary[] {
const defaultScale = 2;
const summaryMap = new Map<
string,
{ tax: TaxItemType; taxable_amount: Dinero; taxes_amount: Dinero }
>();
for (const item of items_summary) {
const { taxable_amount, taxes_amount, ...tax } = item;
const key = tax.code;
const current = summaryMap.get(key) ?? {
tax,
taxable_amount: toDinero(0, defaultScale, currency),
taxes_amount: toDinero(0, defaultScale, currency),
};
summaryMap.set(key, {
tax: current.tax,
taxable_amount: current.taxable_amount.add(toDinero(taxable_amount, defaultScale, currency)),
taxes_amount: current.taxes_amount.add(toDinero(taxes_amount, defaultScale, currency)),
});
}
// Convertimos el mapa en un array con números desescalados
const result: InvoiceItemTaxSummary[] = [];
for (const { tax, taxable_amount, taxes_amount } of summaryMap.values()) {
result.push({
...tax,
taxable_amount: taxable_amount.toUnit(),
taxes_amount: taxes_amount.toUnit(),
});
}
// Los devolvermos ordenador: primero los que suman,
// luego los que restan: IVA, IGIC, IPSI, Recargo de equivalencia, Retención.
return result.sort((a, b) => a.name.localeCompare(b.name));
}

View File

@ -1,91 +0,0 @@
import { TaxCatalogProvider, TaxItemType } from "@erp/core";
import { toDinero, toNum } from "./calculate-utils";
export interface InvoiceItemCalcInput {
quantity?: string; // p.ej. "3.5"
unit_amount?: string; // p.ej. "125.75"
discount_percentage?: string; // p.ej. "10" (=> 10%)
tax_codes: string[]; // ["iva_21", ...]
}
export type InvoiceItemTaxSummary = TaxItemType & {
taxable_amount: number;
taxes_amount: number;
};
export interface InvoiceItemCalcResult {
subtotal_amount: number;
discount_amount: number;
taxable_amount: number;
taxes_amount: number;
taxes_summary: InvoiceItemTaxSummary[];
total_amount: number;
}
/**
* Cálculo financiero exacto por línea de factura.
* Usa Dinero.js (base 10^2 y round half up) resultados seguros en céntimos.
*/
export function calculateInvoiceItemAmounts(
item: InvoiceItemCalcInput,
currency: string,
taxCatalog: TaxCatalogProvider
): InvoiceItemCalcResult {
const defaultScale = 4;
const taxesSummary: InvoiceItemTaxSummary[] = [];
const qty = Number.parseFloat(item.quantity || "0") || 0;
const unit = Number.parseFloat(item.unit_amount || "0") || 0;
const iten_pct = Number.parseFloat(item.discount_percentage || "0") || 0;
// Subtotal = cantidad × precio unitario
const subtotal_amount = toDinero(unit, defaultScale, currency).multiply(qty);
// Descuento = subtotal × (item_pct / 100)
const discount_amount = subtotal_amount.percentage(iten_pct);
const subtotal_w_discount_amount = subtotal_amount.subtract(discount_amount);
// Base imponible
const taxable_amount = subtotal_w_discount_amount;
// Impuestos acumulados con signo
let taxes_amount = toDinero(0, defaultScale, currency);
for (const code of item.tax_codes ?? []) {
const taxItemType = taxCatalog.findByCode(code);
if (taxItemType.isNone()) continue;
const taxItem = taxItemType.unwrap();
const tax_pct_value =
Number.parseFloat(taxItem.value) / 10 ** Number.parseInt(taxItem.scale, 10);
const item_taxables_amount = taxable_amount.percentage(tax_pct_value);
// Sumar o restar según grupo
switch (taxItem.group.toLowerCase()) {
case "retención":
taxes_amount = taxes_amount.subtract(item_taxables_amount);
break;
default:
taxes_amount = taxes_amount.add(item_taxables_amount);
break;
}
taxesSummary.push({
...taxItem,
taxable_amount: toNum(taxable_amount),
taxes_amount: toNum(item_taxables_amount),
});
}
const total = taxable_amount.add(taxes_amount);
return {
subtotal_amount: toNum(subtotal_amount),
discount_amount: toNum(discount_amount),
taxable_amount: toNum(taxable_amount),
taxes_amount: toNum(taxes_amount),
taxes_summary: taxesSummary,
total_amount: toNum(total),
};
}

View File

@ -1,12 +0,0 @@
import DineroFactory, { type Currency } from "dinero.js";
// Función auxiliar para convertir a Dinero
export const toDinero = (n: number, scale: number, currency: string) =>
DineroFactory({
amount: n === 0 ? 0 : Math.round(n * 10 ** scale),
precision: scale,
currency: currency as Currency,
});
// Función auxiliar que devuelve el valor de Dinero
export const toNum = (d: DineroFactory.Dinero) => d.toUnit();

View File

@ -1,2 +0,0 @@
export * from "./calculate-invoice-header-amounts";
export * from "./calculate-invoice-item-amounts";

View File

@ -1,6 +0,0 @@
export * from "./calcs";
export * from "./use-create-customer-invoice-mutation";
export * from "./use-customer-invoices-query";
export * from "./use-invoice-query";
export * from "./use-items-table-navigation";
export * from "./use-pinned-preview-sheet";

View File

@ -1,48 +0,0 @@
import { useDataSource } from "@erp/core/hooks";
import { UniqueID, ValidationErrorCollection } from "@repo/rdx-ddd";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { CreateProformaRequestSchema } from "../../common";
import type { Proforma } from "../proformas/types/proforma.api.schema";
import type { InvoiceFormData } from "../schemas";
type CreateCustomerInvoicePayload = {
data: InvoiceFormData;
};
export const useCreateProforma = () => {
const queryClient = useQueryClient();
const dataSource = useDataSource();
const schema = CreateProformaRequestSchema;
return useMutation<Proforma, DefaultError, CreateCustomerInvoicePayload>({
mutationKey: ["customer-invoice:create"],
mutationFn: async (payload) => {
const { data } = payload;
const invoiceId = UniqueID.generateNewID();
const newInvoiceData = {
...data,
id: invoiceId.toString(),
};
const result = schema.safeParse(newInvoiceData);
if (!result.success) {
// Construye errores detallados
const validationErrors = result.error.issues.map((err) => ({
field: err.path.join("."),
message: err.message,
}));
throw new ValidationErrorCollection("Validation failed", validationErrors);
}
const created = await dataSource.createOne("customer-invoices", newInvoiceData);
return created;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["customer-invoices"] });
},
});
};

View File

@ -1,40 +0,0 @@
import { CriteriaDTO } from '@erp/core';
import { useDataSource } from "@erp/core/hooks";
import { DefaultError, QueryKey, useQuery } from "@tanstack/react-query";
import { CustomerInvoicesPage } from '../schemas';
export const CUSTOMER_INVOICES_QUERY_KEY = (criteria: CriteriaDTO): QueryKey => [
"customer_invoices", {
pageNumber: criteria.pageNumber ?? 0,
pageSize: criteria.pageSize ?? 10,
q: criteria.q ?? "",
filters: criteria.filters ?? [],
orderBy: criteria.orderBy ?? "",
order: criteria.order ?? "",
},
];
type InvoicesQueryOptions = {
enabled?: boolean;
criteria?: CriteriaDTO
};
// Obtener todas las facturas
export const useInvoicesQuery = (options?: InvoicesQueryOptions) => {
const dataSource = useDataSource();
const enabled = options?.enabled ?? true;
const criteria = options?.criteria ?? {};
return useQuery<CustomerInvoicesPage, DefaultError>({
queryKey: CUSTOMER_INVOICES_QUERY_KEY(criteria),
queryFn: async ({ signal }) => {
return await dataSource.getList<CustomerInvoicesPage>("customer-invoices", {
signal,
...criteria,
});
},
enabled,
placeholderData: (previousData, previousQuery) => previousData, // Mantener datos previos mientras se carga nueva datos (antiguo `keepPreviousData`)
});
};

View File

@ -1,92 +0,0 @@
import { Checkbox } from "@repo/shadcn-ui/components";
import { ColumnDef } from "@tanstack/react-table";
import { useId, useMemo } from "react";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function useDetailColumns<TData>(
columns: ReadonlyArray<ColumnDef<TData>>,
{
enableDragHandleColumn = false,
enableSelectionColumn = false,
enableActionsColumn = false,
rowActionFn,
}: {
enableDragHandleColumn?: boolean;
enableSelectionColumn?: boolean;
enableActionsColumn?: boolean;
rowActionFn?: DataTablaRowActionFunction<TData>;
} = {}
): ColumnDef<TData>[] {
const idPrefix = useId();
return useMemo(() => {
const baseColumns: ColumnDef<TData>[] = [...columns];
if (enableDragHandleColumn) {
baseColumns.unshift({
id: "row_drag_handle",
header: () => null,
cell: (info) => <DataTableRowDragHandleCell rowId={info.row.id} />,
enableSorting: false,
enableHiding: false,
size: 40,
});
}
if (enableSelectionColumn) {
baseColumns.unshift({
id: "select",
header: ({ table }) => (
<Checkbox
id={`${idPrefix}-select-all`}
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label='Seleccionar todo'
className='translate-y-[0px]'
/>
),
cell: ({ row }) => (
<Checkbox
id={`${idPrefix}-select-row-${row.id}`}
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
onCheckedChange={row.getToggleSelectedHandler()}
aria-label='Seleccionar fila'
className='mt-2'
/>
),
enableSorting: false,
enableHiding: false,
size: 40,
});
}
if (enableActionsColumn) {
const RowActionsCell = (props: any) => (
<DataTableRowActions rowContext={props} actions={rowActionFn} />
);
baseColumns.push({
id: "row_actions",
header: () => null,
cell: RowActionsCell,
enableSorting: false,
enableHiding: false,
size: 48,
});
}
return baseColumns;
}, [
columns,
rowActionFn,
idPrefix,
enableDragHandleColumn,
enableSelectionColumn,
enableActionsColumn,
]);
}

View File

@ -1,103 +0,0 @@
import { FC, ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from "react-dom";
export type UseInvoicePreviewOptions<T> = {
persistKey?: string; // clave para guardar el pin en localStorage
pinnedWidthClass?: string; // ancho al anclar (Tailwind), p.ej. "w-[500px]"
onOpenChange?: (open: boolean) => void; // callback opcional
};
export type InvoicePreviewHook<T> = {
isOpen: boolean;
isPinned: boolean;
item: T | null;
open: (item: T) => void;
close: () => void;
togglePin: () => void;
/** Añade margen derecho al listado si está anclado */
containerClassName: string;
/** Renderiza el preview en un portal (body). Debes pasar tu Card como children render-prop */
PreviewPortal: FC<{
children: (p: {
item: T;
isOpen: boolean;
isPinned: boolean;
onClose: () => void;
onTogglePin: () => void;
}) => ReactNode
}>;
};
export function useInvoicePreview<T = unknown>(
opts?: UseInvoicePreviewOptions<T>
): InvoicePreviewHook<T> {
const { persistKey = "invoice-preview-pin", pinnedWidthClass = "w-[500px]", onOpenChange } = opts ?? {};
const [isOpen, setOpen] = useState(false);
const [item, setItem] = useState<T | null>(null);
const [isPinned, setPinned] = useState<boolean>(() => {
try { return localStorage.getItem(persistKey) === "1"; } catch { return false; }
});
// Guardar y restaurar foco al cerrar (mejor accesibilidad)
const lastFocusedRef = useRef<HTMLElement | null>(null);
const rememberFocus = () => { lastFocusedRef.current = (document.activeElement as HTMLElement) ?? null; };
const restoreFocus = () => { lastFocusedRef.current?.focus?.(); };
const open = useCallback((next: T) => {
rememberFocus();
setItem(next);
setOpen(true);
onOpenChange?.(true);
}, [onOpenChange]);
const close = useCallback(() => {
if (isPinned) return; // anclado no se cierra
setOpen(false);
onOpenChange?.(false);
setTimeout(restoreFocus, 0);
}, [isPinned, onOpenChange]);
const togglePin = useCallback(() => {
setPinned((prev) => {
const next = !prev;
try { localStorage.setItem(persistKey, next ? "1" : "0"); } catch { }
return next;
});
}, [persistKey]);
// Bloqueo de scroll cuando está abierto y NO anclado
useEffect(() => {
if (isOpen && !isPinned) {
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = prev; };
}
}, [isOpen, isPinned]);
// Cerrar con ESC (solo si no está anclado)
useEffect(() => {
if (!isOpen || isPinned) return;
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") close(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [isOpen, isPinned, close]);
const containerClassName = isPinned ? `mr-[--preview-width] [--preview-width:theme(spacing.0)] ${pinnedWidthClass ? "" : ""}` : "";
// Nota: preferimos aplicar el margen directamente en el layout (ver uso abajo)
const PreviewPortal: InvoicePreviewHook<T>["PreviewPortal"] = useCallback(({ children }) => {
if (!item) return null;
const node = children({
item,
isOpen,
isPinned,
onClose: close,
onTogglePin: togglePin,
});
return createPortal(node as ReactNode, document.body);
}, [item, isOpen, isPinned, close, togglePin]);
return { isOpen, isPinned, item, open, close, togglePin, containerClassName, PreviewPortal };
}

View File

@ -1,49 +0,0 @@
import { useDataSource } from "@erp/core/hooks";
import { type DefaultError, type QueryKey, useQuery } from "@tanstack/react-query";
import type { Proforma } from "../schemas";
export const CUSTOMER_INVOICE_QUERY_KEY = (id: string): QueryKey =>
["customer_invoice", id] as const;
type InvoiceQueryOptions = {
enabled?: boolean;
};
export const useInvoiceQuery = (invoiceId?: string, options?: InvoiceQueryOptions) => {
const dataSource = useDataSource();
const enabled = (options?.enabled ?? true) && !!invoiceId;
return useQuery<Proforma, DefaultError>({
queryKey: CUSTOMER_INVOICE_QUERY_KEY(invoiceId ?? "unknown"),
queryFn: async (context) => {
const { signal } = context;
if (!invoiceId) throw new Error("invoiceId is required");
return await dataSource.getOne<Proforma>("customer-invoices", invoiceId, {
signal,
});
},
enabled,
});
};
/*
export function useQuery<
TQueryFnData = unknown,
TError = unknown,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey
>
TQueryFnData: the type returned from the queryFn.
TError: the type of Errors to expect from the queryFn.
TData: the type our data property will eventually have.
Only relevant if you use the select option,
because then the data property can be different
from what the queryFn returns.
Otherwise, it will default to whatever the queryFn returns.
TQueryKey: the type of our queryKey, only relevant
if you use the queryKey that is passed to your queryFn.
*/

View File

@ -1,131 +0,0 @@
import * as React from "react";
import { FieldValues, Path, UseFormReturn, useFieldArray } from "react-hook-form";
interface UseItemsTableNavigationOptions<TFieldValues extends FieldValues> {
/** Nombre del array de líneas en el formulario (tipo-safe) */
name: Path<TFieldValues>;
/** Creador de una línea vacía */
createEmpty: () => unknown; // ajusta el tipo del item si lo conoces
/** Primer campo editable de la fila */
firstEditableField?: string;
}
export function useItemsTableNavigation<TFieldValues extends FieldValues = FieldValues>(
form: UseFormReturn<TFieldValues>,
{
name,
createEmpty,
firstEditableField = "description",
}: UseItemsTableNavigationOptions<TFieldValues>
) {
const { control, getValues, setFocus } = form;
const fa = useFieldArray<TFieldValues>({ control, name });
// Desestructurar para evitar recreaciones
const { append, insert, remove: faRemove, move } = fa;
// Ref estable para getValues
const getValuesRef = React.useRef(getValues);
getValuesRef.current = getValues;
const length = React.useCallback(() => {
const arr = getValuesRef.current(name) as unknown[];
return Array.isArray(arr) ? arr.length : 0;
}, [name]);
const focusRowFirstField = React.useCallback(
(rowIndex: number) => {
queueMicrotask(() => {
try {
setFocus(`${name}.${rowIndex}.${firstEditableField}` as any, {
shouldSelect: true,
});
} catch {
// el campo aún no está montado
}
});
},
[name, firstEditableField, setFocus]
);
const addEmpty = React.useCallback(
(atEnd = true, index?: number, initial?: Record<string, unknown>) => {
const row = { ...createEmpty(), ...(initial ?? {}) };
if (!atEnd && typeof index === "number") insert(index, row);
else append(row);
},
[append, insert, createEmpty]
);
const duplicate = React.useCallback(
(i: number) => {
const curr = getValuesRef.current(`${name}.${i}`) as Record<string, unknown> | undefined;
if (!curr) return;
const clone =
typeof structuredClone === "function"
? structuredClone(curr)
: JSON.parse(JSON.stringify(curr));
const { id: _id, ...sanitized } = clone;
insert(i + 1, sanitized);
},
[insert, name]
);
const remove = React.useCallback(
(i: number) => {
if (i < 0 || i >= length()) return;
faRemove(i);
},
[faRemove, length]
);
const moveUp = React.useCallback(
(i: number) => {
if (i <= 0) return;
move(i, i - 1);
},
[move]
);
const moveDown = React.useCallback(
(i: number) => {
const len = length();
if (i < 0 || i >= len - 1) return;
move(i, i + 1);
},
[move, length]
);
const onTabFromLastCell = React.useCallback(
(rowIndex: number) => {
const len = length();
if (rowIndex === len - 1) {
addEmpty(true);
focusRowFirstField(len);
} else {
focusRowFirstField(rowIndex + 1);
}
},
[length, addEmpty, focusRowFirstField]
);
const onShiftTabFromFirstCell = React.useCallback(
(rowIndex: number) => {
if (rowIndex <= 0) return;
focusRowFirstField(rowIndex - 1);
},
[focusRowFirstField]
);
return {
fieldArray: fa, // { fields, append, remove, insert, move, ... }
addEmpty,
duplicate,
remove,
moveUp,
moveDown,
onTabFromLastCell,
onShiftTabFromFirstCell,
focusRowFirstField,
};
}

View File

@ -1,128 +0,0 @@
import { Sheet, SheetContent } from "@repo/shadcn-ui/components";
// hooks/use-pinned-preview-sheet.ts
import * as React from "react";
import { createPortal } from 'react-dom';
type UsePinnedPreviewSheetOptions<T> = {
persistKey?: string; // clave localStorage para “pin”
widthClass?: string; // ancho del panel: p. ej. "w-[500px]"
onOpenChange?: (open: boolean) => void;
title?: string | ((item: T | null) => string); // Título del Sheet (no anclado)
};
export type PinnedPreviewSheet<T> = {
/** Estado */
isOpen: boolean;
isPinned: boolean;
item: T | null;
open: (item: T) => void;
close: () => void;
togglePin: () => void;
/** Añade margen al contenedor de la lista cuando está anclado */
listRightMarginClass: string;
/** Renderiza el panel (Sheet o aside) */
Preview: React.FC<{
children: (ctx: {
item: T;
isPinned: boolean;
close: () => void;
togglePin: () => void;
}) => React.ReactNode
}>;
};
export function usePinnedPreviewSheet<T = unknown>({
persistKey = "preview-pin",
widthClass = "w-[500px]",
onOpenChange,
title,
}: UsePinnedPreviewSheetOptions<T> = {}): PinnedPreviewSheet<T> {
const [isOpen, setOpen] = React.useState(false);
const [item, setItem] = React.useState<T | null>(null);
const [isPinned, setPinned] = React.useState<boolean>(() => {
try { return localStorage.getItem(persistKey) === "1"; } catch { return false; }
});
// recordar/restaurar foco para accesibilidad
const lastFocused = React.useRef<HTMLElement | null>(null);
const rememberFocus = () => { lastFocused.current = document.activeElement as HTMLElement | null; };
const restoreFocus = () => { lastFocused.current?.focus?.(); };
const open = React.useCallback((next: T) => {
rememberFocus();
setItem(next);
setOpen(true);
onOpenChange?.(true);
}, [onOpenChange]);
const close = React.useCallback(() => {
if (isPinned) return;
setOpen(false);
onOpenChange?.(false);
setTimeout(restoreFocus, 0);
}, [isPinned, onOpenChange]);
const togglePin = React.useCallback(() => {
setPinned((p) => {
const n = !p;
try { localStorage.setItem(persistKey, n ? "1" : "0"); } catch { }
return n;
});
}, [persistKey]);
// Bloquea scroll solo en modo Sheet
React.useEffect(() => {
if (isOpen && !isPinned) {
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = prev; };
}
}, [isOpen, isPinned]);
// Cerrar con ESC solo en modo Sheet
React.useEffect(() => {
if (!isOpen || isPinned) return;
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") close(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [isOpen, isPinned, close]);
const listRightMarginClass = isPinned ? "mr-[500px]" : ""; // ajusta si cambias widthClass
const HeaderTitle = React.useMemo(() => {
if (!item) return "";
if (typeof title === "function") return title(item);
return title ?? "";
}, [item, title]);
const Preview: PinnedPreviewSheet<T>["Preview"] = React.useCallback(({ children }) => {
if (!item) return null;
// Panel anclado: aside estático sin overlay
if (isPinned) {
return createPortal(
<aside
aria-label="Vista previa anclada"
className={`fixed inset-y-0 right-0 ${widthClass} bg-background border-l z-40`}
>
{children({ item, isPinned: true, close, togglePin })}
</aside>,
document.body
);
}
// Panel no anclado: Sheet de shadcn/ui (con overlay y accesibilidad)
return createPortal(
<Sheet open={isOpen} onOpenChange={(o) => (o ? setOpen(true) : close())}>
<SheetContent side="right" className={`${widthClass} p-0`}>
{children({ item, isPinned: false, close, togglePin })}
</SheetContent>
</Sheet>,
document.body
);
}, [item, isPinned, isOpen, widthClass, close, togglePin]);
return { isOpen, isPinned, item, open, close, togglePin, listRightMarginClass, Preview };
}

View File

@ -50,16 +50,19 @@ export function useProformasGridColumns(
() => [
{
id: "select",
header: ({ table }) => (
<Checkbox
aria-label="Seleccionar todo"
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
/>
),
header: ({ table }) => {
const isAllSelected = table.getIsAllPageRowsSelected();
const isSomeSelected = table.getIsSomePageRowsSelected();
return (
<Checkbox
aria-checked={isSomeSelected ? "mixed" : isAllSelected}
aria-label="Seleccionar todo"
checked={isAllSelected}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
/>
);
},
cell: ({ row }) => (
<Checkbox
aria-label="Select row"
@ -345,323 +348,4 @@ export function useProformasGridColumns(
],
[t, actionHandlers]
);
return React.useMemo<ColumnDef<ProformaListRow, unknown>[]>(
() => [
/*{
id: "select",
header: ({ table }) => (
<Checkbox
aria-label="Seleccionar todo"
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
/>
),
cell: ({ row }) => (
<Checkbox
aria-label="Seleccionar fila"
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
/>
),
enableSorting: false,
enableHiding: false,
},*/
{
accessorKey: "invoiceNumber",
/*header: ({ column }) => {
return (
<Button
className="-ml-4 h-8 font-semibold"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
variant="ghost"
>
#
<ArrowUpDownIcon className="ml-2 size-4" />
</Button>
);
},*/
header: "#",
cell: ({ row }) => <div className="font-medium">{row.getValue("invoiceNumber")}</div>,
},
// Estado
{
accessorKey: "status",
header: "Estado",
cell: ({ row }) => {
const proforma = row.original;
const isIssued = proforma.status === "issued";
const invoiceId = proforma.linkedInvoiceId;
return (
<div className="flex items-center gap-2">
<ProformaStatusBadge status={proforma.status} />
{/* Enlace discreto a factura real */}
{isIssued && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Button
className="size-6 text-foreground hover:text-primary"
size="icon"
variant="ghost"
>
<a href={`/facturas/${invoiceId}`}>
<ExternalLinkIcon />
<span className="sr-only">Ver factura {invoiceId}</span>
</a>
</Button>
}
/>
<TooltipContent>Ver factura {invoiceId}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
);
},
},
// Cliente
{
accessorKey: "recipientName",
header: ({ column }) => {
return (
<Button
className="-ml-4 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
variant="ghost"
>
Cliente
<ArrowUpDownIcon className="ml-2 size-4" />
</Button>
);
},
cell: ({ row }) => {
const proforma = row.original;
return (
<div>
<button
className="text-primary hover:underline font-semibold text-ellipsis"
onClick={() => actionHandlers.onPreviewClick?.(proforma)}
type="button"
>
{proforma.recipient.name}
</button>
<div className="text-xs text-muted-foreground">{proforma.recipient.tin}</div>
</div>
);
},
},
{
accessorKey: "series",
header: "Serie",
},
{
accessorKey: "reference",
header: "Referencia",
cellClassName: "text-ellipsis",
},
{
accessorKey: "invoiceDate",
header: ({ column }) => {
return (
<Button
className="-ml-4 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
variant="ghost"
>
Fecha de proforma
<ArrowUpDownIcon className="ml-2 size-4" />
</Button>
);
},
cell: ({ row }) => (
<div className="font-medium text-left tabular-nums">
{DateHelper.format(row.original.invoiceDate)}
</div>
),
enableSorting: false,
meta: {
title: t("pages.issued_invoices.list.grid_columns.invoice_date"),
},
},
{
accessorKey: "operationDate",
header: ({ column }) => {
return (
<Button
className="-ml-4 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
variant="ghost"
>
Fecha de operación
<ArrowUpDownIcon className="ml-2 size-4" />
</Button>
);
},
},
{
accessorKey: "subtotalAmountFmt",
header: () => <div className="text-right">Subtotal</div>,
cell: ({ row }) => (
<div className="text-right tabular-nums font-medium">
{row.getValue("subtotalAmountFmt")}
</div>
),
},
{
accessorKey: "totalDiscountAmountFmt",
header: () => <div className="text-right">Descuentos</div>,
cell: ({ row }) => (
<div className="text-right tabular-nums font-medium">
{row.getValue("totalDiscountAmountFmt")}
</div>
),
},
{
accessorKey: "taxableAmountFmt",
header: () => <div className="text-right">Base imponible</div>,
cell: ({ row }) => (
<div className="text-right tabular-nums font-medium">
{row.getValue("taxableAmountFmt")}
</div>
),
},
{
accessorKey: "taxesAmountFmt",
header: () => <div className="text-right">Impuestos</div>,
cell: ({ row }) => (
<div className="text-right tabular-nums font-medium">
{row.getValue("taxesAmountFmt")}
</div>
),
},
{
accessorKey: "totalAmountFmt",
header: () => <div className="text-right">Importe total</div>,
cell: ({ row }) => (
<div className="text-right tabular-nums font-bold">{row.getValue("totalAmountFmt")}</div>
),
},
{
id: "actions",
meta: {
isActionsColumn: true,
},
header: "Acciones",
enableSorting: false,
cell: ({ row }) => {
const proforma = row.original;
const isIssued = proforma.status === PROFORMA_STATUS.ISSUED;
const isApproved = proforma.status === PROFORMA_STATUS.APPROVED;
const availableTransitions =
PROFORMA_STATUS_TRANSITIONS[proforma.status as ProformaStatus] ?? [];
return (
<div className="flex items-center gap-1">
{!isIssued && actionHandlers.onEditClick && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Button
className="size-8 cursor-pointer"
onClick={() => actionHandlers.onEditClick?.(proforma)}
size="icon"
variant="ghost"
>
<PencilIcon className="size-4" />
<span className="sr-only">Editar</span>
</Button>
}
/>
<TooltipContent>Editar</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Cambiar estado */}
{!isIssued && availableTransitions.length && actionHandlers.onChangeStatusClick && (
<TooltipProvider key={availableTransitions[0]}>
<Tooltip>
<TooltipTrigger
render={
<Button
className="size-8 cursor-pointer"
onClick={() =>
actionHandlers.onChangeStatusClick?.(proforma, availableTransitions[0])
}
size="icon"
variant="ghost"
>
<RefreshCwIcon className="size-4" />
<span className="sr-only">Cambiar estado</span>
</Button>
}
/>
<TooltipContent>
Cambiar a {t(`catalog.proformas.status.${availableTransitions[0]}.label`)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Emitir factura: solo si approved */}
{!isIssued && isApproved && actionHandlers.onIssueClick && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Button
className="size-8 cursor-pointer"
onClick={() => actionHandlers.onIssueClick?.(proforma)}
size="icon"
variant="ghost"
>
<FileTextIcon className="size-4" />
</Button>
}
/>
<TooltipContent>Emitir a factura</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Eliminar */}
{!isIssued && actionHandlers.onDeleteClick && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Button
className="size-8 text-destructive hover:text-destructive cursor-pointer"
onClick={(e) => {
e.preventDefault();
actionHandlers.onDeleteClick?.(proforma);
}}
size="icon"
variant="ghost"
>
<Trash2Icon className="size-4" />
</Button>
}
/>
<TooltipContent>Eliminar</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
);
},
},
],
[t, actionHandlers]
);
}

View File

@ -1,5 +1,3 @@
import { CustomerInvoiceSummary, CustomerInvoicesPage } from "./invoices.api.schema";
export type InvoiceSummaryFormData = CustomerInvoiceSummary & {
subtotal_amount_fmt: string;
subtotal_amount: number;

File diff suppressed because one or more lines are too long

View File

@ -1,12 +1,14 @@
{
"name": "@erp/customers",
"description": "Customers",
"version": "0.6.4",
"version": "0.6.5",
"private": true,
"type": "module",
"sideEffects": false,
"scripts": {
"typecheck": "tsc -p tsconfig.json --noEmit",
"check": "biome check .",
"lint": "biome lint .",
"clean": "rimraf .turbo node_modules dist"
},
"exports": {

View File

@ -1,7 +1,6 @@
import { EntityNotFoundError, ITransactionManager } from "@erp/core/api";
import { EntityNotFoundError, type ITransactionManager } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { CustomerApplicationService } from "../../application";
type DeleteCustomerUseCaseInput = {
companyId: UniqueID;

View File

@ -1,5 +1,4 @@
export * from "./create-customer.use-case";
export * from "./delete-customer.use-case";
export * from "./get-customer-by-id.use-case";
export * from "./list-customers.use-case";
export * from "./update/update-customer.use-case";

View File

@ -1,294 +0,0 @@
import { buildTextFilters } from "@erp/core/client";
import { LookupDialog } from "@repo/rdx-ui/components";
import {
Badge,
Button,
Card,
CardContent,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Label,
TableCell,
} from "@repo/shadcn-ui/components";
import { Building, Calendar, Mail, MapPin, Phone, Plus, User } from "lucide-react";
import { useState } from "react";
import DataTable, { type TableColumn } from "react-data-table-component";
import { useDebounce } from "use-debounce";
import type { ListCustomersResponseDTO } from "../../../common";
import { useCustomerListQuery } from "../hooks";
type Customer = ListCustomersResponseDTO["items"][number];
const columns: TableColumn<Customer>[] = [
{
name: "Nombre",
selector: (row) => row.name,
sortable: true,
},
{
name: "Email",
selector: (row) => row.email,
sortable: true,
},
{
name: "Empresa",
selector: (row) => row.trade_name ?? row.metadata?.company_name ?? "-",
sortable: false,
},
{
name: "Estado",
selector: (row) => row.status,
sortable: false,
},
];
const mockCustomers: Customer[] = [
{
id: "a1d2e3f4-5678-90ab-cdef-1234567890ab",
name: "Juan Pérez",
email: "juan@email.com",
phone: "+34 600 123 456",
company: "Tech Corp",
address: "Calle Mayor 123, Madrid",
createdAt: "2024-01-15",
status: "Activo",
},
{
id: "b1d2e3f4-5678-90ab-cdef-1234567890ab",
name: "María García",
email: "maria@email.com",
phone: "+34 600 789 012",
company: "Design Studio",
address: "Av. Diagonal 456, Barcelona",
createdAt: "2024-02-20",
status: "Activo",
},
{
id: "c1d2e3f4-5678-90ab-cdef-1234567890ab",
name: "Carlos López",
email: "carlos@email.com",
phone: "+34 600 345 678",
company: "Marketing Plus",
address: "Gran Vía 789, Valencia",
createdAt: "2024-01-30",
status: "Inactivo",
},
{
id: "d1d2e3f4-5678-90ab-cdef-1234567890ab",
name: "Ana Martínez",
email: "ana@email.com",
phone: "+34 600 901 234",
company: "Consulting Group",
address: "Calle Sierpes 321, Sevilla",
createdAt: "2024-03-10",
status: "Activo",
},
];
async function fetchClientes(search: string): Promise<Customer[]> {
await new Promise((res) => setTimeout(res, 500));
const mock: Customer[] = [
{
id: "a1",
name: "Juan Pérez",
email: "juan@email.com",
phone: "+34 600 123 456",
company: "Tech Corp",
address: "Madrid",
createdAt: "2024-01-15",
status: "Activo",
},
{
id: "b1",
name: "María García",
email: "maria@email.com",
phone: "+34 600 789 012",
company: "Design Studio",
address: "Barcelona",
createdAt: "2024-02-20",
status: "Activo",
},
];
return mock.filter(
(c) =>
c.name.toLowerCase().includes(search.toLowerCase()) ||
c.email.toLowerCase().includes(search.toLowerCase()) ||
c.company.toLowerCase().includes(search.toLowerCase())
);
}
export const ClientSelectorModal = () => {
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const [pageNumber, setPageNumber] = useState(1);
const [pageSize] = useState(10);
const [selectedCustomer, setSelectedCustomer] = useState<Customer | undefined>(undefined);
const [debouncedSearch] = useDebounce(search, 400);
const { data, isLoading, isError, refetch } = useCustomerListQuery({
filters: buildTextFilters(["name", "email", "trade_name"], debouncedSearch),
pageSize,
pageNumber,
});
const handleSelectClient = (event): void => {
event.preventDefault();
setOpen(true);
};
return (
<div className="space-y-4 max-w-2xl">
<div className="space-y-1">
<Label>Cliente</Label>
<Button className="w-full justify-start" onClick={handleSelectClient} variant="outline">
<User className="h-4 w-4 mr-2" />
{selectedCustomer ? selectedCustomer.name : "Seleccionar cliente"}
</Button>
</div>
{selectedCustomer && (
<Card>
<CardContent className="p-4 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<User className="h-6 w-6 text-primary" />
<h3 className="font-semibold">{selectedCustomer.name}</h3>
<Badge
className="text-xs"
variant={selectedCustomer.status === "Activo" ? "default" : "secondary"}
>
{selectedCustomer.status}
</Badge>
</div>
<span className="text-sm text-muted-foreground">{selectedCustomer.company}</span>
</div>
<p className="text-sm text-muted-foreground">{selectedCustomer.email}</p>
</CardContent>
</Card>
)}
<LookupDialog
description="Busca un cliente por nombre, email o empresa"
isError={isError}
isLoading={isLoading}
items={data?.items ?? []}
onCreate={(e) => {
e.preventDefault();
setOpen(true);
console.log("Crear nuevo cliente");
}}
onOpenChange={setOpen}
onPageChange={setPageNumber}
onSearchChange={setSearch}
onSelect={(customer) => {
setSelectedCustomer(customer);
setOpen(false);
}}
open={open}
page={pageNumber}
perPage={pageSize}
refetch={refetch}
renderContainer={(items) => (
<DataTable
columns={columns}
data={items}
highlightOnHover
noDataComponent="No se encontraron resultados"
onChangePage={(p) => setPageNumber(p)}
onRowClicked={(item) => {
setSelectedCustomer(item);
setOpen(false);
}}
pagination
paginationPerPage={pageSize}
paginationServer
paginationTotalRows={data?.total_items ?? 0}
pointerOnHover
/>
)}
renderItem={() => null}
search={search}
title="Seleccionar cliente" // No se usa con DataTable
totalItems={data?.total_items ?? 0}
/>
<Dialog onOpenChange={setIsCreateOpen} open={isCreateOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Plus className="size-5" />
Nuevo Cliente
</DialogTitle>
</DialogHeader>
<p className="text-muted-foreground text-sm mb-4">Formulario de creación pendiente</p>
<DialogFooter>
<Button onClick={() => setIsCreateOpen(false)} variant="outline">
Cancelar
</Button>
<Button>
<Plus className="h-4 w-4 mr-2" />
Crear
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
// COMPONENTES VISUALES
const CustomerCard = ({ customer }: { customer: Customer }) => (
<Card>
<CardContent className="p-4 space-y-2">
<div className="flex items-center gap-2">
<User className="size-5" />
<span className="font-semibold">{customer.name}</span>
<Badge variant={customer.status === "Activo" ? "default" : "secondary"}>
{customer.status}
</Badge>
</div>
<div className="text-sm text-muted-foreground flex flex-col gap-1">
<div className="flex items-center gap-1">
<Mail className="h-4 w-4" />
{customer.email}
</div>
<div className="flex items-center gap-1">
<Building className="h-4 w-4" />
{customer.company}
</div>
<div className="flex items-center gap-1">
<Phone className="h-4 w-4" />
{customer.phone}
</div>
<div className="flex items-center gap-1">
<MapPin className="h-4 w-4" />
{customer.address}
</div>
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{new Date(customer.createdAt).toLocaleDateString("es-ES")}
</div>
</div>
</CardContent>
</Card>
);
const CustomerRow = ({ customer }: { customer: Customer }) => (
<>
<TableCell>{customer.name}</TableCell>
<TableCell>{customer.email}</TableCell>
<TableCell>{customer.company}</TableCell>
<TableCell>
<Badge variant={customer.status === "Activo" ? "default" : "secondary"}>
{customer.status}
</Badge>
</TableCell>
</>
);

View File

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

View File

@ -1,73 +0,0 @@
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 { CustomerModalSelector } from "./customer-modal-selector";
type CustomerModalSelectorFieldProps<TFormValues extends FieldValues> = {
control: Control<TFormValues>;
name: FieldPath<TFormValues>;
label?: string;
description?: string;
orientation?: "vertical" | "horizontal" | "responsive";
disabled?: boolean;
required?: boolean;
readOnly?: boolean;
className?: string;
initiaCustomer?: unknown;
};
export function CustomerModalSelectorField<TFormValues extends FieldValues>({
control,
name,
label,
description,
orientation = "vertical",
disabled = false,
required = false,
readOnly = false,
className,
initiaCustomer = {},
}: CustomerModalSelectorFieldProps<TFormValues>) {
const isDisabled = disabled;
const isReadOnly = readOnly && !disabled;
return (
<Controller
control={control}
name={name}
render={({ field, fieldState }) => {
const { name, value, onChange, onBlur, ref } = field;
return (
<Field
className={cn("gap-1", className)}
data-invalid={fieldState.invalid}
orientation={orientation}
>
{label && (
<FieldLabel className="text-xs text-muted-foreground text-nowrap" htmlFor={name}>
{label}
</FieldLabel>
)}
<CustomerModalSelector
disabled={isDisabled}
initialCustomer={initiaCustomer as CustomerSummary}
onValueChange={onChange}
readOnly={isReadOnly}
value={value}
/>
</Field>
);
}}
/>
);
}

View File

@ -1,181 +0,0 @@
import { useEffect, useId, useMemo, useState } from "react";
import { CustomerEmptyCard } from "../../../../../../customer-invoices/src/web/proformas/update/ui/blocks/selected-recipient/selected-recipient-empty-card";
import { useCustomerListQuery } from "../../hooks";
import {
type CustomerFormData,
type CustomerSummary,
defaultCustomerFormData,
} from "../../schemas";
../../../../../../customer-invoices/src/web/proformas/update/ui/blocks/selected-recipient/customer-card
import { CustomerCard } from "./customer-card";
import { CustomerCreateModal } from "./customer-create-modal";
import { CustomerSearchDialog } from "./customer-search-dialog";
import { CustomerViewDialog } from "./customer-view-dialog";
// Debounce pequeño y tipado
function useDebouncedValue<T>(value: T, delay = 300) {
const [debounced, setDebounced] = useState<T>(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
type CustomerModalSelectorProps = {
value?: string;
onValueChange?: (id: string) => void;
disabled?: boolean; // Solo lectura total (sin botones ni selección)
readOnly?: boolean; // Ver ficha, pero no cambiar/crear
initialCustomer?: CustomerSummary;
className?: string;
};
export const CustomerModalSelector = ({
value,
onValueChange,
disabled = false,
readOnly = false,
initialCustomer,
className,
}: CustomerModalSelectorProps) => {
const dialogId = useId();
const [showSearch, setShowSearch] = useState(false);
const [showNewForm, setShowNewForm] = useState(false);
const [showView, setShowView] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const debouncedQuery = useDebouncedValue(searchQuery, 300);
// Cliente seleccionado + creados localmente (optimista)
const [selected, setSelected] = useState<CustomerSummary | null>(initialCustomer ?? null);
const [localCreated, setLocalCreated] = useState<CustomerSummary[]>([]);
const [newClient, setNewClient] =
useState<Omit<CustomerFormData, "id" | "status" | "company_id">>(defaultCustomerFormData);
const criteria = useMemo(
() => ({
q: debouncedQuery || "",
pageSize: 5,
orderBy: "updated_at" as const,
order: "asc" as const,
}),
[debouncedQuery]
);
// Consulta solo cuando el diálogo de búsqueda está abierto
const {
data: remoteCustomersPage,
isLoading,
isError,
error,
} = useCustomerListQuery({
enabled: showSearch, // <- evita llamadas innecesarias
criteria,
});
// Combinar locales optimistas + remotos
const customers: CustomerSummary[] = useMemo(() => {
const remoteCustomers = remoteCustomersPage ? remoteCustomersPage.items : [];
const byId = new Map<string, CustomerSummary>();
[...localCreated, ...remoteCustomers].forEach((c) => byId.set(c.id, c as CustomerSummary));
return Array.from(byId.values());
}, [localCreated, remoteCustomersPage]);
// Sync con value e initialCustomer
useEffect(() => {
const found = customers.find((c) => c.id === value) ?? initialCustomer ?? null;
setSelected(found ?? null);
}, [value, customers, initialCustomer]);
// Crear cliente (optimista) mapeando desde CustomerDraft -> CustomerSummary
const handleCreate = () => {
if (!(newClient.name && newClient.email_primary)) return;
const newCustomer: CustomerSummary = defaultCustomerFormData as CustomerSummary;
setLocalCreated((prev) => [newCustomer, ...prev]);
onValueChange?.(newCustomer.id); // RHF es el source of truth
setShowNewForm(false);
setShowSearch(false);
};
// Handlers de tarjeta según modo
const canChange = !(disabled || readOnly);
const canCreate = !(disabled || readOnly);
const canView = !!selected && !disabled;
return (
<>
<div>
{selected ? (
<CustomerCard
className={className}
customer={selected}
onAddNewCustomer={canCreate ? () => setShowNewForm(true) : undefined}
onChangeCustomer={canChange ? () => setShowSearch(true) : undefined}
onViewCustomer={canView ? () => setShowView(true) : undefined}
/>
) : (
<CustomerEmptyCard
aria-controls={dialogId}
aria-disabled={disabled || readOnly}
aria-haspopup="dialog"
className={className}
onClick={disabled || readOnly ? undefined : () => setShowSearch(true)}
onKeyDown={
disabled || readOnly
? undefined
: (e) => {
if (e.key === "Enter" || e.key === " ") setShowSearch(true);
}
}
/>
)}
</div>
<CustomerSearchDialog
customers={customers}
errorMessage={
isError ? ((error as Error)?.message ?? "Error al cargar clientes") : undefined
}
isError={isError}
isLoading={isLoading}
onCreateClient={(name) => {
setNewClient((prev) => ({ ...prev, name: name ?? "" }));
setShowNewForm(true);
}}
onOpenChange={setShowSearch}
onSearchQueryChange={setSearchQuery}
onSelectClient={(c) => {
setSelected(c);
onValueChange?.(c.id);
setShowSearch(false);
}}
open={showSearch}
searchQuery={searchQuery}
selectedClient={selected}
/>
<CustomerViewDialog
customerId={selected?.id ?? null}
onOpenChange={setShowView}
open={showView}
/>
{/* Diálogo de alta rápida */}
<CustomerCreateModal
client={newClient}
onChange={setNewClient}
onOpenChange={setShowNewForm}
onSubmit={handleCreate}
open={showNewForm}
/>
</>
);
};

View File

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

View File

@ -1,2 +0,0 @@
export * from "./customer-modal-selector";
export * from "./customer-modal-selector-field";

View File

@ -1,3 +0,0 @@
//export * from "./client-selector-modal";
//export * from "./customer-modal-selector";
//export * from "./editor";

View File

@ -1,55 +0,0 @@
import { type PropsWithChildren, createContext } from "react";
/**
*
* 💡 Posibles usos del Context
*
* Este contexto se diseña para encapsular estado y lógica compartida dentro del
* bounded context de facturación (facturas), proporcionando acceso global a datos
* o funciones relevantes para múltiples vistas (listado, detalle, edición, etc).
*
* Usos recomendados:
*
* 1. 🔎 Gestión de filtros globales:
* - Permite que los filtros aplicados en el listado de facturas se conserven
* cuando el usuario navega a otras vistas (detalle, edición) y luego regresa.
* - Mejora la experiencia de usuario evitando la necesidad de reestablecer filtros.
*
* 2. 🛡 Gestión de permisos o configuración de acciones disponibles:
* - Permite definir qué acciones están habilitadas para el usuario actual
* (crear, editar, eliminar).
* - Útil para mostrar u ocultar botones de acción en diferentes pantallas.
*
* 3. 🧭 Control del layout:
* - Si el layout tiene elementos dinámicos (tabs, breadcrumb, loading global),
* este contexto puede coordinar su estado desde componentes hijos.
* - Ejemplo: seleccionar una pestaña activa que aplica en todas las subrutas.
*
* 4. 📦 Cacheo liviano de datos compartidos:
* - Puede almacenar la última factura abierta, borradores de edición,
* o referencias temporales para operaciones CRUD sin tener que usar la URL.
*
* 5. 🚀 Coordinación de side-effects:
* - Permite exponer funciones comunes como `refetch`, `resetFilters`,
* o `notifyInvoiceChanged`, usadas desde cualquier subcomponente del dominio.
*
* Alternativas:
* - Si el estado compartido es muy mutable, grande o requiere persistencia,
* podría ser preferible usar Zustand o Redux Toolkit.
* - No usar contextos para valores que cambian frecuentemente en tiempo real,
* ya que pueden causar renders innecesarios.
*
*
*/
export type CustomersContextType = {};
export type CustomersContextParamsType = {
//service: CustomerApplicationService;
};
export const CustomersContext = createContext<CustomersContextType>({});
export const CustomersProvider = ({ children }: PropsWithChildren) => {
return <CustomersContext.Provider value={{}}>{children}</CustomersContext.Provider>;
};

View File

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

View File

@ -1,2 +0,0 @@
export * from "./create";
export * from "./update";

View File

@ -1,166 +0,0 @@
import { formHasAnyDirty, pickFormDirtyValues } from "@erp/core/client";
import { UnsavedChangesProvider, useHookForm } from "@erp/core/hooks";
import { showErrorToast, showSuccessToast, showWarningToast } from "@repo/rdx-ui/helpers";
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@repo/shadcn-ui/components";
import { X } from "lucide-react";
import { type FieldErrors, FormProvider } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../i18n";
import { CustomerEditorSkeleton } from "../../components";
import { CustomerAdditionalConfigFields } from "../../components/editor/customer-additional-config-fields";
import { CustomerAddressFields } from "../../components/editor/customer-address-fields";
import { CustomerBasicInfoFields } from "../../components/editor/customer-basic-info-fields";
import { useCustomerQuery, useUpdateCustomer } from "../../hooks";
import { type CustomerFormData, CustomerFormSchema, defaultCustomerFormData } from "../../schemas";
interface CustomerEditModalProps {
customerId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function CustomerEditModal({ customerId, open, onOpenChange }: CustomerEditModalProps) {
const { t } = useTranslation();
const navigate = useNavigate();
// 1) Estado de carga del cliente (query)
const {
data: customerData,
isLoading: isLoadingCustomer,
isError: isLoadError,
error: loadError,
} = useCustomerQuery(customerId, { enabled: !!customerId });
// 2) Estado de actualización (mutación)
const {
mutate,
isPending: isUpdating,
isError: isUpdateError,
error: updateError,
} = useUpdateCustomer();
// 3) Form hook
const form = useHookForm<CustomerFormData>({
resolverSchema: CustomerFormSchema,
initialValues: customerData ?? defaultCustomerFormData,
disabled: isUpdating,
});
// 4) Submit con navegación condicionada por éxito
const handleSubmit = (formData: CustomerFormData) => {
const { dirtyFields } = form.formState;
if (!formHasAnyDirty(dirtyFields)) {
showWarningToast("No hay cambios para guardar");
return;
}
const patchData = pickFormDirtyValues(formData, dirtyFields);
mutate(
{ id: customerId!, data: patchData },
{
onSuccess(data) {
showSuccessToast(t("pages.update.success.title"), t("pages.update.success.message"));
// 🔹 limpiar el form e isDirty pasa a false
form.reset(data);
},
onError(error) {
showErrorToast(t("pages.update.errorTitle"), error.message);
},
}
);
};
const handleReset = () => form.reset(customerData ?? defaultCustomerFormData);
const handleBack = () => {
navigate(-1);
};
const handleError = (errors: FieldErrors<CustomerFormData>) => {
console.error("Errores en el formulario:", errors);
// Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario
};
if (isLoadingCustomer || isLoadError) {
return <CustomerEditorSkeleton />;
}
return (
<UnsavedChangesProvider isDirty={form.formState.isDirty}>
<FormProvider {...form}>
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-h-[90vh] max-w-3xl overflow-hidden p-0">
<DialogHeader className="border-b px-6 py-4">
<div className="flex items-center justify-between">
<div>
<DialogTitle className="text-xl">Editar Cliente</DialogTitle>
<DialogDescription className="mt-1">
Modifica la información del cliente
</DialogDescription>
</div>
<Button
className="size-8"
onClick={() => onOpenChange(false)}
size="icon"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
</div>
</DialogHeader>
<Tabs className="flex h-full flex-col" defaultValue="basic">
<TabsList className="mx-6 mt-4 grid w-auto grid-cols-4 gap-2">
<TabsTrigger value="basic">Información Básica</TabsTrigger>
<TabsTrigger value="address">Dirección</TabsTrigger>
<TabsTrigger value="contact">Contacto</TabsTrigger>
<TabsTrigger value="preferences">Preferencias</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-y-auto px-6 py-4">
<TabsContent className="mt-0" value="basic">
<CustomerBasicInfoFields />
</TabsContent>
<TabsContent className="mt-0" value="address">
<CustomerAddressFields />
</TabsContent>
<TabsContent className="mt-0" value="contact">
<CustomerAddressFields />
</TabsContent>
<TabsContent className="mt-0" value="preferences">
<CustomerAdditionalConfigFields />
</TabsContent>
</div>
<div className="border-t px-6 py-4">
<div className="flex justify-end gap-3">
<Button onClick={() => onOpenChange(false)} variant="outline">
Cancelar
</Button>
<Button onClick={handleSubmit}>Guardar</Button>
</div>
</div>
</Tabs>
</DialogContent>
</Dialog>
</FormProvider>
</UnsavedChangesProvider>
);
}

View File

@ -1,2 +0,0 @@
export * from "./customer-update-modal";
export * from "./customer-update-page";

View File

@ -1,7 +0,0 @@
import { CustomerSummary, CustomersPage } from "./customer.api.schema";
export type CustomerSummaryFormData = CustomerSummary & {};
export type CustomersPageFormData = CustomersPage & {
items: CustomerSummaryFormData[];
};

View File

@ -1,31 +0,0 @@
import type { PaginationSchema } from "@erp/core";
import type { ArrayElement } from "@repo/rdx-utils";
import type { z } from "zod/v4";
import {
CreateCustomerRequestSchema,
GetCustomerByIdResponseSchema,
ListCustomersResponseSchema,
UpdateCustomerByIdRequestSchema,
} from "../../../common";
// Esquemas (Zod) provenientes del servidor
export const CustomerSchema = GetCustomerByIdResponseSchema.omit({ metadata: true });
export const CustomerCreateSchema = CreateCustomerRequestSchema;
export const CustomerUpdateSchema = UpdateCustomerByIdRequestSchema;
// Tipos (derivados de Zod o DTOs del backend)
export type Customer = z.infer<typeof CustomerSchema>; // Entidad completa (GET/POST/PUT result)
export type CustomerCreateInput = z.infer<typeof CustomerCreateSchema>; // Cuerpo para crear
export type CustomerUpdateInput = z.infer<typeof CustomerUpdateSchema>; // Cuerpo para actualizar
// Resultado de consulta con criteria (paginado, etc.)
export const CustomersPageSchema = ListCustomersResponseSchema.omit({
metadata: true,
});
export type PaginatedResponse = z.infer<typeof PaginationSchema>;
export type CustomersPage = z.infer<typeof CustomersPageSchema>;
// Ítem simplificado dentro del listado (no toda la entidad)
export type CustomerSummary = Omit<ArrayElement<CustomersPage["items"]>, "metadata">;

View File

@ -1,89 +0,0 @@
import { z } from "zod/v4";
export const CustomerFormSchema = z.object({
reference: z.string().optional(),
is_company: z.string().default("true"),
name: z
.string({
error: "El nombre es obligatorio",
})
.min(1, "El nombre no puede estar vacío"),
trade_name: z.string().optional(),
tin: z.string().optional(),
default_taxes: z.array(z.string()).default([]),
street: z.string().optional(),
street2: z.string().optional(),
city: z.string().optional(),
province: z.string().optional(),
postal_code: z.string().optional(),
country: z
.string({
error: "El país es obligatorio",
})
.min(1, "El país no puede estar vacío")
.toLowerCase() // asegura minúsculas
.default("es"),
email_primary: z.string().optional(),
email_secondary: z.string().optional(),
phone_primary: z.string().optional(),
phone_secondary: z.string().optional(),
mobile_primary: z.string().optional(),
mobile_secondary: z.string().optional(),
fax: z.string().optional(),
website: z.string().optional(),
legal_record: z.string().optional(),
language_code: z
.string({
error: "El idioma es obligatorio",
})
.min(1, "Debe indicar un idioma")
.toUpperCase() // asegura mayúsculas
.default("es"),
currency_code: z
.string({
error: "La moneda es obligatoria",
})
.min(1, "La moneda no puede estar vacía")
.toUpperCase() // asegura mayúsculas
.default("EUR"),
});
export type CustomerFormData = z.infer<typeof CustomerFormSchema>;
export const defaultCustomerFormData: CustomerFormData = {
reference: "",
is_company: "true",
name: "",
trade_name: "",
tin: "",
default_taxes: ["iva_21"],
street: "",
street2: "",
city: "",
province: "",
postal_code: "",
country: "es",
email_primary: "",
email_secondary: "",
phone_primary: "",
phone_secondary: "",
mobile_primary: "",
mobile_secondary: "",
fax: "",
website: "",
legal_record: "",
language_code: "es",
currency_code: "EUR",
};

View File

@ -1,2 +0,0 @@
export * from "./customer.api.schema";
export * from "./customer.form.schema";

View File

@ -1,11 +1,13 @@
{
"name": "@erp/factuges",
"version": "0.6.4",
"version": "0.6.5",
"private": true,
"type": "module",
"sideEffects": false,
"scripts": {
"typecheck": "tsc -p tsconfig.json --noEmit",
"check": "biome check .",
"lint": "biome lint .",
"clean": "rimraf .turbo node_modules dist"
},
"exports": {

View File

@ -164,6 +164,8 @@ export class CreateProformaFromFactugesInputMapper
errors,
});
console.log(paymentProps);
this.throwIfValidationErrors(errors);
return Result.ok({
@ -185,6 +187,7 @@ export class CreateProformaFromFactugesInputMapper
}
}
// TODO: revisar!!!!
private mapPaymentProps(
dto: CreateProformaFromFactugesRequestDTO,
params: {

View File

@ -3,7 +3,6 @@ import { type ITransactionManager, isEntityNotFoundError } from "@erp/core/api";
import type { IProformaPublicServices } from "@erp/customer-invoices/api";
import {
type InvoiceAmount,
InvoicePaymentMethod,
type InvoiceRecipient,
InvoiceStatus,
type ItemAmount,
@ -336,25 +335,16 @@ export class CreateProformaFromFactugesUseCase {
const defaultStatus = InvoiceStatus.approved();
const recipient = Maybe.none<InvoiceRecipient>();
const paymentMethod = Maybe.some(
InvoicePaymentMethod.create({ paymentDescription: payment.description }, payment.id).data
);
console.log({
...proformaDraft,
companyId,
customerId,
status: defaultStatus,
paymentMethod,
recipient,
});
const linkedInvoiceId = Maybe.none<UniqueID>();
const paymentMethodId = Maybe.some(payment.id);
return Result.ok({
...proformaDraft,
companyId,
customerId,
status: defaultStatus,
paymentMethod,
paymentMethodId,
linkedInvoiceId,
recipient,
});
}

View File

@ -1,12 +1,14 @@
{
"name": "@erp/supplier-invoices",
"description": "Supplier invoices",
"version": "0.6.4",
"version": "0.6.5",
"private": true,
"type": "module",
"sideEffects": false,
"scripts": {
"typecheck": "tsc -p tsconfig.json --noEmit",
"check": "biome check .",
"lint": "biome lint .",
"clean": "rimraf .turbo node_modules dist"
},
"exports": {

View File

@ -1,12 +1,14 @@
{
"name": "@erp/suppliers",
"description": "Suppliers",
"version": "0.6.4",
"version": "0.6.5",
"private": true,
"type": "module",
"sideEffects": false,
"scripts": {
"typecheck": "tsc -p tsconfig.json --noEmit",
"check": "biome check .",
"lint": "biome lint .",
"clean": "rimraf .turbo node_modules dist"
},
"exports": {

View File

@ -1,14 +1,13 @@
{
"name": "uecko-erp-2025",
"private": true,
"version": "0.6.4",
"version": "0.6.5",
"workspaces": [
"apps/*",
"modules/*",
"packages/*"
],
"scripts": {
"lint": "turbo run lint",
"build": "turbo build",
"build:templates": "bash scripts/build-templates.sh",
"build:api": "bash scripts/build-api.sh rodax --api",
@ -17,10 +16,15 @@
"dev:client": "turbo dev --filter=client",
"format-and-lint": "biome check .",
"format-and-lint:fix": "biome check . --write",
"ui:add": "pnpm --filter @repo/shadcn-ui ui:add",
"create:package": "ts-node scripts/create-package.ts",
"volta:install": "curl https://get.volta.sh | bash",
"clean": "turbo run clean && rimraf ./node_modules && rimraf ./package-lock.json && rimraf ./out"
"clean": "turbo run clean && rimraf ./node_modules && rimraf ./package-lock.json && rimraf ./out",
"lint": "biome lint .",
"format": "biome format --write .",
"format:check": "biome format .",
"check": "biome check .",
"check:write": "biome check --write .",
"typecheck": "turbo run typecheck"
},
"devDependencies": {
"@biomejs/biome": "2.4.11",

View File

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

View File

@ -1,11 +1,13 @@
{
"name": "@repo/rdx-criteria",
"version": "0.6.4",
"version": "0.6.5",
"private": true,
"type": "module",
"sideEffects": false,
"scripts": {
"typecheck": "tsc -p tsconfig.json --noEmit",
"check": "biome check .",
"lint": "biome lint .",
"clean": "rimraf .turbo node_modules dist"
},
"exports": {

View File

@ -1,11 +1,13 @@
{
"name": "@repo/rdx-ddd",
"version": "0.6.4",
"version": "0.6.5",
"private": true,
"type": "module",
"sideEffects": false,
"scripts": {
"typecheck": "tsc -p tsconfig.json --noEmit",
"check": "biome check .",
"lint": "biome lint .",
"clean": "rimraf .turbo node_modules dist"
},
"exports": {

View File

@ -1,11 +1,13 @@
{
"name": "@repo/rdx-logger",
"version": "0.6.4",
"version": "0.6.5",
"private": true,
"type": "module",
"sideEffects": false,
"scripts": {
"typecheck": "tsc -p tsconfig.json --noEmit",
"check": "biome check .",
"lint": "biome lint .",
"clean": "rimraf .turbo node_modules dist"
},
"exports": {

View File

@ -1,11 +1,13 @@
{
"name": "@repo/rdx-ui",
"version": "0.6.4",
"version": "0.6.5",
"private": true,
"type": "module",
"sideEffects": false,
"scripts": {
"ui:lint": "biome lint --fix"
"ui:lint": "biome lint --fix",
"check": "biome check .",
"lint": "biome lint ."
},
"exports": {
"./helpers": "./src/helpers/index.ts",

View File

@ -1,11 +1,13 @@
{
"name": "@repo/rdx-utils",
"version": "0.6.4",
"version": "0.6.5",
"private": true,
"type": "module",
"sideEffects": false,
"scripts": {
"typecheck": "tsc -p tsconfig.json --noEmit",
"check": "biome check .",
"lint": "biome lint .",
"clean": "rimraf .turbo node_modules dist"
},
"exports": {

View File

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

Binary file not shown.

View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2"/>
</startup>
<System.Windows.Forms.ApplicationConfigurationSection>
<add key="DpiAwareness" value="PerMonitorV2" />
</System.Windows.Forms.ApplicationConfigurationSection>
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Runtime.CompilerServices.Unsafe" publicKeyToken="b03f5f7f11d50a3a" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0"/>
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="Newtonsoft.Json" publicKeyToken="30ad4fe6b2a6aeed" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-13.0.0.1" newVersion="13.0.0.0"/>
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="System.Threading.Tasks.Extensions" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-4.2.0.1" newVersion="4.2.0.1"/>
</dependentAssembly>
</assemblyBinding>
</runtime>
</configuration>

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