feat(payment-terms): add payment terms management components and utilities

- Implemented PaymentTermRowActions component for editing, toggling, and deleting payment terms.
- Created PaymentTermSegmentedBar for visual representation of payment term segments.
- Added PaymentTermsEmptyState and PaymentTermsNoResultsState for handling empty and no results scenarios.
- Developed PaymentTermsSettingsSkeleton for loading state representation.
- Introduced utility functions for building payment term forms and parameters.
- Added criteria building functions for payment terms list filtering.
- Implemented error handling view model for payment terms settings page.
- Created tests for payment terms list criteria and due rules utilities.
- Added shell script to check development environment setup.
This commit is contained in:
David Arranz 2026-08-16 17:12:07 +02:00
parent 97174e580d
commit eba766e7eb
69 changed files with 3147 additions and 29 deletions

View File

@ -18,6 +18,32 @@ Trabajar sobre el ERP mediante cambios:
Antes de modificar código, revisar las implementaciones y patrones existentes en el área afectada. No introducir patrones nuevos si ya existe una solución equivalente en el proyecto.
## Entorno de ejecución
El repositorio canónico es:
`/home/rodax/Documentos/uecko-erp`
Todos los comandos de desarrollo deben ejecutarse en Linux.
No ejecutar Node.js, PNPM, Turborepo, TypeScript, Biome ni tests
desde rutas Windows, unidades `Z:` ni rutas UNC.
Antes de trabajar comprobar:
```bash
pwd
uname -s
git rev-parse --show-toplevel
```
Se espera:
```
Linux
/home/rodax/Documentos/uecko-erp
```
## Stack principal
- Node.js >= 22

View File

