From eba766e7ebe8236703bd40b5b7ebd18f2b1198a6 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 16 Aug 2026 17:12:07 +0200 Subject: [PATCH] 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. --- AGENTS.md | 26 ++ .../models/payment-term-summary.model.ts | 1 + .../payment-term-summary-snapshot-builder.ts | 1 + .../express/payment-terms.routes.ts | 2 +- .../sequelize-payment-term-summary.mapper.ts | 1 + .../payment-term-quick-search.test.ts | 41 +++ .../repositories/payment-term-quick-search.ts | 29 ++ .../sequelize-payment-term.repository.test.ts | 179 ++++++++++- .../sequelize-payment-term.repository.ts | 21 +- .../shared/payment-term-summary.dto.ts | 1 + modules/catalogs/src/common/locales/en.json | 159 +++++++++ modules/catalogs/src/common/locales/es.json | 159 +++++++++ modules/catalogs/src/web/catalogs-routes.tsx | 14 + modules/catalogs/src/web/manifest.test.ts | 15 + modules/catalogs/src/web/manifest.ts | 6 + .../get-payment-term-by-id.adapter.ts | 23 ++ .../payment-terms/shared/adapters/index.ts | 1 + .../list-payment-terms.adapter.test.ts | 73 +++++ .../adapters/list-payment-terms.adapter.ts | 16 +- .../shared/api/create-payment-term.api.ts | 18 ++ .../shared/api/delete-payment-term.api.ts | 9 + .../shared/api/disable-payment-term.api.ts | 18 ++ .../shared/api/enable-payment-term.api.ts | 18 ++ .../shared/api/get-payment-term-by-id.api.ts | 21 ++ .../src/web/payment-terms/shared/api/index.ts | 6 + .../shared/api/update-payment-term.api.ts | 19 ++ .../entities/payment-term-list-row.entity.ts | 4 + .../web/payment-terms/shared/hooks/index.ts | 6 + .../web/payment-terms/shared/hooks/keys.ts | 15 +- .../shared/hooks/to-validation-errors.ts | 9 + .../hooks/use-payment-term-create-mutation.ts | 44 +++ .../hooks/use-payment-term-delete-mutation.ts | 28 ++ .../hooks/use-payment-term-detail-query.ts | 29 ++ .../use-payment-term-disable-mutation.ts | 29 ++ .../hooks/use-payment-term-enable-mutation.ts | 29 ++ .../hooks/use-payment-term-list-query.ts | 2 +- .../hooks/use-payment-term-update-mutation.ts | 44 +++ .../src/web/payment-terms/shared/index.ts | 2 + modules/catalogs/src/web/settings/index.ts | 1 + .../payment-terms/controllers/index.ts | 2 + .../use-payment-terms-settings-controller.ts | 301 ++++++++++++++++++ ...-payment-terms-settings-page-controller.ts | 85 +++++ .../settings/payment-terms/entities/index.ts | 3 + .../entities/payment-term-form.entity.ts | 5 + .../entities/payment-term-form.schema.ts | 87 +++++ ...s-settings-page-error-view-model.entity.ts | 9 + .../src/web/settings/payment-terms/index.ts | 1 + .../components/delete-payment-term-dialog.tsx | 62 ++++ .../payment-terms/ui/components/index.ts | 8 + .../components/payment-term-form-dialog.tsx | 267 ++++++++++++++++ .../ui/components/payment-term-list-item.tsx | 164 ++++++++++ .../components/payment-term-row-actions.tsx | 103 ++++++ .../components/payment-term-segmented-bar.tsx | 96 ++++++ .../components/payment-terms-empty-state.tsx | 22 ++ .../payment-terms-no-results-state.tsx | 26 ++ .../payment-terms-settings-skeleton.tsx | 28 ++ .../web/settings/payment-terms/ui/index.ts | 1 + .../settings/payment-terms/ui/pages/index.ts | 1 + .../ui/pages/payment-terms-settings-page.tsx | 228 +++++++++++++ .../utils/build-payment-term-form.ts | 23 ++ .../utils/build-payment-term-params.ts | 47 +++ .../build-payment-terms-list-criteria.test.ts | 35 ++ .../build-payment-terms-list-criteria.ts | 38 +++ ...nt-terms-settings-page-error-view-model.ts | 43 +++ .../web/settings/payment-terms/utils/index.ts | 6 + .../payment-term-due-rules.utils.test.ts | 149 +++++++++ .../utils/payment-term-due-rules.utils.ts | 186 +++++++++++ ...e-payment-terms-settings-feedback-state.ts | 14 + scripts/dev/check-environment.sh | 17 + 69 files changed, 3147 insertions(+), 29 deletions(-) create mode 100644 modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/repositories/payment-term-quick-search.test.ts create mode 100644 modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/repositories/payment-term-quick-search.ts create mode 100644 modules/catalogs/src/web/payment-terms/shared/adapters/get-payment-term-by-id.adapter.ts create mode 100644 modules/catalogs/src/web/payment-terms/shared/adapters/list-payment-terms.adapter.test.ts create mode 100644 modules/catalogs/src/web/payment-terms/shared/api/create-payment-term.api.ts create mode 100644 modules/catalogs/src/web/payment-terms/shared/api/delete-payment-term.api.ts create mode 100644 modules/catalogs/src/web/payment-terms/shared/api/disable-payment-term.api.ts create mode 100644 modules/catalogs/src/web/payment-terms/shared/api/enable-payment-term.api.ts create mode 100644 modules/catalogs/src/web/payment-terms/shared/api/get-payment-term-by-id.api.ts create mode 100644 modules/catalogs/src/web/payment-terms/shared/api/update-payment-term.api.ts create mode 100644 modules/catalogs/src/web/payment-terms/shared/hooks/to-validation-errors.ts create mode 100644 modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-create-mutation.ts create mode 100644 modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-delete-mutation.ts create mode 100644 modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-detail-query.ts create mode 100644 modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-disable-mutation.ts create mode 100644 modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-enable-mutation.ts create mode 100644 modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-update-mutation.ts create mode 100644 modules/catalogs/src/web/settings/payment-terms/controllers/index.ts create mode 100644 modules/catalogs/src/web/settings/payment-terms/controllers/use-payment-terms-settings-controller.ts create mode 100644 modules/catalogs/src/web/settings/payment-terms/controllers/use-payment-terms-settings-page-controller.ts create mode 100644 modules/catalogs/src/web/settings/payment-terms/entities/index.ts create mode 100644 modules/catalogs/src/web/settings/payment-terms/entities/payment-term-form.entity.ts create mode 100644 modules/catalogs/src/web/settings/payment-terms/entities/payment-term-form.schema.ts create mode 100644 modules/catalogs/src/web/settings/payment-terms/entities/payment-terms-settings-page-error-view-model.entity.ts create mode 100644 modules/catalogs/src/web/settings/payment-terms/index.ts create mode 100644 modules/catalogs/src/web/settings/payment-terms/ui/components/delete-payment-term-dialog.tsx create mode 100644 modules/catalogs/src/web/settings/payment-terms/ui/components/index.ts create mode 100644 modules/catalogs/src/web/settings/payment-terms/ui/components/payment-term-form-dialog.tsx create mode 100644 modules/catalogs/src/web/settings/payment-terms/ui/components/payment-term-list-item.tsx create mode 100644 modules/catalogs/src/web/settings/payment-terms/ui/components/payment-term-row-actions.tsx create mode 100644 modules/catalogs/src/web/settings/payment-terms/ui/components/payment-term-segmented-bar.tsx create mode 100644 modules/catalogs/src/web/settings/payment-terms/ui/components/payment-terms-empty-state.tsx create mode 100644 modules/catalogs/src/web/settings/payment-terms/ui/components/payment-terms-no-results-state.tsx create mode 100644 modules/catalogs/src/web/settings/payment-terms/ui/components/payment-terms-settings-skeleton.tsx create mode 100644 modules/catalogs/src/web/settings/payment-terms/ui/index.ts create mode 100644 modules/catalogs/src/web/settings/payment-terms/ui/pages/index.ts create mode 100644 modules/catalogs/src/web/settings/payment-terms/ui/pages/payment-terms-settings-page.tsx create mode 100644 modules/catalogs/src/web/settings/payment-terms/utils/build-payment-term-form.ts create mode 100644 modules/catalogs/src/web/settings/payment-terms/utils/build-payment-term-params.ts create mode 100644 modules/catalogs/src/web/settings/payment-terms/utils/build-payment-terms-list-criteria.test.ts create mode 100644 modules/catalogs/src/web/settings/payment-terms/utils/build-payment-terms-list-criteria.ts create mode 100644 modules/catalogs/src/web/settings/payment-terms/utils/build-payment-terms-settings-page-error-view-model.ts create mode 100644 modules/catalogs/src/web/settings/payment-terms/utils/index.ts create mode 100644 modules/catalogs/src/web/settings/payment-terms/utils/payment-term-due-rules.utils.test.ts create mode 100644 modules/catalogs/src/web/settings/payment-terms/utils/payment-term-due-rules.utils.ts create mode 100644 modules/catalogs/src/web/settings/payment-terms/utils/resolve-payment-terms-settings-feedback-state.ts create mode 100644 scripts/dev/check-environment.sh diff --git a/AGENTS.md b/AGENTS.md index 9ae4af2d..119b394c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/modules/catalogs/src/api/application/payment-terms/models/payment-term-summary.model.ts b/modules/catalogs/src/api/application/payment-terms/models/payment-term-summary.model.ts index 7ed1fcce..b7f41a55 100644 --- a/modules/catalogs/src/api/application/payment-terms/models/payment-term-summary.model.ts +++ b/modules/catalogs/src/api/application/payment-terms/models/payment-term-summary.model.ts @@ -5,6 +5,7 @@ export type PaymentTermSummary = { id: UniqueID; companyId: UniqueID; name: Name; + description: string | null; isActive: boolean; isSystem: boolean; dueRules: Array; diff --git a/modules/catalogs/src/api/application/payment-terms/snapshot-builders/summary/payment-term-summary-snapshot-builder.ts b/modules/catalogs/src/api/application/payment-terms/snapshot-builders/summary/payment-term-summary-snapshot-builder.ts index a6f9407d..2f08e4b4 100644 --- a/modules/catalogs/src/api/application/payment-terms/snapshot-builders/summary/payment-term-summary-snapshot-builder.ts +++ b/modules/catalogs/src/api/application/payment-terms/snapshot-builders/summary/payment-term-summary-snapshot-builder.ts @@ -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) => ({ diff --git a/modules/catalogs/src/api/infrastructure/payment-terms/express/payment-terms.routes.ts b/modules/catalogs/src/api/infrastructure/payment-terms/express/payment-terms.routes.ts index 19e78a2d..b4a2cfb1 100644 --- a/modules/catalogs/src/api/infrastructure/payment-terms/express/payment-terms.routes.ts +++ b/modules/catalogs/src/api/infrastructure/payment-terms/express/payment-terms.routes.ts @@ -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); diff --git a/modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/mappers/sequelize-payment-term-summary.mapper.ts b/modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/mappers/sequelize-payment-term-summary.mapper.ts index de43642a..b5cb4cfc 100644 --- a/modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/mappers/sequelize-payment-term-summary.mapper.ts +++ b/modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/mappers/sequelize-payment-term-summary.mapper.ts @@ -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, diff --git a/modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/repositories/payment-term-quick-search.test.ts b/modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/repositories/payment-term-quick-search.test.ts new file mode 100644 index 00000000..d97ee8a6 --- /dev/null +++ b/modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/repositories/payment-term-quick-search.test.ts @@ -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%'/); +}); diff --git a/modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/repositories/payment-term-quick-search.ts b/modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/repositories/payment-term-quick-search.ts new file mode 100644 index 00000000..5ab2da97 --- /dev/null +++ b/modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/repositories/payment-term-quick-search.ts @@ -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 | undefined { + const term = params.quickSearch?.trim(); + + if (!term) { + return undefined; + } + + const likePattern = `%${term}%`; + + return { + [Op.or]: [{ name: { [Op.like]: likePattern } }, { description: { [Op.like]: likePattern } }], + }; +} diff --git a/modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/repositories/sequelize-payment-term.repository.test.ts b/modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/repositories/sequelize-payment-term.repository.test.ts index d36a0168..69e7bff0 100644 --- a/modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/repositories/sequelize-payment-term.repository.test.ts +++ b/modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/repositories/sequelize-payment-term.repository.test.ts @@ -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; mapToDomain?: () => Result; + 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 | undefined; + let countOptions: Record | undefined; + + (PaymentTermModel as any).findAll = async (options: Record) => { + findAllOptions = options; + return [createSummaryRow()]; + }; + (PaymentTermModel as any).count = async (options: Record) => { + 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; + const andConditions = where[Op.and as unknown as keyof typeof where] as Array< + Record + >; + + 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 | undefined; + let countOptions: Record | undefined; + + (PaymentTermModel as any).findAll = async (options: Record) => { + findAllOptions = options; + return [createSummaryRow({ description: "Contado inmediato", isActive: true })]; + }; + (PaymentTermModel as any).count = async (options: Record) => { + 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; + 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; + } + }); }); diff --git a/modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/repositories/sequelize-payment-term.repository.ts b/modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/repositories/sequelize-payment-term.repository.ts index 953e8984..5b679c54 100644 --- a/modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/repositories/sequelize-payment-term.repository.ts +++ b/modules/catalogs/src/api/infrastructure/payment-terms/persistence/sequelize/repositories/sequelize-payment-term.repository.ts @@ -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 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([ diff --git a/modules/catalogs/src/common/dto/payment-terms/shared/payment-term-summary.dto.ts b/modules/catalogs/src/common/dto/payment-terms/shared/payment-term-summary.dto.ts index 73c71fac..447f6f68 100644 --- a/modules/catalogs/src/common/dto/payment-terms/shared/payment-term-summary.dto.ts +++ b/modules/catalogs/src/common/dto/payment-terms/shared/payment-term-summary.dto.ts @@ -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), diff --git a/modules/catalogs/src/common/locales/en.json b/modules/catalogs/src/common/locales/en.json index 512ef50e..bbbc2521 100644 --- a/modules/catalogs/src/common/locales/en.json +++ b/modules/catalogs/src/common/locales/en.json @@ -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.", diff --git a/modules/catalogs/src/common/locales/es.json b/modules/catalogs/src/common/locales/es.json index fde6dbd4..4e482b39 100644 --- a/modules/catalogs/src/common/locales/es.json +++ b/modules/catalogs/src/common/locales/es.json @@ -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.", diff --git a/modules/catalogs/src/web/catalogs-routes.tsx b/modules/catalogs/src/web/catalogs-routes.tsx index 47ac8dee..47069bbd 100644 --- a/modules/catalogs/src/web/catalogs-routes.tsx +++ b/modules/catalogs/src/web/catalogs-routes.tsx @@ -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: , }, + { + path: "settings/general/payment-terms", + handle: { + layout: "app-sidebar", + protected: true, + }, + element: , + }, { path: "settings/general/tax-regimes", handle: { diff --git a/modules/catalogs/src/web/manifest.test.ts b/modules/catalogs/src/web/manifest.test.ts index d90890b5..f908750d 100644 --- a/modules/catalogs/src/web/manifest.test.ts +++ b/modules/catalogs/src/web/manifest.test.ts @@ -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"); diff --git a/modules/catalogs/src/web/manifest.ts b/modules/catalogs/src/web/manifest.ts index 1b10311c..7d5c534e 100644 --- a/modules/catalogs/src/web/manifest.ts +++ b/modules/catalogs/src/web/manifest.ts @@ -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", diff --git a/modules/catalogs/src/web/payment-terms/shared/adapters/get-payment-term-by-id.adapter.ts b/modules/catalogs/src/web/payment-terms/shared/adapters/get-payment-term-by-id.adapter.ts new file mode 100644 index 00000000..0a81ecc9 --- /dev/null +++ b/modules/catalogs/src/web/payment-terms/shared/adapters/get-payment-term-by-id.adapter.ts @@ -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), + })), + }; + }, +}; diff --git a/modules/catalogs/src/web/payment-terms/shared/adapters/index.ts b/modules/catalogs/src/web/payment-terms/shared/adapters/index.ts index e83cfb8e..73870d6a 100644 --- a/modules/catalogs/src/web/payment-terms/shared/adapters/index.ts +++ b/modules/catalogs/src/web/payment-terms/shared/adapters/index.ts @@ -1 +1,2 @@ export * from "./list-payment-terms.adapter"; +export * from "./get-payment-term-by-id.adapter"; diff --git a/modules/catalogs/src/web/payment-terms/shared/adapters/list-payment-terms.adapter.test.ts b/modules/catalogs/src/web/payment-terms/shared/adapters/list-payment-terms.adapter.test.ts new file mode 100644 index 00000000..8d60b55a --- /dev/null +++ b/modules/catalogs/src/web/payment-terms/shared/adapters/list-payment-terms.adapter.test.ts @@ -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, + }, + ], + }); +}); diff --git a/modules/catalogs/src/web/payment-terms/shared/adapters/list-payment-terms.adapter.ts b/modules/catalogs/src/web/payment-terms/shared/adapters/list-payment-terms.adapter.ts index 867cef86..f11c27ea 100644 --- a/modules/catalogs/src/web/payment-terms/shared/adapters/list-payment-terms.adapter.ts +++ b/modules/catalogs/src/web/payment-terms/shared/adapters/list-payment-terms.adapter.ts @@ -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); }; -*/ diff --git a/modules/catalogs/src/web/payment-terms/shared/api/create-payment-term.api.ts b/modules/catalogs/src/web/payment-terms/shared/api/create-payment-term.api.ts new file mode 100644 index 00000000..ce7ae418 --- /dev/null +++ b/modules/catalogs/src/web/payment-terms/shared/api/create-payment-term.api.ts @@ -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 { + return dataSource.createOne( + "catalogs/payment-terms", + params.data + ); +} diff --git a/modules/catalogs/src/web/payment-terms/shared/api/delete-payment-term.api.ts b/modules/catalogs/src/web/payment-terms/shared/api/delete-payment-term.api.ts new file mode 100644 index 00000000..c5e1f3e1 --- /dev/null +++ b/modules/catalogs/src/web/payment-terms/shared/api/delete-payment-term.api.ts @@ -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); +} diff --git a/modules/catalogs/src/web/payment-terms/shared/api/disable-payment-term.api.ts b/modules/catalogs/src/web/payment-terms/shared/api/disable-payment-term.api.ts new file mode 100644 index 00000000..92cb0fc0 --- /dev/null +++ b/modules/catalogs/src/web/payment-terms/shared/api/disable-payment-term.api.ts @@ -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 { + return dataSource.custom({ + method: "patch", + path: `catalogs/payment-terms/${params.paymentTermId}/disable`, + data: undefined, + }); +} diff --git a/modules/catalogs/src/web/payment-terms/shared/api/enable-payment-term.api.ts b/modules/catalogs/src/web/payment-terms/shared/api/enable-payment-term.api.ts new file mode 100644 index 00000000..5d9359f8 --- /dev/null +++ b/modules/catalogs/src/web/payment-terms/shared/api/enable-payment-term.api.ts @@ -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 { + return dataSource.custom({ + method: "patch", + path: `catalogs/payment-terms/${params.paymentTermId}/enable`, + data: undefined, + }); +} diff --git a/modules/catalogs/src/web/payment-terms/shared/api/get-payment-term-by-id.api.ts b/modules/catalogs/src/web/payment-terms/shared/api/get-payment-term-by-id.api.ts new file mode 100644 index 00000000..be4963d6 --- /dev/null +++ b/modules/catalogs/src/web/payment-terms/shared/api/get-payment-term-by-id.api.ts @@ -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 { + return dataSource.getOne( + "catalogs/payment-terms", + params.paymentTermId, + { + signal: params.signal, + } + ); +} diff --git a/modules/catalogs/src/web/payment-terms/shared/api/index.ts b/modules/catalogs/src/web/payment-terms/shared/api/index.ts index dfd1838c..c633faf2 100644 --- a/modules/catalogs/src/web/payment-terms/shared/api/index.ts +++ b/modules/catalogs/src/web/payment-terms/shared/api/index.ts @@ -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"; diff --git a/modules/catalogs/src/web/payment-terms/shared/api/update-payment-term.api.ts b/modules/catalogs/src/web/payment-terms/shared/api/update-payment-term.api.ts new file mode 100644 index 00000000..37240bdf --- /dev/null +++ b/modules/catalogs/src/web/payment-terms/shared/api/update-payment-term.api.ts @@ -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 { + return dataSource.updateOne( + "catalogs/payment-terms", + params.paymentTermId, + params.data + ); +} diff --git a/modules/catalogs/src/web/payment-terms/shared/entities/payment-term-list-row.entity.ts b/modules/catalogs/src/web/payment-terms/shared/entities/payment-term-list-row.entity.ts index 33b4e5ef..0ddd20f5 100644 --- a/modules/catalogs/src/web/payment-terms/shared/entities/payment-term-list-row.entity.ts +++ b/modules/catalogs/src/web/payment-terms/shared/entities/payment-term-list-row.entity.ts @@ -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[]; } diff --git a/modules/catalogs/src/web/payment-terms/shared/hooks/index.ts b/modules/catalogs/src/web/payment-terms/shared/hooks/index.ts index f3bb8b7b..1fb5b3fd 100644 --- a/modules/catalogs/src/web/payment-terms/shared/hooks/index.ts +++ b/modules/catalogs/src/web/payment-terms/shared/hooks/index.ts @@ -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"; diff --git a/modules/catalogs/src/web/payment-terms/shared/hooks/keys.ts b/modules/catalogs/src/web/payment-terms/shared/hooks/keys.ts index 1df194e3..63ffe815 100644 --- a/modules/catalogs/src/web/payment-terms/shared/hooks/keys.ts +++ b/modules/catalogs/src/web/payment-terms/shared/hooks/keys.ts @@ -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), +}; diff --git a/modules/catalogs/src/web/payment-terms/shared/hooks/to-validation-errors.ts b/modules/catalogs/src/web/payment-terms/shared/hooks/to-validation-errors.ts new file mode 100644 index 00000000..43e54858 --- /dev/null +++ b/modules/catalogs/src/web/payment-terms/shared/hooks/to-validation-errors.ts @@ -0,0 +1,9 @@ +import type { ValidationErrorDetail } from "@repo/rdx-ddd"; +import type { ZodError } from "zod"; + +export const toValidationErrors = (error: ZodError): ValidationErrorDetail[] => + error.issues.map((issue) => ({ + path: issue.path.join("."), + message: issue.message, + value: issue.input, + })); diff --git a/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-create-mutation.ts b/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-create-mutation.ts new file mode 100644 index 00000000..c767b351 --- /dev/null +++ b/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-create-mutation.ts @@ -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({ + 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, + }); + }, + }); +}; diff --git a/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-delete-mutation.ts b/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-delete-mutation.ts new file mode 100644 index 00000000..b59e6934 --- /dev/null +++ b/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-delete-mutation.ts @@ -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({ + 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 }), + ]); + }, + }); +}; diff --git a/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-detail-query.ts b/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-detail-query.ts new file mode 100644 index 00000000..a1d4dce0 --- /dev/null +++ b/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-detail-query.ts @@ -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 => { + const dataSource = useDataSource(); + const enabled = (options?.enabled ?? true) && Boolean(paymentTermId); + + return useQuery({ + queryKey: PAYMENT_TERM_QUERY_KEY(paymentTermId), + enabled, + queryFn: async ({ signal }) => { + const dto = await getPaymentTermById(dataSource, { + paymentTermId: paymentTermId as string, + signal, + }); + + return GetPaymentTermByIdAdapter.fromDto(dto); + }, + }); +}; diff --git a/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-disable-mutation.ts b/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-disable-mutation.ts new file mode 100644 index 00000000..718e482b --- /dev/null +++ b/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-disable-mutation.ts @@ -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({ + 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 }); + }, + }); +}; diff --git a/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-enable-mutation.ts b/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-enable-mutation.ts new file mode 100644 index 00000000..7cad9bac --- /dev/null +++ b/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-enable-mutation.ts @@ -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({ + 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 }); + }, + }); +}; diff --git a/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-list-query.ts b/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-list-query.ts index 0a507dc0..79588476 100644 --- a/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-list-query.ts +++ b/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-list-query.ts @@ -13,7 +13,7 @@ export interface PaymentTermsListQueryOptions { criteria?: Partial; } -export const usePaymentTermsListQuery = ( +export const usePaymentTermListQuery = ( options?: PaymentTermsListQueryOptions ): UseQueryResult => { const dataSource = useDataSource(); diff --git a/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-update-mutation.ts b/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-update-mutation.ts new file mode 100644 index 00000000..295268e8 --- /dev/null +++ b/modules/catalogs/src/web/payment-terms/shared/hooks/use-payment-term-update-mutation.ts @@ -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({ + 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, + }); + }, + }); +}; diff --git a/modules/catalogs/src/web/payment-terms/shared/index.ts b/modules/catalogs/src/web/payment-terms/shared/index.ts index 3a4614c9..d2bc2683 100644 --- a/modules/catalogs/src/web/payment-terms/shared/index.ts +++ b/modules/catalogs/src/web/payment-terms/shared/index.ts @@ -1,2 +1,4 @@ export * from "./entities"; export * from "./hooks"; +export * from "./api"; +export * from "./adapters"; diff --git a/modules/catalogs/src/web/settings/index.ts b/modules/catalogs/src/web/settings/index.ts index b56fbeed..23f8279d 100644 --- a/modules/catalogs/src/web/settings/index.ts +++ b/modules/catalogs/src/web/settings/index.ts @@ -1,2 +1,3 @@ export * from "./payment-methods"; +export * from "./payment-terms"; export * from "./tax-regimes"; diff --git a/modules/catalogs/src/web/settings/payment-terms/controllers/index.ts b/modules/catalogs/src/web/settings/payment-terms/controllers/index.ts new file mode 100644 index 00000000..ca5f6671 --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-terms/controllers/index.ts @@ -0,0 +1,2 @@ +export * from "./use-payment-terms-settings-controller"; +export * from "./use-payment-terms-settings-page-controller"; diff --git a/modules/catalogs/src/web/settings/payment-terms/controllers/use-payment-terms-settings-controller.ts b/modules/catalogs/src/web/settings/payment-terms/controllers/use-payment-terms-settings-controller.ts new file mode 100644 index 00000000..33c78dfa --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-terms/controllers/use-payment-terms-settings-controller.ts @@ -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(null); + const [deletingPaymentTerm, setDeletingPaymentTerm] = useState(null); + + const editingPaymentTermQuery = usePaymentTermDetailQuery(editingPaymentTermId ?? undefined, { + enabled: editingPaymentTermId !== null, + }); + const editingPaymentTerm = editingPaymentTermQuery.data ?? null; + + const form = useHookForm({ + 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) => { + 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) => { + 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, + }; +}; diff --git a/modules/catalogs/src/web/settings/payment-terms/controllers/use-payment-terms-settings-page-controller.ts b/modules/catalogs/src/web/settings/payment-terms/controllers/use-payment-terms-settings-page-controller.ts new file mode 100644 index 00000000..2d5c052c --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-terms/controllers/use-payment-terms-settings-page-controller.ts @@ -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("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, + }; +}; diff --git a/modules/catalogs/src/web/settings/payment-terms/entities/index.ts b/modules/catalogs/src/web/settings/payment-terms/entities/index.ts new file mode 100644 index 00000000..ee0a6da5 --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-terms/entities/index.ts @@ -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"; diff --git a/modules/catalogs/src/web/settings/payment-terms/entities/payment-term-form.entity.ts b/modules/catalogs/src/web/settings/payment-terms/entities/payment-term-form.entity.ts new file mode 100644 index 00000000..d3d8e436 --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-terms/entities/payment-term-form.entity.ts @@ -0,0 +1,5 @@ +import type { z } from "zod/v4"; + +import type { PaymentTermFormSchema } from "./payment-term-form.schema"; + +export type PaymentTermForm = z.infer; diff --git a/modules/catalogs/src/web/settings/payment-terms/entities/payment-term-form.schema.ts b/modules/catalogs/src/web/settings/payment-terms/entities/payment-term-form.schema.ts new file mode 100644 index 00000000..939f3bdc --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-terms/entities/payment-term-form.schema.ts @@ -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(); + 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"], + }); + } + }); diff --git a/modules/catalogs/src/web/settings/payment-terms/entities/payment-terms-settings-page-error-view-model.entity.ts b/modules/catalogs/src/web/settings/payment-terms/entities/payment-terms-settings-page-error-view-model.entity.ts new file mode 100644 index 00000000..045b983c --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-terms/entities/payment-terms-settings-page-error-view-model.entity.ts @@ -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; +} diff --git a/modules/catalogs/src/web/settings/payment-terms/index.ts b/modules/catalogs/src/web/settings/payment-terms/index.ts new file mode 100644 index 00000000..4aedf593 --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-terms/index.ts @@ -0,0 +1 @@ +export * from "./ui"; diff --git a/modules/catalogs/src/web/settings/payment-terms/ui/components/delete-payment-term-dialog.tsx b/modules/catalogs/src/web/settings/payment-terms/ui/components/delete-payment-term-dialog.tsx new file mode 100644 index 00000000..bad665c1 --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-terms/ui/components/delete-payment-term-dialog.tsx @@ -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 ( + + + + {t("settings.paymentTerms.delete.title")} + + {t("settings.paymentTerms.delete.description", { + name: target?.name ?? "", + })} + + + + + + + + + + ); +}; diff --git a/modules/catalogs/src/web/settings/payment-terms/ui/components/index.ts b/modules/catalogs/src/web/settings/payment-terms/ui/components/index.ts new file mode 100644 index 00000000..f338f710 --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-terms/ui/components/index.ts @@ -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"; diff --git a/modules/catalogs/src/web/settings/payment-terms/ui/components/payment-term-form-dialog.tsx b/modules/catalogs/src/web/settings/payment-terms/ui/components/payment-term-form-dialog.tsx new file mode 100644 index 00000000..8b1d97f8 --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-terms/ui/components/payment-term-form-dialog.tsx @@ -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; + onSubmit: FormEventHandler; + 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 ( + + + + {title} + {description} + + + +
+ + + + + + + + +
+
+
+

{t("settings.paymentTerms.form.rules.title")}

+

+ {t("settings.paymentTerms.form.rules.description")} +

+
+ + + {t("settings.paymentTerms.form.total.label", { + amount: formatPaymentTermTotalPercentage( + totalPercentageInHundredths, + i18n.language + ), + })} + +
+ +
+

{totalStatus.description}

+
+ +
+ {t("settings.paymentTerms.fields.dueDays")} + {t("settings.paymentTerms.fields.percentage")} + {t("settings.paymentTerms.actions.removeRule")} +
+ +
+ {fields.map((field, index) => ( +
+
+
+ + {t("settings.paymentTerms.fields.dueDays")} + + + 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" + /> +
+ +
+ + {t("settings.paymentTerms.fields.percentage")} + + + 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={%} + /> +
+ +
+ +
+
+
+ ))} +
+ + +
+
+
+ + + + + +
+
+ ); +}; diff --git a/modules/catalogs/src/web/settings/payment-terms/ui/components/payment-term-list-item.tsx b/modules/catalogs/src/web/settings/payment-terms/ui/components/payment-term-list-item.tsx new file mode 100644 index 00000000..bebe9561 --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-terms/ui/components/payment-term-list-item.tsx @@ -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 ( + + +
+
+
+ +
+ +
+
+

+ {paymentTerm.name} +

+ + + {paymentTerm.isActive + ? t("settings.paymentTerms.status.active") + : t("settings.paymentTerms.status.inactive")} + + + {paymentTerm.isSystem ? ( + {t("settings.paymentTerms.status.system")} + ) : null} +
+ + {paymentTerm.description ? ( +

{paymentTerm.description}

+ ) : null} +
+
+ + +
+ +
+
+

+ {t("settings.paymentTerms.summary.installmentsLabel")} +

+

+ {t("settings.paymentTerms.summary.installmentsCount", { count: installmentCount })} +

+
+ +
+

+ {t("settings.paymentTerms.summary.lastInstallmentLabel")} +

+

+ {lastDueDays === null ? "-" : formatLocalizedDueDays(lastDueDays)} +

+
+ +
+

+ {t("settings.paymentTerms.summary.distributionLabel")} +

+

+ {percentageDistribution.map(formatLocalizedPercentage).join(" / ")} +

+
+
+ +
+
+

+ {t("settings.paymentTerms.details.title")} +

+ +
+ {sortedDueRules.map((rule, index) => ( +
+
+
+ {index + 1} +
+ +
+

+ {formatLocalizedPercentage(rule.percentage)} ·{" "} + {formatLocalizedDueDays(rule.dueDays)} +

+

+ {rule.dueDays === 0 + ? t("settings.paymentTerms.details.sameDay") + : t("settings.paymentTerms.details.baseDate")} +

+
+
+
+ ))} +
+
+ + +
+
+
+ ); +}; diff --git a/modules/catalogs/src/web/settings/payment-terms/ui/components/payment-term-row-actions.tsx b/modules/catalogs/src/web/settings/payment-terms/ui/components/payment-term-row-actions.tsx new file mode 100644 index 00000000..b2654981 --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-terms/ui/components/payment-term-row-actions.tsx @@ -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 ( +
+ + + + + + + + + } + /> + onDelete(paymentTerm)} + title={deleteDisabled ? deleteDisabledReason : undefined} + variant="destructive" + > + {t("settings.paymentTerms.actions.delete")} + + } + /> + +
+ ); +}; diff --git a/modules/catalogs/src/web/settings/payment-terms/ui/components/payment-term-segmented-bar.tsx b/modules/catalogs/src/web/settings/payment-terms/ui/components/payment-term-segmented-bar.tsx new file mode 100644 index 00000000..ab0ab825 --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-terms/ui/components/payment-term-segmented-bar.tsx @@ -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 ( +
+
+ {segments.map((segment, index) => ( +
+ {formatPercentage(segment.percentage)} +
+ ))} +
+ +
+ {segments.map((segment, index) => ( +
+
+ {formatPercentage(segment.percentage)} +
+
+ {formatDueDays(segment.dueDays)} +
+
+ ))} +
+ +
+ {segments.map((segment) => ( +
+ {formatDueDays(segment.dueDays)} +
+ ))} +
+
+ ); +}; diff --git a/modules/catalogs/src/web/settings/payment-terms/ui/components/payment-terms-empty-state.tsx b/modules/catalogs/src/web/settings/payment-terms/ui/components/payment-terms-empty-state.tsx new file mode 100644 index 00000000..1d6131c4 --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-terms/ui/components/payment-terms-empty-state.tsx @@ -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 ( +
+
+

{t("settings.paymentTerms.empty.title")}

+

+ {t("settings.paymentTerms.empty.description")} +

+
+ + +
+ ); +}; diff --git a/modules/catalogs/src/web/settings/payment-terms/ui/components/payment-terms-no-results-state.tsx b/modules/catalogs/src/web/settings/payment-terms/ui/components/payment-terms-no-results-state.tsx new file mode 100644 index 00000000..3cfbc0bb --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-terms/ui/components/payment-terms-no-results-state.tsx @@ -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 ( +
+
+

{t("settings.paymentTerms.emptyFiltered.title")}

+

+ {t("settings.paymentTerms.emptyFiltered.description")} +

+
+ + +
+ ); +}; diff --git a/modules/catalogs/src/web/settings/payment-terms/ui/components/payment-terms-settings-skeleton.tsx b/modules/catalogs/src/web/settings/payment-terms/ui/components/payment-terms-settings-skeleton.tsx new file mode 100644 index 00000000..8caef3cf --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-terms/ui/components/payment-terms-settings-skeleton.tsx @@ -0,0 +1,28 @@ +import { Skeleton } from "@repo/shadcn-ui/components"; + +export const PaymentTermsSettingsSkeleton = () => { + return ( +
+
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+ + +
+
+ ); +}; diff --git a/modules/catalogs/src/web/settings/payment-terms/ui/index.ts b/modules/catalogs/src/web/settings/payment-terms/ui/index.ts new file mode 100644 index 00000000..c4e34b27 --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-terms/ui/index.ts @@ -0,0 +1 @@ +export * from "./pages"; diff --git a/modules/catalogs/src/web/settings/payment-terms/ui/pages/index.ts b/modules/catalogs/src/web/settings/payment-terms/ui/pages/index.ts new file mode 100644 index 00000000..5237d2d9 --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-terms/ui/pages/index.ts @@ -0,0 +1 @@ +export * from "./payment-terms-settings-page"; diff --git a/modules/catalogs/src/web/settings/payment-terms/ui/pages/payment-terms-settings-page.tsx b/modules/catalogs/src/web/settings/payment-terms/ui/pages/payment-terms-settings-page.tsx new file mode 100644 index 00000000..64212044 --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-terms/ui/pages/payment-terms-settings-page.tsx @@ -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 ; + } + + if (ctrl.pageError) { + return ( + +
+ +
+
+ ); + } + + 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) => { + 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 ( + + + + + {t("settings.paymentTerms.actions.create")} + + } + description={t("settings.paymentTerms.description")} + title={t("settings.paymentTerms.title")} + /> + +
+
+
+ + + {ctrl.isBackgroundFetching ? ( + + ) : null} +
+ +
+ + {t("settings.paymentTerms.toolbar.statusLabel")} + + +
+
+ +
+ {ctrl.isBackgroundFetching ? : null} + {t("settings.paymentTerms.toolbar.totalCount", { count: ctrl.totalItems })} +
+
+ + {ctrl.hasEmptyState ? ( + + ) : ctrl.hasNoResults ? ( + + ) : ( +
+ {ctrl.list.map((paymentTerm) => ( + { + if (item.isActive) { + featureCtrl.disable(item); + return; + } + + featureCtrl.enable(item); + }} + paymentTerm={paymentTerm} + /> + ))} +
+ )} + + { + if (!open) { + featureCtrl.closeCreateDialog(); + } + }} + submitLabel={t("settings.paymentTerms.actions.create")} + title={t("settings.paymentTerms.create.title")} + /> + + { + if (!open) { + featureCtrl.closeEditDialog(); + } + }} + submitLabel={t("settings.paymentTerms.actions.save")} + title={t("settings.paymentTerms.edit.title")} + /> + + { + if (!open) { + featureCtrl.closeDeleteDialog(); + } + }} + open={Boolean(featureCtrl.deletingPaymentTerm)} + target={featureCtrl.deletingPaymentTerm} + /> +
+
+ ); +}; diff --git a/modules/catalogs/src/web/settings/payment-terms/utils/build-payment-term-form.ts b/modules/catalogs/src/web/settings/payment-terms/utils/build-payment-term-form.ts new file mode 100644 index 00000000..740c27d4 --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-terms/utils/build-payment-term-form.ts @@ -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), + })), +}); diff --git a/modules/catalogs/src/web/settings/payment-terms/utils/build-payment-term-params.ts b/modules/catalogs/src/web/settings/payment-terms/utils/build-payment-term-params.ts new file mode 100644 index 00000000..d046268a --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-terms/utils/build-payment-term-params.ts @@ -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), + }, +}); diff --git a/modules/catalogs/src/web/settings/payment-terms/utils/build-payment-terms-list-criteria.test.ts b/modules/catalogs/src/web/settings/payment-terms/utils/build-payment-terms-list-criteria.test.ts new file mode 100644 index 00000000..fab2cf63 --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-terms/utils/build-payment-terms-list-criteria.test.ts @@ -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); +}); diff --git a/modules/catalogs/src/web/settings/payment-terms/utils/build-payment-terms-list-criteria.ts b/modules/catalogs/src/web/settings/payment-terms/utils/build-payment-terms-list-criteria.ts new file mode 100644 index 00000000..f0c75fd3 --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-terms/utils/build-payment-terms-list-criteria.ts @@ -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"; +}; diff --git a/modules/catalogs/src/web/settings/payment-terms/utils/build-payment-terms-settings-page-error-view-model.ts b/modules/catalogs/src/web/settings/payment-terms/utils/build-payment-terms-settings-page-error-view-model.ts new file mode 100644 index 00000000..dea5f503 --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-terms/utils/build-payment-terms-settings-page-error-view-model.ts @@ -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, + }; +}; diff --git a/modules/catalogs/src/web/settings/payment-terms/utils/index.ts b/modules/catalogs/src/web/settings/payment-terms/utils/index.ts new file mode 100644 index 00000000..45e8c5c2 --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-terms/utils/index.ts @@ -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"; diff --git a/modules/catalogs/src/web/settings/payment-terms/utils/payment-term-due-rules.utils.test.ts b/modules/catalogs/src/web/settings/payment-terms/utils/payment-term-due-rules.utils.test.ts new file mode 100644 index 00000000..3c6a4d66 --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-terms/utils/payment-term-due-rules.utils.test.ts @@ -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) => { + 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 }, + ] + ); +}); diff --git a/modules/catalogs/src/web/settings/payment-terms/utils/payment-term-due-rules.utils.ts b/modules/catalogs/src/web/settings/payment-terms/utils/payment-term-due-rules.utils.ts new file mode 100644 index 00000000..f6fb7f09 --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-terms/utils/payment-term-due-rules.utils.ts @@ -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 = (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 +) => { + 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, + locale?: string +) => { + return `${formatPaymentTermPercentage(rule.percentage, locale)} · ${formatPaymentTermDueDays( + rule.dueDays, + translate + )}`; +}; + +export const buildPaymentTermPercentageStatus = ( + valueInHundredths: number, + translate: (key: string, params?: Record) => 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, + }), + }; +}; diff --git a/modules/catalogs/src/web/settings/payment-terms/utils/resolve-payment-terms-settings-feedback-state.ts b/modules/catalogs/src/web/settings/payment-terms/utils/resolve-payment-terms-settings-feedback-state.ts new file mode 100644 index 00000000..3673d391 --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-terms/utils/resolve-payment-terms-settings-feedback-state.ts @@ -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, + }; +}; diff --git a/scripts/dev/check-environment.sh b/scripts/dev/check-environment.sh new file mode 100644 index 00000000..061b52e5 --- /dev/null +++ b/scripts/dev/check-environment.sh @@ -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" \ No newline at end of file