v0.6.5
This commit is contained in:
parent
92faca9bfa
commit
fa913627e2
6
.vscode/settings.json
vendored
6
.vscode/settings.json
vendored
@ -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
12
.vscode/tasks.json
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "biome: check workspace",
|
||||
"type": "shell",
|
||||
"command": "pnpm biome check .",
|
||||
"group": "test",
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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": {
|
||||
|
||||
@ -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");
|
||||
|
||||
|
||||
@ -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" });
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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.`);
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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!");
|
||||
});
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="es">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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'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>
|
||||
|
||||
37
biome.json
37
biome.json
@ -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",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@erp/auth",
|
||||
"version": "0.6.4",
|
||||
"version": "0.6.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
import { IPercentageDTO } from "@erp/core";
|
||||
|
||||
export interface IGetProfileResponseDTO {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from "axios";
|
||||
import type { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from "axios";
|
||||
|
||||
/**
|
||||
* Configura interceptores para una instancia de Axios.
|
||||
|
||||
@ -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 };
|
||||
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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[] = [];
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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> {}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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(),
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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));
|
||||
}
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
@ -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();
|
||||
@ -1,2 +0,0 @@
|
||||
export * from "./calculate-invoice-header-amounts";
|
||||
export * from "./calculate-invoice-item-amounts";
|
||||
@ -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";
|
||||
@ -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"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -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`)
|
||||
});
|
||||
};
|
||||
@ -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,
|
||||
]);
|
||||
}
|
||||
@ -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 };
|
||||
}
|
||||
@ -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.
|
||||
|
||||
*/
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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 };
|
||||
}
|
||||
@ -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]
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
@ -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": {
|
||||
|
||||
@ -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;
|
||||
@ -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";
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -1,2 +0,0 @@
|
||||
export * from "./customer-modal-selector";
|
||||
export * from "./customer-modal-selector-field";
|
||||
@ -1,3 +0,0 @@
|
||||
//export * from "./client-selector-modal";
|
||||
//export * from "./customer-modal-selector";
|
||||
//export * from "./editor";
|
||||
@ -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>;
|
||||
};
|
||||
@ -1 +0,0 @@
|
||||
export * from "./customers-context";
|
||||
@ -1,2 +0,0 @@
|
||||
export * from "./create";
|
||||
export * from "./update";
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -1,2 +0,0 @@
|
||||
export * from "./customer-update-modal";
|
||||
export * from "./customer-update-page";
|
||||
@ -1,7 +0,0 @@
|
||||
import { CustomerSummary, CustomersPage } from "./customer.api.schema";
|
||||
|
||||
export type CustomerSummaryFormData = CustomerSummary & {};
|
||||
|
||||
export type CustomersPageFormData = CustomersPage & {
|
||||
items: CustomerSummaryFormData[];
|
||||
};
|
||||
@ -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">;
|
||||
@ -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",
|
||||
};
|
||||
@ -1,2 +0,0 @@
|
||||
export * from "./customer.api.schema";
|
||||
export * from "./customer.form.schema";
|
||||
@ -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": {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
12
package.json
12
package.json
@ -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",
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -16,7 +16,8 @@
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "biome lint --fix",
|
||||
"check": "biome check .",
|
||||
"lint": "biome lint .",
|
||||
"ui:add": "pnpm dlx shadcn@latest add"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
0
tools/fastreport-designer/AWSSDK.Core.dll → tools/fastreport-designer-community/AWSSDK.Core.dll
Executable file → Normal file
0
tools/fastreport-designer/AWSSDK.Core.dll → tools/fastreport-designer-community/AWSSDK.Core.dll
Executable file → Normal file
0
tools/fastreport-designer/Common.Logging.Core.dll → tools/fastreport-designer-community/Common.Logging.Core.dll
Executable file → Normal file
0
tools/fastreport-designer/Common.Logging.Core.dll → tools/fastreport-designer-community/Common.Logging.Core.dll
Executable file → Normal file
0
tools/fastreport-designer/Common.Logging.dll → tools/fastreport-designer-community/Common.Logging.dll
Executable file → Normal file
0
tools/fastreport-designer/Common.Logging.dll → tools/fastreport-designer-community/Common.Logging.dll
Executable file → Normal file
0
tools/fastreport-designer/Couchbase.NetClient.dll → tools/fastreport-designer-community/Couchbase.NetClient.dll
Executable file → Normal file
0
tools/fastreport-designer/Couchbase.NetClient.dll → tools/fastreport-designer-community/Couchbase.NetClient.dll
Executable file → Normal file
BIN
tools/fastreport-designer-community/Designer.exe
Normal file
BIN
tools/fastreport-designer-community/Designer.exe
Normal file
Binary file not shown.
25
tools/fastreport-designer-community/Designer.exe.config
Normal file
25
tools/fastreport-designer-community/Designer.exe.config
Normal 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>
|
||||
0
tools/fastreport-designer/DnsClient.dll → tools/fastreport-designer-community/DnsClient.dll
Executable file → Normal file
0
tools/fastreport-designer/DnsClient.dll → tools/fastreport-designer-community/DnsClient.dll
Executable file → Normal file
BIN
tools/fastreport-designer-community/FastReport.Compat.dll
Normal file
BIN
tools/fastreport-designer-community/FastReport.Compat.dll
Normal file
Binary file not shown.
0
tools/fastreport-designer/FastReport.Data.Couchbase.dll → tools/fastreport-designer-community/FastReport.Data.Couchbase.dll
Executable file → Normal file
0
tools/fastreport-designer/FastReport.Data.Couchbase.dll → tools/fastreport-designer-community/FastReport.Data.Couchbase.dll
Executable file → Normal file
0
tools/fastreport-designer/FastReport.Data.Json.dll → tools/fastreport-designer-community/FastReport.Data.Json.dll
Executable file → Normal file
0
tools/fastreport-designer/FastReport.Data.Json.dll → tools/fastreport-designer-community/FastReport.Data.Json.dll
Executable file → Normal file
0
tools/fastreport-designer/FastReport.Data.MongoDB.dll → tools/fastreport-designer-community/FastReport.Data.MongoDB.dll
Executable file → Normal file
0
tools/fastreport-designer/FastReport.Data.MongoDB.dll → tools/fastreport-designer-community/FastReport.Data.MongoDB.dll
Executable file → Normal file
0
tools/fastreport-designer/FastReport.Data.MySql.dll → tools/fastreport-designer-community/FastReport.Data.MySql.dll
Executable file → Normal file
0
tools/fastreport-designer/FastReport.Data.MySql.dll → tools/fastreport-designer-community/FastReport.Data.MySql.dll
Executable file → Normal file
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user