@ -5,6 +5,7 @@ export type PaymentTermSummary = {
id: UniqueID;
companyId: UniqueID;
name: Name;
description: string | null;
isActive: boolean;
isSystem: boolean;
dueRules: Array<PaymentTermDueRule>;

View File

@ -12,6 +12,7 @@ export class PaymentTermSummarySnapshotBuilder implements IPaymentTermSummarySna
id: paymentTerm.id.toString(),
company_id: paymentTerm.companyId.toString(),
name: paymentTerm.name.toString(),
description: paymentTerm.description,
is_system: paymentTerm.isSystem,
is_active: paymentTerm.isActive,
due_rules: paymentTerm.dueRules.map((rule) => ({

View File

@ -33,7 +33,7 @@ export const paymentTermsRouter = (params: StartParams) => {
router.get(
"/",
validateRequest(ListPaymentTermsRequestSchema, "params"),
validateRequest(ListPaymentTermsRequestSchema, "query"),
(req: Request, res: Response, next: NextFunction) => {
const controller = new ListPaymentTermsController(deps.useCases.listPaymentTerms());
return controller.execute(req, res, next);

View File

@ -60,6 +60,7 @@ export class SequelizePaymentTermSummaryMapper extends SequelizeQueryMapper<
id: id!,
companyId: companyId!,
name: name!,
description: raw.description ?? null,
isActive: raw.is_active,
isSystem: raw.is_system,
dueRules,

View File

@ -0,0 +1,41 @@
import assert from "node:assert/strict";
import test from "node:test";
import { Op, Sequelize } from "sequelize";
import { buildPaymentTermQuickSearchWhere } from "./payment-term-quick-search.ts";
function buildWhereSql(quickSearch: string): string {
const database = new Sequelize("mysql://root:pass@localhost:3306/erp_test", {
logging: false,
});
const queryGenerator = database.getQueryInterface().queryGenerator as {
whereQuery: (where: unknown) => string;
};
const where = {
[Op.and]: [
{ company_id: "company-1" },
{ deleted_at: null },
buildPaymentTermQuickSearchWhere({ quickSearch }),
],
};
return queryGenerator.whereQuery(where);
}
test("busca por name y description usando LIKE", () => {
const sql = buildWhereSql("contado");
assert.match(sql, /`name` LIKE '%contado%'/);
assert.match(sql, /`description` LIKE '%contado%'/);
assert.doesNotMatch(sql, /MATCH\(/);
});
test("mantiene aislamiento por empresa y soft delete", () => {
const sql = buildWhereSql("6");
assert.match(sql, /`company_id` = 'company-1'/);
assert.match(sql, /`deleted_at` IS NULL/);
assert.match(sql, /`name` LIKE '%6%'/);
});

View File

@ -0,0 +1,29 @@
import { Op, type WhereOptions } from "sequelize";
import type { PaymentTermModel } from "../models";
type BuildPaymentTermQuickSearchWhereParams = {
quickSearch?: string;
};
/**
* Evita depender de índices FULLTEXT en un listado pequeño de configuración.
*
* La búsqueda rápida de plazos de pago debe seguir funcionando aunque la tabla
* no tenga un índice compuesto `FULLTEXT(name, description)`.
*/
export function buildPaymentTermQuickSearchWhere(
params: BuildPaymentTermQuickSearchWhereParams
): WhereOptions<PaymentTermModel> | undefined {
const term = params.quickSearch?.trim();
if (!term) {
return undefined;
}
const likePattern = `%${term}%`;
return {
[Op.or]: [{ name: { [Op.like]: likePattern } }, { description: { [Op.like]: likePattern } }],
};
}

View File

@ -1,15 +1,18 @@
import assert from "node:assert/strict";
import { describe, test } from "node:test";
import { Criteria } from "@repo/rdx-criteria/server";
import { Name, UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { Op } from "sequelize";
import { PaymentTerm } from "../../../../../domain/payment-terms/payment-term.aggregate";
import { PaymentTermDueDays } from "../../../../../domain/payment-terms/payment-term-due-days";
import { PaymentTermPercentage } from "../../../../../domain/payment-terms/payment-term-percentage";
import { PaymentTermDueRuleModel, PaymentTermModel } from "../models";
import { PaymentTerm } from "../../../../../domain/payment-terms/payment-term.aggregate.ts";
import { PaymentTermDueDays } from "../../../../../domain/payment-terms/payment-term-due-days.ts";
import { PaymentTermPercentage } from "../../../../../domain/payment-terms/payment-term-percentage.ts";
import { SequelizePaymentTermSummaryMapper } from "../mappers/index.ts";
import { PaymentTermDueRuleModel, PaymentTermModel } from "../models/index.ts";
import { SequelizePaymentTermRepository } from "./sequelize-payment-term.repository";
import { SequelizePaymentTermRepository } from "./sequelize-payment-term.repository.ts";
const createRuleProps = (params: { dueDays: number; percentageValue: number }) => {
const dueDaysResult = PaymentTermDueDays.create(params.dueDays);
@ -50,9 +53,17 @@ const createPaymentTerm = (companyId = UniqueID.generateNewID()) => {
return paymentTermResult.data;
};
const createUniqueId = (value: string) => {
const result = UniqueID.create(value);
assert.equal(result.isSuccess, true);
return result.data;
};
const createRepository = (params?: {
mapToPersistence?: () => Result<any, Error>;
mapToDomain?: () => Result<PaymentTerm, Error>;
summaryMapper?: SequelizePaymentTermSummaryMapper;
database?: { escape: (value: string) => string };
}) =>
new SequelizePaymentTermRepository(
{
@ -78,10 +89,39 @@ const createRepository = (params?: {
})),
mapToDomain: params?.mapToDomain ?? (() => Result.ok(createPaymentTerm())),
} as any,
{} as any,
{ models: {} } as any
(params?.summaryMapper ?? ({} as any)) as any,
{ models: {}, escape: (value: string) => `'${value}'`, ...params?.database } as any as any
);
const createSummaryRow = (params?: {
id?: string;
companyId?: string;
name?: string;
description?: string | null;
isActive?: boolean;
isSystem?: boolean;
dueRules?: Array<{ dueDays: number; percentageValue: number }>;
}) => ({
id: params?.id ?? "11111111-1111-4111-8111-111111111111",
company_id: params?.companyId ?? "22222222-2222-4222-8222-222222222222",
name: params?.name ?? "Contado 30/60",
description: params?.description ?? "Pago en dos cuotas",
is_active: params?.isActive ?? true,
is_system: params?.isSystem ?? false,
due_rules: (
params?.dueRules ?? [
{ dueDays: 30, percentageValue: 5000 },
{ dueDays: 60, percentageValue: 5000 },
]
).map((rule, index) => ({
due_id: `33333333-3333-4333-8333-33333333333${index}`,
payment_term_id: params?.id ?? "11111111-1111-4111-8111-111111111111",
due_days: rule.dueDays,
percentage_value: rule.percentageValue,
percentage_scale: 2,
})),
});
describe("SequelizePaymentTermRepository", () => {
test("create persists parent and due rules atomically through nested create", async () => {
const repository = createRepository();
@ -394,4 +434,129 @@ describe("SequelizePaymentTermRepository", () => {
(PaymentTermDueRuleModel as any).bulkCreate = originalBulkCreate;
}
});
test("findByCriteriaInCompany incluye description en el summary", async () => {
const repository = createRepository({
summaryMapper: new SequelizePaymentTermSummaryMapper(),
});
const originalFindAll = PaymentTermModel.findAll;
const originalCount = PaymentTermModel.count;
(PaymentTermModel as any).findAll = async () => [createSummaryRow()];
(PaymentTermModel as any).count = async () => 1;
try {
const result = await repository.findByCriteriaInCompany(
createUniqueId("22222222-2222-4222-8222-222222222222"),
Criteria.fromPrimitives([], "name", "ASC", 10, 0)
);
assert.equal(result.isSuccess, true);
assert.equal(result.data.total(), 1);
const item = result.data.getAll()[0];
assert.ok(item);
assert.equal(item.description, "Pago en dos cuotas");
assert.equal(item.dueRules.length, 2);
} finally {
(PaymentTermModel as any).findAll = originalFindAll;
(PaymentTermModel as any).count = originalCount;
}
});
test("findByCriteriaInCompany configura q sobre name y description", async () => {
const repository = createRepository({
summaryMapper: new SequelizePaymentTermSummaryMapper(),
});
const originalFindAll = PaymentTermModel.findAll;
const originalCount = PaymentTermModel.count;
let findAllOptions: Record<string, unknown> | undefined;
let countOptions: Record<string, unknown> | undefined;
(PaymentTermModel as any).findAll = async (options: Record<string, unknown>) => {
findAllOptions = options;
return [createSummaryRow()];
};
(PaymentTermModel as any).count = async (options: Record<string, unknown>) => {
countOptions = options;
return 1;
};
try {
const result = await repository.findByCriteriaInCompany(
createUniqueId("22222222-2222-4222-8222-222222222222"),
Criteria.fromPrimitives([], "name", "ASC", 10, 0, "contado")
);
assert.equal(result.isSuccess, true);
assert.ok(findAllOptions);
assert.ok(countOptions);
assert.deepEqual(countOptions?.where, findAllOptions?.where);
const where = findAllOptions?.where as Record<string, unknown>;
const andConditions = where[Op.and as unknown as keyof typeof where] as Array<
Record<string, unknown>
>;
assert.ok(Array.isArray(andConditions));
assert.equal(andConditions.length, 2);
assert.deepEqual(andConditions[0], {
[Op.or]: [
{ name: { [Op.like]: "%contado%" } },
{ description: { [Op.like]: "%contado%" } },
],
});
assert.deepEqual(andConditions[1], {
company_id: "22222222-2222-4222-8222-222222222222",
deleted_at: null,
});
} finally {
(PaymentTermModel as any).findAll = originalFindAll;
(PaymentTermModel as any).count = originalCount;
}
});
test("findByCriteriaInCompany combina q + isActive y mantiene el count filtrado", async () => {
const repository = createRepository({
summaryMapper: new SequelizePaymentTermSummaryMapper(),
});
const originalFindAll = PaymentTermModel.findAll;
const originalCount = PaymentTermModel.count;
let findAllOptions: Record<string, unknown> | undefined;
let countOptions: Record<string, unknown> | undefined;
(PaymentTermModel as any).findAll = async (options: Record<string, unknown>) => {
findAllOptions = options;
return [createSummaryRow({ description: "Contado inmediato", isActive: true })];
};
(PaymentTermModel as any).count = async (options: Record<string, unknown>) => {
countOptions = options;
return 1;
};
try {
const result = await repository.findByCriteriaInCompany(
createUniqueId("22222222-2222-4222-8222-222222222222"),
Criteria.fromPrimitives(
[{ field: "isActive", operator: "EQUALS", value: "true" }],
"name",
"ASC",
10,
0,
"contado"
)
);
assert.equal(result.isSuccess, true);
assert.ok(findAllOptions);
assert.deepEqual(countOptions?.where, findAllOptions?.where);
const where = findAllOptions?.where as Record<string, unknown>;
const andConditions = where[Op.and as unknown as keyof typeof where] as unknown[];
assert.ok(Array.isArray(andConditions));
assert.equal(andConditions.length, 3);
} finally {
(PaymentTermModel as any).findAll = originalFindAll;
(PaymentTermModel as any).count = originalCount;
}
});
});

View File

@ -7,7 +7,7 @@ import {
import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server";
import type { UniqueID } from "@repo/rdx-ddd";
import { type Collection, Maybe, Result } from "@repo/rdx-utils";
import type { Sequelize, Transaction } from "sequelize";
import { Op, type Sequelize, type Transaction } from "sequelize";
import type { PaymentTermSummary } from "../../../../../application/payment-terms/models/payment-term-summary.model";
import type { IPaymentTermRepository } from "../../../../../application/payment-terms/repositories/payment-term-repository.interface";
@ -18,6 +18,8 @@ import type {
} from "../mappers";
import { PaymentTermDueRuleModel, PaymentTermModel } from "../models";
import { buildPaymentTermQuickSearchWhere } from "./payment-term-quick-search";
export class SequelizePaymentTermRepository
extends SequelizeRepository<PaymentTerm>
implements IPaymentTermRepository
@ -166,15 +168,22 @@ export class SequelizePaymentTermRepository
},
},
sortableFields: ["name"],
enableFullText: true,
database: this.database,
strictMode: true,
});
const quickSearchWhere = buildPaymentTermQuickSearchWhere({
quickSearch: criteria.quickSearch,
});
query.where = {
...query.where,
company_id: companyId.toString(),
deleted_at: null,
[Op.and]: [
...(quickSearchWhere ? [quickSearchWhere] : []),
...(query.where ? [query.where] : []),
{
company_id: companyId.toString(),
deleted_at: null,
},
],
};
const [rows, count] = await Promise.all([

View File

@ -7,6 +7,7 @@ export const PaymentTermSummarySchema = z.object({
company_id: z.uuid(),
name: z.string(),
description: z.string().nullable(),
is_active: z.boolean(),
is_system: z.boolean(),
due_rules: z.array(PaymentTermDueRuleSchema),

View File

@ -128,6 +128,165 @@
}
}
},
"paymentTerms": {
"title": "Payment terms",
"description": "Manage the due date templates available for the company.",
"toolbar": {
"searchPlaceholder": "Search by name or description…",
"statusLabel": "Status",
"statusOptions": {
"all": "All",
"active": "Active",
"inactive": "Inactive"
},
"totalCount_one": "{{count}} payment term in total",
"totalCount_other": "{{count}} payment terms in total"
},
"list": {
"rulesLabel": "Due rules"
},
"summary": {
"installmentsLabel": "Installments",
"installmentsCount_one": "{{count}} installment",
"installmentsCount_other": "{{count}} installments",
"lastInstallmentLabel": "Last installment at",
"distributionLabel": "Distribution"
},
"details": {
"title": "Due rules",
"sameDay": "Cash payment",
"baseDate": "From the base date"
},
"fields": {
"name": "Name",
"nameDescription": "Visible payment term name.",
"namePlaceholder": "e.g. 30/60/90",
"description": "Description",
"descriptionDescription": "Optional internal information.",
"descriptionPlaceholder": "Add an optional description",
"active": "Active",
"activeDescription": "Controls whether the payment term is available.",
"dueDays": "Days",
"dueDaysDescription": "Days from the document base date.",
"dueDaysPlaceholder": "0",
"percentage": "Percentage",
"percentageDescription": "Exact percentage due for this rule.",
"percentagePlaceholder": "100.00"
},
"dueRules": {
"sameDay": "0 days",
"days_one": "{{count}} day",
"days_other": "{{count}} days"
},
"actions": {
"create": "New term",
"creating": "Creating…",
"edit": "Edit",
"save": "Save",
"saving": "Saving…",
"cancel": "Cancel",
"enable": "Enable",
"disable": "Disable",
"delete": "Delete",
"deleting": "Deleting…",
"more": "More actions",
"addRule": "Add due rule",
"removeRule": "Remove due rule"
},
"status": {
"active": "Active",
"inactive": "Inactive",
"system": "System"
},
"create": {
"title": "New payment term",
"description": "Create a new due date template for this company."
},
"edit": {
"title": "Edit payment term",
"description": "Update the selected payment term information and due rules."
},
"delete": {
"title": "Delete payment term",
"description": "This term will stop being available. If it is still in use, the operation will not be completed.",
"errorTitle": "The payment term could not be deleted",
"inUse": "This payment term cannot be deleted because it is being used."
},
"empty": {
"title": "There are no payment terms configured.",
"description": "Available payment terms will appear here."
},
"emptyFiltered": {
"title": "No payment terms were found for the current criteria.",
"description": "Try another search or clear the filter to see every payment term again.",
"clear": "Clear search and filter"
},
"messages": {
"createdTitle": "Payment term created",
"created": "The payment term was created successfully.",
"updatedTitle": "Payment term updated",
"updated": "Changes were saved successfully.",
"enabledTitle": "Payment term enabled",
"enabled": "The term is available again.",
"disabledTitle": "Payment term disabled",
"disabled": "The term is no longer available.",
"deletedTitle": "Payment term deleted",
"deleted": "The payment term is no longer available.",
"validationTitle": "Review the fields",
"validationDescription": "There are validation errors in the form."
},
"protections": {
"systemEdit": "System payment terms cannot be edited.",
"systemDisable": "System payment terms cannot be disabled.",
"systemDelete": "System payment terms cannot be deleted."
},
"form": {
"rules": {
"title": "Due rules",
"description": "Configure the days and percentages that must total exactly 100.00%."
},
"total": {
"label": "Total: {{amount}}",
"valid": "The distribution totals exactly 100.00%.",
"missing": "{{amount}} is missing to reach 100.00%.",
"exceeded": "The total exceeds 100.00% by {{amount}}."
}
},
"pageError": {
"actions": {
"retry": "Retry",
"retrying": "Retrying…"
},
"forbidden": {
"title": "You do not have access to this setting",
"description": "You do not have permission to view payment terms."
},
"notFound": {
"title": "The settings could not be loaded",
"description": "The requested settings are no longer available."
},
"network": {
"title": "Could not connect to the server",
"description": "Check your connection and try again."
},
"timeout": {
"title": "Loading is taking too long",
"description": "The server did not respond in time. Try again."
},
"unavailable": {
"title": "Service unavailable",
"description": "The settings are not available right now."
},
"server": {
"title": "The settings could not be loaded",
"description": "An internal error occurred while loading payment terms."
},
"unknown": {
"title": "The settings could not be loaded",
"description": "An unexpected error occurred while loading payment terms."
}
}
},
"taxRegimes": {
"title": "Tax regimes",
"description": "Manage the tax regimes available for the company.",

View File

@ -128,6 +128,165 @@
}
}
},
"paymentTerms": {
"title": "Plazos de pago",
"description": "Gestiona las plantillas de vencimientos disponibles para la empresa.",
"toolbar": {
"searchPlaceholder": "Buscar por nombre o descripción…",
"statusLabel": "Estado",
"statusOptions": {
"all": "Todos",
"active": "Activos",
"inactive": "Inactivos"
},
"totalCount_one": "{{count}} plazo en total",
"totalCount_other": "{{count}} plazos en total"
},
"list": {
"rulesLabel": "Vencimientos"
},
"summary": {
"installmentsLabel": "Vencimientos",
"installmentsCount_one": "{{count}} cuota",
"installmentsCount_other": "{{count}} cuotas",
"lastInstallmentLabel": "Última cuota a",
"distributionLabel": "Distribución"
},
"details": {
"title": "Vencimientos",
"sameDay": "Al contado",
"baseDate": "Desde la fecha base"
},
"fields": {
"name": "Nombre",
"nameDescription": "Nombre visible del plazo de pago.",
"namePlaceholder": "Ej. 30/60/90",
"description": "Descripción",
"descriptionDescription": "Información adicional opcional para uso interno.",
"descriptionPlaceholder": "Añade una descripción opcional",
"active": "Activo",
"activeDescription": "Controla si el plazo de pago aparece como disponible.",
"dueDays": "Días",
"dueDaysDescription": "Días desde la fecha base del documento.",
"dueDaysPlaceholder": "0",
"percentage": "Porcentaje",
"percentageDescription": "Porcentaje exacto que vence en este plazo.",
"percentagePlaceholder": "100,00"
},
"dueRules": {
"sameDay": "0 días",
"days_one": "{{count}} día",
"days_other": "{{count}} días"
},
"actions": {
"create": "Nuevo plazo",
"creating": "Creando…",
"edit": "Editar",
"save": "Guardar",
"saving": "Guardando…",
"cancel": "Cancelar",
"enable": "Activar",
"disable": "Desactivar",
"delete": "Eliminar",
"deleting": "Eliminando…",
"more": "Más acciones",
"addRule": "Añadir vencimiento",
"removeRule": "Eliminar vencimiento"
},
"status": {
"active": "Activo",
"inactive": "Inactivo",
"system": "Sistema"
},
"create": {
"title": "Nuevo plazo de pago",
"description": "Crea una nueva plantilla de vencimientos para esta empresa."
},
"edit": {
"title": "Editar plazo de pago",
"description": "Actualiza la información y los vencimientos del plazo de pago seleccionado."
},
"delete": {
"title": "Eliminar plazo de pago",
"description": "Este plazo dejará de estar disponible. Si está siendo utilizado, la operación no se completará.",
"errorTitle": "No se pudo eliminar el plazo de pago",
"inUse": "No se puede eliminar este plazo de pago porque está siendo utilizado."
},
"empty": {
"title": "No hay plazos de pago configurados.",
"description": "Cuando existan plazos de pago disponibles aparecerán aquí."
},
"emptyFiltered": {
"title": "No se encontraron plazos con los criterios actuales.",
"description": "Prueba con otra búsqueda o limpia el filtro para volver a ver todos los plazos.",
"clear": "Limpiar búsqueda y filtro"
},
"messages": {
"createdTitle": "Plazo de pago creado",
"created": "El plazo de pago se ha creado correctamente.",
"updatedTitle": "Plazo de pago actualizado",
"updated": "Los cambios se han guardado correctamente.",
"enabledTitle": "Plazo de pago activado",
"enabled": "El plazo vuelve a estar disponible.",
"disabledTitle": "Plazo de pago desactivado",
"disabled": "El plazo ha dejado de estar disponible.",
"deletedTitle": "Plazo de pago eliminado",
"deleted": "El plazo de pago ha dejado de estar disponible.",
"validationTitle": "Revisa los campos",
"validationDescription": "Hay errores de validación en el formulario."
},
"protections": {
"systemEdit": "Los plazos del sistema no se pueden editar.",
"systemDisable": "Los plazos del sistema no se pueden desactivar.",
"systemDelete": "Los plazos del sistema no se pueden eliminar."
},
"form": {
"rules": {
"title": "Vencimientos",
"description": "Configura los días y porcentajes que deben sumar exactamente el 100,00%."
},
"total": {
"label": "Total: {{amount}}",
"valid": "La distribución suma exactamente 100,00%.",
"missing": "Falta {{amount}} para llegar al 100,00%.",
"exceeded": "Excede {{amount}} el 100,00%."
}
},
"pageError": {
"actions": {
"retry": "Reintentar",
"retrying": "Reintentando…"
},
"forbidden": {
"title": "No tienes acceso a esta configuración",
"description": "No dispones de permisos para consultar los plazos de pago."
},
"notFound": {
"title": "No se pudo cargar la configuración",
"description": "La configuración solicitada ya no está disponible."
},
"network": {
"title": "No se pudo conectar con el servidor",
"description": "Comprueba tu conexión e inténtalo de nuevo."
},
"timeout": {
"title": "La carga está tardando demasiado",
"description": "El servidor no respondió a tiempo. Inténtalo de nuevo."
},
"unavailable": {
"title": "Servicio no disponible",
"description": "La configuración no está disponible en este momento."
},
"server": {
"title": "No se pudo cargar la configuración",
"description": "Se produjo un error interno al obtener los plazos de pago."
},
"unknown": {
"title": "No se pudo cargar la configuración",
"description": "Se produjo un error inesperado al obtener los plazos de pago."
}
}
},
"taxRegimes": {
"title": "Regímenes fiscales",
"description": "Gestiona los regímenes fiscales disponibles para la empresa.",

View File

@ -8,6 +8,12 @@ const PaymentMethodsSettingsPage = lazy(() =>
}))
);
const PaymentTermsSettingsPage = lazy(() =>
import("./settings/payment-terms").then((module) => ({
default: module.PaymentTermsSettingsPage,
}))
);
const TaxRegimesSettingsPage = lazy(() =>
import("./settings/tax-regimes").then((module) => ({
default: module.TaxRegimesSettingsPage,
@ -24,6 +30,14 @@ export const CatalogsRoutes = (_params: ModuleClientParams): ModuleRouteObject[]
},
element: <PaymentMethodsSettingsPage />,
},
{
path: "settings/general/payment-terms",
handle: {
layout: "app-sidebar",
protected: true,
},
element: <PaymentTermsSettingsPage />,
},
{
path: "settings/general/tax-regimes",
handle: {

View File

@ -12,6 +12,12 @@ test("publica la entry de Settings para payment methods", () => {
labelKey: "settings.paymentMethods.title",
path: "/settings/general/payment-methods",
},
{
id: "payment-terms",
sectionId: "general",
labelKey: "settings.paymentTerms.title",
path: "/settings/general/payment-terms",
},
{
id: "tax-regimes",
sectionId: "general",
@ -31,6 +37,15 @@ test("expone una ruta modular para settings/general/payment-methods", () => {
assert.equal(settingsRoute?.handle?.protected, true);
});
test("expone una ruta modular para settings/general/payment-terms", () => {
const routes = CatalogsModuleManifest.routes?.({});
const settingsRoute = routes?.find((route) => route.path === "settings/general/payment-terms");
assert.ok(settingsRoute);
assert.equal(settingsRoute?.handle?.layout, "app-sidebar");
assert.equal(settingsRoute?.handle?.protected, true);
});
test("expone una ruta modular para settings/general/tax-regimes", () => {
const routes = CatalogsModuleManifest.routes?.({});
const settingsRoute = routes?.find((route) => route.path === "settings/general/tax-regimes");

View File

@ -22,6 +22,12 @@ export const CatalogsModuleManifest: IModuleClient = {
labelKey: "settings.paymentMethods.title",
path: "/settings/general/payment-methods",
},
{
id: "payment-terms",
sectionId: "general",
labelKey: "settings.paymentTerms.title",
path: "/settings/general/payment-terms",
},
{
id: "tax-regimes",
sectionId: "general",

View File

@ -0,0 +1,23 @@
import type { GetPaymentTermByIdResponseDTO } from "../../../../common";
import type { PaymentTerm } from "../entities";
const percentageDtoToNumber = (dto: { value: string; scale: string }): number => {
return Number(dto.value) / 10 ** Number(dto.scale);
};
export const GetPaymentTermByIdAdapter = {
fromDto(dto: GetPaymentTermByIdResponseDTO): PaymentTerm {
return {
id: dto.id,
companyId: dto.company_id,
name: dto.name,
description: dto.description,
isActive: dto.is_active,
isSystem: dto.is_system,
dueRules: dto.due_rules.map((rule) => ({
dueDays: Number(rule.due_days),
percentage: percentageDtoToNumber(rule.percentage),
})),
};
},
};

View File

@ -1 +1,2 @@
export * from "./list-payment-terms.adapter";
export * from "./get-payment-term-by-id.adapter";

View File

@ -0,0 +1,73 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { ListPaymentTermsResponseDTO } from "../../../../common";
import { ListPaymentTermsAdapter } from "./list-payment-terms.adapter.ts";
test("adapta el listado incluyendo description y reglas numéricas", () => {
const dto: ListPaymentTermsResponseDTO = {
page: 0,
per_page: 10,
total_pages: 1,
total_items: 1,
metadata: {
entity: "payment_terms",
criteria: {
q: "",
filters: [],
orderBy: "name",
orderType: "ASC",
pageSize: 10,
pageNumber: 0,
},
},
items: [
{
id: "11111111-1111-4111-8111-111111111111",
company_id: "22222222-2222-4222-8222-222222222222",
name: "30/60",
description: "Pago dividido en dos cuotas",
is_active: true,
is_system: false,
due_rules: [
{
due_days: "30",
percentage: {
value: "5000",
scale: "2",
},
},
{
due_days: "60",
percentage: {
value: "5000",
scale: "2",
},
},
],
},
],
};
const result = ListPaymentTermsAdapter.fromDto(dto);
assert.deepEqual(result.items[0], {
id: "11111111-1111-4111-8111-111111111111",
companyId: "22222222-2222-4222-8222-222222222222",
name: "30/60",
description: "Pago dividido en dos cuotas",
isSystem: false,
isActive: true,
dueRules: [
{
dueDays: 30,
percentage: 50,
},
{
dueDays: 60,
percentage: 50,
},
],
});
});

View File

@ -1,6 +1,6 @@
import type { ListPaymentTermsResponseDTO } from "../../../../common";
import type { ListPaymentTermsResult } from "../api";
import type { PaymentTermList, PaymentTermListRow } from "../entities";
import type { PaymentTermDueRule, PaymentTermList, PaymentTermListRow } from "../entities";
/**
* Adaptador para transformar los datos de la API de ListPaymentTermsResult
@ -15,13 +15,13 @@ import type { PaymentTermList, PaymentTermListRow } from "../entities";
*/
export const ListPaymentTermsAdapter = {
fromDto(dto: ListPaymentTermsResult, _context?: unknown): PaymentTermList {
fromDto(dto: ListPaymentTermsResult): PaymentTermList {
return {
page: dto.page,
perPage: dto.per_page,
totalPages: dto.total_pages,
totalItems: dto.total_items,
items: dto.items.map((rowDto) => PaymentTermListRowAdapter.fromDto(rowDto, _context)),
items: dto.items.map((rowDto) => PaymentTermListRowAdapter.fromDto(rowDto)),
};
},
};
@ -39,22 +39,21 @@ export const ListPaymentTermsAdapter = {
type ListPaymentTermsItemOutput = ListPaymentTermsResponseDTO["items"][number];
const PaymentTermListRowAdapter = {
fromDto(dto: ListPaymentTermsItemOutput, _context?: unknown): PaymentTermListRow {
fromDto(dto: ListPaymentTermsItemOutput): PaymentTermListRow {
return {
id: dto.id,
companyId: dto.company_id,
name: dto.name,
description: dto.description,
isSystem: dto.is_system,
isActive: dto.is_active,
//dueRules: dto.due_rules.map(PaymentTermDueRuleAdapter.fromDto),
dueRules: dto.due_rules.map(PaymentTermDueRuleAdapter.fromDto),
};
},
};
/** */
/*
const PaymentTermDueRuleAdapter = {
fromDto(dto: PaymentTermDueRuleDTO): PaymentTermDueRule {
fromDto(dto: ListPaymentTermsItemOutput["due_rules"][number]): PaymentTermDueRule {
return {
dueDays: Number(dto.due_days),
percentage: percentageDtoToNumber(dto.percentage),
@ -65,4 +64,3 @@ const PaymentTermDueRuleAdapter = {
const percentageDtoToNumber = (dto: { value: string; scale: string }): number => {
return Number(dto.value) / 10 ** Number(dto.scale);
};
*/

View File

@ -0,0 +1,18 @@
import type { IDataSource } from "@erp/core/client";
import type { CreatePaymentTermRequestDTO, CreatePaymentTermResponseDTO } from "../../../../common";
export interface CreatePaymentTermParams {
id: string;
data: CreatePaymentTermRequestDTO;
}
export function createPaymentTerm(
dataSource: IDataSource,
params: CreatePaymentTermParams
): Promise<CreatePaymentTermResponseDTO> {
return dataSource.createOne<CreatePaymentTermRequestDTO, CreatePaymentTermResponseDTO>(
"catalogs/payment-terms",
params.data
);
}

View File

@ -0,0 +1,9 @@
import type { IDataSource } from "@erp/core/client";
export interface DeletePaymentTermParams {
paymentTermId: string;
}
export function deletePaymentTerm(dataSource: IDataSource, params: DeletePaymentTermParams) {
return dataSource.deleteOne("catalogs/payment-terms", params.paymentTermId);
}

View File

@ -0,0 +1,18 @@
import type { IDataSource } from "@erp/core/client";
import type { UpdatePaymentTermByIdResponseDTO } from "../../../../common";
export interface DisablePaymentTermParams {
paymentTermId: string;
}
export function disablePaymentTerm(
dataSource: IDataSource,
params: DisablePaymentTermParams
): Promise<UpdatePaymentTermByIdResponseDTO> {
return dataSource.custom<undefined, UpdatePaymentTermByIdResponseDTO>({
method: "patch",
path: `catalogs/payment-terms/${params.paymentTermId}/disable`,
data: undefined,
});
}

View File

@ -0,0 +1,18 @@
import type { IDataSource } from "@erp/core/client";
import type { UpdatePaymentTermByIdResponseDTO } from "../../../../common";
export interface EnablePaymentTermParams {
paymentTermId: string;
}
export function enablePaymentTerm(
dataSource: IDataSource,
params: EnablePaymentTermParams
): Promise<UpdatePaymentTermByIdResponseDTO> {
return dataSource.custom<undefined, UpdatePaymentTermByIdResponseDTO>({
method: "patch",
path: `catalogs/payment-terms/${params.paymentTermId}/enable`,
data: undefined,
});
}

View File

@ -0,0 +1,21 @@
import type { IDataSource } from "@erp/core/client";
import type { GetPaymentTermByIdResponseDTO } from "../../../../common";
export interface GetPaymentTermByIdParams {
paymentTermId: string;
signal?: AbortSignal;
}
export function getPaymentTermById(
dataSource: IDataSource,
params: GetPaymentTermByIdParams
): Promise<GetPaymentTermByIdResponseDTO> {
return dataSource.getOne<GetPaymentTermByIdResponseDTO>(
"catalogs/payment-terms",
params.paymentTermId,
{
signal: params.signal,
}
);
}

View File

@ -1 +1,7 @@
export * from "./list-payment-terms-by-criteria.api";
export * from "./get-payment-term-by-id.api";
export * from "./create-payment-term.api";
export * from "./update-payment-term.api";
export * from "./delete-payment-term.api";
export * from "./enable-payment-term.api";
export * from "./disable-payment-term.api";

View File

@ -0,0 +1,19 @@
import type { IDataSource } from "@erp/core/client";
import type { UpdatePaymentTermByIdRequestDTO, UpdatePaymentTermByIdResponseDTO } from "../../../../common";
export interface UpdatePaymentTermParams {
paymentTermId: string;
data: UpdatePaymentTermByIdRequestDTO;
}
export function updatePaymentTerm(
dataSource: IDataSource,
params: UpdatePaymentTermParams
): Promise<UpdatePaymentTermByIdResponseDTO> {
return dataSource.updateOne<UpdatePaymentTermByIdRequestDTO, UpdatePaymentTermByIdResponseDTO>(
"catalogs/payment-terms",
params.paymentTermId,
params.data
);
}

View File

@ -5,12 +5,16 @@
* la información básica de cada vencimiento en la lista.
*/
import type { PaymentTermDueRule } from "./payment-term.entity";
export interface PaymentTermListRow {
id: string;
companyId: string;
name: string;
description: string | null;
isSystem: boolean;
isActive: boolean;
dueRules: PaymentTermDueRule[];
}

View File

@ -1 +1,7 @@
export * from "./use-payment-term-list-query";
export * from "./use-payment-term-detail-query";
export * from "./use-payment-term-create-mutation";
export * from "./use-payment-term-update-mutation";
export * from "./use-payment-term-delete-mutation";
export * from "./use-payment-term-enable-mutation";
export * from "./use-payment-term-disable-mutation";

View File

@ -1,3 +1,4 @@
import { INITIAL_PAGE_INDEX, INITIAL_PAGE_SIZE } from "@repo/rdx-criteria";
import type { QueryKey } from "@tanstack/react-query";
import type { ListPaymentTermsRequestDTO } from "../../../../common";
@ -14,8 +15,8 @@ export const LIST_PAYMENT_TERMS_QUERY_KEY = (criteria?: ListPaymentTermsRequestD
[
...LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX,
{
pageNumber: criteria?.pageNumber ?? 1,
pageSize: criteria?.pageSize ?? 5,
pageNumber: criteria?.pageNumber ?? INITIAL_PAGE_INDEX,
pageSize: criteria?.pageSize ?? INITIAL_PAGE_SIZE,
q: criteria?.q ?? "",
filters: criteria?.filters ?? [],
orderBy: criteria?.orderBy ?? "",
@ -38,7 +39,11 @@ export const PAYMENT_TERM_QUERY_KEY = (paymentTermId?: string): QueryKey => [
export const CREATE_PAYMENT_TERM_MUTATION_KEY = ["payment-terms:create"] as const;
export const UPDATE_PAYMENT_TERM_MUTATION_KEY = ["payment-terms:update"] as const;
export const DELETE_PAYMENT_TERM_MUTATION_KEY = ["payment-terms:delete"] as const;
export const ENABLE_PAYMENT_TERM_MUTATION_KEY = ["payment-terms:enable"] as const;
export const DISABLE_PAYMENT_TERM_MUTATION_KEY = ["payment-terms:disable"] as const;
/**
* Operaciones de dominio
*/
export const paymentTermKeys = {
all: LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX,
list: (criteria?: ListPaymentTermsRequestDTO) => LIST_PAYMENT_TERMS_QUERY_KEY(criteria),
detail: (paymentTermId?: string) => PAYMENT_TERM_QUERY_KEY(paymentTermId),
};

View File

@ -0,0 +1,9 @@
import type { ValidationErrorDetail } from "@repo/rdx-ddd";
import type { ZodError } from "zod";
export const toValidationErrors = (error: ZodError<unknown>): ValidationErrorDetail[] =>
error.issues.map((issue) => ({
path: issue.path.join("."),
message: issue.message,
value: issue.input,
}));

View File

@ -0,0 +1,44 @@
import { useDataSource } from "@erp/core/hooks";
import { ValidationErrorCollection } from "@repo/rdx-ddd";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { CreatePaymentTermRequestSchema } from "../../../../common";
import { GetPaymentTermByIdAdapter } from "../adapters";
import { createPaymentTerm, type CreatePaymentTermParams } from "../api";
import type { PaymentTerm } from "../entities";
import {
CREATE_PAYMENT_TERM_MUTATION_KEY,
LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX,
PAYMENT_TERM_QUERY_KEY,
} from "./keys";
import { toValidationErrors } from "./to-validation-errors";
export const usePaymentTermCreateMutation = () => {
const queryClient = useQueryClient();
const dataSource = useDataSource();
return useMutation<PaymentTerm, DefaultError, CreatePaymentTermParams>({
mutationKey: CREATE_PAYMENT_TERM_MUTATION_KEY,
mutationFn: async (params) => {
if (!params.id) {
throw new Error("paymentTermId is required");
}
const result = CreatePaymentTermRequestSchema.safeParse(params.data);
if (!result.success) {
throw new ValidationErrorCollection("Validation failed", toValidationErrors(result.error));
}
const dto = await createPaymentTerm(dataSource, params);
return GetPaymentTermByIdAdapter.fromDto(dto);
},
onSuccess: async (paymentTerm) => {
queryClient.setQueryData(PAYMENT_TERM_QUERY_KEY(paymentTerm.id), paymentTerm);
await queryClient.invalidateQueries({
queryKey: LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX,
});
},
});
};

View File

@ -0,0 +1,28 @@
import { useDataSource } from "@erp/core/hooks";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { deletePaymentTerm, type DeletePaymentTermParams } from "../api";
import {
DELETE_PAYMENT_TERM_MUTATION_KEY,
LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX,
PAYMENT_TERMS_DETAIL_QUERY_KEY_PREFIX,
} from "./keys";
export const usePaymentTermDeleteMutation = () => {
const queryClient = useQueryClient();
const dataSource = useDataSource();
return useMutation<void, DefaultError, DeletePaymentTermParams>({
mutationKey: DELETE_PAYMENT_TERM_MUTATION_KEY,
mutationFn: async (params) => {
await deletePaymentTerm(dataSource, params);
},
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX }),
queryClient.invalidateQueries({ queryKey: PAYMENT_TERMS_DETAIL_QUERY_KEY_PREFIX }),
]);
},
});
};

View File

@ -0,0 +1,29 @@
import { useDataSource } from "@erp/core/hooks";
import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query";
import { GetPaymentTermByIdAdapter } from "../adapters";
import { getPaymentTermById } from "../api";
import type { PaymentTerm } from "../entities";
import { PAYMENT_TERM_QUERY_KEY } from "./keys";
export const usePaymentTermDetailQuery = (
paymentTermId?: string,
options?: { enabled?: boolean }
): UseQueryResult<PaymentTerm, DefaultError> => {
const dataSource = useDataSource();
const enabled = (options?.enabled ?? true) && Boolean(paymentTermId);
return useQuery<PaymentTerm, DefaultError>({
queryKey: PAYMENT_TERM_QUERY_KEY(paymentTermId),
enabled,
queryFn: async ({ signal }) => {
const dto = await getPaymentTermById(dataSource, {
paymentTermId: paymentTermId as string,
signal,
});
return GetPaymentTermByIdAdapter.fromDto(dto);
},
});
};

View File

@ -0,0 +1,29 @@
import { useDataSource } from "@erp/core/hooks";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { GetPaymentTermByIdAdapter } from "../adapters";
import { disablePaymentTerm, type DisablePaymentTermParams } from "../api";
import type { PaymentTerm } from "../entities";
import {
DISABLE_PAYMENT_TERM_MUTATION_KEY,
LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX,
PAYMENT_TERM_QUERY_KEY,
} from "./keys";
export const usePaymentTermDisableMutation = () => {
const queryClient = useQueryClient();
const dataSource = useDataSource();
return useMutation<PaymentTerm, DefaultError, DisablePaymentTermParams>({
mutationKey: DISABLE_PAYMENT_TERM_MUTATION_KEY,
mutationFn: async (params) => {
const dto = await disablePaymentTerm(dataSource, params);
return GetPaymentTermByIdAdapter.fromDto(dto);
},
onSuccess: async (paymentTerm) => {
queryClient.setQueryData(PAYMENT_TERM_QUERY_KEY(paymentTerm.id), paymentTerm);
await queryClient.invalidateQueries({ queryKey: LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX });
},
});
};

View File

@ -0,0 +1,29 @@
import { useDataSource } from "@erp/core/hooks";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { GetPaymentTermByIdAdapter } from "../adapters";
import { enablePaymentTerm, type EnablePaymentTermParams } from "../api";
import type { PaymentTerm } from "../entities";
import {
ENABLE_PAYMENT_TERM_MUTATION_KEY,
LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX,
PAYMENT_TERM_QUERY_KEY,
} from "./keys";
export const usePaymentTermEnableMutation = () => {
const queryClient = useQueryClient();
const dataSource = useDataSource();
return useMutation<PaymentTerm, DefaultError, EnablePaymentTermParams>({
mutationKey: ENABLE_PAYMENT_TERM_MUTATION_KEY,
mutationFn: async (params) => {
const dto = await enablePaymentTerm(dataSource, params);
return GetPaymentTermByIdAdapter.fromDto(dto);
},
onSuccess: async (paymentTerm) => {
queryClient.setQueryData(PAYMENT_TERM_QUERY_KEY(paymentTerm.id), paymentTerm);
await queryClient.invalidateQueries({ queryKey: LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX });
},
});
};

View File

@ -13,7 +13,7 @@ export interface PaymentTermsListQueryOptions {
criteria?: Partial<CriteriaDTO>;
}
export const usePaymentTermsListQuery = (
export const usePaymentTermListQuery = (
options?: PaymentTermsListQueryOptions
): UseQueryResult<PaymentTermList, DefaultError> => {
const dataSource = useDataSource();

View File

@ -0,0 +1,44 @@
import { useDataSource } from "@erp/core/hooks";
import { ValidationErrorCollection } from "@repo/rdx-ddd";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { UpdatePaymentTermByIdRequestSchema } from "../../../../common";
import { GetPaymentTermByIdAdapter } from "../adapters";
import { updatePaymentTerm, type UpdatePaymentTermParams } from "../api";
import type { PaymentTerm } from "../entities";
import {
LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX,
PAYMENT_TERM_QUERY_KEY,
UPDATE_PAYMENT_TERM_MUTATION_KEY,
} from "./keys";
import { toValidationErrors } from "./to-validation-errors";
export const usePaymentTermUpdateMutation = () => {
const queryClient = useQueryClient();
const dataSource = useDataSource();
return useMutation<PaymentTerm, DefaultError, UpdatePaymentTermParams>({
mutationKey: UPDATE_PAYMENT_TERM_MUTATION_KEY,
mutationFn: async (params) => {
if (!params.paymentTermId) {
throw new Error("paymentTermId is required");
}
const result = UpdatePaymentTermByIdRequestSchema.safeParse(params.data);
if (!result.success) {
throw new ValidationErrorCollection("Validation failed", toValidationErrors(result.error));
}
const dto = await updatePaymentTerm(dataSource, params);
return GetPaymentTermByIdAdapter.fromDto(dto);
},
onSuccess: async (paymentTerm) => {
queryClient.setQueryData(PAYMENT_TERM_QUERY_KEY(paymentTerm.id), paymentTerm);
await queryClient.invalidateQueries({
queryKey: LIST_PAYMENT_TERMS_QUERY_KEY_PREFIX,
});
},
});
};

View File

@ -1,2 +1,4 @@
export * from "./entities";
export * from "./hooks";
export * from "./api";
export * from "./adapters";

View File

@ -1,2 +1,3 @@
export * from "./payment-methods";
export * from "./payment-terms";
export * from "./tax-regimes";

View File

@ -0,0 +1,2 @@
export * from "./use-payment-terms-settings-controller";
export * from "./use-payment-terms-settings-page-controller";

View File

@ -0,0 +1,301 @@
import { applyValidationErrorCollection } from "@erp/core";
import { getUserErrorMessage, isApiClientError } from "@erp/core/client";
import { useHookForm } from "@erp/core/hooks";
import { UniqueID, type ValidationErrorCollection, isValidationErrorCollection } from "@repo/rdx-ddd";
import {
focusFirstInputFormError,
showErrorToast,
showSuccessToast,
showWarningToast,
} from "@repo/rdx-ui/helpers";
import { useCallback, useEffect, useId, useState } from "react";
import type { FormEvent } from "react";
import type { FieldErrors } from "react-hook-form";
import { useTranslation } from "../../../i18n";
import {
type PaymentTermListRow,
usePaymentTermCreateMutation,
usePaymentTermDeleteMutation,
usePaymentTermDetailQuery,
usePaymentTermDisableMutation,
usePaymentTermEnableMutation,
usePaymentTermUpdateMutation,
} from "../../../payment-terms/shared";
import type { PaymentTermForm } from "../entities";
import { PaymentTermFormSchema } from "../entities";
import {
buildCreatePaymentTermParams,
buildPaymentTermFormDefault,
buildPaymentTermFormFromEntity,
buildUpdatePaymentTermParams,
} from "../utils";
const normalizeFormSubmitError = (error: unknown): Error | ValidationErrorCollection => {
if (isValidationErrorCollection(error)) {
return error;
}
if (error instanceof Error) {
return error;
}
return new Error("Unknown error");
};
export const usePaymentTermsSettingsController = () => {
const { t } = useTranslation();
const formId = useId();
const createMutation = usePaymentTermCreateMutation();
const updateMutation = usePaymentTermUpdateMutation();
const deleteMutation = usePaymentTermDeleteMutation();
const enableMutation = usePaymentTermEnableMutation();
const disableMutation = usePaymentTermDisableMutation();
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [editingPaymentTermId, setEditingPaymentTermId] = useState<string | null>(null);
const [deletingPaymentTerm, setDeletingPaymentTerm] = useState<PaymentTermListRow | null>(null);
const editingPaymentTermQuery = usePaymentTermDetailQuery(editingPaymentTermId ?? undefined, {
enabled: editingPaymentTermId !== null,
});
const editingPaymentTerm = editingPaymentTermQuery.data ?? null;
const form = useHookForm<PaymentTermForm>({
resolverSchema: PaymentTermFormSchema,
initialValues: buildPaymentTermFormDefault(),
disabled:
createMutation.isPending || updateMutation.isPending || editingPaymentTermQuery.isLoading,
});
const isSaving =
createMutation.isPending || updateMutation.isPending || editingPaymentTermQuery.isLoading;
const isTogglingActive = enableMutation.isPending || disableMutation.isPending;
const resetForm = useCallback(() => {
form.reset(buildPaymentTermFormDefault(), { keepDirty: false });
form.clearErrors();
}, [form]);
const closeCreateDialog = useCallback(() => {
if (isSaving) {
return;
}
setIsCreateOpen(false);
resetForm();
}, [isSaving, resetForm]);
const openCreateDialog = useCallback(() => {
setEditingPaymentTermId(null);
resetForm();
setIsCreateOpen(true);
}, [resetForm]);
const openEditDialog = useCallback(
(paymentTerm: PaymentTermListRow) => {
if (paymentTerm.isSystem) {
return;
}
setIsCreateOpen(false);
setEditingPaymentTermId(paymentTerm.id);
resetForm();
},
[resetForm]
);
const closeEditDialog = useCallback(() => {
if (isSaving) {
return;
}
setEditingPaymentTermId(null);
resetForm();
}, [isSaving, resetForm]);
const openDeleteDialog = useCallback((paymentTerm: PaymentTermListRow) => {
setDeletingPaymentTerm(paymentTerm);
}, []);
const closeDeleteDialog = useCallback(() => {
if (deleteMutation.isPending) {
return;
}
setDeletingPaymentTerm(null);
}, [deleteMutation.isPending]);
const notifyConflictDelete = useCallback(() => {
showErrorToast(
t("settings.paymentTerms.delete.errorTitle"),
t("settings.paymentTerms.delete.inUse")
);
}, [t]);
const onInvalidSubmit = useCallback(
(errors: FieldErrors<PaymentTermForm>) => {
focusFirstInputFormError(errors, form);
showWarningToast(
t("settings.paymentTerms.messages.validationTitle"),
t("settings.paymentTerms.messages.validationDescription")
);
},
[form, t]
);
const submitPaymentTerm = form.handleSubmit(
async (values) => {
try {
if (editingPaymentTerm) {
await updateMutation.mutateAsync(
buildUpdatePaymentTermParams(editingPaymentTerm.id, values)
);
showSuccessToast(
t("settings.paymentTerms.messages.updatedTitle"),
t("settings.paymentTerms.messages.updated")
);
closeEditDialog();
return;
}
const id = UniqueID.generateNewID().toString();
await createMutation.mutateAsync(buildCreatePaymentTermParams(id, values));
showSuccessToast(
t("settings.paymentTerms.messages.createdTitle"),
t("settings.paymentTerms.messages.created")
);
closeCreateDialog();
} catch (error: unknown) {
const normalizedError = normalizeFormSubmitError(error);
if (isValidationErrorCollection(normalizedError)) {
applyValidationErrorCollection(form, normalizedError);
focusFirstInputFormError(form.formState.errors, form);
showWarningToast(
t("settings.paymentTerms.messages.validationTitle"),
t("settings.paymentTerms.messages.validationDescription")
);
return;
}
const userMessage = getUserErrorMessage(normalizedError);
showErrorToast(userMessage.title, userMessage.description);
}
},
onInvalidSubmit
);
const onSubmit = useCallback(
(event: FormEvent<HTMLFormElement>) => {
submitPaymentTerm(event).catch((error: unknown) => {
const userMessage = getUserErrorMessage(error);
showErrorToast(userMessage.title, userMessage.description);
});
},
[submitPaymentTerm]
);
const enable = useCallback(
(paymentTerm: PaymentTermListRow) => {
if (isTogglingActive) {
return;
}
enableMutation
.mutateAsync({ paymentTermId: paymentTerm.id })
.then(() => {
showSuccessToast(
t("settings.paymentTerms.messages.enabledTitle"),
t("settings.paymentTerms.messages.enabled")
);
})
.catch((error: unknown) => {
const userMessage = getUserErrorMessage(error);
showErrorToast(userMessage.title, userMessage.description);
});
},
[enableMutation, isTogglingActive, t]
);
const disable = useCallback(
(paymentTerm: PaymentTermListRow) => {
if (isTogglingActive) {
return;
}
disableMutation
.mutateAsync({ paymentTermId: paymentTerm.id })
.then(() => {
showSuccessToast(
t("settings.paymentTerms.messages.disabledTitle"),
t("settings.paymentTerms.messages.disabled")
);
})
.catch((error: unknown) => {
const userMessage = getUserErrorMessage(error);
showErrorToast(userMessage.title, userMessage.description);
});
},
[disableMutation, isTogglingActive, t]
);
const confirmDelete = useCallback(() => {
if (!deletingPaymentTerm || deleteMutation.isPending) {
return;
}
deleteMutation
.mutateAsync({ paymentTermId: deletingPaymentTerm.id })
.then(() => {
showSuccessToast(
t("settings.paymentTerms.messages.deletedTitle"),
t("settings.paymentTerms.messages.deleted")
);
closeDeleteDialog();
})
.catch((error: unknown) => {
if (isApiClientError(error) && error.kind === "conflict") {
notifyConflictDelete();
return;
}
const userMessage = getUserErrorMessage(error);
showErrorToast(userMessage.title, userMessage.description);
});
}, [closeDeleteDialog, deleteMutation, deletingPaymentTerm, notifyConflictDelete, t]);
useEffect(() => {
if (!editingPaymentTerm) {
return;
}
form.reset(buildPaymentTermFormFromEntity(editingPaymentTerm), { keepDirty: false });
}, [editingPaymentTerm, form]);
return {
form,
formId,
isCreateOpen,
editingPaymentTerm,
isEditingPaymentTermLoading: editingPaymentTermQuery.isLoading,
deletingPaymentTerm,
isSaving,
isDeleting: deleteMutation.isPending,
isTogglingActive,
openCreateDialog,
closeCreateDialog,
openEditDialog,
closeEditDialog,
openDeleteDialog,
closeDeleteDialog,
onSubmit,
enable,
disable,
confirmDelete,
};
};

View File

@ -0,0 +1,85 @@
import { useCallback, useMemo, useState } from "react";
import { useDebounce } from "use-debounce";
import { useTranslation } from "../../../i18n";
import { usePaymentTermListQuery } from "../../../payment-terms/shared";
import {
type PaymentTermsStatusFilter,
buildPaymentTermsListCriteria,
buildPaymentTermsSettingsPageErrorViewModel,
hasPaymentTermsListCriteriaApplied,
resolvePaymentTermsSettingsFeedbackState,
} from "../utils";
import { usePaymentTermsSettingsController } from "./use-payment-terms-settings-controller";
export const usePaymentTermsSettingsPageController = () => {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const [status, setStatus] = useState<PaymentTermsStatusFilter>("all");
const [debouncedSearch] = useDebounce(search, 300);
const criteria = useMemo(
() =>
buildPaymentTermsListCriteria({
search: debouncedSearch,
status,
}),
[debouncedSearch, status]
);
const listQuery = usePaymentTermListQuery({ criteria });
const controller = usePaymentTermsSettingsController();
const feedbackState = resolvePaymentTermsSettingsFeedbackState({
isLoading: listQuery.isLoading,
isError: listQuery.isError,
error: listQuery.error,
});
const pageError = useMemo(() => {
if (!feedbackState.hasBlockingError) {
return null;
}
return buildPaymentTermsSettingsPageErrorViewModel(listQuery.error, (key) => t(key));
}, [feedbackState.hasBlockingError, listQuery.error, t]);
const retry = useCallback(async () => {
await listQuery.refetch();
}, [listQuery]);
const clearFilters = useCallback(() => {
setSearch("");
setStatus("all");
}, []);
const hasAppliedCriteria = hasPaymentTermsListCriteriaApplied({
search: debouncedSearch,
status,
});
const totalItems = listQuery.data?.totalItems ?? 0;
const list = listQuery.data?.items ?? [];
const isBackgroundFetching = listQuery.isFetching && !listQuery.isLoading;
const hasNoResults = !feedbackState.isInitialLoading && totalItems === 0 && hasAppliedCriteria;
const hasEmptyState = !feedbackState.isInitialLoading && totalItems === 0 && !hasAppliedCriteria;
return {
list,
totalItems,
isInitialLoading: feedbackState.isInitialLoading,
isBackgroundFetching,
hasNoResults,
hasEmptyState,
hasAppliedCriteria,
search,
setSearch,
status,
setStatus,
clearFilters,
criteria,
pageError,
retry,
isRetrying: isBackgroundFetching,
listQuery,
controller,
};
};

View File

@ -0,0 +1,3 @@
export * from "./payment-term-form.entity";
export * from "./payment-term-form.schema";
export * from "./payment-terms-settings-page-error-view-model.entity";

View File

@ -0,0 +1,5 @@
import type { z } from "zod/v4";
import type { PaymentTermFormSchema } from "./payment-term-form.schema";
export type PaymentTermForm = z.infer<typeof PaymentTermFormSchema>;

View File

@ -0,0 +1,87 @@
import { z } from "zod/v4";
import {
parsePaymentTermDueDays,
parsePaymentTermPercentageToHundredths,
} from "../utils/payment-term-due-rules.utils";
const buildInvalidPercentageIssueMessage = (differenceInHundredths: number) => {
const absoluteDifference = Math.abs(differenceInHundredths);
const units = Math.trunc(absoluteDifference / 100);
const decimals = String(absoluteDifference % 100).padStart(2, "0");
const formattedDifference = `${units}.${decimals}`;
if (differenceInHundredths < 0) {
return `Falta ${formattedDifference}% para llegar al 100,00%.`;
}
return `Se supera el 100,00% por ${formattedDifference}%.`;
};
const PaymentTermDueRuleFormSchema = z.object({
dueDays: z.string().trim().min(1, "Los días son obligatorios."),
percentage: z.string().trim().min(1, "El porcentaje es obligatorio."),
});
export const PaymentTermFormSchema = z
.object({
name: z.string().trim().min(1, "El nombre es obligatorio."),
description: z.string().trim(),
isActive: z.boolean(),
dueRules: z
.array(PaymentTermDueRuleFormSchema)
.min(1, "Debes añadir al menos un vencimiento."),
})
.superRefine((value, ctx) => {
const seenDueDays = new Map<number, number>();
let totalPercentage = 0;
value.dueRules.forEach((rule, index) => {
const dueDays = parsePaymentTermDueDays(rule.dueDays);
if (dueDays === null) {
ctx.addIssue({
code: "custom",
message: "Los días deben ser un entero igual o mayor que 0.",
path: ["dueRules", index, "dueDays"],
});
} else {
const previousIndex = seenDueDays.get(dueDays);
if (previousIndex === undefined) {
seenDueDays.set(dueDays, index);
} else {
ctx.addIssue({
code: "custom",
message: "Los días no pueden repetirse.",
path: ["dueRules", index, "dueDays"],
});
ctx.addIssue({
code: "custom",
message: "Los días no pueden repetirse.",
path: ["dueRules", previousIndex, "dueDays"],
});
}
}
const percentage = parsePaymentTermPercentageToHundredths(rule.percentage);
if (percentage === null) {
ctx.addIssue({
code: "custom",
message: "El porcentaje debe ser mayor que 0,00 y menor o igual que 100,00.",
path: ["dueRules", index, "percentage"],
});
} else {
totalPercentage += percentage;
}
});
if (totalPercentage !== 10000) {
ctx.addIssue({
code: "custom",
message: buildInvalidPercentageIssueMessage(totalPercentage - 10000),
path: ["dueRules"],
});
}
});

View File

@ -0,0 +1,9 @@
import type { PageErrorStateProps } from "@erp/core/components";
export interface PaymentTermsSettingsPageErrorViewModel {
variant: PageErrorStateProps["variant"];
title: string;
description: string;
reference?: string;
canRetry: boolean;
}

View File

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

View File

@ -0,0 +1,62 @@
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button,
Spinner,
} from "@repo/shadcn-ui/components";
import { useTranslation } from "../../../../i18n";
import type { PaymentTermListRow } from "../../../../payment-terms/shared";
interface DeletePaymentTermDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
target: PaymentTermListRow | null;
isSubmitting: boolean;
onConfirm: () => void;
}
export const DeletePaymentTermDialog = ({
open,
onOpenChange,
target,
isSubmitting,
onConfirm,
}: DeletePaymentTermDialogProps) => {
const { t } = useTranslation();
return (
<AlertDialog onOpenChange={onOpenChange} open={open}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("settings.paymentTerms.delete.title")}</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.paymentTerms.delete.description", {
name: target?.name ?? "",
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<Button disabled={isSubmitting} onClick={() => onOpenChange(false)} variant="outline">
{t("settings.paymentTerms.actions.cancel")}
</Button>
<Button disabled={isSubmitting || !target} onClick={onConfirm} variant="destructive">
{isSubmitting ? (
<>
<Spinner aria-hidden className="mr-2 size-4" />
{t("settings.paymentTerms.actions.deleting")}
</>
) : (
t("settings.paymentTerms.actions.delete")
)}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@ -0,0 +1,8 @@
export * from "./payment-term-form-dialog";
export * from "./delete-payment-term-dialog";
export * from "./payment-term-list-item";
export * from "./payment-term-row-actions";
export * from "./payment-term-segmented-bar";
export * from "./payment-terms-empty-state";
export * from "./payment-terms-no-results-state";
export * from "./payment-terms-settings-skeleton";

View File

@ -0,0 +1,267 @@
import { useEffect, useMemo } from "react";
import type { FormEventHandler } from "react";
import { FormProvider, useFieldArray, useWatch } from "react-hook-form";
import type { UseFormReturn } from "react-hook-form";
import {
RdxFormGrid,
SwitchField,
TextAreaField,
TextField,
} from "@repo/rdx-ui/components";
import {
Badge,
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Spinner,
} from "@repo/shadcn-ui/components";
import { PlusIcon, Trash2Icon } from "lucide-react";
import { useTranslation } from "../../../../i18n";
import type { PaymentTermForm } from "../../entities";
import {
buildPaymentTermPercentageStatus,
calculatePaymentTermDueRulesTotalPercentageInHundredths,
formatPaymentTermTotalPercentage,
} from "../../utils";
interface PaymentTermFormDialogProps {
open: boolean;
title: string;
description: string;
submitLabel: string;
openChange: (open: boolean) => void;
formId: string;
form: UseFormReturn<PaymentTermForm>;
onSubmit: FormEventHandler<HTMLFormElement>;
isSubmitting: boolean;
isEditMode: boolean;
}
const totalToneClassNames: Record<"success" | "warning" | "destructive", string> = {
success: "border-emerald-200 bg-emerald-50 text-emerald-900",
warning: "border-amber-200 bg-amber-50 text-amber-900",
destructive: "border-red-200 bg-red-50 text-red-900",
};
export const PaymentTermFormDialog = ({
open,
title,
description,
submitLabel,
openChange,
formId,
form,
onSubmit,
isSubmitting,
isEditMode,
}: PaymentTermFormDialogProps) => {
const { t, i18n } = useTranslation();
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "dueRules",
});
const watchedDueRules = useWatch({
control: form.control,
name: "dueRules",
defaultValue: form.getValues("dueRules"),
});
const dueRules = useMemo(() => {
return fields.map((field, index) => {
return watchedDueRules?.[index] ?? {
dueDays: field.dueDays ?? "",
percentage: field.percentage ?? "",
};
});
}, [fields, watchedDueRules]);
const totalPercentageInHundredths = useMemo(() => {
return calculatePaymentTermDueRulesTotalPercentageInHundredths(dueRules);
}, [dueRules]);
const totalStatus = useMemo(() => {
return buildPaymentTermPercentageStatus(totalPercentageInHundredths, t, i18n.language);
}, [i18n.language, t, totalPercentageInHundredths]);
useEffect(() => {
if (!open) {
return;
}
form.setFocus("name");
}, [form, open]);
return (
<Dialog onOpenChange={openChange} open={open}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<FormProvider {...form}>
<form className="space-y-6" id={formId} noValidate onSubmit={onSubmit}>
<RdxFormGrid className="grid-cols-1 md:grid-cols-1">
<TextField
autoComplete="off"
className="w-full"
description={t("settings.paymentTerms.fields.nameDescription")}
disabled={isSubmitting}
label={t("settings.paymentTerms.fields.name")}
name="name"
placeholder={t("settings.paymentTerms.fields.namePlaceholder")}
/>
<TextAreaField
className="w-full"
description={t("settings.paymentTerms.fields.descriptionDescription")}
disabled={isSubmitting}
label={t("settings.paymentTerms.fields.description")}
name="description"
placeholder={t("settings.paymentTerms.fields.descriptionPlaceholder")}
rows={4}
/>
<SwitchField
className="flex items-center justify-between rounded-lg border border-border px-4 py-3"
description={t("settings.paymentTerms.fields.activeDescription")}
disabled={isSubmitting}
label={t("settings.paymentTerms.fields.active")}
name="isActive"
/>
</RdxFormGrid>
<section className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<h3 className="text-sm font-semibold">{t("settings.paymentTerms.form.rules.title")}</h3>
<p className="text-sm text-muted-foreground">
{t("settings.paymentTerms.form.rules.description")}
</p>
</div>
<Badge
className={`border px-3 py-1 text-xs ${totalToneClassNames[totalStatus.tone]}`}
variant="outline"
>
{t("settings.paymentTerms.form.total.label", {
amount: formatPaymentTermTotalPercentage(
totalPercentageInHundredths,
i18n.language
),
})}
</Badge>
</div>
<div
className={`rounded-lg border px-4 py-3 text-sm ${totalToneClassNames[totalStatus.tone]}`}
>
<p className="font-medium">{totalStatus.description}</p>
</div>
<div className="hidden grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] items-center gap-4 px-3 text-xs font-medium text-muted-foreground md:grid">
<span>{t("settings.paymentTerms.fields.dueDays")}</span>
<span>{t("settings.paymentTerms.fields.percentage")}</span>
<span className="text-right">{t("settings.paymentTerms.actions.removeRule")}</span>
</div>
<div className="space-y-2">
{fields.map((field, index) => (
<div
className="rounded-xl border border-border bg-muted/20 p-3"
key={field.id}
>
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] md:items-start">
<div className="space-y-1">
<span className="text-xs font-medium text-muted-foreground md:hidden">
{t("settings.paymentTerms.fields.dueDays")}
</span>
<TextField<PaymentTermForm>
aria-label={t("settings.paymentTerms.fields.dueDays")}
className="w-full"
disabled={isSubmitting}
inputMode="numeric"
name={`dueRules.${index}.dueDays`}
placeholder={t("settings.paymentTerms.fields.dueDaysPlaceholder")}
typePreset="number"
/>
</div>
<div className="space-y-1">
<span className="text-xs font-medium text-muted-foreground md:hidden">
{t("settings.paymentTerms.fields.percentage")}
</span>
<TextField<PaymentTermForm>
aria-label={t("settings.paymentTerms.fields.percentage")}
className="w-full"
disabled={isSubmitting}
inputClassName="tabular-nums"
inputMode="decimal"
name={`dueRules.${index}.percentage`}
placeholder={t("settings.paymentTerms.fields.percentagePlaceholder")}
rightIcon={<span className="text-muted-foreground">%</span>}
/>
</div>
<div className="flex justify-end md:items-start">
<Button
aria-label={t("settings.paymentTerms.actions.removeRule")}
className="shrink-0"
disabled={isSubmitting || fields.length === 1}
onClick={() => remove(index)}
size="icon"
title={t("settings.paymentTerms.actions.removeRule")}
type="button"
variant="outline"
>
<Trash2Icon aria-hidden className="size-4" />
<span className="sr-only">
{t("settings.paymentTerms.actions.removeRule")}
</span>
</Button>
</div>
</div>
</div>
))}
</div>
<Button
disabled={isSubmitting}
onClick={() => append({ dueDays: "", percentage: "" })}
type="button"
variant="outline"
>
<PlusIcon aria-hidden className="mr-2 size-4" />
{t("settings.paymentTerms.actions.addRule")}
</Button>
</section>
</form>
</FormProvider>
<DialogFooter>
<Button disabled={isSubmitting} onClick={() => openChange(false)} variant="outline">
{t("settings.paymentTerms.actions.cancel")}
</Button>
<Button disabled={isSubmitting} form={formId} type="submit">
{isSubmitting ? (
<>
<Spinner aria-hidden className="mr-2 size-4" />
{isEditMode
? t("settings.paymentTerms.actions.saving")
: t("settings.paymentTerms.actions.creating")}
</>
) : (
submitLabel
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,164 @@
import { Badge, Card, CardContent } from "@repo/shadcn-ui/components";
import { CalendarClockIcon } from "lucide-react";
import { useTranslation } from "../../../../i18n";
import type { PaymentTermListRow } from "../../../../payment-terms/shared";
import {
buildPaymentTermDueRuleSegments,
formatPaymentTermDueDays,
formatPaymentTermPercentage,
getPaymentTermInstallmentCount,
getPaymentTermLastDueDays,
getPaymentTermPercentageDistribution,
sortPaymentTermDueRules,
} from "../../utils";
import { PaymentTermRowActions } from "./payment-term-row-actions";
import { PaymentTermSegmentedBar } from "./payment-term-segmented-bar";
interface PaymentTermListItemProps {
paymentTerm: PaymentTermListRow;
onEdit: (paymentTerm: PaymentTermListRow) => void;
onToggleActive: (paymentTerm: PaymentTermListRow) => void;
onDelete: (paymentTerm: PaymentTermListRow) => void;
activeDisabledReason?: string;
deleteDisabledReason?: string;
isTogglingActive?: boolean;
}
export const PaymentTermListItem = ({
paymentTerm,
onEdit,
onToggleActive,
onDelete,
activeDisabledReason,
deleteDisabledReason,
isTogglingActive = false,
}: PaymentTermListItemProps) => {
const { t, i18n } = useTranslation();
const sortedDueRules = sortPaymentTermDueRules(paymentTerm.dueRules);
const installmentCount = getPaymentTermInstallmentCount(sortedDueRules);
const lastDueDays = getPaymentTermLastDueDays(sortedDueRules);
const percentageDistribution = getPaymentTermPercentageDistribution(sortedDueRules);
const segments = buildPaymentTermDueRuleSegments(sortedDueRules);
const formatLocalizedPercentage = (value: number) =>
formatPaymentTermPercentage(value, i18n.language);
const formatLocalizedDueDays = (value: number) => formatPaymentTermDueDays(value, t);
return (
<Card className="border-border/70 shadow-none">
<CardContent className="flex flex-col gap-5 p-4 sm:gap-6 sm:p-5">
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div className="flex min-w-0 items-start gap-3 sm:gap-4">
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border/70 bg-muted/40 text-primary sm:size-11">
<CalendarClockIcon aria-hidden className="size-5" />
</div>
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold text-foreground sm:text-base">
{paymentTerm.name}
</h3>
<Badge variant={paymentTerm.isActive ? "outline" : "secondary"}>
{paymentTerm.isActive
? t("settings.paymentTerms.status.active")
: t("settings.paymentTerms.status.inactive")}
</Badge>
{paymentTerm.isSystem ? (
<Badge variant="secondary">{t("settings.paymentTerms.status.system")}</Badge>
) : null}
</div>
{paymentTerm.description ? (
<p className="max-w-3xl text-sm text-muted-foreground">{paymentTerm.description}</p>
) : null}
</div>
</div>
<PaymentTermRowActions
activeDisabledReason={activeDisabledReason}
deleteDisabledReason={deleteDisabledReason}
isTogglingActive={isTogglingActive}
onDelete={onDelete}
onEdit={onEdit}
onToggleActive={onToggleActive}
paymentTerm={paymentTerm}
/>
</div>
<div className="grid gap-3 sm:gap-4 md:grid-cols-2 xl:grid-cols-3">
<div className="space-y-1 rounded-lg border border-border/60 bg-muted/20 px-4 py-3">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t("settings.paymentTerms.summary.installmentsLabel")}
</p>
<p className="text-sm font-semibold text-foreground">
{t("settings.paymentTerms.summary.installmentsCount", { count: installmentCount })}
</p>
</div>
<div className="space-y-1 rounded-lg border border-border/60 bg-muted/20 px-4 py-3">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t("settings.paymentTerms.summary.lastInstallmentLabel")}
</p>
<p className="text-sm font-semibold text-foreground">
{lastDueDays === null ? "-" : formatLocalizedDueDays(lastDueDays)}
</p>
</div>
<div className="space-y-1 rounded-lg border border-border/60 bg-muted/20 px-4 py-3 md:col-span-2 xl:col-span-1">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t("settings.paymentTerms.summary.distributionLabel")}
</p>
<p className="text-sm font-semibold text-foreground">
{percentageDistribution.map(formatLocalizedPercentage).join(" / ")}
</p>
</div>
</div>
<div className="space-y-4">
<div className="space-y-3">
<h4 className="text-sm font-semibold text-foreground">
{t("settings.paymentTerms.details.title")}
</h4>
<div className="grid gap-3 md:grid-cols-2">
{sortedDueRules.map((rule, index) => (
<div
className="rounded-lg border border-border/60 bg-background px-4 py-3"
key={`${paymentTerm.id}-${rule.dueDays}-${rule.percentage}`}
>
<div className="flex items-start gap-3">
<div className="flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">
{index + 1}
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-foreground tabular-nums">
{formatLocalizedPercentage(rule.percentage)} ·{" "}
{formatLocalizedDueDays(rule.dueDays)}
</p>
<p className="text-xs text-muted-foreground">
{rule.dueDays === 0
? t("settings.paymentTerms.details.sameDay")
: t("settings.paymentTerms.details.baseDate")}
</p>
</div>
</div>
</div>
))}
</div>
</div>
<PaymentTermSegmentedBar
formatDueDays={formatLocalizedDueDays}
formatPercentage={formatLocalizedPercentage}
segments={segments}
/>
</div>
</CardContent>
</Card>
);
};

View File

@ -0,0 +1,103 @@
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Spinner,
} from "@repo/shadcn-ui/components";
import { MoreHorizontalIcon } from "lucide-react";
import { useTranslation } from "../../../../i18n";
import type { PaymentTermListRow } from "../../../../payment-terms/shared";
interface PaymentTermRowActionsProps {
paymentTerm: PaymentTermListRow;
onEdit: (paymentTerm: PaymentTermListRow) => void;
onToggleActive: (paymentTerm: PaymentTermListRow) => void;
onDelete: (paymentTerm: PaymentTermListRow) => void;
activeDisabledReason?: string;
deleteDisabledReason?: string;
isTogglingActive?: boolean;
}
export const PaymentTermRowActions = ({
paymentTerm,
onEdit,
onToggleActive,
onDelete,
activeDisabledReason,
deleteDisabledReason,
isTogglingActive = false,
}: PaymentTermRowActionsProps) => {
const { t } = useTranslation();
const editDisabled = paymentTerm.isSystem;
const toggleDisabled = paymentTerm.isSystem || isTogglingActive;
const deleteDisabled = paymentTerm.isSystem;
return (
<div className="flex w-full flex-wrap items-center gap-2 xl:w-auto xl:justify-end">
<Button
aria-label={t("settings.paymentTerms.actions.edit")}
className="flex-1 sm:flex-none"
disabled={editDisabled}
onClick={() => onEdit(paymentTerm)}
size="sm"
title={editDisabled ? t("settings.paymentTerms.protections.systemEdit") : undefined}
variant="outline"
>
{t("settings.paymentTerms.actions.edit")}
</Button>
<Button
aria-busy={isTogglingActive}
aria-label={
paymentTerm.isActive
? t("settings.paymentTerms.actions.disable")
: t("settings.paymentTerms.actions.enable")
}
className="flex-1 sm:flex-none"
disabled={toggleDisabled}
onClick={() => onToggleActive(paymentTerm)}
size="sm"
title={toggleDisabled ? activeDisabledReason : undefined}
variant="outline"
>
{isTogglingActive ? <Spinner aria-hidden className="mr-2 size-4" /> : null}
{paymentTerm.isActive
? t("settings.paymentTerms.actions.disable")
: t("settings.paymentTerms.actions.enable")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
aria-label={t("settings.paymentTerms.actions.more")}
className="shrink-0"
size="icon"
variant="ghost"
>
<MoreHorizontalIcon className="size-4" />
</Button>
}
/>
<DropdownMenuContent
align="end"
className="w-48"
render={
<DropdownMenuItem
disabled={deleteDisabled}
onClick={() => onDelete(paymentTerm)}
title={deleteDisabled ? deleteDisabledReason : undefined}
variant="destructive"
>
{t("settings.paymentTerms.actions.delete")}
</DropdownMenuItem>
}
/>
</DropdownMenu>
</div>
);
};

View File

@ -0,0 +1,96 @@
import { cn } from "@repo/shadcn-ui/lib/utils";
type PaymentTermSegment = {
dueDays: number;
percentage: number;
fraction: number;
};
interface PaymentTermSegmentedBarProps {
segments: PaymentTermSegment[];
formatPercentage: (value: number) => string;
formatDueDays: (value: number) => string;
}
const SEGMENT_COLOR_CLASSNAMES = [
"bg-sky-500/15 text-sky-700 ring-sky-500/25 dark:bg-sky-400/20 dark:text-sky-100 dark:ring-sky-400/30",
"bg-emerald-500/15 text-emerald-700 ring-emerald-500/25 dark:bg-emerald-400/20 dark:text-emerald-100 dark:ring-emerald-400/30",
"bg-amber-500/15 text-amber-700 ring-amber-500/25 dark:bg-amber-400/20 dark:text-amber-100 dark:ring-amber-400/30",
"bg-fuchsia-500/15 text-fuchsia-700 ring-fuchsia-500/25 dark:bg-fuchsia-400/20 dark:text-fuchsia-100 dark:ring-fuchsia-400/30",
];
export const PaymentTermSegmentedBar = ({
segments,
formatPercentage,
formatDueDays,
}: PaymentTermSegmentedBarProps) => {
if (segments.length === 0) {
return null;
}
const gridTemplateColumns = segments
.map((segment) => `${Math.max(segment.percentage, 1)}fr`)
.join(" ");
return (
<div className="space-y-2">
<div
className="hidden gap-2 sm:grid"
style={{
gridTemplateColumns,
}}
>
{segments.map((segment, index) => (
<div
className={cn(
"flex min-h-11 items-center justify-center rounded-md px-3 text-xs font-semibold tabular-nums ring-1",
SEGMENT_COLOR_CLASSNAMES[index % SEGMENT_COLOR_CLASSNAMES.length]
)}
key={`${segment.dueDays}-${segment.percentage}-${segment.fraction}`}
title={`${formatPercentage(segment.percentage)} · ${formatDueDays(segment.dueDays)}`}
>
{formatPercentage(segment.percentage)}
</div>
))}
</div>
<div className="space-y-2 sm:hidden">
{segments.map((segment, index) => (
<div
className="space-y-2"
key={`${segment.dueDays}-${segment.percentage}-${segment.fraction}-mobile`}
>
<div
className={cn(
"flex min-h-11 items-center justify-center rounded-md px-3 text-sm font-semibold tabular-nums ring-1",
SEGMENT_COLOR_CLASSNAMES[index % SEGMENT_COLOR_CLASSNAMES.length]
)}
title={`${formatPercentage(segment.percentage)} · ${formatDueDays(segment.dueDays)}`}
>
{formatPercentage(segment.percentage)}
</div>
<div className="text-center text-xs text-muted-foreground tabular-nums">
{formatDueDays(segment.dueDays)}
</div>
</div>
))}
</div>
<div
className="hidden gap-2 sm:grid"
style={{
gridTemplateColumns,
}}
>
{segments.map((segment) => (
<div
className="text-center text-xs text-muted-foreground tabular-nums"
key={`${segment.dueDays}-${segment.percentage}-${segment.fraction}-label`}
>
{formatDueDays(segment.dueDays)}
</div>
))}
</div>
</div>
);
};

View File

@ -0,0 +1,22 @@
import { Button } from "@repo/shadcn-ui/components";
import { useTranslation } from "../../../../i18n";
export const PaymentTermsEmptyState = ({ onCreate }: { onCreate: () => void }) => {
const { t } = useTranslation();
return (
<div className="rounded-xl border border-dashed border-border bg-muted/20 p-8 text-center">
<div className="space-y-2">
<h3 className="text-lg font-semibold">{t("settings.paymentTerms.empty.title")}</h3>
<p className="text-sm text-muted-foreground">
{t("settings.paymentTerms.empty.description")}
</p>
</div>
<Button className="mt-6" onClick={onCreate}>
{t("settings.paymentTerms.actions.create")}
</Button>
</div>
);
};

View File

@ -0,0 +1,26 @@
import { Button } from "@repo/shadcn-ui/components";
import { useTranslation } from "../../../../i18n";
export const PaymentTermsNoResultsState = ({
onClear,
}: {
onClear: () => void;
}) => {
const { t } = useTranslation();
return (
<div className="rounded-xl border border-dashed border-border bg-muted/15 p-8 text-center">
<div className="space-y-2">
<h3 className="text-lg font-semibold">{t("settings.paymentTerms.emptyFiltered.title")}</h3>
<p className="text-sm text-muted-foreground">
{t("settings.paymentTerms.emptyFiltered.description")}
</p>
</div>
<Button className="mt-6" onClick={onClear} variant="outline">
{t("settings.paymentTerms.emptyFiltered.clear")}
</Button>
</div>
);
};

View File

@ -0,0 +1,28 @@
import { Skeleton } from "@repo/shadcn-ui/components";
export const PaymentTermsSettingsSkeleton = () => {
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-3">
<Skeleton className="h-9 w-64" />
<Skeleton className="h-4 w-96 max-w-full" />
</div>
<Skeleton className="h-10 w-40" />
</div>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-col gap-3 md:flex-row">
<Skeleton className="h-10 w-full md:w-80" />
<Skeleton className="h-10 w-full md:w-40" />
</div>
<Skeleton className="h-5 w-40" />
</div>
<div className="space-y-3">
<Skeleton className="h-80 w-full" />
<Skeleton className="h-80 w-full" />
</div>
</div>
);
};

View File

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

View File

@ -0,0 +1 @@
export * from "./payment-terms-settings-page";

View File

@ -0,0 +1,228 @@
import { PageErrorState } from "@erp/core/components";
import { RdxPageContainer, RdxPageHeader, RdxPageStack } from "@repo/rdx-ui/components";
import {
Button,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Spinner,
} from "@repo/shadcn-ui/components";
import { PlusIcon, SearchIcon } from "lucide-react";
import type { ChangeEvent } from "react";
import { useTranslation } from "../../../../i18n";
import { usePaymentTermsSettingsPageController } from "../../controllers";
import {
DeletePaymentTermDialog,
PaymentTermFormDialog,
PaymentTermListItem,
PaymentTermsEmptyState,
PaymentTermsNoResultsState,
PaymentTermsSettingsSkeleton,
} from "../components";
export const PaymentTermsSettingsPage = () => {
const { t } = useTranslation();
const ctrl = usePaymentTermsSettingsPageController();
const featureCtrl = ctrl.controller;
if (ctrl.isInitialLoading) {
return <PaymentTermsSettingsSkeleton />;
}
if (ctrl.pageError) {
return (
<RdxPageContainer width="full">
<div className="mx-auto w-full max-w-3xl">
<PageErrorState
description={ctrl.pageError.description}
primaryAction={
ctrl.pageError.canRetry
? {
label: ctrl.isRetrying
? t("settings.paymentTerms.pageError.actions.retrying")
: t("settings.paymentTerms.pageError.actions.retry"),
onClick: ctrl.retry,
isLoading: ctrl.isRetrying,
}
: undefined
}
reference={ctrl.pageError.reference}
title={ctrl.pageError.title}
variant={ctrl.pageError.variant}
/>
</div>
</RdxPageContainer>
);
}
const activeDisabledReason = (isSystem: boolean) => {
if (isSystem) {
return t("settings.paymentTerms.protections.systemDisable");
}
return undefined;
};
const deleteDisabledReason = (isSystem: boolean) => {
if (isSystem) {
return t("settings.paymentTerms.protections.systemDelete");
}
return undefined;
};
const handleSearchChange = (event: ChangeEvent<HTMLInputElement>) => {
const target = event.target as unknown as { value: string };
ctrl.setSearch(target.value);
};
const handleStatusChange = (value: "all" | "active" | "inactive" | null) => {
ctrl.setStatus(value ?? "all");
};
return (
<RdxPageContainer width="full">
<RdxPageStack className="mx-auto w-full max-w-6xl" spacing="default">
<RdxPageHeader
actions={
<Button className="w-full sm:w-auto" onClick={featureCtrl.openCreateDialog}>
<PlusIcon aria-hidden className="mr-2 size-4" />
{t("settings.paymentTerms.actions.create")}
</Button>
}
description={t("settings.paymentTerms.description")}
title={t("settings.paymentTerms.title")}
/>
<div className="flex flex-col gap-4 rounded-xl border border-border/60 bg-card p-4 shadow-none md:p-5 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="relative w-full lg:w-96 xl:w-[30rem]">
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
aria-label={t("settings.paymentTerms.toolbar.searchPlaceholder")}
className="pl-9 pr-10"
onChange={handleSearchChange}
placeholder={t("settings.paymentTerms.toolbar.searchPlaceholder")}
value={ctrl.search}
/>
{ctrl.isBackgroundFetching ? (
<Spinner
aria-hidden
className="absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted-foreground"
/>
) : null}
</div>
<div className="grid gap-2 sm:grid-cols-[auto_minmax(0,1fr)] sm:items-center">
<span className="text-sm font-medium text-muted-foreground sm:whitespace-nowrap">
{t("settings.paymentTerms.toolbar.statusLabel")}
</span>
<Select onValueChange={handleStatusChange} value={ctrl.status}>
<SelectTrigger
aria-label={t("settings.paymentTerms.toolbar.statusLabel")}
className="w-full sm:min-w-44 lg:w-44"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t("settings.paymentTerms.toolbar.statusOptions.all")}
</SelectItem>
<SelectItem value="active">
{t("settings.paymentTerms.toolbar.statusOptions.active")}
</SelectItem>
<SelectItem value="inactive">
{t("settings.paymentTerms.toolbar.statusOptions.inactive")}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground lg:justify-end">
{ctrl.isBackgroundFetching ? <Spinner aria-hidden className="size-4" /> : null}
<span>{t("settings.paymentTerms.toolbar.totalCount", { count: ctrl.totalItems })}</span>
</div>
</div>
{ctrl.hasEmptyState ? (
<PaymentTermsEmptyState onCreate={featureCtrl.openCreateDialog} />
) : ctrl.hasNoResults ? (
<PaymentTermsNoResultsState onClear={ctrl.clearFilters} />
) : (
<div aria-busy={ctrl.isBackgroundFetching} className="space-y-4">
{ctrl.list.map((paymentTerm) => (
<PaymentTermListItem
activeDisabledReason={activeDisabledReason(paymentTerm.isSystem)}
deleteDisabledReason={deleteDisabledReason(paymentTerm.isSystem)}
isTogglingActive={featureCtrl.isTogglingActive}
key={paymentTerm.id}
onDelete={featureCtrl.openDeleteDialog}
onEdit={featureCtrl.openEditDialog}
onToggleActive={(item) => {
if (item.isActive) {
featureCtrl.disable(item);
return;
}
featureCtrl.enable(item);
}}
paymentTerm={paymentTerm}
/>
))}
</div>
)}
<PaymentTermFormDialog
description={t("settings.paymentTerms.create.description")}
form={featureCtrl.form}
formId={featureCtrl.formId}
isEditMode={false}
isSubmitting={featureCtrl.isSaving}
onSubmit={featureCtrl.onSubmit}
open={featureCtrl.isCreateOpen}
openChange={(open) => {
if (!open) {
featureCtrl.closeCreateDialog();
}
}}
submitLabel={t("settings.paymentTerms.actions.create")}
title={t("settings.paymentTerms.create.title")}
/>
<PaymentTermFormDialog
description={t("settings.paymentTerms.edit.description")}
form={featureCtrl.form}
formId={`${featureCtrl.formId}-edit`}
isEditMode
isSubmitting={featureCtrl.isSaving || featureCtrl.isEditingPaymentTermLoading}
onSubmit={featureCtrl.onSubmit}
open={Boolean(featureCtrl.editingPaymentTerm) || featureCtrl.isEditingPaymentTermLoading}
openChange={(open) => {
if (!open) {
featureCtrl.closeEditDialog();
}
}}
submitLabel={t("settings.paymentTerms.actions.save")}
title={t("settings.paymentTerms.edit.title")}
/>
<DeletePaymentTermDialog
isSubmitting={featureCtrl.isDeleting}
onConfirm={featureCtrl.confirmDelete}
onOpenChange={(open) => {
if (!open) {
featureCtrl.closeDeleteDialog();
}
}}
open={Boolean(featureCtrl.deletingPaymentTerm)}
target={featureCtrl.deletingPaymentTerm}
/>
</RdxPageStack>
</RdxPageContainer>
);
};

View File

@ -0,0 +1,23 @@
import type { PaymentTerm } from "../../../payment-terms/shared";
import type { PaymentTermForm } from "../entities";
import {
formatPaymentTermPercentageInput,
sortPaymentTermDueRules,
} from "./payment-term-due-rules.utils";
export const buildPaymentTermFormDefault = (): PaymentTermForm => ({
name: "",
description: "",
isActive: true,
dueRules: [{ dueDays: "0", percentage: "100,00" }],
});
export const buildPaymentTermFormFromEntity = (paymentTerm: PaymentTerm): PaymentTermForm => ({
name: paymentTerm.name,
description: paymentTerm.description ?? "",
isActive: paymentTerm.isActive,
dueRules: sortPaymentTermDueRules(paymentTerm.dueRules).map((rule) => ({
dueDays: String(rule.dueDays),
percentage: formatPaymentTermPercentageInput(rule.percentage),
})),
});

View File

@ -0,0 +1,47 @@
import type { CreatePaymentTermParams, UpdatePaymentTermParams } from "../../../payment-terms/shared";
import type { PaymentTermForm } from "../entities";
import {
parsePaymentTermDueDays,
parsePaymentTermPercentage,
sortPaymentTermDueRules,
toPaymentTermPercentageDto,
} from "./payment-term-due-rules.utils";
const buildDueRulesDto = (form: PaymentTermForm) => {
return sortPaymentTermDueRules(
form.dueRules.map((rule) => ({
dueDays: parsePaymentTermDueDays(rule.dueDays) ?? 0,
percentage: parsePaymentTermPercentage(rule.percentage) ?? 0,
}))
).map((rule) => ({
due_days: String(rule.dueDays),
percentage: toPaymentTermPercentageDto(rule.percentage),
}));
};
export const buildCreatePaymentTermParams = (
id: string,
form: PaymentTermForm
): CreatePaymentTermParams => ({
id,
data: {
id,
name: form.name.trim(),
description: form.description.trim() === "" ? null : form.description.trim(),
is_active: form.isActive,
due_rules: buildDueRulesDto(form),
},
});
export const buildUpdatePaymentTermParams = (
paymentTermId: string,
form: PaymentTermForm
): UpdatePaymentTermParams => ({
paymentTermId,
data: {
name: form.name.trim(),
description: form.description.trim() === "" ? null : form.description.trim(),
is_active: form.isActive,
due_rules: buildDueRulesDto(form),
},
});

View File

@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildPaymentTermsListCriteria,
hasPaymentTermsListCriteriaApplied,
} from "./build-payment-terms-list-criteria.ts";
test("construye criteria sin q ni filtro cuando el estado es todos", () => {
assert.deepEqual(buildPaymentTermsListCriteria({ search: " ", status: "all" }), {
q: undefined,
filters: undefined,
pageNumber: 0,
pageSize: 9999,
orderBy: "name",
order: "asc",
});
});
test("construye criteria con búsqueda y filtro de activos", () => {
assert.deepEqual(buildPaymentTermsListCriteria({ search: " contado ", status: "active" }), {
q: "contado",
filters: [{ field: "isActive", operator: "EQUALS", value: "true" }],
pageNumber: 0,
pageSize: 9999,
orderBy: "name",
order: "asc",
});
});
test("detecta cuándo hay criterios aplicados", () => {
assert.equal(hasPaymentTermsListCriteriaApplied({ search: "", status: "all" }), false);
assert.equal(hasPaymentTermsListCriteriaApplied({ search: "30/60", status: "all" }), true);
assert.equal(hasPaymentTermsListCriteriaApplied({ search: "", status: "inactive" }), true);
});

View File

@ -0,0 +1,38 @@
import type { CriteriaDTO } from "@erp/core";
export type PaymentTermsStatusFilter = "all" | "active" | "inactive";
const PAYMENT_TERMS_LIST_PAGE_SIZE = 9999;
export const buildPaymentTermsListCriteria = (params: {
search: string;
status: PaymentTermsStatusFilter;
}): CriteriaDTO => {
const normalizedSearch = params.search.trim();
const filters =
params.status === "all"
? undefined
: [
{
field: "isActive",
operator: "EQUALS" as const,
value: params.status === "active" ? "true" : "false",
},
];
return {
q: normalizedSearch.length > 0 ? normalizedSearch : undefined,
filters,
pageNumber: 0,
pageSize: PAYMENT_TERMS_LIST_PAGE_SIZE,
orderBy: "name",
order: "asc",
};
};
export const hasPaymentTermsListCriteriaApplied = (params: {
search: string;
status: PaymentTermsStatusFilter;
}) => {
return params.search.trim().length > 0 || params.status !== "all";
};

View File

@ -0,0 +1,43 @@
import { isApiClientError } from "@erp/core/client";
import type { PaymentTermsSettingsPageErrorViewModel } from "../entities";
export const buildPaymentTermsSettingsPageErrorViewModel = (
error: unknown,
translate: (key: string) => string
): PaymentTermsSettingsPageErrorViewModel => {
if (!isApiClientError(error)) {
return {
variant: "server",
title: translate("settings.paymentTerms.pageError.unknown.title"),
description: translate("settings.paymentTerms.pageError.unknown.description"),
canRetry: true,
};
}
if (error.kind === "forbidden") {
return {
variant: "forbidden",
title: translate("settings.paymentTerms.pageError.forbidden.title"),
description: translate("settings.paymentTerms.pageError.forbidden.description"),
reference: error.correlationId,
canRetry: false,
};
}
const variant =
error.kind === "network" ||
error.kind === "timeout" ||
error.kind === "unavailable" ||
error.kind === "notFound"
? error.kind
: "server";
return {
variant,
title: translate(`settings.paymentTerms.pageError.${variant}.title`),
description: translate(`settings.paymentTerms.pageError.${variant}.description`),
reference: error.correlationId,
canRetry: true,
};
};

View File

@ -0,0 +1,6 @@
export * from "./build-payment-term-form";
export * from "./build-payment-term-params";
export * from "./build-payment-terms-list-criteria";
export * from "./build-payment-terms-settings-page-error-view-model";
export * from "./payment-term-due-rules.utils";
export * from "./resolve-payment-terms-settings-feedback-state";

View File

@ -0,0 +1,149 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildPaymentTermDueRuleSegments,
buildPaymentTermPercentageStatus,
calculatePaymentTermDueRulesTotalPercentageInHundredths,
getPaymentTermInstallmentCount,
getPaymentTermLastDueDays,
getPaymentTermPercentageDistribution,
sortPaymentTermDueRules,
} from "./payment-term-due-rules.utils.ts";
const buildDueRules = (...percentages: string[]) => {
return percentages.map((percentage, index) => ({
dueDays: String(index * 30),
percentage,
}));
};
const translate = (key: string, params?: Record<string, unknown>) => {
switch (key) {
case "settings.paymentTerms.form.total.valid":
return "La distribución suma exactamente 100,00%.";
case "settings.paymentTerms.form.total.missing":
return `Falta ${params?.amount as string} para llegar al 100,00%.`;
case "settings.paymentTerms.form.total.exceeded":
return `Excede ${params?.amount as string} el 100,00%.`;
default:
return key;
}
};
test("calcula 100,00% con una única regla al 100%", () => {
assert.equal(
calculatePaymentTermDueRulesTotalPercentageInHundredths(buildDueRules("100")),
10000
);
});
test("calcula 100,00% con dos reglas al 50%", () => {
assert.equal(
calculatePaymentTermDueRulesTotalPercentageInHundredths(buildDueRules("50", "50")),
10000
);
});
test("calcula 99,00% con tres reglas al 33%", () => {
assert.equal(
calculatePaymentTermDueRulesTotalPercentageInHundredths(buildDueRules("33", "33", "33")),
9900
);
});
test("calcula 100,00% con 33,33 + 33,33 + 33,34", () => {
assert.equal(
calculatePaymentTermDueRulesTotalPercentageInHundredths(
buildDueRules("33,33", "33,33", "33,34")
),
10000
);
});
test("calcula 100,00% con cuatro reglas al 25%", () => {
assert.equal(
calculatePaymentTermDueRulesTotalPercentageInHundredths(
buildDueRules("25", "25", "25", "25")
),
10000
);
});
test("describe correctamente el 99,00% pendiente", () => {
assert.deepEqual(buildPaymentTermPercentageStatus(9900, translate, "es"), {
tone: "warning",
description: "Falta 1,00% para llegar al 100,00%.",
});
});
test("describe correctamente el 101,00% excedido", () => {
assert.deepEqual(buildPaymentTermPercentageStatus(10100, translate, "es"), {
tone: "destructive",
description: "Excede 1,00% el 100,00%.",
});
});
test("ordena las due rules por días ascendente", () => {
assert.deepEqual(
sortPaymentTermDueRules([
{ dueDays: 60, percentage: 33 },
{ dueDays: 0, percentage: 34 },
{ dueDays: 30, percentage: 33 },
]),
[
{ dueDays: 0, percentage: 34 },
{ dueDays: 30, percentage: 33 },
{ dueDays: 60, percentage: 33 },
]
);
});
test("calcula el número de cuotas", () => {
assert.equal(
getPaymentTermInstallmentCount([
{ dueDays: 0, percentage: 50 },
{ dueDays: 30, percentage: 50 },
]),
2
);
});
test("devuelve null como último vencimiento cuando no hay reglas", () => {
assert.equal(getPaymentTermLastDueDays([]), null);
});
test("devuelve el último vencimiento ordenando reglas desordenadas", () => {
assert.equal(
getPaymentTermLastDueDays([
{ dueDays: 90, percentage: 34 },
{ dueDays: 30, percentage: 33 },
{ dueDays: 60, percentage: 33 },
]),
90
);
});
test("construye la distribución de porcentajes en orden de vencimiento", () => {
assert.deepEqual(
getPaymentTermPercentageDistribution([
{ dueDays: 30, percentage: 33 },
{ dueDays: 90, percentage: 34 },
{ dueDays: 60, percentage: 33 },
]),
[33, 33, 34]
);
});
test("construye segmentos proporcionales preservando el día 0", () => {
assert.deepEqual(
buildPaymentTermDueRuleSegments([
{ dueDays: 30, percentage: 50 },
{ dueDays: 0, percentage: 50 },
]),
[
{ dueDays: 0, percentage: 50, fraction: 0.5 },
{ dueDays: 30, percentage: 50, fraction: 0.5 },
]
);
});

View File

@ -0,0 +1,186 @@
import type { PaymentTermDueRule } from "../../../payment-terms/shared";
import type { PaymentTermForm } from "../entities";
const PERCENTAGE_SCALE = 2;
const MAX_PERCENTAGE_HUNDREDTHS = 10000;
const PAYMENT_TERM_PERCENTAGE_PATTERN = /^\d+(?:[.,]\d{1,2})?$/;
const PAYMENT_TERM_DUE_DAYS_PATTERN = /^\d+$/;
const buildFormatter = (locale?: string, minimumFractionDigits = 0) => {
return new Intl.NumberFormat(locale, {
minimumFractionDigits,
maximumFractionDigits: PERCENTAGE_SCALE,
});
};
export type PaymentTermDueRuleSegment = {
dueDays: number;
percentage: number;
fraction: number;
};
export const comparePaymentTermDueRules = (left: PaymentTermDueRule, right: PaymentTermDueRule) => {
return left.dueDays - right.dueDays;
};
export const sortPaymentTermDueRules = <TRule extends PaymentTermDueRule>(rules: TRule[]) => {
return [...rules].sort(comparePaymentTermDueRules);
};
export const getPaymentTermInstallmentCount = (rules: PaymentTermDueRule[]) => {
return rules.length;
};
export const getPaymentTermLastDueDays = (rules: PaymentTermDueRule[]) => {
const lastRule = sortPaymentTermDueRules(rules).at(-1);
return lastRule?.dueDays ?? null;
};
export const getPaymentTermPercentageDistribution = (rules: PaymentTermDueRule[]) => {
return sortPaymentTermDueRules(rules).map((rule) => rule.percentage);
};
export const buildPaymentTermDueRuleSegments = (
rules: PaymentTermDueRule[]
): PaymentTermDueRuleSegment[] => {
return sortPaymentTermDueRules(rules).map((rule) => ({
dueDays: rule.dueDays,
percentage: rule.percentage,
fraction: rule.percentage / 100,
}));
};
export const toPaymentTermPercentageHundredths = (value: number) => {
return Math.round(value * 100);
};
export const parsePaymentTermPercentageToHundredths = (value: string) => {
const trimmedValue = value.trim();
if (trimmedValue.length === 0 || !PAYMENT_TERM_PERCENTAGE_PATTERN.test(trimmedValue)) {
return null;
}
const normalizedValue = trimmedValue.replace(",", ".");
const [integerPart, decimalPart = ""] = normalizedValue.split(".");
const hundredths = Number(integerPart) * 100 + Number(decimalPart.padEnd(2, "0"));
if (!Number.isInteger(hundredths) || hundredths <= 0 || hundredths > MAX_PERCENTAGE_HUNDREDTHS) {
return null;
}
return hundredths;
};
export const parsePaymentTermPercentage = (value: string) => {
const hundredths = parsePaymentTermPercentageToHundredths(value);
if (hundredths === null) {
return null;
}
return hundredths / 100;
};
export const calculatePaymentTermDueRulesTotalPercentageInHundredths = (
rules: PaymentTermForm["dueRules"] | undefined
) => {
return (rules ?? []).reduce((sum, rule) => {
const percentage = parsePaymentTermPercentageToHundredths(rule?.percentage ?? "");
if (percentage === null) {
return sum;
}
return sum + percentage;
}, 0);
};
export const toPaymentTermPercentageDto = (value: number) => {
return {
value: String(toPaymentTermPercentageHundredths(value)),
scale: String(PERCENTAGE_SCALE),
};
};
export const formatPaymentTermPercentageInput = (value: number) => {
return value.toFixed(PERCENTAGE_SCALE).replace(".", ",");
};
export const parsePaymentTermDueDays = (value: string) => {
const trimmedValue = value.trim();
if (trimmedValue.length === 0 || !PAYMENT_TERM_DUE_DAYS_PATTERN.test(trimmedValue)) {
return null;
}
const dueDays = Number(trimmedValue);
if (!Number.isInteger(dueDays) || dueDays < 0) {
return null;
}
return dueDays;
};
export const formatPaymentTermPercentage = (value: number, locale?: string) => {
return `${buildFormatter(locale, PERCENTAGE_SCALE).format(value)}%`;
};
export const formatPaymentTermTotalPercentage = (valueInHundredths: number, locale?: string) => {
return `${buildFormatter(locale, PERCENTAGE_SCALE).format(valueInHundredths / 100)}%`;
};
export const formatPaymentTermDueDays = (
value: number,
translate: (key: string, params?: Record<string, unknown>) => string
) => {
if (value === 0) {
return translate("settings.paymentTerms.dueRules.sameDay");
}
return translate("settings.paymentTerms.dueRules.days", { count: value });
};
export const formatPaymentTermDueRuleSummary = (
rule: PaymentTermDueRule,
translate: (key: string, params?: Record<string, unknown>) => string,
locale?: string
) => {
return `${formatPaymentTermPercentage(rule.percentage, locale)} · ${formatPaymentTermDueDays(
rule.dueDays,
translate
)}`;
};
export const buildPaymentTermPercentageStatus = (
valueInHundredths: number,
translate: (key: string, params?: Record<string, unknown>) => string,
locale?: string
) => {
if (valueInHundredths === MAX_PERCENTAGE_HUNDREDTHS) {
return {
tone: "success" as const,
description: translate("settings.paymentTerms.form.total.valid"),
};
}
const difference = Math.abs(MAX_PERCENTAGE_HUNDREDTHS - valueInHundredths);
const formattedDifference = formatPaymentTermTotalPercentage(difference, locale);
if (valueInHundredths < MAX_PERCENTAGE_HUNDREDTHS) {
return {
tone: "warning" as const,
description: translate("settings.paymentTerms.form.total.missing", {
amount: formattedDifference,
}),
};
}
return {
tone: "destructive" as const,
description: translate("settings.paymentTerms.form.total.exceeded", {
amount: formattedDifference,
}),
};
};

View File

@ -0,0 +1,14 @@
import { isApiClientError } from "@erp/core/client";
export const resolvePaymentTermsSettingsFeedbackState = (params: {
isLoading: boolean;
isError: boolean;
error: unknown;
}) => {
const isCancelled = isApiClientError(params.error) && params.error.kind === "cancelled";
return {
isInitialLoading: params.isLoading,
hasBlockingError: params.isError && !isCancelled,
};
};

View File

@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
echo "PWD: $(pwd)"
echo "Node: $(node --version)"
echo "PNPM: $(pnpm --version)"
echo "Git root: $(git rev-parse --show-toplevel)"
test "$(uname -s)" = "Linux"
test -f pnpm-workspace.yaml
test -f turbo.json
test -d modules
test -d apps
echo "Development environment OK"